Compare commits

..

10 Commits

Author SHA1 Message Date
zdl
0e015901ea feat: 删除不需要的组件 2025-11-07 10:35:20 +08:00
2a122b0013 事件中心UI优化 2025-11-07 10:31:42 +08:00
663d73609a 事件中心UI优化 2025-11-07 10:16:21 +08:00
389a45fc0a 事件中心UI优化 2025-11-07 09:57:49 +08:00
67c7fa49e8 事件中心UI优化 2025-11-07 09:45:42 +08:00
a3810499cc 优惠码Bug修复 2025-11-07 08:13:12 +08:00
83c6abdfba 优惠码Bug修复 2025-11-07 07:53:07 +08:00
dcc88251df 优惠码Bug修复 2025-11-07 07:35:13 +08:00
zdl
6271736969 fix: 修复重置按钮不生效问题
问题描述:
- 用户选择所有筛选条件后,点击"重置"按钮无反应
- 筛选条件未被清空,事件列表未重新加载

根本原因:
- 当筛选条件从"有值"重置为"空值"或从"空值"重置为"空值"时
- 如果 filters 对象的字段值没有实质变化
- DynamicNewsCard 的 useEffect 依赖项检测不到变化,不会触发重新加载

解决方案:
1. UnifiedSearchBox.handleReset() 添加 _forceRefresh 时间戳标志
   - 每次重置都生成唯一的 Date.now() 时间戳
   - 确保 filters 对象每次重置都不同

2. DynamicNewsCard 筛选 useEffect 依赖数组添加 filters._forceRefresh
   - 监听强制刷新标志的变化
   - 即使其他筛选条件未变,也能触发重新加载

3. 增强调试日志
   - 添加完整的重置流程日志输出
   - 便于排查后续问题

修改文件:
- src/views/Community/components/UnifiedSearchBox.js (Line 505-536)
- src/views/Community/components/DynamicNewsCard.js (Line 264)

测试场景:
 选择所有筛选条件后点击重置 - 清空并重新加载
 未选择筛选条件时点击重置 - 强制刷新第1页
 重置后 Redux 缓存被清空 (clearCache: true)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 18:00:53 +08:00
zdl
319a78d34c fix: 修复分页、筛选和模式切换相关问题
主要修复:
1. 修复模式切换时 per_page 参数错误
   - 在 useEffect 内直接根据 mode 计算 per_page
   - 避免使用可能过时的 pageSize prop

2. 修复 DISPLAY_MODES 未定义错误
   - 在 DynamicNewsCard.js 中导入 DISPLAY_MODES 常量

3. 添加空状态显示
   - VerticalModeLayout 添加无数据时的友好提示
   - 显示图标和提示文字,引导用户调整筛选条件

4. 修复无限请求循环问题
   - 移除模式切换 useEffect 中的 filters 依赖
   - 避免筛选和模式切换 useEffect 互相触发

5. 修复筛选参数传递问题
   - usePagination 使用 useRef 存储最新 filters
   - 避免 useCallback 闭包捕获旧值
   - 修复时间筛选参数丢失问题

6. 修复分页竞态条件
   - 允许用户在加载时切换到不同页面
   - 只阻止相同页面的重复请求

涉及文件:
- src/views/Community/components/DynamicNewsCard.js
- src/views/Community/components/DynamicNewsCard/VerticalModeLayout.js
- src/views/Community/components/DynamicNewsCard/hooks/usePagination.js
- src/views/Community/hooks/useEventFilters.js
- src/store/slices/communityDataSlice.js
- src/views/Community/components/UnifiedSearchBox.js

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 17:39:03 +08:00
18 changed files with 1537 additions and 1040 deletions

340
CLAUDE.md
View File

@@ -4,24 +4,24 @@ 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 for financial/trading analysis. Built on the Argon Dashboard Chakra PRO template with extensive customization.
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
- **State Management**: Redux Toolkit (@reduxjs/toolkit)
- **Routing**: React Router DOM v6 with lazy loading for code splitting
- **Styling**: Tailwind CSS + custom Chakra theme
- **Build Tool**: CRACO (Create React App Configuration Override) with custom webpack optimizations
- **Charts**: ApexCharts, ECharts, Recharts, D3
- **UI Components**: Ant Design (antd) alongside Chakra UI
- **Other Libraries**: Three.js (@react-three), FullCalendar, Leaflet maps
### Tech Stack
### Backend (Flask/Python)
- **Framework**: Flask with SQLAlchemy ORM
- **Database**: ClickHouse for analytics queries + MySQL/PostgreSQL
- **Real-time**: Flask-SocketIO for WebSocket connections
- **Task Queue**: Celery with Redis for background processing
- **External APIs**: Tencent Cloud SMS, WeChat Pay integration
**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
@@ -46,89 +46,265 @@ npm run reinstall # Clean install (runs clean + install)
### Backend Development
```bash
python app.py # Main Flask server (newer version)
python app_2.py # Flask server (appears to be current main)
python simulation_background_processor.py # Background data processor for simulations
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
```
### Deployment
```bash
npm run deploy # Executes scripts/deploy-from-local.sh
npm run deploy:setup # Setup deployment (scripts/setup-deployment.sh)
npm run rollback # Rollback deployment (scripts/rollback-from-local.sh)
```
### Python Dependencies
```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/App.js** - Main application entry with route definitions (routing moved from src/routes.js)
- **src/layouts/** - Layout wrappers (Auth, Home, MainLayout)
- **src/views/** - Page components (Community, Company, TradingSimulation, etc.)
- **src/components/** - Reusable UI components
- **src/contexts/** - React contexts (AuthContext, NotificationContext, IndustryContext)
- **src/store/** - Redux store with slices (posthogSlice, etc.)
- **src/services/** - API service layer
- **src/theme/** - Chakra UI theme customization
- **src/mocks/** - MSW (Mock Service Worker) handlers for development
- src/mocks/handlers/ - Request handlers by domain
- src/mocks/data/ - Mock data files
- src/mocks/browser.js - MSW browser setup
### 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.py / app_2.py** - Main Flask application with routes, authentication, and business logic
- **simulation_background_processor.py** - Background processor for trading simulations
- **wechat_pay.py / wechat_pay_config.py** - WeChat payment integration
- **concept_api.py** - API for concept/industry analysis
- **tdays.csv** - Trading days calendar data (loaded into memory at startup)
```
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** - High-performance analytics queries
- **Celery + Redis** - Background task processing
- **Flask-SocketIO** - Real-time data updates via WebSocket
- **Tencent Cloud** - SMS services
- **WeChat Pay** - Payment processing
- **PostHog** - Analytics (initialized in Redux)
- **MSW** - API mocking for development/testing
### 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.)
### Routing & Code Splitting
- Routing is defined in **src/App.js** (not src/routes.js - that file is deprecated)
- Heavy components use React.lazy() for code splitting (Community, TradingSimulation, etc.)
- Protected routes use ProtectedRoute and ProtectedRouteRedirect components
### Real-time Updates
- Flask-SocketIO for WebSocket connections
- Example: Community event notifications push via WebSocket
- Client: `socket.io-client` library
## Configuration
### Environment Files
Multiple environment configurations available:
- **.env.mock** - Mock data mode (default for `npm start`)
- **.env.local** - Real backend connection
- **.env.development** - Development environment
- **.env.test** - Test environment
```
.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
```
### Build Configuration (craco.config.js)
- **Webpack caching**: Filesystem cache for faster rebuilds (50-80% improvement)
- **Code splitting**: Aggressive chunk splitting by library (react-vendor, charts-lib, chakra-ui, antd-lib, three-lib, etc.)
- **Path aliases**: `@` → src/, `@components` → src/components/, `@views` → src/views/, `@assets` → src/assets/, `@contexts` → src/contexts/
- **Optimizations**: ESLint plugin removed from build for speed, Babel caching enabled, moment locale stripping
- **Source maps**: Disabled in production, eval-cheap-module-source-map in development
- **Dev server proxy**: `/api` requests proxy to http://49.232.185.254:5001
**Key environment variables:**
- `REACT_APP_ENABLE_MOCK=true` - Enable MSW mocking
- `REACT_APP_API_URL` - Backend URL (empty string = use relative paths or MSW)
### Important Build Notes
- Uses NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' for Node compatibility
- Gulp task adds Creative Tim license headers post-build
- Bundle analyzer available via `ANALYZE=true npm run build:analyze`
- Pre-build: kills any process on port 3000
### MSW (Mock Service Worker) Setup
MSW is used for API mocking during development:
## Testing
- **React Testing Library** for component tests
- **MSW** (Mock Service Worker) for API mocking during tests
- Run tests: `npm test`
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
## Deployment
- Deployment scripts in **scripts/** directory
- Build output processed by Gulp for licensing
- Supports rollback via scripts/rollback-from-local.sh
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
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
**Node compatibility:**
```bash
NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096'
```
## Development Workflow
### 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.

58
app.py
View File

@@ -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
@@ -1415,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
},
@@ -1433,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
}

View 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. 用户可以通过优惠码获得额外折扣

View File

@@ -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 352732020个人信息是指以电子或者其他方式记录的能够单独或者与其他信息结合识别特定自然人身份或者反映特定自然人活动情况的各种信息本隐私政策中涉及的个人信息包括基本信息包括性别地址地区个人电话号码电子邮箱个人身份信息包括身份证护照相关身份证明等网络身份标识信息包括系统账号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">
QQQQ互联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;

View File

@@ -79,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);
@@ -162,6 +163,18 @@ export default function SubscriptionContent() {
// 计算价格(包含升级和优惠码)
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: {
@@ -171,7 +184,7 @@ export default function SubscriptionContent() {
body: JSON.stringify({
to_plan: plan.name,
to_cycle: cycle,
promo_code: promoCodeValue || null
promo_code: validPromoCode
})
});
@@ -191,7 +204,9 @@ export default function SubscriptionContent() {
// 验证优惠码
const handleValidatePromoCode = async () => {
if (!promoCode.trim()) {
const trimmedCode = promoCode.trim();
if (!trimmedCode) {
setPromoCodeError('请输入优惠码');
return;
}
@@ -205,8 +220,8 @@ export default function SubscriptionContent() {
setPromoCodeError('');
try {
// 重新计算价格,包含优惠码
const result = await calculatePrice(selectedPlan, selectedCycle, promoCode);
// 重新计算价格,包含优惠码(使用去除空格后的值)
const result = await calculatePrice(selectedPlan, selectedCycle, trimmedCode);
if (result && !result.promo_error) {
setPromoCodeApplied(true);
@@ -573,15 +588,62 @@ 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;
};
// 获取按钮文字(根据用户当前订阅判断是升级还是新订阅)
@@ -695,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
@@ -897,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>
@@ -1077,7 +1260,7 @@ export default function SubscriptionContent() {
align="center"
>
<Text fontWeight="semibold" color={textColor}>
可以在月付和年付之间切换吗
升级或切换套餐时原套餐的费用怎么办
</Text>
<Icon
as={openFaqIndex === 2 ? FaChevronUp : FaChevronDown}
@@ -1086,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}
@@ -1111,7 +1308,7 @@ export default function SubscriptionContent() {
align="center"
>
<Text fontWeight="semibold" color={textColor}>
是否提供退款
可以在月付和年付之间切换吗
</Text>
<Icon
as={openFaqIndex === 3 ? FaChevronUp : FaChevronDown}
@@ -1121,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}
@@ -1145,7 +1342,7 @@ export default function SubscriptionContent() {
align="center"
>
<Text fontWeight="semibold" color={textColor}>
Pro版和Max版有什么区别
是否支持退款
</Text>
<Icon
as={openFaqIndex === 4 ? FaChevronUp : FaChevronDown}
@@ -1153,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访问无限制的数据存储和团队协作功能并享有优先技术支持
@@ -1171,6 +1422,11 @@ export default function SubscriptionContent() {
stopAutoPaymentCheck();
setPaymentOrder(null);
setPaymentCountdown(0);
// 清空优惠码状态
setPromoCode('');
setPromoCodeApplied(false);
setPromoCodeError('');
setPriceInfo(null);
onPaymentModalClose();
}}
size="lg"
@@ -1201,7 +1457,19 @@ export default function SubscriptionContent() {
</Flex>
<Flex justify="space-between">
<Text color={secondaryText}>计费周期:</Text>
<Text>{selectedCycle === 'monthly' ? '按月付费' : '按年付费'}</Text>
<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>
{/* 价格明细 */}

View File

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

View File

@@ -182,8 +182,12 @@ export const fetchDynamicNews = createAsyncThunk(
sort = 'new',
importance,
q,
date_range,
industry_code
date_range, // 兼容旧格式(已废弃)
industry_code,
// 时间筛选参数(从 TradingTimeFilter 传递)
start_date,
end_date,
recent_days
} = {}, { rejectWithValue }) => {
try {
// 【动态计算 per_page】根据 mode 自动选择合适的每页大小
@@ -197,8 +201,12 @@ export const fetchDynamicNews = createAsyncThunk(
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 (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,
@@ -443,6 +451,17 @@ const communityDataSlice = createSlice({
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 });
}
},
@@ -603,7 +622,7 @@ const communityDataSlice = createSlice({
// ==================== 导出 ====================
export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus } = communityDataSlice.actions;
export const { clearCache, clearSpecificCache, preloadData, setEventFollowStatus, updatePaginationPage } = communityDataSlice.actions;
// 基础选择器Selectors
export const selectPopularKeywords = (state) => state.communityData.popularKeywords;

View File

@@ -24,7 +24,7 @@ import {
ModalCloseButton,
useColorModeValue,
useToast,
useDisclosure
useDisclosure,
} from '@chakra-ui/react';
import { TimeIcon } from '@chakra-ui/icons';
import EventScrollList from './DynamicNewsCard/EventScrollList';
@@ -40,7 +40,7 @@ import {
selectFourRowEventsWithLoading
} from '../../../store/slices/communityDataSlice';
import { usePagination } from './DynamicNewsCard/hooks/usePagination';
import { PAGINATION_CONFIG } from './DynamicNewsCard/constants';
import { PAGINATION_CONFIG, DISPLAY_MODES } from './DynamicNewsCard/constants';
// 🔍 调试:渲染计数器
let dynamicNewsCardRenderCount = 0;
@@ -71,11 +71,23 @@ const DynamicNewsCard = forwardRef(({
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');
// 本地状态:模式(先初始化,后面会被 usePagination 更新)
const [currentMode, setCurrentMode] = useState('vertical');
// 根据当前模式从 Redux 读取对应的数据(添加默认值避免 undefined
const verticalData = useSelector(selectVerticalEventsWithLoading) || {};
@@ -168,7 +180,8 @@ const DynamicNewsCard = forwardRef(({
cachedCount,
dispatch,
toast,
filters // 传递筛选条件
filters, // 传递筛选条件
initialMode: currentMode // 传递当前显示模式
});
// 同步 mode 到 currentMode
@@ -244,8 +257,11 @@ const DynamicNewsCard = forwardRef(({
filters.sort,
filters.importance,
filters.q,
filters.date_range,
filters.start_date, // 时间筛选参数:开始时间
filters.end_date, // 时间筛选参数:结束时间
filters.recent_days, // 时间筛选参数近N天
filters.industry_code,
filters._forceRefresh, // 强制刷新标志(用于重置按钮)
mode, // 添加 mode 到依赖
pageSize, // 添加 pageSize 到依赖
dispatch
@@ -259,16 +275,24 @@ const DynamicNewsCard = forwardRef(({
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: pageSize,
pageSize: pageSize,
per_page: modePageSize, // 使用计算的值,不是 pageSize prop
pageSize: modePageSize,
clearCache: true,
...filters, // 先展开筛选条件
page: PAGINATION_CONFIG.INITIAL_PAGE, // 然后覆盖 page 参数
}));
}
}, [mode]); // 只监听 mode 变化
}, [mode, currentMode, allCachedEventsByPage, allCachedEvents, dispatch]); // 移除 filters 依赖,避免与筛选 useEffect 循环触发 // 添加所有依赖
// 自动选中逻辑 - 只在首次加载时自动选中第一个事件,翻页时不自动选中
useEffect(() => {
@@ -295,10 +319,149 @@ const DynamicNewsCard = forwardRef(({
};
}, []);
// 页码切换时滚动到顶部
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}>
<Card
ref={ref}
{...rest}
bg={cardBg}
borderColor={borderColor}
mb={4}
>
{/* 标题部分 */}
<CardHeader>
<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">
@@ -325,15 +488,34 @@ const DynamicNewsCard = forwardRef(({
onSearchFocus={onSearchFocus}
popularKeywords={popularKeywords}
filters={filters}
mode={mode}
pageSize={pageSize}
/>
</Box>
</CardHeader>
{/* 主体内容 */}
<CardBody position="relative" pt={0}>
{/* 顶部控制栏:模式切换按钮 + 分页控制器(始终显示) */}
<Flex justify="space-between" align="center" mb={2}>
{/* 左侧:模式切换按钮 */}
<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} />
{/* 右侧:分页控制器(仅在纵向模式显示) */}
@@ -341,13 +523,13 @@ const DynamicNewsCard = forwardRef(({
<PaginationControl
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
onPageChange={handlePageChangeWithScroll}
/>
)}
</Flex>
{/* 横向滚动事件列表 - 始终渲染 + Loading 蒙层 */}
<Box position="relative">
{/* 内容区域 - 撑满剩余高度 */}
<Box flex="1" minH={0} position="relative">
{/* Loading 蒙层 - 数据请求时显示 */}
{loading && (
<Box
@@ -384,7 +566,7 @@ const DynamicNewsCard = forwardRef(({
borderColor={borderColor}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
onPageChange={handlePageChangeWithScroll}
loading={loadingPage !== null}
error={error}
mode={mode}
@@ -393,21 +575,13 @@ const DynamicNewsCard = forwardRef(({
hasMore={hasMore}
/>
</Box>
{/* 底部:分页控制器(仅在纵向模式显示) */}
{mode === 'vertical' && totalPages > 1 && (
<PaginationControl
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
)}
</CardBody>
{/* 四排模式详情弹窗 - 未打开时不渲染 */}
{isModalOpen && (
<Modal isOpen={isModalOpen} onClose={onModalClose} size="6xl" scrollBehavior="inside">
<Modal isOpen={isModalOpen} onClose={onModalClose} size="full" scrollBehavior="inside">
<ModalOverlay />
<ModalContent>
<ModalContent maxW="1600px" mx="auto" my={8}>
<ModalHeader>
{modalEvent?.title || '事件详情'}
</ModalHeader>

View File

@@ -2,8 +2,8 @@
// 纵向分栏模式布局组件
import React, { useState, useEffect } from 'react';
import { Box, IconButton, Tooltip, VStack, Flex } from '@chakra-ui/react';
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons';
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';
@@ -94,26 +94,41 @@ const VerticalModeLayout = ({
}}
>
{/* 事件列表 */}
<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>
{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>
{/* 右侧:事件详情 - 独立滚动 */}

View File

@@ -1,8 +1,8 @@
// src/views/Community/components/DynamicNewsCard/hooks/usePagination.js
// 分页逻辑自定义 Hook
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { fetchDynamicNews } from '../../../../../store/slices/communityDataSlice';
import { useState, useMemo, useCallback, useRef } from 'react';
import { fetchDynamicNews, updatePaginationPage } from '../../../../../store/slices/communityDataSlice';
import { logger } from '../../../../../utils/logger';
import {
PAGINATION_CONFIG,
@@ -16,12 +16,13 @@ import {
* @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 }
* @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 = ({
@@ -32,12 +33,20 @@ export const usePagination = ({
cachedCount,
dispatch,
toast,
filters = {}
filters = {},
initialMode // 初始显示模式
}) => {
// 本地状态
const [currentPage, setCurrentPage] = useState(PAGINATION_CONFIG.INITIAL_PAGE);
const [loadingPage, setLoadingPage] = useState(null);
const [mode, setMode] = useState(DEFAULT_MODE);
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 = (() => {
@@ -93,14 +102,14 @@ export const usePagination = ({
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;', filters);
console.log(`%c 筛选条件:`, 'color: #16A34A;', filtersRef.current);
logger.debug('DynamicNewsCard', '开始加载页面数据', {
targetPage,
pageSize,
mode,
clearCache,
filters
filters: filtersRef.current
});
// 🔍 调试dispatch 前
@@ -110,7 +119,7 @@ export const usePagination = ({
per_page: pageSize,
pageSize,
clearCache,
filters
filters: filtersRef.current
});
const result = await dispatch(fetchDynamicNews({
@@ -118,7 +127,7 @@ export const usePagination = ({
per_page: pageSize,
pageSize: pageSize,
clearCache: clearCache, // 传递 clearCache 参数
...filters, // 先展开筛选条件
...filtersRef.current, // 从 ref 读取最新筛选条件
page: targetPage, // 然后覆盖 page 参数(避免被 filters.page 覆盖)
})).unwrap();
@@ -146,7 +155,7 @@ export const usePagination = ({
} finally {
setLoadingPage(null);
}
}, [dispatch, pageSize, toast, mode, filters]);
}, [dispatch, pageSize, toast, mode]); // 移除 filters 依赖,使用 filtersRef 读取最新值
// 翻页处理第1页强制刷新 + 其他页缓存)
const handlePageChange = useCallback(async (newPage) => {
@@ -164,13 +173,20 @@ export const usePagination = ({
return;
}
// 边界检查 3: 防止竞态条件 - 如果正在加载其他页面,忽略新请求
if (loadingPage !== null) {
console.log(`%c⚠ [翻页] 正在加载${loadingPage},忽略新请求第${newPage}`, 'color: #EAB308; font-weight: bold;');
logger.warn('usePagination', '竞态条件:正在加载', { loadingPage, newPage });
// 边界检查 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;');
@@ -179,11 +195,8 @@ export const usePagination = ({
console.log(`%c🔄 [第1页] 清空缓存并重新加载`, 'color: #8B5CF6; font-weight: bold;');
logger.info('usePagination', '第1页强制刷新', { mode });
const success = await loadPage(newPage, true); // clearCache = true
if (success) {
setCurrentPage(newPage);
}
// clearCache = trueAPI 会更新 Redux pagination.current_page
await loadPage(newPage, true);
return;
}
@@ -197,23 +210,18 @@ export const usePagination = ({
if (isPageCached) {
console.log(`%c✅ [缓存] 第${newPage}页已缓存,直接切换`, 'color: #16A34A; font-weight: bold;');
setCurrentPage(newPage);
// 使用缓存数据,同步更新 Redux pagination.current_page
dispatch(updatePaginationPage({ mode, page: newPage }));
} else {
console.log(`%c❌ [缓存] 第${newPage}页未缓存,加载数据`, 'color: #DC2626; font-weight: bold;');
const success = await loadPage(newPage, false); // clearCache = false
if (success) {
setCurrentPage(newPage);
}
// clearCache = falseAPI 会更新 Redux pagination.current_page
await loadPage(newPage, false);
}
} else {
// 平铺模式直接加载新页追加模式clearCache=false
console.log(`%c🟡 [平铺模式] 加载第${newPage}`, 'color: #EAB308; font-weight: bold;');
const success = await loadPage(newPage, false); // clearCache = false
if (success) {
setCurrentPage(newPage);
}
// clearCache = falseAPI 会更新 Redux pagination.current_page
await loadPage(newPage, false);
}
}, [mode, currentPage, totalPages, loadingPage, allCachedEventsByPage, loadPage]);
@@ -272,8 +280,8 @@ export const usePagination = ({
if (newMode === mode) return;
setMode(newMode);
setCurrentPage(PAGINATION_CONFIG.INITIAL_PAGE);
// pageSize 会根据 mode 自动重新计算(第35-44行)
// currentPage 由 Redux pagination.current_page 派生,会在下次请求时自动更新
// pageSize 会根据 mode 自动重新计算(第46-56行)
}, [mode]);
return {

View File

@@ -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';
};
// 获取涨跌幅数据(优先使用 quotefallback 到 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;

View File

@@ -8,6 +8,8 @@ import {
HStack,
Heading,
Text,
Badge,
Icon,
useColorModeValue,
} from '@chakra-ui/react';
import { ViewIcon } from '@chakra-ui/icons';
@@ -100,7 +102,7 @@ const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onTogg
</Text>
</Flex>
{/* 第三行:涨跌幅指标 + 重要性文本 */}
{/* 第三行:涨跌幅指标 + 重要性徽章 */}
<HStack spacing={3} align="center">
<Box maxW="500px">
<StockChangeIndicators
@@ -110,19 +112,28 @@ const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onTogg
/>
</Box>
{/* 重要性文本 */}
<Box
bg={importance.bgColor}
borderWidth="2px"
borderColor={importance.badgeBg}
px={2}
py={1}
borderRadius="md"
{/* 重要性徽章 - 使用渐变色和图标 */}
<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}
>
<Text fontSize="sm" color={importance.badgeBg} whiteSpace="nowrap" fontWeight="medium">
重要性{getImportanceText()}
</Text>
</Box>
<Icon as={importance.icon} boxSize={5} />
<Text>重要性{getImportanceText()}</Text>
</Badge>
</HStack>
</Box>
);

View File

@@ -1,11 +1,18 @@
// src/views/Community/components/DynamicNewsDetail/RelatedStocksSection.js
// 相关股票列表区组件(纯内容,不含标题)
import React from 'react';
import React, { useState } from 'react';
import {
SimpleGrid,
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';
/**
* 相关股票列表区组件(纯内容部分)
@@ -23,24 +30,68 @@ const RelatedStocksSection = ({
watchlistSet = new Set(),
onWatchlistToggle
}) => {
// 显示模式:'detail' 详情模式, 'compact' 精简模式
const [viewMode, setViewMode] = useState('detail');
// 如果没有股票数据,不渲染
if (!stocks || stocks.length === 0) {
return null;
}
return (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={4}>
{stocks.map((stock, index) => (
<StockListItem
key={index}
stock={stock}
quote={quotes[stock.stock_code]}
eventTime={eventTime}
isInWatchlist={watchlistSet.has(stock.stock_code)}
onWatchlistToggle={onWatchlistToggle}
/>
))}
</SimpleGrid>
<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>
);
};

View File

@@ -6,10 +6,12 @@ import {
Box,
Flex,
VStack,
HStack,
Text,
Button,
IconButton,
Collapse,
Tooltip,
useColorModeValue,
} from '@chakra-ui/react';
import { StarIcon } from '@chakra-ui/icons';
@@ -103,162 +105,197 @@ const StockListItem = ({
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
borderRadius="md"
p={4}
onClick={handleViewDetail}
cursor="pointer"
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: 'md',
boxShadow: 'lg',
borderColor: 'blue.300',
}}
transition="all 0.2s"
>
<VStack align="stretch" spacing={3}>
{/* 顶部:股票代码 + 名称 + 操作按钮(上下两行布局) */}
<VStack align="stretch" spacing={2}>
{/* 第一行:股票代码 + 涨跌幅 + 操作按钮 */}
<Flex justify="space-between" align="center">
{/* 左侧:代码 + 涨跌幅 */}
<Flex align="baseline" gap={2}>
<Text
fontSize="md"
fontWeight="bold"
color={codeColor}
cursor="pointer"
onClick={handleViewDetail}
_hover={{ textDecoration: 'underline' }}
>
{stock.stock_code}
</Text>
<Text
fontSize="sm"
fontWeight="semibold"
color={getChangeColor(change)}
>
{formatChange(change)}
</Text>
</Flex>
{/* 右侧:操作按钮 */}
<Flex gap={2}>
{onWatchlistToggle && (
<IconButton
size="sm"
variant={isInWatchlist ? 'solid' : 'outline'}
colorScheme={isInWatchlist ? 'yellow' : 'gray'}
icon={<StarIcon />}
onClick={handleWatchlistClick}
aria-label={isInWatchlist ? '已关注' : '加自选'}
title={isInWatchlist ? '已关注' : '加自选'}
/>
)}
<Button
size="sm"
colorScheme="blue"
onClick={(e) => {
e.stopPropagation();
handleViewDetail();
}}
display={{ base: 'inline-flex', lg: 'none' }}
>
查看
</Button>
</Flex>
</Flex>
{/* 第二行:公司名称 + 分时图 + K线图 */}
<Flex align="center" gap={3}>
{/* 左侧:公司名称 */}
{/* 单行紧凑布局:名称+涨跌幅 | 分时图 | 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="medium"
fontWeight="bold"
color={codeColor}
bg={useColorModeValue('blue.50', 'blue.900')}
px={2}
py={0.5}
borderRadius="sm"
whiteSpace="nowrap"
flexShrink={0}
noOfLines={1}
cursor="pointer"
onClick={handleViewDetail}
_hover={{ textDecoration: 'underline' }}
>
{stock.stock_name}
</Text>
{/* 右侧:分时图 + K线图 */}
<Flex gap={2} flex={1} onClick={(e) => e.stopPropagation()}>
{/* 分时图 */}
<Box flex={1} minW={0}>
<MiniTimelineChart
stockCode={stock.stock_code}
eventTime={eventTime}
onClick={() => setIsModalOpen(true)}
/>
</Box>
{/* K线图 */}
<Box flex={1} minW={0}>
<MiniKLineChart
stockCode={stock.stock_code}
eventTime={eventTime}
onClick={() => setIsModalOpen(true)}
/>
</Box>
</Flex>
</Flex>
</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 borderTop="1px solid" borderColor={dividerColor} />
{/* 分时图 - 固定宽度 */}
<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 !== '--' && (
<Box>
<Text fontSize="xs" color={descColor} mb={1}>
关联描述
</Text>
<Collapse in={isDescExpanded} startingHeight={40}>
<Text
fontSize="sm"
color={nameColor}
lineHeight="1.6"
cursor={needTruncate ? "pointer" : "default"}
onClick={(e) => {
if (needTruncate) {
e.stopPropagation();
setIsDescExpanded(!isDescExpanded);
}
}}
_hover={needTruncate ? { opacity: 0.8 } : {}}
>
{relationText}
</Text>
</Collapse>
{needTruncate && (
<Button
size="xs"
variant="link"
colorScheme="blue"
onClick={(e) => {
e.stopPropagation();
setIsDescExpanded(!isDescExpanded);
}}
mt={1}
>
{isDescExpanded ? '收起' : '展开'}
</Button>
)}
{/* 合规提示 */}
<Text
fontSize="xs"
color="gray.500"
mt={2}
fontStyle="italic"
<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"
>
以上关联描述由AI生成仅供参考不构成投资建议
</Text>
</Box>
{/* 去掉"关联描述"标题 */}
<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>
)}
</VStack>
</HStack>
</Box>
{/* 股票详情弹窗 - 未打开时不渲染 */}

View File

@@ -22,7 +22,9 @@ const UnifiedSearchBox = ({
onSearch,
onSearchFocus,
popularKeywords = [],
filters = {}
filters = {},
mode, // 显示模式vertical, horizontal 等)
pageSize // 每页显示数量
}) => {
// 其他状态
@@ -145,7 +147,8 @@ const UnifiedSearchBox = ({
}
// ✅ 初始化行业分类(需要 industryData 加载完成)
if (filters.industry_code && industryData && industryData.length > 0) {
// ⚠️ 只在 industryValue 为空时才从 filters 初始化,避免用户选择后被覆盖
if (filters.industry_code && industryData && industryData.length > 0 && (!industryValue || industryValue.length === 0)) {
const path = findIndustryPath(filters.industry_code, industryData);
if (path) {
setIndustryValue(path);
@@ -154,6 +157,10 @@ const UnifiedSearchBox = ({
path
});
}
} else if (!filters.industry_code && industryValue && industryValue.length > 0) {
// 如果 filters 中没有行业代码,但本地有值,清空本地值
setIndustryValue([]);
logger.debug('UnifiedSearchBox', '清空行业分类filters中无值');
}
// ✅ 同步 filters.q 到输入框显示值
@@ -163,7 +170,54 @@ const UnifiedSearchBox = ({
// 如果 filters 中没有搜索关键词,清空输入框
setInputValue('');
}
}, [filters.sort, filters.importance, filters.industry_code, filters.q, industryData, findIndustryPath]);
// ✅ 初始化时间筛选(从 filters 中恢复)
// ⚠️ 只在 tradingTimeRange 为空时才从 filters 初始化,避免用户选择后被覆盖
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days;
if (hasTimeInFilters && (!tradingTimeRange || !tradingTimeRange.key)) {
// 根据参数推断按钮 key
let inferredKey = 'custom';
let inferredLabel = '';
if (filters.recent_days) {
// 推断是否是预设按钮
if (filters.recent_days === '7') {
inferredKey = 'week';
inferredLabel = '近一周';
} else if (filters.recent_days === '30') {
inferredKey = 'month';
inferredLabel = '近一月';
} else {
inferredLabel = `${filters.recent_days}`;
}
} else if (filters.start_date && filters.end_date) {
inferredLabel = `${dayjs(filters.start_date).format('MM-DD HH:mm')} - ${dayjs(filters.end_date).format('MM-DD HH:mm')}`;
}
// 从 filters 重建 tradingTimeRange 状态
const timeRange = {
start_date: filters.start_date || '',
end_date: filters.end_date || '',
recent_days: filters.recent_days || '',
label: inferredLabel,
key: inferredKey
};
setTradingTimeRange(timeRange);
logger.debug('UnifiedSearchBox', '初始化时间筛选', {
filters_time: {
start_date: filters.start_date,
end_date: filters.end_date,
recent_days: filters.recent_days
},
tradingTimeRange: timeRange
});
} else if (!hasTimeInFilters && tradingTimeRange) {
// 如果 filters 中没有时间参数,但本地有值,清空本地值
setTradingTimeRange(null);
logger.debug('UnifiedSearchBox', '清空时间筛选filters中无值');
}
}, [filters.sort, filters.importance, filters.industry_code, filters.q, filters.start_date, filters.end_date, filters.recent_days, industryData, findIndustryPath, industryValue, tradingTimeRange]);
// AutoComplete 搜索股票(模糊匹配 code 或 name
const handleSearch = (value) => {
@@ -242,59 +296,45 @@ const UnifiedSearchBox = ({
triggerSearch(params);
};
// ✅ 排序变化(使用防抖
// ✅ 排序变化(立即触发搜索
const handleSortChange = (value) => {
logger.debug('UnifiedSearchBox', '【1/5】排序值改变', {
logger.debug('UnifiedSearchBox', '排序值改变', {
oldValue: sort,
newValue: value
});
setSort(value);
// ⚠️ 注意:setState是异步的,此时sort仍是旧值
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
sort: sort, // 旧值
importance: importance,
dateRange: dateRange,
industryValue: industryValue
});
// 使用防抖搜索
const params = buildFilterParams({ sort: value });
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
// 取消之前的防抖搜索
if (debouncedSearchRef.current) {
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
debouncedSearchRef.current(params);
debouncedSearchRef.current.cancel();
}
// 立即触发搜索
const params = buildFilterParams({ sort: value });
logger.debug('UnifiedSearchBox', '排序改变,立即触发搜索', params);
triggerSearch(params);
};
// ✅ 行业分类变化(使用防抖
// ✅ 行业分类变化(立即触发搜索
const handleIndustryChange = (value) => {
logger.debug('UnifiedSearchBox', '【1/5】行业分类值改变', {
logger.debug('UnifiedSearchBox', '行业分类值改变', {
oldValue: industryValue,
newValue: value
});
setIndustryValue(value);
// ⚠️ 注意:setState是异步的,此时industryValue仍是旧值
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
industryValue: industryValue, // 旧值
sort: sort,
importance: importance,
dateRange: dateRange
});
// 使用防抖搜索 (需要从新值推导参数)
const params = {
...buildFilterParams(),
industry_code: value?.[value.length - 1] || ''
};
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
// 取消之前的防抖搜索
if (debouncedSearchRef.current) {
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
debouncedSearchRef.current(params);
debouncedSearchRef.current.cancel();
}
// 立即触发搜索
const params = buildFilterParams({
industry_code: value?.[value.length - 1] || ''
});
logger.debug('UnifiedSearchBox', '行业改变,立即触发搜索', params);
triggerSearch(params);
};
// ✅ 热门概念点击处理(立即搜索,不使用防抖) - 更新输入框并触发搜索
@@ -350,7 +390,7 @@ const UnifiedSearchBox = ({
setTradingTimeRange({ ...params, label, key });
// 立即触发搜索
const searchParams = buildFilterParams(params);
const searchParams = buildFilterParams({ ...params, mode });
logger.debug('UnifiedSearchBox', '交易时段筛选变化,立即触发搜索', {
timeConfig,
params: searchParams
@@ -392,7 +432,9 @@ const UnifiedSearchBox = ({
sort,
importance,
industryValue,
'filters.q': filters.q
'filters.q': filters.q,
mode,
pageSize
}
});
@@ -421,7 +463,7 @@ const UnifiedSearchBox = ({
// 基础参数overrides 优先级高于本地状态)
sort: actualSort,
importance: importanceValue,
page: 1,
// 搜索参数: 统一使用 q 参数进行搜索(话题/股票/关键词)
q: (overrides.q ?? filters.q) ?? '',
@@ -434,20 +476,35 @@ const UnifiedSearchBox = ({
recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ''),
// 最终 overrides 具有最高优先级
...overrides
...overrides,
page: 1,
per_page: overrides.mode === 'four-row' ? 30: 10
};
// 删除可能来自 overrides 的旧 per_page 值(将由 pageSize 重新设置)
delete result.per_page;
// 添加 return_type 参数(如果需要)
if (returnType) {
result.return_type = returnType;
}
// 添加 mode 和 per_page 参数(如果提供了的话)
if (mode !== undefined && mode !== null) {
result.mode = mode;
}
if (pageSize !== undefined && pageSize !== null) {
result.per_page = pageSize; // 后端实际使用的参数
}
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输出结果', result);
return result;
}, [sort, importance, filters.q, industryValue, tradingTimeRange]);
}, [sort, importance, filters.q, industryValue, tradingTimeRange, mode, pageSize]);
// ✅ 重置筛选 - 清空所有筛选器并触发搜索
const handleReset = () => {
console.log('%c🔄 [重置] 开始重置筛选条件', 'color: #FF4D4F; font-weight: bold;');
// 重置所有筛选器状态
setInputValue(''); // 清空输入框
setStockOptions([]);
@@ -465,11 +522,17 @@ const UnifiedSearchBox = ({
start_date: '',
end_date: '',
recent_days: '',
page: 1
page: 1,
_forceRefresh: Date.now() // 添加强制刷新标志,确保每次重置都触发更新
};
console.log('%c🔄 [重置] 重置参数', 'color: #FF4D4F;', resetParams);
logger.debug('UnifiedSearchBox', '重置筛选', resetParams);
console.log('%c🔄 [重置] 调用 onSearch', 'color: #FF4D4F;', typeof onSearch);
onSearch(resetParams);
console.log('%c✅ [重置] 重置完成', 'color: #52C41A; font-weight: bold;');
};
// 生成已选条件标签(包含所有筛选条件) - 从 filters 和本地状态读取
@@ -578,12 +641,12 @@ const UnifiedSearchBox = ({
};
return (
<Card>
<div style={{padding: '8px'}}>
{/* 第三行:行业 + 重要性 + 排序 */}
<Space style={{ width: '100%', justifyContent: 'space-between' }} size="middle">
{/* 左侧:筛选器组 */}
<Space size="middle" wrap>
<span style={{ fontSize: 14, color: '#666', fontWeight: 'bold' }}>筛选:</span>
<Space size="small" wrap>
<span style={{ fontSize: 12, color: '#666', fontWeight: 'bold' }}>筛选:</span>
{/* 行业分类 */}
<Cascader
value={industryValue}
@@ -602,19 +665,19 @@ const UnifiedSearchBox = ({
expandTrigger="hover"
displayRender={(labels) => labels.join(' > ')}
disabled={industryLoading}
style={{ width: 200 }}
size="middle"
style={{ width: 160 }}
size="small"
/>
{/* 重要性 */}
<Space size="small">
<span style={{ fontSize: 14, color: '#666' }}>重要性:</span>
<span style={{ fontSize: 12, color: '#666' }}>重要性:</span>
<AntSelect
mode="multiple"
value={importance}
onChange={handleImportanceChange}
style={{ width: 150 }}
size="middle"
style={{ width: 120 }}
size="small"
placeholder="全部"
maxTagCount={3}
>
@@ -626,27 +689,27 @@ const UnifiedSearchBox = ({
</Space>
{/* 搜索图标(可点击) + 搜索框 */}
<Space.Compact style={{ flex: 1, minWidth: 300 }}>
<Space.Compact style={{ flex: 1, minWidth: 250 }}>
<SearchOutlined
onClick={handleMainSearch}
style={{
fontSize: 18,
padding: '8px 12px',
background: '#f5f5f5',
fontSize: 14,
padding: '5px 8px',
background: '#e6f7ff',
borderRadius: '6px 0 0 6px',
display: 'flex',
alignItems: 'center',
color: '#666',
color: '#1890ff',
cursor: 'pointer',
transition: 'all 0.3s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = '#1890ff';
e.currentTarget.style.background = '#e6f7ff';
e.currentTarget.style.color = '#096dd9';
e.currentTarget.style.background = '#bae7ff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = '#666';
e.currentTarget.style.background = '#f5f5f5';
e.currentTarget.style.color = '#1890ff';
e.currentTarget.style.background = '#e6f7ff';
}}
/>
<AutoComplete
@@ -663,7 +726,7 @@ const UnifiedSearchBox = ({
}
}}
style={{ flex: 1 }}
size="middle"
size="small"
notFoundContent={inputValue && stockOptions.length === 0 ? "未找到匹配的股票" : null}
/>
</Space.Compact>
@@ -672,14 +735,14 @@ const UnifiedSearchBox = ({
<Button
icon={<CloseCircleOutlined />}
onClick={handleReset}
size="middle"
size="small"
style={{
borderRadius: 6,
border: '1px solid #d9d9d9',
backgroundColor: '#fff',
color: '#666',
fontWeight: 500,
padding: '4px 12px',
padding: '4px 10px',
display: 'flex',
alignItems: 'center',
gap: 4,
@@ -707,12 +770,12 @@ const UnifiedSearchBox = ({
{/* 右侧:排序 */}
<Space size="small">
<span style={{ fontSize: 14, color: '#666' }}>排序:</span>
<span style={{ fontSize: 12, color: '#666' }}>排序:</span>
<AntSelect
value={sort}
onChange={handleSortChange}
style={{ width: 120 }}
size="middle"
style={{ width: 100 }}
size="small"
>
<Option value="new">最新</Option>
<Option value="hot">最热</Option>
@@ -724,7 +787,7 @@ const UnifiedSearchBox = ({
</Space>
{/* 第一行:筛选 + 时间按钮 + 搜索图标 + 搜索框 */}
<Space wrap style={{ width: '100%', marginBottom: 12, marginTop: 8 }} size="middle">
<Space wrap style={{ width: '100%', marginBottom: 4, marginTop: 6 }} size="middle">
<span style={{ fontSize: 14, color: '#666', fontWeight: 'bold' }}>时间筛选:</span>
{/* 交易时段筛选 */}
@@ -735,29 +798,13 @@ const UnifiedSearchBox = ({
</Space>
{/* 第二行:热门概念 */}
<div style={{ marginTop: 8 }}>
<div style={{ marginTop: 2 }}>
<PopularKeywords
keywords={popularKeywords}
onKeywordClick={handleKeywordClick}
/>
</div>
{/* 已选条件标签 */}
{filterTags.length > 0 && (
<Space size={[8, 8]} wrap style={{ marginTop: 12 }}>
{filterTags.map(tag => (
<Tag
key={tag.key}
closable
onClose={() => handleRemoveTag(tag.key)}
color="blue"
>
{tag.label}
</Tag>
))}
</Space>
)}
</Card>
</div>
);
};

View File

@@ -24,9 +24,12 @@ export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {
return {
sort: searchParams.get('sort') || 'new',
importance: searchParams.get('importance') || 'all',
date_range: searchParams.get('date_range') || '',
q: searchParams.get('q') || '',
industry_code: searchParams.get('industry_code') || '',
// 时间筛选参数(从 TradingTimeFilter 传递)
start_date: searchParams.get('start_date') || '',
end_date: searchParams.get('end_date') || '',
recent_days: searchParams.get('recent_days') || '',
page: parseInt(searchParams.get('page') || '1', 10)
};
});

View File

@@ -152,7 +152,7 @@ const Community = () => {
return (
<Box minH="100vh" bg={bgColor}>
{/* 主内容区域 */}
<Container ref={containerRef} maxW="container.xl" pt={6} pb={8}>
<Container ref={containerRef} maxW="1600px" pt={6} pb={8}>
{/* 热点事件区域 */}
<HotEventsSection events={hotEvents} />