Compare commits
108 Commits
23188d5690
...
feature_20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2380c420c | ||
| 8417ab17be | |||
| dd59cb6385 | |||
|
|
d456c3cd5f | ||
|
|
b221c2669c | ||
|
|
356f865f09 | ||
| 512aca16d8 | |||
|
|
e05ea154a2 | ||
|
|
c33181a689 | ||
| 29f035b1cf | |||
| 513134f285 | |||
|
|
7da50aca40 | ||
|
|
72aae585d0 | ||
| 24c6c9e1c6 | |||
|
|
58254d3e8f | ||
|
|
760ce4d5e1 | ||
|
|
95c1eaf97b | ||
|
|
657c446594 | ||
|
|
10f519a764 | ||
|
|
f072256021 | ||
|
|
0e3bdc9b8c | ||
|
|
5e4c4e7cea | ||
|
|
31a7500388 | ||
|
|
03c113fe1b | ||
|
|
0f3bc06716 | ||
|
|
e568b5e05f | ||
| c5aaaabf17 | |||
| 9ede603c9f | |||
|
|
629c63f4ee | ||
|
|
d6bc2c7245 | ||
|
|
dc38199ae6 | ||
|
|
d93b5de319 | ||
|
|
199a54bc12 | ||
|
|
39feae87a6 | ||
|
|
a9dc1191bf | ||
|
|
227e1c9d15 | ||
|
|
b5cdceb92b | ||
|
|
aacbe5c31c | ||
|
|
197c792219 | ||
|
|
794581e429 | ||
|
|
b06d51813a | ||
|
|
5b25136c28 | ||
|
|
97c5ce0d4d | ||
|
|
f1bd9680b6 | ||
|
|
f02d0d0bd0 | ||
|
|
aa332537d4 | ||
|
|
b4b7eae1ba | ||
|
|
4559c57a62 | ||
|
|
9eb13206cc | ||
|
|
8db9a9429e | ||
|
|
916537f25b | ||
|
|
3d90ae7f74 | ||
|
|
3580385967 | ||
|
|
67c3d3a875 | ||
|
|
65d0ec5354 | ||
|
|
05307d6501 | ||
|
|
a5702b631c | ||
|
|
a96f778779 | ||
|
|
0a0d617b20 | ||
|
|
506f89e64e | ||
|
|
094793c022 | ||
|
|
873adda1fd | ||
|
|
b0ae5a2871 | ||
|
|
6f34cab6d1 | ||
|
|
5aebd4b113 | ||
|
|
70f2676c79 | ||
|
|
0b316a5ed8 | ||
|
|
72a009e1ae | ||
|
|
a92d556486 | ||
| 6df66abcb4 | |||
| 16d04a6d28 | |||
|
|
3f881d000b | ||
|
|
801113b7e5 | ||
|
|
e0cd71880b | ||
|
|
10a4dcb5d5 | ||
|
|
9429eb0559 | ||
|
|
e69f822150 | ||
|
|
13c3c74b92 | ||
|
|
bcf81f4d47 | ||
|
|
f0d30244d2 | ||
|
|
f2cdc0756c | ||
|
|
e91656d332 | ||
| 62d6487cbb | |||
| 246adf4538 | |||
| 8dcf643db7 | |||
|
|
5eb4227e29 | ||
|
|
34a6c402c4 | ||
|
|
6ad38594bb | ||
|
|
1ba8b8fd2f | ||
|
|
45b88309b3 | ||
|
|
28975f74e9 | ||
|
|
4eaeab521f | ||
|
|
9dcd4bfbf3 | ||
|
|
d2988d1a33 | ||
|
|
30520542c8 | ||
|
|
035bb9a66d | ||
|
|
8bd7f59d35 | ||
| 37eba48906 | |||
| 9ad2dc7fab | |||
| 0b1591c3dd | |||
| 0a28f235d3 | |||
|
|
db0d0ed269 | ||
|
|
43229a21c0 | ||
|
|
35198aa548 | ||
|
|
1f3fe8ce39 | ||
|
|
a9fee411ea | ||
|
|
433a982a20 | ||
|
|
cc210f9fda |
@@ -3,7 +3,15 @@
|
||||
"allow": [
|
||||
"Read(//Users/qiye/**)",
|
||||
"Bash(npm run lint:check)",
|
||||
"Bash(npm run build)"
|
||||
"Bash(npm run build)",
|
||||
"Bash(chmod +x /Users/qiye/Desktop/jzqy/vf_react/scripts/*.sh)",
|
||||
"Bash(node scripts/parseIndustryCSV.js)",
|
||||
"Bash(cat:*)",
|
||||
"Bash(npm cache clean --force)",
|
||||
"Bash(npm install)",
|
||||
"Bash(npm run start:mock)",
|
||||
"Bash(npm install fsevents@latest --save-optional --force)",
|
||||
"Bash(python -m py_compile:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
63
.env.deploy.example
Normal file
63
.env.deploy.example
Normal file
@@ -0,0 +1,63 @@
|
||||
# 部署配置文件
|
||||
# 首次使用请复制此文件为 .env.deploy 并填写真实配置
|
||||
|
||||
# ==================== 服务器配置 ====================
|
||||
# 服务器 IP 或域名
|
||||
SERVER_HOST=your-server-ip-or-domain
|
||||
|
||||
# SSH 用户名
|
||||
SERVER_USER=ubuntu
|
||||
|
||||
# SSH 端口
|
||||
SERVER_PORT=22
|
||||
|
||||
# SSH 密钥路径(留空使用默认 ~/.ssh/id_rsa)
|
||||
SSH_KEY_PATH=
|
||||
|
||||
# ==================== 路径配置 ====================
|
||||
# 服务器上的 Git 仓库路径
|
||||
REMOTE_PROJECT_PATH=/home/ubuntu/vf_react
|
||||
|
||||
# 生产环境部署路径
|
||||
PRODUCTION_PATH=/var/www/valuefrontier.cn
|
||||
|
||||
# 部署备份目录
|
||||
BACKUP_DIR=/home/ubuntu/deployments
|
||||
|
||||
# 部署日志目录
|
||||
LOG_DIR=/home/ubuntu/deploy-logs
|
||||
|
||||
# ==================== Git 配置 ====================
|
||||
# 部署分支
|
||||
DEPLOY_BRANCH=feature
|
||||
|
||||
# ==================== 备份配置 ====================
|
||||
# 保留备份数量
|
||||
KEEP_BACKUPS=5
|
||||
|
||||
# ==================== 企业微信通知配置 ====================
|
||||
# 是否启用企业微信通知 (true/false)
|
||||
ENABLE_WECHAT_NOTIFY=false
|
||||
|
||||
# 企业微信机器人 Webhook URL
|
||||
WECHAT_WEBHOOK_URL=
|
||||
|
||||
# 通知提及的用户(@all 或 手机号/userid)
|
||||
WECHAT_MENTIONED_LIST=
|
||||
|
||||
# ==================== 部署配置 ====================
|
||||
# 是否在部署前运行 npm install (true/false)
|
||||
RUN_NPM_INSTALL=true
|
||||
|
||||
# 是否在部署前运行 npm test (true/false)
|
||||
RUN_NPM_TEST=false
|
||||
|
||||
# 构建命令
|
||||
BUILD_COMMAND=npm run build
|
||||
|
||||
# ==================== 高级配置 ====================
|
||||
# SSH 连接超时时间(秒)
|
||||
SSH_TIMEOUT=30
|
||||
|
||||
# 部署超时时间(秒)
|
||||
DEPLOY_TIMEOUT=600
|
||||
BIN
__pycache__/app.cpython-310.pyc
Normal file
BIN
__pycache__/app.cpython-310.pyc
Normal file
Binary file not shown.
@@ -1,45 +0,0 @@
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
from flask_cors import CORS
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
import os
|
||||
|
||||
# 创建Flask应用
|
||||
app = Flask(__name__)
|
||||
|
||||
# 配置
|
||||
config_name = os.environ.get('FLASK_ENV', 'development')
|
||||
from config import config
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
# 初始化扩展
|
||||
db = SQLAlchemy(app)
|
||||
CORS(app, resources={r"/api/*": {"origins": "*"}})
|
||||
|
||||
# 时区设置
|
||||
def beijing_now():
|
||||
"""获取北京时间"""
|
||||
tz = pytz.timezone('Asia/Shanghai')
|
||||
return datetime.now(tz)
|
||||
|
||||
# 导入模型
|
||||
from app.models import *
|
||||
|
||||
# 创建数据库表
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# 注册路由
|
||||
from app.routes import events, stocks, limitanalyse, calendar, industries
|
||||
|
||||
app.register_blueprint(events.bp)
|
||||
app.register_blueprint(stocks.bp)
|
||||
app.register_blueprint(limitanalyse.bp)
|
||||
app.register_blueprint(calendar.bp)
|
||||
app.register_blueprint(industries.bp)
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("=== Value Frontier React 架构启动 ===")
|
||||
app.run(host='0.0.0.0', port=5001, debug=True)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,30 +0,0 @@
|
||||
# app/extensions.py
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import LoginManager
|
||||
from flask_compress import Compress
|
||||
from flask_cors import CORS
|
||||
from clickhouse_driver import Client as Cclient
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
# Database instances
|
||||
db = SQLAlchemy()
|
||||
|
||||
# Other extensions
|
||||
login_manager = LoginManager()
|
||||
compress = Compress()
|
||||
cors = CORS()
|
||||
|
||||
# Database engines (如果仍然需要直接使用 engine)
|
||||
engine = create_engine("mysql+pymysql://root:Zzl33818!@111.198.58.126:33060/stock", echo=False)
|
||||
engine_med = create_engine("mysql+pymysql://root:Zzl33818!@111.198.58.126:33060/med", echo=False)
|
||||
engine_2 = create_engine("mysql+pymysql://root:Zzl33818!@111.198.58.126:33060/valuefrontier", echo=False)
|
||||
|
||||
# ClickHouse client factory
|
||||
def get_clickhouse_client():
|
||||
return Cclient(
|
||||
host='111.198.58.126',
|
||||
port=18778,
|
||||
user='default',
|
||||
password='Zzl5588161!',
|
||||
database='stock'
|
||||
)
|
||||
504
app/models.py
504
app/models.py
@@ -1,504 +0,0 @@
|
||||
from app import db
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
import json
|
||||
|
||||
def beijing_now():
|
||||
"""获取北京时间"""
|
||||
tz = pytz.timezone('Asia/Shanghai')
|
||||
return datetime.now(tz)
|
||||
|
||||
class Post(db.Model):
|
||||
"""帖子模型"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
|
||||
# 内容
|
||||
title = db.Column(db.String(200)) # 标题(可选)
|
||||
content = db.Column(db.Text, nullable=False) # 内容
|
||||
content_type = db.Column(db.String(20), default='text') # 内容类型:text/rich_text/link
|
||||
|
||||
# 时间
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
|
||||
|
||||
# 统计
|
||||
likes_count = db.Column(db.Integer, default=0)
|
||||
comments_count = db.Column(db.Integer, default=0)
|
||||
view_count = db.Column(db.Integer, default=0)
|
||||
|
||||
# 状态
|
||||
status = db.Column(db.String(20), default='active') # active/hidden/deleted
|
||||
is_top = db.Column(db.Boolean, default=False) # 是否置顶
|
||||
|
||||
# 关系
|
||||
user = db.relationship('User', backref='posts')
|
||||
likes = db.relationship('PostLike', backref='post', lazy='dynamic')
|
||||
comments = db.relationship('Comment', backref='post', lazy='dynamic')
|
||||
|
||||
class User(db.Model):
|
||||
"""用户模型"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# 基础账号信息(注册时必填)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False) # 用户名
|
||||
email = db.Column(db.String(120), unique=True, nullable=False) # 邮箱
|
||||
password_hash = db.Column(db.String(128), nullable=False) # 密码哈希
|
||||
email_confirmed = db.Column(db.Boolean, default=False) # 邮箱是否验证
|
||||
|
||||
# 账号状态
|
||||
created_at = db.Column(db.DateTime, default=beijing_now) # 注册时间
|
||||
last_seen = db.Column(db.DateTime, default=beijing_now) # 最后活跃时间
|
||||
status = db.Column(db.String(20), default='active') # 账号状态 active/banned/deleted
|
||||
|
||||
# 个人资料(可选,后续在个人中心完善)
|
||||
nickname = db.Column(db.String(30)) # 社区昵称
|
||||
avatar_url = db.Column(db.String(200)) # 头像URL
|
||||
banner_url = db.Column(db.String(200)) # 个人主页背景图
|
||||
bio = db.Column(db.String(200)) # 个人简介
|
||||
gender = db.Column(db.String(10)) # 性别
|
||||
birth_date = db.Column(db.Date) # 生日
|
||||
location = db.Column(db.String(100)) # 所在地
|
||||
|
||||
# 联系方式(可选)
|
||||
phone = db.Column(db.String(20)) # 手机号
|
||||
wechat_id = db.Column(db.String(80)) # 微信号
|
||||
|
||||
# 实名认证信息(可选)
|
||||
real_name = db.Column(db.String(30)) # 真实姓名
|
||||
id_number = db.Column(db.String(18)) # 身份证号(加密存储)
|
||||
is_verified = db.Column(db.Boolean, default=False) # 是否实名认证
|
||||
verify_time = db.Column(db.DateTime) # 实名认证时间
|
||||
|
||||
# 投资相关信息(可选)
|
||||
trading_experience = db.Column(db.Integer) # 炒股年限
|
||||
investment_style = db.Column(db.String(50)) # 投资风格
|
||||
risk_preference = db.Column(db.String(20)) # 风险偏好
|
||||
investment_amount = db.Column(db.String(20)) # 投资规模
|
||||
preferred_markets = db.Column(db.String(200), default='[]') # 偏好市场 JSON
|
||||
|
||||
# 社区信息(系统自动更新)
|
||||
user_level = db.Column(db.Integer, default=1) # 用户等级
|
||||
reputation_score = db.Column(db.Integer, default=0) # 信用积分
|
||||
contribution_point = db.Column(db.Integer, default=0) # 贡献点数
|
||||
post_count = db.Column(db.Integer, default=0) # 发帖数
|
||||
comment_count = db.Column(db.Integer, default=0) # 评论数
|
||||
follower_count = db.Column(db.Integer, default=0) # 粉丝数
|
||||
following_count = db.Column(db.Integer, default=0) # 关注数
|
||||
|
||||
# 创作者信息(可选)
|
||||
is_creator = db.Column(db.Boolean, default=False) # 是否创作者
|
||||
creator_type = db.Column(db.String(20)) # 创作者类型
|
||||
creator_tags = db.Column(db.String(200), default='[]') # 创作者标签 JSON
|
||||
|
||||
# 系统设置
|
||||
email_notifications = db.Column(db.Boolean, default=True) # 邮件通知
|
||||
sms_notifications = db.Column(db.Boolean, default=False) # 短信通知
|
||||
wechat_notifications = db.Column(db.Boolean, default=False) # 微信通知
|
||||
notification_preferences = db.Column(db.String(500), default='{}') # 通知偏好 JSON
|
||||
privacy_level = db.Column(db.String(20), default='public') # 隐私级别
|
||||
theme_preference = db.Column(db.String(20), default='light') # 主题偏好
|
||||
blocked_keywords = db.Column(db.String(500), default='[]') # 屏蔽关键词 JSON
|
||||
# 手机号验证
|
||||
phone_confirmed = db.Column(db.Boolean, default=False) # 手机是否验证
|
||||
phone_confirm_time = db.Column(db.DateTime) # 手机验证时间
|
||||
|
||||
def __init__(self, username, email=None, password=None, phone=None):
|
||||
self.username = username
|
||||
if email:
|
||||
self.email = email
|
||||
if password:
|
||||
self.set_password(password)
|
||||
if phone:
|
||||
self.phone = phone
|
||||
|
||||
def set_password(self, password):
|
||||
from werkzeug.security import generate_password_hash
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
from werkzeug.security import check_password_hash
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def update_last_seen(self):
|
||||
self.last_seen = beijing_now()
|
||||
db.session.commit()
|
||||
|
||||
def get_preferred_markets(self):
|
||||
try:
|
||||
return json.loads(self.preferred_markets)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
def get_blocked_keywords(self):
|
||||
try:
|
||||
return json.loads(self.blocked_keywords)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
def get_notification_preferences(self):
|
||||
try:
|
||||
return json.loads(self.notification_preferences)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
def get_creator_tags(self):
|
||||
try:
|
||||
return json.loads(self.creator_tags)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
def set_preferred_markets(self, markets):
|
||||
self.preferred_markets = json.dumps(markets)
|
||||
|
||||
def set_blocked_keywords(self, keywords):
|
||||
self.blocked_keywords = json.dumps(keywords)
|
||||
|
||||
def set_notification_preferences(self, preferences):
|
||||
self.notification_preferences = json.dumps(preferences)
|
||||
|
||||
def set_creator_tags(self, tags):
|
||||
self.creator_tags = json.dumps(tags)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'username': self.username,
|
||||
'email': self.email,
|
||||
'nickname': self.nickname,
|
||||
'avatar_url': self.avatar_url,
|
||||
'bio': self.bio,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'last_seen': self.last_seen.isoformat() if self.last_seen else None,
|
||||
'status': self.status,
|
||||
'user_level': self.user_level,
|
||||
'reputation_score': self.reputation_score,
|
||||
'post_count': self.post_count,
|
||||
'follower_count': self.follower_count,
|
||||
'following_count': self.following_count
|
||||
}
|
||||
|
||||
def __repr__(self):
|
||||
return f'<User {self.username}>'
|
||||
|
||||
class Comment(db.Model):
|
||||
"""评论"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
parent_id = db.Column(db.Integer, db.ForeignKey('comment.id')) # 父评论ID,用于回复
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
status = db.Column(db.String(20), default='active')
|
||||
|
||||
user = db.relationship('User', backref='comments')
|
||||
replies = db.relationship('Comment', backref=db.backref('parent', remote_side=[id]))
|
||||
|
||||
|
||||
class CommentLike(db.Model):
|
||||
"""评论点赞记录(基于session_id以兼容匿名点赞)"""
|
||||
__tablename__ = 'comment_like'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
comment_id = db.Column(db.Integer, db.ForeignKey('comment.id'), nullable=False)
|
||||
session_id = db.Column(db.String(100), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
__table_args__ = (db.UniqueConstraint('comment_id', 'session_id'),)
|
||||
|
||||
class EventFollow(db.Model):
|
||||
"""事件关注"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
user = db.relationship('User', backref='event_follows')
|
||||
|
||||
__table_args__ = (db.UniqueConstraint('user_id', 'event_id'),)
|
||||
|
||||
class PostLike(db.Model):
|
||||
"""帖子点赞"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||
post_id = db.Column(db.Integer, db.ForeignKey('post.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
user = db.relationship('User', backref='post_likes')
|
||||
|
||||
__table_args__ = (db.UniqueConstraint('user_id', 'post_id'),)
|
||||
|
||||
class Event(db.Model):
|
||||
"""事件模型"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
|
||||
# 事件类型与状态
|
||||
event_type = db.Column(db.String(50))
|
||||
status = db.Column(db.String(20), default='active')
|
||||
|
||||
# 时间相关
|
||||
start_time = db.Column(db.DateTime, default=beijing_now)
|
||||
end_time = db.Column(db.DateTime)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
updated_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
# 热度与统计
|
||||
hot_score = db.Column(db.Float, default=0)
|
||||
view_count = db.Column(db.Integer, default=0)
|
||||
trending_score = db.Column(db.Float, default=0)
|
||||
post_count = db.Column(db.Integer, default=0)
|
||||
follower_count = db.Column(db.Integer, default=0)
|
||||
|
||||
# 关联信息
|
||||
related_industries = db.Column(db.JSON)
|
||||
keywords = db.Column(db.JSON)
|
||||
files = db.Column(db.JSON)
|
||||
importance = db.Column(db.String(20))
|
||||
related_avg_chg = db.Column(db.Float, default=0)
|
||||
related_max_chg = db.Column(db.Float, default=0)
|
||||
related_week_chg = db.Column(db.Float, default=0)
|
||||
|
||||
# 新增字段
|
||||
invest_score = db.Column(db.Integer) # 超预期得分
|
||||
expectation_surprise_score = db.Column(db.Integer)
|
||||
# 创建者信息
|
||||
creator_id = db.Column(db.Integer, db.ForeignKey('user.id'))
|
||||
creator = db.relationship('User', backref='created_events')
|
||||
|
||||
# 关系
|
||||
posts = db.relationship('Post', backref='event', lazy='dynamic')
|
||||
followers = db.relationship('EventFollow', backref='event', lazy='dynamic')
|
||||
related_stocks = db.relationship('RelatedStock', backref='event', lazy='dynamic')
|
||||
historical_events = db.relationship('HistoricalEvent', backref='event', lazy='dynamic')
|
||||
related_data = db.relationship('RelatedData', backref='event', lazy='dynamic')
|
||||
related_concepts = db.relationship('RelatedConcepts', backref='event', lazy='dynamic')
|
||||
|
||||
@property
|
||||
def keywords_list(self):
|
||||
if isinstance(self.keywords, list):
|
||||
return self.keywords
|
||||
elif isinstance(self.keywords, str):
|
||||
try:
|
||||
return json.loads(self.keywords)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
return []
|
||||
|
||||
def set_keywords(self, keywords):
|
||||
if isinstance(keywords, list):
|
||||
self.keywords = keywords
|
||||
elif isinstance(keywords, str):
|
||||
try:
|
||||
self.keywords = json.loads(keywords)
|
||||
except json.JSONDecodeError:
|
||||
self.keywords = [keywords]
|
||||
else:
|
||||
self.keywords = []
|
||||
|
||||
class RelatedStock(db.Model):
|
||||
"""相关标的模型"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
event_id = db.Column(db.Integer, db.ForeignKey('event.id'))
|
||||
stock_code = db.Column(db.String(20)) # 股票代码
|
||||
stock_name = db.Column(db.String(100)) # 股票名称
|
||||
sector = db.Column(db.String(100)) # 关联类型
|
||||
relation_desc = db.Column(db.String(1024)) # 关联原因描述
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
|
||||
correlation = db.Column(db.Float())
|
||||
momentum = db.Column(db.String(1024)) #动量
|
||||
|
||||
class RelatedData(db.Model):
|
||||
"""关联数据模型"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
event_id = db.Column(db.Integer, db.ForeignKey('event.id'))
|
||||
title = db.Column(db.String(200)) # 数据标题
|
||||
data_type = db.Column(db.String(50)) # 数据类型
|
||||
data_content = db.Column(db.JSON) # 数据内容(JSON格式)
|
||||
description = db.Column(db.Text) # 数据描述
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
class RelatedConcepts(db.Model):
|
||||
"""关联数据模型"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
event_id = db.Column(db.Integer, db.ForeignKey('event.id'))
|
||||
concept_code = db.Column(db.String(20)) # 数据标题
|
||||
concept = db.Column(db.String(100)) # 数据类型
|
||||
reason = db.Column(db.Text) # 数据描述
|
||||
image_paths = db.Column(db.JSON) # 数据内容(JSON格式)
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
@property
|
||||
def image_paths_list(self):
|
||||
if isinstance(self.image_paths, list):
|
||||
return self.image_paths
|
||||
elif isinstance(self.image_paths, str):
|
||||
try:
|
||||
return json.loads(self.image_paths)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
return []
|
||||
|
||||
def set_image_paths(self, image_paths):
|
||||
if isinstance(image_paths, list):
|
||||
self.image_paths = image_paths
|
||||
elif isinstance(image_paths, str):
|
||||
try:
|
||||
self.image_paths = json.loads(image_paths)
|
||||
except json.JSONDecodeError:
|
||||
self.image_paths = [image_paths]
|
||||
else:
|
||||
self.image_paths = []
|
||||
|
||||
def get_first_image_path(self):
|
||||
paths = self.image_paths_list
|
||||
return paths[0] if paths else None
|
||||
|
||||
class EventHotHistory(db.Model):
|
||||
"""事件热度历史记录"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
event_id = db.Column(db.Integer, db.ForeignKey('event.id'))
|
||||
score = db.Column(db.Float) # 总分
|
||||
interaction_score = db.Column(db.Float) # 互动分数
|
||||
follow_score = db.Column(db.Float) # 关注度分数
|
||||
view_score = db.Column(db.Float) # 浏览量分数
|
||||
recent_activity_score = db.Column(db.Float) # 最近活跃度分数
|
||||
time_decay = db.Column(db.Float) # 时间衰减因子
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
event = db.relationship('Event', backref='hot_history')
|
||||
|
||||
class EventTransmissionNode(db.Model):
|
||||
"""事件传导节点模型"""
|
||||
__tablename__ = 'event_transmission_nodes'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False)
|
||||
node_type = db.Column(db.Enum('company', 'industry', 'policy', 'technology',
|
||||
'market', 'event', 'other'), nullable=False)
|
||||
node_name = db.Column(db.String(200), nullable=False)
|
||||
node_description = db.Column(db.Text)
|
||||
importance_score = db.Column(db.Integer, default=50)
|
||||
stock_code = db.Column(db.String(20))
|
||||
is_main_event = db.Column(db.Boolean, default=False)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
|
||||
|
||||
# Relationships
|
||||
event = db.relationship('Event', backref='transmission_nodes')
|
||||
outgoing_edges = db.relationship('EventTransmissionEdge',
|
||||
foreign_keys='EventTransmissionEdge.from_node_id',
|
||||
backref='from_node', cascade='all, delete-orphan')
|
||||
incoming_edges = db.relationship('EventTransmissionEdge',
|
||||
foreign_keys='EventTransmissionEdge.to_node_id',
|
||||
backref='to_node', cascade='all, delete-orphan')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_event_node_type', 'event_id', 'node_type'),
|
||||
db.Index('idx_node_name', 'node_name'),
|
||||
)
|
||||
|
||||
class EventTransmissionEdge(db.Model):
|
||||
"""事件传导边模型"""
|
||||
__tablename__ = 'event_transmission_edges'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False)
|
||||
from_node_id = db.Column(db.Integer, db.ForeignKey('event_transmission_nodes.id'), nullable=False)
|
||||
to_node_id = db.Column(db.Integer, db.ForeignKey('event_transmission_nodes.id'), nullable=False)
|
||||
|
||||
transmission_type = db.Column(db.Enum('supply_chain', 'competition', 'policy',
|
||||
'technology', 'capital_flow', 'expectation',
|
||||
'cyclic_effect', 'other'), nullable=False)
|
||||
transmission_mechanism = db.Column(db.Text)
|
||||
direction = db.Column(db.Enum('positive', 'negative', 'neutral', 'mixed'), default='neutral')
|
||||
strength = db.Column(db.Integer, default=50)
|
||||
impact = db.Column(db.Text)
|
||||
is_circular = db.Column(db.Boolean, default=False)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
|
||||
|
||||
# Relationship
|
||||
event = db.relationship('Event', backref='transmission_edges')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_event_edge_type', 'event_id', 'transmission_type'),
|
||||
db.Index('idx_from_to_nodes', 'from_node_id', 'to_node_id'),
|
||||
)
|
||||
|
||||
class EventSankeyFlow(db.Model):
|
||||
"""事件桑基流模型"""
|
||||
__tablename__ = 'event_sankey_flows'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
event_id = db.Column(db.Integer, db.ForeignKey('event.id'), nullable=False)
|
||||
|
||||
# 流的基本信息
|
||||
source_node = db.Column(db.String(200), nullable=False)
|
||||
source_type = db.Column(db.Enum('event', 'policy', 'technology', 'industry',
|
||||
'company', 'product'), nullable=False)
|
||||
source_level = db.Column(db.Integer, nullable=False, default=0)
|
||||
|
||||
target_node = db.Column(db.String(200), nullable=False)
|
||||
target_type = db.Column(db.Enum('policy', 'technology', 'industry',
|
||||
'company', 'product'), nullable=False)
|
||||
target_level = db.Column(db.Integer, nullable=False, default=1)
|
||||
|
||||
# 流量信息
|
||||
flow_value = db.Column(db.Numeric(10, 2), nullable=False)
|
||||
flow_ratio = db.Column(db.Numeric(5, 4), nullable=False)
|
||||
|
||||
# 传导机制
|
||||
transmission_path = db.Column(db.String(500))
|
||||
impact_description = db.Column(db.Text)
|
||||
evidence_strength = db.Column(db.Integer, default=50)
|
||||
|
||||
# 时间戳
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now)
|
||||
|
||||
# 关系
|
||||
event = db.relationship('Event', backref='sankey_flows')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_event_flow', 'event_id'),
|
||||
db.Index('idx_source_target', 'source_node', 'target_node'),
|
||||
)
|
||||
|
||||
class HistoricalEvent(db.Model):
|
||||
"""历史事件模型"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
event_id = db.Column(db.Integer, db.ForeignKey('event.id'))
|
||||
title = db.Column(db.String(200))
|
||||
content = db.Column(db.Text)
|
||||
event_date = db.Column(db.DateTime)
|
||||
relevance = db.Column(db.Integer) # 相关性
|
||||
importance = db.Column(db.Integer) # 重要程度
|
||||
related_stock = db.Column(db.JSON) # 保留JSON字段
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
# 新增关系
|
||||
stocks = db.relationship('HistoricalEventStock', backref='historical_event', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
class HistoricalEventStock(db.Model):
|
||||
"""历史事件相关股票模型"""
|
||||
__tablename__ = 'historical_event_stocks'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
historical_event_id = db.Column(db.Integer, db.ForeignKey('historical_event.id'), nullable=False)
|
||||
stock_code = db.Column(db.String(20), nullable=False)
|
||||
stock_name = db.Column(db.String(50))
|
||||
relation_desc = db.Column(db.Text)
|
||||
correlation = db.Column(db.Float, default=0.5)
|
||||
sector = db.Column(db.String(100))
|
||||
created_at = db.Column(db.DateTime, default=beijing_now)
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_historical_event_stock', 'historical_event_id', 'stock_code'),
|
||||
)
|
||||
@@ -1 +0,0 @@
|
||||
# 路由包初始化文件
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,121 +0,0 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
|
||||
bp = Blueprint('calendar', __name__, url_prefix='/api/v1/calendar')
|
||||
|
||||
@bp.route('/event-counts', methods=['GET'])
|
||||
def get_event_counts():
|
||||
"""获取事件数量统计"""
|
||||
try:
|
||||
year = request.args.get('year', '2027')
|
||||
month = request.args.get('month', '10')
|
||||
|
||||
# 模拟事件数量数据
|
||||
event_counts = []
|
||||
for day in range(1, 32):
|
||||
count = (day % 7) + 1 # 模拟每天1-7个事件
|
||||
event_counts.append({
|
||||
'date': f'{year}-{month.zfill(2)}-{day:02d}',
|
||||
'count': count
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': event_counts
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting event counts: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/events', methods=['GET'])
|
||||
def get_calendar_events():
|
||||
"""获取日历事件"""
|
||||
try:
|
||||
year = request.args.get('year', '2027')
|
||||
month = request.args.get('month', '10')
|
||||
event_type = request.args.get('type', 'all')
|
||||
|
||||
# 模拟日历事件数据
|
||||
events = []
|
||||
for day in range(1, 32):
|
||||
for i in range((day % 7) + 1):
|
||||
event = {
|
||||
'id': f'{year}{month.zfill(2)}{day:02d}{i}',
|
||||
'title': f'事件{day}-{i+1}',
|
||||
'date': f'{year}-{month.zfill(2)}-{day:02d}',
|
||||
'type': ['政策', '技术', '产业', '公司'][i % 4],
|
||||
'importance': ['高', '中', '低'][i % 3],
|
||||
'status': 'active'
|
||||
}
|
||||
events.append(event)
|
||||
|
||||
# 根据类型过滤
|
||||
if event_type != 'all':
|
||||
events = [e for e in events if e['type'] == event_type]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': events
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting calendar events: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/events/<int:event_id>', methods=['GET'])
|
||||
def get_calendar_event_detail(event_id):
|
||||
"""获取日历事件详情"""
|
||||
try:
|
||||
# 模拟事件详情
|
||||
event_detail = {
|
||||
'id': event_id,
|
||||
'title': f'事件{event_id}详情',
|
||||
'description': f'这是事件{event_id}的详细描述',
|
||||
'date': '2027-10-15',
|
||||
'type': '政策',
|
||||
'importance': '高',
|
||||
'status': 'active',
|
||||
'related_stocks': [
|
||||
{'code': '000001', 'name': '股票A'},
|
||||
{'code': '000002', 'name': '股票B'}
|
||||
],
|
||||
'keywords': ['政策', '改革', '创新'],
|
||||
'files': [
|
||||
{'name': '报告.pdf', 'url': '/files/report.pdf'},
|
||||
{'name': '数据.xlsx', 'url': '/files/data.xlsx'}
|
||||
]
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': event_detail
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting calendar event detail: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
def get_event_class(count):
|
||||
"""根据事件数量获取CSS类"""
|
||||
if count == 0:
|
||||
return 'no-events'
|
||||
elif count <= 3:
|
||||
return 'few-events'
|
||||
elif count <= 6:
|
||||
return 'medium-events'
|
||||
else:
|
||||
return 'many-events'
|
||||
|
||||
def parse_json_field(field_value):
|
||||
"""解析JSON字段"""
|
||||
if isinstance(field_value, str):
|
||||
try:
|
||||
return json.loads(field_value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
elif isinstance(field_value, (list, dict)):
|
||||
return field_value
|
||||
else:
|
||||
return []
|
||||
@@ -1,385 +0,0 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app import db
|
||||
from app.models import Event, RelatedStock, RelatedConcepts, HistoricalEvent, EventTransmissionNode, EventTransmissionEdge, EventSankeyFlow
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
bp = Blueprint('events', __name__, url_prefix='/api/events')
|
||||
|
||||
|
||||
|
||||
@bp.route('/<int:event_id>', methods=['GET'])
|
||||
def get_event_detail(event_id):
|
||||
"""获取事件详情"""
|
||||
try:
|
||||
event = Event.query.get(event_id)
|
||||
if not event:
|
||||
return jsonify({'success': False, 'error': '事件不存在'}), 404
|
||||
|
||||
# 获取相关股票
|
||||
related_stocks = RelatedStock.query.filter_by(event_id=event_id).all()
|
||||
stocks_data = []
|
||||
for stock in related_stocks:
|
||||
stocks_data.append({
|
||||
'id': stock.id,
|
||||
'stock_code': stock.stock_code,
|
||||
'stock_name': stock.stock_name,
|
||||
'sector': stock.sector,
|
||||
'relation_desc': stock.relation_desc,
|
||||
'correlation': stock.correlation,
|
||||
'momentum': stock.momentum,
|
||||
'created_at': stock.created_at.isoformat() if stock.created_at else None
|
||||
})
|
||||
|
||||
# 获取相关概念
|
||||
related_concepts = RelatedConcepts.query.filter_by(event_id=event_id).all()
|
||||
concepts_data = []
|
||||
for concept in related_concepts:
|
||||
concepts_data.append({
|
||||
'id': concept.id,
|
||||
'concept_code': concept.concept_code,
|
||||
'concept': concept.concept,
|
||||
'reason': concept.reason,
|
||||
'image_paths': concept.image_paths_list,
|
||||
'created_at': concept.created_at.isoformat() if concept.created_at else None
|
||||
})
|
||||
|
||||
event_data = {
|
||||
'id': event.id,
|
||||
'title': event.title,
|
||||
'description': event.description,
|
||||
'event_type': event.event_type,
|
||||
'status': event.status,
|
||||
'start_time': event.start_time.isoformat() if event.start_time else None,
|
||||
'end_time': event.end_time.isoformat() if event.end_time else None,
|
||||
'created_at': event.created_at.isoformat() if event.created_at else None,
|
||||
'updated_at': event.updated_at.isoformat() if event.updated_at else None,
|
||||
'hot_score': event.hot_score,
|
||||
'view_count': event.view_count,
|
||||
'trending_score': event.trending_score,
|
||||
'post_count': event.post_count,
|
||||
'follower_count': event.follower_count,
|
||||
'related_industries': event.related_industries,
|
||||
'keywords': event.keywords_list,
|
||||
'files': event.files,
|
||||
'importance': event.importance,
|
||||
'related_avg_chg': event.related_avg_chg,
|
||||
'related_max_chg': event.related_max_chg,
|
||||
'related_week_chg': event.related_week_chg,
|
||||
'invest_score': event.invest_score,
|
||||
'expectation_surprise_score': event.expectation_surprise_score,
|
||||
'related_stocks': stocks_data,
|
||||
'related_concepts': concepts_data
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': event_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting event detail: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/<int:event_id>/stocks', methods=['GET'])
|
||||
def get_related_stocks(event_id):
|
||||
"""获取事件相关股票"""
|
||||
try:
|
||||
stocks = RelatedStock.query.filter_by(event_id=event_id).all()
|
||||
stocks_data = []
|
||||
for stock in stocks:
|
||||
stocks_data.append({
|
||||
'id': stock.id,
|
||||
'stock_code': stock.stock_code,
|
||||
'stock_name': stock.stock_name,
|
||||
'sector': stock.sector,
|
||||
'relation_desc': stock.relation_desc,
|
||||
'correlation': stock.correlation,
|
||||
'momentum': stock.momentum,
|
||||
'created_at': stock.created_at.isoformat() if stock.created_at else None
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': stocks_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting related stocks: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/<int:event_id>/stocks', methods=['POST'])
|
||||
def add_related_stock(event_id):
|
||||
"""添加相关股票"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'success': False, 'error': '请提供数据'}), 400
|
||||
|
||||
# 检查事件是否存在
|
||||
event = Event.query.get(event_id)
|
||||
if not event:
|
||||
return jsonify({'success': False, 'error': '事件不存在'}), 404
|
||||
|
||||
# 创建新的相关股票记录
|
||||
new_stock = RelatedStock(
|
||||
event_id=event_id,
|
||||
stock_code=data['stock_code'],
|
||||
stock_name=data.get('stock_name', ''),
|
||||
sector=data.get('sector', ''),
|
||||
relation_desc=data['relation_desc'],
|
||||
correlation=data.get('correlation', 0.5),
|
||||
momentum=data.get('momentum', '')
|
||||
)
|
||||
|
||||
db.session.add(new_stock)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '相关股票添加成功',
|
||||
'data': {
|
||||
'id': new_stock.id,
|
||||
'stock_code': new_stock.stock_code,
|
||||
'stock_name': new_stock.stock_name,
|
||||
'sector': new_stock.sector,
|
||||
'relation_desc': new_stock.relation_desc,
|
||||
'correlation': new_stock.correlation,
|
||||
'momentum': new_stock.momentum
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"Error adding related stock: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/stocks/<int:stock_id>', methods=['DELETE'])
|
||||
def delete_related_stock(stock_id):
|
||||
"""删除相关股票"""
|
||||
try:
|
||||
stock = RelatedStock.query.get(stock_id)
|
||||
if not stock:
|
||||
return jsonify({'success': False, 'error': '相关股票不存在'}), 404
|
||||
|
||||
db.session.delete(stock)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '相关股票删除成功'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"Error deleting related stock: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/<int:event_id>/concepts', methods=['GET'])
|
||||
def get_related_concepts(event_id):
|
||||
"""获取事件相关概念"""
|
||||
try:
|
||||
concepts = RelatedConcepts.query.filter_by(event_id=event_id).all()
|
||||
concepts_data = []
|
||||
for concept in concepts:
|
||||
concepts_data.append({
|
||||
'id': concept.id,
|
||||
'concept_code': concept.concept_code,
|
||||
'concept': concept.concept,
|
||||
'reason': concept.reason,
|
||||
'image_paths': concept.image_paths_list,
|
||||
'created_at': concept.created_at.isoformat() if concept.created_at else None
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': concepts_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting related concepts: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/<int:event_id>/historical', methods=['GET'])
|
||||
def get_historical_events(event_id):
|
||||
"""获取历史事件"""
|
||||
try:
|
||||
historical_events = HistoricalEvent.query.filter_by(event_id=event_id).all()
|
||||
events_data = []
|
||||
for event in historical_events:
|
||||
events_data.append({
|
||||
'id': event.id,
|
||||
'title': event.title,
|
||||
'content': event.content,
|
||||
'event_date': event.event_date.isoformat() if event.event_date else None,
|
||||
'relevance': event.relevance,
|
||||
'importance': event.importance,
|
||||
'related_stock': event.related_stock,
|
||||
'created_at': event.created_at.isoformat() if event.created_at else None
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': events_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting historical events: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/<int:event_id>/expectation-score', methods=['GET'])
|
||||
def get_expectation_score(event_id):
|
||||
"""获取超预期得分"""
|
||||
try:
|
||||
event = Event.query.get(event_id)
|
||||
if not event:
|
||||
return jsonify({'success': False, 'error': '事件不存在'}), 404
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'invest_score': event.invest_score,
|
||||
'expectation_surprise_score': event.expectation_surprise_score
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting expectation score: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/<int:event_id>/follow', methods=['POST'])
|
||||
def toggle_event_follow(event_id):
|
||||
"""关注/取消关注事件"""
|
||||
try:
|
||||
# 这里需要用户认证,暂时返回成功
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '关注状态更新成功'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error toggling event follow: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/<int:event_id>/transmission', methods=['GET'])
|
||||
def get_transmission_chain(event_id):
|
||||
"""获取事件传导链"""
|
||||
try:
|
||||
# 获取传导节点
|
||||
nodes = EventTransmissionNode.query.filter_by(event_id=event_id).all()
|
||||
nodes_data = []
|
||||
for node in nodes:
|
||||
nodes_data.append({
|
||||
'id': node.id,
|
||||
'node_type': node.node_type,
|
||||
'node_name': node.node_name,
|
||||
'node_description': node.node_description,
|
||||
'importance_score': node.importance_score,
|
||||
'stock_code': node.stock_code,
|
||||
'is_main_event': node.is_main_event
|
||||
})
|
||||
|
||||
# 获取传导边
|
||||
edges = EventTransmissionEdge.query.filter_by(event_id=event_id).all()
|
||||
edges_data = []
|
||||
for edge in edges:
|
||||
edges_data.append({
|
||||
'id': edge.id,
|
||||
'from_node_id': edge.from_node_id,
|
||||
'to_node_id': edge.to_node_id,
|
||||
'transmission_type': edge.transmission_type,
|
||||
'transmission_mechanism': edge.transmission_mechanism,
|
||||
'direction': edge.direction,
|
||||
'strength': edge.strength,
|
||||
'impact': edge.impact,
|
||||
'is_circular': edge.is_circular
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'nodes': nodes_data,
|
||||
'edges': edges_data
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting transmission chain: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/<int:event_id>/sankey-data')
|
||||
def get_event_sankey_data(event_id):
|
||||
"""获取事件桑基图数据"""
|
||||
try:
|
||||
flows = EventSankeyFlow.query.filter_by(event_id=event_id).all()
|
||||
flows_data = []
|
||||
for flow in flows:
|
||||
flows_data.append({
|
||||
'id': flow.id,
|
||||
'source_node': flow.source_node,
|
||||
'source_type': flow.source_type,
|
||||
'source_level': flow.source_level,
|
||||
'target_node': flow.target_node,
|
||||
'target_type': flow.target_type,
|
||||
'target_level': flow.target_level,
|
||||
'flow_value': float(flow.flow_value),
|
||||
'flow_ratio': float(flow.flow_ratio),
|
||||
'transmission_path': flow.transmission_path,
|
||||
'impact_description': flow.impact_description,
|
||||
'evidence_strength': flow.evidence_strength
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': flows_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting sankey data: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/<int:event_id>/chain-analysis')
|
||||
def get_event_chain_analysis(event_id):
|
||||
"""获取事件链分析"""
|
||||
try:
|
||||
# 这里可以添加更复杂的链分析逻辑
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'event_id': event_id,
|
||||
'analysis': '链分析数据'
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting chain analysis: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/<int:event_id>/chain-node/<int:node_id>', methods=['GET'])
|
||||
def get_chain_node_detail(event_id, node_id):
|
||||
"""获取链节点详情"""
|
||||
try:
|
||||
node = EventTransmissionNode.query.filter_by(
|
||||
event_id=event_id,
|
||||
id=node_id
|
||||
).first()
|
||||
|
||||
if not node:
|
||||
return jsonify({'success': False, 'error': '节点不存在'}), 404
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'id': node.id,
|
||||
'node_type': node.node_type,
|
||||
'node_name': node.node_name,
|
||||
'node_description': node.node_description,
|
||||
'importance_score': node.importance_score,
|
||||
'stock_code': node.stock_code,
|
||||
'is_main_event': node.is_main_event
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting chain node detail: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
@@ -1,511 +0,0 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
import json
|
||||
|
||||
bp = Blueprint('industries', __name__, url_prefix='/api')
|
||||
|
||||
@bp.route('/classifications', methods=['GET'])
|
||||
def get_classifications():
|
||||
"""获取行业分类"""
|
||||
try:
|
||||
# 模拟行业分类数据
|
||||
classifications = [
|
||||
{
|
||||
'id': 1,
|
||||
'name': '申万一级行业',
|
||||
'description': '申万一级行业分类标准',
|
||||
'levels': [
|
||||
{'id': 1, 'name': '农林牧渔'},
|
||||
{'id': 2, 'name': '采掘'},
|
||||
{'id': 3, 'name': '化工'},
|
||||
{'id': 4, 'name': '钢铁'},
|
||||
{'id': 5, 'name': '有色金属'},
|
||||
{'id': 6, 'name': '建筑材料'},
|
||||
{'id': 7, 'name': '建筑装饰'},
|
||||
{'id': 8, 'name': '电气设备'},
|
||||
{'id': 9, 'name': '国防军工'},
|
||||
{'id': 10, 'name': '汽车'},
|
||||
{'id': 11, 'name': '家用电器'},
|
||||
{'id': 12, 'name': '纺织服装'},
|
||||
{'id': 13, 'name': '轻工制造'},
|
||||
{'id': 14, 'name': '医药生物'},
|
||||
{'id': 15, 'name': '公用事业'},
|
||||
{'id': 16, 'name': '交通运输'},
|
||||
{'id': 17, 'name': '房地产'},
|
||||
{'id': 18, 'name': '商业贸易'},
|
||||
{'id': 19, 'name': '休闲服务'},
|
||||
{'id': 20, 'name': '银行'},
|
||||
{'id': 21, 'name': '非银金融'},
|
||||
{'id': 22, 'name': '综合'},
|
||||
{'id': 23, 'name': '计算机'},
|
||||
{'id': 24, 'name': '传媒'},
|
||||
{'id': 25, 'name': '通信'},
|
||||
{'id': 26, 'name': '电子'},
|
||||
{'id': 27, 'name': '机械设备'},
|
||||
{'id': 28, 'name': '食品饮料'}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': classifications
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting classifications: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/levels', methods=['GET'])
|
||||
def get_industry_levels():
|
||||
"""获取行业层级"""
|
||||
try:
|
||||
classification_id = request.args.get('classification_id', '1')
|
||||
|
||||
# 模拟行业层级数据
|
||||
levels = [
|
||||
{
|
||||
'id': 1,
|
||||
'name': '农林牧渔',
|
||||
'code': '801010',
|
||||
'description': '农业、林业、畜牧业、渔业',
|
||||
'stock_count': 45,
|
||||
'avg_change': 1.2,
|
||||
'total_market_cap': 500000000000,
|
||||
'sub_industries': [
|
||||
{'id': 101, 'name': '种植业', 'stock_count': 20},
|
||||
{'id': 102, 'name': '林业', 'stock_count': 8},
|
||||
{'id': 103, 'name': '畜牧业', 'stock_count': 12},
|
||||
{'id': 104, 'name': '渔业', 'stock_count': 5}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'name': '采掘',
|
||||
'code': '801020',
|
||||
'description': '煤炭、石油、天然气、有色金属矿采选',
|
||||
'stock_count': 38,
|
||||
'avg_change': 0.8,
|
||||
'total_market_cap': 800000000000,
|
||||
'sub_industries': [
|
||||
{'id': 201, 'name': '煤炭开采', 'stock_count': 15},
|
||||
{'id': 202, 'name': '石油开采', 'stock_count': 8},
|
||||
{'id': 203, 'name': '有色金属矿采选', 'stock_count': 15}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 3,
|
||||
'name': '化工',
|
||||
'code': '801030',
|
||||
'description': '化学原料、化学制品、化学纤维',
|
||||
'stock_count': 156,
|
||||
'avg_change': 1.5,
|
||||
'total_market_cap': 1200000000000,
|
||||
'sub_industries': [
|
||||
{'id': 301, 'name': '化学原料', 'stock_count': 45},
|
||||
{'id': 302, 'name': '化学制品', 'stock_count': 78},
|
||||
{'id': 303, 'name': '化学纤维', 'stock_count': 33}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 4,
|
||||
'name': '钢铁',
|
||||
'code': '801040',
|
||||
'description': '钢铁冶炼、钢铁制品',
|
||||
'stock_count': 32,
|
||||
'avg_change': 0.6,
|
||||
'total_market_cap': 600000000000,
|
||||
'sub_industries': [
|
||||
{'id': 401, 'name': '钢铁冶炼', 'stock_count': 18},
|
||||
{'id': 402, 'name': '钢铁制品', 'stock_count': 14}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 5,
|
||||
'name': '有色金属',
|
||||
'code': '801050',
|
||||
'description': '有色金属冶炼、有色金属制品',
|
||||
'stock_count': 67,
|
||||
'avg_change': 1.8,
|
||||
'total_market_cap': 900000000000,
|
||||
'sub_industries': [
|
||||
{'id': 501, 'name': '有色金属冶炼', 'stock_count': 35},
|
||||
{'id': 502, 'name': '有色金属制品', 'stock_count': 32}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 6,
|
||||
'name': '建筑材料',
|
||||
'code': '801060',
|
||||
'description': '水泥、玻璃、陶瓷、其他建材',
|
||||
'stock_count': 89,
|
||||
'avg_change': 1.1,
|
||||
'total_market_cap': 700000000000,
|
||||
'sub_industries': [
|
||||
{'id': 601, 'name': '水泥', 'stock_count': 25},
|
||||
{'id': 602, 'name': '玻璃', 'stock_count': 18},
|
||||
{'id': 603, 'name': '陶瓷', 'stock_count': 12},
|
||||
{'id': 604, 'name': '其他建材', 'stock_count': 34}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 7,
|
||||
'name': '建筑装饰',
|
||||
'code': '801070',
|
||||
'description': '房屋建设、装修装饰、园林工程',
|
||||
'stock_count': 45,
|
||||
'avg_change': 0.9,
|
||||
'total_market_cap': 400000000000,
|
||||
'sub_industries': [
|
||||
{'id': 701, 'name': '房屋建设', 'stock_count': 15},
|
||||
{'id': 702, 'name': '装修装饰', 'stock_count': 20},
|
||||
{'id': 703, 'name': '园林工程', 'stock_count': 10}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 8,
|
||||
'name': '电气设备',
|
||||
'code': '801080',
|
||||
'description': '电机、电气自动化设备、电源设备',
|
||||
'stock_count': 134,
|
||||
'avg_change': 2.1,
|
||||
'total_market_cap': 1500000000000,
|
||||
'sub_industries': [
|
||||
{'id': 801, 'name': '电机', 'stock_count': 25},
|
||||
{'id': 802, 'name': '电气自动化设备', 'stock_count': 45},
|
||||
{'id': 803, 'name': '电源设备', 'stock_count': 64}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 9,
|
||||
'name': '国防军工',
|
||||
'code': '801090',
|
||||
'description': '航天装备、航空装备、地面兵装',
|
||||
'stock_count': 28,
|
||||
'avg_change': 1.6,
|
||||
'total_market_cap': 300000000000,
|
||||
'sub_industries': [
|
||||
{'id': 901, 'name': '航天装备', 'stock_count': 8},
|
||||
{'id': 902, 'name': '航空装备', 'stock_count': 12},
|
||||
{'id': 903, 'name': '地面兵装', 'stock_count': 8}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 10,
|
||||
'name': '汽车',
|
||||
'code': '801100',
|
||||
'description': '汽车整车、汽车零部件',
|
||||
'stock_count': 78,
|
||||
'avg_change': 1.3,
|
||||
'total_market_cap': 1100000000000,
|
||||
'sub_industries': [
|
||||
{'id': 1001, 'name': '汽车整车', 'stock_count': 25},
|
||||
{'id': 1002, 'name': '汽车零部件', 'stock_count': 53}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 11,
|
||||
'name': '家用电器',
|
||||
'code': '801110',
|
||||
'description': '白色家电、小家电、家电零部件',
|
||||
'stock_count': 56,
|
||||
'avg_change': 1.0,
|
||||
'total_market_cap': 800000000000,
|
||||
'sub_industries': [
|
||||
{'id': 1101, 'name': '白色家电', 'stock_count': 20},
|
||||
{'id': 1102, 'name': '小家电', 'stock_count': 18},
|
||||
{'id': 1103, 'name': '家电零部件', 'stock_count': 18}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 12,
|
||||
'name': '纺织服装',
|
||||
'code': '801120',
|
||||
'description': '纺织制造、服装家纺',
|
||||
'stock_count': 67,
|
||||
'avg_change': 0.7,
|
||||
'total_market_cap': 500000000000,
|
||||
'sub_industries': [
|
||||
{'id': 1201, 'name': '纺织制造', 'stock_count': 35},
|
||||
{'id': 1202, 'name': '服装家纺', 'stock_count': 32}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 13,
|
||||
'name': '轻工制造',
|
||||
'code': '801130',
|
||||
'description': '造纸、包装印刷、家用轻工',
|
||||
'stock_count': 89,
|
||||
'avg_change': 0.9,
|
||||
'total_market_cap': 600000000000,
|
||||
'sub_industries': [
|
||||
{'id': 1301, 'name': '造纸', 'stock_count': 25},
|
||||
{'id': 1302, 'name': '包装印刷', 'stock_count': 30},
|
||||
{'id': 1303, 'name': '家用轻工', 'stock_count': 34}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 14,
|
||||
'name': '医药生物',
|
||||
'code': '801140',
|
||||
'description': '化学制药、中药、生物制品、医疗器械',
|
||||
'stock_count': 234,
|
||||
'avg_change': 1.9,
|
||||
'total_market_cap': 2500000000000,
|
||||
'sub_industries': [
|
||||
{'id': 1401, 'name': '化学制药', 'stock_count': 78},
|
||||
{'id': 1402, 'name': '中药', 'stock_count': 45},
|
||||
{'id': 1403, 'name': '生物制品', 'stock_count': 56},
|
||||
{'id': 1404, 'name': '医疗器械', 'stock_count': 55}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 15,
|
||||
'name': '公用事业',
|
||||
'code': '801150',
|
||||
'description': '电力、燃气、水务',
|
||||
'stock_count': 78,
|
||||
'avg_change': 0.5,
|
||||
'total_market_cap': 900000000000,
|
||||
'sub_industries': [
|
||||
{'id': 1501, 'name': '电力', 'stock_count': 45},
|
||||
{'id': 1502, 'name': '燃气', 'stock_count': 18},
|
||||
{'id': 1503, 'name': '水务', 'stock_count': 15}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 16,
|
||||
'name': '交通运输',
|
||||
'code': '801160',
|
||||
'description': '港口、公路、铁路、航空',
|
||||
'stock_count': 67,
|
||||
'avg_change': 0.8,
|
||||
'total_market_cap': 800000000000,
|
||||
'sub_industries': [
|
||||
{'id': 1601, 'name': '港口', 'stock_count': 15},
|
||||
{'id': 1602, 'name': '公路', 'stock_count': 20},
|
||||
{'id': 1603, 'name': '铁路', 'stock_count': 12},
|
||||
{'id': 1604, 'name': '航空', 'stock_count': 20}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 17,
|
||||
'name': '房地产',
|
||||
'code': '801170',
|
||||
'description': '房地产开发、房地产服务',
|
||||
'stock_count': 89,
|
||||
'avg_change': 0.6,
|
||||
'total_market_cap': 1200000000000,
|
||||
'sub_industries': [
|
||||
{'id': 1701, 'name': '房地产开发', 'stock_count': 65},
|
||||
{'id': 1702, 'name': '房地产服务', 'stock_count': 24}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 18,
|
||||
'name': '商业贸易',
|
||||
'code': '801180',
|
||||
'description': '贸易、零售',
|
||||
'stock_count': 78,
|
||||
'avg_change': 0.7,
|
||||
'total_market_cap': 600000000000,
|
||||
'sub_industries': [
|
||||
{'id': 1801, 'name': '贸易', 'stock_count': 35},
|
||||
{'id': 1802, 'name': '零售', 'stock_count': 43}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 19,
|
||||
'name': '休闲服务',
|
||||
'code': '801190',
|
||||
'description': '景点、酒店、旅游综合',
|
||||
'stock_count': 34,
|
||||
'avg_change': 1.2,
|
||||
'total_market_cap': 300000000000,
|
||||
'sub_industries': [
|
||||
{'id': 1901, 'name': '景点', 'stock_count': 12},
|
||||
{'id': 1902, 'name': '酒店', 'stock_count': 15},
|
||||
{'id': 1903, 'name': '旅游综合', 'stock_count': 7}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 20,
|
||||
'name': '银行',
|
||||
'code': '801200',
|
||||
'description': '银行',
|
||||
'stock_count': 28,
|
||||
'avg_change': 0.4,
|
||||
'total_market_cap': 8000000000000,
|
||||
'sub_industries': [
|
||||
{'id': 2001, 'name': '银行', 'stock_count': 28}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 21,
|
||||
'name': '非银金融',
|
||||
'code': '801210',
|
||||
'description': '保险、证券、多元金融',
|
||||
'stock_count': 45,
|
||||
'avg_change': 0.8,
|
||||
'total_market_cap': 2000000000000,
|
||||
'sub_industries': [
|
||||
{'id': 2101, 'name': '保险', 'stock_count': 8},
|
||||
{'id': 2102, 'name': '证券', 'stock_count': 25},
|
||||
{'id': 2103, 'name': '多元金融', 'stock_count': 12}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 22,
|
||||
'name': '综合',
|
||||
'code': '801220',
|
||||
'description': '综合',
|
||||
'stock_count': 23,
|
||||
'avg_change': 0.6,
|
||||
'total_market_cap': 200000000000,
|
||||
'sub_industries': [
|
||||
{'id': 2201, 'name': '综合', 'stock_count': 23}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 23,
|
||||
'name': '计算机',
|
||||
'code': '801230',
|
||||
'description': '计算机设备、计算机应用',
|
||||
'stock_count': 156,
|
||||
'avg_change': 2.3,
|
||||
'total_market_cap': 1800000000000,
|
||||
'sub_industries': [
|
||||
{'id': 2301, 'name': '计算机设备', 'stock_count': 45},
|
||||
{'id': 2302, 'name': '计算机应用', 'stock_count': 111}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 24,
|
||||
'name': '传媒',
|
||||
'code': '801240',
|
||||
'description': '文化传媒、营销传播',
|
||||
'stock_count': 78,
|
||||
'avg_change': 1.4,
|
||||
'total_market_cap': 700000000000,
|
||||
'sub_industries': [
|
||||
{'id': 2401, 'name': '文化传媒', 'stock_count': 45},
|
||||
{'id': 2402, 'name': '营销传播', 'stock_count': 33}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 25,
|
||||
'name': '通信',
|
||||
'code': '801250',
|
||||
'description': '通信设备、通信运营',
|
||||
'stock_count': 45,
|
||||
'avg_change': 1.7,
|
||||
'total_market_cap': 600000000000,
|
||||
'sub_industries': [
|
||||
{'id': 2501, 'name': '通信设备', 'stock_count': 30},
|
||||
{'id': 2502, 'name': '通信运营', 'stock_count': 15}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 26,
|
||||
'name': '电子',
|
||||
'code': '801260',
|
||||
'description': '半导体、电子制造、光学光电子',
|
||||
'stock_count': 178,
|
||||
'avg_change': 2.0,
|
||||
'total_market_cap': 2000000000000,
|
||||
'sub_industries': [
|
||||
{'id': 2601, 'name': '半导体', 'stock_count': 45},
|
||||
{'id': 2602, 'name': '电子制造', 'stock_count': 78},
|
||||
{'id': 2603, 'name': '光学光电子', 'stock_count': 55}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 27,
|
||||
'name': '机械设备',
|
||||
'code': '801270',
|
||||
'description': '通用机械、专用设备、仪器仪表',
|
||||
'stock_count': 234,
|
||||
'avg_change': 1.1,
|
||||
'total_market_cap': 1500000000000,
|
||||
'sub_industries': [
|
||||
{'id': 2701, 'name': '通用机械', 'stock_count': 89},
|
||||
{'id': 2702, 'name': '专用设备', 'stock_count': 98},
|
||||
{'id': 2703, 'name': '仪器仪表', 'stock_count': 47}
|
||||
]
|
||||
},
|
||||
{
|
||||
'id': 28,
|
||||
'name': '食品饮料',
|
||||
'code': '801280',
|
||||
'description': '食品加工、饮料制造',
|
||||
'stock_count': 67,
|
||||
'avg_change': 1.3,
|
||||
'total_market_cap': 1000000000000,
|
||||
'sub_industries': [
|
||||
{'id': 2801, 'name': '食品加工', 'stock_count': 35},
|
||||
{'id': 2802, 'name': '饮料制造', 'stock_count': 32}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': levels
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting industry levels: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/info', methods=['GET'])
|
||||
def get_industry_info():
|
||||
"""获取行业信息"""
|
||||
try:
|
||||
industry_id = request.args.get('industry_id')
|
||||
|
||||
if not industry_id:
|
||||
return jsonify({'success': False, 'error': '请提供行业ID'}), 400
|
||||
|
||||
# 模拟行业信息
|
||||
industry_info = {
|
||||
'id': industry_id,
|
||||
'name': f'行业{industry_id}',
|
||||
'code': f'801{industry_id.zfill(3)}',
|
||||
'description': f'这是行业{industry_id}的详细描述',
|
||||
'stock_count': 50,
|
||||
'avg_change': 1.5,
|
||||
'total_market_cap': 800000000000,
|
||||
'pe_ratio': 15.6,
|
||||
'pb_ratio': 2.3,
|
||||
'roe': 8.5,
|
||||
'top_stocks': [
|
||||
{'code': '000001', 'name': '龙头股A', 'weight': 0.15},
|
||||
{'code': '000002', 'name': '龙头股B', 'weight': 0.12},
|
||||
{'code': '000003', 'name': '龙头股C', 'weight': 0.10}
|
||||
],
|
||||
'sub_industries': [
|
||||
{'id': 1, 'name': '子行业A', 'stock_count': 20},
|
||||
{'id': 2, 'name': '子行业B', 'stock_count': 18},
|
||||
{'id': 3, 'name': '子行业C', 'stock_count': 12}
|
||||
],
|
||||
'performance': {
|
||||
'daily': 1.5,
|
||||
'weekly': 3.2,
|
||||
'monthly': 8.5,
|
||||
'quarterly': 12.3,
|
||||
'yearly': 25.6
|
||||
},
|
||||
'trend': {
|
||||
'direction': 'up',
|
||||
'strength': 'medium',
|
||||
'duration': '3 months'
|
||||
}
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': industry_info
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting industry info: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
@@ -1,469 +0,0 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
import pandas as pd
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
bp = Blueprint('limitanalyse', __name__, url_prefix='/api/limit-analyse')
|
||||
|
||||
@bp.route('/available-dates', methods=['GET'])
|
||||
def get_available_dates():
|
||||
"""获取可用日期列表"""
|
||||
try:
|
||||
# 模拟可用日期
|
||||
dates = [
|
||||
'2025-07-16',
|
||||
'2025-07-15',
|
||||
'2025-07-14',
|
||||
'2025-07-11',
|
||||
'2025-07-10'
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': dates
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error getting available dates: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
def load_stock_data(datestr):
|
||||
"""加载股票数据"""
|
||||
try:
|
||||
# 模拟股票数据
|
||||
data = []
|
||||
for i in range(100):
|
||||
data.append({
|
||||
'code': f'00000{i:03d}',
|
||||
'name': f'股票{i}',
|
||||
'price': 10.0 + i * 0.1,
|
||||
'change': (i % 10 - 5) * 0.5,
|
||||
'sector': f'板块{i % 5}',
|
||||
'limit_type': '涨停' if i % 10 == 0 else '正常',
|
||||
'volume': 1000000 + i * 50000,
|
||||
'amount': 10000000 + i * 500000
|
||||
})
|
||||
|
||||
return pd.DataFrame(data)
|
||||
except Exception as e:
|
||||
print(f"Error loading stock data: {e}")
|
||||
return pd.DataFrame()
|
||||
|
||||
@bp.route('/data', methods=['GET'])
|
||||
def get_analysis_data():
|
||||
"""获取分析数据"""
|
||||
try:
|
||||
date = request.args.get('date', '2025-07-16')
|
||||
|
||||
# 加载数据
|
||||
df = load_stock_data(date)
|
||||
if df.empty:
|
||||
return jsonify({'success': False, 'error': '数据加载失败'}), 500
|
||||
|
||||
# 统计信息
|
||||
total_stocks = len(df)
|
||||
limit_up_stocks = len(df[df['limit_type'] == '涨停'])
|
||||
limit_down_stocks = len(df[df['limit_type'] == '跌停'])
|
||||
|
||||
# 板块统计
|
||||
sector_stats = df.groupby('sector').agg({
|
||||
'code': 'count',
|
||||
'change': 'mean',
|
||||
'volume': 'sum'
|
||||
}).reset_index()
|
||||
|
||||
sector_data = []
|
||||
for _, row in sector_stats.iterrows():
|
||||
sector_data.append({
|
||||
'sector': row['sector'],
|
||||
'stock_count': int(row['code']),
|
||||
'avg_change': round(row['change'], 2),
|
||||
'total_volume': int(row['volume'])
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'date': date,
|
||||
'total_stocks': total_stocks,
|
||||
'limit_up_stocks': limit_up_stocks,
|
||||
'limit_down_stocks': limit_down_stocks,
|
||||
'sector_stats': sector_data
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting analysis data: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/sector-data', methods=['GET'])
|
||||
def get_sector_data():
|
||||
"""获取板块数据"""
|
||||
try:
|
||||
date = request.args.get('date', '2025-07-16')
|
||||
|
||||
# 加载数据
|
||||
df = load_stock_data(date)
|
||||
if df.empty:
|
||||
return jsonify({'success': False, 'error': '数据加载失败'}), 500
|
||||
|
||||
# 板块统计
|
||||
sector_stats = df.groupby('sector').agg({
|
||||
'code': 'count',
|
||||
'change': 'mean',
|
||||
'volume': 'sum',
|
||||
'amount': 'sum'
|
||||
}).reset_index()
|
||||
|
||||
sector_data = []
|
||||
for _, row in sector_stats.iterrows():
|
||||
sector_data.append({
|
||||
'sector': row['sector'],
|
||||
'stock_count': int(row['code']),
|
||||
'avg_change': round(row['change'], 2),
|
||||
'total_volume': int(row['volume']),
|
||||
'total_amount': int(row['amount'])
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': sector_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting sector data: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/word-cloud', methods=['GET'])
|
||||
def get_word_cloud_data():
|
||||
"""获取词云数据"""
|
||||
try:
|
||||
date = request.args.get('date', '2025-07-16')
|
||||
|
||||
# 模拟词云数据
|
||||
word_data = [
|
||||
{'word': '科技', 'value': 100},
|
||||
{'word': '新能源', 'value': 85},
|
||||
{'word': '医药', 'value': 70},
|
||||
{'word': '消费', 'value': 65},
|
||||
{'word': '金融', 'value': 50},
|
||||
{'word': '地产', 'value': 45},
|
||||
{'word': '制造', 'value': 40},
|
||||
{'word': '农业', 'value': 35},
|
||||
{'word': '传媒', 'value': 30},
|
||||
{'word': '环保', 'value': 25}
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': word_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting word cloud data: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/chart-data', methods=['GET'])
|
||||
def get_chart_data():
|
||||
"""获取图表数据"""
|
||||
try:
|
||||
date = request.args.get('date', '2025-07-16')
|
||||
|
||||
# 模拟图表数据
|
||||
chart_data = {
|
||||
'limit_up_distribution': [
|
||||
{'sector': '科技', 'count': 15},
|
||||
{'sector': '新能源', 'count': 12},
|
||||
{'sector': '医药', 'count': 10},
|
||||
{'sector': '消费', 'count': 8},
|
||||
{'sector': '金融', 'count': 6}
|
||||
],
|
||||
'sector_performance': [
|
||||
{'sector': '科技', 'change': 2.5},
|
||||
{'sector': '新能源', 'change': 1.8},
|
||||
{'sector': '医药', 'change': 1.2},
|
||||
{'sector': '消费', 'change': 0.8},
|
||||
{'sector': '金融', 'change': 0.5}
|
||||
]
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': chart_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting chart data: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/stock-details', methods=['GET'])
|
||||
def get_stock_details():
|
||||
"""获取股票详情"""
|
||||
try:
|
||||
code = request.args.get('code')
|
||||
date = request.args.get('date', '2025-07-16')
|
||||
|
||||
if not code:
|
||||
return jsonify({'success': False, 'error': '请提供股票代码'}), 400
|
||||
|
||||
# 模拟股票详情
|
||||
stock_detail = {
|
||||
'code': code,
|
||||
'name': f'股票{code}',
|
||||
'price': 15.50,
|
||||
'change': 2.5,
|
||||
'sector': '科技',
|
||||
'volume': 1500000,
|
||||
'amount': 23250000,
|
||||
'limit_type': '涨停',
|
||||
'turnover_rate': 3.2,
|
||||
'market_cap': 15500000000
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': stock_detail
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting stock details: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/sector-analysis', methods=['GET'])
|
||||
def get_sector_analysis():
|
||||
"""获取板块分析"""
|
||||
try:
|
||||
sector = request.args.get('sector')
|
||||
date = request.args.get('date', '2025-07-16')
|
||||
|
||||
if not sector:
|
||||
return jsonify({'success': False, 'error': '请提供板块名称'}), 400
|
||||
|
||||
# 模拟板块分析数据
|
||||
sector_analysis = {
|
||||
'sector': sector,
|
||||
'stock_count': 25,
|
||||
'avg_change': 1.8,
|
||||
'limit_up_count': 8,
|
||||
'limit_down_count': 2,
|
||||
'total_volume': 50000000,
|
||||
'total_amount': 750000000,
|
||||
'top_stocks': [
|
||||
{'code': '000001', 'name': '股票A', 'change': 10.0},
|
||||
{'code': '000002', 'name': '股票B', 'change': 9.5},
|
||||
{'code': '000003', 'name': '股票C', 'change': 8.8}
|
||||
]
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': sector_analysis
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting sector analysis: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/trend-analysis', methods=['GET'])
|
||||
def get_trend_analysis():
|
||||
"""获取趋势分析"""
|
||||
try:
|
||||
date = request.args.get('date', '2025-07-16')
|
||||
|
||||
# 模拟趋势分析数据
|
||||
trend_data = {
|
||||
'limit_up_trend': [
|
||||
{'date': '2025-07-10', 'count': 45},
|
||||
{'date': '2025-07-11', 'count': 52},
|
||||
{'date': '2025-07-14', 'count': 48},
|
||||
{'date': '2025-07-15', 'count': 55},
|
||||
{'date': '2025-07-16', 'count': 51}
|
||||
],
|
||||
'sector_trend': [
|
||||
{'sector': '科技', 'trend': 'up'},
|
||||
{'sector': '新能源', 'trend': 'up'},
|
||||
{'sector': '医药', 'trend': 'stable'},
|
||||
{'sector': '消费', 'trend': 'down'},
|
||||
{'sector': '金融', 'trend': 'stable'}
|
||||
]
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': trend_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting trend analysis: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/heat-map', methods=['GET'])
|
||||
def get_heat_map_data():
|
||||
"""获取热力图数据"""
|
||||
try:
|
||||
date = request.args.get('date', '2025-07-16')
|
||||
|
||||
# 模拟热力图数据
|
||||
heat_map_data = []
|
||||
sectors = ['科技', '新能源', '医药', '消费', '金融', '地产', '制造', '农业']
|
||||
|
||||
for i, sector in enumerate(sectors):
|
||||
for j in range(8):
|
||||
heat_map_data.append({
|
||||
'sector': sector,
|
||||
'metric': f'指标{j+1}',
|
||||
'value': (i + j) % 10 + 1
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': heat_map_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting heat map data: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/correlation-analysis', methods=['GET'])
|
||||
def get_correlation_analysis():
|
||||
"""获取相关性分析"""
|
||||
try:
|
||||
date = request.args.get('date', '2025-07-16')
|
||||
|
||||
# 模拟相关性分析数据
|
||||
correlation_data = {
|
||||
'sector_correlations': [
|
||||
{'sector1': '科技', 'sector2': '新能源', 'correlation': 0.85},
|
||||
{'sector1': '医药', 'sector2': '消费', 'correlation': 0.72},
|
||||
{'sector1': '金融', 'sector2': '地产', 'correlation': 0.68},
|
||||
{'sector1': '科技', 'sector2': '医药', 'correlation': 0.45},
|
||||
{'sector1': '新能源', 'sector2': '制造', 'correlation': 0.78}
|
||||
],
|
||||
'stock_correlations': [
|
||||
{'stock1': '000001', 'stock2': '000002', 'correlation': 0.92},
|
||||
{'stock1': '000003', 'stock2': '000004', 'correlation': 0.88},
|
||||
{'stock1': '000005', 'stock2': '000006', 'correlation': 0.76}
|
||||
]
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': correlation_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting correlation analysis: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/export-data', methods=['POST'])
|
||||
def export_data():
|
||||
"""导出数据"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
date = data.get('date', '2025-07-16')
|
||||
export_type = data.get('type', 'excel')
|
||||
|
||||
# 模拟导出
|
||||
filename = f'limit_analyse_{date}.{export_type}'
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': '数据导出成功',
|
||||
'data': {
|
||||
'filename': filename,
|
||||
'download_url': f'/downloads/{filename}'
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error exporting data: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/high-position-stocks', methods=['GET'])
|
||||
def get_high_position_stocks():
|
||||
"""获取高位股统计数据"""
|
||||
try:
|
||||
date = request.args.get('date', datetime.now().strftime('%Y%m%d'))
|
||||
|
||||
# 模拟高位股数据 - 实际使用时需要连接真实的数据库
|
||||
# 根据用户提供的表结构,查询连续涨停天数较多的股票
|
||||
high_position_stocks = [
|
||||
{
|
||||
'stock_code': '000001',
|
||||
'stock_name': '平安银行',
|
||||
'price': 15.68,
|
||||
'increase_rate': 10.02,
|
||||
'limit_up_days': 5,
|
||||
'continuous_limit_up': 3,
|
||||
'industry': '银行',
|
||||
'turnover_rate': 3.45,
|
||||
'market_cap': 32000000000
|
||||
},
|
||||
{
|
||||
'stock_code': '000002',
|
||||
'stock_name': '万科A',
|
||||
'price': 18.92,
|
||||
'increase_rate': 9.98,
|
||||
'limit_up_days': 4,
|
||||
'continuous_limit_up': 2,
|
||||
'industry': '房地产',
|
||||
'turnover_rate': 5.67,
|
||||
'market_cap': 21000000000
|
||||
},
|
||||
{
|
||||
'stock_code': '600036',
|
||||
'stock_name': '招商银行',
|
||||
'price': 42.15,
|
||||
'increase_rate': 8.45,
|
||||
'limit_up_days': 6,
|
||||
'continuous_limit_up': 4,
|
||||
'industry': '银行',
|
||||
'turnover_rate': 2.89,
|
||||
'market_cap': 105000000000
|
||||
},
|
||||
{
|
||||
'stock_code': '000858',
|
||||
'stock_name': '五粮液',
|
||||
'price': 168.50,
|
||||
'increase_rate': 7.23,
|
||||
'limit_up_days': 3,
|
||||
'continuous_limit_up': 2,
|
||||
'industry': '白酒',
|
||||
'turnover_rate': 1.56,
|
||||
'market_cap': 650000000000
|
||||
},
|
||||
{
|
||||
'stock_code': '002415',
|
||||
'stock_name': '海康威视',
|
||||
'price': 35.68,
|
||||
'increase_rate': 6.89,
|
||||
'limit_up_days': 4,
|
||||
'continuous_limit_up': 3,
|
||||
'industry': '安防',
|
||||
'turnover_rate': 4.12,
|
||||
'market_cap': 33000000000
|
||||
}
|
||||
]
|
||||
|
||||
# 统计信息
|
||||
total_count = len(high_position_stocks)
|
||||
avg_continuous_days = sum(stock['continuous_limit_up'] for stock in high_position_stocks) / total_count if total_count > 0 else 0
|
||||
|
||||
# 按连续涨停天数排序
|
||||
high_position_stocks.sort(key=lambda x: x['continuous_limit_up'], reverse=True)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'stocks': high_position_stocks,
|
||||
'statistics': {
|
||||
'total_count': total_count,
|
||||
'avg_continuous_days': round(avg_continuous_days, 2),
|
||||
'max_continuous_days': max([stock['continuous_limit_up'] for stock in high_position_stocks], default=0),
|
||||
'industry_distribution': {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting high position stocks: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
@@ -1,241 +0,0 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from app import db
|
||||
from clickhouse_driver import Client
|
||||
import pandas as pd
|
||||
from datetime import datetime, timedelta
|
||||
import pytz
|
||||
|
||||
bp = Blueprint('stocks', __name__, url_prefix='/api/stock')
|
||||
|
||||
def get_clickhouse_client():
|
||||
"""获取ClickHouse客户端"""
|
||||
return Client('localhost', port=9000, user='default', password='', database='default')
|
||||
|
||||
@bp.route('/quotes', methods=['GET', 'POST'])
|
||||
def get_stock_quotes():
|
||||
"""获取股票实时报价"""
|
||||
try:
|
||||
if request.method == 'GET':
|
||||
# GET 请求从 URL 参数获取数据
|
||||
codes = request.args.get('codes', '').split(',')
|
||||
event_time_str = request.args.get('event_time')
|
||||
else:
|
||||
# POST 请求从 JSON 获取数据
|
||||
codes = request.json.get('codes', [])
|
||||
event_time_str = request.json.get('event_time')
|
||||
|
||||
if not codes:
|
||||
return jsonify({'success': False, 'error': '请提供股票代码'}), 400
|
||||
|
||||
# 过滤空字符串
|
||||
codes = [code.strip() for code in codes if code.strip()]
|
||||
|
||||
if not codes:
|
||||
return jsonify({'success': False, 'error': '请提供有效的股票代码'}), 400
|
||||
|
||||
# 解析事件时间
|
||||
event_time = None
|
||||
if event_time_str:
|
||||
try:
|
||||
event_time = datetime.fromisoformat(event_time_str.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
return jsonify({'success': False, 'error': '事件时间格式错误'}), 400
|
||||
|
||||
# 获取当前时间
|
||||
now = datetime.now(pytz.timezone('Asia/Shanghai'))
|
||||
|
||||
# 如果提供了事件时间,使用事件时间;否则使用当前时间
|
||||
target_time = event_time if event_time else now
|
||||
|
||||
# 获取交易日和交易时间
|
||||
def get_trading_day_and_times(event_datetime):
|
||||
"""获取交易日和交易时间列表"""
|
||||
# 这里简化处理,实际应该查询交易日历
|
||||
trading_day = event_datetime.strftime('%Y-%m-%d')
|
||||
|
||||
# 生成交易时间列表 (9:30-11:30, 13:00-15:00)
|
||||
morning_times = [f"{trading_day} {hour:02d}:{minute:02d}"
|
||||
for hour in range(9, 12)
|
||||
for minute in range(0, 60, 1)
|
||||
if not (hour == 9 and minute < 30) and not (hour == 11 and minute > 30)]
|
||||
|
||||
afternoon_times = [f"{trading_day} {hour:02d}:{minute:02d}"
|
||||
for hour in range(13, 16)
|
||||
for minute in range(0, 60, 1)]
|
||||
|
||||
return trading_day, morning_times + afternoon_times
|
||||
|
||||
trading_day, trading_times = get_trading_day_and_times(target_time)
|
||||
|
||||
# 模拟股票数据
|
||||
results = {}
|
||||
for code in codes:
|
||||
# 这里应该从ClickHouse或其他数据源获取真实数据
|
||||
# 现在使用模拟数据
|
||||
import random
|
||||
base_price = 10.0 + random.random() * 20.0
|
||||
change = (random.random() - 0.5) * 2.0
|
||||
|
||||
results[code] = {
|
||||
'price': round(base_price, 2),
|
||||
'change': round(change, 2),
|
||||
'name': f'股票{code}'
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': results
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting stock quotes: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
@bp.route('/<stock_code>/kline')
|
||||
def get_stock_kline(stock_code):
|
||||
"""获取股票K线数据"""
|
||||
try:
|
||||
chart_type = request.args.get('type', 'daily')
|
||||
event_time_str = request.args.get('event_time')
|
||||
|
||||
if not event_time_str:
|
||||
return jsonify({'success': False, 'error': '请提供事件时间'}), 400
|
||||
|
||||
try:
|
||||
event_datetime = datetime.fromisoformat(event_time_str.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
return jsonify({'success': False, 'error': '事件时间格式错误'}), 400
|
||||
|
||||
# 获取股票名称(这里简化处理)
|
||||
stock_name = f'股票{stock_code}'
|
||||
|
||||
if chart_type == 'daily':
|
||||
return get_daily_kline(stock_code, event_datetime, stock_name)
|
||||
elif chart_type == 'minute':
|
||||
return get_minute_kline(stock_code, event_datetime, stock_name)
|
||||
elif chart_type == 'timeline':
|
||||
return get_timeline_data(stock_code, event_datetime, stock_name)
|
||||
else:
|
||||
return jsonify({'error': f'Unsupported chart type: {chart_type}'}), 400
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting stock kline: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
def get_daily_kline(stock_code, event_datetime, stock_name):
|
||||
"""获取日K线数据"""
|
||||
try:
|
||||
# 模拟日K线数据
|
||||
data = []
|
||||
base_price = 10.0
|
||||
for i in range(30):
|
||||
date = (event_datetime - timedelta(days=30-i)).strftime('%Y-%m-%d')
|
||||
open_price = base_price + (i * 0.1) + (i % 3 - 1) * 0.5
|
||||
close_price = open_price + (i % 5 - 2) * 0.3
|
||||
high_price = max(open_price, close_price) + 0.2
|
||||
low_price = min(open_price, close_price) - 0.2
|
||||
volume = 1000000 + i * 50000
|
||||
|
||||
data.append({
|
||||
'date': date,
|
||||
'open': round(open_price, 2),
|
||||
'close': round(close_price, 2),
|
||||
'high': round(high_price, 2),
|
||||
'low': round(low_price, 2),
|
||||
'volume': volume
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'code': stock_code,
|
||||
'name': stock_name,
|
||||
'trade_date': event_datetime.strftime('%Y-%m-%d'),
|
||||
'data': data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting daily kline: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
def get_minute_kline(stock_code, event_datetime, stock_name):
|
||||
"""获取分钟K线数据"""
|
||||
try:
|
||||
# 模拟分钟K线数据
|
||||
data = []
|
||||
base_price = 10.0
|
||||
trading_times = []
|
||||
|
||||
# 生成交易时间
|
||||
for hour in range(9, 16):
|
||||
if hour == 12:
|
||||
continue
|
||||
for minute in range(0, 60):
|
||||
if (hour == 9 and minute < 30) or (hour == 11 and minute > 30):
|
||||
continue
|
||||
trading_times.append(f"{hour:02d}:{minute:02d}")
|
||||
|
||||
for i, time in enumerate(trading_times):
|
||||
open_price = base_price + (i * 0.01) + (i % 10 - 5) * 0.02
|
||||
close_price = open_price + (i % 7 - 3) * 0.01
|
||||
high_price = max(open_price, close_price) + 0.01
|
||||
low_price = min(open_price, close_price) - 0.01
|
||||
volume = 50000 + i * 1000
|
||||
|
||||
data.append({
|
||||
'time': time,
|
||||
'open': round(open_price, 2),
|
||||
'close': round(close_price, 2),
|
||||
'high': round(high_price, 2),
|
||||
'low': round(low_price, 2),
|
||||
'volume': volume
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'code': stock_code,
|
||||
'name': stock_name,
|
||||
'trade_date': event_datetime.strftime('%Y-%m-%d'),
|
||||
'data': data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting minute kline: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
def get_timeline_data(stock_code, event_datetime, stock_name):
|
||||
"""获取分时图数据"""
|
||||
try:
|
||||
# 模拟分时图数据
|
||||
data = []
|
||||
base_price = 10.0
|
||||
trading_times = []
|
||||
|
||||
# 生成交易时间
|
||||
for hour in range(9, 16):
|
||||
if hour == 12:
|
||||
continue
|
||||
for minute in range(0, 60):
|
||||
if (hour == 9 and minute < 30) or (hour == 11 and minute > 30):
|
||||
continue
|
||||
trading_times.append(f"{hour:02d}:{minute:02d}")
|
||||
|
||||
for i, time in enumerate(trading_times):
|
||||
price = base_price + (i * 0.01) + (i % 10 - 5) * 0.02
|
||||
avg_price = price + (i % 5 - 2) * 0.01
|
||||
volume = 50000 + i * 1000
|
||||
|
||||
data.append({
|
||||
'time': time,
|
||||
'price': round(price, 2),
|
||||
'avg_price': round(avg_price, 2),
|
||||
'volume': volume
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'code': stock_code,
|
||||
'name': stock_name,
|
||||
'trade_date': event_datetime.strftime('%Y-%m-%d'),
|
||||
'data': data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting timeline data: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
@@ -20,6 +20,7 @@
|
||||
"@fullcalendar/react": "^5.9.0",
|
||||
"@react-three/drei": "^9.11.3",
|
||||
"@react-three/fiber": "^8.0.27",
|
||||
"@reduxjs/toolkit": "^2.9.2",
|
||||
"@splidejs/react-splide": "^0.7.12",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@visx/visx": "^3.12.0",
|
||||
@@ -59,6 +60,7 @@
|
||||
"react-leaflet": "^3.2.5",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-quill": "^2.0.0-beta.4",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-responsive": "^10.0.1",
|
||||
"react-responsive-masonry": "^2.7.1",
|
||||
"react-router-dom": "^6.30.1",
|
||||
@@ -97,7 +99,9 @@
|
||||
"build:analyze": "NODE_OPTIONS='--openssl-legacy-provider --max_old_space_size=4096' ANALYZE=true craco build",
|
||||
"test": "craco test --env=jsdom",
|
||||
"eject": "react-scripts eject",
|
||||
"deploy": "npm run build",
|
||||
"deploy": "bash scripts/deploy-from-local.sh",
|
||||
"deploy:setup": "bash scripts/setup-deployment.sh",
|
||||
"rollback": "bash scripts/rollback-from-local.sh",
|
||||
"lint:check": "eslint . --ext=js,jsx; exit 0",
|
||||
"lint:fix": "eslint . --ext=js,jsx --fix; exit 0",
|
||||
"install:clean": "rm -rf node_modules/ && rm -rf package-lock.json && npm install && npm start"
|
||||
@@ -140,5 +144,8 @@
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "^2.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.11.5'
|
||||
const PACKAGE_VERSION = '2.11.6'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
392
scripts/deploy-from-local.sh
Executable file
392
scripts/deploy-from-local.sh
Executable file
@@ -0,0 +1,392 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# 本地部署脚本
|
||||
# 在本地运行,通过 SSH 连接服务器并执行部署
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
###############################################################################
|
||||
# 函数:打印带颜色的消息
|
||||
###############################################################################
|
||||
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
log_success() { echo -e "${GREEN}[✓]${NC} $1"; }
|
||||
log_warning() { echo -e "${YELLOW}[⚠]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[✗]${NC} $1"; }
|
||||
log_step() { echo -e "${CYAN}${BOLD}[$1]${NC} $2"; }
|
||||
|
||||
###############################################################################
|
||||
# 函数:加载配置文件
|
||||
###############################################################################
|
||||
load_config() {
|
||||
if [ ! -f "$PROJECT_ROOT/.env.deploy" ]; then
|
||||
log_error "配置文件不存在: $PROJECT_ROOT/.env.deploy"
|
||||
echo ""
|
||||
echo "请先运行以下命令进行配置:"
|
||||
echo " npm run deploy:setup"
|
||||
echo ""
|
||||
echo "或者手动创建配置文件:"
|
||||
echo " cp .env.deploy.example .env.deploy"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 加载配置
|
||||
source "$PROJECT_ROOT/.env.deploy"
|
||||
|
||||
# 检查必需的配置项
|
||||
if [ -z "$SERVER_HOST" ] || [ -z "$SERVER_USER" ]; then
|
||||
log_error "配置不完整,请检查 .env.deploy 文件"
|
||||
echo "必需配置项:"
|
||||
echo " - SERVER_HOST: 服务器地址"
|
||||
echo " - SERVER_USER: SSH 用户名"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "配置加载完成"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:检查本地 Git 状态
|
||||
###############################################################################
|
||||
check_local_git() {
|
||||
log_step "1/8" "检查本地代码"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# 检查是否是 Git 仓库
|
||||
if [ ! -d ".git" ]; then
|
||||
log_error "当前目录不是 Git 仓库"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 获取当前分支
|
||||
local current_branch=$(git branch --show-current)
|
||||
log_info "当前分支: $current_branch"
|
||||
|
||||
# 检查是否有未提交的更改
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
log_warning "存在未提交的更改"
|
||||
echo ""
|
||||
git status --short
|
||||
echo ""
|
||||
read -p "是否继续部署? (y/n): " -n 1 -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
log_info "部署已取消"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 获取最新提交信息
|
||||
COMMIT_HASH=$(git rev-parse --short HEAD)
|
||||
COMMIT_MESSAGE=$(git log -1 --pretty=%B | head -n 1)
|
||||
COMMIT_AUTHOR=$(git log -1 --pretty=%an)
|
||||
|
||||
log_info "最新提交: $COMMIT_HASH - $COMMIT_MESSAGE"
|
||||
log_info "提交作者: $COMMIT_AUTHOR"
|
||||
|
||||
log_success "本地代码检查完成"
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:显示部署预览
|
||||
###############################################################################
|
||||
show_deploy_preview() {
|
||||
log_step "2/8" "部署预览"
|
||||
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ 部署预览 ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo -e "${BOLD}项目信息:${NC}"
|
||||
echo " 项目名称: vf_react"
|
||||
echo " 部署环境: 生产环境"
|
||||
echo " 目标服务器: $SERVER_USER@$SERVER_HOST"
|
||||
echo ""
|
||||
echo -e "${BOLD}代码信息:${NC}"
|
||||
echo " 当前分支: $(git branch --show-current)"
|
||||
echo " 提交版本: $COMMIT_HASH"
|
||||
echo " 提交信息: $COMMIT_MESSAGE"
|
||||
echo " 提交作者: $COMMIT_AUTHOR"
|
||||
echo ""
|
||||
echo -e "${BOLD}部署路径:${NC}"
|
||||
echo " Git 仓库: $REMOTE_PROJECT_PATH"
|
||||
echo " 生产目录: $PRODUCTION_PATH"
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# 询问是否继续
|
||||
read -p "确认部署到生产环境? (yes/no): " -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then
|
||||
log_info "部署已取消"
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:测试 SSH 连接
|
||||
###############################################################################
|
||||
test_ssh_connection() {
|
||||
log_step "3/8" "测试 SSH 连接"
|
||||
|
||||
local ssh_options="-o ConnectTimeout=${SSH_TIMEOUT:-30} -o BatchMode=yes"
|
||||
|
||||
if [ -n "$SSH_KEY_PATH" ]; then
|
||||
ssh_options="$ssh_options -i $SSH_KEY_PATH"
|
||||
fi
|
||||
|
||||
if [ -n "$SERVER_PORT" ]; then
|
||||
ssh_options="$ssh_options -p $SERVER_PORT"
|
||||
fi
|
||||
|
||||
# 测试连接
|
||||
if ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "echo 'SSH 连接成功'" > /dev/null 2>&1; then
|
||||
log_success "SSH 连接成功"
|
||||
else
|
||||
log_error "SSH 连接失败"
|
||||
echo ""
|
||||
echo "请检查:"
|
||||
echo " 1. 服务器地址是否正确: $SERVER_HOST"
|
||||
echo " 2. SSH 用户名是否正确: $SERVER_USER"
|
||||
echo " 3. SSH 密钥是否配置正确"
|
||||
echo " 4. 服务器端口是否正确: ${SERVER_PORT:-22}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:上传服务器端脚本
|
||||
###############################################################################
|
||||
upload_server_scripts() {
|
||||
log_step "4/8" "上传部署脚本"
|
||||
|
||||
local ssh_options=""
|
||||
if [ -n "$SSH_KEY_PATH" ]; then
|
||||
ssh_options="-i $SSH_KEY_PATH"
|
||||
fi
|
||||
if [ -n "$SERVER_PORT" ]; then
|
||||
ssh_options="$ssh_options -P $SERVER_PORT"
|
||||
fi
|
||||
|
||||
# 创建远程脚本目录
|
||||
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "mkdir -p /tmp/deploy-scripts" || {
|
||||
log_error "创建远程目录失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 上传脚本
|
||||
scp $ssh_options \
|
||||
"$SCRIPT_DIR/deploy-on-server.sh" \
|
||||
"$SCRIPT_DIR/rollback-on-server.sh" \
|
||||
"$SCRIPT_DIR/notify-wechat.sh" \
|
||||
"$SERVER_USER@$SERVER_HOST":/tmp/deploy-scripts/ > /dev/null 2>&1 || {
|
||||
log_error "上传脚本失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 设置执行权限
|
||||
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "chmod +x /tmp/deploy-scripts/*.sh" || {
|
||||
log_error "设置脚本权限失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log_success "部署脚本上传完成"
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:执行服务器端部署
|
||||
###############################################################################
|
||||
execute_remote_deployment() {
|
||||
log_step "5/8" "执行远程部署"
|
||||
echo ""
|
||||
|
||||
local ssh_options=""
|
||||
if [ -n "$SSH_KEY_PATH" ]; then
|
||||
ssh_options="-i $SSH_KEY_PATH"
|
||||
fi
|
||||
if [ -n "$SERVER_PORT" ]; then
|
||||
ssh_options="$ssh_options -p $SERVER_PORT"
|
||||
fi
|
||||
|
||||
# 构建环境变量
|
||||
local env_vars="REMOTE_PROJECT_PATH=$REMOTE_PROJECT_PATH "
|
||||
env_vars+="PRODUCTION_PATH=$PRODUCTION_PATH "
|
||||
env_vars+="BACKUP_DIR=$BACKUP_DIR "
|
||||
env_vars+="LOG_DIR=$LOG_DIR "
|
||||
env_vars+="DEPLOY_BRANCH=$DEPLOY_BRANCH "
|
||||
env_vars+="KEEP_BACKUPS=$KEEP_BACKUPS "
|
||||
env_vars+="RUN_NPM_INSTALL=$RUN_NPM_INSTALL"
|
||||
|
||||
# 记录开始时间
|
||||
DEPLOY_START_TIME=$(date +%s)
|
||||
|
||||
# 执行部署脚本
|
||||
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "$env_vars bash /tmp/deploy-scripts/deploy-on-server.sh" || {
|
||||
log_error "远程部署失败"
|
||||
send_failure_notification "部署脚本执行失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 记录结束时间
|
||||
DEPLOY_END_TIME=$(date +%s)
|
||||
DEPLOY_DURATION=$((DEPLOY_END_TIME - DEPLOY_START_TIME))
|
||||
|
||||
echo ""
|
||||
log_success "远程部署完成"
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:发送成功通知
|
||||
###############################################################################
|
||||
send_success_notification() {
|
||||
log_step "6/8" "发送部署通知"
|
||||
|
||||
if [ "$ENABLE_WECHAT_NOTIFY" = "true" ]; then
|
||||
local minutes=$((DEPLOY_DURATION / 60))
|
||||
local seconds=$((DEPLOY_DURATION % 60))
|
||||
local duration="${minutes}分${seconds}秒"
|
||||
|
||||
bash "$SCRIPT_DIR/notify-wechat.sh" success \
|
||||
"$DEPLOY_BRANCH" \
|
||||
"$COMMIT_HASH" \
|
||||
"$COMMIT_MESSAGE" \
|
||||
"$duration" \
|
||||
"$USER" || {
|
||||
log_warning "企业微信通知发送失败"
|
||||
}
|
||||
else
|
||||
log_info "企业微信通知未启用"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:发送失败通知
|
||||
###############################################################################
|
||||
send_failure_notification() {
|
||||
local error_message="$1"
|
||||
|
||||
if [ "$ENABLE_WECHAT_NOTIFY" = "true" ]; then
|
||||
bash "$SCRIPT_DIR/notify-wechat.sh" failure \
|
||||
"$DEPLOY_BRANCH" \
|
||||
"$error_message" \
|
||||
"$USER" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:清理临时文件
|
||||
###############################################################################
|
||||
cleanup() {
|
||||
log_step "7/8" "清理临时文件"
|
||||
|
||||
local ssh_options=""
|
||||
if [ -n "$SSH_KEY_PATH" ]; then
|
||||
ssh_options="-i $SSH_KEY_PATH"
|
||||
fi
|
||||
if [ -n "$SERVER_PORT" ]; then
|
||||
ssh_options="$ssh_options -p $SERVER_PORT"
|
||||
fi
|
||||
|
||||
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "rm -rf /tmp/deploy-scripts" > /dev/null 2>&1 || true
|
||||
|
||||
log_success "清理完成"
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:显示部署结果
|
||||
###############################################################################
|
||||
show_deployment_result() {
|
||||
log_step "8/8" "部署完成"
|
||||
|
||||
local minutes=$((DEPLOY_DURATION / 60))
|
||||
local seconds=$((DEPLOY_DURATION % 60))
|
||||
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ 🎉 部署成功! ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo -e "${BOLD}部署信息:${NC}"
|
||||
echo " 版本: $COMMIT_HASH"
|
||||
echo " 分支: $DEPLOY_BRANCH"
|
||||
echo " 提交: $COMMIT_MESSAGE"
|
||||
echo " 作者: $COMMIT_AUTHOR"
|
||||
echo " 时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo " 耗时: ${minutes}分${seconds}秒"
|
||||
echo ""
|
||||
echo -e "${BOLD}访问地址:${NC}"
|
||||
echo " https://valuefrontier.cn"
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 主函数
|
||||
###############################################################################
|
||||
main() {
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ VF React - 生产环境部署工具 ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# 加载配置
|
||||
load_config
|
||||
|
||||
# 检查本地 Git 状态
|
||||
check_local_git
|
||||
|
||||
# 显示部署预览
|
||||
show_deploy_preview
|
||||
|
||||
# 测试 SSH 连接
|
||||
test_ssh_connection
|
||||
|
||||
# 上传服务器端脚本
|
||||
upload_server_scripts
|
||||
|
||||
# 执行远程部署
|
||||
execute_remote_deployment
|
||||
|
||||
# 发送成功通知
|
||||
send_success_notification
|
||||
|
||||
# 清理临时文件
|
||||
cleanup
|
||||
|
||||
# 显示部署结果
|
||||
show_deployment_result
|
||||
}
|
||||
|
||||
# 错误处理
|
||||
trap 'log_error "部署过程中发生错误"; send_failure_notification "部署异常中断"; exit 1' ERR
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
313
scripts/deploy-on-server.sh
Executable file
313
scripts/deploy-on-server.sh
Executable file
@@ -0,0 +1,313 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# 服务器端部署脚本
|
||||
# 此脚本在服务器上执行,由本地部署脚本远程调用
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
###############################################################################
|
||||
# 配置变量(通过环境变量传入)
|
||||
###############################################################################
|
||||
PROJECT_PATH="${REMOTE_PROJECT_PATH:-/home/ubuntu/vf_react}"
|
||||
PRODUCTION_PATH="${PRODUCTION_PATH:-/var/www/valuefrontier.cn}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-/home/ubuntu/deployments}"
|
||||
LOG_DIR="${LOG_DIR:-/home/ubuntu/deploy-logs}"
|
||||
DEPLOY_BRANCH="${DEPLOY_BRANCH:-feature}"
|
||||
KEEP_BACKUPS="${KEEP_BACKUPS:-5}"
|
||||
RUN_NPM_INSTALL="${RUN_NPM_INSTALL:-true}"
|
||||
|
||||
###############################################################################
|
||||
# 函数:打印带颜色的消息
|
||||
###############################################################################
|
||||
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
|
||||
log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
###############################################################################
|
||||
# 函数:创建必要的目录
|
||||
###############################################################################
|
||||
create_directories() {
|
||||
log_info "创建必要的目录..."
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
mkdir -p "$LOG_DIR"
|
||||
mkdir -p "$PRODUCTION_PATH"
|
||||
log_success "目录创建完成"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:检查 Git 仓库
|
||||
###############################################################################
|
||||
check_git_repo() {
|
||||
log_info "检查 Git 仓库..."
|
||||
|
||||
if [ ! -d "$PROJECT_PATH/.git" ]; then
|
||||
log_error "Git 仓库不存在: $PROJECT_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$PROJECT_PATH"
|
||||
log_success "Git 仓库检查通过"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:切换到目标分支
|
||||
###############################################################################
|
||||
checkout_branch() {
|
||||
log_info "切换到 $DEPLOY_BRANCH 分支..."
|
||||
|
||||
cd "$PROJECT_PATH"
|
||||
|
||||
# 获取当前分支
|
||||
current_branch=$(git branch --show-current)
|
||||
|
||||
if [ "$current_branch" != "$DEPLOY_BRANCH" ]; then
|
||||
log_warning "当前分支是 $current_branch,正在切换到 $DEPLOY_BRANCH..."
|
||||
git checkout "$DEPLOY_BRANCH" || {
|
||||
log_error "切换分支失败"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
|
||||
log_success "已在 $DEPLOY_BRANCH 分支"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:拉取最新代码
|
||||
###############################################################################
|
||||
pull_latest_code() {
|
||||
log_info "拉取最新代码..."
|
||||
|
||||
cd "$PROJECT_PATH"
|
||||
|
||||
# 保存本地修改(如果有)
|
||||
if ! git diff-index --quiet HEAD --; then
|
||||
log_warning "检测到本地修改,正在暂存..."
|
||||
git stash
|
||||
fi
|
||||
|
||||
# 拉取最新代码
|
||||
git pull origin "$DEPLOY_BRANCH" || {
|
||||
log_error "拉取代码失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log_success "代码更新完成"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:获取当前提交信息
|
||||
###############################################################################
|
||||
get_commit_info() {
|
||||
cd "$PROJECT_PATH"
|
||||
COMMIT_HASH=$(git rev-parse --short HEAD)
|
||||
COMMIT_MESSAGE=$(git log -1 --pretty=%B | head -n 1)
|
||||
COMMIT_AUTHOR=$(git log -1 --pretty=%an)
|
||||
COMMIT_TIME=$(git log -1 --pretty=%cd --date=format:'%Y-%m-%d %H:%M:%S')
|
||||
|
||||
echo "提交哈希: $COMMIT_HASH"
|
||||
echo "提交信息: $COMMIT_MESSAGE"
|
||||
echo "提交作者: $COMMIT_AUTHOR"
|
||||
echo "提交时间: $COMMIT_TIME"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:安装依赖
|
||||
###############################################################################
|
||||
install_dependencies() {
|
||||
if [ "$RUN_NPM_INSTALL" = "true" ]; then
|
||||
log_info "安装依赖..."
|
||||
|
||||
cd "$PROJECT_PATH"
|
||||
|
||||
# 检查 package.json 是否变化
|
||||
if git diff HEAD@{1} HEAD --name-only | grep -q "package.json"; then
|
||||
log_info "package.json 有变化,执行 npm install..."
|
||||
npm install || {
|
||||
log_error "依赖安装失败"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log_info "package.json 无变化,跳过 npm install"
|
||||
fi
|
||||
|
||||
log_success "依赖检查完成"
|
||||
else
|
||||
log_info "跳过依赖安装 (RUN_NPM_INSTALL=false)"
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:构建项目
|
||||
###############################################################################
|
||||
build_project() {
|
||||
log_info "构建项目..."
|
||||
|
||||
cd "$PROJECT_PATH"
|
||||
|
||||
# 执行构建
|
||||
npm run build || {
|
||||
log_error "构建失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 检查构建产物
|
||||
if [ ! -d "$PROJECT_PATH/build" ]; then
|
||||
log_error "构建产物不存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "构建完成"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:备份当前版本
|
||||
###############################################################################
|
||||
backup_current_version() {
|
||||
log_info "备份当前版本..."
|
||||
|
||||
local timestamp=$(date +%Y%m%d-%H%M%S)
|
||||
local backup_path="$BACKUP_DIR/backup-$timestamp"
|
||||
|
||||
if [ -d "$PRODUCTION_PATH" ] && [ "$(ls -A $PRODUCTION_PATH)" ]; then
|
||||
mkdir -p "$backup_path"
|
||||
cp -r "$PRODUCTION_PATH"/* "$backup_path/" || {
|
||||
log_error "备份失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 创建符号链接指向当前版本
|
||||
ln -snf "$backup_path" "$BACKUP_DIR/current"
|
||||
|
||||
log_success "备份完成: $backup_path"
|
||||
echo "$backup_path"
|
||||
else
|
||||
log_warning "生产目录为空,跳过备份"
|
||||
echo "no-backup"
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:清理旧备份
|
||||
###############################################################################
|
||||
cleanup_old_backups() {
|
||||
log_info "清理旧备份..."
|
||||
|
||||
cd "$BACKUP_DIR"
|
||||
|
||||
# 获取所有备份目录(排除 current 符号链接)
|
||||
local backup_count=$(find . -maxdepth 1 -type d -name "backup-*" | wc -l)
|
||||
|
||||
if [ "$backup_count" -gt "$KEEP_BACKUPS" ]; then
|
||||
local to_delete=$((backup_count - KEEP_BACKUPS))
|
||||
log_info "当前有 $backup_count 个备份,保留 $KEEP_BACKUPS 个,删除 $to_delete 个"
|
||||
|
||||
find . -maxdepth 1 -type d -name "backup-*" | sort | head -n "$to_delete" | while read dir; do
|
||||
log_info "删除旧备份: $dir"
|
||||
rm -rf "$dir"
|
||||
done
|
||||
|
||||
log_success "旧备份清理完成"
|
||||
else
|
||||
log_info "当前有 $backup_count 个备份,无需清理"
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:部署到生产环境
|
||||
###############################################################################
|
||||
deploy_to_production() {
|
||||
log_info "部署到生产环境..."
|
||||
|
||||
# 清空生产目录
|
||||
log_info "清空生产目录: $PRODUCTION_PATH"
|
||||
rm -rf "$PRODUCTION_PATH"/*
|
||||
|
||||
# 复制构建产物
|
||||
log_info "复制构建产物..."
|
||||
cp -r "$PROJECT_PATH/build"/* "$PRODUCTION_PATH/" || {
|
||||
log_error "复制文件失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 设置权限
|
||||
chmod -R 755 "$PRODUCTION_PATH"
|
||||
|
||||
log_success "部署完成"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 主函数
|
||||
###############################################################################
|
||||
main() {
|
||||
local start_time=$(date +%s)
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 服务器端部署脚本"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
# 创建目录
|
||||
create_directories
|
||||
|
||||
# 检查 Git 仓库
|
||||
check_git_repo
|
||||
|
||||
# 切换分支
|
||||
checkout_branch
|
||||
|
||||
# 拉取最新代码
|
||||
pull_latest_code
|
||||
|
||||
# 获取提交信息
|
||||
get_commit_info
|
||||
|
||||
# 安装依赖
|
||||
install_dependencies
|
||||
|
||||
# 构建项目
|
||||
build_project
|
||||
|
||||
# 备份当前版本
|
||||
backup_path=$(backup_current_version)
|
||||
|
||||
# 部署到生产环境
|
||||
deploy_to_production
|
||||
|
||||
# 清理旧备份
|
||||
cleanup_old_backups
|
||||
|
||||
# 计算耗时
|
||||
local end_time=$(date +%s)
|
||||
local duration=$((end_time - start_time))
|
||||
local minutes=$((duration / 60))
|
||||
local seconds=$((duration % 60))
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 部署成功!"
|
||||
echo "========================================"
|
||||
echo "提交: $COMMIT_HASH - $COMMIT_MESSAGE"
|
||||
echo "备份: $backup_path"
|
||||
echo "耗时: ${minutes}分${seconds}秒"
|
||||
echo ""
|
||||
|
||||
# 输出结果供本地脚本解析
|
||||
echo "DEPLOY_SUCCESS=true"
|
||||
echo "COMMIT_HASH=$COMMIT_HASH"
|
||||
echo "COMMIT_MESSAGE=$COMMIT_MESSAGE"
|
||||
echo "DEPLOY_DURATION=${minutes}分${seconds}秒"
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
234
scripts/notify-wechat.sh
Executable file
234
scripts/notify-wechat.sh
Executable file
@@ -0,0 +1,234 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# 企业微信通知脚本
|
||||
# 用于发送部署成功/失败通知到企业微信群
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# 加载配置文件
|
||||
if [ -f "$PROJECT_ROOT/.env.deploy" ]; then
|
||||
source "$PROJECT_ROOT/.env.deploy"
|
||||
else
|
||||
echo -e "${YELLOW}警告: 配置文件 .env.deploy 不存在,跳过通知${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 检查是否启用通知
|
||||
if [ "$ENABLE_WECHAT_NOTIFY" != "true" ]; then
|
||||
echo "企业微信通知未启用"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 检查 Webhook URL
|
||||
if [ -z "$WECHAT_WEBHOOK_URL" ]; then
|
||||
echo -e "${YELLOW}警告: 未配置企业微信 Webhook URL${NC}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
###############################################################################
|
||||
# 函数:发送文本消息
|
||||
###############################################################################
|
||||
send_text_message() {
|
||||
local content="$1"
|
||||
local mentioned_list="${2:-[]}"
|
||||
|
||||
local json_data=$(cat <<EOF
|
||||
{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": "$content",
|
||||
"mentioned_list": $mentioned_list
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# 发送 HTTP 请求
|
||||
response=$(curl -s -w "\n%{http_code}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$json_data" \
|
||||
"$WECHAT_WEBHOOK_URL")
|
||||
|
||||
# 提取 HTTP 状态码和响应体
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | sed '$d')
|
||||
|
||||
if [ "$http_code" -eq 200 ]; then
|
||||
echo -e "${GREEN}✓ 企业微信通知发送成功${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}✗ 企业微信通知发送失败 (HTTP $http_code)${NC}"
|
||||
echo "响应: $body"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:发送 Markdown 消息
|
||||
###############################################################################
|
||||
send_markdown_message() {
|
||||
local content="$1"
|
||||
|
||||
local json_data=$(cat <<EOF
|
||||
{
|
||||
"msgtype": "markdown",
|
||||
"markdown": {
|
||||
"content": "$content"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# 发送 HTTP 请求
|
||||
response=$(curl -s -w "\n%{http_code}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$json_data" \
|
||||
"$WECHAT_WEBHOOK_URL")
|
||||
|
||||
# 提取 HTTP 状态码和响应体
|
||||
http_code=$(echo "$response" | tail -n1)
|
||||
body=$(echo "$response" | sed '$d')
|
||||
|
||||
if [ "$http_code" -eq 200 ]; then
|
||||
echo -e "${GREEN}✓ 企业微信通知发送成功${NC}"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}✗ 企业微信通知发送失败 (HTTP $http_code)${NC}"
|
||||
echo "响应: $body"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:部署成功通知
|
||||
###############################################################################
|
||||
notify_deploy_success() {
|
||||
local branch="$1"
|
||||
local commit="$2"
|
||||
local message="$3"
|
||||
local duration="$4"
|
||||
local operator="${5:-unknown}"
|
||||
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
local content="【生产环境部署成功】
|
||||
项目:vf_react
|
||||
环境:生产环境
|
||||
分支:$branch
|
||||
版本:$commit
|
||||
提交信息:$message
|
||||
部署时间:$timestamp
|
||||
部署耗时:$duration
|
||||
操作人:$operator
|
||||
访问地址:https://valuefrontier.cn"
|
||||
|
||||
# 处理 mentioned_list
|
||||
local mentioned_list="[]"
|
||||
if [ -n "$WECHAT_MENTIONED_LIST" ]; then
|
||||
if [ "$WECHAT_MENTIONED_LIST" = "@all" ]; then
|
||||
mentioned_list='["@all"]'
|
||||
else
|
||||
# 假设是逗号分隔的手机号或 userid
|
||||
mentioned_list="[\"$WECHAT_MENTIONED_LIST\"]"
|
||||
fi
|
||||
fi
|
||||
|
||||
send_text_message "$content" "$mentioned_list"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:部署失败通知
|
||||
###############################################################################
|
||||
notify_deploy_failure() {
|
||||
local branch="$1"
|
||||
local error_message="$2"
|
||||
local operator="${3:-unknown}"
|
||||
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
local content="【⚠️ 生产环境部署失败】
|
||||
项目:vf_react
|
||||
环境:生产环境
|
||||
分支:$branch
|
||||
失败原因:$error_message
|
||||
失败时间:$timestamp
|
||||
操作人:$operator
|
||||
已自动回滚到上一版本"
|
||||
|
||||
# 处理 mentioned_list
|
||||
local mentioned_list="[]"
|
||||
if [ -n "$WECHAT_MENTIONED_LIST" ]; then
|
||||
if [ "$WECHAT_MENTIONED_LIST" = "@all" ]; then
|
||||
mentioned_list='["@all"]'
|
||||
else
|
||||
mentioned_list="[\"$WECHAT_MENTIONED_LIST\"]"
|
||||
fi
|
||||
fi
|
||||
|
||||
send_text_message "$content" "$mentioned_list"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:回滚成功通知
|
||||
###############################################################################
|
||||
notify_rollback_success() {
|
||||
local version="$1"
|
||||
local operator="${2:-unknown}"
|
||||
|
||||
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
local content="【版本回滚成功】
|
||||
项目:vf_react
|
||||
环境:生产环境
|
||||
回滚版本:$version
|
||||
回滚时间:$timestamp
|
||||
操作人:$operator"
|
||||
|
||||
send_text_message "$content"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 主程序
|
||||
###############################################################################
|
||||
main() {
|
||||
local action="${1:-}"
|
||||
|
||||
case "$action" in
|
||||
success)
|
||||
notify_deploy_success "$2" "$3" "$4" "$5" "$6"
|
||||
;;
|
||||
failure)
|
||||
notify_deploy_failure "$2" "$3" "$4"
|
||||
;;
|
||||
rollback)
|
||||
notify_rollback_success "$2" "$3"
|
||||
;;
|
||||
test)
|
||||
send_text_message "企业微信通知测试消息\n发送时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
;;
|
||||
*)
|
||||
echo "用法: $0 {success|failure|rollback|test} [参数...]"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 success feature abc123 'feat: 新功能' '2分15秒' ubuntu"
|
||||
echo " $0 failure feature '构建失败' ubuntu"
|
||||
echo " $0 rollback backup-20250121-143020 ubuntu"
|
||||
echo " $0 test"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
187
scripts/rollback-from-local.sh
Executable file
187
scripts/rollback-from-local.sh
Executable file
@@ -0,0 +1,187 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# 本地回滚脚本
|
||||
# 在本地运行,通过 SSH 连接服务器并执行回滚
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
###############################################################################
|
||||
# 函数:打印带颜色的消息
|
||||
###############################################################################
|
||||
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
log_success() { echo -e "${GREEN}[✓]${NC} $1"; }
|
||||
log_warning() { echo -e "${YELLOW}[⚠]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[✗]${NC} $1"; }
|
||||
|
||||
###############################################################################
|
||||
# 函数:加载配置文件
|
||||
###############################################################################
|
||||
load_config() {
|
||||
if [ ! -f "$PROJECT_ROOT/.env.deploy" ]; then
|
||||
log_error "配置文件不存在: $PROJECT_ROOT/.env.deploy"
|
||||
echo ""
|
||||
echo "请先运行以下命令进行配置:"
|
||||
echo " npm run deploy:setup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$PROJECT_ROOT/.env.deploy"
|
||||
|
||||
if [ -z "$SERVER_HOST" ] || [ -z "$SERVER_USER" ]; then
|
||||
log_error "配置不完整,请检查 .env.deploy 文件"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_success "配置加载完成"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:列出可回滚的版本
|
||||
###############################################################################
|
||||
list_backup_versions() {
|
||||
echo ""
|
||||
echo "正在获取可用的备份版本..."
|
||||
echo ""
|
||||
|
||||
local ssh_options=""
|
||||
if [ -n "$SSH_KEY_PATH" ]; then
|
||||
ssh_options="-i $SSH_KEY_PATH"
|
||||
fi
|
||||
if [ -n "$SERVER_PORT" ]; then
|
||||
ssh_options="$ssh_options -p $SERVER_PORT"
|
||||
fi
|
||||
|
||||
# 上传回滚脚本
|
||||
scp $ssh_options -q \
|
||||
"$SCRIPT_DIR/rollback-on-server.sh" \
|
||||
"$SERVER_USER@$SERVER_HOST":/tmp/ || {
|
||||
log_error "上传回滚脚本失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 执行列表命令
|
||||
local env_vars="PRODUCTION_PATH=$PRODUCTION_PATH BACKUP_DIR=$BACKUP_DIR"
|
||||
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "$env_vars bash /tmp/rollback-on-server.sh list"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:执行回滚
|
||||
###############################################################################
|
||||
execute_rollback() {
|
||||
local version_index="${1:-1}"
|
||||
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ 版本回滚工具 ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# 列出可用版本
|
||||
list_backup_versions
|
||||
|
||||
# 询问确认
|
||||
echo ""
|
||||
read -p "确认回滚到版本 #$version_index? (yes/no): " -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy][Ee][Ss]$ ]]; then
|
||||
log_info "回滚已取消"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# SSH 选项
|
||||
local ssh_options=""
|
||||
if [ -n "$SSH_KEY_PATH" ]; then
|
||||
ssh_options="-i $SSH_KEY_PATH"
|
||||
fi
|
||||
if [ -n "$SERVER_PORT" ]; then
|
||||
ssh_options="$ssh_options -p $SERVER_PORT"
|
||||
fi
|
||||
|
||||
log_info "正在执行回滚..."
|
||||
echo ""
|
||||
|
||||
# 执行回滚命令
|
||||
local env_vars="PRODUCTION_PATH=$PRODUCTION_PATH BACKUP_DIR=$BACKUP_DIR"
|
||||
local rollback_output=$(ssh $ssh_options "$SERVER_USER@$SERVER_HOST" \
|
||||
"$env_vars bash /tmp/rollback-on-server.sh rollback $version_index" 2>&1)
|
||||
|
||||
if echo "$rollback_output" | grep -q "ROLLBACK_SUCCESS=true"; then
|
||||
# 提取回滚版本
|
||||
local rollback_version=$(echo "$rollback_output" | grep "ROLLBACK_VERSION=" | cut -d= -f2)
|
||||
|
||||
# 发送通知
|
||||
if [ "$ENABLE_WECHAT_NOTIFY" = "true" ]; then
|
||||
bash "$SCRIPT_DIR/notify-wechat.sh" rollback \
|
||||
"$rollback_version" \
|
||||
"$USER" || {
|
||||
log_warning "企业微信通知发送失败"
|
||||
}
|
||||
fi
|
||||
|
||||
# 显示结果
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ 🎉 回滚成功! ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo -e "${BOLD}回滚信息:${NC}"
|
||||
echo " 目标版本: $rollback_version"
|
||||
echo " 回滚时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo ""
|
||||
echo -e "${BOLD}访问地址:${NC}"
|
||||
echo " https://valuefrontier.cn"
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
log_success "回滚完成"
|
||||
else
|
||||
log_error "回滚失败"
|
||||
echo "$rollback_output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 清理临时文件
|
||||
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "rm -f /tmp/rollback-on-server.sh" > /dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 主函数
|
||||
###############################################################################
|
||||
main() {
|
||||
local action="${1:-rollback}"
|
||||
local version_index="${2:-1}"
|
||||
|
||||
# 加载配置
|
||||
load_config
|
||||
|
||||
case "$action" in
|
||||
list)
|
||||
list_backup_versions
|
||||
;;
|
||||
rollback|*)
|
||||
execute_rollback "$version_index"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 错误处理
|
||||
trap 'log_error "回滚过程中发生错误"; exit 1' ERR
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
176
scripts/rollback-on-server.sh
Executable file
176
scripts/rollback-on-server.sh
Executable file
@@ -0,0 +1,176 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# 服务器端回滚脚本
|
||||
# 此脚本在服务器上执行,由本地回滚脚本远程调用
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
###############################################################################
|
||||
# 配置变量(通过环境变量传入)
|
||||
###############################################################################
|
||||
PRODUCTION_PATH="${PRODUCTION_PATH:-/var/www/valuefrontier.cn}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-/home/ubuntu/deployments}"
|
||||
|
||||
###############################################################################
|
||||
# 函数:打印带颜色的消息
|
||||
###############################################################################
|
||||
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
|
||||
log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
###############################################################################
|
||||
# 函数:列出可用的备份版本
|
||||
###############################################################################
|
||||
list_backups() {
|
||||
log_info "可用的备份版本:"
|
||||
echo ""
|
||||
|
||||
if [ ! -d "$BACKUP_DIR" ]; then
|
||||
log_error "备份目录不存在: $BACKUP_DIR"
|
||||
return 1
|
||||
fi
|
||||
|
||||
cd "$BACKUP_DIR"
|
||||
|
||||
# 获取所有备份目录,按时间倒序
|
||||
local backups=($(find . -maxdepth 1 -type d -name "backup-*" | sort -r))
|
||||
|
||||
if [ ${#backups[@]} -eq 0 ]; then
|
||||
log_warning "没有可用的备份版本"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local index=1
|
||||
for backup in "${backups[@]}"; do
|
||||
local backup_name=$(basename "$backup")
|
||||
local backup_time=$(echo "$backup_name" | sed 's/backup-//' | sed 's/-/ /')
|
||||
local is_current=""
|
||||
|
||||
# 检查是否是当前版本
|
||||
if [ -L "$BACKUP_DIR/current" ]; then
|
||||
local current_link=$(readlink "$BACKUP_DIR/current")
|
||||
if [ "$current_link" = "$backup" ] || [ "$current_link" = "$BACKUP_DIR/$backup_name" ]; then
|
||||
is_current=" ${GREEN}[当前版本]${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e " $index. $backup_name ($backup_time)$is_current"
|
||||
((index++))
|
||||
done
|
||||
|
||||
echo ""
|
||||
return 0
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:回滚到指定版本
|
||||
###############################################################################
|
||||
rollback_to_version() {
|
||||
local version_index="${1:-1}"
|
||||
|
||||
log_info "开始回滚到版本 #$version_index..."
|
||||
|
||||
if [ ! -d "$BACKUP_DIR" ]; then
|
||||
log_error "备份目录不存在: $BACKUP_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$BACKUP_DIR"
|
||||
|
||||
# 获取所有备份目录,按时间倒序
|
||||
local backups=($(find . -maxdepth 1 -type d -name "backup-*" | sort -r))
|
||||
|
||||
if [ ${#backups[@]} -eq 0 ]; then
|
||||
log_error "没有可用的备份版本"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查索引是否有效
|
||||
if [ "$version_index" -lt 1 ] || [ "$version_index" -gt "${#backups[@]}" ]; then
|
||||
log_error "无效的版本索引: $version_index (可用范围: 1-${#backups[@]})"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 获取目标备份
|
||||
local target_index=$((version_index - 1))
|
||||
local target_backup="${backups[$target_index]}"
|
||||
local backup_name=$(basename "$target_backup")
|
||||
|
||||
log_info "目标版本: $backup_name"
|
||||
|
||||
# 检查备份是否存在
|
||||
if [ ! -d "$target_backup" ]; then
|
||||
log_error "备份版本不存在: $target_backup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 清空生产目录
|
||||
log_info "清空生产目录: $PRODUCTION_PATH"
|
||||
rm -rf "$PRODUCTION_PATH"/*
|
||||
|
||||
# 恢复备份
|
||||
log_info "恢复备份文件..."
|
||||
cp -r "$target_backup"/* "$PRODUCTION_PATH/" || {
|
||||
log_error "恢复备份失败"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 设置权限
|
||||
chmod -R 755 "$PRODUCTION_PATH"
|
||||
|
||||
# 更新 current 符号链接
|
||||
ln -snf "$target_backup" "$BACKUP_DIR/current"
|
||||
|
||||
log_success "回滚完成"
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 回滚成功!"
|
||||
echo "========================================"
|
||||
echo "目标版本: $backup_name"
|
||||
echo ""
|
||||
|
||||
# 输出结果供本地脚本解析
|
||||
echo "ROLLBACK_SUCCESS=true"
|
||||
echo "ROLLBACK_VERSION=$backup_name"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 主函数
|
||||
###############################################################################
|
||||
main() {
|
||||
local action="${1:-list}"
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " 服务器端回滚脚本"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
case "$action" in
|
||||
list)
|
||||
list_backups
|
||||
;;
|
||||
rollback)
|
||||
local version_index="${2:-1}"
|
||||
rollback_to_version "$version_index"
|
||||
;;
|
||||
*)
|
||||
log_error "未知操作: $action"
|
||||
echo "用法: $0 {list|rollback} [version_index]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
407
scripts/setup-deployment.sh
Executable file
407
scripts/setup-deployment.sh
Executable file
@@ -0,0 +1,407 @@
|
||||
#!/bin/bash
|
||||
|
||||
###############################################################################
|
||||
# 部署配置向导
|
||||
# 首次使用时运行,引导用户完成部署配置
|
||||
###############################################################################
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
CYAN='\033[0;36m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
# 获取脚本所在目录
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
CONFIG_FILE="$PROJECT_ROOT/.env.deploy"
|
||||
EXAMPLE_FILE="$PROJECT_ROOT/.env.deploy.example"
|
||||
|
||||
###############################################################################
|
||||
# 函数:打印带颜色的消息
|
||||
###############################################################################
|
||||
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
||||
log_success() { echo -e "${GREEN}[✓]${NC} $1"; }
|
||||
log_warning() { echo -e "${YELLOW}[⚠]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[✗]${NC} $1"; }
|
||||
log_step() { echo -e "${CYAN}${BOLD}[$1]${NC} $2"; }
|
||||
|
||||
###############################################################################
|
||||
# 函数:读取用户输入(带默认值)
|
||||
###############################################################################
|
||||
read_input() {
|
||||
local prompt="$1"
|
||||
local default="$2"
|
||||
local result
|
||||
|
||||
if [ -n "$default" ]; then
|
||||
read -p "$prompt [$default]: " result
|
||||
echo "${result:-$default}"
|
||||
else
|
||||
read -p "$prompt: " result
|
||||
echo "$result"
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:读取密码(隐藏输入)
|
||||
###############################################################################
|
||||
read_password() {
|
||||
local prompt="$1"
|
||||
local result
|
||||
|
||||
read -sp "$prompt: " result
|
||||
echo ""
|
||||
echo "$result"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:测试 SSH 连接
|
||||
###############################################################################
|
||||
test_ssh() {
|
||||
local host="$1"
|
||||
local user="$2"
|
||||
local port="$3"
|
||||
local key_path="$4"
|
||||
|
||||
local ssh_options="-o ConnectTimeout=10 -o BatchMode=yes"
|
||||
|
||||
if [ -n "$key_path" ]; then
|
||||
ssh_options="$ssh_options -i $key_path"
|
||||
fi
|
||||
|
||||
if [ -n "$port" ] && [ "$port" != "22" ]; then
|
||||
ssh_options="$ssh_options -p $port"
|
||||
fi
|
||||
|
||||
ssh $ssh_options "$user@$host" "echo 'SSH 连接测试成功'" 2>/dev/null
|
||||
return $?
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:测试企业微信 Webhook
|
||||
###############################################################################
|
||||
test_wechat_webhook() {
|
||||
local webhook_url="$1"
|
||||
|
||||
local test_message='{"msgtype":"text","text":{"content":"企业微信通知测试\n发送时间: '$(date +"%Y-%m-%d %H:%M:%S")'"}}'
|
||||
|
||||
local response=$(curl -s -w "\n%{http_code}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$test_message" \
|
||||
"$webhook_url")
|
||||
|
||||
local http_code=$(echo "$response" | tail -n1)
|
||||
|
||||
if [ "$http_code" -eq 200 ]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:显示欢迎信息
|
||||
###############################################################################
|
||||
show_welcome() {
|
||||
clear
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ VF React 部署配置向导 ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "本向导将帮助您完成以下配置:"
|
||||
echo " 1. 服务器连接配置 (SSH)"
|
||||
echo " 2. 部署路径配置"
|
||||
echo " 3. 企业微信通知配置 (可选)"
|
||||
echo " 4. 初始化服务器环境"
|
||||
echo ""
|
||||
read -p "按 Enter 键继续..."
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:配置服务器信息
|
||||
###############################################################################
|
||||
configure_server() {
|
||||
log_step "1/4" "服务器配置"
|
||||
echo ""
|
||||
|
||||
# 服务器地址
|
||||
SERVER_HOST=$(read_input "请输入服务器 IP 或域名")
|
||||
while [ -z "$SERVER_HOST" ]; do
|
||||
log_error "服务器地址不能为空"
|
||||
SERVER_HOST=$(read_input "请输入服务器 IP 或域名")
|
||||
done
|
||||
|
||||
# SSH 用户名
|
||||
SERVER_USER=$(read_input "请输入 SSH 用户名" "ubuntu")
|
||||
|
||||
# SSH 端口
|
||||
SERVER_PORT=$(read_input "请输入 SSH 端口" "22")
|
||||
|
||||
# SSH 密钥路径
|
||||
local default_key="$HOME/.ssh/id_rsa"
|
||||
if [ -f "$default_key" ]; then
|
||||
log_info "检测到 SSH 密钥: $default_key"
|
||||
local use_default=$(read_input "是否使用该密钥? (y/n)" "y")
|
||||
if [ "$use_default" = "y" ] || [ "$use_default" = "Y" ]; then
|
||||
SSH_KEY_PATH="$default_key"
|
||||
else
|
||||
SSH_KEY_PATH=$(read_input "请输入 SSH 密钥路径")
|
||||
fi
|
||||
else
|
||||
SSH_KEY_PATH=$(read_input "请输入 SSH 密钥路径 (留空使用默认)")
|
||||
fi
|
||||
|
||||
# 测试 SSH 连接
|
||||
echo ""
|
||||
log_info "正在测试 SSH 连接..."
|
||||
if test_ssh "$SERVER_HOST" "$SERVER_USER" "$SERVER_PORT" "$SSH_KEY_PATH"; then
|
||||
log_success "SSH 连接测试成功"
|
||||
else
|
||||
log_error "SSH 连接测试失败"
|
||||
echo ""
|
||||
echo "请检查:"
|
||||
echo " 1. 服务器地址是否正确"
|
||||
echo " 2. SSH 用户名和端口是否正确"
|
||||
echo " 3. SSH 密钥是否配置正确"
|
||||
echo ""
|
||||
read -p "是否继续配置? (y/n): " continue_setup
|
||||
if [ "$continue_setup" != "y" ] && [ "$continue_setup" != "Y" ]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:配置部署路径
|
||||
###############################################################################
|
||||
configure_paths() {
|
||||
log_step "2/4" "部署路径配置"
|
||||
echo ""
|
||||
|
||||
# Git 仓库路径
|
||||
REMOTE_PROJECT_PATH=$(read_input "Git 仓库路径" "/home/ubuntu/vf_react")
|
||||
|
||||
# 生产环境路径
|
||||
PRODUCTION_PATH=$(read_input "生产环境路径" "/var/www/valuefrontier.cn")
|
||||
|
||||
# 备份目录
|
||||
BACKUP_DIR=$(read_input "备份目录" "/home/ubuntu/deployments")
|
||||
|
||||
# 日志目录
|
||||
LOG_DIR=$(read_input "日志目录" "/home/ubuntu/deploy-logs")
|
||||
|
||||
# 部署分支
|
||||
DEPLOY_BRANCH=$(read_input "部署分支" "feature")
|
||||
|
||||
# 保留备份数量
|
||||
KEEP_BACKUPS=$(read_input "保留备份数量" "5")
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:配置企业微信通知
|
||||
###############################################################################
|
||||
configure_wechat() {
|
||||
log_step "3/4" "企业微信通知配置"
|
||||
echo ""
|
||||
|
||||
local enable_notify=$(read_input "是否启用企业微信通知? (y/n)" "n")
|
||||
|
||||
if [ "$enable_notify" = "y" ] || [ "$enable_notify" = "Y" ]; then
|
||||
ENABLE_WECHAT_NOTIFY="true"
|
||||
|
||||
echo ""
|
||||
echo "请按以下步骤获取企业微信 Webhook URL:"
|
||||
echo " 1. 打开企业微信群聊"
|
||||
echo " 2. 点击群设置 -> 群机器人 -> 添加机器人"
|
||||
echo " 3. 复制 Webhook URL"
|
||||
echo ""
|
||||
|
||||
WECHAT_WEBHOOK_URL=$(read_input "请输入企业微信 Webhook URL")
|
||||
|
||||
if [ -n "$WECHAT_WEBHOOK_URL" ]; then
|
||||
log_info "正在测试企业微信通知..."
|
||||
if test_wechat_webhook "$WECHAT_WEBHOOK_URL"; then
|
||||
log_success "企业微信通知测试成功"
|
||||
else
|
||||
log_warning "企业微信通知测试失败,请检查 Webhook URL"
|
||||
fi
|
||||
fi
|
||||
|
||||
WECHAT_MENTIONED_LIST=$(read_input "提及用户 (手机号/userid,留空不提及)" "")
|
||||
else
|
||||
ENABLE_WECHAT_NOTIFY="false"
|
||||
WECHAT_WEBHOOK_URL=""
|
||||
WECHAT_MENTIONED_LIST=""
|
||||
fi
|
||||
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:初始化服务器环境
|
||||
###############################################################################
|
||||
initialize_server() {
|
||||
log_step "4/4" "初始化服务器环境"
|
||||
echo ""
|
||||
|
||||
local ssh_options=""
|
||||
if [ -n "$SSH_KEY_PATH" ]; then
|
||||
ssh_options="-i $SSH_KEY_PATH"
|
||||
fi
|
||||
if [ -n "$SERVER_PORT" ] && [ "$SERVER_PORT" != "22" ]; then
|
||||
ssh_options="$ssh_options -p $SERVER_PORT"
|
||||
fi
|
||||
|
||||
log_info "正在创建服务器目录..."
|
||||
|
||||
# 创建必要的目录
|
||||
ssh $ssh_options "$SERVER_USER@$SERVER_HOST" "
|
||||
mkdir -p $BACKUP_DIR
|
||||
mkdir -p $LOG_DIR
|
||||
mkdir -p $PRODUCTION_PATH
|
||||
" || {
|
||||
log_error "创建目录失败"
|
||||
return 1
|
||||
}
|
||||
|
||||
log_success "服务器目录创建完成"
|
||||
|
||||
# 设置脚本执行权限
|
||||
log_info "设置脚本执行权限..."
|
||||
chmod +x "$SCRIPT_DIR"/*.sh
|
||||
|
||||
log_success "服务器环境初始化完成"
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:保存配置文件
|
||||
###############################################################################
|
||||
save_config() {
|
||||
log_info "保存配置文件..."
|
||||
|
||||
# 如果配置文件已存在,先备份
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
local backup_file="$CONFIG_FILE.backup.$(date +%Y%m%d%H%M%S)"
|
||||
cp "$CONFIG_FILE" "$backup_file"
|
||||
log_info "已备份原配置文件: $backup_file"
|
||||
fi
|
||||
|
||||
# 从示例文件复制
|
||||
if [ -f "$EXAMPLE_FILE" ]; then
|
||||
cp "$EXAMPLE_FILE" "$CONFIG_FILE"
|
||||
else
|
||||
touch "$CONFIG_FILE"
|
||||
fi
|
||||
|
||||
# 写入配置
|
||||
cat > "$CONFIG_FILE" <<EOF
|
||||
# 部署配置文件
|
||||
# 由 setup-deployment.sh 自动生成
|
||||
# 生成时间: $(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# ==================== 服务器配置 ====================
|
||||
SERVER_HOST=$SERVER_HOST
|
||||
SERVER_USER=$SERVER_USER
|
||||
SERVER_PORT=$SERVER_PORT
|
||||
SSH_KEY_PATH=$SSH_KEY_PATH
|
||||
|
||||
# ==================== 路径配置 ====================
|
||||
REMOTE_PROJECT_PATH=$REMOTE_PROJECT_PATH
|
||||
PRODUCTION_PATH=$PRODUCTION_PATH
|
||||
BACKUP_DIR=$BACKUP_DIR
|
||||
LOG_DIR=$LOG_DIR
|
||||
|
||||
# ==================== Git 配置 ====================
|
||||
DEPLOY_BRANCH=$DEPLOY_BRANCH
|
||||
|
||||
# ==================== 备份配置 ====================
|
||||
KEEP_BACKUPS=$KEEP_BACKUPS
|
||||
|
||||
# ==================== 企业微信通知配置 ====================
|
||||
ENABLE_WECHAT_NOTIFY=$ENABLE_WECHAT_NOTIFY
|
||||
WECHAT_WEBHOOK_URL=$WECHAT_WEBHOOK_URL
|
||||
WECHAT_MENTIONED_LIST=$WECHAT_MENTIONED_LIST
|
||||
|
||||
# ==================== 部署配置 ====================
|
||||
RUN_NPM_INSTALL=true
|
||||
RUN_NPM_TEST=false
|
||||
BUILD_COMMAND=npm run build
|
||||
|
||||
# ==================== 高级配置 ====================
|
||||
SSH_TIMEOUT=30
|
||||
DEPLOY_TIMEOUT=600
|
||||
EOF
|
||||
|
||||
log_success "配置文件已保存: $CONFIG_FILE"
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 函数:显示完成信息
|
||||
###############################################################################
|
||||
show_completion() {
|
||||
echo ""
|
||||
echo "╔════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ ✓ 配置完成! ║"
|
||||
echo "╚════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo -e "${BOLD}配置信息:${NC}"
|
||||
echo " 服务器: $SERVER_USER@$SERVER_HOST:$SERVER_PORT"
|
||||
echo " Git 仓库: $REMOTE_PROJECT_PATH"
|
||||
echo " 生产环境: $PRODUCTION_PATH"
|
||||
echo " 部署分支: $DEPLOY_BRANCH"
|
||||
echo " 企业微信通知: $([ "$ENABLE_WECHAT_NOTIFY" = "true" ] && echo "已启用" || echo "未启用")"
|
||||
echo ""
|
||||
echo -e "${BOLD}接下来您可以:${NC}"
|
||||
echo " • 部署到生产环境: ${GREEN}npm run deploy${NC}"
|
||||
echo " • 查看备份版本: ${GREEN}npm run rollback -- list${NC}"
|
||||
echo " • 回滚到上一版本: ${GREEN}npm run rollback${NC}"
|
||||
echo " • 修改配置文件: ${GREEN}.env.deploy${NC}"
|
||||
echo ""
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
}
|
||||
|
||||
###############################################################################
|
||||
# 主函数
|
||||
###############################################################################
|
||||
main() {
|
||||
# 显示欢迎信息
|
||||
show_welcome
|
||||
|
||||
# 配置服务器
|
||||
configure_server
|
||||
|
||||
# 配置路径
|
||||
configure_paths
|
||||
|
||||
# 配置企业微信
|
||||
configure_wechat
|
||||
|
||||
# 初始化服务器环境
|
||||
initialize_server
|
||||
|
||||
# 保存配置
|
||||
save_config
|
||||
|
||||
# 显示完成信息
|
||||
show_completion
|
||||
}
|
||||
|
||||
# 错误处理
|
||||
trap 'log_error "配置过程中发生错误"; exit 1' ERR
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
108
src/App.js
108
src/App.js
@@ -40,13 +40,19 @@ const StockOverview = React.lazy(() => import("views/StockOverview"));
|
||||
const EventDetail = React.lazy(() => import("views/EventDetail"));
|
||||
const TradingSimulation = React.lazy(() => import("views/TradingSimulation"));
|
||||
|
||||
// Redux
|
||||
import { Provider as ReduxProvider } from 'react-redux';
|
||||
import { store } from './store';
|
||||
|
||||
// Contexts
|
||||
import { AuthProvider } from "contexts/AuthContext";
|
||||
import { AuthModalProvider } from "contexts/AuthModalContext";
|
||||
import { NotificationProvider, useNotification } from "contexts/NotificationContext";
|
||||
import { IndustryProvider } from "contexts/IndustryContext";
|
||||
|
||||
// Components
|
||||
import ProtectedRoute from "components/ProtectedRoute";
|
||||
import ProtectedRouteRedirect from "components/ProtectedRouteRedirect";
|
||||
import ErrorBoundary from "components/ErrorBoundary";
|
||||
import AuthModalManager from "components/Auth/AuthModalManager";
|
||||
import NotificationContainer from "components/NotificationContainer";
|
||||
@@ -183,15 +189,66 @@ function AppContent() {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 事件详情独立页面路由 (不经 Admin 布局) */}
|
||||
<Route path="event-detail/:eventId" element={<EventDetail />} />
|
||||
{/* 事件详情独立页面路由 - 需要登录(跳转模式) */}
|
||||
<Route
|
||||
path="event-detail/:eventId"
|
||||
element={
|
||||
<ProtectedRouteRedirect>
|
||||
<EventDetail />
|
||||
</ProtectedRouteRedirect>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 公司相关页面 */}
|
||||
<Route path="forecast-report" element={<ForecastReport />} />
|
||||
<Route path="Financial" element={<FinancialPanorama />} />
|
||||
<Route path="company" element={<CompanyIndex />} />
|
||||
<Route path="company/:code" element={<CompanyIndex />} />
|
||||
<Route path="market-data" element={<MarketDataView />} />
|
||||
{/* 财报预测 - 需要登录(跳转模式) */}
|
||||
<Route
|
||||
path="forecast-report"
|
||||
element={
|
||||
<ProtectedRouteRedirect>
|
||||
<ForecastReport />
|
||||
</ProtectedRouteRedirect>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 财务全景 - 需要登录(弹窗模式) */}
|
||||
<Route
|
||||
path="Financial"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<FinancialPanorama />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 公司页面 - 需要登录(弹窗模式) */}
|
||||
<Route
|
||||
path="company"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<CompanyIndex />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 公司详情 - 需要登录(跳转模式) */}
|
||||
<Route
|
||||
path="company/:code"
|
||||
element={
|
||||
<ProtectedRouteRedirect>
|
||||
<CompanyIndex />
|
||||
</ProtectedRouteRedirect>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 市场数据 - 需要登录(弹窗模式) */}
|
||||
<Route
|
||||
path="market-data"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<MarketDataView />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* 认证页面路由 - 不使用 MainLayout */}
|
||||
@@ -238,19 +295,32 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ChakraProvider theme={theme}>
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<AuthModalProvider>
|
||||
<NotificationProvider>
|
||||
<AppContent />
|
||||
<AuthModalManager />
|
||||
<NotificationContainer />
|
||||
<NotificationTestTool />
|
||||
</NotificationProvider>
|
||||
</AuthModalProvider>
|
||||
</AuthProvider>
|
||||
<ReduxProvider store={store}>
|
||||
<ChakraProvider
|
||||
theme={theme}
|
||||
toastOptions={{
|
||||
defaultOptions: {
|
||||
position: 'top',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<NotificationProvider>
|
||||
<AuthProvider>
|
||||
<AuthModalProvider>
|
||||
<IndustryProvider>
|
||||
<AppContent />
|
||||
<AuthModalManager />
|
||||
<NotificationContainer />
|
||||
<NotificationTestTool />
|
||||
</IndustryProvider>
|
||||
</AuthModalProvider>
|
||||
</AuthProvider>
|
||||
</NotificationProvider>
|
||||
</ErrorBoundary>
|
||||
</ChakraProvider>
|
||||
</ReduxProvider>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { FaLock, FaWeixin } from "react-icons/fa";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { useAuthModal } from "../../contexts/AuthModalContext";
|
||||
import { useNotification } from "../../contexts/NotificationContext";
|
||||
import { authService } from "../../services/authService";
|
||||
import AuthHeader from './AuthHeader';
|
||||
import VerificationCodeInput from './VerificationCodeInput';
|
||||
@@ -66,6 +67,7 @@ export default function AuthFormContent() {
|
||||
const navigate = useNavigate();
|
||||
const { checkSession } = useAuth();
|
||||
const { handleLoginSuccess } = useAuthModal();
|
||||
const { showWelcomeGuide } = useNotification();
|
||||
|
||||
// 使用统一配置
|
||||
const config = AUTH_CONFIG;
|
||||
@@ -85,7 +87,7 @@ export default function AuthFormContent() {
|
||||
// 响应式布局配置
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
const stackDirection = useBreakpointValue({ base: "column", md: "row" });
|
||||
const stackSpacing = useBreakpointValue({ base: 4, md: 8 });
|
||||
const stackSpacing = useBreakpointValue({ base: 4, md: 2 }); // ✅ 桌面端从32px减至8px,更紧凑
|
||||
|
||||
// 表单数据
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -141,7 +143,10 @@ export default function AuthFormContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(credential)) {
|
||||
// 清理手机号格式字符(空格、横线、括号等)
|
||||
const cleanedCredential = credential.replace(/[\s\-\(\)\+]/g, '');
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(cleanedCredential)) {
|
||||
toast({
|
||||
title: "请输入有效的手机号",
|
||||
status: "warning",
|
||||
@@ -154,7 +159,7 @@ export default function AuthFormContent() {
|
||||
setSendingCode(true);
|
||||
|
||||
const requestData = {
|
||||
credential: credential.trim(), // 添加 trim() 防止空格
|
||||
credential: cleanedCredential, // 使用清理后的手机号
|
||||
type: 'phone',
|
||||
purpose: config.api.purpose
|
||||
};
|
||||
@@ -187,13 +192,13 @@ export default function AuthFormContent() {
|
||||
if (response.ok && data.success) {
|
||||
// ❌ 移除成功 toast,静默处理
|
||||
logger.info('AuthFormContent', '验证码发送成功', {
|
||||
credential: credential.substring(0, 3) + '****' + credential.substring(7),
|
||||
credential: cleanedCredential.substring(0, 3) + '****' + cleanedCredential.substring(7),
|
||||
dev_code: data.dev_code
|
||||
});
|
||||
|
||||
// ✅ 开发环境下在控制台显示验证码
|
||||
if (data.dev_code) {
|
||||
console.log(`%c✅ [验证码] ${credential} -> ${data.dev_code}`, 'color: #16a34a; font-weight: bold; font-size: 14px;');
|
||||
console.log(`%c✅ [验证码] ${cleanedCredential} -> ${data.dev_code}`, 'color: #16a34a; font-weight: bold; font-size: 14px;');
|
||||
}
|
||||
|
||||
setVerificationCodeSent(true);
|
||||
@@ -202,9 +207,22 @@ export default function AuthFormContent() {
|
||||
throw new Error(data.error || '发送验证码失败');
|
||||
}
|
||||
} catch (error) {
|
||||
// ❌ 移除错误 toast,仅 console 输出
|
||||
logger.api.error('POST', '/api/auth/send-verification-code', error, {
|
||||
credential: credential.substring(0, 3) + '****' + credential.substring(7)
|
||||
credential: cleanedCredential.substring(0, 3) + '****' + cleanedCredential.substring(7)
|
||||
});
|
||||
|
||||
// ✅ 显示错误提示给用户
|
||||
toast({
|
||||
id: 'send-code-error',
|
||||
title: "发送验证码失败",
|
||||
description: error.message || "请稍后重试",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
position: 'top',
|
||||
containerStyle: {
|
||||
zIndex: 10000,
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
@@ -219,7 +237,7 @@ export default function AuthFormContent() {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const { phone, verificationCode, nickname } = formData;
|
||||
const { phone, verificationCode } = formData;
|
||||
|
||||
// 表单验证
|
||||
if (!phone || !verificationCode) {
|
||||
@@ -232,7 +250,10 @@ export default function AuthFormContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
// 清理手机号格式字符(空格、横线、括号等)
|
||||
const cleanedPhone = phone.replace(/[\s\-\(\)\+]/g, '');
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(cleanedPhone)) {
|
||||
toast({
|
||||
title: "请输入有效的手机号",
|
||||
status: "warning",
|
||||
@@ -243,13 +264,13 @@ export default function AuthFormContent() {
|
||||
|
||||
// 构建请求体
|
||||
const requestBody = {
|
||||
credential: phone.trim(), // 添加 trim() 防止空格
|
||||
credential: cleanedPhone, // 使用清理后的手机号
|
||||
verification_code: verificationCode.trim(), // 添加 trim() 防止空格
|
||||
login_type: 'phone',
|
||||
};
|
||||
|
||||
logger.api.request('POST', '/api/auth/login-with-code', {
|
||||
credential: phone.substring(0, 3) + '****' + phone.substring(7),
|
||||
credential: cleanedPhone.substring(0, 3) + '****' + cleanedPhone.substring(7),
|
||||
verification_code: verificationCode.substring(0, 2) + '****',
|
||||
login_type: 'phone'
|
||||
});
|
||||
@@ -321,16 +342,37 @@ export default function AuthFormContent() {
|
||||
handleLoginSuccess({ phone });
|
||||
}, config.features.successDelay);
|
||||
}
|
||||
|
||||
// ⚡ 延迟 10 秒显示权限引导(温和、非侵入)
|
||||
setTimeout(() => {
|
||||
if (showWelcomeGuide) {
|
||||
logger.info('AuthFormContent', '显示欢迎引导');
|
||||
showWelcomeGuide();
|
||||
}
|
||||
}, 10000);
|
||||
} else {
|
||||
throw new Error(data.error || `${config.errorTitle}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// ❌ 移除错误 toast,仅 console 输出
|
||||
const { phone, verificationCode } = formData;
|
||||
logger.error('AuthFormContent', 'handleSubmit', error, {
|
||||
phone: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : 'N/A',
|
||||
hasVerificationCode: !!verificationCode
|
||||
});
|
||||
|
||||
// ✅ 显示错误提示给用户
|
||||
toast({
|
||||
id: 'auth-verification-error',
|
||||
title: config.errorTitle,
|
||||
description: error.message || "请检查验证码是否正确",
|
||||
status: "error",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
position: 'top',
|
||||
containerStyle: {
|
||||
zIndex: 10000,
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
@@ -465,8 +507,8 @@ export default function AuthFormContent() {
|
||||
|
||||
{/* 桌面端:右侧二维码扫描 */}
|
||||
{!isMobile && (
|
||||
<Box flex="1">
|
||||
<Center width="100%" bg="gray.50" borderRadius="lg" p={8}>
|
||||
<Box flex={{ base: "1", md: "0 0 auto" }}> {/* ✅ 桌面端让右侧自适应宽度 */}
|
||||
<Center width="100%"> {/* ✅ 移除bg和p,WechatRegister自带白色背景和padding */}
|
||||
<WechatRegister />
|
||||
</Center>
|
||||
</Box>
|
||||
|
||||
@@ -3,14 +3,20 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
VStack,
|
||||
HStack,
|
||||
Center,
|
||||
Text,
|
||||
Heading,
|
||||
Icon,
|
||||
useToast,
|
||||
Spinner
|
||||
} from "@chakra-ui/react";
|
||||
import { FaQrcode } from "react-icons/fa";
|
||||
import { FiAlertCircle } from "react-icons/fi";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { authService, WECHAT_STATUS } from "../../services/authService";
|
||||
import { authService, WECHAT_STATUS, STATUS_MESSAGES } from "../../services/authService";
|
||||
import { useAuthModal } from "../../contexts/AuthModalContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { logger } from "../../utils/logger";
|
||||
|
||||
// 配置常量
|
||||
@@ -18,7 +24,35 @@ const POLL_INTERVAL = 2000; // 轮询间隔:2秒
|
||||
const BACKUP_POLL_INTERVAL = 3000; // 备用轮询间隔:3秒
|
||||
const QR_CODE_TIMEOUT = 300000; // 二维码超时:5分钟
|
||||
|
||||
/**
|
||||
* 获取状态文字颜色
|
||||
*/
|
||||
const getStatusColor = (status) => {
|
||||
switch(status) {
|
||||
case WECHAT_STATUS.WAITING: return "gray.600"; // ✅ 灰色文字
|
||||
case WECHAT_STATUS.SCANNED: return "green.600"; // ✅ 绿色文字
|
||||
case WECHAT_STATUS.AUTHORIZED: return "green.600"; // ✅ 绿色文字
|
||||
case WECHAT_STATUS.EXPIRED: return "orange.600"; // ✅ 橙色文字
|
||||
case WECHAT_STATUS.LOGIN_SUCCESS: return "green.600"; // ✅ 绿色文字
|
||||
case WECHAT_STATUS.REGISTER_SUCCESS: return "green.600";
|
||||
case WECHAT_STATUS.AUTH_DENIED: return "red.600"; // ✅ 红色文字
|
||||
case WECHAT_STATUS.AUTH_FAILED: return "red.600"; // ✅ 红色文字
|
||||
default: return "gray.600";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取状态文字
|
||||
*/
|
||||
const getStatusText = (status) => {
|
||||
return STATUS_MESSAGES[status] || "点击按钮获取二维码";
|
||||
};
|
||||
|
||||
export default function WechatRegister() {
|
||||
// 获取关闭弹窗方法
|
||||
const { closeModal } = useAuthModal();
|
||||
const { refreshSession } = useAuth();
|
||||
|
||||
// 状态管理
|
||||
const [wechatAuthUrl, setWechatAuthUrl] = useState("");
|
||||
const [wechatSessionId, setWechatSessionId] = useState("");
|
||||
@@ -32,6 +66,7 @@ export default function WechatRegister() {
|
||||
const timeoutRef = useRef(null);
|
||||
const isMountedRef = useRef(true); // 追踪组件挂载状态
|
||||
const containerRef = useRef(null); // 容器DOM引用
|
||||
const sessionIdRef = useRef(null); // 存储最新的 sessionId,避免闭包陷阱
|
||||
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
@@ -64,6 +99,7 @@ export default function WechatRegister() {
|
||||
|
||||
/**
|
||||
* 清理所有定时器
|
||||
* 注意:不清理 sessionIdRef,因为 startPolling 时也会调用此函数
|
||||
*/
|
||||
const clearTimers = useCallback(() => {
|
||||
if (pollIntervalRef.current) {
|
||||
@@ -98,14 +134,14 @@ export default function WechatRegister() {
|
||||
}
|
||||
|
||||
showSuccess(
|
||||
status === WECHAT_STATUS.LOGIN_SUCCESS ? "登录成功" : "注册成功",
|
||||
"正在跳转..."
|
||||
status === WECHAT_STATUS.LOGIN_SUCCESS ? "登录成功" : "欢迎回来!"
|
||||
);
|
||||
|
||||
// 延迟跳转,让用户看到成功提示
|
||||
setTimeout(() => {
|
||||
navigate("/home");
|
||||
}, 1000);
|
||||
// 刷新 AuthContext 状态
|
||||
await refreshSession();
|
||||
|
||||
// 关闭认证弹窗,留在当前页面
|
||||
closeModal();
|
||||
} else {
|
||||
throw new Error(response?.error || '登录失败');
|
||||
}
|
||||
@@ -113,17 +149,27 @@ export default function WechatRegister() {
|
||||
logger.error('WechatRegister', 'handleLoginSuccess', error, { sessionId });
|
||||
showError("登录失败", error.message || "请重试");
|
||||
}
|
||||
}, [navigate, showSuccess, showError]);
|
||||
}, [showSuccess, showError, closeModal, refreshSession]);
|
||||
|
||||
/**
|
||||
* 检查微信扫码状态
|
||||
* 使用 sessionIdRef.current 避免闭包陷阱
|
||||
*/
|
||||
const checkWechatStatus = useCallback(async () => {
|
||||
// 检查组件是否已卸载
|
||||
if (!isMountedRef.current || !wechatSessionId) return;
|
||||
// 检查组件是否已卸载,使用 ref 获取最新的 sessionId
|
||||
if (!isMountedRef.current || !sessionIdRef.current) {
|
||||
logger.debug('WechatRegister', 'checkWechatStatus 跳过', {
|
||||
isMounted: isMountedRef.current,
|
||||
hasSessionId: !!sessionIdRef.current
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSessionId = sessionIdRef.current;
|
||||
logger.debug('WechatRegister', '检查微信状态', { sessionId: currentSessionId });
|
||||
|
||||
try {
|
||||
const response = await authService.checkWechatStatus(wechatSessionId);
|
||||
const response = await authService.checkWechatStatus(currentSessionId);
|
||||
|
||||
// 安全检查:确保 response 存在且包含 status
|
||||
if (!response || typeof response.status === 'undefined') {
|
||||
@@ -132,6 +178,7 @@ export default function WechatRegister() {
|
||||
}
|
||||
|
||||
const { status } = response;
|
||||
logger.debug('WechatRegister', '微信状态', { status });
|
||||
|
||||
// 组件卸载后不再更新状态
|
||||
if (!isMountedRef.current) return;
|
||||
@@ -141,11 +188,14 @@ export default function WechatRegister() {
|
||||
// 处理成功状态
|
||||
if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) {
|
||||
clearTimers(); // 停止轮询
|
||||
await handleLoginSuccess(wechatSessionId, status);
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
|
||||
await handleLoginSuccess(currentSessionId, status);
|
||||
}
|
||||
// 处理过期状态
|
||||
else if (status === WECHAT_STATUS.EXPIRED) {
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "授权已过期",
|
||||
@@ -156,12 +206,40 @@ export default function WechatRegister() {
|
||||
});
|
||||
}
|
||||
}
|
||||
// 处理用户拒绝授权
|
||||
else if (status === WECHAT_STATUS.AUTH_DENIED) {
|
||||
clearTimers();
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "授权已取消",
|
||||
description: "您已取消微信授权登录",
|
||||
status: "warning",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
// 处理授权失败
|
||||
else if (status === WECHAT_STATUS.AUTH_FAILED) {
|
||||
clearTimers();
|
||||
if (isMountedRef.current) {
|
||||
const errorMsg = response.error || "授权过程出现错误";
|
||||
toast({
|
||||
title: "授权失败",
|
||||
description: errorMsg,
|
||||
status: "error",
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: wechatSessionId });
|
||||
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: currentSessionId });
|
||||
// 轮询过程中的错误不显示给用户,避免频繁提示
|
||||
// 但如果错误持续发生,停止轮询避免无限重试
|
||||
if (error.message.includes('网络连接失败')) {
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: "网络连接失败",
|
||||
@@ -173,12 +251,17 @@ export default function WechatRegister() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [wechatSessionId, handleLoginSuccess, clearTimers, toast]);
|
||||
}, [handleLoginSuccess, clearTimers, toast]);
|
||||
|
||||
/**
|
||||
* 启动轮询
|
||||
*/
|
||||
const startPolling = useCallback(() => {
|
||||
logger.debug('WechatRegister', '启动轮询', {
|
||||
sessionId: sessionIdRef.current,
|
||||
interval: POLL_INTERVAL
|
||||
});
|
||||
|
||||
// 清理旧的定时器
|
||||
clearTimers();
|
||||
|
||||
@@ -189,7 +272,9 @@ export default function WechatRegister() {
|
||||
|
||||
// 设置超时
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
logger.debug('WechatRegister', '二维码超时');
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
setWechatStatus(WECHAT_STATUS.EXPIRED);
|
||||
}, QR_CODE_TIMEOUT);
|
||||
}, [checkWechatStatus, clearTimers]);
|
||||
@@ -216,10 +301,17 @@ export default function WechatRegister() {
|
||||
throw new Error(response.message || '获取二维码失败');
|
||||
}
|
||||
|
||||
// 同时更新 ref 和 state,确保轮询能立即读取到最新值
|
||||
sessionIdRef.current = response.data.session_id;
|
||||
setWechatAuthUrl(response.data.auth_url);
|
||||
setWechatSessionId(response.data.session_id);
|
||||
setWechatStatus(WECHAT_STATUS.WAITING);
|
||||
|
||||
logger.debug('WechatRegister', '获取二维码成功', {
|
||||
sessionId: response.data.session_id,
|
||||
authUrl: response.data.auth_url
|
||||
});
|
||||
|
||||
// 启动轮询检查扫码状态
|
||||
startPolling();
|
||||
} catch (error) {
|
||||
@@ -255,50 +347,17 @@ export default function WechatRegister() {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
};
|
||||
}, [clearTimers]);
|
||||
|
||||
/**
|
||||
* 备用轮询机制 - 防止丢失状态
|
||||
* 每3秒检查一次,仅在获取到二维码URL且状态为waiting时执行
|
||||
*/
|
||||
useEffect(() => {
|
||||
// 只在有auth_url、session_id且状态为waiting时启动备用轮询
|
||||
if (wechatAuthUrl && wechatSessionId && wechatStatus === WECHAT_STATUS.WAITING) {
|
||||
logger.debug('WechatRegister', '备用轮询:启动备用轮询机制');
|
||||
|
||||
backupPollIntervalRef.current = setInterval(() => {
|
||||
try {
|
||||
if (wechatStatus === WECHAT_STATUS.WAITING && isMountedRef.current) {
|
||||
logger.debug('WechatRegister', '备用轮询:检查微信状态');
|
||||
// 添加 .catch() 静默处理异步错误,防止被 ErrorBoundary 捕获
|
||||
checkWechatStatus().catch(error => {
|
||||
logger.warn('WechatRegister', '备用轮询检查失败(静默处理)', { error: error.message });
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// 捕获所有同步错误,防止被 ErrorBoundary 捕获
|
||||
logger.warn('WechatRegister', '备用轮询执行出错(静默处理)', { error: error.message });
|
||||
}
|
||||
}, BACKUP_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
// 清理备用轮询
|
||||
return () => {
|
||||
if (backupPollIntervalRef.current) {
|
||||
clearInterval(backupPollIntervalRef.current);
|
||||
backupPollIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [wechatAuthUrl, wechatSessionId, wechatStatus, checkWechatStatus]);
|
||||
|
||||
/**
|
||||
* 测量容器尺寸并计算缩放比例
|
||||
*/
|
||||
useLayoutEffect(() => {
|
||||
// 微信授权页面的原始尺寸
|
||||
const ORIGINAL_WIDTH = 600;
|
||||
const ORIGINAL_HEIGHT = 800;
|
||||
// 微信授权页面的原始尺寸(需要与iframe实际尺寸匹配)
|
||||
const ORIGINAL_WIDTH = 300; // ✅ 修正:与iframe width匹配
|
||||
const ORIGINAL_HEIGHT = 350; // ✅ 修正:与iframe height匹配
|
||||
|
||||
const calculateScale = () => {
|
||||
if (containerRef.current) {
|
||||
@@ -345,116 +404,152 @@ export default function WechatRegister() {
|
||||
// };
|
||||
|
||||
return (
|
||||
<VStack spacing={2} display="flex" alignItems="center" justifyContent="center">
|
||||
{wechatStatus === WECHAT_STATUS.WAITING ? (
|
||||
<>
|
||||
<Text fontSize="lg" fontWeight="bold" color="gray.700" whiteSpace="nowrap">
|
||||
微信扫码
|
||||
</Text>
|
||||
<VStack
|
||||
spacing={0} // ✅ 手动控制间距
|
||||
alignItems="stretch" // ✅ 拉伸对齐
|
||||
justifyContent="flex-start" // ✅ 顶部对齐(标题对齐关键)
|
||||
width="auto" // ✅ 自适应宽度
|
||||
>
|
||||
{/* ========== 标题区域 ========== */}
|
||||
<Heading
|
||||
size="md" // ✅ 16px,与左侧"登陆/注册"一致
|
||||
fontWeight="600"
|
||||
color="gray.800"
|
||||
textAlign="center"
|
||||
mb={3} // 12px底部间距
|
||||
>
|
||||
微信登陆
|
||||
</Heading>
|
||||
|
||||
{/* ========== 二维码区域 ========== */}
|
||||
<Box
|
||||
ref={containerRef}
|
||||
position="relative"
|
||||
width="230px" // ✅ 升级尺寸
|
||||
height="230px"
|
||||
mx="auto"
|
||||
overflow="hidden"
|
||||
borderRadius="md"
|
||||
border="1px solid"
|
||||
borderColor="gray.200"
|
||||
bg="gray.50"
|
||||
boxShadow="sm" // ✅ 添加轻微阴影
|
||||
>
|
||||
{wechatStatus !== WECHAT_STATUS.NONE ? (
|
||||
/* 已获取二维码:显示iframe */
|
||||
<iframe
|
||||
src={wechatAuthUrl}
|
||||
title="微信扫码登录"
|
||||
width="300"
|
||||
height="350"
|
||||
scrolling="no" // ✅ 新增:禁止滚动
|
||||
style={{
|
||||
border: 'none',
|
||||
transform: 'scale(0.77) translateY(-35px)', // ✅ 裁剪顶部logo
|
||||
transformOrigin: 'top left',
|
||||
marginLeft: '-5px',
|
||||
pointerEvents: 'auto', // 允许点击 │ │
|
||||
overflow: 'hidden', // 尝试隐藏滚动条(可能不起作用)
|
||||
}}
|
||||
// 使用 onWheel 事件阻止滚动 │ │
|
||||
onWheel={(e) => e.preventDefault()} // ✅ 在父容器上阻止滚动
|
||||
onTouchMove={(e) => e.preventDefault()} // ✅ 移动端也阻止
|
||||
|
||||
/>
|
||||
) : (
|
||||
/* 未获取:显示占位符 */
|
||||
<Center width="100%" height="100%" flexDirection="column">
|
||||
<Icon as={FaQrcode} w={16} h={16} color="gray.300" mb={4} />
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
onClick={handleGetQRCodeClick}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{wechatStatus === WECHAT_STATUS.EXPIRED ? "刷新二维码" : "获取二维码"}
|
||||
</Button>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* ========== 过期蒙层 ========== */}
|
||||
{wechatStatus === WECHAT_STATUS.EXPIRED && (
|
||||
<Box
|
||||
ref={containerRef}
|
||||
position="relative"
|
||||
width="150px"
|
||||
height="100px"
|
||||
maxWidth="100%"
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
bg="rgba(0,0,0,0.6)"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
overflow="hidden"
|
||||
backdropFilter="blur(4px)"
|
||||
>
|
||||
<iframe
|
||||
src={wechatAuthUrl}
|
||||
title="微信扫码登录"
|
||||
width="300"
|
||||
height="350"
|
||||
style={{
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'center center'
|
||||
}}
|
||||
/>
|
||||
<VStack spacing={2}>
|
||||
<Icon as={FiAlertCircle} w={8} h={8} color="white" />
|
||||
<Text color="white" fontSize="sm">二维码已过期</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
colorScheme="whiteAlpha"
|
||||
onClick={handleGetQRCodeClick}
|
||||
>
|
||||
点击刷新
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
{/* {renderStatusText()} */}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text fontSize="lg" fontWeight="bold" color="gray.700" whiteSpace="nowrap">
|
||||
微信扫码
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* ========== 状态指示器 ========== */}
|
||||
{wechatStatus !== WECHAT_STATUS.NONE && (
|
||||
<Text
|
||||
mt={3}
|
||||
fontSize="sm"
|
||||
fontWeight="500" // ✅ 半粗体
|
||||
textAlign="center"
|
||||
color={getStatusColor(wechatStatus)} // ✅ 根据状态显示不同颜色
|
||||
>
|
||||
{getStatusText(wechatStatus)}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* ========== Mock 模式控制按钮(仅开发环境) ========== */}
|
||||
{process.env.REACT_APP_ENABLE_MOCK === 'true' && wechatStatus === WECHAT_STATUS.WAITING && wechatSessionId && (
|
||||
<Box mt={3} pt={3} borderTop="1px solid" borderColor="gray.200">
|
||||
<Button
|
||||
size="xs"
|
||||
width="100%"
|
||||
colorScheme="purple"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (window.mockWechatScan) {
|
||||
const success = window.mockWechatScan(wechatSessionId);
|
||||
if (success) {
|
||||
toast({
|
||||
title: "Mock 模拟触发成功",
|
||||
description: "正在模拟扫码登录...",
|
||||
status: "info",
|
||||
duration: 2000,
|
||||
isClosable: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toast({
|
||||
title: "Mock API 未加载",
|
||||
description: "请刷新页面重试",
|
||||
status: "warning",
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
}}
|
||||
leftIcon={<Text fontSize="lg">🧪</Text>}
|
||||
>
|
||||
模拟扫码成功(测试)
|
||||
</Button>
|
||||
<Text fontSize="xs" color="gray.400" textAlign="center" mt={1}>
|
||||
开发模式 | 自动登录: 5秒
|
||||
</Text>
|
||||
|
||||
<Box
|
||||
position="relative"
|
||||
width="150px"
|
||||
height="100px"
|
||||
maxWidth="100%"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* 灰色二维码底图 - 始终显示 */}
|
||||
<Icon as={FaQrcode} w={24} h={24} color="gray.300" />
|
||||
|
||||
{/* 加载动画 */}
|
||||
{isLoading && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Spinner
|
||||
size="lg"
|
||||
color="green.500"
|
||||
thickness="4px"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 显示获取/刷新二维码按钮 */}
|
||||
{(wechatStatus === WECHAT_STATUS.NONE || wechatStatus === WECHAT_STATUS.EXPIRED) && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="rgba(255, 255, 255, 0.3)"
|
||||
backdropFilter="blur(2px)"
|
||||
>
|
||||
<VStack spacing={2}>
|
||||
<Button
|
||||
variant="outline"
|
||||
colorScheme="green"
|
||||
size="sm"
|
||||
onClick={handleGetQRCodeClick}
|
||||
isLoading={isLoading}
|
||||
leftIcon={<Icon as={FaQrcode} />}
|
||||
_hover={{ bg: "green.50" }}
|
||||
>
|
||||
{wechatStatus === WECHAT_STATUS.EXPIRED ? "点击刷新" : "获取二维码"}
|
||||
</Button>
|
||||
{wechatStatus === WECHAT_STATUS.EXPIRED && (
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
二维码已过期
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 扫码状态提示 */}
|
||||
{/* {renderStatusText()} */}
|
||||
</>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
|
||||
@@ -53,10 +53,18 @@ const CitationMark = ({ citationId, citation }) => {
|
||||
paddingBottom: 8,
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}}>
|
||||
{/* 左侧:作者 */}
|
||||
{/* 左侧:券商 · 作者(或仅作者) */}
|
||||
<Space size={4}>
|
||||
<UserOutlined style={{ color: '#1890ff', fontSize: 12 }} />
|
||||
<Text style={{ fontSize: 12, color: '#595959' }}>
|
||||
{citation.organization && (
|
||||
<>
|
||||
<Text strong style={{ fontSize: 12, color: '#262626' }}>
|
||||
{citation.organization}
|
||||
</Text>
|
||||
<Text style={{ margin: '0 4px', color: '#bfbfbf' }}> · </Text>
|
||||
</>
|
||||
)}
|
||||
{citation.author}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Button,
|
||||
VStack,
|
||||
Container
|
||||
} from '@chakra-ui/react';
|
||||
// import {
|
||||
// Box,
|
||||
// Alert,
|
||||
// AlertIcon,
|
||||
// AlertTitle,
|
||||
// AlertDescription,
|
||||
// Button,
|
||||
// VStack,
|
||||
// Container
|
||||
// } from '@chakra-ui/react';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
@@ -18,31 +18,21 @@ class ErrorBoundary extends React.Component {
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
// 开发环境:不拦截错误,让 React DevTools 显示完整堆栈
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return { hasError: false };
|
||||
}
|
||||
// 生产环境:拦截错误,显示友好界面
|
||||
// 所有环境都捕获错误,避免无限重渲染
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
// 开发环境:打印错误到控制台,但不显示错误边界
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
logger.error('ErrorBoundary', 'componentDidCatch', error, {
|
||||
componentStack: errorInfo.componentStack,
|
||||
developmentMode: true
|
||||
});
|
||||
// 不更新 state,让错误继续抛出
|
||||
return;
|
||||
}
|
||||
|
||||
// 生产环境:保存错误信息到 state
|
||||
logger.error('ErrorBoundary', 'componentDidCatch', error, {
|
||||
// 记录详细的错误日志
|
||||
logger.error('ErrorBoundary', 'Component Error Caught', error, {
|
||||
componentStack: errorInfo.componentStack,
|
||||
productionMode: true
|
||||
errorName: error.name,
|
||||
errorMessage: error.message,
|
||||
environment: process.env.NODE_ENV,
|
||||
stack: error.stack
|
||||
});
|
||||
|
||||
// 保存错误信息到 state
|
||||
this.setState({
|
||||
error: error,
|
||||
errorInfo: errorInfo
|
||||
@@ -50,57 +40,68 @@ class ErrorBoundary extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
// 开发环境:直接渲染子组件,不显示错误边界
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return this.props.children;
|
||||
}
|
||||
// 静默模式:捕获错误并记录日志(已在 componentDidCatch 中完成)
|
||||
// 但继续渲染子组件,不显示错误页面
|
||||
// 注意:如果组件因错误无法渲染,该区域可能显示为空白
|
||||
// // 如果有错误,显示错误边界(所有环境)
|
||||
// if (this.state.hasError) {
|
||||
// return (
|
||||
// <Container maxW="lg" py={20}>
|
||||
// <VStack spacing={6}>
|
||||
// <Alert status="error" borderRadius="lg" p={6}>
|
||||
// <AlertIcon boxSize="24px" />
|
||||
// <Box>
|
||||
// <AlertTitle fontSize="lg" mb={2}>
|
||||
// 页面出现错误!
|
||||
// </AlertTitle>
|
||||
// <AlertDescription>
|
||||
// {process.env.NODE_ENV === 'development'
|
||||
// ? '组件渲染时发生错误,请查看下方详情和控制台日志。'
|
||||
// : '页面加载时发生了未预期的错误,请尝试刷新页面。'}
|
||||
// </AlertDescription>
|
||||
// </Box>
|
||||
// </Alert>
|
||||
|
||||
// 生产环境:如果有错误,显示错误边界
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Container maxW="lg" py={20}>
|
||||
<VStack spacing={6}>
|
||||
<Alert status="error" borderRadius="lg" p={6}>
|
||||
<AlertIcon boxSize="24px" />
|
||||
<Box>
|
||||
<AlertTitle fontSize="lg" mb={2}>
|
||||
页面出现错误!
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
页面加载时发生了未预期的错误,请尝试刷新页面。
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<Box
|
||||
w="100%"
|
||||
bg="gray.50"
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
fontSize="sm"
|
||||
overflow="auto"
|
||||
maxH="200px"
|
||||
>
|
||||
<Box fontWeight="bold" mb={2}>错误详情:</Box>
|
||||
<Box as="pre" whiteSpace="pre-wrap">
|
||||
{this.state.error && this.state.error.toString()}
|
||||
{this.state.errorInfo && this.state.errorInfo.componentStack}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
重新加载页面
|
||||
</Button>
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
// {/* 开发环境显示详细错误信息 */}
|
||||
// {process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
// <Box
|
||||
// w="100%"
|
||||
// bg="red.50"
|
||||
// p={4}
|
||||
// borderRadius="lg"
|
||||
// fontSize="sm"
|
||||
// overflow="auto"
|
||||
// maxH="400px"
|
||||
// border="1px"
|
||||
// borderColor="red.200"
|
||||
// >
|
||||
// <Box fontWeight="bold" mb={2} color="red.700">错误详情:</Box>
|
||||
// <Box as="pre" whiteSpace="pre-wrap" color="red.900" fontSize="xs">
|
||||
// <Box fontWeight="bold" mb={1}>{this.state.error.name}: {this.state.error.message}</Box>
|
||||
// {this.state.error.stack && (
|
||||
// <Box mt={2} color="gray.700">{this.state.error.stack}</Box>
|
||||
// )}
|
||||
// {this.state.errorInfo && this.state.errorInfo.componentStack && (
|
||||
// <>
|
||||
// <Box fontWeight="bold" mt={3} mb={1} color="red.700">组件堆栈:</Box>
|
||||
// <Box color="gray.700">{this.state.errorInfo.componentStack}</Box>
|
||||
// </>
|
||||
// )}
|
||||
// </Box>
|
||||
// </Box>
|
||||
// )}
|
||||
|
||||
// <Button
|
||||
// colorScheme="blue"
|
||||
// size="lg"
|
||||
// onClick={() => window.location.reload()}
|
||||
// >
|
||||
// 重新加载页面
|
||||
// </Button>
|
||||
// </VStack>
|
||||
// </Container>
|
||||
// );
|
||||
// }
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,12 @@ import {
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Tooltip,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, HamburgerIcon, SunIcon, MoonIcon } from '@chakra-ui/icons';
|
||||
import { FiStar, FiCalendar, FiUser, FiSettings, FiHome, FiLogOut } from 'react-icons/fi';
|
||||
@@ -44,6 +50,7 @@ import { getApiBase } from '../../utils/apiConfig';
|
||||
import SubscriptionButton from '../Subscription/SubscriptionButton';
|
||||
import SubscriptionModal from '../Subscription/SubscriptionModal';
|
||||
import { CrownIcon, TooltipContent } from '../Subscription/CrownTooltip';
|
||||
import InvestmentCalendar from '../../views/Community/components/InvestmentCalendar';
|
||||
|
||||
/** 二级导航栏组件 - 显示当前一级菜单下的所有二级菜单项 */
|
||||
const SecondaryNav = ({ showCompletenessAlert }) => {
|
||||
@@ -51,20 +58,22 @@ const SecondaryNav = ({ showCompletenessAlert }) => {
|
||||
const location = useLocation();
|
||||
const navbarBg = useColorModeValue('gray.50', 'gray.700');
|
||||
const itemHoverBg = useColorModeValue('white', 'gray.600');
|
||||
// ⚠️ 必须在组件顶层调用所有Hooks(不能在JSX中调用)
|
||||
const borderColorValue = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
// 定义二级导航结构
|
||||
const secondaryNavConfig = {
|
||||
'/community': {
|
||||
title: '高频跟踪',
|
||||
items: [
|
||||
{ path: '/community', label: '新闻催化分析', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
|
||||
{ path: '/community', label: '事件中心', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
|
||||
{ path: '/concepts', label: '概念中心', badges: [{ text: 'NEW', colorScheme: 'red' }] }
|
||||
]
|
||||
},
|
||||
'/concepts': {
|
||||
title: '高频跟踪',
|
||||
items: [
|
||||
{ path: '/community', label: '新闻催化分析', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
|
||||
{ path: '/community', label: '事件中心', badges: [{ text: 'HOT', colorScheme: 'green' }, { text: 'NEW', colorScheme: 'red' }] },
|
||||
{ path: '/concepts', label: '概念中心', badges: [{ text: 'NEW', colorScheme: 'red' }] }
|
||||
]
|
||||
},
|
||||
@@ -108,7 +117,7 @@ const SecondaryNav = ({ showCompletenessAlert }) => {
|
||||
<Box
|
||||
bg={navbarBg}
|
||||
borderBottom="1px"
|
||||
borderColor={useColorModeValue('gray.200', 'gray.600')}
|
||||
borderColor={borderColorValue}
|
||||
py={2}
|
||||
position="sticky"
|
||||
top={showCompletenessAlert ? "120px" : "60px"}
|
||||
@@ -217,7 +226,7 @@ const MoreNavMenu = ({ isAuthenticated, user }) => {
|
||||
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
|
||||
>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text fontSize="sm">新闻催化分析</Text>
|
||||
<Text fontSize="sm">事件中心</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="sm" colorScheme="green">HOT</Badge>
|
||||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||
@@ -301,6 +310,9 @@ const NavItems = ({ isAuthenticated, user }) => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// ⚠️ 必须在组件顶层调用所有Hooks(不能在JSX中调用)
|
||||
const contactTextColor = useColorModeValue('gray.500', 'gray.300');
|
||||
|
||||
// 辅助函数:判断导航项是否激活
|
||||
const isActive = useCallback((paths) => {
|
||||
return paths.some(path => location.pathname.includes(path));
|
||||
@@ -333,7 +345,7 @@ const NavItems = ({ isAuthenticated, user }) => {
|
||||
fontWeight={location.pathname.includes('/community') ? 'bold' : 'normal'}
|
||||
>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text fontSize="sm">新闻催化分析</Text>
|
||||
<Text fontSize="sm">事件中心</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="sm" colorScheme="green">HOT</Badge>
|
||||
<Badge size="sm" colorScheme="red">NEW</Badge>
|
||||
@@ -452,7 +464,7 @@ const NavItems = ({ isAuthenticated, user }) => {
|
||||
联系我们
|
||||
</MenuButton>
|
||||
<MenuList minW="260px" p={4}>
|
||||
<Text fontSize="sm" color={useColorModeValue('gray.500', 'gray.300')}>敬请期待</Text>
|
||||
<Text fontSize="sm" color={contactTextColor}>敬请期待</Text>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</HStack>
|
||||
@@ -477,6 +489,11 @@ export default function HomeNavbar() {
|
||||
const brandHover = useColorModeValue('blue.600', 'blue.300');
|
||||
const toast = useToast();
|
||||
|
||||
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
|
||||
const userId = user?.id;
|
||||
const prevUserIdRef = React.useRef(userId);
|
||||
const prevIsAuthenticatedRef = React.useRef(isAuthenticated);
|
||||
|
||||
// 添加调试信息
|
||||
logger.debug('HomeNavbar', '组件渲染状态', {
|
||||
hasUser: !!user,
|
||||
@@ -521,6 +538,9 @@ export default function HomeNavbar() {
|
||||
const WATCHLIST_PAGE_SIZE = 10;
|
||||
const EVENTS_PAGE_SIZE = 8;
|
||||
|
||||
// 投资日历 Modal 状态
|
||||
const [calendarModalOpen, setCalendarModalOpen] = useState(false);
|
||||
|
||||
// 用户信息完整性状态
|
||||
const [profileCompleteness, setProfileCompleteness] = useState(null);
|
||||
const [showCompletenessAlert, setShowCompletenessAlert] = useState(false);
|
||||
@@ -712,65 +732,81 @@ export default function HomeNavbar() {
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}, [isAuthenticated, user?.id]); // 只依赖 user.id,避免 user 对象变化导致无限循环
|
||||
}, [isAuthenticated, userId]); // ⚡ 使用 userId 而不是 user?.id
|
||||
|
||||
// 监听用户变化,重置检查标志(用户切换或退出登录时)
|
||||
React.useEffect(() => {
|
||||
if (!isAuthenticated || !user) {
|
||||
// 用户退出登录,重置标志
|
||||
hasCheckedCompleteness.current = false;
|
||||
setProfileCompleteness(null);
|
||||
setShowCompletenessAlert(false);
|
||||
const userIdChanged = prevUserIdRef.current !== userId;
|
||||
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
|
||||
|
||||
if (userIdChanged || authChanged) {
|
||||
prevUserIdRef.current = userId;
|
||||
prevIsAuthenticatedRef.current = isAuthenticated;
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
// 用户退出登录,重置标志
|
||||
hasCheckedCompleteness.current = false;
|
||||
setProfileCompleteness(null);
|
||||
setShowCompletenessAlert(false);
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, user?.id]); // 监听用户 ID 变化
|
||||
}, [isAuthenticated, userId, user]); // ⚡ 使用 userId
|
||||
|
||||
// 用户登录后检查资料完整性
|
||||
React.useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
const userIdChanged = prevUserIdRef.current !== userId;
|
||||
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
|
||||
|
||||
if ((userIdChanged || authChanged) && isAuthenticated && user) {
|
||||
// 延迟检查,避免过于频繁
|
||||
const timer = setTimeout(checkProfileCompleteness, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isAuthenticated, user?.id, checkProfileCompleteness]); // 只依赖 user.id,避免无限循环
|
||||
}, [isAuthenticated, userId, checkProfileCompleteness, user]); // ⚡ 使用 userId
|
||||
|
||||
// 加载订阅信息
|
||||
React.useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
const loadSubscriptionInfo = async () => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const response = await fetch(base + '/api/subscription/current', {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
// 数据标准化处理:确保type字段是小写的 'free', 'pro', 或 'max'
|
||||
const normalizedData = {
|
||||
type: (data.data.type || data.data.subscription_type || 'free').toLowerCase(),
|
||||
status: data.data.status || 'active',
|
||||
days_left: data.data.days_left || 0,
|
||||
is_active: data.data.is_active !== false,
|
||||
end_date: data.data.end_date || null
|
||||
};
|
||||
setSubscriptionInfo(normalizedData);
|
||||
const userIdChanged = prevUserIdRef.current !== userId;
|
||||
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
|
||||
|
||||
if (userIdChanged || authChanged) {
|
||||
if (isAuthenticated && user) {
|
||||
const loadSubscriptionInfo = async () => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const response = await fetch(base + '/api/subscription/current', {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.data) {
|
||||
// 数据标准化处理:确保type字段是小写的 'free', 'pro', 或 'max'
|
||||
const normalizedData = {
|
||||
type: (data.data.type || data.data.subscription_type || 'free').toLowerCase(),
|
||||
status: data.data.status || 'active',
|
||||
days_left: data.data.days_left || 0,
|
||||
is_active: data.data.is_active !== false,
|
||||
end_date: data.data.end_date || null
|
||||
};
|
||||
setSubscriptionInfo(normalizedData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('HomeNavbar', '加载订阅信息失败', error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('HomeNavbar', '加载订阅信息失败', error);
|
||||
}
|
||||
};
|
||||
loadSubscriptionInfo();
|
||||
} else {
|
||||
// 用户未登录时,重置为免费版
|
||||
setSubscriptionInfo({
|
||||
type: 'free',
|
||||
status: 'active',
|
||||
days_left: 0,
|
||||
is_active: true
|
||||
});
|
||||
};
|
||||
loadSubscriptionInfo();
|
||||
} else {
|
||||
// 用户未登录时,重置为免费版
|
||||
setSubscriptionInfo({
|
||||
type: 'free',
|
||||
status: 'active',
|
||||
days_left: 0,
|
||||
is_active: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, user?.id]); // 只依赖 user.id 而不是整个 user 对象
|
||||
}, [isAuthenticated, userId, user]); // ⚡ 使用 userId,防重复通过 ref 判断
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -889,6 +925,20 @@ export default function HomeNavbar() {
|
||||
) : isAuthenticated && user ? (
|
||||
// 已登录状态 - 用户菜单 + 功能菜单排列
|
||||
<HStack spacing={{ base: 2, md: 3 }}>
|
||||
{/* 投资日历 - 仅大屏显示 */}
|
||||
{isDesktop && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="orange"
|
||||
variant="solid"
|
||||
borderRadius="full"
|
||||
leftIcon={<FiCalendar />}
|
||||
onClick={() => setCalendarModalOpen(true)}
|
||||
>
|
||||
投资日历
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 自选股 - 仅大屏显示 */}
|
||||
{isDesktop && (
|
||||
<Menu onOpen={loadWatchlistQuotes}>
|
||||
@@ -1155,6 +1205,11 @@ export default function HomeNavbar() {
|
||||
|
||||
<MenuDivider />
|
||||
|
||||
{/* 投资日历 */}
|
||||
<MenuItem icon={<FiCalendar />} onClick={() => navigate('/community')}>
|
||||
<Text>投资日历</Text>
|
||||
</MenuItem>
|
||||
|
||||
{/* 自选股 */}
|
||||
<MenuItem icon={<FiStar />} onClick={() => navigate('/home/center')}>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
@@ -1352,7 +1407,7 @@ export default function HomeNavbar() {
|
||||
fontWeight={location.pathname.includes('/community') ? 'bold' : 'normal'}
|
||||
>
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm">新闻催化分析</Text>
|
||||
<Text fontSize="sm">事件中心</Text>
|
||||
<HStack spacing={1}>
|
||||
<Badge size="xs" colorScheme="green">HOT</Badge>
|
||||
<Badge size="xs" colorScheme="red">NEW</Badge>
|
||||
@@ -1516,6 +1571,22 @@ export default function HomeNavbar() {
|
||||
|
||||
{/* 二级导航栏 - 显示当前页面所属的二级菜单 */}
|
||||
{!isMobile && <SecondaryNav showCompletenessAlert={showCompletenessAlert} />}
|
||||
|
||||
{/* 投资日历 Modal */}
|
||||
<Modal
|
||||
isOpen={calendarModalOpen}
|
||||
onClose={() => setCalendarModalOpen(false)}
|
||||
size="6xl"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="1200px">
|
||||
<ModalHeader>投资日历</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<InvestmentCalendar />
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -299,8 +299,8 @@ const NotificationItem = React.memo(({ notification, onClose, isNewest = false }
|
||||
const isDark = useColorModeValue(false, true);
|
||||
const priorityBgOpacity = getPriorityBgOpacity(priority, isDark);
|
||||
|
||||
// 根据优先级调整背景色深度
|
||||
const getPriorityBgColor = () => {
|
||||
// 根据优先级调整背景色深度(使用 useMemo 缓存计算结果)
|
||||
const priorityBgColor = useMemo(() => {
|
||||
const colorScheme = typeConfig.colorScheme;
|
||||
// 亮色模式:根据优先级使用不同深度的颜色
|
||||
if (!isDark) {
|
||||
@@ -323,31 +323,41 @@ const NotificationItem = React.memo(({ notification, onClose, isNewest = false }
|
||||
return 'gray.800';
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [isDark, priority, typeConfig]);
|
||||
|
||||
// 颜色配置 - 支持亮色/暗色模式(使用 useMemo 优化)
|
||||
// 颜色配置 - 支持亮色/暗色模式
|
||||
// ⚠️ 必须在组件顶层调用 useColorModeValue,不能在 useMemo 内部调用
|
||||
const borderColor = useColorModeValue(
|
||||
typeConfig.borderColor,
|
||||
typeConfig.darkBorderColor || `${typeConfig.colorScheme}.400`
|
||||
);
|
||||
const iconColor = useColorModeValue(
|
||||
typeConfig.iconColor,
|
||||
typeConfig.darkIconColor || `${typeConfig.colorScheme}.300`
|
||||
);
|
||||
const textColor = useColorModeValue('gray.800', 'gray.100');
|
||||
const subTextColor = useColorModeValue('gray.600', 'gray.300');
|
||||
const metaTextColor = useColorModeValue('gray.500', 'gray.500');
|
||||
const hoverBgColor = useColorModeValue(
|
||||
typeConfig.hoverBg,
|
||||
typeConfig.darkHoverBg || `${typeConfig.colorScheme}.700`
|
||||
);
|
||||
const closeButtonHoverBgColor = useColorModeValue(
|
||||
`${typeConfig.colorScheme}.200`,
|
||||
`${typeConfig.colorScheme}.700`
|
||||
);
|
||||
|
||||
// 使用 useMemo 缓存颜色对象(避免不必要的重新创建)
|
||||
const colors = useMemo(() => ({
|
||||
bg: getPriorityBgColor(),
|
||||
border: useColorModeValue(
|
||||
typeConfig.borderColor,
|
||||
typeConfig.darkBorderColor || `${typeConfig.colorScheme}.400`
|
||||
),
|
||||
icon: useColorModeValue(
|
||||
typeConfig.iconColor,
|
||||
typeConfig.darkIconColor || `${typeConfig.colorScheme}.300`
|
||||
),
|
||||
text: useColorModeValue('gray.800', 'gray.100'),
|
||||
subText: useColorModeValue('gray.600', 'gray.300'),
|
||||
metaText: useColorModeValue('gray.500', 'gray.500'),
|
||||
hoverBg: useColorModeValue(
|
||||
typeConfig.hoverBg,
|
||||
typeConfig.darkHoverBg || `${typeConfig.colorScheme}.700`
|
||||
),
|
||||
closeButtonHoverBg: useColorModeValue(
|
||||
`${typeConfig.colorScheme}.200`,
|
||||
`${typeConfig.colorScheme}.700`
|
||||
),
|
||||
}), [isDark, priority, typeConfig]);
|
||||
bg: priorityBgColor,
|
||||
border: borderColor,
|
||||
icon: iconColor,
|
||||
text: textColor,
|
||||
subText: subTextColor,
|
||||
metaText: metaTextColor,
|
||||
hoverBg: hoverBgColor,
|
||||
closeButtonHoverBg: closeButtonHoverBgColor,
|
||||
}), [priorityBgColor, borderColor, iconColor, textColor, subTextColor, metaTextColor, hoverBgColor, closeButtonHoverBgColor]);
|
||||
|
||||
// 点击处理(只有真正可点击时才执行)- 使用 useCallback 优化
|
||||
const handleClick = useCallback(() => {
|
||||
@@ -636,6 +646,11 @@ const NotificationContainer = () => {
|
||||
}
|
||||
}, [notifications]);
|
||||
|
||||
// ⚠️ 颜色配置 - 必须在条件return之前调用所有Hooks
|
||||
const collapseBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const collapseHoverBg = useColorModeValue('gray.200', 'gray.600');
|
||||
const collapseTextColor = useColorModeValue('gray.700', 'gray.200');
|
||||
|
||||
// 如果没有通知,不渲染
|
||||
if (notifications.length === 0) {
|
||||
return null;
|
||||
@@ -647,11 +662,6 @@ const NotificationContainer = () => {
|
||||
const visibleNotifications = isExpanded ? notifications : notifications.slice(0, maxVisible);
|
||||
const hiddenCount = notifications.length - maxVisible;
|
||||
|
||||
// 颜色配置
|
||||
const collapseBg = useColorModeValue('gray.100', 'gray.700');
|
||||
const collapseHoverBg = useColorModeValue('gray.200', 'gray.600');
|
||||
const collapseTextColor = useColorModeValue('gray.700', 'gray.200');
|
||||
|
||||
// 构建无障碍描述
|
||||
const containerAriaLabel = hasMore
|
||||
? `通知中心,共有 ${notifications.length} 条通知,当前显示 ${visibleNotifications.length} 条,${isExpanded ? '已展开全部' : `还有 ${hiddenCount} 条折叠`}。使用Tab键导航,Enter键或空格键查看详情。`
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 用于手动测试4种通知类型
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -16,8 +16,15 @@ import {
|
||||
useDisclosure,
|
||||
Badge,
|
||||
Divider,
|
||||
Alert,
|
||||
AlertIcon,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
Code,
|
||||
UnorderedList,
|
||||
ListItem,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp, MdCampaign, MdTrendingUp, MdArticle, MdAssessment } from 'react-icons/md';
|
||||
import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp, MdCampaign, MdTrendingUp, MdArticle, MdAssessment, MdWarning } from 'react-icons/md';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { SOCKET_TYPE } from '../../services/socket';
|
||||
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../../constants/notificationTypes';
|
||||
@@ -27,6 +34,62 @@ const NotificationTestTool = () => {
|
||||
const { addNotification, soundEnabled, toggleSound, isConnected, clearAllNotifications, notifications, browserPermission, requestBrowserPermission } = useNotification();
|
||||
const [testCount, setTestCount] = useState(0);
|
||||
|
||||
// 测试状态
|
||||
const [isTestingNotification, setIsTestingNotification] = useState(false);
|
||||
const [testCountdown, setTestCountdown] = useState(0);
|
||||
const [notificationShown, setNotificationShown] = useState(null); // null | true | false
|
||||
|
||||
// 系统环境检测
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
|
||||
// 故障排查面板
|
||||
const { isOpen: isTroubleshootOpen, onToggle: onTroubleshootToggle } = useDisclosure();
|
||||
|
||||
// 检测系统环境
|
||||
useEffect(() => {
|
||||
// 检测是否为 macOS
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
setIsMacOS(platform.includes('mac'));
|
||||
|
||||
// 检测全屏状态
|
||||
const checkFullscreen = () => {
|
||||
setIsFullscreen(!!document.fullscreenElement);
|
||||
};
|
||||
document.addEventListener('fullscreenchange', checkFullscreen);
|
||||
checkFullscreen();
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', checkFullscreen);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 倒计时逻辑
|
||||
useEffect(() => {
|
||||
if (testCountdown > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
setTestCountdown(testCountdown - 1);
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
} else if (testCountdown === 0 && isTestingNotification) {
|
||||
// 倒计时结束,询问用户
|
||||
setIsTestingNotification(false);
|
||||
|
||||
// 延迟一下再询问,确保用户有时间看到通知
|
||||
setTimeout(() => {
|
||||
const sawNotification = window.confirm('您是否看到了浏览器桌面通知?\n\n点击"确定"表示看到了\n点击"取消"表示没看到');
|
||||
setNotificationShown(sawNotification);
|
||||
|
||||
if (!sawNotification) {
|
||||
// 没看到通知,展开故障排查面板
|
||||
if (!isTroubleshootOpen) {
|
||||
onTroubleshootToggle();
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, [testCountdown, isTestingNotification, isTroubleshootOpen, onTroubleshootToggle]);
|
||||
|
||||
// 浏览器权限状态标签
|
||||
const getPermissionLabel = () => {
|
||||
switch (browserPermission) {
|
||||
@@ -86,51 +149,6 @@ const NotificationTestTool = () => {
|
||||
setTestCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
// 股票动向测试数据(涨)
|
||||
const testStockAlertUp = () => {
|
||||
addNotification({
|
||||
type: NOTIFICATION_TYPES.STOCK_ALERT,
|
||||
priority: PRIORITY_LEVELS.URGENT,
|
||||
title: '【测试】您关注的股票触发预警',
|
||||
content: '宁德时代(300750) 当前价格 ¥245.50,盘中涨幅达 +5.2%,已触达您设置的目标价位',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/stock-overview?code=300750',
|
||||
extra: {
|
||||
stockCode: '300750',
|
||||
stockName: '宁德时代',
|
||||
priceChange: '+5.2%',
|
||||
currentPrice: '245.50',
|
||||
},
|
||||
autoClose: 10000,
|
||||
});
|
||||
setTestCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
// 股票动向测试数据(跌)
|
||||
const testStockAlertDown = () => {
|
||||
addNotification({
|
||||
type: NOTIFICATION_TYPES.STOCK_ALERT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '【测试】您关注的股票异常波动',
|
||||
content: '比亚迪(002594) 5分钟内跌幅达 -3.8%,当前价格 ¥198.20,建议关注',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/stock-overview?code=002594',
|
||||
extra: {
|
||||
stockCode: '002594',
|
||||
stockName: '比亚迪',
|
||||
priceChange: '-3.8%',
|
||||
currentPrice: '198.20',
|
||||
},
|
||||
autoClose: 10000,
|
||||
});
|
||||
setTestCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
// 事件动向测试数据
|
||||
const testEventAlert = () => {
|
||||
@@ -179,30 +197,6 @@ const NotificationTestTool = () => {
|
||||
setTestCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
// AI分析报告测试数据
|
||||
const testAIReport = () => {
|
||||
addNotification({
|
||||
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '【测试】AI产业链投资机会分析',
|
||||
content: '随着大模型应用加速落地,算力、数据、应用三大方向均存在投资机会,重点关注海光信息、寒武纪',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
author: {
|
||||
name: 'AI分析师',
|
||||
organization: '价值前沿',
|
||||
},
|
||||
isAIGenerated: true,
|
||||
clickable: true,
|
||||
link: '/forecast-report?id=test005',
|
||||
extra: {
|
||||
reportType: '策略报告',
|
||||
industry: '人工智能',
|
||||
},
|
||||
autoClose: 12000,
|
||||
});
|
||||
setTestCount(prev => prev + 1);
|
||||
};
|
||||
|
||||
// 预测通知测试数据(不可跳转)
|
||||
const testPrediction = () => {
|
||||
@@ -272,42 +266,11 @@ const NotificationTestTool = () => {
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
// 测试全部类型(层叠效果)
|
||||
const testAllTypes = () => {
|
||||
const tests = [testAnnouncement, testStockAlertUp, testEventAlert, testAnalysisReport];
|
||||
tests.forEach((test, index) => {
|
||||
setTimeout(() => test(), index * 600);
|
||||
});
|
||||
};
|
||||
|
||||
// 测试优先级
|
||||
const testPriority = () => {
|
||||
[
|
||||
{ priority: PRIORITY_LEVELS.URGENT, label: '紧急' },
|
||||
{ priority: PRIORITY_LEVELS.IMPORTANT, label: '重要' },
|
||||
{ priority: PRIORITY_LEVELS.NORMAL, label: '普通' },
|
||||
].forEach((item, index) => {
|
||||
setTimeout(() => {
|
||||
addNotification({
|
||||
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
|
||||
priority: item.priority,
|
||||
title: `【测试】${item.label}优先级通知`,
|
||||
content: `这是一条${item.label}优先级的测试通知,用于验证优先级标签显示`,
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: false,
|
||||
autoClose: 10000,
|
||||
});
|
||||
setTestCount(prev => prev + 1);
|
||||
}, index * 600);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
top="316px"
|
||||
top="116px"
|
||||
right={4}
|
||||
zIndex={9998}
|
||||
bg="white"
|
||||
@@ -363,28 +326,6 @@ const NotificationTestTool = () => {
|
||||
公告通知
|
||||
</Button>
|
||||
|
||||
{/* 股票动向 */}
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="red"
|
||||
leftIcon={<MdTrendingUp />}
|
||||
onClick={testStockAlertUp}
|
||||
flex={1}
|
||||
>
|
||||
股票上涨
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<MdTrendingUp style={{ transform: 'rotate(180deg)' }} />}
|
||||
onClick={testStockAlertDown}
|
||||
flex={1}
|
||||
>
|
||||
股票下跌
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
{/* 事件动向 */}
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -396,27 +337,14 @@ const NotificationTestTool = () => {
|
||||
</Button>
|
||||
|
||||
{/* 分析报告 */}
|
||||
<HStack spacing={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<MdAssessment />}
|
||||
onClick={testAnalysisReport}
|
||||
flex={1}
|
||||
>
|
||||
分析报告
|
||||
</Button>
|
||||
<Badge colorScheme="purple" alignSelf="center">AI</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
variant="outline"
|
||||
onClick={testAIReport}
|
||||
flex={1}
|
||||
>
|
||||
AI报告
|
||||
</Button>
|
||||
</HStack>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<MdAssessment />}
|
||||
onClick={testAnalysisReport}
|
||||
>
|
||||
分析报告
|
||||
</Button>
|
||||
|
||||
{/* 预测通知 */}
|
||||
<Button
|
||||
@@ -434,24 +362,6 @@ const NotificationTestTool = () => {
|
||||
组合测试
|
||||
</Text>
|
||||
|
||||
{/* 层叠测试 */}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="teal"
|
||||
onClick={testAllTypes}
|
||||
>
|
||||
层叠测试(4种类型)
|
||||
</Button>
|
||||
|
||||
{/* 优先级测试 */}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="pink"
|
||||
onClick={testPriority}
|
||||
>
|
||||
优先级测试(3个级别)
|
||||
</Button>
|
||||
|
||||
{/* 预测→详情流程测试 */}
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -479,6 +389,93 @@ const NotificationTestTool = () => {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 测试浏览器通知按钮 */}
|
||||
{browserPermission === 'granted' && (
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<MdNotifications />}
|
||||
onClick={() => {
|
||||
console.log('测试浏览器通知按钮被点击');
|
||||
console.log('Notification support:', 'Notification' in window);
|
||||
console.log('Notification permission:', Notification?.permission);
|
||||
console.log('Platform:', navigator.platform);
|
||||
console.log('Fullscreen:', !!document.fullscreenElement);
|
||||
|
||||
// 直接使用原生 Notification API 测试
|
||||
if (!('Notification' in window)) {
|
||||
alert('您的浏览器不支持桌面通知');
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'granted') {
|
||||
alert('浏览器通知权限未授予\n当前权限状态:' + Notification.permission);
|
||||
return;
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
setNotificationShown(null);
|
||||
setIsTestingNotification(true);
|
||||
setTestCountdown(8); // 8秒倒计时
|
||||
|
||||
try {
|
||||
console.log('正在创建浏览器通知...');
|
||||
const notification = new Notification('【测试】浏览器通知测试', {
|
||||
body: '如果您看到这条系统级通知,说明浏览器通知功能正常工作',
|
||||
icon: '/logo192.png',
|
||||
badge: '/badge.png',
|
||||
tag: 'test_notification_' + Date.now(),
|
||||
requireInteraction: false,
|
||||
});
|
||||
|
||||
console.log('浏览器通知创建成功:', notification);
|
||||
|
||||
// 监听通知显示(成功显示)
|
||||
notification.onshow = () => {
|
||||
console.log('✅ 浏览器通知已显示(onshow 事件触发)');
|
||||
setNotificationShown(true);
|
||||
};
|
||||
|
||||
// 监听通知错误
|
||||
notification.onerror = (error) => {
|
||||
console.error('❌ 浏览器通知错误:', error);
|
||||
setNotificationShown(false);
|
||||
};
|
||||
|
||||
// 监听通知关闭
|
||||
notification.onclose = () => {
|
||||
console.log('浏览器通知已关闭');
|
||||
};
|
||||
|
||||
// 8秒后自动关闭
|
||||
setTimeout(() => {
|
||||
notification.close();
|
||||
console.log('浏览器通知已自动关闭');
|
||||
}, 8000);
|
||||
|
||||
// 点击通知时聚焦窗口
|
||||
notification.onclick = () => {
|
||||
console.log('浏览器通知被点击');
|
||||
window.focus();
|
||||
notification.close();
|
||||
setNotificationShown(true);
|
||||
};
|
||||
|
||||
setTestCount(prev => prev + 1);
|
||||
} catch (error) {
|
||||
console.error('创建浏览器通知失败:', error);
|
||||
alert('创建浏览器通知失败:' + error.message);
|
||||
setIsTestingNotification(false);
|
||||
setNotificationShown(false);
|
||||
}
|
||||
}}
|
||||
isLoading={isTestingNotification}
|
||||
loadingText={`等待通知... ${testCountdown}s`}
|
||||
>
|
||||
{isTestingNotification ? `等待通知... ${testCountdown}s` : '测试浏览器通知(直接)'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 浏览器通知状态说明 */}
|
||||
{browserPermission === 'granted' && (
|
||||
<Text fontSize="xs" color="green.500">
|
||||
@@ -491,6 +488,136 @@ const NotificationTestTool = () => {
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 实时权限状态 */}
|
||||
<HStack spacing={2} justify="center">
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
实际权限:
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={
|
||||
('Notification' in window && Notification.permission === 'granted') ? 'green' :
|
||||
('Notification' in window && Notification.permission === 'denied') ? 'red' : 'gray'
|
||||
}
|
||||
>
|
||||
{('Notification' in window) ? Notification.permission : '不支持'}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
{/* 环境警告 */}
|
||||
{isFullscreen && (
|
||||
<Alert status="warning" size="sm" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<Text fontWeight="bold">全屏模式</Text>
|
||||
<Text>某些浏览器在全屏模式下不显示通知</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isMacOS && notificationShown === false && (
|
||||
<Alert status="error" size="sm" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<Text fontWeight="bold">未检测到通知显示</Text>
|
||||
<Text>可能是专注模式阻止了通知</Text>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 故障排查面板 */}
|
||||
<VStack spacing={2} align="stretch">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
colorScheme="orange"
|
||||
leftIcon={<MdWarning />}
|
||||
onClick={onTroubleshootToggle}
|
||||
>
|
||||
{isTroubleshootOpen ? '收起' : '故障排查指南'}
|
||||
</Button>
|
||||
|
||||
<Collapse in={isTroubleshootOpen} animateOpacity>
|
||||
<VStack spacing={3} align="stretch" p={3} bg="orange.50" borderRadius="md">
|
||||
<Text fontSize="xs" fontWeight="bold" color="orange.800">
|
||||
如果看不到浏览器通知,请检查:
|
||||
</Text>
|
||||
|
||||
{/* macOS 专注模式 */}
|
||||
{isMacOS && (
|
||||
<Alert status="warning" size="sm">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<AlertTitle fontSize="xs">macOS 专注模式</AlertTitle>
|
||||
<AlertDescription>
|
||||
<UnorderedList spacing={1} mt={1}>
|
||||
<ListItem>点击右上角控制中心</ListItem>
|
||||
<ListItem>关闭「专注模式」或「勿扰模式」</ListItem>
|
||||
<ListItem>或者:系统设置 → 专注模式 → 关闭</ListItem>
|
||||
</UnorderedList>
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* macOS 系统通知设置 */}
|
||||
{isMacOS && (
|
||||
<Alert status="info" size="sm">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<AlertTitle fontSize="xs">macOS 系统通知设置</AlertTitle>
|
||||
<AlertDescription>
|
||||
<UnorderedList spacing={1} mt={1}>
|
||||
<ListItem>系统设置 → 通知</ListItem>
|
||||
<ListItem>找到 <Code fontSize="xs">Google Chrome</Code> 或 <Code fontSize="xs">Microsoft Edge</Code></ListItem>
|
||||
<ListItem>确保「允许通知」已开启</ListItem>
|
||||
<ListItem>通知样式设置为「横幅」或「提醒」</ListItem>
|
||||
</UnorderedList>
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Chrome 浏览器设置 */}
|
||||
<Alert status="info" size="sm">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<AlertTitle fontSize="xs">Chrome 浏览器设置</AlertTitle>
|
||||
<AlertDescription>
|
||||
<UnorderedList spacing={1} mt={1}>
|
||||
<ListItem>地址栏输入: <Code fontSize="xs">chrome://settings/content/notifications</Code></ListItem>
|
||||
<ListItem>确保「网站可以请求发送通知」已开启</ListItem>
|
||||
<ListItem>检查本站点是否在「允许」列表中</ListItem>
|
||||
</UnorderedList>
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
|
||||
{/* 全屏模式提示 */}
|
||||
{isFullscreen && (
|
||||
<Alert status="warning" size="sm">
|
||||
<AlertIcon />
|
||||
<Box fontSize="xs">
|
||||
<AlertTitle fontSize="xs">退出全屏模式</AlertTitle>
|
||||
<AlertDescription>
|
||||
按 <Code fontSize="xs">ESC</Code> 键退出全屏,然后重新测试
|
||||
</AlertDescription>
|
||||
</Box>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* 测试结果反馈 */}
|
||||
{notificationShown === true && (
|
||||
<Alert status="success" size="sm">
|
||||
<AlertIcon />
|
||||
<Text fontSize="xs">✅ 通知功能正常!</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</VStack>
|
||||
</Collapse>
|
||||
</VStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 功能按钮 */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/components/ProtectedRoute.js - 弹窗拦截版本
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Box, VStack, Spinner, Text } from '@chakra-ui/react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useAuthModal } from '../contexts/AuthModalContext';
|
||||
@@ -8,15 +8,17 @@ const ProtectedRoute = ({ children }) => {
|
||||
const { isAuthenticated, isLoading, user } = useAuth();
|
||||
const { openAuthModal, isAuthModalOpen } = useAuthModal();
|
||||
|
||||
// 记录当前路径,登录成功后可以跳转回来
|
||||
const currentPath = window.location.pathname + window.location.search;
|
||||
// ⚡ 使用 useRef 保存当前路径,避免每次渲染创建新字符串导致 useEffect 无限循环
|
||||
const currentPathRef = useRef(window.location.pathname + window.location.search);
|
||||
|
||||
// 未登录时自动弹出认证窗口
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated && !user && !isAuthModalOpen) {
|
||||
openAuthModal(currentPath);
|
||||
openAuthModal(currentPathRef.current);
|
||||
}
|
||||
}, [isAuthenticated, user, isLoading, isAuthModalOpen, currentPath, openAuthModal]);
|
||||
// ⚠️ 移除 user 依赖,因为 user 对象每次从 API 返回都是新引用,会导致无限循环
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthenticated, isLoading, isAuthModalOpen, openAuthModal]);
|
||||
|
||||
// 显示加载状态
|
||||
if (isLoading) {
|
||||
|
||||
38
src/components/ProtectedRouteRedirect.js
Normal file
38
src/components/ProtectedRouteRedirect.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// src/components/ProtectedRouteRedirect.js - 跳转版本
|
||||
// 未登录时跳转到首页,用于三级页面(详情页)
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Box, VStack, Spinner, Text } from '@chakra-ui/react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const ProtectedRouteRedirect = ({ children }) => {
|
||||
const { isAuthenticated, isLoading, user } = useAuth();
|
||||
|
||||
// 显示加载状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box
|
||||
height="100vh"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg="gray.50"
|
||||
>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text fontSize="lg" color="gray.600">正在验证登录状态...</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 未登录,直接跳转到首页
|
||||
if (!isAuthenticated || !user) {
|
||||
return <Navigate to="/home" replace />;
|
||||
}
|
||||
|
||||
// 已登录,正常渲染子组件
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRouteRedirect;
|
||||
77
src/constants/importanceLevels.js
Normal file
77
src/constants/importanceLevels.js
Normal file
@@ -0,0 +1,77 @@
|
||||
// src/constants/importanceLevels.js
|
||||
// 事件重要性等级配置
|
||||
|
||||
import {
|
||||
WarningIcon,
|
||||
WarningTwoIcon,
|
||||
InfoIcon,
|
||||
CheckCircleIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
|
||||
/**
|
||||
* 重要性等级配置
|
||||
* 用于事件列表展示和重要性说明
|
||||
*/
|
||||
export const IMPORTANCE_LEVELS = {
|
||||
'S': {
|
||||
level: 'S',
|
||||
color: 'purple.600',
|
||||
bgColor: 'purple.50',
|
||||
borderColor: 'purple.200',
|
||||
icon: WarningIcon,
|
||||
label: '极高',
|
||||
dotBg: 'purple.500',
|
||||
description: '重大事件,市场影响深远',
|
||||
antdColor: '#722ed1', // 对应 Ant Design 的紫色
|
||||
},
|
||||
'A': {
|
||||
level: 'A',
|
||||
color: 'red.600',
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.200',
|
||||
icon: WarningTwoIcon,
|
||||
label: '高',
|
||||
dotBg: 'red.500',
|
||||
description: '重要事件,影响较大',
|
||||
antdColor: '#ff4d4f', // 对应 Ant Design 的红色
|
||||
},
|
||||
'B': {
|
||||
level: 'B',
|
||||
color: 'orange.600',
|
||||
bgColor: 'orange.50',
|
||||
borderColor: 'orange.200',
|
||||
icon: InfoIcon,
|
||||
label: '中',
|
||||
dotBg: 'orange.500',
|
||||
description: '普通事件,有一定影响',
|
||||
antdColor: '#faad14', // 对应 Ant Design 的橙色
|
||||
},
|
||||
'C': {
|
||||
level: 'C',
|
||||
color: 'green.600',
|
||||
bgColor: 'green.50',
|
||||
borderColor: 'green.200',
|
||||
icon: CheckCircleIcon,
|
||||
label: '低',
|
||||
dotBg: 'green.500',
|
||||
description: '参考事件,影响有限',
|
||||
antdColor: '#52c41a', // 对应 Ant Design 的绿色
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取重要性等级配置
|
||||
* @param {string} importance - 重要性等级 (S/A/B/C)
|
||||
* @returns {Object} 重要性配置对象
|
||||
*/
|
||||
export const getImportanceConfig = (importance) => {
|
||||
return IMPORTANCE_LEVELS[importance] || IMPORTANCE_LEVELS['C'];
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有等级配置(用于说明列表)
|
||||
* @returns {Array} 所有等级配置数组
|
||||
*/
|
||||
export const getAllImportanceLevels = () => {
|
||||
return Object.values(IMPORTANCE_LEVELS);
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { logger } from '../utils/logger';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
|
||||
// 创建认证上下文
|
||||
const AuthContext = createContext();
|
||||
@@ -23,11 +24,37 @@ export const AuthProvider = ({ children }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const { showWelcomeGuide } = useNotification();
|
||||
|
||||
// ⚡ 使用 ref 保存最新的 isAuthenticated 值,避免事件监听器重复注册
|
||||
const isAuthenticatedRef = React.useRef(isAuthenticated);
|
||||
|
||||
// ⚡ 请求节流:记录上次请求时间,防止短时间内重复请求
|
||||
const lastCheckTimeRef = React.useRef(0);
|
||||
const MIN_CHECK_INTERVAL = 1000; // 最少间隔1秒
|
||||
|
||||
// 检查Session状态
|
||||
const checkSession = async () => {
|
||||
// 节流检查
|
||||
const now = Date.now();
|
||||
const timeSinceLastCheck = now - lastCheckTimeRef.current;
|
||||
|
||||
if (timeSinceLastCheck < MIN_CHECK_INTERVAL) {
|
||||
logger.warn('AuthContext', 'checkSession 请求被节流(防止频繁请求)', {
|
||||
timeSinceLastCheck: `${timeSinceLastCheck}ms`,
|
||||
minInterval: `${MIN_CHECK_INTERVAL}ms`,
|
||||
reason: '距离上次请求间隔太短'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
lastCheckTimeRef.current = now;
|
||||
|
||||
try {
|
||||
logger.debug('AuthContext', '检查Session状态');
|
||||
logger.debug('AuthContext', '开始检查Session状态', {
|
||||
timestamp: new Date().toISOString(),
|
||||
timeSinceLastCheck: timeSinceLastCheck > 0 ? `${timeSinceLastCheck}ms` : '首次请求'
|
||||
});
|
||||
|
||||
// 创建超时控制器
|
||||
const controller = new AbortController();
|
||||
@@ -55,19 +82,27 @@ export const AuthProvider = ({ children }) => {
|
||||
});
|
||||
|
||||
if (data.isAuthenticated && data.user) {
|
||||
setUser(data.user);
|
||||
setIsAuthenticated(true);
|
||||
// ⚡ 只在 user 数据真正变化时才更新状态,避免无限循环
|
||||
setUser((prevUser) => {
|
||||
// 比较用户 ID,如果相同则不更新
|
||||
if (prevUser && prevUser.id === data.user.id) {
|
||||
return prevUser;
|
||||
}
|
||||
return data.user;
|
||||
});
|
||||
setIsAuthenticated((prev) => prev === true ? prev : true);
|
||||
} else {
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
setUser((prev) => prev === null ? prev : null);
|
||||
setIsAuthenticated((prev) => prev === false ? prev : false);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('AuthContext', 'checkSession', error);
|
||||
// 网络错误或超时,设置为未登录状态
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
setUser((prev) => prev === null ? prev : null);
|
||||
setIsAuthenticated((prev) => prev === false ? prev : false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
// ⚡ 只在 isLoading 为 true 时才设置为 false,避免不必要的状态更新
|
||||
setIsLoading((prev) => prev === false ? prev : false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -77,11 +112,17 @@ export const AuthProvider = ({ children }) => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ⚡ 同步 isAuthenticated 到 ref
|
||||
useEffect(() => {
|
||||
isAuthenticatedRef.current = isAuthenticated;
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// 监听路由变化,检查session(处理微信登录回调)
|
||||
// ⚡ 移除 isAuthenticated 依赖,使用 ref 避免重复注册事件监听器
|
||||
useEffect(() => {
|
||||
const handleRouteChange = () => {
|
||||
// 如果是从微信回调返回的,重新检查session
|
||||
if (window.location.pathname === '/home' && !isAuthenticated) {
|
||||
// 使用 ref 获取最新的认证状态
|
||||
if (window.location.pathname === '/home' && !isAuthenticatedRef.current) {
|
||||
checkSession();
|
||||
}
|
||||
};
|
||||
@@ -89,7 +130,7 @@ export const AuthProvider = ({ children }) => {
|
||||
window.addEventListener('popstate', handleRouteChange);
|
||||
return () => window.removeEventListener('popstate', handleRouteChange);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthenticated]);
|
||||
}, []); // ✅ 空依赖数组,只注册一次事件监听器
|
||||
|
||||
// 更新本地用户的便捷方法
|
||||
const updateUser = (partial) => {
|
||||
@@ -156,6 +197,11 @@ export const AuthProvider = ({ children }) => {
|
||||
// isClosable: true,
|
||||
// });
|
||||
|
||||
// ⚡ 登录成功后显示欢迎引导(延迟2秒,避免与登录Toast冲突)
|
||||
setTimeout(() => {
|
||||
showWelcomeGuide();
|
||||
}, 2000);
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
@@ -166,54 +212,6 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 注册方法
|
||||
const register = async (username, email, password) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', username);
|
||||
formData.append('email', email);
|
||||
formData.append('password', password);
|
||||
|
||||
const response = await fetch(`/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
throw new Error(data.error || '注册失败');
|
||||
}
|
||||
|
||||
// 注册成功后自动登录
|
||||
setUser(data.user);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
toast({
|
||||
title: "注册成功",
|
||||
description: "欢迎加入价值前沿!",
|
||||
status: "success",
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
logger.error('AuthContext', 'register', error);
|
||||
|
||||
// ❌ 移除错误 toast,静默失败
|
||||
return { success: false, error: error.message };
|
||||
} finally{
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 手机号注册
|
||||
const registerWithPhone = async (phone, code, username, password) => {
|
||||
@@ -252,6 +250,11 @@ export const AuthProvider = ({ children }) => {
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// ⚡ 注册成功后显示欢迎引导(延迟2秒)
|
||||
setTimeout(() => {
|
||||
showWelcomeGuide();
|
||||
}, 2000);
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
@@ -299,6 +302,11 @@ export const AuthProvider = ({ children }) => {
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
// ⚡ 注册成功后显示欢迎引导(延迟2秒)
|
||||
setTimeout(() => {
|
||||
showWelcomeGuide();
|
||||
}, 2000);
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
@@ -414,7 +422,6 @@ export const AuthProvider = ({ children }) => {
|
||||
isLoading,
|
||||
updateUser,
|
||||
login,
|
||||
register,
|
||||
registerWithPhone,
|
||||
registerWithEmail,
|
||||
sendSmsCode,
|
||||
|
||||
176
src/contexts/IndustryContext.js
Normal file
176
src/contexts/IndustryContext.js
Normal file
@@ -0,0 +1,176 @@
|
||||
// src/contexts/IndustryContext.js
|
||||
// 行业分类数据全局上下文 - 使用API获取 + 缓存机制
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
import { industryData as staticIndustryData } from '../data/industryData';
|
||||
import { industryService } from '../services/industryService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const IndustryContext = createContext();
|
||||
|
||||
// 缓存配置
|
||||
const CACHE_KEY = 'industry_classifications_cache';
|
||||
const CACHE_DURATION = 24 * 60 * 60 * 1000; // 1天(24小时)
|
||||
|
||||
/**
|
||||
* useIndustry Hook
|
||||
* 在任何组件中使用行业数据
|
||||
*/
|
||||
export const useIndustry = () => {
|
||||
const context = useContext(IndustryContext);
|
||||
if (!context) {
|
||||
throw new Error('useIndustry must be used within IndustryProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 localStorage 读取缓存
|
||||
*/
|
||||
const loadFromCache = () => {
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
if (!cached) return null;
|
||||
|
||||
const { data, timestamp } = JSON.parse(cached);
|
||||
const now = Date.now();
|
||||
|
||||
// 检查缓存是否过期(1天)
|
||||
if (now - timestamp > CACHE_DURATION) {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
logger.debug('IndustryContext', '缓存已过期,清除缓存');
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug('IndustryContext', '从缓存加载行业数据', {
|
||||
count: data?.length || 0,
|
||||
age: Math.round((now - timestamp) / 1000 / 60) + ' 分钟前'
|
||||
});
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('IndustryContext', 'loadFromCache', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存到 localStorage
|
||||
*/
|
||||
const saveToCache = (data) => {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify({
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
logger.debug('IndustryContext', '行业数据已缓存', {
|
||||
count: data?.length || 0
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('IndustryContext', 'saveToCache', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* IndustryProvider 组件
|
||||
* 提供全局行业数据管理 - 使用API获取 + 缓存机制
|
||||
*/
|
||||
export const IndustryProvider = ({ children }) => {
|
||||
const [industryData, setIndustryData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const hasLoadedRef = useRef(false);
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
/**
|
||||
* 加载行业数据
|
||||
*/
|
||||
const loadIndustryData = async () => {
|
||||
// 防止重复加载(处理 StrictMode 双重调用)
|
||||
if (hasLoadedRef.current || isLoadingRef.current) {
|
||||
logger.debug('IndustryContext', '跳过重复加载', {
|
||||
hasLoaded: hasLoadedRef.current,
|
||||
isLoading: isLoadingRef.current
|
||||
});
|
||||
return industryData;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoadingRef.current = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
logger.debug('IndustryContext', '开始加载行业数据');
|
||||
|
||||
// 1. 先尝试从缓存加载
|
||||
const cachedData = loadFromCache();
|
||||
if (cachedData && cachedData.length > 0) {
|
||||
setIndustryData(cachedData);
|
||||
hasLoadedRef.current = true;
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
// 2. 缓存不存在或过期,调用 API
|
||||
logger.debug('IndustryContext', '缓存无效,调用API获取数据');
|
||||
const response = await industryService.getClassifications();
|
||||
|
||||
if (response.success && response.data && response.data.length > 0) {
|
||||
setIndustryData(response.data);
|
||||
saveToCache(response.data);
|
||||
hasLoadedRef.current = true;
|
||||
|
||||
logger.debug('IndustryContext', 'API数据加载成功', {
|
||||
count: response.data.length
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error('API返回数据为空');
|
||||
}
|
||||
} catch (err) {
|
||||
// 3. API 失败,回退到静态数据
|
||||
logger.warn('IndustryContext', 'API加载失败,使用静态数据', {
|
||||
error: err.message
|
||||
});
|
||||
|
||||
setError(err.message);
|
||||
setIndustryData(staticIndustryData);
|
||||
hasLoadedRef.current = true;
|
||||
|
||||
return staticIndustryData;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
isLoadingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 刷新行业数据(清除缓存并重新加载)
|
||||
*/
|
||||
const refreshIndustryData = async () => {
|
||||
logger.debug('IndustryContext', '刷新行业数据,清除缓存');
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
hasLoadedRef.current = false;
|
||||
isLoadingRef.current = false;
|
||||
return loadIndustryData();
|
||||
};
|
||||
|
||||
// 组件挂载时自动加载数据
|
||||
useEffect(() => {
|
||||
loadIndustryData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
industryData,
|
||||
loading,
|
||||
error,
|
||||
loadIndustryData,
|
||||
refreshIndustryData
|
||||
};
|
||||
|
||||
return (
|
||||
<IndustryContext.Provider value={value}>
|
||||
{children}
|
||||
</IndustryContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,8 @@
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useToast, Box, HStack, Text, Button, CloseButton } from '@chakra-ui/react';
|
||||
import { useToast, Box, HStack, Text, Button, CloseButton, VStack, Icon } from '@chakra-ui/react';
|
||||
import { BellIcon } from '@chakra-ui/icons';
|
||||
import { logger } from '../utils/logger';
|
||||
import socket, { SOCKET_TYPE } from '../services/socket';
|
||||
import notificationSound from '../assets/sounds/notification.wav';
|
||||
@@ -20,6 +21,7 @@ import { browserNotificationService } from '../services/browserNotificationServi
|
||||
import { notificationMetricsService } from '../services/notificationMetricsService';
|
||||
import { notificationHistoryService } from '../services/notificationHistoryService';
|
||||
import { PRIORITY_LEVELS, NOTIFICATION_CONFIG, NOTIFICATION_TYPES } from '../constants/notificationTypes';
|
||||
import { usePermissionGuide, GUIDE_TYPES } from '../hooks/usePermissionGuide';
|
||||
|
||||
// 连接状态枚举
|
||||
const CONNECTION_STATUS = {
|
||||
@@ -58,6 +60,11 @@ export const NotificationProvider = ({ children }) => {
|
||||
const [maxReconnectAttempts, setMaxReconnectAttempts] = useState(Infinity);
|
||||
const audioRef = useRef(null);
|
||||
const reconnectedTimerRef = useRef(null); // 用于自动消失 RECONNECTED 状态
|
||||
const processedEventIds = useRef(new Set()); // 用于Socket层去重,记录已处理的事件ID
|
||||
const MAX_PROCESSED_IDS = 1000; // 最多保留1000个ID,避免内存泄漏
|
||||
|
||||
// ⚡ 使用权限引导管理 Hook
|
||||
const { shouldShowGuide, markGuideAsShown } = usePermissionGuide();
|
||||
|
||||
// 初始化音频
|
||||
useEffect(() => {
|
||||
@@ -160,6 +167,127 @@ export const NotificationProvider = ({ children }) => {
|
||||
return permission;
|
||||
}, [toast]);
|
||||
|
||||
/**
|
||||
* ⚡ 显示权限引导(通用方法)
|
||||
* @param {string} guideType - 引导类型
|
||||
* @param {object} options - 引导选项
|
||||
*/
|
||||
const showPermissionGuide = useCallback((guideType, options = {}) => {
|
||||
// 检查是否应该显示引导
|
||||
if (!shouldShowGuide(guideType)) {
|
||||
logger.debug('NotificationContext', 'Guide already shown, skipping', { guideType });
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查权限状态:只在未授权时显示引导
|
||||
if (browserPermission === 'granted') {
|
||||
logger.debug('NotificationContext', 'Permission already granted, skipping guide', { guideType });
|
||||
return;
|
||||
}
|
||||
|
||||
// 默认选项
|
||||
const {
|
||||
title = '开启桌面通知',
|
||||
description = '及时接收重要事件和股票提醒',
|
||||
icon = true,
|
||||
duration = 10000,
|
||||
} = options;
|
||||
|
||||
// 显示引导 Toast
|
||||
const toastId = `permission-guide-${guideType}`;
|
||||
if (!toast.isActive(toastId)) {
|
||||
toast({
|
||||
id: toastId,
|
||||
duration,
|
||||
render: ({ onClose }) => (
|
||||
<Box
|
||||
p={4}
|
||||
bg="blue.500"
|
||||
color="white"
|
||||
borderRadius="md"
|
||||
boxShadow="lg"
|
||||
maxW="400px"
|
||||
>
|
||||
<VStack spacing={3} align="stretch">
|
||||
{icon && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={BellIcon} boxSize={5} />
|
||||
<Text fontWeight="bold" fontSize="md">
|
||||
{title}
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
<Text fontSize="sm" opacity={0.9}>
|
||||
{description}
|
||||
</Text>
|
||||
<HStack spacing={2} justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="whiteAlpha"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
markGuideAsShown(guideType);
|
||||
}}
|
||||
>
|
||||
稍后再说
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="whiteAlpha"
|
||||
bg="whiteAlpha.300"
|
||||
_hover={{ bg: 'whiteAlpha.400' }}
|
||||
onClick={async () => {
|
||||
onClose();
|
||||
markGuideAsShown(guideType);
|
||||
await requestBrowserPermission();
|
||||
}}
|
||||
>
|
||||
立即开启
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
|
||||
logger.info('NotificationContext', 'Permission guide shown', { guideType });
|
||||
}
|
||||
}, [toast, shouldShowGuide, markGuideAsShown, browserPermission, requestBrowserPermission]);
|
||||
|
||||
/**
|
||||
* ⚡ 显示欢迎引导(登录后)
|
||||
*/
|
||||
const showWelcomeGuide = useCallback(() => {
|
||||
showPermissionGuide(GUIDE_TYPES.WELCOME, {
|
||||
title: '🎉 欢迎使用价值前沿',
|
||||
description: '开启桌面通知,第一时间接收重要投资事件和股票提醒',
|
||||
duration: 12000,
|
||||
});
|
||||
}, [showPermissionGuide]);
|
||||
|
||||
/**
|
||||
* ⚡ 显示社区功能引导
|
||||
*/
|
||||
const showCommunityGuide = useCallback(() => {
|
||||
showPermissionGuide(GUIDE_TYPES.COMMUNITY, {
|
||||
title: '关注感兴趣的事件',
|
||||
description: '开启通知后,您关注的事件有新动态时会第一时间提醒您',
|
||||
duration: 10000,
|
||||
});
|
||||
}, [showPermissionGuide]);
|
||||
|
||||
/**
|
||||
* ⚡ 显示首次关注引导
|
||||
*/
|
||||
const showFirstFollowGuide = useCallback(() => {
|
||||
showPermissionGuide(GUIDE_TYPES.FIRST_FOLLOW, {
|
||||
title: '关注成功',
|
||||
description: '开启桌面通知,事件有更新时我们会及时提醒您',
|
||||
duration: 8000,
|
||||
});
|
||||
}, [showPermissionGuide]);
|
||||
|
||||
/**
|
||||
* 发送浏览器通知
|
||||
*/
|
||||
@@ -309,12 +437,23 @@ export const NotificationProvider = ({ children }) => {
|
||||
* @param {object} notification - 通知对象
|
||||
*/
|
||||
const addNotification = useCallback(async (notification) => {
|
||||
// ========== 显示层去重检查 ==========
|
||||
const notificationId = notification.id || `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// 检查当前显示队列中是否已存在该通知
|
||||
const isDuplicate = notifications.some(n => n.id === notificationId);
|
||||
if (isDuplicate) {
|
||||
logger.debug('NotificationContext', 'Duplicate notification ignored at display level', { id: notificationId });
|
||||
return notificationId; // 返回ID但不显示
|
||||
}
|
||||
// ========== 显示层去重检查结束 ==========
|
||||
|
||||
// 根据优先级获取自动关闭时长
|
||||
const priority = notification.priority || PRIORITY_LEVELS.NORMAL;
|
||||
const defaultAutoClose = NOTIFICATION_CONFIG.autoCloseDuration[priority];
|
||||
|
||||
const newNotification = {
|
||||
id: notification.id || `notif_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
id: notificationId, // 使用预先生成的ID
|
||||
type: notification.type || 'info',
|
||||
severity: notification.severity || 'info',
|
||||
title: notification.title || '通知',
|
||||
@@ -327,106 +466,116 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
logger.info('NotificationContext', 'Adding notification', newNotification);
|
||||
|
||||
// ========== 智能权限请求策略 ==========
|
||||
// 首次收到重要/紧急通知时,自动请求桌面通知权限
|
||||
if (priority === PRIORITY_LEVELS.URGENT || priority === PRIORITY_LEVELS.IMPORTANT) {
|
||||
if (browserPermission === 'default' && !hasRequestedPermission) {
|
||||
logger.info('NotificationContext', 'First important notification, requesting browser permission');
|
||||
await requestBrowserPermission();
|
||||
}
|
||||
// 如果权限被拒绝,提示用户可以开启
|
||||
else if (browserPermission === 'denied' && hasRequestedPermission) {
|
||||
// 显示带"开启"按钮的 Toast(仅重要/紧急通知)
|
||||
const toastId = 'enable-notification-toast';
|
||||
if (!toast.isActive(toastId)) {
|
||||
toast({
|
||||
id: toastId,
|
||||
title: newNotification.title,
|
||||
description: '💡 开启桌面通知以便后台接收',
|
||||
status: 'warning',
|
||||
duration: 10000,
|
||||
isClosable: true,
|
||||
position: 'top',
|
||||
render: ({ onClose }) => (
|
||||
<Box
|
||||
p={4}
|
||||
bg="orange.500"
|
||||
color="white"
|
||||
borderRadius="md"
|
||||
boxShadow="lg"
|
||||
>
|
||||
<HStack spacing={3} align="start">
|
||||
<Box flex={1}>
|
||||
<Text fontWeight="bold" mb={1}>
|
||||
{newNotification.title}
|
||||
</Text>
|
||||
<Text fontSize="sm" opacity={0.9}>
|
||||
💡 开启桌面通知以便后台接收
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="whiteAlpha"
|
||||
onClick={() => {
|
||||
requestBrowserPermission();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
开启
|
||||
</Button>
|
||||
<CloseButton onClick={onClose} />
|
||||
// ========== 增强权限请求策略 ==========
|
||||
// 只要收到通知,就检查并提示用户授权
|
||||
|
||||
// 如果权限是default(未授权),自动请求
|
||||
if (browserPermission === 'default' && !hasRequestedPermission) {
|
||||
logger.info('NotificationContext', 'Auto-requesting browser permission on notification');
|
||||
await requestBrowserPermission();
|
||||
}
|
||||
// 如果权限是denied(已拒绝),提供设置指引
|
||||
else if (browserPermission === 'denied') {
|
||||
const toastId = 'browser-permission-denied-guide';
|
||||
if (!toast.isActive(toastId)) {
|
||||
toast({
|
||||
id: toastId,
|
||||
duration: 12000,
|
||||
isClosable: true,
|
||||
position: 'top',
|
||||
render: ({ onClose }) => (
|
||||
<Box
|
||||
p={4}
|
||||
bg="orange.500"
|
||||
color="white"
|
||||
borderRadius="md"
|
||||
boxShadow="lg"
|
||||
maxW="400px"
|
||||
>
|
||||
<VStack spacing={3} align="stretch">
|
||||
<HStack spacing={2}>
|
||||
<Icon as={BellIcon} boxSize={5} />
|
||||
<Text fontWeight="bold" fontSize="md">
|
||||
浏览器通知已被拒绝
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
}
|
||||
<Text fontSize="sm" opacity={0.9}>
|
||||
{newNotification.title}
|
||||
</Text>
|
||||
<Text fontSize="xs" opacity={0.8}>
|
||||
💡 如需接收桌面通知,请在浏览器设置中允许通知权限
|
||||
</Text>
|
||||
<VStack spacing={1} align="start" fontSize="xs" opacity={0.7}>
|
||||
<Text>Chrome: 地址栏左侧 🔒 → 网站设置 → 通知</Text>
|
||||
<Text>Safari: 偏好设置 → 网站 → 通知</Text>
|
||||
<Text>Edge: 地址栏右侧 ⋯ → 网站权限 → 通知</Text>
|
||||
</VStack>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="whiteAlpha"
|
||||
onClick={onClose}
|
||||
alignSelf="flex-end"
|
||||
>
|
||||
知道了
|
||||
</Button>
|
||||
</VStack>
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isPageHidden = document.hidden; // 页面是否在后台
|
||||
|
||||
// ========== 智能分发策略 ==========
|
||||
|
||||
// ========== 原分发策略(按优先级区分)- 已废弃 ==========
|
||||
// 策略 1: 紧急通知 - 双重保障(浏览器 + 网页)
|
||||
if (priority === PRIORITY_LEVELS.URGENT) {
|
||||
logger.info('NotificationContext', 'Urgent notification: sending browser + web');
|
||||
// 总是发送浏览器通知
|
||||
sendBrowserNotification(newNotification);
|
||||
// 如果在前台,也显示网页通知
|
||||
if (!isPageHidden) {
|
||||
addWebNotification(newNotification);
|
||||
}
|
||||
}
|
||||
// if (priority === PRIORITY_LEVELS.URGENT) {
|
||||
// logger.info('NotificationContext', 'Urgent notification: sending browser + web');
|
||||
// // 总是发送浏览器通知
|
||||
// sendBrowserNotification(newNotification);
|
||||
// // 如果在前台,也显示网页通知
|
||||
// if (!isPageHidden) {
|
||||
// addWebNotification(newNotification);
|
||||
// }
|
||||
// }
|
||||
// 策略 2: 重要通知 - 智能切换(后台=浏览器,前台=网页)
|
||||
else if (priority === PRIORITY_LEVELS.IMPORTANT) {
|
||||
if (isPageHidden) {
|
||||
logger.info('NotificationContext', 'Important notification (background): sending browser');
|
||||
sendBrowserNotification(newNotification);
|
||||
} else {
|
||||
logger.info('NotificationContext', 'Important notification (foreground): sending web');
|
||||
addWebNotification(newNotification);
|
||||
}
|
||||
}
|
||||
// else if (priority === PRIORITY_LEVELS.IMPORTANT) {
|
||||
// if (isPageHidden) {
|
||||
// logger.info('NotificationContext', 'Important notification (background): sending browser');
|
||||
// sendBrowserNotification(newNotification);
|
||||
// } else {
|
||||
// logger.info('NotificationContext', 'Important notification (foreground): sending web');
|
||||
// addWebNotification(newNotification);
|
||||
// }
|
||||
// }
|
||||
// 策略 3: 普通通知 - 仅网页通知
|
||||
else {
|
||||
logger.info('NotificationContext', 'Normal notification: sending web only');
|
||||
// else {
|
||||
// logger.info('NotificationContext', 'Normal notification: sending web only');
|
||||
// addWebNotification(newNotification);
|
||||
// }
|
||||
|
||||
// ========== 新分发策略(仅区分前后台) ==========
|
||||
if (isPageHidden) {
|
||||
// 页面在后台:发送浏览器通知
|
||||
logger.info('NotificationContext', 'Page hidden: sending browser notification');
|
||||
sendBrowserNotification(newNotification);
|
||||
} else {
|
||||
// 页面在前台:发送网页通知
|
||||
logger.info('NotificationContext', 'Page visible: sending web notification');
|
||||
addWebNotification(newNotification);
|
||||
}
|
||||
|
||||
return newNotification.id;
|
||||
}, [sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]);
|
||||
}, [notifications, toast, sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]);
|
||||
|
||||
// 连接到 Socket 服务
|
||||
useEffect(() => {
|
||||
logger.info('NotificationContext', 'Initializing socket connection...');
|
||||
console.log(`%c[NotificationContext] Initializing socket (type: ${SOCKET_TYPE})`, 'color: #673AB7; font-weight: bold;');
|
||||
|
||||
// 连接 socket
|
||||
socket.connect();
|
||||
|
||||
// 获取并保存最大重连次数
|
||||
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
|
||||
setMaxReconnectAttempts(maxAttempts);
|
||||
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
|
||||
// ✅ 第一步: 注册所有事件监听器
|
||||
console.log('%c[NotificationContext] Step 1: Registering event listeners...', 'color: #673AB7;');
|
||||
|
||||
// 监听连接状态
|
||||
socket.on('connect', () => {
|
||||
@@ -434,6 +583,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
setIsConnected(true);
|
||||
setReconnectAttempt(0);
|
||||
logger.info('NotificationContext', 'Socket connected', { wasDisconnected });
|
||||
console.log('%c[NotificationContext] ✅ Received connect event, updating state to connected', 'color: #4CAF50; font-weight: bold;');
|
||||
|
||||
// 如果之前断开过,显示 RECONNECTED 状态2秒后自动消失
|
||||
if (wasDisconnected) {
|
||||
@@ -498,6 +648,27 @@ export const NotificationProvider = ({ children }) => {
|
||||
socket.on('new_event', (data) => {
|
||||
logger.info('NotificationContext', 'Received new event', data);
|
||||
|
||||
// ========== Socket层去重检查 ==========
|
||||
const eventId = data.id || `${data.type}_${data.publishTime}`;
|
||||
|
||||
if (processedEventIds.current.has(eventId)) {
|
||||
logger.debug('NotificationContext', 'Duplicate event ignored at socket level', { eventId });
|
||||
return; // 重复事件,直接忽略
|
||||
}
|
||||
|
||||
// 记录已处理的事件ID
|
||||
processedEventIds.current.add(eventId);
|
||||
|
||||
// 限制Set大小,避免内存泄漏
|
||||
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
|
||||
const idsArray = Array.from(processedEventIds.current);
|
||||
processedEventIds.current = new Set(idsArray.slice(-MAX_PROCESSED_IDS));
|
||||
logger.debug('NotificationContext', 'Cleaned up old processed event IDs', {
|
||||
kept: MAX_PROCESSED_IDS
|
||||
});
|
||||
}
|
||||
// ========== Socket层去重检查结束 ==========
|
||||
|
||||
// 使用适配器转换事件格式
|
||||
const notification = adaptEventToNotification(data);
|
||||
addNotification(notification);
|
||||
@@ -509,6 +680,18 @@ export const NotificationProvider = ({ children }) => {
|
||||
addNotification(data);
|
||||
});
|
||||
|
||||
console.log('%c[NotificationContext] ✅ All event listeners registered', 'color: #4CAF50; font-weight: bold;');
|
||||
|
||||
// ✅ 第二步: 获取最大重连次数
|
||||
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
|
||||
setMaxReconnectAttempts(maxAttempts);
|
||||
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
|
||||
|
||||
// ✅ 第三步: 调用 socket.connect()
|
||||
console.log('%c[NotificationContext] Step 2: Calling socket.connect()...', 'color: #673AB7; font-weight: bold;');
|
||||
socket.connect();
|
||||
console.log('%c[NotificationContext] socket.connect() completed', 'color: #673AB7;');
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
logger.info('NotificationContext', 'Cleaning up socket connection');
|
||||
@@ -526,7 +709,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
socket.off('system_notification');
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [adaptEventToNotification, connectionStatus, toast]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, []); // ✅ 空依赖数组,确保只执行一次,避免 React 严格模式重复执行
|
||||
|
||||
// ==================== 智能自动重试 ====================
|
||||
|
||||
@@ -626,6 +809,10 @@ export const NotificationProvider = ({ children }) => {
|
||||
requestBrowserPermission,
|
||||
trackNotificationClick,
|
||||
retryConnection,
|
||||
// ⚡ 新增:权限引导方法
|
||||
showWelcomeGuide,
|
||||
showCommunityGuide,
|
||||
showFirstFollowGuide,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
4340
src/data/industryData.js
Normal file
4340
src/data/industryData.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,8 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { socketService } from '../services/socketService';
|
||||
import socket from '../services/socket';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export const useEventNotifications = (options = {}) => {
|
||||
const {
|
||||
@@ -37,78 +38,158 @@ export const useEventNotifications = (options = {}) => {
|
||||
const [error, setError] = useState(null);
|
||||
const unsubscribeRef = useRef(null);
|
||||
|
||||
// 使用 ref 存储 onNewEvent 回调,避免因回调函数引用改变导致重新连接
|
||||
const onNewEventRef = useRef(onNewEvent);
|
||||
|
||||
// 每次 onNewEvent 改变时更新 ref
|
||||
useEffect(() => {
|
||||
onNewEventRef.current = onNewEvent;
|
||||
}, [onNewEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[useEventNotifications DEBUG] ========== useEffect 执行 ==========');
|
||||
console.log('[useEventNotifications DEBUG] enabled:', enabled);
|
||||
console.log('[useEventNotifications DEBUG] eventType:', eventType);
|
||||
console.log('[useEventNotifications DEBUG] importance:', importance);
|
||||
|
||||
// 如果禁用,则不订阅
|
||||
if (!enabled) {
|
||||
console.log('[useEventNotifications DEBUG] ⚠️ 订阅已禁用,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
// 连接状态监听
|
||||
const handleConnect = () => {
|
||||
console.log('[useEventNotifications DEBUG] ✓ WebSocket 已连接');
|
||||
logger.info('useEventNotifications', 'WebSocket connected');
|
||||
setIsConnected(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
console.log('[useEventNotifications DEBUG] ⚠️ WebSocket 已断开');
|
||||
logger.warn('useEventNotifications', 'WebSocket disconnected');
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
const handleConnectError = (err) => {
|
||||
console.error('[useEventNotifications ERROR] WebSocket 连接错误:', err);
|
||||
logger.error('useEventNotifications', 'WebSocket connect error', err);
|
||||
setError(err);
|
||||
setIsConnected(false);
|
||||
};
|
||||
|
||||
// 监听连接事件(必须在connect之前设置,否则可能错过事件)
|
||||
socket.on('connect', handleConnect);
|
||||
socket.on('disconnect', handleDisconnect);
|
||||
socket.on('connect_error', handleConnectError);
|
||||
|
||||
// 连接 WebSocket
|
||||
socketService.connect();
|
||||
console.log('[useEventNotifications DEBUG] 准备连接 WebSocket...');
|
||||
logger.info('useEventNotifications', 'Initializing WebSocket connection');
|
||||
|
||||
// 监听连接事件
|
||||
socketService.on('connect', handleConnect);
|
||||
socketService.on('disconnect', handleDisconnect);
|
||||
socketService.on('connect_error', handleConnectError);
|
||||
// 先检查是否已经连接
|
||||
const alreadyConnected = socket.connected || false;
|
||||
console.log('[useEventNotifications DEBUG] 当前连接状态:', alreadyConnected);
|
||||
logger.info('useEventNotifications', 'Pre-connection check', { isConnected: alreadyConnected });
|
||||
|
||||
// 新事件处理函数
|
||||
if (alreadyConnected) {
|
||||
// 如果已经连接,直接更新状态
|
||||
console.log('[useEventNotifications DEBUG] Socket已连接,直接更新状态');
|
||||
logger.info('useEventNotifications', 'Socket already connected, updating state immediately');
|
||||
setIsConnected(true);
|
||||
// 验证状态更新
|
||||
setTimeout(() => {
|
||||
console.log('[useEventNotifications DEBUG] 1秒后验证状态更新 - isConnected应该为true');
|
||||
}, 1000);
|
||||
} else {
|
||||
// 否则建立新连接
|
||||
socket.connect();
|
||||
}
|
||||
|
||||
// 新事件处理函数 - 使用 ref 中的回调
|
||||
const handleNewEvent = (eventData) => {
|
||||
setNewEvent(eventData);
|
||||
console.log('\n[useEventNotifications DEBUG] ========== Hook 收到新事件 ==========');
|
||||
console.log('[useEventNotifications DEBUG] 事件数据:', eventData);
|
||||
console.log('[useEventNotifications DEBUG] 事件 ID:', eventData?.id);
|
||||
console.log('[useEventNotifications DEBUG] 事件标题:', eventData?.title);
|
||||
|
||||
// 调用外部回调
|
||||
if (onNewEvent) {
|
||||
onNewEvent(eventData);
|
||||
console.log('[useEventNotifications DEBUG] 设置 newEvent 状态');
|
||||
setNewEvent(eventData);
|
||||
console.log('[useEventNotifications DEBUG] ✓ newEvent 状态已更新');
|
||||
|
||||
// 调用外部回调(从 ref 中获取最新的回调)
|
||||
if (onNewEventRef.current) {
|
||||
console.log('[useEventNotifications DEBUG] 准备调用外部 onNewEvent 回调');
|
||||
onNewEventRef.current(eventData);
|
||||
console.log('[useEventNotifications DEBUG] ✓ 外部 onNewEvent 回调已调用');
|
||||
} else {
|
||||
console.log('[useEventNotifications DEBUG] ⚠️ 没有外部 onNewEvent 回调');
|
||||
}
|
||||
|
||||
console.log('[useEventNotifications DEBUG] ========== Hook 事件处理完成 ==========\n');
|
||||
};
|
||||
|
||||
// 订阅事件推送
|
||||
socketService.subscribeToEvents({
|
||||
eventType,
|
||||
importance,
|
||||
onNewEvent: handleNewEvent,
|
||||
onSubscribed: (data) => {
|
||||
console.log('订阅成功:', data);
|
||||
},
|
||||
});
|
||||
console.log('\n[useEventNotifications DEBUG] ========== 开始订阅事件 ==========');
|
||||
console.log('[useEventNotifications DEBUG] eventType:', eventType);
|
||||
console.log('[useEventNotifications DEBUG] importance:', importance);
|
||||
console.log('[useEventNotifications DEBUG] enabled:', enabled);
|
||||
|
||||
// 检查 socket 是否有 subscribeToEvents 方法(mockSocketService 和 socketService 都有)
|
||||
if (socket.subscribeToEvents) {
|
||||
socket.subscribeToEvents({
|
||||
eventType,
|
||||
importance,
|
||||
onNewEvent: handleNewEvent,
|
||||
onSubscribed: (data) => {
|
||||
console.log('\n[useEventNotifications DEBUG] ========== 订阅成功回调 ==========');
|
||||
console.log('[useEventNotifications DEBUG] 订阅数据:', data);
|
||||
console.log('[useEventNotifications DEBUG] ========== 订阅成功处理完成 ==========\n');
|
||||
},
|
||||
});
|
||||
console.log('[useEventNotifications DEBUG] ========== 订阅请求已发送 ==========\n');
|
||||
} else {
|
||||
console.warn('[useEventNotifications] socket.subscribeToEvents 方法不存在');
|
||||
}
|
||||
|
||||
// 保存取消订阅函数
|
||||
unsubscribeRef.current = () => {
|
||||
socketService.unsubscribeFromEvents({ eventType });
|
||||
if (socket.unsubscribeFromEvents) {
|
||||
socket.unsubscribeFromEvents({ eventType });
|
||||
}
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
return () => {
|
||||
console.log('清理 WebSocket 订阅');
|
||||
console.log('\n[useEventNotifications DEBUG] ========== 清理 WebSocket 订阅 ==========');
|
||||
|
||||
// 取消订阅
|
||||
if (unsubscribeRef.current) {
|
||||
console.log('[useEventNotifications DEBUG] 取消订阅...');
|
||||
unsubscribeRef.current();
|
||||
}
|
||||
|
||||
// 移除监听器
|
||||
socketService.off('connect', handleConnect);
|
||||
socketService.off('disconnect', handleDisconnect);
|
||||
socketService.off('connect_error', handleConnectError);
|
||||
console.log('[useEventNotifications DEBUG] 移除事件监听器...');
|
||||
socket.off('connect', handleConnect);
|
||||
socket.off('disconnect', handleDisconnect);
|
||||
socket.off('connect_error', handleConnectError);
|
||||
|
||||
// 断开连接
|
||||
socketService.disconnect();
|
||||
// 注意:不断开连接,因为 socket 是全局共享的
|
||||
// 由 NotificationContext 统一管理连接生命周期
|
||||
console.log('[useEventNotifications DEBUG] ========== 清理完成 ==========\n');
|
||||
};
|
||||
}, [eventType, importance, enabled, onNewEvent]);
|
||||
}, [eventType, importance, enabled]); // 移除 onNewEvent 依赖
|
||||
|
||||
// 监控 isConnected 状态变化(调试用)
|
||||
useEffect(() => {
|
||||
console.log('[useEventNotifications DEBUG] ========== isConnected 状态变化 ==========');
|
||||
console.log('[useEventNotifications DEBUG] isConnected:', isConnected);
|
||||
console.log('[useEventNotifications DEBUG] ===========================================');
|
||||
}, [isConnected]);
|
||||
|
||||
console.log('[useEventNotifications DEBUG] Hook返回值 - isConnected:', isConnected);
|
||||
|
||||
return {
|
||||
newEvent, // 最新收到的事件
|
||||
|
||||
170
src/hooks/usePermissionGuide.js
Normal file
170
src/hooks/usePermissionGuide.js
Normal file
@@ -0,0 +1,170 @@
|
||||
// src/hooks/usePermissionGuide.js
|
||||
/**
|
||||
* 通知权限引导管理 Hook
|
||||
*
|
||||
* 功能:
|
||||
* - 管理多个引导场景的显示状态
|
||||
* - 使用 localStorage 持久化记录
|
||||
* - 支持定期提醒策略
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// 引导场景类型
|
||||
export const GUIDE_TYPES = {
|
||||
WELCOME: 'welcome', // 首次登录欢迎引导
|
||||
COMMUNITY: 'community', // 社区功能引导
|
||||
FIRST_FOLLOW: 'first_follow', // 首次关注事件引导
|
||||
PERIODIC: 'periodic', // 定期提醒
|
||||
};
|
||||
|
||||
// localStorage 键名
|
||||
const STORAGE_KEYS = {
|
||||
SHOWN_GUIDES: 'notification_guides_shown',
|
||||
LAST_PERIODIC: 'notification_last_periodic_prompt',
|
||||
TOTAL_PROMPTS: 'notification_total_prompts',
|
||||
};
|
||||
|
||||
// 定期提醒间隔(毫秒)
|
||||
const PERIODIC_INTERVAL = 3 * 24 * 60 * 60 * 1000; // 3 天
|
||||
const MAX_PERIODIC_PROMPTS = 3; // 最多提醒 3 次
|
||||
|
||||
/**
|
||||
* 权限引导管理 Hook
|
||||
*/
|
||||
export function usePermissionGuide() {
|
||||
const [shownGuides, setShownGuides] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.SHOWN_GUIDES);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch (error) {
|
||||
logger.error('usePermissionGuide', 'Failed to load shown guides', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 检查是否应该显示某个引导
|
||||
* @param {string} guideType - 引导类型
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const shouldShowGuide = useCallback((guideType) => {
|
||||
// 已经显示过的引导不再显示
|
||||
if (shownGuides.includes(guideType)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 特殊逻辑:定期提醒
|
||||
if (guideType === GUIDE_TYPES.PERIODIC) {
|
||||
try {
|
||||
const lastPrompt = localStorage.getItem(STORAGE_KEYS.LAST_PERIODIC);
|
||||
const totalPrompts = parseInt(localStorage.getItem(STORAGE_KEYS.TOTAL_PROMPTS) || '0', 10);
|
||||
|
||||
// 超过最大提醒次数
|
||||
if (totalPrompts >= MAX_PERIODIC_PROMPTS) {
|
||||
logger.debug('usePermissionGuide', 'Periodic prompts limit reached', { totalPrompts });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 未到提醒间隔
|
||||
if (lastPrompt) {
|
||||
const elapsed = Date.now() - parseInt(lastPrompt, 10);
|
||||
if (elapsed < PERIODIC_INTERVAL) {
|
||||
logger.debug('usePermissionGuide', 'Periodic interval not reached', {
|
||||
elapsed: Math.round(elapsed / 1000 / 60 / 60), // 小时
|
||||
required: Math.round(PERIODIC_INTERVAL / 1000 / 60 / 60)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('usePermissionGuide', 'Failed to check periodic guide', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [shownGuides]);
|
||||
|
||||
/**
|
||||
* 标记引导已显示
|
||||
* @param {string} guideType - 引导类型
|
||||
*/
|
||||
const markGuideAsShown = useCallback((guideType) => {
|
||||
try {
|
||||
// 更新状态
|
||||
setShownGuides(prev => {
|
||||
if (prev.includes(guideType)) {
|
||||
return prev;
|
||||
}
|
||||
const updated = [...prev, guideType];
|
||||
// 持久化
|
||||
localStorage.setItem(STORAGE_KEYS.SHOWN_GUIDES, JSON.stringify(updated));
|
||||
logger.info('usePermissionGuide', 'Guide marked as shown', { guideType });
|
||||
return updated;
|
||||
});
|
||||
|
||||
// 特殊处理:定期提醒
|
||||
if (guideType === GUIDE_TYPES.PERIODIC) {
|
||||
localStorage.setItem(STORAGE_KEYS.LAST_PERIODIC, String(Date.now()));
|
||||
|
||||
const totalPrompts = parseInt(localStorage.getItem(STORAGE_KEYS.TOTAL_PROMPTS) || '0', 10);
|
||||
localStorage.setItem(STORAGE_KEYS.TOTAL_PROMPTS, String(totalPrompts + 1));
|
||||
|
||||
logger.info('usePermissionGuide', 'Periodic prompt recorded', {
|
||||
totalPrompts: totalPrompts + 1
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('usePermissionGuide', 'Failed to mark guide as shown', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 重置所有引导(用于测试或用户主动重置)
|
||||
*/
|
||||
const resetAllGuides = useCallback(() => {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEYS.SHOWN_GUIDES);
|
||||
localStorage.removeItem(STORAGE_KEYS.LAST_PERIODIC);
|
||||
localStorage.removeItem(STORAGE_KEYS.TOTAL_PROMPTS);
|
||||
setShownGuides([]);
|
||||
logger.info('usePermissionGuide', 'All guides reset');
|
||||
} catch (error) {
|
||||
logger.error('usePermissionGuide', 'Failed to reset guides', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 获取定期提醒的统计信息(用于调试)
|
||||
*/
|
||||
const getPeriodicStats = useCallback(() => {
|
||||
try {
|
||||
const lastPrompt = localStorage.getItem(STORAGE_KEYS.LAST_PERIODIC);
|
||||
const totalPrompts = parseInt(localStorage.getItem(STORAGE_KEYS.TOTAL_PROMPTS) || '0', 10);
|
||||
|
||||
return {
|
||||
lastPromptTime: lastPrompt ? new Date(parseInt(lastPrompt, 10)) : null,
|
||||
totalPrompts,
|
||||
remainingPrompts: MAX_PERIODIC_PROMPTS - totalPrompts,
|
||||
nextPromptTime: lastPrompt
|
||||
? new Date(parseInt(lastPrompt, 10) + PERIODIC_INTERVAL)
|
||||
: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('usePermissionGuide', 'Failed to get periodic stats', error);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
shouldShowGuide,
|
||||
markGuideAsShown,
|
||||
resetAllGuides,
|
||||
getPeriodicStats,
|
||||
shownGuides,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/hooks/useSubscription.js
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
@@ -104,10 +104,32 @@ export const useSubscription = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
|
||||
const userId = user?.id;
|
||||
const prevUserIdRef = useRef(userId);
|
||||
const prevIsAuthenticatedRef = useRef(isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSubscriptionInfo();
|
||||
// ⚡ 只在 userId 或 isAuthenticated 真正变化时才请求
|
||||
const userIdChanged = prevUserIdRef.current !== userId;
|
||||
const authChanged = prevIsAuthenticatedRef.current !== isAuthenticated;
|
||||
|
||||
if (userIdChanged || authChanged) {
|
||||
logger.debug('useSubscription', 'fetchSubscriptionInfo 触发', {
|
||||
userIdChanged,
|
||||
authChanged,
|
||||
prevUserId: prevUserIdRef.current,
|
||||
currentUserId: userId,
|
||||
prevAuth: prevIsAuthenticatedRef.current,
|
||||
currentAuth: isAuthenticated
|
||||
});
|
||||
|
||||
prevUserIdRef.current = userId;
|
||||
prevIsAuthenticatedRef.current = isAuthenticated;
|
||||
fetchSubscriptionInfo();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isAuthenticated, user?.id]); // 只依赖 user.id,避免 user 对象变化导致无限循环
|
||||
}, [isAuthenticated, userId]); // 使用 userId 原始值,而不是 user?.id 表达式
|
||||
|
||||
// 获取订阅级别数值
|
||||
const getSubscriptionLevel = (type = null) => {
|
||||
|
||||
32
src/layouts/AppFooter.js
Normal file
32
src/layouts/AppFooter.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Box, Container, VStack, HStack, Text, Link, useColorModeValue } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 应用通用页脚组件
|
||||
* 包含版权信息、备案号等
|
||||
*/
|
||||
const AppFooter = () => {
|
||||
return (
|
||||
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
|
||||
<Container maxW="container.xl">
|
||||
<VStack spacing={2}>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
© 2024 价值前沿. 保留所有权利.
|
||||
</Text>
|
||||
<HStack spacing={4} fontSize="xs" color="gray.400">
|
||||
<Link
|
||||
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
|
||||
isExternal
|
||||
_hover={{ color: 'gray.600' }}
|
||||
>
|
||||
京公网安备11010802046286号
|
||||
</Link>
|
||||
<Text>京ICP备2025107343号-1</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppFooter;
|
||||
@@ -32,7 +32,6 @@ export default function Home() {
|
||||
<Routes>
|
||||
{/* 首页默认路由 */}
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/dashboard" element={<HomePage />} />
|
||||
<Route
|
||||
path="/center"
|
||||
element={
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Outlet } from "react-router-dom";
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import HomeNavbar from "../components/Navbars/HomeNavbar";
|
||||
import PageLoader from "../components/Loading/PageLoader";
|
||||
import AppFooter from "./AppFooter";
|
||||
|
||||
/**
|
||||
* MainLayout - 带导航栏的主布局
|
||||
@@ -15,17 +16,20 @@ import PageLoader from "../components/Loading/PageLoader";
|
||||
*/
|
||||
export default function MainLayout() {
|
||||
return (
|
||||
<Box minH="100vh">
|
||||
<Box minH="100vh" display="flex" flexDirection="column">
|
||||
{/* 导航栏 - 在所有页面间共享,不会重新渲染 */}
|
||||
<HomeNavbar />
|
||||
|
||||
{/* 页面内容区域 - 通过 Outlet 渲染当前路由对应的组件 */}
|
||||
{/* Suspense 只包裹内容区域,导航栏保持可见 */}
|
||||
<Box>
|
||||
<Box flex="1">
|
||||
<Suspense fallback={<PageLoader message="页面加载中..." />}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</Box>
|
||||
|
||||
{/* 页脚 - 在所有页面间共享 */}
|
||||
<AppFooter />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
535
src/mocks/data/company.js
Normal file
535
src/mocks/data/company.js
Normal file
@@ -0,0 +1,535 @@
|
||||
// src/mocks/data/company.js
|
||||
// 公司相关的 Mock 数据
|
||||
|
||||
// 平安银行 (000001) 的完整数据
|
||||
export const PINGAN_BANK_DATA = {
|
||||
stockCode: '000001',
|
||||
stockName: '平安银行',
|
||||
|
||||
// 基本信息
|
||||
basicInfo: {
|
||||
code: '000001',
|
||||
name: '平安银行',
|
||||
english_name: 'Ping An Bank Co., Ltd.',
|
||||
registered_capital: 1940642.3, // 万元
|
||||
registered_capital_unit: '万元',
|
||||
legal_representative: '谢永林',
|
||||
general_manager: '谢永林',
|
||||
secretary: '周强',
|
||||
registered_address: '深圳市深南东路5047号',
|
||||
office_address: '深圳市深南东路5047号',
|
||||
zipcode: '518001',
|
||||
phone: '0755-82080387',
|
||||
fax: '0755-82080386',
|
||||
email: 'ir@bank.pingan.com',
|
||||
website: 'http://bank.pingan.com',
|
||||
business_scope: '吸收公众存款;发放短期、中期和长期贷款;办理国内外结算;办理票据承兑与贴现;发行金融债券;代理发行、代理兑付、承销政府债券;买卖政府债券、金融债券;从事同业拆借;买卖、代理买卖外汇;从事银行卡业务;提供信用证服务及担保;代理收付款项及代理保险业务;提供保管箱服务;经有关监管机构批准的其他业务。',
|
||||
employees: 36542,
|
||||
introduction: '平安银行股份有限公司是中国平安保险(集团)股份有限公司控股的一家跨区域经营的股份制商业银行,为中国大陆12家全国性股份制商业银行之一。注册资本为人民币51.2335亿元,总资产近1.37万亿元,总部位于深圳。平安银行拥有全国性银行经营资质,主要经营商业银行业务。',
|
||||
list_date: '1991-04-03',
|
||||
establish_date: '1987-12-22',
|
||||
province: '广东省',
|
||||
city: '深圳市',
|
||||
industry: '银行',
|
||||
main_business: '商业银行业务',
|
||||
},
|
||||
|
||||
// 实际控制人信息
|
||||
actualControl: {
|
||||
controller_name: '中国平安保险(集团)股份有限公司',
|
||||
controller_type: '企业',
|
||||
shareholding_ratio: 52.38,
|
||||
control_chain: '中国平安保险(集团)股份有限公司 -> 平安银行股份有限公司',
|
||||
is_listed: true,
|
||||
change_date: '2023-12-31',
|
||||
remark: '中国平安通过直接和间接方式控股平安银行',
|
||||
},
|
||||
|
||||
// 股权集中度
|
||||
concentration: {
|
||||
top1_ratio: 52.38,
|
||||
top3_ratio: 58.42,
|
||||
top5_ratio: 60.15,
|
||||
top10_ratio: 63.28,
|
||||
update_date: '2024-09-30',
|
||||
concentration_level: '高度集中',
|
||||
herfindahl_index: 0.2845,
|
||||
},
|
||||
|
||||
// 高管信息
|
||||
management: [
|
||||
{
|
||||
name: '谢永林',
|
||||
position: '董事长、执行董事、行长',
|
||||
gender: '男',
|
||||
age: 56,
|
||||
education: '硕士',
|
||||
appointment_date: '2019-01-01',
|
||||
annual_compensation: 723.8,
|
||||
shareholding: 0,
|
||||
background: '中国平安保险(集团)股份有限公司副总经理兼首席保险业务执行官'
|
||||
},
|
||||
{
|
||||
name: '周强',
|
||||
position: '执行董事、副行长、董事会秘书',
|
||||
gender: '男',
|
||||
age: 54,
|
||||
education: '硕士',
|
||||
appointment_date: '2016-06-01',
|
||||
annual_compensation: 542.3,
|
||||
shareholding: 0.002,
|
||||
background: '历任平安银行深圳分行行长'
|
||||
},
|
||||
{
|
||||
name: '郭世邦',
|
||||
position: '执行董事、副行长、首席财务官',
|
||||
gender: '男',
|
||||
age: 52,
|
||||
education: '博士',
|
||||
appointment_date: '2018-03-01',
|
||||
annual_compensation: 498.6,
|
||||
shareholding: 0.001,
|
||||
background: '历任中国平安集团财务负责人'
|
||||
},
|
||||
{
|
||||
name: '蔡新发',
|
||||
position: '副行长、首席风险官',
|
||||
gender: '男',
|
||||
age: 51,
|
||||
education: '硕士',
|
||||
appointment_date: '2017-05-01',
|
||||
annual_compensation: 467.2,
|
||||
shareholding: 0.0008,
|
||||
background: '历任平安银行风险管理部总经理'
|
||||
},
|
||||
{
|
||||
name: '项有志',
|
||||
position: '副行长、首席信息官',
|
||||
gender: '男',
|
||||
age: 49,
|
||||
education: '硕士',
|
||||
appointment_date: '2019-09-01',
|
||||
annual_compensation: 425.1,
|
||||
shareholding: 0,
|
||||
background: '历任中国平安科技公司总经理'
|
||||
}
|
||||
],
|
||||
|
||||
// 十大流通股东
|
||||
topCirculationShareholders: [
|
||||
{ shareholder_name: '中国平安保险(集团)股份有限公司', shares: 10168542300, ratio: 52.38, change: 0, shareholder_type: '企业' },
|
||||
{ shareholder_name: '香港中央结算有限公司', shares: 542138600, ratio: 2.79, change: 12450000, shareholder_type: '境外法人' },
|
||||
{ shareholder_name: '深圳市投资控股有限公司', shares: 382456100, ratio: 1.97, change: 0, shareholder_type: '国有企业' },
|
||||
{ shareholder_name: '中国证券金融股份有限公司', shares: 298654200, ratio: 1.54, change: -5000000, shareholder_type: '证金公司' },
|
||||
{ shareholder_name: '中央汇金资产管理有限责任公司', shares: 267842100, ratio: 1.38, change: 0, shareholder_type: '中央汇金' },
|
||||
{ shareholder_name: '全国社保基金一零三组合', shares: 156234500, ratio: 0.80, change: 23400000, shareholder_type: '社保基金' },
|
||||
{ shareholder_name: '全国社保基金一零一组合', shares: 142356700, ratio: 0.73, change: 15600000, shareholder_type: '社保基金' },
|
||||
{ shareholder_name: '中国人寿保险股份有限公司', shares: 128945600, ratio: 0.66, change: 0, shareholder_type: '保险公司' },
|
||||
{ shareholder_name: 'GIC PRIVATE LIMITED', shares: 98765400, ratio: 0.51, change: -8900000, shareholder_type: '境外法人' },
|
||||
{ shareholder_name: '挪威中央银行', shares: 87654300, ratio: 0.45, change: 5600000, shareholder_type: '境外法人' }
|
||||
],
|
||||
|
||||
// 十大股东(与流通股东相同,因为平安银行全流通)
|
||||
topShareholders: [
|
||||
{ shareholder_name: '中国平安保险(集团)股份有限公司', shares: 10168542300, ratio: 52.38, change: 0, shareholder_type: '企业', is_restricted: false },
|
||||
{ shareholder_name: '香港中央结算有限公司', shares: 542138600, ratio: 2.79, change: 12450000, shareholder_type: '境外法人', is_restricted: false },
|
||||
{ shareholder_name: '深圳市投资控股有限公司', shares: 382456100, ratio: 1.97, change: 0, shareholder_type: '国有企业', is_restricted: false },
|
||||
{ shareholder_name: '中国证券金融股份有限公司', shares: 298654200, ratio: 1.54, change: -5000000, shareholder_type: '证金公司', is_restricted: false },
|
||||
{ shareholder_name: '中央汇金资产管理有限责任公司', shares: 267842100, ratio: 1.38, change: 0, shareholder_type: '中央汇金', is_restricted: false },
|
||||
{ shareholder_name: '全国社保基金一零三组合', shares: 156234500, ratio: 0.80, change: 23400000, shareholder_type: '社保基金', is_restricted: false },
|
||||
{ shareholder_name: '全国社保基金一零一组合', shares: 142356700, ratio: 0.73, change: 15600000, shareholder_type: '社保基金', is_restricted: false },
|
||||
{ shareholder_name: '中国人寿保险股份有限公司', shares: 128945600, ratio: 0.66, change: 0, shareholder_type: '保险公司', is_restricted: false },
|
||||
{ shareholder_name: 'GIC PRIVATE LIMITED', shares: 98765400, ratio: 0.51, change: -8900000, shareholder_type: '境外法人', is_restricted: false },
|
||||
{ shareholder_name: '挪威中央银行', shares: 87654300, ratio: 0.45, change: 5600000, shareholder_type: '境外法人', is_restricted: false }
|
||||
],
|
||||
|
||||
// 分支机构
|
||||
branches: [
|
||||
{ name: '北京分行', address: '北京市朝阳区建国路88号SOHO现代城', phone: '010-85806888', type: '一级分行', establish_date: '2007-03-15' },
|
||||
{ name: '上海分行', address: '上海市浦东新区陆家嘴环路1366号', phone: '021-38637777', type: '一级分行', establish_date: '2007-05-20' },
|
||||
{ name: '广州分行', address: '广州市天河区珠江新城珠江东路32号', phone: '020-38390888', type: '一级分行', establish_date: '2007-06-10' },
|
||||
{ name: '深圳分行', address: '深圳市福田区益田路5033号', phone: '0755-82538888', type: '一级分行', establish_date: '1995-01-01' },
|
||||
{ name: '杭州分行', address: '杭州市江干区钱江路1366号', phone: '0571-87028888', type: '一级分行', establish_date: '2008-09-12' },
|
||||
{ name: '成都分行', address: '成都市武侯区人民南路四段13号', phone: '028-85266888', type: '一级分行', establish_date: '2009-04-25' },
|
||||
{ name: '南京分行', address: '南京市建邺区江东中路359号', phone: '025-86625888', type: '一级分行', establish_date: '2010-06-30' },
|
||||
{ name: '武汉分行', address: '武汉市江汉区建设大道568号', phone: '027-85712888', type: '一级分行', establish_date: '2011-08-15' },
|
||||
{ name: '西安分行', address: '西安市高新区唐延路35号', phone: '029-88313888', type: '一级分行', establish_date: '2012-10-20' },
|
||||
{ name: '天津分行', address: '天津市和平区南京路189号', phone: '022-23399888', type: '一级分行', establish_date: '2013-03-18' }
|
||||
],
|
||||
|
||||
// 公告列表
|
||||
announcements: [
|
||||
{
|
||||
title: '平安银行股份有限公司2024年第三季度报告',
|
||||
publish_date: '2024-10-28',
|
||||
type: '定期报告',
|
||||
summary: '2024年前三季度实现营业收入1245.6亿元,同比增长8.2%;净利润402.3亿元,同比增长12.5%',
|
||||
url: '/announcement/detail/ann_20241028_001'
|
||||
},
|
||||
{
|
||||
title: '关于召开2024年第一次临时股东大会的通知',
|
||||
publish_date: '2024-10-15',
|
||||
type: '临时公告',
|
||||
summary: '定于2024年11月5日召开2024年第一次临时股东大会,审议关于调整董事会成员等议案',
|
||||
url: '/announcement/detail/ann_20241015_001'
|
||||
},
|
||||
{
|
||||
title: '平安银行股份有限公司关于完成注册资本变更登记的公告',
|
||||
publish_date: '2024-09-20',
|
||||
type: '临时公告',
|
||||
summary: '公司已完成注册资本由人民币194.06亿元变更为194.06亿元的工商变更登记手续',
|
||||
url: '/announcement/detail/ann_20240920_001'
|
||||
},
|
||||
{
|
||||
title: '平安银行股份有限公司2024年半年度报告',
|
||||
publish_date: '2024-08-28',
|
||||
type: '定期报告',
|
||||
summary: '2024年上半年实现营业收入828.5亿元,同比增长7.8%;净利润265.4亿元,同比增长11.2%',
|
||||
url: '/announcement/detail/ann_20240828_001'
|
||||
},
|
||||
{
|
||||
title: '关于2024年上半年利润分配预案的公告',
|
||||
publish_date: '2024-08-20',
|
||||
type: '分配方案',
|
||||
summary: '拟以总股本194.06亿股为基数,向全体股东每10股派发现金红利2.8元(含税)',
|
||||
url: '/announcement/detail/ann_20240820_001'
|
||||
}
|
||||
],
|
||||
|
||||
// 披露时间表
|
||||
disclosureSchedule: [
|
||||
{ report_type: '2024年年度报告', planned_date: '2025-04-30', status: '未披露' },
|
||||
{ report_type: '2024年第四季度报告', planned_date: '2025-01-31', status: '未披露' },
|
||||
{ report_type: '2024年第三季度报告', planned_date: '2024-10-31', status: '已披露' },
|
||||
{ report_type: '2024年半年度报告', planned_date: '2024-08-31', status: '已披露' },
|
||||
{ report_type: '2024年第一季度报告', planned_date: '2024-04-30', status: '已披露' }
|
||||
],
|
||||
|
||||
// 综合分析
|
||||
comprehensiveAnalysis: {
|
||||
overview: {
|
||||
company_name: '平安银行股份有限公司',
|
||||
stock_code: '000001',
|
||||
industry: '银行',
|
||||
established_date: '1987-12-22',
|
||||
listing_date: '1991-04-03',
|
||||
total_assets: 50245.6, // 亿元
|
||||
net_assets: 3256.8,
|
||||
registered_capital: 194.06,
|
||||
employee_count: 36542
|
||||
},
|
||||
financial_highlights: {
|
||||
revenue: 1623.5,
|
||||
revenue_growth: 8.5,
|
||||
net_profit: 528.6,
|
||||
profit_growth: 12.3,
|
||||
roe: 16.23,
|
||||
roa: 1.05,
|
||||
asset_quality_ratio: 1.02,
|
||||
capital_adequacy_ratio: 13.45,
|
||||
core_tier1_ratio: 10.82
|
||||
},
|
||||
business_structure: [
|
||||
{ business: '对公业务', revenue: 685.4, ratio: 42.2, growth: 6.8 },
|
||||
{ business: '零售业务', revenue: 812.3, ratio: 50.1, growth: 11.2 },
|
||||
{ business: '金融市场业务', revenue: 125.8, ratio: 7.7, growth: 3.5 }
|
||||
],
|
||||
competitive_advantages: [
|
||||
'背靠中国平安集团,综合金融优势明显',
|
||||
'零售业务转型成效显著,客户基础雄厚',
|
||||
'金融科技创新能力强,数字化银行建设领先',
|
||||
'风险管理体系完善,资产质量稳定',
|
||||
'管理团队经验丰富,执行力强'
|
||||
],
|
||||
risk_factors: [
|
||||
'宏观经济下行压力影响信贷质量',
|
||||
'利率市场化导致息差收窄',
|
||||
'金融监管趋严,合规成本上升',
|
||||
'同业竞争激烈,市场份额面临挑战',
|
||||
'金融科技发展带来的技术和运营风险'
|
||||
],
|
||||
development_strategy: '坚持"科技引领、零售突破、对公做精"战略,加快数字化转型,提升综合金融服务能力',
|
||||
analyst_rating: {
|
||||
buy: 18,
|
||||
hold: 12,
|
||||
sell: 2,
|
||||
target_price: 15.8,
|
||||
current_price: 13.2
|
||||
}
|
||||
},
|
||||
|
||||
// 价值链分析
|
||||
valueChainAnalysis: {
|
||||
upstream: [
|
||||
{ name: '央行及监管机构', relationship: '政策与监管', importance: '高', description: '接受货币政策调控和监管指导' },
|
||||
{ name: '同业资金市场', relationship: '资金来源', importance: '高', description: '开展同业拆借、债券回购等业务' },
|
||||
{ name: '金融科技公司', relationship: '技术支持', importance: '中', description: '提供金融科技解决方案和技术服务' }
|
||||
],
|
||||
core_business: {
|
||||
deposit_business: { scale: 33256.8, market_share: 2.8, growth_rate: 9.2 },
|
||||
loan_business: { scale: 28945.3, market_share: 2.5, growth_rate: 12.5 },
|
||||
intermediary_business: { scale: 425.6, market_share: 3.2, growth_rate: 15.8 },
|
||||
digital_banking: { user_count: 11256, app_mau: 4235, growth_rate: 28.5 }
|
||||
},
|
||||
downstream: [
|
||||
{ name: '个人客户', scale: '1.12亿户', contribution: '50.1%', description: '零售银行业务主体' },
|
||||
{ name: '企业客户', scale: '85.6万户', contribution: '42.2%', description: '对公业务主体' },
|
||||
{ name: '政府机构', scale: '2.3万户', contribution: '7.7%', description: '公共事业及政府业务' }
|
||||
],
|
||||
ecosystem_partners: [
|
||||
{ name: '中国平安集团', type: '关联方', cooperation: '综合金融服务、客户共享' },
|
||||
{ name: '平安科技', type: '科技支持', cooperation: '金融科技研发、系统建设' },
|
||||
{ name: '平安普惠', type: '业务协同', cooperation: '普惠金融、小微贷款' },
|
||||
{ name: '平安证券', type: '业务协同', cooperation: '投资银行、资产管理' }
|
||||
]
|
||||
},
|
||||
|
||||
// 关键因素时间线
|
||||
keyFactorsTimeline: [
|
||||
{
|
||||
date: '2024-10-28',
|
||||
event: '发布2024年三季报',
|
||||
type: '业绩公告',
|
||||
importance: 'high',
|
||||
impact: '前三季度净利润同比增长12.5%,超市场预期',
|
||||
change: '+5.2%'
|
||||
},
|
||||
{
|
||||
date: '2024-09-15',
|
||||
event: '推出AI智能客服系统',
|
||||
type: '科技创新',
|
||||
importance: 'medium',
|
||||
impact: '提升客户服务效率,降低运营成本',
|
||||
change: '+2.1%'
|
||||
},
|
||||
{
|
||||
date: '2024-08-28',
|
||||
event: '发布2024年中报',
|
||||
type: '业绩公告',
|
||||
importance: 'high',
|
||||
impact: '上半年净利润增长11.2%,资产质量保持稳定',
|
||||
change: '+3.8%'
|
||||
},
|
||||
{
|
||||
date: '2024-07-20',
|
||||
event: '获批设立理财子公司',
|
||||
type: '业务拓展',
|
||||
importance: 'high',
|
||||
impact: '完善财富管理业务布局,拓展收入来源',
|
||||
change: '+4.5%'
|
||||
},
|
||||
{
|
||||
date: '2024-06-10',
|
||||
event: '完成300亿元二级资本债发行',
|
||||
type: '融资事件',
|
||||
importance: 'medium',
|
||||
impact: '补充资本实力,支持业务扩张',
|
||||
change: '+1.8%'
|
||||
},
|
||||
{
|
||||
date: '2024-04-30',
|
||||
event: '发布2024年一季报',
|
||||
type: '业绩公告',
|
||||
importance: 'high',
|
||||
impact: '一季度净利润增长10.8%,开门红表现优异',
|
||||
change: '+4.2%'
|
||||
},
|
||||
{
|
||||
date: '2024-03-15',
|
||||
event: '零售客户突破1.1亿户',
|
||||
type: '业务里程碑',
|
||||
importance: 'medium',
|
||||
impact: '零售转型成效显著,客户基础进一步夯实',
|
||||
change: '+2.5%'
|
||||
},
|
||||
{
|
||||
date: '2024-01-20',
|
||||
event: '获评"2023年度最佳零售银行"',
|
||||
type: '荣誉奖项',
|
||||
importance: 'low',
|
||||
impact: '品牌影响力提升',
|
||||
change: '+0.8%'
|
||||
}
|
||||
],
|
||||
|
||||
// 盈利预测报告
|
||||
forecastReport: {
|
||||
// 营收与利润趋势
|
||||
income_profit_trend: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
income: [116524, 134632, 148956, 162350, 175280, 189450, 204120], // 营业总收入(百万元)
|
||||
profit: [34562, 39845, 43218, 52860, 58420, 64680, 71250] // 归母净利润(百万元)
|
||||
},
|
||||
// 增长率分析
|
||||
growth_bars: {
|
||||
years: ['2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
revenue_growth_pct: [15.5, 10.6, 8.9, 8.0, 8.1, 7.7] // 营收增长率(%)
|
||||
},
|
||||
// EPS趋势
|
||||
eps_trend: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
eps: [1.78, 2.05, 2.23, 2.72, 3.01, 3.33, 3.67] // EPS(稀释,元/股)
|
||||
},
|
||||
// PE与PEG分析
|
||||
pe_peg_axes: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
pe: [7.4, 6.9, 7.2, 4.9, 4.4, 4.0, 3.6], // PE(倍)
|
||||
peg: [0.48, 0.65, 0.81, 0.55, 0.55, 0.49, 0.47] // PEG
|
||||
},
|
||||
// 详细数据表格
|
||||
detail_table: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
rows: [
|
||||
{ '指标': '营业总收入(百万元)', '2020': 116524, '2021': 134632, '2022': 148956, '2023': 162350, '2024E': 175280, '2025E': 189450, '2026E': 204120 },
|
||||
{ '指标': '营收增长率(%)', '2020': '-', '2021': 15.5, '2022': 10.6, '2023': 8.9, '2024E': 8.0, '2025E': 8.1, '2026E': 7.7 },
|
||||
{ '指标': '归母净利润(百万元)', '2020': 34562, '2021': 39845, '2022': 43218, '2023': 52860, '2024E': 58420, '2025E': 64680, '2026E': 71250 },
|
||||
{ '指标': '净利润增长率(%)', '2020': '-', '2021': 15.3, '2022': 8.5, '2023': 22.3, '2024E': 10.5, '2025E': 10.7, '2026E': 10.2 },
|
||||
{ '指标': 'EPS(稀释,元)', '2020': 1.78, '2021': 2.05, '2022': 2.23, '2023': 2.72, '2024E': 3.01, '2025E': 3.33, '2026E': 3.67 },
|
||||
{ '指标': 'ROE(%)', '2020': 14.2, '2021': 15.8, '2022': 15.5, '2023': 16.2, '2024E': 16.5, '2025E': 16.8, '2026E': 17.0 },
|
||||
{ '指标': '总资产(百万元)', '2020': 4512360, '2021': 4856230, '2022': 4923150, '2023': 5024560, '2024E': 5230480, '2025E': 5445200, '2026E': 5668340 },
|
||||
{ '指标': '净资产(百万元)', '2020': 293540, '2021': 312680, '2022': 318920, '2023': 325680, '2024E': 338560, '2025E': 352480, '2026E': 367820 },
|
||||
{ '指标': '资产负债率(%)', '2020': 93.5, '2021': 93.6, '2022': 93.5, '2023': 93.5, '2024E': 93.5, '2025E': 93.5, '2026E': 93.5 },
|
||||
{ '指标': 'PE(倍)', '2020': 7.4, '2021': 6.9, '2022': 7.2, '2023': 4.9, '2024E': 4.4, '2025E': 4.0, '2026E': 3.6 },
|
||||
{ '指标': 'PB(倍)', '2020': 1.05, '2021': 1.09, '2022': 1.12, '2023': 0.79, '2024E': 0.72, '2025E': 0.67, '2026E': 0.61 }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 生成通用公司数据的工具函数
|
||||
export const generateCompanyData = (stockCode, stockName) => {
|
||||
// 如果是平安银行,直接返回详细数据
|
||||
if (stockCode === '000001') {
|
||||
return PINGAN_BANK_DATA;
|
||||
}
|
||||
|
||||
// 否则生成通用数据
|
||||
return {
|
||||
stockCode,
|
||||
stockName,
|
||||
basicInfo: {
|
||||
code: stockCode,
|
||||
name: stockName,
|
||||
registered_capital: Math.floor(Math.random() * 500000) + 10000,
|
||||
registered_capital_unit: '万元',
|
||||
legal_representative: '张三',
|
||||
general_manager: '李四',
|
||||
secretary: '王五',
|
||||
registered_address: '中国某省某市某区某路123号',
|
||||
office_address: '中国某省某市某区某路123号',
|
||||
phone: '021-12345678',
|
||||
email: 'ir@company.com',
|
||||
website: 'http://www.company.com',
|
||||
employees: Math.floor(Math.random() * 10000) + 1000,
|
||||
list_date: '2010-01-01',
|
||||
industry: '制造业',
|
||||
},
|
||||
actualControl: {
|
||||
controller_name: '某控股集团有限公司',
|
||||
controller_type: '企业',
|
||||
shareholding_ratio: 35.5,
|
||||
control_chain: '某控股集团有限公司 -> ' + stockName,
|
||||
},
|
||||
concentration: {
|
||||
top1_ratio: 35.5,
|
||||
top3_ratio: 52.3,
|
||||
top5_ratio: 61.8,
|
||||
top10_ratio: 72.5,
|
||||
concentration_level: '适度集中',
|
||||
},
|
||||
management: [
|
||||
{ name: '张三', position: '董事长', gender: '男', age: 55, education: '硕士', annual_compensation: 320.5 },
|
||||
{ name: '李四', position: '总经理', gender: '男', age: 50, education: '硕士', annual_compensation: 280.3 },
|
||||
{ name: '王五', position: '董事会秘书', gender: '女', age: 45, education: '本科', annual_compensation: 180.2 },
|
||||
],
|
||||
topCirculationShareholders: Array(10).fill(null).map((_, i) => ({
|
||||
shareholder_name: `股东${i + 1}`,
|
||||
shares: Math.floor(Math.random() * 100000000),
|
||||
ratio: (10 - i) * 0.8,
|
||||
change: Math.floor(Math.random() * 10000000) - 5000000,
|
||||
shareholder_type: '企业'
|
||||
})),
|
||||
topShareholders: Array(10).fill(null).map((_, i) => ({
|
||||
shareholder_name: `股东${i + 1}`,
|
||||
shares: Math.floor(Math.random() * 100000000),
|
||||
ratio: (10 - i) * 0.8,
|
||||
change: Math.floor(Math.random() * 10000000) - 5000000,
|
||||
shareholder_type: '企业',
|
||||
is_restricted: false
|
||||
})),
|
||||
branches: [
|
||||
{ name: '北京分公司', address: '北京市朝阳区某路123号', phone: '010-12345678', type: '分公司' },
|
||||
{ name: '上海分公司', address: '上海市浦东新区某路456号', phone: '021-12345678', type: '分公司' },
|
||||
],
|
||||
announcements: [
|
||||
{ title: stockName + '2024年第三季度报告', publish_date: '2024-10-28', type: '定期报告', summary: '业绩稳步增长' },
|
||||
{ title: stockName + '2024年半年度报告', publish_date: '2024-08-28', type: '定期报告', summary: '经营情况良好' },
|
||||
],
|
||||
disclosureSchedule: [
|
||||
{ report_type: '2024年年度报告', planned_date: '2025-04-30', status: '未披露' },
|
||||
{ report_type: '2024年第三季度报告', planned_date: '2024-10-31', status: '已披露' },
|
||||
],
|
||||
comprehensiveAnalysis: {
|
||||
overview: {
|
||||
company_name: stockName,
|
||||
stock_code: stockCode,
|
||||
industry: '制造业',
|
||||
total_assets: Math.floor(Math.random() * 10000) + 100,
|
||||
},
|
||||
financial_highlights: {
|
||||
revenue: Math.floor(Math.random() * 1000) + 50,
|
||||
revenue_growth: (Math.random() * 20 - 5).toFixed(2),
|
||||
net_profit: Math.floor(Math.random() * 100) + 10,
|
||||
profit_growth: (Math.random() * 20 - 5).toFixed(2),
|
||||
},
|
||||
competitive_advantages: ['技术领先', '品牌优势', '管理团队优秀'],
|
||||
risk_factors: ['市场竞争激烈', '原材料价格波动'],
|
||||
},
|
||||
valueChainAnalysis: {
|
||||
upstream: [
|
||||
{ name: '原材料供应商A', relationship: '供应商', importance: '高' },
|
||||
{ name: '原材料供应商B', relationship: '供应商', importance: '中' },
|
||||
],
|
||||
downstream: [
|
||||
{ name: '经销商网络', scale: '1000家', contribution: '60%' },
|
||||
{ name: '直营渠道', scale: '100家', contribution: '40%' },
|
||||
],
|
||||
},
|
||||
keyFactorsTimeline: [
|
||||
{ date: '2024-10-28', event: '发布三季报', type: '业绩公告', importance: 'high', impact: '业绩超预期' },
|
||||
{ date: '2024-08-28', event: '发布中报', type: '业绩公告', importance: 'high', impact: '业绩稳定增长' },
|
||||
],
|
||||
// 通用预测报告数据
|
||||
forecastReport: {
|
||||
income_profit_trend: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
income: [5000, 5800, 6500, 7200, 7900, 8600, 9400],
|
||||
profit: [450, 520, 580, 650, 720, 800, 890]
|
||||
},
|
||||
growth_bars: {
|
||||
years: ['2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
revenue_growth_pct: [16.0, 12.1, 10.8, 9.7, 8.9, 9.3]
|
||||
},
|
||||
eps_trend: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
eps: [0.45, 0.52, 0.58, 0.65, 0.72, 0.80, 0.89]
|
||||
},
|
||||
pe_peg_axes: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
pe: [22.2, 19.2, 17.2, 15.4, 13.9, 12.5, 11.2],
|
||||
peg: [1.39, 1.59, 1.59, 1.42, 1.43, 1.40, 1.20]
|
||||
},
|
||||
detail_table: {
|
||||
years: ['2020', '2021', '2022', '2023', '2024E', '2025E', '2026E'],
|
||||
rows: [
|
||||
{ '指标': '营业总收入(百万元)', '2020': 5000, '2021': 5800, '2022': 6500, '2023': 7200, '2024E': 7900, '2025E': 8600, '2026E': 9400 },
|
||||
{ '指标': '营收增长率(%)', '2020': '-', '2021': 16.0, '2022': 12.1, '2023': 10.8, '2024E': 9.7, '2025E': 8.9, '2026E': 9.3 },
|
||||
{ '指标': '归母净利润(百万元)', '2020': 450, '2021': 520, '2022': 580, '2023': 650, '2024E': 720, '2025E': 800, '2026E': 890 },
|
||||
{ '指标': 'EPS(稀释,元)', '2020': 0.45, '2021': 0.52, '2022': 0.58, '2023': 0.65, '2024E': 0.72, '2025E': 0.80, '2026E': 0.89 },
|
||||
{ '指标': 'ROE(%)', '2020': 12.5, '2021': 13.2, '2022': 13.8, '2023': 14.2, '2024E': 14.5, '2025E': 14.8, '2026E': 15.0 },
|
||||
{ '指标': 'PE(倍)', '2020': 22.2, '2021': 19.2, '2022': 17.2, '2023': 15.4, '2024E': 13.9, '2025E': 12.5, '2026E': 11.2 }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -545,3 +545,274 @@ export function getEventRelatedStocks(eventId) {
|
||||
const count = 3 + (parseInt(eventId) % 4);
|
||||
return generateRelatedStocks(eventId, count);
|
||||
}
|
||||
|
||||
// ==================== Mock 事件列表数据 ====================
|
||||
|
||||
// 事件类型池
|
||||
const eventTypes = ['政策发布', '行业动向', '公司公告', '市场研判', '技术突破', '财报发布', '投融资', '高管变动'];
|
||||
|
||||
// 行业池
|
||||
const industries = ['半导体', '新能源', '人工智能', '医药', '消费', '金融', '房地产', '通信', '互联网', '军工', '化工', '机械'];
|
||||
|
||||
// 事件标题模板
|
||||
const eventTitleTemplates = [
|
||||
'{industry}行业迎来重大政策利好',
|
||||
'{company}发布{quarter}财报,业绩超预期',
|
||||
'{industry}板块集体大涨,{company}涨停',
|
||||
'央行宣布{policy},影响{industry}行业',
|
||||
'{company}与{partner}达成战略合作',
|
||||
'{industry}技术取得重大突破',
|
||||
'{company}拟投资{amount}亿元布局{industry}',
|
||||
'国家发改委:支持{industry}产业发展',
|
||||
'{industry}龙头{company}涨价{percent}%',
|
||||
'{company}回购股份,彰显信心',
|
||||
];
|
||||
|
||||
// 生成随机公司名
|
||||
function generateCompanyName(industry) {
|
||||
const prefixes = ['华为', '中兴', '阿里', '腾讯', '比亚迪', '宁德时代', '隆基', '恒瑞', '茅台', '五粮液', '海康', '中芯'];
|
||||
const suffixes = ['科技', '集团', '股份', '控股', '实业', ''];
|
||||
const prefix = prefixes[Math.floor(Math.random() * prefixes.length)];
|
||||
const suffix = suffixes[Math.floor(Math.random() * suffixes.length)];
|
||||
return `${prefix}${suffix}`;
|
||||
}
|
||||
|
||||
// 生成事件标题
|
||||
function generateEventTitle(industry, seed) {
|
||||
const template = eventTitleTemplates[seed % eventTitleTemplates.length];
|
||||
return template
|
||||
.replace('{industry}', industry)
|
||||
.replace('{company}', generateCompanyName(industry))
|
||||
.replace('{partner}', generateCompanyName(industry))
|
||||
.replace('{quarter}', ['一季度', '半年度', '三季度', '年度'][seed % 4])
|
||||
.replace('{policy}', ['降准0.5%', '降息25BP', 'MLF下调', '提高赤字率'][seed % 4])
|
||||
.replace('{amount}', [50, 100, 200, 500][seed % 4])
|
||||
.replace('{percent}', [5, 10, 15, 20][seed % 4]);
|
||||
}
|
||||
|
||||
// 生成事件描述
|
||||
function generateEventDescription(industry, importance, seed) {
|
||||
const impacts = {
|
||||
S: '重大利好,预计将对行业格局产生深远影响,相关概念股有望持续受益。机构预计该事件将带动行业整体估值提升15-20%,龙头企业市值增长空间广阔。',
|
||||
A: '重要利好,市场情绪积极,短期内资金流入明显。分析师普遍认为该事件将推动行业景气度上行,相关公司业绩有望超预期增长。',
|
||||
B: '中性偏好,对部分细分领域有一定促进作用。虽然不是行业性机会,但优质标的仍有结构性行情,建议关注业绩确定性强的公司。',
|
||||
C: '影响有限,市场反应平淡,但长期来看仍有积极意义。事件对行业发展方向有指引作用,关注后续政策跟进和落地情况。',
|
||||
};
|
||||
|
||||
const details = [
|
||||
`根据最新消息,${industry}领域将获得新一轮政策支持,产业链相关企业订单饱满。`,
|
||||
`${industry}板块近期表现活跃,多只个股创出年内新高,资金持续流入。`,
|
||||
`行业专家指出,${industry}产业正处于高速发展期,市场空间广阔,龙头企业优势明显。`,
|
||||
`券商研报显示,${industry}行业估值处于历史低位,当前具备较高配置价值。`,
|
||||
];
|
||||
|
||||
return impacts[importance] + details[seed % details.length];
|
||||
}
|
||||
|
||||
// 生成关键词
|
||||
function generateKeywords(industry, seed) {
|
||||
const commonKeywords = ['政策', '利好', '业绩', '涨停', '龙头', '突破', '合作', '投资'];
|
||||
const industryKeywords = {
|
||||
'半导体': ['芯片', '晶圆', '封测', 'AI芯片', '国产替代'],
|
||||
'新能源': ['电池', '光伏', '储能', '新能源车', '锂电'],
|
||||
'人工智能': ['大模型', 'AI应用', '算力', '数据', '机器学习'],
|
||||
'医药': ['创新药', 'CRO', '医疗器械', '生物制药', '仿制药'],
|
||||
'消费': ['白酒', '食品', '家电', '零售', '免税'],
|
||||
};
|
||||
|
||||
const keywords = [
|
||||
...commonKeywords.slice(seed % 3, seed % 3 + 3),
|
||||
...(industryKeywords[industry] || []).slice(0, 2)
|
||||
];
|
||||
|
||||
return keywords.slice(0, 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Mock 事件列表
|
||||
* @param {Object} params - 查询参数
|
||||
* @returns {Object} - {events: [], pagination: {}}
|
||||
*/
|
||||
export function generateMockEvents(params = {}) {
|
||||
const {
|
||||
page = 1,
|
||||
per_page = 10,
|
||||
sort = 'new',
|
||||
importance = 'all',
|
||||
date_range = '',
|
||||
q = '',
|
||||
industry_code = '',
|
||||
stock_code = '',
|
||||
} = params;
|
||||
|
||||
// 生成100个事件用于测试
|
||||
const totalEvents = 100;
|
||||
const allEvents = [];
|
||||
|
||||
const importanceLevels = ['S', 'A', 'B', 'C'];
|
||||
const baseDate = new Date('2025-01-15');
|
||||
|
||||
for (let i = 0; i < totalEvents; i++) {
|
||||
const industry = industries[i % industries.length];
|
||||
const imp = importanceLevels[i % importanceLevels.length];
|
||||
const eventType = eventTypes[i % eventTypes.length];
|
||||
|
||||
// 生成随机日期(最近30天内)
|
||||
const createdAt = new Date(baseDate);
|
||||
createdAt.setDate(createdAt.getDate() - (i % 30));
|
||||
|
||||
// 生成随机热度和收益率
|
||||
const hotScore = Math.max(50, 100 - i);
|
||||
const relatedAvgChg = (Math.random() * 20 - 5).toFixed(2); // -5% 到 15%
|
||||
const relatedMaxChg = (Math.random() * 30).toFixed(2); // 0% 到 30%
|
||||
|
||||
// 为每个事件随机选择2-5个相关股票
|
||||
const relatedStockCount = 2 + (i % 4); // 2-5个股票
|
||||
const relatedStocks = [];
|
||||
const industryStocks = stockPool.filter(s => s.industry === industry);
|
||||
|
||||
// 优先选择同行业股票
|
||||
if (industryStocks.length > 0) {
|
||||
for (let j = 0; j < Math.min(relatedStockCount, industryStocks.length); j++) {
|
||||
relatedStocks.push(industryStocks[j % industryStocks.length].stock_code);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果同行业股票不够,从整个 stockPool 中补充
|
||||
while (relatedStocks.length < relatedStockCount && relatedStocks.length < stockPool.length) {
|
||||
const randomStock = stockPool[relatedStocks.length % stockPool.length];
|
||||
if (!relatedStocks.includes(randomStock.stock_code)) {
|
||||
relatedStocks.push(randomStock.stock_code);
|
||||
}
|
||||
}
|
||||
|
||||
allEvents.push({
|
||||
id: i + 1,
|
||||
title: generateEventTitle(industry, i),
|
||||
description: generateEventDescription(industry, imp, i),
|
||||
content: generateEventDescription(industry, imp, i),
|
||||
event_type: eventType,
|
||||
importance: imp,
|
||||
status: 'published',
|
||||
created_at: createdAt.toISOString(),
|
||||
updated_at: createdAt.toISOString(),
|
||||
hot_score: hotScore,
|
||||
view_count: Math.floor(Math.random() * 10000),
|
||||
related_avg_chg: parseFloat(relatedAvgChg),
|
||||
related_max_chg: parseFloat(relatedMaxChg),
|
||||
keywords: generateKeywords(industry, i),
|
||||
is_ai_generated: i % 4 === 0, // 25% 的事件是AI生成
|
||||
industry: industry,
|
||||
related_stocks: relatedStocks, // 添加相关股票列表
|
||||
});
|
||||
}
|
||||
|
||||
// 筛选
|
||||
let filteredEvents = allEvents;
|
||||
|
||||
// 重要性筛选
|
||||
if (importance && importance !== 'all') {
|
||||
filteredEvents = filteredEvents.filter(e => e.importance === importance);
|
||||
}
|
||||
|
||||
// 关键词搜索
|
||||
if (q) {
|
||||
const query = q.toLowerCase();
|
||||
filteredEvents = filteredEvents.filter(e =>
|
||||
e.title.toLowerCase().includes(query) ||
|
||||
e.description.toLowerCase().includes(query) ||
|
||||
e.keywords.some(k => k.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
// 行业筛选
|
||||
if (industry_code) {
|
||||
filteredEvents = filteredEvents.filter(e =>
|
||||
e.industry.includes(industry_code) || e.keywords.includes(industry_code)
|
||||
);
|
||||
}
|
||||
|
||||
// 股票代码筛选
|
||||
if (stock_code) {
|
||||
// 移除可能的后缀 (.SH, .SZ)
|
||||
const cleanStockCode = stock_code.replace(/\.(SH|SZ)$/, '');
|
||||
filteredEvents = filteredEvents.filter(e => {
|
||||
if (!e.related_stocks || e.related_stocks.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// 检查事件的 related_stocks 中是否包含该股票代码
|
||||
return e.related_stocks.some(code => {
|
||||
const cleanCode = code.replace(/\.(SH|SZ)$/, '');
|
||||
return cleanCode === cleanStockCode || code === stock_code;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 日期范围筛选
|
||||
if (date_range) {
|
||||
const [startStr, endStr] = date_range.split(' 至 ');
|
||||
if (startStr && endStr) {
|
||||
const start = new Date(startStr);
|
||||
const end = new Date(endStr);
|
||||
filteredEvents = filteredEvents.filter(e => {
|
||||
const eventDate = new Date(e.created_at);
|
||||
return eventDate >= start && eventDate <= end;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 排序
|
||||
if (sort === 'hot') {
|
||||
filteredEvents.sort((a, b) => b.hot_score - a.hot_score);
|
||||
} else if (sort === 'returns') {
|
||||
filteredEvents.sort((a, b) => b.related_avg_chg - a.related_avg_chg);
|
||||
} else {
|
||||
// 默认按时间排序 (new)
|
||||
filteredEvents.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
}
|
||||
|
||||
// 分页
|
||||
const start = (page - 1) * per_page;
|
||||
const end = start + per_page;
|
||||
const paginatedEvents = filteredEvents.slice(start, end);
|
||||
|
||||
return {
|
||||
events: paginatedEvents,
|
||||
pagination: {
|
||||
page: page,
|
||||
per_page: per_page,
|
||||
total: filteredEvents.length,
|
||||
total_pages: Math.ceil(filteredEvents.length / per_page),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成热点事件
|
||||
* @param {number} limit - 返回数量
|
||||
* @returns {Array} - 热点事件列表
|
||||
*/
|
||||
export function generateHotEvents(limit = 5) {
|
||||
const { events } = generateMockEvents({ sort: 'hot', per_page: limit });
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成热门关键词
|
||||
* @param {number} limit - 返回数量
|
||||
* @returns {Array} - 热门关键词列表
|
||||
*/
|
||||
export function generatePopularKeywords(limit = 20) {
|
||||
const allKeywords = [
|
||||
'人工智能', '芯片', '新能源', '锂电池', '光伏', '储能',
|
||||
'消费', '白酒', '医药', 'CRO', '半导体', '国产替代',
|
||||
'军工', '航空', '5G', '通信', '互联网', '云计算',
|
||||
'大数据', '区块链', '元宇宙', '新基建', '数字经济',
|
||||
];
|
||||
|
||||
return allKeywords.slice(0, limit).map((keyword, index) => ({
|
||||
keyword,
|
||||
count: Math.max(10, 100 - index * 3),
|
||||
trend: index % 3 === 0 ? 'up' : index % 3 === 1 ? 'down' : 'stable',
|
||||
}));
|
||||
}
|
||||
|
||||
139
src/mocks/data/financial.js
Normal file
139
src/mocks/data/financial.js
Normal file
@@ -0,0 +1,139 @@
|
||||
// src/mocks/data/financial.js
|
||||
// 财务数据相关的 Mock 数据
|
||||
|
||||
// 生成财务数据
|
||||
export const generateFinancialData = (stockCode) => {
|
||||
const periods = ['2024-09-30', '2024-06-30', '2024-03-31', '2023-12-31'];
|
||||
|
||||
return {
|
||||
stockCode,
|
||||
|
||||
// 股票基本信息
|
||||
stockInfo: {
|
||||
code: stockCode,
|
||||
name: stockCode === '000001' ? '平安银行' : '示例公司',
|
||||
industry: stockCode === '000001' ? '银行' : '制造业',
|
||||
list_date: '1991-04-03',
|
||||
market: 'SZ'
|
||||
},
|
||||
|
||||
// 资产负债表
|
||||
balanceSheet: periods.map((period, i) => ({
|
||||
period,
|
||||
total_assets: 5024560 - i * 50000, // 百万元
|
||||
total_liabilities: 4698880 - i * 48000,
|
||||
shareholders_equity: 325680 - i * 2000,
|
||||
current_assets: 2512300 - i * 25000,
|
||||
non_current_assets: 2512260 - i * 25000,
|
||||
current_liabilities: 3456780 - i * 35000,
|
||||
non_current_liabilities: 1242100 - i * 13000
|
||||
})),
|
||||
|
||||
// 利润表
|
||||
incomeStatement: periods.map((period, i) => ({
|
||||
period,
|
||||
revenue: 162350 - i * 4000, // 百万元
|
||||
operating_cost: 45620 - i * 1200,
|
||||
gross_profit: 116730 - i * 2800,
|
||||
operating_profit: 68450 - i * 1500,
|
||||
net_profit: 52860 - i * 1200,
|
||||
eps: 2.72 - i * 0.06
|
||||
})),
|
||||
|
||||
// 现金流量表
|
||||
cashflow: periods.map((period, i) => ({
|
||||
period,
|
||||
operating_cashflow: 125600 - i * 3000, // 百万元
|
||||
investing_cashflow: -45300 - i * 1000,
|
||||
financing_cashflow: -38200 + i * 500,
|
||||
net_cashflow: 42100 - i * 1500,
|
||||
cash_ending: 456780 - i * 10000
|
||||
})),
|
||||
|
||||
// 财务指标
|
||||
financialMetrics: periods.map((period, i) => ({
|
||||
period,
|
||||
roe: 16.23 - i * 0.3, // %
|
||||
roa: 1.05 - i * 0.02,
|
||||
gross_margin: 71.92 - i * 0.5,
|
||||
net_margin: 32.56 - i * 0.3,
|
||||
current_ratio: 0.73 + i * 0.01,
|
||||
quick_ratio: 0.71 + i * 0.01,
|
||||
debt_ratio: 93.52 + i * 0.05,
|
||||
asset_turnover: 0.41 - i * 0.01,
|
||||
inventory_turnover: 0, // 银行无库存
|
||||
receivable_turnover: 0 // 银行特殊
|
||||
})),
|
||||
|
||||
// 主营业务
|
||||
mainBusiness: {
|
||||
by_product: [
|
||||
{ name: '对公业务', revenue: 68540, ratio: 42.2, yoy_growth: 6.8 },
|
||||
{ name: '零售业务', revenue: 81320, ratio: 50.1, yoy_growth: 11.2 },
|
||||
{ name: '金融市场业务', revenue: 12490, ratio: 7.7, yoy_growth: 3.5 }
|
||||
],
|
||||
by_region: [
|
||||
{ name: '华南地区', revenue: 56800, ratio: 35.0, yoy_growth: 9.2 },
|
||||
{ name: '华东地区', revenue: 48705, ratio: 30.0, yoy_growth: 8.5 },
|
||||
{ name: '华北地区', revenue: 32470, ratio: 20.0, yoy_growth: 7.8 },
|
||||
{ name: '其他地区', revenue: 24375, ratio: 15.0, yoy_growth: 6.5 }
|
||||
]
|
||||
},
|
||||
|
||||
// 业绩预告
|
||||
forecast: {
|
||||
period: '2024',
|
||||
forecast_net_profit_min: 580000, // 百万元
|
||||
forecast_net_profit_max: 620000,
|
||||
yoy_growth_min: 10.0, // %
|
||||
yoy_growth_max: 17.0,
|
||||
forecast_type: '预增',
|
||||
reason: '受益于零售业务快速增长及资产质量改善,预计全年业绩保持稳定增长',
|
||||
publish_date: '2024-10-15'
|
||||
},
|
||||
|
||||
// 行业排名
|
||||
industryRank: {
|
||||
industry: '银行',
|
||||
total_companies: 42,
|
||||
rankings: [
|
||||
{ metric: '总资产', rank: 8, value: 5024560, percentile: 19 },
|
||||
{ metric: '营业收入', rank: 9, value: 162350, percentile: 21 },
|
||||
{ metric: '净利润', rank: 8, value: 52860, percentile: 19 },
|
||||
{ metric: 'ROE', rank: 12, value: 16.23, percentile: 29 },
|
||||
{ metric: '不良贷款率', rank: 18, value: 1.02, percentile: 43 }
|
||||
]
|
||||
},
|
||||
|
||||
// 期间对比
|
||||
periodComparison: {
|
||||
periods: ['Q3-2024', 'Q2-2024', 'Q1-2024', 'Q4-2023'],
|
||||
metrics: [
|
||||
{
|
||||
name: '营业收入',
|
||||
unit: '百万元',
|
||||
values: [41500, 40800, 40200, 40850],
|
||||
yoy: [8.2, 7.8, 8.5, 9.2]
|
||||
},
|
||||
{
|
||||
name: '净利润',
|
||||
unit: '百万元',
|
||||
values: [13420, 13180, 13050, 13210],
|
||||
yoy: [12.5, 11.2, 10.8, 12.3]
|
||||
},
|
||||
{
|
||||
name: 'ROE',
|
||||
unit: '%',
|
||||
values: [16.23, 15.98, 15.75, 16.02],
|
||||
yoy: [1.2, 0.8, 0.5, 1.0]
|
||||
},
|
||||
{
|
||||
name: 'EPS',
|
||||
unit: '元',
|
||||
values: [0.69, 0.68, 0.67, 0.68],
|
||||
yoy: [12.3, 11.5, 10.5, 12.0]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
};
|
||||
554
src/mocks/data/industries.js
Normal file
554
src/mocks/data/industries.js
Normal file
@@ -0,0 +1,554 @@
|
||||
// src/mocks/data/industries.js
|
||||
// 行业分类完整树形数据 Mock
|
||||
|
||||
/**
|
||||
* 完整的行业分类树形结构
|
||||
* 包含 5 个分类体系,层级深度 2-4 层不等
|
||||
*/
|
||||
export const industryTreeData = [
|
||||
{
|
||||
value: "新财富行业分类",
|
||||
label: "新财富行业分类",
|
||||
children: [
|
||||
{
|
||||
value: "XCF001",
|
||||
label: "传播与文化",
|
||||
children: [
|
||||
{
|
||||
value: "XCF001001",
|
||||
label: "互联网传媒",
|
||||
children: [
|
||||
{ value: "XCF001001001", label: "数字媒体" },
|
||||
{ value: "XCF001001002", label: "社交平台" },
|
||||
{ value: "XCF001001003", label: "短视频平台" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "XCF001002",
|
||||
label: "影视娱乐",
|
||||
children: [
|
||||
{ value: "XCF001002001", label: "电影制作" },
|
||||
{ value: "XCF001002002", label: "网络视频" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "XCF001003",
|
||||
label: "出版发行"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "XCF002",
|
||||
label: "交通运输仓储",
|
||||
children: [
|
||||
{
|
||||
value: "XCF002001",
|
||||
label: "航空运输",
|
||||
children: [
|
||||
{ value: "XCF002001001", label: "航空客运" },
|
||||
{ value: "XCF002001002", label: "航空货运" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "XCF002002",
|
||||
label: "铁路运输"
|
||||
},
|
||||
{
|
||||
value: "XCF002003",
|
||||
label: "公路运输",
|
||||
children: [
|
||||
{ value: "XCF002003001", label: "公路客运" },
|
||||
{ value: "XCF002003002", label: "公路货运" },
|
||||
{ value: "XCF002003003", label: "快递物流" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "XCF003",
|
||||
label: "农林牧渔",
|
||||
children: [
|
||||
{ value: "XCF003001", label: "种植业" },
|
||||
{ value: "XCF003002", label: "林业" },
|
||||
{ value: "XCF003003", label: "畜牧业" },
|
||||
{ value: "XCF003004", label: "渔业" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "XCF004",
|
||||
label: "医药生物",
|
||||
children: [
|
||||
{
|
||||
value: "XCF004001",
|
||||
label: "化学制药",
|
||||
children: [
|
||||
{ value: "XCF004001001", label: "化学原料药" },
|
||||
{ value: "XCF004001002", label: "化学制剂" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "XCF004002",
|
||||
label: "生物制品",
|
||||
children: [
|
||||
{ value: "XCF004002001", label: "疫苗" },
|
||||
{ value: "XCF004002002", label: "血液制品" },
|
||||
{ value: "XCF004002003", label: "诊断试剂" }
|
||||
]
|
||||
},
|
||||
{ value: "XCF004003", label: "中药" },
|
||||
{ value: "XCF004004", label: "医疗器械" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "XCF005",
|
||||
label: "基础化工",
|
||||
children: [
|
||||
{ value: "XCF005001", label: "化学原料" },
|
||||
{ value: "XCF005002", label: "化学制品" },
|
||||
{ value: "XCF005003", label: "塑料" },
|
||||
{ value: "XCF005004", label: "橡胶" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "XCF006",
|
||||
label: "家电",
|
||||
children: [
|
||||
{ value: "XCF006001", label: "白色家电" },
|
||||
{ value: "XCF006002", label: "黑色家电" },
|
||||
{ value: "XCF006003", label: "小家电" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "XCF007",
|
||||
label: "电子",
|
||||
children: [
|
||||
{
|
||||
value: "XCF007001",
|
||||
label: "半导体",
|
||||
children: [
|
||||
{ value: "XCF007001001", label: "芯片设计" },
|
||||
{ value: "XCF007001002", label: "芯片制造" },
|
||||
{ value: "XCF007001003", label: "封装测试" }
|
||||
]
|
||||
},
|
||||
{ value: "XCF007002", label: "元件" },
|
||||
{ value: "XCF007003", label: "光学光电子" },
|
||||
{ value: "XCF007004", label: "消费电子" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "XCF008",
|
||||
label: "计算机",
|
||||
children: [
|
||||
{
|
||||
value: "XCF008001",
|
||||
label: "计算机设备",
|
||||
children: [
|
||||
{ value: "XCF008001001", label: "PC" },
|
||||
{ value: "XCF008001002", label: "服务器" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "XCF008002",
|
||||
label: "软件开发",
|
||||
children: [
|
||||
{ value: "XCF008002001", label: "应用软件" },
|
||||
{ value: "XCF008002002", label: "系统软件" }
|
||||
]
|
||||
},
|
||||
{ value: "XCF008003", label: "IT服务" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "申银万国行业分类",
|
||||
label: "申银万国行业分类",
|
||||
children: [
|
||||
{
|
||||
value: "SW001",
|
||||
label: "电子",
|
||||
children: [
|
||||
{
|
||||
value: "SW001001",
|
||||
label: "半导体",
|
||||
children: [
|
||||
{ value: "SW001001001", label: "半导体材料" },
|
||||
{ value: "SW001001002", label: "半导体设备" },
|
||||
{ value: "SW001001003", label: "集成电路" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "SW001002",
|
||||
label: "电子制造",
|
||||
children: [
|
||||
{ value: "SW001002001", label: "PCB" },
|
||||
{ value: "SW001002002", label: "被动元件" }
|
||||
]
|
||||
},
|
||||
{ value: "SW001003", label: "光学光电子" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "SW002",
|
||||
label: "计算机",
|
||||
children: [
|
||||
{ value: "SW002001", label: "计算机设备" },
|
||||
{ value: "SW002002", label: "计算机应用" },
|
||||
{ value: "SW002003", label: "通信设备" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "SW003",
|
||||
label: "传媒",
|
||||
children: [
|
||||
{ value: "SW003001", label: "互联网传媒" },
|
||||
{ value: "SW003002", label: "营销传播" },
|
||||
{ value: "SW003003", label: "文化传媒" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "SW004",
|
||||
label: "医药生物",
|
||||
children: [
|
||||
{ value: "SW004001", label: "化学制药" },
|
||||
{ value: "SW004002", label: "中药" },
|
||||
{ value: "SW004003", label: "生物制品" },
|
||||
{ value: "SW004004", label: "医疗器械" },
|
||||
{ value: "SW004005", label: "医药商业" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "SW005",
|
||||
label: "汽车",
|
||||
children: [
|
||||
{
|
||||
value: "SW005001",
|
||||
label: "乘用车",
|
||||
children: [
|
||||
{ value: "SW005001001", label: "燃油车" },
|
||||
{ value: "SW005001002", label: "新能源车" }
|
||||
]
|
||||
},
|
||||
{ value: "SW005002", label: "商用车" },
|
||||
{ value: "SW005003", label: "汽车零部件" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "SW006",
|
||||
label: "机械设备",
|
||||
children: [
|
||||
{ value: "SW006001", label: "通用设备" },
|
||||
{ value: "SW006002", label: "专用设备" },
|
||||
{ value: "SW006003", label: "仪器仪表" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "SW007",
|
||||
label: "食品饮料",
|
||||
children: [
|
||||
{ value: "SW007001", label: "白酒" },
|
||||
{ value: "SW007002", label: "啤酒" },
|
||||
{ value: "SW007003", label: "软饮料" },
|
||||
{ value: "SW007004", label: "食品加工" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "SW008",
|
||||
label: "银行",
|
||||
children: [
|
||||
{ value: "SW008001", label: "国有银行" },
|
||||
{ value: "SW008002", label: "股份制银行" },
|
||||
{ value: "SW008003", label: "城商行" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "SW009",
|
||||
label: "非银金融",
|
||||
children: [
|
||||
{ value: "SW009001", label: "证券" },
|
||||
{ value: "SW009002", label: "保险" },
|
||||
{ value: "SW009003", label: "多元金融" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "SW010",
|
||||
label: "房地产",
|
||||
children: [
|
||||
{ value: "SW010001", label: "房地产开发" },
|
||||
{ value: "SW010002", label: "房地产服务" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "证监会行业分类(2001)",
|
||||
label: "证监会行业分类(2001)",
|
||||
children: [
|
||||
{
|
||||
value: "CSRC_A",
|
||||
label: "A 农、林、牧、渔业",
|
||||
children: [
|
||||
{ value: "CSRC_A01", label: "A01 农业" },
|
||||
{ value: "CSRC_A02", label: "A02 林业" },
|
||||
{ value: "CSRC_A03", label: "A03 畜牧业" },
|
||||
{ value: "CSRC_A04", label: "A04 渔业" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "CSRC_B",
|
||||
label: "B 采矿业",
|
||||
children: [
|
||||
{ value: "CSRC_B06", label: "B06 煤炭开采和洗选业" },
|
||||
{ value: "CSRC_B07", label: "B07 石油和天然气开采业" },
|
||||
{ value: "CSRC_B08", label: "B08 黑色金属矿采选业" },
|
||||
{ value: "CSRC_B09", label: "B09 有色金属矿采选业" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "CSRC_C",
|
||||
label: "C 制造业",
|
||||
children: [
|
||||
{
|
||||
value: "CSRC_C13",
|
||||
label: "C13 农副食品加工业",
|
||||
children: [
|
||||
{ value: "CSRC_C1310", label: "C1310 肉制品加工" },
|
||||
{ value: "CSRC_C1320", label: "C1320 水产品加工" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "CSRC_C27",
|
||||
label: "C27 医药制造业",
|
||||
children: [
|
||||
{ value: "CSRC_C2710", label: "C2710 化学药品原料药制造" },
|
||||
{ value: "CSRC_C2720", label: "C2720 化学药品制剂制造" },
|
||||
{ value: "CSRC_C2730", label: "C2730 中药饮片加工" }
|
||||
]
|
||||
},
|
||||
{ value: "CSRC_C35", label: "C35 专用设备制造业" },
|
||||
{ value: "CSRC_C39", label: "C39 计算机、通信和其他电子设备制造业" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "CSRC_I",
|
||||
label: "I 信息传输、软件和信息技术服务业",
|
||||
children: [
|
||||
{ value: "CSRC_I63", label: "I63 电信、广播电视和卫星传输服务" },
|
||||
{ value: "CSRC_I64", label: "I64 互联网和相关服务" },
|
||||
{ value: "CSRC_I65", label: "I65 软件和信息技术服务业" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "CSRC_J",
|
||||
label: "J 金融业",
|
||||
children: [
|
||||
{ value: "CSRC_J66", label: "J66 货币金融服务" },
|
||||
{ value: "CSRC_J67", label: "J67 资本市场服务" },
|
||||
{ value: "CSRC_J68", label: "J68 保险业" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "CSRC_K",
|
||||
label: "K 房地产业",
|
||||
children: [
|
||||
{ value: "CSRC_K70", label: "K70 房地产业" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "中银国际行业分类",
|
||||
label: "中银国际行业分类",
|
||||
children: [
|
||||
{
|
||||
value: "BOC001",
|
||||
label: "能源",
|
||||
children: [
|
||||
{ value: "BOC001001", label: "石油天然气" },
|
||||
{ value: "BOC001002", label: "煤炭" },
|
||||
{ value: "BOC001003", label: "新能源" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "BOC002",
|
||||
label: "原材料",
|
||||
children: [
|
||||
{ value: "BOC002001", label: "化工" },
|
||||
{ value: "BOC002002", label: "钢铁" },
|
||||
{ value: "BOC002003", label: "有色金属" },
|
||||
{ value: "BOC002004", label: "建材" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "BOC003",
|
||||
label: "工业",
|
||||
children: [
|
||||
{ value: "BOC003001", label: "机械" },
|
||||
{ value: "BOC003002", label: "电气设备" },
|
||||
{ value: "BOC003003", label: "国防军工" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "BOC004",
|
||||
label: "消费",
|
||||
children: [
|
||||
{
|
||||
value: "BOC004001",
|
||||
label: "可选消费",
|
||||
children: [
|
||||
{ value: "BOC004001001", label: "汽车" },
|
||||
{ value: "BOC004001002", label: "家电" },
|
||||
{ value: "BOC004001003", label: "纺织服装" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "BOC004002",
|
||||
label: "必需消费",
|
||||
children: [
|
||||
{ value: "BOC004002001", label: "食品饮料" },
|
||||
{ value: "BOC004002002", label: "农林牧渔" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "BOC005",
|
||||
label: "医疗保健",
|
||||
children: [
|
||||
{ value: "BOC005001", label: "医药" },
|
||||
{ value: "BOC005002", label: "医疗器械" },
|
||||
{ value: "BOC005003", label: "医疗服务" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "BOC006",
|
||||
label: "金融",
|
||||
children: [
|
||||
{ value: "BOC006001", label: "银行" },
|
||||
{ value: "BOC006002", label: "非银金融" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "BOC007",
|
||||
label: "科技",
|
||||
children: [
|
||||
{
|
||||
value: "BOC007001",
|
||||
label: "信息技术",
|
||||
children: [
|
||||
{ value: "BOC007001001", label: "半导体" },
|
||||
{ value: "BOC007001002", label: "电子" },
|
||||
{ value: "BOC007001003", label: "计算机" },
|
||||
{ value: "BOC007001004", label: "通信" }
|
||||
]
|
||||
},
|
||||
{ value: "BOC007002", label: "传媒" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "巨潮行业分类",
|
||||
label: "巨潮行业分类",
|
||||
children: [
|
||||
{
|
||||
value: "JC01",
|
||||
label: "制造业",
|
||||
children: [
|
||||
{
|
||||
value: "JC0101",
|
||||
label: "电气机械及器材制造业",
|
||||
children: [
|
||||
{ value: "JC010101", label: "电机制造" },
|
||||
{ value: "JC010102", label: "输配电及控制设备制造" },
|
||||
{ value: "JC010103", label: "电池制造" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "JC0102",
|
||||
label: "医药制造业",
|
||||
children: [
|
||||
{ value: "JC010201", label: "化学药品原药制造" },
|
||||
{ value: "JC010202", label: "化学药品制剂制造" },
|
||||
{ value: "JC010203", label: "中成药制造" },
|
||||
{ value: "JC010204", label: "生物、生化制品制造" }
|
||||
]
|
||||
},
|
||||
{ value: "JC0103", label: "食品制造业" },
|
||||
{ value: "JC0104", label: "纺织业" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "JC02",
|
||||
label: "信息传输、软件和信息技术服务业",
|
||||
children: [
|
||||
{ value: "JC0201", label: "互联网和相关服务" },
|
||||
{ value: "JC0202", label: "软件和信息技术服务业" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "JC03",
|
||||
label: "批发和零售业",
|
||||
children: [
|
||||
{ value: "JC0301", label: "批发业" },
|
||||
{ value: "JC0302", label: "零售业" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "JC04",
|
||||
label: "房地产业",
|
||||
children: [
|
||||
{ value: "JC0401", label: "房地产开发经营" },
|
||||
{ value: "JC0402", label: "物业管理" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "JC05",
|
||||
label: "金融业",
|
||||
children: [
|
||||
{ value: "JC0501", label: "货币金融服务" },
|
||||
{ value: "JC0502", label: "资本市场服务" },
|
||||
{ value: "JC0503", label: "保险业" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "JC06",
|
||||
label: "交通运输、仓储和邮政业",
|
||||
children: [
|
||||
{ value: "JC0601", label: "道路运输业" },
|
||||
{ value: "JC0602", label: "航空运输业" },
|
||||
{ value: "JC0603", label: "水上运输业" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "JC07",
|
||||
label: "采矿业",
|
||||
children: [
|
||||
{ value: "JC0701", label: "煤炭开采和洗选业" },
|
||||
{ value: "JC0702", label: "石油和天然气开采业" },
|
||||
{ value: "JC0703", label: "有色金属矿采选业" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "JC08",
|
||||
label: "农、林、牧、渔业",
|
||||
children: [
|
||||
{ value: "JC0801", label: "农业" },
|
||||
{ value: "JC0802", label: "林业" },
|
||||
{ value: "JC0803", label: "畜牧业" },
|
||||
{ value: "JC0804", label: "渔业" }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: "JC09",
|
||||
label: "建筑业",
|
||||
children: [
|
||||
{ value: "JC0901", label: "房屋建筑业" },
|
||||
{ value: "JC0902", label: "土木工程建筑业" },
|
||||
{ value: "JC0903", label: "建筑装饰和其他建筑业" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
124
src/mocks/data/kline.js
Normal file
124
src/mocks/data/kline.js
Normal file
@@ -0,0 +1,124 @@
|
||||
// src/mocks/data/kline.js
|
||||
// K线数据生成函数
|
||||
|
||||
/**
|
||||
* 生成分时数据 (timeline)
|
||||
* 用于展示当日分钟级别的价格走势
|
||||
*/
|
||||
export const generateTimelineData = (indexCode) => {
|
||||
const data = [];
|
||||
const basePrice = getBasePrice(indexCode);
|
||||
const today = new Date();
|
||||
|
||||
// 生成早盘数据 (09:30 - 11:30)
|
||||
const morningStart = new Date(today.setHours(9, 30, 0, 0));
|
||||
const morningEnd = new Date(today.setHours(11, 30, 0, 0));
|
||||
generateTimeRange(data, morningStart, morningEnd, basePrice, 'morning');
|
||||
|
||||
// 生成午盘数据 (13:00 - 15:00)
|
||||
const afternoonStart = new Date(today.setHours(13, 0, 0, 0));
|
||||
const afternoonEnd = new Date(today.setHours(15, 0, 0, 0));
|
||||
generateTimeRange(data, afternoonStart, afternoonEnd, basePrice, 'afternoon');
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成日线数据 (daily)
|
||||
* 用于获取历史收盘价等数据
|
||||
*/
|
||||
export const generateDailyData = (indexCode, days = 30) => {
|
||||
const data = [];
|
||||
const basePrice = getBasePrice(indexCode);
|
||||
const today = new Date();
|
||||
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
|
||||
// 跳过周末
|
||||
const dayOfWeek = date.getDay();
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) continue;
|
||||
|
||||
const open = basePrice * (1 + (Math.random() * 0.04 - 0.02));
|
||||
const close = open * (1 + (Math.random() * 0.03 - 0.015));
|
||||
const high = Math.max(open, close) * (1 + Math.random() * 0.015);
|
||||
const low = Math.min(open, close) * (1 - Math.random() * 0.015);
|
||||
const volume = Math.floor(Math.random() * 50000000000 + 10000000000);
|
||||
|
||||
data.push({
|
||||
date: formatDate(date),
|
||||
time: formatDate(date),
|
||||
open: parseFloat(open.toFixed(2)),
|
||||
close: parseFloat(close.toFixed(2)),
|
||||
high: parseFloat(high.toFixed(2)),
|
||||
low: parseFloat(low.toFixed(2)),
|
||||
volume: volume,
|
||||
prev_close: i === 0 ? parseFloat((basePrice * 0.99).toFixed(2)) : data[data.length - 1]?.close
|
||||
});
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成时间范围内的数据
|
||||
*/
|
||||
function generateTimeRange(data, startTime, endTime, basePrice, session) {
|
||||
const current = new Date(startTime);
|
||||
let price = basePrice;
|
||||
|
||||
// 波动趋势(早盘和午盘可能有不同的走势)
|
||||
const trend = session === 'morning' ? Math.random() * 0.02 - 0.01 : Math.random() * 0.015 - 0.005;
|
||||
|
||||
while (current <= endTime) {
|
||||
// 添加随机波动
|
||||
const volatility = (Math.random() - 0.5) * 0.005;
|
||||
price = price * (1 + trend / 120 + volatility); // 每分钟微小变化
|
||||
|
||||
const volume = Math.floor(Math.random() * 500000000 + 100000000);
|
||||
|
||||
data.push({
|
||||
time: formatTime(current),
|
||||
price: parseFloat(price.toFixed(2)),
|
||||
close: parseFloat(price.toFixed(2)),
|
||||
volume: volume,
|
||||
prev_close: basePrice
|
||||
});
|
||||
|
||||
// 增加1分钟
|
||||
current.setMinutes(current.getMinutes() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取不同指数的基准价格
|
||||
*/
|
||||
function getBasePrice(indexCode) {
|
||||
const basePrices = {
|
||||
'000001.SH': 3200, // 上证指数
|
||||
'399001.SZ': 10500, // 深证成指
|
||||
'399006.SZ': 2100 // 创业板指
|
||||
};
|
||||
|
||||
return basePrices[indexCode] || 3000;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间为 HH:mm
|
||||
*/
|
||||
function formatTime(date) {
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期为 YYYY-MM-DD
|
||||
*/
|
||||
function formatDate(date) {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
150
src/mocks/data/market.js
Normal file
150
src/mocks/data/market.js
Normal file
@@ -0,0 +1,150 @@
|
||||
// src/mocks/data/market.js
|
||||
// 市场行情相关的 Mock 数据
|
||||
|
||||
// 生成市场数据
|
||||
export const generateMarketData = (stockCode) => {
|
||||
const basePrice = 13.50; // 基准价格(平安银行约13.5元)
|
||||
|
||||
return {
|
||||
stockCode,
|
||||
|
||||
// 成交数据 - 必须包含K线所需的字段
|
||||
tradeData: {
|
||||
success: true,
|
||||
data: Array(30).fill(null).map((_, i) => {
|
||||
const open = basePrice + (Math.random() - 0.5) * 0.5;
|
||||
const close = basePrice + (Math.random() - 0.5) * 0.5;
|
||||
const high = Math.max(open, close) + Math.random() * 0.3;
|
||||
const low = Math.min(open, close) - Math.random() * 0.3;
|
||||
return {
|
||||
date: new Date(Date.now() - (29 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
open: parseFloat(open.toFixed(2)),
|
||||
close: parseFloat(close.toFixed(2)),
|
||||
high: parseFloat(high.toFixed(2)),
|
||||
low: parseFloat(low.toFixed(2)),
|
||||
volume: Math.floor(Math.random() * 500000000) + 100000000, // 1-6亿股
|
||||
amount: Math.floor(Math.random() * 7000000000) + 1300000000, // 13-80亿元
|
||||
turnover_rate: (Math.random() * 2 + 0.5).toFixed(2), // 0.5-2.5%
|
||||
change_pct: (Math.random() * 6 - 3).toFixed(2) // -3% to +3%
|
||||
};
|
||||
})
|
||||
},
|
||||
|
||||
// 资金流向 - 融资融券数据数组
|
||||
fundingData: {
|
||||
success: true,
|
||||
data: Array(30).fill(null).map((_, i) => ({
|
||||
date: new Date(Date.now() - (29 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
financing: {
|
||||
balance: Math.floor(Math.random() * 5000000000) + 10000000000, // 融资余额
|
||||
buy: Math.floor(Math.random() * 500000000) + 100000000, // 融资买入
|
||||
repay: Math.floor(Math.random() * 500000000) + 80000000 // 融资偿还
|
||||
},
|
||||
securities: {
|
||||
balance: Math.floor(Math.random() * 100000000) + 50000000, // 融券余额
|
||||
sell: Math.floor(Math.random() * 10000000) + 5000000, // 融券卖出
|
||||
repay: Math.floor(Math.random() * 10000000) + 3000000 // 融券偿还
|
||||
}
|
||||
}))
|
||||
},
|
||||
|
||||
// 大单统计 - 包含 daily_stats 数组
|
||||
bigDealData: {
|
||||
success: true,
|
||||
data: [],
|
||||
daily_stats: Array(10).fill(null).map((_, i) => ({
|
||||
date: new Date(Date.now() - (9 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
big_buy: Math.floor(Math.random() * 300000000) + 100000000,
|
||||
big_sell: Math.floor(Math.random() * 300000000) + 80000000,
|
||||
medium_buy: Math.floor(Math.random() * 200000000) + 60000000,
|
||||
medium_sell: Math.floor(Math.random() * 200000000) + 50000000,
|
||||
small_buy: Math.floor(Math.random() * 100000000) + 30000000,
|
||||
small_sell: Math.floor(Math.random() * 100000000) + 25000000
|
||||
}))
|
||||
},
|
||||
|
||||
// 异动分析 - 包含 grouped_data 数组
|
||||
unusualData: {
|
||||
success: true,
|
||||
data: [],
|
||||
grouped_data: Array(5).fill(null).map((_, i) => ({
|
||||
date: new Date(Date.now() - (4 - i) * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
events: [
|
||||
{ time: '14:35:22', type: '快速拉升', change: '+2.3%', description: '5分钟内上涨2.3%' },
|
||||
{ time: '11:28:45', type: '大单买入', amount: '5680万', description: '单笔大单买入' },
|
||||
{ time: '10:15:30', type: '量比异动', ratio: '3.2', description: '量比达到3.2倍' }
|
||||
],
|
||||
count: 3
|
||||
}))
|
||||
},
|
||||
|
||||
// 股权质押
|
||||
pledgeData: {
|
||||
success: true,
|
||||
data: {
|
||||
total_pledged: 25.6, // 质押比例%
|
||||
major_shareholders: [
|
||||
{ name: '中国平安保险集团', pledged_shares: 0, total_shares: 10168542300, pledge_ratio: 0 },
|
||||
{ name: '深圳市投资控股', pledged_shares: 50000000, total_shares: 382456100, pledge_ratio: 13.08 }
|
||||
],
|
||||
update_date: '2024-09-30'
|
||||
}
|
||||
},
|
||||
|
||||
// 市场摘要
|
||||
summaryData: {
|
||||
success: true,
|
||||
data: {
|
||||
current_price: basePrice,
|
||||
change: 0.25,
|
||||
change_pct: 1.89,
|
||||
open: 13.35,
|
||||
high: 13.68,
|
||||
low: 13.28,
|
||||
volume: 345678900,
|
||||
amount: 4678900000,
|
||||
turnover_rate: 1.78,
|
||||
pe_ratio: 4.96,
|
||||
pb_ratio: 0.72,
|
||||
total_market_cap: 262300000000,
|
||||
circulating_market_cap: 262300000000
|
||||
}
|
||||
},
|
||||
|
||||
// 涨停分析
|
||||
riseAnalysisData: {
|
||||
success: true,
|
||||
data: {
|
||||
is_limit_up: false,
|
||||
limit_up_price: basePrice * 1.10,
|
||||
current_price: basePrice,
|
||||
distance_to_limit: 8.92, // %
|
||||
consecutive_days: 0,
|
||||
reason: '',
|
||||
concept_tags: ['银行', '深圳国资', 'MSCI', '沪深300']
|
||||
}
|
||||
},
|
||||
|
||||
// 最新分时数据
|
||||
latestMinuteData: {
|
||||
success: true,
|
||||
data: Array(240).fill(null).map((_, i) => {
|
||||
const minute = 9 * 60 + 30 + i; // 从9:30开始
|
||||
const hour = Math.floor(minute / 60);
|
||||
const min = minute % 60;
|
||||
const time = `${hour.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}`;
|
||||
const randomChange = (Math.random() - 0.5) * 0.1;
|
||||
return {
|
||||
time,
|
||||
price: (basePrice + randomChange).toFixed(2),
|
||||
volume: Math.floor(Math.random() * 2000000) + 500000,
|
||||
avg_price: (basePrice + randomChange * 0.8).toFixed(2)
|
||||
};
|
||||
}),
|
||||
code: stockCode,
|
||||
name: stockCode === '000001' ? '平安银行' : '示例股票',
|
||||
trade_date: new Date().toISOString().split('T')[0],
|
||||
type: 'minute'
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -136,37 +136,46 @@ export const authHandlers = [
|
||||
});
|
||||
|
||||
// 模拟微信授权 URL(实际是微信的 URL)
|
||||
const authUrl = `https://open.weixin.qq.com/connect/qrconnect?appid=mock&redirect_uri=&response_type=code&scope=snsapi_login&state=${sessionId}#wechat_redirect`;
|
||||
// 使用真实的微信 AppID 和真实的授权回调地址(必须与微信开放平台配置的域名一致)
|
||||
const mockRedirectUri = encodeURIComponent('http://valuefrontier.cn/api/auth/wechat/callback');
|
||||
const authUrl = `https://open.weixin.qq.com/connect/qrconnect?appid=wxa8d74c47041b5f87&redirect_uri=${mockRedirectUri}&response_type=code&scope=snsapi_login&state=${sessionId}#wechat_redirect`;
|
||||
|
||||
console.log('[Mock] 生成微信二维码:', { sessionId, authUrl });
|
||||
|
||||
// 10秒后自动模拟扫码(方便测试)
|
||||
// 3秒后自动模拟扫码(方便测试,已缩短延迟)
|
||||
setTimeout(() => {
|
||||
const session = mockWechatSessions.get(sessionId);
|
||||
if (session && session.status === 'waiting') {
|
||||
session.status = 'scanned';
|
||||
console.log(`[Mock] 模拟用户扫码: ${sessionId}`);
|
||||
|
||||
// 再过5秒自动确认登录
|
||||
// 再过5秒自动确认登录(延长时间让用户看到 scanned 状态)
|
||||
setTimeout(() => {
|
||||
const session2 = mockWechatSessions.get(sessionId);
|
||||
if (session2 && session2.status === 'scanned') {
|
||||
session2.status = 'confirmed';
|
||||
session2.status = 'authorized'; // ✅ 使用 'authorized' 状态,与后端保持一致
|
||||
session2.user = {
|
||||
id: 999,
|
||||
nickname: '微信用户',
|
||||
wechat_openid: 'mock_openid_' + sessionId,
|
||||
avatar_url: 'https://i.pravatar.cc/150?img=99',
|
||||
avatar_url: 'https://ui-avatars.com/api/?name=微信用户&size=150&background=4299e1&color=fff',
|
||||
phone: null,
|
||||
email: null,
|
||||
has_wechat: true,
|
||||
created_at: new Date().toISOString()
|
||||
created_at: new Date().toISOString(),
|
||||
// 添加默认订阅信息
|
||||
subscription_type: 'free',
|
||||
subscription_status: 'active',
|
||||
subscription_end_date: null,
|
||||
is_subscription_active: true,
|
||||
subscription_days_left: 0
|
||||
};
|
||||
session2.user_info = { user_id: session2.user.id }; // ✅ 添加 user_info 字段
|
||||
console.log(`[Mock] 模拟用户确认登录: ${sessionId}`, session2.user);
|
||||
}
|
||||
}, 5000);
|
||||
}, 2000);
|
||||
}
|
||||
}, 10000);
|
||||
}, 3000);
|
||||
|
||||
return HttpResponse.json({
|
||||
code: 0,
|
||||
@@ -179,7 +188,7 @@ export const authHandlers = [
|
||||
}),
|
||||
|
||||
// 4. 检查微信扫码状态
|
||||
http.post('/api/auth/wechat/check-status', async ({ request }) => {
|
||||
http.post('/api/auth/wechat/check', async ({ request }) => {
|
||||
await delay(200); // 轮询请求,延迟短一些
|
||||
|
||||
const body = await request.json();
|
||||
@@ -203,18 +212,16 @@ export const authHandlers = [
|
||||
|
||||
console.log('[Mock] 检查微信状态:', { session_id, status: session.status });
|
||||
|
||||
// ✅ 返回与后端真实 API 一致的扁平化数据结构
|
||||
return HttpResponse.json({
|
||||
code: 0,
|
||||
message: '成功',
|
||||
data: {
|
||||
status: session.status,
|
||||
user: session.user
|
||||
}
|
||||
status: session.status,
|
||||
user_info: session.user_info,
|
||||
expires_in: Math.floor((session.createdAt + 5 * 60 * 1000 - Date.now()) / 1000)
|
||||
});
|
||||
}),
|
||||
|
||||
// 5. 微信登录确认
|
||||
http.post('/api/auth/wechat/login', async ({ request }) => {
|
||||
http.post('/api/auth/login/wechat', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const body = await request.json();
|
||||
@@ -222,7 +229,7 @@ export const authHandlers = [
|
||||
|
||||
const session = mockWechatSessions.get(session_id);
|
||||
|
||||
if (!session || session.status !== 'confirmed') {
|
||||
if (!session || session.status !== 'authorized') { // ✅ 使用 'authorized' 状态,与前端保持一致
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '微信登录未确认或已过期'
|
||||
@@ -334,3 +341,91 @@ export const authHandlers = [
|
||||
});
|
||||
})
|
||||
];
|
||||
|
||||
// ==================== Mock 调试工具(仅开发环境) ====================
|
||||
|
||||
/**
|
||||
* 暴露全局API,方便手动触发微信扫码模拟
|
||||
* 使用方式:
|
||||
* 1. 浏览器控制台输入:window.mockWechatScan()
|
||||
* 2. 或者在组件中调用:window.mockWechatScan(sessionId)
|
||||
*/
|
||||
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
window.mockWechatScan = (sessionId) => {
|
||||
// 如果没有传入sessionId,尝试获取最新的session
|
||||
let targetSessionId = sessionId;
|
||||
|
||||
if (!targetSessionId) {
|
||||
// 获取最新创建的session
|
||||
const sessions = Array.from(mockWechatSessions.entries());
|
||||
if (sessions.length === 0) {
|
||||
console.warn('[Mock API] 没有活跃的微信session,请先获取二维码');
|
||||
return false;
|
||||
}
|
||||
// 按创建时间排序,获取最新的
|
||||
const latestSession = sessions.sort((a, b) => b[1].createdAt - a[1].createdAt)[0];
|
||||
targetSessionId = latestSession[0];
|
||||
}
|
||||
|
||||
const session = mockWechatSessions.get(targetSessionId);
|
||||
|
||||
if (!session) {
|
||||
console.error('[Mock API] Session不存在:', targetSessionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.status !== 'waiting') {
|
||||
console.warn('[Mock API] Session状态不是waiting,当前状态:', session.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 立即触发扫码
|
||||
session.status = 'scanned';
|
||||
console.log(`[Mock API] ✅ 模拟扫码成功: ${targetSessionId}`);
|
||||
|
||||
// 1秒后自动确认登录
|
||||
setTimeout(() => {
|
||||
const session2 = mockWechatSessions.get(targetSessionId);
|
||||
if (session2 && session2.status === 'scanned') {
|
||||
session2.status = 'authorized'; // ✅ 使用 'authorized' 状态,与自动扫码流程保持一致
|
||||
session2.user = {
|
||||
id: 999,
|
||||
nickname: '微信测试用户',
|
||||
wechat_openid: 'mock_openid_' + targetSessionId,
|
||||
avatar_url: 'https://ui-avatars.com/api/?name=微信测试用户&size=150&background=4299e1&color=fff',
|
||||
phone: null,
|
||||
email: null,
|
||||
has_wechat: true,
|
||||
created_at: new Date().toISOString(),
|
||||
subscription_type: 'free',
|
||||
subscription_status: 'active',
|
||||
subscription_end_date: null,
|
||||
is_subscription_active: true,
|
||||
subscription_days_left: 0
|
||||
};
|
||||
session2.user_info = { user_id: session2.user.id }; // ✅ 添加 user_info 字段
|
||||
console.log(`[Mock API] ✅ 模拟确认登录: ${targetSessionId}`, session2.user);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 暴露获取当前sessions的方法(调试用)
|
||||
window.getMockWechatSessions = () => {
|
||||
const sessions = Array.from(mockWechatSessions.entries()).map(([id, session]) => ({
|
||||
sessionId: id,
|
||||
status: session.status,
|
||||
createdAt: new Date(session.createdAt).toLocaleString(),
|
||||
hasUser: !!session.user
|
||||
}));
|
||||
console.table(sessions);
|
||||
return sessions;
|
||||
};
|
||||
|
||||
console.log('%c[Mock API] 微信登录调试工具已加载', 'color: #00D084; font-weight: bold');
|
||||
console.log('%c使用方法:', 'color: #666');
|
||||
console.log(' window.mockWechatScan() - 触发最新session的扫码');
|
||||
console.log(' window.mockWechatScan(sessionId) - 触发指定session的扫码');
|
||||
console.log(' window.getMockWechatSessions() - 查看所有活跃的sessions');
|
||||
}
|
||||
|
||||
215
src/mocks/handlers/company.js
Normal file
215
src/mocks/handlers/company.js
Normal file
@@ -0,0 +1,215 @@
|
||||
// src/mocks/handlers/company.js
|
||||
// 公司相关的 Mock Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { PINGAN_BANK_DATA, generateCompanyData } from '../data/company';
|
||||
|
||||
// 模拟延迟
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// 获取公司数据的辅助函数
|
||||
const getCompanyData = (stockCode) => {
|
||||
return stockCode === '000001' ? PINGAN_BANK_DATA : generateCompanyData(stockCode, '示例公司');
|
||||
};
|
||||
|
||||
export const companyHandlers = [
|
||||
// 1. 综合分析
|
||||
http.get('/api/company/comprehensive-analysis/:stockCode', async ({ params }) => {
|
||||
await delay(300);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.comprehensiveAnalysis
|
||||
});
|
||||
}),
|
||||
|
||||
// 2. 价值链分析
|
||||
http.get('/api/company/value-chain-analysis/:stockCode', async ({ params }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.valueChainAnalysis
|
||||
});
|
||||
}),
|
||||
|
||||
// 3. 关键因素时间线
|
||||
http.get('/api/company/key-factors-timeline/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
timeline: data.keyFactorsTimeline,
|
||||
total: data.keyFactorsTimeline.length
|
||||
}
|
||||
});
|
||||
}),
|
||||
|
||||
// 4. 基本信息
|
||||
http.get('/api/stock/:stockCode/basic-info', async ({ params }) => {
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.basicInfo
|
||||
});
|
||||
}),
|
||||
|
||||
// 5. 实际控制人
|
||||
http.get('/api/stock/:stockCode/actual-control', async ({ params }) => {
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.actualControl
|
||||
});
|
||||
}),
|
||||
|
||||
// 6. 股权集中度
|
||||
http.get('/api/stock/:stockCode/concentration', async ({ params }) => {
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.concentration
|
||||
});
|
||||
}),
|
||||
|
||||
// 7. 高管信息
|
||||
http.get('/api/stock/:stockCode/management', async ({ params, request }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
// 解析查询参数
|
||||
const url = new URL(request.url);
|
||||
const activeOnly = url.searchParams.get('active_only') === 'true';
|
||||
|
||||
let management = data.management || [];
|
||||
|
||||
// 如果需要只返回在职高管(mock 数据中默认都是在职)
|
||||
if (activeOnly) {
|
||||
management = management.filter(m => m.status !== 'resigned');
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: management // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 8. 十大流通股东
|
||||
http.get('/api/stock/:stockCode/top-circulation-shareholders', async ({ params, request }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
// 解析查询参数
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '10', 10);
|
||||
|
||||
const shareholders = (data.topCirculationShareholders || []).slice(0, limit);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: shareholders // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 9. 十大股东
|
||||
http.get('/api/stock/:stockCode/top-shareholders', async ({ params, request }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
// 解析查询参数
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '10', 10);
|
||||
|
||||
const shareholders = (data.topShareholders || []).slice(0, limit);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: shareholders // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 10. 分支机构
|
||||
http.get('/api/stock/:stockCode/branches', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.branches || [] // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 11. 公告列表
|
||||
http.get('/api/stock/:stockCode/announcements', async ({ params, request }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
// 解析查询参数
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
|
||||
const page = parseInt(url.searchParams.get('page') || '1', 10);
|
||||
const type = url.searchParams.get('type');
|
||||
|
||||
let announcements = data.announcements || [];
|
||||
|
||||
// 类型筛选
|
||||
if (type) {
|
||||
announcements = announcements.filter(a => a.type === type);
|
||||
}
|
||||
|
||||
// 分页
|
||||
const start = (page - 1) * limit;
|
||||
const end = start + limit;
|
||||
const paginatedAnnouncements = announcements.slice(start, end);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: paginatedAnnouncements // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 12. 披露时间表
|
||||
http.get('/api/stock/:stockCode/disclosure-schedule', async ({ params }) => {
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.disclosureSchedule || [] // 直接返回数组
|
||||
});
|
||||
}),
|
||||
|
||||
// 13. 盈利预测报告
|
||||
http.get('/api/stock/:stockCode/forecast-report', async ({ params }) => {
|
||||
await delay(300);
|
||||
const { stockCode } = params;
|
||||
const data = getCompanyData(stockCode);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.forecastReport || null
|
||||
});
|
||||
}),
|
||||
];
|
||||
159
src/mocks/handlers/concept.js
Normal file
159
src/mocks/handlers/concept.js
Normal file
@@ -0,0 +1,159 @@
|
||||
// src/mocks/handlers/concept.js
|
||||
// 概念相关的 Mock Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
// 模拟延迟
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// 生成热门概念数据
|
||||
const generatePopularConcepts = (size = 20) => {
|
||||
const concepts = [
|
||||
'人工智能', '新能源汽车', '半导体', '光伏', '锂电池',
|
||||
'储能', '氢能源', '风电', '特高压', '工业母机',
|
||||
'军工', '航空航天', '卫星导航', '量子科技', '数字货币',
|
||||
'云计算', '大数据', '物联网', '5G', '6G',
|
||||
'元宇宙', '虚拟现实', 'AIGC', 'ChatGPT', '算力',
|
||||
'芯片设计', '芯片制造', '半导体设备', '半导体材料', 'EDA',
|
||||
'新能源', '风光储', '充电桩', '智能电网', '特斯拉',
|
||||
'比亚迪', '宁德时代', '华为', '苹果产业链', '鸿蒙',
|
||||
'国产软件', '信创', '网络安全', '数据安全', '量子通信',
|
||||
'医疗器械', '创新药', '医美', 'CXO', '生物医药',
|
||||
'疫苗', '中药', '医疗信息化', '智慧医疗', '基因测序'
|
||||
];
|
||||
|
||||
const results = [];
|
||||
for (let i = 0; i < Math.min(size, concepts.length); i++) {
|
||||
const changePct = (Math.random() * 12 - 2).toFixed(2); // -2% 到 +10%
|
||||
const stockCount = Math.floor(Math.random() * 50) + 10; // 10-60 只股票
|
||||
|
||||
results.push({
|
||||
concept: concepts[i],
|
||||
concept_id: `CONCEPT_${1000 + i}`,
|
||||
stock_count: stockCount,
|
||||
price_info: {
|
||||
avg_change_pct: parseFloat(changePct),
|
||||
avg_price: (Math.random() * 100 + 10).toFixed(2),
|
||||
total_market_cap: (Math.random() * 1000 + 100).toFixed(2)
|
||||
},
|
||||
description: `${concepts[i]}相关概念股`,
|
||||
hot_score: Math.floor(Math.random() * 100)
|
||||
});
|
||||
}
|
||||
|
||||
// 按涨跌幅降序排序
|
||||
results.sort((a, b) => b.price_info.avg_change_pct - a.price_info.avg_change_pct);
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
// 概念相关的 Handlers
|
||||
export const conceptHandlers = [
|
||||
// 搜索概念(热门概念)
|
||||
http.post('/concept-api/search', async ({ request }) => {
|
||||
await delay(300);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { query = '', size = 20, page = 1, sort_by = 'change_pct' } = body;
|
||||
|
||||
console.log('[Mock Concept] 搜索概念:', { query, size, page, sort_by });
|
||||
|
||||
// 生成数据
|
||||
let results = generatePopularConcepts(size);
|
||||
|
||||
// 如果有查询关键词,过滤结果
|
||||
if (query) {
|
||||
results = results.filter(item =>
|
||||
item.concept.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// 根据排序字段排序
|
||||
if (sort_by === 'change_pct') {
|
||||
results.sort((a, b) => b.price_info.avg_change_pct - a.price_info.avg_change_pct);
|
||||
} else if (sort_by === 'stock_count') {
|
||||
results.sort((a, b) => b.stock_count - a.stock_count);
|
||||
} else if (sort_by === 'hot_score') {
|
||||
results.sort((a, b) => b.hot_score - a.hot_score);
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
results,
|
||||
total: results.length,
|
||||
page,
|
||||
size,
|
||||
message: '搜索成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock Concept] 搜索概念失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
results: [],
|
||||
total: 0,
|
||||
error: '搜索失败'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取单个概念详情
|
||||
http.get('/concept-api/concepts/:conceptId', async ({ params }) => {
|
||||
await delay(300);
|
||||
|
||||
const { conceptId } = params;
|
||||
console.log('[Mock Concept] 获取概念详情:', conceptId);
|
||||
|
||||
const concepts = generatePopularConcepts(50);
|
||||
const concept = concepts.find(c => c.concept_id === conceptId || c.concept === conceptId);
|
||||
|
||||
if (concept) {
|
||||
return HttpResponse.json({
|
||||
...concept,
|
||||
related_stocks: [
|
||||
{ stock_code: '600519', stock_name: '贵州茅台', change_pct: 2.34 },
|
||||
{ stock_code: '000858', stock_name: '五粮液', change_pct: 1.89 },
|
||||
{ stock_code: '000568', stock_name: '泸州老窖', change_pct: 3.12 }
|
||||
],
|
||||
news: [
|
||||
{ title: `${concept.concept}板块异动`, date: '2024-10-24', source: '财经新闻' }
|
||||
]
|
||||
});
|
||||
} else {
|
||||
return HttpResponse.json(
|
||||
{ error: '概念不存在' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取概念相关股票
|
||||
http.get('/concept-api/concepts/:conceptId/stocks', async ({ params, request }) => {
|
||||
await delay(300);
|
||||
|
||||
const { conceptId } = params;
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20');
|
||||
|
||||
console.log('[Mock Concept] 获取概念相关股票:', { conceptId, limit });
|
||||
|
||||
// 生成模拟股票数据
|
||||
const stocks = [];
|
||||
for (let i = 0; i < limit; i++) {
|
||||
stocks.push({
|
||||
stock_code: `${600000 + i}`,
|
||||
stock_name: `股票${i + 1}`,
|
||||
change_pct: (Math.random() * 10 - 2).toFixed(2),
|
||||
price: (Math.random() * 100 + 10).toFixed(2),
|
||||
market_cap: (Math.random() * 1000 + 100).toFixed(2)
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
stocks,
|
||||
total: stocks.length,
|
||||
concept_id: conceptId
|
||||
});
|
||||
})
|
||||
];
|
||||
@@ -2,13 +2,117 @@
|
||||
// 事件相关的 Mock API Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { getEventRelatedStocks } from '../data/events';
|
||||
import { getEventRelatedStocks, generateMockEvents, generateHotEvents, generatePopularKeywords } from '../data/events';
|
||||
import { getMockFutureEvents, getMockEventCountsForMonth } from '../data/account';
|
||||
|
||||
// 模拟网络延迟
|
||||
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export const eventHandlers = [
|
||||
// ==================== 事件列表相关 ====================
|
||||
|
||||
// 获取事件列表
|
||||
http.get('/api/events/', async ({ request }) => {
|
||||
await delay(500);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const params = {
|
||||
page: parseInt(url.searchParams.get('page') || '1'),
|
||||
per_page: parseInt(url.searchParams.get('per_page') || '10'),
|
||||
sort: url.searchParams.get('sort') || 'new',
|
||||
importance: url.searchParams.get('importance') || 'all',
|
||||
date_range: url.searchParams.get('date_range') || '',
|
||||
q: url.searchParams.get('q') || '',
|
||||
industry_code: url.searchParams.get('industry_code') || '',
|
||||
industry_classification: url.searchParams.get('industry_classification') || '',
|
||||
stock_code: url.searchParams.get('stock_code') || '',
|
||||
};
|
||||
|
||||
console.log('[Mock] 获取事件列表:', params);
|
||||
|
||||
try {
|
||||
const result = generateMockEvents(params);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 获取事件列表失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取事件列表失败',
|
||||
data: { events: [], pagination: {} }
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取热点事件
|
||||
http.get('/api/events/hot', async ({ request }) => {
|
||||
await delay(300);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '5');
|
||||
|
||||
console.log('[Mock] 获取热点事件, limit:', limit);
|
||||
|
||||
try {
|
||||
const hotEvents = generateHotEvents(limit);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: hotEvents,
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 获取热点事件失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取热点事件失败',
|
||||
data: []
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取热门关键词
|
||||
http.get('/api/events/keywords/popular', async ({ request }) => {
|
||||
await delay(300);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20');
|
||||
|
||||
console.log('[Mock] 获取热门关键词, limit:', limit);
|
||||
|
||||
try {
|
||||
const keywords = generatePopularKeywords(limit);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: keywords,
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 获取热门关键词失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取热门关键词失败',
|
||||
data: []
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// ==================== 事件详情相关 ====================
|
||||
|
||||
// 获取事件相关股票
|
||||
http.get('/api/events/:eventId/stocks', async ({ params }) => {
|
||||
await delay(300);
|
||||
@@ -317,7 +421,8 @@ export const eventHandlers = [
|
||||
transmission_mechanism: {
|
||||
data: [
|
||||
{
|
||||
author: "中国电子信息产业发展研究院",
|
||||
author: "李明",
|
||||
organization: "中国电子信息产业发展研究院",
|
||||
sentences: "在技术突破和应用场景快速扩张的双重驱动下,国内半导体市场呈现爆发式增长态势。据统计,2024年上半年半导体市场规模达到1.2万亿元,同比增长32%,其中新能源汽车和AI算力芯片需求贡献了超过60%的增量",
|
||||
query_part: "技术突破和需求激增推动半导体市场增长32%",
|
||||
match_score: "好",
|
||||
@@ -336,7 +441,8 @@ export const eventHandlers = [
|
||||
transmission_mechanism: {
|
||||
data: [
|
||||
{
|
||||
author: "经济观察报",
|
||||
author: "张华",
|
||||
organization: "经济观察报",
|
||||
sentences: "随着半导体等高科技产业获得大量政策和资金支持,传统制造业面临融资难、用工成本上升等多重压力。部分劳动密集型企业利润率下降15%,行业整体投资意愿降低",
|
||||
query_part: "资源向高科技倾斜导致传统制造业承压",
|
||||
match_score: "好",
|
||||
@@ -355,7 +461,8 @@ export const eventHandlers = [
|
||||
transmission_mechanism: {
|
||||
data: [
|
||||
{
|
||||
author: "能源研究所",
|
||||
author: "王刚",
|
||||
organization: "能源研究所",
|
||||
sentences: "半导体产业扩张带来电力需求增长约8%,但同时推动节能技术应用,整体能源消费结构趋于优化。新建芯片工厂虽增加用电负荷,但智能电网技术应用使能源利用效率提升12%",
|
||||
query_part: "半导体产业对能源行业影响相对中性",
|
||||
match_score: "中",
|
||||
@@ -374,7 +481,8 @@ export const eventHandlers = [
|
||||
transmission_mechanism: {
|
||||
data: [
|
||||
{
|
||||
author: "教育部职业教育司",
|
||||
author: "赵敏",
|
||||
organization: "教育部职业教育司",
|
||||
sentences: "半导体产业快速发展催生大量专业人才需求,各类培训机构、职业院校纷纷开设相关课程。预计未来三年将新增半导体专业学员超过50万人,带动职业教育市场规模扩大",
|
||||
query_part: "半导体产业推动职业教育发展",
|
||||
match_score: "好",
|
||||
@@ -409,7 +517,8 @@ export const eventHandlers = [
|
||||
transmission_mechanism: {
|
||||
data: [
|
||||
{
|
||||
author: "中国半导体行业协会",
|
||||
author: "刘洋",
|
||||
organization: "中国半导体行业协会",
|
||||
sentences: "受益于新能源汽车、5G通信等新兴应用领域的爆发式增长,国内半导体市场需求持续旺盛,2024年Q1市场规模同比增长28%,创历史新高",
|
||||
query_part: "新兴应用推动半导体需求增长28%",
|
||||
match_score: "好",
|
||||
@@ -417,7 +526,8 @@ export const eventHandlers = [
|
||||
report_title: "2024年Q1中国半导体行业景气度报告"
|
||||
},
|
||||
{
|
||||
author: "中国半导体行业协会",
|
||||
author: "刘洋",
|
||||
organization: "中国半导体行业协会",
|
||||
sentences: "受益于新能源汽车、5G通信等新兴应用领域的爆发式增长,国内半导体市场需求持续旺盛,2024年Q1市场规模同比增长28%,创历史新高",
|
||||
query_part: "新兴应用推动半导体需求增长28%",
|
||||
match_score: "好",
|
||||
@@ -458,6 +568,7 @@ export const eventHandlers = [
|
||||
data: [
|
||||
{
|
||||
author: "张明",
|
||||
organization: "中信证券",
|
||||
sentences: "在半导体行业景气度持续提升的背景下,下游芯片制造企业订单饱满,产能利用率达到历史新高,预计2024年产能扩张将达到30%以上,技术工艺也将从28nm向14nm升级",
|
||||
query_part: "半导体行业繁荣带动芯片制造产能扩张30%",
|
||||
match_score: "好",
|
||||
@@ -466,6 +577,7 @@ export const eventHandlers = [
|
||||
},
|
||||
{
|
||||
author: "李华",
|
||||
organization: "海通证券",
|
||||
sentences: "芯片制造环节作为半导体产业链核心,受益于上游材料供应稳定和下游应用需求旺盛,技术迭代速度明显加快,先进制程占比持续提升",
|
||||
query_part: "技术迭代加快,先进制程占比提升",
|
||||
match_score: "好",
|
||||
@@ -531,6 +643,7 @@ export const eventHandlers = [
|
||||
data: [
|
||||
{
|
||||
author: "王芳",
|
||||
organization: "国泰君安",
|
||||
sentences: "A公司作为国内芯片制造龙头企业,在手订单已排至2024年Q4,预计全年营收增长45%,净利润增长60%以上。公司28nm及以下先进制程产能占比已达到40%,技术实力行业领先",
|
||||
query_part: "A公司在手订单充足,预计营收增长45%",
|
||||
match_score: "好",
|
||||
@@ -550,6 +663,7 @@ export const eventHandlers = [
|
||||
data: [
|
||||
{
|
||||
author: "赵强",
|
||||
organization: "华泰证券",
|
||||
sentences: "随着芯片制造产能的大规模扩张,上游设备和材料供应商迎来历史性机遇。B公司作为核心配套企业,订单量同比增长55%,产品供不应求,预计2024年营收将突破百亿大关。公司在封装测试领域的市场份额已提升至国内第二位",
|
||||
query_part: "B公司订单增长55%,营收将破百亿",
|
||||
match_score: "好",
|
||||
@@ -557,7 +671,8 @@ export const eventHandlers = [
|
||||
report_title: "B公司跟踪报告:芯片产业链配套龙头崛起"
|
||||
},
|
||||
{
|
||||
author: "国信证券",
|
||||
author: "陈彤",
|
||||
organization: "国信证券",
|
||||
sentences: "B公司深度受益于芯片制造产业链的景气度传导。公司凭借先进的封装技术和完善的产能布局,成功绑定多家头部芯片制造企业,形成稳定的供应关系。随着下游客户产能持续扩张,公司业绩增长确定性强",
|
||||
query_part: "B公司受益产业链景气度,业绩增长确定性强",
|
||||
match_score: "好",
|
||||
@@ -633,6 +748,7 @@ export const eventHandlers = [
|
||||
data: [
|
||||
{
|
||||
author: "赵强",
|
||||
organization: "华泰证券",
|
||||
sentences: "随着芯片制造产能的大规模扩张,上游设备和材料供应商迎来历史性机遇。B公司作为核心配套企业,订单量同比增长55%,产品供不应求,预计2024年营收将突破百亿大关",
|
||||
query_part: "B公司订单增长55%,营收将破百亿",
|
||||
match_score: "好",
|
||||
|
||||
121
src/mocks/handlers/financial.js
Normal file
121
src/mocks/handlers/financial.js
Normal file
@@ -0,0 +1,121 @@
|
||||
// src/mocks/handlers/financial.js
|
||||
// 财务数据相关的 Mock Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { generateFinancialData } from '../data/financial';
|
||||
|
||||
// 模拟延迟
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export const financialHandlers = [
|
||||
// 1. 股票基本信息
|
||||
http.get('/api/financial/stock-info/:stockCode', async ({ params }) => {
|
||||
await delay(150);
|
||||
const { stockCode } = params;
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.stockInfo
|
||||
});
|
||||
}),
|
||||
|
||||
// 2. 资产负债表
|
||||
http.get('/api/financial/balance-sheet/:stockCode', async ({ params, request }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '4', 10);
|
||||
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.balanceSheet.slice(0, limit)
|
||||
});
|
||||
}),
|
||||
|
||||
// 3. 利润表
|
||||
http.get('/api/financial/income-statement/:stockCode', async ({ params, request }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '4', 10);
|
||||
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.incomeStatement.slice(0, limit)
|
||||
});
|
||||
}),
|
||||
|
||||
// 4. 现金流量表
|
||||
http.get('/api/financial/cashflow/:stockCode', async ({ params, request }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '4', 10);
|
||||
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.cashflow.slice(0, limit)
|
||||
});
|
||||
}),
|
||||
|
||||
// 5. 财务指标
|
||||
http.get('/api/financial/financial-metrics/:stockCode', async ({ params, request }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const url = new URL(request.url);
|
||||
const limit = parseInt(url.searchParams.get('limit') || '4', 10);
|
||||
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.financialMetrics.slice(0, limit)
|
||||
});
|
||||
}),
|
||||
|
||||
// 6. 主营业务
|
||||
http.get('/api/financial/main-business/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.mainBusiness
|
||||
});
|
||||
}),
|
||||
|
||||
// 7. 业绩预告
|
||||
http.get('/api/financial/forecast/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.forecast
|
||||
});
|
||||
}),
|
||||
|
||||
// 8. 行业排名
|
||||
http.get('/api/financial/industry-rank/:stockCode', async ({ params }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.industryRank
|
||||
});
|
||||
}),
|
||||
|
||||
// 9. 期间对比
|
||||
http.get('/api/financial/comparison/:stockCode', async ({ params }) => {
|
||||
await delay(250);
|
||||
const { stockCode } = params;
|
||||
const data = generateFinancialData(stockCode);
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data.periodComparison
|
||||
});
|
||||
}),
|
||||
];
|
||||
@@ -5,6 +5,13 @@ import { authHandlers } from './auth';
|
||||
import { accountHandlers } from './account';
|
||||
import { simulationHandlers } from './simulation';
|
||||
import { eventHandlers } from './event';
|
||||
import { paymentHandlers } from './payment';
|
||||
import { industryHandlers } from './industry';
|
||||
import { conceptHandlers } from './concept';
|
||||
import { stockHandlers } from './stock';
|
||||
import { companyHandlers } from './company';
|
||||
import { marketHandlers } from './market';
|
||||
import { financialHandlers } from './financial';
|
||||
|
||||
// 可以在这里添加更多的 handlers
|
||||
// import { userHandlers } from './user';
|
||||
@@ -14,5 +21,12 @@ export const handlers = [
|
||||
...accountHandlers,
|
||||
...simulationHandlers,
|
||||
...eventHandlers,
|
||||
...paymentHandlers,
|
||||
...industryHandlers,
|
||||
...conceptHandlers,
|
||||
...stockHandlers,
|
||||
...companyHandlers,
|
||||
...marketHandlers,
|
||||
...financialHandlers,
|
||||
// ...userHandlers,
|
||||
];
|
||||
|
||||
44
src/mocks/handlers/industry.js
Normal file
44
src/mocks/handlers/industry.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// src/mocks/handlers/industry.js
|
||||
// 行业分类相关的 Mock API Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { industryData } from '../../data/industryData';
|
||||
|
||||
// 模拟网络延迟
|
||||
const delay = (ms = 300) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export const industryHandlers = [
|
||||
// 获取行业分类完整树形结构
|
||||
http.get('/api/classifications', async ({ request }) => {
|
||||
await delay(500);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const classification = url.searchParams.get('classification');
|
||||
|
||||
console.log('[Mock] 获取行业分类树形数据(真实数据)', { classification });
|
||||
|
||||
try {
|
||||
let data = industryData;
|
||||
|
||||
// 如果指定了分类体系,只返回该体系的数据
|
||||
if (classification) {
|
||||
data = industryData.filter(item => item.value === classification);
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock] 获取行业分类失败:', error);
|
||||
return HttpResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: '获取行业分类失败',
|
||||
data: []
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
})
|
||||
];
|
||||
74
src/mocks/handlers/market.js
Normal file
74
src/mocks/handlers/market.js
Normal file
@@ -0,0 +1,74 @@
|
||||
// src/mocks/handlers/market.js
|
||||
// 市场行情相关的 Mock Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { generateMarketData } from '../data/market';
|
||||
|
||||
// 模拟延迟
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
export const marketHandlers = [
|
||||
// 1. 成交数据
|
||||
http.get('/api/market/trade/:stockCode', async ({ params, request }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.tradeData);
|
||||
}),
|
||||
|
||||
// 2. 资金流向
|
||||
http.get('/api/market/funding/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.fundingData);
|
||||
}),
|
||||
|
||||
// 3. 大单统计
|
||||
http.get('/api/market/bigdeal/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.bigDealData);
|
||||
}),
|
||||
|
||||
// 4. 异动分析
|
||||
http.get('/api/market/unusual/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.unusualData);
|
||||
}),
|
||||
|
||||
// 5. 股权质押
|
||||
http.get('/api/market/pledge/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.pledgeData);
|
||||
}),
|
||||
|
||||
// 6. 市场摘要
|
||||
http.get('/api/market/summary/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.summaryData);
|
||||
}),
|
||||
|
||||
// 7. 涨停分析
|
||||
http.get('/api/market/rise-analysis/:stockCode', async ({ params }) => {
|
||||
await delay(200);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.riseAnalysisData);
|
||||
}),
|
||||
|
||||
// 8. 最新分时数据
|
||||
http.get('/api/stock/:stockCode/latest-minute', async ({ params }) => {
|
||||
await delay(300);
|
||||
const { stockCode } = params;
|
||||
const data = generateMarketData(stockCode);
|
||||
return HttpResponse.json(data.latestMinuteData);
|
||||
}),
|
||||
];
|
||||
233
src/mocks/handlers/payment.js
Normal file
233
src/mocks/handlers/payment.js
Normal file
@@ -0,0 +1,233 @@
|
||||
// src/mocks/handlers/payment.js
|
||||
import { http, HttpResponse, delay } from 'msw';
|
||||
import { getCurrentUser } from '../data/users';
|
||||
|
||||
// 模拟网络延迟(毫秒)
|
||||
const NETWORK_DELAY = 500;
|
||||
|
||||
// 模拟订单数据存储
|
||||
const mockOrders = new Map();
|
||||
let orderIdCounter = 1000;
|
||||
|
||||
export const paymentHandlers = [
|
||||
// ==================== 支付订单管理 ====================
|
||||
|
||||
// 1. 创建支付订单
|
||||
http.post('/api/payment/create-order', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '未登录'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { plan_name, billing_cycle } = body;
|
||||
|
||||
console.log('[Mock] 创建支付订单:', { plan_name, billing_cycle, user: currentUser.id });
|
||||
|
||||
if (!plan_name || !billing_cycle) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '参数不完整'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// 模拟价格
|
||||
const prices = {
|
||||
pro: { monthly: 0.01, yearly: 0.08 },
|
||||
max: { monthly: 0.1, yearly: 0.8 }
|
||||
};
|
||||
|
||||
const amount = prices[plan_name]?.[billing_cycle] || 0.01;
|
||||
|
||||
// 创建订单
|
||||
const orderId = `ORDER_${orderIdCounter++}_${Date.now()}`;
|
||||
const order = {
|
||||
id: orderId,
|
||||
user_id: currentUser.id,
|
||||
plan_name,
|
||||
billing_cycle,
|
||||
amount,
|
||||
status: 'pending',
|
||||
qr_code_url: `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=weixin://wxpay/bizpayurl?pr=mock_${orderId}`,
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: new Date(Date.now() + 30 * 60 * 1000).toISOString() // 30分钟后过期
|
||||
};
|
||||
|
||||
mockOrders.set(orderId, order);
|
||||
|
||||
console.log('[Mock] 订单创建成功:', order);
|
||||
|
||||
// 模拟5秒后自动支付成功(方便测试)
|
||||
setTimeout(() => {
|
||||
const existingOrder = mockOrders.get(orderId);
|
||||
if (existingOrder && existingOrder.status === 'pending') {
|
||||
existingOrder.status = 'paid';
|
||||
existingOrder.paid_at = new Date().toISOString();
|
||||
console.log(`[Mock] 订单自动支付成功: ${orderId}`);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: order
|
||||
});
|
||||
}),
|
||||
|
||||
// 2. 查询订单状态
|
||||
http.get('/api/payment/order-status/:orderId', async ({ params }) => {
|
||||
await delay(300);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '未登录'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const { orderId } = params;
|
||||
const order = mockOrders.get(orderId);
|
||||
|
||||
console.log('[Mock] 查询订单状态:', { orderId, found: !!order });
|
||||
|
||||
if (!order) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '订单不存在'
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
if (order.user_id !== currentUser.id) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '无权访问此订单'
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: order
|
||||
});
|
||||
}),
|
||||
|
||||
// 3. 获取用户订单列表
|
||||
http.get('/api/payment/orders', async () => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '未登录'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const userOrders = Array.from(mockOrders.values())
|
||||
.filter(order => order.user_id === currentUser.id)
|
||||
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
|
||||
console.log('[Mock] 获取用户订单列表:', { count: userOrders.length });
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: userOrders
|
||||
});
|
||||
}),
|
||||
|
||||
// 4. 取消订单
|
||||
http.post('/api/payment/cancel-order', async ({ request }) => {
|
||||
await delay(NETWORK_DELAY);
|
||||
|
||||
const currentUser = getCurrentUser();
|
||||
if (!currentUser) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '未登录'
|
||||
}, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { order_id } = body;
|
||||
|
||||
const order = mockOrders.get(order_id);
|
||||
|
||||
if (!order) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '订单不存在'
|
||||
}, { status: 404 });
|
||||
}
|
||||
|
||||
if (order.user_id !== currentUser.id) {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '无权操作此订单'
|
||||
}, { status: 403 });
|
||||
}
|
||||
|
||||
if (order.status !== 'pending') {
|
||||
return HttpResponse.json({
|
||||
success: false,
|
||||
error: '只能取消待支付的订单'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
order.status = 'cancelled';
|
||||
order.cancelled_at = new Date().toISOString();
|
||||
|
||||
console.log('[Mock] 订单已取消:', order_id);
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: '订单已取消'
|
||||
});
|
||||
})
|
||||
];
|
||||
|
||||
// ==================== Mock 调试工具(仅开发环境) ====================
|
||||
|
||||
/**
|
||||
* 暴露全局API,方便手动触发支付成功
|
||||
* 使用方式:window.mockPaymentSuccess(orderId)
|
||||
*/
|
||||
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK === 'true') {
|
||||
window.mockPaymentSuccess = (orderId) => {
|
||||
const order = mockOrders.get(orderId);
|
||||
if (!order) {
|
||||
console.error('[Mock Payment] 订单不存在:', orderId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (order.status !== 'pending') {
|
||||
console.warn('[Mock Payment] 订单状态不是待支付:', order.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
order.status = 'paid';
|
||||
order.paid_at = new Date().toISOString();
|
||||
console.log('[Mock Payment] ✅ 支付成功:', orderId);
|
||||
return true;
|
||||
};
|
||||
|
||||
window.getMockOrders = () => {
|
||||
const orders = Array.from(mockOrders.entries()).map(([id, order]) => ({
|
||||
orderId: id,
|
||||
status: order.status,
|
||||
amount: order.amount,
|
||||
plan: `${order.plan_name} - ${order.billing_cycle}`,
|
||||
createdAt: new Date(order.created_at).toLocaleString()
|
||||
}));
|
||||
console.table(orders);
|
||||
return orders;
|
||||
};
|
||||
|
||||
console.log('%c[Mock Payment] 支付调试工具已加载', 'color: #00D084; font-weight: bold');
|
||||
console.log('%c使用方法:', 'color: #666');
|
||||
console.log(' window.mockPaymentSuccess(orderId) - 手动触发订单支付成功');
|
||||
console.log(' window.getMockOrders() - 查看所有模拟订单');
|
||||
}
|
||||
227
src/mocks/handlers/stock.js
Normal file
227
src/mocks/handlers/stock.js
Normal file
@@ -0,0 +1,227 @@
|
||||
// src/mocks/handlers/stock.js
|
||||
// 股票相关的 Mock Handlers
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { generateTimelineData, generateDailyData } from '../data/kline';
|
||||
|
||||
// 模拟延迟
|
||||
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// 生成A股主要股票数据(包含各大指数成分股)
|
||||
const generateStockList = () => {
|
||||
const stocks = [
|
||||
// 银行
|
||||
{ code: '000001', name: '平安银行' },
|
||||
{ code: '600000', name: '浦发银行' },
|
||||
{ code: '600036', name: '招商银行' },
|
||||
{ code: '601166', name: '兴业银行' },
|
||||
{ code: '601169', name: '北京银行' },
|
||||
{ code: '601288', name: '农业银行' },
|
||||
{ code: '601328', name: '交通银行' },
|
||||
{ code: '601398', name: '工商银行' },
|
||||
{ code: '601818', name: '光大银行' },
|
||||
{ code: '601939', name: '建设银行' },
|
||||
{ code: '601998', name: '中信银行' },
|
||||
|
||||
// 证券
|
||||
{ code: '600030', name: '中信证券' },
|
||||
{ code: '600109', name: '国金证券' },
|
||||
{ code: '600837', name: '海通证券' },
|
||||
{ code: '600999', name: '招商证券' },
|
||||
{ code: '601688', name: '华泰证券' },
|
||||
{ code: '601901', name: '方正证券' },
|
||||
|
||||
// 保险
|
||||
{ code: '601318', name: '中国平安' },
|
||||
{ code: '601336', name: '新华保险' },
|
||||
{ code: '601601', name: '中国太保' },
|
||||
{ code: '601628', name: '中国人寿' },
|
||||
|
||||
// 白酒/食品饮料
|
||||
{ code: '000568', name: '泸州老窖' },
|
||||
{ code: '000596', name: '古井贡酒' },
|
||||
{ code: '000858', name: '五粮液' },
|
||||
{ code: '600519', name: '贵州茅台' },
|
||||
{ code: '600600', name: '青岛啤酒' },
|
||||
{ code: '600779', name: '水井坊' },
|
||||
{ code: '603369', name: '今世缘' },
|
||||
|
||||
// 医药
|
||||
{ code: '000538', name: '云南白药' },
|
||||
{ code: '000661', name: '长春高新' },
|
||||
{ code: '002422', name: '科伦药业' },
|
||||
{ code: '002594', name: '比亚迪' },
|
||||
{ code: '600276', name: '恒瑞医药' },
|
||||
{ code: '600436', name: '片仔癀' },
|
||||
{ code: '603259', name: '药明康德' },
|
||||
|
||||
// 科技/半导体
|
||||
{ code: '000063', name: '中兴通讯' },
|
||||
{ code: '000725', name: '京东方A' },
|
||||
{ code: '002049', name: '紫光国微' },
|
||||
{ code: '002415', name: '海康威视' },
|
||||
{ code: '002475', name: '立讯精密' },
|
||||
{ code: '600584', name: '长电科技' },
|
||||
{ code: '600893', name: '航发动力' },
|
||||
{ code: '603501', name: '韦尔股份' },
|
||||
|
||||
// 新能源/电力
|
||||
{ code: '000002', name: '万科A' },
|
||||
{ code: '002460', name: '赣锋锂业' },
|
||||
{ code: '300750', name: '宁德时代' },
|
||||
{ code: '600438', name: '通威股份' },
|
||||
{ code: '601012', name: '隆基绿能' },
|
||||
{ code: '601668', name: '中国建筑' },
|
||||
|
||||
// 汽车
|
||||
{ code: '000625', name: '长安汽车' },
|
||||
{ code: '600066', name: '宇通客车' },
|
||||
{ code: '600104', name: '上汽集团' },
|
||||
{ code: '601238', name: '广汽集团' },
|
||||
{ code: '601633', name: '长城汽车' },
|
||||
|
||||
// 地产
|
||||
{ code: '000002', name: '万科A' },
|
||||
{ code: '000069', name: '华侨城A' },
|
||||
{ code: '600340', name: '华夏幸福' },
|
||||
{ code: '600606', name: '绿地控股' },
|
||||
|
||||
// 家电
|
||||
{ code: '000333', name: '美的集团' },
|
||||
{ code: '000651', name: '格力电器' },
|
||||
{ code: '002032', name: '苏泊尔' },
|
||||
{ code: '600690', name: '海尔智家' },
|
||||
|
||||
// 互联网/电商
|
||||
{ code: '002024', name: '苏宁易购' },
|
||||
{ code: '002074', name: '国轩高科' },
|
||||
{ code: '300059', name: '东方财富' },
|
||||
|
||||
// 能源/化工
|
||||
{ code: '600028', name: '中国石化' },
|
||||
{ code: '600309', name: '万华化学' },
|
||||
{ code: '600547', name: '山东黄金' },
|
||||
{ code: '600585', name: '海螺水泥' },
|
||||
{ code: '601088', name: '中国神华' },
|
||||
{ code: '601857', name: '中国石油' },
|
||||
|
||||
// 电信/运营商
|
||||
{ code: '600050', name: '中国联通' },
|
||||
{ code: '600941', name: '中国移动' },
|
||||
{ code: '601728', name: '中国电信' },
|
||||
|
||||
// 其他蓝筹
|
||||
{ code: '600887', name: '伊利股份' },
|
||||
{ code: '601111', name: '中国国航' },
|
||||
{ code: '601390', name: '中国中铁' },
|
||||
{ code: '601899', name: '紫金矿业' },
|
||||
{ code: '603288', name: '海天味业' },
|
||||
];
|
||||
|
||||
return stocks;
|
||||
};
|
||||
|
||||
// 股票相关的 Handlers
|
||||
export const stockHandlers = [
|
||||
// 获取所有股票列表
|
||||
http.get('/api/stocklist', async () => {
|
||||
await delay(200);
|
||||
|
||||
try {
|
||||
const stocks = generateStockList();
|
||||
|
||||
console.log('[Mock Stock] 获取股票列表成功:', { count: stocks.length });
|
||||
|
||||
return HttpResponse.json(stocks);
|
||||
} catch (error) {
|
||||
console.error('[Mock Stock] 获取股票列表失败:', error);
|
||||
return HttpResponse.json(
|
||||
{ error: '获取股票列表失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取指数K线数据
|
||||
http.get('/api/index/:indexCode/kline', async ({ params, request }) => {
|
||||
await delay(300);
|
||||
|
||||
const { indexCode } = params;
|
||||
const url = new URL(request.url);
|
||||
const type = url.searchParams.get('type') || 'timeline';
|
||||
const eventTime = url.searchParams.get('event_time');
|
||||
|
||||
console.log('[Mock Stock] 获取指数K线数据:', { indexCode, type, eventTime });
|
||||
|
||||
try {
|
||||
let data;
|
||||
|
||||
if (type === 'timeline') {
|
||||
data = generateTimelineData(indexCode);
|
||||
} else if (type === 'daily') {
|
||||
data = generateDailyData(indexCode, 30);
|
||||
} else {
|
||||
return HttpResponse.json(
|
||||
{ error: '不支持的类型' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data,
|
||||
index_code: indexCode,
|
||||
type: type,
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock Stock] 获取K线数据失败:', error);
|
||||
return HttpResponse.json(
|
||||
{ error: '获取K线数据失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
|
||||
// 获取股票K线数据
|
||||
http.get('/api/stock/:stockCode/kline', async ({ params, request }) => {
|
||||
await delay(300);
|
||||
|
||||
const { stockCode } = params;
|
||||
const url = new URL(request.url);
|
||||
const type = url.searchParams.get('type') || 'timeline';
|
||||
const eventTime = url.searchParams.get('event_time');
|
||||
|
||||
console.log('[Mock Stock] 获取股票K线数据:', { stockCode, type, eventTime });
|
||||
|
||||
try {
|
||||
let data;
|
||||
|
||||
if (type === 'timeline') {
|
||||
// 股票使用指数的数据生成逻辑,但价格基数不同
|
||||
data = generateTimelineData('000001.SH'); // 可以根据股票代码调整
|
||||
} else if (type === 'daily') {
|
||||
data = generateDailyData('000001.SH', 30);
|
||||
} else {
|
||||
return HttpResponse.json(
|
||||
{ error: '不支持的类型' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: data,
|
||||
stock_code: stockCode,
|
||||
type: type,
|
||||
message: '获取成功'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Mock Stock] 获取股票K线数据失败:', error);
|
||||
return HttpResponse.json(
|
||||
{ error: '获取K线数据失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}),
|
||||
];
|
||||
@@ -144,19 +144,23 @@ export const WECHAT_STATUS = {
|
||||
WAITING: 'waiting',
|
||||
SCANNED: 'scanned',
|
||||
AUTHORIZED: 'authorized',
|
||||
LOGIN_SUCCESS: 'login_success',
|
||||
REGISTER_SUCCESS: 'register_success',
|
||||
LOGIN_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized'
|
||||
REGISTER_SUCCESS: 'authorized', // ✅ 与后端保持一致,统一使用 'authorized'
|
||||
EXPIRED: 'expired',
|
||||
AUTH_DENIED: 'auth_denied', // 用户拒绝授权
|
||||
AUTH_FAILED: 'auth_failed', // 授权失败
|
||||
};
|
||||
|
||||
/**
|
||||
* 状态提示信息映射
|
||||
*/
|
||||
export const STATUS_MESSAGES = {
|
||||
[WECHAT_STATUS.WAITING]: '请使用微信扫码',
|
||||
[WECHAT_STATUS.WAITING]: '使用微信扫一扫登陆',
|
||||
[WECHAT_STATUS.SCANNED]: '扫码成功,请在手机上确认',
|
||||
[WECHAT_STATUS.AUTHORIZED]: '授权成功,正在登录...',
|
||||
[WECHAT_STATUS.EXPIRED]: '二维码已过期',
|
||||
[WECHAT_STATUS.AUTH_DENIED]: '用户取消授权',
|
||||
[WECHAT_STATUS.AUTH_FAILED]: '授权失败,请重试',
|
||||
};
|
||||
|
||||
export default authService;
|
||||
|
||||
@@ -5,25 +5,25 @@ import axios from 'axios';
|
||||
// 判断当前是否是生产环境
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
|
||||
const API_BASE_URL = getApiBase();
|
||||
|
||||
// 配置 axios 默认包含 credentials
|
||||
axios.defaults.withCredentials = true;
|
||||
|
||||
export const industryService = {
|
||||
// 获取所有行业分类体系
|
||||
async getClassifications() {
|
||||
const res = await axios.get(`${API_BASE_URL}/api/classifications`);
|
||||
return res.data;
|
||||
},
|
||||
// 获取指定体系下的多级行业
|
||||
async getLevels({ classification, level = 1, level1_name, level2_name, level3_name }) {
|
||||
let url = `${API_BASE_URL}/api/levels?classification=${encodeURIComponent(classification)}&level=${level}`;
|
||||
if (level1_name) url += `&level1_name=${encodeURIComponent(level1_name)}`;
|
||||
if (level2_name) url += `&level2_name=${encodeURIComponent(level2_name)}`;
|
||||
if (level3_name) url += `&level3_name=${encodeURIComponent(level3_name)}`;
|
||||
/**
|
||||
* 获取行业分类完整树形结构
|
||||
* @param {string} classification - 可选,指定分类体系名称,不传则返回所有
|
||||
* @returns {Promise} 返回树形结构数据
|
||||
*/
|
||||
async getClassifications(classification) {
|
||||
let url = `${API_BASE_URL}/api/classifications`;
|
||||
if (classification) {
|
||||
url += `?classification=${encodeURIComponent(classification)}`;
|
||||
}
|
||||
const res = await axios.get(url);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
// 注意:getLevels 接口已废弃,使用 getClassifications 替代
|
||||
};
|
||||
@@ -303,6 +303,7 @@ const mockFinancialNews = [
|
||||
class MockSocketService {
|
||||
constructor() {
|
||||
this.connected = false;
|
||||
this.connecting = false; // 新增:正在连接标志,防止重复连接
|
||||
this.listeners = new Map();
|
||||
this.intervals = [];
|
||||
this.messageQueue = [];
|
||||
@@ -325,18 +326,30 @@ class MockSocketService {
|
||||
* 连接到 mock socket
|
||||
*/
|
||||
connect() {
|
||||
// ✅ 防止重复连接
|
||||
if (this.connected) {
|
||||
logger.warn('mockSocketService', 'Already connected');
|
||||
console.log('%c[Mock Socket] Already connected, skipping', 'color: #FF9800; font-weight: bold;');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.connecting) {
|
||||
logger.warn('mockSocketService', 'Connection in progress');
|
||||
console.log('%c[Mock Socket] Connection already in progress, skipping', 'color: #FF9800; font-weight: bold;');
|
||||
return;
|
||||
}
|
||||
|
||||
this.connecting = true; // 标记为连接中
|
||||
logger.info('mockSocketService', 'Connecting to mock socket service...');
|
||||
console.log('%c[Mock Socket] 🔌 Connecting...', 'color: #2196F3; font-weight: bold;');
|
||||
|
||||
// 模拟连接延迟
|
||||
setTimeout(() => {
|
||||
// 检查是否应该模拟连接失败
|
||||
if (this.failConnection) {
|
||||
this.connecting = false; // 清除连接中标志
|
||||
logger.warn('mockSocketService', 'Simulated connection failure');
|
||||
console.log('%c[Mock Socket] ❌ Connection failed (simulated)', 'color: #F44336; font-weight: bold;');
|
||||
|
||||
// 触发连接错误事件
|
||||
this.emit('connect_error', {
|
||||
@@ -351,6 +364,7 @@ class MockSocketService {
|
||||
|
||||
// 正常连接成功
|
||||
this.connected = true;
|
||||
this.connecting = false; // 清除连接中标志
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// 清除自定义重连定时器
|
||||
@@ -360,9 +374,15 @@ class MockSocketService {
|
||||
}
|
||||
|
||||
logger.info('mockSocketService', 'Mock socket connected successfully');
|
||||
console.log('%c[Mock Socket] ✅ Connected successfully!', 'color: #4CAF50; font-weight: bold; font-size: 14px;');
|
||||
console.log(`%c[Mock Socket] Status: connected=${this.connected}, connecting=${this.connecting}`, 'color: #4CAF50;');
|
||||
|
||||
// 触发连接成功事件
|
||||
this.emit('connect', { timestamp: Date.now() });
|
||||
// ✅ 使用 setTimeout(0) 确保监听器已注册后再触发事件
|
||||
setTimeout(() => {
|
||||
console.log('%c[Mock Socket] Emitting connect event...', 'color: #9C27B0;');
|
||||
this.emit('connect', { timestamp: Date.now() });
|
||||
console.log('%c[Mock Socket] Connect event emitted', 'color: #9C27B0;');
|
||||
}, 0);
|
||||
|
||||
// 在连接后3秒发送欢迎消息
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -25,4 +25,73 @@ console.log(
|
||||
`color: ${useMock ? '#FF9800' : '#4CAF50'}; font-weight: bold; font-size: 12px;`
|
||||
);
|
||||
|
||||
// ========== 暴露调试 API 到全局 ==========
|
||||
if (typeof window !== 'undefined') {
|
||||
// 暴露 Socket 类型到全局
|
||||
window.SOCKET_TYPE = SOCKET_TYPE;
|
||||
|
||||
// 暴露调试 API
|
||||
window.__SOCKET_DEBUG__ = {
|
||||
// 获取当前连接状态
|
||||
getStatus: () => {
|
||||
const isConnected = socket.connected || false;
|
||||
return {
|
||||
type: SOCKET_TYPE,
|
||||
connected: isConnected,
|
||||
reconnectAttempts: socket.getReconnectAttempts?.() || 0,
|
||||
maxReconnectAttempts: socket.getMaxReconnectAttempts?.() || Infinity,
|
||||
service: useMock ? 'mockSocketService' : 'socketService',
|
||||
};
|
||||
},
|
||||
|
||||
// 手动重连
|
||||
reconnect: () => {
|
||||
console.log('[Socket Debug] Manual reconnect triggered');
|
||||
if (socket.reconnect) {
|
||||
socket.reconnect();
|
||||
} else {
|
||||
socket.disconnect();
|
||||
socket.connect();
|
||||
}
|
||||
},
|
||||
|
||||
// 断开连接
|
||||
disconnect: () => {
|
||||
console.log('[Socket Debug] Manual disconnect triggered');
|
||||
socket.disconnect();
|
||||
},
|
||||
|
||||
// 连接
|
||||
connect: () => {
|
||||
console.log('[Socket Debug] Manual connect triggered');
|
||||
socket.connect();
|
||||
},
|
||||
|
||||
// 获取服务实例 (仅用于调试)
|
||||
getService: () => socket,
|
||||
|
||||
// 导出诊断信息
|
||||
exportDiagnostics: () => {
|
||||
const status = window.__SOCKET_DEBUG__.getStatus();
|
||||
const diagnostics = {
|
||||
...status,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
url: window.location.href,
|
||||
};
|
||||
console.log('[Socket Diagnostics]', diagnostics);
|
||||
return diagnostics;
|
||||
}
|
||||
};
|
||||
|
||||
console.log(
|
||||
'%c[Socket Debug] Debug API available at window.__SOCKET_DEBUG__',
|
||||
'color: #2196F3; font-weight: bold;'
|
||||
);
|
||||
console.log(
|
||||
'%cTry: window.__SOCKET_DEBUG__.getStatus()',
|
||||
'color: #2196F3;'
|
||||
);
|
||||
}
|
||||
|
||||
export default socket;
|
||||
|
||||
@@ -301,35 +301,64 @@ class SocketService {
|
||||
* 执行订阅操作(内部方法)
|
||||
*/
|
||||
_doSubscribe(eventType, importance, onNewEvent, onSubscribed) {
|
||||
console.log('\n========== [SocketService DEBUG] 开始订阅 ==========');
|
||||
console.log('[SocketService DEBUG] 事件类型:', eventType);
|
||||
console.log('[SocketService DEBUG] 重要性:', importance);
|
||||
console.log('[SocketService DEBUG] Socket 连接状态:', this.connected);
|
||||
console.log('[SocketService DEBUG] Socket ID:', this.socket?.id);
|
||||
|
||||
// 发送订阅请求
|
||||
this.emit('subscribe_events', {
|
||||
const subscribeData = {
|
||||
event_type: eventType,
|
||||
importance: importance,
|
||||
});
|
||||
};
|
||||
console.log('[SocketService DEBUG] 准备发送 subscribe_events:', subscribeData);
|
||||
this.emit('subscribe_events', subscribeData);
|
||||
console.log('[SocketService DEBUG] ✓ 已发送 subscribe_events');
|
||||
|
||||
// 监听订阅确认
|
||||
this.socket.once('subscription_confirmed', (data) => {
|
||||
console.log('\n[SocketService DEBUG] ========== 收到订阅确认 ==========');
|
||||
console.log('[SocketService DEBUG] 订阅确认数据:', data);
|
||||
logger.info('socketService', 'Subscription confirmed', data);
|
||||
if (onSubscribed) {
|
||||
console.log('[SocketService DEBUG] 调用 onSubscribed 回调');
|
||||
onSubscribed(data);
|
||||
}
|
||||
console.log('[SocketService DEBUG] ========== 订阅确认处理完成 ==========\n');
|
||||
});
|
||||
|
||||
// 监听订阅错误
|
||||
this.socket.once('subscription_error', (error) => {
|
||||
console.error('\n[SocketService ERROR] ========== 订阅错误 ==========');
|
||||
console.error('[SocketService ERROR] 错误信息:', error);
|
||||
logger.error('socketService', 'Subscription error', error);
|
||||
console.error('[SocketService ERROR] ========== 订阅错误处理完成 ==========\n');
|
||||
});
|
||||
|
||||
// 监听新事件推送
|
||||
if (onNewEvent) {
|
||||
console.log('[SocketService DEBUG] 设置 new_event 监听器');
|
||||
// 先移除之前的监听器(避免重复)
|
||||
this.socket.off('new_event');
|
||||
console.log('[SocketService DEBUG] ✓ 已移除旧的 new_event 监听器');
|
||||
|
||||
// 添加新的监听器
|
||||
this.socket.on('new_event', (eventData) => {
|
||||
console.log('\n[SocketService DEBUG] ========== 收到新事件推送 ==========');
|
||||
console.log('[SocketService DEBUG] 事件数据:', eventData);
|
||||
console.log('[SocketService DEBUG] 事件 ID:', eventData?.id);
|
||||
console.log('[SocketService DEBUG] 事件标题:', eventData?.title);
|
||||
logger.info('socketService', 'New event received', eventData);
|
||||
console.log('[SocketService DEBUG] 准备调用 onNewEvent 回调');
|
||||
onNewEvent(eventData);
|
||||
console.log('[SocketService DEBUG] ✓ onNewEvent 回调已调用');
|
||||
console.log('[SocketService DEBUG] ========== 新事件处理完成 ==========\n');
|
||||
});
|
||||
console.log('[SocketService DEBUG] ✓ new_event 监听器已设置');
|
||||
}
|
||||
|
||||
console.log('[SocketService DEBUG] ========== 订阅完成 ==========\n');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
101
src/services/stockService.js
Normal file
101
src/services/stockService.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// src/services/stockService.js
|
||||
// 股票数据服务
|
||||
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || '';
|
||||
|
||||
/**
|
||||
* 股票数据服务
|
||||
*/
|
||||
export const stockService = {
|
||||
/**
|
||||
* 获取所有股票列表
|
||||
* @returns {Promise<{success: boolean, data: Array<{code: string, name: string}>}>}
|
||||
*/
|
||||
async getAllStocks() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/stocklist`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
logger.debug('stockService', 'getAllStocks 成功', {
|
||||
count: data?.length || 0
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data || []
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('stockService', 'getAllStocks', error);
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 模糊搜索股票(匹配 code 或 name)
|
||||
* @param {string} query - 搜索关键词
|
||||
* @param {Array<{code: string, name: string}>} stockList - 股票列表
|
||||
* @param {number} limit - 返回结果数量限制
|
||||
* @returns {Array<{code: string, name: string}>}
|
||||
*/
|
||||
fuzzySearch(query, stockList, limit = 10) {
|
||||
if (!query || !stockList || stockList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
// 模糊匹配 code 或 name
|
||||
const results = stockList.filter(stock => {
|
||||
const code = (stock.code || '').toString().toLowerCase();
|
||||
const name = (stock.name || '').toLowerCase();
|
||||
return code.includes(lowerQuery) || name.includes(lowerQuery);
|
||||
});
|
||||
|
||||
// 优先级排序:
|
||||
// 1. code 精确匹配
|
||||
// 2. name 精确匹配
|
||||
// 3. code 开头匹配
|
||||
// 4. name 开头匹配
|
||||
// 5. 其他包含匹配
|
||||
results.sort((a, b) => {
|
||||
const aCode = (a.code || '').toString().toLowerCase();
|
||||
const aName = (a.name || '').toLowerCase();
|
||||
const bCode = (b.code || '').toString().toLowerCase();
|
||||
const bName = (b.name || '').toLowerCase();
|
||||
|
||||
// 精确匹配
|
||||
if (aCode === lowerQuery) return -1;
|
||||
if (bCode === lowerQuery) return 1;
|
||||
if (aName === lowerQuery) return -1;
|
||||
if (bName === lowerQuery) return 1;
|
||||
|
||||
// 开头匹配
|
||||
if (aCode.startsWith(lowerQuery) && !bCode.startsWith(lowerQuery)) return -1;
|
||||
if (!aCode.startsWith(lowerQuery) && bCode.startsWith(lowerQuery)) return 1;
|
||||
if (aName.startsWith(lowerQuery) && !bName.startsWith(lowerQuery)) return -1;
|
||||
if (!aName.startsWith(lowerQuery) && bName.startsWith(lowerQuery)) return 1;
|
||||
|
||||
// 字母顺序
|
||||
return aCode.localeCompare(bCode);
|
||||
});
|
||||
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
};
|
||||
18
src/store/index.js
Normal file
18
src/store/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// src/store/index.js
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import communityDataReducer from './slices/communityDataSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
communityData: communityDataReducer
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
// 忽略这些 action types 的序列化检查
|
||||
ignoredActions: ['communityData/fetchPopularKeywords/fulfilled', 'communityData/fetchHotEvents/fulfilled'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export default store;
|
||||
274
src/store/slices/communityDataSlice.js
Normal file
274
src/store/slices/communityDataSlice.js
Normal file
@@ -0,0 +1,274 @@
|
||||
// src/store/slices/communityDataSlice.js
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import { eventService } from '../../services/eventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { localCacheManager, CACHE_EXPIRY_STRATEGY } from '../../utils/CacheManager';
|
||||
|
||||
// ==================== 常量定义 ====================
|
||||
|
||||
// 缓存键名
|
||||
const CACHE_KEYS = {
|
||||
POPULAR_KEYWORDS: 'community_popular_keywords',
|
||||
HOT_EVENTS: 'community_hot_events'
|
||||
};
|
||||
|
||||
// 请求去重:缓存正在进行的请求
|
||||
const pendingRequests = new Map();
|
||||
|
||||
// ==================== 通用数据获取逻辑 ====================
|
||||
|
||||
/**
|
||||
* 通用的数据获取函数(支持三级缓存 + 请求去重)
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {string} options.cacheKey - 缓存键名
|
||||
* @param {Function} options.fetchFn - API 获取函数
|
||||
* @param {Function} options.getState - Redux getState 函数
|
||||
* @param {string} options.stateKey - Redux state 中的键名
|
||||
* @param {boolean} options.forceRefresh - 是否强制刷新
|
||||
* @returns {Promise<any>} 获取的数据
|
||||
*/
|
||||
const fetchWithCache = async ({
|
||||
cacheKey,
|
||||
fetchFn,
|
||||
getState,
|
||||
stateKey,
|
||||
forceRefresh = false
|
||||
}) => {
|
||||
// 请求去重:如果有正在进行的相同请求,直接返回该 Promise
|
||||
if (!forceRefresh && pendingRequests.has(cacheKey)) {
|
||||
logger.debug('CommunityData', `复用进行中的请求: ${stateKey}`);
|
||||
return pendingRequests.get(cacheKey);
|
||||
}
|
||||
|
||||
const requestPromise = (async () => {
|
||||
try {
|
||||
// 第一级缓存:检查 Redux 状态(除非强制刷新)
|
||||
if (!forceRefresh) {
|
||||
const stateData = getState().communityData[stateKey];
|
||||
if (stateData && stateData.length > 0) {
|
||||
logger.debug('CommunityData', `Redux 状态中已有${stateKey}数据`);
|
||||
return stateData;
|
||||
}
|
||||
|
||||
// 第二级缓存:检查 localStorage
|
||||
const cachedData = localCacheManager.get(cacheKey);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
}
|
||||
}
|
||||
|
||||
// 第三级:从 API 获取
|
||||
logger.debug('CommunityData', `从 API 获取${stateKey}`, { forceRefresh });
|
||||
const response = await fetchFn();
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 保存到 localStorage(午夜过期)
|
||||
localCacheManager.set(cacheKey, response.data, CACHE_EXPIRY_STRATEGY.MIDNIGHT);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
logger.warn('CommunityData', `API 返回数据为空:${stateKey}`);
|
||||
return [];
|
||||
} catch (error) {
|
||||
logger.error('CommunityData', `获取${stateKey}失败`, error);
|
||||
throw error;
|
||||
} finally {
|
||||
// 请求完成后清除缓存
|
||||
pendingRequests.delete(cacheKey);
|
||||
}
|
||||
})();
|
||||
|
||||
// 缓存请求 Promise
|
||||
if (!forceRefresh) {
|
||||
pendingRequests.set(cacheKey, requestPromise);
|
||||
}
|
||||
|
||||
return requestPromise;
|
||||
};
|
||||
|
||||
// ==================== Reducer 工厂函数 ====================
|
||||
|
||||
/**
|
||||
* 创建通用的 reducer cases
|
||||
* @param {Object} builder - Redux Toolkit builder
|
||||
* @param {Object} asyncThunk - createAsyncThunk 返回的对象
|
||||
* @param {string} dataKey - state 中的数据键名(如 'popularKeywords')
|
||||
*/
|
||||
const createDataReducers = (builder, asyncThunk, dataKey) => {
|
||||
builder
|
||||
.addCase(asyncThunk.pending, (state) => {
|
||||
state.loading[dataKey] = true;
|
||||
state.error[dataKey] = null;
|
||||
})
|
||||
.addCase(asyncThunk.fulfilled, (state, action) => {
|
||||
state.loading[dataKey] = false;
|
||||
state[dataKey] = action.payload;
|
||||
state.lastUpdated[dataKey] = new Date().toISOString();
|
||||
})
|
||||
.addCase(asyncThunk.rejected, (state, action) => {
|
||||
state.loading[dataKey] = false;
|
||||
state.error[dataKey] = action.payload;
|
||||
logger.error('CommunityData', `${dataKey} 加载失败`, new Error(action.payload));
|
||||
});
|
||||
};
|
||||
|
||||
// ==================== Async Thunks ====================
|
||||
|
||||
/**
|
||||
* 获取热门关键词
|
||||
* @param {boolean} forceRefresh - 是否强制刷新(跳过缓存)
|
||||
*/
|
||||
export const fetchPopularKeywords = createAsyncThunk(
|
||||
'communityData/fetchPopularKeywords',
|
||||
async (forceRefresh = false, { getState, rejectWithValue }) => {
|
||||
try {
|
||||
return await fetchWithCache({
|
||||
cacheKey: CACHE_KEYS.POPULAR_KEYWORDS,
|
||||
fetchFn: () => eventService.getPopularKeywords(20),
|
||||
getState,
|
||||
stateKey: 'popularKeywords',
|
||||
forceRefresh
|
||||
});
|
||||
} catch (error) {
|
||||
return rejectWithValue(error.message || '获取热门关键词失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取热点事件
|
||||
* @param {boolean} forceRefresh - 是否强制刷新(跳过缓存)
|
||||
*/
|
||||
export const fetchHotEvents = createAsyncThunk(
|
||||
'communityData/fetchHotEvents',
|
||||
async (forceRefresh = false, { getState, rejectWithValue }) => {
|
||||
try {
|
||||
return await fetchWithCache({
|
||||
cacheKey: CACHE_KEYS.HOT_EVENTS,
|
||||
fetchFn: () => eventService.getHotEvents({ days: 5, limit: 20 }),
|
||||
getState,
|
||||
stateKey: 'hotEvents',
|
||||
forceRefresh
|
||||
});
|
||||
} catch (error) {
|
||||
return rejectWithValue(error.message || '获取热点事件失败');
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ==================== Slice 定义 ====================
|
||||
|
||||
const communityDataSlice = createSlice({
|
||||
name: 'communityData',
|
||||
initialState: {
|
||||
// 数据
|
||||
popularKeywords: [],
|
||||
hotEvents: [],
|
||||
|
||||
// 加载状态
|
||||
loading: {
|
||||
popularKeywords: false,
|
||||
hotEvents: false
|
||||
},
|
||||
|
||||
// 错误信息
|
||||
error: {
|
||||
popularKeywords: null,
|
||||
hotEvents: null
|
||||
},
|
||||
|
||||
// 最后更新时间
|
||||
lastUpdated: {
|
||||
popularKeywords: null,
|
||||
hotEvents: null
|
||||
}
|
||||
},
|
||||
|
||||
reducers: {
|
||||
/**
|
||||
* 清除所有缓存(Redux + localStorage)
|
||||
*/
|
||||
clearCache: (state) => {
|
||||
// 清除 localStorage
|
||||
localCacheManager.removeMultiple(Object.values(CACHE_KEYS));
|
||||
|
||||
// 清除 Redux 状态
|
||||
state.popularKeywords = [];
|
||||
state.hotEvents = [];
|
||||
state.lastUpdated.popularKeywords = null;
|
||||
state.lastUpdated.hotEvents = null;
|
||||
|
||||
logger.info('CommunityData', '所有缓存已清除');
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除指定类型的缓存
|
||||
* @param {string} payload - 缓存类型 ('popularKeywords' | 'hotEvents')
|
||||
*/
|
||||
clearSpecificCache: (state, action) => {
|
||||
const type = action.payload;
|
||||
|
||||
if (type === 'popularKeywords') {
|
||||
localCacheManager.remove(CACHE_KEYS.POPULAR_KEYWORDS);
|
||||
state.popularKeywords = [];
|
||||
state.lastUpdated.popularKeywords = null;
|
||||
logger.info('CommunityData', '热门关键词缓存已清除');
|
||||
} else if (type === 'hotEvents') {
|
||||
localCacheManager.remove(CACHE_KEYS.HOT_EVENTS);
|
||||
state.hotEvents = [];
|
||||
state.lastUpdated.hotEvents = null;
|
||||
logger.info('CommunityData', '热点事件缓存已清除');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 预加载数据(用于应用启动时)
|
||||
* 注意:这不是异步 action,只是触发标记
|
||||
*/
|
||||
preloadData: (state) => {
|
||||
logger.info('CommunityData', '准备预加载数据');
|
||||
// 实际的预加载逻辑在组件中调用 dispatch(fetchPopularKeywords()) 等
|
||||
}
|
||||
},
|
||||
|
||||
extraReducers: (builder) => {
|
||||
// 使用工厂函数创建 reducers,消除重复代码
|
||||
createDataReducers(builder, fetchPopularKeywords, 'popularKeywords');
|
||||
createDataReducers(builder, fetchHotEvents, 'hotEvents');
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== 导出 ====================
|
||||
|
||||
export const { clearCache, clearSpecificCache, preloadData } = communityDataSlice.actions;
|
||||
|
||||
// 基础选择器(Selectors)
|
||||
export const selectPopularKeywords = (state) => state.communityData.popularKeywords;
|
||||
export const selectHotEvents = (state) => state.communityData.hotEvents;
|
||||
export const selectLoading = (state) => state.communityData.loading;
|
||||
export const selectError = (state) => state.communityData.error;
|
||||
export const selectLastUpdated = (state) => state.communityData.lastUpdated;
|
||||
|
||||
// 组合选择器
|
||||
export const selectPopularKeywordsWithLoading = (state) => ({
|
||||
data: state.communityData.popularKeywords,
|
||||
loading: state.communityData.loading.popularKeywords,
|
||||
error: state.communityData.error.popularKeywords,
|
||||
lastUpdated: state.communityData.lastUpdated.popularKeywords
|
||||
});
|
||||
|
||||
export const selectHotEventsWithLoading = (state) => ({
|
||||
data: state.communityData.hotEvents,
|
||||
loading: state.communityData.loading.hotEvents,
|
||||
error: state.communityData.error.hotEvents,
|
||||
lastUpdated: state.communityData.lastUpdated.hotEvents
|
||||
});
|
||||
|
||||
// 工具函数:检查数据是否需要刷新(超过指定时间)
|
||||
export const shouldRefresh = (lastUpdated, thresholdMinutes = 30) => {
|
||||
if (!lastUpdated) return true;
|
||||
const elapsed = Date.now() - new Date(lastUpdated).getTime();
|
||||
return elapsed > thresholdMinutes * 60 * 1000;
|
||||
};
|
||||
|
||||
export default communityDataSlice.reducer;
|
||||
313
src/utils/CacheManager.js
Normal file
313
src/utils/CacheManager.js
Normal file
@@ -0,0 +1,313 @@
|
||||
// src/utils/CacheManager.js
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* 缓存过期策略
|
||||
*/
|
||||
export const CACHE_EXPIRY_STRATEGY = {
|
||||
MIDNIGHT: 'midnight', // 当天午夜过期
|
||||
HOURS: 'hours', // 指定小时后过期
|
||||
NEVER: 'never' // 永不过期
|
||||
};
|
||||
|
||||
/**
|
||||
* 缓存管理器类
|
||||
* 提供统一的缓存操作接口,支持多种过期策略
|
||||
*/
|
||||
class CacheManager {
|
||||
constructor(storage = localStorage, logContext = 'CacheManager') {
|
||||
this.storage = storage;
|
||||
this.logContext = logContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算过期时间
|
||||
* @param {string} strategy - 过期策略
|
||||
* @param {number} hours - 小时数(当策略为 HOURS 时使用)
|
||||
* @returns {string|null} ISO 格式的过期时间,或 null(永不过期)
|
||||
*/
|
||||
_calculateExpireTime(strategy = CACHE_EXPIRY_STRATEGY.MIDNIGHT, hours = 24) {
|
||||
if (strategy === CACHE_EXPIRY_STRATEGY.NEVER) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expireDate = new Date();
|
||||
|
||||
if (strategy === CACHE_EXPIRY_STRATEGY.MIDNIGHT) {
|
||||
// 设置为明天凌晨 0 点
|
||||
expireDate.setDate(expireDate.getDate() + 1);
|
||||
expireDate.setHours(0, 0, 0, 0);
|
||||
} else if (strategy === CACHE_EXPIRY_STRATEGY.HOURS) {
|
||||
// 设置为指定小时后
|
||||
expireDate.setHours(expireDate.getHours() + hours);
|
||||
}
|
||||
|
||||
return expireDate.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存是否过期
|
||||
* @param {string|null} expireAt - 过期时间(ISO 格式)
|
||||
* @returns {boolean} 是否过期
|
||||
*/
|
||||
_isExpired(expireAt) {
|
||||
if (!expireAt) return false; // null 表示永不过期
|
||||
return new Date() > new Date(expireAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存数据
|
||||
* @param {string} key - 缓存键名
|
||||
* @returns {any|null} 缓存的数据,如果不存在或已过期返回 null
|
||||
*/
|
||||
get(key) {
|
||||
try {
|
||||
const cached = this.storage.getItem(key);
|
||||
if (!cached) return null;
|
||||
|
||||
const { data, expireAt, cachedAt } = JSON.parse(cached);
|
||||
|
||||
// 检查是否过期
|
||||
if (this._isExpired(expireAt)) {
|
||||
this.remove(key);
|
||||
logger.debug(this.logContext, '缓存已过期', { key, expireAt });
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug(this.logContext, '使用缓存数据', {
|
||||
key,
|
||||
dataLength: Array.isArray(data) ? data.length : typeof data,
|
||||
expireAt,
|
||||
cachedAt
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error(this.logContext, 'get 缓存失败', error, { key });
|
||||
// 清除损坏的缓存
|
||||
this.remove(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存数据
|
||||
* @param {string} key - 缓存键名
|
||||
* @param {any} data - 要缓存的数据
|
||||
* @param {string} strategy - 过期策略
|
||||
* @param {number} hours - 小时数(当策略为 HOURS 时使用)
|
||||
* @returns {boolean} 是否设置成功
|
||||
*/
|
||||
set(key, data, strategy = CACHE_EXPIRY_STRATEGY.MIDNIGHT, hours = 24) {
|
||||
try {
|
||||
const cacheData = {
|
||||
data,
|
||||
expireAt: this._calculateExpireTime(strategy, hours),
|
||||
cachedAt: new Date().toISOString(),
|
||||
strategy
|
||||
};
|
||||
|
||||
this.storage.setItem(key, JSON.stringify(cacheData));
|
||||
|
||||
logger.debug(this.logContext, '数据已缓存', {
|
||||
key,
|
||||
dataLength: Array.isArray(data) ? data.length : typeof data,
|
||||
expireAt: cacheData.expireAt,
|
||||
strategy
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(this.logContext, 'set 缓存失败', error, { key });
|
||||
|
||||
// 处理 localStorage 配额已满
|
||||
if (error.name === 'QuotaExceededError') {
|
||||
logger.warn(this.logContext, 'Storage 配额已满,尝试清理部分缓存');
|
||||
this._handleQuotaExceeded();
|
||||
// 清理后重试一次
|
||||
try {
|
||||
this.storage.setItem(key, JSON.stringify({
|
||||
data,
|
||||
expireAt: this._calculateExpireTime(strategy, hours),
|
||||
cachedAt: new Date().toISOString(),
|
||||
strategy
|
||||
}));
|
||||
return true;
|
||||
} catch (retryError) {
|
||||
logger.error(this.logContext, '重试 set 缓存仍失败', retryError, { key });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除指定缓存
|
||||
* @param {string} key - 缓存键名
|
||||
*/
|
||||
remove(key) {
|
||||
try {
|
||||
this.storage.removeItem(key);
|
||||
logger.debug(this.logContext, '缓存已删除', { key });
|
||||
} catch (error) {
|
||||
logger.error(this.logContext, 'remove 缓存失败', error, { key });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量删除缓存
|
||||
* @param {string[]} keys - 缓存键名数组
|
||||
*/
|
||||
removeMultiple(keys) {
|
||||
keys.forEach(key => this.remove(key));
|
||||
logger.info(this.logContext, '批量删除缓存完成', { count: keys.length });
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有缓存
|
||||
*/
|
||||
clear() {
|
||||
try {
|
||||
this.storage.clear();
|
||||
logger.info(this.logContext, '所有缓存已清除');
|
||||
} catch (error) {
|
||||
logger.error(this.logContext, 'clear 缓存失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存是否存在且有效
|
||||
* @param {string} key - 缓存键名
|
||||
* @returns {boolean} 是否存在有效缓存
|
||||
*/
|
||||
has(key) {
|
||||
return this.get(key) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存元数据(不包含数据本身)
|
||||
* @param {string} key - 缓存键名
|
||||
* @returns {Object|null} 元数据对象 { expireAt, cachedAt, strategy }
|
||||
*/
|
||||
getMetadata(key) {
|
||||
try {
|
||||
const cached = this.storage.getItem(key);
|
||||
if (!cached) return null;
|
||||
|
||||
const { expireAt, cachedAt, strategy } = JSON.parse(cached);
|
||||
return { expireAt, cachedAt, strategy, isExpired: this._isExpired(expireAt) };
|
||||
} catch (error) {
|
||||
logger.error(this.logContext, 'getMetadata 失败', error, { key });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理存储配额已满的情况
|
||||
* 优先删除最旧的或已过期的缓存
|
||||
* @private
|
||||
*/
|
||||
_handleQuotaExceeded() {
|
||||
try {
|
||||
const cacheItems = [];
|
||||
|
||||
// 收集所有缓存项
|
||||
for (let i = 0; i < this.storage.length; i++) {
|
||||
const key = this.storage.key(i);
|
||||
if (!key) continue;
|
||||
|
||||
try {
|
||||
const cached = this.storage.getItem(key);
|
||||
const { cachedAt, expireAt } = JSON.parse(cached);
|
||||
cacheItems.push({
|
||||
key,
|
||||
cachedAt: new Date(cachedAt),
|
||||
expireAt: expireAt ? new Date(expireAt) : null,
|
||||
isExpired: this._isExpired(expireAt)
|
||||
});
|
||||
} catch (e) {
|
||||
// 解析失败的项直接删除
|
||||
this.storage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 按优先级排序:已过期 > 最旧的
|
||||
cacheItems.sort((a, b) => {
|
||||
if (a.isExpired && !b.isExpired) return -1;
|
||||
if (!a.isExpired && b.isExpired) return 1;
|
||||
return a.cachedAt - b.cachedAt;
|
||||
});
|
||||
|
||||
// 删除前 20% 的缓存
|
||||
const deleteCount = Math.max(1, Math.floor(cacheItems.length * 0.2));
|
||||
for (let i = 0; i < deleteCount; i++) {
|
||||
this.storage.removeItem(cacheItems[i].key);
|
||||
}
|
||||
|
||||
logger.info(this.logContext, `已清理 ${deleteCount} 个缓存项`);
|
||||
} catch (error) {
|
||||
logger.error(this.logContext, '_handleQuotaExceeded 失败', error);
|
||||
// 最后手段:清除所有缓存
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有缓存键名
|
||||
* @returns {string[]} 键名数组
|
||||
*/
|
||||
keys() {
|
||||
const keys = [];
|
||||
try {
|
||||
for (let i = 0; i < this.storage.length; i++) {
|
||||
const key = this.storage.key(i);
|
||||
if (key) keys.push(key);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(this.logContext, 'keys 获取失败', error);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取存储使用情况(仅支持 localStorage/sessionStorage)
|
||||
* @returns {Object} { used, total, percentage }
|
||||
*/
|
||||
getStorageInfo() {
|
||||
try {
|
||||
let used = 0;
|
||||
for (let i = 0; i < this.storage.length; i++) {
|
||||
const key = this.storage.key(i);
|
||||
if (key) {
|
||||
used += (key.length + (this.storage.getItem(key)?.length || 0)) * 2; // UTF-16
|
||||
}
|
||||
}
|
||||
|
||||
// 大多数浏览器 localStorage 限制为 5-10MB
|
||||
const total = 5 * 1024 * 1024; // 5MB
|
||||
const percentage = (used / total * 100).toFixed(2);
|
||||
|
||||
return {
|
||||
used,
|
||||
total,
|
||||
percentage: parseFloat(percentage),
|
||||
usedMB: (used / 1024 / 1024).toFixed(2),
|
||||
totalMB: (total / 1024 / 1024).toFixed(2)
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(this.logContext, 'getStorageInfo 失败', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例(使用 localStorage)
|
||||
export const localCacheManager = new CacheManager(localStorage, 'LocalCache');
|
||||
|
||||
// 导出单例实例(使用 sessionStorage)
|
||||
export const sessionCacheManager = new CacheManager(sessionStorage, 'SessionCache');
|
||||
|
||||
// 导出类本身,供自定义实例化
|
||||
export default CacheManager;
|
||||
@@ -82,6 +82,7 @@ export const processCitationData = (rawData) => {
|
||||
// 构建引用信息映射
|
||||
citations[citationId] = {
|
||||
author: item.author || '未知作者',
|
||||
organization: item.organization || '', // 券商名/机构名,可选字段
|
||||
report_title: item.report_title || '未知报告',
|
||||
declare_date: formatDate(item.declare_date),
|
||||
sentences: item.sentences,
|
||||
|
||||
@@ -3,6 +3,43 @@
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
|
||||
// ========== 日志限流配置 ==========
|
||||
const LOG_THROTTLE_TIME = 1000; // 1秒内相同日志只输出一次
|
||||
const recentLogs = new Map(); // 日志缓存,用于去重
|
||||
const MAX_CACHE_SIZE = 100; // 最大缓存数量
|
||||
|
||||
/**
|
||||
* 生成日志的唯一键
|
||||
*/
|
||||
function getLogKey(component, message) {
|
||||
return `${component}:${message}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否应该输出日志(限流检查)
|
||||
*/
|
||||
function shouldLog(component, message) {
|
||||
const key = getLogKey(component, message);
|
||||
const now = Date.now();
|
||||
const lastLog = recentLogs.get(key);
|
||||
|
||||
// 如果1秒内已经输出过相同日志,跳过
|
||||
if (lastLog && now - lastLog < LOG_THROTTLE_TIME) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 记录日志时间
|
||||
recentLogs.set(key, now);
|
||||
|
||||
// 限制缓存大小,避免内存泄漏
|
||||
if (recentLogs.size > MAX_CACHE_SIZE) {
|
||||
const oldestKey = recentLogs.keys().next().value;
|
||||
recentLogs.delete(oldestKey);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一日志工具
|
||||
* 开发环境:输出详细日志
|
||||
@@ -20,7 +57,7 @@ export const logger = {
|
||||
* @param {object} data - 请求参数/body
|
||||
*/
|
||||
request: (method, url, data = null) => {
|
||||
if (isDevelopment) {
|
||||
if (isDevelopment && shouldLog('API', `${method} ${url}`)) {
|
||||
console.group(`🌐 API Request: ${method} ${url}`);
|
||||
console.log('Timestamp:', new Date().toISOString());
|
||||
if (data) console.log('Data:', data);
|
||||
@@ -36,7 +73,7 @@ export const logger = {
|
||||
* @param {any} data - 响应数据
|
||||
*/
|
||||
response: (method, url, status, data) => {
|
||||
if (isDevelopment) {
|
||||
if (isDevelopment && shouldLog('API', `${method} ${url} ${status}`)) {
|
||||
console.group(`✅ API Response: ${method} ${url}`);
|
||||
console.log('Status:', status);
|
||||
console.log('Data:', data);
|
||||
@@ -53,6 +90,7 @@ export const logger = {
|
||||
* @param {object} requestData - 请求参数(可选)
|
||||
*/
|
||||
error: (method, url, error, requestData = null) => {
|
||||
// API 错误始终输出,不做限流
|
||||
console.group(`❌ API Error: ${method} ${url}`);
|
||||
console.error('Error:', error);
|
||||
console.error('Message:', error?.message || error);
|
||||
@@ -75,6 +113,7 @@ export const logger = {
|
||||
* @param {object} context - 上下文信息(可选)
|
||||
*/
|
||||
error: (component, method, error, context = {}) => {
|
||||
// 错误日志始终输出,不做限流
|
||||
console.group(`🔴 Error in ${component}.${method}`);
|
||||
console.error('Error:', error);
|
||||
console.error('Message:', error?.message || error);
|
||||
@@ -93,7 +132,7 @@ export const logger = {
|
||||
* @param {object} data - 相关数据(可选)
|
||||
*/
|
||||
warn: (component, message, data = {}) => {
|
||||
if (isDevelopment) {
|
||||
if (isDevelopment && shouldLog(component, message)) {
|
||||
console.group(`⚠️ Warning: ${component}`);
|
||||
console.warn('Message:', message);
|
||||
if (Object.keys(data).length > 0) {
|
||||
@@ -111,7 +150,7 @@ export const logger = {
|
||||
* @param {object} data - 相关数据(可选)
|
||||
*/
|
||||
debug: (component, message, data = {}) => {
|
||||
if (isDevelopment) {
|
||||
if (isDevelopment && shouldLog(component, message)) {
|
||||
console.group(`🐛 Debug: ${component}`);
|
||||
console.log('Message:', message);
|
||||
if (Object.keys(data).length > 0) {
|
||||
@@ -129,7 +168,7 @@ export const logger = {
|
||||
* @param {object} data - 相关数据(可选)
|
||||
*/
|
||||
info: (component, message, data = {}) => {
|
||||
if (isDevelopment) {
|
||||
if (isDevelopment && shouldLog(component, message)) {
|
||||
console.group(`ℹ️ Info: ${component}`);
|
||||
console.log('Message:', message);
|
||||
if (Object.keys(data).length > 0) {
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
// src/views/Community/components/EventFilters.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Row, Col, DatePicker, Button, Select, Form, Input } from 'antd';
|
||||
import { FilterOutlined } from '@ant-design/icons';
|
||||
import moment from 'moment';
|
||||
import locale from 'antd/es/date-picker/locale/zh_CN';
|
||||
import { industryService } from '../../../services/industryService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
const { Option } = Select;
|
||||
|
||||
const EventFilters = ({ filters, onFilterChange, loading }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [industryData, setIndustryData] = useState({
|
||||
classifications: [],
|
||||
level1: [],
|
||||
level2: [],
|
||||
level3: [],
|
||||
level4: []
|
||||
});
|
||||
|
||||
// 初始化表单值
|
||||
useEffect(() => {
|
||||
const initialValues = {
|
||||
date_range: filters.date_range ? filters.date_range.split(' 至 ').map(d => moment(d)) : null,
|
||||
sort: filters.sort,
|
||||
importance: filters.importance,
|
||||
industry_classification: filters.industry_classification,
|
||||
industry_code: filters.industry_code
|
||||
};
|
||||
form.setFieldsValue(initialValues);
|
||||
}, [filters, form]);
|
||||
|
||||
// 加载行业分类数据
|
||||
const loadIndustryClassifications = async () => {
|
||||
try {
|
||||
const response = await industryService.getClassifications();
|
||||
setIndustryData(prev => ({ ...prev, classifications: response.data }));
|
||||
logger.debug('EventFilters', '行业分类加载成功', {
|
||||
count: response.data?.length || 0
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('EventFilters', 'loadIndustryClassifications', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载行业层级数据
|
||||
const loadIndustryLevels = async (level, params) => {
|
||||
try {
|
||||
const response = await industryService.getLevels(params);
|
||||
setIndustryData(prev => ({ ...prev, [`level${level}`]: response.data }));
|
||||
// 清空下级
|
||||
for (let l = level + 1; l <= 4; l++) {
|
||||
setIndustryData(prev => ({ ...prev, [`level${l}`]: [] }));
|
||||
}
|
||||
logger.debug('EventFilters', '行业层级数据加载成功', {
|
||||
level,
|
||||
count: response.data?.length || 0
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('EventFilters', 'loadIndustryLevels', error, { level, params });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadIndustryClassifications();
|
||||
}, []);
|
||||
|
||||
const handleDateRangeChange = (dates) => {
|
||||
if (dates && dates.length === 2) {
|
||||
const dateRange = `${dates[0].format('YYYY-MM-DD')} 至 ${dates[1].format('YYYY-MM-DD')}`;
|
||||
onFilterChange('date_range', dateRange);
|
||||
} else {
|
||||
onFilterChange('date_range', '');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSortChange = (value) => {
|
||||
onFilterChange('sort', value);
|
||||
};
|
||||
|
||||
const handleImportanceChange = (value) => {
|
||||
onFilterChange('importance', value);
|
||||
};
|
||||
|
||||
// 行业分类体系变化时,加载一级行业
|
||||
const handleIndustryClassificationChange = (value) => {
|
||||
form.setFieldsValue({ industry_code: '' });
|
||||
onFilterChange('industry_classification', value);
|
||||
setIndustryData(prev => ({ ...prev, level1: [], level2: [], level3: [], level4: [] }));
|
||||
if (value) {
|
||||
loadIndustryLevels(1, { classification: value, level: 1 });
|
||||
}
|
||||
};
|
||||
|
||||
// 级联选择行业
|
||||
const handleLevelChange = (level, value) => {
|
||||
// 直接从state里查找name
|
||||
let name = '';
|
||||
if (level === 1) {
|
||||
const found = industryData.level1.find(item => item.code === value);
|
||||
name = found ? found.name : '';
|
||||
} else if (level === 2) {
|
||||
const found = industryData.level2.find(item => item.code === value);
|
||||
name = found ? found.name : '';
|
||||
} else if (level === 3) {
|
||||
const found = industryData.level3.find(item => item.code === value);
|
||||
name = found ? found.name : '';
|
||||
} else if (level === 4) {
|
||||
const found = industryData.level4.find(item => item.code === value);
|
||||
name = found ? found.name : '';
|
||||
}
|
||||
form.setFieldsValue({ [`level${level}`]: value });
|
||||
form.setFieldsValue({ industry_code: value });
|
||||
onFilterChange('industry_code', value);
|
||||
for (let l = level + 1; l <= 4; l++) {
|
||||
form.setFieldsValue({ [`level${l}`]: undefined });
|
||||
}
|
||||
const params = { classification: form.getFieldValue('industry_classification'), level: level + 1 };
|
||||
if (level === 1) params.level1_name = name;
|
||||
if (level === 2) {
|
||||
params.level1_name = form.getFieldValue('level1_name');
|
||||
params.level2_name = name;
|
||||
}
|
||||
if (level === 3) {
|
||||
params.level1_name = form.getFieldValue('level1_name');
|
||||
params.level2_name = form.getFieldValue('level2_name');
|
||||
params.level3_name = name;
|
||||
}
|
||||
if (level < 4 && name) {
|
||||
loadIndustryLevels(level + 1, params);
|
||||
}
|
||||
form.setFieldsValue({ [`level${level}_name`]: name });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="event-filters" title="事件筛选" style={{ marginBottom: 16 }}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="日期范围" name="date_range">
|
||||
<RangePicker
|
||||
style={{ width: '100%' }}
|
||||
locale={locale}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
onChange={handleDateRangeChange}
|
||||
disabled={loading}
|
||||
allowEmpty={[true, true]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Row gutter={8}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="排序方式" name="sort">
|
||||
<Select onChange={handleSortChange} disabled={loading}>
|
||||
<Option value="new">最新</Option>
|
||||
<Option value="hot">热门</Option>
|
||||
<Option value="returns">收益率</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="重要性" name="importance">
|
||||
<Select onChange={handleImportanceChange} disabled={loading}>
|
||||
<Option value="all">全部</Option>
|
||||
<Option value="S">S级</Option>
|
||||
<Option value="A">A级</Option>
|
||||
<Option value="B">B级</Option>
|
||||
<Option value="C">C级</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Form.Item label="行业分类" name="industry_classification">
|
||||
<Select
|
||||
placeholder="选择行业分类体系"
|
||||
onChange={handleIndustryClassificationChange}
|
||||
disabled={loading}
|
||||
allowClear
|
||||
>
|
||||
{industryData.classifications.map(item => (
|
||||
<Option key={item.name} value={item.name}>{item.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Form.Item label="一级行业" name="level1">
|
||||
<Select
|
||||
placeholder="选择一级行业"
|
||||
onChange={value => handleLevelChange(1, value)}
|
||||
disabled={loading || !form.getFieldValue('industry_classification')}
|
||||
allowClear
|
||||
>
|
||||
{industryData.level1.map(item => (
|
||||
<Option key={item.code} value={item.code} data-name={item.name}>{item.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Form.Item label="二级行业" name="level2">
|
||||
<Select
|
||||
placeholder="选择二级行业"
|
||||
onChange={value => handleLevelChange(2, value)}
|
||||
disabled={loading || !form.getFieldValue('level1')}
|
||||
allowClear
|
||||
>
|
||||
{industryData.level2.map(item => (
|
||||
<Option key={item.code} value={item.code} data-name={item.name}>{item.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Form.Item label="三级行业" name="level3">
|
||||
<Select
|
||||
placeholder="选择三级行业"
|
||||
onChange={value => handleLevelChange(3, value)}
|
||||
disabled={loading || !form.getFieldValue('level2')}
|
||||
allowClear
|
||||
>
|
||||
{industryData.level3.map(item => (
|
||||
<Option key={item.code} value={item.code} data-name={item.name}>{item.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Form.Item label="四级行业" name="level4">
|
||||
<Select
|
||||
placeholder="选择四级行业"
|
||||
onChange={value => handleLevelChange(4, value)}
|
||||
disabled={loading || !form.getFieldValue('level3')}
|
||||
allowClear
|
||||
>
|
||||
{industryData.level4.map(item => (
|
||||
<Option key={item.code} value={item.code} data-name={item.name}>{item.name}</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventFilters;
|
||||
File diff suppressed because it is too large
Load Diff
75
src/views/Community/components/EventListSection.js
Normal file
75
src/views/Community/components/EventListSection.js
Normal file
@@ -0,0 +1,75 @@
|
||||
// src/views/Community/components/EventListSection.js
|
||||
// 事件列表区域组件(包含Loading、Empty、List三种状态)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Center,
|
||||
VStack,
|
||||
Spinner,
|
||||
Text
|
||||
} from '@chakra-ui/react';
|
||||
import EventList from './EventList';
|
||||
|
||||
/**
|
||||
* 事件列表区域组件
|
||||
* @param {boolean} loading - 加载状态
|
||||
* @param {Array} events - 事件列表
|
||||
* @param {Object} pagination - 分页信息
|
||||
* @param {Function} onPageChange - 分页变化回调
|
||||
* @param {Function} onEventClick - 事件点击回调
|
||||
* @param {Function} onViewDetail - 查看详情回调
|
||||
*/
|
||||
const EventListSection = ({
|
||||
loading,
|
||||
events,
|
||||
pagination,
|
||||
onPageChange,
|
||||
onEventClick,
|
||||
onViewDetail
|
||||
}) => {
|
||||
// ✅ 最小高度,避免加载后高度突变
|
||||
const minHeight = '600px';
|
||||
|
||||
// Loading 状态
|
||||
if (loading) {
|
||||
return (
|
||||
<Box minH={minHeight}>
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color="gray.500">正在加载最新事件...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty 状态
|
||||
if (!events || events.length === 0) {
|
||||
return (
|
||||
<Box minH={minHeight}>
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// List 状态
|
||||
return (
|
||||
<Box minH={minHeight}>
|
||||
<EventList
|
||||
events={events}
|
||||
pagination={pagination}
|
||||
onPageChange={onPageChange}
|
||||
onEventClick={onEventClick}
|
||||
onViewDetail={onViewDetail}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventListSection;
|
||||
63
src/views/Community/components/EventModals.js
Normal file
63
src/views/Community/components/EventModals.js
Normal file
@@ -0,0 +1,63 @@
|
||||
// src/views/Community/components/EventModals.js
|
||||
// 事件弹窗组合组件(包含详情Modal和股票Drawer)
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton
|
||||
} from '@chakra-ui/react';
|
||||
import EventDetailModal from './EventDetailModal';
|
||||
import StockDetailPanel from './StockDetailPanel';
|
||||
|
||||
/**
|
||||
* 事件弹窗组合组件
|
||||
* @param {Object} eventModalState - 事件详情Modal状态
|
||||
* @param {boolean} eventModalState.isOpen - 是否打开
|
||||
* @param {Function} eventModalState.onClose - 关闭回调
|
||||
* @param {Object} eventModalState.event - 事件对象
|
||||
* @param {Function} eventModalState.onEventClose - 事件关闭回调(清除状态)
|
||||
* @param {Object} stockDrawerState - 股票详情Drawer状态
|
||||
* @param {boolean} stockDrawerState.visible - 是否显示
|
||||
* @param {Object} stockDrawerState.event - 事件对象
|
||||
* @param {Function} stockDrawerState.onClose - 关闭回调
|
||||
*/
|
||||
const EventModals = ({
|
||||
eventModalState,
|
||||
stockDrawerState
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 事件详情模态框 - 使用Chakra UI Modal */}
|
||||
<Modal
|
||||
isOpen={eventModalState.isOpen}
|
||||
onClose={eventModalState.onClose}
|
||||
size="xl"
|
||||
>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>事件详情</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<EventDetailModal
|
||||
event={eventModalState.event}
|
||||
onClose={eventModalState.onEventClose}
|
||||
/>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 股票详情抽屉 - 使用原组件自带的 Antd Drawer */}
|
||||
<StockDetailPanel
|
||||
visible={stockDrawerState.visible}
|
||||
event={stockDrawerState.event}
|
||||
onClose={stockDrawerState.onClose}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventModals;
|
||||
83
src/views/Community/components/EventTimelineCard.js
Normal file
83
src/views/Community/components/EventTimelineCard.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// src/views/Community/components/EventTimelineCard.js
|
||||
// 事件时间轴卡片组件(整合Header + Search + List)
|
||||
|
||||
import React, { forwardRef } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Box,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react';
|
||||
import EventTimelineHeader from './EventTimelineHeader';
|
||||
import UnifiedSearchBox from './UnifiedSearchBox';
|
||||
import EventListSection from './EventListSection';
|
||||
|
||||
/**
|
||||
* 事件时间轴卡片组件
|
||||
* @param {Array} events - 事件列表
|
||||
* @param {boolean} loading - 加载状态
|
||||
* @param {Object} pagination - 分页信息
|
||||
* @param {Object} filters - 筛选条件
|
||||
* @param {Array} popularKeywords - 热门关键词
|
||||
* @param {Date} lastUpdateTime - 最后更新时间
|
||||
* @param {Function} onSearch - 搜索回调
|
||||
* @param {Function} onSearchFocus - 搜索框获得焦点回调
|
||||
* @param {Function} onPageChange - 分页变化回调
|
||||
* @param {Function} onEventClick - 事件点击回调
|
||||
* @param {Function} onViewDetail - 查看详情回调
|
||||
* @param {Object} ref - 用于滚动的ref
|
||||
*/
|
||||
const EventTimelineCard = forwardRef(({
|
||||
events,
|
||||
loading,
|
||||
pagination,
|
||||
filters,
|
||||
popularKeywords,
|
||||
lastUpdateTime,
|
||||
onSearch,
|
||||
onSearchFocus,
|
||||
onPageChange,
|
||||
onEventClick,
|
||||
onViewDetail,
|
||||
...rest
|
||||
}, ref) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
return (
|
||||
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
|
||||
{/* 标题部分 */}
|
||||
<CardHeader>
|
||||
<EventTimelineHeader lastUpdateTime={lastUpdateTime} />
|
||||
</CardHeader>
|
||||
|
||||
{/* 主体内容 */}
|
||||
<CardBody>
|
||||
{/* 统一搜索组件(整合了话题、股票、行业、日期、排序、重要性、热门概念、筛选标签) */}
|
||||
<Box mb={4}>
|
||||
<UnifiedSearchBox
|
||||
onSearch={onSearch}
|
||||
onSearchFocus={onSearchFocus}
|
||||
popularKeywords={popularKeywords}
|
||||
filters={filters}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 事件列表(包含Loading、Empty、List三种状态) */}
|
||||
<EventListSection
|
||||
loading={loading}
|
||||
events={events}
|
||||
pagination={pagination}
|
||||
onPageChange={onPageChange}
|
||||
onEventClick={onEventClick}
|
||||
onViewDetail={onViewDetail}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
EventTimelineCard.displayName = 'EventTimelineCard';
|
||||
|
||||
export default EventTimelineCard;
|
||||
42
src/views/Community/components/EventTimelineHeader.js
Normal file
42
src/views/Community/components/EventTimelineHeader.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// src/views/Community/components/EventTimelineHeader.js
|
||||
// 事件时间轴标题组件
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Flex,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Badge
|
||||
} from '@chakra-ui/react';
|
||||
import { TimeIcon } from '@chakra-ui/icons';
|
||||
|
||||
/**
|
||||
* 事件时间轴标题组件
|
||||
* @param {Date} lastUpdateTime - 最后更新时间
|
||||
*/
|
||||
const EventTimelineHeader = ({ lastUpdateTime }) => {
|
||||
return (
|
||||
<Flex justify="space-between" align="center">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="md">
|
||||
<HStack>
|
||||
<TimeIcon />
|
||||
<Text>实时事件</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
<HStack fontSize="sm" color="gray.500">
|
||||
<Badge colorScheme="green">全网监控</Badge>
|
||||
<Badge colorScheme="orange">智能捕获</Badge>
|
||||
<Badge colorScheme="purple">深度分析</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
最后更新: {lastUpdateTime.toLocaleTimeString()}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTimelineHeader;
|
||||
@@ -1,8 +1,7 @@
|
||||
/* Hot Events Section */
|
||||
.hot-events-section {
|
||||
padding: 24px 0;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -17,11 +16,76 @@
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Carousel */
|
||||
.carousel-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.carousel-counter {
|
||||
position: absolute;
|
||||
top: 8px; /* 容器内部顶部 */
|
||||
right: 48px; /* 避开右侧箭头 */
|
||||
z-index: 100; /* 确保在卡片和箭头上方 */
|
||||
background: rgba(24, 144, 255, 0.95);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
||||
pointer-events: none; /* 不阻挡鼠标事件 */
|
||||
}
|
||||
|
||||
.hot-events-carousel {
|
||||
padding: 0 40px; /* 增加左右padding为箭头留出空间 */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hot-events-carousel .carousel-item {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* 自定义箭头样式 */
|
||||
.custom-carousel-arrow {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
border-radius: 50% !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
.custom-carousel-arrow:hover {
|
||||
background: rgba(255, 255, 255, 1) !important;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
|
||||
}
|
||||
|
||||
.custom-carousel-arrow:hover .anticon {
|
||||
color: #096dd9 !important;
|
||||
}
|
||||
|
||||
/* 箭头位置 */
|
||||
.hot-events-carousel .slick-prev.custom-carousel-arrow {
|
||||
left: 0 !important;
|
||||
}
|
||||
|
||||
.hot-events-carousel .slick-next.custom-carousel-arrow {
|
||||
right: 0 !important;
|
||||
}
|
||||
|
||||
/* 禁用状态 */
|
||||
.custom-carousel-arrow.slick-disabled {
|
||||
opacity: 0.3 !important;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.hot-event-card {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hot-event-card:hover {
|
||||
@@ -29,11 +93,16 @@
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Cover image */
|
||||
/* Card body padding */
|
||||
.hot-event-card .ant-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Cover image - 高度减半 */
|
||||
.event-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
height: 80px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -55,28 +124,53 @@
|
||||
|
||||
/* Card content */
|
||||
.event-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.event-header .ant-tag {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
flex: 1;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* 标题文字 - inline显示,可以换行 */
|
||||
.event-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 标签紧跟标题后面 */
|
||||
.event-tag {
|
||||
display: inline;
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.event-tag .ant-tag {
|
||||
font-size: 11px;
|
||||
padding: 0 6px;
|
||||
height: 18px;
|
||||
line-height: 18px;
|
||||
transform: scale(0.9);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 详情描述 - 三行省略 */
|
||||
.event-description {
|
||||
margin: 8px 0;
|
||||
font-size: 14px;
|
||||
color: #595959;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-height: 4.5em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-footer {
|
||||
@@ -84,6 +178,7 @@
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.creator {
|
||||
@@ -93,6 +188,19 @@
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
/* 时间样式 - 年月日高亮 */
|
||||
.time {
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.time-date {
|
||||
color: #1890ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.time-hour {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
// src/views/Community/components/HotEvents.js
|
||||
import React from 'react';
|
||||
import { Card, Row, Col, Badge, Tag, Empty } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, FireOutlined } from '@ant-design/icons';
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Badge, Tag, Empty, Carousel, Tooltip } from 'antd';
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import './HotEvents.css';
|
||||
import defaultEventImage from '../../../assets/img/default-event.jpg'
|
||||
const HotEvents = ({ events }) => {
|
||||
|
||||
// 自定义箭头组件
|
||||
const CustomArrow = ({ className, style, onClick, direction }) => {
|
||||
const Icon = direction === 'left' ? LeftOutlined : RightOutlined;
|
||||
return (
|
||||
<div
|
||||
className={`${className} custom-carousel-arrow`}
|
||||
style={{
|
||||
...style,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon style={{ fontSize: '20px', color: '#1890ff' }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HotEvents = ({ events, onPageChange }) => {
|
||||
const navigate = useNavigate();
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
|
||||
const renderPriceChange = (value) => {
|
||||
if (value === null || value === undefined) {
|
||||
@@ -39,18 +60,60 @@ const HotEvents = ({ events }) => {
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
};
|
||||
|
||||
// 计算总页数
|
||||
const totalPages = Math.ceil((events?.length || 0) / 4);
|
||||
|
||||
// Carousel 配置
|
||||
const carouselSettings = {
|
||||
dots: false, // 隐藏圆点导航
|
||||
infinite: true, // 始终启用无限循环,确保箭头显示
|
||||
speed: 500,
|
||||
slidesToShow: 4,
|
||||
slidesToScroll: 1,
|
||||
arrows: true, // 保留左右箭头
|
||||
prevArrow: <CustomArrow direction="left" />,
|
||||
nextArrow: <CustomArrow direction="right" />,
|
||||
autoplay: false,
|
||||
beforeChange: (_current, next) => {
|
||||
// 计算实际页码(考虑无限循环)
|
||||
const actualPage = next % totalPages;
|
||||
setCurrentSlide(actualPage);
|
||||
// 通知父组件页码变化
|
||||
if (onPageChange) {
|
||||
onPageChange(actualPage + 1, totalPages);
|
||||
}
|
||||
},
|
||||
responsive: [
|
||||
{
|
||||
breakpoint: 1200,
|
||||
settings: {
|
||||
slidesToShow: 3,
|
||||
slidesToScroll: 1,
|
||||
}
|
||||
},
|
||||
{
|
||||
breakpoint: 992,
|
||||
settings: {
|
||||
slidesToShow: 2,
|
||||
slidesToScroll: 1,
|
||||
}
|
||||
},
|
||||
{
|
||||
breakpoint: 576,
|
||||
settings: {
|
||||
slidesToShow: 1,
|
||||
slidesToScroll: 1,
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="hot-events-section">
|
||||
<h2 className="section-title">
|
||||
<FireOutlined style={{ marginRight: 8, color: '#ff4d4f' }} />
|
||||
近期热点信息
|
||||
</h2>
|
||||
<p className="section-subtitle">展示最近5天内涨幅最高的事件,助您把握市场热点</p>
|
||||
|
||||
{events && events.length > 0 ? (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Carousel {...carouselSettings} className="hot-events-carousel">
|
||||
{events.map((event, index) => (
|
||||
<Col lg={6} md={12} sm={24} key={event.id}>
|
||||
<div key={event.id} className="carousel-item">
|
||||
<Card
|
||||
hoverable
|
||||
className="hot-event-card"
|
||||
@@ -75,33 +138,36 @@ const HotEvents = ({ events }) => {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Card.Meta
|
||||
title={
|
||||
<div className="event-header">
|
||||
{renderPriceChange(event.related_avg_chg)}
|
||||
<span className="event-title">
|
||||
{event.title}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<>
|
||||
<p className="event-description">
|
||||
{event.description && event.description.length > 80
|
||||
? `${event.description.substring(0, 80)}...`
|
||||
: event.description}
|
||||
</p>
|
||||
<div className="event-footer">
|
||||
<span className="creator">{event.creator?.username || 'Anonymous'}</span>
|
||||
<span className="time">{moment(event.created_at).format('MM-DD HH:mm')}</span>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{/* Custom layout without Card.Meta */}
|
||||
<div className="event-header">
|
||||
<Tooltip title={event.title}>
|
||||
<span className="event-title">
|
||||
{event.title}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span className="event-tag">
|
||||
{renderPriceChange(event.related_avg_chg)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tooltip title={event.description}>
|
||||
<div className="event-description">
|
||||
{event.description}
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<div className="event-footer">
|
||||
<span className="creator">{event.creator?.username || 'Anonymous'}</span>
|
||||
<span className="time">
|
||||
<span className="time-date">{moment(event.created_at).format('YYYY-MM-DD')}</span>
|
||||
{' '}
|
||||
<span className="time-hour">{moment(event.created_at).format('HH:mm')}</span>
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
</Carousel>
|
||||
) : (
|
||||
<Card>
|
||||
<Empty description="暂无热点信息" />
|
||||
|
||||
64
src/views/Community/components/HotEventsSection.js
Normal file
64
src/views/Community/components/HotEventsSection.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// src/views/Community/components/HotEventsSection.js
|
||||
// 热点事件区域组件
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
Badge,
|
||||
Box,
|
||||
useColorModeValue
|
||||
} from '@chakra-ui/react';
|
||||
import HotEvents from './HotEvents';
|
||||
|
||||
/**
|
||||
* 热点事件区域组件
|
||||
* @param {Array} events - 热点事件列表
|
||||
*/
|
||||
const HotEventsSection = ({ events }) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// 处理页码变化
|
||||
const handlePageChange = (page, total) => {
|
||||
setCurrentPage(page);
|
||||
setTotalPages(total);
|
||||
};
|
||||
|
||||
// 如果没有热点事件,不渲染组件
|
||||
if (!events || events.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card mt={0} bg={cardBg}>
|
||||
<CardHeader pb={0} display="flex" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box>
|
||||
<Heading size="md">🔥 热点事件</Heading>
|
||||
<p className="section-subtitle" style={{paddingTop: '8px'}}>展示最近5天内涨幅最高的事件,助您把握市场热点</p>
|
||||
</Box>
|
||||
{/* 页码指示器 */}
|
||||
{totalPages > 1 && (
|
||||
<Badge
|
||||
colorScheme="blue"
|
||||
fontSize="sm"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
ml={4}
|
||||
>
|
||||
{currentPage} / {totalPages}
|
||||
</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardBody py={0} px={4}>
|
||||
<HotEvents events={events} onPageChange={handlePageChange} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotEventsSection;
|
||||
75
src/views/Community/components/IndustryCascader.js
Normal file
75
src/views/Community/components/IndustryCascader.js
Normal file
@@ -0,0 +1,75 @@
|
||||
// src/views/Community/components/IndustryCascader.js
|
||||
import React, { useState } from 'react';
|
||||
import { Card, Form, Cascader } from 'antd';
|
||||
import { useIndustry } from '../../../contexts/IndustryContext';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
const IndustryCascader = ({ onFilterChange, loading }) => {
|
||||
const [industryCascaderValue, setIndustryCascaderValue] = useState([]);
|
||||
|
||||
// 使用全局行业数据
|
||||
const { industryData, loadIndustryData, loading: industryLoading } = useIndustry();
|
||||
|
||||
// Cascader 获得焦点时加载数据
|
||||
const handleCascaderFocus = async () => {
|
||||
if (!industryData || industryData.length === 0) {
|
||||
logger.debug('IndustryCascader', 'Cascader 获得焦点,开始加载行业数据');
|
||||
await loadIndustryData();
|
||||
}
|
||||
};
|
||||
|
||||
// Cascader 选择变化
|
||||
const handleIndustryCascaderChange = (value, selectedOptions) => {
|
||||
setIndustryCascaderValue(value);
|
||||
|
||||
if (value && value.length > 0) {
|
||||
// value[0] = 分类体系名称
|
||||
// value[1...n] = 行业代码(一级~四级)
|
||||
const industryCode = value[value.length - 1]; // 最后一级的 code
|
||||
const classification = value[0]; // 分类体系名称
|
||||
|
||||
onFilterChange('industry_classification', classification);
|
||||
onFilterChange('industry_code', industryCode);
|
||||
|
||||
logger.debug('IndustryCascader', 'Cascader 选择变化', {
|
||||
value,
|
||||
classification,
|
||||
industryCode,
|
||||
path: selectedOptions.map(o => o.label).join(' > ')
|
||||
});
|
||||
} else {
|
||||
// 清空
|
||||
onFilterChange('industry_classification', '');
|
||||
onFilterChange('industry_code', '');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="industry-cascader" title="行业分类" style={{ marginBottom: 16 }}>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="选择行业分类体系和具体行业">
|
||||
<Cascader
|
||||
options={industryData || []}
|
||||
value={industryCascaderValue}
|
||||
onChange={handleIndustryCascaderChange}
|
||||
onFocus={handleCascaderFocus}
|
||||
changeOnSelect
|
||||
placeholder={industryLoading ? "加载中..." : "请选择行业分类体系和具体行业"}
|
||||
disabled={loading || industryLoading}
|
||||
loading={industryLoading}
|
||||
allowClear
|
||||
expandTrigger="hover"
|
||||
displayRender={(labels) => labels.join(' > ')}
|
||||
showSearch={{
|
||||
filter: (inputValue, path) =>
|
||||
path.some(option => option.label.toLowerCase().includes(inputValue.toLowerCase()))
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndustryCascader;
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/views/Community/components/InvestmentCalendar.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Card, Calendar, Badge, Modal, Table, Tabs, Tag, Button, List, Spin, Empty,
|
||||
Drawer, Typography, Divider, Space, Tooltip, message, Alert
|
||||
@@ -48,7 +48,7 @@ const InvestmentCalendar = () => {
|
||||
const [expandedReasons, setExpandedReasons] = useState({}); // 跟踪每个股票关联理由的展开状态
|
||||
|
||||
// 加载月度事件统计
|
||||
const loadEventCounts = async (date) => {
|
||||
const loadEventCounts = useCallback(async (date) => {
|
||||
try {
|
||||
const year = date.year();
|
||||
const month = date.month() + 1;
|
||||
@@ -63,7 +63,7 @@ const InvestmentCalendar = () => {
|
||||
month: date.month() + 1
|
||||
});
|
||||
}
|
||||
};
|
||||
}, []); // eventService 是外部导入的稳定引用,不需要作为依赖
|
||||
|
||||
// 加载指定日期的事件
|
||||
const loadDateEvents = async (date) => {
|
||||
@@ -131,7 +131,7 @@ const InvestmentCalendar = () => {
|
||||
|
||||
useEffect(() => {
|
||||
loadEventCounts(currentMonth);
|
||||
}, [currentMonth]);
|
||||
}, [currentMonth, loadEventCounts]);
|
||||
|
||||
// 自定义日期单元格渲染
|
||||
const dateCellRender = (value) => {
|
||||
@@ -700,6 +700,7 @@ const InvestmentCalendar = () => {
|
||||
width={1200}
|
||||
footer={null}
|
||||
bodyStyle={{ padding: '24px' }}
|
||||
zIndex={1500}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Tabs defaultActiveKey="event">
|
||||
@@ -734,6 +735,7 @@ const InvestmentCalendar = () => {
|
||||
width={600}
|
||||
onClose={() => setDetailDrawerVisible(false)}
|
||||
visible={detailDrawerVisible}
|
||||
zIndex={1500}
|
||||
>
|
||||
{selectedDetail?.content?.type === 'citation' ? (
|
||||
<CitedContent
|
||||
@@ -770,6 +772,7 @@ const InvestmentCalendar = () => {
|
||||
关闭
|
||||
</Button>
|
||||
}
|
||||
zIndex={1500}
|
||||
>
|
||||
{hasFeatureAccess('related_stocks') ? (
|
||||
<Table
|
||||
|
||||
@@ -480,9 +480,10 @@ export default function MidjourneyHeroSection() {
|
||||
minH="100vh"
|
||||
bg="linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 50%, #000000 100%)"
|
||||
overflow="hidden"
|
||||
pointerEvents="none"
|
||||
>
|
||||
{/* 粒子背景 */}
|
||||
<Box position="absolute" inset={0} zIndex={0}>
|
||||
<Box position="absolute" inset={0} zIndex={-1} pointerEvents="none">
|
||||
<Particles
|
||||
id="tsparticles"
|
||||
init={particlesInit}
|
||||
@@ -499,7 +500,7 @@ export default function MidjourneyHeroSection() {
|
||||
<DataStreams />
|
||||
|
||||
{/* 内容容器 */}
|
||||
<Container maxW="7xl" position="relative" zIndex={20} pt={20} pb={20}>
|
||||
<Container maxW="7xl" position="relative" zIndex={1} pt={20} pb={20}>
|
||||
<Grid templateColumns={{ base: '1fr', lg: 'repeat(2, 1fr)' }} gap={12} alignItems="center" minH="70vh">
|
||||
|
||||
{/* 左侧文本内容 */}
|
||||
@@ -776,7 +777,7 @@ export default function MidjourneyHeroSection() {
|
||||
borderRadius="full"
|
||||
filter="blur(40px)"
|
||||
animation="pulse 4s ease-in-out infinite"
|
||||
animationDelay="2s"
|
||||
sx={{ animationDelay: '2s' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -793,16 +794,16 @@ export default function MidjourneyHeroSection() {
|
||||
right={0}
|
||||
h="128px"
|
||||
bgGradient="linear(to-t, black, transparent)"
|
||||
zIndex={10}
|
||||
zIndex={-1}
|
||||
/>
|
||||
|
||||
{/* 全局样式 */}
|
||||
<style jsx global>{`
|
||||
<style>{`
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scale(1); }
|
||||
50% { opacity: 0.6; transform: scale(1.1); }
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
// src/views/Community/components/PopularKeywords.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Tag, Space, Spin, Empty, Button } from 'antd';
|
||||
import { FireOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { Tag, Space, Button } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { RightOutlined } from '@ant-design/icons';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||||
? '/concept-api'
|
||||
: 'http://192.168.1.58:6801';
|
||||
// 使用相对路径,让 MSW 在开发环境可以拦截请求
|
||||
const API_BASE_URL = '/concept-api';
|
||||
|
||||
// 获取域名前缀
|
||||
const DOMAIN_PREFIX = process.env.NODE_ENV === 'production'
|
||||
? ''
|
||||
: 'https://valuefrontier.cn';
|
||||
|
||||
const PopularKeywords = ({ onKeywordClick }) => {
|
||||
const PopularKeywords = ({ onKeywordClick, keywords: propKeywords }) => {
|
||||
const [keywords, setKeywords] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 加载热门概念(涨幅前20)
|
||||
const loadPopularConcepts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/search`, {
|
||||
method: 'POST',
|
||||
@@ -45,22 +44,29 @@ const PopularKeywords = ({ onKeywordClick }) => {
|
||||
concept_id: item.concept_id
|
||||
}));
|
||||
setKeywords(formattedData);
|
||||
logger.debug('PopularKeywords', '热门概念加载成功', {
|
||||
logger.debug('PopularKeywords', '热门概念加载成功(自己请求)', {
|
||||
count: formattedData.length
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PopularKeywords', 'loadPopularConcepts', error);
|
||||
setKeywords([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时加载数据
|
||||
// 处理从父组件传入的数据
|
||||
useEffect(() => {
|
||||
loadPopularConcepts();
|
||||
}, []);
|
||||
if (propKeywords && propKeywords.length > 0) {
|
||||
// 使用父组件传入的数据
|
||||
setKeywords(propKeywords);
|
||||
logger.debug('PopularKeywords', '使用父组件传入的数据', {
|
||||
count: propKeywords.length
|
||||
});
|
||||
} else {
|
||||
// 没有 prop 数据,自己加载
|
||||
loadPopularConcepts();
|
||||
}
|
||||
}, [propKeywords]);
|
||||
|
||||
// 根据涨跌幅获取标签颜色
|
||||
const getTagColor = (changePct) => {
|
||||
@@ -83,107 +89,113 @@ const PopularKeywords = ({ onKeywordClick }) => {
|
||||
return `${formatted}%`;
|
||||
};
|
||||
|
||||
// 处理概念标签点击 - 跳转到对应概念页面
|
||||
// ✅ 修复:处理概念标签点击
|
||||
const handleConceptClick = (concept) => {
|
||||
// 如果原有的 onKeywordClick 存在,可以选择是否还要调用
|
||||
// onKeywordClick && onKeywordClick(concept.keyword);
|
||||
|
||||
// 跳转到对应概念的页面
|
||||
const url = `${DOMAIN_PREFIX}/htmls/${encodeURIComponent(concept.keyword)}.html`;
|
||||
window.open(url, '_blank');
|
||||
// 优先调用父组件传入的回调(用于搜索框显示和触发搜索)
|
||||
if (onKeywordClick) {
|
||||
onKeywordClick(concept.keyword);
|
||||
logger.debug('PopularKeywords', '调用 onKeywordClick 回调', {
|
||||
keyword: concept.keyword
|
||||
});
|
||||
} else {
|
||||
// 如果没有回调,则跳转到对应概念的页面(原有行为)
|
||||
const url = `${DOMAIN_PREFIX}/htmls/${encodeURIComponent(concept.keyword)}.html`;
|
||||
window.open(url, '_blank');
|
||||
logger.debug('PopularKeywords', '跳转到概念页面', {
|
||||
keyword: concept.keyword,
|
||||
url
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 查看更多概念
|
||||
const handleViewMore = () => {
|
||||
const url = `${DOMAIN_PREFIX}/concepts`;
|
||||
window.open(url, '_blank');
|
||||
// 处理"更多概念"按钮点击 - 跳转到概念中心
|
||||
const handleMoreClick = () => {
|
||||
navigate('/concepts');
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<span>
|
||||
<FireOutlined style={{ marginRight: 8, color: '#ff4d4f' }} />
|
||||
热门概念
|
||||
</span>
|
||||
}
|
||||
className="popular-keywords"
|
||||
style={{ marginBottom: 16 }}
|
||||
extra={
|
||||
<span style={{ fontSize: 12, color: '#999' }}>
|
||||
涨幅TOP20
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{keywords && keywords.length > 0 ? (
|
||||
<>
|
||||
<Space size={[8, 8]} wrap style={{ marginBottom: 16 }}>
|
||||
{keywords.map((item) => (
|
||||
<Tag
|
||||
key={item.concept_id}
|
||||
color={getTagColor(item.change_pct)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
marginBottom: 8,
|
||||
padding: '2px 8px',
|
||||
transition: 'all 0.3s'
|
||||
}}
|
||||
onClick={() => handleConceptClick(item)}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<span>{item.keyword}</span>
|
||||
<span style={{
|
||||
marginLeft: 6,
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{formatChangePct(item.change_pct)}
|
||||
</span>
|
||||
<span style={{
|
||||
marginLeft: 4,
|
||||
fontSize: 11,
|
||||
opacity: 0.8
|
||||
}}>
|
||||
({item.count}股)
|
||||
</span>
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
{/* 查看更多按钮 */}
|
||||
<div style={{
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
paddingTop: 12,
|
||||
textAlign: 'center'
|
||||
<>
|
||||
{keywords && keywords.length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<Space
|
||||
size={[6, 6]}
|
||||
wrap
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
maxHeight: '62px', // 约两行的高度 (每行约28-30px)
|
||||
overflow: 'hidden',
|
||||
paddingRight: '90px' // 为右侧按钮留出空间
|
||||
}}
|
||||
>
|
||||
{/* 标题 */}
|
||||
<span style={{
|
||||
color: '#ff4d4f',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
marginRight: 4
|
||||
}}>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={handleViewMore}
|
||||
热门概念:
|
||||
</span>
|
||||
|
||||
{/* 所有标签 */}
|
||||
{keywords.map((item, index) => (
|
||||
<Tag
|
||||
key={item.concept_id || `keyword-${index}`}
|
||||
color={getTagColor(item.change_pct)}
|
||||
style={{
|
||||
color: '#1890ff',
|
||||
fontWeight: 500
|
||||
cursor: 'pointer',
|
||||
padding: '1px 6px',
|
||||
fontSize: 12,
|
||||
transition: 'all 0.3s',
|
||||
margin: 0
|
||||
}}
|
||||
onClick={() => handleConceptClick(item)}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
查看更多概念
|
||||
<RightOutlined style={{ fontSize: 12, marginLeft: 4 }} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Empty
|
||||
description="暂无热门概念"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</Card>
|
||||
<span>{item.keyword}</span>
|
||||
<span style={{
|
||||
marginLeft: 4,
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{formatChangePct(item.change_pct)}
|
||||
</span>
|
||||
<span style={{
|
||||
marginLeft: 3,
|
||||
fontSize: 10,
|
||||
opacity: 0.75
|
||||
}}>
|
||||
({item.count}股)
|
||||
</span>
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
{/* 更多概念按钮 - 固定在第二行右侧 */}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={handleMoreClick}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
fontSize: 12,
|
||||
padding: '0 4px',
|
||||
height: 'auto'
|
||||
}}
|
||||
>
|
||||
更多概念 <RightOutlined style={{ fontSize: 10 }} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
679
src/views/Community/components/UnifiedSearchBox.js
Normal file
679
src/views/Community/components/UnifiedSearchBox.js
Normal file
@@ -0,0 +1,679 @@
|
||||
// src/views/Community/components/UnifiedSearchBox.js
|
||||
// 搜索组件:三行布局(主搜索 + 热门概念 + 筛选区)
|
||||
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Card, Input, Cascader, Button, Space, Tag, AutoComplete, DatePicker, Select as AntSelect
|
||||
} from 'antd';
|
||||
import {
|
||||
SearchOutlined, CloseCircleOutlined, StockOutlined
|
||||
} from '@ant-design/icons';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import locale from 'antd/es/date-picker/locale/zh_CN';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useIndustry } from '../../../contexts/IndustryContext';
|
||||
import { stockService } from '../../../services/stockService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import PopularKeywords from './PopularKeywords';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
const { Option } = AntSelect;
|
||||
|
||||
const UnifiedSearchBox = ({
|
||||
onSearch,
|
||||
onSearchFocus,
|
||||
popularKeywords = [],
|
||||
filters = {}
|
||||
}) => {
|
||||
|
||||
// 其他状态
|
||||
const [stockOptions, setStockOptions] = useState([]); // 股票下拉选项列表
|
||||
const [allStocks, setAllStocks] = useState([]); // 所有股票数据
|
||||
const [industryValue, setIndustryValue] = useState([]);
|
||||
|
||||
// 筛选条件状态
|
||||
const [sort, setSort] = useState('new'); // 排序方式
|
||||
const [importance, setImportance] = useState('all'); // 重要性
|
||||
const [dateRange, setDateRange] = useState(null); // 日期范围
|
||||
|
||||
// ✅ 本地输入状态 - 管理用户的实时输入
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
// 使用全局行业数据
|
||||
const { industryData, loadIndustryData, loading: industryLoading } = useIndustry();
|
||||
|
||||
// 搜索触发函数
|
||||
const triggerSearch = useCallback((params) => {
|
||||
logger.debug('UnifiedSearchBox', '【5/5】✅ 最终触发搜索 - 调用onSearch回调', {
|
||||
params: params,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
onSearch(params);
|
||||
}, [onSearch]);
|
||||
|
||||
// ✅ 创建防抖的搜索函数(300ms 延迟)
|
||||
const debouncedSearchRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 创建防抖函数,使用 triggerSearch 而不是直接调用 onSearch
|
||||
debouncedSearchRef.current = debounce((params) => {
|
||||
logger.debug('UnifiedSearchBox', '⏱️ 防抖延迟结束,执行搜索', {
|
||||
params: params,
|
||||
delayMs: 300
|
||||
});
|
||||
triggerSearch(params);
|
||||
}, 300);
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
};
|
||||
}, [triggerSearch]);
|
||||
|
||||
// 加载所有股票数据
|
||||
useEffect(() => {
|
||||
const loadStocks = async () => {
|
||||
const response = await stockService.getAllStocks();
|
||||
if (response.success && response.data) {
|
||||
setAllStocks(response.data);
|
||||
logger.debug('UnifiedSearchBox', '股票数据加载成功', {
|
||||
count: response.data.length
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
loadStocks();
|
||||
}, []);
|
||||
|
||||
// Cascader 获得焦点时加载数据
|
||||
const handleCascaderFocus = async () => {
|
||||
if (!industryData || industryData.length === 0) {
|
||||
logger.debug('UnifiedSearchBox', 'Cascader 获得焦点,开始加载行业数据');
|
||||
await loadIndustryData();
|
||||
}
|
||||
};
|
||||
|
||||
// 从 props.filters 初始化所有内部状态 (只在组件首次挂载时执行)
|
||||
// 辅助函数:递归查找行业代码的完整路径
|
||||
const findIndustryPath = React.useCallback((targetCode, data, currentPath = []) => {
|
||||
if (!data || data.length === 0) return null;
|
||||
|
||||
for (const item of data) {
|
||||
const newPath = [...currentPath, item.value];
|
||||
|
||||
if (item.value === targetCode) {
|
||||
return newPath;
|
||||
}
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
const found = findIndustryPath(targetCode, item.children, newPath);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// ✅ 从 props.filters 初始化筛选条件和输入框值
|
||||
useEffect(() => {
|
||||
if (!filters) return;
|
||||
|
||||
// 初始化排序和重要性
|
||||
if (filters.sort) setSort(filters.sort);
|
||||
if (filters.importance) setImportance(filters.importance);
|
||||
|
||||
// ✅ 初始化日期范围
|
||||
if (filters.date_range) {
|
||||
const parts = filters.date_range.split(' 至 ');
|
||||
if (parts.length === 2) {
|
||||
setDateRange([dayjs(parts[0]), dayjs(parts[1])]);
|
||||
logger.debug('UnifiedSearchBox', '初始化日期范围', {
|
||||
date_range: filters.date_range
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 初始化行业分类(需要 industryData 加载完成)
|
||||
if (filters.industry_code && industryData && industryData.length > 0) {
|
||||
const path = findIndustryPath(filters.industry_code, industryData);
|
||||
if (path) {
|
||||
setIndustryValue(path);
|
||||
logger.debug('UnifiedSearchBox', '初始化行业分类', {
|
||||
industry_code: filters.industry_code,
|
||||
path
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 同步 filters.q 到输入框显示值
|
||||
if (filters.q) {
|
||||
setInputValue(filters.q);
|
||||
} else if (!filters.q) {
|
||||
// 如果 filters 中没有搜索关键词,清空输入框
|
||||
setInputValue('');
|
||||
}
|
||||
}, [filters.sort, filters.importance, filters.date_range, filters.industry_code, filters.q, industryData, findIndustryPath]);
|
||||
|
||||
// AutoComplete 搜索股票(模糊匹配 code 或 name)
|
||||
const handleSearch = (value) => {
|
||||
if (!value || !allStocks || allStocks.length === 0) {
|
||||
setStockOptions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用 stockService 进行模糊搜索
|
||||
const results = stockService.fuzzySearch(value, allStocks, 10);
|
||||
|
||||
// 转换为 AutoComplete 选项格式
|
||||
const options = results.map(stock => ({
|
||||
value: stock.code,
|
||||
label: (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<StockOutlined style={{ color: '#1890ff' }} />
|
||||
<span style={{ fontWeight: 500, color: '#333' }}>{stock.code}</span>
|
||||
<span style={{ color: '#666' }}>{stock.name}</span>
|
||||
</div>
|
||||
),
|
||||
// 保存完整的股票信息,用于选中后显示
|
||||
stockInfo: stock
|
||||
}));
|
||||
|
||||
setStockOptions(options);
|
||||
logger.debug('UnifiedSearchBox', '股票模糊搜索', {
|
||||
query: value,
|
||||
resultCount: options.length
|
||||
});
|
||||
};
|
||||
|
||||
// ✅ 选中股票(从下拉选择) - 更新输入框并触发搜索
|
||||
const handleStockSelect = (_value, option) => {
|
||||
const stockInfo = option.stockInfo;
|
||||
if (stockInfo) {
|
||||
logger.debug('UnifiedSearchBox', '选中股票', {
|
||||
code: stockInfo.code,
|
||||
name: stockInfo.name
|
||||
});
|
||||
|
||||
// 更新输入框显示
|
||||
setInputValue(`${stockInfo.code} ${stockInfo.name}`);
|
||||
|
||||
// 直接构建参数并触发搜索 - 使用股票代码作为 q 参数
|
||||
const params = buildFilterParams({
|
||||
q: stockInfo.code, // 使用股票代码作为搜索关键词
|
||||
industry_code: ''
|
||||
});
|
||||
logger.debug('UnifiedSearchBox', '自动触发股票搜索', params);
|
||||
triggerSearch(params);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 日期范围变化(使用防抖)
|
||||
const handleDateRangeChange = (dates) => {
|
||||
logger.debug('UnifiedSearchBox', '【1/5】日期范围值改变', {
|
||||
oldValue: dateRange,
|
||||
newValue: dates
|
||||
});
|
||||
setDateRange(dates);
|
||||
|
||||
// ⚠️ 注意:setState是异步的,此时dateRange仍是旧值
|
||||
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
|
||||
dateRange: dateRange, // 旧值
|
||||
sort: sort,
|
||||
importance: importance,
|
||||
industryValue: industryValue
|
||||
});
|
||||
|
||||
// 使用防抖搜索(需要从新值推导参数)
|
||||
const params = {
|
||||
...buildFilterParams(),
|
||||
date_range: dates ? `${dates[0].format('YYYY-MM-DD')} 至 ${dates[1].format('YYYY-MM-DD')}` : ''
|
||||
};
|
||||
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
|
||||
|
||||
if (debouncedSearchRef.current) {
|
||||
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
|
||||
debouncedSearchRef.current(params);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 重要性变化(使用防抖)
|
||||
const handleImportanceChange = (value) => {
|
||||
logger.debug('UnifiedSearchBox', '【1/5】重要性值改变', {
|
||||
oldValue: importance,
|
||||
newValue: value
|
||||
});
|
||||
setImportance(value);
|
||||
|
||||
// ⚠️ 注意:setState是异步的,此时importance仍是旧值
|
||||
logger.debug('UnifiedSearchBox', '【2/5】调用buildFilterParams前的状态', {
|
||||
importance: importance, // 旧值
|
||||
sort: sort,
|
||||
dateRange: dateRange,
|
||||
industryValue: industryValue
|
||||
});
|
||||
|
||||
// 使用防抖搜索
|
||||
const params = buildFilterParams({ importance: value });
|
||||
logger.debug('UnifiedSearchBox', '【3/5】buildFilterParams返回的参数', params);
|
||||
|
||||
if (debouncedSearchRef.current) {
|
||||
logger.debug('UnifiedSearchBox', '【4/5】调用防抖函数(300ms延迟)');
|
||||
debouncedSearchRef.current(params);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 排序变化(使用防抖)
|
||||
const handleSortChange = (value) => {
|
||||
logger.debug('UnifiedSearchBox', '【1/5】排序值改变', {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 行业分类变化(使用防抖)
|
||||
const handleIndustryChange = (value) => {
|
||||
logger.debug('UnifiedSearchBox', '【1/5】行业分类值改变', {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 热门概念点击处理(立即搜索,不使用防抖) - 更新输入框并触发搜索
|
||||
const handleKeywordClick = (keyword) => {
|
||||
// 更新输入框显示
|
||||
setInputValue(keyword);
|
||||
|
||||
// 立即触发搜索(取消之前的防抖)
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
const params = buildFilterParams({
|
||||
q: keyword,
|
||||
industry_code: ''
|
||||
});
|
||||
logger.debug('UnifiedSearchBox', '热门概念点击,立即触发搜索', {
|
||||
keyword,
|
||||
params
|
||||
});
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
// 主搜索(点击搜索按钮或回车)
|
||||
const handleMainSearch = () => {
|
||||
// 取消之前的防抖
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 构建参数并触发搜索 - 使用用户输入作为 q 参数
|
||||
const params = buildFilterParams({
|
||||
q: inputValue, // 使用用户输入(可能是话题、股票代码、股票名称等)
|
||||
industry_code: ''
|
||||
});
|
||||
|
||||
logger.debug('UnifiedSearchBox', '主搜索触发', {
|
||||
inputValue,
|
||||
params
|
||||
});
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
// ✅ 处理输入变化 - 更新本地输入状态
|
||||
const handleInputChange = (value) => {
|
||||
logger.debug('UnifiedSearchBox', '输入变化', { value });
|
||||
setInputValue(value);
|
||||
};
|
||||
|
||||
// ✅ 生成完整的筛选参数对象 - 直接从 filters 和本地筛选器状态构建
|
||||
const buildFilterParams = useCallback((overrides = {}) => {
|
||||
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输入参数', {
|
||||
overrides: overrides,
|
||||
currentState: {
|
||||
sort,
|
||||
importance,
|
||||
dateRange,
|
||||
industryValue,
|
||||
'filters.q': filters.q
|
||||
}
|
||||
});
|
||||
|
||||
const result = {
|
||||
// 基础参数(overrides 优先级高于本地状态)
|
||||
sort: overrides.sort ?? sort,
|
||||
importance: overrides.importance ?? importance,
|
||||
date_range: dateRange ? `${dateRange[0].format('YYYY-MM-DD')} 至 ${dateRange[1].format('YYYY-MM-DD')}` : '',
|
||||
page: 1,
|
||||
|
||||
// 搜索参数: 统一使用 q 参数进行搜索(话题/股票/关键词)
|
||||
q: (overrides.q ?? filters.q) ?? '',
|
||||
// 行业代码: 取选中路径的最后一级(最具体的行业代码)
|
||||
industry_code: overrides.industry_code ?? (industryValue?.[industryValue.length - 1] || ''),
|
||||
|
||||
// 最终 overrides 具有最高优先级
|
||||
...overrides
|
||||
};
|
||||
|
||||
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输出结果', result);
|
||||
return result;
|
||||
}, [sort, importance, dateRange, filters.q, industryValue]);
|
||||
|
||||
// ✅ 应用筛选(立即搜索,取消防抖)
|
||||
const handleApplyFilters = () => {
|
||||
// 取消之前的防抖搜索
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
const params = buildFilterParams();
|
||||
logger.debug('UnifiedSearchBox', '应用筛选,立即触发搜索', params);
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
// ✅ 重置筛选 - 清空所有筛选器并触发搜索
|
||||
const handleReset = () => {
|
||||
// 重置所有筛选器状态
|
||||
setInputValue(''); // 清空输入框
|
||||
setStockOptions([]);
|
||||
setIndustryValue([]);
|
||||
setSort('new');
|
||||
setImportance('all');
|
||||
setDateRange(null);
|
||||
|
||||
// 输出重置后的完整参数
|
||||
const resetParams = {
|
||||
q: '',
|
||||
industry_code: '',
|
||||
sort: 'new',
|
||||
importance: 'all',
|
||||
date_range: '',
|
||||
page: 1
|
||||
};
|
||||
|
||||
logger.debug('UnifiedSearchBox', '重置筛选', resetParams);
|
||||
onSearch(resetParams);
|
||||
};
|
||||
|
||||
// 生成已选条件标签(包含所有筛选条件) - 从 filters 和本地状态读取
|
||||
const filterTags = useMemo(() => {
|
||||
const tags = [];
|
||||
|
||||
// 搜索关键词标签 - 从 filters.q 读取
|
||||
if (filters.q) {
|
||||
tags.push({ key: 'search', label: `搜索: ${filters.q}` });
|
||||
}
|
||||
|
||||
// 行业标签
|
||||
if (industryValue && industryValue.length > 0) {
|
||||
const industryLabel = industryValue.slice(1).join(' > ');
|
||||
tags.push({ key: 'industry', label: `行业: ${industryLabel}` });
|
||||
}
|
||||
|
||||
// 日期范围标签
|
||||
if (dateRange && dateRange.length === 2) {
|
||||
const dateLabel = `${dateRange[0].format('YYYY-MM-DD')} 至 ${dateRange[1].format('YYYY-MM-DD')}`;
|
||||
tags.push({ key: 'date_range', label: `日期: ${dateLabel}` });
|
||||
}
|
||||
|
||||
// 重要性标签(排除默认值 'all')
|
||||
if (importance && importance !== 'all') {
|
||||
tags.push({ key: 'importance', label: `重要性: ${importance}级` });
|
||||
}
|
||||
|
||||
// 排序标签(排除默认值 'new')
|
||||
if (sort && sort !== 'new') {
|
||||
const sortLabel = sort === 'hot' ? '最热' : sort === 'importance' ? '重要性' : sort;
|
||||
tags.push({ key: 'sort', label: `排序: ${sortLabel}` });
|
||||
}
|
||||
|
||||
return tags;
|
||||
}, [filters.q, industryValue, dateRange, importance, sort]);
|
||||
|
||||
// ✅ 移除单个标签 - 构建新参数并触发搜索
|
||||
const handleRemoveTag = (key) => {
|
||||
logger.debug('UnifiedSearchBox', '移除标签', { key });
|
||||
|
||||
if (key === 'search') {
|
||||
// 清除搜索关键词和输入框,立即触发搜索
|
||||
setInputValue(''); // 清空输入框
|
||||
const params = buildFilterParams({ q: '' });
|
||||
logger.debug('UnifiedSearchBox', '移除搜索标签后触发搜索', { key, params });
|
||||
triggerSearch(params);
|
||||
} else if (key === 'industry') {
|
||||
// 清除行业选择
|
||||
setIndustryValue([]);
|
||||
const params = buildFilterParams({ industry_code: '' });
|
||||
triggerSearch(params);
|
||||
} else if (key === 'date_range') {
|
||||
// 清除日期范围
|
||||
setDateRange(null);
|
||||
const params = buildFilterParams({ date_range: '' });
|
||||
triggerSearch(params);
|
||||
} else if (key === 'importance') {
|
||||
// 重置重要性为默认值
|
||||
setImportance('all');
|
||||
const params = buildFilterParams({ importance: 'all' });
|
||||
triggerSearch(params);
|
||||
} else if (key === 'sort') {
|
||||
// 重置排序为默认值
|
||||
setSort('new');
|
||||
const params = buildFilterParams({ sort: 'new' });
|
||||
triggerSearch(params);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
{/* 第一行:主搜索框 */}
|
||||
<Space.Compact style={{ width: '100%', marginBottom: 12 }} size="large">
|
||||
<SearchOutlined style={{
|
||||
fontSize: 20,
|
||||
padding: '8px 12px',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: '6px 0 0 6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: '#666'
|
||||
}} />
|
||||
<AutoComplete
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleStockSelect}
|
||||
onFocus={onSearchFocus}
|
||||
options={stockOptions}
|
||||
placeholder="请输入股票代码/股票名称/相关话题"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleMainSearch();
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
size="large"
|
||||
notFoundContent={inputValue && stockOptions.length === 0 ? "未找到匹配的股票" : null}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleMainSearch}
|
||||
size="large"
|
||||
style={{ minWidth: 80 }}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
{/* 第二行:热门概念 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<PopularKeywords
|
||||
keywords={popularKeywords}
|
||||
onKeywordClick={handleKeywordClick}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 第三行:筛选器 + 排序 */}
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} size="middle">
|
||||
{/* 左侧:筛选器组 */}
|
||||
<Space size="middle" wrap>
|
||||
<span style={{ fontSize: 14, color: '#666', fontWeight: 'bold' }}>筛选:</span>
|
||||
{/* 行业分类 */}
|
||||
<Cascader
|
||||
value={industryValue}
|
||||
onChange={handleIndustryChange}
|
||||
onFocus={handleCascaderFocus}
|
||||
options={industryData || []}
|
||||
placeholder="行业分类"
|
||||
changeOnSelect
|
||||
showSearch={{
|
||||
filter: (inputValue, path) =>
|
||||
path.some(option =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
)
|
||||
}}
|
||||
allowClear
|
||||
expandTrigger="hover"
|
||||
displayRender={(labels) => labels.join(' > ')}
|
||||
disabled={industryLoading}
|
||||
style={{ width: 200 }}
|
||||
size="middle"
|
||||
/>
|
||||
|
||||
{/* 日期范围 */}
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={handleDateRangeChange}
|
||||
locale={locale}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
style={{ width: 240 }}
|
||||
size="middle"
|
||||
/>
|
||||
|
||||
{/* 重要性 */}
|
||||
<Space size="small">
|
||||
<span style={{ fontSize: 14, color: '#666' }}>重要性:</span>
|
||||
<AntSelect
|
||||
value={importance}
|
||||
onChange={handleImportanceChange}
|
||||
style={{ width: 100 }}
|
||||
size="middle"
|
||||
>
|
||||
<Option value="all">全部</Option>
|
||||
<Option value="S">S级</Option>
|
||||
<Option value="A">A级</Option>
|
||||
<Option value="B">B级</Option>
|
||||
<Option value="C">C级</Option>
|
||||
</AntSelect>
|
||||
</Space>
|
||||
|
||||
{/* 重置按钮 - 现代化设计 */}
|
||||
<Button
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={handleReset}
|
||||
size="middle"
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
border: '1px solid #d9d9d9',
|
||||
backgroundColor: '#fff',
|
||||
color: '#666',
|
||||
fontWeight: 500,
|
||||
padding: '4px 12px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ff4d4f';
|
||||
e.currentTarget.style.color = '#ff4d4f';
|
||||
e.currentTarget.style.backgroundColor = '#fff1f0';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(255, 77, 79, 0.15)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = '#d9d9d9';
|
||||
e.currentTarget.style.color = '#666';
|
||||
e.currentTarget.style.backgroundColor = '#fff';
|
||||
e.currentTarget.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.05)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{/* 右侧:排序 */}
|
||||
<Space size="small">
|
||||
<span style={{ fontSize: 14, color: '#666' }}>排序:</span>
|
||||
<AntSelect
|
||||
value={sort}
|
||||
onChange={handleSortChange}
|
||||
style={{ width: 120 }}
|
||||
size="middle"
|
||||
>
|
||||
<Option value="new">最新</Option>
|
||||
<Option value="hot">最热</Option>
|
||||
<Option value="importance">重要性</Option>
|
||||
</AntSelect>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
{/* 已选条件标签 */}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedSearchBox;
|
||||
123
src/views/Community/hooks/useEventData.js
Normal file
123
src/views/Community/hooks/useEventData.js
Normal file
@@ -0,0 +1,123 @@
|
||||
// src/views/Community/hooks/useEventData.js
|
||||
// 事件数据加载逻辑 Hook
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
import { eventService } from '../../../services/eventService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
/**
|
||||
* 事件数据加载 Hook
|
||||
* @param {Object} filters - 筛选条件
|
||||
* @param {number} pageSize - 每页数量
|
||||
* @returns {Object} 事件数据和加载状态
|
||||
*/
|
||||
export const useEventData = (filters, pageSize = 10) => {
|
||||
const [events, setEvents] = useState([]);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: pageSize,
|
||||
total: 0
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lastUpdateTime, setLastUpdateTime] = useState(new Date());
|
||||
|
||||
// 加载事件列表
|
||||
// ✅ 修复闭包陷阱: 接受 currentFilters 参数,避免使用闭包中的旧 filters
|
||||
const loadEvents = useCallback(async (page = 1, currentFilters = null) => {
|
||||
// 使用传入的 currentFilters 或回退到闭包中的 filters
|
||||
const filtersToUse = currentFilters || filters;
|
||||
|
||||
const requestParams = {
|
||||
...filtersToUse,
|
||||
page,
|
||||
per_page: pagination.pageSize
|
||||
};
|
||||
|
||||
logger.debug('useEventData', '📡 【准备发起API请求】loadEvents 被调用', {
|
||||
page,
|
||||
currentFilters,
|
||||
filtersToUse,
|
||||
requestParams
|
||||
});
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
logger.debug('useEventData', '🌐 正在调用 eventService.getEvents', { requestParams });
|
||||
const response = await eventService.getEvents(requestParams);
|
||||
logger.debug('useEventData', '✅ API响应成功', {
|
||||
success: response.success,
|
||||
eventCount: response.data?.events?.length,
|
||||
total: response.data?.pagination?.total
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setEvents(response.data.events);
|
||||
setPagination({
|
||||
current: response.data.pagination.page,
|
||||
pageSize: response.data.pagination.per_page,
|
||||
total: response.data.pagination.total
|
||||
});
|
||||
setLastUpdateTime(new Date());
|
||||
|
||||
logger.debug('useEventData', 'loadEvents 成功', {
|
||||
count: response.data.events.length,
|
||||
total: response.data.pagination.total
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('useEventData', '❌ loadEvents 失败', error, {
|
||||
page,
|
||||
filtersToUse
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, pagination.pageSize]);
|
||||
|
||||
// 创建防抖的 loadEvents 函数(500ms 防抖延迟)
|
||||
// ✅ 修复闭包陷阱: 防抖函数接受 filters 参数并传递给 loadEvents
|
||||
const debouncedLoadEvents = useRef(
|
||||
debounce((page, filters) => {
|
||||
logger.debug('useEventData', '⏱️ 【防抖延迟500ms结束】即将执行 loadEvents', {
|
||||
page,
|
||||
filters
|
||||
});
|
||||
loadEvents(page, filters);
|
||||
}, 500)
|
||||
).current;
|
||||
|
||||
// 监听 filters 变化,自动加载数据
|
||||
// 防抖优化:用户快速切换筛选条件时,只执行最后一次请求
|
||||
useEffect(() => {
|
||||
logger.debug('useEventData', '🔔 【filters变化触发useEffect】完整filters对象:', filters);
|
||||
logger.debug('useEventData', '详细参数:', {
|
||||
page: filters.page || 1,
|
||||
sort: filters.sort,
|
||||
importance: filters.importance,
|
||||
date_range: filters.date_range,
|
||||
q: filters.q,
|
||||
industry_code: filters.industry_code,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// ✅ 使用防抖加载事件,将当前 filters 传递给防抖函数
|
||||
logger.debug('useEventData', '⏰ 启动防抖计时器(500ms),传递最新filters');
|
||||
debouncedLoadEvents(filters.page || 1, filters);
|
||||
|
||||
// 组件卸载时取消防抖
|
||||
return () => {
|
||||
debouncedLoadEvents.cancel();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters]); // 监听 filters 状态变化
|
||||
|
||||
return {
|
||||
events,
|
||||
pagination,
|
||||
loading,
|
||||
lastUpdateTime,
|
||||
loadEvents
|
||||
};
|
||||
};
|
||||
79
src/views/Community/hooks/useEventFilters.js
Normal file
79
src/views/Community/hooks/useEventFilters.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// src/views/Community/hooks/useEventFilters.js
|
||||
// 事件筛选逻辑 Hook
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
/**
|
||||
* 事件筛选逻辑 Hook
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {Function} options.navigate - 路由导航函数
|
||||
* @param {Function} options.onEventClick - 事件点击回调
|
||||
* @param {Object} options.eventTimelineRef - 时间轴ref(用于滚动)
|
||||
* @returns {Object} 筛选状态和处理函数
|
||||
*/
|
||||
export const useEventFilters = ({ navigate, onEventClick, eventTimelineRef } = {}) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// 筛选参数状态 - 初始化时从URL读取,之后只用本地状态
|
||||
const [filters, setFilters] = useState(() => {
|
||||
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') || '',
|
||||
page: parseInt(searchParams.get('page') || '1', 10)
|
||||
};
|
||||
});
|
||||
|
||||
// 更新筛选参数 - 直接替换(由 UnifiedSearchBox 输出完整参数)
|
||||
const updateFilters = useCallback((newFilters) => {
|
||||
logger.debug('useEventFilters', '🔄 【接收到onSearch回调】updateFilters 接收到完整参数', {
|
||||
newFilters: newFilters,
|
||||
oldFilters: filters,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
setFilters(newFilters);
|
||||
logger.debug('useEventFilters', '✅ setFilters 已调用 (React异步更新中...)');
|
||||
}, [filters]);
|
||||
|
||||
// 处理分页变化
|
||||
const handlePageChange = useCallback((page) => {
|
||||
// 保持现有筛选条件,只更新页码
|
||||
updateFilters({ ...filters, page });
|
||||
|
||||
// 滚动到实时事件时间轴(平滑滚动)
|
||||
if (eventTimelineRef && eventTimelineRef.current) {
|
||||
setTimeout(() => {
|
||||
eventTimelineRef.current.scrollIntoView({
|
||||
behavior: 'smooth', // 平滑滚动
|
||||
block: 'start' // 滚动到元素顶部
|
||||
});
|
||||
}, 100); // 延迟100ms,确保DOM更新
|
||||
}
|
||||
}, [filters, updateFilters, eventTimelineRef]);
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = useCallback((event) => {
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
}
|
||||
}, [onEventClick]);
|
||||
|
||||
// 处理查看详情
|
||||
const handleViewDetail = useCallback((eventId) => {
|
||||
if (navigate) {
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
return {
|
||||
filters,
|
||||
updateFilters,
|
||||
handlePageChange,
|
||||
handleEventClick,
|
||||
handleViewDetail
|
||||
};
|
||||
};
|
||||
@@ -210,8 +210,6 @@
|
||||
|
||||
/* 热点事件部分样式 */
|
||||
.hot-events-section {
|
||||
margin-top: 48px;
|
||||
padding: 32px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.06);
|
||||
|
||||
@@ -1,490 +1,126 @@
|
||||
// src/views/Community/index.js
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { fetchPopularKeywords, fetchHotEvents } from '../../store/slices/communityDataSlice';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Grid,
|
||||
GridItem,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Button,
|
||||
Text,
|
||||
Heading,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
Spinner,
|
||||
Flex,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagCloseButton,
|
||||
IconButton,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
Stat,
|
||||
StatLabel,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
DrawerContent,
|
||||
DrawerCloseButton,
|
||||
useDisclosure,
|
||||
Center,
|
||||
Image,
|
||||
Divider,
|
||||
useColorModeValue,
|
||||
Link,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
RepeatIcon,
|
||||
TimeIcon,
|
||||
InfoIcon,
|
||||
SearchIcon,
|
||||
CalendarIcon,
|
||||
StarIcon,
|
||||
ChevronRightIcon,
|
||||
CloseIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
|
||||
|
||||
// 导入组件
|
||||
import MidjourneyHeroSection from './components/MidjourneyHeroSection';
|
||||
import EventFilters from './components/EventFilters';
|
||||
import EventList from './components/EventList';
|
||||
import EventDetailModal from './components/EventDetailModal';
|
||||
import StockDetailPanel from './components/StockDetailPanel';
|
||||
import SearchBox from './components/SearchBox';
|
||||
import PopularKeywords from './components/PopularKeywords';
|
||||
import HotEvents from './components/HotEvents';
|
||||
import ImportanceLegend from './components/ImportanceLegend';
|
||||
import InvestmentCalendar from './components/InvestmentCalendar';
|
||||
import { eventService } from '../../services/eventService';
|
||||
import EventTimelineCard from './components/EventTimelineCard';
|
||||
import HotEventsSection from './components/HotEventsSection';
|
||||
import EventModals from './components/EventModals';
|
||||
|
||||
// 导入自定义 Hooks
|
||||
import { useEventData } from './hooks/useEventData';
|
||||
import { useEventFilters } from './hooks/useEventFilters';
|
||||
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
|
||||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||||
|
||||
const filterLabelMap = {
|
||||
date_range: v => v ? `日期: ${v}` : '',
|
||||
sort: v => v ? `排序: ${v === 'new' ? '最新' : v === 'hot' ? '热门' : v === 'returns' ? '收益率' : v}` : '',
|
||||
importance: v => v && v !== 'all' ? `重要性: ${v}` : '',
|
||||
industry_classification: v => v ? `行业: ${v}` : '',
|
||||
industry_code: v => v ? `行业代码: ${v}` : '',
|
||||
q: v => v ? `关键词: ${v}` : '',
|
||||
};
|
||||
|
||||
const Community = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Redux状态
|
||||
const { popularKeywords, hotEvents } = useSelector(state => state.communityData);
|
||||
|
||||
// Chakra UI hooks
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
|
||||
// Modal/Drawer控制
|
||||
const { isOpen: isEventModalOpen, onOpen: onEventModalOpen, onClose: onEventModalClose } = useDisclosure();
|
||||
const { isOpen: isStockDrawerOpen, onOpen: onStockDrawerOpen, onClose: onStockDrawerClose } = useDisclosure();
|
||||
|
||||
// 状态管理
|
||||
const [events, setEvents] = useState([]);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Ref:用于滚动到实时事件时间轴
|
||||
const eventTimelineRef = useRef(null);
|
||||
const hasScrolledRef = useRef(false); // 标记是否已滚动
|
||||
|
||||
// ⚡ 通知权限引导
|
||||
const { showCommunityGuide } = useNotification();
|
||||
|
||||
// Modal/Drawer状态
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const [selectedEventForStock, setSelectedEventForStock] = useState(null);
|
||||
const [popularKeywords, setPopularKeywords] = useState([]);
|
||||
const [hotEvents, setHotEvents] = useState([]);
|
||||
const [lastUpdateTime, setLastUpdateTime] = useState(new Date());
|
||||
|
||||
// 从URL获取筛选参数
|
||||
const getFiltersFromUrl = useCallback(() => {
|
||||
return {
|
||||
sort: searchParams.get('sort') || 'new',
|
||||
importance: searchParams.get('importance') || 'all',
|
||||
date_range: searchParams.get('date_range') || '',
|
||||
q: searchParams.get('q') || '',
|
||||
search_type: searchParams.get('search_type') || 'topic',
|
||||
industry_classification: searchParams.get('industry_classification') || '',
|
||||
industry_code: searchParams.get('industry_code') || '',
|
||||
page: parseInt(searchParams.get('page') || '1', 10)
|
||||
};
|
||||
}, [searchParams]);
|
||||
// 自定义 Hooks
|
||||
const { filters, updateFilters, handlePageChange, handleEventClick, handleViewDetail } = useEventFilters({
|
||||
navigate,
|
||||
onEventClick: (event) => setSelectedEventForStock(event),
|
||||
eventTimelineRef
|
||||
});
|
||||
|
||||
// 更新URL参数
|
||||
const updateUrlParams = useCallback((params) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
newParams.set(key, value);
|
||||
} else {
|
||||
newParams.delete(key);
|
||||
}
|
||||
});
|
||||
setSearchParams(newParams);
|
||||
}, [searchParams, setSearchParams]);
|
||||
const { events, pagination, loading, lastUpdateTime } = useEventData(filters);
|
||||
|
||||
// 加载事件列表
|
||||
const loadEvents = useCallback(async (page = 1) => {
|
||||
logger.debug('Community', 'loadEvents 被调用', { page });
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters = getFiltersFromUrl();
|
||||
const response = await eventService.getEvents({
|
||||
...filters,
|
||||
page,
|
||||
per_page: pagination.pageSize
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setEvents(response.data.events);
|
||||
setPagination({
|
||||
current: response.data.pagination.page,
|
||||
pageSize: response.data.pagination.per_page,
|
||||
total: response.data.pagination.total
|
||||
});
|
||||
setLastUpdateTime(new Date());
|
||||
}
|
||||
} catch (error) {
|
||||
// ❌ 移除 toast,仅 console 输出
|
||||
logger.error('Community', 'loadEvents', error, {
|
||||
page,
|
||||
filters: getFiltersFromUrl()
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getFiltersFromUrl, pagination.pageSize]); // ✅ 移除 toast 依赖
|
||||
|
||||
// 加载热门关键词
|
||||
const loadPopularKeywords = useCallback(async () => {
|
||||
try {
|
||||
const response = await eventService.getPopularKeywords(20);
|
||||
if (response.success) {
|
||||
setPopularKeywords(response.data);
|
||||
logger.debug('Community', '热门关键词加载成功', {
|
||||
count: response.data?.length || 0
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Community', 'loadPopularKeywords', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 加载热点事件
|
||||
const loadHotEvents = useCallback(async () => {
|
||||
try {
|
||||
const response = await eventService.getHotEvents({ days: 5, limit: 4 });
|
||||
if (response.success) {
|
||||
setHotEvents(response.data);
|
||||
logger.debug('Community', '热点事件加载成功', {
|
||||
count: response.data?.length || 0
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Community', 'loadHotEvents', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
// 处理筛选变化
|
||||
const handleFilterChange = useCallback((filterType, value) => {
|
||||
updateUrlParams({ [filterType]: value, page: 1 });
|
||||
}, [updateUrlParams]);
|
||||
|
||||
// 处理分页变化
|
||||
const handlePageChange = useCallback((page) => {
|
||||
updateUrlParams({ page });
|
||||
loadEvents(page);
|
||||
window.scrollTo(0, 0);
|
||||
}, [updateUrlParams, loadEvents]);
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = useCallback((event) => {
|
||||
setSelectedEventForStock(event);
|
||||
onStockDrawerOpen();
|
||||
}, [onStockDrawerOpen]);
|
||||
|
||||
// 处理查看详情
|
||||
const handleViewDetail = useCallback((eventId) => {
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
}, [navigate]);
|
||||
|
||||
// 处理关键词点击
|
||||
const handleKeywordClick = useCallback((keyword) => {
|
||||
updateUrlParams({ q: keyword, page: 1 });
|
||||
}, [updateUrlParams]);
|
||||
|
||||
|
||||
|
||||
// 处理标签删除
|
||||
const handleRemoveFilterTag = (key) => {
|
||||
let reset = '';
|
||||
if (key === 'sort') reset = 'new';
|
||||
if (key === 'importance') reset = 'all';
|
||||
updateUrlParams({ [key]: reset, page: 1 });
|
||||
loadEvents(1);
|
||||
};
|
||||
|
||||
// 获取筛选标签
|
||||
const filters = getFiltersFromUrl();
|
||||
const filterTags = Object.entries(filters)
|
||||
.filter(([key, value]) => {
|
||||
if (key === 'industry_code') return !!value;
|
||||
if (key === 'importance') return value && value !== 'all';
|
||||
if (key === 'sort') return value && value !== 'new';
|
||||
if (key === 'date_range') return !!value;
|
||||
if (key === 'q') return !!value;
|
||||
return false;
|
||||
})
|
||||
.map(([key, value]) => {
|
||||
if (key === 'industry_code') return { key, label: `行业代码: ${value}` };
|
||||
return { key, label: filterLabelMap[key] ? filterLabelMap[key](value) : `${key}: ${value}` };
|
||||
});
|
||||
|
||||
// 初始化加载
|
||||
// 注意: 只监听 searchParams 变化,不监听 loadEvents 等函数
|
||||
// 这是为了避免 StockDetailPanel 打开时触发不必要的重新加载
|
||||
// 如果未来 loadEvents 添加了新的状态依赖,需要在此处同步更新
|
||||
// 加载热门关键词和热点事件(使用Redux,内部有缓存判断)
|
||||
useEffect(() => {
|
||||
logger.debug('Community', 'useEffect 触发,searchParams 变化', {
|
||||
params: searchParams.toString()
|
||||
});
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
loadEvents(page);
|
||||
loadPopularKeywords();
|
||||
loadHotEvents();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams]); // 只监听 URL 参数变化
|
||||
dispatch(fetchPopularKeywords());
|
||||
dispatch(fetchHotEvents());
|
||||
}, [dispatch]);
|
||||
|
||||
// ⚡ 首次访问社区时,延迟显示权限引导
|
||||
useEffect(() => {
|
||||
if (showCommunityGuide) {
|
||||
const timer = setTimeout(() => {
|
||||
logger.info('Community', '显示社区权限引导');
|
||||
showCommunityGuide();
|
||||
}, 5000); // 延迟 5 秒,让用户先浏览页面
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [showCommunityGuide]); // 只在组件挂载时执行一次
|
||||
|
||||
// ⚡ 滚动到实时事件区域(由搜索框聚焦触发)
|
||||
const scrollToTimeline = useCallback(() => {
|
||||
if (!hasScrolledRef.current && eventTimelineRef.current) {
|
||||
eventTimelineRef.current.scrollIntoView({
|
||||
behavior: 'smooth', // 平滑滚动动画
|
||||
block: 'start', // 元素顶部对齐视口顶部,标题正好可见
|
||||
inline: 'nearest' // 水平方向最小滚动
|
||||
});
|
||||
hasScrolledRef.current = true; // 标记已滚动
|
||||
logger.debug('Community', '用户触发搜索,滚动到实时事件时间轴');
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box minH="100vh" bg={bgColor}>
|
||||
{/* 导航栏已由 MainLayout 提供 */}
|
||||
|
||||
{/* Midjourney风格英雄区域 */}
|
||||
<MidjourneyHeroSection />
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<Container maxW="container.xl" py={8}>
|
||||
<Grid templateColumns={{ base: '1fr', lg: '2fr 1fr' }} gap={6}>
|
||||
|
||||
{/* 左侧主要内容 */}
|
||||
<GridItem>
|
||||
{/* 筛选器 - 需要改造为Chakra UI版本 */}
|
||||
<Card mb={4} bg={cardBg} borderColor={borderColor}>
|
||||
<CardBody>
|
||||
<EventFilters
|
||||
filters={filters}
|
||||
onFilterChange={handleFilterChange}
|
||||
loading={loading}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Container maxW="container.xl" pt={6} pb={8}>
|
||||
{/* 热点事件区域 */}
|
||||
<HotEventsSection events={hotEvents} />
|
||||
|
||||
{/* 筛选标签 */}
|
||||
{filterTags.length > 0 && (
|
||||
<Wrap spacing={2} mb={4}>
|
||||
{filterTags.map(tag => (
|
||||
<WrapItem key={tag.key}>
|
||||
<Tag size="md" variant="solid" colorScheme="blue">
|
||||
<TagLabel>{tag.label}</TagLabel>
|
||||
<TagCloseButton onClick={() => handleRemoveFilterTag(tag.key)} />
|
||||
</Tag>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
)}
|
||||
|
||||
{/* 事件列表卡片 */}
|
||||
<Card bg={cardBg} borderColor={borderColor}>
|
||||
<CardHeader>
|
||||
<Flex justify="space-between" align="center">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="md">
|
||||
<HStack>
|
||||
<TimeIcon />
|
||||
<Text>实时事件时间轴</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
<HStack fontSize="sm" color="gray.500">
|
||||
<Badge colorScheme="green">全网监控</Badge>
|
||||
<Badge colorScheme="orange">智能捕获</Badge>
|
||||
<Badge colorScheme="purple">深度分析</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
最后更新: {lastUpdateTime.toLocaleTimeString()}
|
||||
</Text>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
<CardBody>
|
||||
{loading ? (
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color="gray.500">正在加载最新事件...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : events.length > 0 ? (
|
||||
<EventList
|
||||
events={events}
|
||||
pagination={pagination}
|
||||
onPageChange={handlePageChange}
|
||||
onEventClick={handleEventClick}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
) : (
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Text fontSize="lg" color="gray.500">暂无事件数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</GridItem>
|
||||
|
||||
{/* 右侧侧边栏 */}
|
||||
<GridItem>
|
||||
<VStack spacing={4}>
|
||||
{/* 搜索框 - 需要改造为Chakra UI版本 */}
|
||||
<Card w="full" bg={cardBg}>
|
||||
<CardBody>
|
||||
<SearchBox
|
||||
onSearch={(values) => {
|
||||
updateUrlParams({ ...values, page: 1 });
|
||||
}}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 投资日历 - 需要改造为Chakra UI版本 */}
|
||||
<Card w="full" bg={cardBg}>
|
||||
<CardHeader>
|
||||
<Heading size="sm">
|
||||
<HStack>
|
||||
<CalendarIcon />
|
||||
<Text>投资日历</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<InvestmentCalendar />
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 热门关键词 - 需要改造为Chakra UI版本 */}
|
||||
<Card w="full" bg={cardBg}>
|
||||
<CardHeader>
|
||||
<Heading size="sm">
|
||||
<HStack>
|
||||
<StarIcon />
|
||||
<Text>热门关键词</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<PopularKeywords
|
||||
keywords={popularKeywords}
|
||||
onKeywordClick={handleKeywordClick}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 重要性说明 - 需要改造为Chakra UI版本 */}
|
||||
<Card w="full" bg={cardBg}>
|
||||
<CardHeader>
|
||||
<Heading size="sm">
|
||||
<HStack>
|
||||
<InfoIcon />
|
||||
<Text>重要性说明</Text>
|
||||
</HStack>
|
||||
</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<ImportanceLegend />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</VStack>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
|
||||
{/* 热点事件 - 需要改造为Chakra UI版本 */}
|
||||
{hotEvents.length > 0 && (
|
||||
<Card mt={8} bg={cardBg}>
|
||||
<CardHeader>
|
||||
<Heading size="md">🔥 热点事件</Heading>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<HotEvents events={hotEvents} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{/* 实时事件 */}
|
||||
<EventTimelineCard
|
||||
ref={eventTimelineRef}
|
||||
mt={6}
|
||||
events={events}
|
||||
loading={loading}
|
||||
pagination={pagination}
|
||||
filters={filters}
|
||||
popularKeywords={popularKeywords}
|
||||
lastUpdateTime={lastUpdateTime}
|
||||
onSearch={updateFilters}
|
||||
onSearchFocus={scrollToTimeline}
|
||||
onPageChange={handlePageChange}
|
||||
onEventClick={handleEventClick}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
{/* Footer区域 */}
|
||||
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
|
||||
<Container maxW="container.xl">
|
||||
<VStack spacing={2}>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
© 2024 价值前沿. 保留所有权利.
|
||||
</Text>
|
||||
<HStack spacing={4} fontSize="xs" color="gray.400">
|
||||
<Link
|
||||
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
|
||||
isExternal
|
||||
_hover={{ color: 'gray.600' }}
|
||||
>
|
||||
京公网安备11010802046286号
|
||||
</Link>
|
||||
<Text>京ICP备2025107343号-1</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* 事件详情模态框 - 使用Chakra UI Modal */}
|
||||
<Modal isOpen={isEventModalOpen && selectedEvent} onClose={onEventModalClose} size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>事件详情</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody pb={6}>
|
||||
<EventDetailModal
|
||||
event={selectedEvent}
|
||||
onClose={() => {
|
||||
setSelectedEvent(null);
|
||||
onEventModalClose();
|
||||
}}
|
||||
/>
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* 股票详情抽屉 - 使用原组件自带的 Antd Drawer,避免与 Chakra Drawer 重叠导致空白 */}
|
||||
<StockDetailPanel
|
||||
visible={!!selectedEventForStock}
|
||||
event={selectedEventForStock}
|
||||
onClose={() => {
|
||||
setSelectedEventForStock(null);
|
||||
onStockDrawerClose();
|
||||
{/* 事件弹窗 */}
|
||||
<EventModals
|
||||
eventModalState={{
|
||||
isOpen: !!selectedEvent,
|
||||
onClose: () => setSelectedEvent(null),
|
||||
event: selectedEvent,
|
||||
onEventClose: () => setSelectedEvent(null)
|
||||
}}
|
||||
stockDrawerState={{
|
||||
visible: !!selectedEventForStock,
|
||||
event: selectedEventForStock,
|
||||
onClose: () => setSelectedEventForStock(null)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -473,6 +473,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
}
|
||||
];
|
||||
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(balanceSheet) || balanceSheet.length === 0) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
暂无资产负债表数据
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(balanceSheet.length, 6);
|
||||
const displayData = balanceSheet.slice(0, maxColumns);
|
||||
|
||||
@@ -707,6 +717,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
}
|
||||
];
|
||||
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(incomeStatement) || incomeStatement.length === 0) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
暂无利润表数据
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(incomeStatement.length, 6);
|
||||
const displayData = incomeStatement.slice(0, maxColumns);
|
||||
|
||||
@@ -866,6 +886,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
{ name: '自由现金流', key: 'free_cash_flow', path: 'key_metrics.free_cash_flow' },
|
||||
];
|
||||
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(cashflow) || cashflow.length === 0) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
暂无现金流量表数据
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(cashflow.length, 8);
|
||||
const displayData = cashflow.slice(0, maxColumns);
|
||||
|
||||
@@ -1069,6 +1099,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 数组安全检查
|
||||
if (!Array.isArray(financialMetrics) || financialMetrics.length === 0) {
|
||||
return (
|
||||
<Alert status="info">
|
||||
<AlertIcon />
|
||||
暂无财务指标数据
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const maxColumns = Math.min(financialMetrics.length, 6);
|
||||
const displayData = financialMetrics.slice(0, maxColumns);
|
||||
const currentCategory = metricsCategories[selectedCategory];
|
||||
@@ -1426,8 +1466,9 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
|
||||
return (
|
||||
<VStack spacing={4} align="stretch">
|
||||
{industryRank.map((periodData, periodIdx) => (
|
||||
<Card key={periodIdx}>
|
||||
{Array.isArray(industryRank) && industryRank.length > 0 ? (
|
||||
industryRank.map((periodData, periodIdx) => (
|
||||
<Card key={periodIdx}>
|
||||
<CardHeader>
|
||||
<HStack justify="space-between">
|
||||
<Heading size="sm">{periodData.report_type} 行业排名</Heading>
|
||||
@@ -1486,7 +1527,16 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
))}
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Text textAlign="center" color="gray.500" py={8}>
|
||||
暂无行业排名数据
|
||||
</Text>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
@@ -1738,7 +1788,7 @@ const FinancialPanorama = ({ stockCode: propStockCode }) => {
|
||||
|
||||
// 综合对比分析
|
||||
const ComparisonAnalysis = () => {
|
||||
if (!comparison || comparison.length === 0) return null;
|
||||
if (!Array.isArray(comparison) || comparison.length === 0) return null;
|
||||
|
||||
const revenueData = comparison.map(item => ({
|
||||
period: formatUtils.getReportType(item.period),
|
||||
|
||||
@@ -1471,7 +1471,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
</HStack>
|
||||
</StatLabel>
|
||||
<StatNumber color={theme.textPrimary} fontSize="lg">
|
||||
{minuteData.data[0]?.open.toFixed(2)}
|
||||
{minuteData.data[0]?.open != null ? minuteData.data[0].open.toFixed(2) : '-'}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
@@ -1485,13 +1485,15 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
color={minuteData.data[minuteData.data.length - 1]?.close >= minuteData.data[0]?.open ? theme.success : theme.danger}
|
||||
fontSize="lg"
|
||||
>
|
||||
{minuteData.data[minuteData.data.length - 1]?.close.toFixed(2)}
|
||||
{minuteData.data[minuteData.data.length - 1]?.close != null ? minuteData.data[minuteData.data.length - 1].close.toFixed(2) : '-'}
|
||||
</StatNumber>
|
||||
<StatHelpText fontSize="xs">
|
||||
<StatArrow
|
||||
type={minuteData.data[minuteData.data.length - 1]?.close >= minuteData.data[0]?.open ? 'increase' : 'decrease'}
|
||||
/>
|
||||
{Math.abs(((minuteData.data[minuteData.data.length - 1]?.close - minuteData.data[0]?.open) / minuteData.data[0]?.open * 100)).toFixed(2)}%
|
||||
{(minuteData.data[minuteData.data.length - 1]?.close != null && minuteData.data[0]?.open != null)
|
||||
? Math.abs(((minuteData.data[minuteData.data.length - 1].close - minuteData.data[0].open) / minuteData.data[0].open * 100)).toFixed(2)
|
||||
: '0.00'}%
|
||||
</StatHelpText>
|
||||
</Stat>
|
||||
<Stat>
|
||||
@@ -1502,7 +1504,10 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
</HStack>
|
||||
</StatLabel>
|
||||
<StatNumber color={theme.success} fontSize="lg">
|
||||
{Math.max(...minuteData.data.map(item => item.high)).toFixed(2)}
|
||||
{(() => {
|
||||
const highs = minuteData.data.map(item => item.high).filter(h => h != null);
|
||||
return highs.length > 0 ? Math.max(...highs).toFixed(2) : '-';
|
||||
})()}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
<Stat>
|
||||
@@ -1513,7 +1518,10 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
</HStack>
|
||||
</StatLabel>
|
||||
<StatNumber color={theme.danger} fontSize="lg">
|
||||
{Math.min(...minuteData.data.map(item => item.low)).toFixed(2)}
|
||||
{(() => {
|
||||
const lows = minuteData.data.map(item => item.low).filter(l => l != null);
|
||||
return lows.length > 0 ? Math.min(...lows).toFixed(2) : '-';
|
||||
})()}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</SimpleGrid>
|
||||
@@ -1558,7 +1566,10 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
平均价格
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.textPrimary}>
|
||||
{(minuteData.data.reduce((sum, item) => sum + item.close, 0) / minuteData.data.length).toFixed(2)}
|
||||
{(() => {
|
||||
const closes = minuteData.data.map(item => item.close).filter(c => c != null);
|
||||
return closes.length > 0 ? (closes.reduce((sum, c) => sum + c, 0) / closes.length).toFixed(2) : '-';
|
||||
})()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
@@ -1744,7 +1755,7 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
成交额: {formatUtils.formatNumber(dayStats.total_amount)}万元
|
||||
</Badge>
|
||||
<Badge colorScheme="purple" fontSize="md">
|
||||
均价: {dayStats.avg_price.toFixed(2)}元
|
||||
均价: {dayStats.avg_price != null ? dayStats.avg_price.toFixed(2) : '-'}元
|
||||
</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
@@ -1766,23 +1777,23 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
{dayStats.deals.map((deal, i) => (
|
||||
<Tr key={i} _hover={{ bg: colorMode === 'light' ? 'rgba(43, 108, 176, 0.05)' : 'rgba(255, 215, 0, 0.1)' }}>
|
||||
<Td color={theme.textPrimary} fontSize="xs" maxW="200px" isTruncated>
|
||||
<Tooltip label={deal.buyer_dept} placement="top">
|
||||
<Text>{deal.buyer_dept}</Text>
|
||||
<Tooltip label={deal.buyer_dept || '-'} placement="top">
|
||||
<Text>{deal.buyer_dept || '-'}</Text>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td color={theme.textPrimary} fontSize="xs" maxW="200px" isTruncated>
|
||||
<Tooltip label={deal.seller_dept} placement="top">
|
||||
<Text>{deal.seller_dept}</Text>
|
||||
<Tooltip label={deal.seller_dept || '-'} placement="top">
|
||||
<Text>{deal.seller_dept || '-'}</Text>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary} fontWeight="bold">
|
||||
{deal.price.toFixed(2)}
|
||||
{deal.price != null ? deal.price.toFixed(2) : '-'}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>
|
||||
{deal.volume.toFixed(2)}
|
||||
{deal.volume != null ? deal.volume.toFixed(2) : '-'}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textSecondary} fontWeight="bold">
|
||||
{deal.amount.toFixed(2)}
|
||||
{deal.amount != null ? deal.amount.toFixed(2) : '-'}
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
@@ -1845,22 +1856,26 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
买入前五
|
||||
</Text>
|
||||
<VStack spacing={1} align="stretch">
|
||||
{dayData.buyers.slice(0, 5).map((buyer, i) => (
|
||||
<HStack
|
||||
key={i}
|
||||
justify="space-between"
|
||||
p={2}
|
||||
bg={colorMode === 'light' ? 'rgba(255, 68, 68, 0.05)' : 'rgba(255, 68, 68, 0.1)'}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
|
||||
{buyer.dept_name}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.success} fontWeight="bold">
|
||||
{formatUtils.formatNumber(buyer.buy_amount)}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
{dayData.buyers && dayData.buyers.length > 0 ? (
|
||||
dayData.buyers.slice(0, 5).map((buyer, i) => (
|
||||
<HStack
|
||||
key={i}
|
||||
justify="space-between"
|
||||
p={2}
|
||||
bg={colorMode === 'light' ? 'rgba(255, 68, 68, 0.05)' : 'rgba(255, 68, 68, 0.1)'}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
|
||||
{buyer.dept_name}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.success} fontWeight="bold">
|
||||
{formatUtils.formatNumber(buyer.buy_amount)}
|
||||
</Text>
|
||||
</HStack>
|
||||
))
|
||||
) : (
|
||||
<Text fontSize="sm" color={theme.textMuted}>暂无数据</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
@@ -1869,22 +1884,26 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
卖出前五
|
||||
</Text>
|
||||
<VStack spacing={1} align="stretch">
|
||||
{dayData.sellers.slice(0, 5).map((seller, i) => (
|
||||
<HStack
|
||||
key={i}
|
||||
justify="space-between"
|
||||
p={2}
|
||||
bg={colorMode === 'light' ? 'rgba(0, 200, 81, 0.05)' : 'rgba(0, 200, 81, 0.1)'}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
|
||||
{seller.dept_name}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.danger} fontWeight="bold">
|
||||
{formatUtils.formatNumber(seller.sell_amount)}
|
||||
</Text>
|
||||
</HStack>
|
||||
))}
|
||||
{dayData.sellers && dayData.sellers.length > 0 ? (
|
||||
dayData.sellers.slice(0, 5).map((seller, i) => (
|
||||
<HStack
|
||||
key={i}
|
||||
justify="space-between"
|
||||
p={2}
|
||||
bg={colorMode === 'light' ? 'rgba(0, 200, 81, 0.05)' : 'rgba(0, 200, 81, 0.1)'}
|
||||
borderRadius="md"
|
||||
>
|
||||
<Text fontSize="sm" color={theme.textPrimary} isTruncated maxW="70%">
|
||||
{seller.dept_name}
|
||||
</Text>
|
||||
<Text fontSize="sm" color={theme.danger} fontWeight="bold">
|
||||
{formatUtils.formatNumber(seller.sell_amount)}
|
||||
</Text>
|
||||
</HStack>
|
||||
))
|
||||
) : (
|
||||
<Text fontSize="sm" color={theme.textMuted}>暂无数据</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
</Grid>
|
||||
@@ -1948,19 +1967,27 @@ const MarketDataView = ({ stockCode: propStockCode }) => {
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{pledgeData.map((item, idx) => (
|
||||
<Tr key={idx} _hover={{ bg: colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.1)' }}>
|
||||
<Td color={theme.textPrimary}>{item.end_date}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.unrestricted_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.restricted_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary} fontWeight="bold">{formatUtils.formatNumber(item.total_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.total_shares, 0)}</Td>
|
||||
<Td isNumeric color={theme.warning} fontWeight="bold">
|
||||
{formatUtils.formatPercent(item.pledge_ratio)}
|
||||
{Array.isArray(pledgeData) && pledgeData.length > 0 ? (
|
||||
pledgeData.map((item, idx) => (
|
||||
<Tr key={idx} _hover={{ bg: colorMode === 'light' ? theme.bgDark : 'rgba(255, 215, 0, 0.1)' }}>
|
||||
<Td color={theme.textPrimary}>{item.end_date}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.unrestricted_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.restricted_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary} fontWeight="bold">{formatUtils.formatNumber(item.total_pledge, 0)}</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{formatUtils.formatNumber(item.total_shares, 0)}</Td>
|
||||
<Td isNumeric color={theme.warning} fontWeight="bold">
|
||||
{formatUtils.formatPercent(item.pledge_ratio)}
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{item.pledge_count}</Td>
|
||||
</Tr>
|
||||
))
|
||||
) : (
|
||||
<Tr>
|
||||
<Td colSpan={7} textAlign="center" py={8}>
|
||||
<Text fontSize="sm" color={theme.textMuted}>暂无数据</Text>
|
||||
</Td>
|
||||
<Td isNumeric color={theme.textPrimary}>{item.pledge_count}</Td>
|
||||
</Tr>
|
||||
))}
|
||||
)}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
@@ -67,7 +67,11 @@ export default function CenterDashboard() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
|
||||
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
|
||||
const userId = user?.id;
|
||||
const prevUserIdRef = React.useRef(userId);
|
||||
|
||||
// 颜色主题
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
@@ -107,13 +111,13 @@ export default function CenterDashboard() {
|
||||
if (jc.success) setEventComments(Array.isArray(jc.data) ? jc.data : []);
|
||||
} catch (err) {
|
||||
logger.error('Center', 'loadData', err, {
|
||||
userId: user?.id,
|
||||
userId,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [user?.id]); // 只依赖 user.id,避免无限循环
|
||||
}, [userId]); // ⚡ 使用 userId 而不是 user?.id
|
||||
|
||||
// 加载实时行情
|
||||
const loadRealtimeQuotes = useCallback(async () => {
|
||||
@@ -188,7 +192,14 @@ export default function CenterDashboard() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user && location.pathname.includes('/home/center')) {
|
||||
const userIdChanged = prevUserIdRef.current !== userId;
|
||||
|
||||
if (userIdChanged) {
|
||||
prevUserIdRef.current = userId;
|
||||
}
|
||||
|
||||
// 只在 userId 真正变化或路径变化时加载数据
|
||||
if ((userIdChanged || !prevUserIdRef.current) && user && location.pathname.includes('/home/center')) {
|
||||
loadData();
|
||||
}
|
||||
|
||||
@@ -199,7 +210,7 @@ export default function CenterDashboard() {
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVis);
|
||||
return () => document.removeEventListener('visibilitychange', onVis);
|
||||
}, [user?.id, location.pathname, loadData]); // 只依赖 user.id,避免无限循环
|
||||
}, [userId, location.pathname, loadData, user]); // ⚡ 使用 userId,防重复通过 ref 判断
|
||||
|
||||
// 定时刷新实时行情(每分钟一次)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -39,7 +39,6 @@ import {
|
||||
Center,
|
||||
useToast,
|
||||
Skeleton,
|
||||
Link,
|
||||
} from '@chakra-ui/react';
|
||||
import { FiLock } from 'react-icons/fi';
|
||||
import {
|
||||
@@ -823,26 +822,6 @@ const EventDetail = () => {
|
||||
</VStack>
|
||||
</Container>
|
||||
|
||||
{/* Footer区域 */}
|
||||
<Box bg={useColorModeValue('gray.100', 'gray.800')} py={6} mt={8}>
|
||||
<Container maxW="7xl">
|
||||
<VStack spacing={2}>
|
||||
<Text color="gray.500" fontSize="sm">
|
||||
© 2024 价值前沿. 保留所有权利.
|
||||
</Text>
|
||||
<HStack spacing={4} fontSize="xs" color="gray.400">
|
||||
<Link
|
||||
href="https://beian.mps.gov.cn/#/query/webSearch?code=11010802046286"
|
||||
isExternal
|
||||
_hover={{ color: 'gray.600' }}
|
||||
>
|
||||
京公网安备11010802046286号
|
||||
</Link>
|
||||
<Text>京ICP备2025107343号-1</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Container>
|
||||
</Box>
|
||||
{/* 升级弹窗 */}
|
||||
<SubscriptionUpgradeModal
|
||||
isOpen={upgradeModal.isOpen}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user