Compare commits
170 Commits
f05daa3a78
...
origin_pro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e015901ea | ||
| 2a122b0013 | |||
| 663d73609a | |||
| 389a45fc0a | |||
| 67c7fa49e8 | |||
| a3810499cc | |||
| 83c6abdfba | |||
| dcc88251df | |||
|
|
6271736969 | ||
|
|
319a78d34c | ||
|
|
8799964961 | ||
|
|
42808501b0 | ||
|
|
291362b88d | ||
|
|
f5328ec3a1 | ||
|
|
52cf950b21 | ||
|
|
f9b580c871 | ||
|
|
8b25d5d91c | ||
|
|
c6b3b56cb8 | ||
|
|
42f1b2f24e | ||
|
|
935c933cb8 | ||
|
|
f4b58b42cc | ||
|
|
5ff8db8899 | ||
|
|
116594d9b1 | ||
|
|
ca5adb3ad2 | ||
|
|
8eaaef1666 | ||
|
|
ebb737427f | ||
|
|
31e5a4ee48 | ||
|
|
273ff5f72d | ||
|
|
a5e001d975 | ||
|
|
c5d6247f49 | ||
|
|
ad933e9fb2 | ||
|
|
adf6fc7780 | ||
|
|
6930878ff6 | ||
|
|
ed24a14fbf | ||
|
|
25a6ff164b | ||
|
|
612b58c983 | ||
|
|
27b68e928e | ||
|
|
e6ffb0dc74 | ||
|
|
2355004dfb | ||
|
|
c5dcb4897d | ||
|
|
dc0c8e2c60 | ||
|
|
2e89469d05 | ||
|
|
e617eddd46 | ||
|
|
22186eb54a | ||
|
|
c3ef837221 | ||
|
|
870b1f5996 | ||
|
|
bc2a3b71c0 | ||
|
|
ff7b8abe9d | ||
|
|
cb44c18e57 | ||
|
|
623ec73c62 | ||
|
|
4c08ef57ff | ||
|
|
ca52d3bd87 | ||
|
|
62ae2e0803 | ||
|
|
7e781731c4 | ||
|
|
0765f8a800 | ||
|
|
70dbf3b492 | ||
|
|
aa1a93c65b | ||
|
|
f9e4265dd6 | ||
| 1361a2b5b2 | |||
|
|
263ecd77b3 | ||
|
|
b6862aff4f | ||
|
|
327cfc09e2 | ||
|
|
f5d340aa05 | ||
|
|
0da18e868a | ||
|
|
0f7693939a | ||
|
|
becd0268a6 | ||
|
|
8bd7801753 | ||
|
|
d4c731730f | ||
|
|
fe9b3034a1 | ||
|
|
ea0428321b | ||
|
|
d95bd51206 | ||
|
|
69d4b8bae0 | ||
|
|
bf89c0e13e | ||
|
|
4e7fcaad5c | ||
|
|
41baf16d45 | ||
|
|
c5b8fe91c3 | ||
|
|
f919ce255a | ||
|
|
64de7d055b | ||
|
|
b223be2f01 | ||
|
|
188783a8d2 | ||
|
|
d7f27e428b | ||
|
|
f9387ffbd9 | ||
|
|
be0c53b588 | ||
|
|
de1b31c70e | ||
|
|
d96ebd6b8c | ||
|
|
67127aa615 | ||
|
|
e7c495a8b1 | ||
|
|
e0cfa6fab2 | ||
|
|
c51d3811e5 | ||
|
|
8fe13c9fa4 | ||
|
|
e6c422887c | ||
|
|
7e110111c4 | ||
|
|
38d1b51af3 | ||
|
|
c7334191e5 | ||
|
|
7fdc9e26af | ||
|
|
7f01a391e0 | ||
|
|
58db08ca22 | ||
|
|
bf75f9b387 | ||
|
|
2a59e9edb2 | ||
|
|
87476226c3 | ||
|
|
76360102bb | ||
|
|
1a3987afe0 | ||
|
|
a512f3bd7e | ||
|
|
ffa6c2f761 | ||
|
|
64a441b717 | ||
|
|
5b9155a30c | ||
|
|
6e5eaa9089 | ||
| 1ed54d7ee0 | |||
|
|
8ed65b062b | ||
|
|
868b4ccebc | ||
|
|
67981f21a2 | ||
|
|
0a10270ab0 | ||
|
|
ce46820105 | ||
|
|
012c13c49a | ||
|
|
0e9a0d9123 | ||
| 4f163af846 | |||
|
|
ce495ed6fa | ||
|
|
0e66bb471f | ||
|
|
82cb0b4034 | ||
|
|
78e7001372 | ||
|
|
26ad017d32 | ||
|
|
fea0bc3bbe | ||
|
|
f17a8fbd87 | ||
|
|
6a0a8e8e2b | ||
|
|
8ebfad9992 | ||
|
|
c208ba36b7 | ||
|
|
b14eb175f5 | ||
| 0d84ffe87f | |||
|
|
b95607e9b4 | ||
|
|
462933f4af | ||
|
|
26dcfd061c | ||
|
|
7e32dda2df | ||
|
|
9274323151 | ||
|
|
cedfd3978d | ||
|
|
89fe0cd10b | ||
|
|
d027071e98 | ||
|
|
e31e4118a0 | ||
|
|
5611c06991 | ||
|
|
784202025c | ||
|
|
daf7372bab | ||
|
|
7291777488 | ||
|
|
92d6751529 | ||
|
|
95134d526d | ||
|
|
cc2777ae20 | ||
|
|
39a2ccd53b | ||
|
|
6160edf060 | ||
|
|
bdea4209b2 | ||
|
|
6cde2175db | ||
|
|
f432d72151 | ||
|
|
befa68cc51 | ||
|
|
7ae4bc418f | ||
|
|
0110dc2fdc | ||
|
|
e7e2b3bb11 | ||
|
|
e22a39c5cd | ||
|
|
3b8b749eb1 | ||
|
|
571d5e68bc | ||
|
|
933932b86d | ||
|
|
fc251ede05 | ||
|
|
57c4c3c959 | ||
|
|
e1e82555bf | ||
|
|
b44a0ccd39 | ||
|
|
2d936ca1c7 | ||
|
|
14db374820 | ||
|
|
db472620f3 | ||
|
|
37d98203a3 | ||
|
|
2420ff45a4 | ||
|
|
adaebbf800 | ||
|
|
9fd9fcb731 | ||
|
|
c372832f1f | ||
|
|
5d8ad5e442 |
42
.env.production
Normal file
42
.env.production
Normal file
@@ -0,0 +1,42 @@
|
||||
# ========================================
|
||||
# 生产环境配置
|
||||
# ========================================
|
||||
# 使用方式: npm run build
|
||||
#
|
||||
# 工作原理:
|
||||
# 1. 此文件专门用于生产环境构建
|
||||
# 2. 构建时会将环境变量嵌入到打包文件中
|
||||
# 3. 确保 PostHog 等服务使用正确的生产配置
|
||||
# ========================================
|
||||
|
||||
# 环境标识
|
||||
REACT_APP_ENV=production
|
||||
NODE_ENV=production
|
||||
|
||||
# Mock 配置(生产环境禁用 Mock)
|
||||
REACT_APP_ENABLE_MOCK=false
|
||||
|
||||
# 后端 API 地址(生产环境)
|
||||
REACT_APP_API_URL=http://49.232.185.254:5001
|
||||
|
||||
# PostHog 分析配置(生产环境)
|
||||
# PostHog API Key(从 PostHog 项目设置中获取)
|
||||
REACT_APP_POSTHOG_KEY=phc_xKlRyG69Bx7hgOdFeCeLUvQWvSjw18ZKFgCwCeYezWF
|
||||
# PostHog API Host(使用 PostHog Cloud)
|
||||
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
||||
# 启用会话录制(Session Recording)用于回放用户操作、排查问题
|
||||
REACT_APP_ENABLE_SESSION_RECORDING=true
|
||||
|
||||
# React 构建优化配置
|
||||
# 禁用 source map 生成(生产环境不需要,提升打包速度和安全性)
|
||||
GENERATE_SOURCEMAP=false
|
||||
# 跳过预检查(加快启动速度)
|
||||
SKIP_PREFLIGHT_CHECK=true
|
||||
# 禁用 ESLint 检查(生产构建时不需要)
|
||||
DISABLE_ESLINT_PLUGIN=true
|
||||
# TypeScript 编译错误时继续
|
||||
TSC_COMPILE_ON_ERROR=true
|
||||
# 图片内联大小限制
|
||||
IMAGE_INLINE_SIZE_LIMIT=10000
|
||||
# Node.js 内存限制(适用于大型项目)
|
||||
NODE_OPTIONS=--max_old_space_size=4096
|
||||
329
CLAUDE.md
329
CLAUDE.md
@@ -4,88 +4,307 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a hybrid React dashboard application with a Flask/Python backend. The project is built on the Argon Dashboard Chakra PRO template and includes financial/trading analysis features.
|
||||
Hybrid React dashboard for financial/trading analysis with Flask backend. Built on Argon Dashboard Chakra PRO template.
|
||||
|
||||
### Frontend (React + Chakra UI)
|
||||
- **Framework**: React 18.3.1 with Chakra UI 2.8.2
|
||||
- **Styling**: Tailwind CSS + custom Chakra theme
|
||||
- **Build Tool**: React Scripts with custom Gulp tasks
|
||||
- **Charts**: ApexCharts, ECharts, and custom visualization components
|
||||
### Tech Stack
|
||||
|
||||
### Backend (Flask/Python)
|
||||
- **Framework**: Flask with SQLAlchemy ORM
|
||||
- **Database**: ClickHouse for analytics + MySQL/PostgreSQL
|
||||
- **Features**: Real-time data processing, trading analysis, user authentication
|
||||
- **Task Queue**: Celery for background processing
|
||||
**Frontend**
|
||||
- React 18.3.1 + Chakra UI 2.8.2 + Ant Design
|
||||
- Redux Toolkit for state management
|
||||
- React Router v6 with React.lazy() code splitting
|
||||
- CRACO build system with aggressive webpack optimization
|
||||
- Charts: ApexCharts, ECharts, Recharts, D3
|
||||
- Additional: Three.js, FullCalendar, Leaflet maps
|
||||
|
||||
**Backend**
|
||||
- Flask + SQLAlchemy ORM
|
||||
- ClickHouse (analytics) + MySQL/PostgreSQL (transactional)
|
||||
- Flask-SocketIO for WebSocket real-time updates
|
||||
- Celery + Redis for background jobs
|
||||
- Tencent Cloud SMS + WeChat Pay integration
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Frontend Development
|
||||
```bash
|
||||
npm start # Start development server (port 3000, proxies to localhost:5001)
|
||||
npm run build # Production build with license headers
|
||||
npm test # Run React test suite
|
||||
npm run lint:check # Check ESLint rules
|
||||
npm run lint:fix # Auto-fix ESLint issues
|
||||
npm run install:clean # Clean install (removes node_modules and package-lock)
|
||||
npm start # Start with mock data (.env.mock), proxies to localhost:5001
|
||||
npm run start:real # Start with real backend (.env.local)
|
||||
npm run start:dev # Start with development config (.env.development)
|
||||
npm run start:test # Starts both backend (app_2.py) and frontend (.env.test) concurrently
|
||||
npm run dev # Alias for 'npm start'
|
||||
npm run backend # Start Flask server only (python app_2.py)
|
||||
|
||||
npm run build # Production build with Gulp license headers
|
||||
npm run build:analyze # Build with webpack bundle analyzer
|
||||
npm test # Run React test suite with CRACO
|
||||
|
||||
npm run lint:check # Check ESLint rules (exits 0)
|
||||
npm run lint:fix # Auto-fix ESLint issues
|
||||
npm run clean # Remove node_modules and package-lock.json
|
||||
npm run reinstall # Clean install (runs clean + install)
|
||||
```
|
||||
|
||||
### Backend Development
|
||||
```bash
|
||||
python app_2.py # Start Flask server (main backend)
|
||||
python simulation_background_processor.py # Background data processor
|
||||
python app.py # Main Flask server
|
||||
python simulation_background_processor.py # Background task processor for trading simulations
|
||||
pip install -r requirements.txt # Install Python dependencies
|
||||
```
|
||||
|
||||
### Python Dependencies
|
||||
Install from requirements.txt:
|
||||
### Deployment
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
npm run deploy # Deploy from local (scripts/deploy-from-local.sh)
|
||||
npm run rollback # Rollback to previous version
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frontend Structure
|
||||
- `src/layouts/` - Main layout components (Admin, Auth, Home)
|
||||
- `src/views/` - Page components organized by feature (Dashboard, Company, Community, etc.)
|
||||
- `src/components/` - Reusable UI components (Charts, Cards, Buttons, etc.)
|
||||
- `src/theme/` - Chakra UI theme customization
|
||||
- `src/routes.js` - Application routing configuration
|
||||
- `src/contexts/` - React context providers
|
||||
- `src/services/` - API service layer
|
||||
### Application Entry Flow
|
||||
```
|
||||
src/index.js
|
||||
└── src/App.js (root component)
|
||||
├── AppProviders (src/providers/AppProviders.js)
|
||||
│ ├── ReduxProvider (store from src/store/)
|
||||
│ ├── ChakraProvider (theme from src/theme/)
|
||||
│ ├── NotificationProvider (src/contexts/NotificationContext.js)
|
||||
│ └── AuthProvider (src/contexts/AuthContext.js)
|
||||
├── AppRoutes (src/routes/index.js)
|
||||
│ ├── MainLayout routes (with navbar/footer)
|
||||
│ └── Standalone routes (auth pages, fullscreen views)
|
||||
└── GlobalComponents (modal overlays, global UI)
|
||||
```
|
||||
|
||||
### Routing Architecture (Modular Design)
|
||||
Routing is **declarative** and split across multiple files in `src/routes/`:
|
||||
|
||||
- **index.js** - Main router (combines config + renders routes)
|
||||
- **routeConfig.js** - Route definitions (path, component, protection, layout, children)
|
||||
- **lazy-components.js** - React.lazy() imports for code splitting
|
||||
- **homeRoutes.js** - Nested home page routes
|
||||
- **constants/** - Protection modes, layout mappings
|
||||
- **utils/** - Route rendering logic (wrapWithProtection, renderRoute)
|
||||
|
||||
Route protection modes (PROTECTION_MODES):
|
||||
- `PUBLIC` - No authentication required
|
||||
- `MODAL` - Shows auth modal if not logged in
|
||||
- `REDIRECT` - Redirects to /auth/sign-in if not logged in
|
||||
|
||||
### Frontend Directory Structure
|
||||
```
|
||||
src/
|
||||
├── App.js - Root component (providers + routing)
|
||||
├── providers/ - Provider composition (AppProviders.js)
|
||||
├── routes/ - Modular routing system (see above)
|
||||
├── layouts/ - Page layouts (MainLayout, Auth)
|
||||
├── views/ - Page components (Community, TradingSimulation, etc.)
|
||||
├── components/ - Reusable UI components
|
||||
├── contexts/ - React contexts (Auth, Notification, Sidebar)
|
||||
├── store/ - Redux store + slices (auth, posthog, stock, industry, etc.)
|
||||
├── services/ - API layer (axios wrappers)
|
||||
├── utils/ - Utility functions (apiConfig, priceFormatters, logger)
|
||||
├── constants/ - App constants (animations, etc.)
|
||||
├── hooks/ - Custom React hooks
|
||||
├── theme/ - Chakra UI theme customization
|
||||
└── mocks/ - MSW handlers for development
|
||||
├── handlers/ - Request handlers by domain (auth, stock, company, etc.)
|
||||
├── data/ - Mock data files
|
||||
└── browser.js - MSW setup (starts when REACT_APP_ENABLE_MOCK=true)
|
||||
```
|
||||
|
||||
### Backend Structure
|
||||
- `app_2.py` - Main Flask application with routes and business logic
|
||||
- `simulation_background_processor.py` - Background data processing service
|
||||
- `wechat_pay.py` / `wechat_pay_config.py` - Payment integration
|
||||
- `tdays.csv` - Trading days data
|
||||
```
|
||||
app.py - Flask server (routes, auth, business logic)
|
||||
simulation_background_processor.py - Celery worker for trading simulations
|
||||
concept_api.py - Concept/industry analysis API
|
||||
wechat_pay.py / wechat_pay_config.py - WeChat payment integration
|
||||
tdays.csv - Trading days calendar (loaded at startup)
|
||||
requirements.txt - Python dependencies
|
||||
```
|
||||
|
||||
### Key Integrations
|
||||
- ClickHouse for high-performance analytics queries
|
||||
- Celery + Redis for background task processing
|
||||
- Flask-SocketIO for real-time data updates
|
||||
- Tencent Cloud services (SMS, etc.)
|
||||
- WeChat Pay integration
|
||||
### State Management Strategy
|
||||
- **Redux Toolkit**: Global state (auth modal, posthog, stock data, industry data, subscriptions, community data)
|
||||
- **React Context**: Cross-cutting concerns (AuthContext, NotificationContext, SidebarContext)
|
||||
- **Component State**: Local UI state (forms, toggles, etc.)
|
||||
|
||||
### Real-time Updates
|
||||
- Flask-SocketIO for WebSocket connections
|
||||
- Example: Community event notifications push via WebSocket
|
||||
- Client: `socket.io-client` library
|
||||
|
||||
## Configuration
|
||||
|
||||
### Proxy Setup
|
||||
The React dev server proxies API calls to `http://localhost:5001` (see package.json).
|
||||
|
||||
### Environment Files
|
||||
- `.env` - Environment variables for both frontend and backend
|
||||
```
|
||||
.env.mock - Mock mode (default): MSW intercepts all API calls, no backend needed
|
||||
.env.development - Dev mode: Connects to dev backend
|
||||
.env.test - Test mode: Used by 'npm run start:test' (backend + frontend together)
|
||||
.env.production - Production build config
|
||||
```
|
||||
|
||||
**Key environment variables:**
|
||||
- `REACT_APP_ENABLE_MOCK=true` - Enable MSW mocking
|
||||
- `REACT_APP_API_URL` - Backend URL (empty string = use relative paths or MSW)
|
||||
|
||||
### MSW (Mock Service Worker) Setup
|
||||
MSW is used for API mocking during development:
|
||||
|
||||
1. **Activation**: Set `REACT_APP_ENABLE_MOCK=true` in env file
|
||||
2. **Worker file**: `public/mockServiceWorker.js` (auto-generated)
|
||||
3. **Handlers**: `src/mocks/handlers/` (organized by domain: auth, stock, company, etc.)
|
||||
4. **Mode**: `onUnhandledRequest: 'warn'` - unhandled requests pass through to backend
|
||||
|
||||
When MSW is active, the dev server proxy is disabled (MSW intercepts first).
|
||||
|
||||
### Path Aliases (craco.config.js)
|
||||
All aliases resolve to `src/` subdirectories:
|
||||
```
|
||||
@/ → src/
|
||||
@components/ → src/components/
|
||||
@views/ → src/views/
|
||||
@assets/ → src/assets/
|
||||
@contexts/ → src/contexts/
|
||||
@layouts/ → src/layouts/
|
||||
@services/ → src/services/
|
||||
@store/ → src/store/
|
||||
@utils/ → src/utils/
|
||||
@hooks/ → src/hooks/
|
||||
@theme/ → src/theme/
|
||||
@mocks/ → src/mocks/
|
||||
@constants/ → src/constants/
|
||||
```
|
||||
|
||||
### Webpack Optimizations (craco.config.js)
|
||||
**Performance features:**
|
||||
- Filesystem cache (50-80% rebuild speedup)
|
||||
- Aggressive code splitting by library:
|
||||
- `react-vendor` - React core (priority 30)
|
||||
- `charts-lib` - echarts, d3, apexcharts, recharts (priority 25)
|
||||
- `chakra-ui` - Chakra UI + Emotion (priority 23)
|
||||
- `antd-lib` - Ant Design (priority 22)
|
||||
- `three-lib` - Three.js (priority 20)
|
||||
- `calendar-lib` - moment, date-fns, FullCalendar (priority 18)
|
||||
- ESLint plugin removed from build (20-30% speedup)
|
||||
- Babel caching enabled
|
||||
- moment locale stripping (IgnorePlugin)
|
||||
- Source maps: disabled in production, `eval-cheap-module-source-map` in dev
|
||||
|
||||
**Dev server:**
|
||||
- Port 3000 (kills existing process on prestart)
|
||||
- Proxy (when MSW disabled): `/api` → `http://49.232.185.254:5001`
|
||||
- Bundle analyzer: `ANALYZE=true npm run build:analyze`
|
||||
|
||||
### Build Process
|
||||
The build process includes custom Gulp tasks that add Creative Tim license headers to JS, CSS, and HTML files.
|
||||
1. `npm run build` compiles with CRACO + webpack optimizations
|
||||
2. Gulp task (`gulp licenses`) adds Creative Tim license headers to JS/HTML
|
||||
3. Output: `build/` directory
|
||||
|
||||
### Styling Architecture
|
||||
- Tailwind CSS for utility classes
|
||||
- Custom Chakra UI theme with extended color palette
|
||||
- Component-specific SCSS files in `src/assets/scss/`
|
||||
**Node compatibility:**
|
||||
```bash
|
||||
NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096'
|
||||
```
|
||||
|
||||
## Testing
|
||||
- React Testing Library setup for frontend components
|
||||
- Test command: `npm test`
|
||||
## Development Workflow
|
||||
|
||||
## Deployment
|
||||
- Build: `npm run build`
|
||||
- Deploy: `npm run deploy` (builds the project)
|
||||
### Working with Routes
|
||||
To add a new route:
|
||||
|
||||
1. Add lazy import in `src/routes/lazy-components.js`
|
||||
2. Add route config in `src/routes/routeConfig.js` with:
|
||||
- `path` - URL path
|
||||
- `component` - From lazyComponents
|
||||
- `protection` - MODAL/REDIRECT/PUBLIC
|
||||
- `layout` - 'main' (with nav) or 'none' (fullscreen)
|
||||
3. Routes automatically render with Suspense + ErrorBoundary (handled by PageTransitionWrapper)
|
||||
|
||||
### Component Organization Patterns
|
||||
Based on recent refactoring (see README.md for details):
|
||||
|
||||
**Atomic Design Pattern:**
|
||||
- **Atoms** - Basic UI elements (buttons, badges, inputs)
|
||||
- **Molecules** - Combinations of atoms (cards, forms)
|
||||
- **Organisms** - Complex components (lists, panels)
|
||||
|
||||
**Example structure for large components (1000+ lines):**
|
||||
```
|
||||
src/views/Community/components/
|
||||
├── EventCard/
|
||||
│ ├── index.js - Smart wrapper (routes compact vs detailed)
|
||||
│ ├── CompactEventCard.js - Compact view
|
||||
│ ├── DetailedEventCard.js - Detailed view
|
||||
│ ├── EventTimeline.js - Atomic component
|
||||
│ ├── EventImportanceBadge.js - Atomic component
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
**Utility extraction:**
|
||||
- Extract reusable logic to `src/utils/` (e.g., `priceFormatters.js`)
|
||||
- Extract shared constants to `src/constants/` (e.g., `animations.js`)
|
||||
|
||||
### API Integration
|
||||
**Service layer** (`src/services/`):
|
||||
- Use `getApiBase()` from `src/utils/apiConfig.js` for base URL
|
||||
- Example: `${getApiBase()}/api/endpoint`
|
||||
- In mock mode, MSW intercepts; in dev/prod, hits backend
|
||||
|
||||
**Adding new API endpoints:**
|
||||
1. Add service function in `src/services/` (or inline in component)
|
||||
2. If using MSW: Add handler in `src/mocks/handlers/{domain}.js`
|
||||
3. Import handler in `src/mocks/handlers/index.js`
|
||||
|
||||
### Redux State Management
|
||||
**Existing slices** (`src/store/slices/`):
|
||||
- `authModalSlice` - Auth modal state
|
||||
- `posthogSlice` - PostHog analytics
|
||||
- `stockSlice` - Stock data
|
||||
- `industrySlice` - Industry/concept data
|
||||
- `subscriptionSlice` - User subscriptions
|
||||
- `communityDataSlice` - Community content
|
||||
|
||||
**Adding new slice:**
|
||||
1. Create `src/store/slices/yourSlice.js`
|
||||
2. Import and add to `src/store/index.js`
|
||||
3. Access via `useSelector`, dispatch via `useDispatch`
|
||||
|
||||
## Backend Architecture
|
||||
|
||||
### Flask Application (app.py)
|
||||
- **Authentication**: Flask-Login + session management
|
||||
- **Database**: SQLAlchemy models + ClickHouse client
|
||||
- **Background jobs**: Celery tasks for async processing
|
||||
- **Real-time**: Flask-SocketIO for WebSocket events
|
||||
- **Trading days**: `tdays.csv` loaded into memory at startup (global `trading_days` variable)
|
||||
|
||||
### Key Backend Patterns
|
||||
- **ClickHouse**: Used for high-volume analytics queries (stock data, time series)
|
||||
- **MySQL/PostgreSQL**: Used for transactional data (users, orders, subscriptions)
|
||||
- **Celery**: Background processor runs in separate process (`simulation_background_processor.py`)
|
||||
- **CORS**: Enabled for frontend communication
|
||||
|
||||
### API Proxy Configuration
|
||||
When not in mock mode, frontend proxies to backend:
|
||||
- `/api` → `http://49.232.185.254:5001` (main API)
|
||||
- `/concept-api` → `http://49.232.185.254:6801` (concept analysis API)
|
||||
|
||||
## Important Notes
|
||||
|
||||
### Code Splitting Strategy
|
||||
Heavy pages are lazy-loaded to reduce initial bundle size:
|
||||
- Community, TradingSimulation, Company pages use `React.lazy()`
|
||||
- Webpack splits large libraries into separate chunks
|
||||
- Check bundle size with `npm run build:analyze`
|
||||
|
||||
### Error Boundaries
|
||||
- **Layout-level**: Each layout (MainLayout, Auth) has its own ErrorBoundary
|
||||
- **Page-level**: PageTransitionWrapper wraps each route with ErrorBoundary
|
||||
- **Strategy**: Errors are isolated to prevent entire app crashes
|
||||
|
||||
### PostHog Analytics
|
||||
- Initialized in Redux (`posthogSlice`)
|
||||
- Configured during app startup in `App.js`
|
||||
- Used for user behavior tracking
|
||||
|
||||
### Performance Considerations
|
||||
- **Large components**: If component exceeds ~500 lines, consider refactoring (see README.md)
|
||||
- **Re-renders**: Use `React.memo`, `useMemo`, `useCallback` for expensive operations
|
||||
- **Bundle size**: Monitor with webpack-bundle-analyzer
|
||||
- **Caching**: Webpack filesystem cache speeds up rebuilds significantly
|
||||
Binary file not shown.
610
app.py
610
app.py
@@ -706,11 +706,38 @@ class SubscriptionPlan(db.Model):
|
||||
monthly_price = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
yearly_price = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
features = db.Column(db.Text, nullable=True)
|
||||
pricing_options = db.Column(db.Text, nullable=True) # JSON格式:[{"months": 1, "price": 99}, {"months": 12, "price": 999}]
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
sort_order = db.Column(db.Integer, default=0)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
def to_dict(self):
|
||||
# 解析pricing_options(如果存在)
|
||||
pricing_opts = None
|
||||
if self.pricing_options:
|
||||
try:
|
||||
pricing_opts = json.loads(self.pricing_options)
|
||||
except:
|
||||
pricing_opts = None
|
||||
|
||||
# 如果没有pricing_options,则从monthly_price和yearly_price生成默认选项
|
||||
if not pricing_opts:
|
||||
pricing_opts = [
|
||||
{
|
||||
'months': 1,
|
||||
'price': float(self.monthly_price) if self.monthly_price else 0,
|
||||
'label': '月付',
|
||||
'cycle_key': 'monthly'
|
||||
},
|
||||
{
|
||||
'months': 12,
|
||||
'price': float(self.yearly_price) if self.yearly_price else 0,
|
||||
'label': '年付',
|
||||
'cycle_key': 'yearly',
|
||||
'discount_percent': 20 # 年付默认20%折扣
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
@@ -718,6 +745,7 @@ class SubscriptionPlan(db.Model):
|
||||
'description': self.description,
|
||||
'monthly_price': float(self.monthly_price) if self.monthly_price else 0,
|
||||
'yearly_price': float(self.yearly_price) if self.yearly_price else 0,
|
||||
'pricing_options': pricing_opts, # 新增:灵活计费周期选项
|
||||
'features': json.loads(self.features) if self.features else [],
|
||||
'is_active': self.is_active,
|
||||
'sort_order': self.sort_order
|
||||
@@ -776,6 +804,10 @@ class PaymentOrder(db.Model):
|
||||
'plan_name': self.plan_name,
|
||||
'billing_cycle': self.billing_cycle,
|
||||
'amount': float(self.amount) if self.amount else 0,
|
||||
'original_amount': float(self.original_amount) if hasattr(self, 'original_amount') and self.original_amount else None,
|
||||
'discount_amount': float(self.discount_amount) if hasattr(self, 'discount_amount') and self.discount_amount else 0,
|
||||
'promo_code': self.promo_code.code if hasattr(self, 'promo_code') and self.promo_code else None,
|
||||
'is_upgrade': self.is_upgrade if hasattr(self, 'is_upgrade') else False,
|
||||
'qr_code_url': self.qr_code_url,
|
||||
'status': self.status,
|
||||
'is_expired': self.is_expired(),
|
||||
@@ -786,6 +818,107 @@ class PaymentOrder(db.Model):
|
||||
}
|
||||
|
||||
|
||||
class PromoCode(db.Model):
|
||||
"""优惠码表"""
|
||||
__tablename__ = 'promo_codes'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
code = db.Column(db.String(50), unique=True, nullable=False, index=True)
|
||||
description = db.Column(db.String(200), nullable=True)
|
||||
|
||||
# 折扣类型和值
|
||||
discount_type = db.Column(db.String(20), nullable=False) # 'percentage' 或 'fixed_amount'
|
||||
discount_value = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
|
||||
# 适用范围
|
||||
applicable_plans = db.Column(db.String(200), nullable=True) # JSON格式
|
||||
applicable_cycles = db.Column(db.String(50), nullable=True) # JSON格式
|
||||
min_amount = db.Column(db.Numeric(10, 2), nullable=True)
|
||||
|
||||
# 使用限制
|
||||
max_uses = db.Column(db.Integer, nullable=True)
|
||||
max_uses_per_user = db.Column(db.Integer, default=1)
|
||||
current_uses = db.Column(db.Integer, default=0)
|
||||
|
||||
# 有效期
|
||||
valid_from = db.Column(db.DateTime, nullable=False)
|
||||
valid_until = db.Column(db.DateTime, nullable=False)
|
||||
|
||||
# 状态
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
created_by = db.Column(db.Integer, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'code': self.code,
|
||||
'description': self.description,
|
||||
'discount_type': self.discount_type,
|
||||
'discount_value': float(self.discount_value) if self.discount_value else 0,
|
||||
'applicable_plans': json.loads(self.applicable_plans) if self.applicable_plans else None,
|
||||
'applicable_cycles': json.loads(self.applicable_cycles) if self.applicable_cycles else None,
|
||||
'min_amount': float(self.min_amount) if self.min_amount else None,
|
||||
'max_uses': self.max_uses,
|
||||
'max_uses_per_user': self.max_uses_per_user,
|
||||
'current_uses': self.current_uses,
|
||||
'valid_from': self.valid_from.isoformat() if self.valid_from else None,
|
||||
'valid_until': self.valid_until.isoformat() if self.valid_until else None,
|
||||
'is_active': self.is_active
|
||||
}
|
||||
|
||||
|
||||
class PromoCodeUsage(db.Model):
|
||||
"""优惠码使用记录表"""
|
||||
__tablename__ = 'promo_code_usage'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
promo_code_id = db.Column(db.Integer, db.ForeignKey('promo_codes.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, nullable=False, index=True)
|
||||
order_id = db.Column(db.Integer, db.ForeignKey('payment_orders.id'), nullable=False)
|
||||
|
||||
original_amount = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
discount_amount = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
final_amount = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
|
||||
used_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
# 关系
|
||||
promo_code = db.relationship('PromoCode', backref='usages')
|
||||
order = db.relationship('PaymentOrder', backref='promo_usage')
|
||||
|
||||
|
||||
class SubscriptionUpgrade(db.Model):
|
||||
"""订阅升级/降级记录表"""
|
||||
__tablename__ = 'subscription_upgrades'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
user_id = db.Column(db.Integer, nullable=False, index=True)
|
||||
order_id = db.Column(db.Integer, db.ForeignKey('payment_orders.id'), nullable=False)
|
||||
|
||||
# 原订阅信息
|
||||
from_plan = db.Column(db.String(20), nullable=False)
|
||||
from_cycle = db.Column(db.String(10), nullable=False)
|
||||
from_end_date = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# 新订阅信息
|
||||
to_plan = db.Column(db.String(20), nullable=False)
|
||||
to_cycle = db.Column(db.String(10), nullable=False)
|
||||
to_end_date = db.Column(db.DateTime, nullable=False)
|
||||
|
||||
# 价格计算
|
||||
remaining_value = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
upgrade_amount = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
actual_amount = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
|
||||
upgrade_type = db.Column(db.String(20), nullable=False) # 'plan_upgrade', 'cycle_change', 'both'
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
# 关系
|
||||
order = db.relationship('PaymentOrder', backref='upgrade_record')
|
||||
|
||||
|
||||
# ============================================
|
||||
# 模拟盘相关模型
|
||||
# ============================================
|
||||
@@ -982,8 +1115,15 @@ def get_user_subscription_safe(user_id):
|
||||
return DefaultSub()
|
||||
|
||||
|
||||
def activate_user_subscription(user_id, plan_type, billing_cycle):
|
||||
"""激活用户订阅"""
|
||||
def activate_user_subscription(user_id, plan_type, billing_cycle, extend_from_now=False):
|
||||
"""激活用户订阅
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
plan_type: 套餐类型
|
||||
billing_cycle: 计费周期
|
||||
extend_from_now: 是否从当前时间开始延长(用于升级场景)
|
||||
"""
|
||||
try:
|
||||
subscription = UserSubscription.query.filter_by(user_id=user_id).first()
|
||||
if not subscription:
|
||||
@@ -993,7 +1133,9 @@ def activate_user_subscription(user_id, plan_type, billing_cycle):
|
||||
subscription.subscription_type = plan_type
|
||||
subscription.subscription_status = 'active'
|
||||
subscription.billing_cycle = billing_cycle
|
||||
subscription.start_date = beijing_now()
|
||||
|
||||
if not extend_from_now or not subscription.start_date:
|
||||
subscription.start_date = beijing_now()
|
||||
|
||||
if billing_cycle == 'monthly':
|
||||
subscription.end_date = beijing_now() + timedelta(days=30)
|
||||
@@ -1007,6 +1149,195 @@ def activate_user_subscription(user_id, plan_type, billing_cycle):
|
||||
return None
|
||||
|
||||
|
||||
def validate_promo_code(code, plan_name, billing_cycle, amount, user_id):
|
||||
"""验证优惠码
|
||||
|
||||
Returns:
|
||||
tuple: (promo_code_obj, error_message)
|
||||
"""
|
||||
try:
|
||||
promo = PromoCode.query.filter_by(code=code.upper(), is_active=True).first()
|
||||
|
||||
if not promo:
|
||||
return None, "优惠码不存在或已失效"
|
||||
|
||||
# 检查有效期
|
||||
now = beijing_now()
|
||||
if now < promo.valid_from:
|
||||
return None, "优惠码尚未生效"
|
||||
if now > promo.valid_until:
|
||||
return None, "优惠码已过期"
|
||||
|
||||
# 检查使用次数
|
||||
if promo.max_uses and promo.current_uses >= promo.max_uses:
|
||||
return None, "优惠码已被使用完"
|
||||
|
||||
# 检查每用户使用次数
|
||||
if promo.max_uses_per_user:
|
||||
user_usage_count = PromoCodeUsage.query.filter_by(
|
||||
promo_code_id=promo.id,
|
||||
user_id=user_id
|
||||
).count()
|
||||
if user_usage_count >= promo.max_uses_per_user:
|
||||
return None, f"您已使用过此优惠码(限用{promo.max_uses_per_user}次)"
|
||||
|
||||
# 检查适用套餐
|
||||
if promo.applicable_plans:
|
||||
try:
|
||||
applicable = json.loads(promo.applicable_plans)
|
||||
if plan_name not in applicable:
|
||||
return None, "该优惠码不适用于此套餐"
|
||||
except:
|
||||
pass
|
||||
|
||||
# 检查适用周期
|
||||
if promo.applicable_cycles:
|
||||
try:
|
||||
applicable = json.loads(promo.applicable_cycles)
|
||||
if billing_cycle not in applicable:
|
||||
return None, "该优惠码不适用于此计费周期"
|
||||
except:
|
||||
pass
|
||||
|
||||
# 检查最低消费
|
||||
if promo.min_amount and amount < float(promo.min_amount):
|
||||
return None, f"需满{float(promo.min_amount):.2f}元才可使用此优惠码"
|
||||
|
||||
return promo, None
|
||||
except Exception as e:
|
||||
return None, f"验证优惠码时出错: {str(e)}"
|
||||
|
||||
|
||||
def calculate_discount(promo_code, amount):
|
||||
"""计算优惠金额"""
|
||||
try:
|
||||
if promo_code.discount_type == 'percentage':
|
||||
discount = amount * (float(promo_code.discount_value) / 100)
|
||||
else: # fixed_amount
|
||||
discount = float(promo_code.discount_value)
|
||||
|
||||
# 确保折扣不超过总金额
|
||||
return min(discount, amount)
|
||||
except:
|
||||
return 0
|
||||
|
||||
|
||||
def calculate_remaining_value(subscription, current_plan):
|
||||
"""计算当前订阅的剩余价值"""
|
||||
try:
|
||||
if not subscription or not subscription.end_date:
|
||||
return 0
|
||||
|
||||
now = beijing_now()
|
||||
if subscription.end_date <= now:
|
||||
return 0
|
||||
|
||||
days_left = (subscription.end_date - now).days
|
||||
|
||||
if subscription.billing_cycle == 'monthly':
|
||||
daily_value = float(current_plan.monthly_price) / 30
|
||||
else: # yearly
|
||||
daily_value = float(current_plan.yearly_price) / 365
|
||||
|
||||
return daily_value * days_left
|
||||
except:
|
||||
return 0
|
||||
|
||||
|
||||
def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None):
|
||||
"""计算升级所需价格
|
||||
|
||||
Returns:
|
||||
dict: 包含价格计算结果的字典
|
||||
"""
|
||||
try:
|
||||
# 1. 获取当前订阅
|
||||
current_sub = UserSubscription.query.filter_by(user_id=user_id).first()
|
||||
|
||||
# 2. 获取目标套餐
|
||||
to_plan = SubscriptionPlan.query.filter_by(name=to_plan_name, is_active=True).first()
|
||||
if not to_plan:
|
||||
return {'error': '目标套餐不存在'}
|
||||
|
||||
# 3. 计算目标套餐价格
|
||||
new_price = float(to_plan.yearly_price if to_cycle == 'yearly' else to_plan.monthly_price)
|
||||
|
||||
# 4. 如果是新订阅(非升级)
|
||||
if not current_sub or current_sub.subscription_type == 'free':
|
||||
result = {
|
||||
'is_upgrade': False,
|
||||
'new_plan_price': new_price,
|
||||
'remaining_value': 0,
|
||||
'upgrade_amount': new_price,
|
||||
'original_amount': new_price,
|
||||
'discount_amount': 0,
|
||||
'final_amount': new_price,
|
||||
'promo_code': None
|
||||
}
|
||||
|
||||
# 应用优惠码
|
||||
if promo_code:
|
||||
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, new_price, user_id)
|
||||
if promo:
|
||||
discount = calculate_discount(promo, new_price)
|
||||
result['discount_amount'] = discount
|
||||
result['final_amount'] = new_price - discount
|
||||
result['promo_code'] = promo.code
|
||||
elif error:
|
||||
result['promo_error'] = error
|
||||
|
||||
return result
|
||||
|
||||
# 5. 升级场景:计算剩余价值
|
||||
current_plan = SubscriptionPlan.query.filter_by(name=current_sub.subscription_type, is_active=True).first()
|
||||
if not current_plan:
|
||||
return {'error': '当前套餐信息不存在'}
|
||||
|
||||
remaining_value = calculate_remaining_value(current_sub, current_plan)
|
||||
|
||||
# 6. 计算升级差价
|
||||
upgrade_amount = max(0, new_price - remaining_value)
|
||||
|
||||
# 7. 判断升级类型
|
||||
upgrade_type = 'new'
|
||||
if current_sub.subscription_type != to_plan_name and current_sub.billing_cycle != to_cycle:
|
||||
upgrade_type = 'both'
|
||||
elif current_sub.subscription_type != to_plan_name:
|
||||
upgrade_type = 'plan_upgrade'
|
||||
elif current_sub.billing_cycle != to_cycle:
|
||||
upgrade_type = 'cycle_change'
|
||||
|
||||
result = {
|
||||
'is_upgrade': True,
|
||||
'upgrade_type': upgrade_type,
|
||||
'current_plan': current_sub.subscription_type,
|
||||
'current_cycle': current_sub.billing_cycle,
|
||||
'current_end_date': current_sub.end_date.isoformat() if current_sub.end_date else None,
|
||||
'new_plan_price': new_price,
|
||||
'remaining_value': remaining_value,
|
||||
'upgrade_amount': upgrade_amount,
|
||||
'original_amount': upgrade_amount,
|
||||
'discount_amount': 0,
|
||||
'final_amount': upgrade_amount,
|
||||
'promo_code': None
|
||||
}
|
||||
|
||||
# 8. 应用优惠码
|
||||
if promo_code and upgrade_amount > 0:
|
||||
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, upgrade_amount, user_id)
|
||||
if promo:
|
||||
discount = calculate_discount(promo, upgrade_amount)
|
||||
result['discount_amount'] = discount
|
||||
result['final_amount'] = upgrade_amount - discount
|
||||
result['promo_code'] = promo.code
|
||||
elif error:
|
||||
result['promo_error'] = error
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def initialize_subscription_plans_safe():
|
||||
"""安全地初始化订阅套餐"""
|
||||
try:
|
||||
@@ -1112,16 +1443,24 @@ def get_subscription_plans():
|
||||
'data': [plan.to_dict() for plan in plans]
|
||||
})
|
||||
except Exception as e:
|
||||
# 返回默认套餐
|
||||
# 返回默认套餐(包含pricing_options以兼容新前端)
|
||||
default_plans = [
|
||||
{
|
||||
'id': 1,
|
||||
'name': 'pro',
|
||||
'display_name': 'Pro版本',
|
||||
'description': '适合个人投资者的基础功能套餐',
|
||||
'monthly_price': 0.01,
|
||||
'yearly_price': 0.08,
|
||||
'features': ['基础股票分析工具', '历史数据查询', '基础财务报表'],
|
||||
'monthly_price': 198,
|
||||
'yearly_price': 2000,
|
||||
'pricing_options': [
|
||||
{'months': 1, 'price': 198, 'label': '月付', 'cycle_key': 'monthly'},
|
||||
{'months': 3, 'price': 534, 'label': '3个月', 'cycle_key': '3months', 'discount_percent': 10},
|
||||
{'months': 6, 'price': 950, 'label': '半年', 'cycle_key': '6months', 'discount_percent': 20},
|
||||
{'months': 12, 'price': 2000, 'label': '1年', 'cycle_key': 'yearly', 'discount_percent': 16},
|
||||
{'months': 24, 'price': 3600, 'label': '2年', 'cycle_key': '2years', 'discount_percent': 24},
|
||||
{'months': 36, 'price': 5040, 'label': '3年', 'cycle_key': '3years', 'discount_percent': 29}
|
||||
],
|
||||
'features': ['基础股票分析工具', '历史数据查询', '基础财务报表', '简单投资计划记录', '标准客服支持'],
|
||||
'is_active': True,
|
||||
'sort_order': 1
|
||||
},
|
||||
@@ -1130,9 +1469,17 @@ def get_subscription_plans():
|
||||
'name': 'max',
|
||||
'display_name': 'Max版本',
|
||||
'description': '适合专业投资者的全功能套餐',
|
||||
'monthly_price': 0.1,
|
||||
'yearly_price': 0.8,
|
||||
'features': ['全部Pro版本功能', '高级分析工具', '实时数据推送'],
|
||||
'monthly_price': 998,
|
||||
'yearly_price': 10000,
|
||||
'pricing_options': [
|
||||
{'months': 1, 'price': 998, 'label': '月付', 'cycle_key': 'monthly'},
|
||||
{'months': 3, 'price': 2695, 'label': '3个月', 'cycle_key': '3months', 'discount_percent': 10},
|
||||
{'months': 6, 'price': 4790, 'label': '半年', 'cycle_key': '6months', 'discount_percent': 20},
|
||||
{'months': 12, 'price': 10000, 'label': '1年', 'cycle_key': 'yearly', 'discount_percent': 17},
|
||||
{'months': 24, 'price': 18000, 'label': '2年', 'cycle_key': '2years', 'discount_percent': 25},
|
||||
{'months': 36, 'price': 25200, 'label': '3年', 'cycle_key': '3years', 'discount_percent': 30}
|
||||
],
|
||||
'features': ['全部Pro版本功能', '高级分析工具', '实时数据推送', 'API访问', '优先客服支持'],
|
||||
'is_active': True,
|
||||
'sort_order': 2
|
||||
}
|
||||
@@ -1189,9 +1536,90 @@ def get_subscription_info():
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/promo-code/validate', methods=['POST'])
|
||||
def validate_promo_code_api():
|
||||
"""验证优惠码"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
|
||||
data = request.get_json()
|
||||
code = data.get('code', '').strip()
|
||||
plan_name = data.get('plan_name')
|
||||
billing_cycle = data.get('billing_cycle')
|
||||
amount = data.get('amount', 0)
|
||||
|
||||
if not code or not plan_name or not billing_cycle:
|
||||
return jsonify({'success': False, 'error': '参数不完整'}), 400
|
||||
|
||||
# 验证优惠码
|
||||
promo, error = validate_promo_code(code, plan_name, billing_cycle, amount, session['user_id'])
|
||||
|
||||
if error:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'valid': False,
|
||||
'error': error
|
||||
})
|
||||
|
||||
# 计算折扣
|
||||
discount_amount = calculate_discount(promo, amount)
|
||||
final_amount = amount - discount_amount
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'valid': True,
|
||||
'promo_code': promo.to_dict(),
|
||||
'discount_amount': discount_amount,
|
||||
'final_amount': final_amount
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'验证失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/subscription/calculate-price', methods=['POST'])
|
||||
def calculate_subscription_price():
|
||||
"""计算订阅价格(支持升级和优惠码)"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
|
||||
data = request.get_json()
|
||||
to_plan = data.get('to_plan')
|
||||
to_cycle = data.get('to_cycle')
|
||||
promo_code = data.get('promo_code', '').strip() or None
|
||||
|
||||
if not to_plan or not to_cycle:
|
||||
return jsonify({'success': False, 'error': '参数不完整'}), 400
|
||||
|
||||
# 计算价格
|
||||
result = calculate_upgrade_price(session['user_id'], to_plan, to_cycle, promo_code)
|
||||
|
||||
if 'error' in result:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': result['error']
|
||||
}), 400
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': result
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'计算失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/payment/create-order', methods=['POST'])
|
||||
def create_payment_order():
|
||||
"""创建支付订单"""
|
||||
"""创建支付订单(支持升级和优惠码)"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
@@ -1199,23 +1627,21 @@ def create_payment_order():
|
||||
data = request.get_json()
|
||||
plan_name = data.get('plan_name')
|
||||
billing_cycle = data.get('billing_cycle')
|
||||
promo_code = data.get('promo_code', '').strip() or None
|
||||
|
||||
if not plan_name or not billing_cycle:
|
||||
return jsonify({'success': False, 'error': '参数不完整'}), 400
|
||||
|
||||
# 获取套餐信息
|
||||
try:
|
||||
plan = SubscriptionPlan.query.filter_by(name=plan_name, is_active=True).first()
|
||||
if not plan:
|
||||
# 如果表不存在,使用默认价格
|
||||
prices = {'pro': {'monthly': 0.01, 'yearly': 0.08}, 'max': {'monthly': 0.1, 'yearly': 0.8}}
|
||||
amount = prices.get(plan_name, {}).get(billing_cycle, 0.01)
|
||||
else:
|
||||
amount = plan.monthly_price if billing_cycle == 'monthly' else plan.yearly_price
|
||||
except:
|
||||
# 默认价格
|
||||
prices = {'pro': {'monthly': 0.01, 'yearly': 0.08}, 'max': {'monthly': 0.1, 'yearly': 0.8}}
|
||||
amount = prices.get(plan_name, {}).get(billing_cycle, 0.01)
|
||||
# 计算价格(包括升级和优惠码)
|
||||
price_result = calculate_upgrade_price(session['user_id'], plan_name, billing_cycle, promo_code)
|
||||
|
||||
if 'error' in price_result:
|
||||
return jsonify({'success': False, 'error': price_result['error']}), 400
|
||||
|
||||
amount = price_result['final_amount']
|
||||
original_amount = price_result['original_amount']
|
||||
discount_amount = price_result['discount_amount']
|
||||
is_upgrade = price_result.get('is_upgrade', False)
|
||||
|
||||
# 创建订单
|
||||
try:
|
||||
@@ -1225,10 +1651,52 @@ def create_payment_order():
|
||||
billing_cycle=billing_cycle,
|
||||
amount=amount
|
||||
)
|
||||
|
||||
# 添加扩展字段(使用动态属性)
|
||||
if hasattr(order, 'original_amount') or True: # 兼容性检查
|
||||
order.original_amount = original_amount
|
||||
order.discount_amount = discount_amount
|
||||
order.is_upgrade = is_upgrade
|
||||
|
||||
# 如果使用了优惠码,关联优惠码
|
||||
if promo_code and price_result.get('promo_code'):
|
||||
promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first()
|
||||
if promo_obj:
|
||||
order.promo_code_id = promo_obj.id
|
||||
|
||||
# 如果是升级,记录原套餐信息
|
||||
if is_upgrade:
|
||||
order.upgrade_from_plan = price_result.get('current_plan')
|
||||
|
||||
db.session.add(order)
|
||||
db.session.commit()
|
||||
|
||||
# 如果是升级订单,创建升级记录
|
||||
if is_upgrade and price_result.get('upgrade_type'):
|
||||
try:
|
||||
upgrade_record = SubscriptionUpgrade(
|
||||
user_id=session['user_id'],
|
||||
order_id=order.id,
|
||||
from_plan=price_result['current_plan'],
|
||||
from_cycle=price_result['current_cycle'],
|
||||
from_end_date=datetime.fromisoformat(price_result['current_end_date']) if price_result.get('current_end_date') else None,
|
||||
to_plan=plan_name,
|
||||
to_cycle=billing_cycle,
|
||||
to_end_date=beijing_now() + timedelta(days=365 if billing_cycle == 'yearly' else 30),
|
||||
remaining_value=price_result['remaining_value'],
|
||||
upgrade_amount=price_result['upgrade_amount'],
|
||||
actual_amount=amount,
|
||||
upgrade_type=price_result['upgrade_type']
|
||||
)
|
||||
db.session.add(upgrade_record)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
print(f"创建升级记录失败: {e}")
|
||||
# 不影响主流程
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': '订单创建失败'}), 500
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': f'订单创建失败: {str(e)}'}), 500
|
||||
|
||||
# 尝试调用真实的微信支付API
|
||||
try:
|
||||
@@ -1420,6 +1888,26 @@ def force_update_order_status(order_id):
|
||||
# 激活用户订阅
|
||||
activate_user_subscription(order.user_id, order.plan_name, order.billing_cycle)
|
||||
|
||||
# 记录优惠码使用(如果使用了优惠码)
|
||||
if hasattr(order, 'promo_code_id') and order.promo_code_id:
|
||||
try:
|
||||
promo_usage = PromoCodeUsage(
|
||||
promo_code_id=order.promo_code_id,
|
||||
user_id=order.user_id,
|
||||
order_id=order.id,
|
||||
original_amount=order.original_amount if hasattr(order, 'original_amount') else order.amount,
|
||||
discount_amount=order.discount_amount if hasattr(order, 'discount_amount') else 0,
|
||||
final_amount=order.amount
|
||||
)
|
||||
db.session.add(promo_usage)
|
||||
|
||||
# 更新优惠码使用次数
|
||||
promo = PromoCode.query.get(order.promo_code_id)
|
||||
if promo:
|
||||
promo.current_uses = (promo.current_uses or 0) + 1
|
||||
except Exception as e:
|
||||
print(f"记录优惠码使用失败: {e}")
|
||||
|
||||
db.session.commit()
|
||||
|
||||
print(f"✅ 订单状态强制更新成功: {old_status} -> paid")
|
||||
@@ -2602,13 +3090,9 @@ def get_wechat_qrcode():
|
||||
# 生成唯一state参数
|
||||
state = uuid.uuid4().hex
|
||||
|
||||
print(f"🆕 [QRCODE] 生成新的微信二维码, state={state[:8]}...")
|
||||
|
||||
# URL编码回调地址
|
||||
redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI)
|
||||
|
||||
print(f"🔗 [QRCODE] 回调地址: {WECHAT_REDIRECT_URI}")
|
||||
|
||||
# 构建微信授权URL
|
||||
wechat_auth_url = (
|
||||
f"https://open.weixin.qq.com/connect/qrconnect?"
|
||||
@@ -2626,8 +3110,6 @@ def get_wechat_qrcode():
|
||||
'wechat_unionid': None
|
||||
}
|
||||
|
||||
print(f"✅ [QRCODE] session 已存储, 当前总数: {len(wechat_qr_sessions)}")
|
||||
|
||||
return jsonify({"code":0,
|
||||
"data":
|
||||
{
|
||||
@@ -2691,8 +3173,6 @@ def check_wechat_scan():
|
||||
del wechat_qr_sessions[session_id]
|
||||
return jsonify({'status': 'expired'}), 200
|
||||
|
||||
print(f"📡 [CHECK] session_id: {session_id[:8]}..., status: {session['status']}, user_info: {session.get('user_info')}")
|
||||
|
||||
return jsonify({
|
||||
'status': session['status'],
|
||||
'user_info': session.get('user_info'),
|
||||
@@ -2751,17 +3231,12 @@ def wechat_callback():
|
||||
|
||||
# 验证state
|
||||
if state not in wechat_qr_sessions:
|
||||
print(f"❌ [CALLBACK] state 不在 wechat_qr_sessions 中: {state[:8]}...")
|
||||
print(f" 当前 sessions: {list(wechat_qr_sessions.keys())}")
|
||||
return redirect('/auth/signin?error=session_expired')
|
||||
|
||||
session_data = wechat_qr_sessions[state]
|
||||
|
||||
print(f"✅ [CALLBACK] 找到 session_data, mode={session_data.get('mode')}")
|
||||
|
||||
# 检查过期
|
||||
if time.time() > session_data['expires']:
|
||||
print(f"❌ [CALLBACK] session 已过期")
|
||||
del wechat_qr_sessions[state]
|
||||
return redirect('/auth/signin?error=session_expired')
|
||||
|
||||
@@ -2790,8 +3265,6 @@ def wechat_callback():
|
||||
print(f"❌ 获取微信用户信息失败: openid={token_data['openid']}")
|
||||
return redirect('/auth/signin?error=userinfo_failed')
|
||||
|
||||
print(f"✅ [CALLBACK] 获取用户信息成功, nickname={user_info.get('nickname', 'N/A')}")
|
||||
|
||||
# 查找或创建用户 / 或处理绑定
|
||||
openid = token_data['openid']
|
||||
unionid = user_info.get('unionid') or token_data.get('unionid')
|
||||
@@ -2842,8 +3315,7 @@ def wechat_callback():
|
||||
user = User.query.filter_by(wechat_open_id=openid).first()
|
||||
|
||||
if not user:
|
||||
# 创建新用户(自动注册)
|
||||
is_new_user = True
|
||||
# 创建新用户
|
||||
# 先清理微信昵称
|
||||
raw_nickname = user_info.get('nickname', '微信用户')
|
||||
# 创建临时用户实例以使用清理方法
|
||||
@@ -2893,22 +3365,8 @@ def wechat_callback():
|
||||
session_item['user_info'] = {'user_id': user.id}
|
||||
print(f"✅ 微信扫码状态已更新: {session_item['status']}, user_id: {user.id}")
|
||||
|
||||
# 返回一个简单的成功页面(前端轮询会检测到状态变化)
|
||||
return '''
|
||||
<html>
|
||||
<head><title>授权成功</title></head>
|
||||
<body>
|
||||
<h2>微信授权成功</h2>
|
||||
<p>请返回原页面继续操作...</p>
|
||||
<script>
|
||||
// 尝试关闭窗口(如果是弹窗的话)
|
||||
setTimeout(function() {
|
||||
window.close();
|
||||
}, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
''', 200
|
||||
# 直接跳转到首页
|
||||
return redirect('/home')
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 微信登录失败: {e}")
|
||||
@@ -2934,16 +3392,16 @@ def login_with_wechat():
|
||||
return jsonify({'success': False, 'error': 'session_id不能为空'}), 400
|
||||
|
||||
# 验证session
|
||||
wechat_session = wechat_qr_sessions.get(session_id)
|
||||
if not wechat_session:
|
||||
session = wechat_qr_sessions.get(session_id)
|
||||
if not session:
|
||||
return jsonify({'success': False, 'error': '会话不存在或已过期'}), 400
|
||||
|
||||
# 检查session状态
|
||||
if wechat_session['status'] not in ['login_success', 'register_success']:
|
||||
if session['status'] not in ['login_ready', 'register_ready']:
|
||||
return jsonify({'success': False, 'error': '会话状态无效'}), 400
|
||||
|
||||
# 检查是否有用户信息
|
||||
user_info = wechat_session.get('user_info')
|
||||
user_info = session.get('user_info')
|
||||
if not user_info or not user_info.get('user_id'):
|
||||
return jsonify({'success': False, 'error': '用户信息不完整'}), 400
|
||||
|
||||
@@ -2955,33 +3413,18 @@ def login_with_wechat():
|
||||
# 更新最后登录时间
|
||||
user.update_last_seen()
|
||||
|
||||
# 设置 Flask session
|
||||
session.permanent = True
|
||||
session['user_id'] = user.id
|
||||
session['username'] = user.username
|
||||
session['logged_in'] = True
|
||||
session['wechat_login'] = True # 标记是微信登录
|
||||
|
||||
# Flask-Login 登录
|
||||
login_user(user, remember=True)
|
||||
|
||||
# 判断是否为新用户
|
||||
is_new_user = user_info.get('is_new_user', False)
|
||||
|
||||
# 清除 wechat_qr_sessions
|
||||
# 清除session
|
||||
del wechat_qr_sessions[session_id]
|
||||
|
||||
# 生成登录响应
|
||||
response_data = {
|
||||
'success': True,
|
||||
'message': '注册成功' if is_new_user else '登录成功',
|
||||
'isNewUser': is_new_user,
|
||||
'message': '登录成功' if session['status'] == 'login_ready' else '注册并登录成功',
|
||||
'user': {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'nickname': user.nickname or user.username,
|
||||
'email': user.email,
|
||||
'phone': user.phone,
|
||||
'avatar_url': user.avatar_url,
|
||||
'has_wechat': True,
|
||||
'wechat_open_id': user.wechat_open_id,
|
||||
@@ -6644,8 +7087,15 @@ def api_get_events():
|
||||
query = query.filter_by(status=event_status)
|
||||
if event_type != 'all':
|
||||
query = query.filter_by(event_type=event_type)
|
||||
# 支持多个重要性级别筛选,用逗号分隔(如 importance=S,A)
|
||||
if importance != 'all':
|
||||
query = query.filter_by(importance=importance)
|
||||
if ',' in importance:
|
||||
# 多个重要性级别
|
||||
importance_list = [imp.strip() for imp in importance.split(',') if imp.strip()]
|
||||
query = query.filter(Event.importance.in_(importance_list))
|
||||
else:
|
||||
# 单个重要性级别
|
||||
query = query.filter_by(importance=importance)
|
||||
if creator_id:
|
||||
query = query.filter_by(creator_id=creator_id)
|
||||
# 新增:行业代码过滤(申银万国行业分类)
|
||||
|
||||
@@ -2,6 +2,9 @@ const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const { BundleAnalyzerPlugin } = process.env.ANALYZE ? require('webpack-bundle-analyzer') : { BundleAnalyzerPlugin: null };
|
||||
|
||||
// 检查是否为 Mock 模式(与 src/utils/apiConfig.js 保持一致)
|
||||
const isMockMode = () => process.env.REACT_APP_ENABLE_MOCK === 'true';
|
||||
|
||||
module.exports = {
|
||||
webpack: {
|
||||
configure: (webpackConfig, { env, paths }) => {
|
||||
@@ -27,7 +30,7 @@ module.exports = {
|
||||
chunks: 'all',
|
||||
maxInitialRequests: 30,
|
||||
minSize: 20000,
|
||||
maxSize: 244000, // 限制单个 chunk 最大大小(约 244KB)
|
||||
maxSize: 512000, // 限制单个 chunk 最大大小(512KB,与 performance.maxAssetSize 一致)
|
||||
cacheGroups: {
|
||||
// React 核心库单独分离
|
||||
react: {
|
||||
@@ -47,7 +50,7 @@ module.exports = {
|
||||
chakraUI: {
|
||||
test: /[\\/]node_modules[\\/](@chakra-ui|@emotion)[\\/]/,
|
||||
name: 'chakra-ui',
|
||||
priority: 22,
|
||||
priority: 23, // 从 22 改为 23,避免与 antd 优先级冲突
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
// Ant Design
|
||||
@@ -236,14 +239,31 @@ module.exports = {
|
||||
devMiddleware: {
|
||||
writeToDisk: false,
|
||||
},
|
||||
// 代理配置:将 /api 请求代理到后端服务器
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://49.232.185.254:5001',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
logLevel: 'debug',
|
||||
},
|
||||
|
||||
// 调试日志
|
||||
onListening: (devServer) => {
|
||||
console.log(`[CRACO] Mock Mode: ${isMockMode() ? 'Enabled ✅' : 'Disabled ❌'}`);
|
||||
console.log(`[CRACO] Proxy: ${isMockMode() ? 'Disabled (MSW intercepts)' : 'Enabled (forwarding to backend)'}`);
|
||||
},
|
||||
|
||||
// 代理配置:将 /api 请求代理到后端服务器
|
||||
// 注意:Mock 模式下禁用 proxy,让 MSW 拦截请求
|
||||
...(isMockMode() ? {} : {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://49.232.185.254:5001',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
logLevel: 'debug',
|
||||
},
|
||||
'/concept-api': {
|
||||
target: 'http://49.232.185.254:6801',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
logLevel: 'debug',
|
||||
pathRewrite: { '^/concept-api': '' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
59
database_migration_add_pricing_options.sql
Normal file
59
database_migration_add_pricing_options.sql
Normal file
@@ -0,0 +1,59 @@
|
||||
-- 数据库迁移:添加 pricing_options 字段,支持灵活的计费周期
|
||||
-- 执行时间:2025-01-XX
|
||||
-- 说明:支持用户选择"包N个月"或"包N年"的套餐
|
||||
|
||||
-- 1. 添加 pricing_options 字段
|
||||
ALTER TABLE subscription_plans
|
||||
ADD COLUMN pricing_options TEXT NULL COMMENT 'JSON格式的计费周期选项';
|
||||
|
||||
-- 2. 为Pro套餐配置多种计费周期(基于现有价格:198元/月,2000元/年)
|
||||
UPDATE subscription_plans
|
||||
SET pricing_options = '[
|
||||
{"months": 1, "price": 198, "label": "月付", "cycle_key": "monthly"},
|
||||
{"months": 3, "price": 534, "label": "3个月", "cycle_key": "3months", "discount_percent": 10},
|
||||
{"months": 6, "price": 950, "label": "半年", "cycle_key": "6months", "discount_percent": 20},
|
||||
{"months": 12, "price": 2000, "label": "1年", "cycle_key": "yearly", "discount_percent": 16},
|
||||
{"months": 24, "price": 3600, "label": "2年", "cycle_key": "2years", "discount_percent": 24},
|
||||
{"months": 36, "price": 5040, "label": "3年", "cycle_key": "3years", "discount_percent": 29}
|
||||
]'
|
||||
WHERE name = 'pro';
|
||||
|
||||
-- 3. 为Max套餐配置多种计费周期(基于现有价格:998元/月,10000元/年)
|
||||
UPDATE subscription_plans
|
||||
SET pricing_options = '[
|
||||
{"months": 1, "price": 998, "label": "月付", "cycle_key": "monthly"},
|
||||
{"months": 3, "price": 2695, "label": "3个月", "cycle_key": "3months", "discount_percent": 10},
|
||||
{"months": 6, "price": 4790, "label": "半年", "cycle_key": "6months", "discount_percent": 20},
|
||||
{"months": 12, "price": 10000, "label": "1年", "cycle_key": "yearly", "discount_percent": 17},
|
||||
{"months": 24, "price": 18000, "label": "2年", "cycle_key": "2years", "discount_percent": 25},
|
||||
{"months": 36, "price": 25200, "label": "3年", "cycle_key": "3years", "discount_percent": 30}
|
||||
]'
|
||||
WHERE name = 'max';
|
||||
|
||||
-- ========================================
|
||||
-- 价格计算说明
|
||||
-- ========================================
|
||||
-- Pro版(198元/月,2000元/年):
|
||||
-- - 月付:198元
|
||||
-- - 3个月:198×3×0.9 = 534元(打9折,省10%)
|
||||
-- - 半年:198×6×0.8 = 950元(打8折,省20%)
|
||||
-- - 1年:2000元(已有年付价格,约省16%)
|
||||
-- - 2年:198×24×0.76 = 3600元(省24%)
|
||||
-- - 3年:198×36×0.7 = 5040元(省30%)
|
||||
|
||||
-- Max版(998元/月,10000元/年):
|
||||
-- - 月付:998元
|
||||
-- - 3个月:998×3×0.9 = 2695元(打9折,省10%)
|
||||
-- - 半年:998×6×0.8 = 4790元(打8折,省20%)
|
||||
-- - 1年:10000元(已有年付价格,约省17%)
|
||||
-- - 2年:998×24×0.75 = 18000元(省25%)
|
||||
-- - 3年:998×36×0.7 = 25200元(省30%)
|
||||
|
||||
-- ========================================
|
||||
-- 注意事项
|
||||
-- ========================================
|
||||
-- 1. 上述价格仅为示例,请根据实际营销策略调整
|
||||
-- 2. 折扣力度建议:时间越长,优惠越大
|
||||
-- 3. 如果不想提供某个周期,直接从数组中删除即可
|
||||
-- 4. 前端会自动渲染所有可用的计费周期选项
|
||||
-- 5. 用户可以通过优惠码获得额外折扣
|
||||
134
migrations/add_promo_code_tables.sql
Normal file
134
migrations/add_promo_code_tables.sql
Normal file
@@ -0,0 +1,134 @@
|
||||
-- 数据库迁移脚本:添加优惠码和订阅升级相关表
|
||||
-- 执行时间:2025-xx-xx
|
||||
-- 作者:Claude Code
|
||||
-- 说明:此脚本添加了优惠码、优惠码使用记录和订阅升级记录三张新表,并扩展了 payment_orders 表
|
||||
|
||||
-- ============================================
|
||||
-- 1. 创建优惠码表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS `promo_codes` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`code` VARCHAR(50) UNIQUE NOT NULL COMMENT '优惠码(唯一)',
|
||||
`description` VARCHAR(200) DEFAULT NULL COMMENT '优惠码描述',
|
||||
|
||||
-- 折扣类型和值
|
||||
`discount_type` VARCHAR(20) NOT NULL COMMENT '折扣类型: percentage(百分比) 或 fixed_amount(固定金额)',
|
||||
`discount_value` DECIMAL(10, 2) NOT NULL COMMENT '折扣值',
|
||||
|
||||
-- 适用范围
|
||||
`applicable_plans` VARCHAR(200) DEFAULT NULL COMMENT '适用套餐(JSON格式),如 ["pro", "max"],null表示全部适用',
|
||||
`applicable_cycles` VARCHAR(50) DEFAULT NULL COMMENT '适用周期(JSON格式),如 ["monthly", "yearly"],null表示全部适用',
|
||||
`min_amount` DECIMAL(10, 2) DEFAULT NULL COMMENT '最低消费金额',
|
||||
|
||||
-- 使用限制
|
||||
`max_uses` INT DEFAULT NULL COMMENT '最大使用次数,null表示无限制',
|
||||
`max_uses_per_user` INT DEFAULT 1 COMMENT '每个用户最多使用次数',
|
||||
`current_uses` INT DEFAULT 0 COMMENT '当前已使用次数',
|
||||
|
||||
-- 有效期
|
||||
`valid_from` DATETIME NOT NULL COMMENT '生效时间',
|
||||
`valid_until` DATETIME NOT NULL COMMENT '失效时间',
|
||||
|
||||
-- 状态
|
||||
`is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||
`created_by` INT DEFAULT NULL COMMENT '创建人(管理员ID)',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_code (`code`),
|
||||
INDEX idx_valid_dates (`valid_from`, `valid_until`),
|
||||
INDEX idx_is_active (`is_active`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码表';
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 2. 创建优惠码使用记录表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS `promo_code_usage` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`promo_code_id` INT NOT NULL COMMENT '优惠码ID',
|
||||
`user_id` INT NOT NULL COMMENT '用户ID',
|
||||
`order_id` INT NOT NULL COMMENT '订单ID',
|
||||
|
||||
`original_amount` DECIMAL(10, 2) NOT NULL COMMENT '原价',
|
||||
`discount_amount` DECIMAL(10, 2) NOT NULL COMMENT '优惠金额',
|
||||
`final_amount` DECIMAL(10, 2) NOT NULL COMMENT '实付金额',
|
||||
|
||||
`used_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '使用时间',
|
||||
|
||||
FOREIGN KEY (`promo_code_id`) REFERENCES `promo_codes`(`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`order_id`) REFERENCES `payment_orders`(`id`) ON DELETE CASCADE,
|
||||
|
||||
INDEX idx_user_id (`user_id`),
|
||||
INDEX idx_promo_code_id (`promo_code_id`),
|
||||
INDEX idx_order_id (`order_id`),
|
||||
INDEX idx_used_at (`used_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码使用记录表';
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 3. 创建订阅升级记录表
|
||||
-- ============================================
|
||||
CREATE TABLE IF NOT EXISTS `subscription_upgrades` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL COMMENT '用户ID',
|
||||
`order_id` INT NOT NULL COMMENT '订单ID',
|
||||
|
||||
-- 原订阅信息
|
||||
`from_plan` VARCHAR(20) NOT NULL COMMENT '原套餐',
|
||||
`from_cycle` VARCHAR(10) NOT NULL COMMENT '原周期',
|
||||
`from_end_date` DATETIME DEFAULT NULL COMMENT '原到期日',
|
||||
|
||||
-- 新订阅信息
|
||||
`to_plan` VARCHAR(20) NOT NULL COMMENT '新套餐',
|
||||
`to_cycle` VARCHAR(10) NOT NULL COMMENT '新周期',
|
||||
`to_end_date` DATETIME NOT NULL COMMENT '新到期日',
|
||||
|
||||
-- 价格计算
|
||||
`remaining_value` DECIMAL(10, 2) NOT NULL COMMENT '剩余价值',
|
||||
`upgrade_amount` DECIMAL(10, 2) NOT NULL COMMENT '升级应付金额',
|
||||
`actual_amount` DECIMAL(10, 2) NOT NULL COMMENT '实际支付金额',
|
||||
|
||||
`upgrade_type` VARCHAR(20) NOT NULL COMMENT '升级类型: plan_upgrade(套餐升级), cycle_change(周期变更), both(都变更)',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (`order_id`) REFERENCES `payment_orders`(`id`) ON DELETE CASCADE,
|
||||
|
||||
INDEX idx_user_id (`user_id`),
|
||||
INDEX idx_order_id (`order_id`),
|
||||
INDEX idx_created_at (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅升级/降级记录表';
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 4. 扩展 payment_orders 表(添加新字段)
|
||||
-- ============================================
|
||||
-- 注意:这些字段是可选的扩展,用于记录优惠码和升级信息
|
||||
-- 如果字段已存在会报错,可以忽略
|
||||
|
||||
ALTER TABLE `payment_orders`
|
||||
ADD COLUMN `promo_code_id` INT DEFAULT NULL COMMENT '使用的优惠码ID' AFTER `remark`,
|
||||
ADD COLUMN `original_amount` DECIMAL(10, 2) DEFAULT NULL COMMENT '原价(使用优惠码前)' AFTER `promo_code_id`,
|
||||
ADD COLUMN `discount_amount` DECIMAL(10, 2) DEFAULT 0 COMMENT '优惠金额' AFTER `original_amount`,
|
||||
ADD COLUMN `is_upgrade` BOOLEAN DEFAULT FALSE COMMENT '是否为升级订单' AFTER `discount_amount`,
|
||||
ADD COLUMN `upgrade_from_plan` VARCHAR(20) DEFAULT NULL COMMENT '从哪个套餐升级' AFTER `is_upgrade`;
|
||||
|
||||
-- 添加外键约束
|
||||
ALTER TABLE `payment_orders`
|
||||
ADD CONSTRAINT `fk_payment_orders_promo_code`
|
||||
FOREIGN KEY (`promo_code_id`) REFERENCES `promo_codes`(`id`) ON DELETE SET NULL;
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 5. 插入示例优惠码(供测试使用)
|
||||
-- ============================================
|
||||
-- 10% 折扣优惠码,适用所有套餐和周期
|
||||
INSERT INTO `promo_codes`
|
||||
(`code`, `description`, `discount_type`, `discount_value`, `applicable_plans`, `applicable_cycles`, `min_amount`, `max_uses`, `max_uses_per_user`, `valid_from`, `valid_until`, `is_active`)
|
||||
VALUES
|
||||
('WELCOME10', '新用户欢迎优惠 - 10%折扣', 'percentage', 10.00, NULL, NULL, NULL, NULL, 1, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), TRUE),
|
||||
('ANNUAL20', '年付专享 - 20%折扣', 'percentage', 20.00, NULL, '["yearly"]', NULL, 100, 1, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), TRUE),
|
||||
('SUMMER50', '夏季促销 - 减免50元', 'fixed_amount', 50.00, '["max"]', NULL, 100.00, 50, 1, NOW(), DATE_ADD(NOW(), INTERVAL 3 MONTH), TRUE);
|
||||
|
||||
-- 完成
|
||||
SELECT 'Migration completed successfully!' AS status;
|
||||
@@ -22,6 +22,7 @@
|
||||
"@react-three/fiber": "^8.0.27",
|
||||
"@reduxjs/toolkit": "^2.9.2",
|
||||
"@splidejs/react-splide": "^0.7.12",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@visx/visx": "^3.12.0",
|
||||
"antd": "^5.27.4",
|
||||
@@ -101,7 +102,7 @@
|
||||
"frontend:test": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.test craco start",
|
||||
"dev": "npm start",
|
||||
"backend": "python app_2.py",
|
||||
"build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' craco build && gulp licenses",
|
||||
"build": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' env-cmd -f .env.production craco build && gulp licenses",
|
||||
"build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build",
|
||||
"test": "craco test --env=jsdom",
|
||||
"eject": "react-scripts eject",
|
||||
@@ -132,7 +133,6 @@
|
||||
"prettier": "2.2.1",
|
||||
"react-error-overlay": "6.0.9",
|
||||
"sharp": "^0.34.4",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"ts-node": "^10.9.2",
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"yn": "^5.1.0"
|
||||
|
||||
BIN
public/badge.png
Normal file
BIN
public/badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
BIN
public/logo192.png
Normal file
BIN
public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -4,8 +4,24 @@
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.png",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "badge.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "badge"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "apple-icon.png",
|
||||
"sizes": "32x32",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": "./index.html",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.11.6'
|
||||
const PACKAGE_VERSION = '2.12.0'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
92
public/service-worker.js
Normal file
92
public/service-worker.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// public/service-worker.js
|
||||
/**
|
||||
* Service Worker for Browser Notifications
|
||||
* 主要功能:支持浏览器通知的稳定运行
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'valuefrontier-v1';
|
||||
|
||||
// Service Worker 安装事件
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[Service Worker] Installing...');
|
||||
// 跳过等待,立即激活
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Service Worker 激活事件
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[Service Worker] Activating...');
|
||||
// 立即接管所有页面
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
// 通知点击事件
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
console.log('[Service Worker] Notification clicked:', event.notification.tag);
|
||||
|
||||
event.notification.close();
|
||||
|
||||
// 获取通知数据中的链接
|
||||
const urlToOpen = event.notification.data?.link;
|
||||
|
||||
if (urlToOpen) {
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true })
|
||||
.then((windowClients) => {
|
||||
// 查找是否已有打开的窗口
|
||||
for (let client of windowClients) {
|
||||
if (client.url.includes(window.location.origin) && 'focus' in client) {
|
||||
// 聚焦现有窗口并导航到目标页面
|
||||
return client.focus().then(client => {
|
||||
return client.navigate(urlToOpen);
|
||||
});
|
||||
}
|
||||
}
|
||||
// 如果没有打开的窗口,打开新窗口
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow(urlToOpen);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 通知关闭事件
|
||||
self.addEventListener('notificationclose', (event) => {
|
||||
console.log('[Service Worker] Notification closed:', event.notification.tag);
|
||||
});
|
||||
|
||||
// Fetch 事件 - 基础的网络优先策略
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// 对于通知相关的资源,使用网络优先策略
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.catch(() => {
|
||||
// 网络失败时,尝试从缓存获取
|
||||
return caches.match(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// 推送消息事件(预留,用于未来的 Push API 集成)
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[Service Worker] Push message received:', event);
|
||||
|
||||
if (event.data) {
|
||||
const data = event.data.json();
|
||||
const options = {
|
||||
body: data.body || '您有新消息',
|
||||
icon: data.icon || '/favicon.png',
|
||||
badge: '/favicon.png',
|
||||
data: data.data || {},
|
||||
requireInteraction: data.requireInteraction || false,
|
||||
tag: data.tag || `notification_${Date.now()}`,
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || '价值前沿', options)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[Service Worker] Loaded successfully');
|
||||
@@ -501,26 +501,26 @@ export default function WechatRegister() {
|
||||
bg="gray.50"
|
||||
boxShadow="sm" // ✅ 添加轻微阴影
|
||||
>
|
||||
{wechatStatus !== WECHAT_STATUS.NONE ? (
|
||||
{wechatStatus === WECHAT_STATUS.WAITING ? (
|
||||
/* 已获取二维码:显示iframe */
|
||||
<iframe
|
||||
src={wechatAuthUrl}
|
||||
title="微信扫码登录"
|
||||
width="300"
|
||||
height="350"
|
||||
scrolling="no" // ✅ 新增:禁止滚动
|
||||
scrolling="no" // ✅ 新增:禁止滚动
|
||||
// sandbox="allow-scripts allow-same-origin allow-forms" // ✅ 阻止iframe跳转父页面
|
||||
style={{
|
||||
border: 'none',
|
||||
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
|
||||
transformOrigin: 'top left',
|
||||
marginLeft: '-5px',
|
||||
pointerEvents: 'auto', // 允许点击 │ │
|
||||
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
|
||||
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
|
||||
}}
|
||||
// 使用 onWheel 事件阻止滚动 │ │
|
||||
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
|
||||
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
|
||||
|
||||
/>
|
||||
) : (
|
||||
/* 未获取:显示占位符 */
|
||||
|
||||
@@ -106,7 +106,29 @@ const FollowingEventsMenu = memo(() => {
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
<HStack flexShrink={0}>
|
||||
<HStack flexShrink={0} spacing={1}>
|
||||
{/* 热度 */}
|
||||
{typeof ev.hot_score === 'number' && (
|
||||
<Badge
|
||||
colorScheme={
|
||||
ev.hot_score >= 80 ? 'red' :
|
||||
(ev.hot_score >= 60 ? 'orange' : 'gray')
|
||||
}
|
||||
fontSize="xs"
|
||||
>
|
||||
🔥 {ev.hot_score}
|
||||
</Badge>
|
||||
)}
|
||||
{/* 关注数 */}
|
||||
{typeof ev.follower_count === 'number' && ev.follower_count > 0 && (
|
||||
<Badge
|
||||
colorScheme="purple"
|
||||
fontSize="xs"
|
||||
>
|
||||
👥 {ev.follower_count}
|
||||
</Badge>
|
||||
)}
|
||||
{/* 日均涨跌幅 */}
|
||||
{typeof ev.related_avg_chg === 'number' && (
|
||||
<Badge
|
||||
colorScheme={
|
||||
@@ -119,6 +141,7 @@ const FollowingEventsMenu = memo(() => {
|
||||
{ev.related_avg_chg.toFixed(2)}%
|
||||
</Badge>
|
||||
)}
|
||||
{/* 周涨跌幅 */}
|
||||
{typeof ev.related_week_chg === 'number' && (
|
||||
<Badge
|
||||
colorScheme={
|
||||
@@ -131,6 +154,7 @@ const FollowingEventsMenu = memo(() => {
|
||||
{ev.related_week_chg.toFixed(2)}%
|
||||
</Badge>
|
||||
)}
|
||||
{/* 取消关注按钮 */}
|
||||
<Box
|
||||
as="span"
|
||||
fontSize="xs"
|
||||
|
||||
@@ -12,12 +12,12 @@ import {
|
||||
Text,
|
||||
Flex,
|
||||
Badge,
|
||||
useColorModeValue,
|
||||
useDisclosure
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useNavigationEvents } from '../../../../hooks/useNavigationEvents';
|
||||
import { useDelayedMenu } from '../../../../hooks/useDelayedMenu';
|
||||
|
||||
/**
|
||||
* 桌面版主导航菜单组件
|
||||
@@ -37,11 +37,11 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
// 🎯 初始化导航埋点Hook
|
||||
const navEvents = useNavigationEvents({ component: 'top_nav' });
|
||||
|
||||
// 🎯 为每个菜单创建独立的 useDisclosure Hook
|
||||
const { isOpen: isHighFreqOpen, onOpen: onHighFreqOpen, onClose: onHighFreqClose } = useDisclosure();
|
||||
const { isOpen: isMarketReviewOpen, onOpen: onMarketReviewOpen, onClose: onMarketReviewClose } = useDisclosure();
|
||||
const { isOpen: isAgentCommunityOpen, onOpen: onAgentCommunityOpen, onClose: onAgentCommunityClose } = useDisclosure();
|
||||
const { isOpen: isContactUsOpen, onOpen: onContactUsOpen, onClose: onContactUsClose } = useDisclosure();
|
||||
// 🎯 为每个菜单创建延迟关闭控制(200ms 延迟)
|
||||
const highFreqMenu = useDelayedMenu({ closeDelay: 200 });
|
||||
const marketReviewMenu = useDelayedMenu({ closeDelay: 200 });
|
||||
const agentCommunityMenu = useDelayedMenu({ closeDelay: 200 });
|
||||
const contactUsMenu = useDelayedMenu({ closeDelay: 200 });
|
||||
|
||||
// 辅助函数:判断导航项是否激活
|
||||
const isActive = useCallback((paths) => {
|
||||
@@ -53,7 +53,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
return (
|
||||
<HStack spacing={8}>
|
||||
{/* 高频跟踪 */}
|
||||
<Menu isOpen={isHighFreqOpen} onClose={onHighFreqClose}>
|
||||
<Menu isOpen={highFreqMenu.isOpen} onClose={highFreqMenu.onClose}>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
@@ -64,17 +64,24 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
borderBottom={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.100' : 'gray.50' }}
|
||||
onMouseEnter={onHighFreqOpen}
|
||||
onMouseLeave={onHighFreqClose}
|
||||
onMouseEnter={highFreqMenu.handleMouseEnter}
|
||||
onMouseLeave={highFreqMenu.handleMouseLeave}
|
||||
onClick={highFreqMenu.handleClick}
|
||||
>
|
||||
高频跟踪
|
||||
</MenuButton>
|
||||
<MenuList minW="260px" p={2} onMouseEnter={onHighFreqOpen} onMouseLeave={onHighFreqClose}>
|
||||
<MenuList
|
||||
minW="260px"
|
||||
p={2}
|
||||
onMouseEnter={highFreqMenu.handleMouseEnter}
|
||||
onMouseLeave={highFreqMenu.handleMouseLeave}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
// 🎯 追踪菜单项点击
|
||||
navEvents.trackMenuItemClicked('事件中心', 'dropdown', '/community');
|
||||
navigate('/community');
|
||||
highFreqMenu.onClose(); // 跳转后关闭菜单
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
|
||||
@@ -95,6 +102,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
// 🎯 追踪菜单项点击
|
||||
navEvents.trackMenuItemClicked('概念中心', 'dropdown', '/concepts');
|
||||
navigate('/concepts');
|
||||
highFreqMenu.onClose(); // 跳转后关闭菜单
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
|
||||
@@ -111,7 +119,7 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
</Menu>
|
||||
|
||||
{/* 行情复盘 */}
|
||||
<Menu isOpen={isMarketReviewOpen} onClose={onMarketReviewClose}>
|
||||
<Menu isOpen={marketReviewMenu.isOpen} onClose={marketReviewMenu.onClose}>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
@@ -122,14 +130,23 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
borderBottom={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '2px solid' : 'none'}
|
||||
borderColor="blue.600"
|
||||
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.100' : 'gray.50' }}
|
||||
onMouseEnter={onMarketReviewOpen}
|
||||
onMouseLeave={onMarketReviewClose}
|
||||
onMouseEnter={marketReviewMenu.handleMouseEnter}
|
||||
onMouseLeave={marketReviewMenu.handleMouseLeave}
|
||||
onClick={marketReviewMenu.handleClick}
|
||||
>
|
||||
行情复盘
|
||||
</MenuButton>
|
||||
<MenuList minW="260px" p={2} onMouseEnter={onMarketReviewOpen} onMouseLeave={onMarketReviewClose}>
|
||||
<MenuList
|
||||
minW="260px"
|
||||
p={2}
|
||||
onMouseEnter={marketReviewMenu.handleMouseEnter}
|
||||
onMouseLeave={marketReviewMenu.handleMouseLeave}
|
||||
>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/limit-analyse')}
|
||||
onClick={() => {
|
||||
navigate('/limit-analyse');
|
||||
marketReviewMenu.onClose(); // 跳转后关闭菜单
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/limit-analyse') ? '3px solid' : 'none'}
|
||||
@@ -142,7 +159,10 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/stocks')}
|
||||
onClick={() => {
|
||||
navigate('/stocks');
|
||||
marketReviewMenu.onClose(); // 跳转后关闭菜单
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/stocks') ? '3px solid' : 'none'}
|
||||
@@ -155,7 +175,10 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/trading-simulation')}
|
||||
onClick={() => {
|
||||
navigate('/trading-simulation');
|
||||
marketReviewMenu.onClose(); // 跳转后关闭菜单
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/trading-simulation') ? '3px solid' : 'none'}
|
||||
@@ -171,17 +194,23 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
</Menu>
|
||||
|
||||
{/* AGENT社群 */}
|
||||
<Menu isOpen={isAgentCommunityOpen} onClose={onAgentCommunityClose}>
|
||||
<Menu isOpen={agentCommunityMenu.isOpen} onClose={agentCommunityMenu.onClose}>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
onMouseEnter={onAgentCommunityOpen}
|
||||
onMouseLeave={onAgentCommunityClose}
|
||||
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
||||
onMouseLeave={agentCommunityMenu.handleMouseLeave}
|
||||
onClick={agentCommunityMenu.handleClick}
|
||||
>
|
||||
AGENT社群
|
||||
</MenuButton>
|
||||
<MenuList minW="300px" p={4} onMouseEnter={onAgentCommunityOpen} onMouseLeave={onAgentCommunityClose}>
|
||||
<MenuList
|
||||
minW="300px"
|
||||
p={4}
|
||||
onMouseEnter={agentCommunityMenu.handleMouseEnter}
|
||||
onMouseLeave={agentCommunityMenu.handleMouseLeave}
|
||||
>
|
||||
<MenuItem
|
||||
isDisabled
|
||||
cursor="not-allowed"
|
||||
@@ -200,17 +229,23 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
|
||||
</Menu>
|
||||
|
||||
{/* 联系我们 */}
|
||||
<Menu isOpen={isContactUsOpen} onClose={onContactUsClose}>
|
||||
<Menu isOpen={contactUsMenu.isOpen} onClose={contactUsMenu.onClose}>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
onMouseEnter={onContactUsOpen}
|
||||
onMouseLeave={onContactUsClose}
|
||||
onMouseEnter={contactUsMenu.handleMouseEnter}
|
||||
onMouseLeave={contactUsMenu.handleMouseLeave}
|
||||
onClick={contactUsMenu.handleClick}
|
||||
>
|
||||
联系我们
|
||||
</MenuButton>
|
||||
<MenuList minW="260px" p={4} onMouseEnter={onContactUsOpen} onMouseLeave={onContactUsClose}>
|
||||
<MenuList
|
||||
minW="260px"
|
||||
p={4}
|
||||
onMouseEnter={contactUsMenu.handleMouseEnter}
|
||||
onMouseLeave={contactUsMenu.handleMouseLeave}
|
||||
>
|
||||
<Text fontSize="sm" color={contactTextColor}>敬请期待</Text>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
|
||||
@@ -12,11 +12,11 @@ import {
|
||||
Text,
|
||||
Flex,
|
||||
HStack,
|
||||
Badge,
|
||||
useDisclosure
|
||||
Badge
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useDelayedMenu } from '../../../../hooks/useDelayedMenu';
|
||||
|
||||
/**
|
||||
* 平板版"更多"下拉菜单组件
|
||||
@@ -30,8 +30,8 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// 🎯 为"更多"菜单创建 useDisclosure Hook
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
// 🎯 使用延迟关闭菜单控制
|
||||
const moreMenu = useDelayedMenu({ closeDelay: 200 });
|
||||
|
||||
// 辅助函数:判断导航项是否激活
|
||||
const isActive = useCallback((paths) => {
|
||||
@@ -41,22 +41,31 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
||||
if (!isAuthenticated || !user) return null;
|
||||
|
||||
return (
|
||||
<Menu isOpen={isOpen} onClose={onClose}>
|
||||
<Menu isOpen={moreMenu.isOpen} onClose={moreMenu.onClose}>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
variant="ghost"
|
||||
rightIcon={<ChevronDownIcon />}
|
||||
fontWeight="medium"
|
||||
onMouseEnter={onOpen}
|
||||
onMouseLeave={onClose}
|
||||
onMouseEnter={moreMenu.handleMouseEnter}
|
||||
onMouseLeave={moreMenu.handleMouseLeave}
|
||||
onClick={moreMenu.handleClick}
|
||||
>
|
||||
更多
|
||||
</MenuButton>
|
||||
<MenuList minW="300px" p={2} onMouseEnter={onOpen} onMouseLeave={onClose}>
|
||||
<MenuList
|
||||
minW="300px"
|
||||
p={2}
|
||||
onMouseEnter={moreMenu.handleMouseEnter}
|
||||
onMouseLeave={moreMenu.handleMouseLeave}
|
||||
>
|
||||
{/* 高频跟踪组 */}
|
||||
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">高频跟踪</Text>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/community')}
|
||||
onClick={() => {
|
||||
moreMenu.onClose(); // 先关闭菜单
|
||||
navigate('/community');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
@@ -69,7 +78,10 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/concepts')}
|
||||
onClick={() => {
|
||||
moreMenu.onClose(); // 先关闭菜单
|
||||
navigate('/concepts');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
@@ -84,7 +96,10 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
||||
{/* 行情复盘组 */}
|
||||
<Text fontSize="xs" fontWeight="bold" px={3} py={2} color="gray.500">行情复盘</Text>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/limit-analyse')}
|
||||
onClick={() => {
|
||||
moreMenu.onClose(); // 先关闭菜单
|
||||
navigate('/limit-analyse');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/limit-analyse') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
@@ -94,7 +109,10 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/stocks')}
|
||||
onClick={() => {
|
||||
moreMenu.onClose(); // 先关闭菜单
|
||||
navigate('/stocks');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/stocks') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
@@ -104,7 +122,10 @@ const MoreMenu = memo(({ isAuthenticated, user }) => {
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/trading-simulation')}
|
||||
onClick={() => {
|
||||
moreMenu.onClose(); // 先关闭菜单
|
||||
navigate('/trading-simulation');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/trading-simulation') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
|
||||
@@ -57,7 +57,7 @@ const PersonalCenterMenu = memo(({ user, handleLogout }) => {
|
||||
>
|
||||
个人中心
|
||||
</MenuButton>
|
||||
<MenuList onMouseEnter={onOpen} onMouseLeave={onClose}>
|
||||
<MenuList onMouseEnter={onOpen}>
|
||||
{/* 用户信息区 */}
|
||||
<Box px={3} py={2} borderBottom="1px" borderColor="gray.200">
|
||||
<Text fontSize="sm" fontWeight="bold">{getDisplayName()}</Text>
|
||||
@@ -71,24 +71,36 @@ const PersonalCenterMenu = memo(({ user, handleLogout }) => {
|
||||
</Box>
|
||||
|
||||
{/* 前往个人中心 */}
|
||||
<MenuItem icon={<FiHome />} onClick={() => navigate('/home/center')}>
|
||||
<MenuItem icon={<FiHome />} onClick={() => {
|
||||
onClose(); // 先关闭菜单
|
||||
navigate('/home/center');
|
||||
}}>
|
||||
前往个人中心
|
||||
</MenuItem>
|
||||
|
||||
<MenuDivider />
|
||||
|
||||
{/* 账户管理组 */}
|
||||
<MenuItem icon={<FiUser />} onClick={() => navigate('/home/profile')}>
|
||||
<MenuItem icon={<FiUser />} onClick={() => {
|
||||
onClose(); // 先关闭菜单
|
||||
navigate('/home/profile');
|
||||
}}>
|
||||
个人资料
|
||||
</MenuItem>
|
||||
<MenuItem icon={<FiSettings />} onClick={() => navigate('/home/settings')}>
|
||||
<MenuItem icon={<FiSettings />} onClick={() => {
|
||||
onClose(); // 先关闭菜单
|
||||
navigate('/home/settings');
|
||||
}}>
|
||||
账户设置
|
||||
</MenuItem>
|
||||
|
||||
<MenuDivider />
|
||||
|
||||
{/* 功能入口组 */}
|
||||
<MenuItem icon={<FaCrown />} onClick={() => navigate('/home/pages/account/subscription')}>
|
||||
<MenuItem icon={<FaCrown />} onClick={() => {
|
||||
onClose(); // 先关闭菜单
|
||||
navigate('/home/pages/account/subscription');
|
||||
}}>
|
||||
订阅管理
|
||||
</MenuItem>
|
||||
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Text,
|
||||
Box,
|
||||
VStack,
|
||||
Divider,
|
||||
useColorModeValue
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
const PrivacyPolicyModal = ({ isOpen, onClose }) => {
|
||||
const modalBg = useColorModeValue("white", "gray.800");
|
||||
const headingColor = useColorModeValue("gray.800", "white");
|
||||
const textColor = useColorModeValue("gray.600", "gray.300");
|
||||
|
||||
// Conditional rendering: only render Modal when open
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="full"
|
||||
scrollBehavior="inside"
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay bg="blackAlpha.600" />
|
||||
<ModalContent
|
||||
maxW="95vw"
|
||||
maxH="95vh"
|
||||
bg={modalBg}
|
||||
borderRadius="xl"
|
||||
boxShadow="2xl"
|
||||
mx={4}
|
||||
>
|
||||
<ModalHeader
|
||||
fontSize="2xl"
|
||||
fontWeight="bold"
|
||||
color={headingColor}
|
||||
borderBottom="1px solid"
|
||||
borderColor="gray.200"
|
||||
borderRadius="xl xl 0 0"
|
||||
py={6}
|
||||
>
|
||||
隐私政策
|
||||
</ModalHeader>
|
||||
<ModalCloseButton
|
||||
size="lg"
|
||||
_hover={{ bg: "gray.100" }}
|
||||
/>
|
||||
<ModalBody py={8} px={8}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Box bg="blue.50" p={4} borderRadius="md" border="1px solid" borderColor="blue.100">
|
||||
<Text fontSize="md" color="blue.600" mb={2} fontWeight="semibold">
|
||||
生效日期:2025年1月20日
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.8" mb={4}>
|
||||
【北京价值前沿科技有限公司】(以下简称"我们")深知个人信息对您的重要性,并会尽全力保护您的个人信息安全可靠。我们致力于维持您对我们的信任,恪守以下原则,保护您的个人信息:权责一致原则、目的明确原则、选择同意原则、最少够用原则、确保安全原则、主体参与原则、公开透明原则等。同时,我们承诺,我们将按业界成熟的安全标准,采取相应的安全保护措施来保护您的个人信息。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.8" fontWeight="medium">
|
||||
请在使用我们的产品(或服务)前,仔细阅读并了解本《隐私政策》。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
一、我们如何收集和使用您的个人信息
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
根据《信息安全技术个人信息安全规范》(GB/T 35273—2020),个人信息是指以电子或者其他方式记录的能够单独或者与其他信息结合识别特定自然人身份或者反映特定自然人活动情况的各种信息。本隐私政策中涉及的个人信息包括:基本信息(包括性别、地址、地区、个人电话号码、电子邮箱);个人身份信息(包括身份证、护照、相关身份证明等);网络身份标识信息(包括系统账号、IP地址、口令);个人上网记录(包括登录记录、浏览记录);个人常用设备信息(包括硬件型号、操作系统类型、应用安装列表、运行中进程信息、设备MAC地址、软件列表设备识别码如IMEI/android ID/IDFA/IMSI 在内的描述个人常用设备基本情况的信息);个人位置信息(包括精准定位信息、经纬度等);
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
个人敏感信息是指一旦泄露、非法提供或滥用可能危害人身和财产安全,极易导致个人名誉、身心健康受到损害或歧视性待遇等的个人信息,本隐私政策中涉及的个人敏感信息包括:个人身份信息(包括身份证、护照、相关身份证明等);网络身份识别信息(包括账户名、账户昵称、用户头像、与前述有关的密码);其他信息(包括个人电话号码、浏览记录、精准定位信息)。对于个人敏感信息,我们将在本政策中进行显著标识,请您仔细阅读。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="green.300" bg="green.50" p={4} borderRadius="md">
|
||||
<Text fontSize="xl" fontWeight="semibold" color="green.700" mb={2}>
|
||||
(一)手机号注册/登录
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.8" mb={4}>
|
||||
当您使用手机号注册/登录服务时,我们会收集您的手机号码、验证码匹配结果、手机系统平台等信息,用于保存您的登录信息,使您在使用不同设备登录时能够同步您的数据。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="purple.300" bg="purple.50" p={4} borderRadius="md">
|
||||
<Text fontSize="xl" fontWeight="semibold" color="purple.700" mb={2}>
|
||||
(二)第三方登录
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.8" mb={4}>
|
||||
当您使用微信/QQ等第三方登录时,我们会收集您第三方的唯一标识、头像、昵称,用于保存您的登录信息,使您在使用不同设备登录时能够同步您的数据。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.8" mb={4}>
|
||||
当您使用微信,微博,QQ 进行三方分享的时候,我们的产品可能会集成第三方的SDK或其他类似的应用程序,用于三方登录以及分享内容到三方平台。您可以登陆以下网址了解相关隐私政策:
|
||||
</Text>
|
||||
<VStack align="start" spacing={2} pl={4}>
|
||||
<Text fontSize="md" color="blue.600" lineHeight="1.6">
|
||||
【新浪微博】微博个人信息保护政策:https://m.weibo.cn/c/privacy
|
||||
</Text>
|
||||
<Text fontSize="md" color="blue.600" lineHeight="1.6">
|
||||
【微信】微信开放平台开发者服务协议:https://open.weixin.qq.com/cgi-bin/frame?t=news/protocol_developer_tmpl
|
||||
</Text>
|
||||
<Text fontSize="md" color="blue.600" lineHeight="1.6">
|
||||
【QQ】QQ互联SDK隐私保护声明:https://wiki.connect.qq.com/qq互联sdk隐私保护声明
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="orange.300" bg="orange.50" p={4} borderRadius="md">
|
||||
<Text fontSize="xl" fontWeight="semibold" color="orange.700" mb={2}>
|
||||
(三)第三方支付
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.8" mb={4}>
|
||||
当您使用 微信 支付宝 华为 进行三方支付的时候,我们的产品可能会集成第三方的SDK或其他类似的应用程序,帮助用户在应用内使用三方支付:
|
||||
</Text>
|
||||
<VStack align="start" spacing={2} pl={4}>
|
||||
<Text fontSize="md" color="blue.600" lineHeight="1.6">
|
||||
【支付宝】客户端 SDK 隐私说明:https://opendocs.alipay.com/open/01g6qm
|
||||
</Text>
|
||||
<Text fontSize="md" color="blue.600" lineHeight="1.6">
|
||||
【微信支付】微信支付服务协议:https://pay.weixin.qq.com/index.php/public/apply_sign/protocol_v2
|
||||
</Text>
|
||||
<Text fontSize="md" color="blue.600" lineHeight="1.6">
|
||||
【华为支付】SDK数据安全说明:https://developer.huawei.com/consumer/cn/doc/development/HMSCore-Guides/sdk-data-security-0000001050044906
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
二、我们如何使用 Cookie 和同类技术
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
为确保网站正常运转,我们会在您的计算机或移动设备上存储名为 Cookie 的小数据文件。Cookie 通常包含标识符、站点名称以及一些号码和字符。借助于 Cookie,网站能够存储您的偏好或购物篮内的商品等数据。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
三、我们如何共享、转让、公开披露您的个人信息
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
我们不会向其他任何公司、组织和个人分享您的个人信息,但以下情况除外:
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={2}>
|
||||
1、在获取明确同意的情况下共享:获得您的明确同意后,我们会与其他方共享您的个人信息。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={2}>
|
||||
2、我们可能会根据法律法规规定,或按政府主管部门的强制性要求,对外共享您的个人信息。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
3、与我们的关联公司共享:您的个人信息可能会与我们关联公司共享。我们只会共享必要的个人信息,且受本隐私政策中所声明目的的约束。关联公司如要改变个人信息的处理目的,将再次征求您的授权同意。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
我们的关联公司包括:北京价值经纬咨询有限责任公司等。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
四、我们如何保护您的个人信息
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
我们已使用符合业界标准的安全防护措施保护您提供的个人信息,防止数据遭到未经授权访问、公开披露、使用、修改、损坏或丢失。我们会采取一切合理可行的措施,保护您的个人信息。例如,在您的浏览器与"服务"之间交换数据(如信用卡信息)时受 SSL 加密保护;我们同时对我们网站提供 https 安全浏览方式;我们会使用加密技术确保数据的保密性;我们会使用受信赖的保护机制防止数据遭到恶意攻击;我们会部署访问控制机制,确保只有授权人员才可访问个人信息;以及我们会举办安全和隐私保护培训课程,加强员工对于保护个人信息重要性的认识。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
五、您的权利
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
按照中国相关的法律、法规、标准,以及其他国家、地区的通行做法,我们保障您对自己的个人信息行使以下权利:
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={2}>
|
||||
(一)访问您的个人信息
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={2}>
|
||||
(二)更正您的个人信息
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={2}>
|
||||
(三)删除您的个人信息
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
(四)约束信息系统自动决策
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
如果您无法通过上述链接更正这些个人信息,您可以随时使用我们的 Web 表单联系,或发送电子邮件至admin@valuefrontier.cn。我们将在30天内回复您的更正请求。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
六、我们如何处理儿童的个人信息
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
我们的产品、网站和服务主要面向成人。如果没有父母或监护人的同意,儿童不得创建自己的用户账户。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
对于经父母同意而收集儿童个人信息的情况,我们只会在受到法律允许、父母或监护人明确同意或者保护儿童所必要的情况下使用或公开披露此信息。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
尽管当地法律和习俗对儿童的定义不同,但我们将不满 14 周岁的任何人均视为儿童。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
如果我们发现自己在未事先获得可证实的父母同意的情况下收集了儿童的个人信息,则会设法尽快删除相关数据。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
七、本隐私政策如何更新
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
我们可能适时会对本隐私政策进行调整或变更,本隐私政策的任何更新将在用户启动应用时以弹窗形式提醒用户更新内容并提示查看最新的隐私政策,提醒用户重新确认是否同意隐私政策条款,除法律法规或监管规定另有强制性规定外,经调整或变更的内容一经用户确认后将即时生效。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
八、如何联系我们
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
如果您对本隐私政策有任何疑问、意见或建议,通过以下方式与我们联系:
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
邮箱:admin@valuefrontier.cn
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={4}>
|
||||
九、未成年人保护方面
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
1、若您是未满18周岁的未成年人,您应在您的监护人监护、指导下并获得监护人同意的情况下,认真阅读并同意本协议后,方可使用价值前沿app及相关服务。若您未取得监护人的同意,监护人可以通过联系价值前沿官方公布的客服联系方式通知价值前沿处理相关账号,价值前沿有权对相关账号的功能、使用进行限制,包括但不限于浏览、发布信息、互动交流等功能。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
2、价值前沿重视对未成年人个人信息的保护,未成年用户在填写个人信息时,请加强个人保护意识并谨慎对待,并应在取得监护人的同意以及在监护人指导下正确使用价值前沿app及相关服务。
|
||||
</Text>
|
||||
<Text fontSize="xl" color={textColor} lineHeight="1.6" mb={4}>
|
||||
3、未成年人用户及其监护人理解并确认,如您违反法律法规、本协议内容,则您及您的监护人应依照法律规定承担因此而可能导致的全部法律责任。
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrivacyPolicyModal;
|
||||
167
src/components/StockChangeIndicators.js
Normal file
167
src/components/StockChangeIndicators.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// src/components/StockChangeIndicators.js
|
||||
// 股票涨跌幅指标组件(通用)
|
||||
|
||||
import React from 'react';
|
||||
import { Flex, Box, Text, useColorModeValue } from '@chakra-ui/react';
|
||||
import { TriangleUpIcon, TriangleDownIcon } from '@chakra-ui/icons';
|
||||
|
||||
/**
|
||||
* 股票涨跌幅指标组件(3分天下布局)
|
||||
* @param {Object} props
|
||||
* @param {number} props.avgChange - 平均涨跌幅
|
||||
* @param {number} props.maxChange - 最大涨跌幅
|
||||
* @param {number} props.weekChange - 周涨跌幅
|
||||
* @param {'default'|'comfortable'|'large'} props.size - 尺寸模式:default=紧凑,comfortable=舒适(事件列表),large=大卡片(详情面板)
|
||||
*/
|
||||
const StockChangeIndicators = ({
|
||||
avgChange,
|
||||
maxChange,
|
||||
weekChange,
|
||||
size = 'default',
|
||||
}) => {
|
||||
const isLarge = size === 'large';
|
||||
const isComfortable = size === 'comfortable';
|
||||
const isDefault = size === 'default';
|
||||
|
||||
// 根据涨跌幅获取数字颜色(统一颜色,不分级)
|
||||
const getNumberColor = (value) => {
|
||||
if (value == null) {
|
||||
return useColorModeValue('gray.700', 'gray.400');
|
||||
}
|
||||
|
||||
// 0值使用中性灰色
|
||||
if (value === 0) {
|
||||
return 'gray.700';
|
||||
}
|
||||
|
||||
// 统一颜色:上涨红色,下跌绿色
|
||||
return value > 0 ? 'red.500' : 'green.500';
|
||||
};
|
||||
|
||||
// 根据涨跌幅获取背景色(永远比文字色浅)
|
||||
const getBgColor = (value) => {
|
||||
if (value == null) {
|
||||
return useColorModeValue('gray.50', 'gray.800');
|
||||
}
|
||||
|
||||
// 0值使用中性灰色背景
|
||||
if (value === 0) {
|
||||
return useColorModeValue('gray.50', 'gray.800');
|
||||
}
|
||||
|
||||
// 统一背景色:上涨红色系,下跌绿色系
|
||||
return value > 0
|
||||
? useColorModeValue('red.50', 'red.900')
|
||||
: useColorModeValue('green.50', 'green.900');
|
||||
};
|
||||
|
||||
// 根据涨跌幅获取边框色(比背景深,比文字浅)
|
||||
const getBorderColor = (value) => {
|
||||
if (value == null) {
|
||||
return useColorModeValue('gray.200', 'gray.700');
|
||||
}
|
||||
|
||||
// 0值使用中性灰色边框
|
||||
if (value === 0) {
|
||||
return useColorModeValue('gray.200', 'gray.700');
|
||||
}
|
||||
|
||||
// 统一边框色:上涨红色系,下跌绿色系
|
||||
return value > 0
|
||||
? useColorModeValue('red.200', 'red.700')
|
||||
: useColorModeValue('green.200', 'green.700');
|
||||
};
|
||||
|
||||
// 渲染单个指标
|
||||
const renderIndicator = (label, value) => {
|
||||
if (value == null) return null;
|
||||
|
||||
const sign = value > 0 ? '+' : '-';
|
||||
// 0值显示为 "0",其他值显示两位小数
|
||||
const numStr = value === 0 ? '0' : Math.abs(value).toFixed(2);
|
||||
const numberColor = getNumberColor(value);
|
||||
const bgColor = getBgColor(value);
|
||||
const borderColor = getBorderColor(value);
|
||||
const labelColor = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={bgColor}
|
||||
borderWidth={isLarge ? "2px" : "1px"}
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
px={isLarge ? 4 : (isDefault ? 1.5 : (isComfortable ? 3 : 2))}
|
||||
py={isLarge ? 3 : (isDefault ? 1.5 : (isComfortable ? 2 : 1))}
|
||||
display="flex"
|
||||
flexDirection={(isLarge || isDefault) ? "column" : "row"}
|
||||
alignItems={(isLarge || isDefault) ? "flex-start" : "center"}
|
||||
gap={(isLarge || isDefault) ? (isLarge ? 2 : 1) : 1}
|
||||
maxW={isLarge ? "200px" : "none"}
|
||||
flex="0 1 auto"
|
||||
minW="0"
|
||||
>
|
||||
{/* Large 和 Default 模式:标签单独一行 */}
|
||||
{(isLarge || isDefault) && (
|
||||
<Text fontSize={isLarge ? "sm" : "xs"} color={labelColor} fontWeight="medium">
|
||||
{label.trim()}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 数值 + 图标 */}
|
||||
<Flex align="center" gap={isLarge ? 2 : (isDefault ? 1 : 1)}>
|
||||
{/* 三角形图标 */}
|
||||
{value !== 0 && (
|
||||
value > 0 ? (
|
||||
<TriangleUpIcon
|
||||
w={2}
|
||||
h={2}
|
||||
color={numberColor}
|
||||
/>
|
||||
) : (
|
||||
<TriangleDownIcon
|
||||
w={2}
|
||||
h={2}
|
||||
color={numberColor}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 数字 */}
|
||||
<Text
|
||||
fontSize={isLarge ? "2xl" : (isDefault ? "md" : "lg")}
|
||||
fontWeight="bold"
|
||||
color={numberColor}
|
||||
lineHeight="1.2"
|
||||
whiteSpace="nowrap"
|
||||
overflow="hidden"
|
||||
textOverflow="ellipsis"
|
||||
>
|
||||
{/* Comfortable 模式:标签和数字在同一行 */}
|
||||
{!isLarge && !isDefault && (
|
||||
<Text as="span" color={labelColor} fontWeight="medium" fontSize="sm">
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
{sign}{numStr}
|
||||
<Text as="span" fontWeight="medium" fontSize="sm">%</Text>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 如果没有任何数据,不渲染
|
||||
if (avgChange == null && maxChange == null && weekChange == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex width="100%" justify="flex-start" align="center" gap={isLarge ? 4 : (isDefault ? 2 : 1)}>
|
||||
{renderIndicator('平均涨幅', avgChange)}
|
||||
{renderIndicator('最大涨幅', maxChange)}
|
||||
{renderIndicator('周涨幅', weekChange)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockChangeIndicators;
|
||||
@@ -23,6 +23,25 @@ const StockChartModal = ({
|
||||
const [chartData, setChartData] = useState(null);
|
||||
const [preloadedData, setPreloadedData] = useState({});
|
||||
|
||||
// 处理关联描述(兼容对象和字符串格式)
|
||||
const getRelationDesc = () => {
|
||||
const relationDesc = stock?.relation_desc;
|
||||
|
||||
if (!relationDesc) return null;
|
||||
|
||||
if (typeof relationDesc === 'string') {
|
||||
return relationDesc;
|
||||
} else if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
|
||||
// 新格式:{data: [{query_part: "...", sentences: "..."}]}
|
||||
return relationDesc.data
|
||||
.map(item => item.query_part || item.sentences || '')
|
||||
.filter(s => s)
|
||||
.join(';') || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 预加载数据
|
||||
const preloadData = async (type) => {
|
||||
if (!stock || preloadedData[type]) return;
|
||||
@@ -539,10 +558,10 @@ const StockChartModal = ({
|
||||
<div ref={chartRef} style={{ height: '100%', width: '100%', minHeight: '500px' }} />
|
||||
</Box>
|
||||
|
||||
{stock?.relation_desc && (
|
||||
{getRelationDesc() && (
|
||||
<Box p={4} borderTop="1px solid" borderTopColor="gray.200">
|
||||
<Text fontSize="sm" fontWeight="bold" mb={2}>关联描述:</Text>
|
||||
<Text fontSize="sm" color="gray.600">{stock.relation_desc}</Text>
|
||||
<Text fontSize="sm" color="gray.600">{getRelationDesc()}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ import {
|
||||
Td,
|
||||
Heading,
|
||||
Collapse,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
} from '@chakra-ui/react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { logger } from '../../utils/logger';
|
||||
@@ -76,7 +79,8 @@ export default function SubscriptionContent() {
|
||||
// State
|
||||
const [subscriptionPlans, setSubscriptionPlans] = useState([]);
|
||||
const [selectedPlan, setSelectedPlan] = useState(null);
|
||||
const [selectedCycle, setSelectedCycle] = useState('monthly');
|
||||
const [selectedCycle, setSelectedCycle] = useState('monthly'); // 保持向后兼容,默认月付
|
||||
const [selectedCycleOption, setSelectedCycleOption] = useState(null); // 当前选中的pricing_option对象
|
||||
const [paymentOrder, setPaymentOrder] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [paymentCountdown, setPaymentCountdown] = useState(0);
|
||||
@@ -85,6 +89,13 @@ export default function SubscriptionContent() {
|
||||
const [forceUpdating, setForceUpdating] = useState(false);
|
||||
const [openFaqIndex, setOpenFaqIndex] = useState(null);
|
||||
|
||||
// 优惠码相关state
|
||||
const [promoCode, setPromoCode] = useState('');
|
||||
const [promoCodeApplied, setPromoCodeApplied] = useState(false);
|
||||
const [promoCodeError, setPromoCodeError] = useState('');
|
||||
const [validatingPromo, setValidatingPromo] = useState(false);
|
||||
const [priceInfo, setPriceInfo] = useState(null); // 价格信息(包含升级计算)
|
||||
|
||||
// 加载订阅套餐数据
|
||||
useEffect(() => {
|
||||
fetchSubscriptionPlans();
|
||||
@@ -149,7 +160,102 @@ export default function SubscriptionContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscribe = (plan) => {
|
||||
// 计算价格(包含升级和优惠码)
|
||||
const calculatePrice = async (plan, cycle, promoCodeValue = null) => {
|
||||
try {
|
||||
// 确保优惠码值正确:只接受非空字符串,其他情况传null
|
||||
const validPromoCode = promoCodeValue && typeof promoCodeValue === 'string' && promoCodeValue.trim()
|
||||
? promoCodeValue.trim()
|
||||
: null;
|
||||
|
||||
logger.debug('SubscriptionContent', '计算价格', {
|
||||
plan: plan.name,
|
||||
cycle,
|
||||
promoCodeValue,
|
||||
validPromoCode
|
||||
});
|
||||
|
||||
const response = await fetch('/api/subscription/calculate-price', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
to_plan: plan.name,
|
||||
to_cycle: cycle,
|
||||
promo_code: validPromoCode
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setPriceInfo(data.data);
|
||||
return data.data;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('SubscriptionContent', 'calculatePrice', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 验证优惠码
|
||||
const handleValidatePromoCode = async () => {
|
||||
const trimmedCode = promoCode.trim();
|
||||
|
||||
if (!trimmedCode) {
|
||||
setPromoCodeError('请输入优惠码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedPlan) {
|
||||
setPromoCodeError('请先选择套餐');
|
||||
return;
|
||||
}
|
||||
|
||||
setValidatingPromo(true);
|
||||
setPromoCodeError('');
|
||||
|
||||
try {
|
||||
// 重新计算价格,包含优惠码(使用去除空格后的值)
|
||||
const result = await calculatePrice(selectedPlan, selectedCycle, trimmedCode);
|
||||
|
||||
if (result && !result.promo_error) {
|
||||
setPromoCodeApplied(true);
|
||||
toast({
|
||||
title: '优惠码已应用',
|
||||
description: `节省 ¥${result.discount_amount.toFixed(2)}`,
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
setPromoCodeError(result?.promo_error || '优惠码无效');
|
||||
setPromoCodeApplied(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setPromoCodeError('验证失败,请重试');
|
||||
setPromoCodeApplied(false);
|
||||
} finally {
|
||||
setValidatingPromo(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 移除优惠码
|
||||
const handleRemovePromoCode = async () => {
|
||||
setPromoCode('');
|
||||
setPromoCodeApplied(false);
|
||||
setPromoCodeError('');
|
||||
// 重新计算价格(不含优惠码)
|
||||
if (selectedPlan) {
|
||||
await calculatePrice(selectedPlan, selectedCycle, null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscribe = async (plan) => {
|
||||
if (!user) {
|
||||
toast({
|
||||
title: '请先登录',
|
||||
@@ -178,6 +284,10 @@ export default function SubscriptionContent() {
|
||||
);
|
||||
|
||||
setSelectedPlan(plan);
|
||||
|
||||
// 计算价格(包含升级判断)
|
||||
await calculatePrice(plan, selectedCycle, promoCodeApplied ? promoCode : null);
|
||||
|
||||
onPaymentModalOpen();
|
||||
};
|
||||
|
||||
@@ -186,7 +296,7 @@ export default function SubscriptionContent() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const price = selectedCycle === 'monthly' ? selectedPlan.monthly_price : selectedPlan.yearly_price;
|
||||
const price = priceInfo?.final_amount || (selectedCycle === 'monthly' ? selectedPlan.monthly_price : selectedPlan.yearly_price);
|
||||
|
||||
// 🎯 追踪支付发起
|
||||
subscriptionEvents.trackPaymentInitiated({
|
||||
@@ -205,7 +315,8 @@ export default function SubscriptionContent() {
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
plan_name: selectedPlan.name,
|
||||
billing_cycle: selectedCycle
|
||||
billing_cycle: selectedCycle,
|
||||
promo_code: promoCodeApplied ? promoCode : null
|
||||
})
|
||||
});
|
||||
|
||||
@@ -477,15 +588,83 @@ export default function SubscriptionContent() {
|
||||
|
||||
const getCurrentPrice = (plan) => {
|
||||
if (!plan) return 0;
|
||||
|
||||
// 如果有pricing_options,使用它
|
||||
if (plan.pricing_options && plan.pricing_options.length > 0) {
|
||||
// 查找当前选中的周期选项
|
||||
const option = plan.pricing_options.find(opt =>
|
||||
opt.cycle_key === selectedCycle ||
|
||||
(selectedCycle === 'monthly' && opt.months === 1) ||
|
||||
(selectedCycle === 'yearly' && opt.months === 12)
|
||||
);
|
||||
return option ? option.price : plan.monthly_price;
|
||||
}
|
||||
|
||||
// 向后兼容:回退到monthly_price/yearly_price
|
||||
return selectedCycle === 'monthly' ? plan.monthly_price : plan.yearly_price;
|
||||
};
|
||||
|
||||
const getSavingsText = (plan) => {
|
||||
if (!plan || selectedCycle !== 'yearly') return null;
|
||||
const yearlyTotal = plan.monthly_price * 12;
|
||||
const savings = yearlyTotal - plan.yearly_price;
|
||||
const percentage = Math.round((savings / yearlyTotal) * 100);
|
||||
return `年付节省 ${percentage}%`;
|
||||
if (!plan) return null;
|
||||
|
||||
// 如果有pricing_options,从中查找discount_percent
|
||||
if (plan.pricing_options && plan.pricing_options.length > 0) {
|
||||
const currentOption = plan.pricing_options.find(opt =>
|
||||
opt.cycle_key === selectedCycle ||
|
||||
(selectedCycle === 'monthly' && opt.months === 1) ||
|
||||
(selectedCycle === 'yearly' && opt.months === 12)
|
||||
);
|
||||
|
||||
if (currentOption && currentOption.discount_percent) {
|
||||
return `立减 ${currentOption.discount_percent}%`;
|
||||
}
|
||||
|
||||
// 如果没有discount_percent,尝试计算节省金额
|
||||
if (currentOption && currentOption.months > 1) {
|
||||
const monthlyOption = plan.pricing_options.find(opt => opt.months === 1);
|
||||
if (monthlyOption) {
|
||||
const expectedTotal = monthlyOption.price * currentOption.months;
|
||||
const savings = expectedTotal - currentOption.price;
|
||||
if (savings > 0) {
|
||||
const percentage = Math.round((savings / expectedTotal) * 100);
|
||||
return `${currentOption.label || `${currentOption.months}个月`}节省 ${percentage}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 向后兼容:计算年付节省
|
||||
if (selectedCycle === 'yearly') {
|
||||
const yearlyTotal = plan.monthly_price * 12;
|
||||
const savings = yearlyTotal - plan.yearly_price;
|
||||
if (savings > 0) {
|
||||
const percentage = Math.round((savings / yearlyTotal) * 100);
|
||||
return `年付节省 ${percentage}%`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 获取按钮文字(根据用户当前订阅判断是升级还是新订阅)
|
||||
const getButtonText = (plan, user) => {
|
||||
if (!user || user.subscription_type === 'free') {
|
||||
return `选择 ${plan.display_name}`;
|
||||
}
|
||||
|
||||
// 判断是否为升级
|
||||
const planLevels = { 'free': 0, 'pro': 1, 'max': 2 };
|
||||
const currentLevel = planLevels[user.subscription_type] || 0;
|
||||
const targetLevel = planLevels[plan.name] || 0;
|
||||
|
||||
if (targetLevel > currentLevel) {
|
||||
return `升级至 ${plan.display_name}`;
|
||||
} else if (targetLevel < currentLevel) {
|
||||
return `切换至 ${plan.display_name}`;
|
||||
} else {
|
||||
// 同级别,可能是切换周期
|
||||
return `切换至 ${plan.display_name}`;
|
||||
}
|
||||
};
|
||||
|
||||
// 统一的功能列表定义 - 基于商业定价(10月15日)文档
|
||||
@@ -578,36 +757,107 @@ export default function SubscriptionContent() {
|
||||
)}
|
||||
|
||||
{/* 计费周期选择 */}
|
||||
<Flex justify="center">
|
||||
<HStack
|
||||
spacing={0}
|
||||
bg={bgAccent}
|
||||
borderRadius="lg"
|
||||
p={1}
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Button
|
||||
variant={selectedCycle === 'monthly' ? 'solid' : 'ghost'}
|
||||
colorScheme={selectedCycle === 'monthly' ? 'blue' : 'gray'}
|
||||
size="md"
|
||||
onClick={() => setSelectedCycle('monthly')}
|
||||
borderRadius="md"
|
||||
<Box>
|
||||
<Text textAlign="center" fontSize="sm" color={secondaryText} mb={3}>
|
||||
选择计费周期 · 时长越长优惠越大
|
||||
</Text>
|
||||
<Flex justify="center" mb={2}>
|
||||
<HStack
|
||||
spacing={2}
|
||||
bg={bgAccent}
|
||||
borderRadius="xl"
|
||||
p={2}
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
flexWrap="wrap"
|
||||
justify="center"
|
||||
>
|
||||
按月付费
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedCycle === 'yearly' ? 'solid' : 'ghost'}
|
||||
colorScheme={selectedCycle === 'yearly' ? 'blue' : 'gray'}
|
||||
size="md"
|
||||
onClick={() => setSelectedCycle('yearly')}
|
||||
borderRadius="md"
|
||||
>
|
||||
按年付费
|
||||
<Badge ml={2} colorScheme="red" fontSize="xs">省20%</Badge>
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
{(() => {
|
||||
// 获取第一个套餐的pricing_options作为周期选项(假设所有套餐都有相同的周期)
|
||||
const firstPlan = subscriptionPlans.find(plan => plan.pricing_options);
|
||||
const cycleOptions = firstPlan?.pricing_options || [
|
||||
{ cycle_key: 'monthly', label: '月付', months: 1 },
|
||||
{ cycle_key: 'yearly', label: '年付', months: 12, discount_percent: 20 }
|
||||
];
|
||||
|
||||
return cycleOptions.map((option, index) => {
|
||||
const cycleKey = option.cycle_key || (option.months === 1 ? 'monthly' : option.months === 12 ? 'yearly' : `${option.months}months`);
|
||||
const isSelected = selectedCycle === cycleKey;
|
||||
const hasDiscount = option.discount_percent && option.discount_percent > 0;
|
||||
|
||||
return (
|
||||
<VStack
|
||||
key={index}
|
||||
spacing={0}
|
||||
position="relative"
|
||||
>
|
||||
{/* 折扣标签 */}
|
||||
{hasDiscount && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top="-8px"
|
||||
colorScheme="red"
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
borderRadius="full"
|
||||
fontWeight="bold"
|
||||
>
|
||||
省{option.discount_percent}%
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={isSelected ? 'solid' : 'outline'}
|
||||
colorScheme={isSelected ? 'blue' : 'gray'}
|
||||
size="md"
|
||||
onClick={() => setSelectedCycle(cycleKey)}
|
||||
borderRadius="lg"
|
||||
minW="80px"
|
||||
h="50px"
|
||||
position="relative"
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
shadow: 'md'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<VStack spacing={0}>
|
||||
<Text fontSize="md" fontWeight="bold">
|
||||
{option.label || `${option.months}个月`}
|
||||
</Text>
|
||||
{hasDiscount && (
|
||||
<Text fontSize="xs" color={isSelected ? 'white' : 'gray.500'}>
|
||||
更优惠
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Button>
|
||||
</VStack>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</HStack>
|
||||
</Flex>
|
||||
{/* 提示文字 */}
|
||||
{(() => {
|
||||
const firstPlan = subscriptionPlans.find(plan => plan.pricing_options);
|
||||
const cycleOptions = firstPlan?.pricing_options || [];
|
||||
const currentOption = cycleOptions.find(opt =>
|
||||
opt.cycle_key === selectedCycle ||
|
||||
(selectedCycle === 'monthly' && opt.months === 1) ||
|
||||
(selectedCycle === 'yearly' && opt.months === 12)
|
||||
);
|
||||
|
||||
if (currentOption && currentOption.discount_percent > 0) {
|
||||
return (
|
||||
<Text textAlign="center" fontSize="sm" color="green.600" fontWeight="medium">
|
||||
🎉 当前选择可节省 {currentOption.discount_percent}% 的费用
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</Box>
|
||||
|
||||
{/* 订阅套餐 */}
|
||||
<Grid
|
||||
@@ -780,19 +1030,69 @@ export default function SubscriptionContent() {
|
||||
{getCurrentPrice(plan).toFixed(0)}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
/{selectedCycle === 'monthly' ? '月' : '年'}
|
||||
{(() => {
|
||||
if (plan.pricing_options) {
|
||||
const option = plan.pricing_options.find(opt =>
|
||||
opt.cycle_key === selectedCycle ||
|
||||
(selectedCycle === 'monthly' && opt.months === 1) ||
|
||||
(selectedCycle === 'yearly' && opt.months === 12)
|
||||
);
|
||||
if (option) {
|
||||
// 如果months是1,显示"/月";如果是12,显示"/年";否则显示周期label
|
||||
if (option.months === 1) return '/月';
|
||||
if (option.months === 12) return '/年';
|
||||
return `/${option.months}个月`;
|
||||
}
|
||||
}
|
||||
return selectedCycle === 'monthly' ? '/月' : '/年';
|
||||
})()}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text fontSize="xs" color={secondaryText} pl={11}>
|
||||
<Flex justify="space-between" align="center" flexWrap="wrap" gap={2}>
|
||||
<Text fontSize="xs" color={secondaryText} pl={11} flex={1}>
|
||||
{plan.description}
|
||||
</Text>
|
||||
{getSavingsText(plan) && (
|
||||
<Badge colorScheme="green" fontSize="xs" px={2} py={1}>
|
||||
{getSavingsText(plan)}
|
||||
</Badge>
|
||||
)}
|
||||
{(() => {
|
||||
// 获取当前选中的周期信息
|
||||
if (plan.pricing_options) {
|
||||
const currentOption = plan.pricing_options.find(opt =>
|
||||
opt.cycle_key === selectedCycle ||
|
||||
(selectedCycle === 'monthly' && opt.months === 1) ||
|
||||
(selectedCycle === 'yearly' && opt.months === 12)
|
||||
);
|
||||
|
||||
if (currentOption && currentOption.discount_percent > 0) {
|
||||
// 计算原价和节省金额
|
||||
const monthlyOption = plan.pricing_options.find(opt => opt.months === 1);
|
||||
if (monthlyOption) {
|
||||
const originalPrice = monthlyOption.price * currentOption.months;
|
||||
const savedAmount = originalPrice - currentOption.price;
|
||||
|
||||
return (
|
||||
<VStack spacing={0} align="flex-end">
|
||||
<Badge colorScheme="red" fontSize="xs" px={3} py={1} borderRadius="full">
|
||||
立省 {currentOption.discount_percent}%
|
||||
</Badge>
|
||||
<Text fontSize="xs" color="gray.500" textDecoration="line-through">
|
||||
原价¥{originalPrice.toFixed(0)}
|
||||
</Text>
|
||||
<Text fontSize="xs" color="green.600" fontWeight="bold">
|
||||
省¥{savedAmount.toFixed(0)}
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge colorScheme="green" fontSize="xs" px={3} py={1} borderRadius="full">
|
||||
{getSavingsText(plan)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</Flex>
|
||||
</VStack>
|
||||
|
||||
@@ -838,7 +1138,8 @@ export default function SubscriptionContent() {
|
||||
onClick={() => handleSubscribe(plan)}
|
||||
isDisabled={
|
||||
user?.subscription_type === plan.name &&
|
||||
user?.subscription_status === 'active'
|
||||
user?.subscription_status === 'active' &&
|
||||
user?.billing_cycle === selectedCycle
|
||||
}
|
||||
_hover={{
|
||||
transform: 'scale(1.02)',
|
||||
@@ -846,9 +1147,10 @@ export default function SubscriptionContent() {
|
||||
transition="all 0.2s"
|
||||
>
|
||||
{user?.subscription_type === plan.name &&
|
||||
user?.subscription_status === 'active'
|
||||
? '✓ 已订阅'
|
||||
: `选择 ${plan.display_name}`
|
||||
user?.subscription_status === 'active' &&
|
||||
user?.billing_cycle === selectedCycle
|
||||
? '✓ 当前套餐'
|
||||
: getButtonText(plan, user)
|
||||
}
|
||||
</Button>
|
||||
</VStack>
|
||||
@@ -958,7 +1260,7 @@ export default function SubscriptionContent() {
|
||||
align="center"
|
||||
>
|
||||
<Text fontWeight="semibold" color={textColor}>
|
||||
可以在月付和年付之间切换吗?
|
||||
升级或切换套餐时,原套餐的费用怎么办?
|
||||
</Text>
|
||||
<Icon
|
||||
as={openFaqIndex === 2 ? FaChevronUp : FaChevronDown}
|
||||
@@ -967,14 +1269,28 @@ export default function SubscriptionContent() {
|
||||
</Flex>
|
||||
<Collapse in={openFaqIndex === 2}>
|
||||
<Box p={4} pt={0} color={secondaryText}>
|
||||
<Text>
|
||||
可以。您可以随时更改计费周期。如果从月付切换到年付,系统会计算剩余价值并应用到新的订阅中。年付用户可享受20%的折扣优惠。
|
||||
</Text>
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Text>
|
||||
当您升级套餐或切换计费周期时,系统会自动计算您当前订阅的剩余价值并用于抵扣新套餐的费用。
|
||||
</Text>
|
||||
<Text fontWeight="medium" fontSize="sm">
|
||||
计算方式:
|
||||
</Text>
|
||||
<Text fontSize="sm" pl={3}>
|
||||
• <strong>剩余价值</strong> = 原套餐价格 × (剩余天数 / 总天数)
|
||||
</Text>
|
||||
<Text fontSize="sm" pl={3}>
|
||||
• <strong>实付金额</strong> = 新套餐价格 - 剩余价值 - 优惠码折扣
|
||||
</Text>
|
||||
<Text fontSize="sm" color="blue.600" mt={2}>
|
||||
例如:您购买了年付Pro版(¥999),使用了180天后升级到Max版(¥1999/年),剩余价值约¥500将自动抵扣,实付约¥1499。
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* FAQ 4 */}
|
||||
{/* FAQ 4 - 原FAQ 3 */}
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
@@ -992,7 +1308,7 @@ export default function SubscriptionContent() {
|
||||
align="center"
|
||||
>
|
||||
<Text fontWeight="semibold" color={textColor}>
|
||||
是否提供退款?
|
||||
可以在月付和年付之间切换吗?
|
||||
</Text>
|
||||
<Icon
|
||||
as={openFaqIndex === 3 ? FaChevronUp : FaChevronDown}
|
||||
@@ -1002,13 +1318,13 @@ export default function SubscriptionContent() {
|
||||
<Collapse in={openFaqIndex === 3}>
|
||||
<Box p={4} pt={0} color={secondaryText}>
|
||||
<Text>
|
||||
我们提供7天无理由退款保证。如果您在订阅后7天内对服务不满意,可以申请全额退款。超过7天后,我们将根据实际使用情况进行评估。
|
||||
可以。您可以随时更改计费周期。如果从月付切换到年付,系统会计算剩余价值并应用到新的订阅中。年付用户可享受20%的折扣优惠。
|
||||
</Text>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* FAQ 5 */}
|
||||
{/* FAQ 5 - 原FAQ 4 */}
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
@@ -1026,7 +1342,7 @@ export default function SubscriptionContent() {
|
||||
align="center"
|
||||
>
|
||||
<Text fontWeight="semibold" color={textColor}>
|
||||
Pro版和Max版有什么区别?
|
||||
是否支持退款?
|
||||
</Text>
|
||||
<Icon
|
||||
as={openFaqIndex === 4 ? FaChevronUp : FaChevronDown}
|
||||
@@ -1034,6 +1350,60 @@ export default function SubscriptionContent() {
|
||||
/>
|
||||
</Flex>
|
||||
<Collapse in={openFaqIndex === 4}>
|
||||
<Box p={4} pt={0} color={secondaryText}>
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Text>
|
||||
为了保障服务质量和维护公平的商业环境,我们<strong>不支持退款</strong>。
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
建议您在订阅前:
|
||||
</Text>
|
||||
<Text fontSize="sm" pl={3}>
|
||||
• 充分了解各套餐的功能差异
|
||||
</Text>
|
||||
<Text fontSize="sm" pl={3}>
|
||||
• 使用免费版体验基础功能
|
||||
</Text>
|
||||
<Text fontSize="sm" pl={3}>
|
||||
• 根据实际需求选择合适的计费周期
|
||||
</Text>
|
||||
<Text fontSize="sm" pl={3}>
|
||||
• 如有疑问可联系客服咨询
|
||||
</Text>
|
||||
<Text fontSize="sm" color="blue.600" mt={2}>
|
||||
提示:选择长期套餐(如半年付、年付)可享受更大折扣,性价比更高。
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* FAQ 6 - 原FAQ 5 */}
|
||||
<Box
|
||||
border="1px solid"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Flex
|
||||
p={4}
|
||||
cursor="pointer"
|
||||
onClick={() => setOpenFaqIndex(openFaqIndex === 5 ? null : 5)}
|
||||
bg={openFaqIndex === 5 ? bgAccent : 'transparent'}
|
||||
_hover={{ bg: bgAccent }}
|
||||
transition="all 0.2s"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
>
|
||||
<Text fontWeight="semibold" color={textColor}>
|
||||
Pro版和Max版有什么区别?
|
||||
</Text>
|
||||
<Icon
|
||||
as={openFaqIndex === 5 ? FaChevronUp : FaChevronDown}
|
||||
color={textColor}
|
||||
/>
|
||||
</Flex>
|
||||
<Collapse in={openFaqIndex === 5}>
|
||||
<Box p={4} pt={0} color={secondaryText}>
|
||||
<Text>
|
||||
Pro版适合个人专业用户,提供高级图表、历史数据分析等功能。Max版则是为团队和企业设计,额外提供实时数据推送、API访问、无限制的数据存储和团队协作功能,并享有优先技术支持。
|
||||
@@ -1052,6 +1422,11 @@ export default function SubscriptionContent() {
|
||||
stopAutoPaymentCheck();
|
||||
setPaymentOrder(null);
|
||||
setPaymentCountdown(0);
|
||||
// 清空优惠码状态
|
||||
setPromoCode('');
|
||||
setPromoCodeApplied(false);
|
||||
setPromoCodeError('');
|
||||
setPriceInfo(null);
|
||||
onPaymentModalClose();
|
||||
}}
|
||||
size="lg"
|
||||
@@ -1082,16 +1457,76 @@ export default function SubscriptionContent() {
|
||||
</Flex>
|
||||
<Flex justify="space-between">
|
||||
<Text color={secondaryText}>计费周期:</Text>
|
||||
<Text>{selectedCycle === 'monthly' ? '按月付费' : '按年付费'}</Text>
|
||||
</Flex>
|
||||
<Divider />
|
||||
<Flex justify="space-between" align="baseline">
|
||||
<Text color={secondaryText}>应付金额:</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="blue.500">
|
||||
¥{getCurrentPrice(selectedPlan).toFixed(2)}
|
||||
<Text>
|
||||
{(() => {
|
||||
if (selectedPlan?.pricing_options) {
|
||||
const option = selectedPlan.pricing_options.find(opt =>
|
||||
opt.cycle_key === selectedCycle ||
|
||||
(selectedCycle === 'monthly' && opt.months === 1) ||
|
||||
(selectedCycle === 'yearly' && opt.months === 12)
|
||||
);
|
||||
return option?.label || (selectedCycle === 'monthly' ? '按月付费' : '按年付费');
|
||||
}
|
||||
return selectedCycle === 'monthly' ? '按月付费' : '按年付费';
|
||||
})()}
|
||||
</Text>
|
||||
</Flex>
|
||||
{getSavingsText(selectedPlan) && (
|
||||
|
||||
{/* 价格明细 */}
|
||||
<Divider my={2} />
|
||||
|
||||
{priceInfo && priceInfo.is_upgrade && (
|
||||
<Box bg="blue.50" p={3} borderRadius="md" mb={2}>
|
||||
<HStack spacing={2} mb={2}>
|
||||
<Icon as={FaCheck} color="blue.500" boxSize={4} />
|
||||
<Text fontSize="sm" fontWeight="bold" color="blue.700">
|
||||
{priceInfo.upgrade_type === 'plan_upgrade' ? '套餐升级' :
|
||||
priceInfo.upgrade_type === 'cycle_change' ? '周期变更' : '套餐和周期调整'}
|
||||
</Text>
|
||||
</HStack>
|
||||
<VStack spacing={1} align="stretch" fontSize="xs">
|
||||
<Flex justify="space-between" color="gray.600">
|
||||
<Text>当前订阅: {priceInfo.current_plan === 'pro' ? 'Pro版' : 'Max版'} ({priceInfo.current_cycle === 'monthly' ? '月付' : '年付'})</Text>
|
||||
</Flex>
|
||||
<Flex justify="space-between" color="gray.600">
|
||||
<Text>剩余价值:</Text>
|
||||
<Text>¥{priceInfo.remaining_value.toFixed(2)}</Text>
|
||||
</Flex>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Flex justify="space-between">
|
||||
<Text color={secondaryText}>
|
||||
{priceInfo && priceInfo.is_upgrade ? '新套餐价格:' : '套餐价格:'}
|
||||
</Text>
|
||||
<Text fontWeight="medium">
|
||||
¥{priceInfo ? priceInfo.new_plan_price.toFixed(2) : getCurrentPrice(selectedPlan).toFixed(2)}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{priceInfo && priceInfo.is_upgrade && priceInfo.remaining_value > 0 && (
|
||||
<Flex justify="space-between" color="blue.600">
|
||||
<Text>已付剩余抵扣:</Text>
|
||||
<Text>-¥{priceInfo.remaining_value.toFixed(2)}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{priceInfo && priceInfo.discount_amount > 0 && (
|
||||
<Flex justify="space-between" color="green.600">
|
||||
<Text>优惠码折扣:</Text>
|
||||
<Text>-¥{priceInfo.discount_amount.toFixed(2)}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
<Flex justify="space-between" align="baseline">
|
||||
<Text fontSize="lg" fontWeight="bold">实付金额:</Text>
|
||||
<Text fontSize="2xl" fontWeight="bold" color="blue.500">
|
||||
¥{priceInfo ? priceInfo.final_amount.toFixed(2) : getCurrentPrice(selectedPlan).toFixed(2)}
|
||||
</Text>
|
||||
</Flex>
|
||||
{getSavingsText(selectedPlan) && !priceInfo?.is_upgrade && (
|
||||
<Badge colorScheme="green" alignSelf="flex-end" fontSize="xs">
|
||||
{getSavingsText(selectedPlan)}
|
||||
</Badge>
|
||||
@@ -1104,6 +1539,53 @@ export default function SubscriptionContent() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 优惠码输入 */}
|
||||
{selectedPlan && (
|
||||
<Box>
|
||||
<HStack spacing={2}>
|
||||
<Input
|
||||
placeholder="输入优惠码(可选)"
|
||||
value={promoCode}
|
||||
onChange={(e) => {
|
||||
setPromoCode(e.target.value.toUpperCase());
|
||||
setPromoCodeError('');
|
||||
}}
|
||||
size="md"
|
||||
isDisabled={promoCodeApplied}
|
||||
/>
|
||||
<Button
|
||||
colorScheme="purple"
|
||||
onClick={handleValidatePromoCode}
|
||||
isLoading={validatingPromo}
|
||||
isDisabled={!promoCode || promoCodeApplied}
|
||||
minW="80px"
|
||||
>
|
||||
应用
|
||||
</Button>
|
||||
</HStack>
|
||||
{promoCodeError && (
|
||||
<Text color="red.500" fontSize="sm" mt={2}>
|
||||
{promoCodeError}
|
||||
</Text>
|
||||
)}
|
||||
{promoCodeApplied && priceInfo && (
|
||||
<HStack mt={2} p={2} bg="green.50" borderRadius="md">
|
||||
<Icon as={FaCheck} color="green.500" />
|
||||
<Text color="green.700" fontSize="sm" fontWeight="medium" flex={1}>
|
||||
优惠码已应用!节省 ¥{priceInfo.discount_amount.toFixed(2)}
|
||||
</Text>
|
||||
<Icon
|
||||
as={FaTimes}
|
||||
color="gray.500"
|
||||
cursor="pointer"
|
||||
onClick={handleRemovePromoCode}
|
||||
_hover={{ color: 'red.500' }}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
colorScheme="green"
|
||||
size="lg"
|
||||
|
||||
83
src/components/SubscriptionBadge.js
Normal file
83
src/components/SubscriptionBadge.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// src/components/SubscriptionBadge.js
|
||||
// 会员专享标签组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Badge,
|
||||
HStack,
|
||||
Icon,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaStar, FaCrown } from 'react-icons/fa';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* 会员专享标签组件
|
||||
* @param {Object} props
|
||||
* @param {'pro' | 'max'} props.tier - 会员等级:pro 或 max
|
||||
* @param {'sm' | 'md'} props.size - 标签尺寸
|
||||
*/
|
||||
const SubscriptionBadge = ({ tier = 'pro', size = 'sm' }) => {
|
||||
// PRO 和 MAX 配置
|
||||
const config = {
|
||||
pro: {
|
||||
label: 'PRO专享',
|
||||
icon: FaStar,
|
||||
bgGradient: 'linear(to-r, blue.400, purple.500)',
|
||||
color: 'white',
|
||||
},
|
||||
max: {
|
||||
label: 'MAX专享',
|
||||
icon: FaCrown,
|
||||
bgGradient: 'linear(to-r, pink.400, red.500)',
|
||||
color: 'white',
|
||||
},
|
||||
};
|
||||
|
||||
const tierConfig = config[tier] || config.pro;
|
||||
|
||||
// 尺寸配置
|
||||
const sizeConfig = {
|
||||
sm: {
|
||||
fontSize: 'xs',
|
||||
iconSize: 2.5,
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
},
|
||||
md: {
|
||||
fontSize: 'sm',
|
||||
iconSize: 3,
|
||||
px: 3,
|
||||
py: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const currentSize = sizeConfig[size] || sizeConfig.sm;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
bgGradient={tierConfig.bgGradient}
|
||||
color={tierConfig.color}
|
||||
borderRadius="full"
|
||||
px={currentSize.px}
|
||||
py={currentSize.py}
|
||||
fontSize={currentSize.fontSize}
|
||||
fontWeight="bold"
|
||||
boxShadow="sm"
|
||||
display="inline-flex"
|
||||
alignItems="center"
|
||||
gap={1}
|
||||
transition="all 0.2s"
|
||||
_hover={{
|
||||
transform: 'scale(1.05)',
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
>
|
||||
<Icon as={tierConfig.icon} boxSize={currentSize.iconSize} />
|
||||
<Text>{tierConfig.label}</Text>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionBadge;
|
||||
@@ -1,275 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Text,
|
||||
Box,
|
||||
VStack,
|
||||
Divider,
|
||||
useColorModeValue
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
const UserAgreementModal = ({ isOpen, onClose }) => {
|
||||
const modalBg = useColorModeValue("white", "gray.800");
|
||||
const headingColor = useColorModeValue("gray.800", "white");
|
||||
const textColor = useColorModeValue("gray.600", "gray.300");
|
||||
|
||||
// Conditional rendering: only render Modal when open
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="full"
|
||||
scrollBehavior="inside"
|
||||
isCentered
|
||||
>
|
||||
<ModalOverlay bg="blackAlpha.600" />
|
||||
<ModalContent
|
||||
maxW="95vw"
|
||||
maxH="95vh"
|
||||
bg={modalBg}
|
||||
borderRadius="xl"
|
||||
boxShadow="2xl"
|
||||
mx={4}
|
||||
>
|
||||
<ModalHeader
|
||||
fontSize="2xl"
|
||||
fontWeight="bold"
|
||||
color={headingColor}
|
||||
borderBottom="1px solid"
|
||||
borderColor="gray.200"
|
||||
borderRadius="xl xl 0 0"
|
||||
py={6}
|
||||
>
|
||||
价值前沿用户协议
|
||||
</ModalHeader>
|
||||
<ModalCloseButton
|
||||
size="lg"
|
||||
_hover={{ bg: "gray.100" }}
|
||||
/>
|
||||
<ModalBody py={8} px={8}>
|
||||
<VStack spacing={6} align="stretch">
|
||||
<Box bg="orange.50" p={6} borderRadius="lg" border="1px solid" borderColor="orange.100">
|
||||
<Text fontSize="xl" fontWeight="bold" color="orange.700" mb={6}>
|
||||
欢迎你使用价值前沿及服务!
|
||||
</Text>
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={5}>
|
||||
为使用价值前沿(以下简称"本软件")及服务,你应当阅读并遵守《价值前沿用户协议》(以下简称"本协议")。请你务必审慎阅读、充分理解各条款内容,特别是免除或者限制责任的条款,以及开通或使用某项服务的单独协议,并选择接受或不接受。限制、免责条款可能以加粗形式提示你注意。
|
||||
</Text>
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={5}>
|
||||
除非你已阅读并接受本协议所有条款,否则你无权下载、安装或使用本软件及相关服务。你的下载、安装、使用、获取价值前沿帐号、登录等行为即视为你已阅读并同意上述协议的约束。
|
||||
</Text>
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" fontWeight="medium">
|
||||
如果你未满18周岁,请在法定监护人的陪同下阅读本协议及其他上述协议,并特别注意未成年人使用条款。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="xl" fontWeight="bold" color={headingColor} mb={6}>
|
||||
一、协议的范围
|
||||
</Text>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="blue.300" bg="blue.50" p={4} borderRadius="md" mb={4}>
|
||||
<Text fontSize="lg" fontWeight="semibold" color="blue.700" mb={2}>
|
||||
1.1 协议适用主体范围
|
||||
</Text>
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8">
|
||||
本协议是你与北京价值前沿科技有限公司之间关于你下载、安装、使用、复制本软件,以及使用价值前沿相关服务所订立的协议。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="green.300" bg="green.50" p={4} borderRadius="md" mb={4}>
|
||||
<Text fontSize="lg" fontWeight="semibold" color="green.700" mb={2}>
|
||||
1.2 协议关系及冲突条款
|
||||
</Text>
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8">
|
||||
本协议内容同时包括北京价值前沿科技有限公司可能不断发布的关于本服务的相关协议、业务规则等内容。上述内容一经正式发布,即为本协议不可分割的组成部分,你同样应当遵守。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="purple.300" bg="purple.50" p={4} borderRadius="md" mb={4}>
|
||||
<Text fontSize="lg" fontWeight="semibold" color="purple.700" mb={2}>
|
||||
1.3 许可范围
|
||||
</Text>
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8">
|
||||
明确标识免费产品的,用户可以进行自用的、非商业性、无限制数量地下载、安装及使用,但不得复制、分发。其他收费类产品或者信息,除遵守本协议规定之外,还须遵守专门协议的规定。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="red.300" bg="red.50" p={4} borderRadius="md">
|
||||
<Text fontSize="lg" fontWeight="semibold" color="red.700" mb={2}>
|
||||
1.4 权利限制
|
||||
</Text>
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8">
|
||||
禁止反向工程、反向编译和反向汇编:用户不得对价值前沿软件类产品进行反向工程、反向编译或反向汇编,同时不得改动编译程序文件内部的任何资源。除法律、法规明文规定允许上述活动外,用户必须遵守此协议限制。
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
|
||||
二、关于本服务
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
本服务内容是指北京价值前沿科技有限公司向用户提供的跨平台的社交资讯工具(以下简称"价值前沿"),支持单人、多人参与,在发布图片和文字等内容服务的基础上,同时为用户提供包括但不限于社交关系拓展、便捷工具等功能或内容的软件服务(以下简称"本服务")。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
|
||||
三、免责条款
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
北京价值前沿科技有限公司作为面向全球投资人提供信息和服务的商家,对以下情况不承担相关责任:
|
||||
</Text>
|
||||
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="yellow.400" bg="yellow.50" p={3} borderRadius="md">
|
||||
<Text fontSize="lg" fontWeight="semibold" color="yellow.700" mb={2}>
|
||||
1)不可抗力
|
||||
</Text>
|
||||
<Text fontSize="md" color={textColor} lineHeight="1.6">
|
||||
如因发生自然灾害、战争、第三方侵害等不可控因素而发生的信息、服务中断,价值前沿不承担相应责任。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="yellow.400" bg="yellow.50" p={3} borderRadius="md">
|
||||
<Text fontSize="lg" fontWeight="semibold" color="yellow.700" mb={2}>
|
||||
2)信息网络传播
|
||||
</Text>
|
||||
<Text fontSize="md" color={textColor} lineHeight="1.6">
|
||||
如因信息网络传播中的拥塞、断续、病毒木马、黑客窃取侦听等网络通道上的因素,而造成信息缺失、丢失、延迟、被篡改等,价值前沿不对此承担相应责任。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="yellow.400" bg="yellow.50" p={3} borderRadius="md">
|
||||
<Text fontSize="lg" fontWeight="semibold" color="yellow.700" mb={2}>
|
||||
3)第三方信息的收集整理
|
||||
</Text>
|
||||
<Text fontSize="md" color={textColor} lineHeight="1.6">
|
||||
价值前沿为了更好地服务投资者,便于用户分析研判投资环境,尽可能多地收集整理来自第三方的所有信息,分门别类地提供给用户参考,并明确标识为来自第三方的信息,而对内容的真实性、合理性、完整性、合法性等并不承担判断责任,也不承担用户因信息而造成的损失责任。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="yellow.400" bg="yellow.50" p={3} borderRadius="md">
|
||||
<Text fontSize="lg" fontWeight="semibold" color="yellow.700" mb={2}>
|
||||
4)证券信息汇总
|
||||
</Text>
|
||||
<Text fontSize="md" color={textColor} lineHeight="1.6">
|
||||
价值前沿仅提供证券信息汇总及证券投资品种历史数据统计功能,不针对用户提供任何情况判断、投资参考、品种操作建议等等,不属于荐股软件。用户按照自身对于市场环境的分析研判而做出的评论参考,用户可以结合自身需求予以借鉴,并自行作出判断,风险和收益都由用户自行承担。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="yellow.400" bg="yellow.50" p={3} borderRadius="md">
|
||||
<Text fontSize="lg" fontWeight="semibold" color="yellow.700" mb={2}>
|
||||
5)信息储存
|
||||
</Text>
|
||||
<Text fontSize="md" color={textColor} lineHeight="1.6">
|
||||
用户在使用价值前沿系统时,会因信息注册、产品购买、软件使用过程中的某些需求,而留存于系统中的账户、密码、真实身份、联系方式、用户网络信息等个人信息,价值前沿将按照国家相关规定进行必要的保护。
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
|
||||
四、用户个人信息保护
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
保护用户个人信息是北京价值前沿科技有限公司的一项基本原则,北京价值前沿科技有限公司将会采取合理的措施保护用户的个人信息。除法律法规规定的情形外,未经用户许可北京价值前沿科技有限公司不会向第三方公开、透露用户个人信息。
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
你在注册帐号或使用本服务的过程中,需要提供一些必要的信息,例如:为向你提供帐号注册服务或进行用户身份识别,需要你填写手机号码;手机通讯录匹配功能需要你授权访问手机通讯录等。若国家法律法规或政策有特殊规定的,你需要提供真实的身份信息。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
|
||||
五、用户行为规范
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
你理解并同意,价值前沿一直致力于为用户提供文明健康、规范有序的网络环境,你不得利用价值前沿帐号或本软件及服务制作、复制、发布、传播干扰正常运营,以及侵犯其他用户或第三方合法权益的内容。
|
||||
</Text>
|
||||
|
||||
<Box pl={4} borderLeft="3px solid" borderColor="red.400" bg="red.50" p={4} borderRadius="md">
|
||||
<Text fontSize="lg" fontWeight="semibold" color="red.700" mb={2}>
|
||||
禁止内容包括但不限于:
|
||||
</Text>
|
||||
<VStack align="start" spacing={1} pl={4}>
|
||||
<Text fontSize="md" color={textColor}>• 违反宪法确定的基本原则的内容</Text>
|
||||
<Text fontSize="md" color={textColor}>• 危害国家安全,泄露国家秘密的内容</Text>
|
||||
<Text fontSize="md" color={textColor}>• 损害国家荣誉和利益的内容</Text>
|
||||
<Text fontSize="md" color={textColor}>• 散布谣言,扰乱社会秩序的内容</Text>
|
||||
<Text fontSize="md" color={textColor}>• 散布淫秽、色情、赌博、暴力、恐怖的内容</Text>
|
||||
<Text fontSize="md" color={textColor}>• 侮辱或者诽谤他人,侵害他人合法权益的内容</Text>
|
||||
<Text fontSize="md" color={textColor}>• 其他违反法律法规的内容</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
|
||||
六、知识产权声明
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
北京价值前沿科技有限公司是本软件的知识产权权利人。本软件的一切著作权、商标权、专利权、商业秘密等知识产权,以及与本软件相关的所有信息内容(包括但不限于文字、图片、音频、视频、图表、界面设计、版面框架、有关数据或电子文档等)均受中华人民共和国法律法规和相应的国际条约保护。
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
未经北京价值前沿科技有限公司或相关权利人书面同意,你不得为任何商业或非商业目的自行或许可任何第三方实施、利用、转让上述知识产权。
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box>
|
||||
<Text fontSize="lg" fontWeight="bold" color={headingColor} mb={4}>
|
||||
七、其他条款
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
你使用本软件即视为你已阅读并同意受本协议的约束。北京价值前沿科技有限公司有权在必要时修改本协议条款。你可以在本软件的最新版本中查阅相关协议条款。
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8" mb={4}>
|
||||
本协议签订地为中华人民共和国北京市海淀区。本协议的成立、生效、履行、解释及纠纷解决,适用中华人民共和国大陆地区法律(不包括冲突法)。
|
||||
</Text>
|
||||
|
||||
<Text fontSize="lg" color={textColor} lineHeight="1.8">
|
||||
若你和北京价值前沿科技有限公司之间发生任何纠纷或争议,首先应友好协商解决;协商不成的,你同意将纠纷或争议提交本协议签订地有管辖权的人民法院管辖。
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserAgreementModal;
|
||||
@@ -15,47 +15,59 @@ import {
|
||||
export const IMPORTANCE_LEVELS = {
|
||||
'S': {
|
||||
level: 'S',
|
||||
color: 'purple.600',
|
||||
bgColor: 'purple.50',
|
||||
borderColor: 'purple.200',
|
||||
color: 'red.800',
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.200',
|
||||
colorScheme: 'red',
|
||||
badgeBg: '#dc2626', // 圆形徽章背景色 - 红色
|
||||
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
|
||||
icon: WarningIcon,
|
||||
label: '极高',
|
||||
dotBg: 'purple.500',
|
||||
dotBg: 'red.800',
|
||||
description: '重大事件,市场影响深远',
|
||||
antdColor: '#722ed1', // 对应 Ant Design 的紫色
|
||||
antdColor: '#cf1322',
|
||||
},
|
||||
'A': {
|
||||
level: 'A',
|
||||
color: 'red.600',
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.200',
|
||||
colorScheme: 'red',
|
||||
badgeBg: '#ea580c', // 圆形徽章背景色 - 橙色
|
||||
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
|
||||
icon: WarningTwoIcon,
|
||||
label: '高',
|
||||
dotBg: 'red.500',
|
||||
dotBg: 'red.600',
|
||||
description: '重要事件,影响较大',
|
||||
antdColor: '#ff4d4f', // 对应 Ant Design 的红色
|
||||
antdColor: '#ff4d4f',
|
||||
},
|
||||
'B': {
|
||||
level: 'B',
|
||||
color: 'orange.600',
|
||||
bgColor: 'orange.50',
|
||||
borderColor: 'orange.200',
|
||||
color: 'red.500',
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.100',
|
||||
colorScheme: 'red',
|
||||
badgeBg: '#2563eb', // 圆形徽章背景色 - 蓝色
|
||||
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
|
||||
icon: InfoIcon,
|
||||
label: '中',
|
||||
dotBg: 'orange.500',
|
||||
dotBg: 'red.500',
|
||||
description: '普通事件,有一定影响',
|
||||
antdColor: '#faad14', // 对应 Ant Design 的橙色
|
||||
antdColor: '#ff7875',
|
||||
},
|
||||
'C': {
|
||||
level: 'C',
|
||||
color: 'green.600',
|
||||
bgColor: 'green.50',
|
||||
borderColor: 'green.200',
|
||||
color: 'red.400',
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.100',
|
||||
colorScheme: 'red',
|
||||
badgeBg: '#6b7280', // 圆形徽章背景色 - 灰色
|
||||
badgeColor: 'white', // 圆形徽章文字颜色 - 白色
|
||||
icon: CheckCircleIcon,
|
||||
label: '低',
|
||||
dotBg: 'green.500',
|
||||
dotBg: 'red.400',
|
||||
description: '参考事件,影响有限',
|
||||
antdColor: '#52c41a', // 对应 Ant Design 的绿色
|
||||
antdColor: '#ffa39e',
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
142
src/hooks/useDelayedMenu.js
Normal file
142
src/hooks/useDelayedMenu.js
Normal file
@@ -0,0 +1,142 @@
|
||||
// src/hooks/useDelayedMenu.js
|
||||
// 导航菜单延迟关闭 Hook - 优化 hover 和 click 交互体验
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* 自定义 Hook:提供带延迟关闭功能的菜单控制
|
||||
*
|
||||
* 解决问题:
|
||||
* 1. 用户快速移动鼠标导致菜单意外关闭
|
||||
* 2. Hover 和 Click 状态冲突
|
||||
* 3. 从 MenuButton 移动到 MenuList 时菜单闪烁
|
||||
*
|
||||
* 功能特性:
|
||||
* - ✅ Hover 进入:立即打开菜单
|
||||
* - ✅ Hover 离开:延迟关闭(默认 200ms)
|
||||
* - ✅ Click 切换:支持点击切换打开/关闭状态
|
||||
* - ✅ 智能取消:再次 hover 进入时取消关闭定时器
|
||||
*
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {number} options.closeDelay - 延迟关闭时间(毫秒),默认 200ms
|
||||
* @returns {Object} 菜单控制对象
|
||||
*/
|
||||
export function useDelayedMenu({ closeDelay = 200 } = {}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const closeTimerRef = useRef(null);
|
||||
const isClickedRef = useRef(false); // 追踪是否通过点击打开
|
||||
|
||||
/**
|
||||
* 打开菜单
|
||||
* - 立即打开,无延迟
|
||||
* - 清除任何待执行的关闭定时器
|
||||
*/
|
||||
const onOpen = useCallback(() => {
|
||||
// 清除待执行的关闭定时器
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 延迟关闭菜单
|
||||
* - 设置定时器,延迟后关闭
|
||||
* - 如果在延迟期间再次 hover 进入,会被 onOpen 取消
|
||||
*/
|
||||
const onDelayedClose = useCallback(() => {
|
||||
// 如果是点击打开的,hover 离开时不自动关闭
|
||||
if (isClickedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除之前的定时器(防止重复设置)
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
}
|
||||
|
||||
// 设置延迟关闭定时器
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
closeTimerRef.current = null;
|
||||
}, closeDelay);
|
||||
}, [closeDelay]);
|
||||
|
||||
/**
|
||||
* 立即关闭菜单
|
||||
* - 无延迟,立即关闭
|
||||
* - 清除所有定时器和状态标记
|
||||
*/
|
||||
const onClose = useCallback(() => {
|
||||
// 清除定时器
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
setIsOpen(false);
|
||||
isClickedRef.current = false;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 切换菜单状态(用于点击)
|
||||
* - 如果关闭 → 打开,并标记为点击打开
|
||||
* - 如果打开 → 关闭,并清除点击标记
|
||||
*/
|
||||
const onToggle = useCallback(() => {
|
||||
if (isOpen) {
|
||||
// 当前已打开 → 关闭
|
||||
onClose();
|
||||
} else {
|
||||
// 当前已关闭 → 打开
|
||||
onOpen();
|
||||
isClickedRef.current = true; // 标记为点击打开
|
||||
}
|
||||
}, [isOpen, onOpen, onClose]);
|
||||
|
||||
/**
|
||||
* Hover 进入处理
|
||||
* - 打开菜单
|
||||
* - 清除点击标记(允许 hover 离开时自动关闭)
|
||||
*/
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
onOpen();
|
||||
isClickedRef.current = false; // 清除点击标记,允许 hover 控制
|
||||
}, [onOpen]);
|
||||
|
||||
/**
|
||||
* Hover 离开处理
|
||||
* - 延迟关闭菜单
|
||||
*/
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
onDelayedClose();
|
||||
}, [onDelayedClose]);
|
||||
|
||||
/**
|
||||
* 点击处理
|
||||
* - 切换菜单状态
|
||||
*/
|
||||
const handleClick = useCallback(() => {
|
||||
onToggle();
|
||||
}, [onToggle]);
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
const cleanup = useCallback(() => {
|
||||
if (closeTimerRef.current) {
|
||||
clearTimeout(closeTimerRef.current);
|
||||
closeTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
onOpen,
|
||||
onClose,
|
||||
onDelayedClose,
|
||||
onToggle,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
handleClick,
|
||||
cleanup
|
||||
};
|
||||
}
|
||||
53
src/index.js
53
src/index.js
@@ -11,6 +11,49 @@ import './styles/brainwave-colors.css';
|
||||
// Import the main App component
|
||||
import App from './App';
|
||||
|
||||
// 注册 Service Worker(用于支持浏览器通知)
|
||||
function registerServiceWorker() {
|
||||
// ⚠️ Mock 模式下跳过 Service Worker 注册(避免与 MSW 冲突)
|
||||
if (process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
console.log(
|
||||
'%c[App] Mock 模式已启用,跳过通知 Service Worker 注册(避免与 MSW 冲突)',
|
||||
'color: #FF9800; font-weight: bold;'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 仅在支持 Service Worker 的浏览器中注册
|
||||
if ('serviceWorker' in navigator) {
|
||||
// 在页面加载完成后注册
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/service-worker.js')
|
||||
.then((registration) => {
|
||||
console.log('[App] Service Worker registered successfully:', registration.scope);
|
||||
|
||||
// 监听更新
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
console.log('[App] Service Worker update found');
|
||||
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'activated') {
|
||||
console.log('[App] Service Worker activated');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[App] Service Worker registration failed:', error);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.warn('[App] Service Worker is not supported in this browser');
|
||||
}
|
||||
}
|
||||
|
||||
// 启动 Mock Service Worker(如果启用)
|
||||
async function startApp() {
|
||||
// 只在开发环境启动 MSW
|
||||
@@ -25,11 +68,19 @@ async function startApp() {
|
||||
// Render the app with Router wrapper
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Router>
|
||||
<Router
|
||||
future={{
|
||||
// v7_startTransition: true, // 禁用:导致路由切换延迟2秒,影响用户体验
|
||||
v7_relativeSplatPath: true,
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</Router>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// 注册 Service Worker
|
||||
registerServiceWorker();
|
||||
}
|
||||
|
||||
// 启动应用
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
// src/layouts/Auth.js
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import ErrorBoundary from '../components/ErrorBoundary';
|
||||
|
||||
// 导入认证相关页面
|
||||
import SignInIllustration from '../views/Authentication/SignIn/SignInIllustration';
|
||||
import SignUpIllustration from '../views/Authentication/SignUp/SignUpIllustration';
|
||||
|
||||
// 认证路由组件 - 已登录用户不能访问登录页
|
||||
const AuthRoute = ({ children }) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
// 加载中不做跳转
|
||||
if (isLoading) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// 已登录用户跳转到首页
|
||||
if (isAuthenticated) {
|
||||
// 检查是否有记录的重定向路径
|
||||
const redirectPath = localStorage.getItem('redirectPath');
|
||||
if (redirectPath && redirectPath !== '/auth/signin' && redirectPath !== '/auth/sign-up') {
|
||||
localStorage.removeItem('redirectPath');
|
||||
return <Navigate to={redirectPath} replace />;
|
||||
}
|
||||
return <Navigate to="/home" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default function Auth() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<Box minH="100vh">
|
||||
<Routes>
|
||||
{/* 登录页面 */}
|
||||
<Route
|
||||
path="/signin"
|
||||
element={
|
||||
<AuthRoute>
|
||||
<SignInIllustration />
|
||||
</AuthRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 注册页面 */}
|
||||
<Route
|
||||
path="/sign-up"
|
||||
element={
|
||||
<AuthRoute>
|
||||
<SignUpIllustration />
|
||||
</AuthRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 默认重定向到登录页 */}
|
||||
<Route path="/" element={<Navigate to="/auth/signin" replace />} />
|
||||
<Route path="*" element={<Navigate to="/auth/signin" replace />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
// src/layouts/Home.js
|
||||
import React from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
// 导航栏已由 MainLayout 提供,此处不再导入
|
||||
// import HomeNavbar from "../components/Navbars/HomeNavbar";
|
||||
|
||||
// 导入页面组件
|
||||
import HomePage from "views/Home/HomePage";
|
||||
import ProfilePage from "views/Profile/ProfilePage";
|
||||
import SettingsPage from "views/Settings/SettingsPage";
|
||||
import CenterDashboard from "views/Dashboard/Center";
|
||||
import Subscription from "views/Pages/Account/Subscription";
|
||||
|
||||
// 懒加载隐私政策、用户协议、微信回调和模拟交易页面
|
||||
const PrivacyPolicy = React.lazy(() => import("views/Pages/PrivacyPolicy"));
|
||||
const UserAgreement = React.lazy(() => import("views/Pages/UserAgreement"));
|
||||
const WechatCallback = React.lazy(() => import("views/Pages/WechatCallback"));
|
||||
const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
|
||||
|
||||
// 导入保护路由组件
|
||||
import ProtectedRoute from "../components/ProtectedRoute";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Box minH="100vh">
|
||||
{/* 导航栏已由 MainLayout 提供,此处不再渲染 */}
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<Box>
|
||||
<Routes>
|
||||
{/* 首页默认路由 */}
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route
|
||||
path="/center"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CenterDashboard />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 需要登录保护的页面 */}
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ProfilePage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 订阅管理页面 */}
|
||||
<Route
|
||||
path="/pages/account/subscription"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Subscription />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 模拟盘交易页面 */}
|
||||
<Route
|
||||
path="/trading-simulation"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TradingSimulation />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 隐私政策页面 - 无需登录 */}
|
||||
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
|
||||
|
||||
{/* 用户协议页面 - 无需登录 */}
|
||||
<Route path="/user-agreement" element={<UserAgreement />} />
|
||||
|
||||
{/* 微信授权回调页面 - 无需登录 */}
|
||||
<Route path="/wechat-callback" element={<WechatCallback />} />
|
||||
|
||||
{/* 其他可能的路由 */}
|
||||
<Route path="*" element={<HomePage />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
// src/layouts/MainLayout.js
|
||||
// 主布局组件 - 为所有带导航栏的页面提供统一布局
|
||||
import React, { memo } from "react";
|
||||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import React, { memo, Suspense } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import HomeNavbar from "../components/Navbars/HomeNavbar";
|
||||
import AppFooter from "./AppFooter";
|
||||
import BackToTopButton from "./components/BackToTopButton";
|
||||
import PageTransitionWrapper from "./components/PageTransitionWrapper";
|
||||
import { ANIMATION_CONFIG, BACK_TO_TOP_CONFIG } from "./config/layoutConfig";
|
||||
import ErrorBoundary from "../components/ErrorBoundary";
|
||||
import PageLoader from "../components/Loading/PageLoader";
|
||||
import { BACK_TO_TOP_CONFIG } from "./config/layoutConfig";
|
||||
|
||||
// ✅ P0 性能优化:缓存静态组件,避免路由切换时不必要的重新渲染
|
||||
// HomeNavbar (1623行) 和 AppFooter 不依赖路由参数,使用 memo 可大幅减少渲染次数
|
||||
@@ -20,38 +21,27 @@ const MemoizedAppFooter = memo(AppFooter);
|
||||
* 使用 <Outlet /> 渲染子路由,确保导航栏只渲染一次
|
||||
* 页面切换时只有 Outlet 内的内容会更新,导航栏保持不变
|
||||
*
|
||||
* 架构优化(2024-10-30):
|
||||
* - ✅ P0: 组件拆分 - BackToTopButton 独立复用(37行 → 独立文件)
|
||||
* - ✅ P0: 组件拆分 - PageTransitionWrapper 封装复杂逻辑(18行 → 独立文件)
|
||||
* - ✅ P0: 性能优化 - 使用 memo 避免导航栏和页脚重新渲染(性能提升 50%+)
|
||||
* - ✅ P1: 性能优化 - 使用 RAF 节流滚动事件(性能提升 80%)
|
||||
* - ✅ P1: 错误隔离 - ErrorBoundary 包裹页面内容,确保导航栏可用
|
||||
* - ✅ P2: 用户体验 - 页面过渡动画(framer-motion)
|
||||
* - ✅ P2: 配置集中 - layoutConfig 统一管理配置常量
|
||||
* - ✅ P3: 用户体验 - 返回顶部按钮(滚动 > 300px 显示)
|
||||
*
|
||||
* 代码优化成果:
|
||||
* - 代码量:115 行 → 42 行(减少 63%)
|
||||
* - 复杂度:内联组件 → 独立模块
|
||||
* - 可维护性:配置分散 → 集中管理
|
||||
* - 可复用性:耦合 → 解耦
|
||||
* 架构优化:
|
||||
* - ✅ 组件拆分 - BackToTopButton 独立复用
|
||||
* - ✅ 性能优化 - 使用 memo 避免导航栏和页脚重新渲染
|
||||
* - ✅ 错误隔离 - ErrorBoundary 包裹页面内容,确保导航栏可用
|
||||
* - ✅ 懒加载支持 - Suspense 统一处理懒加载
|
||||
* - ✅ 布局简化 - 直接内联容器逻辑,减少嵌套层级
|
||||
*/
|
||||
export default function MainLayout() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<Box minH="100vh" display="flex" flexDirection="column">
|
||||
{/* 导航栏 - 在所有页面间共享,memo 后不会在路由切换时重新渲染 */}
|
||||
<MemoizedHomeNavbar />
|
||||
|
||||
{/* 页面内容区域 - 包含动画、错误边界、懒加载 */}
|
||||
<PageTransitionWrapper
|
||||
location={location}
|
||||
animationConfig={ANIMATION_CONFIG.default}
|
||||
loaderMessage="页面加载中..."
|
||||
>
|
||||
<Outlet />
|
||||
</PageTransitionWrapper>
|
||||
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
|
||||
<Box flex="1" w="100%" position="relative" overflow="hidden">
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
|
||||
{/* 页脚 - 在所有页面间共享,memo 后不会在路由切换时重新渲染 */}
|
||||
<MemoizedAppFooter />
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// src/layouts/components/PageTransitionWrapper.js
|
||||
import React, { Suspense, memo } from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import ErrorBoundary from '../../components/ErrorBoundary';
|
||||
import PageLoader from '../../components/Loading/PageLoader';
|
||||
|
||||
// 创建 motion 包裹的 Box 组件
|
||||
const MotionBox = motion(Box);
|
||||
|
||||
/**
|
||||
* 页面过渡动画包裹组件
|
||||
*
|
||||
* 功能:
|
||||
* - 页面切换时的过渡动画(AnimatePresence)
|
||||
* - 错误边界隔离(ErrorBoundary)
|
||||
* - 懒加载支持(Suspense)
|
||||
*
|
||||
* 优化:
|
||||
* - ✅ 使用 memo 避免不必要的重新渲染
|
||||
* - ✅ 支持自定义动画配置
|
||||
* - ✅ 错误隔离,确保导航栏不受影响
|
||||
*
|
||||
* @param {React.ReactNode} children - 要渲染的子组件(通常是 <Outlet />)
|
||||
* @param {object} location - 路由位置对象(用于动画 key)
|
||||
* @param {object} animationConfig - 自定义动画配置
|
||||
* @param {string} loaderMessage - 加载时显示的消息
|
||||
*/
|
||||
const PageTransitionWrapper = memo(({
|
||||
children,
|
||||
location,
|
||||
animationConfig = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.2 }
|
||||
},
|
||||
loaderMessage = '页面加载中...'
|
||||
}) => {
|
||||
return (
|
||||
<Box flex="1" position="relative" overflow="hidden">
|
||||
<AnimatePresence mode="wait">
|
||||
<MotionBox
|
||||
key={location.pathname}
|
||||
initial={animationConfig.initial}
|
||||
animate={animationConfig.animate}
|
||||
exit={animationConfig.exit}
|
||||
transition={animationConfig.transition}
|
||||
style={{ height: '100%' }}
|
||||
>
|
||||
{/* 错误边界:隔离页面错误,确保导航栏仍可用 */}
|
||||
<ErrorBoundary>
|
||||
{/* Suspense:支持 React.lazy() 懒加载 */}
|
||||
<Suspense fallback={<PageLoader message={loaderMessage} />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</MotionBox>
|
||||
</AnimatePresence>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
PageTransitionWrapper.displayName = 'PageTransitionWrapper';
|
||||
|
||||
export default PageTransitionWrapper;
|
||||
@@ -92,7 +92,7 @@ export const initPostHog = () => {
|
||||
loaded: (posthogInstance) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('✅ PostHog initialized successfully');
|
||||
posthogInstance.debug(); // Enable debug mode in development
|
||||
// posthogInstance.debug(); // 已关闭:减少控制台日志噪音
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -143,7 +143,7 @@ export const identifyUser = (userId, userProperties = {}) => {
|
||||
...userProperties,
|
||||
});
|
||||
|
||||
console.log('👤 User identified:', userId);
|
||||
// console.log('👤 User identified:', userId); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ User identification failed:', error);
|
||||
}
|
||||
@@ -158,7 +158,7 @@ export const identifyUser = (userId, userProperties = {}) => {
|
||||
export const setUserProperties = (properties) => {
|
||||
try {
|
||||
posthog.people.set(properties);
|
||||
console.log('📝 User properties updated');
|
||||
// console.log('📝 User properties updated'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to update user properties:', error);
|
||||
}
|
||||
@@ -177,9 +177,9 @@ export const trackEvent = (eventName, properties = {}) => {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📍 Event tracked:', eventName, properties);
|
||||
}
|
||||
// if (process.env.NODE_ENV === 'development') {
|
||||
// console.log('📍 Event tracked:', eventName, properties);
|
||||
// } // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Event tracking failed:', error);
|
||||
}
|
||||
@@ -201,9 +201,9 @@ export const trackPageView = (pagePath, properties = {}) => {
|
||||
...properties,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📄 Page view tracked:', pagePath);
|
||||
}
|
||||
// if (process.env.NODE_ENV === 'development') {
|
||||
// console.log('📄 Page view tracked:', pagePath);
|
||||
// } // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Page view tracking failed:', error);
|
||||
}
|
||||
@@ -216,7 +216,7 @@ export const trackPageView = (pagePath, properties = {}) => {
|
||||
export const resetUser = () => {
|
||||
try {
|
||||
posthog.reset();
|
||||
console.log('🔄 User session reset');
|
||||
// console.log('🔄 User session reset'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Session reset failed:', error);
|
||||
}
|
||||
@@ -228,7 +228,7 @@ export const resetUser = () => {
|
||||
export const optOut = () => {
|
||||
try {
|
||||
posthog.opt_out_capturing();
|
||||
console.log('🚫 User opted out of tracking');
|
||||
// console.log('🚫 User opted out of tracking'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-out failed:', error);
|
||||
}
|
||||
@@ -240,7 +240,7 @@ export const optOut = () => {
|
||||
export const optIn = () => {
|
||||
try {
|
||||
posthog.opt_in_capturing();
|
||||
console.log('✅ User opted in to tracking');
|
||||
// console.log('✅ User opted in to tracking'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-in failed:', error);
|
||||
}
|
||||
|
||||
@@ -31,11 +31,11 @@ export async function startMockServiceWorker() {
|
||||
|
||||
try {
|
||||
await worker.start({
|
||||
// 🎯 智能穿透模式(关键配置)
|
||||
// 🎯 警告模式(关键配置)
|
||||
// 'bypass': 未定义 Mock 的请求自动转发到真实后端
|
||||
// 'warn': 未定义的请求会显示警告(调试用)
|
||||
// 'error': 未定义的请求会抛出错误(严格模式)
|
||||
onUnhandledRequest: 'bypass',
|
||||
// 'warn': 未定义的请求会显示警告(调试用)✅ 当前使用(允许 passthrough)
|
||||
// 'error': 未定义的请求会抛出错误(严格模式,不允许 passthrough)
|
||||
onUnhandledRequest: 'warn',
|
||||
|
||||
// 自定义 Service Worker URL(如果需要)
|
||||
serviceWorker: {
|
||||
@@ -48,12 +48,12 @@ export async function startMockServiceWorker() {
|
||||
|
||||
isStarted = true;
|
||||
console.log(
|
||||
'%c[MSW] Mock Service Worker 已启动 🎭',
|
||||
'%c[MSW] Mock Service Worker 已启动 🎭 (警告模式)',
|
||||
'color: #4CAF50; font-weight: bold; font-size: 14px;'
|
||||
);
|
||||
console.log(
|
||||
'%c智能穿透模式:已定义 Mock → 返回假数据 | 未定义 Mock → 转发到 ' + (process.env.REACT_APP_API_URL || '真实后端'),
|
||||
'color: #FF9800; font-size: 12px;'
|
||||
'%c警告模式:已定义 Mock → 返回假数据 | 未定义 Mock → 显示警告 ⚠️ | 允许 passthrough',
|
||||
'color: #FF9800; font-weight: bold; font-size: 12px;'
|
||||
);
|
||||
console.log(
|
||||
'%c查看 src/mocks/handlers/ 目录管理 Mock 接口',
|
||||
|
||||
@@ -138,6 +138,9 @@ export const mockFollowingEvents = [
|
||||
upvote_count: 489,
|
||||
heat_score: 95,
|
||||
exceed_expectation_score: 85,
|
||||
related_avg_chg: 1.25,
|
||||
related_max_chg: 3.15,
|
||||
related_week_chg: 2.80,
|
||||
creator: {
|
||||
id: 1001,
|
||||
username: '财经分析师',
|
||||
@@ -155,6 +158,9 @@ export const mockFollowingEvents = [
|
||||
upvote_count: 567,
|
||||
heat_score: 88,
|
||||
exceed_expectation_score: 78,
|
||||
related_avg_chg: 5.60,
|
||||
related_max_chg: 12.50,
|
||||
related_week_chg: 8.90,
|
||||
creator: {
|
||||
id: 1002,
|
||||
username: '科技观察者',
|
||||
@@ -172,6 +178,9 @@ export const mockFollowingEvents = [
|
||||
upvote_count: 345,
|
||||
heat_score: 72,
|
||||
exceed_expectation_score: 68,
|
||||
related_avg_chg: 2.35,
|
||||
related_max_chg: 6.80,
|
||||
related_week_chg: 4.20,
|
||||
creator: {
|
||||
id: 1003,
|
||||
username: '产业研究员',
|
||||
@@ -189,6 +198,9 @@ export const mockFollowingEvents = [
|
||||
upvote_count: 432,
|
||||
heat_score: 80,
|
||||
exceed_expectation_score: 72,
|
||||
related_avg_chg: 3.80,
|
||||
related_max_chg: 9.20,
|
||||
related_week_chg: 6.50,
|
||||
creator: {
|
||||
id: 1004,
|
||||
username: '半导体观察',
|
||||
@@ -206,6 +218,9 @@ export const mockFollowingEvents = [
|
||||
upvote_count: 234,
|
||||
heat_score: 65,
|
||||
exceed_expectation_score: null,
|
||||
related_avg_chg: -0.80,
|
||||
related_max_chg: 2.50,
|
||||
related_week_chg: 1.20,
|
||||
creator: {
|
||||
id: 1005,
|
||||
username: '医药行业专家',
|
||||
|
||||
@@ -556,7 +556,7 @@ const industries = ['半导体', '新能源', '人工智能', '医药', '消费'
|
||||
|
||||
// 事件标题模板
|
||||
const eventTitleTemplates = [
|
||||
'{industry}行业迎来重大政策利好',
|
||||
'{industry}行业迎来重大政策利好中国物流与采购联合会发布《国有企业采购业务监督指南》团体标准',
|
||||
'{company}发布{quarter}财报,业绩超预期',
|
||||
'{industry}板块集体大涨,{company}涨停',
|
||||
'央行宣布{policy},影响{industry}行业',
|
||||
@@ -609,7 +609,7 @@ function generateEventDescription(industry, importance, seed) {
|
||||
return impacts[importance] + details[seed % details.length];
|
||||
}
|
||||
|
||||
// 生成关键词
|
||||
// 生成关键词(对象数组格式,包含完整信息)
|
||||
function generateKeywords(industry, seed) {
|
||||
const commonKeywords = ['政策', '利好', '业绩', '涨停', '龙头', '突破', '合作', '投资'];
|
||||
const industryKeywords = {
|
||||
@@ -620,12 +620,100 @@ function generateKeywords(industry, seed) {
|
||||
'消费': ['白酒', '食品', '家电', '零售', '免税'],
|
||||
};
|
||||
|
||||
const keywords = [
|
||||
// 概念描述模板
|
||||
const descriptionTemplates = {
|
||||
'政策': '政策性利好消息对相关行业和板块产生积极影响,带动市场情绪和资金流向。',
|
||||
'利好': '市场积极因素推动相关板块上涨,投资者情绪乐观,资金持续流入。',
|
||||
'业绩': '公司业绩超预期增长,盈利能力提升,市场给予更高估值预期。',
|
||||
'涨停': '强势涨停板显示市场热度,短期资金追捧,板块效应明显。',
|
||||
'龙头': '行业龙头企业具备竞争优势,市场地位稳固,带动板块走势。',
|
||||
'突破': '技术面或基本面出现重大突破,打开上涨空间,吸引资金关注。',
|
||||
'合作': '战略合作为公司带来新的增长点,业务协同效应显著。',
|
||||
'投资': '重大投资项目落地,长期发展空间广阔,市场预期良好。',
|
||||
'芯片': '国产芯片替代加速,自主可控需求强烈,政策和资金支持力度大。',
|
||||
'晶圆': '晶圆产能紧张,供需关系改善,相关企业盈利能力提升。',
|
||||
'封测': '封测环节景气度上行,订单饱满,产能利用率提高。',
|
||||
'AI芯片': '人工智能快速发展带动AI芯片需求爆发,市场空间巨大。',
|
||||
'国产替代': '国产替代进程加速,政策扶持力度大,进口依赖度降低。',
|
||||
'电池': '新能源汽车渗透率提升,动力电池需求旺盛,技术迭代加快。',
|
||||
'光伏': '光伏装机量快速增长,成本持续下降,行业景气度维持高位。',
|
||||
'储能': '储能市场爆发式增长,政策支持力度大,应用场景不断拓展。',
|
||||
'新能源车': '新能源汽车销量高增长,渗透率持续提升,产业链受益明显。',
|
||||
'锂电': '锂电池技术进步,成本优势扩大,下游应用领域持续扩张。',
|
||||
'大模型': '大语言模型技术突破,商业化进程加速,应用场景广阔。',
|
||||
'AI应用': '人工智能应用落地加速,垂直领域渗透率提升,市场空间巨大。',
|
||||
'算力': '算力需求持续增长,数据中心建设加速,相关产业链受益。',
|
||||
'数据': '数据要素市场化改革推进,数据价值释放,相关企业盈利模式清晰。',
|
||||
'机器学习': '机器学习技术成熟,应用场景丰富,商业价值逐步显现。',
|
||||
'创新药': '创新药研发管线丰富,商业化进程顺利,市场给予高估值。',
|
||||
'CRO': 'CRO行业高景气,订单充足,盈利能力稳定增长。',
|
||||
'医疗器械': '医疗器械国产化率提升,技术创新加快,市场份额扩大。',
|
||||
'生物制药': '生物制药技术突破,产品管线丰富,商业化前景广阔。',
|
||||
'仿制药': '仿制药集采常态化,质量优势企业市场份额提升。',
|
||||
'白酒': '白酒消费升级,高端产品量价齐升,龙头企业护城河深厚。',
|
||||
'食品': '食品饮料需求稳定,品牌力强的企业市场份额持续提升。',
|
||||
'家电': '家电消费需求回暖,智能化升级带动产品结构优化。',
|
||||
'零售': '零售行业数字化转型,线上线下融合,运营效率提升。',
|
||||
'免税': '免税政策优化,消费回流加速,行业景气度上行。'
|
||||
};
|
||||
|
||||
const keywordNames = [
|
||||
...commonKeywords.slice(seed % 3, seed % 3 + 3),
|
||||
...(industryKeywords[industry] || []).slice(0, 2)
|
||||
];
|
||||
].slice(0, 5);
|
||||
|
||||
return keywords.slice(0, 5);
|
||||
const matchTypes = ['hybrid_knn', 'keyword', 'semantic'];
|
||||
|
||||
// 生成历史触发时间(3-5个历史日期)
|
||||
const generateHappenedTimes = (baseSeed) => {
|
||||
const times = [];
|
||||
const count = 3 + (baseSeed % 3); // 3-5个时间点
|
||||
for (let i = 0; i < count; i++) {
|
||||
const daysAgo = 30 + (baseSeed * 7 + i * 11) % 330; // 30-360天前
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
times.push(date.toISOString().split('T')[0]);
|
||||
}
|
||||
return times.sort().reverse(); // 降序排列
|
||||
};
|
||||
|
||||
// 生成核心相关股票(4-6只)
|
||||
const generateRelatedStocks = (conceptName, baseSeed) => {
|
||||
const stockCount = 4 + (baseSeed % 3); // 4-6只股票
|
||||
const selectedStocks = [];
|
||||
|
||||
for (let i = 0; i < stockCount && i < stockPool.length; i++) {
|
||||
const stockIndex = (baseSeed + i * 7) % stockPool.length;
|
||||
const stock = stockPool[stockIndex];
|
||||
selectedStocks.push({
|
||||
stock_name: stock.stock_name,
|
||||
stock_code: stock.stock_code,
|
||||
reason: relationDescTemplates[(baseSeed + i) % relationDescTemplates.length],
|
||||
change_pct: (Math.random() * 15 - 5).toFixed(2) // -5% ~ +10%
|
||||
});
|
||||
}
|
||||
|
||||
return selectedStocks;
|
||||
};
|
||||
|
||||
// 将字符串数组转换为对象数组,匹配真实API数据结构
|
||||
return keywordNames.map((name, index) => {
|
||||
const score = (70 + Math.floor((seed * 7 + index * 11) % 30)) / 100; // 0.70-0.99的分数
|
||||
const avgChangePct = (Math.random() * 15 - 5).toFixed(2); // -5% ~ +10% 的涨跌幅
|
||||
|
||||
return {
|
||||
concept: name, // 使用 concept 字段而不是 name
|
||||
stock_count: 10 + Math.floor((seed + index) % 30), // 10-39个相关股票
|
||||
score: parseFloat(score.toFixed(2)), // 0-1之间的分数,而不是0-100
|
||||
description: descriptionTemplates[name] || `${name}相关概念,市场关注度较高,具有一定的投资价值。`,
|
||||
price_info: { // 将 avg_change_pct 嵌套在 price_info 对象中
|
||||
avg_change_pct: parseFloat(avgChangePct)
|
||||
},
|
||||
match_type: matchTypes[(seed + index) % 3], // 随机匹配类型
|
||||
happened_times: generateHappenedTimes(seed + index), // 历史触发时间
|
||||
stocks: generateRelatedStocks(name, seed + index) // 核心相关股票
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -645,12 +733,12 @@ export function generateMockEvents(params = {}) {
|
||||
stock_code = '',
|
||||
} = params;
|
||||
|
||||
// 生成100个事件用于测试
|
||||
const totalEvents = 100;
|
||||
// 生成200个事件用于测试(足够测试分页功能)
|
||||
const totalEvents = 200;
|
||||
const allEvents = [];
|
||||
|
||||
const importanceLevels = ['S', 'A', 'B', 'C'];
|
||||
const baseDate = new Date('2025-01-15');
|
||||
const baseDate = new Date(); // 使用当前日期作为基准
|
||||
|
||||
for (let i = 0; i < totalEvents; i++) {
|
||||
const industry = industries[i % industries.length];
|
||||
@@ -665,27 +753,87 @@ export function generateMockEvents(params = {}) {
|
||||
const hotScore = Math.max(50, 100 - i);
|
||||
const relatedAvgChg = (Math.random() * 20 - 5).toFixed(2); // -5% 到 15%
|
||||
const relatedMaxChg = (Math.random() * 30).toFixed(2); // 0% 到 30%
|
||||
const relatedWeekChg = (Math.random() * 30 - 10).toFixed(2); // -10% 到 20%
|
||||
|
||||
// 生成价格走势数据(前一天、当天、后一天)
|
||||
const generatePriceTrend = (seed) => {
|
||||
const basePrice = 10 + (seed % 90); // 基础价格 10-100
|
||||
const trend = [];
|
||||
|
||||
// 前一天(5个数据点)
|
||||
let price = basePrice;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
price = price + (Math.random() - 0.5) * 0.5;
|
||||
trend.push(parseFloat(price.toFixed(2)));
|
||||
}
|
||||
|
||||
// 当天(5个数据点)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
price = price + (Math.random() - 0.4) * 0.8; // 轻微上涨趋势
|
||||
trend.push(parseFloat(price.toFixed(2)));
|
||||
}
|
||||
|
||||
// 后一天(5个数据点)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
price = price + (Math.random() - 0.45) * 1.0;
|
||||
trend.push(parseFloat(price.toFixed(2)));
|
||||
}
|
||||
|
||||
return trend;
|
||||
};
|
||||
|
||||
// 为每个事件随机选择2-5个相关股票
|
||||
const relatedStockCount = 2 + (i % 4); // 2-5个股票
|
||||
const relatedStocks = [];
|
||||
const industryStocks = stockPool.filter(s => s.industry === industry);
|
||||
const addedStockCodes = new Set(); // 用于去重
|
||||
|
||||
// 优先选择同行业股票
|
||||
if (industryStocks.length > 0) {
|
||||
for (let j = 0; j < Math.min(relatedStockCount, industryStocks.length); j++) {
|
||||
relatedStocks.push(industryStocks[j % industryStocks.length].stock_code);
|
||||
const stock = industryStocks[j % industryStocks.length];
|
||||
if (!addedStockCodes.has(stock.stock_code)) {
|
||||
const dailyChange = (Math.random() * 6 - 2).toFixed(2); // -2% ~ +4%
|
||||
const weekChange = (Math.random() * 10 - 3).toFixed(2); // -3% ~ +7%
|
||||
|
||||
relatedStocks.push({
|
||||
stock_name: stock.stock_name,
|
||||
stock_code: stock.stock_code,
|
||||
relation_desc: relationDescTemplates[(i + j) % relationDescTemplates.length],
|
||||
daily_change: dailyChange,
|
||||
week_change: weekChange,
|
||||
price_trend: generatePriceTrend(i * 100 + j)
|
||||
});
|
||||
addedStockCodes.add(stock.stock_code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果同行业股票不够,从整个 stockPool 中补充
|
||||
while (relatedStocks.length < relatedStockCount && relatedStocks.length < stockPool.length) {
|
||||
const randomStock = stockPool[relatedStocks.length % stockPool.length];
|
||||
if (!relatedStocks.includes(randomStock.stock_code)) {
|
||||
relatedStocks.push(randomStock.stock_code);
|
||||
let poolIndex = 0;
|
||||
while (relatedStocks.length < relatedStockCount && poolIndex < stockPool.length) {
|
||||
const randomStock = stockPool[poolIndex % stockPool.length];
|
||||
if (!addedStockCodes.has(randomStock.stock_code)) {
|
||||
const dailyChange = (Math.random() * 6 - 2).toFixed(2); // -2% ~ +4%
|
||||
const weekChange = (Math.random() * 10 - 3).toFixed(2); // -3% ~ +7%
|
||||
|
||||
relatedStocks.push({
|
||||
stock_name: randomStock.stock_name,
|
||||
stock_code: randomStock.stock_code,
|
||||
relation_desc: relationDescTemplates[(i + poolIndex) % relationDescTemplates.length],
|
||||
daily_change: dailyChange,
|
||||
week_change: weekChange,
|
||||
price_trend: generatePriceTrend(i * 100 + poolIndex)
|
||||
});
|
||||
addedStockCodes.add(randomStock.stock_code);
|
||||
}
|
||||
poolIndex++;
|
||||
}
|
||||
|
||||
// 计算交易日期(模拟下一交易日,这里简单地加1天)
|
||||
const tradingDate = new Date(createdAt);
|
||||
tradingDate.setDate(tradingDate.getDate() + 1);
|
||||
|
||||
allEvents.push({
|
||||
id: i + 1,
|
||||
title: generateEventTitle(industry, i),
|
||||
@@ -696,14 +844,18 @@ export function generateMockEvents(params = {}) {
|
||||
status: 'published',
|
||||
created_at: createdAt.toISOString(),
|
||||
updated_at: createdAt.toISOString(),
|
||||
trading_date: tradingDate.toISOString().split('T')[0], // YYYY-MM-DD 格式
|
||||
hot_score: hotScore,
|
||||
view_count: Math.floor(Math.random() * 10000),
|
||||
related_avg_chg: parseFloat(relatedAvgChg),
|
||||
related_max_chg: parseFloat(relatedMaxChg),
|
||||
related_week_chg: parseFloat(relatedWeekChg),
|
||||
keywords: generateKeywords(industry, i),
|
||||
is_ai_generated: i % 4 === 0, // 25% 的事件是AI生成
|
||||
industry: industry,
|
||||
related_stocks: relatedStocks, // 添加相关股票列表
|
||||
historical_events: generateHistoricalEvents(industry, i), // 添加历史事件
|
||||
transmission_chain: generateTransmissionChain(industry, i), // 添加传导链数据
|
||||
});
|
||||
}
|
||||
|
||||
@@ -776,13 +928,16 @@ export function generateMockEvents(params = {}) {
|
||||
const end = start + per_page;
|
||||
const paginatedEvents = filteredEvents.slice(start, end);
|
||||
|
||||
const totalPages = Math.ceil(filteredEvents.length / per_page);
|
||||
return {
|
||||
events: paginatedEvents,
|
||||
pagination: {
|
||||
page: page,
|
||||
per_page: per_page,
|
||||
total: filteredEvents.length,
|
||||
total_pages: Math.ceil(filteredEvents.length / per_page),
|
||||
pages: totalPages, // ← 对齐后端字段名 (was: total_pages)
|
||||
has_prev: page > 1, // ← 对齐后端:是否有上一页
|
||||
has_next: page < totalPages // ← 对齐后端:是否有下一页
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -816,3 +971,213 @@ export function generatePopularKeywords(limit = 20) {
|
||||
trend: index % 3 === 0 ? 'up' : index % 3 === 1 ? 'down' : 'stable',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成历史事件对比数据
|
||||
* @param {string} industry - 行业
|
||||
* @param {number} index - 索引
|
||||
* @returns {Array} - 历史事件列表
|
||||
*/
|
||||
function generateHistoricalEvents(industry, index) {
|
||||
const historicalCount = 3 + (index % 3); // 3-5个历史事件
|
||||
const historical = [];
|
||||
const baseDate = new Date();
|
||||
|
||||
for (let i = 0; i < historicalCount; i++) {
|
||||
// 生成过去1-6个月的随机时间
|
||||
const monthsAgo = 1 + Math.floor(Math.random() * 6);
|
||||
const eventDate = new Date(baseDate);
|
||||
eventDate.setMonth(eventDate.getMonth() - monthsAgo);
|
||||
|
||||
const similarityScore = 0.6 + Math.random() * 0.35; // 60%-95%相似度
|
||||
|
||||
historical.push({
|
||||
id: `hist_${industry}_${index}_${i}`,
|
||||
title: generateEventTitle(industry, i + index * 10),
|
||||
created_at: eventDate.toISOString(),
|
||||
related_avg_chg: parseFloat((Math.random() * 15 - 3).toFixed(2)),
|
||||
related_max_chg: parseFloat((Math.random() * 25).toFixed(2)),
|
||||
similarity_score: parseFloat(similarityScore.toFixed(2)),
|
||||
view_count: Math.floor(Math.random() * 3000) + 500,
|
||||
});
|
||||
}
|
||||
|
||||
// 按相似度排序
|
||||
historical.sort((a, b) => b.similarity_score - a.similarity_score);
|
||||
return historical;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成传导链数据
|
||||
* @param {string} industry - 行业
|
||||
* @param {number} index - 索引
|
||||
* @returns {Object} - 传导链数据 { nodes, edges }
|
||||
*/
|
||||
function generateTransmissionChain(industry, index) {
|
||||
const nodeTypes = ['event', 'industry', 'company', 'policy', 'technology', 'market'];
|
||||
const impactTypes = ['positive', 'negative', 'neutral', 'mixed'];
|
||||
const strengthLevels = ['strong', 'medium', 'weak'];
|
||||
|
||||
const nodes = [];
|
||||
const edges = [];
|
||||
|
||||
// 主事件节点
|
||||
nodes.push({
|
||||
id: 1,
|
||||
name: '主事件',
|
||||
type: 'event',
|
||||
extra: { is_main_event: true, description: `${industry}重要事件` }
|
||||
});
|
||||
|
||||
// 生成5-8个相关节点
|
||||
const nodeCount = 5 + (index % 4);
|
||||
for (let i = 2; i <= nodeCount; i++) {
|
||||
const nodeType = nodeTypes[i % nodeTypes.length];
|
||||
const industryStock = stockPool.find(s => s.industry === industry);
|
||||
|
||||
let nodeName;
|
||||
if (nodeType === 'company' && industryStock) {
|
||||
nodeName = industryStock.name;
|
||||
} else if (nodeType === 'industry') {
|
||||
nodeName = `${industry}产业`;
|
||||
} else if (nodeType === 'policy') {
|
||||
nodeName = '相关政策';
|
||||
} else if (nodeType === 'technology') {
|
||||
nodeName = '技术创新';
|
||||
} else if (nodeType === 'market') {
|
||||
nodeName = '市场需求';
|
||||
} else {
|
||||
nodeName = `节点${i}`;
|
||||
}
|
||||
|
||||
nodes.push({
|
||||
id: i,
|
||||
name: nodeName,
|
||||
type: nodeType,
|
||||
extra: { description: `${nodeName}相关信息` }
|
||||
});
|
||||
|
||||
// 创建与主事件或其他节点的连接
|
||||
const targetId = i === 2 ? 1 : Math.max(1, Math.floor(Math.random() * (i - 1)) + 1);
|
||||
edges.push({
|
||||
source: targetId,
|
||||
target: i,
|
||||
impact: impactTypes[i % impactTypes.length],
|
||||
strength: strengthLevels[i % strengthLevels.length],
|
||||
description: `传导路径${i}`
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成动态新闻事件(实时要闻·动态追踪专用)
|
||||
* @param {Object} timeRange - 时间范围 { startTime, endTime }
|
||||
* @param {number} count - 生成事件数量,默认30条
|
||||
* @returns {Array} - 事件列表
|
||||
*/
|
||||
export function generateDynamicNewsEvents(timeRange = null, count = 30) {
|
||||
const events = [];
|
||||
const importanceLevels = ['S', 'A', 'B', 'C'];
|
||||
|
||||
// 如果没有提供时间范围,默认生成最近24小时的事件
|
||||
let startTime, endTime;
|
||||
if (timeRange) {
|
||||
startTime = new Date(timeRange.startTime);
|
||||
endTime = new Date(timeRange.endTime);
|
||||
} else {
|
||||
endTime = new Date();
|
||||
startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000); // 24小时前
|
||||
}
|
||||
|
||||
// 计算时间跨度(毫秒)
|
||||
const timeSpan = endTime.getTime() - startTime.getTime();
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const industry = industries[i % industries.length];
|
||||
const imp = importanceLevels[i % importanceLevels.length];
|
||||
const eventType = eventTypes[i % eventTypes.length];
|
||||
|
||||
// 在时间范围内随机生成事件时间
|
||||
const randomOffset = Math.random() * timeSpan;
|
||||
const createdAt = new Date(startTime.getTime() + randomOffset);
|
||||
|
||||
// 生成随机热度和收益率
|
||||
const hotScore = Math.max(60, 100 - i * 1.2); // 动态新闻热度更高
|
||||
const relatedAvgChg = (Math.random() * 15 - 3).toFixed(2); // -3% 到 12%
|
||||
const relatedMaxChg = (Math.random() * 25).toFixed(2); // 0% 到 25%
|
||||
const relatedWeekChg = (Math.random() * 30 - 10).toFixed(2); // -10% 到 20%
|
||||
|
||||
// 为每个事件随机选择2-5个相关股票(完整对象)
|
||||
const relatedStockCount = 2 + (i % 4);
|
||||
const relatedStocks = [];
|
||||
const industryStocks = stockPool.filter(s => s.industry === industry);
|
||||
const relationDescriptions = [
|
||||
'直接受益标的',
|
||||
'产业链上游企业',
|
||||
'产业链下游企业',
|
||||
'行业龙头企业',
|
||||
'潜在受益标的',
|
||||
'概念相关个股'
|
||||
];
|
||||
|
||||
// 优先选择同行业股票
|
||||
if (industryStocks.length > 0) {
|
||||
for (let j = 0; j < Math.min(relatedStockCount, industryStocks.length); j++) {
|
||||
const stock = industryStocks[j % industryStocks.length];
|
||||
relatedStocks.push({
|
||||
stock_code: stock.stock_code,
|
||||
stock_name: stock.name,
|
||||
relation_desc: relationDescriptions[j % relationDescriptions.length]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 如果同行业股票不够,从整个 stockPool 中补充
|
||||
while (relatedStocks.length < relatedStockCount && relatedStocks.length < stockPool.length) {
|
||||
const randomStock = stockPool[relatedStocks.length % stockPool.length];
|
||||
if (!relatedStocks.some(s => s.stock_code === randomStock.stock_code)) {
|
||||
relatedStocks.push({
|
||||
stock_code: randomStock.stock_code,
|
||||
stock_name: randomStock.name,
|
||||
relation_desc: relationDescriptions[relatedStocks.length % relationDescriptions.length]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
events.push({
|
||||
id: `dynamic_${i + 1}`,
|
||||
title: generateEventTitle(industry, i),
|
||||
description: generateEventDescription(industry, imp, i),
|
||||
content: generateEventDescription(industry, imp, i),
|
||||
event_type: eventType,
|
||||
importance: imp,
|
||||
status: 'published',
|
||||
created_at: createdAt.toISOString(),
|
||||
updated_at: createdAt.toISOString(),
|
||||
hot_score: hotScore,
|
||||
view_count: Math.floor(Math.random() * 5000) + 1000, // 1000-6000 浏览量
|
||||
follower_count: Math.floor(Math.random() * 500) + 50, // 50-550 关注数
|
||||
post_count: Math.floor(Math.random() * 100) + 10, // 10-110 帖子数
|
||||
related_avg_chg: parseFloat(relatedAvgChg),
|
||||
related_max_chg: parseFloat(relatedMaxChg),
|
||||
related_week_chg: parseFloat(relatedWeekChg),
|
||||
keywords: generateKeywords(industry, i),
|
||||
is_ai_generated: i % 3 === 0, // 33% 的事件是AI生成
|
||||
industry: industry,
|
||||
related_stocks: relatedStocks,
|
||||
historical_events: generateHistoricalEvents(industry, i),
|
||||
transmission_chain: generateTransmissionChain(industry, i),
|
||||
creator: {
|
||||
username: authorPool[i % authorPool.length],
|
||||
avatar_url: null
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 按时间倒序排序(最新的在前)
|
||||
events.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
@@ -112,20 +112,20 @@ export function getCurrentUser() {
|
||||
const stored = localStorage.getItem('mock_current_user');
|
||||
if (stored) {
|
||||
const user = JSON.parse(stored);
|
||||
console.log('[Mock State] 获取当前登录用户:', {
|
||||
id: user.id,
|
||||
phone: user.phone,
|
||||
nickname: user.nickname,
|
||||
subscription_type: user.subscription_type,
|
||||
subscription_status: user.subscription_status,
|
||||
subscription_days_left: user.subscription_days_left
|
||||
});
|
||||
// console.log('[Mock State] 获取当前登录用户:', { // 已关闭:减少日志
|
||||
// id: user.id,
|
||||
// phone: user.phone,
|
||||
// nickname: user.nickname,
|
||||
// subscription_type: user.subscription_type,
|
||||
// subscription_status: user.subscription_status,
|
||||
// subscription_days_left: user.subscription_days_left
|
||||
// });
|
||||
return user;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Mock State] 解析用户数据失败:', error);
|
||||
}
|
||||
console.log('[Mock State] 未找到当前登录用户');
|
||||
// console.log('[Mock State] 未找到当前登录用户'); // 已关闭:减少日志
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ export const accountHandlers = [
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[Mock] 获取自选股列表');
|
||||
// console.log('[Mock] 获取自选股列表'); // 已关闭:减少日志
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -24,8 +24,6 @@ export const authHandlers = [
|
||||
const body = await request.json();
|
||||
const { credential, type, purpose } = body;
|
||||
|
||||
console.log('[Mock] 发送验证码:', { credential, type, purpose });
|
||||
|
||||
// 生成验证码
|
||||
const code = generateVerificationCode();
|
||||
mockVerificationCodes.set(credential, {
|
||||
@@ -33,7 +31,20 @@ export const authHandlers = [
|
||||
expiresAt: Date.now() + 5 * 60 * 1000 // 5分钟后过期
|
||||
});
|
||||
|
||||
console.log(`[Mock] 验证码已生成: ${credential} -> ${code}`);
|
||||
// 超醒目的验证码提示 - 方便开发调试
|
||||
console.log(
|
||||
`%c\n` +
|
||||
`╔════════════════════════════════════════════╗\n` +
|
||||
`║ 验证码: ${code.padEnd(22)}║\n` +
|
||||
`╚════════════════════════════════════════════╝\n`,
|
||||
'color: #ffffff; background: #16a34a; font-weight: bold; font-size: 16px; padding: 20px; line-height: 1.8;'
|
||||
);
|
||||
|
||||
// 额外的高亮提示
|
||||
console.log(
|
||||
`%c 验证码: ${code} `,
|
||||
'color: #ffffff; background: #dc2626; font-weight: bold; font-size: 24px; padding: 15px 30px; border-radius: 8px; margin: 10px 0;'
|
||||
);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
@@ -43,6 +54,86 @@ export const authHandlers = [
|
||||
});
|
||||
}),
|
||||
|
||||
// 1.1 发送手机验证码(前端实际调用的接口)
|
||||
http.post('/api/auth/send-sms-code', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const body = await request.json();
|
||||
const { phone } = body;
|
||||
|
||||
console.log('[Mock] 发送手机验证码请求:', { phone });
|
||||
|
||||
// 生成验证码
|
||||
const code = generateVerificationCode();
|
||||
mockVerificationCodes.set(phone, {
|
||||
code,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000 // 5分钟后过期
|
||||
});
|
||||
|
||||
// 超醒目的验证码提示 - 方便开发调试
|
||||
console.log(
|
||||
`%c\n` +
|
||||
`╔════════════════════════════════════════════╗\n` +
|
||||
`║ 📱 手机验证码: ${code.padEnd(19)}║\n` +
|
||||
`║ 📞 手机号: ${phone.padEnd(23)}║\n` +
|
||||
`╚════════════════════════════════════════════╝\n`,
|
||||
'color: #ffffff; background: #16a34a; font-weight: bold; font-size: 16px; padding: 20px; line-height: 1.8;'
|
||||
);
|
||||
|
||||
// 额外的高亮提示
|
||||
console.log(
|
||||
`%c 📱 验证码: ${code} `,
|
||||
'color: #ffffff; background: #dc2626; font-weight: bold; font-size: 24px; padding: 15px 30px; border-radius: 8px; margin: 10px 0;'
|
||||
);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: `验证码已发送到 ${phone}(Mock: ${code})`,
|
||||
// 开发环境下返回验证码,方便测试
|
||||
dev_code: code
|
||||
});
|
||||
}),
|
||||
|
||||
// 1.2 发送邮箱验证码(前端实际调用的接口)
|
||||
http.post('/api/auth/send-email-code', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const body = await request.json();
|
||||
const { email } = body;
|
||||
|
||||
console.log('[Mock] 发送邮箱验证码请求:', { email });
|
||||
|
||||
// 生成验证码
|
||||
const code = generateVerificationCode();
|
||||
mockVerificationCodes.set(email, {
|
||||
code,
|
||||
expiresAt: Date.now() + 5 * 60 * 1000 // 5分钟后过期
|
||||
});
|
||||
|
||||
// 超醒目的验证码提示 - 方便开发调试
|
||||
console.log(
|
||||
`%c\n` +
|
||||
`╔════════════════════════════════════════════╗\n` +
|
||||
`║ 📧 邮箱验证码: ${code.padEnd(19)}║\n` +
|
||||
`║ 📮 邮箱: ${email.padEnd(27)}║\n` +
|
||||
`╚════════════════════════════════════════════╝\n`,
|
||||
'color: #ffffff; background: #2563eb; font-weight: bold; font-size: 16px; padding: 20px; line-height: 1.8;'
|
||||
);
|
||||
|
||||
// 额外的高亮提示
|
||||
console.log(
|
||||
`%c 📧 验证码: ${code} `,
|
||||
'color: #ffffff; background: #dc2626; font-weight: bold; font-size: 24px; padding: 15px 30px; border-radius: 8px; margin: 10px 0;'
|
||||
);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: `验证码已发送到 ${email}(Mock: ${code})`,
|
||||
// 开发环境下返回验证码,方便测试
|
||||
dev_code: code
|
||||
});
|
||||
}),
|
||||
|
||||
// 2. 验证码登录
|
||||
http.post('/api/auth/login-with-code', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
@@ -6,8 +6,50 @@ import { http, HttpResponse } from 'msw';
|
||||
// 模拟延迟
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// 生成历史触发时间(3-5个历史日期)
|
||||
const generateHappenedTimes = (seed) => {
|
||||
const times = [];
|
||||
const count = 3 + (seed % 3); // 3-5个时间点
|
||||
for (let i = 0; i < count; i++) {
|
||||
const daysAgo = 30 + (seed * 7 + i * 11) % 330; // 30-360天前
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - daysAgo);
|
||||
times.push(date.toISOString().split('T')[0]);
|
||||
}
|
||||
return times.sort().reverse(); // 降序排列
|
||||
};
|
||||
|
||||
// 生成核心相关股票
|
||||
const generateStocksForConcept = (seed, count = 4) => {
|
||||
const stockPool = [
|
||||
{ name: '贵州茅台', code: '600519' },
|
||||
{ name: '宁德时代', code: '300750' },
|
||||
{ name: '中国平安', code: '601318' },
|
||||
{ name: '比亚迪', code: '002594' },
|
||||
{ name: '隆基绿能', code: '601012' },
|
||||
{ name: '阳光电源', code: '300274' },
|
||||
{ name: '三一重工', code: '600031' },
|
||||
{ name: '中芯国际', code: '688981' },
|
||||
{ name: '京东方A', code: '000725' },
|
||||
{ name: '立讯精密', code: '002475' }
|
||||
];
|
||||
|
||||
const stocks = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const stockIndex = (seed + i * 7) % stockPool.length;
|
||||
const stock = stockPool[stockIndex];
|
||||
stocks.push({
|
||||
stock_name: stock.name,
|
||||
stock_code: stock.code,
|
||||
reason: `作为行业龙头企业,${stock.name}在该领域具有核心竞争优势,市场份额领先,技术实力雄厚。`,
|
||||
change_pct: parseFloat((Math.random() * 15 - 5).toFixed(2)) // -5% ~ +10%
|
||||
});
|
||||
}
|
||||
return stocks;
|
||||
};
|
||||
|
||||
// 生成热门概念数据
|
||||
const generatePopularConcepts = (size = 20) => {
|
||||
export const generatePopularConcepts = (size = 20) => {
|
||||
const concepts = [
|
||||
'人工智能', '新能源汽车', '半导体', '光伏', '锂电池',
|
||||
'储能', '氢能源', '风电', '特高压', '工业母机',
|
||||
@@ -22,21 +64,38 @@ const generatePopularConcepts = (size = 20) => {
|
||||
'疫苗', '中药', '医疗信息化', '智慧医疗', '基因测序'
|
||||
];
|
||||
|
||||
const conceptDescriptions = {
|
||||
'人工智能': '人工智能是"技术突破+政策扶持"双轮驱动的硬科技主题。随着大模型技术的突破,AI应用场景不断拓展,预计将催化算力、数据、应用三大产业链。',
|
||||
'新能源汽车': '新能源汽车行业景气度持续向好,渗透率不断提升。政策支持力度大,产业链上下游企业均受益明显。',
|
||||
'半导体': '国产半导体替代加速,自主可控需求强烈。政策和资金支持力度大,行业迎来黄金发展期。',
|
||||
'光伏': '光伏装机量快速增长,成本持续下降,行业景气度维持高位。双碳目标下,光伏行业前景广阔。',
|
||||
'锂电池': '锂电池技术进步,成本优势扩大,下游应用领域持续扩张。新能源汽车和储能需求旺盛。',
|
||||
'储能': '储能市场爆发式增长,政策支持力度大,应用场景不断拓展。未来市场空间巨大。',
|
||||
'默认': '该概念市场关注度较高,具有一定的投资价值。相关企业技术实力雄厚,市场前景广阔。'
|
||||
};
|
||||
|
||||
const matchTypes = ['hybrid_knn', 'keyword', 'semantic'];
|
||||
|
||||
const results = [];
|
||||
for (let i = 0; i < Math.min(size, concepts.length); i++) {
|
||||
const changePct = (Math.random() * 12 - 2).toFixed(2); // -2% 到 +10%
|
||||
const stockCount = Math.floor(Math.random() * 50) + 10; // 10-60 只股票
|
||||
const score = parseFloat((Math.random() * 5 + 3).toFixed(2)); // 3-8 分数范围
|
||||
|
||||
results.push({
|
||||
concept: concepts[i],
|
||||
concept_id: `CONCEPT_${1000 + i}`,
|
||||
stock_count: stockCount,
|
||||
score: score, // 相关度分数
|
||||
match_type: matchTypes[i % 3], // 匹配类型
|
||||
description: conceptDescriptions[concepts[i]] || conceptDescriptions['默认'],
|
||||
price_info: {
|
||||
avg_change_pct: parseFloat(changePct),
|
||||
avg_price: (Math.random() * 100 + 10).toFixed(2),
|
||||
total_market_cap: (Math.random() * 1000 + 100).toFixed(2)
|
||||
avg_price: parseFloat((Math.random() * 100 + 10).toFixed(2)),
|
||||
total_market_cap: parseFloat((Math.random() * 1000 + 100).toFixed(2))
|
||||
},
|
||||
description: `${concepts[i]}相关概念股`,
|
||||
happened_times: generateHappenedTimes(i), // 历史触发时间
|
||||
stocks: generateStocksForConcept(i, 4), // 核心相关股票
|
||||
hot_score: Math.floor(Math.random() * 100)
|
||||
});
|
||||
}
|
||||
@@ -115,15 +174,12 @@ export const conceptHandlers = [
|
||||
|
||||
console.log('[Mock Concept] 搜索概念:', { query, size, page, sort_by });
|
||||
|
||||
// 生成数据
|
||||
// 生成数据(不过滤,模拟真实 API 的语义搜索返回热门概念)
|
||||
let results = generatePopularConcepts(size);
|
||||
console.log('[Mock Concept] 生成概念数量:', results.length);
|
||||
|
||||
// 如果有查询关键词,过滤结果
|
||||
if (query) {
|
||||
results = results.filter(item =>
|
||||
item.concept.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
}
|
||||
// Mock 环境下不做过滤,直接返回热门概念
|
||||
// 真实环境会根据 query 进行语义搜索
|
||||
|
||||
// 根据排序字段排序
|
||||
if (sort_by === 'change_pct') {
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// 事件相关的 Mock API Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords } from '../data/events';
|
||||
import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords, generateDynamicNewsEvents } from '../data/events';
|
||||
import { getMockFutureEvents, getMockEventCountsForMonth } from '../data/account';
|
||||
import { generatePopularConcepts } from './concept';
|
||||
|
||||
// 模拟网络延迟
|
||||
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
@@ -12,7 +13,7 @@ export const eventHandlers = [
|
||||
// ==================== 事件列表相关 ====================
|
||||
|
||||
// 获取事件列表
|
||||
http.get('/api/events/', async ({ request }) => {
|
||||
http.get('/api/events', async ({ request }) => {
|
||||
await delay(500);
|
||||
|
||||
const url = new URL(request.url);
|
||||
@@ -40,11 +41,26 @@ export const eventHandlers = [
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 获取事件列表失败:', error);
|
||||
console.error('[Mock] Error details:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
params: params
|
||||
});
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取事件列表失败',
|
||||
data: { events: [], pagination: {} }
|
||||
data: {
|
||||
events: [],
|
||||
pagination: {
|
||||
page: 1,
|
||||
per_page: 10,
|
||||
total: 0,
|
||||
pages: 0, // ← 对齐后端字段名
|
||||
has_prev: false, // ← 对齐后端
|
||||
has_next: false // ← 对齐后端
|
||||
}
|
||||
}
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
@@ -111,6 +127,47 @@ export const eventHandlers = [
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取动态新闻(实时要闻·动态追踪专用)
|
||||
http.get('/api/events/dynamic-news', async ({ request }) => {
|
||||
await delay(400);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const count = parseInt(url.searchParams.get('count') || '30');
|
||||
const startTime = url.searchParams.get('start_time');
|
||||
const endTime = url.searchParams.get('end_time');
|
||||
|
||||
console.log('[Mock] 获取动态新闻, count:', count, 'startTime:', startTime, 'endTime:', endTime);
|
||||
|
||||
try {
|
||||
let timeRange = null;
|
||||
if (startTime && endTime) {
|
||||
timeRange = {
|
||||
startTime: new Date(startTime),
|
||||
endTime: new Date(endTime)
|
||||
};
|
||||
}
|
||||
|
||||
const events = generateDynamicNewsEvents(timeRange, count);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: events,
|
||||
total: events.length,
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 获取动态新闻失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取动态新闻失败',
|
||||
data: []
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// ==================== 事件详情相关 ====================
|
||||
|
||||
// 获取事件相关股票
|
||||
@@ -142,6 +199,71 @@ export const eventHandlers = [
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取事件相关概念
|
||||
http.get('/api/events/:eventId/concepts', async ({ params }) => {
|
||||
await delay(300);
|
||||
|
||||
const { eventId } = params;
|
||||
|
||||
console.log('[Mock] 获取事件相关概念, eventId:', eventId);
|
||||
|
||||
try {
|
||||
// 返回热门概念列表(模拟真实场景下根据事件标题搜索的结果)
|
||||
const concepts = generatePopularConcepts(5);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: concepts,
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 获取事件相关概念失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取事件相关概念失败',
|
||||
data: []
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 切换事件关注状态
|
||||
http.post('/api/events/:eventId/follow', async ({ params }) => {
|
||||
await delay(200);
|
||||
|
||||
const { eventId } = params;
|
||||
|
||||
console.log('[Mock] 切换事件关注状态, eventId:', eventId);
|
||||
|
||||
try {
|
||||
// 模拟切换逻辑:随机生成关注状态
|
||||
// 实际应用中,这里应该从某个状态存储中读取和更新
|
||||
const isFollowing = Math.random() > 0.5;
|
||||
const followerCount = Math.floor(Math.random() * 1000) + 100;
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
is_following: isFollowing,
|
||||
follower_count: followerCount
|
||||
},
|
||||
message: isFollowing ? '关注成功' : '取消关注成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 切换事件关注状态失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '切换关注状态失败',
|
||||
data: null
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取事件传导链分析数据
|
||||
http.get('/api/events/:eventId/transmission', async ({ params }) => {
|
||||
await delay(500);
|
||||
|
||||
25
src/mocks/handlers/external.js
Normal file
25
src/mocks/handlers/external.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// src/mocks/handlers/external.js
|
||||
// 外部服务 Mock Handler(允许通过)
|
||||
|
||||
import { http, passthrough } from 'msw';
|
||||
|
||||
/**
|
||||
* 外部服务处理器
|
||||
* 对于外部服务(如头像、CDN等),使用 passthrough 让请求正常发送到真实服务器
|
||||
*/
|
||||
export const externalHandlers = [
|
||||
// Pravatar 头像服务 - 允许通过到真实服务
|
||||
http.get('https://i.pravatar.cc/*', async () => {
|
||||
return passthrough();
|
||||
}),
|
||||
|
||||
// 如果需要 mock 头像,也可以返回一个占位图片
|
||||
// http.get('https://i.pravatar.cc/*', async () => {
|
||||
// return HttpResponse.text(
|
||||
// '<svg width="150" height="150" xmlns="http://www.w3.org/2000/svg"><rect width="150" height="150" fill="#ddd"/><text x="50%" y="50%" text-anchor="middle" dy=".3em" fill="#999">Avatar</text></svg>',
|
||||
// {
|
||||
// headers: { 'Content-Type': 'image/svg+xml' }
|
||||
// }
|
||||
// );
|
||||
// }),
|
||||
];
|
||||
@@ -13,6 +13,8 @@ import { companyHandlers } from './company';
|
||||
import { marketHandlers } from './market';
|
||||
import { financialHandlers } from './financial';
|
||||
import { limitAnalyseHandlers } from './limitAnalyse';
|
||||
import { posthogHandlers } from './posthog';
|
||||
import { externalHandlers } from './external';
|
||||
|
||||
// 可以在这里添加更多的 handlers
|
||||
// import { userHandlers } from './user';
|
||||
@@ -30,5 +32,7 @@ export const handlers = [
|
||||
...marketHandlers,
|
||||
...financialHandlers,
|
||||
...limitAnalyseHandlers,
|
||||
...posthogHandlers,
|
||||
...externalHandlers,
|
||||
// ...userHandlers,
|
||||
];
|
||||
|
||||
132
src/mocks/handlers/posthog.js
Normal file
132
src/mocks/handlers/posthog.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// src/mocks/handlers/posthog.js
|
||||
// PostHog 埋点请求 Mock Handler
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
/**
|
||||
* PostHog 埋点 Mock Handler
|
||||
* 拦截所有发往 PostHog 的埋点请求,避免在 Mock 模式下产生 500 错误
|
||||
*/
|
||||
export const posthogHandlers = [
|
||||
// PostHog 事件追踪接口
|
||||
http.post('https://us.i.posthog.com/e/', async ({ request }) => {
|
||||
try {
|
||||
// 读取埋点数据(可选,用于调试)
|
||||
const body = await request.text();
|
||||
|
||||
// 开发环境输出埋点日志(可选,方便调试)
|
||||
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_LOG_POSTHOG === 'true') {
|
||||
console.log('[Mock] PostHog 埋点请求:', {
|
||||
url: request.url,
|
||||
bodyPreview: body.substring(0, 150) + (body.length > 150 ? '...' : ''),
|
||||
});
|
||||
}
|
||||
|
||||
// 返回成功响应(模拟 PostHog 服务器响应)
|
||||
return HttpResponse.json(
|
||||
{ status: 1 },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Mock] PostHog handler error:', error);
|
||||
return HttpResponse.json(
|
||||
{ status: 0, error: 'Mock handler error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// PostHog batch 批量事件追踪接口(可选)
|
||||
http.post('https://us.i.posthog.com/batch/', async ({ request }) => {
|
||||
try {
|
||||
const body = await request.text();
|
||||
|
||||
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_LOG_POSTHOG === 'true') {
|
||||
console.log('[Mock] PostHog 批量埋点请求:', {
|
||||
url: request.url,
|
||||
bodyPreview: body.substring(0, 150) + (body.length > 150 ? '...' : ''),
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json(
|
||||
{ status: 1 },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Mock] PostHog batch handler error:', error);
|
||||
return HttpResponse.json(
|
||||
{ status: 0, error: 'Mock handler error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// PostHog decide 接口(功能开关、特性标志)
|
||||
http.post('https://us.i.posthog.com/decide/', async ({ request }) => {
|
||||
try {
|
||||
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_LOG_POSTHOG === 'true') {
|
||||
const body = await request.json();
|
||||
console.log('[Mock] PostHog decide 请求:', body);
|
||||
}
|
||||
|
||||
// 返回空的特性标志配置
|
||||
return HttpResponse.json({
|
||||
featureFlags: {},
|
||||
sessionRecording: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] PostHog decide handler error:', error);
|
||||
return HttpResponse.json(
|
||||
{ featureFlags: {}, sessionRecording: false },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// PostHog session recording 接口(会话录制)
|
||||
http.post('https://us.i.posthog.com/s/', async ({ request }) => {
|
||||
try {
|
||||
const body = await request.text();
|
||||
|
||||
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_LOG_POSTHOG === 'true') {
|
||||
console.log('[Mock] PostHog session recording 请求:', {
|
||||
url: request.url,
|
||||
bodyPreview: body.substring(0, 100) + (body.length > 100 ? '...' : ''),
|
||||
});
|
||||
}
|
||||
|
||||
// 返回成功响应
|
||||
return HttpResponse.json(
|
||||
{ status: 1 },
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Mock] PostHog session recording handler error:', error);
|
||||
return HttpResponse.json(
|
||||
{ status: 0, error: 'Mock handler error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// PostHog feature flags 接口(特性标志查询)
|
||||
http.post('https://us.i.posthog.com/flags/', async ({ request }) => {
|
||||
try {
|
||||
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_LOG_POSTHOG === 'true') {
|
||||
console.log('[Mock] PostHog feature flags 请求:', request.url);
|
||||
}
|
||||
|
||||
// 返回空的特性标志
|
||||
return HttpResponse.json({
|
||||
featureFlags: {},
|
||||
featureFlagPayloads: {},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] PostHog flags handler error:', error);
|
||||
return HttpResponse.json(
|
||||
{ featureFlags: {}, featureFlagPayloads: {} },
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
];
|
||||
@@ -130,7 +130,7 @@ export const stockHandlers = [
|
||||
try {
|
||||
const stocks = generateStockList();
|
||||
|
||||
console.log('[Mock Stock] 获取股票列表成功:', { count: stocks.length });
|
||||
// console.log('[Mock Stock] 获取股票列表成功:', { count: stocks.length }); // 已关闭:减少日志
|
||||
|
||||
return HttpResponse.json(stocks);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// src/routes/components/RouteContainer.js
|
||||
// 路由容器组件 - 提供统一的错误边界、加载状态和主题背景
|
||||
|
||||
import React, { Suspense } from 'react';
|
||||
import { Box, useColorMode } from '@chakra-ui/react';
|
||||
import ErrorBoundary from '@components/ErrorBoundary';
|
||||
import PageLoader from '@components/Loading/PageLoader';
|
||||
|
||||
/**
|
||||
* RouteContainer - 路由容器组件
|
||||
*
|
||||
* 为路由系统提供统一的外层包装,包含:
|
||||
* 1. 主题感知的背景色(深色/浅色模式)
|
||||
* 2. Suspense 懒加载边界(显示加载提示)
|
||||
* 3. ErrorBoundary 错误边界(隔离路由错误)
|
||||
*
|
||||
* 这个组件确保:
|
||||
* - 所有路由页面都有一致的背景色
|
||||
* - 懒加载组件有统一的加载提示
|
||||
* - 单个路由的错误不会导致整个应用崩溃
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {React.ReactNode} props.children - 子组件(通常是 Routes)
|
||||
* @param {string} [props.loadingMessage='加载页面中...'] - 加载提示文本
|
||||
*
|
||||
* @example
|
||||
* <RouteContainer>
|
||||
* <Routes>
|
||||
* <Route path="/" element={<Home />} />
|
||||
* </Routes>
|
||||
* </RouteContainer>
|
||||
*/
|
||||
export function RouteContainer({
|
||||
children,
|
||||
loadingMessage = "加载页面中..."
|
||||
}) {
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
return (
|
||||
<Box
|
||||
minH="100vh"
|
||||
bg={colorMode === 'dark' ? 'gray.800' : 'white'}
|
||||
>
|
||||
{/* Suspense 统一处理懒加载组件的加载状态 */}
|
||||
<Suspense fallback={<PageLoader message={loadingMessage} />}>
|
||||
{/* ErrorBoundary 隔离路由错误,防止整个应用崩溃 */}
|
||||
<ErrorBoundary>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// src/routes/components/index.js
|
||||
// 统一导出所有路由组件
|
||||
|
||||
export { RouteContainer } from './RouteContainer';
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/routes/constants/index.js
|
||||
// 统一导出所有路由常量
|
||||
|
||||
export { LAYOUT_COMPONENTS } from './layoutComponents';
|
||||
export { PROTECTION_WRAPPER_MAP } from './protectionWrappers';
|
||||
export { PROTECTION_MODES } from './protectionModes';
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// src/routes/constants/layoutComponents.js
|
||||
// 布局组件映射表
|
||||
|
||||
import Auth from '@layouts/Auth';
|
||||
import HomeLayout from '@layouts/Home';
|
||||
|
||||
/**
|
||||
* 特殊布局组件映射表
|
||||
*
|
||||
* 用于将字符串标识符映射到实际的组件。
|
||||
* 这些是非懒加载的布局组件,在 routeConfig.js 中通过字符串引用。
|
||||
*
|
||||
* @example
|
||||
* // 在 routeConfig.js 中:
|
||||
* {
|
||||
* path: 'auth/*',
|
||||
* component: 'Auth', // 字符串标识符
|
||||
* ...
|
||||
* }
|
||||
*
|
||||
* // 通过 LAYOUT_COMPONENTS['Auth'] 获取实际组件
|
||||
*/
|
||||
export const LAYOUT_COMPONENTS = {
|
||||
Auth,
|
||||
HomeLayout,
|
||||
};
|
||||
14
src/routes/constants/protectionModes.js
Normal file
14
src/routes/constants/protectionModes.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// src/routes/constants/protectionModes.js
|
||||
// 路由保护模式常量
|
||||
|
||||
/**
|
||||
* 路由保护模式
|
||||
* - 'modal': 使用 ProtectedRoute (弹窗模式登录)
|
||||
* - 'redirect': 使用 ProtectedRouteRedirect (跳转模式登录)
|
||||
* - 'public': 公开访问,无需登录
|
||||
*/
|
||||
export const PROTECTION_MODES = {
|
||||
MODAL: 'modal',
|
||||
REDIRECT: 'redirect',
|
||||
PUBLIC: 'public',
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import ProtectedRoute from '@components/ProtectedRoute';
|
||||
import ProtectedRouteRedirect from '@components/ProtectedRouteRedirect';
|
||||
import { PROTECTION_MODES } from '../routeConfig';
|
||||
import { PROTECTION_MODES } from './protectionModes';
|
||||
|
||||
/**
|
||||
* 保护模式包装器映射表
|
||||
|
||||
115
src/routes/homeRoutes.js
Normal file
115
src/routes/homeRoutes.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// src/routes/homeRoutes.js
|
||||
// Home 模块子路由配置
|
||||
|
||||
import { lazyComponents } from './lazy-components';
|
||||
import { PROTECTION_MODES } from './constants/protectionModes';
|
||||
|
||||
/**
|
||||
* Home 模块的子路由配置
|
||||
* 这些路由将作为 /home/* 的嵌套路由
|
||||
*
|
||||
* 注意:
|
||||
* - 使用相对路径(不带前导斜杠)
|
||||
* - 空字符串 '' 表示索引路由,匹配 /home
|
||||
* - 这些路由将通过 Outlet 渲染到父路由中
|
||||
*/
|
||||
export const homeRoutes = [
|
||||
// 首页 - /home
|
||||
{
|
||||
path: '',
|
||||
component: lazyComponents.HomePage,
|
||||
protection: PROTECTION_MODES.PUBLIC,
|
||||
meta: {
|
||||
title: '首页',
|
||||
description: '价值前沿首页'
|
||||
}
|
||||
},
|
||||
|
||||
// 个人中心 - /home/center
|
||||
{
|
||||
path: 'center',
|
||||
component: lazyComponents.CenterDashboard,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
meta: {
|
||||
title: '个人中心',
|
||||
description: '用户个人中心'
|
||||
}
|
||||
},
|
||||
|
||||
// 个人资料 - /home/profile
|
||||
{
|
||||
path: 'profile',
|
||||
component: lazyComponents.ProfilePage,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
meta: {
|
||||
title: '个人资料',
|
||||
description: '用户个人资料'
|
||||
}
|
||||
},
|
||||
|
||||
// 账户设置 - /home/settings
|
||||
{
|
||||
path: 'settings',
|
||||
component: lazyComponents.SettingsPage,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
meta: {
|
||||
title: '账户设置',
|
||||
description: '用户账户设置'
|
||||
}
|
||||
},
|
||||
|
||||
// 订阅管理 - /home/pages/account/subscription
|
||||
{
|
||||
path: 'pages/account/subscription',
|
||||
component: lazyComponents.Subscription,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
meta: {
|
||||
title: '订阅管理',
|
||||
description: '管理订阅服务'
|
||||
}
|
||||
},
|
||||
|
||||
// 隐私政策 - /home/privacy-policy
|
||||
{
|
||||
path: 'privacy-policy',
|
||||
component: lazyComponents.PrivacyPolicy,
|
||||
protection: PROTECTION_MODES.PUBLIC,
|
||||
meta: {
|
||||
title: '隐私政策',
|
||||
description: '隐私保护政策'
|
||||
}
|
||||
},
|
||||
|
||||
// 用户协议 - /home/user-agreement
|
||||
{
|
||||
path: 'user-agreement',
|
||||
component: lazyComponents.UserAgreement,
|
||||
protection: PROTECTION_MODES.PUBLIC,
|
||||
meta: {
|
||||
title: '用户协议',
|
||||
description: '用户使用协议'
|
||||
}
|
||||
},
|
||||
|
||||
// 微信授权回调 - /home/wechat-callback
|
||||
{
|
||||
path: 'wechat-callback',
|
||||
component: lazyComponents.WechatCallback,
|
||||
protection: PROTECTION_MODES.PUBLIC,
|
||||
meta: {
|
||||
title: '微信授权',
|
||||
description: '微信授权回调页面'
|
||||
}
|
||||
},
|
||||
|
||||
// 回退路由 - 匹配任何未定义的 /home/* 路径
|
||||
{
|
||||
path: '*',
|
||||
component: lazyComponents.HomePage,
|
||||
protection: PROTECTION_MODES.PUBLIC,
|
||||
meta: {
|
||||
title: '首页',
|
||||
description: '价值前沿首页'
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -10,9 +10,8 @@ import { getMainLayoutRoutes, getStandaloneRoutes } from './routeConfig';
|
||||
// 布局组件
|
||||
import MainLayout from '@layouts/MainLayout';
|
||||
|
||||
// 路由工具和组件
|
||||
// 路由工具
|
||||
import { renderRoute } from './utils';
|
||||
import { RouteContainer } from './components';
|
||||
|
||||
/**
|
||||
* AppRoutes - 应用路由组件
|
||||
@@ -31,7 +30,11 @@ import { RouteContainer } from './components';
|
||||
* 目录结构:
|
||||
* - constants/ - 常量配置(布局映射、保护包装器)
|
||||
* - utils/ - 工具函数(renderRoute, wrapWithProtection)
|
||||
* - components/ - 路由组件(RouteContainer)
|
||||
* - components/ - 路由相关组件
|
||||
*
|
||||
* 注意:
|
||||
* - Suspense/ErrorBoundary 由 PageTransitionWrapper 统一处理
|
||||
* - 全屏容器由 MainLayout 提供(minH="100vh")
|
||||
*/
|
||||
export function AppRoutes() {
|
||||
// 🎯 性能优化:使用 useMemo 缓存路由计算结果
|
||||
@@ -39,23 +42,21 @@ export function AppRoutes() {
|
||||
const standaloneRoutes = useMemo(() => getStandaloneRoutes(), []);
|
||||
|
||||
return (
|
||||
<RouteContainer>
|
||||
<Routes>
|
||||
{/* 主布局路由 - 带导航栏和页脚 */}
|
||||
<Route element={<MainLayout />}>
|
||||
{mainLayoutRoutes.map(renderRoute)}
|
||||
</Route>
|
||||
<Routes>
|
||||
{/* 主布局路由 - 带导航栏和页脚 */}
|
||||
<Route element={<MainLayout />}>
|
||||
{mainLayoutRoutes.map(renderRoute)}
|
||||
</Route>
|
||||
|
||||
{/* 独立路由 - 无布局(如登录页)*/}
|
||||
{standaloneRoutes.map(renderRoute)}
|
||||
{/* 独立路由 - 无布局(如登录页)*/}
|
||||
{standaloneRoutes.map(renderRoute)}
|
||||
|
||||
{/* 默认路由 - 重定向到首页 */}
|
||||
<Route path="/" element={<Navigate to="/home" replace />} />
|
||||
{/* 默认路由 - 重定向到首页 */}
|
||||
<Route path="/" element={<Navigate to="/home" replace />} />
|
||||
|
||||
{/* 404 页面 - 捕获所有未匹配的路由 */}
|
||||
<Route path="*" element={<Navigate to="/home" replace />} />
|
||||
</Routes>
|
||||
</RouteContainer>
|
||||
{/* 404 页面 - 捕获所有未匹配的路由 */}
|
||||
<Route path="*" element={<Navigate to="/home" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,16 @@ import React from 'react';
|
||||
* 使用 React.lazy() 实现路由懒加载,大幅减少初始 JS 包大小
|
||||
*/
|
||||
export const lazyComponents = {
|
||||
// Home 模块
|
||||
HomePage: React.lazy(() => import('../views/Home/HomePage')),
|
||||
CenterDashboard: React.lazy(() => import('../views/Dashboard/Center')),
|
||||
ProfilePage: React.lazy(() => import('../views/Profile/ProfilePage')),
|
||||
SettingsPage: React.lazy(() => import('../views/Settings/SettingsPage')),
|
||||
Subscription: React.lazy(() => import('../views/Pages/Account/Subscription')),
|
||||
PrivacyPolicy: React.lazy(() => import('../views/Pages/PrivacyPolicy')),
|
||||
UserAgreement: React.lazy(() => import('../views/Pages/UserAgreement')),
|
||||
WechatCallback: React.lazy(() => import('../views/Pages/WechatCallback')),
|
||||
|
||||
// 社区/内容模块
|
||||
Community: React.lazy(() => import('../views/Community')),
|
||||
ConceptCenter: React.lazy(() => import('../views/Concept')),
|
||||
@@ -31,6 +41,14 @@ export const lazyComponents = {
|
||||
* 按需导出单个组件(可选)
|
||||
*/
|
||||
export const {
|
||||
HomePage,
|
||||
CenterDashboard,
|
||||
ProfilePage,
|
||||
SettingsPage,
|
||||
Subscription,
|
||||
PrivacyPolicy,
|
||||
UserAgreement,
|
||||
WechatCallback,
|
||||
Community,
|
||||
ConceptCenter,
|
||||
StockOverview,
|
||||
|
||||
@@ -2,35 +2,30 @@
|
||||
// 声明式路由配置
|
||||
|
||||
import { lazyComponents } from './lazy-components';
|
||||
import { homeRoutes } from './homeRoutes';
|
||||
import { PROTECTION_MODES } from './constants/protectionModes';
|
||||
|
||||
/**
|
||||
* 路由保护模式
|
||||
* - 'modal': 使用 ProtectedRoute (弹窗模式登录)
|
||||
* - 'redirect': 使用 ProtectedRouteRedirect (跳转模式登录)
|
||||
* - 'public': 公开访问,无需登录
|
||||
*/
|
||||
export const PROTECTION_MODES = {
|
||||
MODAL: 'modal',
|
||||
REDIRECT: 'redirect',
|
||||
PUBLIC: 'public',
|
||||
};
|
||||
// 重新导出 PROTECTION_MODES 以保持向后兼容
|
||||
export { PROTECTION_MODES };
|
||||
|
||||
/**
|
||||
* 路由配置
|
||||
* 每个路由对象包含:
|
||||
* - path: 路由路径
|
||||
* - component: 组件(从 lazyComponents 引用)
|
||||
* - component: 组件(从 lazyComponents 引用,或设为 null 使用 Outlet)
|
||||
* - protection: 保护模式 (modal/redirect/public)
|
||||
* - layout: 布局类型 (main/auth/none)
|
||||
* - children: 子路由配置数组(可选,用于嵌套路由)
|
||||
* - meta: 路由元数据(可选,用于面包屑、标题等)
|
||||
*/
|
||||
export const routeConfig = [
|
||||
// ==================== 首页 ====================
|
||||
{
|
||||
path: 'home/*',
|
||||
component: 'HomeLayout', // 非懒加载,直接在 App.js 导入
|
||||
path: 'home',
|
||||
component: null, // 使用 Outlet 渲染子路由
|
||||
protection: PROTECTION_MODES.PUBLIC,
|
||||
layout: 'main',
|
||||
children: homeRoutes, // 子路由配置
|
||||
meta: {
|
||||
title: '首页',
|
||||
description: '价值前沿首页'
|
||||
@@ -107,7 +102,7 @@ export const routeConfig = [
|
||||
{
|
||||
path: 'forecast-report',
|
||||
component: lazyComponents.ForecastReport,
|
||||
protection: PROTECTION_MODES.REDIRECT,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
layout: 'main',
|
||||
meta: {
|
||||
title: '财报预测',
|
||||
@@ -115,7 +110,7 @@ export const routeConfig = [
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'Financial',
|
||||
path: 'financial',
|
||||
component: lazyComponents.FinancialPanorama,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
layout: 'main',
|
||||
@@ -154,18 +149,6 @@ export const routeConfig = [
|
||||
description: '实时市场数据'
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== 认证模块 ====================
|
||||
{
|
||||
path: 'auth/*',
|
||||
component: 'Auth', // 非懒加载,直接在 App.js 导入
|
||||
protection: PROTECTION_MODES.PUBLIC,
|
||||
layout: 'none',
|
||||
meta: {
|
||||
title: '登录/注册',
|
||||
description: '用户认证'
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
// 路由渲染工具函数
|
||||
|
||||
import React from 'react';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { LAYOUT_COMPONENTS } from '../constants';
|
||||
import { Route, Outlet } from 'react-router-dom';
|
||||
import { wrapWithProtection } from './wrapWithProtection';
|
||||
|
||||
/**
|
||||
@@ -11,14 +10,16 @@ import { wrapWithProtection } from './wrapWithProtection';
|
||||
*
|
||||
* 根据路由配置项生成 React Router 的 Route 组件。
|
||||
* 处理以下逻辑:
|
||||
* 1. 解析组件(特殊布局组件 vs 懒加载组件)
|
||||
* 1. 解析组件(懒加载组件 or Outlet)
|
||||
* 2. 应用路由保护(根据 protection 字段)
|
||||
* 3. 生成唯一 key
|
||||
* 3. 处理嵌套路由(children 数组)
|
||||
* 4. 生成唯一 key
|
||||
*
|
||||
* @param {Object} routeItem - 路由配置项(来自 routeConfig.js)
|
||||
* @param {string} routeItem.path - 路由路径
|
||||
* @param {React.ComponentType|string} routeItem.component - 组件或组件标识符
|
||||
* @param {React.ComponentType|null} routeItem.component - 懒加载组件或 null(null 表示使用 Outlet)
|
||||
* @param {string} routeItem.protection - 保护模式 (modal/redirect/public)
|
||||
* @param {Array} [routeItem.children] - 子路由配置数组(可选)
|
||||
* @param {number} index - 路由索引,用于生成唯一 key
|
||||
*
|
||||
* @returns {React.ReactElement} Route 组件
|
||||
@@ -27,19 +28,41 @@ import { wrapWithProtection } from './wrapWithProtection';
|
||||
* // 使用示例
|
||||
* const routes = [
|
||||
* { path: 'community', component: CommunityComponent, protection: 'modal' },
|
||||
* { path: 'auth/*', component: 'Auth', protection: 'public' },
|
||||
* { path: 'home', component: null, protection: 'public', children: [...] },
|
||||
* ];
|
||||
*
|
||||
* routes.map((route, index) => renderRoute(route, index));
|
||||
*/
|
||||
export function renderRoute(routeItem, index) {
|
||||
const { path, component, protection } = routeItem;
|
||||
const { path, component, protection, children } = routeItem;
|
||||
|
||||
// 解析组件:
|
||||
// - 如果是字符串(如 'Auth', 'HomeLayout'),从 LAYOUT_COMPONENTS 映射表查找
|
||||
// - 如果是 null,使用 <Outlet /> 用于嵌套路由
|
||||
// - 否则直接使用(懒加载组件)
|
||||
const Component = LAYOUT_COMPONENTS[component] || component;
|
||||
let Component;
|
||||
let isOutletRoute = false;
|
||||
|
||||
if (component === null) {
|
||||
Component = Outlet; // 用于嵌套路由
|
||||
isOutletRoute = true;
|
||||
} else {
|
||||
Component = component; // 直接使用懒加载组件
|
||||
}
|
||||
|
||||
// 如果有子路由,递归渲染
|
||||
if (children && children.length > 0) {
|
||||
return (
|
||||
<Route
|
||||
key={`${path}-${index}`}
|
||||
path={path}
|
||||
element={isOutletRoute ? <Outlet /> : wrapWithProtection(Component, protection)}
|
||||
>
|
||||
{children.map((childRoute, childIndex) => renderRoute(childRoute, childIndex))}
|
||||
</Route>
|
||||
);
|
||||
}
|
||||
|
||||
// 没有子路由,渲染单个路由
|
||||
return (
|
||||
<Route
|
||||
key={`${path}-${index}`}
|
||||
|
||||
@@ -37,10 +37,12 @@ const apiRequest = async (url, options = {}) => {
|
||||
|
||||
export const eventService = {
|
||||
getEvents: (params = {}) => {
|
||||
// Filter out empty params
|
||||
const cleanParams = Object.fromEntries(Object.entries(params).filter(([_, v]) => v != null && v !== ''));
|
||||
// Filter out null, undefined, and empty strings (but keep 0 and false)
|
||||
const cleanParams = Object.fromEntries(
|
||||
Object.entries(params).filter(([_, v]) => v !== null && v !== undefined && v !== '')
|
||||
);
|
||||
const query = new URLSearchParams(cleanParams).toString();
|
||||
return apiRequest(`/api/events/?${query}`);
|
||||
return apiRequest(`/api/events?${query}`);
|
||||
},
|
||||
getHotEvents: (params = {}) => {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
|
||||
144
src/store/api/eventsApi.js
Normal file
144
src/store/api/eventsApi.js
Normal file
@@ -0,0 +1,144 @@
|
||||
// src/store/api/eventsApi.js
|
||||
// RTK Query API for Events - 事件数据获取 API
|
||||
|
||||
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
/**
|
||||
* Events API Slice - 使用 RTK Query 管理事件数据
|
||||
*
|
||||
* 特性:
|
||||
* - ✅ 自动缓存管理(按 queryKey 缓存)
|
||||
* - ✅ 自动去重请求
|
||||
* - ✅ 返回第一页刷新数据(invalidateTags)
|
||||
* - ✅ 预加载支持(prefetch)
|
||||
* - ✅ 统一在 Redux DevTools 中调试
|
||||
* - ✅ 无需额外 Provider
|
||||
*/
|
||||
export const eventsApi = createApi({
|
||||
reducerPath: 'eventsApi',
|
||||
|
||||
// 基础查询配置
|
||||
baseQuery: fetchBaseQuery({
|
||||
baseUrl: '/api',
|
||||
prepareHeaders: (headers) => {
|
||||
// 可以在这里添加认证 token
|
||||
// const token = localStorage.getItem('token');
|
||||
// if (token) {
|
||||
// headers.set('Authorization', `Bearer ${token}`);
|
||||
// }
|
||||
return headers;
|
||||
},
|
||||
}),
|
||||
|
||||
// 标签类型定义(用于缓存失效)
|
||||
tagTypes: ['Events'],
|
||||
|
||||
// API 端点定义
|
||||
endpoints: (builder) => ({
|
||||
/**
|
||||
* 获取分页事件列表
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {number} params.page - 页码
|
||||
* @param {number} params.per_page - 每页数量
|
||||
* @param {string} params.mode - 显示模式(vertical / four-row)
|
||||
* @param {string} params.sort - 排序方式
|
||||
* @param {string} params.importance - 重要性筛选
|
||||
* @param {string} params.q - 搜索关键词
|
||||
* @param {string} params.date_range - 日期范围
|
||||
* @param {string} params.industry_code - 行业代码
|
||||
*
|
||||
* @returns {Object} { events: Array, pagination: Object }
|
||||
*/
|
||||
getEvents: builder.query({
|
||||
query: ({ page = 1, per_page = 5, mode = 'vertical', ...filters }) => {
|
||||
logger.debug('eventsApi', 'getEvents 请求', {
|
||||
page,
|
||||
per_page,
|
||||
mode,
|
||||
filters,
|
||||
});
|
||||
|
||||
return {
|
||||
url: '/events',
|
||||
params: {
|
||||
page,
|
||||
per_page,
|
||||
...filters,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
// 🔥 缓存标签:用于缓存失效
|
||||
providesTags: (result, error, { page, mode }) => {
|
||||
if (error) return [];
|
||||
|
||||
return [
|
||||
{ type: 'Events', id: `${mode}-${page}` }, // 具体页面的标签
|
||||
{ type: 'Events', id: `${mode}-LIST` }, // 模式的总标签
|
||||
];
|
||||
},
|
||||
|
||||
// 转换响应数据
|
||||
transformResponse: (response) => {
|
||||
logger.debug('eventsApi', 'getEvents 响应', {
|
||||
eventsCount: response.data?.events?.length,
|
||||
total: response.data?.pagination?.total,
|
||||
});
|
||||
|
||||
if (!response.success || !response.data?.events) {
|
||||
throw new Error('数据格式错误');
|
||||
}
|
||||
|
||||
return {
|
||||
events: response.data.events,
|
||||
pagination: response.data.pagination || {},
|
||||
};
|
||||
},
|
||||
|
||||
// 错误处理
|
||||
transformErrorResponse: (response) => {
|
||||
logger.error('eventsApi', 'getEvents 失败', new Error(response.status));
|
||||
return {
|
||||
status: response.status,
|
||||
message: response.data?.message || '获取事件数据失败',
|
||||
};
|
||||
},
|
||||
|
||||
// 🔥 keepUnusedDataFor: 缓存保留时间(秒)
|
||||
keepUnusedDataFor: 600, // 10分钟
|
||||
|
||||
// 🔥 合并查询结果(用于无限滚动)
|
||||
// serializeQueryArgs: ({ endpointName, queryArgs }) => {
|
||||
// const { mode, ...filters } = queryArgs;
|
||||
// return `${endpointName}(${mode})`;
|
||||
// },
|
||||
// merge: (currentCache, newItems) => {
|
||||
// currentCache.events.push(...newItems.events);
|
||||
// },
|
||||
// forceRefetch: ({ currentArg, previousArg }) => {
|
||||
// return currentArg.page !== previousArg?.page;
|
||||
// },
|
||||
}),
|
||||
|
||||
/**
|
||||
* 预加载下一页(性能优化)
|
||||
*
|
||||
* 用法:
|
||||
* dispatch(eventsApi.util.prefetch('getEvents', { page: 2, ... }))
|
||||
*/
|
||||
}),
|
||||
});
|
||||
|
||||
// 导出自动生成的 Hooks
|
||||
export const {
|
||||
useGetEventsQuery,
|
||||
useLazyGetEventsQuery, // 手动触发的版本
|
||||
usePrefetch, // 预加载 Hook
|
||||
} = eventsApi;
|
||||
|
||||
// 导出工具方法
|
||||
export const {
|
||||
util: { invalidateTags, prefetch },
|
||||
} = eventsApi;
|
||||
@@ -7,6 +7,7 @@ import stockReducer from './slices/stockSlice';
|
||||
import authModalReducer from './slices/authModalSlice';
|
||||
import subscriptionReducer from './slices/subscriptionSlice';
|
||||
import posthogMiddleware from './middleware/posthogMiddleware';
|
||||
import { eventsApi } from './api/eventsApi'; // ✅ RTK Query API
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
@@ -16,6 +17,7 @@ export const store = configureStore({
|
||||
stock: stockReducer, // ✅ 股票和事件数据管理
|
||||
authModal: authModalReducer, // ✅ 认证弹窗状态管理
|
||||
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
|
||||
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
@@ -29,7 +31,9 @@ export const store = configureStore({
|
||||
'stock/fetchStockQuotes/fulfilled',
|
||||
],
|
||||
},
|
||||
}).concat(posthogMiddleware), // ✅ PostHog 自动追踪中间件
|
||||
})
|
||||
.concat(posthogMiddleware) // ✅ PostHog 自动追踪中间件
|
||||
.concat(eventsApi.middleware), // ✅ RTK Query 中间件(自动缓存、去重、重试)
|
||||
});
|
||||
|
||||
export default store;
|
||||
|
||||
@@ -103,7 +103,6 @@ const createDataReducers = (builder, asyncThunk, dataKey) => {
|
||||
.addCase(asyncThunk.fulfilled, (state, action) => {
|
||||
state.loading[dataKey] = false;
|
||||
state[dataKey] = action.payload;
|
||||
state.lastUpdated[dataKey] = new Date().toISOString();
|
||||
})
|
||||
.addCase(asyncThunk.rejected, (state, action) => {
|
||||
state.loading[dataKey] = false;
|
||||
@@ -156,8 +155,191 @@ export const fetchHotEvents = createAsyncThunk(
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取动态新闻(客户端缓存 + 虚拟滚动)
|
||||
* 用于 DynamicNewsCard 组件
|
||||
* @param {Object} params - 请求参数
|
||||
* @param {string} params.mode - 显示模式('vertical' | 'four-row')
|
||||
* @param {number} params.page - 页码
|
||||
* @param {number} params.per_page - 每页数量(可选,不提供时自动根据 mode 计算)
|
||||
* @param {boolean} params.clearCache - 是否清空缓存(默认 false)
|
||||
* @param {boolean} params.prependMode - 是否追加到头部(用于定时刷新,默认 false)
|
||||
* @param {string} params.sort - 排序方式(new/hot)
|
||||
* @param {string} params.importance - 重要性筛选(all/1/2/3/4/5)
|
||||
* @param {string} params.q - 搜索关键词
|
||||
* @param {string} params.date_range - 时间范围
|
||||
* @param {string} params.industry_code - 行业代码
|
||||
*/
|
||||
export const fetchDynamicNews = createAsyncThunk(
|
||||
'communityData/fetchDynamicNews',
|
||||
async ({
|
||||
mode = 'vertical',
|
||||
page = 1,
|
||||
per_page, // 移除默认值,下面动态计算
|
||||
pageSize, // 向后兼容(已废弃,使用 per_page)
|
||||
clearCache = false,
|
||||
prependMode = false,
|
||||
sort = 'new',
|
||||
importance,
|
||||
q,
|
||||
date_range, // 兼容旧格式(已废弃)
|
||||
industry_code,
|
||||
// 时间筛选参数(从 TradingTimeFilter 传递)
|
||||
start_date,
|
||||
end_date,
|
||||
recent_days
|
||||
} = {}, { rejectWithValue }) => {
|
||||
try {
|
||||
// 【动态计算 per_page】根据 mode 自动选择合适的每页大小
|
||||
// - 平铺模式 (four-row): 30 条(7.5行 × 4列,提供充足的缓冲数据)
|
||||
// - 纵向模式 (vertical): 10 条(传统分页)
|
||||
// 优先使用传入的 per_page,其次使用 pageSize(向后兼容),最后根据 mode 计算
|
||||
const finalPerPage = per_page || pageSize || (mode === 'four-row' ? 30 : 10);
|
||||
|
||||
// 构建筛选参数
|
||||
const filters = {};
|
||||
if (sort) filters.sort = sort;
|
||||
if (importance && importance !== 'all') filters.importance = importance;
|
||||
if (q) filters.q = q;
|
||||
if (date_range) filters.date_range = date_range; // 兼容旧格式
|
||||
if (industry_code) filters.industry_code = industry_code;
|
||||
// 时间筛选参数
|
||||
if (start_date) filters.start_date = start_date;
|
||||
if (end_date) filters.end_date = end_date;
|
||||
if (recent_days) filters.recent_days = recent_days;
|
||||
|
||||
logger.debug('CommunityData', '开始获取动态新闻', {
|
||||
mode,
|
||||
page,
|
||||
per_page: finalPerPage,
|
||||
clearCache,
|
||||
prependMode,
|
||||
filters
|
||||
});
|
||||
|
||||
const response = await eventService.getEvents({
|
||||
page,
|
||||
per_page: finalPerPage,
|
||||
...filters
|
||||
});
|
||||
|
||||
if (response.success && response.data?.events) {
|
||||
logger.info('CommunityData', '动态新闻加载成功', {
|
||||
count: response.data.events.length,
|
||||
page: response.data.pagination?.page || page,
|
||||
total: response.data.pagination?.total || 0,
|
||||
per_page: finalPerPage
|
||||
});
|
||||
// 【兜底处理】支持多种 pagination 字段名:pages (后端) / total_pages (旧Mock) / totalPages
|
||||
const paginationData = response.data.pagination || {};
|
||||
const calculatedTotalPages = paginationData.pages // ← 后端格式 (优先)
|
||||
|| paginationData.total_pages // ← Mock 旧格式 (兼容)
|
||||
|| paginationData.totalPages // ← 其他可能格式
|
||||
|| Math.ceil((paginationData.total || 0) / finalPerPage); // ← 兜底计算
|
||||
|
||||
return {
|
||||
mode,
|
||||
events: response.data.events,
|
||||
total: paginationData.total || 0,
|
||||
totalPages: calculatedTotalPages,
|
||||
page,
|
||||
per_page: finalPerPage,
|
||||
clearCache,
|
||||
prependMode
|
||||
};
|
||||
}
|
||||
|
||||
logger.warn('CommunityData', '动态新闻返回数据为空', response);
|
||||
// 【兜底处理】空数据情况也尝试读取 pagination
|
||||
const emptyPaginationData = response.data?.pagination || {};
|
||||
const emptyTotalPages = emptyPaginationData.pages || emptyPaginationData.total_pages || 0;
|
||||
|
||||
return {
|
||||
mode,
|
||||
events: [],
|
||||
total: 0,
|
||||
totalPages: emptyTotalPages,
|
||||
page,
|
||||
per_page: finalPerPage,
|
||||
clearCache,
|
||||
prependMode,
|
||||
isEmpty: true // 标记为空数据,用于边界条件处理
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('CommunityData', '获取动态新闻失败', error);
|
||||
return rejectWithValue(error.message || '获取动态新闻失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 切换事件关注状态
|
||||
* 复用 EventList.js 中的关注逻辑
|
||||
* @param {number} eventId - 事件ID
|
||||
*/
|
||||
export const toggleEventFollow = createAsyncThunk(
|
||||
'communityData/toggleEventFollow',
|
||||
async (eventId, { rejectWithValue }) => {
|
||||
try {
|
||||
logger.debug('CommunityData', '切换事件关注状态', { eventId });
|
||||
|
||||
// 调用 API(自动切换关注状态,后端根据当前状态决定关注/取消关注)
|
||||
const response = await fetch(`/api/events/${eventId}/follow`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || '操作失败');
|
||||
}
|
||||
|
||||
const isFollowing = data.data?.is_following;
|
||||
const followerCount = data.data?.follower_count ?? 0;
|
||||
|
||||
logger.info('CommunityData', '关注状态切换成功', {
|
||||
eventId,
|
||||
isFollowing,
|
||||
followerCount
|
||||
});
|
||||
|
||||
return {
|
||||
eventId,
|
||||
isFollowing,
|
||||
followerCount
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('CommunityData', '切换关注状态失败', error);
|
||||
return rejectWithValue(error.message || '切换关注状态失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== Slice 定义 ====================
|
||||
|
||||
/**
|
||||
* 【Redux State 架构设计】
|
||||
*
|
||||
* 核心原则:
|
||||
* 1. **模式独立存储**: verticalEvents 和 fourRowEvents 完全独立
|
||||
* - 原因:两种模式使用不同的 pageSize (10 vs 30),共享缓存会导致分页混乱
|
||||
* - 代价:~50% 内存冗余(同一事件可能存在于两个数组)
|
||||
* - 权衡:简化逻辑复杂度,避免 pageSize 切换时的边界计算问题
|
||||
*
|
||||
* 2. **数据去重**: 使用 Set 去重,防止重复事件
|
||||
* - 场景1:网络请求乱序(慢请求后返回)
|
||||
* - 场景2:定时刷新 + prepend 模式(新事件插入头部)
|
||||
* - 场景3:后端分页漂移(新数据导致页码偏移)
|
||||
*
|
||||
* 3. **追加模式 (append)**: 虚拟滚动必须使用累积数组
|
||||
* - 原因:虚拟滚动需要完整数据计算 totalHeight
|
||||
* - 对比:传统分页每次替换数据(page mode)
|
||||
*
|
||||
* 4. **加载状态管理**: 分模式独立管理 loading/error
|
||||
* - 避免模式切换时的加载状态冲突
|
||||
*/
|
||||
const communityDataSlice = createSlice({
|
||||
name: 'communityData',
|
||||
initialState: {
|
||||
@@ -165,28 +347,47 @@ const communityDataSlice = createSlice({
|
||||
popularKeywords: [],
|
||||
hotEvents: [],
|
||||
|
||||
// 加载状态
|
||||
// 【纵向模式】独立存储(传统分页 + 每页10条)
|
||||
verticalEventsByPage: {}, // 页码映射存储 { 1: [10条], 2: [8条], 3: [10条] }
|
||||
verticalPagination: { // 分页元数据
|
||||
total: 0, // 总记录数
|
||||
total_pages: 0, // 总页数
|
||||
current_page: 1, // 当前页码
|
||||
per_page: 10 // 每页大小
|
||||
},
|
||||
|
||||
// 【平铺模式】独立存储(虚拟滚动 + 每页30条)
|
||||
fourRowEvents: [], // 完整缓存列表(虚拟滚动的数据源)
|
||||
fourRowPagination: { // 分页元数据
|
||||
total: 0, // 总记录数
|
||||
total_pages: 0, // 总页数
|
||||
current_page: 1, // 当前页码
|
||||
per_page: 30 // 每页大小
|
||||
},
|
||||
|
||||
eventFollowStatus: {}, // 事件关注状态(全局共享){ [eventId]: { isFollowing: boolean, followerCount: number } }
|
||||
|
||||
// 加载状态(分模式管理)
|
||||
loading: {
|
||||
popularKeywords: false,
|
||||
hotEvents: false
|
||||
hotEvents: false,
|
||||
verticalEvents: false,
|
||||
fourRowEvents: false
|
||||
},
|
||||
|
||||
// 错误信息
|
||||
// 错误信息(分模式管理)
|
||||
error: {
|
||||
popularKeywords: null,
|
||||
hotEvents: null
|
||||
},
|
||||
|
||||
// 最后更新时间
|
||||
lastUpdated: {
|
||||
popularKeywords: null,
|
||||
hotEvents: null
|
||||
hotEvents: null,
|
||||
verticalEvents: null,
|
||||
fourRowEvents: null
|
||||
}
|
||||
},
|
||||
|
||||
reducers: {
|
||||
/**
|
||||
* 清除所有缓存(Redux + localStorage)
|
||||
* 注意:verticalEvents 和 fourRowEvents 不使用 localStorage 缓存
|
||||
*/
|
||||
clearCache: (state) => {
|
||||
// 清除 localStorage
|
||||
@@ -195,15 +396,19 @@ const communityDataSlice = createSlice({
|
||||
// 清除 Redux 状态
|
||||
state.popularKeywords = [];
|
||||
state.hotEvents = [];
|
||||
state.lastUpdated.popularKeywords = null;
|
||||
state.lastUpdated.hotEvents = null;
|
||||
|
||||
// 清除动态新闻数据(两个模式)
|
||||
state.verticalEventsByPage = {};
|
||||
state.fourRowEvents = [];
|
||||
state.verticalPagination = { total: 0, total_pages: 0, current_page: 1, per_page: 10 };
|
||||
state.fourRowPagination = { total: 0, total_pages: 0, current_page: 1, per_page: 30 };
|
||||
|
||||
logger.info('CommunityData', '所有缓存已清除');
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除指定类型的缓存
|
||||
* @param {string} payload - 缓存类型 ('popularKeywords' | 'hotEvents')
|
||||
* @param {string} payload - 缓存类型 ('popularKeywords' | 'hotEvents' | 'verticalEvents' | 'fourRowEvents')
|
||||
*/
|
||||
clearSpecificCache: (state, action) => {
|
||||
const type = action.payload;
|
||||
@@ -211,13 +416,21 @@ const communityDataSlice = createSlice({
|
||||
if (type === 'popularKeywords') {
|
||||
localCacheManager.remove(CACHE_KEYS.POPULAR_KEYWORDS);
|
||||
state.popularKeywords = [];
|
||||
state.lastUpdated.popularKeywords = null;
|
||||
logger.info('CommunityData', '热门关键词缓存已清除');
|
||||
} else if (type === 'hotEvents') {
|
||||
localCacheManager.remove(CACHE_KEYS.HOT_EVENTS);
|
||||
state.hotEvents = [];
|
||||
state.lastUpdated.hotEvents = null;
|
||||
logger.info('CommunityData', '热点事件缓存已清除');
|
||||
} else if (type === 'verticalEvents') {
|
||||
// verticalEvents 不使用 localStorage,只清除 Redux state
|
||||
state.verticalEventsByPage = {};
|
||||
state.verticalPagination = { total: 0, total_pages: 0, current_page: 1, per_page: 10 };
|
||||
logger.info('CommunityData', '纵向模式事件数据已清除');
|
||||
} else if (type === 'fourRowEvents') {
|
||||
// fourRowEvents 不使用 localStorage,只清除 Redux state
|
||||
state.fourRowEvents = [];
|
||||
state.fourRowPagination = { total: 0, total_pages: 0, current_page: 1, per_page: 30 };
|
||||
logger.info('CommunityData', '平铺模式事件数据已清除');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -225,9 +438,30 @@ const communityDataSlice = createSlice({
|
||||
* 预加载数据(用于应用启动时)
|
||||
* 注意:这不是异步 action,只是触发标记
|
||||
*/
|
||||
preloadData: (state) => {
|
||||
preloadData: (_state) => {
|
||||
logger.info('CommunityData', '准备预加载数据');
|
||||
// 实际的预加载逻辑在组件中调用 dispatch(fetchPopularKeywords()) 等
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置单个事件的关注状态(同步)
|
||||
* @param {Object} action.payload - { eventId, isFollowing, followerCount }
|
||||
*/
|
||||
setEventFollowStatus: (state, action) => {
|
||||
const { eventId, isFollowing, followerCount } = action.payload;
|
||||
state.eventFollowStatus[eventId] = { isFollowing, followerCount };
|
||||
logger.debug('CommunityData', '设置事件关注状态', { eventId, isFollowing, followerCount });
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新分页页码(用于缓存场景,无需 API 请求)
|
||||
* @param {Object} action.payload - { mode, page }
|
||||
*/
|
||||
updatePaginationPage: (state, action) => {
|
||||
const { mode, page } = action.payload;
|
||||
const paginationKey = mode === 'four-row' ? 'fourRowPagination' : 'verticalPagination';
|
||||
state[paginationKey].current_page = page;
|
||||
logger.debug('CommunityData', '同步更新分页页码(缓存场景)', { mode, page });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -235,40 +469,213 @@ const communityDataSlice = createSlice({
|
||||
// 使用工厂函数创建 reducers,消除重复代码
|
||||
createDataReducers(builder, fetchPopularKeywords, 'popularKeywords');
|
||||
createDataReducers(builder, fetchHotEvents, 'hotEvents');
|
||||
|
||||
// dynamicNews 需要特殊处理(缓存 + 追加模式)
|
||||
// 根据 mode 更新不同的 state(verticalEvents 或 fourRowEvents)
|
||||
builder
|
||||
.addCase(fetchDynamicNews.pending, (state, action) => {
|
||||
const mode = action.meta.arg.mode || 'vertical';
|
||||
const stateKey = mode === 'four-row' ? 'fourRowEvents' : 'verticalEvents';
|
||||
state.loading[stateKey] = true;
|
||||
state.error[stateKey] = null;
|
||||
})
|
||||
.addCase(fetchDynamicNews.fulfilled, (state, action) => {
|
||||
const { mode, events, total, page, per_page, clearCache, prependMode, isEmpty } = action.payload;
|
||||
const stateKey = mode === 'four-row' ? 'fourRowEvents' : 'verticalEvents';
|
||||
const totalKey = mode === 'four-row' ? 'fourRowTotal' : 'verticalTotal';
|
||||
|
||||
// 边界条件:空数据只记录日志,不更新 state(保留现有数据)
|
||||
if (isEmpty || (events.length === 0 && !clearCache)) {
|
||||
logger.info('CommunityData', `${mode} 模式返回空数据,跳过更新`);
|
||||
state.loading[stateKey] = false;
|
||||
state.error[stateKey] = '暂无更多数据'; // 设置提示信息供组件显示 toast
|
||||
return; // 提前返回,不更新数据
|
||||
}
|
||||
|
||||
// 🔍 调试:收到数据
|
||||
console.log('%c[Redux] fetchDynamicNews.fulfilled 收到数据', 'color: #10B981; font-weight: bold;', {
|
||||
mode,
|
||||
stateKey,
|
||||
eventsCount: events.length,
|
||||
total,
|
||||
page,
|
||||
clearCache,
|
||||
prependMode,
|
||||
'state[stateKey] 类型': Array.isArray(state[stateKey]) ? 'Array' : 'Object',
|
||||
'state[stateKey] 之前': Array.isArray(state[stateKey])
|
||||
? `数组长度: ${state[stateKey].length}`
|
||||
: `对象页数: ${Object.keys(state[stateKey] || {}).length}`,
|
||||
});
|
||||
|
||||
/**
|
||||
* 【数据存储逻辑】根据模式选择不同的存储策略
|
||||
*
|
||||
* 纵向模式(vertical):页码映射存储
|
||||
* - clearCache=true: 清空所有页,存储新页(第1页专用)
|
||||
* - clearCache=false: 存储到对应页码(第2、3、4...页)
|
||||
* - 优点:每页独立,不受去重影响,支持缓存
|
||||
*
|
||||
* 平铺模式(four-row):去重追加存储
|
||||
* - clearCache=true: 直接替换(用于刷新)
|
||||
* - prependMode=true: 去重后插入头部(定时刷新)
|
||||
* - 默认:去重后追加到末尾(无限滚动)
|
||||
* - 优点:累积显示,支持虚拟滚动
|
||||
*/
|
||||
if (mode === 'vertical') {
|
||||
// 【纵向模式】页码映射存储
|
||||
if (clearCache) {
|
||||
// 第1页:清空所有页,只保留新页
|
||||
state.verticalEventsByPage = { [page]: events };
|
||||
logger.debug('CommunityData', `清空缓存并加载第${page}页 (纵向模式)`, {
|
||||
count: events.length
|
||||
});
|
||||
console.log('%c[Redux] 纵向模式 clearCache,清空所有页', 'color: #10B981; font-weight: bold;', {
|
||||
page,
|
||||
eventsCount: events.length
|
||||
});
|
||||
} else {
|
||||
// 其他页:存储到对应页码
|
||||
state.verticalEventsByPage = state.verticalEventsByPage || {};
|
||||
state.verticalEventsByPage[page] = events;
|
||||
logger.debug('CommunityData', `存储第${page}页数据 (纵向模式)`, {
|
||||
page,
|
||||
count: events.length,
|
||||
totalPages: Object.keys(state.verticalEventsByPage || {}).length
|
||||
});
|
||||
console.log('%c[Redux] 纵向模式追加页面', 'color: #10B981; font-weight: bold;', {
|
||||
page,
|
||||
eventsCount: events.length,
|
||||
cachedPages: Object.keys(state.verticalEventsByPage || {})
|
||||
});
|
||||
}
|
||||
} else if (mode === 'four-row') {
|
||||
// 【平铺模式】去重追加存储
|
||||
if (clearCache) {
|
||||
// 清空缓存模式:直接替换
|
||||
state.fourRowEvents = events;
|
||||
logger.debug('CommunityData', `清空缓存并加载新数据 (平铺模式)`, {
|
||||
count: events.length
|
||||
});
|
||||
console.log('%c[Redux] 平铺模式 clearCache,直接替换数据', 'color: #10B981; font-weight: bold;', {
|
||||
eventsCount: events.length
|
||||
});
|
||||
} else if (prependMode) {
|
||||
// 追加到头部模式(用于定时刷新):去重后插入头部
|
||||
const existingIds = new Set((state.fourRowEvents || []).map(e => e.id));
|
||||
const newEvents = events.filter(e => !existingIds.has(e.id));
|
||||
state.fourRowEvents = [...newEvents, ...(state.fourRowEvents || [])];
|
||||
logger.debug('CommunityData', `追加新数据到头部 (平铺模式)`, {
|
||||
newCount: newEvents.length,
|
||||
totalCount: state.fourRowEvents.length
|
||||
});
|
||||
} else {
|
||||
// 默认追加模式:去重后追加到末尾(用于虚拟滚动加载下一页)
|
||||
const existingIds = new Set((state.fourRowEvents || []).map(e => e.id));
|
||||
const newEvents = events.filter(e => !existingIds.has(e.id));
|
||||
state.fourRowEvents = [...(state.fourRowEvents || []), ...newEvents];
|
||||
|
||||
logger.debug('CommunityData', `追加新数据(去重,平铺模式)`, {
|
||||
page,
|
||||
originalEventsCount: events.length,
|
||||
newEventsCount: newEvents.length,
|
||||
filteredCount: events.length - newEvents.length,
|
||||
totalCount: state.fourRowEvents.length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 【元数据存储】存储完整的 pagination 对象
|
||||
const paginationKey = mode === 'four-row' ? 'fourRowPagination' : 'verticalPagination';
|
||||
const finalPerPage = per_page || (mode === 'four-row' ? 30 : 10); // 兜底默认值
|
||||
state[paginationKey] = {
|
||||
total: total,
|
||||
total_pages: action.payload.totalPages || Math.ceil(total / finalPerPage),
|
||||
current_page: page,
|
||||
per_page: finalPerPage
|
||||
};
|
||||
|
||||
console.log('%c[Redux] 更新分页元数据', 'color: #8B5CF6; font-weight: bold;', {
|
||||
mode,
|
||||
pagination: state[paginationKey]
|
||||
});
|
||||
|
||||
state.loading[stateKey] = false;
|
||||
})
|
||||
.addCase(fetchDynamicNews.rejected, (state, action) => {
|
||||
const mode = action.meta.arg.mode || 'vertical';
|
||||
const stateKey = mode === 'four-row' ? 'fourRowEvents' : 'verticalEvents';
|
||||
state.loading[stateKey] = false;
|
||||
state.error[stateKey] = action.payload;
|
||||
logger.error('CommunityData', `${stateKey} 加载失败`, new Error(action.payload));
|
||||
})
|
||||
// toggleEventFollow
|
||||
.addCase(toggleEventFollow.fulfilled, (state, action) => {
|
||||
const { eventId, isFollowing, followerCount } = action.payload;
|
||||
state.eventFollowStatus[eventId] = { isFollowing, followerCount };
|
||||
logger.debug('CommunityData', 'toggleEventFollow fulfilled', { eventId, isFollowing, followerCount });
|
||||
})
|
||||
.addCase(toggleEventFollow.rejected, (_state, action) => {
|
||||
logger.error('CommunityData', 'toggleEventFollow rejected', action.payload);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 导出 ====================
|
||||
|
||||
export const { clearCache, clearSpecificCache, preloadData } = communityDataSlice.actions;
|
||||
export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus, updatePaginationPage } = communityDataSlice.actions;
|
||||
|
||||
// 基础选择器(Selectors)
|
||||
export const selectPopularKeywords = (state) => state.communityData.popularKeywords;
|
||||
export const selectHotEvents = (state) => state.communityData.hotEvents;
|
||||
export const selectEventFollowStatus = (state) => state.communityData.eventFollowStatus;
|
||||
export const selectLoading = (state) => state.communityData.loading;
|
||||
export const selectError = (state) => state.communityData.error;
|
||||
export const selectLastUpdated = (state) => state.communityData.lastUpdated;
|
||||
|
||||
// 纵向模式数据选择器
|
||||
export const selectVerticalEventsByPage = (state) => state.communityData.verticalEventsByPage;
|
||||
export const selectVerticalPagination = (state) => state.communityData.verticalPagination;
|
||||
export const selectVerticalCachedPageCount = (state) => Object.keys(state.communityData.verticalEventsByPage || {}).length;
|
||||
|
||||
// 平铺模式数据选择器
|
||||
export const selectFourRowEvents = (state) => state.communityData.fourRowEvents;
|
||||
export const selectFourRowPagination = (state) => state.communityData.fourRowPagination;
|
||||
export const selectFourRowCachedCount = (state) => (state.communityData.fourRowEvents || []).length;
|
||||
|
||||
// 向后兼容的选择器(已废弃,建议使用 selectVerticalPagination.total)
|
||||
export const selectVerticalTotal = (state) => state.communityData.verticalPagination?.total || 0;
|
||||
export const selectFourRowTotal = (state) => state.communityData.fourRowPagination?.total || 0;
|
||||
|
||||
// 组合选择器
|
||||
export const selectPopularKeywordsWithLoading = (state) => ({
|
||||
data: state.communityData.popularKeywords,
|
||||
loading: state.communityData.loading.popularKeywords,
|
||||
error: state.communityData.error.popularKeywords,
|
||||
lastUpdated: state.communityData.lastUpdated.popularKeywords
|
||||
error: state.communityData.error.popularKeywords
|
||||
});
|
||||
|
||||
export const selectHotEventsWithLoading = (state) => ({
|
||||
data: state.communityData.hotEvents,
|
||||
loading: state.communityData.loading.hotEvents,
|
||||
error: state.communityData.error.hotEvents,
|
||||
lastUpdated: state.communityData.lastUpdated.hotEvents
|
||||
error: state.communityData.error.hotEvents
|
||||
});
|
||||
|
||||
// 工具函数:检查数据是否需要刷新(超过指定时间)
|
||||
export const shouldRefresh = (lastUpdated, thresholdMinutes = 30) => {
|
||||
if (!lastUpdated) return true;
|
||||
const elapsed = Date.now() - new Date(lastUpdated).getTime();
|
||||
return elapsed > thresholdMinutes * 60 * 1000;
|
||||
};
|
||||
// 纵向模式数据 + 加载状态选择器
|
||||
export const selectVerticalEventsWithLoading = (state) => ({
|
||||
data: state.communityData.verticalEventsByPage, // 页码映射 { 1: [...], 2: [...] }
|
||||
loading: state.communityData.loading.verticalEvents,
|
||||
error: state.communityData.error.verticalEvents,
|
||||
pagination: state.communityData.verticalPagination, // 完整分页元数据 { total, total_pages, current_page, per_page }
|
||||
total: state.communityData.verticalPagination?.total || 0, // 向后兼容:服务端总数量
|
||||
cachedPageCount: Object.keys(state.communityData.verticalEventsByPage || {}).length // 已缓存页数
|
||||
});
|
||||
|
||||
// 平铺模式数据 + 加载状态选择器
|
||||
export const selectFourRowEventsWithLoading = (state) => ({
|
||||
data: state.communityData.fourRowEvents, // 完整缓存列表
|
||||
loading: state.communityData.loading.fourRowEvents,
|
||||
error: state.communityData.error.fourRowEvents,
|
||||
pagination: state.communityData.fourRowPagination, // 完整分页元数据 { total, total_pages, current_page, per_page }
|
||||
total: state.communityData.fourRowPagination?.total || 0, // 向后兼容:服务端总数量
|
||||
cachedCount: (state.communityData.fourRowEvents || []).length // 已缓存有效数量
|
||||
});
|
||||
|
||||
export default communityDataSlice.reducer;
|
||||
|
||||
@@ -2,35 +2,19 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { eventService, stockService } from '../../services/eventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { localCacheManager, CACHE_EXPIRY_STRATEGY } from '../../utils/CacheManager';
|
||||
import { getApiBase } from '../../utils/apiConfig';
|
||||
|
||||
// ==================== 常量定义 ====================
|
||||
|
||||
// 缓存键名
|
||||
const CACHE_KEYS = {
|
||||
EVENT_STOCKS: 'event_stocks_',
|
||||
EVENT_DETAIL: 'event_detail_',
|
||||
HISTORICAL_EVENTS: 'historical_events_',
|
||||
CHAIN_ANALYSIS: 'chain_analysis_',
|
||||
EXPECTATION_SCORE: 'expectation_score_',
|
||||
WATCHLIST: 'user_watchlist'
|
||||
};
|
||||
|
||||
// 请求去重:缓存正在进行的请求
|
||||
const pendingRequests = new Map();
|
||||
|
||||
// ==================== Async Thunks ====================
|
||||
|
||||
/**
|
||||
* 获取事件相关股票(三级缓存)
|
||||
* 获取事件相关股票(Redux 缓存)
|
||||
*/
|
||||
export const fetchEventStocks = createAsyncThunk(
|
||||
'stock/fetchEventStocks',
|
||||
async ({ eventId, forceRefresh = false }, { getState }) => {
|
||||
logger.debug('stockSlice', 'fetchEventStocks', { eventId, forceRefresh });
|
||||
|
||||
// 1. Redux 状态缓存
|
||||
// Redux 状态缓存
|
||||
if (!forceRefresh) {
|
||||
const cached = getState().stock.eventStocksCache[eventId];
|
||||
if (cached && cached.length > 0) {
|
||||
@@ -39,27 +23,13 @@ export const fetchEventStocks = createAsyncThunk(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. LocalStorage 缓存
|
||||
if (!forceRefresh) {
|
||||
const localCached = localCacheManager.get(CACHE_KEYS.EVENT_STOCKS + eventId);
|
||||
if (localCached) {
|
||||
logger.debug('stockSlice', 'LocalStorage 缓存命中', { eventId });
|
||||
return { eventId, stocks: localCached };
|
||||
}
|
||||
}
|
||||
|
||||
// 3. API 请求
|
||||
// API 请求
|
||||
const res = await eventService.getRelatedStocks(eventId);
|
||||
if (res.success && res.data) {
|
||||
logger.debug('stockSlice', 'API 请求成功', {
|
||||
eventId,
|
||||
stockCount: res.data.length
|
||||
});
|
||||
localCacheManager.set(
|
||||
CACHE_KEYS.EVENT_STOCKS + eventId,
|
||||
res.data,
|
||||
CACHE_EXPIRY_STRATEGY.LONG // 1小时
|
||||
);
|
||||
return { eventId, stocks: res.data };
|
||||
}
|
||||
|
||||
@@ -84,7 +54,7 @@ export const fetchStockQuotes = createAsyncThunk(
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取事件详情
|
||||
* 获取事件详情(Redux 缓存)
|
||||
*/
|
||||
export const fetchEventDetail = createAsyncThunk(
|
||||
'stock/fetchEventDetail',
|
||||
@@ -100,23 +70,9 @@ export const fetchEventDetail = createAsyncThunk(
|
||||
}
|
||||
}
|
||||
|
||||
// LocalStorage 缓存
|
||||
if (!forceRefresh) {
|
||||
const localCached = localCacheManager.get(CACHE_KEYS.EVENT_DETAIL + eventId);
|
||||
if (localCached) {
|
||||
logger.debug('stockSlice', 'LocalStorage 缓存命中 - eventDetail', { eventId });
|
||||
return { eventId, detail: localCached };
|
||||
}
|
||||
}
|
||||
|
||||
// API 请求
|
||||
const res = await eventService.getEventDetail(eventId);
|
||||
if (res.success && res.data) {
|
||||
localCacheManager.set(
|
||||
CACHE_KEYS.EVENT_DETAIL + eventId,
|
||||
res.data,
|
||||
CACHE_EXPIRY_STRATEGY.LONG
|
||||
);
|
||||
return { eventId, detail: res.data };
|
||||
}
|
||||
|
||||
@@ -125,7 +81,7 @@ export const fetchEventDetail = createAsyncThunk(
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取历史事件对比
|
||||
* 获取历史事件对比(Redux 缓存)
|
||||
*/
|
||||
export const fetchHistoricalEvents = createAsyncThunk(
|
||||
'stock/fetchHistoricalEvents',
|
||||
@@ -140,22 +96,9 @@ export const fetchHistoricalEvents = createAsyncThunk(
|
||||
}
|
||||
}
|
||||
|
||||
// LocalStorage 缓存
|
||||
if (!forceRefresh) {
|
||||
const localCached = localCacheManager.get(CACHE_KEYS.HISTORICAL_EVENTS + eventId);
|
||||
if (localCached) {
|
||||
return { eventId, events: localCached };
|
||||
}
|
||||
}
|
||||
|
||||
// API 请求
|
||||
const res = await eventService.getHistoricalEvents(eventId);
|
||||
if (res.success && res.data) {
|
||||
localCacheManager.set(
|
||||
CACHE_KEYS.HISTORICAL_EVENTS + eventId,
|
||||
res.data,
|
||||
CACHE_EXPIRY_STRATEGY.LONG
|
||||
);
|
||||
return { eventId, events: res.data };
|
||||
}
|
||||
|
||||
@@ -164,7 +107,7 @@ export const fetchHistoricalEvents = createAsyncThunk(
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取传导链分析
|
||||
* 获取传导链分析(Redux 缓存)
|
||||
*/
|
||||
export const fetchChainAnalysis = createAsyncThunk(
|
||||
'stock/fetchChainAnalysis',
|
||||
@@ -179,22 +122,9 @@ export const fetchChainAnalysis = createAsyncThunk(
|
||||
}
|
||||
}
|
||||
|
||||
// LocalStorage 缓存
|
||||
if (!forceRefresh) {
|
||||
const localCached = localCacheManager.get(CACHE_KEYS.CHAIN_ANALYSIS + eventId);
|
||||
if (localCached) {
|
||||
return { eventId, analysis: localCached };
|
||||
}
|
||||
}
|
||||
|
||||
// API 请求
|
||||
const res = await eventService.getTransmissionChainAnalysis(eventId);
|
||||
if (res.success && res.data) {
|
||||
localCacheManager.set(
|
||||
CACHE_KEYS.CHAIN_ANALYSIS + eventId,
|
||||
res.data,
|
||||
CACHE_EXPIRY_STRATEGY.LONG
|
||||
);
|
||||
return { eventId, analysis: res.data };
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
--color-n-6: #252134;
|
||||
--color-n-7: #15131D;
|
||||
--color-n-8: #0E0C15;
|
||||
|
||||
|
||||
/* Brainwave 主题色 */
|
||||
--color-1: #AC6AFF;
|
||||
--color-2: #FFC876;
|
||||
@@ -17,7 +17,7 @@
|
||||
--color-4: #7ADB78;
|
||||
--color-5: #858DFF;
|
||||
--color-6: #FF98E2;
|
||||
|
||||
|
||||
/* 描边色 */
|
||||
--stroke-1: #26242C;
|
||||
}
|
||||
@@ -47,37 +47,3 @@
|
||||
.bg-gradient-to-br {
|
||||
background-image: linear-gradient(to bottom right, var(--tw-gradient-stops)) !important;
|
||||
}
|
||||
|
||||
.from-n-8 { --tw-gradient-from: var(--color-n-8); --tw-gradient-to: transparent; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
|
||||
.via-n-7 { --tw-gradient-to: transparent; --tw-gradient-stops: var(--tw-gradient-from), var(--color-n-7), var(--tw-gradient-to); }
|
||||
.to-n-6 { --tw-gradient-to: var(--color-n-6); }
|
||||
|
||||
/* 文字渐变 */
|
||||
.bg-gradient-to-r { background-image: linear-gradient(to right, var(--tw-gradient-stops)) !important; }
|
||||
.from-color-1 { --tw-gradient-from: var(--color-1); --tw-gradient-to: transparent; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
|
||||
.to-color-2 { --tw-gradient-to: var(--color-2); }
|
||||
.from-color-2 { --tw-gradient-from: var(--color-2); --tw-gradient-to: transparent; --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
|
||||
.to-color-1 { --tw-gradient-to: var(--color-1); }
|
||||
|
||||
.bg-clip-text {
|
||||
-webkit-background-clip: text !important;
|
||||
background-clip: text !important;
|
||||
}
|
||||
.text-transparent {
|
||||
color: transparent !important;
|
||||
}
|
||||
|
||||
/* 其他样式增强 */
|
||||
.backdrop-blur-sm { backdrop-filter: blur(8px) !important; }
|
||||
.backdrop-blur { backdrop-filter: blur(16px) !important; }
|
||||
|
||||
/* 确保body有深色背景 */
|
||||
body {
|
||||
background-color: var(--color-n-8) !important;
|
||||
}
|
||||
|
||||
/* z-index 修复 */
|
||||
.z-50 { z-index: 50 !important; }
|
||||
.z-10 { z-index: 10 !important; }
|
||||
.z-2 { z-index: 2 !important; }
|
||||
.z-1 { z-index: 1 !important; }
|
||||
|
||||
@@ -32,4 +32,4 @@ body {
|
||||
@apply md:grid !important;
|
||||
@apply md:grid-cols-3 md:gap-x-10 md:gap-y-[4.5rem] xl:gap-y-[6rem];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
175
src/utils/tradingTimeUtils.js
Normal file
175
src/utils/tradingTimeUtils.js
Normal file
@@ -0,0 +1,175 @@
|
||||
// src/utils/tradingTimeUtils.js
|
||||
// 交易时间相关工具函数
|
||||
|
||||
import moment from 'moment';
|
||||
|
||||
/**
|
||||
* 获取当前时间应该显示的实时要闻时间范围
|
||||
* 规则:
|
||||
* - 15:00 之前:显示昨日 15:00 - 今日 15:00
|
||||
* - 15:30 之后:显示今日 15:00 - 当前时间
|
||||
*
|
||||
* @returns {{ startTime: Date, endTime: Date, description: string }}
|
||||
*/
|
||||
export const getCurrentTradingTimeRange = () => {
|
||||
const now = moment();
|
||||
const currentHour = now.hour();
|
||||
const currentMinute = now.minute();
|
||||
|
||||
// 计算当前是第几分钟(方便比较)
|
||||
const currentTimeInMinutes = currentHour * 60 + currentMinute;
|
||||
const cutoffTime1500 = 15 * 60; // 15:00 = 900分钟
|
||||
const cutoffTime1530 = 15 * 60 + 30; // 15:30 = 930分钟
|
||||
|
||||
let startTime, endTime, description;
|
||||
|
||||
if (currentTimeInMinutes < cutoffTime1500) {
|
||||
// 15:00 之前:显示昨日 15:00 - 今日 15:00
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
} else if (currentTimeInMinutes >= cutoffTime1530) {
|
||||
// 15:30 之后:显示今日 15:00 - 当前时间
|
||||
startTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = now.toDate();
|
||||
description = '今日15:00 - 当前时间';
|
||||
} else {
|
||||
// 15:00 - 15:30 之间:过渡期,保持显示昨日 15:00 - 今日 15:00
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
}
|
||||
|
||||
return {
|
||||
startTime,
|
||||
endTime,
|
||||
description,
|
||||
rangeType: currentTimeInMinutes >= cutoffTime1530 ? 'current_day' : 'full_day'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取市场复盘的时间范围
|
||||
* 规则:显示最近一个完整的交易日(昨日 15:00 - 今日 15:00)
|
||||
*
|
||||
* @returns {{ startTime: Date, endTime: Date, description: string }}
|
||||
*/
|
||||
export const getMarketReviewTimeRange = () => {
|
||||
const now = moment();
|
||||
const currentHour = now.hour();
|
||||
const currentMinute = now.minute();
|
||||
|
||||
// 计算当前是第几分钟
|
||||
const currentTimeInMinutes = currentHour * 60 + currentMinute;
|
||||
const cutoffTime1530 = 15 * 60 + 30; // 15:30 = 930分钟
|
||||
|
||||
let startTime, endTime, description;
|
||||
|
||||
if (currentTimeInMinutes >= cutoffTime1530) {
|
||||
// 15:30 之后:显示昨日 15:00 - 今日 15:00(刚刚完成的交易日)
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
} else {
|
||||
// 15:30 之前:显示前日 15:00 - 昨日 15:00(上一个完整交易日)
|
||||
startTime = moment().subtract(2, 'days').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '前日15:00 - 昨日15:00';
|
||||
}
|
||||
|
||||
return {
|
||||
startTime,
|
||||
endTime,
|
||||
description,
|
||||
rangeType: 'market_review'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据时间范围过滤事件列表
|
||||
*
|
||||
* @param {Array} events - 事件列表
|
||||
* @param {Date} startTime - 开始时间
|
||||
* @param {Date} endTime - 结束时间
|
||||
* @returns {Array} 过滤后的事件列表
|
||||
*/
|
||||
export const filterEventsByTimeRange = (events, startTime, endTime) => {
|
||||
if (!events || !Array.isArray(events)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!startTime || !endTime) {
|
||||
return events;
|
||||
}
|
||||
|
||||
const startMoment = moment(startTime);
|
||||
const endMoment = moment(endTime);
|
||||
|
||||
return events.filter(event => {
|
||||
if (!event.created_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const eventTime = moment(event.created_at);
|
||||
return eventTime.isSameOrAfter(startMoment) && eventTime.isSameOrBefore(endMoment);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断当前是否应该显示市场复盘模块
|
||||
* 根据需求:市场复盘模块一直显示
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const shouldShowMarketReview = () => {
|
||||
// 市场复盘模块始终显示
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取时间范围的描述文本
|
||||
*
|
||||
* @param {Date} startTime - 开始时间
|
||||
* @param {Date} endTime - 结束时间
|
||||
* @returns {string}
|
||||
*/
|
||||
export const getTimeRangeDescription = (startTime, endTime) => {
|
||||
if (!startTime || !endTime) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const startStr = moment(startTime).format('MM-DD HH:mm');
|
||||
const endStr = moment(endTime).format('MM-DD HH:mm');
|
||||
|
||||
return `${startStr} - ${endStr}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断是否为交易日(简化版本,只判断周末)
|
||||
* 注意:这里没有考虑节假日,如需精确判断需要接入交易日历API
|
||||
*
|
||||
* @param {Date} date - 日期
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTradingDay = (date) => {
|
||||
const day = moment(date).day();
|
||||
// 0 = 周日, 6 = 周六
|
||||
return day !== 0 && day !== 6;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取上一个交易日(简化版本)
|
||||
*
|
||||
* @param {Date} date - 日期
|
||||
* @returns {Date}
|
||||
*/
|
||||
export const getPreviousTradingDay = (date) => {
|
||||
let prevDay = moment(date).subtract(1, 'day');
|
||||
|
||||
// 如果是周末,继续往前找
|
||||
while (!isTradingDay(prevDay.toDate())) {
|
||||
prevDay = prevDay.subtract(1, 'day');
|
||||
}
|
||||
|
||||
return prevDay.toDate();
|
||||
};
|
||||
@@ -1,160 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
// Chakra imports
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
Stack,
|
||||
useColorModeValue,
|
||||
Text,
|
||||
Link,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../../../contexts/AuthContext";
|
||||
|
||||
export default function SignInBasic() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const { login, isLoading } = useAuth();
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.email || !formData.password) {
|
||||
toast({
|
||||
title: "请填写完整信息",
|
||||
description: "邮箱和密码都是必填项",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await login(formData.email, formData.password, 'email');
|
||||
|
||||
if (result.success) {
|
||||
// 登录成功,跳转到首页
|
||||
navigate("/home");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex minH="100vh" align="center" justify="center" bg={useColorModeValue("gray.50", "gray.900")}>
|
||||
<Stack spacing={8} mx="auto" maxW="lg" py={12} px={6}>
|
||||
<Stack align="center">
|
||||
<Heading style={{minWidth: '140px'}} fontSize="4xl" color="blue.600">
|
||||
价小前投研
|
||||
</Heading>
|
||||
<Text fontSize="lg" color="gray.600">
|
||||
登录您的账户
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
rounded="lg"
|
||||
bg={useColorModeValue("white", "gray.700")}
|
||||
boxShadow="lg"
|
||||
p={8}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack spacing={4}>
|
||||
<FormControl id="email" isRequired>
|
||||
<FormLabel>邮箱地址</FormLabel>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入您的邮箱"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl id="password" isRequired>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入您的密码"
|
||||
/>
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
||||
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
<Stack spacing={10}>
|
||||
<Button
|
||||
type="submit"
|
||||
bg="blue.600"
|
||||
color="white"
|
||||
_hover={{
|
||||
bg: "blue.700",
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
loadingText="登录中..."
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
</Stack>
|
||||
<Stack pt={6}>
|
||||
<Text align="center">
|
||||
还没有账户?{" "}
|
||||
<Link color="blue.600" onClick={() => navigate("/auth/signup")}>
|
||||
立即注册
|
||||
</Link>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
VStack,
|
||||
Heading,
|
||||
Text,
|
||||
Link,
|
||||
useColorMode,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
Spinner,
|
||||
} from "@chakra-ui/react";
|
||||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../../../contexts/AuthContext";
|
||||
|
||||
export default function SignInCentered() {
|
||||
const { colorMode } = useColorMode();
|
||||
const navigate = useNavigate();
|
||||
const { login, isLoading } = useAuth();
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState({
|
||||
email: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
// UI状态
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
// 处理输入变化
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
// 清除对应字段的错误
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: ""
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 表单验证
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = "邮箱是必填项";
|
||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
newErrors.email = "请输入有效的邮箱地址";
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = "密码是必填项";
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = "密码至少需要6个字符";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// 处理表单提交
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await login(formData.email, formData.password);
|
||||
|
||||
if (result.success) {
|
||||
// 登录成功,跳转到首页
|
||||
navigate("/home");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
minH="100vh"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg={colorMode === "dark" ? "gray.800" : "gray.50"}
|
||||
p={4}
|
||||
>
|
||||
<Box
|
||||
w="full"
|
||||
maxW="md"
|
||||
p={8}
|
||||
bg={colorMode === "dark" ? "gray.700" : "white"}
|
||||
borderRadius="lg"
|
||||
shadow="xl"
|
||||
>
|
||||
<VStack spacing={6}>
|
||||
<Box textAlign="center">
|
||||
<Heading size="lg" mb={2}>欢迎回来1</Heading>
|
||||
<Text color="gray.500">请输入您的凭据登录</Text>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ width: "100%" }}>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isInvalid={!!errors.email}>
|
||||
<FormLabel>邮箱地址</FormLabel>
|
||||
<Input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
size="lg"
|
||||
/>
|
||||
{errors.email && (
|
||||
<Text color="red.500" fontSize="sm" mt={1}>
|
||||
{errors.email}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.password}>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<InputGroup size="lg">
|
||||
<Input
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="********"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
||||
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
|
||||
variant="ghost"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
{errors.password && (
|
||||
<Text color="red.500" fontSize="sm" mt={1}>
|
||||
{errors.password}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="blue"
|
||||
w="full"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
loadingText="登录中..."
|
||||
>
|
||||
{isLoading ? <Spinner size="sm" /> : "登录"}
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
|
||||
<VStack spacing={3}>
|
||||
<Text fontSize="sm" textAlign="center">
|
||||
还没有账户?{" "}
|
||||
<Link
|
||||
color="blue.500"
|
||||
onClick={() => navigate("/auth/signup")}
|
||||
_hover={{ textDecoration: "underline" }}
|
||||
>
|
||||
立即注册
|
||||
</Link>
|
||||
</Text>
|
||||
|
||||
<Box textAlign="center">
|
||||
<Link
|
||||
color="gray.500"
|
||||
fontSize="sm"
|
||||
_hover={{ color: "blue.500" }}
|
||||
>
|
||||
忘记密码?
|
||||
</Link>
|
||||
<Text color="gray.500" fontSize="sm" mt={2}>
|
||||
还没有账户?{" "}
|
||||
<Link
|
||||
color="blue.500"
|
||||
fontWeight="medium"
|
||||
_hover={{ textDecoration: "underline" }}
|
||||
onClick={() => navigate('/auth/sign-up')}
|
||||
>
|
||||
立即注册
|
||||
</Link>
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
// Chakra imports
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
HStack,
|
||||
Icon,
|
||||
Input,
|
||||
Link,
|
||||
Switch,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
// Assets
|
||||
import CoverImage from "assets/img/CoverImage.png";
|
||||
import React from "react";
|
||||
import { FaApple, FaFacebook, FaGoogle } from "react-icons/fa";
|
||||
import AuthCover from "layouts/AuthCover";
|
||||
|
||||
function SignInCover() {
|
||||
// Chakra color mode
|
||||
const textColor = useColorModeValue("gray.400", "white");
|
||||
const bgForm = useColorModeValue("white", "navy.800");
|
||||
const titleColor = useColorModeValue("gray.700", "blue.500");
|
||||
const colorIcons = useColorModeValue("gray.700", "white");
|
||||
const bgIcons = useColorModeValue("trasnparent", "navy.700");
|
||||
const bgIconsHover = useColorModeValue("gray.50", "whiteAlpha.100");
|
||||
return (
|
||||
<AuthCover image={CoverImage}>
|
||||
<Flex
|
||||
w="100%"
|
||||
h="100%"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
mb="60px"
|
||||
mt={{ base: "60px", md: "160px" }}
|
||||
>
|
||||
<Flex
|
||||
zIndex="2"
|
||||
direction="column"
|
||||
w="445px"
|
||||
background="transparent"
|
||||
borderRadius="15px"
|
||||
p="40px"
|
||||
mx={{ base: "100px" }}
|
||||
mb={{ base: "20px", md: "auto" }}
|
||||
bg={bgForm}
|
||||
boxShadow={useColorModeValue(
|
||||
"0px 5px 14px rgba(0, 0, 0, 0.05)",
|
||||
"unset"
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
fontSize="xl"
|
||||
color={textColor}
|
||||
fontWeight="bold"
|
||||
textAlign="center"
|
||||
mb="22px"
|
||||
>
|
||||
Sign In with
|
||||
</Text>
|
||||
<HStack spacing="15px" justify="center" mb="22px">
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
w="75px"
|
||||
h="75px"
|
||||
borderRadius="8px"
|
||||
border={useColorModeValue("1px solid", "0px")}
|
||||
borderColor="gray.200"
|
||||
cursor="pointer"
|
||||
transition="all .25s ease"
|
||||
bg={bgIcons}
|
||||
_hover={{ bg: bgIconsHover }}
|
||||
>
|
||||
<Link href="#">
|
||||
<Icon as={FaFacebook} color={colorIcons} w="30px" h="30px" />
|
||||
</Link>
|
||||
</Flex>
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
w="75px"
|
||||
h="75px"
|
||||
borderRadius="8px"
|
||||
border={useColorModeValue("1px solid", "0px")}
|
||||
borderColor="gray.200"
|
||||
cursor="pointer"
|
||||
transition="all .25s ease"
|
||||
bg={bgIcons}
|
||||
_hover={{ bg: bgIconsHover }}
|
||||
>
|
||||
<Link href="#">
|
||||
<Icon
|
||||
as={FaApple}
|
||||
color={colorIcons}
|
||||
w="30px"
|
||||
h="30px"
|
||||
_hover={{ filter: "brightness(120%)" }}
|
||||
/>
|
||||
</Link>
|
||||
</Flex>
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
w="75px"
|
||||
h="75px"
|
||||
borderRadius="8px"
|
||||
border={useColorModeValue("1px solid", "0px")}
|
||||
borderColor="gray.200"
|
||||
cursor="pointer"
|
||||
transition="all .25s ease"
|
||||
bg={bgIcons}
|
||||
_hover={{ bg: bgIconsHover }}
|
||||
>
|
||||
<Link href="#">
|
||||
<Icon
|
||||
as={FaGoogle}
|
||||
color={colorIcons}
|
||||
w="30px"
|
||||
h="30px"
|
||||
_hover={{ filter: "brightness(120%)" }}
|
||||
/>
|
||||
</Link>
|
||||
</Flex>
|
||||
</HStack>
|
||||
<Text
|
||||
fontSize="lg"
|
||||
color="gray.400"
|
||||
fontWeight="bold"
|
||||
textAlign="center"
|
||||
mb="22px"
|
||||
>
|
||||
or
|
||||
</Text>
|
||||
<FormControl>
|
||||
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
|
||||
Name
|
||||
</FormLabel>
|
||||
<Input
|
||||
variant="auth"
|
||||
fontSize="sm"
|
||||
ms="4px"
|
||||
type="text"
|
||||
placeholder="Your full name"
|
||||
mb="24px"
|
||||
size="lg"
|
||||
/>
|
||||
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
|
||||
Password
|
||||
</FormLabel>
|
||||
<Input
|
||||
variant="auth"
|
||||
fontSize="sm"
|
||||
ms="4px"
|
||||
type="password"
|
||||
placeholder="Your password"
|
||||
mb="24px"
|
||||
size="lg"
|
||||
/>
|
||||
<FormControl display="flex" alignItems="center" mb="24px">
|
||||
<Switch id="remember-login" colorScheme="blue" me="10px" />
|
||||
<FormLabel htmlFor="remember-login" mb="0" fontWeight="normal">
|
||||
Remember me
|
||||
</FormLabel>
|
||||
</FormControl>
|
||||
<Button
|
||||
fontSize="10px"
|
||||
variant="dark"
|
||||
fontWeight="bold"
|
||||
w="100%"
|
||||
h="45"
|
||||
mb="24px"
|
||||
>
|
||||
SIGN IN
|
||||
</Button>
|
||||
</FormControl>
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
maxW="100%"
|
||||
mt="0px"
|
||||
>
|
||||
<Text color={textColor} fontWeight="medium">
|
||||
Don’t have an account?
|
||||
<Link
|
||||
color={titleColor}
|
||||
as="span"
|
||||
ms="5px"
|
||||
href="#"
|
||||
fontWeight="bold"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</AuthCover>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignInCover;
|
||||
@@ -1,538 +0,0 @@
|
||||
// src/views/Authentication/SignIn/SignInIllustration.js - Session版本
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
Input,
|
||||
Text,
|
||||
Heading,
|
||||
VStack,
|
||||
HStack,
|
||||
useToast,
|
||||
Icon,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
Link as ChakraLink,
|
||||
Center,
|
||||
useDisclosure,
|
||||
FormErrorMessage
|
||||
} from "@chakra-ui/react";
|
||||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
|
||||
import { FaMobile, FaLock } from "react-icons/fa";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "../../../contexts/AuthContext";
|
||||
import PrivacyPolicyModal from "../../../components/PrivacyPolicyModal";
|
||||
import UserAgreementModal from "../../../components/UserAgreementModal";
|
||||
import AuthBackground from "../../../components/Auth/AuthBackground";
|
||||
import AuthHeader from "../../../components/Auth/AuthHeader";
|
||||
import AuthFooter from "../../../components/Auth/AuthFooter";
|
||||
import VerificationCodeInput from "../../../components/Auth/VerificationCodeInput";
|
||||
import WechatRegister from "../../../components/Auth/WechatRegister";
|
||||
import { logger } from "../../../utils/logger";
|
||||
|
||||
export default function SignInIllustration() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const toast = useToast();
|
||||
const { login, checkSession } = useAuth();
|
||||
|
||||
// 追踪组件挂载状态,防止内存泄漏
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// 页面状态
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
// 检查URL参数中的错误信息(微信登录失败时)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const error = params.get('error');
|
||||
|
||||
if (error) {
|
||||
let errorMessage = '登录失败';
|
||||
switch (error) {
|
||||
case 'wechat_auth_failed':
|
||||
errorMessage = '微信授权失败';
|
||||
break;
|
||||
case 'session_expired':
|
||||
errorMessage = '会话已过期,请重新登录';
|
||||
break;
|
||||
case 'token_failed':
|
||||
errorMessage = '获取微信授权失败';
|
||||
break;
|
||||
case 'userinfo_failed':
|
||||
errorMessage = '获取用户信息失败';
|
||||
break;
|
||||
case 'login_failed':
|
||||
errorMessage = '登录处理失败,请重试';
|
||||
break;
|
||||
default:
|
||||
errorMessage = '登录失败,请重试';
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "登录失败",
|
||||
description: errorMessage,
|
||||
status: "error",
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// 清除URL参数
|
||||
const newUrl = window.location.pathname;
|
||||
window.history.replaceState({}, document.title, newUrl);
|
||||
}
|
||||
}, [location, toast]);
|
||||
|
||||
// 传统登录数据
|
||||
// 表单数据初始化
|
||||
const [formData, setFormData] = useState({
|
||||
username: "", // 用户名称
|
||||
email: "", // 邮箱
|
||||
phone: "", // 电话
|
||||
password: "", // 密码
|
||||
verificationCode: "", // 添加验证码字段
|
||||
});
|
||||
|
||||
// 验证码登录状态 是否开启验证码
|
||||
const [useVerificationCode, setUseVerificationCode] = useState(false);
|
||||
// 密码展示状态
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
|
||||
const [verificationCodeSent, setVerificationCodeSent] = useState(false); // 验证码发送状态
|
||||
const [sendingCode, setSendingCode] = useState(false); // 发送验证码状态
|
||||
|
||||
|
||||
// 隐私政策弹窗状态
|
||||
const { isOpen: isPrivacyModalOpen, onOpen: onPrivacyModalOpen, onClose: onPrivacyModalClose } = useDisclosure();
|
||||
|
||||
// 用户协议弹窗状态
|
||||
const { isOpen: isUserAgreementModalOpen, onOpen: onUserAgreementModalOpen, onClose: onUserAgreementModalClose } = useDisclosure();
|
||||
|
||||
// 输入框输入
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// ========== 发送验证码逻辑 =============
|
||||
// 倒计时效果
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
let isMounted = true;
|
||||
|
||||
if (countdown > 0) {
|
||||
timer = setInterval(() => {
|
||||
if (isMounted) {
|
||||
setCountdown(prev => prev - 1);
|
||||
}
|
||||
}, 1000);
|
||||
} else if (countdown === 0 && isMounted) {
|
||||
setVerificationCodeSent(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [countdown]);
|
||||
|
||||
// 发送验证码
|
||||
const sendVerificationCode = async () => {
|
||||
const credential = formData.phone;
|
||||
const type = 'phone';
|
||||
|
||||
if (!credential) {
|
||||
toast({
|
||||
title: "请先输入手机号",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 基本格式验证
|
||||
if (!/^1[3-9]\d{9}$/.test(credential)) {
|
||||
toast({
|
||||
title: "请输入有效的手机号",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSendingCode(true);
|
||||
const response = await fetch('/api/auth/send-verification-code', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
credential,
|
||||
type,
|
||||
purpose: 'login'
|
||||
}),
|
||||
});
|
||||
|
||||
// ✅ 安全检查:验证 response 存在
|
||||
if (!response) {
|
||||
throw new Error('网络请求失败,请检查网络连接');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 组件卸载后不再执行后续操作
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// ✅ 安全检查:验证 data 存在
|
||||
if (!data) {
|
||||
throw new Error('服务器响应为空');
|
||||
}
|
||||
|
||||
if (response.ok && data.success) {
|
||||
toast({
|
||||
title: "验证码已发送",
|
||||
description: "验证码已发送到您的手机号",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
});
|
||||
setVerificationCodeSent(true);
|
||||
setCountdown(60); // 60秒倒计时
|
||||
} else {
|
||||
throw new Error(data.error || '发送验证码失败');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "发送验证码失败",
|
||||
description: error.message || "请稍后重试",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 验证码登录函数
|
||||
const loginWithVerificationCode = async (credential, verificationCode, authLoginType) => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login-with-code', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
credential,
|
||||
verification_code: verificationCode,
|
||||
login_type: authLoginType
|
||||
}),
|
||||
});
|
||||
|
||||
// ✅ 安全检查:验证 response 存在
|
||||
if (!response) {
|
||||
throw new Error('网络请求失败,请检查网络连接');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 组件卸载后不再执行后续操作
|
||||
if (!isMountedRef.current) {
|
||||
return { success: false, error: '操作已取消' };
|
||||
}
|
||||
|
||||
// ✅ 安全检查:验证 data 存在
|
||||
if (!data) {
|
||||
throw new Error('服务器响应为空');
|
||||
}
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// 更新认证状态
|
||||
await checkSession();
|
||||
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "登录成功",
|
||||
description: "欢迎回来!",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
return { success: true };
|
||||
} else {
|
||||
throw new Error(data.error || '验证码登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "登录失败",
|
||||
description: error.message || "请检查验证码是否正确",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 传统行业登陆
|
||||
const handleTraditionalLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const credential = formData.phone;
|
||||
const authLoginType = 'phone';
|
||||
|
||||
if (useVerificationCode) { // 验证码登陆
|
||||
if (!credential || !formData.verificationCode) {
|
||||
toast({
|
||||
title: "请填写完整信息",
|
||||
description: "手机号和验证码不能为空",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await loginWithVerificationCode(credential, formData.verificationCode, authLoginType);
|
||||
|
||||
if (result.success) {
|
||||
navigate("/home");
|
||||
}
|
||||
} else { // 密码登陆
|
||||
if (!credential || !formData.password) {
|
||||
toast({
|
||||
title: "请填写完整信息",
|
||||
description: `手机号和密码不能为空`,
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await login(credential, formData.password, authLoginType);
|
||||
|
||||
if (result.success) {
|
||||
// ✅ 显示成功提示
|
||||
toast({
|
||||
title: "登录成功",
|
||||
description: "欢迎回来!",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
navigate("/home");
|
||||
} else {
|
||||
// ❌ 显示错误提示
|
||||
toast({
|
||||
title: "登录失败",
|
||||
description: result.error || "请检查您的登录信息",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('SignInIllustration', 'handleTraditionalLogin', error, {
|
||||
phone: formData.phone ? formData.phone.substring(0, 3) + '****' + formData.phone.substring(7) : 'N/A',
|
||||
useVerificationCode,
|
||||
loginType: 'phone'
|
||||
});
|
||||
toast({
|
||||
title: "登录失败",
|
||||
description: error.message || "发生未预期的错误,请重试",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换登录方式
|
||||
const handleChangeMethod = () => {
|
||||
setUseVerificationCode(!useVerificationCode);
|
||||
// 切换到密码模式时清空验证码
|
||||
if (useVerificationCode) {
|
||||
setFormData(prev => ({ ...prev, verificationCode: "" }));
|
||||
}
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Flex minH="100vh" position="relative" overflow="hidden">
|
||||
{/* 背景 */}
|
||||
<AuthBackground />
|
||||
|
||||
{/* 主要内容 */}
|
||||
<Flex width="100%" align="center" justify="center" position="relative" zIndex={1} px={6} py={12}>
|
||||
{/* 登录卡片 */}
|
||||
<Box bg="white" borderRadius="2xl" boxShadow="2xl" p={8} width="100%" maxW="800px" backdropFilter="blur(20px)" border="1px solid rgba(255, 255, 255, 0.2)">
|
||||
{/* 头部区域 */}
|
||||
<AuthHeader title="欢迎回来" subtitle="登录价值前沿,继续您的投资之旅" />
|
||||
{/* 左右布局 */}
|
||||
<HStack spacing={8} align="stretch">
|
||||
{/* 左侧:手机号登陆 - 80% 宽度 */}
|
||||
<Box flex="4">
|
||||
<form onSubmit={handleTraditionalLogin}>
|
||||
<VStack spacing={4}>
|
||||
<Heading size="md" color="gray.700" alignSelf="flex-start">
|
||||
手机号登陆
|
||||
</Heading>
|
||||
<FormControl isRequired isInvalid={!!errors.phone}>
|
||||
<Input
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入11位手机号"
|
||||
pr="2.5rem"
|
||||
/>
|
||||
<FormErrorMessage>{errors.phone}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
{/* 密码/验证码输入框 */}
|
||||
{useVerificationCode ? (
|
||||
<VerificationCodeInput
|
||||
value={formData.verificationCode}
|
||||
onChange={handleInputChange}
|
||||
onSendCode={sendVerificationCode}
|
||||
countdown={countdown}
|
||||
isLoading={isLoading}
|
||||
isSending={sendingCode}
|
||||
error={errors.verificationCode}
|
||||
colorScheme="green"
|
||||
/>
|
||||
) : (
|
||||
<FormControl isRequired isInvalid={!!errors.password}>
|
||||
<InputGroup>
|
||||
<Input
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
pr="3rem"
|
||||
placeholder="请输入密码"
|
||||
_focus={{
|
||||
borderColor: "blue.500",
|
||||
boxShadow: "0 0 0 1px #667eea"
|
||||
}}
|
||||
/>
|
||||
<InputRightElement width="3rem">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
<FormErrorMessage>{errors.password}</FormErrorMessage>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<AuthFooter
|
||||
linkText="还没有账号,"
|
||||
linkLabel="去注册"
|
||||
linkTo="/auth/sign-up"
|
||||
useVerificationCode={useVerificationCode}
|
||||
onSwitchMethod={handleChangeMethod}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
width="100%"
|
||||
size="lg"
|
||||
colorScheme="green"
|
||||
color="white"
|
||||
borderRadius="lg"
|
||||
_hover={{
|
||||
transform: "translateY(-2px)",
|
||||
boxShadow: "lg"
|
||||
}}
|
||||
_active={{ transform: "translateY(0)" }}
|
||||
isLoading={isLoading}
|
||||
loadingText="登录中..."
|
||||
fontWeight="bold"
|
||||
cursor={"pointer"}
|
||||
>
|
||||
<Icon as={FaLock} mr={2} />登录
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
</Box>
|
||||
{/* 右侧:微信登陆 - 20% 宽度 */}
|
||||
<Box flex="1">
|
||||
<Center width="100%" bg="gray.50" borderRadius="lg" p={8}>
|
||||
<WechatRegister />
|
||||
</Center>
|
||||
</Box>
|
||||
</HStack>
|
||||
|
||||
{/* 底部链接 */}
|
||||
<VStack spacing={4} mt={6}>
|
||||
{/* 协议同意勾选框 */}
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
注册登录即表示阅读并同意{" "}
|
||||
<ChakraLink
|
||||
color="blue.500"
|
||||
fontSize="sm"
|
||||
onClick={onUserAgreementModalOpen}
|
||||
textDecoration="underline"
|
||||
_hover={{ color: "blue.600" }}
|
||||
>
|
||||
《用户协议》
|
||||
</ChakraLink>
|
||||
{" "}和{" "}
|
||||
<ChakraLink
|
||||
color="blue.500"
|
||||
fontSize="sm"
|
||||
onClick={onPrivacyModalOpen}
|
||||
textDecoration="underline"
|
||||
_hover={{ color: "blue.600" }}
|
||||
>
|
||||
《隐私政策》
|
||||
</ChakraLink>
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
|
||||
{/* 隐私政策弹窗 */}
|
||||
<PrivacyPolicyModal isOpen={isPrivacyModalOpen} onClose={onPrivacyModalClose} />
|
||||
|
||||
{/* 用户协议弹窗 */}
|
||||
<UserAgreementModal isOpen={isUserAgreementModalOpen} onClose={onUserAgreementModalClose} />
|
||||
</Flex >
|
||||
);
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
// Chakra imports
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
Stack,
|
||||
useColorModeValue,
|
||||
Text,
|
||||
Link,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
useToast,
|
||||
Checkbox,
|
||||
} from "@chakra-ui/react";
|
||||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function SignUpBasic() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
agreeToTerms: false,
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === "checkbox" ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
toast({
|
||||
title: "密码不匹配",
|
||||
description: "请确保两次输入的密码相同",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.agreeToTerms) {
|
||||
toast({
|
||||
title: "请同意条款",
|
||||
description: "请阅读并同意用户协议和隐私政策",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
// 模拟注册过程
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
toast({
|
||||
title: "注册成功",
|
||||
description: "欢迎加入价值前沿投资助手",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
navigate("/home");
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex minH="100vh" align="center" justify="center" bg={useColorModeValue("gray.50", "gray.900")}>
|
||||
<Stack spacing={8} mx="auto" maxW="lg" py={12} px={6}>
|
||||
<Stack align="center">
|
||||
<Heading fontSize="4xl" color="blue.600">
|
||||
价小前投研
|
||||
</Heading>
|
||||
<Text fontSize="lg" color="gray.600">
|
||||
创建您的账户
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box
|
||||
rounded="lg"
|
||||
bg={useColorModeValue("white", "gray.700")}
|
||||
boxShadow="lg"
|
||||
p={8}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack spacing={4}>
|
||||
<FormControl id="username" isRequired>
|
||||
<FormLabel>用户名</FormLabel>
|
||||
<Input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入您的用户名"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl id="email" isRequired>
|
||||
<FormLabel>邮箱地址</FormLabel>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入您的邮箱"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl id="password" isRequired>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入您的密码"
|
||||
/>
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
||||
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormControl id="confirmPassword" isRequired>
|
||||
<FormLabel>确认密码</FormLabel>
|
||||
<InputGroup>
|
||||
<Input
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请再次输入您的密码"
|
||||
/>
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
aria-label={showConfirmPassword ? "隐藏密码" : "显示密码"}
|
||||
icon={showConfirmPassword ? <ViewOffIcon /> : <ViewIcon />}
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormControl id="agreeToTerms">
|
||||
<Checkbox
|
||||
name="agreeToTerms"
|
||||
isChecked={formData.agreeToTerms}
|
||||
onChange={handleInputChange}
|
||||
colorScheme="blue"
|
||||
>
|
||||
<Text fontSize="sm">
|
||||
我已阅读并同意{" "}
|
||||
<Link color="blue.600" href="#" isExternal>
|
||||
用户协议
|
||||
</Link>{" "}
|
||||
和{" "}
|
||||
<Link color="blue.600" href="#" isExternal>
|
||||
隐私政策
|
||||
</Link>
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</FormControl>
|
||||
|
||||
<Stack spacing={10}>
|
||||
<Button
|
||||
type="submit"
|
||||
bg="blue.600"
|
||||
color="white"
|
||||
_hover={{
|
||||
bg: "blue.700",
|
||||
}}
|
||||
isLoading={isLoading}
|
||||
loadingText="注册中..."
|
||||
>
|
||||
注册
|
||||
</Button>
|
||||
</Stack>
|
||||
<Stack pt={6}>
|
||||
<Text align="center">
|
||||
已有账户?{" "}
|
||||
<Link color="blue.600" onClick={() => navigate("/auth/signin")}>
|
||||
立即登录
|
||||
</Link>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</form>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
VStack,
|
||||
Heading,
|
||||
Text,
|
||||
Link,
|
||||
useColorMode,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
Spinner,
|
||||
Checkbox,
|
||||
HStack,
|
||||
} from "@chakra-ui/react";
|
||||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../../../contexts/AuthContext";
|
||||
|
||||
export default function SignUpCentered() {
|
||||
const { colorMode } = useColorMode();
|
||||
const navigate = useNavigate();
|
||||
const { register, isLoading } = useAuth();
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
// UI状态
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
|
||||
// 处理输入变化
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
// 清除对应字段的错误
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: ""
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 表单验证
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = "姓名是必填项";
|
||||
} else if (formData.name.trim().length < 2) {
|
||||
newErrors.name = "姓名至少需要2个字符";
|
||||
}
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = "邮箱是必填项";
|
||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
newErrors.email = "请输入有效的邮箱地址";
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = "密码是必填项";
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = "密码至少需要6个字符";
|
||||
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(formData.password)) {
|
||||
newErrors.password = "密码必须包含大小写字母和数字";
|
||||
}
|
||||
|
||||
if (!formData.confirmPassword) {
|
||||
newErrors.confirmPassword = "请确认密码";
|
||||
} else if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = "两次输入的密码不一致";
|
||||
}
|
||||
|
||||
if (!agreedToTerms) {
|
||||
newErrors.terms = "请同意服务条款和隐私政策";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// 处理表单提交
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await register(
|
||||
formData.name, // username
|
||||
formData.email,
|
||||
formData.password
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
// 注册成功,跳转到首页
|
||||
navigate("/home");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
minH="100vh"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg={colorMode === "dark" ? "gray.800" : "gray.50"}
|
||||
p={4}
|
||||
>
|
||||
<Box
|
||||
w="full"
|
||||
maxW="md"
|
||||
p={8}
|
||||
bg={colorMode === "dark" ? "gray.700" : "white"}
|
||||
borderRadius="lg"
|
||||
shadow="xl"
|
||||
>
|
||||
<VStack spacing={6}>
|
||||
<Box textAlign="center">
|
||||
<Heading size="lg" mb={2}>创建账户</Heading>
|
||||
<Text color="gray.500">加入价值前沿,开启智能投资之旅</Text>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ width: "100%" }}>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isInvalid={!!errors.name}>
|
||||
<FormLabel>姓名</FormLabel>
|
||||
<Input
|
||||
name="name"
|
||||
placeholder="您的姓名"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
size="lg"
|
||||
/>
|
||||
{errors.name && (
|
||||
<Text color="red.500" fontSize="sm" mt={1}>
|
||||
{errors.name}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.email}>
|
||||
<FormLabel>邮箱地址</FormLabel>
|
||||
<Input
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
size="lg"
|
||||
/>
|
||||
{errors.email && (
|
||||
<Text color="red.500" fontSize="sm" mt={1}>
|
||||
{errors.email}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.password}>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<InputGroup size="lg">
|
||||
<Input
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="********"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
aria-label={showPassword ? "隐藏密码" : "显示密码"}
|
||||
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
|
||||
variant="ghost"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
{errors.password && (
|
||||
<Text color="red.500" fontSize="sm" mt={1}>
|
||||
{errors.password}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.confirmPassword}>
|
||||
<FormLabel>确认密码</FormLabel>
|
||||
<InputGroup size="lg">
|
||||
<Input
|
||||
name="confirmPassword"
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
placeholder="********"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
aria-label={showConfirmPassword ? "隐藏密码" : "显示密码"}
|
||||
icon={showConfirmPassword ? <ViewOffIcon /> : <ViewIcon />}
|
||||
variant="ghost"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
{errors.confirmPassword && (
|
||||
<Text color="red.500" fontSize="sm" mt={1}>
|
||||
{errors.confirmPassword}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isInvalid={!!errors.terms}>
|
||||
<HStack spacing={3}>
|
||||
<Checkbox
|
||||
isChecked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
colorScheme="blue"
|
||||
>
|
||||
<Text fontSize="sm">
|
||||
我同意{" "}
|
||||
<Link color="blue.500" _hover={{ textDecoration: "underline" }}>
|
||||
服务条款
|
||||
</Link>
|
||||
{" "}和{" "}
|
||||
<Link color="blue.500" _hover={{ textDecoration: "underline" }}>
|
||||
隐私政策
|
||||
</Link>
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</HStack>
|
||||
{errors.terms && (
|
||||
<Text color="red.500" fontSize="sm" mt={1}>
|
||||
{errors.terms}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
colorScheme="blue"
|
||||
w="full"
|
||||
size="lg"
|
||||
isLoading={isLoading}
|
||||
loadingText="注册中..."
|
||||
>
|
||||
{isLoading ? <Spinner size="sm" /> : "创建账户"}
|
||||
</Button>
|
||||
</VStack>
|
||||
</form>
|
||||
|
||||
<VStack spacing={3}>
|
||||
<Text fontSize="sm" textAlign="center">
|
||||
已有账户?{" "}
|
||||
<Link
|
||||
color="blue.500"
|
||||
onClick={() => navigate("/auth/signin")}
|
||||
_hover={{ textDecoration: "underline" }}
|
||||
>
|
||||
立即登录
|
||||
</Link>
|
||||
</Text>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
/*!
|
||||
|
||||
=========================================================
|
||||
* Argon Dashboard Chakra PRO - v1.0.0
|
||||
=========================================================
|
||||
|
||||
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
|
||||
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
|
||||
|
||||
* Designed and Coded by Simmmple & Creative Tim
|
||||
|
||||
=========================================================
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
*/
|
||||
|
||||
// Chakra imports
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
HStack,
|
||||
Icon,
|
||||
Input,
|
||||
Link,
|
||||
Switch,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
// Assets
|
||||
import CoverImage from "assets/img/CoverImage.png";
|
||||
import React from "react";
|
||||
import { FaApple, FaFacebook, FaGoogle } from "react-icons/fa";
|
||||
import AuthCover from "layouts/AuthCover";
|
||||
|
||||
function SignUpCover() {
|
||||
// Chakra color mode
|
||||
const textColor = useColorModeValue("gray.400", "white");
|
||||
const bgForm = useColorModeValue("white", "navy.800");
|
||||
const titleColor = useColorModeValue("gray.700", "blue.500");
|
||||
const colorIcons = useColorModeValue("gray.700", "white");
|
||||
const bgIcons = useColorModeValue("trasnparent", "navy.700");
|
||||
const bgIconsHover = useColorModeValue("gray.50", "whiteAlpha.100");
|
||||
return (
|
||||
<AuthCover image={CoverImage}>
|
||||
<Flex
|
||||
w="100%"
|
||||
h="100%"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
mb="60px"
|
||||
mt={{ base: "60px", md: "160px" }}
|
||||
>
|
||||
<Flex
|
||||
zIndex="2"
|
||||
direction="column"
|
||||
w="445px"
|
||||
background="transparent"
|
||||
borderRadius="15px"
|
||||
p="40px"
|
||||
mx={{ base: "100px" }}
|
||||
mb={{ base: "20px", md: "auto" }}
|
||||
bg={bgForm}
|
||||
boxShadow={useColorModeValue(
|
||||
"0px 5px 14px rgba(0, 0, 0, 0.05)",
|
||||
"unset"
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
fontSize="xl"
|
||||
color={textColor}
|
||||
fontWeight="bold"
|
||||
textAlign="center"
|
||||
mb="22px"
|
||||
>
|
||||
Sign In with
|
||||
</Text>
|
||||
<HStack spacing="15px" justify="center" mb="22px">
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
w="75px"
|
||||
h="75px"
|
||||
borderRadius="8px"
|
||||
border={useColorModeValue("1px solid", "0px")}
|
||||
borderColor="gray.200"
|
||||
cursor="pointer"
|
||||
transition="all .25s ease"
|
||||
bg={bgIcons}
|
||||
_hover={{ bg: bgIconsHover }}
|
||||
>
|
||||
<Link href="#">
|
||||
<Icon as={FaFacebook} color={colorIcons} w="30px" h="30px" />
|
||||
</Link>
|
||||
</Flex>
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
w="75px"
|
||||
h="75px"
|
||||
borderRadius="8px"
|
||||
border={useColorModeValue("1px solid", "0px")}
|
||||
borderColor="gray.200"
|
||||
cursor="pointer"
|
||||
transition="all .25s ease"
|
||||
bg={bgIcons}
|
||||
_hover={{ bg: bgIconsHover }}
|
||||
>
|
||||
<Link href="#">
|
||||
<Icon
|
||||
as={FaApple}
|
||||
color={colorIcons}
|
||||
w="30px"
|
||||
h="30px"
|
||||
_hover={{ filter: "brightness(120%)" }}
|
||||
/>
|
||||
</Link>
|
||||
</Flex>
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
w="75px"
|
||||
h="75px"
|
||||
borderRadius="8px"
|
||||
border={useColorModeValue("1px solid", "0px")}
|
||||
borderColor="gray.200"
|
||||
cursor="pointer"
|
||||
transition="all .25s ease"
|
||||
bg={bgIcons}
|
||||
_hover={{ bg: bgIconsHover }}
|
||||
>
|
||||
<Link href="#">
|
||||
<Icon
|
||||
as={FaGoogle}
|
||||
color={colorIcons}
|
||||
w="30px"
|
||||
h="30px"
|
||||
_hover={{ filter: "brightness(120%)" }}
|
||||
/>
|
||||
</Link>
|
||||
</Flex>
|
||||
</HStack>
|
||||
<Text
|
||||
fontSize="lg"
|
||||
color="gray.400"
|
||||
fontWeight="bold"
|
||||
textAlign="center"
|
||||
mb="22px"
|
||||
>
|
||||
or
|
||||
</Text>
|
||||
<FormControl>
|
||||
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
|
||||
Name
|
||||
</FormLabel>
|
||||
<Input
|
||||
variant="auth"
|
||||
fontSize="sm"
|
||||
ms="4px"
|
||||
type="text"
|
||||
placeholder="Your full name"
|
||||
mb="24px"
|
||||
size="lg"
|
||||
/>
|
||||
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
|
||||
Email
|
||||
</FormLabel>
|
||||
<Input
|
||||
variant="auth"
|
||||
fontSize="sm"
|
||||
ms="4px"
|
||||
type="email"
|
||||
placeholder="Your full email adress"
|
||||
mb="24px"
|
||||
size="lg"
|
||||
/>
|
||||
<FormLabel ms="4px" fontSize="sm" fontWeight="normal">
|
||||
Password
|
||||
</FormLabel>
|
||||
<Input
|
||||
variant="auth"
|
||||
fontSize="sm"
|
||||
ms="4px"
|
||||
type="password"
|
||||
placeholder="Your password"
|
||||
mb="24px"
|
||||
size="lg"
|
||||
/>
|
||||
<FormControl display="flex" alignItems="center" mb="24px">
|
||||
<Switch id="remember-login" colorScheme="blue" me="10px" />
|
||||
<FormLabel htmlFor="remember-login" mb="0" fontWeight="normal">
|
||||
Remember me
|
||||
</FormLabel>
|
||||
</FormControl>
|
||||
<Button
|
||||
fontSize="10px"
|
||||
variant="dark"
|
||||
fontWeight="bold"
|
||||
w="100%"
|
||||
h="45"
|
||||
mb="24px"
|
||||
>
|
||||
SIGN IN
|
||||
</Button>
|
||||
</FormControl>
|
||||
<Flex
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
maxW="100%"
|
||||
mt="0px"
|
||||
>
|
||||
<Text color={textColor} fontWeight="medium">
|
||||
Don’t have an account?
|
||||
<Link
|
||||
color={titleColor}
|
||||
as="span"
|
||||
ms="5px"
|
||||
href="#"
|
||||
fontWeight="bold"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</AuthCover>
|
||||
);
|
||||
}
|
||||
|
||||
export default SignUpCover;
|
||||
@@ -1,445 +0,0 @@
|
||||
// src\views\Authentication\SignUp/SignUpIllustration.js
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FormControl,
|
||||
Input,
|
||||
Text,
|
||||
Heading,
|
||||
VStack,
|
||||
HStack,
|
||||
useToast,
|
||||
InputGroup,
|
||||
InputRightElement,
|
||||
IconButton,
|
||||
Center,
|
||||
FormErrorMessage,
|
||||
Link as ChakraLink,
|
||||
useDisclosure
|
||||
} from "@chakra-ui/react";
|
||||
import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import axios from "axios";
|
||||
import AuthBackground from '../../../components/Auth/AuthBackground';
|
||||
import AuthHeader from '../../../components/Auth/AuthHeader';
|
||||
import AuthFooter from '../../../components/Auth/AuthFooter';
|
||||
import VerificationCodeInput from '../../../components/Auth/VerificationCodeInput';
|
||||
import WechatRegister from '../../../components/Auth/WechatRegister';
|
||||
import PrivacyPolicyModal from '../../../components/PrivacyPolicyModal';
|
||||
import UserAgreementModal from '../../../components/UserAgreementModal';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
export default function SignUpPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
verificationCode: ""
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
// 追踪组件挂载状态,防止内存泄漏
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// 隐私政策弹窗状态
|
||||
const { isOpen: isPrivacyModalOpen, onOpen: onPrivacyModalOpen, onClose: onPrivacyModalClose } = useDisclosure();
|
||||
|
||||
// 用户协议弹窗状态
|
||||
const { isOpen: isUserAgreementModalOpen, onOpen: onUserAgreementModalOpen, onClose: onUserAgreementModalClose } = useDisclosure();
|
||||
|
||||
// 验证码登录状态 是否开启验证码
|
||||
const [useVerificationCode, setUseVerificationCode] = useState(false);
|
||||
|
||||
// 切换注册方式
|
||||
const handleChangeMethod = () => {
|
||||
setUseVerificationCode(!useVerificationCode);
|
||||
// 切换到密码模式时清空验证码
|
||||
if (useVerificationCode) {
|
||||
setFormData(prev => ({ ...prev, verificationCode: "" }));
|
||||
}
|
||||
};
|
||||
|
||||
// 发送验证码
|
||||
const sendVerificationCode = async () => {
|
||||
const contact = formData.phone;
|
||||
const endpoint = "send-sms-code";
|
||||
const fieldName = "phone";
|
||||
|
||||
if (!contact) {
|
||||
toast({
|
||||
title: "请输入手机号",
|
||||
status: "warning",
|
||||
duration: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(contact)) {
|
||||
toast({
|
||||
title: "请输入正确的手机号",
|
||||
status: "warning",
|
||||
duration: 2000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await axios.post(`${API_BASE_URL}/api/auth/${endpoint}`, {
|
||||
[fieldName]: contact
|
||||
}, {
|
||||
timeout: 10000 // 添加10秒超时
|
||||
});
|
||||
|
||||
// 组件卸载后不再执行后续操作
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// ✅ 安全检查:验证 response 和 data 存在
|
||||
if (!response || !response.data) {
|
||||
throw new Error('服务器响应为空');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "验证码已发送",
|
||||
description: "请查收短信",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
setCountdown(60);
|
||||
} catch (error) {
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "发送失败",
|
||||
description: error.response?.data?.error || error.message || "请稍后重试",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 倒计时效果
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
if (isMounted) {
|
||||
setCountdown(countdown - 1);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}
|
||||
}, [countdown]);
|
||||
|
||||
// 表单验证
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
// 手机号验证(两种方式都需要)
|
||||
if (!formData.phone) {
|
||||
newErrors.phone = "请输入手机号";
|
||||
} else if (!/^1[3-9]\d{9}$/.test(formData.phone)) {
|
||||
newErrors.phone = "请输入正确的手机号";
|
||||
}
|
||||
|
||||
if (useVerificationCode) {
|
||||
// 验证码注册方式:只验证手机号和验证码
|
||||
if (!formData.verificationCode) {
|
||||
newErrors.verificationCode = "请输入验证码";
|
||||
}
|
||||
} else {
|
||||
// 密码注册方式:验证用户名、密码和确认密码
|
||||
if (!formData.password || formData.password.length < 6) {
|
||||
newErrors.password = "密码至少6个字符";
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = "两次密码不一致";
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// 处理注册提交
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
let endpoint, data;
|
||||
|
||||
if (useVerificationCode) {
|
||||
// 验证码注册:只发送手机号和验证码
|
||||
endpoint = "/api/auth/register/phone-code";
|
||||
data = {
|
||||
phone: formData.phone,
|
||||
code: formData.verificationCode
|
||||
};
|
||||
} else {
|
||||
// 密码注册:发送手机号、用户名和密码
|
||||
endpoint = "/api/auth/register/phone";
|
||||
data = {
|
||||
phone: formData.phone,
|
||||
username: formData.username,
|
||||
password: formData.password
|
||||
};
|
||||
}
|
||||
|
||||
const response = await axios.post(`${API_BASE_URL}${endpoint}`, data, {
|
||||
timeout: 10000 // 添加10秒超时
|
||||
});
|
||||
|
||||
// 组件卸载后不再执行后续操作
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// ✅ 安全检查:验证 response 和 data 存在
|
||||
if (!response || !response.data) {
|
||||
throw new Error('注册请求失败:服务器响应为空');
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "注册成功",
|
||||
description: "即将跳转到登录页面",
|
||||
status: "success",
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
navigate("/auth/sign-in");
|
||||
}
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "注册失败",
|
||||
description: error.response?.data?.error || error.message || "请稍后重试",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({ ...prev, [name]: "" }));
|
||||
}
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 公用的用户名和密码输入框组件
|
||||
const commonAuthFields = (
|
||||
<VStack spacing={4} width="100%">
|
||||
<FormControl isRequired isInvalid={!!errors.password}>
|
||||
<InputGroup>
|
||||
<Input
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="设置密码(至少6个字符)"
|
||||
pr="3rem"
|
||||
/>
|
||||
<InputRightElement width="3rem">
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={showPassword ? <ViewOffIcon /> : <ViewIcon />}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
/>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
<FormErrorMessage>{errors.password}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired isInvalid={!!errors.confirmPassword}>
|
||||
<Input
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="再次输入密码"
|
||||
/>
|
||||
<FormErrorMessage>{errors.confirmPassword}</FormErrorMessage>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex minH="100vh" position="relative" overflow="hidden">
|
||||
{/* 背景 */}
|
||||
<AuthBackground />
|
||||
|
||||
{/* 主要内容 */}
|
||||
<Flex width="100%" align="center" justify="center" position="relative" zIndex={1} px={6} py={12}>
|
||||
<Box bg="white" borderRadius="2xl" boxShadow="2xl" p={8} width="100%" maxW="800px" backdropFilter="blur(20px)" border="1px solid rgba(255, 255, 255, 0.2)">
|
||||
{/* 头部区域 */}
|
||||
<AuthHeader title="创建账户" subtitle="加入价值前沿,开启投资新征程" />
|
||||
{/* 左右布局 */}
|
||||
<HStack spacing={8} align="stretch">
|
||||
{/* 左侧:手机号注册 - 80% 宽度 */}
|
||||
<Box flex="4">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<VStack spacing={4}>
|
||||
<Heading size="md" color="gray.700" alignSelf="flex-start">
|
||||
注册
|
||||
</Heading>
|
||||
<FormControl isRequired isInvalid={!!errors.phone}>
|
||||
<Input
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleInputChange}
|
||||
placeholder="请输入11位手机号"
|
||||
pr="2.5rem"
|
||||
/>
|
||||
<FormErrorMessage>{errors.phone}</FormErrorMessage>
|
||||
</FormControl>
|
||||
|
||||
{/* 表单字段区域 */}
|
||||
<Box width="100%">
|
||||
{
|
||||
useVerificationCode ? (
|
||||
<VStack spacing={4} width="100%">
|
||||
<VerificationCodeInput
|
||||
value={formData.verificationCode}
|
||||
onChange={handleInputChange}
|
||||
onSendCode={sendVerificationCode}
|
||||
countdown={countdown}
|
||||
isLoading={isLoading}
|
||||
isSending={isLoading && countdown === 0}
|
||||
error={errors.verificationCode}
|
||||
colorScheme="green"
|
||||
/>
|
||||
{/* 隐藏的占位元素,保持与密码模式相同的高度 */}
|
||||
<Box height="40px" width="100%" visibility="hidden" />
|
||||
</VStack>
|
||||
) : (
|
||||
<>
|
||||
{commonAuthFields}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</Box>
|
||||
|
||||
<AuthFooter
|
||||
linkText="已有账号?"
|
||||
linkLabel="去登录"
|
||||
linkTo="/auth/sign-in"
|
||||
useVerificationCode={useVerificationCode}
|
||||
onSwitchMethod={handleChangeMethod}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
width="100%"
|
||||
size="lg"
|
||||
colorScheme="green"
|
||||
color="white"
|
||||
borderRadius="lg"
|
||||
_hover={{
|
||||
transform: "translateY(-2px)",
|
||||
boxShadow: "lg"
|
||||
}}
|
||||
_active={{ transform: "translateY(0)" }}
|
||||
isLoading={isLoading}
|
||||
loadingText="注册中..."
|
||||
fontWeight="bold"
|
||||
cursor="pointer"
|
||||
>
|
||||
注册
|
||||
</Button>
|
||||
|
||||
{/* 协议同意文本 */}
|
||||
<Text fontSize="sm" color="gray.600" textAlign="center" width="100%">
|
||||
注册登录即表示阅读并同意{" "}
|
||||
<ChakraLink
|
||||
color="blue.500"
|
||||
fontSize="sm"
|
||||
onClick={onUserAgreementModalOpen}
|
||||
textDecoration="underline"
|
||||
_hover={{ color: "blue.600" }}
|
||||
cursor="pointer"
|
||||
>
|
||||
《用户协议》
|
||||
</ChakraLink>
|
||||
{" "}和{" "}
|
||||
<ChakraLink
|
||||
color="blue.500"
|
||||
fontSize="sm"
|
||||
onClick={onPrivacyModalOpen}
|
||||
textDecoration="underline"
|
||||
_hover={{ color: "blue.600" }}
|
||||
cursor="pointer"
|
||||
>
|
||||
《隐私政策》
|
||||
</ChakraLink>
|
||||
</Text>
|
||||
</VStack>
|
||||
</form>
|
||||
</Box>
|
||||
|
||||
{/* 右侧:微信注册 - 20% 宽度 */}
|
||||
<Box flex="1">
|
||||
<Center width="100%" bg="gray.50" borderRadius="lg" p={8}>
|
||||
<WechatRegister />
|
||||
</Center>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{/* 隐私政策弹窗 */}
|
||||
<PrivacyPolicyModal isOpen={isPrivacyModalOpen} onClose={onPrivacyModalClose} />
|
||||
|
||||
{/* 用户协议弹窗 */}
|
||||
<UserAgreementModal isOpen={isUserAgreementModalOpen} onClose={onUserAgreementModalClose} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
601
src/views/Community/components/DynamicNewsCard.js
Normal file
601
src/views/Community/components/DynamicNewsCard.js
Normal file
@@ -0,0 +1,601 @@
|
||||
// src/views/Community/components/DynamicNewsCard.js
|
||||
// 横向滚动事件卡片组件(实时要闻·动态追踪)
|
||||
|
||||
import React, { forwardRef, useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Box,
|
||||
Flex,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Badge,
|
||||
Center,
|
||||
Spinner,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { TimeIcon } from '@chakra-ui/icons';
|
||||
import EventScrollList from './DynamicNewsCard/EventScrollList';
|
||||
import ModeToggleButtons from './DynamicNewsCard/ModeToggleButtons';
|
||||
import PaginationControl from './DynamicNewsCard/PaginationControl';
|
||||
import DynamicNewsDetailPanel from './DynamicNewsDetail';
|
||||
import UnifiedSearchBox from './UnifiedSearchBox';
|
||||
import {
|
||||
fetchDynamicNews,
|
||||
toggleEventFollow,
|
||||
selectEventFollowStatus,
|
||||
selectVerticalEventsWithLoading,
|
||||
selectFourRowEventsWithLoading
|
||||
} from '../../../store/slices/communityDataSlice';
|
||||
import { usePagination } from './DynamicNewsCard/hooks/usePagination';
|
||||
import { PAGINATION_CONFIG, DISPLAY_MODES } from './DynamicNewsCard/constants';
|
||||
|
||||
// 🔍 调试:渲染计数器
|
||||
let dynamicNewsCardRenderCount = 0;
|
||||
|
||||
/**
|
||||
* 实时要闻·动态追踪 - 事件展示卡片组件
|
||||
* @param {Object} filters - 筛选条件
|
||||
* @param {Array} popularKeywords - 热门关键词
|
||||
* @param {Date} lastUpdateTime - 最后更新时间
|
||||
* @param {Function} onSearch - 搜索回调
|
||||
* @param {Function} onSearchFocus - 搜索框获得焦点回调
|
||||
* @param {Function} onEventClick - 事件点击回调
|
||||
* @param {Function} onViewDetail - 查看详情回调
|
||||
* @param {Object} ref - 用于滚动的ref
|
||||
*/
|
||||
const DynamicNewsCard = forwardRef(({
|
||||
filters = {},
|
||||
popularKeywords = [],
|
||||
lastUpdateTime,
|
||||
onSearch,
|
||||
onSearchFocus,
|
||||
onEventClick,
|
||||
onViewDetail,
|
||||
...rest
|
||||
}, ref) => {
|
||||
const dispatch = useDispatch();
|
||||
const toast = useToast();
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
// 固定模式状态
|
||||
const [isFixedMode, setIsFixedMode] = useState(false);
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
const cardHeaderRef = useRef(null);
|
||||
const cardBodyRef = useRef(null);
|
||||
|
||||
// 导航栏和页脚固定高度
|
||||
const NAVBAR_HEIGHT = 64; // 主导航高度
|
||||
const SECONDARY_NAV_HEIGHT = 44; // 二级导航高度
|
||||
const FOOTER_HEIGHT = 120; // 页脚高度(预留)
|
||||
const TOTAL_NAV_HEIGHT = NAVBAR_HEIGHT + SECONDARY_NAV_HEIGHT; // 总导航高度 128px
|
||||
|
||||
// 从 Redux 读取关注状态
|
||||
const eventFollowStatus = useSelector(selectEventFollowStatus);
|
||||
|
||||
// 本地状态:模式(先初始化,后面会被 usePagination 更新)
|
||||
const [currentMode, setCurrentMode] = useState('vertical');
|
||||
|
||||
// 根据当前模式从 Redux 读取对应的数据(添加默认值避免 undefined)
|
||||
const verticalData = useSelector(selectVerticalEventsWithLoading) || {};
|
||||
const fourRowData = useSelector(selectFourRowEventsWithLoading) || {};
|
||||
|
||||
// 🔍 调试:从 Redux 读取数据
|
||||
console.log('%c[DynamicNewsCard] 从 Redux 读取数据', 'color: #3B82F6; font-weight: bold;', {
|
||||
currentMode,
|
||||
'verticalData.data type': typeof verticalData.data,
|
||||
'verticalData.data keys': verticalData.data ? Object.keys(verticalData.data) : [],
|
||||
'verticalData.total': verticalData.total,
|
||||
'verticalData.cachedPageCount': verticalData.cachedPageCount,
|
||||
'verticalData.loading': verticalData.loading,
|
||||
'fourRowData.data?.length': fourRowData.data?.length || 0,
|
||||
'fourRowData.total': fourRowData.total,
|
||||
});
|
||||
|
||||
// 根据模式选择数据源
|
||||
// 纵向模式:data 是页码映射 { 1: [...], 2: [...] }
|
||||
// 平铺模式:data 是数组 [...]
|
||||
const modeData = currentMode === 'four-row' ? fourRowData : verticalData;
|
||||
const {
|
||||
data = currentMode === 'vertical' ? {} : [], // 纵向是对象,平铺是数组
|
||||
loading = false,
|
||||
error = null,
|
||||
pagination, // 分页元数据
|
||||
total = 0, // 向后兼容
|
||||
cachedCount = 0,
|
||||
cachedPageCount = 0
|
||||
} = modeData;
|
||||
|
||||
// 传递给 usePagination 的数据
|
||||
const allCachedEventsByPage = currentMode === 'vertical' ? data : undefined;
|
||||
const allCachedEvents = currentMode === 'four-row' ? data : undefined;
|
||||
|
||||
// 🔍 调试:选择的数据源
|
||||
console.log('%c[DynamicNewsCard] 选择的数据源', 'color: #3B82F6; font-weight: bold;', {
|
||||
mode: currentMode,
|
||||
'allCachedEventsByPage': allCachedEventsByPage ? Object.keys(allCachedEventsByPage) : 'undefined',
|
||||
'allCachedEvents?.length': allCachedEvents?.length,
|
||||
total,
|
||||
cachedCount,
|
||||
cachedPageCount,
|
||||
loading,
|
||||
error
|
||||
});
|
||||
|
||||
// 🔍 调试:记录每次渲染
|
||||
dynamicNewsCardRenderCount++;
|
||||
console.log(`%c🔍 [DynamicNewsCard] 渲染 #${dynamicNewsCardRenderCount} - mode=${currentMode}, allCachedEvents.length=${allCachedEvents?.length || 0}, total=${total}`, 'color: #FF9800; font-weight: bold; font-size: 14px;');
|
||||
|
||||
// 关注按钮点击处理
|
||||
const handleToggleFollow = useCallback((eventId) => {
|
||||
dispatch(toggleEventFollow(eventId));
|
||||
}, [dispatch]);
|
||||
|
||||
// 本地状态
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
|
||||
// 弹窗状态(用于四排模式)
|
||||
const { isOpen: isModalOpen, onOpen: onModalOpen, onClose: onModalClose } = useDisclosure();
|
||||
const [modalEvent, setModalEvent] = useState(null);
|
||||
|
||||
// 初始化标记 - 确保初始加载只执行一次
|
||||
const hasInitialized = useRef(false);
|
||||
// 追踪是否已自动选中过首个事件
|
||||
const hasAutoSelectedFirstEvent = useRef(false);
|
||||
// 追踪筛选条件 useEffect 是否是第一次渲染(避免初始加载时重复请求)
|
||||
const isFirstRenderForFilters = useRef(true);
|
||||
|
||||
// 使用分页 Hook
|
||||
const {
|
||||
currentPage,
|
||||
mode,
|
||||
loadingPage,
|
||||
pageSize,
|
||||
totalPages,
|
||||
hasMore,
|
||||
currentPageEvents,
|
||||
displayEvents, // 当前显示的事件列表
|
||||
handlePageChange,
|
||||
handleModeToggle,
|
||||
loadNextPage, // 加载下一页
|
||||
loadPrevPage // 加载上一页
|
||||
} = usePagination({
|
||||
allCachedEventsByPage, // 纵向模式:页码映射
|
||||
allCachedEvents, // 平铺模式:数组
|
||||
pagination, // 分页元数据对象
|
||||
total, // 向后兼容
|
||||
cachedCount,
|
||||
dispatch,
|
||||
toast,
|
||||
filters, // 传递筛选条件
|
||||
initialMode: currentMode // 传递当前显示模式
|
||||
});
|
||||
|
||||
// 同步 mode 到 currentMode
|
||||
useEffect(() => {
|
||||
setCurrentMode(mode);
|
||||
}, [mode]);
|
||||
|
||||
// 监听 error 状态,显示空数据提示
|
||||
useEffect(() => {
|
||||
if (error && error.includes('暂无更多数据')) {
|
||||
toast({
|
||||
title: '提示',
|
||||
description: error,
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}, [error, toast]);
|
||||
|
||||
// 四排模式的事件点击处理(打开弹窗)
|
||||
const handleFourRowEventClick = useCallback((event) => {
|
||||
console.log('%c🔲 [四排模式] 点击事件,打开详情弹窗', 'color: #8B5CF6; font-weight: bold;', { eventId: event.id, title: event.title });
|
||||
setModalEvent(event);
|
||||
onModalOpen();
|
||||
}, [onModalOpen]);
|
||||
|
||||
// 初始加载 - 只在组件首次挂载且对应模式数据为空时执行
|
||||
useEffect(() => {
|
||||
// 添加防抖:如果已经初始化,不再执行
|
||||
if (hasInitialized.current) return;
|
||||
|
||||
const isDataEmpty = currentMode === 'vertical'
|
||||
? Object.keys(allCachedEventsByPage || {}).length === 0
|
||||
: (allCachedEvents?.length || 0) === 0;
|
||||
|
||||
if (isDataEmpty) {
|
||||
hasInitialized.current = true;
|
||||
dispatch(fetchDynamicNews({
|
||||
mode: mode, // 传递当前模式
|
||||
per_page: pageSize,
|
||||
pageSize: pageSize, // 传递 pageSize 确保索引计算一致
|
||||
clearCache: true,
|
||||
...filters, // 先展开筛选条件
|
||||
page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数
|
||||
}));
|
||||
}
|
||||
}, [dispatch, currentMode, mode, pageSize]); // 移除 allCachedEventsByPage, allCachedEvents 依赖,避免数据更新触发重复请求
|
||||
|
||||
// 监听筛选条件变化 - 清空缓存并重新请求数据
|
||||
useEffect(() => {
|
||||
// 跳过初始加载(由上面的 useEffect 处理)
|
||||
if (!hasInitialized.current) return;
|
||||
|
||||
// 跳过第一次渲染(避免与初始加载 useEffect 重复)
|
||||
if (isFirstRenderForFilters.current) {
|
||||
isFirstRenderForFilters.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('%c🔍 [筛选] 筛选条件改变,重新请求数据', 'color: #8B5CF6; font-weight: bold;', filters);
|
||||
|
||||
// 筛选条件改变时,清空对应模式的缓存并从第1页开始加载
|
||||
dispatch(fetchDynamicNews({
|
||||
mode: mode, // 传递当前模式
|
||||
per_page: pageSize,
|
||||
pageSize: pageSize,
|
||||
clearCache: true, // 清空缓存
|
||||
...filters, // 先展开筛选条件
|
||||
page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数
|
||||
}));
|
||||
}, [
|
||||
filters.sort,
|
||||
filters.importance,
|
||||
filters.q,
|
||||
filters.start_date, // 时间筛选参数:开始时间
|
||||
filters.end_date, // 时间筛选参数:结束时间
|
||||
filters.recent_days, // 时间筛选参数:近N天
|
||||
filters.industry_code,
|
||||
filters._forceRefresh, // 强制刷新标志(用于重置按钮)
|
||||
mode, // 添加 mode 到依赖
|
||||
pageSize, // 添加 pageSize 到依赖
|
||||
dispatch
|
||||
]); // 只监听筛选参数的变化,不监听 page
|
||||
|
||||
// 监听模式切换 - 如果新模式数据为空,请求数据
|
||||
useEffect(() => {
|
||||
const isDataEmpty = currentMode === 'vertical'
|
||||
? Object.keys(allCachedEventsByPage || {}).length === 0
|
||||
: (allCachedEvents?.length || 0) === 0;
|
||||
|
||||
if (hasInitialized.current && isDataEmpty) {
|
||||
console.log(`%c🔄 [模式切换] ${mode} 模式数据为空,开始加载`, 'color: #8B5CF6; font-weight: bold;');
|
||||
|
||||
// 🔧 根据 mode 直接计算 per_page,避免使用可能过时的 pageSize prop
|
||||
const modePageSize = mode === DISPLAY_MODES.FOUR_ROW
|
||||
? PAGINATION_CONFIG.FOUR_ROW_PAGE_SIZE // 30
|
||||
: PAGINATION_CONFIG.VERTICAL_PAGE_SIZE; // 10
|
||||
|
||||
console.log(`%c 计算的 per_page: ${modePageSize} (mode: ${mode})`, 'color: #8B5CF6;');
|
||||
|
||||
dispatch(fetchDynamicNews({
|
||||
mode: mode,
|
||||
per_page: modePageSize, // 使用计算的值,不是 pageSize prop
|
||||
pageSize: modePageSize,
|
||||
clearCache: true,
|
||||
...filters, // 先展开筛选条件
|
||||
page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数
|
||||
}));
|
||||
}
|
||||
}, [mode, currentMode, allCachedEventsByPage, allCachedEvents, dispatch]); // 移除 filters 依赖,避免与筛选 useEffect 循环触发 // 添加所有依赖
|
||||
|
||||
// 自动选中逻辑 - 只在首次加载时自动选中第一个事件,翻页时不自动选中
|
||||
useEffect(() => {
|
||||
if (currentPageEvents.length > 0) {
|
||||
// 情况1: 首次加载 - 自动选中第一个事件并触发详情加载
|
||||
if (!hasAutoSelectedFirstEvent.current && !selectedEvent) {
|
||||
console.log('%c🎯 [首次加载] 自动选中第一个事件', 'color: #10B981; font-weight: bold;');
|
||||
hasAutoSelectedFirstEvent.current = true;
|
||||
setSelectedEvent(currentPageEvents[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 情况2: 翻页 - 如果选中的事件不在当前页,根据模式决定处理方式
|
||||
const selectedEventInCurrentPage = currentPageEvents.find(
|
||||
e => e.id === selectedEvent?.id
|
||||
);
|
||||
}
|
||||
}, [currentPageEvents, selectedEvent?.id, mode]);
|
||||
|
||||
// 组件卸载时清理选中状态
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setSelectedEvent(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 页码切换时滚动到顶部
|
||||
const handlePageChangeWithScroll = useCallback((page) => {
|
||||
// 先切换页码
|
||||
handlePageChange(page);
|
||||
|
||||
// 延迟一帧,确保DOM更新完成后再滚动
|
||||
requestAnimationFrame(() => {
|
||||
// 查找所有标记为滚动容器的元素
|
||||
const containers = document.querySelectorAll('[data-scroll-container]');
|
||||
containers.forEach(container => {
|
||||
container.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
console.log('📜 页码切换,滚动到顶部', { containersFound: containers.length });
|
||||
});
|
||||
}, [handlePageChange]);
|
||||
|
||||
// 测量 CardHeader 高度
|
||||
useEffect(() => {
|
||||
const cardHeaderElement = cardHeaderRef.current;
|
||||
if (!cardHeaderElement) return;
|
||||
|
||||
// 测量并更新高度
|
||||
const updateHeaderHeight = () => {
|
||||
const height = cardHeaderElement.offsetHeight;
|
||||
setHeaderHeight(height);
|
||||
};
|
||||
|
||||
// 初始测量
|
||||
updateHeaderHeight();
|
||||
|
||||
// 监听窗口大小变化(响应式调整)
|
||||
window.addEventListener('resize', updateHeaderHeight);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateHeaderHeight);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 监听 CardHeader 是否到达触发点,动态切换固定模式
|
||||
useEffect(() => {
|
||||
const cardHeaderElement = cardHeaderRef.current;
|
||||
const cardBodyElement = cardBodyRef.current;
|
||||
if (!cardHeaderElement || !cardBodyElement) return;
|
||||
|
||||
let ticking = false;
|
||||
const TRIGGER_OFFSET = 100; // 提前 100px 触发
|
||||
|
||||
// 外部滚动监听:触发固定模式
|
||||
const handleExternalScroll = () => {
|
||||
// 只在非固定模式下监听外部滚动
|
||||
if (!isFixedMode && !ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
// 获取 CardHeader 相对视口的位置
|
||||
const rect = cardHeaderElement.getBoundingClientRect();
|
||||
const elementTop = rect.top;
|
||||
|
||||
// 计算触发点:总导航高度 + 100px 偏移量
|
||||
const triggerPoint = TOTAL_NAV_HEIGHT + TRIGGER_OFFSET;
|
||||
|
||||
// 向上滑动:元素顶部到达触发点 → 激活固定模式
|
||||
if (elementTop <= triggerPoint) {
|
||||
setIsFixedMode(true);
|
||||
console.log('🔒 切换为固定全屏模式', {
|
||||
elementTop,
|
||||
triggerPoint,
|
||||
offset: TRIGGER_OFFSET
|
||||
});
|
||||
}
|
||||
|
||||
ticking = false;
|
||||
});
|
||||
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
// 内部滚动监听:退出固定模式
|
||||
const handleWheel = (e) => {
|
||||
// 只在固定模式下监听内部滚动
|
||||
if (!isFixedMode) return;
|
||||
|
||||
// 检测向上滚动(deltaY < 0)
|
||||
if (e.deltaY < 0) {
|
||||
// 查找所有滚动容器
|
||||
const scrollContainers = cardBodyElement.querySelectorAll('[data-scroll-container]');
|
||||
|
||||
if (scrollContainers.length === 0) {
|
||||
// 如果没有找到标记的容器,查找所有可滚动元素
|
||||
const allScrollable = cardBodyElement.querySelectorAll('[style*="overflow"]');
|
||||
scrollContainers = allScrollable;
|
||||
}
|
||||
|
||||
// 检查是否所有滚动容器都在顶部
|
||||
const allAtTop = scrollContainers.length === 0 ||
|
||||
Array.from(scrollContainers).every(
|
||||
container => container.scrollTop === 0
|
||||
);
|
||||
|
||||
if (allAtTop) {
|
||||
setIsFixedMode(false);
|
||||
console.log('🔓 恢复正常文档流模式(内部滚动到顶部)');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 监听外部滚动
|
||||
window.addEventListener('scroll', handleExternalScroll, { passive: true });
|
||||
|
||||
// 监听内部滚轮事件(固定模式下)
|
||||
if (isFixedMode) {
|
||||
cardBodyElement.addEventListener('wheel', handleWheel, { passive: true });
|
||||
}
|
||||
|
||||
// 初次检查位置
|
||||
handleExternalScroll();
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleExternalScroll);
|
||||
cardBodyElement.removeEventListener('wheel', handleWheel);
|
||||
};
|
||||
}, [isFixedMode]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={ref}
|
||||
{...rest}
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
mb={4}
|
||||
>
|
||||
{/* 标题部分 */}
|
||||
<CardHeader
|
||||
ref={cardHeaderRef}
|
||||
position={isFixedMode ? 'fixed' : 'relative'}
|
||||
top={isFixedMode ? `${TOTAL_NAV_HEIGHT}px` : 'auto'}
|
||||
left={isFixedMode ? 0 : 'auto'}
|
||||
right={isFixedMode ? 0 : 'auto'}
|
||||
maxW={isFixedMode ? '1600px' : '100%'}
|
||||
mx={isFixedMode ? 'auto' : 0}
|
||||
px={isFixedMode ? { base: 3, md: 4 } : undefined}
|
||||
zIndex={isFixedMode ? 999 : 1}
|
||||
bg={cardBg}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="md">
|
||||
<HStack>
|
||||
<TimeIcon />
|
||||
<Text>实时要闻·动态追踪</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
<HStack fontSize="sm" color="gray.500">
|
||||
<Badge colorScheme="red">实时</Badge>
|
||||
<Badge colorScheme="green">盘中</Badge>
|
||||
<Badge colorScheme="blue">快讯</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* 搜索和筛选组件 */}
|
||||
<Box mt={4}>
|
||||
<UnifiedSearchBox
|
||||
onSearch={onSearch}
|
||||
onSearchFocus={onSearchFocus}
|
||||
popularKeywords={popularKeywords}
|
||||
filters={filters}
|
||||
mode={mode}
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
</Box>
|
||||
</CardHeader>
|
||||
|
||||
{/* 主体内容 */}
|
||||
<CardBody
|
||||
ref={cardBodyRef}
|
||||
position={isFixedMode ? 'fixed' : 'relative'}
|
||||
top={isFixedMode ? `${TOTAL_NAV_HEIGHT + headerHeight}px` : 'auto'}
|
||||
left={isFixedMode ? 0 : 'auto'}
|
||||
right={isFixedMode ? 0 : 'auto'}
|
||||
bottom={isFixedMode ? `${FOOTER_HEIGHT}px` : 'auto'}
|
||||
maxW={isFixedMode ? '1600px' : '100%'}
|
||||
mx={isFixedMode ? 'auto' : 0}
|
||||
h={isFixedMode ? `calc(100vh - ${TOTAL_NAV_HEIGHT + headerHeight + FOOTER_HEIGHT}px)` : 'auto'}
|
||||
px={isFixedMode ? { base: 3, md: 4 } : undefined}
|
||||
pt={4}
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
overflow="hidden"
|
||||
zIndex={isFixedMode ? 1000 : 1}
|
||||
bg={cardBg}
|
||||
>
|
||||
{/* 顶部控制栏:模式切换按钮 + 筛选按钮 + 分页控制器(固定不滚动) */}
|
||||
<Flex justify="space-between" align="center" mb={2} flexShrink={0}>
|
||||
{/* 左侧:模式切换按钮 + 筛选按钮 */}
|
||||
<ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />
|
||||
|
||||
{/* 右侧:分页控制器(仅在纵向模式显示) */}
|
||||
{mode === 'vertical' && totalPages > 1 && (
|
||||
<PaginationControl
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChangeWithScroll}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* 内容区域 - 撑满剩余高度 */}
|
||||
<Box flex="1" minH={0} position="relative">
|
||||
{/* Loading 蒙层 - 数据请求时显示 */}
|
||||
{loading && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
bg={useColorModeValue('rgba(255, 255, 255, 0.85)', 'rgba(26, 32, 44, 0.85)')}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
zIndex={10}
|
||||
borderRadius="md"
|
||||
>
|
||||
<VStack spacing={3}>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color={useColorModeValue('gray.600', 'gray.300')} fontWeight="medium">
|
||||
正在加载最新事件...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 列表内容 - 始终渲染 */}
|
||||
<EventScrollList
|
||||
events={currentPageEvents}
|
||||
displayEvents={displayEvents} // 累积显示的事件列表(平铺模式)
|
||||
loadNextPage={loadNextPage} // 加载下一页
|
||||
loadPrevPage={loadPrevPage} // 加载上一页
|
||||
onFourRowEventClick={handleFourRowEventClick} // 四排模式事件点击
|
||||
selectedEvent={selectedEvent}
|
||||
onEventSelect={setSelectedEvent}
|
||||
borderColor={borderColor}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChangeWithScroll}
|
||||
loading={loadingPage !== null}
|
||||
error={error}
|
||||
mode={mode}
|
||||
eventFollowStatus={eventFollowStatus}
|
||||
onToggleFollow={handleToggleFollow}
|
||||
hasMore={hasMore}
|
||||
/>
|
||||
</Box>
|
||||
</CardBody>
|
||||
|
||||
{/* 四排模式详情弹窗 - 未打开时不渲染 */}
|
||||
{isModalOpen && (
|
||||
<Modal isOpen={isModalOpen} onClose={onModalClose} size="full" scrollBehavior="inside">
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="1600px" mx="auto" my={8}>
|
||||
<ModalHeader>
|
||||
{modalEvent?.title || '事件详情'}
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
{modalEvent && <DynamicNewsDetailPanel event={modalEvent} />}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
DynamicNewsCard.displayName = 'DynamicNewsCard';
|
||||
|
||||
export default DynamicNewsCard;
|
||||
@@ -0,0 +1,62 @@
|
||||
// src/views/Community/components/DynamicNewsCard/EventDetailScrollPanel.js
|
||||
// 事件详情滚动面板组件
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Center, VStack, Text } from '@chakra-ui/react';
|
||||
import DynamicNewsDetailPanel from '../DynamicNewsDetail';
|
||||
|
||||
/**
|
||||
* 事件详情滚动面板
|
||||
* 带自定义滚动条样式的事件详情容器
|
||||
*
|
||||
* @param {Object} selectedEvent - 当前选中的事件
|
||||
* @param {string} scrollbarTrackBg - 滚动条轨道背景色
|
||||
* @param {string} scrollbarThumbBg - 滚动条滑块背景色
|
||||
* @param {string} scrollbarThumbHoverBg - 滚动条滑块悬浮背景色
|
||||
*/
|
||||
const EventDetailScrollPanel = ({
|
||||
selectedEvent,
|
||||
scrollbarTrackBg,
|
||||
scrollbarThumbBg,
|
||||
scrollbarThumbHoverBg,
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
pl={2}
|
||||
position="relative"
|
||||
sx={{
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '3px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: scrollbarTrackBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: scrollbarThumbBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: scrollbarThumbHoverBg,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{selectedEvent ? (
|
||||
<DynamicNewsDetailPanel event={selectedEvent} />
|
||||
) : (
|
||||
<Center h="100%" minH="400px">
|
||||
<VStack spacing={4}>
|
||||
<Text fontSize="lg" color="gray.500">
|
||||
请选择左侧事件查看详情
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetailScrollPanel;
|
||||
@@ -0,0 +1,146 @@
|
||||
// src/views/Community/components/DynamicNewsCard/EventScrollList.js
|
||||
// 横向滚动事件列表组件
|
||||
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react';
|
||||
import VirtualizedFourRowGrid from './VirtualizedFourRowGrid';
|
||||
import VerticalModeLayout from './VerticalModeLayout';
|
||||
|
||||
/**
|
||||
* 事件列表组件 - 支持纵向和平铺两种展示模式
|
||||
* @param {Array} events - 当前页的事件列表(服务端已分页)
|
||||
* @param {Array} displayEvents - 累积显示的事件列表(平铺模式用)
|
||||
* @param {Function} loadNextPage - 加载下一页(无限滚动)
|
||||
* @param {Function} loadPrevPage - 加载上一页(双向无限滚动)
|
||||
* @param {Function} onFourRowEventClick - 平铺模式事件点击回调(打开弹窗)
|
||||
* @param {Object} selectedEvent - 当前选中的事件
|
||||
* @param {Function} onEventSelect - 事件选择回调
|
||||
* @param {string} borderColor - 边框颜色
|
||||
* @param {number} currentPage - 当前页码
|
||||
* @param {number} totalPages - 总页数(由服务端返回)
|
||||
* @param {Function} onPageChange - 页码改变回调
|
||||
* @param {boolean} loading - 全局加载状态
|
||||
* @param {Object} error - 错误状态
|
||||
* @param {string} mode - 展示模式:'vertical'(纵向分栏)| 'four-row'(平铺网格)
|
||||
* @param {boolean} hasMore - 是否还有更多数据
|
||||
* @param {Object} eventFollowStatus - 事件关注状态 { [eventId]: { isFollowing, followerCount } }
|
||||
* @param {Function} onToggleFollow - 关注按钮回调
|
||||
*/
|
||||
const EventScrollList = ({
|
||||
events,
|
||||
displayEvents,
|
||||
loadNextPage,
|
||||
loadPrevPage,
|
||||
onFourRowEventClick,
|
||||
selectedEvent,
|
||||
onEventSelect,
|
||||
borderColor,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
loading = false,
|
||||
error,
|
||||
mode = 'vertical',
|
||||
hasMore = true,
|
||||
eventFollowStatus = {},
|
||||
onToggleFollow
|
||||
}) => {
|
||||
const scrollContainerRef = useRef(null);
|
||||
|
||||
// 所有 useColorModeValue 必须在组件顶层调用(不能在条件渲染中)
|
||||
const timelineBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const timelineBorderColor = useColorModeValue('gray.400', 'gray.500');
|
||||
const timelineTextColor = useColorModeValue('blue.600', 'blue.400');
|
||||
|
||||
// 滚动条颜色
|
||||
const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748');
|
||||
const scrollbarThumbBg = useColorModeValue('#888', '#4A5568');
|
||||
const scrollbarThumbHoverBg = useColorModeValue('#555', '#718096');
|
||||
|
||||
const getTimelineBoxStyle = () => {
|
||||
return {
|
||||
bg: timelineBg,
|
||||
borderColor: timelineBorderColor,
|
||||
borderWidth: '2px',
|
||||
textColor: timelineTextColor,
|
||||
boxShadow: 'sm',
|
||||
};
|
||||
};
|
||||
|
||||
// 重试函数
|
||||
const handleRetry = useCallback(() => {
|
||||
if (onPageChange) {
|
||||
onPageChange(currentPage);
|
||||
}
|
||||
}, [onPageChange, currentPage]);
|
||||
|
||||
{/* 事件卡片容器 */}
|
||||
return (
|
||||
<Box
|
||||
ref={scrollContainerRef}
|
||||
overflowX="hidden"
|
||||
h="100%"
|
||||
pt={0}
|
||||
pb={4}
|
||||
px={mode === 'four-row' ? 0 : 2}
|
||||
position="relative"
|
||||
data-scroll-container="true"
|
||||
css={{
|
||||
// 统一滚动条样式(支持横向和纵向)
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: scrollbarTrackBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: scrollbarThumbBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: scrollbarThumbHoverBg,
|
||||
},
|
||||
scrollBehavior: 'smooth',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{/* 平铺网格模式 - 使用虚拟滚动 + 双向无限滚动 */}
|
||||
<VirtualizedFourRowGrid
|
||||
display={mode === 'four-row' ? 'block' : 'none'}
|
||||
columnsPerRow={4} // 每行显示4列
|
||||
events={displayEvents || events} // 使用累积列表(如果有)
|
||||
selectedEvent={selectedEvent}
|
||||
onEventSelect={onFourRowEventClick} // 四排模式点击打开弹窗
|
||||
eventFollowStatus={eventFollowStatus}
|
||||
onToggleFollow={onToggleFollow}
|
||||
getTimelineBoxStyle={getTimelineBoxStyle}
|
||||
borderColor={borderColor}
|
||||
loadNextPage={loadNextPage} // 加载下一页
|
||||
loadPrevPage={loadPrevPage} // 加载上一页(双向滚动)
|
||||
hasMore={hasMore} // 是否还有更多数据
|
||||
loading={loading} // 加载状态
|
||||
error={error} // 错误状态
|
||||
onRetry={handleRetry} // 重试回调
|
||||
/>
|
||||
|
||||
{/* 纵向分栏模式 */}
|
||||
<VerticalModeLayout
|
||||
display={mode === 'vertical' ? 'flex' : 'none'}
|
||||
events={events}
|
||||
selectedEvent={selectedEvent}
|
||||
onEventSelect={onEventSelect}
|
||||
eventFollowStatus={eventFollowStatus}
|
||||
onToggleFollow={onToggleFollow}
|
||||
getTimelineBoxStyle={getTimelineBoxStyle}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventScrollList;
|
||||
@@ -0,0 +1,33 @@
|
||||
// src/views/Community/components/DynamicNewsCard/ModeToggleButtons.js
|
||||
// 事件列表模式切换按钮组
|
||||
|
||||
import React from 'react';
|
||||
import { Button, ButtonGroup } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 事件列表模式切换按钮组
|
||||
* @param {string} mode - 当前模式 'vertical' | 'four-row'
|
||||
* @param {Function} onModeChange - 模式切换回调
|
||||
*/
|
||||
const ModeToggleButtons = ({ mode, onModeChange }) => {
|
||||
return (
|
||||
<ButtonGroup size="sm" isAttached>
|
||||
<Button
|
||||
onClick={() => onModeChange('vertical')}
|
||||
colorScheme="blue"
|
||||
variant={mode === 'vertical' ? 'solid' : 'outline'}
|
||||
>
|
||||
纵向
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onModeChange('four-row')}
|
||||
colorScheme="blue"
|
||||
variant={mode === 'four-row' ? 'solid' : 'outline'}
|
||||
>
|
||||
平铺
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModeToggleButtons;
|
||||
@@ -0,0 +1,83 @@
|
||||
// src/views/Community/components/DynamicNewsCard/PageNavigationButton.js
|
||||
// 翻页导航按钮组件
|
||||
|
||||
import React from 'react';
|
||||
import { IconButton, useColorModeValue } from '@chakra-ui/react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
|
||||
|
||||
/**
|
||||
* 翻页导航按钮组件
|
||||
* @param {Object} props
|
||||
* @param {'prev'|'next'} props.direction - 按钮方向(prev=上一页,next=下一页)
|
||||
* @param {number} props.currentPage - 当前页码
|
||||
* @param {number} props.totalPages - 总页数
|
||||
* @param {Function} props.onPageChange - 翻页回调
|
||||
* @param {string} props.mode - 显示模式(只在carousel/grid模式下显示)
|
||||
*/
|
||||
const PageNavigationButton = ({
|
||||
direction,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
mode
|
||||
}) => {
|
||||
// 主题适配
|
||||
const arrowBtnBg = useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(0, 0, 0, 0.6)');
|
||||
const arrowBtnHoverBg = useColorModeValue('rgba(255, 255, 255, 1)', 'rgba(0, 0, 0, 0.8)');
|
||||
|
||||
// 根据方向计算配置
|
||||
const isPrev = direction === 'prev';
|
||||
const isNext = direction === 'next';
|
||||
|
||||
const Icon = isPrev ? ChevronLeftIcon : ChevronRightIcon;
|
||||
const position = isPrev ? 'left' : 'right';
|
||||
const label = isPrev ? '上一页' : '下一页';
|
||||
const targetPage = isPrev ? currentPage - 1 : currentPage + 1;
|
||||
const shouldShow = isPrev
|
||||
? currentPage > 1
|
||||
: currentPage < totalPages;
|
||||
const isDisabled = isNext ? currentPage >= totalPages : false;
|
||||
|
||||
// 判断是否显示(只在单排/双排模式显示)
|
||||
const shouldRender = shouldShow && (mode === 'carousel' || mode === 'grid');
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
const handleClick = () => {
|
||||
console.log(
|
||||
`%c🔵 [翻页] 点击${label}: 当前页${currentPage} → 目标页${targetPage} (共${totalPages}页)`,
|
||||
'color: #3B82F6; font-weight: bold;'
|
||||
);
|
||||
onPageChange(targetPage);
|
||||
};
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
icon={<Icon boxSize={6} color="blue.500" />}
|
||||
position="absolute"
|
||||
{...{ [position]: 0 }}
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
zIndex={2}
|
||||
onClick={handleClick}
|
||||
variant="ghost"
|
||||
size="md"
|
||||
w="40px"
|
||||
h="40px"
|
||||
minW="40px"
|
||||
borderRadius="full"
|
||||
bg={arrowBtnBg}
|
||||
boxShadow="0 2px 8px rgba(0, 0, 0, 0.15)"
|
||||
_hover={{
|
||||
bg: arrowBtnHoverBg,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.2)',
|
||||
transform: 'translateY(-50%) scale(1.05)'
|
||||
}}
|
||||
isDisabled={isDisabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageNavigationButton;
|
||||
@@ -0,0 +1,215 @@
|
||||
// src/views/Community/components/DynamicNewsCard/PaginationControl.js
|
||||
// 分页控制器组件
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
Button,
|
||||
Input,
|
||||
Text,
|
||||
IconButton,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
|
||||
/**
|
||||
* 分页控制器组件(使用 React.memo 优化,避免不必要的重新渲染)
|
||||
* @param {number} currentPage - 当前页码
|
||||
* @param {number} totalPages - 总页数
|
||||
* @param {Function} onPageChange - 页码改变回调
|
||||
*/
|
||||
const PaginationControl = React.memo(({ currentPage, totalPages, onPageChange }) => {
|
||||
const [jumpPage, setJumpPage] = useState('');
|
||||
const toast = useToast();
|
||||
|
||||
const buttonBg = useColorModeValue('white', 'gray.700');
|
||||
const activeBg = useColorModeValue('blue.500', 'blue.400');
|
||||
const activeColor = useColorModeValue('white', 'white');
|
||||
const borderColor = useColorModeValue('gray.300', 'gray.600');
|
||||
const hoverBg = useColorModeValue('gray.100', 'gray.600');
|
||||
|
||||
// 生成页码数字列表(智能省略)
|
||||
const getPageNumbers = () => {
|
||||
const pageNumbers = [];
|
||||
const maxVisible = 5; // 最多显示5个页码(精简版)
|
||||
|
||||
if (totalPages <= maxVisible) {
|
||||
// 总页数少,显示全部
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pageNumbers.push(i);
|
||||
}
|
||||
} else {
|
||||
// 总页数多,使用省略号
|
||||
if (currentPage <= 3) {
|
||||
// 当前页在前面
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
pageNumbers.push(i);
|
||||
}
|
||||
pageNumbers.push('...');
|
||||
pageNumbers.push(totalPages);
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
// 当前页在后面
|
||||
pageNumbers.push(1);
|
||||
pageNumbers.push('...');
|
||||
for (let i = totalPages - 3; i <= totalPages; i++) {
|
||||
pageNumbers.push(i);
|
||||
}
|
||||
} else {
|
||||
// 当前页在中间
|
||||
pageNumbers.push(1);
|
||||
pageNumbers.push('...');
|
||||
pageNumbers.push(currentPage);
|
||||
pageNumbers.push('...');
|
||||
pageNumbers.push(totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
return pageNumbers;
|
||||
};
|
||||
|
||||
// 处理页码跳转
|
||||
const handleJump = () => {
|
||||
const page = parseInt(jumpPage, 10);
|
||||
|
||||
if (isNaN(page)) {
|
||||
toast({
|
||||
title: '请输入有效的页码',
|
||||
status: 'warning',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (page < 1 || page > totalPages) {
|
||||
toast({
|
||||
title: `页码范围:1 - ${totalPages}`,
|
||||
status: 'warning',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onPageChange(page);
|
||||
setJumpPage('');
|
||||
};
|
||||
|
||||
// 处理回车键
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleJump();
|
||||
}
|
||||
};
|
||||
|
||||
const pageNumbers = getPageNumbers();
|
||||
|
||||
return (
|
||||
<Box mb={3}>
|
||||
<HStack spacing={1.5} justify="center" flexWrap="wrap">
|
||||
{/* 上一页按钮 */}
|
||||
<IconButton
|
||||
icon={<ChevronLeftIcon />}
|
||||
size="xs"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
isDisabled={currentPage === 1}
|
||||
bg={buttonBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
_hover={{ bg: hoverBg }}
|
||||
aria-label="上一页"
|
||||
title="上一页"
|
||||
/>
|
||||
|
||||
{/* 数字页码列表 */}
|
||||
{pageNumbers.map((page, index) => {
|
||||
if (page === '...') {
|
||||
return (
|
||||
<Text
|
||||
key={`ellipsis-${index}`}
|
||||
px={1}
|
||||
fontSize="xs"
|
||||
color="gray.500"
|
||||
>
|
||||
...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
size="xs"
|
||||
onClick={() => onPageChange(page)}
|
||||
bg={currentPage === page ? activeBg : buttonBg}
|
||||
color={currentPage === page ? activeColor : undefined}
|
||||
borderWidth="1px"
|
||||
borderColor={currentPage === page ? activeBg : borderColor}
|
||||
_hover={{
|
||||
bg: currentPage === page ? activeBg : hoverBg,
|
||||
}}
|
||||
minW="28px"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 下一页按钮 */}
|
||||
<IconButton
|
||||
icon={<ChevronRightIcon />}
|
||||
size="xs"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
isDisabled={currentPage === totalPages}
|
||||
bg={buttonBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
_hover={{ bg: hoverBg }}
|
||||
aria-label="下一页"
|
||||
title="下一页"
|
||||
/>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<Box w="1px" h="20px" bg={borderColor} mx={1.5} />
|
||||
|
||||
{/* 输入框跳转 */}
|
||||
<HStack spacing={1.5}>
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
跳转到
|
||||
</Text>
|
||||
<Input
|
||||
size="xs"
|
||||
width="50px"
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={jumpPage}
|
||||
onChange={(e) => setJumpPage(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="页"
|
||||
bg={buttonBg}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="blue"
|
||||
onClick={handleJump}
|
||||
>
|
||||
跳转
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// 自定义比较函数:只有当 currentPage 或 totalPages 变化时才重新渲染
|
||||
return prevProps.currentPage === nextProps.currentPage &&
|
||||
prevProps.totalPages === nextProps.totalPages;
|
||||
});
|
||||
|
||||
export default PaginationControl;
|
||||
@@ -0,0 +1,171 @@
|
||||
// src/views/Community/components/DynamicNewsCard/VerticalModeLayout.js
|
||||
// 纵向分栏模式布局组件
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, IconButton, Tooltip, VStack, Flex, Center, Text } from '@chakra-ui/react';
|
||||
import { ViewIcon, ViewOffIcon, InfoIcon } from '@chakra-ui/icons';
|
||||
import HorizontalDynamicNewsEventCard from '../EventCard/HorizontalDynamicNewsEventCard';
|
||||
import EventDetailScrollPanel from './EventDetailScrollPanel';
|
||||
|
||||
/**
|
||||
* 纵向分栏模式布局
|
||||
* 支持两种展示模式:
|
||||
* - detail(默认):左侧事件列表 1fr | 右侧详情 2fr
|
||||
* - list:左侧事件列表 7fr | 右侧详情 300px
|
||||
*
|
||||
* @param {string} display - CSS display 属性(用于显示/隐藏组件)
|
||||
* @param {Array} events - 当前页的事件列表(分页数据)
|
||||
* @param {Object} selectedEvent - 当前选中的事件
|
||||
* @param {Function} onEventSelect - 事件选择回调
|
||||
* @param {Object} eventFollowStatus - 事件关注状态
|
||||
* @param {Function} onToggleFollow - 关注按钮回调
|
||||
* @param {Function} getTimelineBoxStyle - 时间线样式获取函数
|
||||
* @param {string} borderColor - 边框颜色
|
||||
*/
|
||||
const VerticalModeLayout = ({
|
||||
display = 'flex',
|
||||
events,
|
||||
selectedEvent,
|
||||
onEventSelect,
|
||||
eventFollowStatus,
|
||||
onToggleFollow,
|
||||
getTimelineBoxStyle,
|
||||
borderColor,
|
||||
}) => {
|
||||
// 布局模式状态:'detail' = 聚焦详情(默认),'list' = 聚焦列表
|
||||
const [layoutMode, setLayoutMode] = useState('list');
|
||||
|
||||
// 详情面板重置 key(切换到 list 模式时改变,强制重新渲染)
|
||||
const [detailPanelKey, setDetailPanelKey] = useState(0);
|
||||
|
||||
// 监听事件选择 - 自动切换到详情模式
|
||||
useEffect(() => {
|
||||
if (selectedEvent) {
|
||||
setLayoutMode('detail');
|
||||
}
|
||||
}, [selectedEvent]);
|
||||
|
||||
// 切换布局模式
|
||||
const toggleLayoutMode = () => {
|
||||
const newMode = layoutMode === 'detail' ? 'list' : 'detail';
|
||||
setLayoutMode(newMode);
|
||||
|
||||
// 如果切换到 list 模式,重置详情面板(收起所有 CollapsibleSection)
|
||||
if (newMode === 'list') {
|
||||
setDetailPanelKey(prev => prev + 1); // 改变 key,强制重新渲染
|
||||
}
|
||||
};
|
||||
|
||||
// 根据模式计算 flex 比例
|
||||
const leftFlex = layoutMode === 'detail' ? '4' : '6';
|
||||
const rightFlex = layoutMode === 'detail' ? '6' : '4';
|
||||
|
||||
return (
|
||||
<Flex
|
||||
display={display}
|
||||
gap={6}
|
||||
position="relative"
|
||||
transition="all 0.3s ease-in-out"
|
||||
h="100%"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 左侧:事件列表 - 独立滚动 */}
|
||||
<Box
|
||||
flex={leftFlex}
|
||||
minWidth={0}
|
||||
overflowY="auto"
|
||||
h="100%"
|
||||
data-scroll-container="true"
|
||||
css={{
|
||||
overscrollBehavior: 'contain',
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '6px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: '#f1f1f1',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#888',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: '#555',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* 事件列表 */}
|
||||
{events && events.length > 0 ? (
|
||||
<VStack
|
||||
spacing={2}
|
||||
align="stretch"
|
||||
p={2}
|
||||
>
|
||||
{events.map((event) => (
|
||||
<HorizontalDynamicNewsEventCard
|
||||
key={event.id}
|
||||
event={event}
|
||||
isSelected={selectedEvent?.id === event.id}
|
||||
onEventClick={() => onEventSelect(event)}
|
||||
isFollowing={eventFollowStatus[event.id]?.isFollowing}
|
||||
followerCount={eventFollowStatus[event.id]?.followerCount}
|
||||
onToggleFollow={onToggleFollow}
|
||||
timelineStyle={getTimelineBoxStyle()}
|
||||
borderColor={borderColor}
|
||||
indicatorSize={layoutMode === 'detail' ? 'default' : 'comfortable'}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<Center h="100%" minH="400px">
|
||||
<VStack spacing={4}>
|
||||
<InfoIcon w={12} h={12} color="gray.400" />
|
||||
<Text fontSize="lg" color="gray.500" textAlign="center">
|
||||
当前筛选条件下暂无数据
|
||||
</Text>
|
||||
<Text fontSize="sm" color="gray.400" textAlign="center">
|
||||
请尝试调整筛选条件
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 右侧:事件详情 - 独立滚动 */}
|
||||
<Box
|
||||
flex={rightFlex}
|
||||
minHeight={0}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
h="100%"
|
||||
>
|
||||
{/* 布局切换按钮 */}
|
||||
<Tooltip
|
||||
label={layoutMode === 'detail' ? '展开事件列表' : '展开详情面板'}
|
||||
placement="left"
|
||||
>
|
||||
<IconButton
|
||||
position="absolute"
|
||||
top={2}
|
||||
right={2}
|
||||
zIndex={9999}
|
||||
size="md"
|
||||
icon={layoutMode === 'detail' ? <ViewOffIcon /> : <ViewIcon />}
|
||||
onClick={toggleLayoutMode}
|
||||
aria-label="切换布局模式"
|
||||
colorScheme="blue"
|
||||
variant="solid"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* 详情面板 */}
|
||||
<EventDetailScrollPanel
|
||||
key={detailPanelKey}
|
||||
selectedEvent={selectedEvent}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerticalModeLayout;
|
||||
@@ -0,0 +1,353 @@
|
||||
// src/views/Community/components/DynamicNewsCard/VirtualizedFourRowGrid.js
|
||||
// 虚拟化网格组件(支持多列布局 + 纵向滚动 + 无限滚动)
|
||||
|
||||
import React, { useRef, useMemo, useEffect } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Box, Grid, Spinner, Text, VStack, Center, HStack, IconButton } from '@chakra-ui/react';
|
||||
import { RepeatIcon } from '@chakra-ui/icons';
|
||||
import { useColorModeValue } from '@chakra-ui/react';
|
||||
import DynamicNewsEventCard from '../EventCard/DynamicNewsEventCard';
|
||||
|
||||
/**
|
||||
* 虚拟化网格组件(支持多列布局 + 无限滚动)
|
||||
* @param {Object} props
|
||||
* @param {string} props.display - CSS display 属性(用于显示/隐藏组件)
|
||||
* @param {Array} props.events - 事件列表(累积显示)
|
||||
* @param {number} props.columnsPerRow - 每行列数(默认 4,单列模式传 1)
|
||||
* @param {React.Component} props.CardComponent - 卡片组件(默认 DynamicNewsEventCard)
|
||||
* @param {Object} props.selectedEvent - 当前选中的事件
|
||||
* @param {Function} props.onEventSelect - 事件选择回调
|
||||
* @param {Object} props.eventFollowStatus - 事件关注状态
|
||||
* @param {Function} props.onToggleFollow - 关注切换回调
|
||||
* @param {Function} props.getTimelineBoxStyle - 时间轴样式获取函数
|
||||
* @param {string} props.borderColor - 边框颜色
|
||||
* @param {Function} props.loadNextPage - 加载下一页(无限滚动)
|
||||
* @param {boolean} props.hasMore - 是否还有更多数据
|
||||
* @param {boolean} props.loading - 加载状态
|
||||
*/
|
||||
const VirtualizedFourRowGrid = ({
|
||||
display = 'block',
|
||||
events,
|
||||
columnsPerRow = 4,
|
||||
CardComponent = DynamicNewsEventCard,
|
||||
selectedEvent,
|
||||
onEventSelect,
|
||||
eventFollowStatus,
|
||||
onToggleFollow,
|
||||
getTimelineBoxStyle,
|
||||
borderColor,
|
||||
loadNextPage,
|
||||
onRefreshFirstPage, // 修改:顶部刷新回调(替代 loadPrevPage)
|
||||
hasMore,
|
||||
loading,
|
||||
error, // 新增:错误状态
|
||||
onRetry, // 新增:重试回调
|
||||
}) => {
|
||||
const parentRef = useRef(null);
|
||||
const isLoadingMore = useRef(false); // 防止重复加载
|
||||
const lastRefreshTime = useRef(0); // 记录上次刷新时间(用于30秒防抖)
|
||||
|
||||
// 滚动条颜色(主题适配)
|
||||
const scrollbarTrackBg = useColorModeValue('#f1f1f1', '#2D3748');
|
||||
const scrollbarThumbBg = useColorModeValue('#888', '#4A5568');
|
||||
const scrollbarThumbHoverBg = useColorModeValue('#555', '#718096');
|
||||
|
||||
// 将事件按 columnsPerRow 个一组分成行
|
||||
const rows = useMemo(() => {
|
||||
const r = [];
|
||||
for (let i = 0; i < events.length; i += columnsPerRow) {
|
||||
r.push(events.slice(i, i + columnsPerRow));
|
||||
}
|
||||
return r;
|
||||
}, [events, columnsPerRow]);
|
||||
|
||||
// 配置虚拟滚动器(纵向滚动 + 动态高度测量)
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 250, // 提供初始估算值,库会自动测量实际高度
|
||||
overscan: 2, // 预加载2行(上下各1行)
|
||||
});
|
||||
|
||||
/**
|
||||
* 【核心逻辑1】无限滚动 + 顶部刷新 - 监听滚动事件,根据滚动位置自动加载数据或刷新
|
||||
*
|
||||
* 工作原理:
|
||||
* 1. 向下滚动到 60% 位置时,触发 loadNextPage()
|
||||
* - 调用 usePagination.loadNextPage()
|
||||
* - 内部执行 handlePageChange(currentPage + 1)
|
||||
* - dispatch(fetchDynamicNews({ page: nextPage }))
|
||||
* - 后端返回下一页数据(30条)
|
||||
* - Redux 去重后追加到 fourRowEvents 数组
|
||||
* - events prop 更新,虚拟滚动自动渲染新内容
|
||||
*
|
||||
* 2. 向上滚动到顶部 10% 以内时,触发 onRefreshFirstPage()
|
||||
* - 清空缓存 + 重新加载第一页(获取最新数据)
|
||||
* - 30秒防抖:避免频繁刷新
|
||||
* - 与5分钟定时刷新协同工作
|
||||
*
|
||||
* 设计要点:
|
||||
* - 60% 触发点:提前加载,避免滚动到底部时才出现加载状态
|
||||
* - 防抖机制:isLoadingMore.current 防止重复触发
|
||||
* - 两层缓存:
|
||||
* - Redux 缓存(HTTP层):fourRowEvents 数组存储已加载数据,避免重复请求
|
||||
* - 虚拟滚动缓存(渲染层):@tanstack/react-virtual 只渲染可见行,复用 DOM 节点
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 如果组件被隐藏,不执行滚动监听
|
||||
if (display === 'none') return;
|
||||
|
||||
const scrollElement = parentRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
const handleScroll = async () => {
|
||||
// 防止重复触发
|
||||
if (isLoadingMore.current || loading) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||||
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||||
|
||||
// 向下滚动:滚动到 60% 时开始加载下一页
|
||||
if (loadNextPage && hasMore && scrollPercentage > 0.6) {
|
||||
console.log('%c📜 [无限滚动] 到达底部,加载下一页', 'color: #8B5CF6; font-weight: bold;');
|
||||
isLoadingMore.current = true;
|
||||
await loadNextPage();
|
||||
isLoadingMore.current = false;
|
||||
}
|
||||
|
||||
// 向上滚动到顶部:触发刷新(30秒防抖)
|
||||
if (onRefreshFirstPage && scrollTop < clientHeight * 0.1) {
|
||||
const now = Date.now();
|
||||
const timeSinceLastRefresh = now - lastRefreshTime.current;
|
||||
|
||||
// 30秒防抖:避免频繁刷新
|
||||
if (timeSinceLastRefresh >= 30000) {
|
||||
console.log('%c🔄 [顶部刷新] 滚动到顶部,清空缓存并重新加载第一页', 'color: #10B981; font-weight: bold;', {
|
||||
timeSinceLastRefresh: `${(timeSinceLastRefresh / 1000).toFixed(1)}秒`
|
||||
});
|
||||
isLoadingMore.current = true;
|
||||
lastRefreshTime.current = now;
|
||||
|
||||
await onRefreshFirstPage();
|
||||
isLoadingMore.current = false;
|
||||
} else {
|
||||
const remainingTime = Math.ceil((30000 - timeSinceLastRefresh) / 1000);
|
||||
console.log('%c🔄 [顶部刷新] 防抖中,请等待', 'color: #EAB308; font-weight: bold;', {
|
||||
remainingTime: `${remainingTime}秒`
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
scrollElement.addEventListener('scroll', handleScroll);
|
||||
return () => scrollElement.removeEventListener('scroll', handleScroll);
|
||||
}, [display, loadNextPage, onRefreshFirstPage, hasMore, loading]);
|
||||
|
||||
/**
|
||||
* 【核心逻辑2】主动检测内容高度 - 确保内容始终填满容器
|
||||
*
|
||||
* 场景:
|
||||
* - 初次加载时,如果 30 条数据不足以填满 800px 容器(例如显示器很大)
|
||||
* - 用户无法滚动,也就无法触发上面的滚动监听逻辑
|
||||
*
|
||||
* 解决方案:
|
||||
* - 定时检查 scrollHeight 是否小于等于 clientHeight
|
||||
* - 如果内容不足,主动调用 loadNextPage() 加载更多数据
|
||||
* - 递归触发,直到内容高度超过容器高度(出现滚动条)
|
||||
*
|
||||
* 优化:
|
||||
* - 500ms 延迟:确保虚拟滚动已完成首次渲染和高度测量
|
||||
* - 监听 events.length 变化:新数据加载后重新检查
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 如果组件被隐藏,不执行高度检测
|
||||
if (display === 'none') return;
|
||||
|
||||
const scrollElement = parentRef.current;
|
||||
if (!scrollElement || !loadNextPage) return;
|
||||
|
||||
// 延迟检查,确保虚拟滚动已渲染
|
||||
const timer = setTimeout(() => {
|
||||
// 防止重复触发
|
||||
if (isLoadingMore.current || !hasMore || loading) return;
|
||||
|
||||
const { scrollHeight, clientHeight } = scrollElement;
|
||||
|
||||
// 如果内容高度不足以填满容器(没有滚动条),主动加载下一页
|
||||
if (scrollHeight <= clientHeight) {
|
||||
console.log('%c📜 [无限滚动] 内容不足以填满容器,主动加载下一页', 'color: #8B5CF6; font-weight: bold;', {
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
eventsCount: events.length
|
||||
});
|
||||
isLoadingMore.current = true;
|
||||
loadNextPage().finally(() => {
|
||||
isLoadingMore.current = false;
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [display, events.length, hasMore, loading, loadNextPage]);
|
||||
|
||||
// 错误指示器(同行显示)
|
||||
const renderErrorIndicator = () => {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<Center py={6}>
|
||||
<HStack spacing={2}>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
数据加载失败,
|
||||
</Text>
|
||||
<IconButton
|
||||
icon={<RepeatIcon />}
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
variant="ghost"
|
||||
onClick={onRetry}
|
||||
aria-label="刷新"
|
||||
/>
|
||||
<Text
|
||||
color="blue.500"
|
||||
fontSize="sm"
|
||||
fontWeight="medium"
|
||||
cursor="pointer"
|
||||
onClick={onRetry}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
刷新
|
||||
</Text>
|
||||
</HStack>
|
||||
</Center>
|
||||
);
|
||||
};
|
||||
|
||||
// 底部加载指示器
|
||||
const renderLoadingIndicator = () => {
|
||||
if (!hasMore) {
|
||||
return (
|
||||
<Center py={6}>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
已加载全部内容
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<Center py={6}>
|
||||
<VStack spacing={2}>
|
||||
<Spinner size="md" color="blue.500" thickness="3px" />
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
加载中...
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={parentRef}
|
||||
display={display}
|
||||
overflowY="auto"
|
||||
overflowX="hidden"
|
||||
minH="800px"
|
||||
maxH="800px"
|
||||
w="100%"
|
||||
position="relative"
|
||||
css={{
|
||||
// 滚动条样式
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '4px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: scrollbarTrackBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: scrollbarThumbBg,
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: scrollbarThumbHoverBg,
|
||||
},
|
||||
scrollBehavior: 'smooth',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{/* 虚拟滚动容器 + 底部加载指示器 */}
|
||||
<Box position="relative" w="100%">
|
||||
{/* 虚拟滚动内容 */}
|
||||
<Box
|
||||
position="relative"
|
||||
w="100%"
|
||||
h={`${rowVirtualizer.getTotalSize()}px`}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const rowEvents = rows[virtualRow.index];
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
w="100%"
|
||||
transform={`translateY(${virtualRow.start}px)`}
|
||||
>
|
||||
{/* 使用 Grid 横向排列卡片(列数由 columnsPerRow 决定) */}
|
||||
<Grid
|
||||
templateColumns={`repeat(${columnsPerRow}, 1fr)`}
|
||||
gap={columnsPerRow === 1 ? 3 : 4}
|
||||
w="100%"
|
||||
>
|
||||
{rowEvents.map((event, colIndex) => (
|
||||
<Box key={event.id} w="100%" minW={0}>
|
||||
<CardComponent
|
||||
event={event}
|
||||
index={virtualRow.index * columnsPerRow + colIndex}
|
||||
isFollowing={eventFollowStatus[event.id]?.isFollowing || false}
|
||||
followerCount={eventFollowStatus[event.id]?.followerCount || event.follower_count || 0}
|
||||
isSelected={selectedEvent?.id === event.id}
|
||||
onEventClick={(clickedEvent) => {
|
||||
onEventSelect(clickedEvent);
|
||||
}}
|
||||
onTitleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEventSelect(event);
|
||||
}}
|
||||
onToggleFollow={() => onToggleFollow?.(event.id)}
|
||||
timelineStyle={getTimelineBoxStyle?.()}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
{/* 底部加载指示器 - 绝对定位在虚拟内容底部 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top={`${rowVirtualizer.getTotalSize()}px`}
|
||||
left={0}
|
||||
right={0}
|
||||
w="100%"
|
||||
>
|
||||
{error ? renderErrorIndicator() : renderLoadingIndicator()}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualizedFourRowGrid;
|
||||
40
src/views/Community/components/DynamicNewsCard/constants.js
Normal file
40
src/views/Community/components/DynamicNewsCard/constants.js
Normal file
@@ -0,0 +1,40 @@
|
||||
// src/views/Community/components/DynamicNewsCard/constants.js
|
||||
// 动态新闻卡片组件 - 常量配置
|
||||
|
||||
// ========== 分页配置常量 ==========
|
||||
/**
|
||||
* 分页大小计算依据:
|
||||
*
|
||||
* 【四排模式 (FOUR_ROW_PAGE_SIZE)】
|
||||
* - 容器高度: 800px (VirtualizedFourRowGrid)
|
||||
* - 单行高度: ~250px (包含卡片 + 间距)
|
||||
* - 每行显示: 4 列
|
||||
* - 可视区域: 800px / 250px ≈ 3.2 行
|
||||
* - overscan 缓冲: 2 行 (上下各预渲染1行)
|
||||
* - 实际渲染区域: 3.2 + 2 = 5.2 行
|
||||
* - 单次加载数据量: 7.5 行 × 4 列 = 30 个
|
||||
* - 设计目标: 提供充足的缓冲数据,确保快速滚动时不出现空白
|
||||
*
|
||||
* 【纵向模式 (VERTICAL_PAGE_SIZE)】
|
||||
* - 每页显示 10 条数据
|
||||
* - 使用传统分页器,用户手动翻页
|
||||
*/
|
||||
export const PAGINATION_CONFIG = {
|
||||
FOUR_ROW_PAGE_SIZE: 30, // 平铺模式每页数量 (7.5行 × 4列,包含缓冲)
|
||||
VERTICAL_PAGE_SIZE: 10, // 纵向模式每页数量 (传统分页)
|
||||
INITIAL_PAGE: 1, // 初始页码
|
||||
};
|
||||
|
||||
// ========== 显示模式常量 ==========
|
||||
export const DISPLAY_MODES = {
|
||||
FOUR_ROW: 'four-row', // 平铺网格模式
|
||||
VERTICAL: 'vertical', // 纵向分栏模式
|
||||
};
|
||||
|
||||
export const DEFAULT_MODE = DISPLAY_MODES.VERTICAL;
|
||||
|
||||
// ========== Toast 提示配置 ==========
|
||||
export const TOAST_CONFIG = {
|
||||
DURATION_ERROR: 3000, // 错误提示持续时间(毫秒)
|
||||
DURATION_WARNING: 2000, // 警告提示持续时间(毫秒)
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
// src/views/Community/components/DynamicNewsCard/hooks/useInfiniteScroll.js
|
||||
// 无限滚动 Hook
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* 无限滚动 Hook
|
||||
* 监听容器滚动事件,当滚动到底部附近时触发加载更多数据
|
||||
*
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Function} options.onLoadMore - 加载更多回调函数(返回 Promise)
|
||||
* @param {boolean} options.hasMore - 是否还有更多数据
|
||||
* @param {boolean} options.isLoading - 是否正在加载
|
||||
* @param {number} options.threshold - 触发阈值(距离底部多少像素时触发,默认200px)
|
||||
* @returns {Object} { containerRef } - 容器引用
|
||||
*/
|
||||
export const useInfiniteScroll = ({
|
||||
onLoadMore,
|
||||
hasMore = true,
|
||||
isLoading = false,
|
||||
threshold = 200
|
||||
}) => {
|
||||
const containerRef = useRef(null);
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
// 滚动处理函数
|
||||
const handleScroll = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
|
||||
// 检查条件:容器存在、未加载中、还有更多数据
|
||||
if (!container || isLoadingRef.current || !hasMore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
|
||||
|
||||
// 距离底部小于阈值时触发加载
|
||||
if (distanceToBottom < threshold) {
|
||||
console.log(
|
||||
'%c⬇️ [懒加载] 触发加载下一页',
|
||||
'color: #8B5CF6; font-weight: bold;',
|
||||
{
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
clientHeight,
|
||||
distanceToBottom,
|
||||
threshold
|
||||
}
|
||||
);
|
||||
|
||||
isLoadingRef.current = true;
|
||||
|
||||
// 调用加载函数并更新状态
|
||||
onLoadMore()
|
||||
.then(() => {
|
||||
console.log('%c✅ [懒加载] 加载完成', 'color: #10B981; font-weight: bold;');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('%c❌ [懒加载] 加载失败', 'color: #DC2626; font-weight: bold;', error);
|
||||
})
|
||||
.finally(() => {
|
||||
isLoadingRef.current = false;
|
||||
});
|
||||
}
|
||||
}, [onLoadMore, hasMore, threshold]);
|
||||
|
||||
// 绑定滚动事件
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// 添加滚动监听
|
||||
container.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [handleScroll]);
|
||||
|
||||
// 更新 loading 状态的 ref
|
||||
useEffect(() => {
|
||||
isLoadingRef.current = isLoading;
|
||||
}, [isLoading]);
|
||||
|
||||
return { containerRef };
|
||||
};
|
||||
@@ -0,0 +1,304 @@
|
||||
// src/views/Community/components/DynamicNewsCard/hooks/usePagination.js
|
||||
// 分页逻辑自定义 Hook
|
||||
|
||||
import { useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { fetchDynamicNews, updatePaginationPage } from '../../../../../store/slices/communityDataSlice';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
import {
|
||||
PAGINATION_CONFIG,
|
||||
DISPLAY_MODES,
|
||||
DEFAULT_MODE,
|
||||
TOAST_CONFIG
|
||||
} from '../constants';
|
||||
|
||||
/**
|
||||
* 分页逻辑自定义 Hook
|
||||
* @param {Object} options - Hook 配置选项
|
||||
* @param {Object} options.allCachedEventsByPage - 纵向模式页码映射 { 1: [...], 2: [...] }
|
||||
* @param {Array} options.allCachedEvents - 平铺模式数组 [...]
|
||||
* @param {Object} options.pagination - 分页元数据 { total, total_pages, current_page, per_page, page }
|
||||
* @param {number} options.total - 【废弃】服务端总数量(向后兼容,建议使用 pagination.total)
|
||||
* @param {number} options.cachedCount - 已缓存数量
|
||||
* @param {Function} options.dispatch - Redux dispatch 函数
|
||||
* @param {Function} options.toast - Toast 通知函数
|
||||
* @param {Object} options.filters - 筛选条件
|
||||
* @param {string} options.initialMode - 初始显示模式(可选)
|
||||
* @returns {Object} 分页状态和方法
|
||||
*/
|
||||
export const usePagination = ({
|
||||
allCachedEventsByPage, // 纵向模式:页码映射
|
||||
allCachedEvents, // 平铺模式:数组
|
||||
pagination, // 分页元数据对象
|
||||
total, // 向后兼容
|
||||
cachedCount,
|
||||
dispatch,
|
||||
toast,
|
||||
filters = {},
|
||||
initialMode // 初始显示模式
|
||||
}) => {
|
||||
// 本地状态
|
||||
const [loadingPage, setLoadingPage] = useState(null);
|
||||
const [mode, setMode] = useState(initialMode || DEFAULT_MODE);
|
||||
|
||||
// 【核心改动】从 Redux pagination 派生 currentPage,不再使用本地状态
|
||||
const currentPage = pagination?.current_page || PAGINATION_CONFIG.INITIAL_PAGE;
|
||||
|
||||
// 使用 ref 存储最新的 filters,避免 useCallback 闭包问题
|
||||
// 当 filters 对象引用不变但内容改变时,闭包中的 filters 是旧值
|
||||
const filtersRef = useRef(filters);
|
||||
filtersRef.current = filters;
|
||||
|
||||
// 根据模式决定每页显示数量
|
||||
const pageSize = (() => {
|
||||
switch (mode) {
|
||||
case DISPLAY_MODES.FOUR_ROW:
|
||||
return PAGINATION_CONFIG.FOUR_ROW_PAGE_SIZE;
|
||||
case DISPLAY_MODES.VERTICAL:
|
||||
return PAGINATION_CONFIG.VERTICAL_PAGE_SIZE;
|
||||
default:
|
||||
return PAGINATION_CONFIG.VERTICAL_PAGE_SIZE;
|
||||
}
|
||||
})();
|
||||
|
||||
// 【优化】优先使用后端返回的 total_pages,避免前端重复计算
|
||||
// 向后兼容:如果没有 pagination 对象,则使用 total 计算
|
||||
const totalPages = pagination?.total_pages || Math.ceil((pagination?.total || total || 0) / pageSize) || 1;
|
||||
|
||||
// 检查是否还有更多数据(使用页码判断,不受去重影响)
|
||||
const hasMore = currentPage < totalPages;
|
||||
|
||||
// 从页码映射或数组获取当前页数据
|
||||
const currentPageEvents = useMemo(() => {
|
||||
if (mode === DISPLAY_MODES.VERTICAL) {
|
||||
// 纵向模式:从页码映射获取当前页
|
||||
return allCachedEventsByPage?.[currentPage] || [];
|
||||
} else {
|
||||
// 平铺模式:返回全部累积数据
|
||||
return allCachedEvents || [];
|
||||
}
|
||||
}, [mode, allCachedEventsByPage, allCachedEvents, currentPage]);
|
||||
|
||||
// 当前显示的事件列表
|
||||
const displayEvents = useMemo(() => {
|
||||
if (mode === DISPLAY_MODES.FOUR_ROW) {
|
||||
// 平铺模式:返回全部累积数据
|
||||
return allCachedEvents || [];
|
||||
} else {
|
||||
// 纵向模式:返回当前页数据
|
||||
return currentPageEvents;
|
||||
}
|
||||
}, [mode, allCachedEvents, currentPageEvents]);
|
||||
|
||||
/**
|
||||
* 加载单个页面数据
|
||||
* @param {number} targetPage - 目标页码
|
||||
* @param {boolean} clearCache - 是否清空缓存(第1页专用)
|
||||
* @returns {Promise<boolean>} 是否加载成功
|
||||
*/
|
||||
const loadPage = useCallback(async (targetPage, clearCache = false) => {
|
||||
// 显示 loading 状态
|
||||
setLoadingPage(targetPage);
|
||||
|
||||
try {
|
||||
console.log(`%c🟢 [API请求] 开始加载第${targetPage}页数据`, 'color: #16A34A; font-weight: bold;');
|
||||
console.log(`%c 请求参数: page=${targetPage}, per_page=${pageSize}, mode=${mode}, clearCache=${clearCache}`, 'color: #16A34A;');
|
||||
console.log(`%c 筛选条件:`, 'color: #16A34A;', filtersRef.current);
|
||||
|
||||
logger.debug('DynamicNewsCard', '开始加载页面数据', {
|
||||
targetPage,
|
||||
pageSize,
|
||||
mode,
|
||||
clearCache,
|
||||
filters: filtersRef.current
|
||||
});
|
||||
|
||||
// 🔍 调试:dispatch 前
|
||||
console.log(`%c🔵 [dispatch] 准备调用 fetchDynamicNews`, 'color: #3B82F6; font-weight: bold;', {
|
||||
mode,
|
||||
page: targetPage,
|
||||
per_page: pageSize,
|
||||
pageSize,
|
||||
clearCache,
|
||||
filters: filtersRef.current
|
||||
});
|
||||
|
||||
const result = await dispatch(fetchDynamicNews({
|
||||
mode: mode, // 传递 mode 参数
|
||||
per_page: pageSize,
|
||||
pageSize: pageSize,
|
||||
clearCache: clearCache, // 传递 clearCache 参数
|
||||
...filtersRef.current, // 从 ref 读取最新筛选条件
|
||||
page: targetPage, // 然后覆盖 page 参数(避免被 filters.page 覆盖)
|
||||
})).unwrap();
|
||||
|
||||
// 🔍 调试:dispatch 后
|
||||
console.log(`%c🔵 [dispatch] fetchDynamicNews 返回结果`, 'color: #3B82F6; font-weight: bold;', result);
|
||||
console.log(`%c🟢 [API请求] 第${targetPage}页加载完成`, 'color: #16A34A; font-weight: bold;');
|
||||
logger.debug('DynamicNewsCard', `第 ${targetPage} 页加载完成`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('DynamicNewsCard', 'loadPage', error, {
|
||||
targetPage
|
||||
});
|
||||
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: `无法加载第 ${targetPage} 页数据,请稍后重试`,
|
||||
status: 'error',
|
||||
duration: TOAST_CONFIG.DURATION_ERROR,
|
||||
isClosable: true,
|
||||
position: 'top'
|
||||
});
|
||||
|
||||
return false;
|
||||
} finally {
|
||||
setLoadingPage(null);
|
||||
}
|
||||
}, [dispatch, pageSize, toast, mode]); // 移除 filters 依赖,使用 filtersRef 读取最新值
|
||||
|
||||
// 翻页处理(第1页强制刷新 + 其他页缓存)
|
||||
const handlePageChange = useCallback(async (newPage) => {
|
||||
// 边界检查 1: 检查页码范围
|
||||
if (newPage < 1 || newPage > totalPages) {
|
||||
console.log(`%c⚠️ [翻页] 页码超出范围: ${newPage}`, 'color: #DC2626; font-weight: bold;');
|
||||
logger.warn('usePagination', '页码超出范围', { newPage, totalPages });
|
||||
return;
|
||||
}
|
||||
|
||||
// 边界检查 2: 检查是否重复点击
|
||||
if (newPage === currentPage) {
|
||||
console.log(`%c⚠️ [翻页] 重复点击当前页: ${newPage}`, 'color: #EAB308; font-weight: bold;');
|
||||
logger.debug('usePagination', '页码未改变', { newPage });
|
||||
return;
|
||||
}
|
||||
|
||||
// 边界检查 3: 防止竞态条件 - 只拦截相同页面的重复请求
|
||||
if (loadingPage === newPage) {
|
||||
console.log(`%c⚠️ [翻页] 第${newPage}页正在加载中,忽略重复请求`, 'color: #EAB308; font-weight: bold;');
|
||||
logger.warn('usePagination', '竞态条件:相同页面正在加载', { loadingPage, newPage });
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果正在加载其他页面,允许切换(会取消当前加载状态,开始新的加载)
|
||||
if (loadingPage !== null && loadingPage !== newPage) {
|
||||
console.log(`%c🔄 [翻页] 正在加载第${loadingPage}页,用户切换到第${newPage}页`, 'color: #8B5CF6; font-weight: bold;');
|
||||
logger.info('usePagination', '用户切换页面,继续处理新请求', { loadingPage, newPage });
|
||||
// 继续执行,loadPage 会覆盖 loadingPage 状态
|
||||
}
|
||||
|
||||
console.log(`%c🔵 [翻页逻辑] handlePageChange 开始`, 'color: #3B82F6; font-weight: bold;');
|
||||
console.log(`%c 当前页: ${currentPage}, 目标页: ${newPage}, 模式: ${mode}`, 'color: #3B82F6;');
|
||||
|
||||
// 【核心逻辑】第1页特殊处理:强制清空缓存并重新加载
|
||||
if (newPage === 1) {
|
||||
console.log(`%c🔄 [第1页] 清空缓存并重新加载`, 'color: #8B5CF6; font-weight: bold;');
|
||||
logger.info('usePagination', '第1页:强制刷新', { mode });
|
||||
|
||||
// clearCache = true:API 会更新 Redux pagination.current_page
|
||||
await loadPage(newPage, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 【其他页】检查缓存
|
||||
if (mode === DISPLAY_MODES.VERTICAL) {
|
||||
// 纵向模式:检查页码映射中是否有缓存
|
||||
const isPageCached = allCachedEventsByPage?.[newPage]?.length > 0;
|
||||
|
||||
console.log(`%c🟡 [缓存检查] 第${newPage}页缓存状态`, 'color: #EAB308; font-weight: bold;');
|
||||
console.log(`%c 是否已缓存: ${isPageCached ? '✅ 是' : '❌ 否'}`, `color: ${isPageCached ? '#16A34A' : '#DC2626'};`);
|
||||
|
||||
if (isPageCached) {
|
||||
console.log(`%c✅ [缓存] 第${newPage}页已缓存,直接切换`, 'color: #16A34A; font-weight: bold;');
|
||||
// 使用缓存数据,同步更新 Redux pagination.current_page
|
||||
dispatch(updatePaginationPage({ mode, page: newPage }));
|
||||
} else {
|
||||
console.log(`%c❌ [缓存] 第${newPage}页未缓存,加载数据`, 'color: #DC2626; font-weight: bold;');
|
||||
// clearCache = false:API 会更新 Redux pagination.current_page
|
||||
await loadPage(newPage, false);
|
||||
}
|
||||
} else {
|
||||
// 平铺模式:直接加载新页(追加模式,clearCache=false)
|
||||
console.log(`%c🟡 [平铺模式] 加载第${newPage}页`, 'color: #EAB308; font-weight: bold;');
|
||||
// clearCache = false:API 会更新 Redux pagination.current_page
|
||||
await loadPage(newPage, false);
|
||||
}
|
||||
}, [mode, currentPage, totalPages, loadingPage, allCachedEventsByPage, loadPage]);
|
||||
|
||||
|
||||
// 加载下一页(用于无限滚动)
|
||||
const loadNextPage = useCallback(async () => {
|
||||
// 使用 hasMore 判断(基于 currentPage < totalPages)
|
||||
if (!hasMore || loadingPage !== null) {
|
||||
logger.debug('DynamicNewsCard', '无法加载下一页', {
|
||||
currentPage,
|
||||
totalPages,
|
||||
hasMore,
|
||||
loadingPage,
|
||||
reason: !hasMore ? '已加载全部数据 (currentPage >= totalPages)' : '正在加载中'
|
||||
});
|
||||
return Promise.resolve(false); // 没有更多数据或正在加载
|
||||
}
|
||||
|
||||
const nextPage = currentPage + 1;
|
||||
logger.debug('DynamicNewsCard', '懒加载:加载下一页', { currentPage, nextPage, hasMore, totalPages });
|
||||
|
||||
try {
|
||||
await handlePageChange(nextPage);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('DynamicNewsCard', '懒加载失败', error, { nextPage });
|
||||
return false;
|
||||
}
|
||||
}, [currentPage, totalPages, hasMore, loadingPage, handlePageChange]);
|
||||
|
||||
// 加载上一页(用于双向无限滚动)
|
||||
const loadPrevPage = useCallback(async () => {
|
||||
if (currentPage <= 1 || loadingPage !== null) {
|
||||
logger.debug('DynamicNewsCard', '无法加载上一页', {
|
||||
currentPage,
|
||||
loadingPage,
|
||||
reason: currentPage <= 1 ? '已是第一页' : '正在加载中'
|
||||
});
|
||||
return Promise.resolve(false); // 已经是第一页或正在加载
|
||||
}
|
||||
|
||||
const prevPage = currentPage - 1;
|
||||
logger.debug('DynamicNewsCard', '懒加载:加载上一页', { currentPage, prevPage });
|
||||
|
||||
try {
|
||||
await handlePageChange(prevPage);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('DynamicNewsCard', '懒加载上一页失败', error, { prevPage });
|
||||
return false;
|
||||
}
|
||||
}, [currentPage, loadingPage, handlePageChange]);
|
||||
|
||||
// 模式切换处理(简化版 - 模式切换时始终请求数据,因为两种模式使用独立存储)
|
||||
const handleModeToggle = useCallback((newMode) => {
|
||||
if (newMode === mode) return;
|
||||
|
||||
setMode(newMode);
|
||||
// currentPage 由 Redux pagination.current_page 派生,会在下次请求时自动更新
|
||||
// pageSize 会根据 mode 自动重新计算(第46-56行)
|
||||
}, [mode]);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
currentPage,
|
||||
mode,
|
||||
loadingPage,
|
||||
pageSize,
|
||||
totalPages,
|
||||
hasMore,
|
||||
currentPageEvents,
|
||||
displayEvents, // 当前显示的事件列表
|
||||
|
||||
// 方法
|
||||
handlePageChange,
|
||||
handleModeToggle,
|
||||
loadNextPage, // 加载下一页(用于无限滚动)
|
||||
loadPrevPage // 加载上一页(用于双向无限滚动)
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/CollapsibleHeader.js
|
||||
// 可折叠模块标题组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
HStack,
|
||||
Heading,
|
||||
Badge,
|
||||
IconButton,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
|
||||
/**
|
||||
* 可折叠模块标题组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.title - 标题文本
|
||||
* @param {boolean} props.isOpen - 是否展开
|
||||
* @param {Function} props.onToggle - 切换展开/收起的回调
|
||||
* @param {number} props.count - 可选的数量徽章
|
||||
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件
|
||||
*/
|
||||
const CollapsibleHeader = ({ title, isOpen, onToggle, count = null, subscriptionBadge = null }) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const hoverBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
|
||||
return (
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
cursor="pointer"
|
||||
onClick={onToggle}
|
||||
p={3}
|
||||
bg={sectionBg}
|
||||
borderRadius="md"
|
||||
_hover={{ bg: hoverBg }}
|
||||
transition="background 0.2s"
|
||||
>
|
||||
<HStack spacing={2}>
|
||||
<Heading size="sm" color={headingColor}>
|
||||
{title}
|
||||
</Heading>
|
||||
{subscriptionBadge && (
|
||||
<Box>
|
||||
{subscriptionBadge}
|
||||
</Box>
|
||||
)}
|
||||
{count !== null && count > 0 && (
|
||||
<Badge colorScheme="blue" borderRadius="full">
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon={isOpen ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={isOpen ? '收起' : '展开'}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollapsibleHeader;
|
||||
@@ -0,0 +1,68 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/CollapsibleSection.js
|
||||
// 通用可折叠区块组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Collapse,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import CollapsibleHeader from './CollapsibleHeader';
|
||||
|
||||
/**
|
||||
* 通用可折叠区块组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.title - 标题文本
|
||||
* @param {boolean} props.isOpen - 是否展开
|
||||
* @param {Function} props.onToggle - 切换展开/收起的回调
|
||||
* @param {number} props.count - 可选的数量徽章
|
||||
* @param {React.ReactNode} props.subscriptionBadge - 可选的会员标签组件
|
||||
* @param {boolean} props.isLocked - 是否锁定(不可展开)
|
||||
* @param {Function} props.onLockedClick - 锁定时点击的回调
|
||||
* @param {React.ReactNode} props.children - 子内容
|
||||
*/
|
||||
const CollapsibleSection = ({
|
||||
title,
|
||||
isOpen,
|
||||
onToggle,
|
||||
count = null,
|
||||
subscriptionBadge = null,
|
||||
isLocked = false,
|
||||
onLockedClick = null,
|
||||
children
|
||||
}) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
|
||||
// 处理点击:如果锁定则触发锁定回调,否则触发正常切换
|
||||
const handleToggle = () => {
|
||||
if (isLocked && onLockedClick) {
|
||||
onLockedClick();
|
||||
} else if (!isLocked) {
|
||||
onToggle();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<CollapsibleHeader
|
||||
title={title}
|
||||
isOpen={isOpen}
|
||||
onToggle={handleToggle}
|
||||
count={count}
|
||||
subscriptionBadge={subscriptionBadge}
|
||||
/>
|
||||
<Collapse
|
||||
in={isOpen && !isLocked}
|
||||
animateOpacity
|
||||
unmountOnExit={false}
|
||||
startingHeight={0}
|
||||
>
|
||||
<Box mt={2} bg={sectionBg} p={3} borderRadius="md">
|
||||
{children}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollapsibleSection;
|
||||
@@ -0,0 +1,137 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/CompactStockItem.js
|
||||
// 精简模式股票卡片组件(浮动卡片样式)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 精简模式股票卡片组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.stock - 股票对象
|
||||
* @param {Object} props.quote - 股票行情数据(可选)
|
||||
*/
|
||||
const CompactStockItem = ({ stock, quote = null }) => {
|
||||
const nameColor = useColorModeValue('gray.700', 'gray.300');
|
||||
|
||||
const handleViewDetail = () => {
|
||||
const stockCode = stock.stock_code.split('.')[0];
|
||||
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
|
||||
};
|
||||
|
||||
// 格式化涨跌幅显示
|
||||
const formatChange = (value) => {
|
||||
if (value === null || value === undefined || isNaN(value)) return '--';
|
||||
const prefix = value > 0 ? '+' : '';
|
||||
return `${prefix}${parseFloat(value).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
// 获取涨跌幅颜色(涨红跌绿)
|
||||
const getChangeColor = (value) => {
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num) || num === 0) return 'gray.500';
|
||||
return num > 0 ? 'red.500' : 'green.500';
|
||||
};
|
||||
|
||||
// 获取背景渐变色(涨红跌绿)
|
||||
const getBackgroundGradient = (value) => {
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num) || num === 0) {
|
||||
return 'linear(to-br, gray.50, gray.100)';
|
||||
}
|
||||
return num > 0
|
||||
? 'linear(to-br, red.50, red.100)'
|
||||
: 'linear(to-br, green.50, green.100)';
|
||||
};
|
||||
|
||||
// 获取边框颜色
|
||||
const getBorderColor = (value) => {
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num) || num === 0) return 'gray.300';
|
||||
return num > 0 ? 'red.300' : 'green.300';
|
||||
};
|
||||
|
||||
// 获取涨跌幅数据(优先使用 quote,fallback 到 stock)
|
||||
const change = quote?.change ?? stock.daily_change ?? null;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label={`${stock.stock_name} - 点击查看详情`}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="gray.700"
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
>
|
||||
<Box
|
||||
bgGradient={getBackgroundGradient(change)}
|
||||
borderWidth="3px"
|
||||
borderColor={getBorderColor(change)}
|
||||
borderRadius="2xl"
|
||||
p={4}
|
||||
onClick={handleViewDetail}
|
||||
cursor="pointer"
|
||||
boxShadow="lg"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
_before={{
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '4px',
|
||||
bg: getBorderColor(change),
|
||||
}}
|
||||
_hover={{
|
||||
boxShadow: '2xl',
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
}}
|
||||
transition="all 0.3s ease-in-out"
|
||||
display="inline-block"
|
||||
minW="150px"
|
||||
>
|
||||
{/* 股票代码 */}
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
color={getChangeColor(change)}
|
||||
mb={2}
|
||||
textAlign="center"
|
||||
>
|
||||
{stock.stock_code}
|
||||
</Text>
|
||||
|
||||
{/* 涨跌幅 - 超大号显示 */}
|
||||
<Text
|
||||
fontSize="3xl"
|
||||
fontWeight="black"
|
||||
color={getChangeColor(change)}
|
||||
textAlign="center"
|
||||
lineHeight="1"
|
||||
textShadow="0 1px 2px rgba(0,0,0,0.1)"
|
||||
>
|
||||
{formatChange(change)}
|
||||
</Text>
|
||||
|
||||
{/* 股票名称(小字) */}
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={nameColor}
|
||||
mt={2}
|
||||
textAlign="center"
|
||||
noOfLines={1}
|
||||
fontWeight="medium"
|
||||
>
|
||||
{stock.stock_name}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompactStockItem;
|
||||
@@ -0,0 +1,361 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
|
||||
// 动态新闻详情面板主组件(组装所有子组件)
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import {
|
||||
Card,
|
||||
CardBody,
|
||||
VStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
import { eventService } from '../../../../services/eventService';
|
||||
import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks';
|
||||
import { toggleEventFollow, selectEventFollowStatus } from '../../../../store/slices/communityDataSlice';
|
||||
import { useAuth } from '../../../../contexts/AuthContext';
|
||||
import EventHeaderInfo from './EventHeaderInfo';
|
||||
import EventDescriptionSection from './EventDescriptionSection';
|
||||
import RelatedConceptsSection from './RelatedConceptsSection';
|
||||
import RelatedStocksSection from './RelatedStocksSection';
|
||||
import CollapsibleSection from './CollapsibleSection';
|
||||
import HistoricalEvents from '../../../EventDetail/components/HistoricalEvents';
|
||||
import TransmissionChainAnalysis from '../../../EventDetail/components/TransmissionChainAnalysis';
|
||||
import SubscriptionBadge from '../../../../components/SubscriptionBadge';
|
||||
import SubscriptionUpgradeModal from '../../../../components/SubscriptionUpgradeModal';
|
||||
|
||||
/**
|
||||
* 动态新闻详情面板主组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.event - 事件对象(包含详情数据)
|
||||
*/
|
||||
const DynamicNewsDetailPanel = ({ event }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { user } = useAuth();
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
const toast = useToast();
|
||||
|
||||
// 获取用户会员等级(修复:字段名从 subscription_tier 改为 subscription_type)
|
||||
const userTier = user?.subscription_type || 'free';
|
||||
|
||||
// 从 Redux 读取关注状态
|
||||
const eventFollowStatus = useSelector(selectEventFollowStatus);
|
||||
const isFollowing = event?.id ? (eventFollowStatus[event.id]?.isFollowing || false) : false;
|
||||
const followerCount = event?.id ? (eventFollowStatus[event.id]?.followerCount || event.follower_count || 0) : 0;
|
||||
|
||||
// 权限判断函数
|
||||
const hasAccess = useCallback((requiredTier) => {
|
||||
const tierLevel = { free: 0, pro: 1, max: 2 };
|
||||
const result = tierLevel[userTier] >= tierLevel[requiredTier];
|
||||
return result;
|
||||
}, [userTier]);
|
||||
|
||||
// 升级弹窗状态
|
||||
const [upgradeModal, setUpgradeModal] = useState({
|
||||
isOpen: false,
|
||||
requiredLevel: 'pro',
|
||||
featureName: ''
|
||||
});
|
||||
|
||||
// 使用 Hook 获取实时数据(禁用自动加载,改为手动触发)
|
||||
const {
|
||||
stocks,
|
||||
quotes,
|
||||
eventDetail,
|
||||
historicalEvents,
|
||||
expectationScore,
|
||||
loading,
|
||||
loadStocksData,
|
||||
loadHistoricalData,
|
||||
loadChainAnalysis
|
||||
} = useEventStocks(event?.id, event?.created_at, { autoLoad: false });
|
||||
|
||||
// 相关股票、相关概念、历史事件和传导链的权限
|
||||
const canAccessStocks = hasAccess('pro');
|
||||
const canAccessConcepts = hasAccess('pro');
|
||||
const canAccessHistorical = hasAccess('pro');
|
||||
const canAccessTransmission = hasAccess('max');
|
||||
|
||||
// 子区块折叠状态管理 + 加载追踪
|
||||
// PRO 会员的相关股票默认展开
|
||||
const [isStocksOpen, setIsStocksOpen] = useState(canAccessStocks && userTier === 'pro');
|
||||
const [hasLoadedStocks, setHasLoadedStocks] = useState(false);
|
||||
|
||||
const [isConceptsOpen, setIsConceptsOpen] = useState(false);
|
||||
|
||||
const [isHistoricalOpen, setIsHistoricalOpen] = useState(false);
|
||||
const [hasLoadedHistorical, setHasLoadedHistorical] = useState(false);
|
||||
|
||||
const [isTransmissionOpen, setIsTransmissionOpen] = useState(false);
|
||||
const [hasLoadedTransmission, setHasLoadedTransmission] = useState(false);
|
||||
|
||||
// 自选股管理(使用 localStorage)
|
||||
const [watchlistSet, setWatchlistSet] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('stock_watchlist');
|
||||
return saved ? new Set(JSON.parse(saved)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
|
||||
// 锁定点击处理 - 弹出升级弹窗
|
||||
const handleLockedClick = useCallback((featureName, requiredLevel) => {
|
||||
setUpgradeModal({
|
||||
isOpen: true,
|
||||
requiredLevel,
|
||||
featureName
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 关闭升级弹窗
|
||||
const handleCloseUpgradeModal = useCallback(() => {
|
||||
setUpgradeModal({
|
||||
isOpen: false,
|
||||
requiredLevel: 'pro',
|
||||
featureName: ''
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 相关股票 - 展开时加载(需要 PRO 权限)
|
||||
const handleStocksToggle = useCallback(() => {
|
||||
const newState = !isStocksOpen;
|
||||
setIsStocksOpen(newState);
|
||||
|
||||
if (newState && !hasLoadedStocks) {
|
||||
console.log('%c📊 [相关股票] 首次展开,加载股票数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
|
||||
loadStocksData();
|
||||
setHasLoadedStocks(true);
|
||||
}
|
||||
}, [isStocksOpen, hasLoadedStocks, loadStocksData, event?.id]);
|
||||
|
||||
// 相关概念 - 展开/收起(无需加载)
|
||||
const handleConceptsToggle = useCallback(() => {
|
||||
setIsConceptsOpen(!isConceptsOpen);
|
||||
}, [isConceptsOpen]);
|
||||
|
||||
// 历史事件对比 - 展开时加载
|
||||
const handleHistoricalToggle = useCallback(() => {
|
||||
const newState = !isHistoricalOpen;
|
||||
setIsHistoricalOpen(newState);
|
||||
|
||||
if (newState && !hasLoadedHistorical) {
|
||||
console.log('%c📜 [历史事件] 首次展开,加载历史事件数据', 'color: #3B82F6; font-weight: bold;', { eventId: event?.id });
|
||||
loadHistoricalData();
|
||||
setHasLoadedHistorical(true);
|
||||
}
|
||||
}, [isHistoricalOpen, hasLoadedHistorical, loadHistoricalData, event?.id]);
|
||||
|
||||
// 传导链分析 - 展开时加载
|
||||
const handleTransmissionToggle = useCallback(() => {
|
||||
const newState = !isTransmissionOpen;
|
||||
setIsTransmissionOpen(newState);
|
||||
|
||||
if (newState && !hasLoadedTransmission) {
|
||||
console.log('%c🔗 [传导链] 首次展开,加载传导链数据', 'color: #8B5CF6; font-weight: bold;', { eventId: event?.id });
|
||||
loadChainAnalysis();
|
||||
setHasLoadedTransmission(true);
|
||||
}
|
||||
}, [isTransmissionOpen, hasLoadedTransmission, loadChainAnalysis, event?.id]);
|
||||
|
||||
// 事件切换时重置所有子模块状态
|
||||
useEffect(() => {
|
||||
console.log('%c🔄 [事件切换] 重置所有子模块状态', 'color: #F59E0B; font-weight: bold;', { eventId: event?.id });
|
||||
|
||||
// PRO 会员的相关股票默认展开,其他情况收起
|
||||
const shouldOpenStocks = canAccessStocks && userTier === 'pro';
|
||||
setIsStocksOpen(shouldOpenStocks);
|
||||
setHasLoadedStocks(false);
|
||||
|
||||
// PRO 会员默认展开时,自动加载股票数据
|
||||
if (shouldOpenStocks) {
|
||||
console.log('%c📊 [PRO会员] 自动加载相关股票数据', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
|
||||
loadStocksData();
|
||||
setHasLoadedStocks(true);
|
||||
}
|
||||
|
||||
setIsConceptsOpen(false);
|
||||
setIsHistoricalOpen(false);
|
||||
setHasLoadedHistorical(false);
|
||||
setIsTransmissionOpen(false);
|
||||
setHasLoadedTransmission(false);
|
||||
}, [event?.id, canAccessStocks, userTier, loadStocksData]);
|
||||
|
||||
// 切换关注状态
|
||||
const handleToggleFollow = useCallback(async () => {
|
||||
if (!event?.id) return;
|
||||
dispatch(toggleEventFollow(event.id));
|
||||
}, [dispatch, event?.id]);
|
||||
|
||||
// 切换自选股
|
||||
const handleWatchlistToggle = useCallback(async (stockCode, isInWatchlist) => {
|
||||
try {
|
||||
const newWatchlist = new Set(watchlistSet);
|
||||
|
||||
if (isInWatchlist) {
|
||||
newWatchlist.delete(stockCode);
|
||||
toast({
|
||||
title: '已移除自选股',
|
||||
status: 'info',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
} else {
|
||||
newWatchlist.add(stockCode);
|
||||
toast({
|
||||
title: '已添加至自选股',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
|
||||
setWatchlistSet(newWatchlist);
|
||||
localStorage.setItem('stock_watchlist', JSON.stringify(Array.from(newWatchlist)));
|
||||
} catch (error) {
|
||||
console.error('切换自选股失败:', error);
|
||||
toast({
|
||||
title: '操作失败',
|
||||
description: error.message,
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}, [watchlistSet, toast]);
|
||||
|
||||
// 空状态
|
||||
if (!event) {
|
||||
return (
|
||||
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
|
||||
<CardBody>
|
||||
<Text color={textColor} textAlign="center">
|
||||
请选择一个事件查看详情
|
||||
</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
|
||||
return (
|
||||
<Card bg={cardBg} borderColor={borderColor} borderWidth="1px">
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 头部信息区 */}
|
||||
<EventHeaderInfo
|
||||
event={event}
|
||||
importance={importance}
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggleFollow={handleToggleFollow}
|
||||
/>
|
||||
|
||||
{/* 事件描述 */}
|
||||
<EventDescriptionSection description={event.description} />
|
||||
|
||||
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 */}
|
||||
<CollapsibleSection
|
||||
title="相关股票"
|
||||
isOpen={isStocksOpen}
|
||||
onToggle={handleStocksToggle}
|
||||
count={stocks?.length || 0}
|
||||
subscriptionBadge={(() => {
|
||||
if (!canAccessStocks) {
|
||||
return <SubscriptionBadge tier="pro" size="sm" />;
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
isLocked={!canAccessStocks}
|
||||
onLockedClick={() => handleLockedClick('相关股票', 'pro')}
|
||||
>
|
||||
{loading.stocks || loading.quotes ? (
|
||||
<Center py={4}>
|
||||
<Spinner size="md" color="blue.500" />
|
||||
<Text ml={2} color={textColor}>加载股票数据中...</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<RelatedStocksSection
|
||||
stocks={stocks}
|
||||
quotes={quotes}
|
||||
eventTime={event.created_at}
|
||||
watchlistSet={watchlistSet}
|
||||
onWatchlistToggle={handleWatchlistToggle}
|
||||
/>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 相关概念(可折叠) - 需要 PRO 权限 */}
|
||||
<RelatedConceptsSection
|
||||
eventTitle={event.title}
|
||||
effectiveTradingDate={event.trading_date || event.created_at}
|
||||
eventTime={event.created_at}
|
||||
isOpen={isConceptsOpen}
|
||||
onToggle={handleConceptsToggle}
|
||||
subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
||||
isLocked={!canAccessConcepts}
|
||||
onLockedClick={() => handleLockedClick('相关概念', 'pro')}
|
||||
/>
|
||||
|
||||
{/* 历史事件对比(可折叠) - 懒加载 - 需要 PRO 权限 */}
|
||||
<CollapsibleSection
|
||||
title="历史事件对比"
|
||||
isOpen={isHistoricalOpen}
|
||||
onToggle={handleHistoricalToggle}
|
||||
count={historicalEvents?.length || 0}
|
||||
subscriptionBadge={!canAccessHistorical ? <SubscriptionBadge tier="pro" size="sm" /> : null}
|
||||
isLocked={!canAccessHistorical}
|
||||
onLockedClick={() => handleLockedClick('历史事件对比', 'pro')}
|
||||
>
|
||||
{loading.historicalEvents ? (
|
||||
<Center py={4}>
|
||||
<Spinner size="sm" color="blue.500" />
|
||||
<Text ml={2} color={textColor} fontSize="sm">加载历史事件...</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<HistoricalEvents
|
||||
events={historicalEvents || []}
|
||||
expectationScore={expectationScore}
|
||||
/>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* 传导链分析(可折叠) - 懒加载 - 需要 MAX 权限 */}
|
||||
<CollapsibleSection
|
||||
title="传导链分析"
|
||||
isOpen={isTransmissionOpen}
|
||||
onToggle={handleTransmissionToggle}
|
||||
subscriptionBadge={!canAccessTransmission ? <SubscriptionBadge tier="max" size="sm" /> : null}
|
||||
isLocked={!canAccessTransmission}
|
||||
onLockedClick={() => handleLockedClick('传导链分析', 'max')}
|
||||
>
|
||||
<TransmissionChainAnalysis
|
||||
eventId={event.id}
|
||||
eventService={eventService}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
|
||||
{/* 升级弹窗 */}
|
||||
{upgradeModal.isOpen ? (
|
||||
<SubscriptionUpgradeModal
|
||||
isOpen={upgradeModal.isOpen}
|
||||
onClose={handleCloseUpgradeModal}
|
||||
requiredLevel={upgradeModal.requiredLevel}
|
||||
featureName={upgradeModal.featureName}
|
||||
currentLevel={userTier}
|
||||
/>
|
||||
): null }
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicNewsDetailPanel;
|
||||
@@ -0,0 +1,42 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/EventDescriptionSection.js
|
||||
// 事件描述区组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 事件描述区组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.description - 事件描述文本
|
||||
*/
|
||||
const EventDescriptionSection = ({ description }) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
// 如果没有描述,不渲染
|
||||
if (!description) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box bg={sectionBg} p={3} borderRadius="md">
|
||||
{/* 事件描述 */}
|
||||
<Box>
|
||||
<Heading size="sm" color={headingColor} mb={2}>
|
||||
事件描述
|
||||
</Heading>
|
||||
<Text fontSize="sm" color={textColor} lineHeight="tall">
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDescriptionSection;
|
||||
@@ -0,0 +1,142 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/EventHeaderInfo.js
|
||||
// 事件头部信息区组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ViewIcon } from '@chakra-ui/icons';
|
||||
import moment from 'moment';
|
||||
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
|
||||
import EventFollowButton from '../EventCard/EventFollowButton';
|
||||
|
||||
/**
|
||||
* 事件头部信息区组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.event - 事件对象
|
||||
* @param {Object} props.importance - 重要性配置对象(包含 level, color 等)
|
||||
* @param {boolean} props.isFollowing - 是否已关注
|
||||
* @param {number} props.followerCount - 关注数
|
||||
* @param {Function} props.onToggleFollow - 切换关注回调
|
||||
*/
|
||||
const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onToggleFollow }) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
|
||||
// 获取重要性文本
|
||||
const getImportanceText = () => {
|
||||
const levelMap = {
|
||||
'S': '极高',
|
||||
'A': '高',
|
||||
'B': '中',
|
||||
'C': '低'
|
||||
};
|
||||
return levelMap[importance.level] || '中';
|
||||
};
|
||||
|
||||
// 格式化涨跌幅数字
|
||||
const formatChange = (value) => {
|
||||
if (value === null || value === undefined) return '--';
|
||||
const prefix = value > 0 ? '+' : '';
|
||||
return `${prefix}${value.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={sectionBg} p={3} borderRadius="md" position="relative">
|
||||
{/* 粉色圆角标签(左上角绝对定位) */}
|
||||
{event.related_avg_chg !== null && event.related_avg_chg !== undefined && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-8px"
|
||||
left="-8px"
|
||||
bg="pink.500"
|
||||
color="white"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
boxShadow="md"
|
||||
zIndex={1}
|
||||
>
|
||||
{formatChange(event.related_avg_chg)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 第一行:标题 + 关注按钮 */}
|
||||
<Flex align="center" justify="space-between" mb={3} gap={4}>
|
||||
{/* 标题 */}
|
||||
<Heading size="md" color={headingColor} flex={1}>
|
||||
{event.title}
|
||||
</Heading>
|
||||
|
||||
{/* 关注按钮 */}
|
||||
<EventFollowButton
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggle={onToggleFollow}
|
||||
size="sm"
|
||||
showCount={true}
|
||||
/>
|
||||
</Flex>
|
||||
{/* 第二行:浏览数 + 日期 */}
|
||||
<Flex align="left" mb={3} gap={4}>
|
||||
{/* 浏览数 */}
|
||||
<HStack spacing={1}>
|
||||
<ViewIcon color="gray.400" boxSize={4} />
|
||||
<Text fontSize="sm" color="gray.400" whiteSpace="nowrap">
|
||||
{(event.view_count || 0).toLocaleString()}次浏览
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 日期 */}
|
||||
<Text fontSize="sm" color="red.500" fontWeight="medium" whiteSpace="nowrap">
|
||||
{moment(event.created_at).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* 第三行:涨跌幅指标 + 重要性徽章 */}
|
||||
<HStack spacing={3} align="center">
|
||||
<Box maxW="500px">
|
||||
<StockChangeIndicators
|
||||
avgChange={event.related_avg_chg}
|
||||
maxChange={event.related_max_chg}
|
||||
weekChange={event.related_week_chg}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 重要性徽章 - 使用渐变色和图标 */}
|
||||
<Badge
|
||||
px={4}
|
||||
py={2}
|
||||
borderRadius="full"
|
||||
fontSize="md"
|
||||
fontWeight="bold"
|
||||
bgGradient={
|
||||
importance.level === 'S' ? 'linear(to-r, red.500, red.700)' :
|
||||
importance.level === 'A' ? 'linear(to-r, orange.500, orange.700)' :
|
||||
importance.level === 'B' ? 'linear(to-r, blue.500, blue.700)' :
|
||||
'linear(to-r, gray.500, gray.700)'
|
||||
}
|
||||
color="white"
|
||||
boxShadow="lg"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
>
|
||||
<Icon as={importance.icon} boxSize={5} />
|
||||
<Text>重要性:{getImportanceText()}</Text>
|
||||
</Badge>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventHeaderInfo;
|
||||
@@ -0,0 +1,184 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
fetchKlineData,
|
||||
getCacheKey,
|
||||
klineDataCache
|
||||
} from '../StockDetailPanel/utils/klineDataCache';
|
||||
|
||||
/**
|
||||
* 迷你K线图组件
|
||||
* 显示股票的K线走势(蜡烛图),支持事件时间标记
|
||||
*
|
||||
* @param {string} stockCode - 股票代码
|
||||
* @param {string} eventTime - 事件时间(可选)
|
||||
* @param {Function} onClick - 点击回调(可选)
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime, onClick }) {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
const loadedRef = useRef(false);
|
||||
const dataFetchedRef = useRef(false);
|
||||
|
||||
// 稳定的事件时间
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stockCode) {
|
||||
setData([]);
|
||||
loadedRef.current = false;
|
||||
dataFetchedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataFetchedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查缓存(K线图使用 'daily' 类型)
|
||||
const cacheKey = getCacheKey(stockCode, stableEventTime, 'daily');
|
||||
const cachedData = klineDataCache.get(cacheKey);
|
||||
|
||||
if (cachedData && cachedData.length > 0) {
|
||||
setData(cachedData);
|
||||
loadedRef.current = true;
|
||||
dataFetchedRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
dataFetchedRef.current = true;
|
||||
setLoading(true);
|
||||
|
||||
// 获取日K线数据
|
||||
fetchKlineData(stockCode, stableEventTime, 'daily')
|
||||
.then((result) => {
|
||||
if (mountedRef.current) {
|
||||
setData(result);
|
||||
setLoading(false);
|
||||
loadedRef.current = true;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (mountedRef.current) {
|
||||
setData([]);
|
||||
setLoading(false);
|
||||
loadedRef.current = true;
|
||||
}
|
||||
});
|
||||
}, [stockCode, stableEventTime]);
|
||||
|
||||
const chartOption = useMemo(() => {
|
||||
// 提取K线数据 [open, close, low, high]
|
||||
const klineData = data
|
||||
.filter(item => item.open && item.close && item.low && item.high)
|
||||
.map(item => [item.open, item.close, item.low, item.high]);
|
||||
|
||||
// 日K线使用 date 字段
|
||||
const dates = data.map(item => item.date || item.time);
|
||||
const hasData = klineData.length > 0;
|
||||
|
||||
if (!hasData) {
|
||||
return {
|
||||
title: {
|
||||
text: loading ? '加载中...' : '无数据',
|
||||
left: 'center',
|
||||
top: 'middle',
|
||||
textStyle: { color: '#999', fontSize: 10 }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 计算事件时间标记
|
||||
let eventMarkLineData = [];
|
||||
if (stableEventTime && Array.isArray(dates) && dates.length > 0) {
|
||||
try {
|
||||
const eventDate = moment(stableEventTime).format('YYYY-MM-DD');
|
||||
const eventIdx = dates.findIndex(d => {
|
||||
const dateStr = typeof d === 'object' ? moment(d).format('YYYY-MM-DD') : String(d);
|
||||
return dateStr.includes(eventDate);
|
||||
});
|
||||
|
||||
if (eventIdx >= 0) {
|
||||
eventMarkLineData.push({
|
||||
xAxis: eventIdx,
|
||||
lineStyle: { color: '#FFD700', type: 'solid', width: 1.5 },
|
||||
label: { show: false }
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略异常
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
grid: { left: 2, right: 2, top: 2, bottom: 2, containLabel: false },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
show: false,
|
||||
boundaryGap: true
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false,
|
||||
scale: true
|
||||
},
|
||||
series: [{
|
||||
type: 'candlestick',
|
||||
data: klineData,
|
||||
itemStyle: {
|
||||
color: '#ef5350', // 涨(阳线)
|
||||
color0: '#26a69a', // 跌(阴线)
|
||||
borderColor: '#ef5350', // 涨(边框)
|
||||
borderColor0: '#26a69a' // 跌(边框)
|
||||
},
|
||||
barWidth: '60%',
|
||||
markLine: {
|
||||
silent: true,
|
||||
symbol: 'none',
|
||||
label: { show: false },
|
||||
data: eventMarkLineData
|
||||
}
|
||||
}],
|
||||
tooltip: { show: false },
|
||||
animation: false
|
||||
};
|
||||
}, [data, loading, stableEventTime]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 30,
|
||||
cursor: onClick ? 'pointer' : 'default'
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<ReactECharts
|
||||
option={chartOption}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
notMerge={true}
|
||||
lazyUpdate={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
return prevProps.stockCode === nextProps.stockCode &&
|
||||
prevProps.eventTime === nextProps.eventTime &&
|
||||
prevProps.onClick === nextProps.onClick;
|
||||
});
|
||||
|
||||
export default MiniKLineChart;
|
||||
@@ -0,0 +1,94 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/MiniLineChart.js
|
||||
// Mini 折线图组件(用于股票卡片)
|
||||
|
||||
import React from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* Mini 折线图组件
|
||||
* @param {Object} props
|
||||
* @param {Array<number>} props.data - 价格走势数据数组(15个数据点:前5+中5+后5)
|
||||
* @param {number} props.width - 图表宽度(默认180)
|
||||
* @param {number} props.height - 图表高度(默认60)
|
||||
*/
|
||||
const MiniLineChart = ({ data = [], width = 180, height = 60 }) => {
|
||||
if (!data || data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算最大值和最小值,用于归一化
|
||||
const max = Math.max(...data);
|
||||
const min = Math.min(...data);
|
||||
const range = max - min || 1; // 防止除以0
|
||||
|
||||
// 将数据点转换为 SVG 路径坐标
|
||||
const points = data.map((value, index) => {
|
||||
const x = (index / (data.length - 1)) * width;
|
||||
const y = height - ((value - min) / range) * height;
|
||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
||||
});
|
||||
|
||||
// 构建 SVG 路径字符串
|
||||
const pathD = `M ${points.join(' L ')}`;
|
||||
|
||||
// 判断整体趋势(比较第一个和最后一个值)
|
||||
const isPositive = data[data.length - 1] >= data[0];
|
||||
const strokeColor = isPositive ? '#48BB78' : '#F56565'; // 绿色上涨,红色下跌
|
||||
|
||||
// 创建渐变填充区域路径
|
||||
const fillPathD = `${pathD} L ${width},${height} L 0,${height} Z`;
|
||||
|
||||
return (
|
||||
<Box width={`${width}px`} height={`${height}px`}>
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
|
||||
<defs>
|
||||
<linearGradient id={`gradient-${isPositive ? 'up' : 'down'}`} x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={strokeColor} stopOpacity="0.05" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
{/* 填充区域 */}
|
||||
<path
|
||||
d={fillPathD}
|
||||
fill={`url(#gradient-${isPositive ? 'up' : 'down'})`}
|
||||
/>
|
||||
|
||||
{/* 折线 */}
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke={strokeColor}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* 垂直分隔线(标记三个时间段) */}
|
||||
{/* 前一天和当天之间 */}
|
||||
<line
|
||||
x1={width / 3}
|
||||
y1={0}
|
||||
x2={width / 3}
|
||||
y2={height}
|
||||
stroke="#E2E8F0"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
|
||||
{/* 当天和后一天之间 */}
|
||||
<line
|
||||
x1={(width * 2) / 3}
|
||||
y1={0}
|
||||
x2={(width * 2) / 3}
|
||||
y2={height}
|
||||
stroke="#E2E8F0"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="2,2"
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MiniLineChart;
|
||||
@@ -0,0 +1,79 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/ConceptStockItem.js
|
||||
// 概念股票列表项组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 概念股票列表项组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.stock - 股票对象
|
||||
* - stock_name: 股票名称
|
||||
* - stock_code: 股票代码
|
||||
* - change_pct: 涨跌幅
|
||||
* - reason: 关联原因
|
||||
*/
|
||||
const ConceptStockItem = ({ stock }) => {
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const conceptNameColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
const stockChangePct = parseFloat(stock.change_pct);
|
||||
const stockChangeColor = stockChangePct > 0 ? 'red' : stockChangePct < 0 ? 'green' : 'gray';
|
||||
const stockChangeSymbol = stockChangePct > 0 ? '+' : '';
|
||||
|
||||
// 处理股票详情跳转
|
||||
const handleStockClick = (e) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡到概念卡片
|
||||
const cleanCode = stock.stock_code.replace(/\.(SZ|SH)$/i, '');
|
||||
window.open(`https://valuefrontier.cn/company?scode=${cleanCode}`, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={sectionBg}
|
||||
fontSize="xs"
|
||||
cursor="pointer"
|
||||
onClick={handleStockClick}
|
||||
_hover={{
|
||||
bg: useColorModeValue('gray.100', 'gray.700'),
|
||||
transform: 'translateX(4px)',
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<HStack justify="space-between" mb={1}>
|
||||
<HStack spacing={2}>
|
||||
<Text fontWeight="semibold" color={conceptNameColor}>
|
||||
{stock.stock_name}
|
||||
</Text>
|
||||
<Badge size="sm" variant="outline">
|
||||
{stock.stock_code}
|
||||
</Badge>
|
||||
</HStack>
|
||||
{stock.change_pct && (
|
||||
<Badge
|
||||
colorScheme={stockChangeColor}
|
||||
fontSize="xs"
|
||||
>
|
||||
{stockChangeSymbol}{stockChangePct.toFixed(2)}%
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
{stock.reason && (
|
||||
<Text fontSize="xs" color={stockCountColor} mt={1} noOfLines={2}>
|
||||
{stock.reason}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConceptStockItem;
|
||||
@@ -0,0 +1,190 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/DetailedConceptCard.js
|
||||
// 详细概念卡片组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
HStack,
|
||||
VStack,
|
||||
Text,
|
||||
Badge,
|
||||
Card,
|
||||
CardBody,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import ConceptStockItem from './ConceptStockItem';
|
||||
|
||||
/**
|
||||
* 详细概念卡片组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.concept - 概念对象
|
||||
* - name: 概念名称
|
||||
* - stock_count: 相关股票数量
|
||||
* - relevance: 相关度(0-100)
|
||||
* - avg_change_pct: 平均涨跌幅
|
||||
* - description: 概念描述
|
||||
* - happened_times: 历史触发时间数组
|
||||
* - stocks: 相关股票数组
|
||||
* @param {Function} props.onClick - 点击回调
|
||||
*/
|
||||
const DetailedConceptCard = ({ concept, onClick }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.700');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
// 计算相关度百分比
|
||||
const relevanceScore = Math.round((concept.score || 0) * 100);
|
||||
|
||||
// 计算涨跌幅颜色
|
||||
const changePct = parseFloat(concept.price_info?.avg_change_pct);
|
||||
const changeColor = changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray';
|
||||
const changeSymbol = changePct > 0 ? '+' : '';
|
||||
|
||||
return (
|
||||
<Card
|
||||
bg={cardBg}
|
||||
borderColor={borderColor}
|
||||
borderWidth="2px"
|
||||
cursor="pointer"
|
||||
transition="all 0.3s"
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: 'xl',
|
||||
borderColor: 'blue.400'
|
||||
}}
|
||||
onClick={() => onClick(concept)}
|
||||
>
|
||||
<CardBody p={4}>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{/* 头部信息 */}
|
||||
<HStack justify="space-between" align="flex-start">
|
||||
{/* 左侧:概念名称 + Badge */}
|
||||
<VStack align="start" spacing={2} flex={1}>
|
||||
<Text fontSize="md" fontWeight="bold" color="blue.600">
|
||||
{concept.concept}
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Badge colorScheme="purple" fontSize="xs">
|
||||
相关度: {relevanceScore}%
|
||||
</Badge>
|
||||
<Badge colorScheme="orange" fontSize="xs">
|
||||
{concept.stock_count} 只股票
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 右侧:涨跌幅 */}
|
||||
{concept.price_info?.avg_change_pct && (
|
||||
<Box textAlign="right">
|
||||
<Text fontSize="xs" color={stockCountColor} mb={1}>
|
||||
平均涨跌幅
|
||||
</Text>
|
||||
<Badge
|
||||
size="lg"
|
||||
colorScheme={changeColor}
|
||||
fontSize="md"
|
||||
px={3}
|
||||
py={1}
|
||||
>
|
||||
{changeSymbol}{changePct.toFixed(2)}%
|
||||
</Badge>
|
||||
</Box>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 概念描述 */}
|
||||
{concept.description && (
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={stockCountColor}
|
||||
lineHeight="1.6"
|
||||
noOfLines={3}
|
||||
>
|
||||
{concept.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 历史触发时间 */}
|
||||
{concept.happened_times && concept.happened_times.length > 0 && (
|
||||
<Box>
|
||||
<Text fontSize="xs" fontWeight="semibold" mb={2} color={stockCountColor}>
|
||||
历史触发时间:
|
||||
</Text>
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{concept.happened_times.map((time, idx) => (
|
||||
<Badge key={idx} variant="subtle" colorScheme="gray" fontSize="xs">
|
||||
{time}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 核心相关股票 */}
|
||||
{concept.stocks && concept.stocks.length > 0 && (
|
||||
<Box>
|
||||
<HStack justify="space-between" mb={2}>
|
||||
<Text fontSize="sm" fontWeight="semibold" color={headingColor}>
|
||||
核心相关股票
|
||||
</Text>
|
||||
<Text fontSize="xs" color={stockCountColor}>
|
||||
共 {concept.stock_count} 只
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 可滚动容器 - 默认显示4条,可滚动查看全部 */}
|
||||
<Box
|
||||
maxH="300px"
|
||||
overflowY="auto"
|
||||
pr={2}
|
||||
onWheel={(e) => {
|
||||
const element = e.currentTarget;
|
||||
const scrollTop = element.scrollTop;
|
||||
const scrollHeight = element.scrollHeight;
|
||||
const clientHeight = element.clientHeight;
|
||||
|
||||
// 如果在滚动范围内,阻止事件冒泡到父容器
|
||||
if (
|
||||
(e.deltaY < 0 && scrollTop > 0) || // 向上滚动且未到顶部
|
||||
(e.deltaY > 0 && scrollTop + clientHeight < scrollHeight) // 向下滚动且未到底部
|
||||
) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
css={{
|
||||
overscrollBehavior: 'contain', // 防止滚动链
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '6px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: '#f1f1f1',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#888',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: '#555',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SimpleGrid columns={{ base: 1 }} spacing={2}>
|
||||
{concept.stocks.map((stock, idx) => (
|
||||
<ConceptStockItem key={idx} stock={stock} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DetailedConceptCard;
|
||||
@@ -0,0 +1,103 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/SimpleConceptCard.js
|
||||
// 简单概念卡片组件(横向卡片)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Flex,
|
||||
VStack,
|
||||
Box,
|
||||
Text,
|
||||
Badge,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 简单概念卡片组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.concept - 概念对象
|
||||
* - name: 概念名称
|
||||
* - stock_count: 相关股票数量
|
||||
* - relevance: 相关度(0-100)
|
||||
* @param {Function} props.onClick - 点击回调
|
||||
* @param {Function} props.getRelevanceColor - 获取相关度颜色的函数
|
||||
*/
|
||||
const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.700');
|
||||
const conceptNameColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const borderColor = useColorModeValue('gray.300', 'gray.600');
|
||||
|
||||
const relevanceScore = Math.round((concept.score || 0) * 100);
|
||||
const relevanceColors = getRelevanceColor(relevanceScore);
|
||||
|
||||
// 涨跌幅数据
|
||||
const changePct = concept.price_info?.avg_change_pct ? parseFloat(concept.price_info.avg_change_pct) : null;
|
||||
const changeColor = changePct !== null ? (changePct > 0 ? 'red' : changePct < 0 ? 'green' : 'gray') : null;
|
||||
const changeSymbol = changePct !== null && changePct > 0 ? '+' : '';
|
||||
|
||||
return (
|
||||
<VStack
|
||||
align="stretch"
|
||||
spacing={2}
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
px={4}
|
||||
py={2}
|
||||
cursor="pointer"
|
||||
transition="all 0.2s"
|
||||
minW="200px"
|
||||
_hover={{
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
onClick={() => onClick(concept)}
|
||||
>
|
||||
{/* 第一行:概念名 + 数量(允许折行) */}
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="normal"
|
||||
color={conceptNameColor}
|
||||
wordBreak="break-word"
|
||||
lineHeight="1.4"
|
||||
>
|
||||
{concept.concept}{' '}
|
||||
<Text as="span" color="gray.500">
|
||||
({concept.stock_count})
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
{/* 第二行:相关度 + 涨跌幅 */}
|
||||
<Flex justify="space-between" align="center" gap={2} flexWrap="wrap">
|
||||
{/* 相关度标签 */}
|
||||
<Box
|
||||
bg={relevanceColors.bg}
|
||||
color={relevanceColors.color}
|
||||
px={2}
|
||||
py={0.5}
|
||||
borderRadius="sm"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text fontSize="xs" fontWeight="medium" whiteSpace="nowrap">
|
||||
相关度: {relevanceScore}%
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 涨跌幅数据 */}
|
||||
{changePct !== null && (
|
||||
<Badge
|
||||
colorScheme={changeColor}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={0.5}
|
||||
flexShrink={0}
|
||||
>
|
||||
{changeSymbol}{changePct.toFixed(2)}%
|
||||
</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleConceptCard;
|
||||
@@ -0,0 +1,41 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/TradingDateInfo.js
|
||||
// 交易日期信息提示组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
HStack,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaCalendarAlt } from 'react-icons/fa';
|
||||
import moment from 'moment';
|
||||
|
||||
/**
|
||||
* 交易日期信息提示组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
|
||||
* @param {string|Object} props.eventTime - 事件发生时间
|
||||
*/
|
||||
const TradingDateInfo = ({ effectiveTradingDate, eventTime }) => {
|
||||
const stockCountColor = useColorModeValue('gray.500', 'gray.400');
|
||||
|
||||
if (!effectiveTradingDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack spacing={2}>
|
||||
<FaCalendarAlt color="gray" size={12} />
|
||||
<Text fontSize="xs" color={stockCountColor}>
|
||||
涨跌幅数据:{effectiveTradingDate}
|
||||
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
|
||||
<Text as="span" ml={2} fontSize="xs" color={stockCountColor}>
|
||||
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : moment(eventTime).format('YYYY-MM-DD HH:mm')},显示下一交易日数据)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradingDateInfo;
|
||||
@@ -0,0 +1,281 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/RelatedConceptsSection/index.js
|
||||
// 相关概念区组件(主组件)
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
SimpleGrid,
|
||||
Flex,
|
||||
Button,
|
||||
Collapse,
|
||||
Heading,
|
||||
Center,
|
||||
Spinner,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import SimpleConceptCard from './SimpleConceptCard';
|
||||
import DetailedConceptCard from './DetailedConceptCard';
|
||||
import TradingDateInfo from './TradingDateInfo';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
|
||||
/**
|
||||
* 相关概念区组件
|
||||
* @param {Object} props
|
||||
* @param {string} props.eventTitle - 事件标题(用于搜索概念)
|
||||
* @param {string} props.effectiveTradingDate - 有效交易日期(涨跌幅数据日期)
|
||||
* @param {string|Object} props.eventTime - 事件发生时间
|
||||
* @param {React.ReactNode} props.subscriptionBadge - 订阅徽章组件(可选)
|
||||
* @param {boolean} props.isLocked - 是否锁定详细模式(需要付费)
|
||||
* @param {Function} props.onLockedClick - 锁定时的点击回调(触发付费弹窗)
|
||||
*/
|
||||
const RelatedConceptsSection = ({
|
||||
eventTitle,
|
||||
effectiveTradingDate,
|
||||
eventTime,
|
||||
subscriptionBadge = null,
|
||||
isLocked = false,
|
||||
onLockedClick = null
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [concepts, setConcepts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 颜色配置
|
||||
const sectionBg = useColorModeValue('gray.50', 'gray.750');
|
||||
const headingColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const textColor = useColorModeValue('gray.600', 'gray.400');
|
||||
|
||||
console.log('[RelatedConceptsSection] 组件渲染', {
|
||||
eventTitle,
|
||||
effectiveTradingDate,
|
||||
eventTime,
|
||||
loading,
|
||||
conceptsCount: concepts?.length || 0,
|
||||
error
|
||||
});
|
||||
|
||||
// 搜索相关概念
|
||||
useEffect(() => {
|
||||
const searchConcepts = async () => {
|
||||
console.log('[RelatedConceptsSection] useEffect 触发', {
|
||||
eventTitle,
|
||||
effectiveTradingDate
|
||||
});
|
||||
|
||||
if (!eventTitle || !effectiveTradingDate) {
|
||||
console.log('[RelatedConceptsSection] 缺少必要参数,跳过搜索', {
|
||||
hasEventTitle: !!eventTitle,
|
||||
hasEffectiveTradingDate: !!effectiveTradingDate
|
||||
});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 格式化交易日期 - 统一使用 moment 处理
|
||||
let formattedTradeDate;
|
||||
try {
|
||||
// 不管传入的是什么格式,都用 moment 解析并格式化为 YYYY-MM-DD
|
||||
formattedTradeDate = moment(effectiveTradingDate).format('YYYY-MM-DD');
|
||||
|
||||
// 验证日期是否有效
|
||||
if (!moment(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
|
||||
console.warn('[RelatedConceptsSection] 无效日期,使用当前日期');
|
||||
formattedTradeDate = moment().format('YYYY-MM-DD');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[RelatedConceptsSection] 日期格式化失败,使用当前日期', error);
|
||||
formattedTradeDate = moment().format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
query: eventTitle,
|
||||
size: 5,
|
||||
page: 1,
|
||||
sort_by: "_score",
|
||||
trade_date: formattedTradeDate
|
||||
};
|
||||
|
||||
console.log('[RelatedConceptsSection] 发送请求', {
|
||||
url: '/concept-api/search',
|
||||
requestBody
|
||||
});
|
||||
logger.debug('RelatedConceptsSection', '搜索概念', requestBody);
|
||||
|
||||
const response = await fetch('/concept-api/search', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
console.log('[RelatedConceptsSection] 响应状态', {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('[RelatedConceptsSection] 响应数据', {
|
||||
hasResults: !!data.results,
|
||||
resultsCount: data.results?.length || 0,
|
||||
hasDataConcepts: !!(data.data && data.data.concepts),
|
||||
data: data
|
||||
});
|
||||
logger.debug('RelatedConceptsSection', '概念搜索响应', {
|
||||
hasResults: !!data.results,
|
||||
resultsCount: data.results?.length || 0
|
||||
});
|
||||
|
||||
// 设置概念数据
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
console.log('[RelatedConceptsSection] 设置概念数据 (results)', data.results);
|
||||
setConcepts(data.results);
|
||||
} else if (data.data && data.data.concepts) {
|
||||
// 向后兼容
|
||||
console.log('[RelatedConceptsSection] 设置概念数据 (data.concepts)', data.data.concepts);
|
||||
setConcepts(data.data.concepts);
|
||||
} else {
|
||||
console.log('[RelatedConceptsSection] 没有找到概念数据,设置为空数组');
|
||||
setConcepts([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[RelatedConceptsSection] 搜索概念失败', err);
|
||||
logger.error('RelatedConceptsSection', 'searchConcepts', err);
|
||||
setError('加载概念数据失败');
|
||||
setConcepts([]);
|
||||
} finally {
|
||||
console.log('[RelatedConceptsSection] 加载完成');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
searchConcepts();
|
||||
}, [eventTitle, effectiveTradingDate]);
|
||||
|
||||
// 加载中状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Box bg={sectionBg} p={3} borderRadius="md">
|
||||
<Center py={4}>
|
||||
<Spinner size="md" color="blue.500" mr={2} />
|
||||
<Text color={textColor} fontSize="sm">加载相关概念中...</Text>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有概念,不渲染
|
||||
if (!concepts || concepts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据相关度获取颜色(浅色背景 + 深色文字)
|
||||
* @param {number} relevance - 相关度(0-100)
|
||||
* @returns {Object} 包含背景色和文字色
|
||||
*/
|
||||
const getRelevanceColor = (relevance) => {
|
||||
if (relevance >= 90) {
|
||||
return { bg: 'purple.50', color: 'purple.800' }; // 极高相关
|
||||
} else if (relevance >= 80) {
|
||||
return { bg: 'pink.50', color: 'pink.800' }; // 高相关
|
||||
} else if (relevance >= 70) {
|
||||
return { bg: 'orange.50', color: 'orange.800' }; // 中等相关
|
||||
} else {
|
||||
return { bg: 'gray.100', color: 'gray.700' }; // 低相关
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理概念点击
|
||||
* @param {Object} concept - 概念对象
|
||||
*/
|
||||
const handleConceptClick = (concept) => {
|
||||
// 跳转到概念中心,并搜索该概念
|
||||
navigate(`/concepts?q=${encodeURIComponent(concept.concept)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={sectionBg} p={3} borderRadius="md">
|
||||
{/* 标题栏 - 两行布局 */}
|
||||
<Box mb={3}>
|
||||
{/* 第一行:标题 + Badge + 按钮 */}
|
||||
<Flex justify="space-between" align="center" mb={2}>
|
||||
<Flex align="center" gap={2}>
|
||||
<Heading size="sm" color={headingColor}>
|
||||
相关概念
|
||||
</Heading>
|
||||
{/* 订阅徽章 */}
|
||||
{subscriptionBadge}
|
||||
</Flex>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
rightIcon={isExpanded ? <ChevronUpIcon /> : <ChevronDownIcon />}
|
||||
onClick={() => {
|
||||
// 如果被锁定且有回调函数,触发付费弹窗
|
||||
if (isLocked && onLockedClick) {
|
||||
onLockedClick();
|
||||
} else {
|
||||
// 否则正常展开/收起
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isExpanded ? '收起' : '查看详细描述'}
|
||||
</Button>
|
||||
</Flex>
|
||||
{/* 第二行:交易日期信息 */}
|
||||
<TradingDateInfo
|
||||
effectiveTradingDate={effectiveTradingDate}
|
||||
eventTime={eventTime}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 简单模式:横向卡片列表(总是显示) */}
|
||||
<Flex gap={2} flexWrap="wrap" mb={isExpanded ? 3 : 0}>
|
||||
{concepts.map((concept, index) => (
|
||||
<SimpleConceptCard
|
||||
key={index}
|
||||
concept={concept}
|
||||
onClick={handleConceptClick}
|
||||
getRelevanceColor={getRelevanceColor}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
|
||||
{/* 详细模式:卡片网格(可折叠) */}
|
||||
<Collapse in={isExpanded} animateOpacity>
|
||||
{/* 详细概念卡片网格 */}
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{concepts.map((concept, index) => (
|
||||
<DetailedConceptCard
|
||||
key={index}
|
||||
concept={concept}
|
||||
onClick={handleConceptClick}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RelatedConceptsSection;
|
||||
@@ -0,0 +1,98 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js
|
||||
// 相关股票列表区组件(纯内容,不含标题)
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
VStack,
|
||||
Flex,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
} from '@chakra-ui/react';
|
||||
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons';
|
||||
import StockListItem from './StockListItem';
|
||||
import CompactStockItem from './CompactStockItem';
|
||||
|
||||
/**
|
||||
* 相关股票列表区组件(纯内容部分)
|
||||
* @param {Object} props
|
||||
* @param {Array<Object>} props.stocks - 股票数组
|
||||
* @param {Object} props.quotes - 股票行情字典 { [stockCode]: { change: number } }
|
||||
* @param {string} props.eventTime - 事件时间
|
||||
* @param {Set} props.watchlistSet - 自选股代码集合
|
||||
* @param {Function} props.onWatchlistToggle - 切换自选股回调
|
||||
*/
|
||||
const RelatedStocksSection = ({
|
||||
stocks,
|
||||
quotes = {},
|
||||
eventTime = null,
|
||||
watchlistSet = new Set(),
|
||||
onWatchlistToggle
|
||||
}) => {
|
||||
// 显示模式:'detail' 详情模式, 'compact' 精简模式
|
||||
const [viewMode, setViewMode] = useState('detail');
|
||||
|
||||
// 如果没有股票数据,不渲染
|
||||
if (!stocks || stocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{/* 模式切换按钮 */}
|
||||
<Flex justify="flex-end">
|
||||
<ButtonGroup size="sm" isAttached variant="outline">
|
||||
<Button
|
||||
leftIcon={<ViewIcon />}
|
||||
colorScheme={viewMode === 'detail' ? 'blue' : 'gray'}
|
||||
variant={viewMode === 'detail' ? 'solid' : 'outline'}
|
||||
onClick={() => setViewMode('detail')}
|
||||
>
|
||||
详情模式
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<ViewOffIcon />}
|
||||
colorScheme={viewMode === 'compact' ? 'blue' : 'gray'}
|
||||
variant={viewMode === 'compact' ? 'solid' : 'outline'}
|
||||
onClick={() => setViewMode('compact')}
|
||||
>
|
||||
精简模式
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
|
||||
{/* 详情模式 */}
|
||||
{viewMode === 'detail' && (
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{stocks.map((stock, index) => (
|
||||
<StockListItem
|
||||
key={index}
|
||||
stock={stock}
|
||||
quote={quotes[stock.stock_code]}
|
||||
eventTime={eventTime}
|
||||
isInWatchlist={watchlistSet.has(stock.stock_code)}
|
||||
onWatchlistToggle={onWatchlistToggle}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
{/* 精简模式 */}
|
||||
{viewMode === 'compact' && (
|
||||
<Wrap spacing={4}>
|
||||
{stocks.map((stock, index) => (
|
||||
<WrapItem key={index}>
|
||||
<CompactStockItem
|
||||
stock={stock}
|
||||
quote={quotes[stock.stock_code]}
|
||||
/>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default RelatedStocksSection;
|
||||
@@ -0,0 +1,315 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/StockListItem.js
|
||||
// 股票卡片组件(融合表格功能的卡片样式)
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
IconButton,
|
||||
Collapse,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { StarIcon } from '@chakra-ui/icons';
|
||||
import MiniTimelineChart from '../StockDetailPanel/components/MiniTimelineChart';
|
||||
import MiniKLineChart from './MiniKLineChart';
|
||||
import StockChartModal from '../../../../components/StockChart/StockChartModal';
|
||||
|
||||
/**
|
||||
* 股票卡片组件
|
||||
* @param {Object} props
|
||||
* @param {Object} props.stock - 股票对象
|
||||
* @param {string} props.stock.stock_name - 股票名称
|
||||
* @param {string} props.stock.stock_code - 股票代码
|
||||
* @param {string} props.stock.relation_desc - 关联描述
|
||||
* @param {Object} props.quote - 股票行情数据(可选)
|
||||
* @param {number} props.quote.change - 涨跌幅
|
||||
* @param {string} props.eventTime - 事件时间(可选)
|
||||
* @param {boolean} props.isInWatchlist - 是否在自选股中
|
||||
* @param {Function} props.onWatchlistToggle - 切换自选股回调
|
||||
*/
|
||||
const StockListItem = ({
|
||||
stock,
|
||||
quote = null,
|
||||
eventTime = null,
|
||||
isInWatchlist = false,
|
||||
onWatchlistToggle
|
||||
}) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const codeColor = useColorModeValue('blue.600', 'blue.300');
|
||||
const nameColor = useColorModeValue('gray.700', 'gray.300');
|
||||
const descColor = useColorModeValue('gray.600', 'gray.400');
|
||||
const dividerColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
const [isDescExpanded, setIsDescExpanded] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const handleViewDetail = () => {
|
||||
const stockCode = stock.stock_code.split('.')[0];
|
||||
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
|
||||
};
|
||||
|
||||
const handleWatchlistClick = (e) => {
|
||||
e.stopPropagation();
|
||||
onWatchlistToggle?.(stock.stock_code, isInWatchlist);
|
||||
};
|
||||
|
||||
// 格式化涨跌幅显示
|
||||
const formatChange = (value) => {
|
||||
if (value === null || value === undefined || isNaN(value)) return '--';
|
||||
const prefix = value > 0 ? '+' : '';
|
||||
return `${prefix}${parseFloat(value).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
// 获取涨跌幅颜色
|
||||
const getChangeColor = (value) => {
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num) || num === 0) return 'gray.500';
|
||||
return num > 0 ? 'red.500' : 'green.500';
|
||||
};
|
||||
|
||||
// 获取涨跌幅数据(优先使用 quote,fallback 到 stock)
|
||||
const change = quote?.change ?? stock.daily_change ?? null;
|
||||
|
||||
// 处理关联描述
|
||||
const getRelationDesc = () => {
|
||||
const relationDesc = stock.relation_desc;
|
||||
|
||||
if (!relationDesc) return '--';
|
||||
|
||||
if (typeof relationDesc === 'string') {
|
||||
return relationDesc;
|
||||
} else if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
|
||||
// 新格式:{data: [{query_part: "...", sentences: "..."}]}
|
||||
return relationDesc.data
|
||||
.map(item => item.query_part || item.sentences || '')
|
||||
.filter(s => s)
|
||||
.join(';') || '--';
|
||||
}
|
||||
|
||||
return '--';
|
||||
};
|
||||
|
||||
const relationText = getRelationDesc();
|
||||
const maxLength = 50; // 收缩时显示的最大字符数
|
||||
const needTruncate = relationText && relationText !== '--' && relationText.length > maxLength;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
p={3}
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
_before={{
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
bgGradient: 'linear(to-r, blue.400, purple.500, pink.500)',
|
||||
}}
|
||||
_hover={{
|
||||
boxShadow: 'lg',
|
||||
borderColor: 'blue.300',
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
{/* 单行紧凑布局:名称+涨跌幅 | 分时图 | K线图 | 关联描述 */}
|
||||
<HStack spacing={3} align="stretch">
|
||||
{/* 左侧:股票名称 + 涨跌幅(垂直排列) - 收窄 */}
|
||||
<VStack
|
||||
align="stretch"
|
||||
spacing={1}
|
||||
minW="100px"
|
||||
maxW="120px"
|
||||
justify="center"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Tooltip
|
||||
label="点击查看股票详情"
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="blue.600"
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={codeColor}
|
||||
noOfLines={1}
|
||||
cursor="pointer"
|
||||
onClick={handleViewDetail}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{stock.stock_name}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<HStack spacing={1} align="center">
|
||||
<Text
|
||||
fontSize="lg"
|
||||
fontWeight="bold"
|
||||
color={getChangeColor(change)}
|
||||
>
|
||||
{formatChange(change)}
|
||||
</Text>
|
||||
{onWatchlistToggle && (
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant={isInWatchlist ? 'solid' : 'ghost'}
|
||||
colorScheme={isInWatchlist ? 'yellow' : 'gray'}
|
||||
icon={<StarIcon />}
|
||||
onClick={handleWatchlistClick}
|
||||
aria-label={isInWatchlist ? '已关注' : '加自选'}
|
||||
borderRadius="full"
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 分时图 - 固定宽度 */}
|
||||
<Box
|
||||
w="160px"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('blue.100', 'blue.700')}
|
||||
borderRadius="md"
|
||||
p={2}
|
||||
bg={useColorModeValue('blue.50', 'blue.900')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
cursor="pointer"
|
||||
flexShrink={0}
|
||||
_hover={{
|
||||
borderColor: useColorModeValue('blue.300', 'blue.500'),
|
||||
boxShadow: 'sm'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={useColorModeValue('blue.700', 'blue.200')}
|
||||
mb={1}
|
||||
fontWeight="semibold"
|
||||
>
|
||||
📈 分时
|
||||
</Text>
|
||||
<MiniTimelineChart
|
||||
stockCode={stock.stock_code}
|
||||
eventTime={eventTime}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* K线图 - 固定宽度 */}
|
||||
<Box
|
||||
w="160px"
|
||||
borderWidth="1px"
|
||||
borderColor={useColorModeValue('purple.100', 'purple.700')}
|
||||
borderRadius="md"
|
||||
p={2}
|
||||
bg={useColorModeValue('purple.50', 'purple.900')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
cursor="pointer"
|
||||
flexShrink={0}
|
||||
_hover={{
|
||||
borderColor: useColorModeValue('purple.300', 'purple.500'),
|
||||
boxShadow: 'sm'
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={useColorModeValue('purple.700', 'purple.200')}
|
||||
mb={1}
|
||||
fontWeight="semibold"
|
||||
>
|
||||
📊 日线
|
||||
</Text>
|
||||
<MiniKLineChart
|
||||
stockCode={stock.stock_code}
|
||||
eventTime={eventTime}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 关联描述(单行显示,点击展开)- 占据更多空间 */}
|
||||
{relationText && relationText !== '--' && (
|
||||
<Tooltip
|
||||
label={isDescExpanded ? "点击收起" : "点击展开完整描述"}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="gray.600"
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
>
|
||||
<Box
|
||||
flex={1}
|
||||
minW={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsDescExpanded(!isDescExpanded);
|
||||
}}
|
||||
cursor="pointer"
|
||||
px={3}
|
||||
py={2}
|
||||
bg={useColorModeValue('gray.50', 'gray.700')}
|
||||
borderRadius="md"
|
||||
_hover={{
|
||||
bg: useColorModeValue('gray.100', 'gray.600'),
|
||||
}}
|
||||
transition="background 0.2s"
|
||||
>
|
||||
{/* 去掉"关联描述"标题 */}
|
||||
<Collapse in={isDescExpanded} startingHeight={40}>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
color={nameColor}
|
||||
lineHeight="1.6"
|
||||
>
|
||||
{relationText}
|
||||
</Text>
|
||||
</Collapse>
|
||||
{isDescExpanded && (
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="gray.500"
|
||||
mt={2}
|
||||
fontStyle="italic"
|
||||
>
|
||||
⚠️ AI生成,仅供参考
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 股票详情弹窗 - 未打开时不渲染 */}
|
||||
{isModalOpen && (
|
||||
<StockChartModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
stock={stock}
|
||||
eventTime={eventTime}
|
||||
size="6xl"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockListItem;
|
||||
@@ -0,0 +1,5 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/index.js
|
||||
// 统一导出 DynamicNewsDetailPanel 组件
|
||||
|
||||
export { default } from './DynamicNewsDetailPanel';
|
||||
export { default as DynamicNewsDetailPanel } from './DynamicNewsDetailPanel';
|
||||
325
src/views/Community/components/EventCard/DynamicNewsEventCard.js
Normal file
325
src/views/Community/components/EventCard/DynamicNewsEventCard.js
Normal file
@@ -0,0 +1,325 @@
|
||||
// src/views/Community/components/EventCard/DynamicNewsEventCard.js
|
||||
// 动态新闻事件卡片组件(纵向布局,时间在上)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
VStack,
|
||||
Card,
|
||||
CardBody,
|
||||
Box,
|
||||
Text,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
|
||||
// 导入子组件
|
||||
import ImportanceBadge from './ImportanceBadge';
|
||||
import EventFollowButton from './EventFollowButton';
|
||||
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
|
||||
|
||||
/**
|
||||
* 动态新闻事件卡片组件(极简版)
|
||||
* @param {Object} props
|
||||
* @param {Object} props.event - 事件对象
|
||||
* @param {number} props.index - 事件索引
|
||||
* @param {boolean} props.isFollowing - 是否已关注
|
||||
* @param {number} props.followerCount - 关注数
|
||||
* @param {boolean} props.isSelected - 是否被选中
|
||||
* @param {Function} props.onEventClick - 卡片点击事件
|
||||
* @param {Function} props.onTitleClick - 标题点击事件
|
||||
* @param {Function} props.onToggleFollow - 切换关注事件
|
||||
* @param {string} props.borderColor - 边框颜色
|
||||
*/
|
||||
const DynamicNewsEventCard = ({
|
||||
event,
|
||||
index,
|
||||
isFollowing,
|
||||
followerCount,
|
||||
isSelected = false,
|
||||
onEventClick,
|
||||
onTitleClick,
|
||||
onToggleFollow,
|
||||
borderColor,
|
||||
}) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
|
||||
/**
|
||||
* 判断交易时段(盘前、盘中上午、午休、盘中下午、盘后)
|
||||
* @param {string} timestamp - 事件时间戳
|
||||
* @returns {'pre-market' | 'morning-trading' | 'lunch-break' | 'afternoon-trading' | 'after-market'}
|
||||
*/
|
||||
const getTradingPeriod = (timestamp) => {
|
||||
const eventTime = moment(timestamp);
|
||||
const hour = eventTime.hour();
|
||||
const minute = eventTime.minute();
|
||||
const timeInMinutes = hour * 60 + minute;
|
||||
|
||||
// 时间常量
|
||||
const morningStart = 9 * 60 + 30; // 09:30 = 570分钟
|
||||
const morningEnd = 11 * 60 + 30; // 11:30 = 690分钟
|
||||
const lunchEnd = 13 * 60; // 13:00 = 780分钟
|
||||
const afternoonEnd = 15 * 60; // 15:00 = 900分钟
|
||||
|
||||
// 盘中上午:09:30-11:30
|
||||
if (timeInMinutes >= morningStart && timeInMinutes < morningEnd) {
|
||||
return 'morning-trading';
|
||||
}
|
||||
// 午休:11:30-13:00
|
||||
else if (timeInMinutes >= morningEnd && timeInMinutes < lunchEnd) {
|
||||
return 'lunch-break';
|
||||
}
|
||||
// 盘中下午:13:00-15:00
|
||||
else if (timeInMinutes >= lunchEnd && timeInMinutes < afternoonEnd) {
|
||||
return 'afternoon-trading';
|
||||
}
|
||||
// 盘前:00:00-09:30
|
||||
else if (timeInMinutes < morningStart) {
|
||||
return 'pre-market';
|
||||
}
|
||||
// 盘后:15:00-23:59
|
||||
else {
|
||||
return 'after-market';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取时间标签样式(根据交易时段)
|
||||
* @param {string} period - 交易时段
|
||||
* @returns {Object} Chakra UI 样式对象
|
||||
*/
|
||||
const getTimeLabelStyle = (period) => {
|
||||
switch (period) {
|
||||
case 'pre-market':
|
||||
// 盘前:粉红色系(浅红)
|
||||
return {
|
||||
bg: useColorModeValue('pink.50', 'pink.900'),
|
||||
borderColor: useColorModeValue('pink.300', 'pink.500'),
|
||||
textColor: useColorModeValue('pink.600', 'pink.300'),
|
||||
};
|
||||
case 'morning-trading':
|
||||
case 'afternoon-trading':
|
||||
// 盘中:红色系(强烈,表示交易活跃)
|
||||
return {
|
||||
bg: useColorModeValue('red.50', 'red.900'),
|
||||
borderColor: useColorModeValue('red.400', 'red.500'),
|
||||
textColor: useColorModeValue('red.700', 'red.300'),
|
||||
};
|
||||
case 'lunch-break':
|
||||
// 午休:灰色系(中性)
|
||||
return {
|
||||
bg: useColorModeValue('gray.100', 'gray.800'),
|
||||
borderColor: useColorModeValue('gray.400', 'gray.500'),
|
||||
textColor: useColorModeValue('gray.600', 'gray.400'),
|
||||
};
|
||||
case 'after-market':
|
||||
// 盘后:橙色系(暖色但区别于盘中红色)
|
||||
return {
|
||||
bg: useColorModeValue('orange.50', 'orange.900'),
|
||||
borderColor: useColorModeValue('orange.400', 'orange.500'),
|
||||
textColor: useColorModeValue('orange.600', 'orange.300'),
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: useColorModeValue('gray.100', 'gray.800'),
|
||||
borderColor: useColorModeValue('gray.400', 'gray.500'),
|
||||
textColor: useColorModeValue('gray.600', 'gray.400'),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取交易时段文字标签
|
||||
* @param {string} period - 交易时段
|
||||
* @returns {string} 时段文字标签
|
||||
*/
|
||||
const getPeriodLabel = (period) => {
|
||||
switch (period) {
|
||||
case 'pre-market':
|
||||
return '盘前';
|
||||
case 'morning-trading':
|
||||
case 'afternoon-trading':
|
||||
return '盘中';
|
||||
case 'lunch-break':
|
||||
return '午休';
|
||||
case 'after-market':
|
||||
return '盘后';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据平均涨幅计算背景色(分级策略)
|
||||
* @param {number} avgChange - 平均涨跌幅
|
||||
* @returns {string} Chakra UI 颜色值
|
||||
*/
|
||||
const getChangeBasedBgColor = (avgChange) => {
|
||||
// 转换为数字类型(处理可能的字符串类型数据)
|
||||
const numChange = Number(avgChange);
|
||||
|
||||
// 🔍 调试日志:排查背景色计算问题
|
||||
console.log('📊 [背景色计算]', {
|
||||
rawValue: avgChange,
|
||||
numValue: numChange,
|
||||
type: typeof avgChange,
|
||||
isNull: avgChange == null,
|
||||
isNaN: isNaN(numChange),
|
||||
title: event.title.substring(0, 30) + '...'
|
||||
});
|
||||
|
||||
// 如果没有涨跌幅数据或转换失败,使用默认的奇偶行背景
|
||||
if (avgChange == null || isNaN(numChange)) {
|
||||
return index % 2 === 0 ? cardBg : useColorModeValue('gray.50', 'gray.750');
|
||||
}
|
||||
|
||||
// 根据涨跌幅分级返回背景色
|
||||
if (numChange >= 5) {
|
||||
return useColorModeValue('red.100', 'red.900');
|
||||
} else if (numChange >= 3) {
|
||||
return useColorModeValue('red.100', 'red.800');
|
||||
} else if (numChange > 0) {
|
||||
return useColorModeValue('red.50', 'red.700');
|
||||
} else if (numChange > -3) {
|
||||
return useColorModeValue('green.50', 'green.700');
|
||||
} else if (numChange > -5) {
|
||||
return useColorModeValue('green.100', 'green.800');
|
||||
} else {
|
||||
return useColorModeValue('green.100', 'green.900');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取当前事件的交易时段、样式和文字标签
|
||||
const tradingPeriod = getTradingPeriod(event.created_at);
|
||||
const timeLabelStyle = getTimeLabelStyle(tradingPeriod);
|
||||
const periodLabel = getPeriodLabel(tradingPeriod);
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={2} w="100%" pt={3}>
|
||||
|
||||
|
||||
{/* 事件卡片 */}
|
||||
<Card
|
||||
position="relative"
|
||||
bg={isSelected
|
||||
? useColorModeValue('blue.50', 'blue.900')
|
||||
: getChangeBasedBgColor(event.related_avg_chg)
|
||||
}
|
||||
borderWidth={isSelected ? "2px" : "1px"}
|
||||
borderColor={isSelected
|
||||
? useColorModeValue('blue.500', 'blue.400')
|
||||
: borderColor
|
||||
}
|
||||
borderRadius="md"
|
||||
boxShadow={isSelected ? "lg" : "sm"}
|
||||
// overflow="hidden"
|
||||
_hover={{
|
||||
boxShadow: 'xl',
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: isSelected ? 'blue.600' : importance.color,
|
||||
}}
|
||||
transition="all 0.3s ease"
|
||||
cursor="pointer"
|
||||
onClick={() => onEventClick?.(event)}
|
||||
>
|
||||
<CardBody p={3}>
|
||||
{/* 左上角:重要性标签 */}
|
||||
<ImportanceBadge importance={event.importance} position={{ top:-1, left: 0}} />
|
||||
|
||||
{/* 时间标签 - 在卡片上方,宽度自适应,左对齐 */}
|
||||
<Box
|
||||
bg={timeLabelStyle.bg}
|
||||
borderWidth="2px"
|
||||
borderColor={timeLabelStyle.borderColor}
|
||||
borderRadius="md"
|
||||
px={0.5}
|
||||
py={0.5}
|
||||
width="fit-content"
|
||||
alignSelf="flex-start"
|
||||
boxShadow="sm"
|
||||
transition="all 0.3s ease"
|
||||
position="absolute"
|
||||
top={-2}
|
||||
left={8}
|
||||
>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
color={timeLabelStyle.textColor}
|
||||
lineHeight="1.3"
|
||||
>
|
||||
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{periodLabel && (
|
||||
<>
|
||||
{' • '}
|
||||
<Text as="span" fontWeight="extrabold">
|
||||
{periodLabel}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 右上角:关注按钮 */}
|
||||
<Box position="absolute" top={2} right={2} zIndex={1}>
|
||||
<EventFollowButton
|
||||
isFollowing={isFollowing}
|
||||
followerCount={followerCount}
|
||||
onToggle={() => onToggleFollow?.(event.id)}
|
||||
size="xs"
|
||||
showCount={false}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{/* 标题 - 固定两行高度,保持卡片高度一致 */}
|
||||
<Tooltip
|
||||
label={event.title}
|
||||
placement="top"
|
||||
hasArrow
|
||||
bg="gray.700"
|
||||
color="white"
|
||||
fontSize="sm"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
onClick={(e) => onTitleClick?.(e, event)}
|
||||
mt={1}
|
||||
paddingRight="10px"
|
||||
minHeight="2.8em"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
>
|
||||
<Text
|
||||
fontSize="md"
|
||||
fontWeight="semibold"
|
||||
color={linkColor}
|
||||
lineHeight="1.4"
|
||||
noOfLines={2}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{event.title}
|
||||
</Text>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<Box position='relative'>
|
||||
{/* 第二行:涨跌幅数据 */}
|
||||
<StockChangeIndicators
|
||||
avgChange={event.related_avg_chg}
|
||||
maxChange={event.related_max_chg}
|
||||
weekChange={event.related_week_chg}
|
||||
/>
|
||||
</Box>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicNewsEventCard;
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/EventCard/EventFollowButton.js
|
||||
import React from 'react';
|
||||
import { Button } from '@chakra-ui/react';
|
||||
import { StarIcon } from '@chakra-ui/icons';
|
||||
import { IconButton, Box } from '@chakra-ui/react';
|
||||
import { AiFillStar, AiOutlineStar } from 'react-icons/ai';
|
||||
|
||||
/**
|
||||
* 事件关注按钮组件
|
||||
@@ -19,7 +19,7 @@ const EventFollowButton = ({
|
||||
size = 'sm',
|
||||
showCount = true
|
||||
}) => {
|
||||
const iconSize = size === 'xs' ? '10px' : '12px';
|
||||
const iconSize = size === 'xs' ? '16px' : size === 'sm' ? '18px' : '22px';
|
||||
|
||||
const handleClick = (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -27,16 +27,38 @@ const EventFollowButton = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size={size}
|
||||
colorScheme="yellow"
|
||||
variant={isFollowing ? 'solid' : 'outline'}
|
||||
leftIcon={<StarIcon boxSize={iconSize} />}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{isFollowing ? '已关注' : '关注'}
|
||||
{showCount && followerCount > 0 && `(${followerCount})`}
|
||||
</Button>
|
||||
<Box display="inline-flex" alignItems="center" gap={1}>
|
||||
<IconButton
|
||||
size={size}
|
||||
colorScheme="yellow"
|
||||
variant="ghost"
|
||||
bg="whiteAlpha.500"
|
||||
boxShadow="sm"
|
||||
_hover={{
|
||||
bg: 'whiteAlpha.800',
|
||||
boxShadow: 'md'
|
||||
}}
|
||||
icon={
|
||||
isFollowing ? (
|
||||
<AiFillStar
|
||||
size={iconSize}
|
||||
color="gold"
|
||||
/>
|
||||
) : (
|
||||
<AiOutlineStar
|
||||
size={iconSize}
|
||||
color="#718096"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
)
|
||||
}
|
||||
onClick={handleClick}
|
||||
aria-label={isFollowing ? '取消关注' : '关注'}
|
||||
/>
|
||||
{/* <Box fontSize="xs" color="gray.500">
|
||||
{followerCount || 0}
|
||||
</Box> */}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user