parent
a8a84c00da
commit
f665480226
@ -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
|
||||
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,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,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,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,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,
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in new issue