fix: 增加多个前端

master
Lxy 1 month ago
parent a8a84c00da
commit f665480226

63
.gitignore vendored

@ -0,0 +1,63 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual Environment
venv/
env/
ENV/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Environment variables
.env
.env.local
.env.*.local
# Database
*.db
*.sqlite3
# Logs
*.log
logs/
# Frontend
node_modules/
dist/
build/
.npm
.yarn
# Docker
.dockerignore
# Misc
.DS_Store
Thumbs.db

@ -0,0 +1,345 @@
# Design System Inspired by Binance.US
## 1. Visual Theme & Atmosphere
Binance.US radiates the polished urgency of a digital trading floor — a space where money moves and decisions happen in seconds. The design is a two-tone composition that alternates between stark white trading surfaces and deep near-black panels (`#222126`), creating a visual rhythm that mirrors the bull-and-bear duality of crypto markets. Binance Yellow (`#F0B90B`) cuts through this monochrome foundation like a gold ingot on a steel desk — unmistakable, confident, and engineered to guide every eye toward the next action.
The interface speaks the language of fintech trust. Custom BinancePlex typography gives every headline and data point a proprietary gravitas, while generous whitespace and restrained decoration keep the focus on numbers, charts, and call-to-action buttons. The design avoids visual complexity in favor of operational clarity — every element exists to either inform or convert. Product screenshots of the mobile trading app dominate the middle sections, presented on floating device mockups against golden gradients, reinforcing that this is a platform you carry with you.
What makes Binance.US distinctive is the tension between warmth and precision. The golden yellow brand color — warm, optimistic, almost celebratory — lives inside a system of cold, clinical grey text and razor-sharp borders. This isn't a playful fintech like Robinhood or a corporate fortress like Fidelity — it's a crypto-native platform that wraps cutting-edge trading technology in the visual language of established finance.
**Key Characteristics:**
- Two-tone light/dark section alternation — white surfaces for trust, dark panels for depth
- Binance Yellow (`#F0B90B`) as the singular accent color driving all primary actions
- BinancePlex custom typeface providing proprietary brand identity at every text level
- Pill-shaped CTA buttons (50px radius) that demand attention
- Floating device mockups on golden gradients for product showcasing
- Crypto price tickers with real-time data prominently displayed
- Shadow-light elevation with subtle 5% opacity card shadows
## 2. Color Palette & Roles
### Primary
- **Binance Yellow** (`#F0B90B`): The signature — primary CTA backgrounds, brand accent, active states, link color. The single most important color in the system
- **Binance Gold** (`#FFD000`): Lighter gold variant used for pill button borders, secondary CTA fills, and golden gradient highlights
- **Light Gold** (`#F8D12F`): Soft gold for gradient endpoints and hover-adjacent states
### Secondary & Accent
- **Active Yellow** (`#D0980B`): Darkened yellow for active/pressed button states — the "clicked" gold
- **Focus Blue** (`#1EAEDB`): Accessibility focus state — appears on hover and focus for all interactive elements
### Surface & Background
- **Pure White** (`#FFFFFF`): Primary page canvas, card surfaces, light section backgrounds
- **Snow** (`#F5F5F5`): Subtle surface differentiation, input backgrounds, alternating row fills
- **Binance Dark** (`#222126`): Dark section backgrounds, footer canvas, "Trusted by millions" panel — a near-black with a faint purple undertone
- **Dark Card** (`#2B2F36`): Card surfaces within dark sections, elevated dark containers
- **Ink** (`#1E2026`): Button text on yellow backgrounds, deepest text color on light surfaces
### Neutrals & Text
- **Primary Text** (`#1E2026`): Main body text, headings on light backgrounds — near-black with slight warmth
- **Secondary Text** (`#32313A`): Navigation links, descriptive copy on light surfaces
- **Slate** (`#848E9C`): Tertiary text, metadata, timestamps, footer links — the workhorse grey
- **Steel** (`#686A6C`): Disabled-adjacent text, subtle labels
- **Muted** (`#777E90`): Secondary navigation links, less prominent footer text
- **Hover Dark** (`#1A1A1A`): Universal link hover color — text darkens on hover
### Semantic & Accent
- **Crypto Green** (`#0ECB81`): Positive price movement, success states, "up" indicators
- **Crypto Red** (`#F6465D`): Negative price movement, error states, "down" indicators
- **Border Light** (`#E6E8EA`): Standard card and section borders on light backgrounds
- **Border Gold** (`#FFD000`): Active/selected state borders, pill button outlines
### Gradient System
- **Golden Glow**: Radial gradient from `#F0B90B` center to `#F8D12F` edge — used behind product mockup screenshots
- **Dark Fade**: Linear gradient from `#222126` to transparent — used for dark section transitions
- **Hero Shimmer**: Subtle animated gold gradient on hero section accents
## 3. Typography Rules
### Font Family
**Primary:** BinancePlex (custom proprietary typeface designed by Binance)
- Fallbacks: Arial, sans-serif
- Replaced DIN Next to solve multi-language spacing issues
- Available in weights: 400 (Regular), 500 (Medium), 600 (SemiBold), 700 (Bold)
**System:** system-ui stack for cookie banners and third-party UI
- Fallbacks: Segoe UI, Roboto, Helvetica, Arial
### Hierarchy
| Role | Size | Weight | Line Height | Letter Spacing | Notes |
|------|------|--------|-------------|----------------|-------|
| Display Hero | 60px | 700 | 1.08 | — | Hero headlines, maximum impact |
| Display Secondary | 34px | 700 | 1.00 | — | Section titles on dark backgrounds |
| Heading 1 | 28px | 500 | 1.00 | — | Major section headings |
| Heading 2 | 24px | 700 | 1.00 | — | Feature headings, card titles |
| Heading 3 | 24px | 600 | 1.00 | — | Subsection headings |
| Heading 4 | 20px | 600 | 1.25 | — | Card headings, feature labels |
| Body Large | 20px | 500 | 1.50 | — | Hero subtitle, lead paragraphs |
| Body | 16px | 500 | 1.50 | — | Standard body text |
| Body SemiBold | 16px | 600 | 1.30 | — | Emphasized body, nav links |
| Body Bold | 16px | 700 | 1.50 | — | Strong emphasis text |
| Button | 16px | 600 | 1.25 | 0.16px | Primary button text |
| Button Small | 14.4px | 600 | 1.60 | 0.72px | Secondary buttons, wider tracking |
| Caption | 14px | 500 | 1.43 | — | Metadata, labels, prices |
| Caption SemiBold | 14px | 600 | 1.50 | — | Emphasized captions |
| Small | 12px | 600 | 1.00 | — | Tags, badges, fine print |
| Tiny | 11px | 500 | 1.00 | — | Micro-labels, chart annotations |
### Principles
BinancePlex is engineered for data-dense interfaces where numbers and text must coexist at multiple scales. The typeface has tabular numerals by default — critical for price columns and portfolio values that need perfect vertical alignment. Weights lean toward the heavier end (500-700), giving the interface a sense of authority and confidence that's essential for a financial platform. The tight line-heights (1.00-1.25) on headings create a stacked, compressed feel that mirrors the density of trading dashboards, while body text opens up to 1.50 for comfortable reading of educational and marketing content.
## 4. Component Stylings
### Buttons
**Primary (Yellow Fill)**
- Background: Binance Yellow (`#F0B90B`)
- Text: Ink (`#1E2026`), 16px/600, BinancePlex
- Border: none
- Border radius: slightly rounded (6px)
- Padding: 6px 32px
- Hover: shifts to Focus Blue (`#1EAEDB`) with white text
- Active: darkens to Active Yellow (`#D0980B`)
- Focus: Focus Blue (`#1EAEDB`) bg, 1px black border, 2px black outline, opacity 0.9
- Transition: background 200ms ease
**Primary Pill (Gold)**
- Background: Binance Gold (`#FFD000`)
- Text: White (`#FFFFFF`)
- Border: 1px solid `#FFD000`
- Border radius: full pill (50px)
- Padding: 10px horizontal
- Shadow: `rgb(153,153,153) 0px 2px 10px -3px`
- Hover: shifts to Focus Blue (`#1EAEDB`) with white text
**Secondary (White Outlined)**
- Background: White (`#FFFFFF`)
- Text: Binance Yellow (`#F0B90B`)
- Border: 1px solid `#F0B90B`
- Border radius: full pill (50px)
- Padding: 10px horizontal
- Shadow: `rgb(153,153,153) 0px 2px 10px -3px`
- Hover: shifts to Focus Blue bg, white text
**Disabled**
- Background: `#E6E8EA`
- Text: `#848E9C`
- Cursor: not-allowed
### Cards & Containers
- Background: White (`#FFFFFF`) on light sections, Dark Card (`#2B2F36`) on dark sections
- Border: 1px solid `#E6E8EA` on light cards
- Border radius: medium rounded (12px) for content cards, tight (8px) for data cards
- Shadow: `rgba(32, 32, 37, 0.05) 0px 3px 5px 0px` — barely visible, trust-building
- Hover: shadow intensifies to `rgba(8, 8, 8, 0.05) 0px 3px 5px 5px`
- Transition: box-shadow 200ms ease
### Inputs & Forms
- Background: White (`#FFFFFF`) or Snow (`#F5F5F5`)
- Text: Ink (`#1E2026`)
- Border: 1px solid `#E6E8EA`
- Border radius: 8px
- Padding: 0px 12px (compact for trading context)
- Focus: border shifts to black (`#000000`), 1px outline
- Placeholder: Slate (`#848E9C`)
- Transition: border-color 200ms ease
### Navigation
- Background: White (`#FFFFFF`), sticky
- Height: ~64px
- Left: Binance logo (SVG, yellow mark + dark wordmark)
- Center/Right: navigation links in 14px/600 BinancePlex, color `#32313A`
- CTA: Yellow pill button "Get Started" in nav right
- Hover: links darken to `#1A1A1A`
- Mobile: hamburger menu, full-height overlay
- Top: optional promotional banner bar
### Image Treatment
- Product mockups: device frames on golden gradient backgrounds, floating with subtle shadow
- Hero images: full-width contained within card-like areas with rounded corners (24px)
- Video sections: 24px radius with embedded player controls
- App screenshots: dark-themed trading UI shown within phone/tablet bezels
- Crypto icons: 48px circular with brand colors
### Trust Indicators
- Real-time crypto price ticker (BTC, BNB, SOL with green/red price change)
- "Trusted by millions" section with statistics on dark background
- Security badges and regulatory compliance mentions
- QR code for direct app download in footer
## 5. Layout Principles
### Spacing System
Base unit: 8px
| Token | Value | Use |
|-------|-------|-----|
| space-1 | 4px | Tight inline gaps, icon padding |
| space-2 | 8px | Base unit, button icon gaps, tight margins |
| space-3 | 12px | Card internal padding, input padding |
| space-4 | 16px | Standard padding, section margins |
| space-5 | 20px | Card gaps, medium padding |
| space-6 | 24px | Section internal padding |
| space-7 | 32px | Section breaks, large padding |
| space-8 | 48px | Major section padding |
| space-9 | 64px | Hero section padding |
| space-10 | 80px | Large section spacing |
### Grid & Container
- Max container width: 1200px (centered)
- Hero area: single column with side-by-side text + image above 1024px
- Feature grid: 3-column on desktop, single column on mobile
- Product showcase: 2-column (text + device mockup)
- Horizontal padding: 32px desktop, 16px mobile
- Grid gap: 24px between feature cards
### Whitespace Philosophy
Binance.US uses whitespace as a trust signal. Generous padding around the hero section and between content blocks creates a sense of spaciousness that counters the information density typically associated with crypto exchanges. The light sections breathe — wide margins around headlines and ample spacing between cards — while dark sections compress, packing features into tighter grids to convey capability and depth. The overall rhythm alternates between "inviting entry" (light, spacious) and "deep functionality" (dark, dense).
### Border Radius Scale
| Value | Context |
|-------|---------|
| 1px | Subtle edge softening, fine UI elements |
| 2px | Close buttons, micro-interactive elements |
| 6px | Primary buttons (non-pill), small cards |
| 8px | Form inputs, data cards, image containers |
| 10px | Navigation pills, tag containers |
| 12px | Content cards, feature containers |
| 24px | Video containers, hero imagery, large cards |
| 50px | Pill buttons (CTA), search inputs, full-round elements |
## 6. Depth & Elevation
| Level | Treatment | Use |
|-------|-----------|-----|
| Flat | No shadow, solid background | Default for inline elements |
| Subtle | `rgba(32, 32, 37, 0.05) 0px 3px 5px` | Content cards, resting state |
| Medium | `rgba(8, 8, 8, 0.05) 0px 3px 5px 5px` | Hovered cards, elevated containers |
| Pill Shadow | `rgb(153,153,153) 0px 2px 10px -3px` | Pill CTA buttons, floating actions |
| Heavy | `rgba(0,0,0) 0px 32px 37px` | Modal overlays, dropdown menus |
Binance.US uses a whisper-light shadow system. Card shadows are barely perceptible at 5% opacity — they exist not for dramatic depth but as subtle ground cues that keep cards from feeling pasted onto the surface. The pill button shadow is the exception: slightly more visible to give CTAs a "floating" quality that invites clicks. The philosophy is pragmatic — in a financial context, heavy shadows feel frivolous, while no shadows at all feel flat and untrustworthy. The 5% sweet spot communicates professionalism.
### Decorative Depth
- **Golden gradient backgrounds**: Behind device mockup sections, radial golden glow centered on the product
- **Dark-to-light section transitions**: Hard cut (no gradient blend) between white and `#222126` sections
- **Price ticker strip**: Flat, borderless, reads as a data bar rather than a decorative element
## 7. Do's and Don'ts
### Do
- Use Binance Yellow (`#F0B90B`) exclusively for primary CTAs and brand accents — it's the single point of color
- Keep light and dark sections strictly alternating for visual rhythm
- Use BinancePlex at weight 500+ for all interactive elements — this is a confidence-forward design
- Apply 50px radius to all primary CTA pill buttons — the signature interactive shape
- Maintain 12px radius on content cards for a polished but not overly rounded feel
- Show real-time data prominently (prices, percentages, stats) — numbers build trust
- Use Slate (`#848E9C`) for all secondary/metadata text — the universal quiet voice
- Keep shadows at 5% opacity or less — barely there but present
### Don't
- Don't introduce additional brand colors — Binance Yellow is the only accent; all other color is data-driven (green up, red down)
- Don't use rounded corners above 12px on content cards — only CTAs and video containers go higher
- Don't add heavy shadows or hover lift effects — this is a restrained financial platform
- Don't use BinancePlex below weight 500 for headings — lighter weights undermine authority
- Don't place yellow text on yellow backgrounds — always ensure high contrast pairing
- Don't mix pill (50px) and square (6px) button styles in the same row
- Don't soften the dark sections — `#222126` should feel authoritative, not grey
- Don't use decorative illustrations — imagery should be product screenshots or data visualizations
- Don't add animation beyond subtle transitions (200ms ease) — financial platforms need stability
- Don't use colored backgrounds for semantic states in cards — keep cards white or dark, use text color for semantic meaning
## 8. Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|------|-------|-------------|
| Mobile | <425px | Single column, stacked hero, hamburger nav, 16px padding |
| Small Mobile | 425-599px | Wider mobile layout, price ticker wraps |
| Tablet Small | 600-768px | 2-column feature grid begins |
| Tablet | 769-896px | Hero side-by-side layout begins |
| Desktop Small | 897-1024px | Full nav expands, 3-column features |
| Desktop | 1024-1280px | Full layout, max content width |
| Large Desktop | 1280-1440px | Increased margins, centered container |
| XL Desktop | >1440px | Max-width container (1200px) with expanded margins |
### Touch Targets
- Minimum touch target: 44x44px (WCAG AAA)
- Pill CTA buttons: 48px height minimum
- Nav links: 44px touch area
- Crypto ticker items: full-width tappable rows on mobile
- App download buttons: large tap zones (50px+)
### Collapsing Strategy
- **Navigation**: Full horizontal links → hamburger menu below 897px; logo and "Get Started" CTA remain visible
- **Hero section**: Side-by-side (text left, image right) → stacked (text top, image below) at 768px
- **Feature grid**: 3-col → 2-col at 768px → 1-col at 600px
- **Price ticker**: Horizontal row → wrapping or scrollable at 600px
- **Section padding**: 64px → 48px → 32px → 16px as viewport narrows
- **Device mockups**: Scale down proportionally, maintain centered positioning
- **Footer**: Multi-column → stacked accordion sections on mobile
### Image Behavior
- Device mockups: CSS-scaled with max-width constraints, maintain aspect ratio
- Hero imagery: contained within rounded containers (24px), scale proportionally
- App screenshots: responsive width with fixed aspect ratio
- QR code: fixed 120px square, hidden on mobile (replaced with direct app store links)
## 9. Agent Prompt Guide
### Quick Color Reference
- Primary CTA: Binance Yellow (`#F0B90B`)
- Secondary CTA: Binance Gold (`#FFD000`)
- Background Light: Pure White (`#FFFFFF`)
- Background Dark: Binance Dark (`#222126`)
- Heading text: Ink (`#1E2026`)
- Body text: Slate (`#848E9C`)
- Border: Border Light (`#E6E8EA`)
- Positive: Crypto Green (`#0ECB81`)
- Negative: Crypto Red (`#F6465D`)
### Example Component Prompts
- "Create a hero section with white background, a 60px/700 bold headline in Ink (#1E2026), a 20px/500 subtitle in Slate (#848E9C), and a Binance Yellow (#F0B90B) pill button (50px radius) with dark text (#1E2026)"
- "Design a crypto price ticker strip showing BTC, BNB, SOL prices in 14px/600 Ink (#1E2026) with green (#0ECB81) or red (#F6465D) percentage changes, on a white background with #E6E8EA bottom border"
- "Build a feature card grid (3-column, 24px gap) with 12px radius white cards, subtle shadow (rgba(32,32,37,0.05) 0px 3px 5px), each containing a yellow (#F0B90B) icon, 20px/600 heading, and 14px/500 #848E9C description"
- "Create a dark section (#222126) with a 34px/700 white headline centered, and a 3-column feature grid using dark cards (#2B2F36) with 12px radius and yellow (#F0B90B) accent icons"
- "Design a sticky navigation bar with white background, Binance logo left, 14px/600 #32313A nav links center, and a yellow (#F0B90B) pill button (50px radius, 6px padding 32px) labeled 'Get Started' right"
### Iteration Guide
When refining existing screens generated with this design system:
1. Focus on ONE component at a time
2. Reference specific color names and hex codes from this document
3. Remember: Binance Yellow (#F0B90B) is the ONLY accent color — everything else is grey/dark/white
4. Use the dark/light section alternation for visual pacing
5. Numbers and data should be prominent — this is a financial platform
6. Pill buttons (50px radius) for CTAs, regular buttons (6px radius) for form actions
7. Keep shadows almost invisible (5% opacity) — trust comes from clarity, not depth
8. BinancePlex at 600+ weight for any text that needs to feel authoritative

352
app/package-lock.json generated

@ -35,6 +35,7 @@
"@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"axios": "^1.6.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
@ -50,6 +51,7 @@
"react-hook-form": "^7.70.0", "react-hook-form": "^7.70.0",
"react-resizable-panels": "^4.2.2", "react-resizable-panels": "^4.2.2",
"recharts": "^2.15.4", "recharts": "^2.15.4",
"socket.io-client": "^4.7.4",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
@ -4601,6 +4603,12 @@
"win32" "win32"
] ]
}, },
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": { "node_modules/@standard-schema/utils": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@ -5165,6 +5173,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.23", "version": "10.4.23",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
@ -5202,6 +5216,17 @@
"postcss": "^8.1.0" "postcss": "^8.1.0"
} }
}, },
"node_modules/axios": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/babel-plugin-polyfill-corejs2": { "node_modules/babel-plugin-polyfill-corejs2": {
"version": "0.4.15", "version": "0.4.15",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.15.tgz",
@ -5332,6 +5357,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -5485,6 +5523,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": { "node_modules/commander": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@ -5698,7 +5748,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@ -5725,6 +5774,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-node-es": { "node_modules/detect-node-es": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
@ -5755,6 +5813,20 @@
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
}, },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/echarts": { "node_modules/echarts": {
"version": "5.4.3", "version": "5.4.3",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-5.4.3.tgz", "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.4.3.tgz",
@ -5806,6 +5878,73 @@
"embla-carousel": "8.6.0" "embla-carousel": "8.6.0"
} }
}, },
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.2", "version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@ -6213,6 +6352,42 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fraction.js": { "node_modules/fraction.js": {
"version": "5.3.4", "version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@ -6246,7 +6421,6 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
@ -6262,6 +6436,30 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-nonce": { "node_modules/get-nonce": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
@ -6271,6 +6469,19 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -6297,6 +6508,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-flag": { "node_modules/has-flag": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -6307,11 +6530,37 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@ -6678,6 +6927,15 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -6715,6 +6973,27 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -6732,7 +7011,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mz": { "node_modules/mz": {
@ -7139,6 +7417,15 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -7630,6 +7917,34 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/sonner": { "node_modules/sonner": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
@ -8203,6 +8518,35 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

@ -4,10 +4,10 @@ PORT=3000
API_PREFIX=/api/v1 API_PREFIX=/api/v1
# 数据库配置 # 数据库配置
DATABASE_URL=postgresql://user:password@localhost:5432/futures_analysis?schema=public DATABASE_URL=postgresql://futures_user:futures_pass@localhost:5432/futures_analysis?schema=public
# Redis配置 # Redis配置
REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6380
REDIS_PASSWORD= REDIS_PASSWORD=
# JWT配置 # JWT配置

File diff suppressed because it is too large Load Diff

@ -0,0 +1,293 @@
-- CreateTable
CREATE TABLE "users" (
"id" SERIAL NOT NULL,
"username" VARCHAR(50) NOT NULL,
"email" VARCHAR(100) NOT NULL,
"password_hash" VARCHAR(255) NOT NULL,
"phone" VARCHAR(20),
"avatar_url" VARCHAR(255),
"membership_level" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_sessions" (
"id" SERIAL NOT NULL,
"user_id" INTEGER NOT NULL,
"token" VARCHAR(500) NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "products" (
"id" SERIAL NOT NULL,
"symbol" VARCHAR(20) NOT NULL,
"name" VARCHAR(100) NOT NULL,
"category" VARCHAR(50) NOT NULL,
"exchange" VARCHAR(50),
"unit" VARCHAR(20),
"min_change" DECIMAL(10,4),
"description" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "products_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "kline_data" (
"id" SERIAL NOT NULL,
"product_id" INTEGER NOT NULL,
"period" VARCHAR(10) NOT NULL,
"time" TIMESTAMP(3) NOT NULL,
"open" DECIMAL(18,6) NOT NULL,
"high" DECIMAL(18,6) NOT NULL,
"low" DECIMAL(18,6) NOT NULL,
"close" DECIMAL(18,6) NOT NULL,
"volume" BIGINT NOT NULL,
"open_interest" BIGINT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "kline_data_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tick_data" (
"id" SERIAL NOT NULL,
"product_id" INTEGER NOT NULL,
"price" DECIMAL(18,6) NOT NULL,
"change" DECIMAL(18,6) NOT NULL,
"change_percent" DECIMAL(10,4) NOT NULL,
"open" DECIMAL(18,6) NOT NULL,
"high" DECIMAL(18,6) NOT NULL,
"low" DECIMAL(18,6) NOT NULL,
"volume" BIGINT NOT NULL,
"open_interest" BIGINT,
"bid_price" DECIMAL(18,6),
"ask_price" DECIMAL(18,6),
"bid_volume" BIGINT,
"ask_volume" BIGINT,
"timestamp" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "tick_data_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "watchlist" (
"id" SERIAL NOT NULL,
"user_id" INTEGER NOT NULL,
"symbol" VARCHAR(20) NOT NULL,
"alert_price" DECIMAL(18,6),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "watchlist_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "price_alerts" (
"id" SERIAL NOT NULL,
"user_id" INTEGER NOT NULL,
"symbol" VARCHAR(20) NOT NULL,
"alert_type" VARCHAR(20) NOT NULL,
"alert_price" DECIMAL(18,6) NOT NULL,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"triggered_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "price_alerts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "hot_events" (
"id" SERIAL NOT NULL,
"title" VARCHAR(255) NOT NULL,
"content" TEXT,
"summary" TEXT,
"impact" VARCHAR(20) NOT NULL,
"impact_level" INTEGER NOT NULL,
"source" VARCHAR(100),
"analysis" TEXT,
"risks" VARCHAR(255)[],
"event_time" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "hot_events_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "event_products" (
"id" SERIAL NOT NULL,
"event_id" INTEGER NOT NULL,
"product_id" INTEGER NOT NULL,
"impact_confidence" DECIMAL(3,2),
CONSTRAINT "event_products_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "trading_signals" (
"id" SERIAL NOT NULL,
"product_id" INTEGER NOT NULL,
"timeframe" VARCHAR(10) NOT NULL,
"signal_type" VARCHAR(20) NOT NULL,
"strength" INTEGER NOT NULL,
"indicators" JSONB,
"description" TEXT,
"entry_price" DECIMAL(18,6),
"stop_loss" DECIMAL(18,6),
"target_price" DECIMAL(18,6),
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "trading_signals_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "option_contracts" (
"id" SERIAL NOT NULL,
"product_id" INTEGER NOT NULL,
"symbol" VARCHAR(20) NOT NULL,
"type" VARCHAR(10) NOT NULL,
"strike_price" DECIMAL(18,6) NOT NULL,
"expiry_date" TIMESTAMP(3) NOT NULL,
"price" DECIMAL(18,6),
"iv" DECIMAL(10,4),
"delta" DECIMAL(10,4),
"gamma" DECIMAL(10,4),
"theta" DECIMAL(10,4),
"vega" DECIMAL(10,4),
"rho" DECIMAL(10,4),
"volume" BIGINT,
"open_interest" BIGINT,
"updated_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "option_contracts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "paper_trades" (
"id" SERIAL NOT NULL,
"user_id" INTEGER NOT NULL,
"symbol" VARCHAR(20) NOT NULL,
"direction" VARCHAR(10) NOT NULL,
"entry_price" DECIMAL(18,6) NOT NULL,
"exit_price" DECIMAL(18,6),
"quantity" INTEGER NOT NULL,
"entry_time" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"exit_time" TIMESTAMP(3),
"pnl" DECIMAL(18,6),
"pnl_percent" DECIMAL(10,4),
"status" VARCHAR(20) NOT NULL DEFAULT 'open',
"notes" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "paper_trades_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "system_configs" (
"id" SERIAL NOT NULL,
"key" VARCHAR(100) NOT NULL,
"value" TEXT NOT NULL,
"description" TEXT,
"updated_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "system_configs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "user_sessions_token_key" ON "user_sessions"("token");
-- CreateIndex
CREATE INDEX "user_sessions_token_idx" ON "user_sessions"("token");
-- CreateIndex
CREATE UNIQUE INDEX "products_symbol_key" ON "products"("symbol");
-- CreateIndex
CREATE INDEX "kline_data_product_id_period_time_idx" ON "kline_data"("product_id", "period", "time");
-- CreateIndex
CREATE UNIQUE INDEX "kline_data_product_id_period_time_key" ON "kline_data"("product_id", "period", "time");
-- CreateIndex
CREATE INDEX "tick_data_product_id_timestamp_idx" ON "tick_data"("product_id", "timestamp");
-- CreateIndex
CREATE INDEX "watchlist_user_id_idx" ON "watchlist"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "watchlist_user_id_symbol_key" ON "watchlist"("user_id", "symbol");
-- CreateIndex
CREATE INDEX "price_alerts_user_id_idx" ON "price_alerts"("user_id");
-- CreateIndex
CREATE INDEX "price_alerts_symbol_is_active_idx" ON "price_alerts"("symbol", "is_active");
-- CreateIndex
CREATE UNIQUE INDEX "event_products_event_id_product_id_key" ON "event_products"("event_id", "product_id");
-- CreateIndex
CREATE INDEX "trading_signals_product_id_timeframe_created_at_idx" ON "trading_signals"("product_id", "timeframe", "created_at");
-- CreateIndex
CREATE INDEX "option_contracts_product_id_expiry_date_idx" ON "option_contracts"("product_id", "expiry_date");
-- CreateIndex
CREATE UNIQUE INDEX "option_contracts_product_id_type_strike_price_expiry_date_key" ON "option_contracts"("product_id", "type", "strike_price", "expiry_date");
-- CreateIndex
CREATE INDEX "paper_trades_user_id_idx" ON "paper_trades"("user_id");
-- CreateIndex
CREATE INDEX "paper_trades_user_id_status_idx" ON "paper_trades"("user_id", "status");
-- CreateIndex
CREATE UNIQUE INDEX "system_configs_key_key" ON "system_configs"("key");
-- AddForeignKey
ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "kline_data" ADD CONSTRAINT "kline_data_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tick_data" ADD CONSTRAINT "tick_data_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "watchlist" ADD CONSTRAINT "watchlist_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "price_alerts" ADD CONSTRAINT "price_alerts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "event_products" ADD CONSTRAINT "event_products_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "hot_events"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "event_products" ADD CONSTRAINT "event_products_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "trading_signals" ADD CONSTRAINT "trading_signals_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "option_contracts" ADD CONSTRAINT "option_contracts_product_id_fkey" FOREIGN KEY ("product_id") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "paper_trades" ADD CONSTRAINT "paper_trades_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>期货智析 - Binance Design</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,82 @@
{
"name": "binance-design",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"axios": "^1.6.5",
"socket.io-client": "^4.7.4",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"echarts": "^5.4.3",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.70.0",
"react-resizable-panels": "^4.2.2",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
"zod": "^4.3.5"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"kimi-plugin-inspect-react": "^1.0.3",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

@ -0,0 +1,323 @@
import { useState, useEffect } from 'react';
import { Navbar } from '@/components/Navbar';
import { MarketOverviewPanel } from '@/components/MarketOverview';
import { HotEventsPanel } from '@/components/HotEvents';
import { ProductCard } from '@/components/ProductCard';
import { ProductDetail } from '@/components/ProductDetail';
import { RiskAlertsPanel } from '@/components/RiskAlerts';
import { marketOverview, hotEvents, futuresProducts, riskAlerts } from '@/data/mockData';
import { Filter, Search, TrendingUp, TrendingDown, Grid3X3, List } from 'lucide-react';
import type { FuturesProduct } from '@/types';
type ViewType = 'grid' | 'list';
const categoryFilters = [
{ key: 'all', label: '全部' },
{ key: 'energy', label: '能源' },
{ key: 'metal', label: '金属' },
{ key: 'agriculture', label: '农产品' },
{ key: 'financial', label: '金融' },
];
const sortOptions = [
{ key: 'successRate', label: '成功率' },
{ key: 'trendScore', label: '趋势强度' },
{ key: 'changePercent', label: '涨跌幅' },
];
function App() {
const [activeTab, setActiveTab] = useState('overview');
const [selectedProduct, setSelectedProduct] = useState<FuturesProduct | null>(null);
const [viewType, setViewType] = useState<ViewType>('grid');
const [categoryFilter, setCategoryFilter] = useState('all');
const [sortBy, setSortBy] = useState('successRate');
const [searchQuery, setSearchQuery] = useState('');
const filteredProducts = futuresProducts
.filter((product) => {
if (categoryFilter !== 'all' && product.category !== categoryFilter) {
return false;
}
if (searchQuery && !product.name.includes(searchQuery) && !product.code.includes(searchQuery)) {
return false;
}
return true;
})
.sort((a, b) => {
if (sortBy === 'successRate') {
return b.successRate - a.successRate;
} else if (sortBy === 'trendScore') {
return b.trendScore - a.trendScore;
} else if (sortBy === 'changePercent') {
return b.changePercent - a.changePercent;
}
return 0;
});
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
setIsLoaded(true);
}, []);
const renderOverview = () => (
<div className={`space-y-space-6 transition-all duration-500 ${isLoaded ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-text-ink"></h2>
<span className="text-sm text-text-slate"></span>
</div>
<MarketOverviewPanel data={marketOverview} />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-space-6">
<div className="lg:col-span-2">
<HotEventsPanel events={hotEvents} />
</div>
<div>
<RiskAlertsPanel alerts={riskAlerts} />
</div>
</div>
</div>
);
const renderEvents = () => (
<div className="space-y-space-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-text-ink"></h2>
<span className="text-sm text-text-slate"> {hotEvents.length} </span>
</div>
<HotEventsPanel events={hotEvents} />
</div>
);
const renderProducts = () => (
<div className="space-y-space-6">
{selectedProduct ? (
<ProductDetail
product={selectedProduct}
onBack={() => setSelectedProduct(null)}
/>
) : (
<>
<div className="p-4 rounded-card border border-border-light bg-white space-y-4">
<div className="flex flex-wrap items-center gap-4">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-slate" />
<input
type="text"
placeholder="搜索品种名称或代码..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-lg bg-surface-snow border border-binanceBorder-light text-text-ink text-sm placeholder:text-text-slate focus:outline-none focus:border-binance-yellow transition-colors"
/>
</div>
<div className="flex items-center gap-1 p-1 rounded-lg bg-surface-snow border border-border-light">
<button
onClick={() => setViewType('grid')}
className={`p-2 rounded-md transition-colors ${viewType === 'grid' ? 'bg-binance-yellow text-text-ink' : 'text-text-secondary hover:text-text-ink'}`}
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewType('list')}
className={`p-2 rounded-md transition-colors ${viewType === 'list' ? 'bg-binance-yellow text-text-ink' : 'text-text-secondary hover:text-text-ink'}`}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-text-slate" />
<span className="text-sm text-text-secondary">:</span>
<div className="flex gap-1">
{categoryFilters.map((filter) => (
<button
key={filter.key}
onClick={() => setCategoryFilter(filter.key)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${
categoryFilter === filter.key
? 'bg-binance-yellow text-text-ink font-semibold'
: 'bg-surface-snow text-text-secondary border border-border-light hover:border-binance-gold'
}`}
>
{filter.label}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-text-secondary">:</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="px-3 py-1.5 rounded-lg bg-surface-snow border border-border-light text-text-ink text-sm focus:outline-none focus:border-binance-yellow"
>
{sortOptions.map((option) => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</select>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-sm text-text-secondary">
<span className="text-text-ink font-semibold">{filteredProducts.length}</span>
</span>
<div className="flex items-center gap-2 text-sm">
<span className="flex items-center gap-1 text-semantic-green">
<TrendingUp className="w-3 h-3" />
{filteredProducts.filter(p => p.change >= 0).length}
</span>
<span className="flex items-center gap-1 text-semantic-red">
<TrendingDown className="w-3 h-3" />
{filteredProducts.filter(p => p.change < 0).length}
</span>
</div>
</div>
</div>
{viewType === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredProducts.map((product) => (
<ProductCard
key={product.id}
product={product}
onClick={() => setSelectedProduct(product)}
/>
))}
</div>
) : (
<div className="space-y-2">
{filteredProducts.map((product) => (
<div
key={product.id}
onClick={() => setSelectedProduct(product)}
className="p-4 rounded-card border border-binanceBorder-light bg-white hover:border-binance-yellow/50 transition-all cursor-pointer"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div>
<div className="font-semibold text-text-ink">{product.name}</div>
<div className="text-xs text-text-slate">{product.code}</div>
</div>
<div className="flex gap-2">
{Object.entries(product.cycles).map(([key, trend]) => (
<span
key={key}
className={`px-2 py-1 rounded text-xs ${
trend === 'up' ? 'bg-semantic-green/10 text-semantic-green' :
trend === 'down' ? 'bg-semantic-red/10 text-semantic-red' :
'bg-text-slate/10 text-text-slate'
}`}
>
{key === 'm5' ? '5分' : key === 'm15' ? '15分' : key === 'm30' ? '30分' : '60分'}
</span>
))}
</div>
</div>
<div className="text-right">
<div className={`font-mono font-semibold ${product.change >= 0 ? 'text-semantic-green' : 'text-semantic-red'}`}>
¥{product.price.toLocaleString()}
</div>
<div className={`text-sm ${product.change >= 0 ? 'text-semantic-green' : 'text-semantic-red'}`}>
{product.change >= 0 ? '+' : ''}{product.changePercent}%
</div>
</div>
</div>
</div>
))}
</div>
)}
{filteredProducts.length === 0 && (
<div className="text-center py-12">
<div className="text-text-slate mb-2"></div>
<button
onClick={() => {
setCategoryFilter('all');
setSearchQuery('');
}}
className="text-binance-yellow hover:underline"
>
</button>
</div>
)}
</>
)}
</div>
);
const renderRisks = () => (
<div className="space-y-space-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-text-ink"></h2>
<span className="text-sm text-text-slate"></span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-space-6">
<RiskAlertsPanel alerts={riskAlerts} />
<div className="p-6 rounded-card border border-binanceBorder-light bg-white">
<h3 className="font-bold text-text-ink mb-space-4"></h3>
<div className="space-y-space-4">
<div className="p-4 rounded-lg bg-semantic-red/5 border border-semantic-red/20">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-semantic-red" />
<span className="font-semibold text-text-ink"></span>
</div>
<p className="text-sm text-text-secondary">
30%
</p>
</div>
<div className="p-4 rounded-lg bg-binance-gold/5 border border-binance-gold/20">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-binance-gold" />
<span className="font-semibold text-text-ink"></span>
</div>
<p className="text-sm text-text-secondary">
</p>
</div>
<div className="p-4 rounded-lg bg-semantic-green/5 border border-semantic-green/20">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-semantic-green" />
<span className="font-semibold text-text-ink"></span>
</div>
<p className="text-sm text-text-secondary">
</p>
</div>
</div>
</div>
</div>
</div>
);
return (
<div className="min-h-screen bg-white">
<Navbar activeTab={activeTab} onTabChange={setActiveTab} />
<main className="pt-20 pb-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-container mx-auto">
{activeTab === 'overview' && renderOverview()}
{activeTab === 'events' && renderEvents()}
{activeTab === 'products' && renderProducts()}
{activeTab === 'risks' && renderRisks()}
</div>
</main>
</div>
);
}
export default App;

@ -0,0 +1,174 @@
import { useState } from 'react';
import { Flame, TrendingUp, TrendingDown, Minus, AlertTriangle, ChevronRight, Calendar, Target } from 'lucide-react';
import type { HotEvent } from '@/types';
interface HotEventsProps {
events: HotEvent[];
}
function ImpactStars({ level }: { level: number }) {
return (
<div className="flex gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className={`w-4 h-4 rounded-sm ${i < level ? 'bg-binance-yellow' : 'bg-binanceBorder-light'}`}
/>
))}
</div>
);
}
function ImpactBadge({ impact }: { impact: 'bullish' | 'bearish' | 'neutral' }) {
const config = {
bullish: { icon: TrendingUp, text: '利多', color: 'text-semantic-green', bg: 'bg-semantic-green/10' },
bearish: { icon: TrendingDown, text: '利空', color: 'text-semantic-red', bg: 'bg-semantic-red/10' },
neutral: { icon: Minus, text: '中性', color: 'text-text-slate', bg: 'bg-text-slate/10' },
};
const { icon: Icon, text, color, bg } = config[impact];
return (
<div className={`flex items-center gap-1 px-2 py-1 rounded-md ${bg}`}>
<Icon className={`w-3 h-3 ${color}`} />
<span className={`text-xs font-semibold ${color}`}>{text}</span>
</div>
);
}
export function HotEventsPanel({ events }: HotEventsProps) {
const [selectedEvent, setSelectedEvent] = useState<HotEvent>(events[0]);
return (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-space-4">
<div className="lg:col-span-3 space-y-space-3">
<div className="flex items-center gap-2 mb-space-4">
<Flame className="w-5 h-5 text-binance-yellow" />
<h3 className="text-lg font-bold text-text-ink"></h3>
<span className="text-xs text-text-slate ml-auto"> {events.length} </span>
</div>
{events.map((event) => (
<div
key={event.id}
onClick={() => setSelectedEvent(event)}
className={`p-4 rounded-card border cursor-pointer transition-all duration-200 ${
selectedEvent?.id === event.id
? 'border-binance-yellow bg-binance-yellow/5'
: 'border-border-light bg-white hover:border-binance-gold'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h4 className="font-semibold text-text-ink">{event.title}</h4>
<ImpactBadge impact={event.impact} />
</div>
<p className="text-sm text-text-slate mb-space-3 line-clamp-2">{event.summary}</p>
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1 text-text-slate">
<Calendar className="w-3 h-3" />
<span>{event.time}</span>
</div>
<div className="flex items-center gap-2">
<Target className="w-3 h-3 text-text-slate" />
<span className="text-text-slate">:</span>
<div className="flex gap-1">
{event.affectedProducts.slice(0, 3).map((product) => (
<span
key={product}
className="px-1.5 py-0.5 rounded bg-surface-snow text-text-secondary"
>
{product}
</span>
))}
{event.affectedProducts.length > 3 && (
<span className="text-text-slate">+{event.affectedProducts.length - 3}</span>
)}
</div>
</div>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<ImpactStars level={event.impactLevel} />
<ChevronRight className={`w-4 h-4 transition-transform ${
selectedEvent?.id === event.id ? 'text-binance-yellow rotate-90' : 'text-text-slate'
}`} />
</div>
</div>
</div>
))}
</div>
<div className="lg:col-span-2">
<div className="sticky top-4 p-space-5 rounded-card border border-border-light bg-white">
{selectedEvent ? (
<>
<div className="flex items-center gap-2 mb-space-4">
<div className="p-2 rounded-lg bg-binance-yellow/10">
<AlertTriangle className="w-4 h-4 text-binance-yellow" />
</div>
<h3 className="font-bold text-text-ink"></h3>
</div>
<div className="space-y-space-4">
<div>
<h4 className="text-sm text-text-slate mb-2"></h4>
<p className="text-sm text-text-ink leading-relaxed">{selectedEvent.analysis}</p>
</div>
<div>
<h4 className="text-sm text-text-slate mb-2"></h4>
<div className="flex flex-wrap gap-2">
{selectedEvent.affectedProducts.map((product) => (
<span
key={product}
className={`px-3 py-1.5 rounded-lg text-sm font-semibold ${
selectedEvent.impact === 'bullish'
? 'bg-semantic-green/10 text-semantic-green'
: selectedEvent.impact === 'bearish'
? 'bg-semantic-red/10 text-semantic-red'
: 'bg-text-slate/10 text-text-slate'
}`}
>
{product}
</span>
))}
</div>
</div>
<div>
<h4 className="text-sm text-text-slate mb-2"></h4>
<ul className="space-y-2">
{selectedEvent.risks.map((risk, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-text-secondary">
<span className="w-1.5 h-1.5 rounded-full bg-binance-gold mt-1.5 flex-shrink-0" />
<span>{risk}</span>
</li>
))}
</ul>
</div>
<div className="pt-space-4 border-t border-binanceBorder-light">
<div className="flex items-center justify-between">
<span className="text-sm text-text-slate"></span>
<ImpactStars level={selectedEvent.impactLevel} />
</div>
</div>
</div>
</>
) : (
<div className="text-center py-8 text-text-slate">
<AlertTriangle className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p></p>
</div>
)}
</div>
</div>
</div>
);
}

@ -0,0 +1,365 @@
import { useEffect, useRef, useState } from 'react';
import * as echarts from 'echarts';
import type { KLineData, MACDData } from '@/types';
interface KLineChartProps {
klineData: KLineData[];
macdData: MACDData[];
resistance: number[];
support: number[];
height?: number;
}
export function KLineChart({ klineData, macdData, resistance, support, height = 500 }: KLineChartProps) {
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!chartRef.current || klineData.length === 0) return;
if (!chartInstance.current) {
chartInstance.current = echarts.init(chartRef.current);
}
const chart = chartInstance.current;
const dates = klineData.map(d => {
const date = new Date(d.time);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
});
const klineValues = klineData.map(d => [d.open, d.close, d.low, d.high]);
const volumes = klineData.map(d => d.volume);
const difData = macdData.map(d => d.dif);
const deaData = macdData.map(d => d.dea);
const macdBarData = macdData.map(d => ({
value: d.macd,
itemStyle: {
color: d.macd >= 0 ? '#0ECB81' : '#F6465D',
},
}));
const markLines: any[] = [];
resistance.forEach((price, index) => {
markLines.push({
yAxis: price,
name: `压力${index + 1}`,
lineStyle: {
color: '#F6465D',
type: 'solid',
width: 1.5,
},
label: {
formatter: `压力{${index + 1}}: {c}`,
color: '#F6465D',
fontSize: 11,
},
});
});
support.forEach((price, index) => {
markLines.push({
yAxis: price,
name: `支撑${index + 1}`,
lineStyle: {
color: '#0ECB81',
type: 'solid',
width: 1.5,
},
label: {
formatter: `支撑{${index + 1}}: {c}`,
color: '#0ECB81',
fontSize: 11,
},
});
});
const calculateMA = (dayCount: number) => {
const result: number[] = [];
for (let i = 0; i < klineData.length; i++) {
if (i < dayCount - 1) {
result.push(Number(klineData[i].close.toFixed(2)));
continue;
}
let sum = 0;
for (let j = 0; j < dayCount; j++) {
sum += klineData[i - j].close;
}
result.push(Number((sum / dayCount).toFixed(2)));
}
return result;
};
const ma5 = calculateMA(5);
const ma10 = calculateMA(10);
const ma20 = calculateMA(20);
const option: echarts.EChartsOption = {
backgroundColor: 'transparent',
animation: true,
animationDuration: 500,
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#F0B90B',
},
},
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#F0B90B',
borderWidth: 1,
textStyle: {
color: '#1E2026',
fontSize: 12,
},
formatter: (params: any) => {
const kline = params.find((p: any) => p.seriesName === 'K线');
const vol = params.find((p: any) => p.seriesName === '成交量');
const macd = params.find((p: any) => p.seriesName === 'MACD');
if (!kline) return '';
const data = kline.data;
const open = data[1];
const close = data[2];
const low = data[3];
const high = data[4];
const change = ((close - open) / open * 100).toFixed(2);
const changeColor = close >= open ? '#0ECB81' : '#F6465D';
return `
<div style="padding: 8px;">
<div style="font-weight: bold; margin-bottom: 8px; color: #1E2026;">${kline.axisValue}</div>
<div style="display: grid; grid-template-columns: auto auto; gap: 4px 16px; font-size: 12px;">
<span style="color: #848E9C;">:</span><span style="color: #1E2026;">${open.toFixed(2)}</span>
<span style="color: #848E9C;">:</span><span style="color: #1E2026;">${high.toFixed(2)}</span>
<span style="color: #848E9C;">:</span><span style="color: #1E2026;">${low.toFixed(2)}</span>
<span style="color: #848E9C;">:</span><span style="color: ${changeColor};">${close.toFixed(2)} (${change}%)</span>
${vol ? `<span style="color: #848E9C;">成交量:</span><span style="color: #1E2026;">${vol.data.toLocaleString()}</span>` : ''}
${macd ? `<span style="color: #848E9C;">MACD:</span><span style="color: ${macd.data >= 0 ? '#0ECB81' : '#F6465D'};">${macd.data.toFixed(4)}</span>` : ''}
</div>
</div>
`;
},
},
axisPointer: {
link: [{ xAxisIndex: 'all' }],
label: {
backgroundColor: '#F0B90B',
},
},
grid: [
{
left: '3%',
right: '3%',
top: '5%',
height: '50%',
containLabel: true,
},
{
left: '3%',
right: '3%',
top: '58%',
height: '15%',
containLabel: true,
},
{
left: '3%',
right: '3%',
top: '76%',
height: '18%',
containLabel: true,
},
],
xAxis: [
{
type: 'category',
data: dates,
boundaryGap: true,
axisLine: { lineStyle: { color: '#E6E8EA' } },
axisLabel: { color: '#848E9C', fontSize: 10 },
axisTick: { show: false },
splitLine: { show: false },
},
{
type: 'category',
gridIndex: 1,
data: dates,
boundaryGap: true,
axisLine: { show: false },
axisLabel: { show: false },
axisTick: { show: false },
splitLine: { show: false },
},
{
type: 'category',
gridIndex: 2,
data: dates,
boundaryGap: true,
axisLine: { lineStyle: { color: '#E6E8EA' } },
axisLabel: { color: '#848E9C', fontSize: 10 },
axisTick: { show: false },
splitLine: { show: false },
},
],
yAxis: [
{
scale: true,
axisLine: { lineStyle: { color: '#E6E8EA' } },
axisLabel: { color: '#848E9C', fontSize: 10 },
splitLine: { lineStyle: { color: '#E6E8EA', type: 'dashed' } },
position: 'right',
},
{
scale: true,
gridIndex: 1,
axisLine: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
},
{
scale: true,
gridIndex: 2,
axisLine: { lineStyle: { color: '#E6E8EA' } },
axisLabel: { color: '#848E9C', fontSize: 10 },
splitLine: { lineStyle: { color: '#E6E8EA', type: 'dashed' } },
position: 'right',
},
],
dataZoom: [
{
type: 'inside',
xAxisIndex: [0, 1, 2],
start: 50,
end: 100,
},
{
type: 'slider',
xAxisIndex: [0, 1, 2],
start: 50,
end: 100,
height: 20,
bottom: 0,
borderColor: '#E6E8EA',
fillerColor: 'rgba(240, 185, 11, 0.2)',
handleStyle: { color: '#F0B90B' },
textStyle: { color: '#848E9C' },
},
],
series: [
{
name: 'K线',
type: 'candlestick',
data: klineValues,
itemStyle: {
color: '#0ECB81',
color0: '#F6465D',
borderColor: '#0ECB81',
borderColor0: '#F6465D',
},
markLine: {
symbol: 'none',
data: markLines,
animation: true,
},
},
{
name: 'MA5',
type: 'line',
data: ma5,
smooth: true,
showSymbol: false,
lineStyle: { color: '#FFD000', width: 1 },
},
{
name: 'MA10',
type: 'line',
data: ma10,
smooth: true,
showSymbol: false,
lineStyle: { color: '#1EAEDB', width: 1 },
},
{
name: 'MA20',
type: 'line',
data: ma20,
smooth: true,
showSymbol: false,
lineStyle: { color: '#F0B90B', width: 1 },
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes,
itemStyle: {
color: (params: any) => {
const index = params.dataIndex;
const close = klineData[index]?.close;
const open = klineData[index]?.open;
return close >= open ? 'rgba(14, 203, 129, 0.6)' : 'rgba(246, 70, 93, 0.6)';
},
},
},
{
name: 'MACD',
type: 'bar',
xAxisIndex: 2,
yAxisIndex: 2,
data: macdBarData,
},
{
name: 'DIF',
type: 'line',
xAxisIndex: 2,
yAxisIndex: 2,
data: difData,
smooth: true,
showSymbol: false,
lineStyle: { color: '#FFD000', width: 1.5 },
},
{
name: 'DEA',
type: 'line',
xAxisIndex: 2,
yAxisIndex: 2,
data: deaData,
smooth: true,
showSymbol: false,
lineStyle: { color: '#1EAEDB', width: 1.5 },
},
],
};
chart.setOption(option);
setLoading(false);
const handleResize = () => {
chart.resize();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [klineData, macdData, resistance, support]);
return (
<div className="relative">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 z-10">
<div className="flex items-center gap-2 text-binance-yellow">
<div className="w-5 h-5 border-2 border-binance-yellow border-t-transparent rounded-full animate-spin" />
<span className="text-sm">...</span>
</div>
</div>
)}
<div ref={chartRef} style={{ height: `${height}px` }} className="w-full" />
</div>
);
}

@ -0,0 +1,160 @@
import { useEffect, useRef, useState } from 'react';
import { TrendingUp, TrendingDown, Activity, DollarSign, BarChart3, Zap } from 'lucide-react';
import type { MarketOverview } from '@/types';
interface MarketOverviewProps {
data: MarketOverview;
}
function AnimatedNumber({ value, decimals = 1, suffix = '' }: { value: number; decimals?: number; suffix?: string }) {
const [displayValue, setDisplayValue] = useState(0);
const startTime = useRef<number | null>(null);
const duration = 1000;
useEffect(() => {
const animate = (timestamp: number) => {
if (!startTime.current) startTime.current = timestamp;
const progress = Math.min((timestamp - startTime.current) / duration, 1);
const easeOut = 1 - Math.pow(1 - progress, 3);
setDisplayValue(value * easeOut);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
return () => {
startTime.current = null;
};
}, [value]);
return (
<span>
{displayValue.toFixed(decimals)}{suffix}
</span>
);
}
function MiniChart({ isUp }: { isUp: boolean }) {
const points = isUp
? '0,30 10,25 20,28 30,20 40,22 50,15 60,18 70,10 80,12 90,5 100,8'
: '0,10 10,15 20,12 30,20 40,18 50,25 60,22 70,30 80,28 90,35 100,32';
return (
<svg viewBox="0 0 100 40" className="w-full h-10">
<polyline
fill="none"
stroke={isUp ? '#0ECB81' : '#F6465D'}
strokeWidth="2"
points={points}
/>
<defs>
<linearGradient id={`gradient-${isUp}`} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={isUp ? '#0ECB81' : '#F6465D'} stopOpacity="0.3" />
<stop offset="100%" stopColor={isUp ? '#0ECB81' : '#F6465D'} stopOpacity="0" />
</linearGradient>
</defs>
<polygon
fill={`url(#gradient-${isUp})`}
points={`${points} 100,40 0,40`}
/>
</svg>
);
}
export function MarketOverviewPanel({ data }: MarketOverviewProps) {
const cards = [
{
title: '市场热度指数',
value: data.heatIndex,
change: data.heatChange,
suffix: '',
decimals: 1,
icon: Activity,
isUp: data.heatChange > 0,
description: data.heatChange > 0 ? '多头情绪高涨' : '市场情绪偏冷',
},
{
title: '涨跌分布',
value: data.upCount,
change: data.downCount,
suffix: '',
decimals: 0,
icon: BarChart3,
isUp: true,
description: `涨: ${data.upCount} | 跌: ${data.downCount}`,
isDistribution: true,
},
{
title: '资金流向',
value: data.capitalFlow,
change: 0,
suffix: '亿',
decimals: 1,
icon: DollarSign,
isUp: data.capitalFlow > 0,
description: data.capitalFlow > 0 ? '资金净流入' : '资金净流出',
},
{
title: '波动率指数',
value: data.volatilityIndex,
change: data.volatilityChange,
suffix: '',
decimals: 1,
icon: Zap,
isUp: data.volatilityChange > 0,
description: data.volatilityChange > 0 ? '波动扩大' : '波动收窄',
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-space-6">
{cards.map((card, index) => (
<div
key={card.title}
className="bg-white border border-binanceBorder-light rounded-card p-space-5 hover:shadow-card-hover transition-all duration-300 group"
style={{
animationDelay: `${index * 100}ms`,
}}
>
<div className="flex items-start justify-between mb-space-3">
<div className="flex items-center gap-2">
<div className={`p-2 rounded-lg ${card.isUp ? 'bg-semantic-green/10' : 'bg-semantic-red/10'}`}>
<card.icon className={`w-4 h-4 ${card.isUp ? 'text-semantic-green' : 'text-semantic-red'}`} />
</div>
<span className="text-text-slate text-sm">{card.title}</span>
</div>
{!card.isDistribution && card.change !== 0 && (
<div className={`flex items-center gap-1 text-xs ${card.change > 0 ? 'text-semantic-green' : 'text-semantic-red'}`}>
{card.change > 0 ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
<span>{card.change > 0 ? '+' : ''}{card.change}%</span>
</div>
)}
</div>
<div className="mb-space-3">
<span className={`text-2xl font-bold font-mono ${card.isUp ? 'text-text-ink' : 'text-semantic-red'}`}>
{card.isDistribution ? (
<span className="flex items-baseline gap-2">
<span className="text-semantic-green">{data.upCount}</span>
<span className="text-text-slate text-lg">/</span>
<span className="text-semantic-red">{data.downCount}</span>
</span>
) : (
<>
<AnimatedNumber value={card.value} decimals={card.decimals} suffix={card.suffix} />
</>
)}
</span>
</div>
<div className="text-xs text-text-slate mb-space-3">{card.description}</div>
<MiniChart isUp={card.isUp} />
</div>
))}
</div>
);
}

@ -0,0 +1,147 @@
import { useState, useEffect } from 'react';
import { BarChart3, Bell, Clock, Menu, X } from 'lucide-react';
interface NavbarProps {
activeTab: string;
onTabChange: (tab: string) => void;
}
const navItems = [
{ id: 'overview', label: '市场概览' },
{ id: 'events', label: '热点事件' },
{ id: 'products', label: '品种分析' },
{ id: 'risks', label: '风险提醒' },
];
export function Navbar({ activeTab, onTabChange }: NavbarProps) {
const [currentTime, setCurrentTime] = useState(new Date());
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
const handleScroll = () => {
setIsScrolled(window.scrollY > 10);
};
window.addEventListener('scroll', handleScroll);
return () => {
clearInterval(timer);
window.removeEventListener('scroll', handleScroll);
};
}, []);
const formatTime = (date: Date) => {
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};
return (
<nav
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
isScrolled
? 'bg-white/95 backdrop-blur-md shadow-card'
: 'bg-white'
}`}
>
<div className="max-w-container mx-auto px-space-7">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-binance-yellow/10">
<BarChart3 className="w-5 h-5 text-binance-yellow" />
</div>
<div>
<h1 className="text-lg font-bold text-text-ink"></h1>
<p className="text-xs text-text-slate hidden sm:block"></p>
</div>
</div>
<div className="hidden md:flex items-center gap-1">
{navItems.map((item) => (
<button
key={item.id}
onClick={() => onTabChange(item.id)}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all duration-200 relative ${
activeTab === item.id
? 'text-binance-yellow'
: 'text-text-secondary hover:text-text-hoverDark'
}`}
>
{item.label}
{activeTab === item.id && (
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-4 h-0.5 bg-binance-yellow rounded-full" />
)}
</button>
))}
</div>
<div className="flex items-center gap-4">
<div className="hidden sm:flex items-center gap-2 text-sm text-text-slate">
<Clock className="w-4 h-4" />
<span className="font-mono">{formatTime(currentTime)}</span>
</div>
<button className="p-2 rounded-lg hover:bg-surface-snow transition-colors relative">
<Bell className="w-4 h-4 text-text-secondary" />
<span className="absolute top-1 right-1 w-2 h-2 bg-semantic-red rounded-full" />
</button>
<button className="btn-pill text-sm">
使
</button>
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="md:hidden p-2 rounded-lg hover:bg-surface-snow transition-colors"
>
{isMobileMenuOpen ? (
<X className="w-5 h-5 text-text-secondary" />
) : (
<Menu className="w-5 h-5 text-text-secondary" />
)}
</button>
</div>
</div>
</div>
{isMobileMenuOpen && (
<div className="md:hidden border-t border-binanceBorder-light bg-white/95 backdrop-blur-md">
<div className="px-space-7 py-3 space-y-1">
{navItems.map((item) => (
<button
key={item.id}
onClick={() => {
onTabChange(item.id);
setIsMobileMenuOpen(false);
}}
className={`w-full px-4 py-3 rounded-lg text-left text-sm font-semibold transition-all duration-200 ${
activeTab === item.id
? 'bg-binance-yellow/10 text-binance-yellow'
: 'text-text-secondary hover:bg-surface-snow hover:text-text-hoverDark'
}`}
>
{item.label}
</button>
))}
<div className="pt-3 border-t border-binanceBorder-light mt-3">
<div className="flex items-center gap-2 text-sm text-text-slate px-4 py-2">
<Clock className="w-4 h-4" />
<span className="font-mono">{formatTime(currentTime)}</span>
</div>
</div>
</div>
</div>
)}
</nav>
);
}

@ -0,0 +1,160 @@
import { TrendingUp, TrendingDown, Minus, Clock, Target, BarChart3, ChevronRight } from 'lucide-react';
import type { FuturesProduct, TrendDirection } from '@/types';
interface ProductCardProps {
product: FuturesProduct;
onClick?: () => void;
}
function TrendBadge({ direction, label }: { direction: TrendDirection; label: string }) {
const config = {
up: { icon: TrendingUp, color: 'text-semantic-green', bg: 'bg-semantic-green/10', border: 'border-semantic-green/30' },
down: { icon: TrendingDown, color: 'text-semantic-red', bg: 'bg-semantic-red/10', border: 'border-semantic-red/30' },
sideways: { icon: Minus, color: 'text-text-slate', bg: 'bg-text-slate/10', border: 'border-text-slate/30' },
};
const { icon: Icon, color, bg, border } = config[direction];
return (
<div className={`flex items-center gap-1 px-2 py-1 rounded-md ${bg} ${border} border`}>
<Icon className={`w-3 h-3 ${color}`} />
<span className={`text-xs font-semibold ${color}`}>{label}</span>
</div>
);
}
function RecommendationBadge({ recommendation }: { recommendation: 'long' | 'short' | 'wait' }) {
const config = {
long: { text: '逢低做多', color: 'text-semantic-green', bg: 'bg-semantic-green/10' },
short: { text: '逢高做空', color: 'text-semantic-red', bg: 'bg-semantic-red/10' },
wait: { text: '观望等待', color: 'text-text-slate', bg: 'bg-text-slate/10' },
};
const { text, color, bg } = config[recommendation];
return (
<span className={`px-2 py-1 rounded-md text-xs font-semibold ${color} ${bg}`}>
{text}
</span>
);
}
function SuccessRateBar({ rate }: { rate: number }) {
const getColor = (r: number) => {
if (r >= 70) return 'bg-semantic-green';
if (r >= 50) return 'bg-binance-gold';
return 'bg-semantic-red';
};
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-border-light rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${getColor(rate)}`}
style={{ width: `${rate}%` }}
/>
</div>
<span className={`text-xs font-semibold ${rate >= 70 ? 'text-semantic-green' : rate >= 50 ? 'text-binance-gold' : 'text-semantic-red'}`}>
{rate}%
</span>
</div>
);
}
export function ProductCard({ product, onClick }: ProductCardProps) {
const isUp = product.change >= 0;
const cycleLabels: Record<string, string> = {
m5: '5分',
m15: '15分',
m30: '30分',
m60: '60分',
};
return (
<div
onClick={onClick}
className="p-4 rounded-card border border-border-light bg-white hover:border-binance-yellow/50 transition-all duration-300 cursor-pointer group hover:shadow-card-hover"
>
<div className="flex items-start justify-between mb-space-3">
<div>
<div className="flex items-center gap-2 mb-1">
<h4 className="font-bold text-text-ink">{product.name}</h4>
<span className="text-xs text-text-slate">({product.code})</span>
</div>
<RecommendationBadge recommendation={product.recommendation} />
</div>
<div className="text-right">
<div className={`text-xl font-bold font-mono ${isUp ? 'text-semantic-green' : 'text-semantic-red'}`}>
¥{product.price.toLocaleString()}
</div>
<div className={`flex items-center justify-end gap-1 text-sm ${isUp ? 'text-semantic-green' : 'text-semantic-red'}`}>
{isUp ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
<span>{isUp ? '+' : ''}{product.change.toFixed(2)} ({isUp ? '+' : ''}{product.changePercent}%)</span>
</div>
</div>
</div>
<div className="mb-space-3">
<div className="flex items-center gap-1 text-xs text-text-slate mb-2">
<Clock className="w-3 h-3" />
<span></span>
</div>
<div className="flex gap-2">
{Object.entries(product.cycles).map(([key, trend]) => (
<TrendBadge key={key} direction={trend} label={cycleLabels[key]} />
))}
</div>
</div>
<div className="space-y-2 mb-space-3">
<div className="flex items-center justify-between text-xs">
<span className="text-text-slate flex items-center gap-1">
<BarChart3 className="w-3 h-3" />
</span>
</div>
<SuccessRateBar rate={product.successRate} />
<div className="flex items-center justify-between text-xs">
<span className="text-text-slate"></span>
<span className={`font-semibold ${product.trendScore >= 80 ? 'text-semantic-green' : product.trendScore >= 60 ? 'text-binance-gold' : 'text-semantic-red'}`}>
{product.trendScore}/100
</span>
</div>
<div className="h-1.5 bg-border-light rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
product.trendScore >= 80 ? 'bg-semantic-green' : product.trendScore >= 60 ? 'bg-binance-gold' : 'bg-semantic-red'
}`}
style={{ width: `${product.trendScore}%` }}
/>
</div>
</div>
<div className="pt-space-3 border-t border-binanceBorder-light">
<div className="flex items-center gap-1 text-xs text-text-slate mb-2">
<Target className="w-3 h-3" />
<span></span>
</div>
<div className="flex justify-between text-xs">
<div>
<span className="text-text-slate">: </span>
<span className="text-semantic-red font-mono">{product.keyLevels.resistance[0]?.toLocaleString() || '-'}</span>
</div>
<div>
<span className="text-text-slate">: </span>
<span className="text-semantic-green font-mono">{product.keyLevels.support[0]?.toLocaleString() || '-'}</span>
</div>
</div>
</div>
<div className="flex items-center justify-end mt-space-3 pt-2 border-t border-binanceBorder-light/50">
<span className="text-xs text-text-slate group-hover:text-binance-yellow transition-colors flex items-center gap-1">
<ChevronRight className="w-3 h-3 group-hover:translate-x-1 transition-transform" />
</span>
</div>
</div>
);
}

@ -0,0 +1,366 @@
import { useState, useEffect } from 'react';
import { ArrowLeft, TrendingUp, TrendingDown, Target, Clock, BarChart3, Activity, Zap } from 'lucide-react';
import { KLineChart } from './KLineChart';
import type { FuturesProduct, CycleAnalysis, TechnicalIndicators, TradingAdvice } from '@/types';
import { generateCycleAnalysis, generateTechnicalIndicators, generateTradingAdvice } from '@/data/mockData';
interface ProductDetailProps {
product: FuturesProduct;
onBack: () => void;
}
function PeriodButton({
label,
isActive,
onClick
}: {
label: string;
isActive: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`px-4 py-2 rounded-lg text-sm font-semibold transition-all duration-200 ${
isActive
? 'bg-binance-yellow text-text-ink'
: 'bg-surface-snow text-text-secondary hover:bg-border-light hover:text-text-ink'
}`}
>
{label}
</button>
);
}
function IndicatorCard({
title,
value,
status,
signal
}: {
title: string;
value: string;
status: string;
signal?: 'positive' | 'negative' | 'neutral';
}) {
const signalColors = {
positive: 'text-semantic-green',
negative: 'text-semantic-red',
neutral: 'text-text-slate',
};
return (
<div className="p-3 rounded-lg border border-border-light bg-surface-snow">
<div className="text-xs text-text-slate mb-1">{title}</div>
<div className={`text-sm font-semibold ${signal ? signalColors[signal] : 'text-text-ink'}`}>{value}</div>
<div className="text-xs text-text-slate mt-1">{status}</div>
</div>
);
}
function KeyLevelRow({
label,
value,
type
}: {
label: string;
value: number;
type: 'resistance' | 'support';
}) {
return (
<div className="flex items-center justify-between py-2 border-b border-border-light/50 last:border-0">
<span className="text-sm text-text-slate">{label}</span>
<span className={`text-sm font-mono font-semibold ${type === 'resistance' ? 'text-semantic-red' : 'text-semantic-green'}`}>
{value.toLocaleString()}
</span>
</div>
);
}
export function ProductDetail({ product, onBack }: ProductDetailProps) {
const [selectedPeriod, setSelectedPeriod] = useState<'m5' | 'm15' | 'm30' | 'm60'>('m15');
const [cycleData, setCycleData] = useState<CycleAnalysis | null>(null);
const [indicators, setIndicators] = useState<TechnicalIndicators | null>(null);
const [advice, setAdvice] = useState<TradingAdvice | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
setTimeout(() => {
setCycleData(generateCycleAnalysis(product.id, selectedPeriod));
setIndicators(generateTechnicalIndicators(product.id));
setAdvice(generateTradingAdvice(product.id));
setLoading(false);
}, 300);
}, [product.id, selectedPeriod]);
const isUp = product.change >= 0;
const periods = [
{ key: 'm5', label: '5分钟' },
{ key: 'm15', label: '15分钟' },
{ key: 'm30', label: '30分钟' },
{ key: 'm60', label: '60分钟' },
];
const cycleLabels: Record<string, string> = {
m5: '5分钟',
m15: '15分钟',
m30: '30分钟',
m60: '60分钟',
};
return (
<div className="space-y-space-4">
<div className="flex items-center gap-4">
<button
onClick={onBack}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-binanceBorder-light bg-white text-text-secondary hover:text-text-ink hover:border-binance-gold transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span></span>
</button>
<div>
<h2 className="text-xl font-bold text-text-ink">{product.name} ({product.code})</h2>
<p className="text-sm text-text-slate"></p>
</div>
</div>
<div className="p-4 rounded-card border border-border-light bg-white">
<div className="flex flex-wrap items-center gap-6">
<div>
<div className={`text-3xl font-bold font-mono ${isUp ? 'text-semantic-green' : 'text-semantic-red'}`}>
¥{product.price.toLocaleString()}
</div>
<div className={`flex items-center gap-1 text-sm ${isUp ? 'text-semantic-green' : 'text-semantic-red'}`}>
{isUp ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
<span>{isUp ? '+' : ''}{product.change.toFixed(2)} ({isUp ? '+' : ''}{product.changePercent}%)</span>
</div>
</div>
<div className="h-12 w-px bg-border-light hidden sm:block" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div>
<div className="text-xs text-text-slate"></div>
<div className="text-sm font-mono text-text-ink">{product.open.toLocaleString()}</div>
</div>
<div>
<div className="text-xs text-text-slate"></div>
<div className="text-sm font-mono text-semantic-green">{product.high.toLocaleString()}</div>
</div>
<div>
<div className="text-xs text-text-slate"></div>
<div className="text-sm font-mono text-semantic-red">{product.low.toLocaleString()}</div>
</div>
<div>
<div className="text-xs text-text-slate"></div>
<div className="text-sm font-mono text-text-ink">{product.openInterest.toLocaleString()}</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-space-4">
<div className="lg:col-span-2 space-y-space-4">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-binance-yellow" />
<span className="text-sm text-text-slate"></span>
<div className="flex gap-2 ml-2">
{periods.map((p) => (
<PeriodButton
key={p.key}
label={p.label}
isActive={selectedPeriod === p.key}
onClick={() => setSelectedPeriod(p.key as any)}
/>
))}
</div>
</div>
<div className="p-4 rounded-card border border-border-light bg-white">
{loading ? (
<div className="h-[500px] flex items-center justify-center">
<div className="flex items-center gap-2 text-binance-yellow">
<div className="w-5 h-5 border-2 border-binance-yellow border-t-transparent rounded-full animate-spin" />
<span>...</span>
</div>
</div>
) : cycleData ? (
<KLineChart
klineData={cycleData.klineData}
macdData={cycleData.macdData}
resistance={cycleData.keyLevels.resistance}
support={cycleData.keyLevels.support}
height={500}
/>
) : null}
</div>
</div>
<div className="space-y-space-4">
<div className="p-4 rounded-card border border-border-light bg-white">
<div className="flex items-center gap-2 mb-space-4">
<div className={`p-2 rounded-lg ${
advice?.action === 'long' ? 'bg-semantic-green/10' :
advice?.action === 'short' ? 'bg-semantic-red/10' : 'bg-text-slate/10'
}`}>
<Target className={`w-4 h-4 ${
advice?.action === 'long' ? 'text-semantic-green' :
advice?.action === 'short' ? 'text-semantic-red' : 'text-text-slate'
}`} />
</div>
<h3 className="font-bold text-text-ink"></h3>
</div>
{advice && (
<div className="space-y-3">
<div className={`p-3 rounded-lg ${
advice.action === 'long' ? 'bg-semantic-green/10 border border-semantic-green/30' :
advice.action === 'short' ? 'bg-semantic-red/10 border border-semantic-red/30' :
'bg-text-slate/10 border border-text-slate/30'
}`}>
<div className="text-xs text-text-slate mb-1"></div>
<div className={`text-lg font-bold ${
advice.action === 'long' ? 'text-semantic-green' :
advice.action === 'short' ? 'text-semantic-red' : 'text-text-slate'
}`}>
{advice.action === 'long' ? '逢低做多' : advice.action === 'short' ? '逢高做空' : '观望等待'}
</div>
<div className="text-xs text-text-slate mt-1">{advice.reason}</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="p-2 rounded-lg bg-surface-snow">
<div className="text-xs text-text-slate"></div>
<div className="text-sm font-mono text-text-ink">{advice.entryPrice.toLocaleString()}</div>
</div>
<div className="p-2 rounded-lg bg-surface-snow">
<div className="text-xs text-text-slate"></div>
<div className="text-sm font-mono text-semantic-green">{advice.targetPrice.toLocaleString()}</div>
</div>
<div className="p-2 rounded-lg bg-surface-snow">
<div className="text-xs text-text-slate"></div>
<div className="text-sm font-mono text-semantic-red">{advice.stopLoss.toLocaleString()}</div>
</div>
<div className="p-2 rounded-lg bg-surface-snow">
<div className="text-xs text-text-slate"></div>
<div className={`text-sm font-semibold ${
advice.riskLevel === 'low' ? 'text-semantic-green' :
advice.riskLevel === 'medium' ? 'text-binance-gold' : 'text-semantic-red'
}`}>
{advice.riskLevel === 'low' ? '低' : advice.riskLevel === 'medium' ? '中' : '高'}
</div>
</div>
</div>
</div>
)}
</div>
<div className="p-4 rounded-card border border-border-light bg-white">
<div className="flex items-center gap-2 mb-space-4">
<div className="p-2 rounded-lg bg-focus-blue/10">
<Activity className="w-4 h-4 text-focus-blue" />
</div>
<h3 className="font-bold text-text-ink"></h3>
</div>
{indicators && (
<div className="grid grid-cols-2 gap-2">
<IndicatorCard
title="MACD"
value={indicators.macd.signal === 'golden_cross' ? '金叉' : indicators.macd.signal === 'dead_cross' ? '死叉' : '中性'}
status={`DIF: ${indicators.macd.value}`}
signal={indicators.macd.signal === 'golden_cross' ? 'positive' : indicators.macd.signal === 'dead_cross' ? 'negative' : 'neutral'}
/>
<IndicatorCard
title="RSI"
value={`${indicators.rsi.value}`}
status={indicators.rsi.status === 'overbought' ? '超买' : indicators.rsi.status === 'oversold' ? '超卖' : '正常'}
signal={indicators.rsi.status === 'oversold' ? 'positive' : indicators.rsi.status === 'overbought' ? 'negative' : 'neutral'}
/>
<IndicatorCard
title="布林带"
value={indicators.bollinger.position === 'upper' ? '上轨' : indicators.bollinger.position === 'lower' ? '下轨' : '中轨'}
status={`区间: ${indicators.bollinger.lower.toFixed(0)}-${indicators.bollinger.upper.toFixed(0)}`}
signal={indicators.bollinger.position === 'lower' ? 'positive' : indicators.bollinger.position === 'upper' ? 'negative' : 'neutral'}
/>
<IndicatorCard
title="KDJ"
value={indicators.kdj.signal === 'golden_cross' ? '金叉' : indicators.kdj.signal === 'dead_cross' ? '死叉' : '中性'}
status={`K: ${indicators.kdj.k} D: ${indicators.kdj.d}`}
signal={indicators.kdj.signal === 'golden_cross' ? 'positive' : indicators.kdj.signal === 'dead_cross' ? 'negative' : 'neutral'}
/>
</div>
)}
</div>
<div className="p-4 rounded-card border border-border-light bg-white">
<div className="flex items-center gap-2 mb-space-4">
<div className="p-2 rounded-lg bg-binance-gold/10">
<BarChart3 className="w-4 h-4 text-binance-gold" />
</div>
<h3 className="font-bold text-text-ink"></h3>
</div>
<div className="space-y-1">
<div className="text-xs text-semantic-red font-semibold mb-2"></div>
{product.keyLevels.resistance.map((level, index) => (
<KeyLevelRow key={index} label={`压力 ${index + 1}`} value={level} type="resistance" />
))}
</div>
<div className="mt-space-4 space-y-1">
<div className="text-xs text-semantic-green font-semibold mb-2"></div>
{product.keyLevels.support.map((level, index) => (
<KeyLevelRow key={index} label={`支撑 ${index + 1}`} value={level} type="support" />
))}
</div>
</div>
<div className="p-4 rounded-card border border-border-light bg-white">
<div className="flex items-center gap-2 mb-space-4">
<div className="p-2 rounded-lg bg-binance-yellow/10">
<Zap className="w-4 h-4 text-binance-yellow" />
</div>
<h3 className="font-bold text-text-ink"></h3>
</div>
<div className="space-y-2">
{Object.entries(product.cycles).map(([key, trend]) => (
<div key={key} className="flex items-center justify-between py-1.5">
<span className="text-sm text-text-slate">{cycleLabels[key]}</span>
<div className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${
trend === 'up' ? 'bg-semantic-green/10 text-semantic-green' :
trend === 'down' ? 'bg-semantic-red/10 text-semantic-red' :
'bg-text-slate/10 text-text-slate'
}`}>
{trend === 'up' ? <TrendingUp className="w-3 h-3" /> : trend === 'down' ? <TrendingDown className="w-3 h-3" /> : <div className="w-3 h-0.5 bg-current" />}
<span>{trend === 'up' ? '上涨' : trend === 'down' ? '下跌' : '震荡'}</span>
</div>
</div>
))}
</div>
<div className="mt-space-4 pt-space-3 border-t border-binanceBorder-light">
<div className="flex items-center justify-between">
<span className="text-sm text-text-slate"></span>
<span className={`text-sm font-bold ${product.trendScore >= 80 ? 'text-semantic-green' : product.trendScore >= 60 ? 'text-binance-gold' : 'text-semantic-red'}`}>
{product.trendScore}%
</span>
</div>
<div className="mt-2 h-2 bg-binanceBorder-light rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
product.trendScore >= 80 ? 'bg-semantic-green' : product.trendScore >= 60 ? 'bg-binance-gold' : 'bg-semantic-red'
}`}
style={{ width: `${product.trendScore}%` }}
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

@ -0,0 +1,99 @@
import { AlertTriangle, Bell, Shield, TrendingDown, AlertOctagon } from 'lucide-react';
import type { RiskAlert } from '@/types';
interface RiskAlertsProps {
alerts: RiskAlert[];
}
function RiskLevelBadge({ level }: { level: 'high' | 'medium' | 'low' }) {
const config = {
high: { text: '高风险', color: 'text-semantic-red', bg: 'bg-semantic-red/10', icon: AlertOctagon },
medium: { text: '中风险', color: 'text-binance-gold', bg: 'bg-binance-gold/10', icon: AlertTriangle },
low: { text: '低风险', color: 'text-semantic-green', bg: 'bg-semantic-green/10', icon: Shield },
};
const { text, color, bg, icon: Icon } = config[level];
return (
<div className={`flex items-center gap-1 px-2 py-1 rounded-md ${bg}`}>
<Icon className={`w-3 h-3 ${color}`} />
<span className={`text-xs font-semibold ${color}`}>{text}</span>
</div>
);
}
export function RiskAlertsPanel({ alerts }: RiskAlertsProps) {
if (alerts.length === 0) {
return (
<div className="p-4 rounded-card border border-border-light bg-white">
<div className="flex items-center gap-2 mb-space-4">
<div className="p-2 rounded-lg bg-semantic-green/10">
<Shield className="w-4 h-4 text-semantic-green" />
</div>
<h3 className="font-bold text-text-ink"></h3>
</div>
<div className="text-center py-6 text-text-slate">
<Shield className="w-10 h-10 mx-auto mb-2 opacity-50" />
<p className="text-sm"></p>
</div>
</div>
);
}
return (
<div className="p-4 rounded-card border border-border-light bg-white">
<div className="flex items-center justify-between mb-space-4">
<div className="flex items-center gap-2">
<div className="p-2 rounded-lg bg-semantic-red/10">
<Bell className="w-4 h-4 text-semantic-red" />
</div>
<h3 className="font-bold text-text-ink"></h3>
</div>
<span className="px-2 py-1 rounded-full bg-semantic-red/10 text-semantic-red text-xs font-semibold">
{alerts.length}
</span>
</div>
<div className="space-y-space-3">
{alerts.map((alert) => (
<div
key={alert.id}
className="p-3 rounded-lg border border-binanceBorder-light bg-surface-snow hover:border-semantic-red/30 transition-colors"
>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex-1">
<h4 className="text-sm font-semibold text-text-ink mb-1">{alert.title}</h4>
<p className="text-xs text-text-slate line-clamp-2">{alert.description}</p>
</div>
<RiskLevelBadge level={alert.riskLevel} />
</div>
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className="text-text-slate">:</span>
<div className="flex gap-1">
{alert.affectedProducts.slice(0, 2).map((product) => (
<span key={product} className="px-1.5 py-0.5 rounded bg-white text-text-secondary">
{product}
</span>
))}
{alert.affectedProducts.length > 2 && (
<span className="text-text-slate">+{alert.affectedProducts.length - 2}</span>
)}
</div>
</div>
<span className="text-text-slate">{alert.time}</span>
</div>
<div className="mt-2 pt-2 border-t border-binanceBorder-light/50">
<div className="flex items-center gap-1 text-xs">
<TrendingDown className="w-3 h-3 text-binance-gold" />
<span className="text-binance-gold">: {alert.suggestion}</span>
</div>
</div>
</div>
))}
</div>
</div>
);
}

@ -0,0 +1,457 @@
import type { FuturesProduct, HotEvent, MarketOverview, RiskAlert, CycleAnalysis, KLineData, MACDData, TechnicalIndicators, TradingAdvice } from '@/types';
export const marketOverview: MarketOverview = {
heatIndex: 78.5,
heatChange: 5.2,
upCount: 156,
downCount: 89,
capitalFlow: 28.5,
volatilityIndex: 23.8,
volatilityChange: -1.2,
};
export const hotEvents: HotEvent[] = [
{
id: '1',
title: '地缘政治风险升级',
time: '2025-03-02',
summary: '美以袭伊朗,"海上油阀"被关,中东局势紧张升级',
affectedProducts: ['原油', '黄金', '白银', '燃油'],
impact: 'bullish',
impactLevel: 5,
analysis: '地缘政治风险急剧升温,霍尔木兹海峡封锁风险上升。原油供应中断担忧推动油价上涨,避险资产黄金、白银同步走强。短期油价易涨难跌,建议关注原油、黄金相关品种做多机会。',
risks: ['冲突升级可能', '供应中断风险', '波动率激增'],
},
{
id: '2',
title: '黄金价格创历史新高',
time: '2025-03-01',
summary: 'COMEX黄金突破3100美元关口避险需求强劲',
affectedProducts: ['黄金', '白银', '铂金', '美元指数'],
impact: 'bullish',
impactLevel: 4,
analysis: '特朗普关税政策"2.0"版本引发市场担忧,叠加地缘政治风险,黄金避险属性凸显。技术面突破历史高点,多头趋势强劲。预计金价短期维持强势,回调即是买入机会。',
risks: ['获利回吐压力', '美元反弹风险', '美联储政策转向'],
},
{
id: '3',
title: '铜供应紧张预期',
time: '2025-02-28',
summary: '全球铜市场预计出现18万吨供应缺口美国或加征铜进口关税',
affectedProducts: ['铜', '铝', '镍', '锌'],
impact: 'bullish',
impactLevel: 4,
analysis: '铜精矿TC现货指数持续回落矿山供应增长放缓。美国可能加征铜进口关税刺激美铜价格上涨全球套利行为收紧供应。需求端新能源产业用铜需求快速增长供需矛盾支撑铜价。',
risks: ['需求放缓风险', '美元走强压制', '库存累积'],
},
{
id: '4',
title: '美联储通胀数据超预期',
time: '2025-02-27',
summary: '核心PCE数据超预期降息预期降温',
affectedProducts: ['美元指数', '黄金', '原油', '大宗商品'],
impact: 'bearish',
impactLevel: 3,
analysis: '美国2月核心PCE通胀数据超预期市场对美联储降息预期降温。美元指数短期获得支撑对大宗商品形成一定压制。但地缘政治风险对冲了部分利空影响。',
risks: ['加息预期升温', '美元持续走强', '风险资产抛售'],
},
{
id: '5',
title: 'OPEC+减产延期预期',
time: '2025-02-26',
summary: 'OPEC+可能延长减产协议至二季度末',
affectedProducts: ['原油', '燃油', '沥青', '石化品种'],
impact: 'bullish',
impactLevel: 3,
analysis: 'OPEC+成员国倾向于延长减产协议以支撑油价。全球原油需求预期改善,叠加供应端约束,原油市场供需格局趋紧。关注减产协议正式落地情况。',
risks: ['减产执行力度', '非OPEC增产', '需求不及预期'],
},
];
export const futuresProducts: FuturesProduct[] = [
{
id: '1',
name: '原油',
code: 'SC',
category: 'energy',
price: 528.6,
change: 12.1,
changePercent: 2.35,
open: 518.0,
high: 535.0,
low: 515.5,
volume: 285600,
openInterest: 125800,
trendScore: 85,
successRate: 72,
cycles: {
m5: 'up',
m15: 'up',
m30: 'up',
m60: 'sideways',
},
keyLevels: {
resistance: [535.0, 542.0, 550.0],
support: [518.0, 510.0, 500.0],
},
recommendation: 'long',
recommendationReason: '地缘政治风险推动,多周期共振向上',
},
{
id: '2',
name: '黄金',
code: 'AU',
category: 'metal',
price: 685.2,
change: 12.45,
changePercent: 1.85,
open: 675.0,
high: 692.0,
low: 672.0,
volume: 456200,
openInterest: 215600,
trendScore: 92,
successRate: 78,
cycles: {
m5: 'up',
m15: 'up',
m30: 'up',
m60: 'up',
},
keyLevels: {
resistance: [692.0, 700.0, 710.0],
support: [678.0, 670.0, 660.0],
},
recommendation: 'long',
recommendationReason: '创历史新高,趋势强劲,避险需求旺盛',
},
{
id: '3',
name: '铜',
code: 'CU',
category: 'metal',
price: 80610,
change: 112,
changePercent: 0.14,
open: 80200,
high: 81200,
low: 79800,
volume: 125800,
openInterest: 98500,
trendScore: 65,
successRate: 58,
cycles: {
m5: 'sideways',
m15: 'up',
m30: 'sideways',
m60: 'up',
},
keyLevels: {
resistance: [81200, 82000, 83000],
support: [79800, 79000, 78000],
},
recommendation: 'wait',
recommendationReason: '高位震荡,方向不明,建议观望',
},
{
id: '4',
name: '白银',
code: 'AG',
category: 'metal',
price: 8250,
change: 165,
changePercent: 2.04,
open: 8100,
high: 8350,
low: 8050,
volume: 325600,
openInterest: 185200,
trendScore: 88,
successRate: 75,
cycles: {
m5: 'up',
m15: 'up',
m30: 'up',
m60: 'up',
},
keyLevels: {
resistance: [8350, 8500, 8650],
support: [8100, 8000, 7850],
},
recommendation: 'long',
recommendationReason: '跟随黄金上涨,波动更大,机会更好',
},
{
id: '5',
name: '铁矿石',
code: 'I',
category: 'metal',
price: 785.5,
change: 28.0,
changePercent: 3.7,
open: 760.0,
high: 792.0,
low: 755.0,
volume: 185200,
openInterest: 95600,
trendScore: 82,
successRate: 68,
cycles: {
m5: 'up',
m15: 'up',
m30: 'up',
m60: 'up',
},
keyLevels: {
resistance: [792.0, 800.0, 815.0],
support: [770.0, 760.0, 745.0],
},
recommendation: 'long',
recommendationReason: '黑色系领涨,需求预期改善',
},
{
id: '6',
name: '豆粕',
code: 'M',
category: 'agriculture',
price: 2985,
change: -51,
changePercent: -1.68,
open: 3040,
high: 3050,
low: 2960,
volume: 256800,
openInterest: 325600,
trendScore: 35,
successRate: 42,
cycles: {
m5: 'down',
m15: 'down',
m30: 'down',
m60: 'sideways',
},
keyLevels: {
resistance: [3050, 3100, 3180],
support: [2960, 2900, 2820],
},
recommendation: 'short',
recommendationReason: '供应充足,需求疲软,短期偏弱',
},
{
id: '7',
name: '棕榈油',
code: 'P',
category: 'agriculture',
price: 8750,
change: 0,
changePercent: 0,
open: 8720,
high: 8820,
low: 8680,
volume: 125600,
openInterest: 185200,
trendScore: 50,
successRate: 52,
cycles: {
m5: 'sideways',
m15: 'sideways',
m30: 'sideways',
m60: 'sideways',
},
keyLevels: {
resistance: [8820, 8900, 9050],
support: [8680, 8600, 8450],
},
recommendation: 'wait',
recommendationReason: '横盘整理,等待方向选择',
},
{
id: '8',
name: '集运指数',
code: 'EC',
category: 'financial',
price: 2150,
change: 196,
changePercent: 10.06,
open: 1960,
high: 2200,
low: 1940,
volume: 85600,
openInterest: 45600,
trendScore: 90,
successRate: 80,
cycles: {
m5: 'up',
m15: 'up',
m30: 'up',
m60: 'up',
},
keyLevels: {
resistance: [2200, 2300, 2400],
support: [2000, 1900, 1800],
},
recommendation: 'long',
recommendationReason: '涨停突破,地缘风险推升运价',
},
];
export const riskAlerts: RiskAlert[] = [
{
id: '1',
title: '中东地缘风险急剧升温',
affectedProducts: ['原油', '黄金', '燃油'],
riskLevel: 'high',
description: '美以与伊朗冲突升级,霍尔木兹海峡封锁风险上升',
suggestion: '控制仓位,设置止损,关注局势发展',
time: '2025-03-02 14:30',
},
{
id: '2',
title: '黄金价格创历史新高',
affectedProducts: ['黄金', '白银'],
riskLevel: 'medium',
description: '金价快速上涨后存在获利回吐风险',
suggestion: '避免追高,等待回调后再入场',
time: '2025-03-01 09:15',
},
{
id: '3',
title: '集运指数涨停',
affectedProducts: ['集运指数'],
riskLevel: 'high',
description: '连续涨停后波动率激增,注意回调风险',
suggestion: '减仓或获利了结,避免隔夜重仓',
time: '2025-03-02 15:00',
},
];
export function generateKLineData(basePrice: number, count: number = 100): KLineData[] {
const data: KLineData[] = [];
let price = basePrice;
const now = new Date();
for (let i = count; i >= 0; i--) {
const time = new Date(now.getTime() - i * 5 * 60 * 1000);
const volatility = price * 0.008;
const change = (Math.random() - 0.48) * volatility;
const open = price;
const close = price + change;
const high = Math.max(open, close) + Math.random() * volatility * 0.5;
const low = Math.min(open, close) - Math.random() * volatility * 0.5;
const volume = Math.floor(Math.random() * 10000 + 5000);
data.push({
time: time.toISOString(),
open: Number(open.toFixed(2)),
high: Number(high.toFixed(2)),
low: Number(low.toFixed(2)),
close: Number(close.toFixed(2)),
volume,
});
price = close;
}
return data;
}
export function generateMACDData(klineData: KLineData[]): MACDData[] {
const data: MACDData[] = [];
let ema12 = klineData[0].close;
let ema26 = klineData[0].close;
let dea = 0;
const k12 = 2 / 13;
const k26 = 2 / 27;
const k9 = 2 / 10;
for (const kline of klineData) {
ema12 = kline.close * k12 + ema12 * (1 - k12);
ema26 = kline.close * k26 + ema26 * (1 - k26);
const dif = ema12 - ema26;
dea = dif * k9 + dea * (1 - k9);
const macd = (dif - dea) * 2;
data.push({
time: kline.time,
dif: Number(dif.toFixed(4)),
dea: Number(dea.toFixed(4)),
macd: Number(macd.toFixed(4)),
});
}
return data;
}
export function generateCycleAnalysis(productId: string, period: 'm5' | 'm15' | 'm30' | 'm60'): CycleAnalysis {
const product = futuresProducts.find(p => p.id === productId);
const basePrice = product?.price || 500;
const klineData = generateKLineData(basePrice, 80);
const macdData = generateMACDData(klineData);
const volumeData = klineData.map(d => ({
time: d.time,
value: d.volume,
}));
return {
period,
trend: product?.cycles[period] || 'sideways',
trendStrength: Math.floor(Math.random() * 40) + 60,
klineData,
volumeData,
macdData,
keyLevels: {
resistance: product?.keyLevels.resistance || [],
support: product?.keyLevels.support || [],
},
};
}
export function generateTechnicalIndicators(productId: string): TechnicalIndicators {
const product = futuresProducts.find(p => p.id === productId);
const price = product?.price || 500;
return {
macd: {
signal: Math.random() > 0.5 ? 'golden_cross' : 'neutral',
value: Number((Math.random() * 2 - 0.5).toFixed(4)),
},
rsi: {
value: Math.floor(Math.random() * 40) + 30,
status: 'normal',
},
bollinger: {
upper: price * 1.03,
middle: price,
lower: price * 0.97,
position: 'middle',
},
kdj: {
k: Math.floor(Math.random() * 100),
d: Math.floor(Math.random() * 100),
j: Math.floor(Math.random() * 100),
signal: Math.random() > 0.6 ? 'golden_cross' : 'neutral',
},
};
}
export function generateTradingAdvice(productId: string): TradingAdvice {
const product = futuresProducts.find(p => p.id === productId);
const price = product?.price || 500;
const recommendation = product?.recommendation || 'wait';
const volatility = price * 0.02;
return {
action: recommendation,
entryPrice: recommendation === 'long' ? price - volatility * 0.3 : price + volatility * 0.3,
stopLoss: recommendation === 'long' ? price - volatility : price + volatility,
targetPrice: recommendation === 'long' ? price + volatility * 2 : price - volatility * 2,
riskLevel: product?.trendScore && product.trendScore > 80 ? 'low' : product?.trendScore && product.trendScore > 60 ? 'medium' : 'high',
position: product?.trendScore && product.trendScore > 80 ? 'medium' : 'light',
holdingTime: 'short',
reason: product?.recommendationReason || '技术面与基本面综合判断',
};
}

@ -0,0 +1,17 @@
import * as React from 'react'
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: 768px)`)
const onChange = () => {
setIsMobile(window.innerWidth < 768)
}
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < 768)
return () => mql.removeEventListener('change', onChange)
}, [])
return !!isMobile
}

@ -0,0 +1,196 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--binance-yellow: #F0B90B;
--binance-gold: #FFD000;
--binance-light-gold: #F8D12F;
--binance-active-yellow: #D0980B;
--binance-dark: #222126;
--binance-dark-card: #2B2F36;
--focus-blue: #1EAEDB;
--surface-white: #FFFFFF;
--surface-snow: #F5F5F5;
--text-ink: #1E2026;
--text-primary: #1E2026;
--text-secondary: #32313A;
--text-slate: #848E9C;
--text-steel: #686A6C;
--text-muted: #777E90;
--semantic-green: #0ECB81;
--semantic-red: #F6465D;
--border-light: #E6E8EA;
--border-gold: #FFD000;
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 48 96% 53%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.75rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 48 96% 53%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-binance-dark text-white antialiased;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #222126;
}
::-webkit-scrollbar-thumb {
background: #2B2F36;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #3B3F46;
}
::selection {
background: rgba(240, 185, 11, 0.3);
color: white;
}
*:focus-visible {
outline: 2px solid #1EAEDB;
outline-offset: 2px;
}
html {
scroll-behavior: smooth;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.5s ease-out forwards;
}
.font-mono {
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', monospace;
}
.btn-primary {
@apply bg-binance-yellow text-text-ink font-semibold rounded-button px-8 py-1.5;
@apply hover:bg-focus-blue hover:text-white;
@apply active:bg-binance-activeYellow;
@apply focus:bg-focus-blue focus:border-black focus:outline-black;
@apply transition-all duration-200;
}
.btn-pill {
@apply bg-binance-gold text-white font-semibold rounded-pill px-2.5 py-2.5;
@apply shadow-pill;
@apply hover:bg-focus-blue hover:text-white;
@apply transition-all duration-200;
}
.btn-secondary {
@apply bg-white text-binance-yellow font-semibold rounded-pill px-2.5 py-2.5;
@apply border border-binance-yellow;
@apply shadow-pill;
@apply hover:bg-focus-blue hover:text-white hover:border-focus-blue;
@apply transition-all duration-200;
}
.card-light {
@apply bg-white border border-binanceBorder-light rounded-card;
@apply shadow-card hover:shadow-card-hover;
@apply transition-all duration-200;
}
.card-dark {
@apply bg-binance-darkCard rounded-card;
@apply hover:shadow-card-hover;
@apply transition-all duration-200;
}
.input-field {
@apply bg-surface-snow;
@apply text-text-ink border border-binanceBorder-light rounded-data;
@apply px-3 py-0;
@apply focus:border-black focus:outline-1;
@apply placeholder:text-text-slate;
@apply transition-all duration-200;
}
.nav-link {
@apply text-text-secondary font-semibold text-caption;
@apply hover:text-text-hoverDark;
@apply transition-all duration-200;
}
.price-up {
@apply text-semantic-green;
}
.price-down {
@apply text-semantic-red;
}

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

@ -0,0 +1,112 @@
import axios, { type AxiosInstance, type AxiosError } from 'axios';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
const api: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error),
);
api.interceptors.response.use(
(response) => {
if (response.data && response.data.code === 200) {
return response.data.data;
}
return response.data;
},
(error: AxiosError) => {
if (error.response) {
const { status } = error.response as any;
switch (status) {
case 401:
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
break;
case 403:
console.error('没有权限访问该资源');
break;
case 404:
console.error('请求的资源不存在');
break;
case 500:
console.error('服务器内部错误');
break;
default:
console.error('请求失败');
}
} else if (error.request) {
console.error('网络请求失败,请检查网络连接');
}
return Promise.reject(error);
},
);
export const authApi = {
register: (data: { username: string; email: string; password: string; phone?: string }) =>
api.post('/auth/register', data),
login: (data: { username: string; password: string }) =>
api.post('/auth/login', data),
logout: () => api.post('/auth/logout'),
getProfile: () => api.get('/auth/profile'),
};
export const marketApi = {
getProducts: (params?: { category?: string; page?: number; size?: number }) =>
api.get('/market/products', { params }),
getProduct: (symbol: string) => api.get(`/market/products/${symbol}`),
getKline: (symbol: string, params: { period: string; count?: number }) =>
api.get(`/market/products/${symbol}/kline`, { params }),
getTick: (symbol: string) => api.get(`/market/products/${symbol}/tick`),
getOverview: () => api.get('/market/overview'),
};
export const indicatorsApi = {
getIndicators: (symbol: string, params: { period: string }) =>
api.get(`/indicators/${symbol}`, { params }),
getMACD: (symbol: string) => api.get(`/indicators/${symbol}/macd`),
getRSI: (symbol: string) => api.get(`/indicators/${symbol}/rsi`),
getBollinger: (symbol: string) => api.get(`/indicators/${symbol}/bollinger`),
getKDJ: (symbol: string) => api.get(`/indicators/${symbol}/kdj`),
};
export const optionsApi = {
calculatePricing: (data: any) => api.post('/options/pricing', data),
getOptionChain: (underlying: string) => api.get(`/options/chain/${underlying}`),
getVolatilitySurface: (underlying: string) => api.get(`/options/volatility-surface/${underlying}`),
getStrategies: () => api.get('/options/strategies'),
};
export const aiApi = {
analyzeSymbol: (symbol: string) => api.get(`/ai/analyze/${symbol}`),
};
export const eventsApi = {
getEvents: (params?: any) => api.get('/events', { params }),
getEventById: (id: string) => api.get(`/events/${id}`),
};
export const watchlistApi = {
getWatchlist: () => api.get('/watchlist'),
addToWatchlist: (data: { symbol: string; alertPrice?: number }) =>
api.post('/watchlist', data),
removeFromWatchlist: (symbol: string) => api.delete(`/watchlist/${symbol}`),
};
export default api;

@ -0,0 +1,87 @@
import { io, Socket } from 'socket.io-client';
const WS_URL = import.meta.env.VITE_WS_URL || 'http://localhost:3000/market';
export class MarketWebSocket {
private socket: Socket | null = null;
private subscribers: Map<string, Set<(data: any) => void>> = new Map();
private subscribedSymbols: Set<string> = new Set();
connect(token?: string): void {
if (this.socket?.connected) return;
this.socket = io(WS_URL, {
auth: token ? { token } : undefined,
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
this.socket.on('connect', () => {
console.log('WebSocket已连接');
if (this.subscribedSymbols.size > 0) {
this.subscribe(Array.from(this.subscribedSymbols), ['tick']);
}
});
this.socket.on('disconnect', () => {
console.log('WebSocket已断开');
});
this.socket.on('tick', (data) => {
this.notifySubscribers(`tick:${data.symbol}`, data);
});
this.socket.on('market:overview', (data) => {
this.notifySubscribers('market:overview', data);
});
}
disconnect(): void {
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
}
subscribe(symbols: string[], channels: string[] = ['tick']): void {
if (!this.socket?.connected) return;
symbols.forEach((symbol) => this.subscribedSymbols.add(symbol));
this.socket.emit('subscribe', { symbols, channels });
}
unsubscribe(symbols: string[]): void {
if (!this.socket?.connected) return;
symbols.forEach((symbol) => this.subscribedSymbols.delete(symbol));
this.socket.emit('unsubscribe', { symbols });
}
on(event: string, callback: (data: any) => void): () => void {
if (!this.subscribers.has(event)) {
this.subscribers.set(event, new Set());
}
this.subscribers.get(event)!.add(callback);
return () => this.subscribers.get(event)?.delete(callback);
}
private notifySubscribers(event: string, data: any): void {
this.subscribers.get(event)?.forEach((cb) => {
try { cb(data); } catch (e) {}
});
}
onTick(symbol: string, callback: (data: any) => void): () => void {
return this.on(`tick:${symbol}`, callback);
}
onMarketOverview(callback: (data: any) => void): () => void {
return this.on('market:overview', callback);
}
isConnected(): boolean {
return this.socket?.connected || false;
}
}
export const marketWS = new MarketWebSocket();
export default marketWS;

@ -0,0 +1,125 @@
export interface FuturesProduct {
id: string;
name: string;
code: string;
category: 'energy' | 'metal' | 'agriculture' | 'financial';
price: number;
change: number;
changePercent: number;
open: number;
high: number;
low: number;
volume: number;
openInterest: number;
trendScore: number;
successRate: number;
cycles: {
m5: TrendDirection;
m15: TrendDirection;
m30: TrendDirection;
m60: TrendDirection;
};
keyLevels: {
resistance: number[];
support: number[];
};
recommendation: 'long' | 'short' | 'wait';
recommendationReason: string;
}
export type TrendDirection = 'up' | 'down' | 'sideways';
export interface HotEvent {
id: string;
title: string;
time: string;
summary: string;
affectedProducts: string[];
impact: 'bullish' | 'bearish' | 'neutral';
impactLevel: 1 | 2 | 3 | 4 | 5;
analysis: string;
risks: string[];
}
export interface MarketOverview {
heatIndex: number;
heatChange: number;
upCount: number;
downCount: number;
capitalFlow: number;
volatilityIndex: number;
volatilityChange: number;
}
export interface KLineData {
time: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
}
export interface MACDData {
time: string;
dif: number;
dea: number;
macd: number;
}
export interface TechnicalIndicators {
macd: {
signal: 'golden_cross' | 'dead_cross' | 'neutral';
value: number;
};
rsi: {
value: number;
status: 'overbought' | 'oversold' | 'normal';
};
bollinger: {
upper: number;
middle: number;
lower: number;
position: 'upper' | 'middle' | 'lower';
};
kdj: {
k: number;
d: number;
j: number;
signal: 'golden_cross' | 'dead_cross' | 'neutral';
};
}
export interface TradingAdvice {
action: 'long' | 'short' | 'wait';
entryPrice: number;
stopLoss: number;
targetPrice: number;
riskLevel: 'low' | 'medium' | 'high';
position: 'light' | 'medium' | 'heavy';
holdingTime: 'short' | 'medium' | 'long';
reason: string;
}
export interface RiskAlert {
id: string;
title: string;
affectedProducts: string[];
riskLevel: 'high' | 'medium' | 'low';
description: string;
suggestion: string;
time: string;
}
export interface CycleAnalysis {
period: 'm5' | 'm15' | 'm30' | 'm60';
trend: TrendDirection;
trendStrength: number;
klineData: KLineData[];
volumeData: { time: string; value: number }[];
macdData: MACDData[];
keyLevels: {
resistance: number[];
support: number[];
};
}

@ -0,0 +1,157 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
binance: {
yellow: '#F0B90B',
gold: '#FFD000',
lightGold: '#F8D12F',
activeYellow: '#D0980B',
dark: '#222126',
darkCard: '#2B2F36',
},
focus: {
blue: '#1EAEDB',
},
surface: {
white: '#FFFFFF',
snow: '#F5F5F5',
},
text: {
ink: '#1E2026',
primary: '#1E2026',
secondary: '#32313A',
slate: '#848E9C',
steel: '#686A6C',
muted: '#777E90',
hoverDark: '#1A1A1A',
},
semantic: {
green: '#0ECB81',
red: '#F6465D',
},
binanceBorder: {
light: '#E6E8EA',
gold: '#FFD000',
},
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
fontFamily: {
binance: ['Inter', 'Arial', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'SF Mono', 'monospace'],
},
fontSize: {
'display-hero': ['60px', { lineHeight: '1.08', fontWeight: '700' }],
'display-secondary': ['34px', { lineHeight: '1.00', fontWeight: '700' }],
'heading-1': ['28px', { lineHeight: '1.00', fontWeight: '500' }],
'heading-2': ['24px', { lineHeight: '1.00', fontWeight: '700' }],
'heading-3': ['24px', { lineHeight: '1.00', fontWeight: '600' }],
'heading-4': ['20px', { lineHeight: '1.25', fontWeight: '600' }],
'body-large': ['20px', { lineHeight: '1.50', fontWeight: '500' }],
'body': ['16px', { lineHeight: '1.50', fontWeight: '500' }],
'body-semibold': ['16px', { lineHeight: '1.30', fontWeight: '600' }],
'body-bold': ['16px', { lineHeight: '1.50', fontWeight: '700' }],
'button': ['16px', { lineHeight: '1.25', fontWeight: '600', letterSpacing: '0.16px' }],
'button-small': ['14.4px', { lineHeight: '1.60', fontWeight: '600', letterSpacing: '0.72px' }],
'caption': ['14px', { lineHeight: '1.43', fontWeight: '500' }],
'caption-semibold': ['14px', { lineHeight: '1.50', fontWeight: '600' }],
'small': ['12px', { lineHeight: '1.00', fontWeight: '600' }],
'tiny': ['11px', { lineHeight: '1.00', fontWeight: '500' }],
},
borderRadius: {
'pill': '50px',
'card': '12px',
'data': '8px',
'button': '6px',
'micro': '2px',
xl: "calc(var(--radius) + 4px)",
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
xs: "calc(var(--radius) - 6px)",
},
boxShadow: {
'card': 'rgba(32, 32, 37, 0.05) 0px 3px 5px 0px',
'card-hover': 'rgba(8, 8, 8, 0.05) 0px 3px 5px 5px',
'pill': 'rgb(153,153,153) 0px 2px 10px -3px',
'heavy': 'rgba(0,0,0) 0px 32px 37px',
xs: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
},
spacing: {
'space-1': '4px',
'space-2': '8px',
'space-3': '12px',
'space-4': '16px',
'space-5': '20px',
'space-6': '24px',
'space-7': '32px',
'space-8': '48px',
'space-9': '64px',
'space-10': '80px',
},
maxWidth: {
'container': '1200px',
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
"caret-blink": {
"0%,70%,100%": { opacity: "1" },
"20%,50%": { opacity: "0" },
},
"fade-in": {
from: { opacity: "0", transform: "translateY(10px)" },
to: { opacity: "1", transform: "translateY(0)" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"caret-blink": "caret-blink 1.25s ease-out infinite",
"fade-in": "fade-in 0.5s ease-out forwards",
},
},
},
plugins: [require("tailwindcss-animate")],
}

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"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": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

@ -0,0 +1,21 @@
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: 5174,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
})

@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>期货智析 - Revolut Design</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,45 @@
{
"name": "revolut-design",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"axios": "^1.6.5",
"socket.io-client": "^4.7.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"echarts": "^5.4.3",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.70.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zod": "^4.3.5"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

@ -0,0 +1,323 @@
import { useState, useEffect } from 'react';
import { Navbar } from '@/components/Navbar';
import { MarketOverviewPanel } from '@/components/MarketOverview';
import { HotEventsPanel } from '@/components/HotEvents';
import { ProductCard } from '@/components/ProductCard';
import { ProductDetail } from '@/components/ProductDetail';
import { RiskAlertsPanel } from '@/components/RiskAlerts';
import { marketOverview, hotEvents, futuresProducts, riskAlerts } from '@/data/mockData';
import { Filter, Search, TrendingUp, TrendingDown, Grid3X3, List } from 'lucide-react';
import type { FuturesProduct } from '@/types';
type ViewType = 'grid' | 'list';
const categoryFilters = [
{ key: 'all', label: '全部' },
{ key: 'energy', label: '能源' },
{ key: 'metal', label: '金属' },
{ key: 'agriculture', label: '农产品' },
{ key: 'financial', label: '金融' },
];
const sortOptions = [
{ key: 'successRate', label: '成功率' },
{ key: 'trendScore', label: '趋势强度' },
{ key: 'changePercent', label: '涨跌幅' },
];
function App() {
const [activeTab, setActiveTab] = useState('overview');
const [selectedProduct, setSelectedProduct] = useState<FuturesProduct | null>(null);
const [viewType, setViewType] = useState<ViewType>('grid');
const [categoryFilter, setCategoryFilter] = useState('all');
const [sortBy, setSortBy] = useState('successRate');
const [searchQuery, setSearchQuery] = useState('');
const filteredProducts = futuresProducts
.filter((product) => {
if (categoryFilter !== 'all' && product.category !== categoryFilter) {
return false;
}
if (searchQuery && !product.name.includes(searchQuery) && !product.code.includes(searchQuery)) {
return false;
}
return true;
})
.sort((a, b) => {
if (sortBy === 'successRate') {
return b.successRate - a.successRate;
} else if (sortBy === 'trendScore') {
return b.trendScore - a.trendScore;
} else if (sortBy === 'changePercent') {
return b.changePercent - a.changePercent;
}
return 0;
});
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
setIsLoaded(true);
}, []);
const renderOverview = () => (
<div className={`space-y-6 transition-all duration-500 ${isLoaded ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4'}`}>
<div className="flex items-center justify-between">
<h2 className="text-xl font-medium text-revolut-dark heading-display"></h2>
<span className="text-sm text-revolut-midSlate"></span>
</div>
<MarketOverviewPanel data={marketOverview} />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<HotEventsPanel events={hotEvents} />
</div>
<div>
<RiskAlertsPanel alerts={riskAlerts} />
</div>
</div>
</div>
);
const renderEvents = () => (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-medium text-revolut-dark heading-display"></h2>
<span className="text-sm text-revolut-midSlate"> {hotEvents.length} </span>
</div>
<HotEventsPanel events={hotEvents} />
</div>
);
const renderProducts = () => (
<div className="space-y-6">
{selectedProduct ? (
<ProductDetail
product={selectedProduct}
onBack={() => setSelectedProduct(null)}
/>
) : (
<>
<div className="p-4 rounded-card border border-revolut-grayTone bg-revolut-white space-y-4">
<div className="flex flex-wrap items-center gap-4">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-revolut-midSlate" />
<input
type="text"
placeholder="搜索品种名称或代码..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-standard bg-revolut-surface border border-revolut-grayTone text-revolut-dark text-sm placeholder:text-revolut-midSlate focus:outline-none focus:border-revolut-blue transition-colors"
/>
</div>
<div className="flex items-center gap-1 p-1 rounded-standard bg-revolut-surface border border-revolut-grayTone">
<button
onClick={() => setViewType('grid')}
className={`p-2 rounded-standard transition-colors ${viewType === 'grid' ? 'bg-revolut-dark text-revolut-white' : 'text-revolut-midSlate hover:text-revolut-dark'}`}
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewType('list')}
className={`p-2 rounded-standard transition-colors ${viewType === 'list' ? 'bg-revolut-dark text-revolut-white' : 'text-revolut-midSlate hover:text-revolut-dark'}`}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-revolut-midSlate" />
<span className="text-sm text-revolut-midSlate">:</span>
<div className="flex gap-1">
{categoryFilters.map((filter) => (
<button
key={filter.key}
onClick={() => setCategoryFilter(filter.key)}
className={`px-3 py-1.5 rounded-pill text-sm transition-colors ${
categoryFilter === filter.key
? 'bg-revolut-dark text-revolut-white font-medium'
: 'bg-revolut-surface text-revolut-midSlate border border-revolut-grayTone hover:border-revolut-midSlate'
}`}
>
{filter.label}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-revolut-midSlate">:</span>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className="px-3 py-1.5 rounded-standard bg-revolut-surface border border-revolut-grayTone text-revolut-dark text-sm focus:outline-none focus:border-revolut-blue"
>
{sortOptions.map((option) => (
<option key={option.key} value={option.key}>
{option.label}
</option>
))}
</select>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<span className="text-sm text-revolut-midSlate">
<span className="text-revolut-dark font-medium">{filteredProducts.length}</span>
</span>
<div className="flex items-center gap-2 text-sm">
<span className="flex items-center gap-1 text-revolut-teal">
<TrendingUp className="w-3 h-3" />
{filteredProducts.filter(p => p.change >= 0).length}
</span>
<span className="flex items-center gap-1 text-revolut-danger">
<TrendingDown className="w-3 h-3" />
{filteredProducts.filter(p => p.change < 0).length}
</span>
</div>
</div>
</div>
{viewType === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredProducts.map((product) => (
<ProductCard
key={product.id}
product={product}
onClick={() => setSelectedProduct(product)}
/>
))}
</div>
) : (
<div className="space-y-2">
{filteredProducts.map((product) => (
<div
key={product.id}
onClick={() => setSelectedProduct(product)}
className="p-4 rounded-card border border-revolut-grayTone bg-revolut-white hover:border-revolut-blue/50 transition-all cursor-pointer"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div>
<div className="font-medium text-revolut-dark">{product.name}</div>
<div className="text-xs text-revolut-coolGray">{product.code}</div>
</div>
<div className="flex gap-2">
{Object.entries(product.cycles).map(([key, trend]) => (
<span
key={key}
className={`px-2 py-1 rounded-pill text-xs ${
trend === 'up' ? 'bg-revolut-teal/10 text-revolut-teal' :
trend === 'down' ? 'bg-revolut-danger/10 text-revolut-danger' :
'bg-revolut-midSlate/10 text-revolut-midSlate'
}`}
>
{key === 'm5' ? '5分' : key === 'm15' ? '15分' : key === 'm30' ? '30分' : '60分'}
</span>
))}
</div>
</div>
<div className="text-right">
<div className={`font-mono font-medium ${product.change >= 0 ? 'text-revolut-teal' : 'text-revolut-danger'}`}>
¥{product.price.toLocaleString()}
</div>
<div className={`text-sm ${product.change >= 0 ? 'text-revolut-teal' : 'text-revolut-danger'}`}>
{product.change >= 0 ? '+' : ''}{product.changePercent}%
</div>
</div>
</div>
</div>
))}
</div>
)}
{filteredProducts.length === 0 && (
<div className="text-center py-12">
<div className="text-revolut-midSlate mb-2"></div>
<button
onClick={() => {
setCategoryFilter('all');
setSearchQuery('');
}}
className="text-revolut-blue hover:underline"
>
</button>
</div>
)}
</>
)}
</div>
);
const renderRisks = () => (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-medium text-revolut-dark heading-display"></h2>
<span className="text-sm text-revolut-midSlate"></span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<RiskAlertsPanel alerts={riskAlerts} />
<div className="p-6 rounded-card border border-revolut-grayTone bg-revolut-white">
<h3 className="font-medium text-revolut-dark mb-4"></h3>
<div className="space-y-4">
<div className="p-4 rounded-standard bg-revolut-danger/5 border border-revolut-danger/20">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-revolut-danger" />
<span className="font-medium text-revolut-dark"></span>
</div>
<p className="text-sm text-revolut-midSlate">
30%
</p>
</div>
<div className="p-4 rounded-standard bg-revolut-warning/5 border border-revolut-warning/20">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-revolut-warning" />
<span className="font-medium text-revolut-dark"></span>
</div>
<p className="text-sm text-revolut-midSlate">
</p>
</div>
<div className="p-4 rounded-standard bg-revolut-teal/5 border border-revolut-teal/20">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-revolut-teal" />
<span className="font-medium text-revolut-dark"></span>
</div>
<p className="text-sm text-revolut-midSlate">
</p>
</div>
</div>
</div>
</div>
</div>
);
return (
<div className="min-h-screen bg-revolut-white">
<Navbar activeTab={activeTab} onTabChange={setActiveTab} />
<main className="pt-20 pb-8 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
{activeTab === 'overview' && renderOverview()}
{activeTab === 'events' && renderEvents()}
{activeTab === 'products' && renderProducts()}
{activeTab === 'risks' && renderRisks()}
</div>
</main>
</div>
);
}
export default App;

@ -0,0 +1,174 @@
import { useState } from 'react';
import { Flame, TrendingUp, TrendingDown, Minus, AlertTriangle, ChevronRight, Calendar, Target } from 'lucide-react';
import type { HotEvent } from '@/types';
interface HotEventsProps {
events: HotEvent[];
}
function ImpactStars({ level }: { level: number }) {
return (
<div className="flex gap-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className={`w-4 h-4 rounded-sm ${i < level ? 'bg-revolut-blue' : 'bg-revolut-grayTone'}`}
/>
))}
</div>
);
}
function ImpactBadge({ impact }: { impact: 'bullish' | 'bearish' | 'neutral' }) {
const config = {
bullish: { icon: TrendingUp, text: '利多', color: 'text-revolut-teal', bg: 'bg-revolut-teal/10' },
bearish: { icon: TrendingDown, text: '利空', color: 'text-revolut-danger', bg: 'bg-revolut-danger/10' },
neutral: { icon: Minus, text: '中性', color: 'text-revolut-midSlate', bg: 'bg-revolut-midSlate/10' },
};
const { icon: Icon, text, color, bg } = config[impact];
return (
<div className={`flex items-center gap-1 px-2 py-1 rounded-pill ${bg}`}>
<Icon className={`w-3 h-3 ${color}`} />
<span className={`text-xs font-medium ${color}`}>{text}</span>
</div>
);
}
export function HotEventsPanel({ events }: HotEventsProps) {
const [selectedEvent, setSelectedEvent] = useState<HotEvent>(events[0]);
return (
<div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
<div className="lg:col-span-3 space-y-3">
<div className="flex items-center gap-2 mb-4">
<Flame className="w-5 h-5 text-revolut-blue" />
<h3 className="text-lg font-medium text-revolut-dark heading-display"></h3>
<span className="text-xs text-revolut-midSlate ml-auto"> {events.length} </span>
</div>
{events.map((event) => (
<div
key={event.id}
onClick={() => setSelectedEvent(event)}
className={`p-4 rounded-card border cursor-pointer transition-all duration-200 ${
selectedEvent?.id === event.id
? 'border-revolut-blue bg-revolut-blue/5'
: 'border-revolut-grayTone bg-revolut-white hover:border-revolut-midSlate'
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h4 className="font-medium text-revolut-dark">{event.title}</h4>
<ImpactBadge impact={event.impact} />
</div>
<p className="text-sm text-revolut-midSlate mb-3 line-clamp-2">{event.summary}</p>
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1 text-revolut-coolGray">
<Calendar className="w-3 h-3" />
<span>{event.time}</span>
</div>
<div className="flex items-center gap-2">
<Target className="w-3 h-3 text-revolut-coolGray" />
<span className="text-revolut-coolGray">:</span>
<div className="flex gap-1">
{event.affectedProducts.slice(0, 3).map((product) => (
<span
key={product}
className="px-1.5 py-0.5 rounded-standard bg-revolut-surface text-revolut-dark"
>
{product}
</span>
))}
{event.affectedProducts.length > 3 && (
<span className="text-revolut-coolGray">+{event.affectedProducts.length - 3}</span>
)}
</div>
</div>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<ImpactStars level={event.impactLevel} />
<ChevronRight className={`w-4 h-4 transition-transform ${
selectedEvent?.id === event.id ? 'text-revolut-blue rotate-90' : 'text-revolut-coolGray'
}`} />
</div>
</div>
</div>
))}
</div>
<div className="lg:col-span-2">
<div className="sticky top-4 p-5 rounded-card border border-revolut-grayTone bg-revolut-white">
{selectedEvent ? (
<>
<div className="flex items-center gap-2 mb-4">
<div className="p-2 rounded-standard bg-revolut-blue/10">
<AlertTriangle className="w-4 h-4 text-revolut-blue" />
</div>
<h3 className="font-medium text-revolut-dark"></h3>
</div>
<div className="space-y-4">
<div>
<h4 className="text-sm text-revolut-midSlate mb-2"></h4>
<p className="text-sm text-revolut-dark leading-relaxed">{selectedEvent.analysis}</p>
</div>
<div>
<h4 className="text-sm text-revolut-midSlate mb-2"></h4>
<div className="flex flex-wrap gap-2">
{selectedEvent.affectedProducts.map((product) => (
<span
key={product}
className={`px-3 py-1.5 rounded-pill text-sm font-medium ${
selectedEvent.impact === 'bullish'
? 'bg-revolut-teal/10 text-revolut-teal'
: selectedEvent.impact === 'bearish'
? 'bg-revolut-danger/10 text-revolut-danger'
: 'bg-revolut-midSlate/10 text-revolut-midSlate'
}`}
>
{product}
</span>
))}
</div>
</div>
<div>
<h4 className="text-sm text-revolut-midSlate mb-2"></h4>
<ul className="space-y-2">
{selectedEvent.risks.map((risk, index) => (
<li key={index} className="flex items-start gap-2 text-sm text-revolut-dark">
<span className="w-1.5 h-1.5 rounded-full bg-revolut-warning mt-1.5 flex-shrink-0" />
<span>{risk}</span>
</li>
))}
</ul>
</div>
<div className="pt-4 border-t border-revolut-grayTone">
<div className="flex items-center justify-between">
<span className="text-sm text-revolut-midSlate"></span>
<ImpactStars level={selectedEvent.impactLevel} />
</div>
</div>
</div>
</>
) : (
<div className="text-center py-8 text-revolut-midSlate">
<AlertTriangle className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p></p>
</div>
)}
</div>
</div>
</div>
);
}

@ -0,0 +1,365 @@
import { useEffect, useRef, useState } from 'react';
import * as echarts from 'echarts';
import type { KLineData, MACDData } from '@/types';
interface KLineChartProps {
klineData: KLineData[];
macdData: MACDData[];
resistance: number[];
support: number[];
height?: number;
}
export function KLineChart({ klineData, macdData, resistance, support, height = 500 }: KLineChartProps) {
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!chartRef.current || klineData.length === 0) return;
if (!chartInstance.current) {
chartInstance.current = echarts.init(chartRef.current);
}
const chart = chartInstance.current;
const dates = klineData.map(d => {
const date = new Date(d.time);
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
});
const klineValues = klineData.map(d => [d.open, d.close, d.low, d.high]);
const volumes = klineData.map(d => d.volume);
const difData = macdData.map(d => d.dif);
const deaData = macdData.map(d => d.dea);
const macdBarData = macdData.map(d => ({
value: d.macd,
itemStyle: {
color: d.macd >= 0 ? '#00a87e' : '#e23b4a',
},
}));
const markLines: any[] = [];
resistance.forEach((price, index) => {
markLines.push({
yAxis: price,
name: `压力${index + 1}`,
lineStyle: {
color: '#e23b4a',
type: 'solid',
width: 1.5,
},
label: {
formatter: `压力{${index + 1}}: {c}`,
color: '#e23b4a',
fontSize: 11,
},
});
});
support.forEach((price, index) => {
markLines.push({
yAxis: price,
name: `支撑${index + 1}`,
lineStyle: {
color: '#00a87e',
type: 'solid',
width: 1.5,
},
label: {
formatter: `支撑{${index + 1}}: {c}`,
color: '#00a87e',
fontSize: 11,
},
});
});
const calculateMA = (dayCount: number) => {
const result: number[] = [];
for (let i = 0; i < klineData.length; i++) {
if (i < dayCount - 1) {
result.push(Number(klineData[i].close.toFixed(2)));
continue;
}
let sum = 0;
for (let j = 0; j < dayCount; j++) {
sum += klineData[i - j].close;
}
result.push(Number((sum / dayCount).toFixed(2)));
}
return result;
};
const ma5 = calculateMA(5);
const ma10 = calculateMA(10);
const ma20 = calculateMA(20);
const option: echarts.EChartsOption = {
backgroundColor: 'transparent',
animation: true,
animationDuration: 500,
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#494fdf',
},
},
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#494fdf',
borderWidth: 1,
textStyle: {
color: '#191c1f',
fontSize: 12,
},
formatter: (params: any) => {
const kline = params.find((p: any) => p.seriesName === 'K线');
const vol = params.find((p: any) => p.seriesName === '成交量');
const macd = params.find((p: any) => p.seriesName === 'MACD');
if (!kline) return '';
const data = kline.data;
const open = data[1];
const close = data[2];
const low = data[3];
const high = data[4];
const change = ((close - open) / open * 100).toFixed(2);
const changeColor = close >= open ? '#00a87e' : '#e23b4a';
return `
<div style="padding: 8px;">
<div style="font-weight: bold; margin-bottom: 8px; color: #191c1f;">${kline.axisValue}</div>
<div style="display: grid; grid-template-columns: auto auto; gap: 4px 16px; font-size: 12px;">
<span style="color: #505a63;">:</span><span style="color: #191c1f;">${open.toFixed(2)}</span>
<span style="color: #505a63;">:</span><span style="color: #191c1f;">${high.toFixed(2)}</span>
<span style="color: #505a63;">:</span><span style="color: #191c1f;">${low.toFixed(2)}</span>
<span style="color: #505a63;">:</span><span style="color: ${changeColor};">${close.toFixed(2)} (${change}%)</span>
${vol ? `<span style="color: #505a63;">成交量:</span><span style="color: #191c1f;">${vol.data.toLocaleString()}</span>` : ''}
${macd ? `<span style="color: #505a63;">MACD:</span><span style="color: ${macd.data >= 0 ? '#00a87e' : '#e23b4a'};">${macd.data.toFixed(4)}</span>` : ''}
</div>
</div>
`;
},
},
axisPointer: {
link: [{ xAxisIndex: 'all' }],
label: {
backgroundColor: '#494fdf',
},
},
grid: [
{
left: '3%',
right: '3%',
top: '5%',
height: '50%',
containLabel: true,
},
{
left: '3%',
right: '3%',
top: '58%',
height: '15%',
containLabel: true,
},
{
left: '3%',
right: '3%',
top: '76%',
height: '18%',
containLabel: true,
},
],
xAxis: [
{
type: 'category',
data: dates,
boundaryGap: true,
axisLine: { lineStyle: { color: '#c9c9cd' } },
axisLabel: { color: '#505a63', fontSize: 10 },
axisTick: { show: false },
splitLine: { show: false },
},
{
type: 'category',
gridIndex: 1,
data: dates,
boundaryGap: true,
axisLine: { show: false },
axisLabel: { show: false },
axisTick: { show: false },
splitLine: { show: false },
},
{
type: 'category',
gridIndex: 2,
data: dates,
boundaryGap: true,
axisLine: { lineStyle: { color: '#c9c9cd' } },
axisLabel: { color: '#505a63', fontSize: 10 },
axisTick: { show: false },
splitLine: { show: false },
},
],
yAxis: [
{
scale: true,
axisLine: { lineStyle: { color: '#c9c9cd' } },
axisLabel: { color: '#505a63', fontSize: 10 },
splitLine: { lineStyle: { color: '#f4f4f4', type: 'dashed' } },
position: 'right',
},
{
scale: true,
gridIndex: 1,
axisLine: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
},
{
scale: true,
gridIndex: 2,
axisLine: { lineStyle: { color: '#c9c9cd' } },
axisLabel: { color: '#505a63', fontSize: 10 },
splitLine: { lineStyle: { color: '#f4f4f4', type: 'dashed' } },
position: 'right',
},
],
dataZoom: [
{
type: 'inside',
xAxisIndex: [0, 1, 2],
start: 50,
end: 100,
},
{
type: 'slider',
xAxisIndex: [0, 1, 2],
start: 50,
end: 100,
height: 20,
bottom: 0,
borderColor: '#c9c9cd',
fillerColor: 'rgba(73, 79, 223, 0.2)',
handleStyle: { color: '#494fdf' },
textStyle: { color: '#505a63' },
},
],
series: [
{
name: 'K线',
type: 'candlestick',
data: klineValues,
itemStyle: {
color: '#00a87e',
color0: '#e23b4a',
borderColor: '#00a87e',
borderColor0: '#e23b4a',
},
markLine: {
symbol: 'none',
data: markLines,
animation: true,
},
},
{
name: 'MA5',
type: 'line',
data: ma5,
smooth: true,
showSymbol: false,
lineStyle: { color: '#ec7e00', width: 1 },
},
{
name: 'MA10',
type: 'line',
data: ma10,
smooth: true,
showSymbol: false,
lineStyle: { color: '#007bc2', width: 1 },
},
{
name: 'MA20',
type: 'line',
data: ma20,
smooth: true,
showSymbol: false,
lineStyle: { color: '#e61e49', width: 1 },
},
{
name: '成交量',
type: 'bar',
xAxisIndex: 1,
yAxisIndex: 1,
data: volumes,
itemStyle: {
color: (params: any) => {
const index = params.dataIndex;
const close = klineData[index]?.close;
const open = klineData[index]?.open;
return close >= open ? 'rgba(0, 168, 126, 0.6)' : 'rgba(226, 59, 74, 0.6)';
},
},
},
{
name: 'MACD',
type: 'bar',
xAxisIndex: 2,
yAxisIndex: 2,
data: macdBarData,
},
{
name: 'DIF',
type: 'line',
xAxisIndex: 2,
yAxisIndex: 2,
data: difData,
smooth: true,
showSymbol: false,
lineStyle: { color: '#ec7e00', width: 1.5 },
},
{
name: 'DEA',
type: 'line',
xAxisIndex: 2,
yAxisIndex: 2,
data: deaData,
smooth: true,
showSymbol: false,
lineStyle: { color: '#007bc2', width: 1.5 },
},
],
};
chart.setOption(option);
setLoading(false);
const handleResize = () => {
chart.resize();
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [klineData, macdData, resistance, support]);
return (
<div className="relative">
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-revolut-white/80 z-10">
<div className="flex items-center gap-2 text-revolut-blue">
<div className="w-5 h-5 border-2 border-revolut-blue border-t-transparent rounded-full animate-spin" />
<span className="text-sm">...</span>
</div>
</div>
)}
<div ref={chartRef} style={{ height: `${height}px` }} className="w-full" />
</div>
);
}

@ -0,0 +1,162 @@
import { useEffect, useRef, useState } from 'react';
import { TrendingUp, TrendingDown, Activity, DollarSign, BarChart3, Zap } from 'lucide-react';
import type { MarketOverview } from '@/types';
interface MarketOverviewProps {
data: MarketOverview;
}
function AnimatedNumber({ value, decimals = 1, suffix = '' }: { value: number; decimals?: number; suffix?: string }) {
const [displayValue, setDisplayValue] = useState(0);
const startTime = useRef<number | null>(null);
const duration = 1000;
useEffect(() => {
const animate = (timestamp: number) => {
if (!startTime.current) startTime.current = timestamp;
const progress = Math.min((timestamp - startTime.current) / duration, 1);
const easeOut = 1 - Math.pow(1 - progress, 3);
setDisplayValue(value * easeOut);
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
return () => {
startTime.current = null;
};
}, [value]);
return (
<span>
{displayValue.toFixed(decimals)}{suffix}
</span>
);
}
function MiniChart({ isUp }: { isUp: boolean }) {
const points = isUp
? '0,30 10,25 20,28 30,20 40,22 50,15 60,18 70,10 80,12 90,5 100,8'
: '0,10 10,15 20,12 30,20 40,18 50,25 60,22 70,30 80,28 90,35 100,32';
const color = isUp ? '#00a87e' : '#e23b4a';
return (
<svg viewBox="0 0 100 40" className="w-full h-10">
<polyline
fill="none"
stroke={color}
strokeWidth="2"
points={points}
/>
<defs>
<linearGradient id={`gradient-${isUp}`} x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
<polygon
fill={`url(#gradient-${isUp})`}
points={`${points} 100,40 0,40`}
/>
</svg>
);
}
export function MarketOverviewPanel({ data }: MarketOverviewProps) {
const cards = [
{
title: '市场热度指数',
value: data.heatIndex,
change: data.heatChange,
suffix: '',
decimals: 1,
icon: Activity,
isUp: data.heatChange > 0,
description: data.heatChange > 0 ? '多头情绪高涨' : '市场情绪偏冷',
},
{
title: '涨跌分布',
value: data.upCount,
change: data.downCount,
suffix: '',
decimals: 0,
icon: BarChart3,
isUp: true,
description: `涨: ${data.upCount} | 跌: ${data.downCount}`,
isDistribution: true,
},
{
title: '资金流向',
value: data.capitalFlow,
change: 0,
suffix: '亿',
decimals: 1,
icon: DollarSign,
isUp: data.capitalFlow > 0,
description: data.capitalFlow > 0 ? '资金净流入' : '资金净流出',
},
{
title: '波动率指数',
value: data.volatilityIndex,
change: data.volatilityChange,
suffix: '',
decimals: 1,
icon: Zap,
isUp: data.volatilityChange > 0,
description: data.volatilityChange > 0 ? '波动扩大' : '波动收窄',
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{cards.map((card, index) => (
<div
key={card.title}
className="bg-revolut-white border border-revolut-grayTone rounded-card p-5 hover:border-revolut-blue/50 transition-all duration-300 group"
style={{
animationDelay: `${index * 100}ms`,
}}
>
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className={`p-2 rounded-standard ${card.isUp ? 'bg-revolut-teal/10' : 'bg-revolut-danger/10'}`}>
<card.icon className={`w-4 h-4 ${card.isUp ? 'text-revolut-teal' : 'text-revolut-danger'}`} />
</div>
<span className="text-revolut-midSlate text-sm">{card.title}</span>
</div>
{!card.isDistribution && card.change !== 0 && (
<div className={`flex items-center gap-1 text-xs ${card.change > 0 ? 'text-revolut-teal' : 'text-revolut-danger'}`}>
{card.change > 0 ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
<span>{card.change > 0 ? '+' : ''}{card.change}%</span>
</div>
)}
</div>
<div className="mb-3">
<span className={`text-2xl font-medium font-mono ${card.isUp ? 'text-revolut-dark' : 'text-revolut-danger'}`}>
{card.isDistribution ? (
<span className="flex items-baseline gap-2">
<span className="text-revolut-teal">{data.upCount}</span>
<span className="text-revolut-midSlate text-lg">/</span>
<span className="text-revolut-danger">{data.downCount}</span>
</span>
) : (
<>
<AnimatedNumber value={card.value} decimals={card.decimals} suffix={card.suffix} />
</>
)}
</span>
</div>
<div className="text-xs text-revolut-coolGray mb-3">{card.description}</div>
<MiniChart isUp={card.isUp} />
</div>
))}
</div>
);
}

@ -0,0 +1,140 @@
import { useState, useEffect } from 'react';
import { BarChart3, Bell, Clock, Menu, X } from 'lucide-react';
interface NavbarProps {
activeTab: string;
onTabChange: (tab: string) => void;
}
const navItems = [
{ id: 'overview', label: '市场概览' },
{ id: 'events', label: '热点事件' },
{ id: 'products', label: '品种分析' },
{ id: 'risks', label: '风险提醒' },
];
export function Navbar({ activeTab, onTabChange }: NavbarProps) {
const [currentTime, setCurrentTime] = useState(new Date());
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
const handleScroll = () => {
setIsScrolled(window.scrollY > 10);
};
window.addEventListener('scroll', handleScroll);
return () => {
clearInterval(timer);
window.removeEventListener('scroll', handleScroll);
};
}, []);
const formatTime = (date: Date) => {
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
};
return (
<nav
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
isScrolled
? 'bg-revolut-white/95 backdrop-blur-md'
: 'bg-revolut-white'
}`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-3">
<div className="p-2 rounded-standard bg-revolut-blue/10">
<BarChart3 className="w-5 h-5 text-revolut-blue" />
</div>
<div>
<h1 className="text-lg font-medium text-revolut-dark heading-display"></h1>
<p className="text-xs text-revolut-midSlate hidden sm:block"></p>
</div>
</div>
<div className="hidden md:flex items-center gap-2">
{navItems.map((item) => (
<button
key={item.id}
onClick={() => onTabChange(item.id)}
className={`px-4 py-2 rounded-pill text-sm font-medium transition-all duration-200 ${
activeTab === item.id
? 'bg-revolut-dark text-revolut-white'
: 'text-revolut-midSlate hover:bg-revolut-surface'
}`}
>
{item.label}
</button>
))}
</div>
<div className="flex items-center gap-4">
<div className="hidden sm:flex items-center gap-2 text-sm text-revolut-midSlate">
<Clock className="w-4 h-4" />
<span className="font-mono">{formatTime(currentTime)}</span>
</div>
<button className="p-2 rounded-standard hover:bg-revolut-surface transition-colors relative">
<Bell className="w-4 h-4 text-revolut-coolGray" />
<span className="absolute top-1 right-1 w-2 h-2 bg-revolut-danger rounded-full" />
</button>
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="md:hidden p-2 rounded-standard hover:bg-revolut-surface transition-colors"
>
{isMobileMenuOpen ? (
<X className="w-5 h-5 text-revolut-coolGray" />
) : (
<Menu className="w-5 h-5 text-revolut-coolGray" />
)}
</button>
</div>
</div>
</div>
{isMobileMenuOpen && (
<div className="md:hidden border-t border-revolut-grayTone bg-revolut-white/95 backdrop-blur-md">
<div className="px-4 py-3 space-y-2">
{navItems.map((item) => (
<button
key={item.id}
onClick={() => {
onTabChange(item.id);
setIsMobileMenuOpen(false);
}}
className={`w-full px-4 py-3 rounded-pill text-left text-sm font-medium transition-all duration-200 ${
activeTab === item.id
? 'bg-revolut-dark text-revolut-white'
: 'text-revolut-midSlate hover:bg-revolut-surface'
}`}
>
{item.label}
</button>
))}
<div className="pt-3 border-t border-revolut-grayTone mt-3">
<div className="flex items-center gap-2 text-sm text-revolut-midSlate px-4 py-2">
<Clock className="w-4 h-4" />
<span className="font-mono">{formatTime(currentTime)}</span>
</div>
</div>
</div>
</div>
)}
</nav>
);
}

@ -0,0 +1,160 @@
import { TrendingUp, TrendingDown, Minus, Clock, Target, BarChart3, ChevronRight } from 'lucide-react';
import type { FuturesProduct, TrendDirection } from '@/types';
interface ProductCardProps {
product: FuturesProduct;
onClick?: () => void;
}
function TrendBadge({ direction, label }: { direction: TrendDirection; label: string }) {
const config = {
up: { icon: TrendingUp, color: 'text-revolut-teal', bg: 'bg-revolut-teal/10' },
down: { icon: TrendingDown, color: 'text-revolut-danger', bg: 'bg-revolut-danger/10' },
sideways: { icon: Minus, color: 'text-revolut-midSlate', bg: 'bg-revolut-midSlate/10' },
};
const { icon: Icon, color, bg } = config[direction];
return (
<div className={`flex items-center gap-1 px-2 py-1 rounded-pill ${bg}`}>
<Icon className={`w-3 h-3 ${color}`} />
<span className={`text-xs font-medium ${color}`}>{label}</span>
</div>
);
}
function RecommendationBadge({ recommendation }: { recommendation: 'long' | 'short' | 'wait' }) {
const config = {
long: { text: '逢低做多', color: 'text-revolut-teal', bg: 'bg-revolut-teal/10' },
short: { text: '逢高做空', color: 'text-revolut-danger', bg: 'bg-revolut-danger/10' },
wait: { text: '观望等待', color: 'text-revolut-midSlate', bg: 'bg-revolut-midSlate/10' },
};
const { text, color, bg } = config[recommendation];
return (
<span className={`px-2 py-1 rounded-pill text-xs font-medium ${color} ${bg}`}>
{text}
</span>
);
}
function SuccessRateBar({ rate }: { rate: number }) {
const getColor = (r: number) => {
if (r >= 70) return 'bg-revolut-teal';
if (r >= 50) return 'bg-revolut-warning';
return 'bg-revolut-danger';
};
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-revolut-surface rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${getColor(rate)}`}
style={{ width: `${rate}%` }}
/>
</div>
<span className={`text-xs font-medium ${rate >= 70 ? 'text-revolut-teal' : rate >= 50 ? 'text-revolut-warning' : 'text-revolut-danger'}`}>
{rate}%
</span>
</div>
);
}
export function ProductCard({ product, onClick }: ProductCardProps) {
const isUp = product.change >= 0;
const cycleLabels: Record<string, string> = {
m5: '5分',
m15: '15分',
m30: '30分',
m60: '60分',
};
return (
<div
onClick={onClick}
className="p-4 rounded-card border border-revolut-grayTone bg-revolut-white hover:border-revolut-blue/50 transition-all duration-300 cursor-pointer group"
>
<div className="flex items-start justify-between mb-3">
<div>
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium text-revolut-dark">{product.name}</h4>
<span className="text-xs text-revolut-coolGray">({product.code})</span>
</div>
<RecommendationBadge recommendation={product.recommendation} />
</div>
<div className="text-right">
<div className={`text-xl font-medium font-mono ${isUp ? 'text-revolut-teal' : 'text-revolut-danger'}`}>
¥{product.price.toLocaleString()}
</div>
<div className={`flex items-center justify-end gap-1 text-sm ${isUp ? 'text-revolut-teal' : 'text-revolut-danger'}`}>
{isUp ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
<span>{isUp ? '+' : ''}{product.change.toFixed(2)} ({isUp ? '+' : ''}{product.changePercent}%)</span>
</div>
</div>
</div>
<div className="mb-3">
<div className="flex items-center gap-1 text-xs text-revolut-midSlate mb-2">
<Clock className="w-3 h-3" />
<span></span>
</div>
<div className="flex gap-2">
{Object.entries(product.cycles).map(([key, trend]) => (
<TrendBadge key={key} direction={trend} label={cycleLabels[key]} />
))}
</div>
</div>
<div className="space-y-2 mb-3">
<div className="flex items-center justify-between text-xs">
<span className="text-revolut-midSlate flex items-center gap-1">
<BarChart3 className="w-3 h-3" />
</span>
</div>
<SuccessRateBar rate={product.successRate} />
<div className="flex items-center justify-between text-xs">
<span className="text-revolut-midSlate"></span>
<span className={`font-medium ${product.trendScore >= 80 ? 'text-revolut-teal' : product.trendScore >= 60 ? 'text-revolut-warning' : 'text-revolut-danger'}`}>
{product.trendScore}/100
</span>
</div>
<div className="h-1.5 bg-revolut-surface rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
product.trendScore >= 80 ? 'bg-revolut-teal' : product.trendScore >= 60 ? 'bg-revolut-warning' : 'bg-revolut-danger'
}`}
style={{ width: `${product.trendScore}%` }}
/>
</div>
</div>
<div className="pt-3 border-t border-revolut-grayTone">
<div className="flex items-center gap-1 text-xs text-revolut-midSlate mb-2">
<Target className="w-3 h-3" />
<span></span>
</div>
<div className="flex justify-between text-xs">
<div>
<span className="text-revolut-coolGray">: </span>
<span className="text-revolut-danger font-mono">{product.keyLevels.resistance[0]?.toLocaleString() || '-'}</span>
</div>
<div>
<span className="text-revolut-coolGray">: </span>
<span className="text-revolut-teal font-mono">{product.keyLevels.support[0]?.toLocaleString() || '-'}</span>
</div>
</div>
</div>
<div className="flex items-center justify-end mt-3 pt-2 border-t border-revolut-grayTone/50">
<span className="text-xs text-revolut-coolGray group-hover:text-revolut-blue transition-colors flex items-center gap-1">
<ChevronRight className="w-3 h-3 group-hover:translate-x-1 transition-transform" />
</span>
</div>
</div>
);
}

@ -0,0 +1,366 @@
import { useState, useEffect } from 'react';
import { ArrowLeft, TrendingUp, TrendingDown, Target, Clock, BarChart3, Activity, Zap } from 'lucide-react';
import { KLineChart } from './KLineChart';
import type { FuturesProduct, CycleAnalysis, TechnicalIndicators, TradingAdvice } from '@/types';
import { generateCycleAnalysis, generateTechnicalIndicators, generateTradingAdvice } from '@/data/mockData';
interface ProductDetailProps {
product: FuturesProduct;
onBack: () => void;
}
function PeriodButton({
label,
isActive,
onClick
}: {
label: string;
isActive: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
className={`px-4 py-2 rounded-pill text-sm font-medium transition-all duration-200 ${
isActive
? 'bg-revolut-dark text-revolut-white'
: 'bg-revolut-surface text-revolut-midSlate hover:bg-revolut-grayTone'
}`}
>
{label}
</button>
);
}
function IndicatorCard({
title,
value,
status,
signal
}: {
title: string;
value: string;
status: string;
signal?: 'positive' | 'negative' | 'neutral';
}) {
const signalColors = {
positive: 'text-revolut-teal',
negative: 'text-revolut-danger',
neutral: 'text-revolut-midSlate',
};
return (
<div className="p-3 rounded-standard border border-revolut-grayTone bg-revolut-white">
<div className="text-xs text-revolut-coolGray mb-1">{title}</div>
<div className={`text-sm font-medium ${signal ? signalColors[signal] : 'text-revolut-dark'}`}>{value}</div>
<div className="text-xs text-revolut-midSlate mt-1">{status}</div>
</div>
);
}
function KeyLevelRow({
label,
value,
type
}: {
label: string;
value: number;
type: 'resistance' | 'support';
}) {
return (
<div className="flex items-center justify-between py-2 border-b border-revolut-grayTone/50 last:border-0">
<span className="text-sm text-revolut-midSlate">{label}</span>
<span className={`text-sm font-mono font-medium ${type === 'resistance' ? 'text-revolut-danger' : 'text-revolut-teal'}`}>
{value.toLocaleString()}
</span>
</div>
);
}
export function ProductDetail({ product, onBack }: ProductDetailProps) {
const [selectedPeriod, setSelectedPeriod] = useState<'m5' | 'm15' | 'm30' | 'm60'>('m15');
const [cycleData, setCycleData] = useState<CycleAnalysis | null>(null);
const [indicators, setIndicators] = useState<TechnicalIndicators | null>(null);
const [advice, setAdvice] = useState<TradingAdvice | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
setTimeout(() => {
setCycleData(generateCycleAnalysis(product.id, selectedPeriod));
setIndicators(generateTechnicalIndicators(product.id));
setAdvice(generateTradingAdvice(product.id));
setLoading(false);
}, 300);
}, [product.id, selectedPeriod]);
const isUp = product.change >= 0;
const periods = [
{ key: 'm5', label: '5分钟' },
{ key: 'm15', label: '15分钟' },
{ key: 'm30', label: '30分钟' },
{ key: 'm60', label: '60分钟' },
];
const cycleLabels: Record<string, string> = {
m5: '5分钟',
m15: '15分钟',
m30: '30分钟',
m60: '60分钟',
};
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<button
onClick={onBack}
className="flex items-center gap-2 px-4 py-2 rounded-pill border border-revolut-grayTone bg-revolut-white text-revolut-midSlate hover:text-revolut-dark hover:border-revolut-midSlate transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span></span>
</button>
<div>
<h2 className="text-xl font-medium text-revolut-dark heading-display">{product.name} ({product.code})</h2>
<p className="text-sm text-revolut-midSlate"></p>
</div>
</div>
<div className="p-4 rounded-card border border-revolut-grayTone bg-revolut-white">
<div className="flex flex-wrap items-center gap-6">
<div>
<div className={`text-3xl font-medium font-mono ${isUp ? 'text-revolut-teal' : 'text-revolut-danger'}`}>
¥{product.price.toLocaleString()}
</div>
<div className={`flex items-center gap-1 text-sm ${isUp ? 'text-revolut-teal' : 'text-revolut-danger'}`}>
{isUp ? <TrendingUp className="w-4 h-4" /> : <TrendingDown className="w-4 h-4" />}
<span>{isUp ? '+' : ''}{product.change.toFixed(2)} ({isUp ? '+' : ''}{product.changePercent}%)</span>
</div>
</div>
<div className="h-12 w-px bg-revolut-grayTone hidden sm:block" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div>
<div className="text-xs text-revolut-coolGray"></div>
<div className="text-sm font-mono text-revolut-dark">{product.open.toLocaleString()}</div>
</div>
<div>
<div className="text-xs text-revolut-coolGray"></div>
<div className="text-sm font-mono text-revolut-teal">{product.high.toLocaleString()}</div>
</div>
<div>
<div className="text-xs text-revolut-coolGray"></div>
<div className="text-sm font-mono text-revolut-danger">{product.low.toLocaleString()}</div>
</div>
<div>
<div className="text-xs text-revolut-coolGray"></div>
<div className="text-sm font-mono text-revolut-dark">{product.openInterest.toLocaleString()}</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2 space-y-4">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-revolut-blue" />
<span className="text-sm text-revolut-midSlate"></span>
<div className="flex gap-2 ml-2">
{periods.map((p) => (
<PeriodButton
key={p.key}
label={p.label}
isActive={selectedPeriod === p.key}
onClick={() => setSelectedPeriod(p.key as any)}
/>
))}
</div>
</div>
<div className="p-4 rounded-card border border-revolut-grayTone bg-revolut-white">
{loading ? (
<div className="h-[500px] flex items-center justify-center">
<div className="flex items-center gap-2 text-revolut-blue">
<div className="w-5 h-5 border-2 border-revolut-blue border-t-transparent rounded-full animate-spin" />
<span>...</span>
</div>
</div>
) : cycleData ? (
<KLineChart
klineData={cycleData.klineData}
macdData={cycleData.macdData}
resistance={cycleData.keyLevels.resistance}
support={cycleData.keyLevels.support}
height={500}
/>
) : null}
</div>
</div>
<div className="space-y-4">
<div className="p-4 rounded-card border border-revolut-grayTone bg-revolut-white">
<div className="flex items-center gap-2 mb-4">
<div className={`p-2 rounded-standard ${
advice?.action === 'long' ? 'bg-revolut-teal/10' :
advice?.action === 'short' ? 'bg-revolut-danger/10' : 'bg-revolut-midSlate/10'
}`}>
<Target className={`w-4 h-4 ${
advice?.action === 'long' ? 'text-revolut-teal' :
advice?.action === 'short' ? 'text-revolut-danger' : 'text-revolut-midSlate'
}`} />
</div>
<h3 className="font-medium text-revolut-dark"></h3>
</div>
{advice && (
<div className="space-y-3">
<div className={`p-3 rounded-standard ${
advice.action === 'long' ? 'bg-revolut-teal/10 border border-revolut-teal/30' :
advice.action === 'short' ? 'bg-revolut-danger/10 border border-revolut-danger/30' :
'bg-revolut-midSlate/10 border border-revolut-midSlate/30'
}`}>
<div className="text-xs text-revolut-coolGray mb-1"></div>
<div className={`text-lg font-medium ${
advice.action === 'long' ? 'text-revolut-teal' :
advice.action === 'short' ? 'text-revolut-danger' : 'text-revolut-midSlate'
}`}>
{advice.action === 'long' ? '逢低做多' : advice.action === 'short' ? '逢高做空' : '观望等待'}
</div>
<div className="text-xs text-revolut-midSlate mt-1">{advice.reason}</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="p-2 rounded-standard bg-revolut-surface">
<div className="text-xs text-revolut-coolGray"></div>
<div className="text-sm font-mono text-revolut-dark">{advice.entryPrice.toLocaleString()}</div>
</div>
<div className="p-2 rounded-standard bg-revolut-surface">
<div className="text-xs text-revolut-coolGray"></div>
<div className="text-sm font-mono text-revolut-teal">{advice.targetPrice.toLocaleString()}</div>
</div>
<div className="p-2 rounded-standard bg-revolut-surface">
<div className="text-xs text-revolut-coolGray"></div>
<div className="text-sm font-mono text-revolut-danger">{advice.stopLoss.toLocaleString()}</div>
</div>
<div className="p-2 rounded-standard bg-revolut-surface">
<div className="text-xs text-revolut-coolGray"></div>
<div className={`text-sm font-medium ${
advice.riskLevel === 'low' ? 'text-revolut-teal' :
advice.riskLevel === 'medium' ? 'text-revolut-warning' : 'text-revolut-danger'
}`}>
{advice.riskLevel === 'low' ? '低' : advice.riskLevel === 'medium' ? '中' : '高'}
</div>
</div>
</div>
</div>
)}
</div>
<div className="p-4 rounded-card border border-revolut-grayTone bg-revolut-white">
<div className="flex items-center gap-2 mb-4">
<div className="p-2 rounded-standard bg-revolut-blue/10">
<Activity className="w-4 h-4 text-revolut-blue" />
</div>
<h3 className="font-medium text-revolut-dark"></h3>
</div>
{indicators && (
<div className="grid grid-cols-2 gap-2">
<IndicatorCard
title="MACD"
value={indicators.macd.signal === 'golden_cross' ? '金叉' : indicators.macd.signal === 'dead_cross' ? '死叉' : '中性'}
status={`DIF: ${indicators.macd.value}`}
signal={indicators.macd.signal === 'golden_cross' ? 'positive' : indicators.macd.signal === 'dead_cross' ? 'negative' : 'neutral'}
/>
<IndicatorCard
title="RSI"
value={`${indicators.rsi.value}`}
status={indicators.rsi.status === 'overbought' ? '超买' : indicators.rsi.status === 'oversold' ? '超卖' : '正常'}
signal={indicators.rsi.status === 'oversold' ? 'positive' : indicators.rsi.status === 'overbought' ? 'negative' : 'neutral'}
/>
<IndicatorCard
title="布林带"
value={indicators.bollinger.position === 'upper' ? '上轨' : indicators.bollinger.position === 'lower' ? '下轨' : '中轨'}
status={`区间: ${indicators.bollinger.lower.toFixed(0)}-${indicators.bollinger.upper.toFixed(0)}`}
signal={indicators.bollinger.position === 'lower' ? 'positive' : indicators.bollinger.position === 'upper' ? 'negative' : 'neutral'}
/>
<IndicatorCard
title="KDJ"
value={indicators.kdj.signal === 'golden_cross' ? '金叉' : indicators.kdj.signal === 'dead_cross' ? '死叉' : '中性'}
status={`K: ${indicators.kdj.k} D: ${indicators.kdj.d}`}
signal={indicators.kdj.signal === 'golden_cross' ? 'positive' : indicators.kdj.signal === 'dead_cross' ? 'negative' : 'neutral'}
/>
</div>
)}
</div>
<div className="p-4 rounded-card border border-revolut-grayTone bg-revolut-white">
<div className="flex items-center gap-2 mb-4">
<div className="p-2 rounded-standard bg-revolut-warning/10">
<BarChart3 className="w-4 h-4 text-revolut-warning" />
</div>
<h3 className="font-medium text-revolut-dark"></h3>
</div>
<div className="space-y-1">
<div className="text-xs text-revolut-danger font-medium mb-2"></div>
{product.keyLevels.resistance.map((level, index) => (
<KeyLevelRow key={index} label={`压力 ${index + 1}`} value={level} type="resistance" />
))}
</div>
<div className="mt-4 space-y-1">
<div className="text-xs text-revolut-teal font-medium mb-2"></div>
{product.keyLevels.support.map((level, index) => (
<KeyLevelRow key={index} label={`支撑 ${index + 1}`} value={level} type="support" />
))}
</div>
</div>
<div className="p-4 rounded-card border border-revolut-grayTone bg-revolut-white">
<div className="flex items-center gap-2 mb-4">
<div className="p-2 rounded-standard bg-revolut-deepPink/10">
<Zap className="w-4 h-4 text-revolut-deepPink" />
</div>
<h3 className="font-medium text-revolut-dark"></h3>
</div>
<div className="space-y-2">
{Object.entries(product.cycles).map(([key, trend]) => (
<div key={key} className="flex items-center justify-between py-1.5">
<span className="text-sm text-revolut-midSlate">{cycleLabels[key]}</span>
<div className={`flex items-center gap-1 px-2 py-1 rounded-pill text-xs ${
trend === 'up' ? 'bg-revolut-teal/10 text-revolut-teal' :
trend === 'down' ? 'bg-revolut-danger/10 text-revolut-danger' :
'bg-revolut-midSlate/10 text-revolut-midSlate'
}`}>
{trend === 'up' ? <TrendingUp className="w-3 h-3" /> : trend === 'down' ? <TrendingDown className="w-3 h-3" /> : <div className="w-3 h-0.5 bg-current" />}
<span>{trend === 'up' ? '上涨' : trend === 'down' ? '下跌' : '震荡'}</span>
</div>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-revolut-grayTone">
<div className="flex items-center justify-between">
<span className="text-sm text-revolut-midSlate"></span>
<span className={`text-sm font-medium ${product.trendScore >= 80 ? 'text-revolut-teal' : product.trendScore >= 60 ? 'text-revolut-warning' : 'text-revolut-danger'}`}>
{product.trendScore}%
</span>
</div>
<div className="mt-2 h-2 bg-revolut-surface rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
product.trendScore >= 80 ? 'bg-revolut-teal' : product.trendScore >= 60 ? 'bg-revolut-warning' : 'bg-revolut-danger'
}`}
style={{ width: `${product.trendScore}%` }}
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

@ -0,0 +1,99 @@
import { AlertTriangle, Bell, Shield, TrendingDown, AlertOctagon } from 'lucide-react';
import type { RiskAlert } from '@/types';
interface RiskAlertsProps {
alerts: RiskAlert[];
}
function RiskLevelBadge({ level }: { level: 'high' | 'medium' | 'low' }) {
const config = {
high: { text: '高风险', color: 'text-revolut-danger', bg: 'bg-revolut-danger/10', icon: AlertOctagon },
medium: { text: '中风险', color: 'text-revolut-warning', bg: 'bg-revolut-warning/10', icon: AlertTriangle },
low: { text: '低风险', color: 'text-revolut-teal', bg: 'bg-revolut-teal/10', icon: Shield },
};
const { text, color, bg, icon: Icon } = config[level];
return (
<div className={`flex items-center gap-1 px-2 py-1 rounded-pill ${bg}`}>
<Icon className={`w-3 h-3 ${color}`} />
<span className={`text-xs font-medium ${color}`}>{text}</span>
</div>
);
}
export function RiskAlertsPanel({ alerts }: RiskAlertsProps) {
if (alerts.length === 0) {
return (
<div className="p-4 rounded-card border border-revolut-grayTone bg-revolut-white">
<div className="flex items-center gap-2 mb-4">
<div className="p-2 rounded-standard bg-revolut-teal/10">
<Shield className="w-4 h-4 text-revolut-teal" />
</div>
<h3 className="font-medium text-revolut-dark"></h3>
</div>
<div className="text-center py-6 text-revolut-midSlate">
<Shield className="w-10 h-10 mx-auto mb-2 opacity-50" />
<p className="text-sm"></p>
</div>
</div>
);
}
return (
<div className="p-4 rounded-card border border-revolut-grayTone bg-revolut-white">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="p-2 rounded-standard bg-revolut-danger/10">
<Bell className="w-4 h-4 text-revolut-danger" />
</div>
<h3 className="font-medium text-revolut-dark"></h3>
</div>
<span className="px-2 py-1 rounded-pill bg-revolut-danger/10 text-revolut-danger text-xs font-medium">
{alerts.length}
</span>
</div>
<div className="space-y-3">
{alerts.map((alert) => (
<div
key={alert.id}
className="p-3 rounded-standard border border-revolut-grayTone bg-revolut-surface hover:border-revolut-danger/30 transition-colors"
>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex-1">
<h4 className="text-sm font-medium text-revolut-dark mb-1">{alert.title}</h4>
<p className="text-xs text-revolut-midSlate line-clamp-2">{alert.description}</p>
</div>
<RiskLevelBadge level={alert.riskLevel} />
</div>
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className="text-revolut-coolGray">:</span>
<div className="flex gap-1">
{alert.affectedProducts.slice(0, 2).map((product) => (
<span key={product} className="px-1.5 py-0.5 rounded-standard bg-revolut-white text-revolut-dark">
{product}
</span>
))}
{alert.affectedProducts.length > 2 && (
<span className="text-revolut-coolGray">+{alert.affectedProducts.length - 2}</span>
)}
</div>
</div>
<span className="text-revolut-coolGray">{alert.time}</span>
</div>
<div className="mt-2 pt-2 border-t border-revolut-grayTone/50">
<div className="flex items-center gap-1 text-xs">
<TrendingDown className="w-3 h-3 text-revolut-warning" />
<span className="text-revolut-warning">: {alert.suggestion}</span>
</div>
</div>
</div>
))}
</div>
</div>
);
}

@ -0,0 +1,457 @@
import type { FuturesProduct, HotEvent, MarketOverview, RiskAlert, CycleAnalysis, KLineData, MACDData, TechnicalIndicators, TradingAdvice } from '@/types';
export const marketOverview: MarketOverview = {
heatIndex: 78.5,
heatChange: 5.2,
upCount: 156,
downCount: 89,
capitalFlow: 28.5,
volatilityIndex: 23.8,
volatilityChange: -1.2,
};
export const hotEvents: HotEvent[] = [
{
id: '1',
title: '地缘政治风险升级',
time: '2025-03-02',
summary: '美以袭伊朗,"海上油阀"被关,中东局势紧张升级',
affectedProducts: ['原油', '黄金', '白银', '燃油'],
impact: 'bullish',
impactLevel: 5,
analysis: '地缘政治风险急剧升温,霍尔木兹海峡封锁风险上升。原油供应中断担忧推动油价上涨,避险资产黄金、白银同步走强。短期油价易涨难跌,建议关注原油、黄金相关品种做多机会。',
risks: ['冲突升级可能', '供应中断风险', '波动率激增'],
},
{
id: '2',
title: '黄金价格创历史新高',
time: '2025-03-01',
summary: 'COMEX黄金突破3100美元关口避险需求强劲',
affectedProducts: ['黄金', '白银', '铂金', '美元指数'],
impact: 'bullish',
impactLevel: 4,
analysis: '特朗普关税政策"2.0"版本引发市场担忧,叠加地缘政治风险,黄金避险属性凸显。技术面突破历史高点,多头趋势强劲。预计金价短期维持强势,回调即是买入机会。',
risks: ['获利回吐压力', '美元反弹风险', '美联储政策转向'],
},
{
id: '3',
title: '铜供应紧张预期',
time: '2025-02-28',
summary: '全球铜市场预计出现18万吨供应缺口美国或加征铜进口关税',
affectedProducts: ['铜', '铝', '镍', '锌'],
impact: 'bullish',
impactLevel: 4,
analysis: '铜精矿TC现货指数持续回落矿山供应增长放缓。美国可能加征铜进口关税刺激美铜价格上涨全球套利行为收紧供应。需求端新能源产业用铜需求快速增长供需矛盾支撑铜价。',
risks: ['需求放缓风险', '美元走强压制', '库存累积'],
},
{
id: '4',
title: '美联储通胀数据超预期',
time: '2025-02-27',
summary: '核心PCE数据超预期降息预期降温',
affectedProducts: ['美元指数', '黄金', '原油', '大宗商品'],
impact: 'bearish',
impactLevel: 3,
analysis: '美国2月核心PCE通胀数据超预期市场对美联储降息预期降温。美元指数短期获得支撑对大宗商品形成一定压制。但地缘政治风险对冲了部分利空影响。',
risks: ['加息预期升温', '美元持续走强', '风险资产抛售'],
},
{
id: '5',
title: 'OPEC+减产延期预期',
time: '2025-02-26',
summary: 'OPEC+可能延长减产协议至二季度末',
affectedProducts: ['原油', '燃油', '沥青', '石化品种'],
impact: 'bullish',
impactLevel: 3,
analysis: 'OPEC+成员国倾向于延长减产协议以支撑油价。全球原油需求预期改善,叠加供应端约束,原油市场供需格局趋紧。关注减产协议正式落地情况。',
risks: ['减产执行力度', '非OPEC增产', '需求不及预期'],
},
];
export const futuresProducts: FuturesProduct[] = [
{
id: '1',
name: '原油',
code: 'SC',
category: 'energy',
price: 528.6,
change: 12.1,
changePercent: 2.35,
open: 518.0,
high: 535.0,
low: 515.5,
volume: 285600,
openInterest: 125800,
trendScore: 85,
successRate: 72,
cycles: {
m5: 'up',
m15: 'up',
m30: 'up',
m60: 'sideways',
},
keyLevels: {
resistance: [535.0, 542.0, 550.0],
support: [518.0, 510.0, 500.0],
},
recommendation: 'long',
recommendationReason: '地缘政治风险推动,多周期共振向上',
},
{
id: '2',
name: '黄金',
code: 'AU',
category: 'metal',
price: 685.2,
change: 12.45,
changePercent: 1.85,
open: 675.0,
high: 692.0,
low: 672.0,
volume: 456200,
openInterest: 215600,
trendScore: 92,
successRate: 78,
cycles: {
m5: 'up',
m15: 'up',
m30: 'up',
m60: 'up',
},
keyLevels: {
resistance: [692.0, 700.0, 710.0],
support: [678.0, 670.0, 660.0],
},
recommendation: 'long',
recommendationReason: '创历史新高,趋势强劲,避险需求旺盛',
},
{
id: '3',
name: '铜',
code: 'CU',
category: 'metal',
price: 80610,
change: 112,
changePercent: 0.14,
open: 80200,
high: 81200,
low: 79800,
volume: 125800,
openInterest: 98500,
trendScore: 65,
successRate: 58,
cycles: {
m5: 'sideways',
m15: 'up',
m30: 'sideways',
m60: 'up',
},
keyLevels: {
resistance: [81200, 82000, 83000],
support: [79800, 79000, 78000],
},
recommendation: 'wait',
recommendationReason: '高位震荡,方向不明,建议观望',
},
{
id: '4',
name: '白银',
code: 'AG',
category: 'metal',
price: 8250,
change: 165,
changePercent: 2.04,
open: 8100,
high: 8350,
low: 8050,
volume: 325600,
openInterest: 185200,
trendScore: 88,
successRate: 75,
cycles: {
m5: 'up',
m15: 'up',
m30: 'up',
m60: 'up',
},
keyLevels: {
resistance: [8350, 8500, 8650],
support: [8100, 8000, 7850],
},
recommendation: 'long',
recommendationReason: '跟随黄金上涨,波动更大,机会更好',
},
{
id: '5',
name: '铁矿石',
code: 'I',
category: 'metal',
price: 785.5,
change: 28.0,
changePercent: 3.7,
open: 760.0,
high: 792.0,
low: 755.0,
volume: 185200,
openInterest: 95600,
trendScore: 82,
successRate: 68,
cycles: {
m5: 'up',
m15: 'up',
m30: 'up',
m60: 'up',
},
keyLevels: {
resistance: [792.0, 800.0, 815.0],
support: [770.0, 760.0, 745.0],
},
recommendation: 'long',
recommendationReason: '黑色系领涨,需求预期改善',
},
{
id: '6',
name: '豆粕',
code: 'M',
category: 'agriculture',
price: 2985,
change: -51,
changePercent: -1.68,
open: 3040,
high: 3050,
low: 2960,
volume: 256800,
openInterest: 325600,
trendScore: 35,
successRate: 42,
cycles: {
m5: 'down',
m15: 'down',
m30: 'down',
m60: 'sideways',
},
keyLevels: {
resistance: [3050, 3100, 3180],
support: [2960, 2900, 2820],
},
recommendation: 'short',
recommendationReason: '供应充足,需求疲软,短期偏弱',
},
{
id: '7',
name: '棕榈油',
code: 'P',
category: 'agriculture',
price: 8750,
change: 0,
changePercent: 0,
open: 8720,
high: 8820,
low: 8680,
volume: 125600,
openInterest: 185200,
trendScore: 50,
successRate: 52,
cycles: {
m5: 'sideways',
m15: 'sideways',
m30: 'sideways',
m60: 'sideways',
},
keyLevels: {
resistance: [8820, 8900, 9050],
support: [8680, 8600, 8450],
},
recommendation: 'wait',
recommendationReason: '横盘整理,等待方向选择',
},
{
id: '8',
name: '集运指数',
code: 'EC',
category: 'financial',
price: 2150,
change: 196,
changePercent: 10.06,
open: 1960,
high: 2200,
low: 1940,
volume: 85600,
openInterest: 45600,
trendScore: 90,
successRate: 80,
cycles: {
m5: 'up',
m15: 'up',
m30: 'up',
m60: 'up',
},
keyLevels: {
resistance: [2200, 2300, 2400],
support: [2000, 1900, 1800],
},
recommendation: 'long',
recommendationReason: '涨停突破,地缘风险推升运价',
},
];
export const riskAlerts: RiskAlert[] = [
{
id: '1',
title: '中东地缘风险急剧升温',
affectedProducts: ['原油', '黄金', '燃油'],
riskLevel: 'high',
description: '美以与伊朗冲突升级,霍尔木兹海峡封锁风险上升',
suggestion: '控制仓位,设置止损,关注局势发展',
time: '2025-03-02 14:30',
},
{
id: '2',
title: '黄金价格创历史新高',
affectedProducts: ['黄金', '白银'],
riskLevel: 'medium',
description: '金价快速上涨后存在获利回吐风险',
suggestion: '避免追高,等待回调后再入场',
time: '2025-03-01 09:15',
},
{
id: '3',
title: '集运指数涨停',
affectedProducts: ['集运指数'],
riskLevel: 'high',
description: '连续涨停后波动率激增,注意回调风险',
suggestion: '减仓或获利了结,避免隔夜重仓',
time: '2025-03-02 15:00',
},
];
export function generateKLineData(basePrice: number, count: number = 100): KLineData[] {
const data: KLineData[] = [];
let price = basePrice;
const now = new Date();
for (let i = count; i >= 0; i--) {
const time = new Date(now.getTime() - i * 5 * 60 * 1000);
const volatility = price * 0.008;
const change = (Math.random() - 0.48) * volatility;
const open = price;
const close = price + change;
const high = Math.max(open, close) + Math.random() * volatility * 0.5;
const low = Math.min(open, close) - Math.random() * volatility * 0.5;
const volume = Math.floor(Math.random() * 10000 + 5000);
data.push({
time: time.toISOString(),
open: Number(open.toFixed(2)),
high: Number(high.toFixed(2)),
low: Number(low.toFixed(2)),
close: Number(close.toFixed(2)),
volume,
});
price = close;
}
return data;
}
export function generateMACDData(klineData: KLineData[]): MACDData[] {
const data: MACDData[] = [];
let ema12 = klineData[0].close;
let ema26 = klineData[0].close;
let dea = 0;
const k12 = 2 / 13;
const k26 = 2 / 27;
const k9 = 2 / 10;
for (const kline of klineData) {
ema12 = kline.close * k12 + ema12 * (1 - k12);
ema26 = kline.close * k26 + ema26 * (1 - k26);
const dif = ema12 - ema26;
dea = dif * k9 + dea * (1 - k9);
const macd = (dif - dea) * 2;
data.push({
time: kline.time,
dif: Number(dif.toFixed(4)),
dea: Number(dea.toFixed(4)),
macd: Number(macd.toFixed(4)),
});
}
return data;
}
export function generateCycleAnalysis(productId: string, period: 'm5' | 'm15' | 'm30' | 'm60'): CycleAnalysis {
const product = futuresProducts.find(p => p.id === productId);
const basePrice = product?.price || 500;
const klineData = generateKLineData(basePrice, 80);
const macdData = generateMACDData(klineData);
const volumeData = klineData.map(d => ({
time: d.time,
value: d.volume,
}));
return {
period,
trend: product?.cycles[period] || 'sideways',
trendStrength: Math.floor(Math.random() * 40) + 60,
klineData,
volumeData,
macdData,
keyLevels: {
resistance: product?.keyLevels.resistance || [],
support: product?.keyLevels.support || [],
},
};
}
export function generateTechnicalIndicators(productId: string): TechnicalIndicators {
const product = futuresProducts.find(p => p.id === productId);
const price = product?.price || 500;
return {
macd: {
signal: Math.random() > 0.5 ? 'golden_cross' : 'neutral',
value: Number((Math.random() * 2 - 0.5).toFixed(4)),
},
rsi: {
value: Math.floor(Math.random() * 40) + 30,
status: 'normal',
},
bollinger: {
upper: price * 1.03,
middle: price,
lower: price * 0.97,
position: 'middle',
},
kdj: {
k: Math.floor(Math.random() * 100),
d: Math.floor(Math.random() * 100),
j: Math.floor(Math.random() * 100),
signal: Math.random() > 0.6 ? 'golden_cross' : 'neutral',
},
};
}
export function generateTradingAdvice(productId: string): TradingAdvice {
const product = futuresProducts.find(p => p.id === productId);
const price = product?.price || 500;
const recommendation = product?.recommendation || 'wait';
const volatility = price * 0.02;
return {
action: recommendation,
entryPrice: recommendation === 'long' ? price - volatility * 0.3 : price + volatility * 0.3,
stopLoss: recommendation === 'long' ? price - volatility : price + volatility,
targetPrice: recommendation === 'long' ? price + volatility * 2 : price - volatility * 2,
riskLevel: product?.trendScore && product.trendScore > 80 ? 'low' : product?.trendScore && product.trendScore > 60 ? 'medium' : 'high',
position: product?.trendScore && product.trendScore > 80 ? 'medium' : 'light',
holdingTime: 'short',
reason: product?.recommendationReason || '技术面与基本面综合判断',
};
}

@ -0,0 +1,171 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--rui-color-dark: #191c1f;
--rui-color-white: #ffffff;
--rui-color-surface: #f4f4f4;
--rui-color-blue: #494fdf;
--rui-color-action-blue: #4f55f1;
--rui-color-blue-text: #376cd5;
--rui-color-danger: #e23b4a;
--rui-color-deep-pink: #e61e49;
--rui-color-warning: #ec7e00;
--rui-color-yellow: #b09000;
--rui-color-teal: #00a87e;
--rui-color-light-green: #428619;
--rui-color-green-text: #006400;
--rui-color-light-blue: #007bc2;
--rui-color-brown: #936d62;
--rui-color-red-text: #8b0000;
--rui-color-mid-slate: #505a63;
--rui-color-cool-gray: #8d969e;
--rui-color-gray-tone: #c9c9cd;
}
* {
@apply border-revolut-grayTone;
}
body {
@apply bg-revolut-white text-revolut-dark antialiased;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
letter-spacing: 0.24px;
}
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f4f4f4;
}
::-webkit-scrollbar-thumb {
background: #c9c9cd;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #8d969e;
}
::selection {
background: rgba(73, 79, 223, 0.2);
color: #191c1f;
}
*:focus-visible {
outline: 0 0 0 0.125rem #494fdf;
outline-offset: 2px;
}
html {
scroll-behavior: smooth;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.5s ease-out forwards;
}
.font-mono {
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', monospace;
}
@layer components {
.btn-pill {
@apply rounded-pill px-8 py-3.5 font-display text-nav font-medium transition-all duration-200;
}
.btn-pill-primary {
@apply btn-pill bg-revolut-dark text-revolut-white hover:opacity-85;
}
.btn-pill-secondary {
@apply btn-pill bg-revolut-surface text-revolut-dark hover:opacity-85;
}
.btn-pill-outline {
@apply btn-pill bg-transparent text-revolut-dark border-2 border-revolut-dark;
}
.btn-pill-ghost {
@apply btn-pill bg-revolut-surface/10 text-revolut-surface border-2 border-revolut-surface;
}
.card-revolut {
@apply rounded-card bg-revolut-white border border-revolut-grayTone;
}
.card-dark {
@apply rounded-card bg-revolut-dark text-revolut-white;
}
.heading-display {
font-family: 'Aeonik Pro', 'Inter', 'Arial', sans-serif;
font-weight: 500;
letter-spacing: -0.48px;
}
.heading-hero {
font-family: 'Aeonik Pro', 'Inter', 'Arial', sans-serif;
font-size: 5rem;
font-weight: 500;
line-height: 1;
letter-spacing: -0.8px;
}
.heading-section {
font-family: 'Aeonik Pro', 'Inter', 'Arial', sans-serif;
font-size: 3rem;
font-weight: 500;
line-height: 1.21;
letter-spacing: -0.48px;
}
.heading-card {
font-family: 'Aeonik Pro', 'Inter', 'Arial', sans-serif;
font-size: 2rem;
font-weight: 500;
line-height: 1.19;
letter-spacing: -0.32px;
}
.text-body {
font-family: 'Inter', 'Arial', sans-serif;
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
letter-spacing: 0.24px;
}
.text-body-semibold {
font-family: 'Inter', 'Arial', sans-serif;
font-size: 1rem;
font-weight: 600;
line-height: 1.5;
letter-spacing: 0.16px;
}
}

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

@ -0,0 +1,125 @@
export interface FuturesProduct {
id: string;
name: string;
code: string;
category: 'energy' | 'metal' | 'agriculture' | 'financial';
price: number;
change: number;
changePercent: number;
open: number;
high: number;
low: number;
volume: number;
openInterest: number;
trendScore: number;
successRate: number;
cycles: {
m5: TrendDirection;
m15: TrendDirection;
m30: TrendDirection;
m60: TrendDirection;
};
keyLevels: {
resistance: number[];
support: number[];
};
recommendation: 'long' | 'short' | 'wait';
recommendationReason: string;
}
export type TrendDirection = 'up' | 'down' | 'sideways';
export interface HotEvent {
id: string;
title: string;
time: string;
summary: string;
affectedProducts: string[];
impact: 'bullish' | 'bearish' | 'neutral';
impactLevel: 1 | 2 | 3 | 4 | 5;
analysis: string;
risks: string[];
}
export interface MarketOverview {
heatIndex: number;
heatChange: number;
upCount: number;
downCount: number;
capitalFlow: number;
volatilityIndex: number;
volatilityChange: number;
}
export interface KLineData {
time: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
}
export interface MACDData {
time: string;
dif: number;
dea: number;
macd: number;
}
export interface TechnicalIndicators {
macd: {
signal: 'golden_cross' | 'dead_cross' | 'neutral';
value: number;
};
rsi: {
value: number;
status: 'overbought' | 'oversold' | 'normal';
};
bollinger: {
upper: number;
middle: number;
lower: number;
position: 'upper' | 'middle' | 'lower';
};
kdj: {
k: number;
d: number;
j: number;
signal: 'golden_cross' | 'dead_cross' | 'neutral';
};
}
export interface TradingAdvice {
action: 'long' | 'short' | 'wait';
entryPrice: number;
stopLoss: number;
targetPrice: number;
riskLevel: 'low' | 'medium' | 'high';
position: 'light' | 'medium' | 'heavy';
holdingTime: 'short' | 'medium' | 'long';
reason: string;
}
export interface RiskAlert {
id: string;
title: string;
affectedProducts: string[];
riskLevel: 'high' | 'medium' | 'low';
description: string;
suggestion: string;
time: string;
}
export interface CycleAnalysis {
period: 'm5' | 'm15' | 'm30' | 'm60';
trend: TrendDirection;
trendStrength: number;
klineData: KLineData[];
volumeData: { time: string; value: number }[];
macdData: MACDData[];
keyLevels: {
resistance: number[];
support: number[];
};
}

@ -0,0 +1,77 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
revolut: {
dark: '#191c1f',
white: '#ffffff',
surface: '#f4f4f4',
blue: '#494fdf',
actionBlue: '#4f55f1',
blueText: '#376cd5',
danger: '#e23b4a',
deepPink: '#e61e49',
warning: '#ec7e00',
yellow: '#b09000',
teal: '#00a87e',
lightGreen: '#428619',
greenText: '#006400',
lightBlue: '#007bc2',
brown: '#936d62',
redText: '#8b0000',
midSlate: '#505a63',
coolGray: '#8d969e',
grayTone: '#c9c9cd',
},
},
fontFamily: {
display: ['Aeonik Pro', 'Inter', 'Arial', 'sans-serif'],
body: ['Inter', 'Arial', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'SF Mono', 'monospace'],
},
fontSize: {
'display-mega': ['8.50rem', { lineHeight: '1.00', letterSpacing: '-2.72px', fontWeight: '500' }],
'display-hero': ['5.00rem', { lineHeight: '1.00', letterSpacing: '-0.8px', fontWeight: '500' }],
'section': ['3.00rem', { lineHeight: '1.21', letterSpacing: '-0.48px', fontWeight: '500' }],
'subheading': ['2.50rem', { lineHeight: '1.20', letterSpacing: '-0.4px', fontWeight: '500' }],
'card-title': ['2.00rem', { lineHeight: '1.19', letterSpacing: '-0.32px', fontWeight: '500' }],
'feature': ['1.50rem', { lineHeight: '1.33', letterSpacing: '0', fontWeight: '400' }],
'nav': ['1.25rem', { lineHeight: '1.40', letterSpacing: '0', fontWeight: '500' }],
'body-lg': ['1.13rem', { lineHeight: '1.56', letterSpacing: '-0.09px', fontWeight: '400' }],
'body': ['1.00rem', { lineHeight: '1.50', letterSpacing: '0.24px', fontWeight: '400' }],
'body-semibold': ['1.00rem', { lineHeight: '1.50', letterSpacing: '0.16px', fontWeight: '600' }],
'body-bold': ['1.00rem', { lineHeight: '1.50', letterSpacing: '0.24px', fontWeight: '700' }],
},
borderRadius: {
'pill': '9999px',
'card': '20px',
'standard': '12px',
},
spacing: {
'4': '4px',
'6': '6px',
'8': '8px',
'14': '14px',
'16': '16px',
'20': '20px',
'24': '24px',
'32': '32px',
'40': '40px',
'48': '48px',
'80': '80px',
'88': '88px',
'120': '120px',
},
screens: {
'mobile-sm': '400px',
'mobile': '720px',
'tablet': '1024px',
'desktop': '1280px',
'large': '1920px',
},
},
},
plugins: [],
}

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

@ -0,0 +1,21 @@
import path from "path"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
export default defineConfig({
base: './',
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
rollupOptions: {
external: [],
},
commonjsOptions: {
esmExternals: true,
},
},
});

@ -0,0 +1,185 @@
# Design System Inspired by Revolut
## 1. Visual Theme & Atmosphere
Revolut's website is fintech confidence distilled into pixels — a design system that communicates "your money is in capable hands" through massive typography, generous whitespace, and a disciplined neutral palette. The visual language is built on Aeonik Pro, a geometric grotesque that creates billboard-scale headlines at 136px with weight 500 and aggressive negative tracking (-2.72px). This isn't subtle branding; it's fintech at stadium scale.
The color system is built on a comprehensive `--rui-*` (Revolut UI) token architecture with semantic naming for every state: danger (`#e23b4a`), warning (`#ec7e00`), teal (`#00a87e`), blue (`#494fdf`), deep-pink (`#e61e49`), and more. But the marketing surface itself is remarkably restrained — near-black (`#191c1f`) and pure white (`#ffffff`) dominate, with the colorful semantic tokens reserved for the product interface, not the marketing page.
What distinguishes Revolut is its pill-everything button system. Every button uses 9999px radius — primary dark (`#191c1f`), secondary light (`#f4f4f4`), outlined (`transparent + 2px solid`), and ghost on dark (`rgba(244,244,244,0.1) + 2px solid`). The padding is generous (14px 32px34px), creating large, confident touch targets. Combined with Inter for body text at various weights and positive letter-spacing (0.16px0.24px), the result is a design that feels both premium and accessible — banking for the modern era.
**Key Characteristics:**
- Aeonik Pro display at 136px weight 500 — billboard-scale fintech headlines
- Near-black (`#191c1f`) + white binary with comprehensive `--rui-*` semantic tokens
- Universal pill buttons (9999px radius) with generous padding (14px 32px)
- Inter for body text with positive letter-spacing (0.16px0.24px)
- Rich semantic color system: blue, teal, pink, yellow, green, brown, danger, warning
- Zero shadows detected — depth through color contrast only
- Tight display line-heights (1.00) with relaxed body (1.501.56)
## 2. Color Palette & Roles
### Primary
- **Revolut Dark** (`#191c1f`): Primary dark surface, button background, near-black text
- **Pure White** (`#ffffff`): `--rui-color-action-label`, primary light surface
- **Light Surface** (`#f4f4f4`): Secondary button background, subtle surface
### Brand / Interactive
- **Revolut Blue** (`#494fdf`): `--rui-color-blue`, primary brand blue
- **Action Blue** (`#4f55f1`): `--rui-color-action-photo-header-text`, header accent
- **Blue Text** (`#376cd5`): `--website-color-blue-text`, link blue
### Semantic
- **Danger Red** (`#e23b4a`): `--rui-color-danger`, error/destructive
- **Deep Pink** (`#e61e49`): `--rui-color-deep-pink`, critical accent
- **Warning Orange** (`#ec7e00`): `--rui-color-warning`, warning states
- **Yellow** (`#b09000`): `--rui-color-yellow`, attention
- **Teal** (`#00a87e`): `--rui-color-teal`, success/positive
- **Light Green** (`#428619`): `--rui-color-light-green`, secondary success
- **Green Text** (`#006400`): `--website-color-green-text`, green text
- **Light Blue** (`#007bc2`): `--rui-color-light-blue`, informational
- **Brown** (`#936d62`): `--rui-color-brown`, warm neutral accent
- **Red Text** (`#8b0000`): `--website-color-red-text`, dark red text
### Neutral Scale
- **Mid Slate** (`#505a63`): Secondary text
- **Cool Gray** (`#8d969e`): Muted text, tertiary
- **Gray Tone** (`#c9c9cd`): `--rui-color-grey-tone-20`, borders/dividers
## 3. Typography Rules
### Font Families
- **Display**: `Aeonik Pro` — geometric grotesque, no detected fallbacks
- **Body / UI**: `Inter` — standard system sans
- **Fallback**: `Arial` for specific button contexts
### Hierarchy
| Role | Font | Size | Weight | Line Height | Letter Spacing | Notes |
|------|------|------|--------|-------------|----------------|-------|
| Display Mega | Aeonik Pro | 136px (8.50rem) | 500 | 1.00 (tight) | -2.72px | Stadium-scale hero |
| Display Hero | Aeonik Pro | 80px (5.00rem) | 500 | 1.00 (tight) | -0.8px | Primary hero |
| Section Heading | Aeonik Pro | 48px (3.00rem) | 500 | 1.21 (tight) | -0.48px | Feature sections |
| Sub-heading | Aeonik Pro | 40px (2.50rem) | 500 | 1.20 (tight) | -0.4px | Sub-sections |
| Card Title | Aeonik Pro | 32px (2.00rem) | 500 | 1.19 (tight) | -0.32px | Card headings |
| Feature Title | Aeonik Pro | 24px (1.50rem) | 400 | 1.33 | normal | Light headings |
| Nav / UI | Aeonik Pro | 20px (1.25rem) | 500 | 1.40 | normal | Navigation, buttons |
| Body Large | Inter | 18px (1.13rem) | 400 | 1.56 | -0.09px | Introductions |
| Body | Inter | 16px (1.00rem) | 400 | 1.50 | 0.24px | Standard reading |
| Body Semibold | Inter | 16px (1.00rem) | 600 | 1.50 | 0.16px | Emphasized body |
| Body Bold Link | Inter | 16px (1.00rem) | 700 | 1.50 | 0.24px | Bold links |
### Principles
- **Weight 500 as display default**: Aeonik Pro uses medium (500) for ALL headings — no bold. This creates authority through size and tracking, not weight.
- **Billboard tracking**: -2.72px at 136px is extremely compressed — text designed to be read at a glance, like airport signage.
- **Positive tracking on body**: Inter uses +0.16px to +0.24px, creating airy, well-spaced reading text that contrasts with the compressed headings.
## 4. Component Stylings
### Buttons
**Primary Dark Pill**
- Background: `#191c1f`
- Text: `#ffffff`
- Padding: 14px 32px
- Radius: 9999px (full pill)
- Hover: opacity 0.85
- Focus: `0 0 0 0.125rem` ring
**Secondary Light Pill**
- Background: `#f4f4f4`
- Text: `#000000`
- Padding: 14px 34px
- Radius: 9999px
- Hover: opacity 0.85
**Outlined Pill**
- Background: transparent
- Text: `#191c1f`
- Border: `2px solid #191c1f`
- Padding: 14px 32px
- Radius: 9999px
**Ghost on Dark**
- Background: `rgba(244, 244, 244, 0.1)`
- Text: `#f4f4f4`
- Border: `2px solid #f4f4f4`
- Padding: 14px 32px
- Radius: 9999px
### Cards & Containers
- Radius: 12px (small), 20px (cards)
- No shadows — flat surfaces with color contrast
- Dark and light section alternation
### Navigation
- Aeonik Pro 20px weight 500
- Clean header, hamburger toggle at 12px radius
- Pill CTAs right-aligned
## 5. Layout Principles
### Spacing System
- Base unit: 8px
- Scale: 4px, 6px, 8px, 14px, 16px, 20px, 24px, 32px, 40px, 48px, 80px, 88px, 120px
- Large section spacing: 80px120px
### Border Radius Scale
- Standard (12px): Navigation, small buttons
- Card (20px): Feature cards
- Pill (9999px): All buttons
## 6. Depth & Elevation
| Level | Treatment | Use |
|-------|-----------|-----|
| Flat (Level 0) | No shadow | Everything — Revolut uses zero shadows |
| Focus | `0 0 0 0.125rem` ring | Accessibility focus |
**Shadow Philosophy**: Revolut uses ZERO shadows. Depth comes entirely from the dark/light section contrast and the generous whitespace between elements.
## 7. Do's and Don'ts
### Do
- Use Aeonik Pro weight 500 for all display headings
- Apply 9999px radius to all buttons — pill shape is universal
- Use generous button padding (14px 32px)
- Keep the palette to near-black + white for marketing surfaces
- Apply positive letter-spacing on Inter body text
### Don't
- Don't use shadows — Revolut is flat by design
- Don't use bold (700) for Aeonik Pro headings — 500 is the weight
- Don't use small buttons — the generous padding is intentional
- Don't apply semantic colors to marketing surfaces — they're for the product
## 8. Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|------|-------|-------------|
| Mobile Small | <400px | Compact, single column |
| Mobile | 400720px | Standard mobile |
| Tablet | 7201024px | 2-column layouts |
| Desktop | 10241280px | Standard desktop |
| Large | 12801920px | Full layout |
## 9. Agent Prompt Guide
### Quick Color Reference
- Dark: Revolut Dark (`#191c1f`)
- Light: White (`#ffffff`)
- Surface: Light (`#f4f4f4`)
- Blue: Revolut Blue (`#494fdf`)
- Danger: Red (`#e23b4a`)
- Success: Teal (`#00a87e`)
### Example Component Prompts
- "Create a hero: white background. Headline at 136px Aeonik Pro weight 500, line-height 1.00, letter-spacing -2.72px, #191c1f text. Dark pill CTA (#191c1f, 9999px, 14px 32px). Outlined pill secondary (transparent, 2px solid #191c1f)."
- "Build a pill button: #191c1f background, white text, 9999px radius, 14px 32px padding, 20px Aeonik Pro weight 500. Hover: opacity 0.85."
### Iteration Guide
1. Aeonik Pro 500 for headings — never bold
2. All buttons are pills (9999px) with generous padding
3. Zero shadows — flat is the Revolut identity
4. Near-black + white for marketing, semantic colors for product
Loading…
Cancel
Save