Compare commits
35 Commits
origin_pro
...
1fc9f4790f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fc9f4790f | ||
| b48ff99658 | |||
| ae558996b6 | |||
| 71742c0116 | |||
| 2ead50c37c | |||
| 9e8519bb94 | |||
| a4d16e7686 | |||
|
|
3eb31c99dc | ||
|
|
5f6b4b083b | ||
|
|
905023c056 | ||
|
|
25cc28e03b | ||
|
|
5f9901a098 | ||
|
|
28643d7c4a | ||
|
|
bb28e141e6 | ||
|
|
8fa273c8d4 | ||
|
|
17c04211bb | ||
|
|
c9419d3c14 | ||
|
|
dfc13c5737 | ||
|
|
de8d0ef1c3 | ||
|
|
65c16d65ac | ||
|
|
13a291b979 | ||
|
|
4d6da77aeb | ||
|
|
fc1f667700 | ||
|
|
46639030bb | ||
|
|
f747a0bdb2 | ||
|
|
9b55610167 | ||
|
|
a93fcfa9b9 | ||
|
|
8914a46c40 | ||
|
|
678eb6838e | ||
|
|
c06d3a88ae | ||
|
|
307c308739 | ||
|
|
cbb6517bb1 | ||
|
|
f33489f5d7 | ||
|
|
9ff77b570d | ||
|
|
de37546ddb |
@@ -44,7 +44,7 @@
|
||||
**前端**
|
||||
- **核心框架**: React 18.3.1
|
||||
- **类型系统**: TypeScript 5.9.3(渐进式接入中,支持 JS/TS 混合开发)
|
||||
- **UI 组件库**: Chakra UI 2.8.2(主要) + Ant Design 5.27.4(表格/表单)
|
||||
- **UI 组件库**: Chakra UI 2.10.9(主要) + Ant Design 5.27.4(表格/表单)
|
||||
- **状态管理**: Redux Toolkit 2.9.2
|
||||
- **路由**: React Router v6.30.1 配合 React.lazy() 实现代码分割
|
||||
- **构建系统**: CRACO 7.1.0 + 激进的 webpack 5 优化
|
||||
|
||||
316
app.py
316
app.py
@@ -1127,36 +1127,61 @@ def get_user_subscription_safe(user_id):
|
||||
|
||||
|
||||
def activate_user_subscription(user_id, plan_type, billing_cycle, extend_from_now=False):
|
||||
"""激活用户订阅
|
||||
"""
|
||||
激活用户订阅(新版:续费时从当前订阅结束时间开始延长)
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
plan_type: 套餐类型
|
||||
billing_cycle: 计费周期
|
||||
extend_from_now: 是否从当前时间开始延长(用于升级场景)
|
||||
plan_type: 套餐类型 (pro/max)
|
||||
billing_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
|
||||
extend_from_now: 废弃参数,保留以兼容(现在自动判断)
|
||||
|
||||
Returns:
|
||||
UserSubscription 对象 或 None
|
||||
"""
|
||||
try:
|
||||
subscription = UserSubscription.query.filter_by(user_id=user_id).first()
|
||||
if not subscription:
|
||||
# 新用户,创建订阅记录
|
||||
subscription = UserSubscription(user_id=user_id)
|
||||
db.session.add(subscription)
|
||||
|
||||
# 更新订阅类型和状态
|
||||
subscription.subscription_type = plan_type
|
||||
subscription.subscription_status = 'active'
|
||||
subscription.billing_cycle = billing_cycle
|
||||
|
||||
if not extend_from_now or not subscription.start_date:
|
||||
subscription.start_date = beijing_now()
|
||||
# 计算订阅周期天数
|
||||
cycle_days_map = {
|
||||
'monthly': 30,
|
||||
'quarterly': 90, # 3个月
|
||||
'semiannual': 180, # 6个月
|
||||
'yearly': 365
|
||||
}
|
||||
days = cycle_days_map.get(billing_cycle, 30)
|
||||
|
||||
if billing_cycle == 'monthly':
|
||||
subscription.end_date = beijing_now() + timedelta(days=30)
|
||||
else: # yearly
|
||||
subscription.end_date = beijing_now() + timedelta(days=365)
|
||||
now = beijing_now()
|
||||
|
||||
# 判断是新购还是续费
|
||||
if subscription.end_date and subscription.end_date > now:
|
||||
# 续费:从当前订阅结束时间开始延长
|
||||
start_date = subscription.end_date
|
||||
end_date = start_date + timedelta(days=days)
|
||||
else:
|
||||
# 新购或过期后重新购买:从当前时间开始
|
||||
start_date = now
|
||||
end_date = now + timedelta(days=days)
|
||||
subscription.start_date = start_date
|
||||
|
||||
subscription.end_date = end_date
|
||||
subscription.updated_at = now
|
||||
|
||||
subscription.updated_at = beijing_now()
|
||||
db.session.commit()
|
||||
return subscription
|
||||
|
||||
except Exception as e:
|
||||
print(f"激活订阅失败: {e}")
|
||||
db.session.rollback()
|
||||
return None
|
||||
|
||||
|
||||
@@ -1233,33 +1258,29 @@ def calculate_discount(promo_code, amount):
|
||||
return 0
|
||||
|
||||
|
||||
def calculate_remaining_value(subscription, current_plan):
|
||||
"""计算当前订阅的剩余价值"""
|
||||
try:
|
||||
if not subscription or not subscription.end_date:
|
||||
return 0
|
||||
def calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_code=None):
|
||||
"""
|
||||
简化版价格计算:续费用户和新用户价格完全一致,不计算剩余价值
|
||||
|
||||
now = beijing_now()
|
||||
if subscription.end_date <= now:
|
||||
return 0
|
||||
|
||||
days_left = (subscription.end_date - now).days
|
||||
|
||||
if subscription.billing_cycle == 'monthly':
|
||||
daily_value = float(current_plan.monthly_price) / 30
|
||||
else: # yearly
|
||||
daily_value = float(current_plan.yearly_price) / 365
|
||||
|
||||
return daily_value * days_left
|
||||
except:
|
||||
return 0
|
||||
|
||||
|
||||
def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None):
|
||||
"""计算升级所需价格
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
to_plan_name: 目标套餐名称 (pro/max)
|
||||
to_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
|
||||
promo_code: 优惠码(可选)
|
||||
|
||||
Returns:
|
||||
dict: 包含价格计算结果的字典
|
||||
dict: {
|
||||
'is_renewal': False/True, # 是否为续费
|
||||
'subscription_type': 'new'/'renew', # 订阅类型
|
||||
'current_plan': 'pro', # 当前套餐(如果有)
|
||||
'current_cycle': 'yearly', # 当前周期(如果有)
|
||||
'new_plan_price': 2699.00, # 新套餐价格
|
||||
'original_amount': 2699.00, # 原价
|
||||
'discount_amount': 0, # 优惠金额
|
||||
'final_amount': 2699.00, # 实付金额
|
||||
'promo_code': None, # 使用的优惠码
|
||||
'promo_error': None # 优惠码错误信息
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# 1. 获取当前订阅
|
||||
@@ -1270,83 +1291,90 @@ def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None):
|
||||
if not to_plan:
|
||||
return {'error': '目标套餐不存在'}
|
||||
|
||||
# 3. 计算目标套餐价格
|
||||
new_price = float(to_plan.yearly_price if to_cycle == 'yearly' else to_plan.monthly_price)
|
||||
# 3. 根据计费周期获取价格
|
||||
# 优先从 pricing_options 获取价格
|
||||
price = None
|
||||
if to_plan.pricing_options:
|
||||
try:
|
||||
pricing_opts = json.loads(to_plan.pricing_options)
|
||||
# 查找匹配的周期
|
||||
for opt in pricing_opts:
|
||||
cycle_key = opt.get('cycle_key', '')
|
||||
months = opt.get('months', 0)
|
||||
|
||||
# 4. 如果是新订阅(非升级)
|
||||
if not current_sub or current_sub.subscription_type == 'free':
|
||||
result = {
|
||||
'is_upgrade': False,
|
||||
'new_plan_price': new_price,
|
||||
'remaining_value': 0,
|
||||
'upgrade_amount': new_price,
|
||||
'original_amount': new_price,
|
||||
'discount_amount': 0,
|
||||
'final_amount': new_price,
|
||||
'promo_code': None
|
||||
}
|
||||
# 匹配逻辑
|
||||
if (cycle_key == to_cycle or
|
||||
(to_cycle == 'monthly' and months == 1) or
|
||||
(to_cycle == 'quarterly' and months == 3) or
|
||||
(to_cycle == 'semiannual' and months == 6) or
|
||||
(to_cycle == 'yearly' and months == 12)):
|
||||
price = float(opt.get('price', 0))
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
# 应用优惠码
|
||||
if promo_code:
|
||||
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, new_price, user_id)
|
||||
if promo:
|
||||
discount = calculate_discount(promo, new_price)
|
||||
result['discount_amount'] = discount
|
||||
result['final_amount'] = new_price - discount
|
||||
result['promo_code'] = promo.code
|
||||
elif error:
|
||||
result['promo_error'] = error
|
||||
# 如果 pricing_options 中没有找到,使用旧的 monthly_price/yearly_price
|
||||
if price is None:
|
||||
if to_cycle == 'yearly':
|
||||
price = float(to_plan.yearly_price) if to_plan.yearly_price else 0
|
||||
else: # 默认月付
|
||||
price = float(to_plan.monthly_price) if to_plan.monthly_price else 0
|
||||
|
||||
return result
|
||||
if price <= 0:
|
||||
return {'error': f'{to_cycle} 周期价格未配置'}
|
||||
|
||||
# 5. 升级场景:计算剩余价值
|
||||
current_plan = SubscriptionPlan.query.filter_by(name=current_sub.subscription_type, is_active=True).first()
|
||||
if not current_plan:
|
||||
return {'error': '当前套餐信息不存在'}
|
||||
# 4. 判断是新购还是续费
|
||||
is_renewal = False
|
||||
subscription_type = 'new'
|
||||
current_plan = None
|
||||
current_cycle = None
|
||||
|
||||
remaining_value = calculate_remaining_value(current_sub, current_plan)
|
||||
|
||||
# 6. 计算升级差价
|
||||
upgrade_amount = max(0, new_price - remaining_value)
|
||||
|
||||
# 7. 判断升级类型
|
||||
upgrade_type = 'new'
|
||||
if current_sub.subscription_type != to_plan_name and current_sub.billing_cycle != to_cycle:
|
||||
upgrade_type = 'both'
|
||||
elif current_sub.subscription_type != to_plan_name:
|
||||
upgrade_type = 'plan_upgrade'
|
||||
elif current_sub.billing_cycle != to_cycle:
|
||||
upgrade_type = 'cycle_change'
|
||||
if current_sub and current_sub.subscription_type in ['pro', 'max']:
|
||||
# 如果当前是付费用户,则为续费
|
||||
is_renewal = True
|
||||
subscription_type = 'renew'
|
||||
current_plan = current_sub.subscription_type
|
||||
current_cycle = current_sub.billing_cycle
|
||||
|
||||
# 5. 构建结果(续费和新购价格完全一致)
|
||||
result = {
|
||||
'is_upgrade': True,
|
||||
'upgrade_type': upgrade_type,
|
||||
'current_plan': current_sub.subscription_type,
|
||||
'current_cycle': current_sub.billing_cycle,
|
||||
'current_end_date': current_sub.end_date.isoformat() if current_sub.end_date else None,
|
||||
'new_plan_price': new_price,
|
||||
'remaining_value': remaining_value,
|
||||
'upgrade_amount': upgrade_amount,
|
||||
'original_amount': upgrade_amount,
|
||||
'is_renewal': is_renewal,
|
||||
'subscription_type': subscription_type,
|
||||
'current_plan': current_plan,
|
||||
'current_cycle': current_cycle,
|
||||
'new_plan_price': price,
|
||||
'original_amount': price,
|
||||
'discount_amount': 0,
|
||||
'final_amount': upgrade_amount,
|
||||
'promo_code': None
|
||||
'final_amount': price,
|
||||
'promo_code': None,
|
||||
'promo_error': None
|
||||
}
|
||||
|
||||
# 8. 应用优惠码
|
||||
if promo_code and upgrade_amount > 0:
|
||||
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, upgrade_amount, user_id)
|
||||
# 6. 应用优惠码
|
||||
if promo_code and promo_code.strip():
|
||||
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, price, user_id)
|
||||
if promo:
|
||||
discount = calculate_discount(promo, upgrade_amount)
|
||||
result['discount_amount'] = discount
|
||||
result['final_amount'] = upgrade_amount - discount
|
||||
discount = calculate_discount(promo, price)
|
||||
result['discount_amount'] = float(discount)
|
||||
result['final_amount'] = price - float(discount)
|
||||
result['promo_code'] = promo.code
|
||||
elif error:
|
||||
result['promo_error'] = error
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
return {'error': f'价格计算失败: {str(e)}'}
|
||||
|
||||
|
||||
# 保留旧函数以兼容(标记为废弃)
|
||||
def calculate_upgrade_price(user_id, to_plan_name, to_cycle, promo_code=None):
|
||||
"""
|
||||
【已废弃】旧版升级价格计算函数,保留以兼容旧代码
|
||||
新代码请使用 calculate_subscription_price_simple
|
||||
"""
|
||||
# 直接调用新函数
|
||||
return calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_code)
|
||||
|
||||
|
||||
def initialize_subscription_plans_safe():
|
||||
@@ -1594,7 +1622,33 @@ def validate_promo_code_api():
|
||||
|
||||
@app.route('/api/subscription/calculate-price', methods=['POST'])
|
||||
def calculate_subscription_price():
|
||||
"""计算订阅价格(支持升级和优惠码)"""
|
||||
"""
|
||||
计算订阅价格(新版:续费和新购价格一致)
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"to_plan": "pro",
|
||||
"to_cycle": "yearly",
|
||||
"promo_code": "WELCOME2025" // 可选
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"is_renewal": true, // 是否为续费
|
||||
"subscription_type": "renew", // new 或 renew
|
||||
"current_plan": "pro", // 当前套餐(如果有)
|
||||
"current_cycle": "monthly", // 当前周期(如果有)
|
||||
"new_plan_price": 2699.00,
|
||||
"original_amount": 2699.00,
|
||||
"discount_amount": 0,
|
||||
"final_amount": 2699.00,
|
||||
"promo_code": null,
|
||||
"promo_error": null
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
@@ -1607,8 +1661,8 @@ def calculate_subscription_price():
|
||||
if not to_plan or not to_cycle:
|
||||
return jsonify({'success': False, 'error': '参数不完整'}), 400
|
||||
|
||||
# 计算价格
|
||||
result = calculate_upgrade_price(session['user_id'], to_plan, to_cycle, promo_code)
|
||||
# 使用新的简化价格计算函数
|
||||
result = calculate_subscription_price_simple(session['user_id'], to_plan, to_cycle, promo_code)
|
||||
|
||||
if 'error' in result:
|
||||
return jsonify({
|
||||
@@ -1630,7 +1684,16 @@ def calculate_subscription_price():
|
||||
|
||||
@app.route('/api/payment/create-order', methods=['POST'])
|
||||
def create_payment_order():
|
||||
"""创建支付订单(支持升级和优惠码)"""
|
||||
"""
|
||||
创建支付订单(新版:简化逻辑,不再记录升级)
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"plan_name": "pro",
|
||||
"billing_cycle": "yearly",
|
||||
"promo_code": "WELCOME2025" // 可选
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
@@ -1643,16 +1706,14 @@ def create_payment_order():
|
||||
if not plan_name or not billing_cycle:
|
||||
return jsonify({'success': False, 'error': '参数不完整'}), 400
|
||||
|
||||
# 计算价格(包括升级和优惠码)
|
||||
price_result = calculate_upgrade_price(session['user_id'], plan_name, billing_cycle, promo_code)
|
||||
# 使用新的简化价格计算
|
||||
price_result = calculate_subscription_price_simple(session['user_id'], plan_name, billing_cycle, promo_code)
|
||||
|
||||
if 'error' in price_result:
|
||||
return jsonify({'success': False, 'error': price_result['error']}), 400
|
||||
|
||||
amount = price_result['final_amount']
|
||||
original_amount = price_result['original_amount']
|
||||
discount_amount = price_result['discount_amount']
|
||||
is_upgrade = price_result.get('is_upgrade', False)
|
||||
subscription_type = price_result.get('subscription_type', 'new') # new 或 renew
|
||||
|
||||
# 创建订单
|
||||
try:
|
||||
@@ -1663,48 +1724,23 @@ def create_payment_order():
|
||||
amount=amount
|
||||
)
|
||||
|
||||
# 添加扩展字段(使用动态属性)
|
||||
if hasattr(order, 'original_amount') or True: # 兼容性检查
|
||||
order.original_amount = original_amount
|
||||
order.discount_amount = discount_amount
|
||||
order.is_upgrade = is_upgrade
|
||||
# 添加订阅类型标记(用于前端展示)
|
||||
order.remark = f"{subscription_type}订阅" if subscription_type == 'renew' else "新购订阅"
|
||||
|
||||
# 如果使用了优惠码,关联优惠码
|
||||
if promo_code and price_result.get('promo_code'):
|
||||
promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first()
|
||||
if promo_obj:
|
||||
# 如果使用了优惠码,关联优惠码
|
||||
if promo_code and price_result.get('promo_code'):
|
||||
promo_obj = PromoCode.query.filter_by(code=promo_code.upper()).first()
|
||||
if promo_obj:
|
||||
# 注意:需要在 PaymentOrder 表中添加 promo_code_id 字段
|
||||
# 如果没有该字段,这行会报错,可以注释掉
|
||||
try:
|
||||
order.promo_code_id = promo_obj.id
|
||||
|
||||
# 如果是升级,记录原套餐信息
|
||||
if is_upgrade:
|
||||
order.upgrade_from_plan = price_result.get('current_plan')
|
||||
except:
|
||||
pass # 如果表中没有该字段,跳过
|
||||
|
||||
db.session.add(order)
|
||||
db.session.commit()
|
||||
|
||||
# 如果是升级订单,创建升级记录
|
||||
if is_upgrade and price_result.get('upgrade_type'):
|
||||
try:
|
||||
upgrade_record = SubscriptionUpgrade(
|
||||
user_id=session['user_id'],
|
||||
order_id=order.id,
|
||||
from_plan=price_result['current_plan'],
|
||||
from_cycle=price_result['current_cycle'],
|
||||
from_end_date=datetime.fromisoformat(price_result['current_end_date']) if price_result.get('current_end_date') else None,
|
||||
to_plan=plan_name,
|
||||
to_cycle=billing_cycle,
|
||||
to_end_date=beijing_now() + timedelta(days=365 if billing_cycle == 'yearly' else 30),
|
||||
remaining_value=price_result['remaining_value'],
|
||||
upgrade_amount=price_result['upgrade_amount'],
|
||||
actual_amount=amount,
|
||||
upgrade_type=price_result['upgrade_type']
|
||||
)
|
||||
db.session.add(upgrade_record)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
print(f"创建升级记录失败: {e}")
|
||||
# 不影响主流程
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': f'订单创建失败: {str(e)}'}), 500
|
||||
|
||||
@@ -69,7 +69,7 @@ module.exports = {
|
||||
},
|
||||
// 日期/日历库
|
||||
calendar: {
|
||||
test: /[\\/]node_modules[\\/](moment|date-fns|@fullcalendar|react-big-calendar)[\\/]/,
|
||||
test: /[\\/]node_modules[\\/](dayjs|date-fns|@fullcalendar|react-big-calendar)[\\/]/,
|
||||
name: 'calendar-lib',
|
||||
priority: 18,
|
||||
reuseExistingChunk: true,
|
||||
@@ -161,13 +161,8 @@ module.exports = {
|
||||
);
|
||||
}
|
||||
|
||||
// 忽略 moment 的语言包(如果项目使用了 moment)
|
||||
webpackConfig.plugins.push(
|
||||
new webpack.IgnorePlugin({
|
||||
resourceRegExp: /^\.\/locale$/,
|
||||
contextRegExp: /moment$/,
|
||||
})
|
||||
);
|
||||
// Day.js 的语言包非常小(每个约 0.5KB),所以不需要特别忽略
|
||||
// 如果需要优化,可以只导入需要的语言包
|
||||
|
||||
// ============== Loader 优化 ==============
|
||||
const babelLoaderRule = webpackConfig.module.rules.find(
|
||||
|
||||
427
database_migration.sql
Normal file
427
database_migration.sql
Normal file
@@ -0,0 +1,427 @@
|
||||
-- ============================================
|
||||
-- 订阅支付系统数据库迁移 SQL
|
||||
-- 版本: v2.0.0
|
||||
-- 日期: 2025-11-19
|
||||
-- ============================================
|
||||
|
||||
-- ============================================
|
||||
-- 第一步: 备份现有数据
|
||||
-- ============================================
|
||||
|
||||
-- 创建备份表
|
||||
CREATE TABLE IF NOT EXISTS user_subscriptions_backup AS SELECT * FROM user_subscriptions;
|
||||
CREATE TABLE IF NOT EXISTS payment_orders_backup AS SELECT * FROM payment_orders;
|
||||
CREATE TABLE IF NOT EXISTS subscription_plans_backup AS SELECT * FROM subscription_plans;
|
||||
|
||||
-- ============================================
|
||||
-- 第二步: 删除旧表(先删除外键依赖的表)
|
||||
-- ============================================
|
||||
|
||||
DROP TABLE IF EXISTS subscription_upgrades; -- 删除升级表,不再使用
|
||||
DROP TABLE IF EXISTS promo_code_usage; -- 暂时删除,稍后重建
|
||||
DROP TABLE IF EXISTS payment_orders; -- 删除旧订单表
|
||||
DROP TABLE IF EXISTS user_subscriptions; -- 删除旧订阅表
|
||||
DROP TABLE IF EXISTS subscription_plans; -- 删除旧套餐表
|
||||
|
||||
-- ============================================
|
||||
-- 第三步: 创建新表结构
|
||||
-- ============================================
|
||||
|
||||
-- 1. 订阅套餐表(重构)
|
||||
CREATE TABLE subscription_plans (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
plan_code VARCHAR(20) NOT NULL UNIQUE COMMENT '套餐代码: pro, max',
|
||||
plan_name VARCHAR(50) NOT NULL COMMENT '套餐名称: Pro专业版, Max旗舰版',
|
||||
description TEXT COMMENT '套餐描述',
|
||||
features JSON COMMENT '功能列表',
|
||||
|
||||
-- 价格配置(所有周期价格)
|
||||
price_monthly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '月付价格',
|
||||
price_quarterly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '季付价格(3个月)',
|
||||
price_semiannual DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '半年付价格(6个月)',
|
||||
price_yearly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '年付价格(12个月)',
|
||||
|
||||
-- 状态字段
|
||||
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||
display_order INT DEFAULT 0 COMMENT '展示顺序',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_plan_code (plan_code),
|
||||
INDEX idx_active_order (is_active, display_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅套餐配置表';
|
||||
|
||||
|
||||
-- 2. 用户订阅记录表(重构)
|
||||
CREATE TABLE user_subscriptions (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
subscription_id VARCHAR(32) UNIQUE NOT NULL COMMENT '订阅ID(唯一标识)',
|
||||
|
||||
-- 订阅基本信息
|
||||
plan_code VARCHAR(20) NOT NULL COMMENT '套餐代码: pro, max, free',
|
||||
billing_cycle VARCHAR(20) NOT NULL COMMENT '计费周期: monthly, quarterly, semiannual, yearly',
|
||||
|
||||
-- 订阅时间
|
||||
start_date DATETIME NOT NULL COMMENT '订阅开始时间',
|
||||
end_date DATETIME NOT NULL COMMENT '订阅结束时间',
|
||||
|
||||
-- 订阅状态
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active(有效), expired(已过期), cancelled(已取消)',
|
||||
is_current BOOLEAN DEFAULT FALSE COMMENT '是否为当前生效的订阅',
|
||||
|
||||
-- 支付信息
|
||||
payment_order_id INT COMMENT '关联的支付订单ID',
|
||||
paid_amount DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '实际支付金额',
|
||||
original_price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '原价',
|
||||
discount_amount DECIMAL(10,2) DEFAULT 0 COMMENT '优惠金额',
|
||||
|
||||
-- 订阅类型
|
||||
subscription_type VARCHAR(20) DEFAULT 'new' COMMENT '订阅类型: new(新购), renew(续费)',
|
||||
previous_subscription_id VARCHAR(32) COMMENT '上一个订阅ID(续费时记录)',
|
||||
|
||||
-- 自动续费
|
||||
auto_renew BOOLEAN DEFAULT FALSE COMMENT '是否自动续费',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_subscription_id (subscription_id),
|
||||
INDEX idx_user_current (user_id, is_current),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_end_date (end_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户订阅记录表';
|
||||
|
||||
|
||||
-- 3. 支付订单表(重构)
|
||||
CREATE TABLE payment_orders (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
order_no VARCHAR(32) UNIQUE NOT NULL COMMENT '订单号',
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
|
||||
-- 订阅信息
|
||||
plan_code VARCHAR(20) NOT NULL COMMENT '套餐代码',
|
||||
billing_cycle VARCHAR(20) NOT NULL COMMENT '计费周期',
|
||||
subscription_type VARCHAR(20) DEFAULT 'new' COMMENT '订阅类型: new(新购), renew(续费)',
|
||||
|
||||
-- 价格信息
|
||||
original_price DECIMAL(10,2) NOT NULL COMMENT '原价',
|
||||
discount_amount DECIMAL(10,2) DEFAULT 0 COMMENT '优惠金额',
|
||||
final_amount DECIMAL(10,2) NOT NULL COMMENT '实付金额',
|
||||
|
||||
-- 优惠码
|
||||
promo_code_id INT COMMENT '优惠码ID',
|
||||
promo_code VARCHAR(50) COMMENT '优惠码',
|
||||
|
||||
-- 支付信息
|
||||
payment_method VARCHAR(20) DEFAULT 'wechat' COMMENT '支付方式: wechat, alipay',
|
||||
payment_channel VARCHAR(50) COMMENT '支付渠道详情',
|
||||
transaction_id VARCHAR(64) COMMENT '第三方交易号',
|
||||
qr_code_url TEXT COMMENT '支付二维码URL',
|
||||
|
||||
-- 订单状态
|
||||
status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending(待支付), paid(已支付), expired(已过期), cancelled(已取消)',
|
||||
|
||||
-- 时间信息
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
paid_at TIMESTAMP NULL COMMENT '支付时间',
|
||||
expired_at TIMESTAMP NULL COMMENT '过期时间',
|
||||
|
||||
-- 备注
|
||||
remark TEXT COMMENT '备注信息',
|
||||
|
||||
INDEX idx_order_no (order_no),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单表';
|
||||
|
||||
|
||||
-- 4. 优惠码使用记录表(重建)
|
||||
CREATE TABLE promo_code_usage (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
promo_code_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
order_id INT NOT NULL,
|
||||
discount_amount DECIMAL(10,2) NOT NULL COMMENT '实际优惠金额',
|
||||
used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_promo_code (promo_code_id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_order_id (order_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码使用记录表';
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 第四步: 插入初始数据
|
||||
-- ============================================
|
||||
|
||||
-- 插入套餐数据
|
||||
INSERT INTO subscription_plans (
|
||||
plan_code,
|
||||
plan_name,
|
||||
description,
|
||||
price_monthly,
|
||||
price_quarterly,
|
||||
price_semiannual,
|
||||
price_yearly,
|
||||
features,
|
||||
display_order,
|
||||
is_active
|
||||
) VALUES
|
||||
(
|
||||
'pro',
|
||||
'Pro 专业版',
|
||||
'为专业投资者打造,解锁高级分析功能',
|
||||
299.00,
|
||||
799.00,
|
||||
1499.00,
|
||||
2699.00,
|
||||
JSON_ARRAY(
|
||||
'新闻信息流',
|
||||
'历史事件对比',
|
||||
'事件传导链分析(AI)',
|
||||
'事件-相关标的分析',
|
||||
'相关概念展示',
|
||||
'AI复盘功能',
|
||||
'企业概览',
|
||||
'个股深度分析(AI) - 50家/月',
|
||||
'高效数据筛选工具',
|
||||
'概念中心(548大概念)',
|
||||
'历史时间轴查询 - 100天',
|
||||
'涨停板块数据分析',
|
||||
'个股涨停分析'
|
||||
),
|
||||
1,
|
||||
TRUE
|
||||
),
|
||||
(
|
||||
'max',
|
||||
'Max 旗舰版',
|
||||
'旗舰级体验,无限制使用所有功能',
|
||||
599.00,
|
||||
1599.00,
|
||||
2999.00,
|
||||
5399.00,
|
||||
JSON_ARRAY(
|
||||
'全部 Pro 版功能',
|
||||
'板块深度分析(AI)',
|
||||
'个股深度分析(AI) - 无限制',
|
||||
'历史时间轴查询 - 无限制',
|
||||
'概念高频更新',
|
||||
'优先客服支持',
|
||||
'独家功能抢先体验'
|
||||
),
|
||||
2,
|
||||
TRUE
|
||||
);
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 第五步: 数据迁移(可选)
|
||||
-- ============================================
|
||||
|
||||
-- 如果需要迁移旧数据,取消以下注释:
|
||||
|
||||
/*
|
||||
-- 迁移旧的用户订阅数据
|
||||
INSERT INTO user_subscriptions (
|
||||
user_id,
|
||||
subscription_id,
|
||||
plan_code,
|
||||
billing_cycle,
|
||||
start_date,
|
||||
end_date,
|
||||
status,
|
||||
is_current,
|
||||
paid_amount,
|
||||
original_price,
|
||||
subscription_type,
|
||||
auto_renew,
|
||||
created_at
|
||||
)
|
||||
SELECT
|
||||
user_id,
|
||||
CONCAT('SUB_', id, '_', UNIX_TIMESTAMP(NOW())), -- 生成订阅ID
|
||||
subscription_type, -- 将 subscription_type 映射为 plan_code
|
||||
COALESCE(billing_cycle, 'yearly'), -- 默认年付
|
||||
COALESCE(start_date, NOW()),
|
||||
COALESCE(end_date, DATE_ADD(NOW(), INTERVAL 365 DAY)),
|
||||
subscription_status,
|
||||
TRUE, -- 设为当前订阅
|
||||
0, -- 旧数据没有支付金额,设为0
|
||||
0, -- 旧数据没有原价,设为0
|
||||
'new', -- 默认为新购
|
||||
COALESCE(auto_renewal, FALSE),
|
||||
created_at
|
||||
FROM user_subscriptions_backup
|
||||
WHERE subscription_type IN ('pro', 'max'); -- 只迁移付费用户
|
||||
*/
|
||||
|
||||
-- ============================================
|
||||
-- 第六步: 创建免费订阅记录(为所有用户)
|
||||
-- ============================================
|
||||
|
||||
-- 为所有现有用户创建免费订阅记录(如果没有付费订阅)
|
||||
/*
|
||||
INSERT INTO user_subscriptions (
|
||||
user_id,
|
||||
subscription_id,
|
||||
plan_code,
|
||||
billing_cycle,
|
||||
start_date,
|
||||
end_date,
|
||||
status,
|
||||
is_current,
|
||||
paid_amount,
|
||||
original_price,
|
||||
subscription_type
|
||||
)
|
||||
SELECT
|
||||
id AS user_id,
|
||||
CONCAT('FREE_', id, '_', UNIX_TIMESTAMP(NOW())),
|
||||
'free',
|
||||
'monthly',
|
||||
NOW(),
|
||||
'2099-12-31 23:59:59', -- 免费版永久有效
|
||||
'active',
|
||||
TRUE,
|
||||
0,
|
||||
0,
|
||||
'new'
|
||||
FROM user
|
||||
WHERE id NOT IN (
|
||||
SELECT DISTINCT user_id FROM user_subscriptions WHERE plan_code IN ('pro', 'max')
|
||||
);
|
||||
*/
|
||||
|
||||
-- ============================================
|
||||
-- 第七步: 验证数据完整性
|
||||
-- ============================================
|
||||
|
||||
-- 检查套餐数据
|
||||
SELECT * FROM subscription_plans;
|
||||
|
||||
-- 检查用户订阅数据
|
||||
SELECT
|
||||
plan_code,
|
||||
COUNT(*) as user_count,
|
||||
SUM(CASE WHEN is_current = TRUE THEN 1 ELSE 0 END) as current_count
|
||||
FROM user_subscriptions
|
||||
GROUP BY plan_code;
|
||||
|
||||
-- 检查支付订单数据
|
||||
SELECT
|
||||
status,
|
||||
COUNT(*) as order_count,
|
||||
SUM(final_amount) as total_amount
|
||||
FROM payment_orders
|
||||
GROUP BY status;
|
||||
|
||||
-- ============================================
|
||||
-- 第八步: 添加外键约束(可选)
|
||||
-- ============================================
|
||||
|
||||
-- 注意: 只有在确认 users 表存在且数据完整时才执行
|
||||
|
||||
-- ALTER TABLE user_subscriptions
|
||||
-- ADD CONSTRAINT fk_user_subscriptions_user
|
||||
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
-- ALTER TABLE payment_orders
|
||||
-- ADD CONSTRAINT fk_payment_orders_user
|
||||
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
-- ALTER TABLE payment_orders
|
||||
-- ADD CONSTRAINT fk_payment_orders_promo
|
||||
-- FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE SET NULL;
|
||||
|
||||
-- ALTER TABLE promo_code_usage
|
||||
-- ADD CONSTRAINT fk_promo_usage_promo
|
||||
-- FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE CASCADE;
|
||||
|
||||
-- ALTER TABLE promo_code_usage
|
||||
-- ADD CONSTRAINT fk_promo_usage_user
|
||||
-- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
-- ALTER TABLE promo_code_usage
|
||||
-- ADD CONSTRAINT fk_promo_usage_order
|
||||
-- FOREIGN KEY (order_id) REFERENCES payment_orders(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 第九步: 创建测试数据(开发环境)
|
||||
-- ============================================
|
||||
|
||||
-- 插入测试优惠码
|
||||
INSERT INTO promo_codes (
|
||||
code,
|
||||
description,
|
||||
discount_type,
|
||||
discount_value,
|
||||
applicable_plans,
|
||||
applicable_cycles,
|
||||
max_total_uses,
|
||||
max_uses_per_user,
|
||||
valid_from,
|
||||
valid_until,
|
||||
is_active
|
||||
) VALUES
|
||||
(
|
||||
'WELCOME2025',
|
||||
'2025新用户专享',
|
||||
'percentage',
|
||||
20.00,
|
||||
NULL, -- 适用所有套餐
|
||||
NULL, -- 适用所有周期
|
||||
1000,
|
||||
1,
|
||||
NOW(),
|
||||
DATE_ADD(NOW(), INTERVAL 90 DAY),
|
||||
TRUE
|
||||
),
|
||||
(
|
||||
'YEAR2025',
|
||||
'年付专享',
|
||||
'percentage',
|
||||
10.00,
|
||||
NULL,
|
||||
JSON_ARRAY('yearly'), -- 仅适用年付
|
||||
500,
|
||||
1,
|
||||
NOW(),
|
||||
DATE_ADD(NOW(), INTERVAL 365 DAY),
|
||||
TRUE
|
||||
),
|
||||
(
|
||||
'TESTCODE',
|
||||
'测试优惠码 - 固定减100元',
|
||||
'fixed_amount',
|
||||
100.00,
|
||||
NULL,
|
||||
NULL,
|
||||
100,
|
||||
1,
|
||||
NOW(),
|
||||
DATE_ADD(NOW(), INTERVAL 30 DAY),
|
||||
TRUE
|
||||
);
|
||||
|
||||
|
||||
-- ============================================
|
||||
-- 迁移完成提示
|
||||
-- ============================================
|
||||
|
||||
SELECT '===================================' AS '';
|
||||
SELECT '数据库迁移完成!' AS '状态';
|
||||
SELECT '===================================' AS '';
|
||||
SELECT '请检查以下数据:' AS '提示';
|
||||
SELECT '1. subscription_plans 表是否有2条记录 (pro, max)' AS '';
|
||||
SELECT '2. user_subscriptions 表数据是否正确' AS '';
|
||||
SELECT '3. payment_orders 表结构是否正确' AS '';
|
||||
SELECT '4. 备份表 (*_backup) 已创建' AS '';
|
||||
SELECT '===================================' AS '';
|
||||
SELECT '下一步: 更新后端代码 (app.py, models.py)' AS '';
|
||||
SELECT '===================================' AS '';
|
||||
@@ -1,626 +0,0 @@
|
||||
# 通知系统增强功能 - 使用指南
|
||||
|
||||
## 📋 概述
|
||||
|
||||
本指南介绍通知系统的三大增强功能:
|
||||
1. **智能桌面通知** - 自动请求权限,系统级通知
|
||||
2. **性能监控** - 追踪推送效果,数据驱动优化
|
||||
3. **历史记录** - 持久化存储,随时查询
|
||||
|
||||
---
|
||||
|
||||
## 🎯 功能 1:智能桌面通知
|
||||
|
||||
### 功能说明
|
||||
|
||||
首次收到重要/紧急通知时,自动请求浏览器通知权限,确保用户不错过关键信息。
|
||||
|
||||
### 工作原理
|
||||
|
||||
```javascript
|
||||
// 在 NotificationContext 中的逻辑
|
||||
if (priority === URGENT || priority === IMPORTANT) {
|
||||
if (browserPermission === 'default' && !hasRequestedPermission) {
|
||||
// 首次遇到重要通知,自动请求权限
|
||||
await requestBrowserPermission();
|
||||
setHasRequestedPermission(true); // 避免重复请求
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 权限状态
|
||||
|
||||
- **granted**: 已授权,可以发送桌面通知
|
||||
- **denied**: 已拒绝,无法发送桌面通知
|
||||
- **default**: 未请求,首次重要通知时会自动请求
|
||||
|
||||
### 使用示例
|
||||
|
||||
**自动触发**(推荐)
|
||||
```javascript
|
||||
// 无需任何代码,系统自动处理
|
||||
// 首次收到重要/紧急通知时会自动弹出权限请求
|
||||
```
|
||||
|
||||
**手动请求**
|
||||
```javascript
|
||||
import { useNotification } from 'contexts/NotificationContext';
|
||||
|
||||
function SettingsPage() {
|
||||
const { requestBrowserPermission, browserPermission } = useNotification();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>当前状态: {browserPermission}</p>
|
||||
<button onClick={requestBrowserPermission}>
|
||||
开启桌面通知
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 通知分发策略
|
||||
|
||||
| 优先级 | 页面在前台 | 页面在后台 |
|
||||
|-------|----------|----------|
|
||||
| 紧急 | 桌面通知 + 网页通知 | 桌面通知 + 网页通知 |
|
||||
| 重要 | 网页通知 | 桌面通知 |
|
||||
| 普通 | 网页通知 | 网页通知 |
|
||||
|
||||
### 测试步骤
|
||||
|
||||
1. **清除已保存的权限状态**
|
||||
```javascript
|
||||
localStorage.removeItem('browser_notification_requested');
|
||||
```
|
||||
|
||||
2. **刷新页面**
|
||||
|
||||
3. **触发一个重要/紧急通知**
|
||||
- Mock 模式:等待自动推送
|
||||
- Real 模式:创建测试事件
|
||||
|
||||
4. **观察权限请求弹窗**
|
||||
- 浏览器会弹出通知权限请求
|
||||
- 点击"允许"授权
|
||||
|
||||
5. **验证桌面通知**
|
||||
- 切换到其他标签页
|
||||
- 收到重要通知时应该看到桌面通知
|
||||
|
||||
---
|
||||
|
||||
## 📊 功能 2:性能监控
|
||||
|
||||
### 功能说明
|
||||
|
||||
追踪通知推送的各项指标,包括:
|
||||
- **到达率**: 发送 vs 接收
|
||||
- **点击率**: 点击 vs 接收
|
||||
- **响应时间**: 收到通知到点击的平均时间
|
||||
- **类型分布**: 各类型通知的数量和效果
|
||||
- **时段分布**: 每小时推送量
|
||||
|
||||
### API 参考
|
||||
|
||||
#### 获取汇总统计
|
||||
|
||||
```javascript
|
||||
import { notificationMetricsService } from 'services/notificationMetricsService';
|
||||
|
||||
const summary = notificationMetricsService.getSummary();
|
||||
console.log(summary);
|
||||
/* 输出:
|
||||
{
|
||||
totalSent: 100,
|
||||
totalReceived: 98,
|
||||
totalClicked: 45,
|
||||
totalDismissed: 53,
|
||||
avgResponseTime: 5200, // 毫秒
|
||||
clickRate: '45.92', // 百分比
|
||||
deliveryRate: '98.00' // 百分比
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
#### 获取按类型统计
|
||||
|
||||
```javascript
|
||||
const byType = notificationMetricsService.getByType();
|
||||
console.log(byType);
|
||||
/* 输出:
|
||||
{
|
||||
announcement: { sent: 20, received: 20, clicked: 15, dismissed: 5, clickRate: '75.00' },
|
||||
stock_alert: { sent: 30, received: 30, clicked: 20, dismissed: 10, clickRate: '66.67' },
|
||||
event_alert: { sent: 40, received: 38, clicked: 10, dismissed: 28, clickRate: '26.32' },
|
||||
analysis_report: { sent: 10, received: 10, clicked: 0, dismissed: 10, clickRate: '0.00' }
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
#### 获取按优先级统计
|
||||
|
||||
```javascript
|
||||
const byPriority = notificationMetricsService.getByPriority();
|
||||
console.log(byPriority);
|
||||
/* 输出:
|
||||
{
|
||||
urgent: { sent: 10, received: 10, clicked: 9, dismissed: 1, clickRate: '90.00' },
|
||||
important: { sent: 40, received: 39, clicked: 25, dismissed: 14, clickRate: '64.10' },
|
||||
normal: { sent: 50, received: 49, clicked: 11, dismissed: 38, clickRate: '22.45' }
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
#### 获取每日数据
|
||||
|
||||
```javascript
|
||||
const dailyData = notificationMetricsService.getDailyData(7); // 最近 7 天
|
||||
console.log(dailyData);
|
||||
/* 输出:
|
||||
[
|
||||
{ date: '2025-01-15', sent: 15, received: 14, clicked: 6, dismissed: 8, clickRate: '42.86' },
|
||||
{ date: '2025-01-16', sent: 20, received: 20, clicked: 10, dismissed: 10, clickRate: '50.00' },
|
||||
...
|
||||
]
|
||||
*/
|
||||
```
|
||||
|
||||
#### 获取完整指标
|
||||
|
||||
```javascript
|
||||
const allMetrics = notificationMetricsService.getAllMetrics();
|
||||
console.log(allMetrics);
|
||||
```
|
||||
|
||||
#### 导出数据
|
||||
|
||||
```javascript
|
||||
// 导出为 JSON
|
||||
const json = notificationMetricsService.exportToJSON();
|
||||
console.log(json);
|
||||
|
||||
// 导出为 CSV
|
||||
const csv = notificationMetricsService.exportToCSV();
|
||||
console.log(csv);
|
||||
```
|
||||
|
||||
#### 重置指标
|
||||
|
||||
```javascript
|
||||
notificationMetricsService.reset();
|
||||
```
|
||||
|
||||
### 在控制台查看实时指标
|
||||
|
||||
打开浏览器控制台,执行:
|
||||
|
||||
```javascript
|
||||
// 引入服务
|
||||
import { notificationMetricsService } from './services/notificationMetricsService.js';
|
||||
|
||||
// 查看汇总
|
||||
console.table(notificationMetricsService.getSummary());
|
||||
|
||||
// 查看按类型分布
|
||||
console.table(notificationMetricsService.getByType());
|
||||
|
||||
// 查看最近 7 天数据
|
||||
console.table(notificationMetricsService.getDailyData(7));
|
||||
```
|
||||
|
||||
### 监控埋点(自动)
|
||||
|
||||
监控服务已自动集成到 `NotificationContext`,无需手动调用:
|
||||
|
||||
- **trackReceived**: 收到通知时自动调用
|
||||
- **trackClicked**: 点击通知时自动调用
|
||||
- **trackDismissed**: 关闭通知时自动调用
|
||||
|
||||
### 可视化展示(可选)
|
||||
|
||||
你可以基于监控数据创建仪表板:
|
||||
|
||||
```javascript
|
||||
import { notificationMetricsService } from 'services/notificationMetricsService';
|
||||
import { PieChart, LineChart } from 'recharts';
|
||||
|
||||
function MetricsDashboard() {
|
||||
const summary = notificationMetricsService.getSummary();
|
||||
const dailyData = notificationMetricsService.getDailyData(7);
|
||||
const byType = notificationMetricsService.getByType();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 汇总卡片 */}
|
||||
<StatsCard title="总推送数" value={summary.totalSent} />
|
||||
<StatsCard title="点击率" value={`${summary.clickRate}%`} />
|
||||
<StatsCard title="平均响应时间" value={`${summary.avgResponseTime}ms`} />
|
||||
|
||||
{/* 类型分布饼图 */}
|
||||
<PieChart data={Object.entries(byType).map(([type, data]) => ({
|
||||
name: type,
|
||||
value: data.received
|
||||
}))} />
|
||||
|
||||
{/* 每日趋势折线图 */}
|
||||
<LineChart data={dailyData} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📜 功能 3:历史记录
|
||||
|
||||
### 功能说明
|
||||
|
||||
持久化存储所有接收到的通知,支持:
|
||||
- 查询和筛选
|
||||
- 搜索关键词
|
||||
- 标记已读/已点击
|
||||
- 批量删除
|
||||
- 导出(JSON/CSV)
|
||||
|
||||
### API 参考
|
||||
|
||||
#### 获取历史记录(支持筛选和分页)
|
||||
|
||||
```javascript
|
||||
import { notificationHistoryService } from 'services/notificationHistoryService';
|
||||
|
||||
const result = notificationHistoryService.getHistory({
|
||||
type: 'event_alert', // 可选:筛选类型
|
||||
priority: 'urgent', // 可选:筛选优先级
|
||||
readStatus: 'unread', // 可选:'read' | 'unread' | 'all'
|
||||
startDate: Date.now() - 7 * 24 * 60 * 60 * 1000, // 可选:开始日期
|
||||
endDate: Date.now(), // 可选:结束日期
|
||||
page: 1, // 页码
|
||||
pageSize: 20, // 每页数量
|
||||
});
|
||||
|
||||
console.log(result);
|
||||
/* 输出:
|
||||
{
|
||||
records: [...], // 当前页的记录
|
||||
total: 150, // 总记录数
|
||||
page: 1, // 当前页
|
||||
pageSize: 20, // 每页数量
|
||||
totalPages: 8 // 总页数
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
#### 搜索历史记录
|
||||
|
||||
```javascript
|
||||
const results = notificationHistoryService.searchHistory('降准');
|
||||
console.log(results); // 返回标题/内容中包含"降准"的所有记录
|
||||
```
|
||||
|
||||
#### 标记已读/已点击
|
||||
|
||||
```javascript
|
||||
// 标记已读
|
||||
notificationHistoryService.markAsRead('notification_id');
|
||||
|
||||
// 标记已点击
|
||||
notificationHistoryService.markAsClicked('notification_id');
|
||||
```
|
||||
|
||||
#### 删除记录
|
||||
|
||||
```javascript
|
||||
// 删除单条
|
||||
notificationHistoryService.deleteRecord('notification_id');
|
||||
|
||||
// 批量删除
|
||||
notificationHistoryService.deleteRecords(['id1', 'id2', 'id3']);
|
||||
|
||||
// 清空所有
|
||||
notificationHistoryService.clearHistory();
|
||||
```
|
||||
|
||||
#### 获取统计数据
|
||||
|
||||
```javascript
|
||||
const stats = notificationHistoryService.getStats();
|
||||
console.log(stats);
|
||||
/* 输出:
|
||||
{
|
||||
total: 500, // 总记录数
|
||||
read: 320, // 已读数
|
||||
unread: 180, // 未读数
|
||||
clicked: 150, // 已点击数
|
||||
clickRate: '30.00', // 点击率
|
||||
byType: { // 按类型统计
|
||||
announcement: 100,
|
||||
stock_alert: 150,
|
||||
event_alert: 200,
|
||||
analysis_report: 50
|
||||
},
|
||||
byPriority: { // 按优先级统计
|
||||
urgent: 50,
|
||||
important: 200,
|
||||
normal: 250
|
||||
}
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
#### 导出历史记录
|
||||
|
||||
```javascript
|
||||
// 导出为 JSON 字符串
|
||||
const json = notificationHistoryService.exportToJSON({
|
||||
type: 'event_alert' // 可选:只导出特定类型
|
||||
});
|
||||
|
||||
// 导出为 CSV 字符串
|
||||
const csv = notificationHistoryService.exportToCSV();
|
||||
|
||||
// 直接下载 JSON 文件
|
||||
notificationHistoryService.downloadJSON();
|
||||
|
||||
// 直接下载 CSV 文件
|
||||
notificationHistoryService.downloadCSV();
|
||||
```
|
||||
|
||||
### 在控制台使用
|
||||
|
||||
打开浏览器控制台,执行:
|
||||
|
||||
```javascript
|
||||
// 引入服务
|
||||
import { notificationHistoryService } from './services/notificationHistoryService.js';
|
||||
|
||||
// 查看所有历史
|
||||
console.table(notificationHistoryService.getHistory().records);
|
||||
|
||||
// 搜索
|
||||
const results = notificationHistoryService.searchHistory('央行');
|
||||
console.table(results);
|
||||
|
||||
// 查看统计
|
||||
console.table(notificationHistoryService.getStats());
|
||||
|
||||
// 导出并下载
|
||||
notificationHistoryService.downloadJSON();
|
||||
```
|
||||
|
||||
### 数据结构
|
||||
|
||||
每条历史记录包含:
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: 'notif_123', // 通知 ID
|
||||
notification: { // 完整通知对象
|
||||
type: 'event_alert',
|
||||
priority: 'urgent',
|
||||
title: '...',
|
||||
content: '...',
|
||||
...
|
||||
},
|
||||
receivedAt: 1737459600000, // 接收时间戳
|
||||
readAt: 1737459650000, // 已读时间戳(null 表示未读)
|
||||
clickedAt: null, // 已点击时间戳(null 表示未点击)
|
||||
}
|
||||
```
|
||||
|
||||
### 存储限制
|
||||
|
||||
- **最大数量**: 500 条(超过后自动删除最旧的)
|
||||
- **存储位置**: localStorage
|
||||
- **容量估算**: 约 2-5MB(取决于通知内容长度)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 技术细节
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── services/
|
||||
│ ├── browserNotificationService.js [已存在] 浏览器通知服务
|
||||
│ ├── notificationMetricsService.js [新建] 性能监控服务
|
||||
│ └── notificationHistoryService.js [新建] 历史记录服务
|
||||
├── contexts/
|
||||
│ └── NotificationContext.js [修改] 集成所有功能
|
||||
└── components/
|
||||
└── NotificationContainer/
|
||||
└── index.js [修改] 添加点击追踪
|
||||
```
|
||||
|
||||
### 修改清单
|
||||
|
||||
| 文件 | 修改内容 | 状态 |
|
||||
|------|---------|------|
|
||||
| `NotificationContext.js` | 添加智能权限请求、监控埋点、历史保存 | ✅ 已完成 |
|
||||
| `NotificationContainer/index.js` | 添加点击追踪 | ✅ 已完成 |
|
||||
| `notificationMetricsService.js` | 性能监控服务 | ✅ 已创建 |
|
||||
| `notificationHistoryService.js` | 历史记录服务 | ✅ 已创建 |
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
用户收到通知
|
||||
↓
|
||||
NotificationContext.addWebNotification()
|
||||
├─ notificationMetricsService.trackReceived() [监控埋点]
|
||||
├─ notificationHistoryService.saveNotification() [历史保存]
|
||||
├─ 首次重要通知 → requestBrowserPermission() [智能权限]
|
||||
└─ 显示网页通知或桌面通知
|
||||
|
||||
用户点击通知
|
||||
↓
|
||||
NotificationContainer.handleClick()
|
||||
├─ notificationMetricsService.trackClicked() [监控埋点]
|
||||
├─ notificationHistoryService.markAsClicked() [历史标记]
|
||||
└─ 跳转到目标页面
|
||||
|
||||
用户关闭通知
|
||||
↓
|
||||
NotificationContext.removeNotification()
|
||||
└─ notificationMetricsService.trackDismissed() [监控埋点]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试步骤
|
||||
|
||||
### 1. 测试智能桌面通知
|
||||
|
||||
```bash
|
||||
# 1. 清除已保存的权限状态
|
||||
localStorage.removeItem('browser_notification_requested');
|
||||
|
||||
# 2. 刷新页面
|
||||
|
||||
# 3. 等待或触发一个重要/紧急通知
|
||||
|
||||
# 4. 观察浏览器弹出权限请求
|
||||
|
||||
# 5. 授权后验证桌面通知功能
|
||||
```
|
||||
|
||||
### 2. 测试性能监控
|
||||
|
||||
```javascript
|
||||
// 在控制台执行
|
||||
import { notificationMetricsService } from './services/notificationMetricsService.js';
|
||||
|
||||
// 查看实时统计
|
||||
console.table(notificationMetricsService.getSummary());
|
||||
|
||||
// 模拟推送几条通知,再次查看
|
||||
console.table(notificationMetricsService.getAllMetrics());
|
||||
|
||||
// 导出数据
|
||||
console.log(notificationMetricsService.exportToJSON());
|
||||
```
|
||||
|
||||
### 3. 测试历史记录
|
||||
|
||||
```javascript
|
||||
// 在控制台执行
|
||||
import { notificationHistoryService } from './services/notificationHistoryService.js';
|
||||
|
||||
// 查看历史
|
||||
console.table(notificationHistoryService.getHistory().records);
|
||||
|
||||
// 搜索
|
||||
console.table(notificationHistoryService.searchHistory('降准'));
|
||||
|
||||
// 查看统计
|
||||
console.table(notificationHistoryService.getStats());
|
||||
|
||||
// 导出
|
||||
notificationHistoryService.downloadJSON();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📈 数据导出示例
|
||||
|
||||
### 导出性能监控数据
|
||||
|
||||
```javascript
|
||||
import { notificationMetricsService } from 'services/notificationMetricsService';
|
||||
|
||||
// 导出 JSON
|
||||
const json = notificationMetricsService.exportToJSON();
|
||||
// 复制到剪贴板或保存
|
||||
|
||||
// 导出 CSV
|
||||
const csv = notificationMetricsService.exportToCSV();
|
||||
// 可以在 Excel 中打开
|
||||
```
|
||||
|
||||
### 导出历史记录
|
||||
|
||||
```javascript
|
||||
import { notificationHistoryService } from 'services/notificationHistoryService';
|
||||
|
||||
// 导出最近 7 天的事件动向通知
|
||||
const json = notificationHistoryService.exportToJSON({
|
||||
type: 'event_alert',
|
||||
startDate: Date.now() - 7 * 24 * 60 * 60 * 1000
|
||||
});
|
||||
|
||||
// 直接下载为文件
|
||||
notificationHistoryService.downloadJSON({
|
||||
type: 'event_alert'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. localStorage 容量限制
|
||||
|
||||
- 大多数浏览器限制为 5-10MB
|
||||
- 建议定期清理历史记录和监控数据
|
||||
- 使用导出功能备份数据
|
||||
|
||||
### 2. 浏览器兼容性
|
||||
|
||||
- **桌面通知**: 需要 HTTPS 或 localhost
|
||||
- **localStorage**: 所有现代浏览器支持
|
||||
- **权限请求**: 需要用户交互(不能自动授权)
|
||||
|
||||
### 3. 隐私和数据安全
|
||||
|
||||
- 所有数据存储在本地(localStorage)
|
||||
- 不会上传到服务器
|
||||
- 用户可以随时清空数据
|
||||
|
||||
### 4. 性能影响
|
||||
|
||||
- 监控埋点非常轻量,几乎无性能影响
|
||||
- 历史记录保存异步进行,不阻塞 UI
|
||||
- 数据查询在客户端完成,不增加服务器负担
|
||||
|
||||
---
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
### 已实现的功能
|
||||
|
||||
✅ **智能桌面通知**
|
||||
- 首次重要通知时自动请求权限
|
||||
- 智能分发策略(前台/后台)
|
||||
- localStorage 持久化权限状态
|
||||
|
||||
✅ **性能监控**
|
||||
- 到达率、点击率、响应时间追踪
|
||||
- 按类型、优先级、时段统计
|
||||
- 数据导出(JSON/CSV)
|
||||
|
||||
✅ **历史记录**
|
||||
- 持久化存储(最多 500 条)
|
||||
- 筛选、搜索、分页
|
||||
- 已读/已点击标记
|
||||
- 数据导出(JSON/CSV)
|
||||
|
||||
### 未实现的功能(备份,待上线)
|
||||
|
||||
⏸️ 历史记录页面 UI(代码已备份,随时可上线)
|
||||
⏸️ 监控仪表板 UI(可选,暂未实现)
|
||||
|
||||
### 下一步建议
|
||||
|
||||
1. **用户设置页面**: 允许用户自定义通知偏好
|
||||
2. **声音提示**: 为紧急通知添加音效
|
||||
3. **数据同步**: 将历史和监控数据同步到服务器
|
||||
4. **高级筛选**: 添加更多筛选维度(如关键词、股票代码等)
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**最后更新**: 2025-01-21
|
||||
**维护者**: Claude Code
|
||||
@@ -1,371 +0,0 @@
|
||||
# 消息推送系统整合 - 测试指南
|
||||
|
||||
## 📋 整合完成清单
|
||||
|
||||
✅ **统一事件名称**
|
||||
- Mock 和真实 Socket.IO 都使用 `new_event` 事件名
|
||||
- 移除了 `trade_notification` 事件名
|
||||
|
||||
✅ **数据适配器**
|
||||
- 创建了 `adaptEventToNotification` 函数
|
||||
- 自动识别后端事件格式并转换为前端通知格式
|
||||
- 重要性映射:S → urgent, A → important, B/C → normal
|
||||
|
||||
✅ **NotificationContext 升级**
|
||||
- 监听 `new_event` 事件
|
||||
- 自动使用适配器转换事件数据
|
||||
- 支持 Mock 和 Real 模式无缝切换
|
||||
|
||||
✅ **EventList 实时推送**
|
||||
- 集成 `useEventNotifications` Hook
|
||||
- 实时更新事件列表
|
||||
- Toast 通知提示
|
||||
- WebSocket 连接状态指示器
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试步骤
|
||||
|
||||
### 1. 测试 Mock 模式(开发环境)
|
||||
|
||||
#### 1.1 配置环境变量
|
||||
确保 `.env` 文件包含以下配置:
|
||||
```bash
|
||||
REACT_APP_USE_MOCK_SOCKET=true
|
||||
# 或者
|
||||
REACT_APP_ENABLE_MOCK=true
|
||||
```
|
||||
|
||||
#### 1.2 启动应用
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
#### 1.3 验证功能
|
||||
|
||||
**a) 右下角通知卡片**
|
||||
- 启动后等待 3 秒,应该看到 "连接成功" 系统通知
|
||||
- 每隔 60 秒会自动推送 1-2 条模拟消息
|
||||
- 通知类型包括:
|
||||
- 📢 公告通知(蓝色)
|
||||
- 📈 股票动向(红/绿色,根据涨跌)
|
||||
- 📰 事件动向(橙色)
|
||||
- 📊 分析报告(紫色)
|
||||
|
||||
**b) 事件列表页面**
|
||||
- 访问事件列表页面(Community/Events)
|
||||
- 顶部应显示 "🟢 实时推送已开启"
|
||||
- 收到新事件时:
|
||||
- 右上角显示 Toast 通知
|
||||
- 事件自动添加到列表顶部
|
||||
- 无重复添加
|
||||
|
||||
**c) 控制台日志**
|
||||
打开浏览器控制台,应该看到:
|
||||
```
|
||||
[Socket Service] Using MOCK Socket Service
|
||||
NotificationContext: Socket connected
|
||||
EventList: 收到新事件推送
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 测试 Real 模式(生产环境)
|
||||
|
||||
#### 2.1 配置环境变量
|
||||
修改 `.env` 文件:
|
||||
```bash
|
||||
REACT_APP_USE_MOCK_SOCKET=false
|
||||
# 或删除该配置项
|
||||
```
|
||||
|
||||
#### 2.2 启动后端 Flask 服务
|
||||
```bash
|
||||
python app_2.py
|
||||
```
|
||||
|
||||
确保后端已启动 Socket.IO 服务并监听事件推送。
|
||||
|
||||
#### 2.3 启动前端应用
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
#### 2.4 创建测试事件(后端)
|
||||
使用后端提供的测试脚本:
|
||||
```bash
|
||||
python test_create_event.py
|
||||
```
|
||||
|
||||
#### 2.5 验证功能
|
||||
|
||||
**a) WebSocket 连接**
|
||||
- 检查控制台:`[Socket Service] Using REAL Socket Service`
|
||||
- 事件列表顶部显示 "🟢 实时推送已开启"
|
||||
|
||||
**b) 事件推送流程**
|
||||
1. 运行 `test_create_event.py` 创建新事件
|
||||
2. 后端轮询检测到新事件(最多等待 30 秒)
|
||||
3. 后端通过 Socket.IO 推送 `new_event`
|
||||
4. 前端接收事件并转换格式
|
||||
5. 同时显示:
|
||||
- 右下角通知卡片
|
||||
- 事件列表 Toast 提示
|
||||
- 事件添加到列表顶部
|
||||
|
||||
**c) 数据格式验证**
|
||||
在控制台查看事件对象,应包含:
|
||||
```javascript
|
||||
{
|
||||
id: 123,
|
||||
type: "event_alert", // 适配器转换后
|
||||
priority: "urgent", // importance: S → urgent
|
||||
title: "事件标题",
|
||||
content: "事件描述",
|
||||
clickable: true,
|
||||
link: "/event-detail/123",
|
||||
extra: {
|
||||
eventType: "tech",
|
||||
importance: "S",
|
||||
// ... 更多后端字段
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 验证清单
|
||||
|
||||
### 功能验证
|
||||
|
||||
- [ ] Mock 模式下收到模拟通知
|
||||
- [ ] Real 模式下收到真实后端推送
|
||||
- [ ] 通知卡片正确显示(类型、颜色、内容)
|
||||
- [ ] 事件列表实时更新
|
||||
- [ ] Toast 通知正常弹出
|
||||
- [ ] 连接状态指示器正确显示
|
||||
- [ ] 点击通知可跳转到详情页
|
||||
- [ ] 无重复事件添加
|
||||
|
||||
### 数据验证
|
||||
|
||||
- [ ] 后端事件格式正确转换
|
||||
- [ ] 重要性映射正确(S/A/B/C → urgent/important/normal)
|
||||
- [ ] 时间戳正确显示
|
||||
- [ ] 链接路径正确生成
|
||||
- [ ] 所有字段完整保留在 extra 中
|
||||
|
||||
### 性能验证
|
||||
|
||||
- [ ] 事件列表最多保留 100 条
|
||||
- [ ] 通知自动关闭(紧急=不关闭,重要=30s,普通=15s)
|
||||
- [ ] WebSocket 自动重连
|
||||
- [ ] 无内存泄漏
|
||||
|
||||
---
|
||||
|
||||
## 🐛 常见问题排查
|
||||
|
||||
### Q1: Mock 模式下没有收到通知?
|
||||
**A:** 检查:
|
||||
1. 环境变量 `REACT_APP_USE_MOCK_SOCKET=true` 是否设置
|
||||
2. 控制台是否显示 "Using MOCK Socket Service"
|
||||
3. 是否等待了 3 秒(首次通知延迟)
|
||||
|
||||
### Q2: Real 模式下无法连接?
|
||||
**A:** 检查:
|
||||
1. Flask 后端是否启动:`python app_2.py`
|
||||
2. API_BASE_URL 是否正确配置
|
||||
3. CORS 设置是否包含前端域名
|
||||
4. 控制台是否有连接错误
|
||||
|
||||
### Q3: 收到重复通知?
|
||||
**A:** 检查:
|
||||
1. 是否多次渲染了 EventList 组件
|
||||
2. 是否在多个地方调用了 `useEventNotifications`
|
||||
3. 控制台日志中是否有 "事件已存在,跳过添加"
|
||||
|
||||
### Q4: 通知卡片样式异常?
|
||||
**A:** 检查:
|
||||
1. 事件的 `type` 字段是否正确
|
||||
2. 是否缺少必要的字段(title, content)
|
||||
3. `NOTIFICATION_TYPE_CONFIGS` 是否定义了该类型
|
||||
|
||||
### Q5: 事件列表不更新?
|
||||
**A:** 检查:
|
||||
1. WebSocket 连接状态(顶部 Badge)
|
||||
2. `onNewEvent` 回调是否触发(控制台日志)
|
||||
3. `setLocalEvents` 是否正确执行
|
||||
|
||||
---
|
||||
|
||||
## 📊 测试数据示例
|
||||
|
||||
### Mock 模拟数据类型
|
||||
|
||||
**公告通知**
|
||||
```javascript
|
||||
{
|
||||
type: "announcement",
|
||||
priority: "urgent",
|
||||
title: "贵州茅台发布2024年度财报公告",
|
||||
content: "2024年度营收同比增长15.2%..."
|
||||
}
|
||||
```
|
||||
|
||||
**股票动向**
|
||||
```javascript
|
||||
{
|
||||
type: "stock_alert",
|
||||
priority: "urgent",
|
||||
title: "您关注的股票触发预警",
|
||||
extra: {
|
||||
stockCode: "300750",
|
||||
priceChange: "+5.2%"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**事件动向**
|
||||
```javascript
|
||||
{
|
||||
type: "event_alert",
|
||||
priority: "important",
|
||||
title: "央行宣布降准0.5个百分点",
|
||||
extra: {
|
||||
eventId: "evt001",
|
||||
sectors: ["银行", "地产", "基建"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**分析报告**
|
||||
```javascript
|
||||
{
|
||||
type: "analysis_report",
|
||||
priority: "important",
|
||||
title: "医药行业深度报告:创新药迎来政策拐点",
|
||||
author: {
|
||||
name: "李明",
|
||||
organization: "中信证券"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 真实后端事件格式
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: 123,
|
||||
title: "新能源汽车补贴政策延期",
|
||||
description: "财政部宣布新能源汽车购置补贴政策延长至2024年底",
|
||||
event_type: "policy",
|
||||
importance: "S",
|
||||
status: "active",
|
||||
created_at: "2025-01-21T14:30:00",
|
||||
hot_score: 95.5,
|
||||
view_count: 1234,
|
||||
related_avg_chg: 5.2,
|
||||
related_max_chg: 15.8,
|
||||
keywords: ["新能源", "补贴", "政策"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 下一步建议
|
||||
|
||||
### 1. 用户设置
|
||||
允许用户控制通知偏好:
|
||||
```jsx
|
||||
<Switch
|
||||
isChecked={enableNotifications}
|
||||
onChange={handleToggle}
|
||||
>
|
||||
启用实时通知
|
||||
</Switch>
|
||||
```
|
||||
|
||||
### 2. 通知过滤
|
||||
按重要性、类型过滤通知:
|
||||
```javascript
|
||||
useEventNotifications({
|
||||
eventType: 'tech', // 只订阅科技类
|
||||
importance: 'S', // 只订阅 S 级
|
||||
enabled: true
|
||||
})
|
||||
```
|
||||
|
||||
### 3. 声音提示
|
||||
添加音效提醒:
|
||||
```javascript
|
||||
onNewEvent: (event) => {
|
||||
if (event.priority === 'urgent') {
|
||||
new Audio('/alert.mp3').play();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 桌面通知
|
||||
利用浏览器通知 API:
|
||||
```javascript
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification(event.title, {
|
||||
body: event.content,
|
||||
icon: '/logo.png'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 技术说明
|
||||
|
||||
### 架构优势
|
||||
|
||||
1. **统一接口**:Mock 和 Real 完全相同的 API
|
||||
2. **自动适配**:智能识别数据格式并转换
|
||||
3. **解耦设计**:通知系统和事件列表独立工作
|
||||
4. **向后兼容**:不影响现有功能
|
||||
|
||||
### 关键文件
|
||||
|
||||
- `src/services/socketService.js` - Socket.IO 服务
|
||||
- `src/services/socket/index.js` - Socket 服务导出
|
||||
- `src/contexts/NotificationContext.js` - 通知上下文
|
||||
- `src/hooks/useEventNotifications.js` - React Hook
|
||||
- `src/views/Community/components/EventList.js` - 事件列表集成
|
||||
|
||||
> **注意**: `mockSocketService.js` 已移除(2025-01-10),现仅使用真实 Socket 连接。
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
后端创建事件
|
||||
↓
|
||||
后端轮询检测(30秒)
|
||||
↓
|
||||
Socket.IO 推送 new_event
|
||||
↓
|
||||
前端 socketService 接收
|
||||
↓
|
||||
NotificationContext 监听并适配
|
||||
↓
|
||||
同时触发:
|
||||
├─ NotificationContainer(右下角卡片)
|
||||
└─ EventList onNewEvent(Toast + 列表更新)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 整合完成
|
||||
|
||||
所有代码和功能已经就绪!你现在可以:
|
||||
|
||||
1. ✅ 在 Mock 模式下测试实时推送
|
||||
2. ✅ 在 Real 模式下连接后端
|
||||
3. ✅ 查看右下角通知卡片
|
||||
4. ✅ 体验事件列表实时更新
|
||||
5. ✅ 随时切换 Mock/Real 模式
|
||||
|
||||
**祝测试顺利!🎉**
|
||||
576
docs/NEW_PAYMENT_SYSTEM_DESIGN.md
Normal file
576
docs/NEW_PAYMENT_SYSTEM_DESIGN.md
Normal file
@@ -0,0 +1,576 @@
|
||||
# 订阅支付系统重新设计方案
|
||||
|
||||
## 📊 问题分析
|
||||
|
||||
### 现有系统的问题
|
||||
|
||||
1. **价格配置混乱**
|
||||
- 季付和月付价格相同(配置错误)
|
||||
- `monthly_price` 和 `yearly_price` 字段命名不清晰
|
||||
- 缺少季付、半年付等周期的价格配置
|
||||
|
||||
2. **升级逻辑复杂且不合理**
|
||||
- 计算剩余价值折算(按天计算 `remaining_value`)
|
||||
- 用户难以理解升级价格
|
||||
- 续费用户和新用户价格不一致
|
||||
- 逻辑复杂,容易出错
|
||||
|
||||
3. **按钮文案不清晰**
|
||||
- 已订阅用户应显示"续费 Pro"/"续费 Max"
|
||||
- 而不是"升级至 Pro"/"切换至 Pro"
|
||||
|
||||
4. **数据库表设计问题**
|
||||
- `SubscriptionUpgrade` 表记录升级,但逻辑过于复杂
|
||||
- `PaymentOrder` 表缺少必要字段
|
||||
- 价格配置分散在多个字段
|
||||
|
||||
---
|
||||
|
||||
## ✨ 新设计方案
|
||||
|
||||
### 核心原则
|
||||
|
||||
1. **简化续费逻辑**: **续费用户与新用户价格完全一致**,不做任何折算
|
||||
2. **清晰的价格体系**: 每个套餐每个周期都有明确的价格
|
||||
3. **统一的用户体验**: 无论是新购还是续费,价格透明一致
|
||||
4. **独立的订阅记录**: 每次支付都创建新的订阅记录(历史可追溯)
|
||||
|
||||
---
|
||||
|
||||
## 📐 数据库表设计
|
||||
|
||||
### 1. `subscription_plans` - 订阅套餐表(重构)
|
||||
|
||||
```sql
|
||||
CREATE TABLE subscription_plans (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
plan_code VARCHAR(20) NOT NULL UNIQUE COMMENT '套餐代码: pro, max',
|
||||
plan_name VARCHAR(50) NOT NULL COMMENT '套餐名称: Pro专业版, Max旗舰版',
|
||||
description TEXT COMMENT '套餐描述',
|
||||
features JSON COMMENT '功能列表',
|
||||
|
||||
-- 价格配置(所有周期价格)
|
||||
price_monthly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '月付价格',
|
||||
price_quarterly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '季付价格(3个月)',
|
||||
price_semiannual DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '半年付价格(6个月)',
|
||||
price_yearly DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '年付价格(12个月)',
|
||||
|
||||
-- 状态字段
|
||||
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||
display_order INT DEFAULT 0 COMMENT '展示顺序',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_plan_code (plan_code),
|
||||
INDEX idx_active_order (is_active, display_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订阅套餐配置表';
|
||||
```
|
||||
|
||||
**示例数据**:
|
||||
```sql
|
||||
INSERT INTO subscription_plans (plan_code, plan_name, description, price_monthly, price_quarterly, price_semiannual, price_yearly) VALUES
|
||||
('pro', 'Pro 专业版', '为专业投资者打造', 299.00, 799.00, 1499.00, 2699.00),
|
||||
('max', 'Max 旗舰版', '旗舰级体验', 599.00, 1599.00, 2999.00, 5399.00);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. `user_subscriptions` - 用户订阅记录表(重构)
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_subscriptions (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
subscription_id VARCHAR(32) UNIQUE NOT NULL COMMENT '订阅ID(唯一标识)',
|
||||
|
||||
-- 订阅基本信息
|
||||
plan_code VARCHAR(20) NOT NULL COMMENT '套餐代码: pro, max',
|
||||
billing_cycle VARCHAR(20) NOT NULL COMMENT '计费周期: monthly, quarterly, semiannual, yearly',
|
||||
|
||||
-- 订阅时间
|
||||
start_date DATETIME NOT NULL COMMENT '订阅开始时间',
|
||||
end_date DATETIME NOT NULL COMMENT '订阅结束时间',
|
||||
|
||||
-- 订阅状态
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active(有效), expired(已过期), cancelled(已取消)',
|
||||
is_current BOOLEAN DEFAULT FALSE COMMENT '是否为当前生效的订阅',
|
||||
|
||||
-- 支付信息
|
||||
payment_order_id INT COMMENT '关联的支付订单ID',
|
||||
paid_amount DECIMAL(10,2) NOT NULL COMMENT '实际支付金额',
|
||||
original_price DECIMAL(10,2) NOT NULL COMMENT '原价',
|
||||
discount_amount DECIMAL(10,2) DEFAULT 0 COMMENT '优惠金额',
|
||||
|
||||
-- 订阅类型
|
||||
subscription_type VARCHAR(20) DEFAULT 'new' COMMENT '订阅类型: new(新购), renew(续费)',
|
||||
previous_subscription_id VARCHAR(32) COMMENT '上一个订阅ID(续费时记录)',
|
||||
|
||||
-- 自动续费
|
||||
auto_renew BOOLEAN DEFAULT FALSE COMMENT '是否自动续费',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_subscription_id (subscription_id),
|
||||
INDEX idx_user_current (user_id, is_current),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_end_date (end_date),
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户订阅记录表';
|
||||
```
|
||||
|
||||
**设计说明**:
|
||||
- 每次支付都创建新的订阅记录
|
||||
- 通过 `is_current` 标识当前生效的订阅
|
||||
- 支持订阅历史追溯
|
||||
- 续费时记录 `previous_subscription_id` 形成订阅链
|
||||
|
||||
---
|
||||
|
||||
### 3. `payment_orders` - 支付订单表(重构)
|
||||
|
||||
```sql
|
||||
CREATE TABLE payment_orders (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
order_no VARCHAR(32) UNIQUE NOT NULL COMMENT '订单号',
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
|
||||
-- 订阅信息
|
||||
plan_code VARCHAR(20) NOT NULL COMMENT '套餐代码',
|
||||
billing_cycle VARCHAR(20) NOT NULL COMMENT '计费周期',
|
||||
subscription_type VARCHAR(20) DEFAULT 'new' COMMENT '订阅类型: new(新购), renew(续费)',
|
||||
|
||||
-- 价格信息
|
||||
original_price DECIMAL(10,2) NOT NULL COMMENT '原价',
|
||||
discount_amount DECIMAL(10,2) DEFAULT 0 COMMENT '优惠金额',
|
||||
final_amount DECIMAL(10,2) NOT NULL COMMENT '实付金额',
|
||||
|
||||
-- 优惠码
|
||||
promo_code_id INT COMMENT '优惠码ID',
|
||||
promo_code VARCHAR(50) COMMENT '优惠码',
|
||||
|
||||
-- 支付信息
|
||||
payment_method VARCHAR(20) DEFAULT 'wechat' COMMENT '支付方式: wechat, alipay',
|
||||
payment_channel VARCHAR(50) COMMENT '支付渠道详情',
|
||||
transaction_id VARCHAR(64) COMMENT '第三方交易号',
|
||||
qr_code_url TEXT COMMENT '支付二维码URL',
|
||||
|
||||
-- 订单状态
|
||||
status VARCHAR(20) DEFAULT 'pending' COMMENT '状态: pending(待支付), paid(已支付), expired(已过期), cancelled(已取消)',
|
||||
|
||||
-- 时间信息
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
paid_at TIMESTAMP NULL COMMENT '支付时间',
|
||||
expired_at TIMESTAMP NULL COMMENT '过期时间',
|
||||
|
||||
-- 备注
|
||||
remark TEXT COMMENT '备注信息',
|
||||
|
||||
INDEX idx_order_no (order_no),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_created_at (created_at),
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. `promo_codes` - 优惠码表(保持不变,微调)
|
||||
|
||||
```sql
|
||||
CREATE TABLE promo_codes (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
code VARCHAR(50) UNIQUE NOT NULL COMMENT '优惠码',
|
||||
description VARCHAR(200) COMMENT '描述',
|
||||
|
||||
-- 折扣类型
|
||||
discount_type VARCHAR(20) NOT NULL COMMENT '折扣类型: percentage(百分比), fixed_amount(固定金额)',
|
||||
discount_value DECIMAL(10,2) NOT NULL COMMENT '折扣值',
|
||||
|
||||
-- 适用范围
|
||||
applicable_plans JSON COMMENT '适用套餐: ["pro", "max"] 或 null(全部)',
|
||||
applicable_cycles JSON COMMENT '适用周期: ["monthly", "yearly"] 或 null(全部)',
|
||||
min_amount DECIMAL(10,2) COMMENT '最低消费金额',
|
||||
|
||||
-- 使用限制
|
||||
max_total_uses INT COMMENT '最大使用次数(总)',
|
||||
max_uses_per_user INT DEFAULT 1 COMMENT '每用户最大使用次数',
|
||||
current_uses INT DEFAULT 0 COMMENT '当前使用次数',
|
||||
|
||||
-- 有效期
|
||||
valid_from TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '生效时间',
|
||||
valid_until TIMESTAMP NULL COMMENT '过期时间',
|
||||
|
||||
-- 状态
|
||||
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_code (code),
|
||||
INDEX idx_active (is_active),
|
||||
INDEX idx_valid_period (valid_from, valid_until)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. `promo_code_usage` - 优惠码使用记录表(保持不变)
|
||||
|
||||
```sql
|
||||
CREATE TABLE promo_code_usage (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
promo_code_id INT NOT NULL,
|
||||
user_id INT NOT NULL,
|
||||
order_id INT NOT NULL,
|
||||
discount_amount DECIMAL(10,2) NOT NULL COMMENT '实际优惠金额',
|
||||
used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
INDEX idx_promo_code (promo_code_id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_order_id (order_id),
|
||||
|
||||
FOREIGN KEY (promo_code_id) REFERENCES promo_codes(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (order_id) REFERENCES payment_orders(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='优惠码使用记录表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. 删除不必要的表
|
||||
|
||||
**删除 `subscription_upgrades` 表** - 不再需要复杂的升级逻辑
|
||||
|
||||
---
|
||||
|
||||
## 💡 业务逻辑设计
|
||||
|
||||
### 1. 价格计算逻辑(简化版)
|
||||
|
||||
```python
|
||||
def calculate_subscription_price(plan_code, billing_cycle, promo_code=None):
|
||||
"""
|
||||
计算订阅价格(新购和续费价格完全一致)
|
||||
|
||||
Args:
|
||||
plan_code: 套餐代码 (pro/max)
|
||||
billing_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
|
||||
promo_code: 优惠码(可选)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'plan_code': 'pro',
|
||||
'billing_cycle': 'yearly',
|
||||
'original_price': 2699.00,
|
||||
'discount_amount': 0,
|
||||
'final_amount': 2699.00,
|
||||
'promo_code': None,
|
||||
'promo_error': None
|
||||
}
|
||||
"""
|
||||
# 1. 查询套餐价格
|
||||
plan = SubscriptionPlan.query.filter_by(plan_code=plan_code, is_active=True).first()
|
||||
if not plan:
|
||||
return {'error': '套餐不存在'}
|
||||
|
||||
# 2. 获取对应周期的价格
|
||||
price_field = f'price_{billing_cycle}'
|
||||
original_price = getattr(plan, price_field, 0)
|
||||
|
||||
if original_price <= 0:
|
||||
return {'error': '价格配置错误'}
|
||||
|
||||
result = {
|
||||
'plan_code': plan_code,
|
||||
'plan_name': plan.plan_name,
|
||||
'billing_cycle': billing_cycle,
|
||||
'original_price': float(original_price),
|
||||
'discount_amount': 0,
|
||||
'final_amount': float(original_price),
|
||||
'promo_code': None,
|
||||
'promo_error': None
|
||||
}
|
||||
|
||||
# 3. 应用优惠码(如果有)
|
||||
if promo_code:
|
||||
promo, error = validate_promo_code(promo_code, plan_code, billing_cycle, original_price, user_id)
|
||||
if promo:
|
||||
discount = calculate_discount(promo, original_price)
|
||||
result['discount_amount'] = float(discount)
|
||||
result['final_amount'] = float(original_price - discount)
|
||||
result['promo_code'] = promo.code
|
||||
elif error:
|
||||
result['promo_error'] = error
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- ✅ 不再计算 `remaining_value`(剩余价值)
|
||||
- ✅ 不再区分新购/续费价格
|
||||
- ✅ 逻辑简单,易于维护
|
||||
- ✅ 用户体验清晰透明
|
||||
|
||||
---
|
||||
|
||||
### 2. 创建订单逻辑
|
||||
|
||||
```python
|
||||
def create_subscription_order(user_id, plan_code, billing_cycle, promo_code=None):
|
||||
"""
|
||||
创建订阅支付订单
|
||||
"""
|
||||
# 1. 计算价格
|
||||
price_result = calculate_subscription_price(plan_code, billing_cycle, promo_code)
|
||||
if 'error' in price_result:
|
||||
return {'success': False, 'error': price_result['error']}
|
||||
|
||||
# 2. 判断是新购还是续费
|
||||
current_sub = get_current_subscription(user_id)
|
||||
|
||||
subscription_type = 'new'
|
||||
if current_sub and current_sub.plan_code in ['pro', 'max']:
|
||||
subscription_type = 'renew'
|
||||
|
||||
# 3. 创建支付订单
|
||||
order = PaymentOrder(
|
||||
order_no=generate_order_no(user_id),
|
||||
user_id=user_id,
|
||||
plan_code=plan_code,
|
||||
billing_cycle=billing_cycle,
|
||||
subscription_type=subscription_type,
|
||||
original_price=price_result['original_price'],
|
||||
discount_amount=price_result['discount_amount'],
|
||||
final_amount=price_result['final_amount'],
|
||||
promo_code=promo_code,
|
||||
status='pending',
|
||||
expired_at=datetime.now() + timedelta(minutes=30)
|
||||
)
|
||||
|
||||
db.session.add(order)
|
||||
db.session.commit()
|
||||
|
||||
# 4. 生成支付二维码(微信支付)
|
||||
qr_code_url = generate_wechat_qr_code(order)
|
||||
order.qr_code_url = qr_code_url
|
||||
db.session.commit()
|
||||
|
||||
return {'success': True, 'order': order.to_dict()}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 支付成功后的订阅激活逻辑
|
||||
|
||||
```python
|
||||
def activate_subscription_after_payment(order_id):
|
||||
"""
|
||||
支付成功后激活订阅
|
||||
"""
|
||||
order = PaymentOrder.query.get(order_id)
|
||||
if not order or order.status != 'paid':
|
||||
return {'success': False, 'error': '订单状态错误'}
|
||||
|
||||
user_id = order.user_id
|
||||
plan_code = order.plan_code
|
||||
billing_cycle = order.billing_cycle
|
||||
|
||||
# 1. 计算订阅周期
|
||||
cycle_days = {
|
||||
'monthly': 30,
|
||||
'quarterly': 90,
|
||||
'semiannual': 180,
|
||||
'yearly': 365
|
||||
}
|
||||
days = cycle_days.get(billing_cycle, 30)
|
||||
|
||||
# 2. 获取当前订阅
|
||||
current_sub = UserSubscription.query.filter_by(
|
||||
user_id=user_id,
|
||||
is_current=True
|
||||
).first()
|
||||
|
||||
# 3. 计算开始和结束时间
|
||||
now = datetime.now()
|
||||
|
||||
if current_sub and current_sub.end_date > now:
|
||||
# 续费:从当前订阅结束时间开始
|
||||
start_date = current_sub.end_date
|
||||
else:
|
||||
# 新购:从当前时间开始
|
||||
start_date = now
|
||||
|
||||
end_date = start_date + timedelta(days=days)
|
||||
|
||||
# 4. 创建新订阅记录
|
||||
new_subscription = UserSubscription(
|
||||
user_id=user_id,
|
||||
subscription_id=generate_subscription_id(),
|
||||
plan_code=plan_code,
|
||||
billing_cycle=billing_cycle,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
status='active',
|
||||
is_current=True,
|
||||
payment_order_id=order.id,
|
||||
paid_amount=order.final_amount,
|
||||
original_price=order.original_price,
|
||||
discount_amount=order.discount_amount,
|
||||
subscription_type=order.subscription_type,
|
||||
previous_subscription_id=current_sub.subscription_id if current_sub else None
|
||||
)
|
||||
|
||||
# 5. 将旧订阅标记为非当前
|
||||
if current_sub:
|
||||
current_sub.is_current = False
|
||||
|
||||
db.session.add(new_subscription)
|
||||
db.session.commit()
|
||||
|
||||
return {'success': True, 'subscription': new_subscription.to_dict()}
|
||||
```
|
||||
|
||||
**关键特性**:
|
||||
- ✅ 续费时从**当前订阅结束时间**开始,避免浪费
|
||||
- ✅ 每次支付都创建新的订阅记录
|
||||
- ✅ 保留历史订阅记录(通过 `previous_subscription_id` 形成链)
|
||||
- ✅ 逻辑清晰,易于理解
|
||||
|
||||
---
|
||||
|
||||
### 4. 按钮文案逻辑
|
||||
|
||||
```python
|
||||
def get_subscription_button_text(user, plan_code, billing_cycle):
|
||||
"""
|
||||
获取订阅按钮文字
|
||||
|
||||
Args:
|
||||
user: 用户对象
|
||||
plan_code: 套餐代码 (pro/max)
|
||||
billing_cycle: 计费周期
|
||||
|
||||
Returns:
|
||||
str: 按钮文字
|
||||
"""
|
||||
current_sub = get_current_subscription(user.id)
|
||||
|
||||
# 1. 如果没有订阅或订阅已过期
|
||||
if not current_sub or current_sub.plan_code == 'free' or current_sub.status != 'active':
|
||||
return f"选择 {get_plan_display_name(plan_code)}"
|
||||
|
||||
# 2. 如果是当前套餐且周期相同
|
||||
if current_sub.plan_code == plan_code and current_sub.billing_cycle == billing_cycle:
|
||||
return f"续费 {get_plan_display_name(plan_code)}"
|
||||
|
||||
# 3. 如果是当前套餐但周期不同
|
||||
if current_sub.plan_code == plan_code:
|
||||
return f"切换至{get_cycle_display_name(billing_cycle)}"
|
||||
|
||||
# 4. 如果是不同套餐
|
||||
return f"选择 {get_plan_display_name(plan_code)}"
|
||||
|
||||
def get_plan_display_name(plan_code):
|
||||
names = {'pro': 'Pro 专业版', 'max': 'Max 旗舰版'}
|
||||
return names.get(plan_code, plan_code)
|
||||
|
||||
def get_cycle_display_name(billing_cycle):
|
||||
names = {
|
||||
'monthly': '月付',
|
||||
'quarterly': '季付',
|
||||
'semiannual': '半年付',
|
||||
'yearly': '年付'
|
||||
}
|
||||
return names.get(billing_cycle, billing_cycle)
|
||||
```
|
||||
|
||||
**示例**:
|
||||
- 免费用户看 Pro 年付: "选择 Pro 专业版"
|
||||
- Pro 月付用户看 Pro 年付: "切换至年付"
|
||||
- Pro 年付用户看 Pro 年付: "续费 Pro 专业版"
|
||||
- Pro 用户看 Max 年付: "选择 Max 旗舰版"
|
||||
|
||||
---
|
||||
|
||||
## 📊 价格配置示例
|
||||
|
||||
### Pro 专业版价格设定
|
||||
|
||||
| 计费周期 | 价格 | 原价 | 折扣 | 月均价格 |
|
||||
|---------|------|------|------|---------|
|
||||
| 月付 | ¥299 | - | - | ¥299 |
|
||||
| 季付(3个月) | ¥799 | ¥897 | 11% | ¥266 |
|
||||
| 半年付(6个月) | ¥1499 | ¥1794 | 16% | ¥250 |
|
||||
| 年付(12个月) | ¥2699 | ¥3588 | 25% | ¥225 |
|
||||
|
||||
### Max 旗舰版价格设定
|
||||
|
||||
| 计费周期 | 价格 | 原价 | 折扣 | 月均价格 |
|
||||
|---------|------|------|------|---------|
|
||||
| 月付 | ¥599 | - | - | ¥599 |
|
||||
| 季付(3个月) | ¥1599 | ¥1797 | 11% | ¥533 |
|
||||
| 半年付(6个月) | ¥2999 | ¥3594 | 17% | ¥500 |
|
||||
| 年付(12个月) | ¥5399 | ¥7188 | 25% | ¥450 |
|
||||
|
||||
---
|
||||
|
||||
## 🔄 迁移方案
|
||||
|
||||
### 数据迁移 SQL
|
||||
|
||||
参见 `database_migration.sql`
|
||||
|
||||
### 代码迁移步骤
|
||||
|
||||
1. **备份现有数据库**
|
||||
2. **执行数据库迁移 SQL**
|
||||
3. **更新数据库模型** (`models.py`)
|
||||
4. **更新价格计算逻辑** (`calculate_price.py`)
|
||||
5. **更新 API 路由** (`routes.py`)
|
||||
6. **更新前端组件** (`SubscriptionContentNew.tsx`)
|
||||
7. **测试完整流程**
|
||||
8. **灰度发布**
|
||||
|
||||
---
|
||||
|
||||
## ✅ 优势总结
|
||||
|
||||
### 相比旧系统的改进
|
||||
|
||||
1. **价格透明** - 续费用户和新用户价格完全一致
|
||||
2. **逻辑简化** - 不再计算剩余价值,代码减少 50%+
|
||||
3. **易于理解** - 用户体验更清晰
|
||||
4. **灵活扩展** - 轻松添加新的计费周期
|
||||
5. **历史追溯** - 完整的订阅历史记录
|
||||
6. **数据完整** - 每次支付都有完整的记录
|
||||
|
||||
### 用户体验改进
|
||||
|
||||
1. **按钮文案清晰** - "续费 Pro"/"选择 Pro"明确表达意图
|
||||
2. **价格一致性** - 所有用户看到的价格都一样
|
||||
3. **无隐藏费用** - 不会因为"升级折算"产生困惑
|
||||
4. **透明计费** - 支付金额 = 显示价格 - 优惠码折扣
|
||||
|
||||
---
|
||||
|
||||
## 📝 后续优化建议
|
||||
|
||||
1. **自动续费** - 到期前自动扣款续费
|
||||
2. **订阅提醒** - 到期前 7 天、3 天、1 天发送通知
|
||||
3. **订阅暂停** - 允许用户暂停订阅
|
||||
4. **订阅降级** - 从 Max 降级到 Pro(当前周期结束后生效)
|
||||
5. **发票管理** - 支持开具电子发票
|
||||
6. **支付方式扩展** - 支持支付宝、银行卡等
|
||||
|
||||
---
|
||||
|
||||
**设计时间**: 2025-11-19
|
||||
**设计者**: Claude Code
|
||||
**版本**: v2.0.0
|
||||
@@ -1,280 +0,0 @@
|
||||
# 消息推送系统优化总结
|
||||
|
||||
## 优化目标
|
||||
1. 简化通知信息密度,通过视觉层次(边框+背景色)表达优先级
|
||||
2. 增强紧急通知的视觉冲击力(红色脉冲边框动画)
|
||||
3. 采用智能显示策略,降低普通通知的视觉干扰
|
||||
|
||||
## 实施内容
|
||||
|
||||
### 1. 优先级配置更新 (src/constants/notificationTypes.js)
|
||||
|
||||
#### 新增配置项
|
||||
- `borderWidth`: 边框宽度
|
||||
- 紧急 (urgent): 6px
|
||||
- 重要 (important): 4px
|
||||
- 普通 (normal): 2px
|
||||
|
||||
- `bgOpacity`: 背景色透明度(亮色模式)
|
||||
- 紧急: 0.25 (深色背景)
|
||||
- 重要: 0.15 (中色背景)
|
||||
- 普通: 0.08 (浅色背景)
|
||||
|
||||
- `darkBgOpacity`: 背景色透明度(暗色模式)
|
||||
- 紧急: 0.30
|
||||
- 重要: 0.20
|
||||
- 普通: 0.12
|
||||
|
||||
#### 新增辅助函数
|
||||
- `getPriorityBgOpacity(priority, isDark)`: 获取优先级对应的背景色透明度
|
||||
- `getPriorityBorderWidth(priority)`: 获取优先级对应的边框宽度
|
||||
|
||||
### 2. 紧急通知脉冲动画 (src/components/NotificationContainer/index.js)
|
||||
|
||||
#### 动画效果
|
||||
- 使用 `@emotion/react` 的 `keyframes` 创建脉冲动画
|
||||
- 仅紧急通知 (urgent) 应用动画效果
|
||||
- 动画特性:
|
||||
- 边框颜色脉冲效果
|
||||
- 阴影扩散效果(0 → 12px)
|
||||
- 持续时间:2秒
|
||||
- 缓动函数:ease-in-out
|
||||
- 无限循环
|
||||
|
||||
```javascript
|
||||
const pulseAnimation = keyframes`
|
||||
0%, 100% {
|
||||
border-left-color: currentColor;
|
||||
box-shadow: 0 0 0 0 currentColor;
|
||||
}
|
||||
50% {
|
||||
border-left-color: currentColor;
|
||||
box-shadow: -4px 0 12px 0 currentColor;
|
||||
}
|
||||
`;
|
||||
```
|
||||
|
||||
### 3. 背景色优先级优化
|
||||
|
||||
#### 亮色模式
|
||||
- **紧急通知**:`${colorScheme}.200` - 深色背景 + 脉冲动画
|
||||
- **重要通知**:`${colorScheme}.100` - 中色背景
|
||||
- **普通通知**:`white` - 极淡背景(降低视觉干扰)
|
||||
|
||||
#### 暗色模式
|
||||
- **紧急通知**:`${colorScheme}.800` 或 typeConfig.darkBg
|
||||
- **重要通知**:`${colorScheme}.800` 或 typeConfig.darkBg
|
||||
- **普通通知**:`gray.800` - 暗灰背景(降低视觉干扰)
|
||||
|
||||
### 4. 可点击性视觉提示
|
||||
|
||||
#### 问题
|
||||
- 用户需要 hover 才能知道通知是否可点击
|
||||
- cursor: pointer 不够直观
|
||||
|
||||
#### 解决方案
|
||||
- **可点击的通知**:
|
||||
- 添加完整边框(四周 1px solid)
|
||||
- 保持左侧优先级边框宽度
|
||||
- 使用更明显的阴影(md 级别)
|
||||
- 产生微妙的悬浮感
|
||||
|
||||
- **不可点击的通知**:
|
||||
- 仅左侧边框
|
||||
- 使用较淡的阴影(sm 级别)
|
||||
|
||||
```javascript
|
||||
// 可点击的通知添加完整边框
|
||||
{...(isActuallyClickable && {
|
||||
border: '1px solid',
|
||||
borderLeftWidth: priorityBorderWidth, // 保持优先级
|
||||
})}
|
||||
|
||||
// 可点击的通知使用更明显的阴影
|
||||
boxShadow={isActuallyClickable
|
||||
? (isNewest ? '2xl' : 'md')
|
||||
: (isNewest ? 'xl' : 'sm')}
|
||||
```
|
||||
|
||||
### 5. 通知组件简化 (src/components/NotificationContainer/index.js)
|
||||
|
||||
#### 显示元素分级
|
||||
|
||||
**LV1 - 必需元素(始终显示)**
|
||||
- ✅ 标题 (title)
|
||||
- ✅ 内容 (content, 最多3行)
|
||||
- ✅ 时间 (publishTime/pushTime)
|
||||
- ✅ 查看详情 (仅当 clickable=true 时)
|
||||
- ✅ 关闭按钮
|
||||
|
||||
**LV2 - 可选元素(数据存在时显示)**
|
||||
- ✅ 图标:仅在紧急/重要通知时显示
|
||||
- ❌ 优先级标签:已移除,改用边框+背景色表示
|
||||
- ✅ 状态提示:仅当 `extra?.statusHint` 存在时显示
|
||||
|
||||
**LV3 - 可选元素(数据存在时显示)**
|
||||
- ✅ AI 标识:仅当 `isAIGenerated = true` 时显示
|
||||
- ✅ 预测标识:仅当 `isPrediction = true` 时显示
|
||||
|
||||
**其他**
|
||||
- ✅ 作者信息:移除屏幕尺寸限制,仅当 `author` 存在时显示
|
||||
|
||||
#### 优先级视觉样式
|
||||
- ✅ 边框宽度:根据优先级动态调整 (2px/4px/6px)
|
||||
- ✅ 背景色深度:根据优先级使用不同深度的颜色
|
||||
- 亮色模式: .50 (普通) / .100 (重要) / .200 (紧急)
|
||||
- 暗色模式: 使用 typeConfig 的 darkBg 配置
|
||||
|
||||
#### 布局优化
|
||||
- ✅ 内容和元数据区域的左侧填充根据图标显示状态自适应
|
||||
- ✅ 无图标时不添加额外的左侧间距
|
||||
|
||||
## 预期效果
|
||||
|
||||
### 视觉改进
|
||||
- **清晰度提升**:移除冗余的优先级标签,视觉更整洁
|
||||
- **优先级强化**:
|
||||
- 紧急通知:6px 粗边框 + 深色背景 + **红色脉冲动画** → 视觉冲击力极强
|
||||
- 重要通知:4px 中等边框 + 中色背景 + 图标 → 醒目但不打扰
|
||||
- 普通通知:2px 细边框 + 白色/极淡背景 → 低视觉干扰
|
||||
- **可点击性一目了然**:
|
||||
- 可点击:完整边框 + 明显阴影 → 卡片悬浮感
|
||||
- 不可点击:仅左侧边框 + 淡阴影 → 平面感
|
||||
- **信息密度降低**:减少不必要的视觉元素,关键信息更突出
|
||||
|
||||
### 用户体验
|
||||
- **紧急通知引起注意**:脉冲动画确保用户不会错过紧急信息
|
||||
- **快速识别优先级**:
|
||||
- 动画 = 紧急(需要立即关注)
|
||||
- 图标 + 粗边框 = 重要(需要关注)
|
||||
- 细边框 + 淡背景 = 普通(可稍后查看)
|
||||
- **可点击性无需 hover**:
|
||||
- 完整边框 + 悬浮感 = 可以点击查看详情
|
||||
- 仅左侧边框 = 信息已完整,无需跳转
|
||||
- **智能显示**:可选信息只在数据存在时显示,避免空白占位
|
||||
- **响应式优化**:所有设备上保持一致的显示逻辑
|
||||
|
||||
### 向后兼容
|
||||
- ✅ 完全兼容现有通知数据结构
|
||||
- ✅ 可选字段不存在时自动隐藏
|
||||
- ✅ 不影响现有功能(点击、关闭、自动消失等)
|
||||
|
||||
## 测试建议
|
||||
|
||||
### 1. 功能测试
|
||||
```bash
|
||||
# 启动开发服务器
|
||||
npm start
|
||||
|
||||
# 观察不同优先级通知的显示效果
|
||||
# - 紧急通知:粗边框 (6px) + 深色背景 + 红色脉冲动画 + 图标 + 不自动关闭
|
||||
# - 重要通知:中等边框 (4px) + 中色背景 + 图标 + 30秒后关闭
|
||||
# - 普通通知:细边框 (2px) + 白色背景 + 无图标 + 15秒后关闭
|
||||
```
|
||||
|
||||
### 1.1 动画测试
|
||||
- [ ] 紧急通知的脉冲动画流畅无卡顿
|
||||
- [ ] 动画周期为 2 秒
|
||||
- [ ] 动画在紧急通知显示期间持续循环
|
||||
- [ ] 阴影扩散效果清晰可见
|
||||
|
||||
### 2. 边界测试
|
||||
- [ ] 仅必需字段的通知(无作者、无 AI 标识、无预测标识)
|
||||
- [ ] 包含所有可选字段的通知
|
||||
- [ ] 不同类型的通知(公告、股票、事件、分析报告)
|
||||
- [ ] 不同优先级的通知(紧急、重要、普通)
|
||||
|
||||
### 3. 响应式测试
|
||||
- [ ] 移动设备 (< 480px)
|
||||
- [ ] 平板设备 (480px - 768px)
|
||||
- [ ] 桌面设备 (> 768px)
|
||||
|
||||
### 4. 暗色模式测试
|
||||
- [ ] 切换到暗色模式,确认背景色对比度合适
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 关键代码变更
|
||||
|
||||
#### 1. 脉冲动画实现
|
||||
```javascript
|
||||
// 导入 keyframes
|
||||
import { keyframes } from '@emotion/react';
|
||||
|
||||
// 定义脉冲动画
|
||||
const pulseAnimation = keyframes`
|
||||
0%, 100% {
|
||||
border-left-color: currentColor;
|
||||
box-shadow: 0 0 0 0 currentColor;
|
||||
}
|
||||
50% {
|
||||
border-left-color: currentColor;
|
||||
box-shadow: -4px 0 12px 0 currentColor;
|
||||
}
|
||||
`;
|
||||
|
||||
// 应用到紧急通知
|
||||
<Box
|
||||
animation={priority === PRIORITY_LEVELS.URGENT
|
||||
? `${pulseAnimation} 2s ease-in-out infinite`
|
||||
: undefined}
|
||||
...
|
||||
/>
|
||||
```
|
||||
|
||||
#### 2. 优先级标签自动隐藏
|
||||
```javascript
|
||||
// PRIORITY_CONFIGS 中所有 show 属性设置为 false
|
||||
show: false, // 不再显示标签,改用边框+背景色表示
|
||||
```
|
||||
|
||||
#### 3. 背景色优先级优化
|
||||
```javascript
|
||||
const getPriorityBgColor = () => {
|
||||
const colorScheme = typeConfig.colorScheme;
|
||||
if (!isDark) {
|
||||
if (priority === PRIORITY_LEVELS.URGENT) {
|
||||
return `${colorScheme}.200`; // 深色背景 + 脉冲动画
|
||||
} else if (priority === PRIORITY_LEVELS.IMPORTANT) {
|
||||
return `${colorScheme}.100`; // 中色背景
|
||||
} else {
|
||||
return 'white'; // 极淡背景(降低视觉干扰)
|
||||
}
|
||||
} else {
|
||||
if (priority === PRIORITY_LEVELS.URGENT) {
|
||||
return typeConfig.darkBg || `${colorScheme}.800`;
|
||||
} else if (priority === PRIORITY_LEVELS.IMPORTANT) {
|
||||
return typeConfig.darkBg || `${colorScheme}.800`;
|
||||
} else {
|
||||
return 'gray.800'; // 暗灰背景(降低视觉干扰)
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### 4. 图标条件显示
|
||||
```javascript
|
||||
const shouldShowIcon = priority === PRIORITY_LEVELS.URGENT ||
|
||||
priority === PRIORITY_LEVELS.IMPORTANT;
|
||||
|
||||
{shouldShowIcon && (
|
||||
<Icon as={typeConfig.icon} ... />
|
||||
)}
|
||||
};
|
||||
```
|
||||
|
||||
## 后续改进建议
|
||||
|
||||
### 短期
|
||||
- [ ] 添加通知优先级过渡动画(边框和背景色渐变)
|
||||
- [ ] 提供配置选项让用户自定义显示元素
|
||||
|
||||
### 长期
|
||||
- [ ] 支持通知分组(按类型或优先级)
|
||||
- [ ] 添加通知搜索和筛选功能
|
||||
- [ ] 通知历史记录可视化统计
|
||||
|
||||
## 构建状态
|
||||
✅ 构建成功 (npm run build)
|
||||
✅ 无语法错误
|
||||
✅ 无 TypeScript 错误
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,338 +0,0 @@
|
||||
# 崩溃修复测试指南
|
||||
|
||||
> 测试时间:2025-10-14
|
||||
> 测试范围:SignInIllustration.js + SignUpIllustration.js
|
||||
> 服务器地址:http://localhost:3000
|
||||
|
||||
---
|
||||
|
||||
## 🎯 测试目标
|
||||
|
||||
验证以下修复是否有效:
|
||||
- ✅ 响应对象崩溃(6处)
|
||||
- ✅ 组件卸载后 setState(6处)
|
||||
- ✅ 定时器内存泄漏(2处)
|
||||
|
||||
---
|
||||
|
||||
## 📋 测试清单
|
||||
|
||||
### ✅ 关键测试(必做)
|
||||
|
||||
#### 1. **网络异常测试** - 验证响应对象修复
|
||||
|
||||
**登录页面 - 发送验证码**
|
||||
```
|
||||
测试步骤:
|
||||
1. 打开 http://localhost:3000/auth/sign-in
|
||||
2. 切换到"验证码登录"模式
|
||||
3. 输入手机号:13800138000
|
||||
4. 打开浏览器开发者工具 (F12) → Network 标签
|
||||
5. 点击 Offline 模拟断网
|
||||
6. 点击"发送验证码"按钮
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误提示:"发送验证码失败 - 网络请求失败,请检查网络连接"
|
||||
✅ 页面不崩溃
|
||||
✅ 无 JavaScript 错误
|
||||
|
||||
修复前:
|
||||
❌ 页面白屏崩溃
|
||||
❌ Console 报错:Cannot read property 'json' of null
|
||||
```
|
||||
|
||||
**登录页面 - 微信登录**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面,保持断网状态
|
||||
2. 点击"扫码登录"按钮
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误提示:"获取微信授权失败 - 网络请求失败,请检查网络连接"
|
||||
✅ 页面不崩溃
|
||||
✅ 无 JavaScript 错误
|
||||
```
|
||||
|
||||
**注册页面 - 发送验证码**
|
||||
```
|
||||
测试步骤:
|
||||
1. 打开 http://localhost:3000/auth/sign-up
|
||||
2. 切换到"验证码注册"模式
|
||||
3. 输入手机号:13800138000
|
||||
4. 保持断网状态
|
||||
5. 点击"发送验证码"按钮
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误提示:"发送失败 - 网络请求失败..."
|
||||
✅ 页面不崩溃
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. **组件卸载测试** - 验证内存泄漏修复
|
||||
|
||||
**倒计时中离开页面**
|
||||
```
|
||||
测试步骤:
|
||||
1. 恢复网络连接
|
||||
2. 在登录页面输入手机号并发送验证码
|
||||
3. 等待倒计时开始(60秒倒计时)
|
||||
4. 立即点击浏览器后退按钮或切换到其他页面
|
||||
5. 打开 Console 查看是否有警告
|
||||
|
||||
预期结果:
|
||||
✅ 无警告:"Can't perform a React state update on an unmounted component"
|
||||
✅ 倒计时定时器正确清理
|
||||
✅ 无内存泄漏
|
||||
|
||||
修复前:
|
||||
❌ Console 警告:Memory leak warning
|
||||
❌ setState 在组件卸载后仍被调用
|
||||
```
|
||||
|
||||
**请求进行中离开页面**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在注册页面填写完整信息
|
||||
2. 点击"注册"按钮
|
||||
3. 在请求响应前(loading 状态)快速刷新页面或关闭标签页
|
||||
4. 打开新标签页查看 Console
|
||||
|
||||
预期结果:
|
||||
✅ 无崩溃
|
||||
✅ 无警告信息
|
||||
✅ 请求被正确取消或忽略
|
||||
```
|
||||
|
||||
**注册成功跳转前离开**
|
||||
```
|
||||
测试步骤:
|
||||
1. 完成注册提交
|
||||
2. 在显示"注册成功"提示后
|
||||
3. 立即关闭标签页(不等待2秒自动跳转)
|
||||
|
||||
预期结果:
|
||||
✅ 无警告
|
||||
✅ navigate 不会在组件卸载后执行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. **边界情况测试** - 验证数据完整性检查
|
||||
|
||||
**后端返回空响应**
|
||||
```
|
||||
测试步骤(需要模拟后端):
|
||||
1. 使用 Chrome DevTools → Network → 右键请求 → Edit and Resend
|
||||
2. 修改响应为空对象 {}
|
||||
3. 观察页面反应
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误:"服务器响应为空"
|
||||
✅ 不会尝试访问 undefined 属性
|
||||
✅ 页面不崩溃
|
||||
```
|
||||
|
||||
**后端返回 500 错误**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面点击"扫码登录"
|
||||
2. 如果后端返回 500 错误
|
||||
|
||||
预期结果:
|
||||
✅ 显示错误:"获取二维码失败:HTTP 500"
|
||||
✅ 页面不崩溃
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🧪 进阶测试(推荐)
|
||||
|
||||
#### 4. **弱网环境测试**
|
||||
|
||||
**慢速网络模拟**
|
||||
```
|
||||
测试步骤:
|
||||
1. Chrome DevTools → Network → Throttling → Slow 3G
|
||||
2. 尝试发送验证码
|
||||
3. 等待 10 秒(超时时间)
|
||||
|
||||
预期结果:
|
||||
✅ 10秒后显示超时错误
|
||||
✅ 不会无限等待
|
||||
✅ 用户可以重试
|
||||
```
|
||||
|
||||
**丢包模拟**
|
||||
```
|
||||
测试步骤:
|
||||
1. 使用 Chrome DevTools 模拟丢包
|
||||
2. 连续点击"发送验证码"多次
|
||||
|
||||
预期结果:
|
||||
✅ 每次请求都有适当的错误提示
|
||||
✅ 不会因为并发请求而崩溃
|
||||
✅ 按钮在请求期间正确禁用
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 5. **定时器清理测试**
|
||||
|
||||
**倒计时清理验证**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面发送验证码
|
||||
2. 等待倒计时到 50 秒
|
||||
3. 快速切换到注册页面
|
||||
4. 再切换回登录页面
|
||||
5. 观察倒计时是否重置
|
||||
|
||||
预期结果:
|
||||
✅ 定时器在页面切换时正确清理
|
||||
✅ 返回登录页面时倒计时重新开始(如果再次发送)
|
||||
✅ 没有多个定时器同时运行
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 6. **并发请求测试**
|
||||
|
||||
**快速连续点击**
|
||||
```
|
||||
测试步骤:
|
||||
1. 在登录页面输入手机号
|
||||
2. 快速连续点击"发送验证码"按钮 5 次
|
||||
|
||||
预期结果:
|
||||
✅ 只发送一次请求(按钮在请求期间禁用)
|
||||
✅ 不会因为并发而崩溃
|
||||
✅ 正确显示 loading 状态
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 监控指标
|
||||
|
||||
### Console 检查清单
|
||||
|
||||
在测试过程中,打开 Console (F12) 监控以下内容:
|
||||
|
||||
```
|
||||
✅ 无红色错误(Error)
|
||||
✅ 无内存泄漏警告(Memory leak warning)
|
||||
✅ 无 setState 警告(Can't perform a React state update...)
|
||||
✅ 无 undefined 访问错误(Cannot read property of undefined)
|
||||
```
|
||||
|
||||
### Network 检查清单
|
||||
|
||||
打开 Network 标签监控:
|
||||
|
||||
```
|
||||
✅ 请求超时时间:10秒
|
||||
✅ 失败请求有正确的错误处理
|
||||
✅ 没有重复的请求
|
||||
✅ 请求被正确取消(如果页面卸载)
|
||||
```
|
||||
|
||||
### Performance 检查清单
|
||||
|
||||
打开 Performance 标签(可选):
|
||||
|
||||
```
|
||||
✅ 无内存泄漏(Memory 不会持续增长)
|
||||
✅ 定时器正确清理(Timer count 正确)
|
||||
✅ EventListener 正确清理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 测试记录表
|
||||
|
||||
请在测试时填写以下表格:
|
||||
|
||||
| 测试项 | 状态 | 问题描述 | 截图 |
|
||||
|--------|------|---------|------|
|
||||
| 登录页 - 断网发送验证码 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 登录页 - 断网微信登录 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 注册页 - 断网发送验证码 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 倒计时中离开页面 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 请求进行中离开页面 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 注册成功跳转前离开 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 后端返回空响应 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 慢速网络超时 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 定时器清理 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
| 并发请求 | ⬜ 通过 / ⬜ 失败 | | |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 如何报告问题
|
||||
|
||||
如果发现问题,请提供:
|
||||
|
||||
1. **测试场景**:具体的测试步骤
|
||||
2. **预期结果**:应该发生什么
|
||||
3. **实际结果**:实际发生了什么
|
||||
4. **Console 错误**:完整的错误信息
|
||||
5. **截图/录屏**:问题的视觉证明
|
||||
6. **环境信息**:
|
||||
- 浏览器版本
|
||||
- 操作系统
|
||||
- 网络状态
|
||||
|
||||
---
|
||||
|
||||
## ✅ 测试完成检查
|
||||
|
||||
测试完成后,确认以下内容:
|
||||
|
||||
```
|
||||
□ 所有关键测试通过
|
||||
□ Console 无错误
|
||||
□ Network 请求正常
|
||||
□ 无内存泄漏警告
|
||||
□ 用户体验流畅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 快速测试命令
|
||||
|
||||
```bash
|
||||
# 1. 确认服务器运行
|
||||
curl http://localhost:3000
|
||||
|
||||
# 2. 打开浏览器测试
|
||||
open http://localhost:3000/auth/sign-in
|
||||
|
||||
# 3. 查看编译日志
|
||||
tail -f /tmp/react-build.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📱 测试页面链接
|
||||
|
||||
- **登录页面**: http://localhost:3000/auth/sign-in
|
||||
- **注册页面**: http://localhost:3000/auth/sign-up
|
||||
- **首页**: http://localhost:3000/home
|
||||
|
||||
---
|
||||
|
||||
## 🔧 开发者工具快捷键
|
||||
|
||||
```
|
||||
F12 - 打开开发者工具
|
||||
Ctrl/Cmd+R - 刷新页面
|
||||
Ctrl/Cmd+Shift+R - 强制刷新(清除缓存)
|
||||
Ctrl/Cmd+Shift+C - 元素选择器
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**测试时间**:2025-10-14
|
||||
**预计测试时长**:15-30 分钟
|
||||
**建议测试人员**:开发者 + QA
|
||||
|
||||
祝测试顺利!如发现问题请及时反馈。
|
||||
File diff suppressed because it is too large
Load Diff
339
index.pug
Normal file
339
index.pug
Normal file
@@ -0,0 +1,339 @@
|
||||
extends layouts/layout
|
||||
block content
|
||||
+header(true, false, false)
|
||||
<div class="overflow-hidden">
|
||||
// hero
|
||||
<div class="relative pt-58 pb-20 max-xl:pt-48 max-lg:pt-44 max-md:pt-21 max-md:pb-15">
|
||||
<div class="center relative z-3" data-aos="fade">
|
||||
<div class="max-w-187">
|
||||
<div class="inline-flex items-center gap-2 mb-6 px-4 py-2 rounded-full bg-gradient-to-r from-green/20 to-green/5 border border-green/30 backdrop-blur-sm max-md:mb-3">
|
||||
<svg class="size-4 fill-green" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M8 0L9.798 5.579L15.708 4.292L11.854 8.854L15.708 13.416L9.798 12.129L8 18L6.202 12.421L0.292 13.708L4.146 9.146L0.292 4.584L6.202 5.871L8 0Z"/>
|
||||
</svg>
|
||||
<span class="text-title-5 text-green max-md:text-[14px]">金融AI技术领航者</span>
|
||||
</div>
|
||||
<div class="mb-8 text-big-title-1 bg-radial-white-1 bg-clip-text text-transparent max-xl:text-big-title-2 max-lg:text-title-1 max-lg:mb-10 max-md:mb-6 max-md:text-big-title-mobile">智能舆情分析系统</div>
|
||||
<div class="flex flex-wrap gap-3 mb-8 max-lg:mb-6 max-md:mb-4">
|
||||
<div class="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg bg-black/30 border border-line/50 backdrop-blur-sm">
|
||||
<svg class="size-4 fill-green" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M8 0C3.6 0 0 3.6 0 8s3.6 8 8 8 8-3.6 8-8-3.6-8-8-8zm0 14c-3.3 0-6-2.7-6-6s2.7-6 6-6 6 2.7 6 6-2.7 6-6 6zm3.5-6c0 1.9-1.6 3.5-3.5 3.5S4.5 9.9 4.5 8 6.1 4.5 8 4.5 11.5 6.1 11.5 8z"/>
|
||||
</svg>
|
||||
<span class="text-title-5 text-white/90 max-md:text-[13px]">深度数据挖掘</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-2 px-3.5 py-2 rounded-lg bg-black/30 border border-line/50 backdrop-blur-sm">
|
||||
<svg class="size-4 fill-green" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path d="M13.5 2h-11C1.7 2 1 2.7 1 3.5v9c0 .8.7 1.5 1.5 1.5h11c.8 0 1.5-.7 1.5-1.5v-9c0-.8-.7-1.5-1.5-1.5zM8 11.5c-1.9 0-3.5-1.6-3.5-3.5S6.1 4.5 8 4.5s3.5 1.6 3.5 3.5-1.6 3.5-3.5 3.5z"/>
|
||||
</svg>
|
||||
<span class="text-title-5 text-white/90 max-md:text-[13px]">7×24小时监控</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-94 mb-9.5 text-description max-lg:max-w-76 max-md:max-w-full max-md:mb-3.5">基于金融领域微调的大语言模型,7×24小时不间断对舆情数据进行深度挖掘和分析,对历史事件进行复盘,关联相关标的,为投资决策提供前瞻性的智能洞察。</div>
|
||||
<div class="flex gap-7.5 max-md:mb-12.5">
|
||||
<a class="wechat-icon-link fill-white transition-colors hover:fill-green relative" href="javascript:void(0)" data-wechat-img="wechat-app.jpg" title="微信小程序">
|
||||
<svg class="size-5 fill-inherit" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M13.889 8.333c.31 0 .611.028.903.078C14.292 5.31 11.403 3 7.917 3 4.083 3 1 5.686 1 9.028c0 1.944 1.028 3.639 2.639 4.861L3 16.111l2.5-1.25c.833.194 1.528.333 2.417.333.278 0 .556-.014.833-.042-.278-.805-.417-1.652-.417-2.513 0-3.264 2.764-5.903 6.556-5.903v-.403zM10.139 6.528c.583 0 1.055.472 1.055 1.055s-.472 1.055-1.055 1.055-1.055-.472-1.055-1.055.472-1.055 1.055-1.055zM5.694 8.639c-.583 0-1.055-.472-1.055-1.055s.472-1.055 1.055-1.055 1.055.472 1.055 1.055-.472 1.055-1.055 1.055zm8.195 1.694c-2.847 0-5.139 2.014-5.139 4.486 0 2.472 2.292 4.486 5.139 4.486.764 0 1.528-.139 2.222-.347L18.333 20l-.625-1.875c1.25-.972 2.014-2.361 2.014-3.958 0-2.472-2.292-4.486-5.139-4.486h-.694zm-2.084 3.125c.389 0 .695.306.695.694s-.306.695-.695.695-.694-.306-.694-.695.305-.694.694-.694zm4.167 0c.389 0 .694.306.694.694s-.305.695-.694.695-.695-.306-.695-.695.306-.694.695-.694z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a class="wechat-icon-link fill-white transition-colors hover:fill-green relative" href="javascript:void(0)" data-wechat-img="public.jpg" title="微信公众号">
|
||||
<svg class="size-5 fill-inherit" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M10 0C4.477 0 0 4.477 0 10s4.477 10 10 10 10-4.477 10-10S15.523 0 10 0zm3.889 6.944c.139 0 .278.014.417.028-1.306-2.958-4.723-5.027-8.611-5.027C2.611 1.945 0 4.306 0 7.222c0 1.528.806 2.861 2.083 3.819l-.417 1.945 1.945-.972c.639.139 1.167.25 1.861.25.222 0 .444-.014.667-.028-.222-.639-.333-1.306-.333-1.986 0-2.569 2.181-4.653 5.139-4.653l.944-.653zm-5.278-2.5c.458 0 .833.375.833.833s-.375.833-.833.833-.833-.375-.833-.833.375-.833.833-.833zM4.167 6.111c-.458 0-.833-.375-.833-.833s.375-.833.833-.833.833.375.833.833-.375.833-.833.833zm9.722 3.333c-2.236 0-4.028 1.583-4.028 3.528s1.792 3.528 4.028 3.528c.597 0 1.194-.111 1.736-.278l1.542.694-.486-1.472c.972-.764 1.597-1.861 1.597-3.125 0-1.945-1.792-3.528-4.028-3.528h-.361zm-1.667 2.5c.306 0 .556.25.556.556s-.25.556-.556.556-.556-.25-.556-.556.25-.556.556-.556zm3.334 0c.305 0 .555.25.555.556s-.25.556-.555.556-.556-.25-.556-.556.25-.556.556-.556z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a class="wechat-icon-link fill-white transition-colors hover:fill-green relative" href="javascript:void(0)" data-wechat-img="customer-service.jpg" title="微信客服号">
|
||||
<svg class="size-5 fill-inherit" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M10 0C4.477 0 0 4.477 0 10s4.477 10 10 10 10-4.477 10-10S15.523 0 10 0zm4.861 7.222c.167 0 .333.014.5.028C14.097 4.444 11.139 2 7.5 2 3.889 2 1 4.444 1 7.5c0 1.778.972 3.333 2.5 4.444l-.5 2.223 2.222-1.111c.722.167 1.333.278 2.111.278.278 0 .556-.014.834-.028-.278-.722-.417-1.5-.417-2.306 0-2.972 2.5-5.389 5.833-5.389l1.278-.389zm-6.028-2.777c.528 0 .945.417.945.945s-.417.944-.945.944-.944-.416-.944-.944.416-.945.944-.945zm-4.166 1.888c-.528 0-.945-.416-.945-.944s.417-.945.945-.945.944.417.944.945-.416.944-.944.944zm10.277 3.611c-2.569 0-4.611 1.806-4.611 4.028s2.042 4.028 4.611 4.028c.694 0 1.389-.125 2-.306L19 18.889l-.556-1.667c1.111-.889 1.833-2.139 1.833-3.611 0-2.222-2.042-4.028-4.611-4.028h-.722zm-1.944 2.778c.361 0 .639.278.639.639s-.278.639-.639.639-.639-.278-.639-.639.278-.639.639-.639zm3.889 0c.361 0 .639.278.639.639s-.278.639-.639.639-.639-.278-.639-.639.278-.639.639-.639zM10 14.444c0 .306-.25.556-.556.556H6.111c-.306 0-.556-.25-.556-.556s.25-.555.556-.555h3.333c.306 0 .556.25.556.555z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="absolute right-20 bottom-0 flex gap-4 max-xl:right-10 max-md:static">
|
||||
<div class="relative w-42 p-5 pb-6.5 rounded-[1.25rem] bg-content text-center shadow-1 backdrop-blur-[1.25rem] max-md:px-3">
|
||||
<div class="absolute inset-0 border border-line rounded-[1.25rem] pointer-events-none"></div>
|
||||
<div class="relative flex justify-center items-center size-11 mx-auto mb-4 rounded-lg bg-gradient-to-b from-black/15 to-white/15 shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset,0_0_0.625rem_0_rgba(255,255,255,0.10)_inset]">
|
||||
<div class="absolute inset-0 border border-line rounded-lg"></div>
|
||||
img(class="w-5" src=require('Images/clock.svg') alt="")
|
||||
</div>
|
||||
<div class="text-title-4 max-md:text-title-3-mobile">实时数据分析</div>
|
||||
</div>
|
||||
<div class="relative w-42 p-5 pb-6.5 rounded-[1.25rem] bg-content text-center shadow-1 backdrop-blur-[1.25rem] max-md:px-3">
|
||||
<div class="absolute inset-0 border border-line rounded-[1.25rem] pointer-events-none"></div>
|
||||
<div class="relative flex justify-center items-center size-11 mx-auto mb-4 rounded-lg bg-gradient-to-b from-black/15 to-white/15 shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset,0_0_0.625rem_0_rgba(255,255,255,0.10)_inset]">
|
||||
<div class="absolute inset-0 border border-line rounded-lg"></div>
|
||||
img(class="w-5" src=require('Images/floor.svg') alt="")
|
||||
</div>
|
||||
<div class="text-title-4 max-md:text-title-3-mobile">低延迟推理</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute top-23 right-[calc(50%-28.5rem)] size-178 rounded-full max-xl:size-140 max-md:top-36 max-md:right-auto max-md:left-8.5 max-md:size-133">
|
||||
<div class="absolute -inset-[10%] mask-radial-at-center mask-radial-from-20% mask-radial-to-52%">
|
||||
video(class="w-full" src=require('Videos/video-1.webm') autoplay loop muted playsinline)
|
||||
</div>
|
||||
<div class="absolute inset-0 rounded-full shadow-[0.875rem_1.0625rem_1.25rem_0_rgba(255,255,255,0.25)_inset] bg-black/1"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="absolute top-61.5 right-[calc(50%-35.18rem)] z-2 size-116.5 bg-green/20 rounded-full blur-[8rem] max-md:top-36 max-lg:-right-96 max-md:left-74 max-md:right-auto"></div>
|
||||
<div class="absolute top-77 left-[calc(50%-57.5rem)] z-2 size-116.5 bg-green/20 rounded-full blur-[8rem] max-lg:-left-60 max-md:top-84 max-md:-left-52 max-md:size-80"></div>
|
||||
</div>
|
||||
</div>
|
||||
// details
|
||||
<div class="pt-40.5 pb-30.5 max-xl:pt-30 max-lg:py-24 max-md:py-15">
|
||||
<div class="center">
|
||||
<div class="flex flex-wrap -mt-4 -mx-2">
|
||||
<div class="relative min-h-75 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-xl:min-h-70 flex w-[calc(50%-1rem)] h-full mt-4 mx-2 pt-6 pb-7 px-8.5 max-xl:px-6 max-lg:w-[calc(100%-1rem)] max-md:px-8 max-md:min-h-112.5" data-aos="fade">
|
||||
<div class="relative z-2 max-w-58 flex flex-col max-md:max-w-full">
|
||||
<div class="mb-auto bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-xl:text-title-2 max-md:mb-0.5 max-md:text-title-1-mobile">99%</div>
|
||||
<div class="mt-3 text-title-4 max-md:text-title-3-mobile">金融数据理解准确率</div>
|
||||
<div class="mt-2.5 text-description max-md:mt-2">基于金融领域深度微调的大语言模型,精准理解市场动态和舆情变化。</div>
|
||||
</div>
|
||||
<div class="absolute top-0 right-0 bottom-0 flex items-center max-2xl:-right-16 max-lg:right-0 max-md:top-auto max-md:left-0 max-md:pl-7.5">
|
||||
img(class="w-86.25 max-xl:w-72 max-md:w-full" src=require('Images/details-pic-1.png') alt="")
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative min-h-75 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-xl:min-h-70 flex w-[calc(50%-1rem)] h-full mt-4 mx-2 pt-6 pb-7 px-8.5 max-xl:px-6 max-lg:w-[calc(100%-1rem)] max-md:px-8 max-md:min-h-112.5" data-aos="fade">
|
||||
<div class="relative z-2 max-w-58 flex flex-col max-md:max-w-full">
|
||||
<div class="mb-auto bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-xl:text-title-2 max-md:mb-0.5 max-md:text-title-1-mobile">24/7</div>
|
||||
<div class="mt-3 text-title-4 max-md:text-title-3-mobile">全天候舆情监控</div>
|
||||
<div class="mt-2.5 text-description max-md:mt-2">7×24小时不间断监控市场舆情,第一时间捕捉关键信息。</div>
|
||||
</div>
|
||||
<div class="absolute top-0 right-0 bottom-0 flex items-center max-2xl:-right-16 max-lg:right-0 max-md:top-auto max-md:left-0 max-md:pl-7.5">
|
||||
img(class="w-86.25 max-xl:w-72 max-md:w-full" src=require('Images/details-pic-2.png') alt="")
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative min-h-75 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-xl:min-h-70 flex items-end w-62.5 mt-4 mx-2 px-8.5 pb-7 max-xl:px-6 max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)] max-md:min-h-72 max-md:px-7 max-md:pb-6" data-aos="fade">
|
||||
<div class="absolute top-0 left-0 right-0 flex justify-center">
|
||||
img(class="w-full max-lg:max-w-60 max-md:max-w-73.5" src=require('Images/details-pic-3.png') alt="")
|
||||
</div>
|
||||
<div class="relative z-2 max-w-58 flex flex-col">
|
||||
<div class="mb-2.5 text-title-4 max-md:mb-1.5 max-md:text-title-3-mobile">深度模型微调</div>
|
||||
<div class="text-description">针对金融领域数据进行专业化模型训练和优化。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative min-h-75 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-xl:min-h-70 flex items-end grow mt-4 mx-2 px-8.5 pb-7 overflow-hidden max-xl:px-6 max-lg:order-5" data-aos="fade">
|
||||
<div class="absolute top-0 left-0 flex justify-center max-2xl:top-8 max-lg:top-0 max-md:-left-3 max-md:w-176">
|
||||
img(class="w-full" src=require('Images/details-pic-4.png') alt="")
|
||||
</div>
|
||||
<div class="relative z-2 max-w-58 flex flex-col">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="relative flex justify-center items-center shrink-0 w-12.5 h-12.5 rounded-lg bg-gradient-to-b from-[#F4D03F] to-[#D4AF37] shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(212,175,55,0.30)_inset,_0_0_0.625rem_0_rgba(212,175,55,0.50)_inset] after:absolute after:inset-0 after:border after:border-line after:rounded-lg after:pointer-events-none">
|
||||
img(class="w-4" src=require('Images/lightning.svg') alt="")
|
||||
</div>
|
||||
<div class="bg-radial-white-2 bg-clip-text text-transparent text-title-2 leading-tight max-xl:text-title-2 max-md:text-title-1-mobile"><100ms</div>
|
||||
</div>
|
||||
<div class="text-title-4 max-md:text-title-3-mobile">低延迟推理系统</div>
|
||||
<div class="mt-2.5 text-description max-md:mt-2">毫秒级响应速度,实时处理海量舆情数据。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative min-h-75 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-xl:min-h-70 flex items-end w-62.5 mt-4 mx-2 px-8.5 pb-7 max-xl:px-6 max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)] max-md:min-h-72 max-md:px-7 max-md:pb-6" data-aos="fade">
|
||||
<div class="absolute top-0 left-0 right-0 flex justify-center">
|
||||
img(class="w-full max-lg:max-w-60 max-md:max-w-73.5" src=require('Images/details-pic-5.png') alt="")
|
||||
</div>
|
||||
<div class="relative z-2 max-w-58 flex flex-col">
|
||||
<div class="bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-xl:text-title-2 max-md:text-title-1-mobile">历史复盘</div>
|
||||
<div class="text-description">对历史事件进行深度复盘分析,关联标的,辅助投资决策。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// features
|
||||
<div class="relative pt-34.5 pb-41 max-xl:pt-20 max-xl:pb-30 max-lg:py-24 max-md:pt-15 max-md:pb-14">
|
||||
<div class="center relative z-2">
|
||||
<div class="max-w-148 mx-auto mb-18 text-center max-xl:mb-14 max-md:mb-8.5" data-aos="fade">
|
||||
<div class="label mb-3 max-md:mb-1">核心功能</div>
|
||||
<div class="mb-6 bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-lg:text-title-2 max-md:mb-3 max-md:text-title-1-mobile">我们能做什么?</div>
|
||||
<div class="text-description">基于AI的舆情分析系统,深度挖掘市场动态,为投资决策提供实时智能洞察。</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap -mt-4 -mx-2">
|
||||
<div class="relative w-[calc(25%-1rem)] mt-4 mx-2 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)]" data-aos="fade">
|
||||
<div class="max-md:text-center">
|
||||
img(class="w-full max-md:max-w-73.5" src=require('Images/features-pic-1.png') alt="")
|
||||
</div>
|
||||
<div class="pt-0.5 px-8.5 pb-7.5 max-xl:px-5 max-xl:pb-5 max-lg:px-8 max-lg:pb-7 max-md:pb-6">
|
||||
<div class="mb-2.5 text-title-4 max-md:mb-1 max-md:text-title-2-mobile">舆情数据挖掘</div>
|
||||
<div class="text-description">实时采集和分析全网金融舆情,捕捉市场情绪变化。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative w-[calc(25%-1rem)] mt-4 mx-2 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)]" data-aos="fade">
|
||||
<div class="max-md:text-center">
|
||||
img(class="w-full max-md:max-w-73.5" src=require('Images/features-pic-2.png') alt="")
|
||||
</div>
|
||||
<div class="pt-0.5 px-8.5 pb-7.5 max-xl:px-5 max-xl:pb-5 max-lg:px-8 max-lg:pb-7 max-md:pb-6">
|
||||
<div class="mb-2.5 text-title-4 max-md:mb-1 max-md:text-title-2-mobile">智能事件关联</div>
|
||||
<div class="text-description">自动关联相关标的和历史事件,构建完整的信息图谱。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative w-[calc(25%-1rem)] mt-4 mx-2 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)]" data-aos="fade">
|
||||
<div class="max-md:text-center">
|
||||
img(class="w-full max-md:max-w-73.5" src=require('Images/features-pic-3.png') alt="")
|
||||
</div>
|
||||
<div class="pt-0.5 px-8.5 pb-7.5 max-xl:px-5 max-xl:pb-5 max-lg:px-8 max-lg:pb-7 max-md:pb-6">
|
||||
<div class="mb-2.5 text-title-4 max-md:mb-1 max-md:text-title-2-mobile">历史复盘</div>
|
||||
<div class="text-description">深度复盘历史事件走势,洞察关键节点与转折,为投资决策提供经验参考。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative w-[calc(25%-1rem)] mt-4 mx-2 rounded-[1.25rem] bg-content shadow-2 backdrop-blur-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:w-[calc(50%-1rem)] max-md:w-[calc(100%-1rem)]" data-aos="fade">
|
||||
<div class="max-md:text-center">
|
||||
img(class="w-full max-md:max-w-73.5" src=require('Images/features-pic-4.png') alt="")
|
||||
</div>
|
||||
<div class="pt-0.5 px-8.5 pb-7.5 max-xl:px-5 max-xl:pb-5 max-lg:px-8 max-lg:pb-7 max-md:pb-6">
|
||||
<div class="mb-2.5 text-title-4 max-md:mb-1 max-md:text-title-2-mobile">专精金融的AI聊天</div>
|
||||
<div class="text-description">基于金融领域深度训练的智能对话助手,即时解答市场问题,提供专业投资建议。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-md:hidden">
|
||||
<div class="absolute top-47.5 left-[calc(50%-52.38rem)] size-98.5 bg-gold/15 rounded-full blur-[6.75rem]"></div>
|
||||
<div class="absolute bottom-2.5 right-[calc(50%-51.44rem)] size-98.5 bg-gold/15 rounded-full blur-[6.75rem]"></div>
|
||||
</div>
|
||||
</div>
|
||||
// pricing
|
||||
<div class="pt-34.5 pb-25 max-2xl:pt-25 max-lg:py-20 max-md:py-15" id="pricing">
|
||||
<div class="center">
|
||||
<div class="max-w-175 mx-auto mb-17.5 text-center max-xl:mb-14 max-md:mb-8" data-aos="fade">
|
||||
<div class="label mb-3 max-md:mb-1.5">订阅方案</div>
|
||||
<div class="bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-lg:text-title-2 max-md:text-title-1-mobile">立即开启智能决策</div>
|
||||
</div>
|
||||
<div class="flex justify-center gap-4 max-lg:-mx-10 max-lg:px-10 max-lg:overflow-x-auto max-lg:scrollbar-none max-md:-mx-5 max-md:px-5" data-aos="fade">
|
||||
<div class="relative flex flex-col flex-1 max-w-md rounded-[1.25rem] overflow-hidden shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:shrink-0 max-lg:flex-auto max-lg:w-84">
|
||||
<div class="relative z-2 pt-8 px-8.5 pb-10 text-title-4 max-md:text-title-5 text-white">PRO</div>
|
||||
<div class="relative z-3 flex flex-col grow -mt-5 p-3.5 pb-8.25 backdrop-blur-[1.25rem] bg-white/1 rounded-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none">
|
||||
<div class="relative mb-8 p-5 rounded-[0.8125rem] bg-white/2 backdrop-blur-[1.25rem] shadow-2 after:absolute after:inset-0 after:border after:border-line after:rounded-[0.8125rem] after:pointer-events-none">
|
||||
<div class="flex items-end gap-3 mb-4">
|
||||
<div class="bg-radial-white-2 bg-clip-text text-transparent text-title-1 leading-[3.1rem] max-xl:text-title-2 max-xl:leading-[2.4rem]">¥198</div>
|
||||
<div class="text-title-5">/月</div>
|
||||
</div>
|
||||
<a class="btn btn-secondary w-full bg-line !text-description hover:!text-white" href="https://valuefrontier.cn/home/pages/account/subscription" target="_blank">选择Pro版</a>
|
||||
</div>
|
||||
<div class="flex flex-col gap-6.5 px-3.5 max-xl:px-0 max-xl:gap-5 max-md:px-3.5">
|
||||
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
|
||||
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
|
||||
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>事件关联股票深度分析</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
|
||||
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
|
||||
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>历史事件智能对比复盘</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
|
||||
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
|
||||
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>事件概念关联与挖掘</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
|
||||
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
|
||||
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>概念板块个股追踪</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
|
||||
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
|
||||
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>概念深度研报与解读</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
|
||||
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
|
||||
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>个股异动实时预警</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative flex flex-col flex-1 max-w-md rounded-[1.25rem] overflow-hidden shadow-2 before:absolute before:-top-20 before:left-1/2 before:z-1 before:-translate-x-1/2 before:w-65 before:h-57 before:bg-gold/15 before:rounded-full before:blur-[3.375rem] after:absolute after:inset-0 after:border after:border-gold/30 after:rounded-[1.25rem] after:pointer-events-none max-lg:shrink-0 max-lg:flex-auto max-lg:w-84">
|
||||
<div class="absolute -top-36 left-13 w-105 mask-radial-at-center mask-radial-from-20% mask-radial-to-52%">
|
||||
video(class="w-full" src=require('Videos/video-1.webm') autoplay loop muted playsinline)
|
||||
</div>
|
||||
<div class="relative z-2 pt-8 px-8.5 pb-10 text-title-4 max-md:text-title-5 bg-gradient-to-r from-gold-dark/20 to-gold/20 rounded-t-[1.25rem] text-gold">MAX</div>
|
||||
<div class="relative z-3 flex flex-col grow -mt-5 p-3.5 pb-8.25 backdrop-blur-[2rem] shadow-2 bg-white/7 rounded-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none">
|
||||
<div class="relative mb-8 p-5 rounded-[0.8125rem] bg-line backdrop-blur-[1.25rem] shadow-2 after:absolute after:inset-0 after:border after:border-line after:rounded-[0.8125rem] after:pointer-events-none">
|
||||
<div class="flex items-end gap-3 mb-4">
|
||||
<div class="bg-radial-white-2 bg-clip-text text-transparent text-title-1 leading-[3.1rem] max-xl:text-title-2 max-xl:leading-[2.4rem]">¥998</div>
|
||||
<div class="text-title-5">/月</div>
|
||||
</div>
|
||||
<a class="btn btn-primary w-full" href="https://valuefrontier.cn/home/pages/account/subscription" target="_blank">选择Max版</a>
|
||||
</div>
|
||||
<div class="flex flex-col gap-6.5 px-3.5 max-xl:px-0 max-xl:gap-5 max-md:px-3.5">
|
||||
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
|
||||
<div class="flex justify-center items-center shrink-0 size-5 bg-gold rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(212,175,55,0.30)_inset,_0_0_0.625rem_0_rgba(212,175,55,0.50)_inset]">
|
||||
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="font-medium">包含Pro版全部功能</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
|
||||
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
|
||||
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>事件传导链路智能分析</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
|
||||
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
|
||||
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>概念演变时间轴追溯</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
|
||||
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
|
||||
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>个股全方位深度研究</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
|
||||
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
|
||||
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>价小前投研助手无限使用</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
|
||||
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
|
||||
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>新功能优先体验权</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile">
|
||||
<div class="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
|
||||
<svg class="size-5 fill-black" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
||||
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>专属客服一对一服务</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
include includes/start
|
||||
</div>
|
||||
+footer(true)
|
||||
631
new_subscription_logic.py
Normal file
631
new_subscription_logic.py
Normal file
@@ -0,0 +1,631 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
新版订阅支付系统核心逻辑
|
||||
版本: v2.0.0
|
||||
日期: 2025-11-19
|
||||
|
||||
核心改进:
|
||||
1. 续费价格与新购价格完全一致
|
||||
2. 不再计算剩余价值折算
|
||||
3. 逻辑简化,易于维护
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
import json
|
||||
import random
|
||||
|
||||
|
||||
# ============================================
|
||||
# 辅助函数
|
||||
# ============================================
|
||||
|
||||
def beijing_now():
|
||||
"""获取北京时间"""
|
||||
from datetime import timezone, timedelta
|
||||
utc_now = datetime.now(timezone.utc)
|
||||
beijing_time = utc_now.astimezone(timezone(timedelta(hours=8)))
|
||||
return beijing_time.replace(tzinfo=None)
|
||||
|
||||
|
||||
def generate_order_no(user_id):
|
||||
"""生成订单号"""
|
||||
timestamp = int(beijing_now().timestamp() * 1000000)
|
||||
random_suffix = random.randint(1000, 9999)
|
||||
return f"{timestamp}{user_id:04d}{random_suffix}"
|
||||
|
||||
|
||||
def generate_subscription_id():
|
||||
"""生成订阅ID"""
|
||||
timestamp = int(beijing_now().timestamp() * 1000)
|
||||
random_suffix = random.randint(10000, 99999)
|
||||
return f"SUB_{timestamp}_{random_suffix}"
|
||||
|
||||
|
||||
# ============================================
|
||||
# 核心业务逻辑
|
||||
# ============================================
|
||||
|
||||
def calculate_subscription_price(plan_code, billing_cycle, promo_code=None, user_id=None, db_session=None):
|
||||
"""
|
||||
计算订阅价格(新购和续费价格完全一致)
|
||||
|
||||
Args:
|
||||
plan_code: 套餐代码 (pro/max)
|
||||
billing_cycle: 计费周期 (monthly/quarterly/semiannual/yearly)
|
||||
promo_code: 优惠码(可选)
|
||||
user_id: 用户ID(可选,用于优惠码验证)
|
||||
db_session: 数据库会话(可选)
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'success': True/False,
|
||||
'plan_code': 'pro',
|
||||
'plan_name': 'Pro 专业版',
|
||||
'billing_cycle': 'yearly',
|
||||
'original_price': 2699.00,
|
||||
'discount_amount': 0,
|
||||
'final_amount': 2699.00,
|
||||
'promo_code': None,
|
||||
'promo_error': None,
|
||||
'error': None # 如果有错误
|
||||
}
|
||||
"""
|
||||
from models import SubscriptionPlan, PromoCode # 需要在实际使用时导入
|
||||
|
||||
try:
|
||||
# 1. 查询套餐
|
||||
plan = SubscriptionPlan.query.filter_by(plan_code=plan_code, is_active=True).first()
|
||||
if not plan:
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'套餐 {plan_code} 不存在或已下架'
|
||||
}
|
||||
|
||||
# 2. 获取对应周期的价格
|
||||
price_field_map = {
|
||||
'monthly': 'price_monthly',
|
||||
'quarterly': 'price_quarterly',
|
||||
'semiannual': 'price_semiannual',
|
||||
'yearly': 'price_yearly'
|
||||
}
|
||||
|
||||
price_field = price_field_map.get(billing_cycle)
|
||||
if not price_field:
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'不支持的计费周期: {billing_cycle}'
|
||||
}
|
||||
|
||||
original_price = getattr(plan, price_field, None)
|
||||
if original_price is None or original_price <= 0:
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'{billing_cycle} 周期价格未配置'
|
||||
}
|
||||
|
||||
original_price = float(original_price)
|
||||
|
||||
# 3. 构建基础结果
|
||||
result = {
|
||||
'success': True,
|
||||
'plan_code': plan_code,
|
||||
'plan_name': plan.plan_name,
|
||||
'billing_cycle': billing_cycle,
|
||||
'original_price': original_price,
|
||||
'discount_amount': 0.0,
|
||||
'final_amount': original_price,
|
||||
'promo_code': None,
|
||||
'promo_error': None,
|
||||
'error': None
|
||||
}
|
||||
|
||||
# 4. 应用优惠码(如果有)
|
||||
if promo_code and promo_code.strip():
|
||||
promo_code = promo_code.strip().upper()
|
||||
|
||||
# 验证优惠码
|
||||
promo, error = validate_promo_code(
|
||||
promo_code,
|
||||
plan_code,
|
||||
billing_cycle,
|
||||
original_price,
|
||||
user_id,
|
||||
db_session
|
||||
)
|
||||
|
||||
if promo:
|
||||
# 计算折扣
|
||||
discount = calculate_discount(promo, original_price)
|
||||
result['discount_amount'] = float(discount)
|
||||
result['final_amount'] = float(original_price - discount)
|
||||
result['promo_code'] = promo.code
|
||||
elif error:
|
||||
result['promo_error'] = error
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'价格计算失败: {str(e)}'
|
||||
}
|
||||
|
||||
|
||||
def get_current_subscription(user_id, db_session=None):
|
||||
"""
|
||||
获取用户当前生效的订阅
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
db_session: 数据库会话(可选)
|
||||
|
||||
Returns:
|
||||
UserSubscription 对象 或 None
|
||||
"""
|
||||
from models import UserSubscription
|
||||
|
||||
try:
|
||||
subscription = UserSubscription.query.filter_by(
|
||||
user_id=user_id,
|
||||
is_current=True
|
||||
).first()
|
||||
|
||||
# 检查是否过期
|
||||
if subscription and subscription.end_date < beijing_now():
|
||||
subscription.status = 'expired'
|
||||
subscription.is_current = False
|
||||
if db_session:
|
||||
db_session.commit()
|
||||
return None
|
||||
|
||||
return subscription
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取当前订阅失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def determine_subscription_type(user_id, plan_code, billing_cycle):
|
||||
"""
|
||||
判断订阅类型(新购还是续费)
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
plan_code: 目标套餐代码
|
||||
billing_cycle: 目标计费周期
|
||||
|
||||
Returns:
|
||||
str: 'new' 或 'renew'
|
||||
"""
|
||||
current_sub = get_current_subscription(user_id)
|
||||
|
||||
# 如果没有订阅或订阅是免费版,则为新购
|
||||
if not current_sub or current_sub.plan_code == 'free':
|
||||
return 'new'
|
||||
|
||||
# 如果是付费订阅,则为续费
|
||||
if current_sub.plan_code in ['pro', 'max']:
|
||||
return 'renew'
|
||||
|
||||
return 'new'
|
||||
|
||||
|
||||
def create_subscription_order(user_id, plan_code, billing_cycle, promo_code=None, db_session=None):
|
||||
"""
|
||||
创建订阅支付订单
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
plan_code: 套餐代码
|
||||
billing_cycle: 计费周期
|
||||
promo_code: 优惠码(可选)
|
||||
db_session: 数据库会话
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'success': True/False,
|
||||
'order': PaymentOrder 对象,
|
||||
'error': None
|
||||
}
|
||||
"""
|
||||
from models import PaymentOrder
|
||||
|
||||
try:
|
||||
# 1. 计算价格
|
||||
price_result = calculate_subscription_price(
|
||||
plan_code,
|
||||
billing_cycle,
|
||||
promo_code,
|
||||
user_id,
|
||||
db_session
|
||||
)
|
||||
|
||||
if not price_result.get('success'):
|
||||
return {
|
||||
'success': False,
|
||||
'error': price_result.get('error', '价格计算失败')
|
||||
}
|
||||
|
||||
# 2. 判断订阅类型
|
||||
subscription_type = determine_subscription_type(user_id, plan_code, billing_cycle)
|
||||
|
||||
# 3. 创建支付订单
|
||||
order = PaymentOrder(
|
||||
order_no=generate_order_no(user_id),
|
||||
user_id=user_id,
|
||||
plan_code=plan_code,
|
||||
billing_cycle=billing_cycle,
|
||||
subscription_type=subscription_type,
|
||||
original_price=Decimal(str(price_result['original_price'])),
|
||||
discount_amount=Decimal(str(price_result['discount_amount'])),
|
||||
final_amount=Decimal(str(price_result['final_amount'])),
|
||||
promo_code=promo_code,
|
||||
status='pending',
|
||||
expired_at=beijing_now() + timedelta(minutes=30)
|
||||
)
|
||||
|
||||
if db_session:
|
||||
db_session.add(order)
|
||||
db_session.commit()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'order': order,
|
||||
'subscription_type': subscription_type,
|
||||
'error': None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
if db_session:
|
||||
db_session.rollback()
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'创建订单失败: {str(e)}'
|
||||
}
|
||||
|
||||
|
||||
def activate_subscription_after_payment(order_id, db_session=None):
|
||||
"""
|
||||
支付成功后激活订阅
|
||||
|
||||
Args:
|
||||
order_id: 订单ID
|
||||
db_session: 数据库会话
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'success': True/False,
|
||||
'subscription': UserSubscription 对象,
|
||||
'error': None
|
||||
}
|
||||
"""
|
||||
from models import PaymentOrder, UserSubscription, PromoCodeUsage
|
||||
|
||||
try:
|
||||
# 1. 查询订单
|
||||
order = PaymentOrder.query.get(order_id)
|
||||
if not order:
|
||||
return {'success': False, 'error': '订单不存在'}
|
||||
|
||||
if order.status != 'paid':
|
||||
return {'success': False, 'error': '订单未支付'}
|
||||
|
||||
# 2. 检查是否已经激活
|
||||
existing_sub = UserSubscription.query.filter_by(
|
||||
payment_order_id=order.id
|
||||
).first()
|
||||
|
||||
if existing_sub:
|
||||
return {
|
||||
'success': True,
|
||||
'subscription': existing_sub,
|
||||
'message': '订阅已激活'
|
||||
}
|
||||
|
||||
# 3. 计算订阅周期天数
|
||||
cycle_days_map = {
|
||||
'monthly': 30,
|
||||
'quarterly': 90,
|
||||
'semiannual': 180,
|
||||
'yearly': 365
|
||||
}
|
||||
days = cycle_days_map.get(order.billing_cycle, 30)
|
||||
|
||||
# 4. 获取当前订阅
|
||||
current_sub = get_current_subscription(order.user_id, db_session)
|
||||
|
||||
# 5. 计算开始和结束时间
|
||||
now = beijing_now()
|
||||
|
||||
if current_sub and current_sub.end_date > now:
|
||||
# 续费:从当前订阅结束时间开始
|
||||
start_date = current_sub.end_date
|
||||
else:
|
||||
# 新购:从当前时间开始
|
||||
start_date = now
|
||||
|
||||
end_date = start_date + timedelta(days=days)
|
||||
|
||||
# 6. 创建新订阅记录
|
||||
new_subscription = UserSubscription(
|
||||
user_id=order.user_id,
|
||||
subscription_id=generate_subscription_id(),
|
||||
plan_code=order.plan_code,
|
||||
billing_cycle=order.billing_cycle,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
status='active',
|
||||
is_current=True,
|
||||
payment_order_id=order.id,
|
||||
paid_amount=order.final_amount,
|
||||
original_price=order.original_price,
|
||||
discount_amount=order.discount_amount,
|
||||
subscription_type=order.subscription_type,
|
||||
previous_subscription_id=current_sub.subscription_id if current_sub else None,
|
||||
auto_renew=False
|
||||
)
|
||||
|
||||
# 7. 将旧订阅标记为非当前
|
||||
if current_sub:
|
||||
current_sub.is_current = False
|
||||
|
||||
if db_session:
|
||||
db_session.add(new_subscription)
|
||||
|
||||
# 8. 记录优惠码使用
|
||||
if order.promo_code_id:
|
||||
usage = PromoCodeUsage(
|
||||
promo_code_id=order.promo_code_id,
|
||||
user_id=order.user_id,
|
||||
order_id=order.id,
|
||||
discount_amount=order.discount_amount
|
||||
)
|
||||
db_session.add(usage)
|
||||
|
||||
# 更新优惠码使用次数
|
||||
from models import PromoCode
|
||||
promo = PromoCode.query.get(order.promo_code_id)
|
||||
if promo:
|
||||
promo.current_uses += 1
|
||||
|
||||
db_session.commit()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'subscription': new_subscription,
|
||||
'error': None
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
if db_session:
|
||||
db_session.rollback()
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'激活订阅失败: {str(e)}'
|
||||
}
|
||||
|
||||
|
||||
def get_subscription_button_text(user_id, plan_code, billing_cycle):
|
||||
"""
|
||||
获取订阅按钮文字
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
plan_code: 套餐代码 (pro/max)
|
||||
billing_cycle: 计费周期
|
||||
|
||||
Returns:
|
||||
str: 按钮文字
|
||||
"""
|
||||
from models import SubscriptionPlan
|
||||
|
||||
# 获取套餐显示名称
|
||||
plan = SubscriptionPlan.query.filter_by(plan_code=plan_code).first()
|
||||
plan_name = plan.plan_name if plan else plan_code.upper()
|
||||
|
||||
# 获取周期显示名称
|
||||
cycle_names = {
|
||||
'monthly': '月付',
|
||||
'quarterly': '季付',
|
||||
'semiannual': '半年付',
|
||||
'yearly': '年付'
|
||||
}
|
||||
cycle_name = cycle_names.get(billing_cycle, billing_cycle)
|
||||
|
||||
# 获取当前订阅
|
||||
current_sub = get_current_subscription(user_id)
|
||||
|
||||
# 1. 如果没有订阅或订阅已过期
|
||||
if not current_sub or current_sub.plan_code == 'free' or current_sub.status != 'active':
|
||||
return f"选择 {plan_name}"
|
||||
|
||||
# 2. 如果是当前套餐且周期相同
|
||||
if current_sub.plan_code == plan_code and current_sub.billing_cycle == billing_cycle:
|
||||
return f"续费 {plan_name}"
|
||||
|
||||
# 3. 如果是当前套餐但周期不同
|
||||
if current_sub.plan_code == plan_code:
|
||||
return f"切换至{cycle_name}"
|
||||
|
||||
# 4. 如果是不同套餐
|
||||
return f"选择 {plan_name}"
|
||||
|
||||
|
||||
# ============================================
|
||||
# 优惠码相关函数
|
||||
# ============================================
|
||||
|
||||
def validate_promo_code(code, plan_code, billing_cycle, amount, user_id=None, db_session=None):
|
||||
"""
|
||||
验证优惠码
|
||||
|
||||
Args:
|
||||
code: 优惠码
|
||||
plan_code: 套餐代码
|
||||
billing_cycle: 计费周期
|
||||
amount: 订单金额
|
||||
user_id: 用户ID(可选)
|
||||
db_session: 数据库会话(可选)
|
||||
|
||||
Returns:
|
||||
tuple: (PromoCode对象 或 None, 错误信息 或 None)
|
||||
"""
|
||||
from models import PromoCode, PromoCodeUsage
|
||||
|
||||
try:
|
||||
# 查询优惠码
|
||||
promo = PromoCode.query.filter_by(code=code.upper(), is_active=True).first()
|
||||
|
||||
if not promo:
|
||||
return None, "优惠码不存在或已失效"
|
||||
|
||||
# 检查有效期
|
||||
now = beijing_now()
|
||||
if promo.valid_from and now < promo.valid_from:
|
||||
return None, "优惠码尚未生效"
|
||||
|
||||
if promo.valid_until and now > promo.valid_until:
|
||||
return None, "优惠码已过期"
|
||||
|
||||
# 检查总使用次数
|
||||
if promo.max_total_uses and promo.current_uses >= promo.max_total_uses:
|
||||
return None, "优惠码使用次数已达上限"
|
||||
|
||||
# 检查每用户使用次数
|
||||
if user_id and promo.max_uses_per_user:
|
||||
user_usage_count = PromoCodeUsage.query.filter_by(
|
||||
promo_code_id=promo.id,
|
||||
user_id=user_id
|
||||
).count()
|
||||
|
||||
if user_usage_count >= promo.max_uses_per_user:
|
||||
return None, f"您已使用过此优惠码(限用{promo.max_uses_per_user}次)"
|
||||
|
||||
# 检查适用套餐
|
||||
if promo.applicable_plans:
|
||||
try:
|
||||
applicable = json.loads(promo.applicable_plans)
|
||||
if isinstance(applicable, list) and plan_code not in applicable:
|
||||
return None, "该优惠码不适用于此套餐"
|
||||
except:
|
||||
pass
|
||||
|
||||
# 检查适用周期
|
||||
if promo.applicable_cycles:
|
||||
try:
|
||||
applicable = json.loads(promo.applicable_cycles)
|
||||
if isinstance(applicable, list) and billing_cycle not in applicable:
|
||||
return None, "该优惠码不适用于此计费周期"
|
||||
except:
|
||||
pass
|
||||
|
||||
# 检查最低消费
|
||||
if promo.min_amount and amount < float(promo.min_amount):
|
||||
return None, f"需满 ¥{float(promo.min_amount):.2f} 才可使用此优惠码"
|
||||
|
||||
return promo, None
|
||||
|
||||
except Exception as e:
|
||||
return None, f"验证优惠码时出错: {str(e)}"
|
||||
|
||||
|
||||
def calculate_discount(promo_code, amount):
|
||||
"""
|
||||
计算优惠金额
|
||||
|
||||
Args:
|
||||
promo_code: PromoCode 对象
|
||||
amount: 订单金额
|
||||
|
||||
Returns:
|
||||
Decimal: 优惠金额
|
||||
"""
|
||||
try:
|
||||
if promo_code.discount_type == 'percentage':
|
||||
# 百分比折扣
|
||||
discount = Decimal(str(amount)) * Decimal(str(promo_code.discount_value)) / Decimal('100')
|
||||
elif promo_code.discount_type == 'fixed_amount':
|
||||
# 固定金额折扣
|
||||
discount = Decimal(str(promo_code.discount_value))
|
||||
else:
|
||||
discount = Decimal('0')
|
||||
|
||||
# 确保折扣不超过总金额
|
||||
discount = min(discount, Decimal(str(amount)))
|
||||
|
||||
return discount
|
||||
|
||||
except Exception as e:
|
||||
print(f"计算折扣失败: {e}")
|
||||
return Decimal('0')
|
||||
|
||||
|
||||
# ============================================
|
||||
# 辅助查询函数
|
||||
# ============================================
|
||||
|
||||
def get_user_subscription_history(user_id, limit=10):
|
||||
"""
|
||||
获取用户订阅历史
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
limit: 返回记录数量限制
|
||||
|
||||
Returns:
|
||||
list: UserSubscription 对象列表
|
||||
"""
|
||||
from models import UserSubscription
|
||||
|
||||
try:
|
||||
subscriptions = UserSubscription.query.filter_by(
|
||||
user_id=user_id
|
||||
).order_by(
|
||||
UserSubscription.created_at.desc()
|
||||
).limit(limit).all()
|
||||
|
||||
return subscriptions
|
||||
|
||||
except Exception as e:
|
||||
print(f"获取订阅历史失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def check_subscription_status(user_id):
|
||||
"""
|
||||
检查用户订阅状态
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
'has_subscription': True/False,
|
||||
'plan_code': 'pro' 或 'max' 或 'free',
|
||||
'status': 'active' 或 'expired',
|
||||
'end_date': datetime 或 None,
|
||||
'days_left': int
|
||||
}
|
||||
"""
|
||||
current_sub = get_current_subscription(user_id)
|
||||
|
||||
if not current_sub or current_sub.plan_code == 'free':
|
||||
return {
|
||||
'has_subscription': False,
|
||||
'plan_code': 'free',
|
||||
'status': 'active',
|
||||
'end_date': None,
|
||||
'days_left': 999
|
||||
}
|
||||
|
||||
now = beijing_now()
|
||||
days_left = (current_sub.end_date - now).days if current_sub.end_date > now else 0
|
||||
|
||||
return {
|
||||
'has_subscription': True,
|
||||
'plan_code': current_sub.plan_code,
|
||||
'status': current_sub.status,
|
||||
'end_date': current_sub.end_date,
|
||||
'days_left': days_left
|
||||
}
|
||||
669
new_subscription_routes.py
Normal file
669
new_subscription_routes.py
Normal file
@@ -0,0 +1,669 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
新版订阅支付系统 API 路由
|
||||
版本: v2.0.0
|
||||
日期: 2025-11-19
|
||||
|
||||
使用方法:
|
||||
将这些路由添加到你的 Flask app.py 中
|
||||
"""
|
||||
|
||||
from flask import jsonify, request, session
|
||||
from new_subscription_logic import (
|
||||
calculate_subscription_price,
|
||||
create_subscription_order,
|
||||
activate_subscription_after_payment,
|
||||
get_subscription_button_text,
|
||||
get_current_subscription,
|
||||
check_subscription_status,
|
||||
get_user_subscription_history
|
||||
)
|
||||
|
||||
|
||||
# ============================================
|
||||
# API 路由定义
|
||||
# ============================================
|
||||
|
||||
@app.route('/api/v2/subscription/plans', methods=['GET'])
|
||||
def get_subscription_plans_v2():
|
||||
"""
|
||||
获取订阅套餐列表(新版)
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"plan_code": "pro",
|
||||
"plan_name": "Pro 专业版",
|
||||
"description": "为专业投资者打造",
|
||||
"prices": {
|
||||
"monthly": 299.00,
|
||||
"quarterly": 799.00,
|
||||
"semiannual": 1499.00,
|
||||
"yearly": 2699.00
|
||||
},
|
||||
"features": [...],
|
||||
"is_active": true
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
from models import SubscriptionPlan
|
||||
|
||||
plans = SubscriptionPlan.query.filter_by(is_active=True).order_by(
|
||||
SubscriptionPlan.display_order
|
||||
).all()
|
||||
|
||||
data = []
|
||||
for plan in plans:
|
||||
data.append({
|
||||
'plan_code': plan.plan_code,
|
||||
'plan_name': plan.plan_name,
|
||||
'description': plan.description,
|
||||
'prices': {
|
||||
'monthly': float(plan.price_monthly),
|
||||
'quarterly': float(plan.price_quarterly),
|
||||
'semiannual': float(plan.price_semiannual),
|
||||
'yearly': float(plan.price_yearly)
|
||||
},
|
||||
'features': json.loads(plan.features) if plan.features else [],
|
||||
'is_active': plan.is_active,
|
||||
'display_order': plan.display_order
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'获取套餐列表失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/v2/subscription/calculate-price', methods=['POST'])
|
||||
def calculate_price_v2():
|
||||
"""
|
||||
计算订阅价格(新版 - 新购和续费价格一致)
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"plan_code": "pro",
|
||||
"billing_cycle": "yearly",
|
||||
"promo_code": "WELCOME2025" // 可选
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"plan_code": "pro",
|
||||
"plan_name": "Pro 专业版",
|
||||
"billing_cycle": "yearly",
|
||||
"original_price": 2699.00,
|
||||
"discount_amount": 539.80,
|
||||
"final_amount": 2159.20,
|
||||
"promo_code": "WELCOME2025",
|
||||
"promo_error": null
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
|
||||
data = request.get_json()
|
||||
plan_code = data.get('plan_code')
|
||||
billing_cycle = data.get('billing_cycle')
|
||||
promo_code = data.get('promo_code')
|
||||
|
||||
if not plan_code or not billing_cycle:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '参数不完整'
|
||||
}), 400
|
||||
|
||||
# 计算价格
|
||||
result = calculate_subscription_price(
|
||||
plan_code=plan_code,
|
||||
billing_cycle=billing_cycle,
|
||||
promo_code=promo_code,
|
||||
user_id=session['user_id'],
|
||||
db_session=db.session
|
||||
)
|
||||
|
||||
if not result.get('success'):
|
||||
return jsonify(result), 400
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': result
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'计算价格失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/v2/payment/create-order', methods=['POST'])
|
||||
def create_order_v2():
|
||||
"""
|
||||
创建支付订单(新版)
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"plan_code": "pro",
|
||||
"billing_cycle": "yearly",
|
||||
"promo_code": "WELCOME2025" // 可选
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"order_no": "1732012345678901231234",
|
||||
"plan_code": "pro",
|
||||
"billing_cycle": "yearly",
|
||||
"subscription_type": "renew", // 或 "new"
|
||||
"original_price": 2699.00,
|
||||
"discount_amount": 539.80,
|
||||
"final_amount": 2159.20,
|
||||
"qr_code_url": "https://...",
|
||||
"status": "pending",
|
||||
"expired_at": "2025-11-19T15:30:00",
|
||||
...
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
|
||||
data = request.get_json()
|
||||
plan_code = data.get('plan_code')
|
||||
billing_cycle = data.get('billing_cycle')
|
||||
promo_code = data.get('promo_code')
|
||||
|
||||
if not plan_code or not billing_cycle:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '参数不完整'
|
||||
}), 400
|
||||
|
||||
# 创建订单
|
||||
order_result = create_subscription_order(
|
||||
user_id=session['user_id'],
|
||||
plan_code=plan_code,
|
||||
billing_cycle=billing_cycle,
|
||||
promo_code=promo_code,
|
||||
db_session=db.session
|
||||
)
|
||||
|
||||
if not order_result.get('success'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': order_result.get('error')
|
||||
}), 400
|
||||
|
||||
order = order_result['order']
|
||||
|
||||
# 生成微信支付二维码
|
||||
try:
|
||||
from wechat_pay import create_wechat_pay_instance, check_wechat_pay_ready
|
||||
|
||||
is_ready, ready_msg = check_wechat_pay_ready()
|
||||
if not is_ready:
|
||||
# 使用模拟二维码
|
||||
order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}"
|
||||
order.remark = f"演示模式 - {ready_msg}"
|
||||
else:
|
||||
wechat_pay = create_wechat_pay_instance()
|
||||
|
||||
# 创建微信支付订单
|
||||
plan_display = f"{plan_code.upper()}-{billing_cycle}"
|
||||
wechat_result = wechat_pay.create_native_order(
|
||||
order_no=order.order_no,
|
||||
total_fee=float(order.final_amount),
|
||||
body=f"VFr-{plan_display}",
|
||||
product_id=f"{plan_code}_{billing_cycle}"
|
||||
)
|
||||
|
||||
if wechat_result['success']:
|
||||
wechat_code_url = wechat_result['code_url']
|
||||
|
||||
import urllib.parse
|
||||
encoded_url = urllib.parse.quote(wechat_code_url, safe='')
|
||||
qr_image_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={encoded_url}"
|
||||
|
||||
order.qr_code_url = qr_image_url
|
||||
order.prepay_id = wechat_result.get('prepay_id')
|
||||
order.remark = f"微信支付 - {wechat_code_url}"
|
||||
else:
|
||||
order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}"
|
||||
order.remark = f"微信支付失败: {wechat_result.get('error')}"
|
||||
|
||||
except Exception as e:
|
||||
order.qr_code_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wxpay://order/{order.order_no}"
|
||||
order.remark = f"支付异常: {str(e)}"
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'id': order.id,
|
||||
'order_no': order.order_no,
|
||||
'plan_code': order.plan_code,
|
||||
'billing_cycle': order.billing_cycle,
|
||||
'subscription_type': order.subscription_type,
|
||||
'original_price': float(order.original_price),
|
||||
'discount_amount': float(order.discount_amount),
|
||||
'final_amount': float(order.final_amount),
|
||||
'promo_code': order.promo_code,
|
||||
'qr_code_url': order.qr_code_url,
|
||||
'status': order.status,
|
||||
'expired_at': order.expired_at.isoformat() if order.expired_at else None,
|
||||
'created_at': order.created_at.isoformat() if order.created_at else None
|
||||
},
|
||||
'message': '订单创建成功'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'创建订单失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/v2/payment/order/<int:order_id>/status', methods=['GET'])
|
||||
def check_order_status_v2(order_id):
|
||||
"""
|
||||
查询订单支付状态(新版)
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"payment_success": true, // 是否支付成功
|
||||
"data": {
|
||||
"order_no": "...",
|
||||
"status": "paid",
|
||||
...
|
||||
},
|
||||
"message": "支付成功!订阅已激活"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
|
||||
from models import PaymentOrder
|
||||
|
||||
order = PaymentOrder.query.filter_by(
|
||||
id=order_id,
|
||||
user_id=session['user_id']
|
||||
).first()
|
||||
|
||||
if not order:
|
||||
return jsonify({'success': False, 'error': '订单不存在'}), 404
|
||||
|
||||
# 如果订单已经是已支付状态
|
||||
if order.status == 'paid':
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'payment_success': True,
|
||||
'data': {
|
||||
'order_no': order.order_no,
|
||||
'status': order.status,
|
||||
'final_amount': float(order.final_amount)
|
||||
},
|
||||
'message': '订单已支付'
|
||||
})
|
||||
|
||||
# 如果订单过期
|
||||
if order.is_expired():
|
||||
order.status = 'expired'
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'payment_success': False,
|
||||
'data': {'status': 'expired'},
|
||||
'message': '订单已过期'
|
||||
})
|
||||
|
||||
# 调用微信支付API查询状态
|
||||
try:
|
||||
from wechat_pay import create_wechat_pay_instance
|
||||
wechat_pay = create_wechat_pay_instance()
|
||||
|
||||
query_result = wechat_pay.query_order(order_no=order.order_no)
|
||||
|
||||
if query_result['success']:
|
||||
trade_state = query_result.get('trade_state')
|
||||
transaction_id = query_result.get('transaction_id')
|
||||
|
||||
if trade_state == 'SUCCESS':
|
||||
# 支付成功
|
||||
order.mark_as_paid(transaction_id)
|
||||
db.session.commit()
|
||||
|
||||
# 激活订阅
|
||||
activate_result = activate_subscription_after_payment(
|
||||
order.id,
|
||||
db_session=db.session
|
||||
)
|
||||
|
||||
if activate_result.get('success'):
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'payment_success': True,
|
||||
'data': {
|
||||
'order_no': order.order_no,
|
||||
'status': 'paid'
|
||||
},
|
||||
'message': '支付成功!订阅已激活'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'payment_success': True,
|
||||
'data': {'status': 'paid'},
|
||||
'message': '支付成功,但激活失败,请联系客服'
|
||||
})
|
||||
|
||||
elif trade_state in ['NOTPAY', 'USERPAYING']:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'payment_success': False,
|
||||
'data': {'status': 'pending'},
|
||||
'message': '等待支付...'
|
||||
})
|
||||
else:
|
||||
order.status = 'cancelled'
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'payment_success': False,
|
||||
'data': {'status': 'cancelled'},
|
||||
'message': '支付已取消'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# 查询失败,返回当前状态
|
||||
pass
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'payment_success': False,
|
||||
'data': {'status': order.status},
|
||||
'message': '无法查询支付状态,请稍后重试'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'查询失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/v2/payment/order/<int:order_id>/force-update', methods=['POST'])
|
||||
def force_update_status_v2(order_id):
|
||||
"""
|
||||
强制更新订单支付状态(新版)
|
||||
|
||||
用于支付完成但页面未更新的情况
|
||||
"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
|
||||
from models import PaymentOrder
|
||||
|
||||
order = PaymentOrder.query.filter_by(
|
||||
id=order_id,
|
||||
user_id=session['user_id']
|
||||
).first()
|
||||
|
||||
if not order:
|
||||
return jsonify({'success': False, 'error': '订单不存在'}), 404
|
||||
|
||||
# 检查微信支付状态
|
||||
try:
|
||||
from wechat_pay import create_wechat_pay_instance
|
||||
wechat_pay = create_wechat_pay_instance()
|
||||
|
||||
query_result = wechat_pay.query_order(order_no=order.order_no)
|
||||
|
||||
if query_result['success'] and query_result.get('trade_state') == 'SUCCESS':
|
||||
transaction_id = query_result.get('transaction_id')
|
||||
|
||||
# 标记订单为已支付
|
||||
order.mark_as_paid(transaction_id)
|
||||
db.session.commit()
|
||||
|
||||
# 激活订阅
|
||||
activate_result = activate_subscription_after_payment(
|
||||
order.id,
|
||||
db_session=db.session
|
||||
)
|
||||
|
||||
if activate_result.get('success'):
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'payment_success': True,
|
||||
'message': '状态更新成功!订阅已激活'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'payment_success': True,
|
||||
'message': '支付成功,但激活失败,请联系客服',
|
||||
'error': activate_result.get('error')
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'payment_success': False,
|
||||
'message': '微信支付状态未更新,请继续等待'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'查询微信支付状态失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'强制更新失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/v2/subscription/current', methods=['GET'])
|
||||
def get_current_subscription_v2():
|
||||
"""
|
||||
获取当前用户订阅信息(新版)
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"subscription_id": "SUB_1732012345_12345",
|
||||
"plan_code": "pro",
|
||||
"plan_name": "Pro 专业版",
|
||||
"billing_cycle": "yearly",
|
||||
"status": "active",
|
||||
"start_date": "2025-11-19T00:00:00",
|
||||
"end_date": "2026-11-19T00:00:00",
|
||||
"days_left": 365,
|
||||
"auto_renew": false
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
|
||||
from models import SubscriptionPlan
|
||||
|
||||
subscription = get_current_subscription(session['user_id'])
|
||||
|
||||
if not subscription:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'plan_code': 'free',
|
||||
'plan_name': '免费版',
|
||||
'status': 'active'
|
||||
}
|
||||
})
|
||||
|
||||
# 获取套餐名称
|
||||
plan = SubscriptionPlan.query.filter_by(plan_code=subscription.plan_code).first()
|
||||
plan_name = plan.plan_name if plan else subscription.plan_code.upper()
|
||||
|
||||
# 计算剩余天数
|
||||
from datetime import datetime
|
||||
now = datetime.now()
|
||||
days_left = (subscription.end_date - now).days if subscription.end_date > now else 0
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'subscription_id': subscription.subscription_id,
|
||||
'plan_code': subscription.plan_code,
|
||||
'plan_name': plan_name,
|
||||
'billing_cycle': subscription.billing_cycle,
|
||||
'status': subscription.status,
|
||||
'start_date': subscription.start_date.isoformat() if subscription.start_date else None,
|
||||
'end_date': subscription.end_date.isoformat() if subscription.end_date else None,
|
||||
'days_left': days_left,
|
||||
'auto_renew': subscription.auto_renew
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'获取订阅信息失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/v2/subscription/history', methods=['GET'])
|
||||
def get_subscription_history_v2():
|
||||
"""
|
||||
获取用户订阅历史(新版)
|
||||
|
||||
Query Params:
|
||||
limit: 返回记录数量(默认10)
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"subscription_id": "SUB_...",
|
||||
"plan_code": "pro",
|
||||
"billing_cycle": "yearly",
|
||||
"start_date": "...",
|
||||
"end_date": "...",
|
||||
"paid_amount": 2699.00,
|
||||
"status": "expired"
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({'success': False, 'error': '未登录'}), 401
|
||||
|
||||
limit = request.args.get('limit', 10, type=int)
|
||||
|
||||
subscriptions = get_user_subscription_history(session['user_id'], limit)
|
||||
|
||||
data = []
|
||||
for sub in subscriptions:
|
||||
data.append({
|
||||
'subscription_id': sub.subscription_id,
|
||||
'plan_code': sub.plan_code,
|
||||
'billing_cycle': sub.billing_cycle,
|
||||
'start_date': sub.start_date.isoformat() if sub.start_date else None,
|
||||
'end_date': sub.end_date.isoformat() if sub.end_date else None,
|
||||
'paid_amount': float(sub.paid_amount),
|
||||
'original_price': float(sub.original_price),
|
||||
'discount_amount': float(sub.discount_amount),
|
||||
'status': sub.status,
|
||||
'created_at': sub.created_at.isoformat() if sub.created_at else None
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'获取订阅历史失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/v2/subscription/button-text', methods=['POST'])
|
||||
def get_button_text_v2():
|
||||
"""
|
||||
获取订阅按钮文字(新版)
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"plan_code": "pro",
|
||||
"billing_cycle": "yearly"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"button_text": "续费 Pro 专业版"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if 'user_id' not in session:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'button_text': '选择套餐'
|
||||
})
|
||||
|
||||
data = request.get_json()
|
||||
plan_code = data.get('plan_code')
|
||||
billing_cycle = data.get('billing_cycle')
|
||||
|
||||
if not plan_code or not billing_cycle:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '参数不完整'
|
||||
}), 400
|
||||
|
||||
button_text = get_subscription_button_text(
|
||||
session['user_id'],
|
||||
plan_code,
|
||||
billing_cycle
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'button_text': button_text
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'获取按钮文字失败: {str(e)}'
|
||||
}), 500
|
||||
10
package.json
10
package.json
@@ -6,9 +6,9 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@asseinfo/react-kanban": "^2.2.0",
|
||||
"@chakra-ui/icons": "^2.1.1",
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@chakra-ui/theme-tools": "^1.3.6",
|
||||
"@chakra-ui/icons": "^2.2.6",
|
||||
"@chakra-ui/react": "^2.10.9",
|
||||
"@chakra-ui/theme-tools": "^2.2.6",
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.4.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
@@ -29,6 +29,7 @@
|
||||
"classnames": "^2.5.1",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^2.23.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"draft-js": "^0.11.7",
|
||||
"echarts": "^5.6.0",
|
||||
"echarts-for-react": "^3.0.2",
|
||||
@@ -39,9 +40,8 @@
|
||||
"history": "^5.3.0",
|
||||
"lucide-react": "^0.540.0",
|
||||
"match-sorter": "6.3.0",
|
||||
"moment": "^2.29.1",
|
||||
"nouislider": "15.0.0",
|
||||
"posthog-js": "^1.281.0",
|
||||
"posthog-js": "^1.295.0",
|
||||
"react": "18.3.1",
|
||||
"react-apexcharts": "^1.3.9",
|
||||
"react-big-calendar": "^0.33.2",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr" layout="admin">
|
||||
<html lang="zh-CN" dir="ltr" layout="admin">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
@@ -7,6 +7,177 @@
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<!-- 基本 SEO -->
|
||||
<title>价值前沿 - 金融AI舆情分析系统 | LLM赋能的智能分析平台</title>
|
||||
<meta name="description" content="基于金融大语言模型,实时监控股市行情、a股、美股,提供英伟达、小米等企业舆情分析,助力投资决策" />
|
||||
<meta name="keywords" content="金融AI,舆情分析,股市行情,LLM,价值前沿,a股,美股,投资分析" />
|
||||
<link rel="canonical" href="https://valuefrontier.cn/" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://valuefrontier.cn/" />
|
||||
<meta property="og:title" content="价值前沿 - 金融AI舆情分析系统" />
|
||||
<meta property="og:description" content="基于金融大语言模型,实时监控股市行情、a股、美股,提供英伟达、小米等企业舆情分析" />
|
||||
<meta property="og:image" content="https://valuefrontier.cn/og-image.jpg" />
|
||||
<meta property="og:site_name" content="价值前沿" />
|
||||
<meta property="og:locale" content="zh_CN" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://valuefrontier.cn/" />
|
||||
<meta name="twitter:title" content="价值前沿 - 金融AI舆情分析系统" />
|
||||
<meta name="twitter:description" content="基于金融大语言模型,实时监控股市行情、a股、美股" />
|
||||
<meta name="twitter:image" content="https://valuefrontier.cn/og-image.jpg" />
|
||||
|
||||
<!-- SEO 增强 -->
|
||||
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
|
||||
<meta name="author" content="价值前沿团队" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="价值前沿 - 金融AI舆情分析系统" />
|
||||
|
||||
<!-- 性能优化: DNS 预连接 -->
|
||||
<link rel="preconnect" href="https://valuefrontier.cn" />
|
||||
<link rel="dns-prefetch" href="https://valuefrontier.cn" />
|
||||
|
||||
<!-- JSON-LD 结构化数据: 组织信息 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "价值前沿",
|
||||
"url": "https://valuefrontier.cn",
|
||||
"logo": "https://valuefrontier.cn/logo.png",
|
||||
"description": "基于金融大语言模型的智能舆情分析平台",
|
||||
"foundingDate": "2023",
|
||||
"contactPoint": {
|
||||
"@type": "ContactPoint",
|
||||
"contactType": "Customer Service",
|
||||
"availableLanguage": ["zh-CN"]
|
||||
},
|
||||
"sameAs": [
|
||||
"https://valuefrontier.cn"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- JSON-LD 结构化数据: 网站信息 + 搜索功能 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "价值前沿",
|
||||
"url": "https://valuefrontier.cn",
|
||||
"description": "金融AI舆情分析系统,实时监控股市行情",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": {
|
||||
"@type": "EntryPoint",
|
||||
"urlTemplate": "https://valuefrontier.cn/search?q={search_term_string}"
|
||||
},
|
||||
"query-input": "required name=search_term_string"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- JSON-LD 结构化数据: 软件应用产品信息 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "价值前沿",
|
||||
"applicationCategory": "FinanceApplication",
|
||||
"operatingSystem": "Web",
|
||||
"url": "https://valuefrontier.cn",
|
||||
"description": "基于金融大语言模型,实时监控股市行情、a股、美股,提供企业舆情分析",
|
||||
"offers": [
|
||||
{
|
||||
"@type": "Offer",
|
||||
"name": "专业版",
|
||||
"priceSpecification": {
|
||||
"@type": "UnitPriceSpecification",
|
||||
"price": "198",
|
||||
"priceCurrency": "CNY",
|
||||
"billingDuration": "P1M",
|
||||
"referenceQuantity": {
|
||||
"@type": "QuantitativeValue",
|
||||
"value": "1",
|
||||
"unitText": "月"
|
||||
}
|
||||
},
|
||||
"availability": "https://schema.org/InStock",
|
||||
"url": "https://valuefrontier.cn/home/pages/account/subscription"
|
||||
},
|
||||
{
|
||||
"@type": "Offer",
|
||||
"name": "旗舰版",
|
||||
"priceSpecification": {
|
||||
"@type": "UnitPriceSpecification",
|
||||
"price": "998",
|
||||
"priceCurrency": "CNY",
|
||||
"billingDuration": "P1M",
|
||||
"referenceQuantity": {
|
||||
"@type": "QuantitativeValue",
|
||||
"value": "1",
|
||||
"unitText": "月"
|
||||
}
|
||||
},
|
||||
"availability": "https://schema.org/InStock",
|
||||
"url": "https://valuefrontier.cn/home/pages/account/subscription"
|
||||
}
|
||||
],
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.8",
|
||||
"ratingCount": "1250",
|
||||
"bestRating": "5",
|
||||
"worstRating": "1"
|
||||
},
|
||||
"featureList": [
|
||||
"实时舆情监控",
|
||||
"智能事件分析",
|
||||
"多维度数据可视化",
|
||||
"AI驱动的投资建议",
|
||||
"行业板块分析"
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- JSON-LD 结构化数据: 面包屑导航 -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 1,
|
||||
"name": "首页",
|
||||
"item": "https://valuefrontier.cn/"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 2,
|
||||
"name": "事件中心",
|
||||
"item": "https://valuefrontier.cn/community"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 3,
|
||||
"name": "概念分析",
|
||||
"item": "https://valuefrontier.cn/concepts"
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": 4,
|
||||
"name": "个股分析",
|
||||
"item": "https://valuefrontier.cn/stocks"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" />
|
||||
<link
|
||||
@@ -15,10 +186,19 @@
|
||||
href="%PUBLIC_URL%/apple-icon.png"
|
||||
/>
|
||||
<link rel="shortcut icon" type="image/x-icon" href="./favicon.png" />
|
||||
<title>价值前沿——LLM赋能的分析平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||
<noscript>
|
||||
<div style="display: flex; align-items: center; justify-content: center; height: 100vh; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; text-align: center; padding: 20px;">
|
||||
<div>
|
||||
<h1 style="font-size: 2em; margin-bottom: 20px;">⚠️ 需要启用 JavaScript</h1>
|
||||
<p style="font-size: 1.2em; line-height: 1.6; max-width: 600px; margin: 0 auto;">
|
||||
价值前沿是一个现代化的 Web 应用,需要 JavaScript 才能正常运行。<br><br>
|
||||
请在浏览器设置中启用 JavaScript,然后刷新页面。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
76
src/App.js
76
src/App.js
@@ -9,8 +9,9 @@
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Visionware.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
// Routes
|
||||
import AppRoutes from './routes';
|
||||
@@ -30,12 +31,24 @@ import { initializePostHog } from './store/slices/posthogSlice';
|
||||
// Utils
|
||||
import { logger } from './utils/logger';
|
||||
|
||||
// PostHog 追踪
|
||||
import { trackEvent, trackEventAsync } from '@lib/posthog';
|
||||
|
||||
// Contexts
|
||||
import { useAuth } from '@contexts/AuthContext';
|
||||
|
||||
/**
|
||||
* AppContent - 应用核心内容
|
||||
* 负责 PostHog 初始化和渲染路由
|
||||
*/
|
||||
function AppContent() {
|
||||
const dispatch = useDispatch();
|
||||
const location = useLocation();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
// ✅ 使用 Ref 存储页面进入时间和路径(避免闭包问题)
|
||||
const pageEnterTimeRef = useRef(Date.now());
|
||||
const currentPathRef = useRef(location.pathname);
|
||||
|
||||
// 🎯 PostHog Redux 初始化
|
||||
useEffect(() => {
|
||||
@@ -43,6 +56,67 @@ function AppContent() {
|
||||
logger.info('App', 'PostHog Redux 初始化已触发');
|
||||
}, [dispatch]);
|
||||
|
||||
// ✅ 首次访问追踪
|
||||
useEffect(() => {
|
||||
const hasVisited = localStorage.getItem('has_visited');
|
||||
|
||||
if (!hasVisited) {
|
||||
const urlParams = new URLSearchParams(location.search);
|
||||
|
||||
// ⚡ 使用异步追踪,不阻塞页面渲染
|
||||
trackEventAsync('first_visit', {
|
||||
referrer: document.referrer || 'direct',
|
||||
utm_source: urlParams.get('utm_source'),
|
||||
utm_medium: urlParams.get('utm_medium'),
|
||||
utm_campaign: urlParams.get('utm_campaign'),
|
||||
landing_page: location.pathname,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
localStorage.setItem('has_visited', 'true');
|
||||
}
|
||||
}, [location.search, location.pathname]);
|
||||
|
||||
// ✅ 页面浏览时长追踪
|
||||
useEffect(() => {
|
||||
// 计算上一个页面的停留时长
|
||||
const calculateAndTrackDuration = () => {
|
||||
const exitTime = Date.now();
|
||||
const duration = Math.round((exitTime - pageEnterTimeRef.current) / 1000); // 秒
|
||||
|
||||
// 只追踪停留时间 > 1 秒的页面(过滤快速跳转)
|
||||
if (duration > 1) {
|
||||
// ⚡ 使用异步追踪,不阻塞页面切换
|
||||
trackEventAsync('page_view_duration', {
|
||||
path: currentPathRef.current,
|
||||
duration_seconds: duration,
|
||||
is_authenticated: isAuthenticated,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 路由切换时追踪上一个页面的时长
|
||||
if (currentPathRef.current !== location.pathname) {
|
||||
calculateAndTrackDuration();
|
||||
|
||||
// 更新为新页面
|
||||
currentPathRef.current = location.pathname;
|
||||
pageEnterTimeRef.current = Date.now();
|
||||
}
|
||||
|
||||
// 页面关闭/刷新时追踪时长
|
||||
const handleBeforeUnload = () => {
|
||||
calculateAndTrackDuration();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}, [location.pathname, isAuthenticated]);
|
||||
|
||||
return <AppRoutes />;
|
||||
}
|
||||
|
||||
|
||||
@@ -356,24 +356,22 @@ export default function AuthFormContent() {
|
||||
// 更新session
|
||||
await checkSession();
|
||||
|
||||
// ✅ 兼容后端两种命名格式:camelCase (isNewUser) 和 snake_case (is_new_user)
|
||||
const isNewUser = data.isNewUser ?? data.is_new_user ?? false;
|
||||
|
||||
// 追踪登录成功并识别用户
|
||||
authEvents.trackLoginSuccess(data.user, 'phone', data.isNewUser);
|
||||
authEvents.trackLoginSuccess(data.user, 'phone', isNewUser);
|
||||
|
||||
// ✅ 保留登录成功 toast(关键操作提示)
|
||||
toast({
|
||||
title: data.isNewUser ? '注册成功' : '登录成功',
|
||||
title: isNewUser ? '注册成功' : '登录成功',
|
||||
description: config.successDescription,
|
||||
status: "success",
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
logger.info('AuthFormContent', '登录成功', {
|
||||
isNewUser: data.isNewUser,
|
||||
userId: data.user?.id
|
||||
});
|
||||
|
||||
// 检查是否为新注册用户
|
||||
if (data.isNewUser) {
|
||||
if (isNewUser) {
|
||||
// 新注册用户,延迟后显示昵称设置引导
|
||||
setTimeout(() => {
|
||||
setCurrentPhone(phone);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/components/Auth/AuthModalManager.js
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { useAuthModal } from '../../hooks/useAuthModal';
|
||||
import AuthFormContent from './AuthFormContent';
|
||||
import { trackEventAsync } from '@lib/posthog';
|
||||
import { ACTIVATION_EVENTS } from '@lib/constants';
|
||||
|
||||
/**
|
||||
* 全局认证弹窗管理器
|
||||
@@ -21,6 +23,27 @@ export default function AuthModalManager() {
|
||||
closeModal
|
||||
} = useAuthModal();
|
||||
|
||||
// ✅ 追踪弹窗打开次数(用于漏斗分析)
|
||||
const hasTrackedOpen = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthModalOpen && !hasTrackedOpen.current) {
|
||||
// ✅ 使用异步追踪,不阻塞渲染
|
||||
trackEventAsync(ACTIVATION_EVENTS.LOGIN_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
modal_type: 'auth_modal',
|
||||
trigger_source: 'user_action', // 可以通过 props 传递更精确的来源
|
||||
});
|
||||
|
||||
hasTrackedOpen.current = true;
|
||||
}
|
||||
|
||||
// ✅ 弹窗关闭时重置标记(允许再次追踪)
|
||||
if (!isAuthModalOpen) {
|
||||
hasTrackedOpen.current = false;
|
||||
}
|
||||
}, [isAuthModalOpen]);
|
||||
|
||||
// 响应式尺寸配置
|
||||
const modalSize = useBreakpointValue({
|
||||
base: "md", // 移动端:md(不占满全屏)
|
||||
|
||||
53
src/components/Button2/index.tsx
Normal file
53
src/components/Button2/index.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from "react";
|
||||
import Link, { LinkProps } from "next/link";
|
||||
|
||||
type CommonProps = {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
isPrimary?: boolean;
|
||||
isSecondary?: boolean;
|
||||
};
|
||||
|
||||
type ButtonAsButton = {
|
||||
as?: "button";
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
type ButtonAsAnchor = {
|
||||
as: "a";
|
||||
} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
|
||||
|
||||
type ButtonAsLink = {
|
||||
as: "link";
|
||||
} & LinkProps;
|
||||
|
||||
type ButtonProps = CommonProps &
|
||||
(ButtonAsButton | ButtonAsAnchor | ButtonAsLink);
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
className,
|
||||
children,
|
||||
isPrimary,
|
||||
isSecondary,
|
||||
as = "button",
|
||||
...props
|
||||
}) => {
|
||||
const isLink = as === "link";
|
||||
const Component: React.ElementType = isLink ? Link : as;
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={`relative inline-flex justify-center items-center h-10 px-3.5 rounded-lg text-title-5 cursor-pointer transition-all ${
|
||||
isPrimary ? "bg-white text-black hover:bg-white/90" : ""
|
||||
} ${
|
||||
isSecondary
|
||||
? "shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset] text-white after:absolute after:inset-0 after:border after:border-line after:rounded-lg after:pointer-events-none after:transition-colors hover:after:border-white"
|
||||
: ""
|
||||
} ${className || ""}`}
|
||||
{...(isLink ? (props as LinkProps) : props)}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const CommentItem = ({ comment }) => {
|
||||
const itemBg = useColorModeValue('gray.50', 'gray.700');
|
||||
@@ -26,8 +26,8 @@ const CommentItem = ({ comment }) => {
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp) => {
|
||||
const now = moment();
|
||||
const time = moment(timestamp);
|
||||
const now = dayjs();
|
||||
const time = dayjs(timestamp);
|
||||
const diffMinutes = now.diff(time, 'minutes');
|
||||
const diffHours = now.diff(time, 'hours');
|
||||
const diffDays = now.diff(time, 'days');
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, Button, Spin, Typography } from 'antd';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import CitedContent from '../Citation/CitedContent';
|
||||
import { logger } from '../../utils/logger';
|
||||
@@ -35,7 +35,7 @@ const StockChartAntdModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid()) {
|
||||
// 如果是15:00之后的事件,推到下一个交易日的9:30
|
||||
if (eventMoment.hour() >= 15) {
|
||||
@@ -92,7 +92,7 @@ const StockChartAntdModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid()) {
|
||||
// 如果是15:00之后的事件,推到下一个交易日的9:30
|
||||
if (eventMoment.hour() >= 15) {
|
||||
@@ -180,7 +180,7 @@ const StockChartAntdModal = ({
|
||||
// 计算事件标记线位置
|
||||
let markLineData = [];
|
||||
if (eventTime && times.length > 0) {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
||||
|
||||
if (activeChartType === 'timeline') {
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Modal, ModalOverlay, ModalContent, ModalHeader, ModalCloseButton, ModalBody, Button, ButtonGroup, VStack, HStack, Text, Badge, Box, Flex, CircularProgress } from '@chakra-ui/react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import RiskDisclaimer from '../RiskDisclaimer';
|
||||
@@ -50,7 +50,7 @@ const StockChartModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid() && eventMoment.hour() >= 15) {
|
||||
const nextDay = eventMoment.clone().add(1, 'day');
|
||||
nextDay.hour(9).minute(30).second(0).millisecond(0);
|
||||
@@ -111,7 +111,7 @@ const StockChartModal = ({
|
||||
let adjustedEventTime = eventTime;
|
||||
if (eventTime) {
|
||||
try {
|
||||
const eventMoment = moment(eventTime);
|
||||
const eventMoment = dayjs(eventTime);
|
||||
if (eventMoment.isValid() && eventMoment.hour() >= 15) {
|
||||
const nextDay = eventMoment.clone().add(1, 'day');
|
||||
nextDay.hour(9).minute(30).second(0).millisecond(0);
|
||||
@@ -182,7 +182,7 @@ const StockChartModal = ({
|
||||
// 计算事件标记线位置
|
||||
let eventMarkLineData = [];
|
||||
if (originalEventTime && times.length > 0) {
|
||||
const eventMoment = moment(originalEventTime);
|
||||
const eventMoment = dayjs(originalEventTime);
|
||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
||||
const eventTime = eventMoment.format('HH:mm');
|
||||
|
||||
@@ -357,7 +357,7 @@ const StockChartModal = ({
|
||||
// 计算事件标记线位置(重要修复)
|
||||
let eventMarkLineData = [];
|
||||
if (originalEventTime && dates.length > 0) {
|
||||
const eventMoment = moment(originalEventTime);
|
||||
const eventMoment = dayjs(originalEventTime);
|
||||
const eventDate = eventMoment.format('YYYY-MM-DD');
|
||||
|
||||
// 找到事件发生日期或最接近的交易日
|
||||
|
||||
2
src/components/Subscription/SubscriptionContentNew.d.ts
vendored
Normal file
2
src/components/Subscription/SubscriptionContentNew.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
// Type declarations for SubscriptionContentNew component
|
||||
export {};
|
||||
1308
src/components/Subscription/SubscriptionContentNew.tsx
Normal file
1308
src/components/Subscription/SubscriptionContentNew.tsx
Normal file
File diff suppressed because it is too large
Load Diff
204
src/constants/tracking.js
Normal file
204
src/constants/tracking.js
Normal file
@@ -0,0 +1,204 @@
|
||||
// src/constants/tracking.js
|
||||
// PostHog 事件追踪优先级配置
|
||||
|
||||
/**
|
||||
* 事件优先级枚举
|
||||
*
|
||||
* 用于决定事件的追踪时机,优化性能和用户体验。
|
||||
*
|
||||
* @enum {string}
|
||||
*/
|
||||
export const EVENT_PRIORITY = {
|
||||
/**
|
||||
* 关键事件 - 立即发送,不可延迟
|
||||
* 示例:登录、注册、支付、订阅购买
|
||||
*/
|
||||
CRITICAL: 'critical',
|
||||
|
||||
/**
|
||||
* 高优先级事件 - 立即发送
|
||||
* 示例:详情打开、搜索提交、关注操作、分享操作
|
||||
*/
|
||||
HIGH: 'high',
|
||||
|
||||
/**
|
||||
* 普通优先级事件 - 空闲时发送
|
||||
* 示例:列表查看、筛选应用、排序变更
|
||||
*/
|
||||
NORMAL: 'normal',
|
||||
|
||||
/**
|
||||
* 低优先级事件 - 空闲时发送,可批量合并
|
||||
* 示例:鼠标移动、滚动事件、hover 事件
|
||||
*/
|
||||
LOW: 'low',
|
||||
};
|
||||
|
||||
/**
|
||||
* Community 页面(新闻催化分析)事件优先级映射
|
||||
*
|
||||
* 映射规则:
|
||||
* - CRITICAL: 无(Community 页面无关键业务操作)
|
||||
* - HIGH: 用户明确的交互操作(点击、打开详情、搜索、跳转)
|
||||
* - NORMAL: 被动浏览事件(页面加载、列表查看、筛选、排序)
|
||||
* - LOW: 暂未使用
|
||||
*
|
||||
* @type {Object<string, string>}
|
||||
*/
|
||||
export const COMMUNITY_EVENT_PRIORITIES = {
|
||||
// ==================== 普通优先级(空闲时追踪)====================
|
||||
|
||||
/**
|
||||
* 页面浏览事件 - NORMAL
|
||||
* 触发时机:用户进入 Community 页面
|
||||
* 延迟原因:页面加载时避免阻塞渲染
|
||||
*/
|
||||
'Community Page Viewed': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻列表查看 - NORMAL
|
||||
* 触发时机:新闻列表加载完成
|
||||
* 延迟原因:避免阻塞列表渲染
|
||||
*/
|
||||
'News List Viewed': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻筛选应用 - NORMAL
|
||||
* 触发时机:用户应用筛选条件(重要性、日期、行业)
|
||||
* 延迟原因:筛选操作频繁,避免阻塞 UI 更新
|
||||
*/
|
||||
'News Filter Applied': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻排序变更 - NORMAL
|
||||
* 触发时机:用户切换排序方式(最新、最热、收益率)
|
||||
* 延迟原因:排序操作频繁,避免阻塞 UI 更新
|
||||
*/
|
||||
'News Sorted': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
/**
|
||||
* 新闻标签页点击 - NORMAL
|
||||
* 触发时机:用户点击新闻详情中的标签页(相关股票、相关概念、时间线)
|
||||
* 延迟原因:标签切换高频,延迟追踪不影响用户体验
|
||||
*/
|
||||
'News Tab Clicked': EVENT_PRIORITY.NORMAL,
|
||||
|
||||
// ==================== 高优先级(立即追踪)====================
|
||||
|
||||
/**
|
||||
* 新闻文章点击 - HIGH
|
||||
* 触发时机:用户点击新闻卡片
|
||||
* 立即追踪原因:关键交互操作,需要准确记录点击位置和时间
|
||||
*/
|
||||
'News Article Clicked': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 新闻详情打开 - HIGH
|
||||
* 触发时机:打开新闻详情弹窗或页面
|
||||
* 立即追踪原因:关键交互操作,需要准确记录查看时间
|
||||
*/
|
||||
'News Detail Opened': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 搜索查询提交 - HIGH
|
||||
* 触发时机:用户提交搜索关键词
|
||||
* 立即追踪原因:用户明确操作,需要准确记录搜索意图
|
||||
*/
|
||||
'Search Query Submitted': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 搜索无结果 - HIGH
|
||||
* 触发时机:搜索返回 0 个结果
|
||||
* 立即追踪原因:重要的用户体验指标,需要及时发现问题
|
||||
*/
|
||||
'Search No Results': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 相关股票点击 - HIGH
|
||||
* 触发时机:用户从新闻详情点击相关股票
|
||||
* 立即追踪原因:重要的跳转行为,需要准确记录导流效果
|
||||
*/
|
||||
'Stock Clicked': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 相关概念点击 - HIGH
|
||||
* 触发时机:用户从新闻详情点击相关概念
|
||||
* 立即追踪原因:重要的跳转行为,需要准确记录导流效果
|
||||
*/
|
||||
'Concept Clicked': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 事件关注操作 - HIGH
|
||||
* 触发时机:用户点击关注按钮
|
||||
* 立即追踪原因:关键业务操作,需要准确记录关注行为
|
||||
*/
|
||||
'Event Followed': EVENT_PRIORITY.HIGH,
|
||||
|
||||
/**
|
||||
* 事件取消关注 - HIGH
|
||||
* 触发时机:用户取消关注事件
|
||||
* 立即追踪原因:关键业务操作,需要准确记录取关原因
|
||||
*/
|
||||
'Event Unfollowed': EVENT_PRIORITY.HIGH,
|
||||
};
|
||||
|
||||
/**
|
||||
* requestIdleCallback 配置
|
||||
*
|
||||
* @type {Object}
|
||||
*/
|
||||
export const IDLE_CALLBACK_CONFIG = {
|
||||
/**
|
||||
* 超时时间(毫秒)
|
||||
* 即使浏览器不空闲,也会在此时间后强制执行追踪
|
||||
*
|
||||
* 设置为 2000ms 的原因:
|
||||
* - 足够长:避免在用户快速操作时阻塞主线程
|
||||
* - 足够短:确保用户快速关闭页面前也能发送事件
|
||||
* - 平衡点:2 秒是用户注意力的典型持续时间
|
||||
*/
|
||||
timeout: 2000,
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取事件优先级
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @returns {string} 事件优先级(CRITICAL | HIGH | NORMAL | LOW)
|
||||
*/
|
||||
export const getEventPriority = (eventName) => {
|
||||
return COMMUNITY_EVENT_PRIORITIES[eventName] || EVENT_PRIORITY.NORMAL;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断事件是否需要立即追踪
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @returns {boolean} 是否立即追踪
|
||||
*/
|
||||
export const shouldTrackImmediately = (eventName) => {
|
||||
const priority = getEventPriority(eventName);
|
||||
return priority === EVENT_PRIORITY.CRITICAL || priority === EVENT_PRIORITY.HIGH;
|
||||
};
|
||||
|
||||
/**
|
||||
* 判断事件是否可以延迟追踪
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @returns {boolean} 是否可以延迟追踪
|
||||
*/
|
||||
export const canTrackIdle = (eventName) => {
|
||||
const priority = getEventPriority(eventName);
|
||||
return priority === EVENT_PRIORITY.NORMAL || priority === EVENT_PRIORITY.LOW;
|
||||
};
|
||||
|
||||
// ==================== 默认导出 ====================
|
||||
|
||||
export default {
|
||||
EVENT_PRIORITY,
|
||||
COMMUNITY_EVENT_PRIORITIES,
|
||||
IDLE_CALLBACK_CONFIG,
|
||||
getEventPriority,
|
||||
shouldTrackImmediately,
|
||||
canTrackIdle,
|
||||
};
|
||||
@@ -4,6 +4,8 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { logger } from '../utils/logger';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
|
||||
import { SPECIAL_EVENTS } from '@lib/constants';
|
||||
|
||||
// 创建认证上下文
|
||||
const AuthContext = createContext();
|
||||
@@ -90,6 +92,16 @@ export const AuthProvider = ({ children }) => {
|
||||
if (prevUser && prevUser.id === data.user.id) {
|
||||
return prevUser;
|
||||
}
|
||||
|
||||
// ✅ 识别用户身份到 PostHog
|
||||
identifyUser(data.user.id, {
|
||||
email: data.user.email,
|
||||
username: data.user.username,
|
||||
subscription_tier: data.user.subscription_tier,
|
||||
role: data.user.role,
|
||||
registration_date: data.user.created_at
|
||||
});
|
||||
|
||||
return data.user;
|
||||
});
|
||||
setIsAuthenticated((prev) => prev === true ? prev : true);
|
||||
@@ -209,6 +221,11 @@ export const AuthProvider = ({ children }) => {
|
||||
setUser(data.user);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
// ❌ 过时的追踪代码已移除(新代码在组件中使用 useAuthEvents 追踪)
|
||||
// 正确的事件追踪在 AuthFormContent.js 中调用 authEvents.trackLoginSuccess()
|
||||
// 事件名:'User Logged In' 或 'User Signed Up'
|
||||
// 属性名:login_method (不是 loginType)
|
||||
|
||||
// ⚡ 移除toast,让调用者处理UI反馈,避免并发更新冲突
|
||||
// toast({
|
||||
// title: "登录成功",
|
||||
@@ -263,6 +280,11 @@ export const AuthProvider = ({ children }) => {
|
||||
setUser(data.user);
|
||||
setIsAuthenticated(true);
|
||||
|
||||
// ❌ 过时的追踪代码已移除(新代码在组件中使用 useAuthEvents 追踪)
|
||||
// 正确的事件追踪在 AuthFormContent.js 中调用 authEvents.trackLoginSuccess()
|
||||
// 事件名:'User Signed Up'(不是 'user_registered')
|
||||
// 属性名:login_method(不是 method)
|
||||
|
||||
toast({
|
||||
title: "注册成功",
|
||||
description: "欢迎加入价值前沿!",
|
||||
@@ -286,58 +308,6 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 邮箱注册
|
||||
const registerWithEmail = async (email, code, username, password) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const response = await fetch(`/api/auth/register/email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
code,
|
||||
username,
|
||||
password
|
||||
})
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
// ⚡ 注册成功后显示欢迎引导(延迟2秒)
|
||||
setTimeout(() => {
|
||||
showWelcomeGuide();
|
||||
}, 2000);
|
||||
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
logger.error('AuthContext', 'registerWithEmail', error);
|
||||
return { success: false, error: error.message };
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 发送手机验证码
|
||||
const sendSmsCode = async (phone) => {
|
||||
try {
|
||||
@@ -367,35 +337,6 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 发送邮箱验证码
|
||||
const sendEmailCode = async (email) => {
|
||||
try {
|
||||
const response = await fetch(`/api/auth/send-email-code`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '发送失败');
|
||||
}
|
||||
|
||||
// ❌ 移除成功 toast
|
||||
logger.info('AuthContext', '邮箱验证码已发送', { email: email.substring(0, 3) + '***@***' });
|
||||
return { success: true };
|
||||
|
||||
} catch (error) {
|
||||
// ❌ 移除错误 toast
|
||||
logger.error('AuthContext', 'sendEmailCode', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
};
|
||||
|
||||
// 登出方法
|
||||
const logout = async () => {
|
||||
try {
|
||||
@@ -405,6 +346,18 @@ export const AuthProvider = ({ children }) => {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
// ✅ 追踪登出事件(必须在 resetUser() 之前,否则会丢失用户身份)
|
||||
trackEvent(SPECIAL_EVENTS.USER_LOGGED_OUT, {
|
||||
timestamp: new Date().toISOString(),
|
||||
user_id: user?.id || null,
|
||||
session_duration_minutes: user?.session_start
|
||||
? Math.round((Date.now() - new Date(user.session_start).getTime()) / 60000)
|
||||
: null,
|
||||
});
|
||||
|
||||
// ✅ 重置 PostHog 用户会话
|
||||
resetUser();
|
||||
|
||||
// 清除本地状态
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
@@ -444,9 +397,7 @@ export const AuthProvider = ({ children }) => {
|
||||
updateUser,
|
||||
login,
|
||||
registerWithPhone,
|
||||
registerWithEmail,
|
||||
sendSmsCode,
|
||||
sendEmailCode,
|
||||
logout,
|
||||
hasRole,
|
||||
refreshSession,
|
||||
|
||||
@@ -124,6 +124,7 @@ async function startApp() {
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
|
||||
// Render the app with Router wrapper
|
||||
// ✅ StrictMode 已启用(Chakra UI 2.10.9+ 已修复兼容性问题)
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Router
|
||||
|
||||
@@ -33,8 +33,8 @@ export const initPostHog = () => {
|
||||
posthog.init(apiKey, {
|
||||
api_host: apiHost,
|
||||
|
||||
// Pageview tracking - manual control for better accuracy
|
||||
capture_pageview: false, // We'll manually capture with custom properties
|
||||
// Pageview tracking - auto-capture for DAU/MAU analytics
|
||||
capture_pageview: true, // Auto-capture all page views (required for DAU tracking)
|
||||
capture_pageleave: true, // Auto-capture when user leaves page
|
||||
|
||||
// Session Recording Configuration
|
||||
@@ -185,6 +185,30 @@ export const trackEvent = (eventName, properties = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 异步追踪事件(不阻塞主线程)
|
||||
* 使用 requestIdleCallback 在浏览器空闲时发送事件
|
||||
*
|
||||
* @param {string} eventName - 事件名称
|
||||
* @param {object} properties - 事件属性
|
||||
*/
|
||||
export const trackEventAsync = (eventName, properties = {}) => {
|
||||
// 浏览器支持 requestIdleCallback 时使用(推荐)
|
||||
if (typeof requestIdleCallback !== 'undefined') {
|
||||
requestIdleCallback(
|
||||
() => {
|
||||
trackEvent(eventName, properties);
|
||||
},
|
||||
{ timeout: 2000 } // 最多延迟 2 秒(防止永远不执行)
|
||||
);
|
||||
} else {
|
||||
// 降级方案:使用 setTimeout(兼容性更好)
|
||||
setTimeout(() => {
|
||||
trackEvent(eventName, properties);
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Track page view
|
||||
*
|
||||
|
||||
@@ -53,3 +53,13 @@ export type {
|
||||
CommentAuthor,
|
||||
CreateCommentParams,
|
||||
} from './comment';
|
||||
|
||||
// 投资规划相关类型
|
||||
export type {
|
||||
EventType,
|
||||
EventSource,
|
||||
EventStatus,
|
||||
InvestmentEvent,
|
||||
PlanFormData,
|
||||
PlanningContextValue,
|
||||
} from './investment';
|
||||
|
||||
148
src/types/investment.ts
Normal file
148
src/types/investment.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 投资规划相关类型定义
|
||||
* 用于 InvestmentPlanningCenter 组件及其子组件
|
||||
*/
|
||||
|
||||
import { UseToastOptions } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 事件类型枚举
|
||||
*/
|
||||
export type EventType = 'plan' | 'review' | 'reminder' | 'analysis';
|
||||
|
||||
/**
|
||||
* 事件来源
|
||||
*/
|
||||
export type EventSource = 'user' | 'future' | 'system';
|
||||
|
||||
/**
|
||||
* 事件状态
|
||||
*/
|
||||
export type EventStatus = 'active' | 'completed' | 'cancelled';
|
||||
|
||||
/**
|
||||
* 投资事件接口
|
||||
* 表示日历中的投资计划、复盘或其他事件
|
||||
*/
|
||||
export interface InvestmentEvent {
|
||||
/** 事件唯一标识符 */
|
||||
id: number;
|
||||
|
||||
/** 事件标题 */
|
||||
title: string;
|
||||
|
||||
/** 事件描述/详细内容 */
|
||||
description?: string;
|
||||
|
||||
/** 事件日期 (YYYY-MM-DD 格式) */
|
||||
event_date: string;
|
||||
|
||||
/** 事件类型 */
|
||||
type: EventType;
|
||||
|
||||
/** 事件来源(用户创建/系统生成/未来事件) */
|
||||
source?: EventSource;
|
||||
|
||||
/** 重要度 (1-5) */
|
||||
importance?: number;
|
||||
|
||||
/** 相关股票代码列表 */
|
||||
stocks?: string[];
|
||||
|
||||
/** 标签列表 */
|
||||
tags?: string[];
|
||||
|
||||
/** 事件状态 */
|
||||
status?: EventStatus;
|
||||
|
||||
/** 创建时间 */
|
||||
created_at?: string;
|
||||
|
||||
/** 更新时间 */
|
||||
updated_at?: string;
|
||||
|
||||
/** 事件内容(用于计划/复盘的详细内容) */
|
||||
content?: string;
|
||||
|
||||
/** 日期字段(兼容旧数据) */
|
||||
date?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 表单数据类型
|
||||
* 用于创建/编辑投资计划或复盘
|
||||
*/
|
||||
export interface PlanFormData {
|
||||
/** 事件日期 (YYYY-MM-DD 格式) */
|
||||
date: string;
|
||||
|
||||
/** 标题 */
|
||||
title: string;
|
||||
|
||||
/** 内容/描述 */
|
||||
content: string;
|
||||
|
||||
/** 事件类型 */
|
||||
type: EventType;
|
||||
|
||||
/** 相关股票代码列表 */
|
||||
stocks: string[];
|
||||
|
||||
/** 标签列表 */
|
||||
tags: string[];
|
||||
|
||||
/** 事件状态 */
|
||||
status: EventStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Planning Context 值类型
|
||||
* 用于在 InvestmentPlanningCenter 的子组件间共享数据
|
||||
*/
|
||||
export interface PlanningContextValue {
|
||||
/** 所有事件列表 */
|
||||
allEvents: InvestmentEvent[];
|
||||
|
||||
/** 设置事件列表 */
|
||||
setAllEvents: React.Dispatch<React.SetStateAction<InvestmentEvent[]>>;
|
||||
|
||||
/** 重新加载所有数据 */
|
||||
loadAllData: () => Promise<void>;
|
||||
|
||||
/** 加载状态 */
|
||||
loading: boolean;
|
||||
|
||||
/** 设置加载状态 */
|
||||
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
/** 当前激活的标签页索引 (0: 日历, 1: 计划, 2: 复盘) */
|
||||
activeTab: number;
|
||||
|
||||
/** 设置激活的标签页 */
|
||||
setActiveTab: React.Dispatch<React.SetStateAction<number>>;
|
||||
|
||||
/** Chakra UI Toast 实例 */
|
||||
toast: {
|
||||
(options?: UseToastOptions): string | number | undefined;
|
||||
close: (id: string | number) => void;
|
||||
closeAll: (options?: { positions?: Array<'top' | 'top-right' | 'top-left' | 'bottom' | 'bottom-right' | 'bottom-left'> }) => void;
|
||||
update: (id: string | number, options: Omit<UseToastOptions, 'id'>) => void;
|
||||
isActive: (id: string | number) => boolean;
|
||||
};
|
||||
|
||||
// 颜色主题变量(基于当前主题模式)
|
||||
/** 背景色 */
|
||||
bgColor: string;
|
||||
|
||||
/** 边框颜色 */
|
||||
borderColor: string;
|
||||
|
||||
/** 主要文本颜色 */
|
||||
textColor: string;
|
||||
|
||||
/** 次要文本颜色 */
|
||||
secondaryText: string;
|
||||
|
||||
/** 卡片背景色 */
|
||||
cardBg: string;
|
||||
}
|
||||
337
src/utils/trackingHelpers.js
Normal file
337
src/utils/trackingHelpers.js
Normal file
@@ -0,0 +1,337 @@
|
||||
// src/utils/trackingHelpers.js
|
||||
// PostHog 追踪性能优化工具 - 使用 requestIdleCallback 延迟非关键事件
|
||||
|
||||
import { shouldTrackImmediately } from '../constants/tracking';
|
||||
|
||||
/**
|
||||
* requestIdleCallback Polyfill
|
||||
* Safari 和旧浏览器不支持 requestIdleCallback,使用 setTimeout 降级
|
||||
*
|
||||
* @param {Function} callback - 回调函数
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {number} options.timeout - 超时时间(毫秒)
|
||||
* @returns {number} 定时器 ID
|
||||
*/
|
||||
const requestIdleCallbackPolyfill = (callback, options = {}) => {
|
||||
const timeout = options.timeout || 2000;
|
||||
const start = Date.now();
|
||||
|
||||
return setTimeout(() => {
|
||||
callback({
|
||||
didTimeout: false,
|
||||
timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),
|
||||
});
|
||||
}, 1);
|
||||
};
|
||||
|
||||
/**
|
||||
* cancelIdleCallback Polyfill
|
||||
*
|
||||
* @param {number} id - 定时器 ID
|
||||
*/
|
||||
const cancelIdleCallbackPolyfill = (id) => {
|
||||
clearTimeout(id);
|
||||
};
|
||||
|
||||
// 使用原生 API 或 polyfill
|
||||
const requestIdleCallbackCompat =
|
||||
typeof window !== 'undefined' && window.requestIdleCallback
|
||||
? window.requestIdleCallback.bind(window)
|
||||
: requestIdleCallbackPolyfill;
|
||||
|
||||
const cancelIdleCallbackCompat =
|
||||
typeof window !== 'undefined' && window.cancelIdleCallback
|
||||
? window.cancelIdleCallback.bind(window)
|
||||
: cancelIdleCallbackPolyfill;
|
||||
|
||||
// ==================== 待发送事件队列 ====================
|
||||
|
||||
/**
|
||||
* 待发送事件队列(用于批量发送优化)
|
||||
* @type {Array<{trackFn: Function, args: Array}>}
|
||||
*/
|
||||
let pendingEvents = [];
|
||||
|
||||
/**
|
||||
* 已调度的 idle callback ID(防止重复调度)
|
||||
* @type {number|null}
|
||||
*/
|
||||
let scheduledCallbackId = null;
|
||||
|
||||
/**
|
||||
* 刷新待发送事件队列
|
||||
* 立即执行所有待发送的追踪事件
|
||||
*/
|
||||
const flushPendingEvents = () => {
|
||||
if (pendingEvents.length === 0) return;
|
||||
|
||||
const eventsToFlush = [...pendingEvents];
|
||||
pendingEvents = [];
|
||||
|
||||
eventsToFlush.forEach(({ trackFn, args }) => {
|
||||
try {
|
||||
trackFn(...args);
|
||||
} catch (error) {
|
||||
console.error('❌ [trackingHelpers] Failed to flush event:', error);
|
||||
}
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
`%c✅ [trackingHelpers] Flushed ${eventsToFlush.length} pending event(s)`,
|
||||
'color: #10B981; font-weight: bold;'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理空闲时执行待发送事件
|
||||
*
|
||||
* @param {IdleDeadline} deadline - 空闲时间信息
|
||||
*/
|
||||
const processIdleEvents = (deadline) => {
|
||||
scheduledCallbackId = null;
|
||||
|
||||
// 如果超时或队列为空,强制刷新
|
||||
if (deadline.didTimeout || pendingEvents.length === 0) {
|
||||
flushPendingEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
// 在空闲时间内尽可能多地处理事件
|
||||
while (pendingEvents.length > 0 && deadline.timeRemaining() > 0) {
|
||||
const { trackFn, args } = pendingEvents.shift();
|
||||
try {
|
||||
trackFn(...args);
|
||||
} catch (error) {
|
||||
console.error('❌ [trackingHelpers] Failed to track event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果还有未处理的事件,继续调度
|
||||
if (pendingEvents.length > 0) {
|
||||
scheduledCallbackId = requestIdleCallbackCompat(processIdleEvents, {
|
||||
timeout: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 公共 API ====================
|
||||
|
||||
/**
|
||||
* 在浏览器空闲时追踪事件(非关键事件优化)
|
||||
*
|
||||
* 使用 requestIdleCallback API 延迟事件追踪到浏览器空闲时执行,
|
||||
* 避免阻塞主线程,提升页面交互响应速度。
|
||||
*
|
||||
* **适用场景**:
|
||||
* - 页面浏览事件(page_viewed)
|
||||
* - 列表查看事件(list_viewed)
|
||||
* - 筛选/排序事件(filter_applied, sorted)
|
||||
* - 低优先级交互事件
|
||||
*
|
||||
* **不适用场景**:
|
||||
* - 关键业务事件(登录、支付、关注)
|
||||
* - 用户明确操作事件(按钮点击、详情打开)
|
||||
* - 需要实时追踪的事件
|
||||
*
|
||||
* @param {Function} trackFn - PostHog 追踪函数(如 track, trackPageView)
|
||||
* @param {...any} args - 传递给追踪函数的参数
|
||||
*
|
||||
* @example
|
||||
* import { trackEventIdle } from '@utils/trackingHelpers';
|
||||
* import { trackEvent } from '@lib/posthog';
|
||||
*
|
||||
* // 延迟追踪页面浏览事件
|
||||
* trackEventIdle(trackEvent, 'page_viewed', { page: '/community' });
|
||||
*
|
||||
* // 延迟追踪筛选事件
|
||||
* trackEventIdle(track, 'news_filter_applied', { importance: 'high' });
|
||||
*/
|
||||
export const trackEventIdle = (trackFn, ...args) => {
|
||||
if (!trackFn || typeof trackFn !== 'function') {
|
||||
console.warn('⚠️ [trackingHelpers] trackFn must be a function');
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加到待发送队列
|
||||
pendingEvents.push({ trackFn, args });
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
`%c⏱️ [trackingHelpers] Event queued for idle execution (queue: ${pendingEvents.length})`,
|
||||
'color: #8B5CF6; font-weight: bold;',
|
||||
args[0] // 事件名称
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有已调度的 callback,调度一个新的
|
||||
if (scheduledCallbackId === null) {
|
||||
scheduledCallbackId = requestIdleCallbackCompat(processIdleEvents, {
|
||||
timeout: 2000, // 2秒超时保护,确保事件不会无限延迟
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 立即追踪事件(关键事件)
|
||||
*
|
||||
* 同步执行追踪,不延迟。用于需要实时追踪的关键业务事件。
|
||||
*
|
||||
* **适用场景**:
|
||||
* - 关键业务事件(登录、注册、支付、订阅)
|
||||
* - 用户明确操作(按钮点击、详情打开、搜索提交)
|
||||
* - 高优先级交互事件(关注、分享、评论)
|
||||
* - 需要准确时序的事件
|
||||
*
|
||||
* @param {Function} trackFn - PostHog 追踪函数
|
||||
* @param {...any} args - 传递给追踪函数的参数
|
||||
*
|
||||
* @example
|
||||
* import { trackEventImmediate } from '@utils/trackingHelpers';
|
||||
* import { trackEvent } from '@lib/posthog';
|
||||
*
|
||||
* // 立即追踪登录事件
|
||||
* trackEventImmediate(trackEvent, 'user_logged_in', { method: 'password' });
|
||||
*
|
||||
* // 立即追踪详情打开事件
|
||||
* trackEventImmediate(track, 'news_detail_opened', { news_id: 123 });
|
||||
*/
|
||||
export const trackEventImmediate = (trackFn, ...args) => {
|
||||
if (!trackFn || typeof trackFn !== 'function') {
|
||||
console.warn('⚠️ [trackingHelpers] trackFn must be a function');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
trackFn(...args);
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
`%c⚡ [trackingHelpers] Event tracked immediately`,
|
||||
'color: #F59E0B; font-weight: bold;',
|
||||
args[0] // 事件名称
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [trackingHelpers] Failed to track event immediately:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 智能追踪包装器
|
||||
*
|
||||
* 根据事件优先级自动选择立即追踪或空闲时追踪。
|
||||
* 使用 `shouldTrackImmediately()` 判断事件优先级,简化调用方代码。
|
||||
*
|
||||
* **适用场景**:
|
||||
* - 业务代码不需要关心事件优先级细节
|
||||
* - 统一的追踪接口,自动优化性能
|
||||
* - 易于维护和扩展
|
||||
*
|
||||
* **优先级规则**(由 `src/constants/tracking.js` 配置):
|
||||
* - CRITICAL / HIGH → 立即追踪(`trackEventImmediate`)
|
||||
* - NORMAL / LOW → 空闲时追踪(`trackEventIdle`)
|
||||
*
|
||||
* @param {Function} trackFn - PostHog 追踪函数(如 `track` from `usePostHogTrack`)
|
||||
* @param {string} eventName - 事件名称(需在 `tracking.js` 中定义优先级)
|
||||
* @param {Object} properties - 事件属性
|
||||
*
|
||||
* @example
|
||||
* import { smartTrack } from '@/utils/trackingHelpers';
|
||||
* import { usePostHogTrack } from '@/hooks/usePostHogRedux';
|
||||
* import { RETENTION_EVENTS } from '@/lib/constants';
|
||||
*
|
||||
* const { track } = usePostHogTrack();
|
||||
*
|
||||
* // 自动根据优先级选择追踪方式
|
||||
* smartTrack(track, RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, { news_id: 123 });
|
||||
* smartTrack(track, RETENTION_EVENTS.NEWS_LIST_VIEWED, { total_count: 30 });
|
||||
*/
|
||||
export const smartTrack = (trackFn, eventName, properties = {}) => {
|
||||
if (!trackFn || typeof trackFn !== 'function') {
|
||||
console.warn('⚠️ [trackingHelpers] smartTrack: trackFn must be a function');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!eventName || typeof eventName !== 'string') {
|
||||
console.warn('⚠️ [trackingHelpers] smartTrack: eventName must be a string');
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据事件优先级选择追踪方式
|
||||
if (shouldTrackImmediately(eventName)) {
|
||||
// 高优先级事件:立即追踪
|
||||
trackEventImmediate(trackFn, eventName, properties);
|
||||
} else {
|
||||
// 普通优先级事件:空闲时追踪
|
||||
trackEventIdle(trackFn, eventName, properties);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 页面卸载前刷新所有待发送事件
|
||||
*
|
||||
* 在 beforeunload 事件中调用,确保页面关闭前发送所有待发送的追踪事件。
|
||||
* 防止用户快速关闭页面时丢失事件数据。
|
||||
*
|
||||
* **使用方式**:
|
||||
* ```javascript
|
||||
* import { flushPendingEventsBeforeUnload } from '@utils/trackingHelpers';
|
||||
*
|
||||
* useEffect(() => {
|
||||
* window.addEventListener('beforeunload', flushPendingEventsBeforeUnload);
|
||||
* return () => {
|
||||
* window.removeEventListener('beforeunload', flushPendingEventsBeforeUnload);
|
||||
* };
|
||||
* }, []);
|
||||
* ```
|
||||
*/
|
||||
export const flushPendingEventsBeforeUnload = () => {
|
||||
// 取消已调度的 idle callback
|
||||
if (scheduledCallbackId !== null) {
|
||||
cancelIdleCallbackCompat(scheduledCallbackId);
|
||||
scheduledCallbackId = null;
|
||||
}
|
||||
|
||||
// 立即刷新所有待发送事件
|
||||
flushPendingEvents();
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(
|
||||
'%c🔄 [trackingHelpers] Flushed pending events before unload',
|
||||
'color: #3B82F6; font-weight: bold;'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前待发送事件数量(调试用)
|
||||
*
|
||||
* @returns {number} 待发送事件数量
|
||||
*/
|
||||
export const getPendingEventsCount = () => {
|
||||
return pendingEvents.length;
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空待发送事件队列(测试用)
|
||||
*/
|
||||
export const clearPendingEvents = () => {
|
||||
if (scheduledCallbackId !== null) {
|
||||
cancelIdleCallbackCompat(scheduledCallbackId);
|
||||
scheduledCallbackId = null;
|
||||
}
|
||||
pendingEvents = [];
|
||||
};
|
||||
|
||||
// ==================== 默认导出 ====================
|
||||
|
||||
export default {
|
||||
trackEventIdle,
|
||||
trackEventImmediate,
|
||||
smartTrack,
|
||||
flushPendingEventsBeforeUnload,
|
||||
getPendingEventsCount,
|
||||
clearPendingEvents,
|
||||
};
|
||||
@@ -1,7 +1,13 @@
|
||||
// src/utils/tradingTimeUtils.js
|
||||
// 交易时间相关工具函数
|
||||
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
|
||||
|
||||
// 扩展 Day.js 插件
|
||||
dayjs.extend(isSameOrBefore);
|
||||
dayjs.extend(isSameOrAfter);
|
||||
|
||||
/**
|
||||
* 获取当前时间应该显示的实时要闻时间范围
|
||||
@@ -12,7 +18,7 @@ import moment from 'moment';
|
||||
* @returns {{ startTime: Date, endTime: Date, description: string }}
|
||||
*/
|
||||
export const getCurrentTradingTimeRange = () => {
|
||||
const now = moment();
|
||||
const now = dayjs();
|
||||
const currentHour = now.hour();
|
||||
const currentMinute = now.minute();
|
||||
|
||||
@@ -25,18 +31,18 @@ export const getCurrentTradingTimeRange = () => {
|
||||
|
||||
if (currentTimeInMinutes < cutoffTime1500) {
|
||||
// 15:00 之前:显示昨日 15:00 - 今日 15:00
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
} else if (currentTimeInMinutes >= cutoffTime1530) {
|
||||
// 15:30 之后:显示今日 15:00 - 当前时间
|
||||
startTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = now.toDate();
|
||||
description = '今日15:00 - 当前时间';
|
||||
} else {
|
||||
// 15:00 - 15:30 之间:过渡期,保持显示昨日 15:00 - 今日 15:00
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
}
|
||||
|
||||
@@ -55,7 +61,7 @@ export const getCurrentTradingTimeRange = () => {
|
||||
* @returns {{ startTime: Date, endTime: Date, description: string }}
|
||||
*/
|
||||
export const getMarketReviewTimeRange = () => {
|
||||
const now = moment();
|
||||
const now = dayjs();
|
||||
const currentHour = now.hour();
|
||||
const currentMinute = now.minute();
|
||||
|
||||
@@ -67,13 +73,13 @@ export const getMarketReviewTimeRange = () => {
|
||||
|
||||
if (currentTimeInMinutes >= cutoffTime1530) {
|
||||
// 15:30 之后:显示昨日 15:00 - 今日 15:00(刚刚完成的交易日)
|
||||
startTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '昨日15:00 - 今日15:00';
|
||||
} else {
|
||||
// 15:30 之前:显示前日 15:00 - 昨日 15:00(上一个完整交易日)
|
||||
startTime = moment().subtract(2, 'days').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = moment().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
startTime = dayjs().subtract(2, 'days').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
endTime = dayjs().subtract(1, 'day').hour(15).minute(0).second(0).millisecond(0).toDate();
|
||||
description = '前日15:00 - 昨日15:00';
|
||||
}
|
||||
|
||||
@@ -102,15 +108,15 @@ export const filterEventsByTimeRange = (events, startTime, endTime) => {
|
||||
return events;
|
||||
}
|
||||
|
||||
const startMoment = moment(startTime);
|
||||
const endMoment = moment(endTime);
|
||||
const startMoment = dayjs(startTime);
|
||||
const endMoment = dayjs(endTime);
|
||||
|
||||
return events.filter(event => {
|
||||
if (!event.created_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const eventTime = moment(event.created_at);
|
||||
const eventTime = dayjs(event.created_at);
|
||||
return eventTime.isSameOrAfter(startMoment) && eventTime.isSameOrBefore(endMoment);
|
||||
});
|
||||
};
|
||||
@@ -138,8 +144,8 @@ export const getTimeRangeDescription = (startTime, endTime) => {
|
||||
return '';
|
||||
}
|
||||
|
||||
const startStr = moment(startTime).format('MM-DD HH:mm');
|
||||
const endStr = moment(endTime).format('MM-DD HH:mm');
|
||||
const startStr = dayjs(startTime).format('MM-DD HH:mm');
|
||||
const endStr = dayjs(endTime).format('MM-DD HH:mm');
|
||||
|
||||
return `${startStr} - ${endStr}`;
|
||||
};
|
||||
@@ -152,7 +158,7 @@ export const getTimeRangeDescription = (startTime, endTime) => {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTradingDay = (date) => {
|
||||
const day = moment(date).day();
|
||||
const day = dayjs(date).day();
|
||||
// 0 = 周日, 6 = 周六
|
||||
return day !== 0 && day !== 6;
|
||||
};
|
||||
@@ -164,7 +170,7 @@ export const isTradingDay = (date) => {
|
||||
* @returns {Date}
|
||||
*/
|
||||
export const getPreviousTradingDay = (date) => {
|
||||
let prevDay = moment(date).subtract(1, 'day');
|
||||
let prevDay = dayjs(date).subtract(1, 'day');
|
||||
|
||||
// 如果是周末,继续往前找
|
||||
while (!isTradingDay(prevDay.toDate())) {
|
||||
|
||||
@@ -109,10 +109,13 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
'fourRowData.total': fourRowData.total,
|
||||
});
|
||||
|
||||
// 根据模式选择数据源
|
||||
// 根据模式选择数据源(使用 useMemo 缓存,避免重复计算)
|
||||
// 纵向模式:data 是页码映射 { 1: [...], 2: [...] }
|
||||
// 平铺模式:data 是数组 [...]
|
||||
const modeData = currentMode === 'four-row' ? fourRowData : verticalData;
|
||||
const modeData = useMemo(
|
||||
() => currentMode === 'four-row' ? fourRowData : verticalData,
|
||||
[currentMode, fourRowData, verticalData]
|
||||
);
|
||||
const {
|
||||
data = currentMode === 'vertical' ? {} : [], // 纵向是对象,平铺是数组
|
||||
loading = false,
|
||||
@@ -123,9 +126,15 @@ const [currentMode, setCurrentMode] = useState('vertical');
|
||||
cachedPageCount = 0
|
||||
} = modeData;
|
||||
|
||||
// 传递给 usePagination 的数据
|
||||
const allCachedEventsByPage = currentMode === 'vertical' ? data : undefined;
|
||||
const allCachedEvents = currentMode === 'four-row' ? data : undefined;
|
||||
// 传递给 usePagination 的数据(使用 useMemo 缓存,避免重复计算)
|
||||
const allCachedEventsByPage = useMemo(
|
||||
() => currentMode === 'vertical' ? data : undefined,
|
||||
[currentMode, data]
|
||||
);
|
||||
const allCachedEvents = useMemo(
|
||||
() => currentMode === 'four-row' ? data : undefined,
|
||||
[currentMode, data]
|
||||
);
|
||||
|
||||
// 🔍 调试:选择的数据源
|
||||
console.log('%c[DynamicNewsCard] 选择的数据源', 'color: #3B82F6; font-weight: bold;', {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { ViewIcon } from '@chakra-ui/icons';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import StockChangeIndicators from '../../../../components/StockChangeIndicators';
|
||||
import EventFollowButton from '../EventCard/EventFollowButton';
|
||||
|
||||
@@ -98,7 +98,7 @@ const EventHeaderInfo = ({ event, importance, isFollowing, followerCount, onTogg
|
||||
|
||||
{/* 日期 */}
|
||||
<Text fontSize="sm" color="red.500" fontWeight="medium" whiteSpace="nowrap">
|
||||
{moment(event.created_at).format('YYYY年MM月DD日')}
|
||||
{dayjs(event.created_at).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/DynamicNewsDetail/MiniKLineChart.js
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchKlineData,
|
||||
getCacheKey,
|
||||
@@ -26,7 +26,7 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
|
||||
|
||||
// 稳定的事件时间
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -105,9 +105,9 @@ const MiniKLineChart = React.memo(function MiniKLineChart({ stockCode, eventTime
|
||||
let eventMarkLineData = [];
|
||||
if (stableEventTime && Array.isArray(dates) && dates.length > 0) {
|
||||
try {
|
||||
const eventDate = moment(stableEventTime).format('YYYY-MM-DD');
|
||||
const eventDate = dayjs(stableEventTime).format('YYYY-MM-DD');
|
||||
const eventIdx = dates.findIndex(d => {
|
||||
const dateStr = typeof d === 'object' ? moment(d).format('YYYY-MM-DD') : String(d);
|
||||
const dateStr = typeof d === 'object' ? dayjs(d).format('YYYY-MM-DD') : String(d);
|
||||
return dateStr.includes(eventDate);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaCalendarAlt } from 'react-icons/fa';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* 交易日期信息提示组件
|
||||
@@ -28,9 +28,9 @@ const TradingDateInfo = ({ effectiveTradingDate, eventTime }) => {
|
||||
<FaCalendarAlt color="gray" size={12} />
|
||||
<Text fontSize="xs" color={stockCountColor}>
|
||||
涨跌幅数据:{effectiveTradingDate}
|
||||
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
|
||||
{eventTime && effectiveTradingDate !== dayjs(eventTime).format('YYYY-MM-DD') && (
|
||||
<Text as="span" ml={2} fontSize="xs" color={stockCountColor}>
|
||||
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : moment(eventTime).format('YYYY-MM-DD HH:mm')},显示下一交易日数据)
|
||||
(事件发生于 {typeof eventTime === 'object' ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : dayjs(eventTime).format('YYYY-MM-DD HH:mm')},显示下一交易日数据)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import SimpleConceptCard from './SimpleConceptCard';
|
||||
import DetailedConceptCard from './DetailedConceptCard';
|
||||
import TradingDateInfo from './TradingDateInfo';
|
||||
@@ -89,16 +89,16 @@ const RelatedConceptsSection = ({
|
||||
let formattedTradeDate;
|
||||
try {
|
||||
// 不管传入的是什么格式,都用 moment 解析并格式化为 YYYY-MM-DD
|
||||
formattedTradeDate = moment(effectiveTradingDate).format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs(effectiveTradingDate).format('YYYY-MM-DD');
|
||||
|
||||
// 验证日期是否有效
|
||||
if (!moment(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
|
||||
if (!dayjs(formattedTradeDate, 'YYYY-MM-DD', true).isValid()) {
|
||||
console.warn('[RelatedConceptsSection] 无效日期,使用当前日期');
|
||||
formattedTradeDate = moment().format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[RelatedConceptsSection] 日期格式化失败,使用当前日期', error);
|
||||
formattedTradeDate = moment().format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
|
||||
// 导入子组件
|
||||
@@ -137,7 +137,7 @@ const CompactEventCard = ({
|
||||
<Text>@{event.creator?.username || 'Anonymous'}</Text>
|
||||
<Text>•</Text>
|
||||
<Text fontWeight="bold" color={linkColor}>
|
||||
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Text,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
|
||||
// 导入子组件
|
||||
@@ -127,7 +127,7 @@ const DetailedEventCard = ({
|
||||
{/* 右侧:时间 + 作者 */}
|
||||
<HStack spacing={2} fontSize="sm" flexShrink={0}>
|
||||
<Text fontWeight="bold" color={linkColor}>
|
||||
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</Text>
|
||||
<Text color={mutedColor}>•</Text>
|
||||
<Text color={mutedColor}>@{event.creator?.username || 'Anonymous'}</Text>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { getImportanceConfig } from '../../../../constants/importanceLevels';
|
||||
import { getChangeColor } from '../../../../utils/colorUtils';
|
||||
|
||||
@@ -54,7 +54,7 @@ const DynamicNewsEventCard = React.memo(({
|
||||
* @returns {'pre-market' | 'morning-trading' | 'lunch-break' | 'afternoon-trading' | 'after-market'}
|
||||
*/
|
||||
const getTradingPeriod = (timestamp) => {
|
||||
const eventTime = moment(timestamp);
|
||||
const eventTime = dayjs(timestamp);
|
||||
const hour = eventTime.hour();
|
||||
const minute = eventTime.minute();
|
||||
const timeInMinutes = hour * 60 + minute;
|
||||
@@ -248,7 +248,7 @@ const DynamicNewsEventCard = React.memo(({
|
||||
color={timeLabelStyle.textColor}
|
||||
lineHeight="1.3"
|
||||
>
|
||||
{moment(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{dayjs(event.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
{periodLabel && (
|
||||
<>
|
||||
{' • '}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// src/views/Community/components/EventCard/EventTimeline.js
|
||||
import React from 'react';
|
||||
import { Box, VStack, Text, useColorModeValue, Badge } from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* 事件时间轴组件
|
||||
@@ -56,7 +56,7 @@ const EventTimeline = ({ createdAt, timelineStyle, borderColor, minHeight = '40p
|
||||
color={timelineStyle.textColor}
|
||||
lineHeight="1.2"
|
||||
>
|
||||
{moment(createdAt).format('MM-DD')}
|
||||
{dayjs(createdAt).format('MM-DD')}
|
||||
</Text>
|
||||
{/* 时间 HH:mm */}
|
||||
<Text
|
||||
@@ -66,7 +66,7 @@ const EventTimeline = ({ createdAt, timelineStyle, borderColor, minHeight = '40p
|
||||
lineHeight="1.2"
|
||||
mt={0.5}
|
||||
>
|
||||
{moment(createdAt).format('HH:mm')}
|
||||
{dayjs(createdAt).format('HH:mm')}
|
||||
</Text>
|
||||
</Box>
|
||||
{/* 时间轴竖线 */}
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
// src/views/Community/components/EventDetailModal.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Spin, Descriptions, Tag, List, Badge, Empty, Input, Button, message } from 'antd';
|
||||
import { eventService } from '../../../services/eventService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import moment from 'moment';
|
||||
|
||||
const EventDetailModal = ({ visible, event, onClose }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [eventDetail, setEventDetail] = useState(null);
|
||||
const [commentText, setCommentText] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [comments, setComments] = useState([]);
|
||||
const [commentsLoading, setCommentsLoading] = useState(false);
|
||||
|
||||
const loadEventDetail = async () => {
|
||||
if (!event) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await eventService.getEventDetail(event.id);
|
||||
if (response.success) {
|
||||
setEventDetail(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('EventDetailModal', 'loadEventDetail', error, {
|
||||
eventId: event?.id
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadComments = async () => {
|
||||
if (!event) return;
|
||||
|
||||
setCommentsLoading(true);
|
||||
try {
|
||||
// 使用统一的posts API获取评论
|
||||
const result = await eventService.getPosts(event.id);
|
||||
if (result.success) {
|
||||
setComments(result.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('EventDetailModal', 'loadComments', error, {
|
||||
eventId: event?.id
|
||||
});
|
||||
} finally {
|
||||
setCommentsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && event) {
|
||||
loadEventDetail();
|
||||
loadComments();
|
||||
}
|
||||
}, [visible, event]);
|
||||
|
||||
const getImportanceColor = (importance) => {
|
||||
const colors = {
|
||||
S: 'red',
|
||||
A: 'orange',
|
||||
B: 'blue',
|
||||
C: 'green'
|
||||
};
|
||||
return colors[importance] || 'default';
|
||||
};
|
||||
|
||||
const getRelationDesc = (relationDesc) => {
|
||||
// 处理空值
|
||||
if (!relationDesc) return '';
|
||||
|
||||
// 如果是字符串,直接返回
|
||||
if (typeof relationDesc === 'string') {
|
||||
return relationDesc;
|
||||
}
|
||||
|
||||
// 如果是对象且包含data数组
|
||||
if (typeof relationDesc === 'object' && relationDesc.data && Array.isArray(relationDesc.data)) {
|
||||
const firstItem = relationDesc.data[0];
|
||||
if (firstItem) {
|
||||
// 优先使用 query_part,其次使用 sentences
|
||||
return firstItem.query_part || firstItem.sentences || '';
|
||||
}
|
||||
}
|
||||
|
||||
// 其他情况返回空字符串
|
||||
return '';
|
||||
};
|
||||
|
||||
const renderPriceTag = (value, label) => {
|
||||
if (value === null || value === undefined) return `${label}: --`;
|
||||
|
||||
const color = value > 0 ? '#ff4d4f' : '#52c41a';
|
||||
const prefix = value > 0 ? '+' : '';
|
||||
|
||||
return (
|
||||
<span>
|
||||
{label}: <span style={{ color }}>{prefix}{value.toFixed(2)}%</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmitComment = async () => {
|
||||
if (!commentText.trim()) {
|
||||
message.warning('请输入评论内容');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// 使用统一的createPost API
|
||||
const result = await eventService.createPost(event.id, {
|
||||
content: commentText.trim(),
|
||||
content_type: 'text'
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
message.success('评论发布成功');
|
||||
setCommentText('');
|
||||
// 重新加载评论列表
|
||||
loadComments();
|
||||
} else {
|
||||
throw new Error(result.message || '评论失败');
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e.message || '评论失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={eventDetail?.title || '事件详情'}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width={800}
|
||||
footer={null}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
{eventDetail && (
|
||||
<>
|
||||
<Descriptions bordered column={2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{moment(eventDetail.created_at).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建者">
|
||||
{eventDetail.creator?.username || 'Anonymous'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="重要性">
|
||||
<Badge color={getImportanceColor(eventDetail.importance)} text={`${eventDetail.importance}级`} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="浏览数">
|
||||
{eventDetail.view_count || 0}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="涨幅统计" span={2}>
|
||||
<Tag>{renderPriceTag(eventDetail.related_avg_chg, '平均涨幅')}</Tag>
|
||||
<Tag>{renderPriceTag(eventDetail.related_max_chg, '最大涨幅')}</Tag>
|
||||
<Tag>{renderPriceTag(eventDetail.related_week_chg, '周涨幅')}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="事件描述" span={2}>
|
||||
{eventDetail.description}(AI合成)
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{eventDetail.keywords && eventDetail.keywords.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<h4>相关概念</h4>
|
||||
{eventDetail.keywords.map((keyword, index) => (
|
||||
<Tag key={index} color="blue" style={{ marginBottom: 8 }}>
|
||||
{keyword}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{eventDetail.related_stocks && eventDetail.related_stocks.length > 0 && (
|
||||
<div>
|
||||
<h4>相关股票</h4>
|
||||
<List
|
||||
size="small"
|
||||
dataSource={eventDetail.related_stocks}
|
||||
renderItem={stock => (
|
||||
<List.Item
|
||||
actions={[
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const stockCode = stock.stock_code.split('.')[0];
|
||||
window.open(`https://valuefrontier.cn/company?scode=${stockCode}`, '_blank');
|
||||
}}
|
||||
>
|
||||
股票详情
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={`${stock.stock_name} (${stock.stock_code})`}
|
||||
description={getRelationDesc(stock.relation_desc) ? `${getRelationDesc(stock.relation_desc)}(AI合成)` : ''}
|
||||
/>
|
||||
{stock.change !== null && (
|
||||
<Tag color={stock.change > 0 ? 'red' : 'green'}>
|
||||
{stock.change > 0 ? '+' : ''}{stock.change.toFixed(2)}%
|
||||
</Tag>
|
||||
)}
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 讨论区 */}
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<h4>讨论区</h4>
|
||||
|
||||
{/* 评论列表 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Spin spinning={commentsLoading}>
|
||||
{comments.length === 0 ? (
|
||||
<Empty
|
||||
description="暂无评论"
|
||||
style={{ padding: '20px 0' }}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
itemLayout="vertical"
|
||||
dataSource={comments}
|
||||
renderItem={comment => (
|
||||
<List.Item key={comment.id}>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<div style={{ fontSize: '14px' }}>
|
||||
<strong>{comment.author?.username || 'Anonymous'}</strong>
|
||||
<span style={{ marginLeft: 8, color: '#999', fontWeight: 'normal' }}>
|
||||
{moment(comment.created_at).format('MM-DD HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.6',
|
||||
marginTop: 8
|
||||
}}>
|
||||
{comment.content}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
|
||||
{/* 评论输入框(登录后可用,未登录后端会返回401) */}
|
||||
<div>
|
||||
<h4>发表评论</h4>
|
||||
<Input.TextArea
|
||||
placeholder="说点什么..."
|
||||
rows={3}
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
maxLength={500}
|
||||
showCount
|
||||
/>
|
||||
<div style={{ textAlign: 'right', marginTop: 8 }}>
|
||||
<Button type="primary" loading={submitting} onClick={handleSubmitComment}>
|
||||
发布
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Spin>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventDetailModal;
|
||||
@@ -1,387 +0,0 @@
|
||||
/* src/views/Community/components/EventList.css */
|
||||
|
||||
/* 时间轴容器样式 */
|
||||
.event-timeline {
|
||||
padding: 0 0 0 24px;
|
||||
}
|
||||
|
||||
/* 时间轴圆点样式 */
|
||||
.timeline-dot {
|
||||
border: none !important;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
/* 时间轴事件卡片 */
|
||||
.timeline-event-card {
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-event-card:hover {
|
||||
transform: translateX(8px);
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
/* 重要性标记线 */
|
||||
.importance-marker {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.timeline-event-card:hover .importance-marker {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
/* 事件标题 */
|
||||
.event-title {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.event-title a {
|
||||
color: #1890ff;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.event-title a:hover {
|
||||
color: #40a9ff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* 事件元信息 */
|
||||
.event-meta {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.event-meta .anticon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.event-meta .separator {
|
||||
margin: 0;
|
||||
color: #e8e8e8;
|
||||
}
|
||||
|
||||
/* 事件描述 */
|
||||
.event-description {
|
||||
margin: 0 0 12px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 事件统计标签 */
|
||||
.event-stats {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.event-stats .ant-tag {
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 事件操作区域 */
|
||||
.event-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.event-actions > span {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.event-actions .anticon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
/* 事件按钮 */
|
||||
.event-buttons {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn-sm {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
/* 重要性指示器 */
|
||||
.importance-indicator {
|
||||
text-align: right;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.importance-indicator .ant-badge {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.importance-indicator .ant-avatar {
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.timeline-event-card:hover .importance-indicator .ant-avatar {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.importance-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 分页容器 */
|
||||
.pagination-container {
|
||||
margin-top: 32px;
|
||||
text-align: center;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.event-timeline {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.timeline-event-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.event-title a {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.event-description {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.event-actions {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.event-buttons {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.event-buttons .ant-space {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.importance-indicator {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.importance-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色主题支持(可选) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.timeline-event-card {
|
||||
background: #1f1f1f;
|
||||
border-color: #303030;
|
||||
}
|
||||
|
||||
.timeline-event-card:hover {
|
||||
border-color: #434343;
|
||||
}
|
||||
|
||||
.event-title a {
|
||||
color: #4096ff;
|
||||
}
|
||||
|
||||
.event-description {
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画效果 */
|
||||
@keyframes fadeInLeft {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 时间轴项目动画 */
|
||||
.ant-timeline-item {
|
||||
animation: fadeInLeft 0.5s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ant-timeline-item:nth-child(1) { animation-delay: 0.1s; }
|
||||
.ant-timeline-item:nth-child(2) { animation-delay: 0.2s; }
|
||||
.ant-timeline-item:nth-child(3) { animation-delay: 0.3s; }
|
||||
.ant-timeline-item:nth-child(4) { animation-delay: 0.4s; }
|
||||
.ant-timeline-item:nth-child(5) { animation-delay: 0.5s; }
|
||||
.ant-timeline-item:nth-child(6) { animation-delay: 0.6s; }
|
||||
.ant-timeline-item:nth-child(7) { animation-delay: 0.7s; }
|
||||
.ant-timeline-item:nth-child(8) { animation-delay: 0.8s; }
|
||||
.ant-timeline-item:nth-child(9) { animation-delay: 0.9s; }
|
||||
.ant-timeline-item:nth-child(10) { animation-delay: 1s; }
|
||||
|
||||
/* 时间轴连接线样式 */
|
||||
.ant-timeline-item-tail {
|
||||
border-left-style: dashed;
|
||||
border-left-width: 2px;
|
||||
}
|
||||
|
||||
/* 涨跌幅标签特殊样式 */
|
||||
.event-stats .ant-tag[color="#ff4d4f"] {
|
||||
background-color: #fff1f0;
|
||||
border-color: #ffccc7;
|
||||
}
|
||||
|
||||
.event-stats .ant-tag[color="#52c41a"] {
|
||||
background-color: #f6ffed;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
|
||||
/* 快速查看和详细信息按钮悬停效果 */
|
||||
.event-buttons .ant-btn-default:hover {
|
||||
color: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn-primary {
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.event-buttons .ant-btn-primary:hover {
|
||||
background: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
}
|
||||
|
||||
/* 工具提示样式 */
|
||||
.ant-tooltip-inner {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 徽章计数样式 */
|
||||
.importance-indicator .ant-badge-count {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
border-radius: 10px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
/* 加载状态动画 */
|
||||
.timeline-event-card.loading {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 特殊重要性等级样式增强 */
|
||||
.timeline-event-card[data-importance="S"] {
|
||||
border-left: 4px solid #722ed1;
|
||||
}
|
||||
|
||||
.timeline-event-card[data-importance="A"] {
|
||||
border-left: 4px solid #ff4d4f;
|
||||
}
|
||||
|
||||
.timeline-event-card[data-importance="B"] {
|
||||
border-left: 4px solid #faad14;
|
||||
}
|
||||
|
||||
.timeline-event-card[data-importance="C"] {
|
||||
border-left: 4px solid #52c41a;
|
||||
}
|
||||
|
||||
/* 时间轴左侧内容区域优化 */
|
||||
.ant-timeline-item-content {
|
||||
padding-bottom: 0;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
/* 确保最后一个时间轴项目没有连接线 */
|
||||
.ant-timeline-item:last-child .ant-timeline-item-tail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 打印样式优化 */
|
||||
@media print {
|
||||
.timeline-event-card {
|
||||
page-break-inside: avoid;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
.event-buttons {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.importance-marker {
|
||||
width: 2px !important;
|
||||
background: #000 !important;
|
||||
}
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
// src/views/Community/components/EventList.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Badge,
|
||||
Flex,
|
||||
Container,
|
||||
useColorModeValue,
|
||||
Switch,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
useToast,
|
||||
Center,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import { InfoIcon } from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
// 导入工具函数和常量
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import { useEventNotifications } from '../../../hooks/useEventNotifications';
|
||||
import { browserNotificationService } from '../../../services/browserNotificationService';
|
||||
import { useNotification } from '../../../contexts/NotificationContext';
|
||||
import { getImportanceConfig } from '../../../constants/importanceLevels';
|
||||
|
||||
// 导入子组件
|
||||
import EventCard from './EventCard';
|
||||
|
||||
// ========== 主组件 ==========
|
||||
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
|
||||
const [followingMap, setFollowingMap] = useState({});
|
||||
const [followCountMap, setFollowCountMap] = useState({});
|
||||
const [localEvents, setLocalEvents] = useState(events); // 用于实时更新的本地事件列表
|
||||
|
||||
// 从 NotificationContext 获取推送权限相关状态和方法
|
||||
const { browserPermission, requestBrowserPermission } = useNotification();
|
||||
|
||||
// 实时事件推送集成
|
||||
const { isConnected } = useEventNotifications({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
enabled: true,
|
||||
onNewEvent: (event) => {
|
||||
console.log('\n[EventList DEBUG] ========== EventList 收到新事件 ==========');
|
||||
console.log('[EventList DEBUG] 事件数据:', event);
|
||||
console.log('[EventList DEBUG] 事件 ID:', event?.id);
|
||||
console.log('[EventList DEBUG] 事件标题:', event?.title);
|
||||
logger.info('EventList', '收到新事件推送', event);
|
||||
|
||||
// 发送浏览器原生通知
|
||||
console.log('[EventList DEBUG] 准备发送浏览器原生通知');
|
||||
console.log('[EventList DEBUG] 通知权限状态:', browserPermission);
|
||||
if (browserPermission === 'granted') {
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
const notification = browserNotificationService.sendNotification({
|
||||
title: `🔔 ${importance.label}级事件`,
|
||||
body: event.title,
|
||||
tag: `event_${event.id}`,
|
||||
data: {
|
||||
link: `/event-detail/${event.id}`,
|
||||
eventId: event.id,
|
||||
},
|
||||
autoClose: 10000, // 10秒后自动关闭
|
||||
});
|
||||
|
||||
if (notification) {
|
||||
browserNotificationService.setupClickHandler(notification, navigate);
|
||||
console.log('[EventList DEBUG] ✓ 浏览器原生通知已发送');
|
||||
} else {
|
||||
console.log('[EventList DEBUG] ⚠️ 浏览器原生通知发送失败');
|
||||
}
|
||||
} else {
|
||||
console.log('[EventList DEBUG] ⚠️ 浏览器通知权限未授予,跳过原生通知');
|
||||
}
|
||||
|
||||
console.log('[EventList DEBUG] 准备更新事件列表');
|
||||
// 将新事件添加到列表顶部(防止重复)
|
||||
setLocalEvents((prevEvents) => {
|
||||
console.log('[EventList DEBUG] 当前事件列表数量:', prevEvents.length);
|
||||
const exists = prevEvents.some(e => e.id === event.id);
|
||||
console.log('[EventList DEBUG] 事件是否已存在:', exists);
|
||||
if (exists) {
|
||||
logger.debug('EventList', '事件已存在,跳过添加', { eventId: event.id });
|
||||
console.log('[EventList DEBUG] ⚠️ 事件已存在,跳过添加');
|
||||
return prevEvents;
|
||||
}
|
||||
logger.info('EventList', '新事件添加到列表顶部', { eventId: event.id });
|
||||
console.log('[EventList DEBUG] ✓ 新事件添加到列表顶部');
|
||||
// 添加到顶部,最多保留 100 个
|
||||
const updatedEvents = [event, ...prevEvents].slice(0, 100);
|
||||
console.log('[EventList DEBUG] 更新后事件列表数量:', updatedEvents.length);
|
||||
return updatedEvents;
|
||||
});
|
||||
console.log('[EventList DEBUG] ✓ 事件列表更新完成');
|
||||
console.log('[EventList DEBUG] ========== EventList 处理完成 ==========\n');
|
||||
}
|
||||
});
|
||||
|
||||
// 同步外部 events 到 localEvents
|
||||
useEffect(() => {
|
||||
setLocalEvents(events);
|
||||
}, [events]);
|
||||
|
||||
// 初始化关注状态与计数
|
||||
useEffect(() => {
|
||||
// 初始化计数映射
|
||||
const initCounts = {};
|
||||
localEvents.forEach(ev => {
|
||||
initCounts[ev.id] = ev.follower_count || 0;
|
||||
});
|
||||
setFollowCountMap(initCounts);
|
||||
|
||||
const loadFollowing = async () => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const res = await fetch(base + '/api/account/events/following', { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
const map = {};
|
||||
(data.data || []).forEach(ev => { map[ev.id] = true; });
|
||||
setFollowingMap(map);
|
||||
logger.debug('EventList', '关注状态加载成功', {
|
||||
followingCount: Object.keys(map).length
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('EventList', '加载关注状态失败', { error: e.message });
|
||||
}
|
||||
};
|
||||
loadFollowing();
|
||||
// 仅在 localEvents 更新时重跑
|
||||
}, [localEvents]);
|
||||
|
||||
const toggleFollow = async (eventId) => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
const res = await fetch(base + `/api/events/${eventId}/follow`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) throw new Error(data.error || '操作失败');
|
||||
const isFollowing = data.data?.is_following;
|
||||
const count = data.data?.follower_count ?? 0;
|
||||
setFollowingMap(prev => ({ ...prev, [eventId]: isFollowing }));
|
||||
setFollowCountMap(prev => ({ ...prev, [eventId]: count }));
|
||||
logger.debug('EventList', '关注状态切换成功', {
|
||||
eventId,
|
||||
isFollowing,
|
||||
followerCount: count
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn('EventList', '关注操作失败', {
|
||||
eventId,
|
||||
error: e.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 处理推送开关切换
|
||||
const handlePushToggle = async (e) => {
|
||||
const isChecked = e.target.checked;
|
||||
|
||||
if (isChecked) {
|
||||
// 用户想开启推送
|
||||
logger.info('EventList', '用户请求开启推送');
|
||||
const permission = await requestBrowserPermission();
|
||||
|
||||
if (permission === 'denied') {
|
||||
// 权限被拒绝,显示设置指引
|
||||
logger.warn('EventList', '用户拒绝了推送权限');
|
||||
toast({
|
||||
title: '推送权限被拒绝',
|
||||
description: '如需开启推送,请在浏览器设置中允许通知权限',
|
||||
status: 'warning',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
position: 'top',
|
||||
});
|
||||
} else if (permission === 'granted') {
|
||||
logger.info('EventList', '推送权限已授予');
|
||||
}
|
||||
} else {
|
||||
// 用户想关闭推送 - 提示需在浏览器设置中操作
|
||||
logger.info('EventList', '用户尝试关闭推送');
|
||||
toast({
|
||||
title: '关闭推送通知',
|
||||
description: '如需关闭,请在浏览器设置中撤销通知权限',
|
||||
status: 'info',
|
||||
duration: 5000,
|
||||
isClosable: true,
|
||||
position: 'top',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 专业的金融配色方案
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
|
||||
const handleTitleClick = (e, event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEventClick(event);
|
||||
};
|
||||
|
||||
const handleViewDetailClick = (e, eventId) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
};
|
||||
|
||||
// 时间轴样式配置(固定使用轻量卡片样式)
|
||||
const getTimelineBoxStyle = () => {
|
||||
return {
|
||||
bg: useColorModeValue('gray.50', 'gray.700'),
|
||||
borderColor: useColorModeValue('gray.400', 'gray.500'),
|
||||
borderWidth: '2px',
|
||||
textColor: useColorModeValue('blue.600', 'blue.400'),
|
||||
boxShadow: 'sm',
|
||||
};
|
||||
};
|
||||
|
||||
// 分页组件
|
||||
const Pagination = ({ current, total, pageSize, onChange }) => {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
// 计算要显示的页码数组(智能分页)
|
||||
const getPageNumbers = () => {
|
||||
const delta = 2; // 当前页左右各显示2个页码
|
||||
const range = [];
|
||||
const rangeWithDots = [];
|
||||
|
||||
// 始终显示第1页
|
||||
range.push(1);
|
||||
|
||||
// 显示当前页附近的页码
|
||||
for (let i = current - delta; i <= current + delta; i++) {
|
||||
if (i > 1 && i < totalPages) {
|
||||
range.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// 始终显示最后一页(如果总页数>1)
|
||||
if (totalPages > 1) {
|
||||
range.push(totalPages);
|
||||
}
|
||||
|
||||
// 去重并排序
|
||||
const uniqueRange = [...new Set(range)].sort((a, b) => a - b);
|
||||
|
||||
// 添加省略号
|
||||
let prev = 0;
|
||||
for (const page of uniqueRange) {
|
||||
if (page - prev === 2) {
|
||||
// 如果只差一个页码,直接显示
|
||||
rangeWithDots.push(prev + 1);
|
||||
} else if (page - prev > 2) {
|
||||
// 如果差距大于2,显示省略号
|
||||
rangeWithDots.push('...');
|
||||
}
|
||||
rangeWithDots.push(page);
|
||||
prev = page;
|
||||
}
|
||||
|
||||
return rangeWithDots;
|
||||
};
|
||||
|
||||
const pageNumbers = getPageNumbers();
|
||||
|
||||
return (
|
||||
<Flex justify="center" align="center" mt={8} gap={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChange(current - 1)}
|
||||
isDisabled={current === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
|
||||
<HStack spacing={1}>
|
||||
{pageNumbers.map((page, index) => {
|
||||
if (page === '...') {
|
||||
return (
|
||||
<Text key={`ellipsis-${index}`} px={2} color="gray.500">
|
||||
...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
size="sm"
|
||||
variant={current === page ? 'solid' : 'ghost'}
|
||||
colorScheme={current === page ? 'blue' : 'gray'}
|
||||
onClick={() => onChange(page)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChange(current + 1)}
|
||||
isDisabled={current === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
|
||||
<Text fontSize="sm" color={mutedColor} ml={4}>
|
||||
共 {total} 条
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" pb={8}>
|
||||
{/* 顶部控制栏:左空白 + 中间分页器 + 右侧控制(固定sticky) - 铺满全宽 */}
|
||||
<Box
|
||||
position="sticky"
|
||||
top={0}
|
||||
zIndex={10}
|
||||
bg={useColorModeValue('rgba(255, 255, 255, 0.9)', 'rgba(26, 32, 44, 0.9)')}
|
||||
backdropFilter="blur(10px)"
|
||||
boxShadow="sm"
|
||||
mb={4}
|
||||
py={2}
|
||||
w="100%"
|
||||
>
|
||||
<Container maxW="container.xl">
|
||||
<Flex justify="space-between" align="center">
|
||||
{/* 左侧占位 */}
|
||||
<Box key="left-spacer" flex="1" />
|
||||
|
||||
{/* 中间:分页器 */}
|
||||
{pagination.total > 0 && localEvents.length > 0 ? (
|
||||
<Flex key="pagination-controls" align="center" gap={2}>
|
||||
<Button
|
||||
key="prev-page"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => onPageChange(pagination.current - 1)}
|
||||
isDisabled={pagination.current === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Text key="page-info" fontSize="xs" color={mutedColor} px={2} whiteSpace="nowrap">
|
||||
第 {pagination.current} / {Math.ceil(pagination.total / pagination.pageSize)} 页
|
||||
</Text>
|
||||
<Button
|
||||
key="next-page"
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => onPageChange(pagination.current + 1)}
|
||||
isDisabled={pagination.current === Math.ceil(pagination.total / pagination.pageSize)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
<Text key="total-count" fontSize="xs" color={mutedColor} ml={2} whiteSpace="nowrap">
|
||||
共 {pagination.total} 条
|
||||
</Text>
|
||||
</Flex>
|
||||
) : (
|
||||
<Box key="center-spacer" flex="1" />
|
||||
)}
|
||||
|
||||
{/* 右侧:控制按钮 */}
|
||||
<Flex key="right-controls" align="center" gap={3} flex="1" justify="flex-end">
|
||||
{/* WebSocket 连接状态 */}
|
||||
<Badge
|
||||
key="websocket-status"
|
||||
colorScheme={isConnected ? 'green' : 'red'}
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
>
|
||||
{isConnected ? '🟢 实时' : '🔴 离线'}
|
||||
</Badge>
|
||||
|
||||
{/* 桌面推送开关 */}
|
||||
<FormControl key="push-notification" display="flex" alignItems="center" w="auto">
|
||||
<FormLabel htmlFor="push-notification" mb="0" fontSize="xs" color={textColor} mr={2}>
|
||||
推送
|
||||
</FormLabel>
|
||||
<Tooltip
|
||||
label={
|
||||
browserPermission === 'granted'
|
||||
? '桌面推送已开启'
|
||||
: browserPermission === 'denied'
|
||||
? '推送权限被拒绝,请在浏览器设置中允许通知权限'
|
||||
: '点击开启桌面推送通知'
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<Switch
|
||||
id="push-notification"
|
||||
size="sm"
|
||||
isChecked={browserPermission === 'granted'}
|
||||
onChange={handlePushToggle}
|
||||
colorScheme="green"
|
||||
/>
|
||||
</Tooltip>
|
||||
</FormControl>
|
||||
|
||||
{/* 视图切换控制 */}
|
||||
<FormControl key="compact-mode" display="flex" alignItems="center" w="auto">
|
||||
<FormLabel htmlFor="compact-mode" mb="0" fontSize="xs" color={textColor} mr={2}>
|
||||
精简
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="compact-mode"
|
||||
size="sm"
|
||||
isChecked={isCompactMode}
|
||||
onChange={(e) => setIsCompactMode(e.target.checked)}
|
||||
colorScheme="blue"
|
||||
/>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* 事件列表内容 */}
|
||||
<Container maxW="container.xl">
|
||||
{localEvents.length > 0 ? (
|
||||
<VStack key="event-list" align="stretch" spacing={0}>
|
||||
{localEvents.map((event, index) => (
|
||||
<Box key={event.id} position="relative">
|
||||
<EventCard
|
||||
event={event}
|
||||
index={index}
|
||||
isCompactMode={isCompactMode}
|
||||
isFollowing={!!followingMap[event.id]}
|
||||
followerCount={followCountMap[event.id] ?? (event.follower_count || 0)}
|
||||
onEventClick={onEventClick}
|
||||
onTitleClick={handleTitleClick}
|
||||
onViewDetail={handleViewDetailClick}
|
||||
onToggleFollow={toggleFollow}
|
||||
timelineStyle={getTimelineBoxStyle()}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Center key="empty-state" h="300px">
|
||||
<VStack spacing={4}>
|
||||
<InfoIcon key="empty-icon" boxSize={12} color={mutedColor} />
|
||||
<Text key="empty-text" color={mutedColor} fontSize="lg">
|
||||
暂无事件数据
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{pagination.total > 0 && (
|
||||
<Pagination
|
||||
key="bottom-pagination"
|
||||
current={pagination.current}
|
||||
total={pagination.total}
|
||||
pageSize={pagination.pageSize}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventList;
|
||||
@@ -1,818 +0,0 @@
|
||||
// src/views/Community/components/EventList.js
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
Badge,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
Flex,
|
||||
Avatar,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Divider,
|
||||
Container,
|
||||
useColorModeValue,
|
||||
Circle,
|
||||
Stat,
|
||||
StatNumber,
|
||||
StatHelpText,
|
||||
StatArrow,
|
||||
ButtonGroup,
|
||||
Heading,
|
||||
SimpleGrid,
|
||||
Card,
|
||||
CardBody,
|
||||
Center,
|
||||
Link,
|
||||
Spacer,
|
||||
Switch,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
ViewIcon,
|
||||
ChatIcon,
|
||||
StarIcon,
|
||||
TimeIcon,
|
||||
InfoIcon,
|
||||
WarningIcon,
|
||||
WarningTwoIcon,
|
||||
CheckCircleIcon,
|
||||
TriangleUpIcon,
|
||||
TriangleDownIcon,
|
||||
ArrowForwardIcon,
|
||||
ExternalLinkIcon,
|
||||
ViewOffIcon,
|
||||
} from '@chakra-ui/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import moment from 'moment';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
// ========== 工具函数定义在组件外部 ==========
|
||||
// 涨跌颜色配置(中国A股配色:红涨绿跌)- 分档次显示
|
||||
const getPriceChangeColor = (value) => {
|
||||
if (value === null || value === undefined) return 'gray.500';
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (value > 0) {
|
||||
// 上涨用红色,根据涨幅大小使用不同深浅
|
||||
if (absValue >= 3) return 'red.600'; // 深红色:3%以上
|
||||
if (absValue >= 1) return 'red.500'; // 中红色:1-3%
|
||||
return 'red.400'; // 浅红色:0-1%
|
||||
} else if (value < 0) {
|
||||
// 下跌用绿色,根据跌幅大小使用不同深浅
|
||||
if (absValue >= 3) return 'green.600'; // 深绿色:3%以上
|
||||
if (absValue >= 1) return 'green.500'; // 中绿色:1-3%
|
||||
return 'green.400'; // 浅绿色:0-1%
|
||||
}
|
||||
return 'gray.500';
|
||||
};
|
||||
|
||||
const getPriceChangeBg = (value) => {
|
||||
if (value === null || value === undefined) return 'gray.50';
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (value > 0) {
|
||||
// 上涨背景色
|
||||
if (absValue >= 3) return 'red.100'; // 深色背景:3%以上
|
||||
if (absValue >= 1) return 'red.50'; // 中色背景:1-3%
|
||||
return 'red.50'; // 浅色背景:0-1%
|
||||
} else if (value < 0) {
|
||||
// 下跌背景色
|
||||
if (absValue >= 3) return 'green.100'; // 深色背景:3%以上
|
||||
if (absValue >= 1) return 'green.50'; // 中色背景:1-3%
|
||||
return 'green.50'; // 浅色背景:0-1%
|
||||
}
|
||||
return 'gray.50';
|
||||
};
|
||||
|
||||
const getPriceChangeBorderColor = (value) => {
|
||||
if (value === null || value === undefined) return 'gray.300';
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (value > 0) {
|
||||
// 上涨边框色
|
||||
if (absValue >= 3) return 'red.500'; // 深边框:3%以上
|
||||
if (absValue >= 1) return 'red.400'; // 中边框:1-3%
|
||||
return 'red.300'; // 浅边框:0-1%
|
||||
} else if (value < 0) {
|
||||
// 下跌边框色
|
||||
if (absValue >= 3) return 'green.500'; // 深边框:3%以上
|
||||
if (absValue >= 1) return 'green.400'; // 中边框:1-3%
|
||||
return 'green.300'; // 浅边框:0-1%
|
||||
}
|
||||
return 'gray.300';
|
||||
};
|
||||
|
||||
// 重要性等级配置 - 金融配色方案
|
||||
const importanceLevels = {
|
||||
'S': {
|
||||
color: 'purple.600',
|
||||
bgColor: 'purple.50',
|
||||
borderColor: 'purple.200',
|
||||
icon: WarningIcon,
|
||||
label: '极高',
|
||||
dotBg: 'purple.500',
|
||||
},
|
||||
'A': {
|
||||
color: 'red.600',
|
||||
bgColor: 'red.50',
|
||||
borderColor: 'red.200',
|
||||
icon: WarningTwoIcon,
|
||||
label: '高',
|
||||
dotBg: 'red.500',
|
||||
},
|
||||
'B': {
|
||||
color: 'orange.600',
|
||||
bgColor: 'orange.50',
|
||||
borderColor: 'orange.200',
|
||||
icon: InfoIcon,
|
||||
label: '中',
|
||||
dotBg: 'orange.500',
|
||||
},
|
||||
'C': {
|
||||
color: 'green.600',
|
||||
bgColor: 'green.50',
|
||||
borderColor: 'green.200',
|
||||
icon: CheckCircleIcon,
|
||||
label: '低',
|
||||
dotBg: 'green.500',
|
||||
}
|
||||
};
|
||||
|
||||
const getImportanceConfig = (importance) => {
|
||||
return importanceLevels[importance] || importanceLevels['C'];
|
||||
};
|
||||
|
||||
// 自定义的涨跌箭头组件(修复颜色问题)
|
||||
const PriceArrow = ({ value }) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
|
||||
const Icon = value > 0 ? TriangleUpIcon : TriangleDownIcon;
|
||||
const color = value > 0 ? 'red.500' : 'green.500';
|
||||
|
||||
return <Icon color={color} boxSize="16px" />;
|
||||
};
|
||||
|
||||
// ========== 主组件 ==========
|
||||
const EventList = ({ events, pagination, onPageChange, onEventClick, onViewDetail }) => {
|
||||
const navigate = useNavigate();
|
||||
const [isCompactMode, setIsCompactMode] = useState(false); // 新增:紧凑模式状态
|
||||
const [followingMap, setFollowingMap] = useState({});
|
||||
const [followCountMap, setFollowCountMap] = useState({});
|
||||
|
||||
// 初始化关注状态与计数
|
||||
useEffect(() => {
|
||||
// 初始化计数映射
|
||||
const initCounts = {};
|
||||
events.forEach(ev => {
|
||||
initCounts[ev.id] = ev.follower_count || 0;
|
||||
});
|
||||
setFollowCountMap(initCounts);
|
||||
|
||||
const loadFollowing = async () => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const res = await fetch(base + '/api/account/events/following', { credentials: 'include' });
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
const map = {};
|
||||
(data.data || []).forEach(ev => { map[ev.id] = true; });
|
||||
setFollowingMap(map);
|
||||
logger.debug('EventList', '关注状态加载成功', {
|
||||
followingCount: Object.keys(map).length
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('EventList', '加载关注状态失败', { error: e.message });
|
||||
}
|
||||
};
|
||||
loadFollowing();
|
||||
// 仅在 events 更新时重跑
|
||||
}, [events]);
|
||||
|
||||
const toggleFollow = async (eventId) => {
|
||||
try {
|
||||
const base = (process.env.NODE_ENV === 'production' ? '' : process.env.REACT_APP_API_URL || 'http://49.232.185.254:5001');
|
||||
const res = await fetch(base + `/api/events/${eventId}/follow`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.success) throw new Error(data.error || '操作失败');
|
||||
const isFollowing = data.data?.is_following;
|
||||
const count = data.data?.follower_count ?? 0;
|
||||
setFollowingMap(prev => ({ ...prev, [eventId]: isFollowing }));
|
||||
setFollowCountMap(prev => ({ ...prev, [eventId]: count }));
|
||||
logger.debug('EventList', '关注状态切换成功', {
|
||||
eventId,
|
||||
isFollowing,
|
||||
followerCount: count
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn('EventList', '关注操作失败', {
|
||||
eventId,
|
||||
error: e.message
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 专业的金融配色方案
|
||||
const bgColor = useColorModeValue('gray.50', 'gray.900');
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const textColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
||||
const hoverBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
const renderPriceChange = (value, label) => {
|
||||
if (value === null || value === undefined) {
|
||||
return (
|
||||
<Tag size="lg" colorScheme="gray" borderRadius="full" variant="subtle">
|
||||
<TagLabel fontSize="sm" fontWeight="medium">{label}: --</TagLabel>
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
const isPositive = value > 0;
|
||||
|
||||
// 根据涨跌幅大小选择不同的颜色深浅
|
||||
let colorScheme = 'gray';
|
||||
let variant = 'solid';
|
||||
|
||||
if (isPositive) {
|
||||
// 上涨用红色系
|
||||
if (absValue >= 3) {
|
||||
colorScheme = 'red';
|
||||
variant = 'solid'; // 深色
|
||||
} else if (absValue >= 1) {
|
||||
colorScheme = 'red';
|
||||
variant = 'subtle'; // 中等
|
||||
} else {
|
||||
colorScheme = 'red';
|
||||
variant = 'outline'; // 浅色
|
||||
}
|
||||
} else {
|
||||
// 下跌用绿色系
|
||||
if (absValue >= 3) {
|
||||
colorScheme = 'green';
|
||||
variant = 'solid'; // 深色
|
||||
} else if (absValue >= 1) {
|
||||
colorScheme = 'green';
|
||||
variant = 'subtle'; // 中等
|
||||
} else {
|
||||
colorScheme = 'green';
|
||||
variant = 'outline'; // 浅色
|
||||
}
|
||||
}
|
||||
|
||||
const Icon = isPositive ? TriangleUpIcon : TriangleDownIcon;
|
||||
|
||||
return (
|
||||
<Tag
|
||||
size="lg"
|
||||
colorScheme={colorScheme}
|
||||
borderRadius="full"
|
||||
variant={variant}
|
||||
boxShadow="sm"
|
||||
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<TagLeftIcon as={Icon} boxSize="16px" />
|
||||
<TagLabel fontSize="sm" fontWeight="bold">
|
||||
{label}: {isPositive ? '+' : ''}{value.toFixed(2)}%
|
||||
</TagLabel>
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const handleTitleClick = (e, event) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onEventClick(event);
|
||||
};
|
||||
|
||||
const handleViewDetailClick = (e, eventId) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/event-detail/${eventId}`);
|
||||
};
|
||||
|
||||
// 精简模式的事件渲染
|
||||
const renderCompactEvent = (event) => {
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
const isFollowing = !!followingMap[event.id];
|
||||
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
|
||||
|
||||
return (
|
||||
<HStack align="stretch" spacing={4} w="full">
|
||||
{/* 时间线和重要性标记 */}
|
||||
<VStack spacing={0} align="center">
|
||||
<Circle
|
||||
size="32px"
|
||||
bg={importance.dotBg}
|
||||
color="white"
|
||||
fontWeight="bold"
|
||||
fontSize="sm"
|
||||
boxShadow="sm"
|
||||
border="2px solid"
|
||||
borderColor={cardBg}
|
||||
>
|
||||
{event.importance || 'C'}
|
||||
</Circle>
|
||||
<Box
|
||||
w="2px"
|
||||
flex="1"
|
||||
bg={borderColor}
|
||||
minH="60px"
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
{/* 精简事件卡片 */}
|
||||
<Card
|
||||
flex="1"
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
_hover={{
|
||||
boxShadow: 'md',
|
||||
transform: 'translateY(-1px)',
|
||||
borderColor: importance.color,
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
cursor="pointer"
|
||||
onClick={() => onEventClick(event)}
|
||||
mb={3}
|
||||
>
|
||||
<CardBody p={4}>
|
||||
<Flex align="center" justify="space-between" wrap="wrap" gap={3}>
|
||||
{/* 左侧:标题和时间 */}
|
||||
<VStack align="start" spacing={2} flex="1" minW="200px">
|
||||
<Heading
|
||||
size="sm"
|
||||
color={linkColor}
|
||||
_hover={{ textDecoration: 'underline' }}
|
||||
onClick={(e) => handleTitleClick(e, event)}
|
||||
cursor="pointer"
|
||||
noOfLines={1}
|
||||
>
|
||||
{event.title}
|
||||
</Heading>
|
||||
<HStack spacing={2} fontSize="xs" color={mutedColor}>
|
||||
<TimeIcon />
|
||||
<Text>{moment(event.created_at).format('MM-DD HH:mm')}</Text>
|
||||
<Text>•</Text>
|
||||
<Text>{event.creator?.username || 'Anonymous'}</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* 右侧:涨跌幅指标 */}
|
||||
<HStack spacing={3}>
|
||||
<Tooltip label="平均涨幅" placement="top">
|
||||
<Box
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_avg_chg)}
|
||||
borderWidth="1px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
|
||||
>
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_avg_chg} />
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
color={getPriceChangeColor(event.related_avg_chg)}
|
||||
>
|
||||
{event.related_avg_chg != null
|
||||
? `${event.related_avg_chg > 0 ? '+' : ''}${event.related_avg_chg.toFixed(2)}%`
|
||||
: '--'}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
onClick={(e) => handleViewDetailClick(e, event.id)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isFollowing ? 'solid' : 'outline'}
|
||||
colorScheme="yellow"
|
||||
leftIcon={<StarIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFollow(event.id);
|
||||
}}
|
||||
>
|
||||
{isFollowing ? '已关注' : '关注'} {followerCount ? `(${followerCount})` : ''}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
// 详细模式的事件渲染(原有的渲染方式,但修复了箭头颜色)
|
||||
const renderDetailedEvent = (event) => {
|
||||
const importance = getImportanceConfig(event.importance);
|
||||
const isFollowing = !!followingMap[event.id];
|
||||
const followerCount = followCountMap[event.id] ?? (event.follower_count || 0);
|
||||
|
||||
return (
|
||||
<HStack align="stretch" spacing={4} w="full">
|
||||
{/* 时间线和重要性标记 */}
|
||||
<VStack spacing={0} align="center">
|
||||
<Circle
|
||||
size="40px"
|
||||
bg={importance.dotBg}
|
||||
color="white"
|
||||
fontWeight="bold"
|
||||
fontSize="lg"
|
||||
boxShadow="md"
|
||||
border="3px solid"
|
||||
borderColor={cardBg}
|
||||
>
|
||||
{event.importance || 'C'}
|
||||
</Circle>
|
||||
<Box
|
||||
w="2px"
|
||||
flex="1"
|
||||
bg={borderColor}
|
||||
minH="100px"
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
{/* 事件卡片 */}
|
||||
<Card
|
||||
flex="1"
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
_hover={{
|
||||
boxShadow: 'md',
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: importance.color,
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
cursor="pointer"
|
||||
onClick={() => onEventClick(event)}
|
||||
mb={4}
|
||||
>
|
||||
<CardBody p={5}>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
{/* 标题和重要性标签 */}
|
||||
<Flex align="center" justify="space-between">
|
||||
<Tooltip
|
||||
label="点击查看事件详情"
|
||||
placement="top"
|
||||
hasArrow
|
||||
openDelay={500}
|
||||
>
|
||||
<Heading
|
||||
size="md"
|
||||
color={linkColor}
|
||||
_hover={{ textDecoration: 'underline', color: 'blue.500' }}
|
||||
onClick={(e) => handleTitleClick(e, event)}
|
||||
cursor="pointer"
|
||||
>
|
||||
{event.title}
|
||||
</Heading>
|
||||
</Tooltip>
|
||||
<Badge
|
||||
colorScheme={importance.color.split('.')[0]}
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontSize="sm"
|
||||
>
|
||||
{importance.label}优先级
|
||||
</Badge>
|
||||
</Flex>
|
||||
|
||||
{/* 元信息 */}
|
||||
<HStack spacing={4} fontSize="sm">
|
||||
<HStack
|
||||
bg="blue.50"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
color="blue.700"
|
||||
fontWeight="medium"
|
||||
>
|
||||
<TimeIcon />
|
||||
<Text>{moment(event.created_at).format('YYYY-MM-DD HH:mm')}</Text>
|
||||
</HStack>
|
||||
<Text color={mutedColor}>•</Text>
|
||||
<Text color={mutedColor}>{event.creator?.username || 'Anonymous'}</Text>
|
||||
</HStack>
|
||||
|
||||
{/* 描述 */}
|
||||
<Text color={textColor} fontSize="sm" lineHeight="tall" noOfLines={3}>
|
||||
{event.description}
|
||||
</Text>
|
||||
|
||||
{/* 价格变化指标 */}
|
||||
<Box
|
||||
bg={useColorModeValue('gradient.subtle', 'gray.700')}
|
||||
bgGradient="linear(to-r, gray.50, white)"
|
||||
p={4}
|
||||
borderRadius="lg"
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
boxShadow="sm"
|
||||
>
|
||||
<SimpleGrid columns={{ base: 1, md: 3 }} spacing={3}>
|
||||
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_avg_chg)}
|
||||
borderWidth="2px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_avg_chg)}
|
||||
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Stat size="sm">
|
||||
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
|
||||
平均涨幅
|
||||
</StatHelpText>
|
||||
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_avg_chg)}>
|
||||
{event.related_avg_chg != null ? (
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_avg_chg} />
|
||||
<Text fontWeight="bold">
|
||||
{event.related_avg_chg > 0 ? '+' : ''}{event.related_avg_chg.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text color="gray.400">--</Text>
|
||||
)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_max_chg)}
|
||||
borderWidth="2px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_max_chg)}
|
||||
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Stat size="sm">
|
||||
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
|
||||
最大涨幅
|
||||
</StatHelpText>
|
||||
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_max_chg)}>
|
||||
{event.related_max_chg != null ? (
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_max_chg} />
|
||||
<Text fontWeight="bold">
|
||||
{event.related_max_chg > 0 ? '+' : ''}{event.related_max_chg.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text color="gray.400">--</Text>
|
||||
)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="点击查看相关股票" placement="top" hasArrow>
|
||||
<Box
|
||||
cursor="pointer"
|
||||
p={2}
|
||||
borderRadius="md"
|
||||
bg={getPriceChangeBg(event.related_week_chg)}
|
||||
borderWidth="2px"
|
||||
borderColor={getPriceChangeBorderColor(event.related_week_chg)}
|
||||
_hover={{ transform: 'scale(1.02)', boxShadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Stat size="sm">
|
||||
<StatHelpText mb={1} fontWeight="semibold" color="gray.600" fontSize="xs">
|
||||
周涨幅
|
||||
</StatHelpText>
|
||||
<StatNumber fontSize="xl" color={getPriceChangeColor(event.related_week_chg)}>
|
||||
{event.related_week_chg != null ? (
|
||||
<HStack spacing={1}>
|
||||
<PriceArrow value={event.related_week_chg} />
|
||||
<Text fontWeight="bold">
|
||||
{event.related_week_chg > 0 ? '+' : ''}{event.related_week_chg.toFixed(2)}%
|
||||
</Text>
|
||||
</HStack>
|
||||
) : (
|
||||
<Text color="gray.400">--</Text>
|
||||
)}
|
||||
</StatNumber>
|
||||
</Stat>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 统计信息和操作按钮 */}
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={3}>
|
||||
<HStack spacing={6}>
|
||||
<Tooltip label="浏览量" placement="top">
|
||||
<HStack spacing={1} color={mutedColor}>
|
||||
<ViewIcon />
|
||||
<Text fontSize="sm">{event.view_count || 0}</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
<Tooltip label="帖子数" placement="top">
|
||||
<HStack spacing={1} color={mutedColor}>
|
||||
<ChatIcon />
|
||||
<Text fontSize="sm">{event.post_count || 0}</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
<Tooltip label="关注数" placement="top">
|
||||
<HStack spacing={1} color={mutedColor}>
|
||||
<StarIcon />
|
||||
<Text fontSize="sm">{followerCount}</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
|
||||
<ButtonGroup size="sm" spacing={2}>
|
||||
<Button
|
||||
variant="outline"
|
||||
colorScheme="gray"
|
||||
leftIcon={<ViewIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEventClick(event);
|
||||
}}
|
||||
>
|
||||
快速查看
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="blue"
|
||||
leftIcon={<ExternalLinkIcon />}
|
||||
onClick={(e) => handleViewDetailClick(e, event.id)}
|
||||
>
|
||||
详细信息
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="yellow"
|
||||
variant={isFollowing ? 'solid' : 'outline'}
|
||||
leftIcon={<StarIcon />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFollow(event.id);
|
||||
}}
|
||||
>
|
||||
{isFollowing ? '已关注' : '关注'}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
// 分页组件
|
||||
const Pagination = ({ current, total, pageSize, onChange }) => {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<Flex justify="center" align="center" mt={8} gap={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChange(current - 1)}
|
||||
isDisabled={current === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
|
||||
<HStack spacing={1}>
|
||||
{[...Array(Math.min(5, totalPages))].map((_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
size="sm"
|
||||
variant={current === pageNum ? 'solid' : 'ghost'}
|
||||
colorScheme={current === pageNum ? 'blue' : 'gray'}
|
||||
onClick={() => onChange(pageNum)}
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{totalPages > 5 && <Text>...</Text>}
|
||||
{totalPages > 5 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={current === totalPages ? 'solid' : 'ghost'}
|
||||
colorScheme={current === totalPages ? 'blue' : 'gray'}
|
||||
onClick={() => onChange(totalPages)}
|
||||
>
|
||||
{totalPages}
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onChange(current + 1)}
|
||||
isDisabled={current === totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
|
||||
<Text fontSize="sm" color={mutedColor} ml={4}>
|
||||
共 {total} 条
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box bg={bgColor} minH="100vh" py={8}>
|
||||
<Container maxW="container.xl">
|
||||
{/* 视图切换控制 */}
|
||||
<Flex justify="flex-end" mb={6}>
|
||||
<FormControl display="flex" alignItems="center" w="auto">
|
||||
<FormLabel htmlFor="compact-mode" mb="0" fontSize="sm" color={textColor}>
|
||||
精简模式
|
||||
</FormLabel>
|
||||
<Switch
|
||||
id="compact-mode"
|
||||
isChecked={isCompactMode}
|
||||
onChange={(e) => setIsCompactMode(e.target.checked)}
|
||||
colorScheme="blue"
|
||||
/>
|
||||
</FormControl>
|
||||
</Flex>
|
||||
|
||||
{events.length > 0 ? (
|
||||
<VStack align="stretch" spacing={0}>
|
||||
{events.map((event, index) => (
|
||||
<Box key={event.id} position="relative">
|
||||
{isCompactMode
|
||||
? renderCompactEvent(event)
|
||||
: renderDetailedEvent(event)
|
||||
}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
) : (
|
||||
<Center h="300px">
|
||||
<VStack spacing={4}>
|
||||
<InfoIcon boxSize={12} color={mutedColor} />
|
||||
<Text color={mutedColor} fontSize="lg">
|
||||
暂无事件数据
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{pagination.total > 0 && (
|
||||
<Pagination
|
||||
current={pagination.current}
|
||||
total={pagination.total}
|
||||
pageSize={pagination.pageSize}
|
||||
onChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventList;
|
||||
@@ -1,75 +0,0 @@
|
||||
// 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;
|
||||
@@ -1,42 +0,0 @@
|
||||
// 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;
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
ModalCloseButton,
|
||||
useDisclosure
|
||||
} from '@chakra-ui/react';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import './HotEvents.css';
|
||||
import defaultEventImage from '../../../assets/img/default-event.jpg';
|
||||
import DynamicNewsDetailPanel from './DynamicNewsDetail';
|
||||
@@ -181,9 +181,9 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
|
||||
<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-date">{dayjs(event.created_at).format('YYYY-MM-DD')}</span>
|
||||
{' '}
|
||||
<span className="time-hour">{moment(event.created_at).format('HH:mm')}</span>
|
||||
<span className="time-hour">{dayjs(event.created_at).format('HH:mm')}</span>
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
// src/views/Community/components/ImportanceLegend.js
|
||||
import React from 'react';
|
||||
import { Card, Space, Badge } from 'antd';
|
||||
|
||||
const ImportanceLegend = () => {
|
||||
const levels = [
|
||||
{ level: 'S', color: '#ff4d4f', description: '重大事件,市场影响深远' },
|
||||
{ level: 'A', color: '#faad14', description: '重要事件,影响较大' },
|
||||
{ level: 'B', color: '#1890ff', description: '普通事件,有一定影响' },
|
||||
{ level: 'C', color: '#52c41a', description: '参考事件,影响有限' }
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="重要性等级说明" className="importance-legend">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{levels.map(item => (
|
||||
<div key={item.level} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Badge
|
||||
color={item.color}
|
||||
text={
|
||||
<span>
|
||||
<strong style={{ marginRight: 8 }}>{item.level}级</strong>
|
||||
{item.description}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportanceLegend;
|
||||
@@ -1,78 +0,0 @@
|
||||
// src/views/Community/components/IndustryCascader.js
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Card, Form, Cascader } from 'antd';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '../../../store/slices/industrySlice';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
const IndustryCascader = ({ onFilterChange, loading }) => {
|
||||
const [industryCascaderValue, setIndustryCascaderValue] = useState([]);
|
||||
|
||||
// 使用 Redux 获取行业数据
|
||||
const dispatch = useDispatch();
|
||||
const industryData = useSelector(selectIndustryData);
|
||||
const industryLoading = useSelector(selectIndustryLoading);
|
||||
|
||||
// Cascader 获得焦点时加载数据
|
||||
const handleCascaderFocus = useCallback(async () => {
|
||||
if (!industryData || industryData.length === 0) {
|
||||
logger.debug('IndustryCascader', 'Cascader 获得焦点,开始加载行业数据');
|
||||
await dispatch(fetchIndustryData());
|
||||
}
|
||||
}, [dispatch, industryData]);
|
||||
|
||||
// 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;
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
StarFilled, StarOutlined, CalendarOutlined, LinkOutlined, StockOutlined,
|
||||
TagsOutlined, ClockCircleOutlined, InfoCircleOutlined, LockOutlined, RobotOutlined
|
||||
} from '@ant-design/icons';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { eventService, stockService } from '../../../services/eventService';
|
||||
import StockChartAntdModal from '../../../components/StockChart/StockChartAntdModal';
|
||||
@@ -33,7 +33,7 @@ const InvestmentCalendar = () => {
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(moment());
|
||||
const [currentMonth, setCurrentMonth] = useState(dayjs());
|
||||
|
||||
// 新增状态
|
||||
const [detailDrawerVisible, setDetailDrawerVisible] = useState(false);
|
||||
@@ -344,7 +344,7 @@ const InvestmentCalendar = () => {
|
||||
render: (time) => (
|
||||
<Space>
|
||||
<ClockCircleOutlined />
|
||||
<Text>{moment(time).format('HH:mm')}</Text>
|
||||
<Text>{dayjs(time).format('HH:mm')}</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
// src/views/Community/components/MarketReviewCard.js
|
||||
// 市场复盘组件(左右布局:事件列表 | 事件详情)
|
||||
|
||||
import React, { forwardRef, useState } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Box,
|
||||
Flex,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Badge,
|
||||
Center,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
Grid,
|
||||
GridItem,
|
||||
} from '@chakra-ui/react';
|
||||
import { TimeIcon, InfoIcon } from '@chakra-ui/icons';
|
||||
import moment from 'moment';
|
||||
import CompactEventCard from './EventCard/CompactEventCard';
|
||||
import EventHeader from './EventCard/EventHeader';
|
||||
import EventStats from './EventCard/EventStats';
|
||||
import EventFollowButton from './EventCard/EventFollowButton';
|
||||
import EventPriceDisplay from './EventCard/EventPriceDisplay';
|
||||
import EventDescription from './EventCard/EventDescription';
|
||||
import { getImportanceConfig } from '../../../constants/importanceLevels';
|
||||
|
||||
/**
|
||||
* 市场复盘 - 左右布局卡片组件
|
||||
* @param {Array} events - 事件列表
|
||||
* @param {boolean} loading - 加载状态
|
||||
* @param {Date} lastUpdateTime - 最后更新时间
|
||||
* @param {Function} onEventClick - 事件点击回调
|
||||
* @param {Function} onViewDetail - 查看详情回调
|
||||
* @param {Function} onToggleFollow - 切换关注回调
|
||||
* @param {Object} ref - 用于滚动的ref
|
||||
*/
|
||||
const MarketReviewCard = forwardRef(({
|
||||
events,
|
||||
loading,
|
||||
lastUpdateTime,
|
||||
onEventClick,
|
||||
onViewDetail,
|
||||
onToggleFollow,
|
||||
...rest
|
||||
}, ref) => {
|
||||
const cardBg = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.700');
|
||||
const linkColor = useColorModeValue('blue.600', 'blue.400');
|
||||
const mutedColor = useColorModeValue('gray.500', 'gray.400');
|
||||
const textColor = useColorModeValue('gray.700', 'gray.200');
|
||||
const selectedBg = useColorModeValue('blue.50', 'blue.900');
|
||||
|
||||
// 选中的事件
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
|
||||
// 时间轴样式配置
|
||||
const getTimelineBoxStyle = () => {
|
||||
return {
|
||||
bg: useColorModeValue('gray.50', 'gray.700'),
|
||||
borderColor: useColorModeValue('gray.400', 'gray.500'),
|
||||
borderWidth: '2px',
|
||||
textColor: useColorModeValue('blue.600', 'blue.400'),
|
||||
boxShadow: 'sm',
|
||||
};
|
||||
};
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = (event) => {
|
||||
setSelectedEvent(event);
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染右侧事件详情
|
||||
const renderEventDetail = () => {
|
||||
if (!selectedEvent) {
|
||||
return (
|
||||
<Center h="full" minH="400px">
|
||||
<VStack spacing={4}>
|
||||
<InfoIcon boxSize={12} color={mutedColor} />
|
||||
<Text color={mutedColor} fontSize="lg">
|
||||
请从左侧选择事件查看详情
|
||||
</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const importance = getImportanceConfig(selectedEvent.importance);
|
||||
|
||||
return (
|
||||
<Card
|
||||
bg={cardBg}
|
||||
borderWidth="1px"
|
||||
borderColor={borderColor}
|
||||
borderRadius="md"
|
||||
boxShadow="md"
|
||||
h="full"
|
||||
>
|
||||
<CardBody p={6}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{/* 第一行:标题+优先级 | 统计+关注 */}
|
||||
<Flex align="center" justify="space-between" gap={3}>
|
||||
{/* 左侧:标题 + 优先级标签 */}
|
||||
<EventHeader
|
||||
title={selectedEvent.title}
|
||||
importance={selectedEvent.importance}
|
||||
onTitleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onViewDetail) {
|
||||
onViewDetail(e, selectedEvent.id);
|
||||
}
|
||||
}}
|
||||
linkColor={linkColor}
|
||||
compact={false}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* 右侧:统计数据 + 关注按钮 */}
|
||||
<HStack spacing={4} flexShrink={0}>
|
||||
{/* 统计数据 */}
|
||||
<EventStats
|
||||
viewCount={selectedEvent.view_count}
|
||||
postCount={selectedEvent.post_count}
|
||||
followerCount={selectedEvent.follower_count}
|
||||
size="md"
|
||||
spacing={4}
|
||||
display="flex"
|
||||
mutedColor={mutedColor}
|
||||
/>
|
||||
|
||||
{/* 关注按钮 */}
|
||||
<EventFollowButton
|
||||
isFollowing={false}
|
||||
followerCount={selectedEvent.follower_count}
|
||||
onToggle={() => onToggleFollow && onToggleFollow(selectedEvent.id)}
|
||||
size="sm"
|
||||
showCount={false}
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 第二行:价格标签 | 时间+作者 */}
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={3}>
|
||||
{/* 左侧:价格标签 */}
|
||||
<EventPriceDisplay
|
||||
avgChange={selectedEvent.related_avg_chg}
|
||||
maxChange={selectedEvent.related_max_chg}
|
||||
weekChange={selectedEvent.related_week_chg}
|
||||
compact={false}
|
||||
/>
|
||||
|
||||
{/* 右侧:时间 + 作者 */}
|
||||
<HStack spacing={2} fontSize="sm" flexShrink={0}>
|
||||
<Text fontWeight="bold" color={linkColor}>
|
||||
{moment(selectedEvent.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</Text>
|
||||
<Text color={mutedColor}>•</Text>
|
||||
<Text color={mutedColor}>@{selectedEvent.creator?.username || 'Anonymous'}</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 第三行:描述文字 */}
|
||||
<EventDescription
|
||||
description={selectedEvent.description}
|
||||
textColor={textColor}
|
||||
minLength={200}
|
||||
noOfLines={10}
|
||||
/>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card ref={ref} {...rest} bg={cardBg} borderColor={borderColor} mb={4}>
|
||||
{/* 标题部分 */}
|
||||
<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="orange">复盘</Badge>
|
||||
<Badge colorScheme="purple">总结</Badge>
|
||||
<Badge colorScheme="gray">完整</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Text fontSize="xs" color="gray.500">
|
||||
最后更新: {lastUpdateTime?.toLocaleTimeString() || '未知'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
|
||||
{/* 主体内容 */}
|
||||
<CardBody>
|
||||
{/* Loading 状态 */}
|
||||
{loading && (
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Spinner size="xl" color="blue.500" thickness="4px" />
|
||||
<Text color="gray.500">正在加载复盘数据...</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* Empty 状态 */}
|
||||
{!loading && (!events || events.length === 0) && (
|
||||
<Center py={10}>
|
||||
<VStack>
|
||||
<Text fontSize="lg" color="gray.500">暂无复盘数据</Text>
|
||||
</VStack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* 左右布局:事件列表 | 事件详情 */}
|
||||
{!loading && events && events.length > 0 && (
|
||||
<Grid templateColumns="1fr 2fr" gap={6} minH="500px">
|
||||
{/* 左侧:事件列表 (33.3%) */}
|
||||
<GridItem>
|
||||
<Box
|
||||
overflowY="auto"
|
||||
maxH="600px"
|
||||
pr={2}
|
||||
css={{
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '6px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: useColorModeValue('#f1f1f1', '#2D3748'),
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: useColorModeValue('#888', '#4A5568'),
|
||||
borderRadius: '10px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: useColorModeValue('#555', '#718096'),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
{events.map((event, index) => (
|
||||
<Box
|
||||
key={event.id}
|
||||
onClick={() => handleEventClick(event)}
|
||||
cursor="pointer"
|
||||
bg={selectedEvent?.id === event.id ? selectedBg : 'transparent'}
|
||||
borderRadius="md"
|
||||
transition="all 0.2s"
|
||||
_hover={{ bg: selectedBg }}
|
||||
>
|
||||
<CompactEventCard
|
||||
event={event}
|
||||
index={index}
|
||||
isFollowing={false}
|
||||
followerCount={event.follower_count || 0}
|
||||
onEventClick={() => handleEventClick(event)}
|
||||
onTitleClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleEventClick(event);
|
||||
}}
|
||||
onViewDetail={onViewDetail}
|
||||
onToggleFollow={() => {}}
|
||||
timelineStyle={getTimelineBoxStyle()}
|
||||
borderColor={borderColor}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
</Box>
|
||||
</GridItem>
|
||||
|
||||
{/* 右侧:事件详情 (66.7%) */}
|
||||
<GridItem>
|
||||
{renderEventDetail()}
|
||||
</GridItem>
|
||||
</Grid>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
MarketReviewCard.displayName = 'MarketReviewCard';
|
||||
|
||||
export default MarketReviewCard;
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import { Drawer, Spin, Button, Alert } from 'antd';
|
||||
import { CloseOutlined, LockOutlined, CrownOutlined } from '@ant-design/icons';
|
||||
import { Tabs as AntdTabs } from 'antd';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// Services and Utils
|
||||
import { eventService } from '../../../services/eventService';
|
||||
@@ -167,7 +167,7 @@ function StockDetailPanel({ visible, event, onClose }) {
|
||||
if (fixedCharts.length === 0) return null;
|
||||
|
||||
const formattedEventTime = event?.start_time
|
||||
? moment(event.start_time).format('YYYY-MM-DD HH:mm')
|
||||
? dayjs(event.start_time).format('YYYY-MM-DD HH:mm')
|
||||
: undefined;
|
||||
|
||||
return fixedCharts.map(({ stock }, index) => (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import * as echarts from 'echarts';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchKlineData,
|
||||
getCacheKey,
|
||||
@@ -27,7 +27,7 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
|
||||
|
||||
// 稳定的事件时间,避免因为格式化导致的重复请求
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -109,7 +109,7 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
|
||||
let eventMarkLineData = [];
|
||||
if (stableEventTime && Array.isArray(times) && times.length > 0) {
|
||||
try {
|
||||
const eventMinute = moment(stableEventTime, 'YYYY-MM-DD HH:mm').format('HH:mm');
|
||||
const eventMinute = dayjs(stableEventTime, 'YYYY-MM-DD HH:mm').format('HH:mm');
|
||||
const parseMinuteTime = (timeStr) => {
|
||||
const [h, m] = String(timeStr).split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { Table, Button } from 'antd';
|
||||
import { StarFilled, StarOutlined } from '@ant-design/icons';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import MiniTimelineChart from './MiniTimelineChart';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
|
||||
@@ -31,7 +31,7 @@ const StockTable = ({
|
||||
|
||||
// 稳定的事件时间,避免重复渲染
|
||||
const stableEventTime = useMemo(() => {
|
||||
return eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
|
||||
}, [eventTime]);
|
||||
|
||||
// 切换行展开状态
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/views/Community/components/StockDetailPanel/utils/klineDataCache.js
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import { stockService } from '../../../../../services/eventService';
|
||||
import { logger } from '../../../../../utils/logger';
|
||||
|
||||
@@ -19,7 +19,7 @@ const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数
|
||||
* @returns {string} 缓存键
|
||||
*/
|
||||
export const getCacheKey = (stockCode, eventTime, chartType = 'timeline') => {
|
||||
const date = eventTime ? moment(eventTime).format('YYYY-MM-DD') : moment().format('YYYY-MM-DD');
|
||||
const date = eventTime ? dayjs(eventTime).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
|
||||
return `${stockCode}|${date}|${chartType}`;
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ export const shouldRefreshData = (cacheKey) => {
|
||||
const elapsed = now - lastTime;
|
||||
|
||||
// 如果是今天的数据且交易时间内,允许更频繁的更新
|
||||
const today = moment().format('YYYY-MM-DD');
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const isToday = cacheKey.includes(today);
|
||||
const currentHour = new Date().getHours();
|
||||
const isTradingHours = currentHour >= 9 && currentHour < 16;
|
||||
@@ -76,7 +76,7 @@ export const fetchKlineData = async (stockCode, eventTime, chartType = 'timeline
|
||||
|
||||
// 3. 发起新请求
|
||||
logger.debug('klineDataCache', '发起新K线数据请求', { cacheKey, chartType });
|
||||
const normalizedEventTime = eventTime ? moment(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
|
||||
const normalizedEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
|
||||
const requestPromise = stockService
|
||||
.getKlineData(stockCode, chartType, normalizedEventTime)
|
||||
.then((res) => {
|
||||
|
||||
@@ -1,898 +0,0 @@
|
||||
// src/views/Community/components/UnifiedSearchBox.js
|
||||
// 搜索组件:三行布局(主搜索 + 热门概念 + 筛选区)
|
||||
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Card, Input, Cascader, Button, Space, Tag, AutoComplete, Select as AntSelect
|
||||
} from 'antd';
|
||||
import {
|
||||
SearchOutlined, CloseCircleOutlined, StockOutlined
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '../../../store/slices/industrySlice';
|
||||
import { stockService } from '../../../services/stockService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import PopularKeywords from './PopularKeywords';
|
||||
import TradingTimeFilter from './TradingTimeFilter';
|
||||
|
||||
const { Option } = AntSelect;
|
||||
|
||||
const UnifiedSearchBox = ({
|
||||
onSearch,
|
||||
onSearchFocus,
|
||||
popularKeywords = [],
|
||||
filters = {},
|
||||
mode, // 显示模式(如:vertical, horizontal 等)
|
||||
pageSize, // 每页显示数量
|
||||
trackingFunctions = {} // PostHog 追踪函数集合
|
||||
}) => {
|
||||
|
||||
// 其他状态
|
||||
const [stockOptions, setStockOptions] = useState([]); // 股票下拉选项列表
|
||||
const [allStocks, setAllStocks] = useState([]); // 所有股票数据
|
||||
const [industryValue, setIndustryValue] = useState([]);
|
||||
|
||||
// 筛选条件状态
|
||||
const [sort, setSort] = useState('new'); // 排序方式
|
||||
const [importance, setImportance] = useState([]); // 重要性(数组,支持多选)
|
||||
const [tradingTimeRange, setTradingTimeRange] = useState(null); // 交易时段筛选
|
||||
|
||||
// ✅ 本地输入状态 - 管理用户的实时输入
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
// 使用 Redux 获取行业数据
|
||||
const dispatch = useDispatch();
|
||||
const industryData = useSelector(selectIndustryData);
|
||||
const industryLoading = useSelector(selectIndustryLoading);
|
||||
|
||||
// 加载行业数据函数
|
||||
const loadIndustryData = useCallback(() => {
|
||||
if (!industryData) {
|
||||
dispatch(fetchIndustryData());
|
||||
}
|
||||
}, [dispatch, industryData]);
|
||||
|
||||
// 搜索触发函数
|
||||
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) {
|
||||
const importanceArray = filters.importance === 'all'
|
||||
? [] // 'all' 对应空数组(不显示任何选中)
|
||||
: filters.importance.split(',').map(v => v.trim()).filter(Boolean);
|
||||
setImportance(importanceArray);
|
||||
logger.debug('UnifiedSearchBox', '初始化重要性', {
|
||||
filters_importance: filters.importance,
|
||||
importanceArray
|
||||
});
|
||||
} else {
|
||||
setImportance([]);
|
||||
}
|
||||
|
||||
// ✅ 初始化行业分类(需要 industryData 加载完成)
|
||||
// ⚠️ 只在 industryValue 为空时才从 filters 初始化,避免用户选择后被覆盖
|
||||
if (filters.industry_code && industryData && industryData.length > 0 && (!industryValue || industryValue.length === 0)) {
|
||||
const path = findIndustryPath(filters.industry_code, industryData);
|
||||
if (path) {
|
||||
setIndustryValue(path);
|
||||
logger.debug('UnifiedSearchBox', '初始化行业分类', {
|
||||
industry_code: filters.industry_code,
|
||||
path
|
||||
});
|
||||
}
|
||||
} else if (!filters.industry_code && industryValue && industryValue.length > 0) {
|
||||
// 如果 filters 中没有行业代码,但本地有值,清空本地值
|
||||
setIndustryValue([]);
|
||||
logger.debug('UnifiedSearchBox', '清空行业分类(filters中无值)');
|
||||
}
|
||||
|
||||
// ✅ 同步 filters.q 到输入框显示值
|
||||
if (filters.q) {
|
||||
setInputValue(filters.q);
|
||||
} else if (!filters.q) {
|
||||
// 如果 filters 中没有搜索关键词,清空输入框
|
||||
setInputValue('');
|
||||
}
|
||||
|
||||
// ✅ 初始化时间筛选(从 filters 中恢复)
|
||||
// ⚠️ 只在 tradingTimeRange 为空时才从 filters 初始化,避免用户选择后被覆盖
|
||||
const hasTimeInFilters = filters.start_date || filters.end_date || filters.recent_days;
|
||||
|
||||
if (hasTimeInFilters && (!tradingTimeRange || !tradingTimeRange.key)) {
|
||||
// 根据参数推断按钮 key
|
||||
let inferredKey = 'custom';
|
||||
let inferredLabel = '';
|
||||
|
||||
if (filters.recent_days) {
|
||||
// 推断是否是预设按钮
|
||||
if (filters.recent_days === '7') {
|
||||
inferredKey = 'week';
|
||||
inferredLabel = '近一周';
|
||||
} else if (filters.recent_days === '30') {
|
||||
inferredKey = 'month';
|
||||
inferredLabel = '近一月';
|
||||
} else {
|
||||
inferredLabel = `近${filters.recent_days}天`;
|
||||
}
|
||||
} else if (filters.start_date && filters.end_date) {
|
||||
inferredLabel = `${dayjs(filters.start_date).format('MM-DD HH:mm')} - ${dayjs(filters.end_date).format('MM-DD HH:mm')}`;
|
||||
}
|
||||
|
||||
// 从 filters 重建 tradingTimeRange 状态
|
||||
const timeRange = {
|
||||
start_date: filters.start_date || '',
|
||||
end_date: filters.end_date || '',
|
||||
recent_days: filters.recent_days || '',
|
||||
label: inferredLabel,
|
||||
key: inferredKey
|
||||
};
|
||||
setTradingTimeRange(timeRange);
|
||||
logger.debug('UnifiedSearchBox', '初始化时间筛选', {
|
||||
filters_time: {
|
||||
start_date: filters.start_date,
|
||||
end_date: filters.end_date,
|
||||
recent_days: filters.recent_days
|
||||
},
|
||||
tradingTimeRange: timeRange
|
||||
});
|
||||
} else if (!hasTimeInFilters && tradingTimeRange) {
|
||||
// 如果 filters 中没有时间参数,但本地有值,清空本地值
|
||||
setTradingTimeRange(null);
|
||||
logger.debug('UnifiedSearchBox', '清空时间筛选(filters中无值)');
|
||||
}
|
||||
}, [filters.sort, filters.importance, filters.industry_code, filters.q, filters.start_date, filters.end_date, filters.recent_days, industryData, findIndustryPath, industryValue, tradingTimeRange]);
|
||||
|
||||
// AutoComplete 搜索股票(模糊匹配 code 或 name)
|
||||
const handleSearch = (value) => {
|
||||
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
|
||||
});
|
||||
|
||||
// 🎯 追踪股票点击
|
||||
if (trackingFunctions.trackRelatedStockClicked) {
|
||||
trackingFunctions.trackRelatedStockClicked({
|
||||
stockCode: stockInfo.code,
|
||||
stockName: stockInfo.name,
|
||||
source: 'search_box_autocomplete',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 更新输入框显示
|
||||
setInputValue(`${stockInfo.code} ${stockInfo.name}`);
|
||||
|
||||
// 直接构建参数并触发搜索 - 使用股票代码作为 q 参数
|
||||
const params = buildFilterParams({
|
||||
q: stockInfo.code, // 使用股票代码作为搜索关键词
|
||||
industry_code: ''
|
||||
});
|
||||
logger.debug('UnifiedSearchBox', '自动触发股票搜索', params);
|
||||
triggerSearch(params);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 重要性变化(立即执行)- 支持多选
|
||||
const handleImportanceChange = (value) => {
|
||||
logger.debug('UnifiedSearchBox', '重要性值改变', {
|
||||
oldValue: importance,
|
||||
newValue: value
|
||||
});
|
||||
|
||||
setImportance(value);
|
||||
|
||||
// 取消之前的防抖搜索
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 转换为逗号分隔字符串传给后端(空数组表示"全部")
|
||||
const importanceStr = value.length === 0 ? 'all' : value.join(',');
|
||||
|
||||
// 🎯 追踪筛选操作
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'importance',
|
||||
filterValue: importanceStr,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 立即触发搜索
|
||||
const params = buildFilterParams({ importance: importanceStr });
|
||||
logger.debug('UnifiedSearchBox', '重要性改变,立即触发搜索', params);
|
||||
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
// ✅ 排序变化(立即触发搜索)
|
||||
const handleSortChange = (value) => {
|
||||
logger.debug('UnifiedSearchBox', '排序值改变', {
|
||||
oldValue: sort,
|
||||
newValue: value
|
||||
});
|
||||
setSort(value);
|
||||
|
||||
// 取消之前的防抖搜索
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 🎯 追踪排序操作
|
||||
if (trackingFunctions.trackNewsSorted) {
|
||||
trackingFunctions.trackNewsSorted({
|
||||
sortBy: value,
|
||||
previousSortBy: sort,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 立即触发搜索
|
||||
const params = buildFilterParams({ sort: value });
|
||||
logger.debug('UnifiedSearchBox', '排序改变,立即触发搜索', params);
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
// ✅ 行业分类变化(立即触发搜索)
|
||||
const handleIndustryChange = (value) => {
|
||||
logger.debug('UnifiedSearchBox', '行业分类值改变', {
|
||||
oldValue: industryValue,
|
||||
newValue: value
|
||||
});
|
||||
setIndustryValue(value);
|
||||
|
||||
// 取消之前的防抖搜索
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 🎯 追踪行业筛选
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'industry',
|
||||
filterValue: value?.[value.length - 1] || '',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 立即触发搜索
|
||||
const params = buildFilterParams({
|
||||
industry_code: value?.[value.length - 1] || ''
|
||||
});
|
||||
logger.debug('UnifiedSearchBox', '行业改变,立即触发搜索', params);
|
||||
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
// ✅ 热门概念点击处理(立即搜索,不使用防抖) - 更新输入框并触发搜索
|
||||
const handleKeywordClick = (keyword) => {
|
||||
// 更新输入框显示
|
||||
setInputValue(keyword);
|
||||
|
||||
// 立即触发搜索(取消之前的防抖)
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 🎯 追踪热门关键词点击
|
||||
if (trackingFunctions.trackNewsSearched) {
|
||||
trackingFunctions.trackNewsSearched({
|
||||
searchQuery: keyword,
|
||||
searchType: 'popular_keyword',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const params = buildFilterParams({
|
||||
q: keyword,
|
||||
industry_code: ''
|
||||
});
|
||||
logger.debug('UnifiedSearchBox', '热门概念点击,立即触发搜索', {
|
||||
keyword,
|
||||
params
|
||||
});
|
||||
triggerSearch(params);
|
||||
};
|
||||
|
||||
// ✅ 交易时段筛选变化(立即触发搜索)
|
||||
const handleTradingTimeChange = (timeConfig) => {
|
||||
if (!timeConfig) {
|
||||
// 清空筛选
|
||||
setTradingTimeRange(null);
|
||||
|
||||
// 🎯 追踪时间筛选清空
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'time_range',
|
||||
filterValue: 'cleared',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const params = buildFilterParams({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
recent_days: ''
|
||||
});
|
||||
triggerSearch(params);
|
||||
return;
|
||||
}
|
||||
|
||||
const { range, type, label, key } = timeConfig;
|
||||
let params = {};
|
||||
|
||||
if (type === 'recent_days') {
|
||||
// 近一周/近一月使用 recent_days
|
||||
params.recent_days = range;
|
||||
params.start_date = '';
|
||||
params.end_date = '';
|
||||
} else {
|
||||
// 其他使用 start_date + end_date
|
||||
params.start_date = range[0].format('YYYY-MM-DD HH:mm:ss');
|
||||
params.end_date = range[1].format('YYYY-MM-DD HH:mm:ss');
|
||||
params.recent_days = '';
|
||||
}
|
||||
|
||||
setTradingTimeRange({ ...params, label, key });
|
||||
|
||||
// 🎯 追踪时间筛选
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'time_range',
|
||||
filterValue: label,
|
||||
timeRangeType: type,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 立即触发搜索
|
||||
const searchParams = buildFilterParams({ ...params, mode });
|
||||
logger.debug('UnifiedSearchBox', '交易时段筛选变化,立即触发搜索', {
|
||||
timeConfig,
|
||||
params: searchParams
|
||||
});
|
||||
triggerSearch(searchParams);
|
||||
};
|
||||
|
||||
// 主搜索(点击搜索按钮或回车)
|
||||
const handleMainSearch = () => {
|
||||
// 取消之前的防抖
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
// 构建参数并触发搜索 - 使用用户输入作为 q 参数
|
||||
const params = buildFilterParams({
|
||||
q: inputValue, // 使用用户输入(可能是话题、股票代码、股票名称等)
|
||||
industry_code: ''
|
||||
});
|
||||
|
||||
// 🎯 追踪搜索操作
|
||||
if (trackingFunctions.trackNewsSearched && inputValue) {
|
||||
trackingFunctions.trackNewsSearched({
|
||||
searchQuery: inputValue,
|
||||
searchType: 'main_search',
|
||||
filters: params,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
industryValue,
|
||||
'filters.q': filters.q,
|
||||
mode,
|
||||
pageSize
|
||||
}
|
||||
});
|
||||
|
||||
// 处理排序参数 - 将 returns_avg/returns_week 转换为 sort=returns + return_type
|
||||
const sortValue = overrides.sort ?? sort;
|
||||
let actualSort = sortValue;
|
||||
let returnType;
|
||||
|
||||
if (sortValue === 'returns_avg') {
|
||||
actualSort = 'returns';
|
||||
returnType = 'avg';
|
||||
} else if (sortValue === 'returns_week') {
|
||||
actualSort = 'returns';
|
||||
returnType = 'week';
|
||||
}
|
||||
|
||||
// 处理重要性参数:数组转换为逗号分隔字符串
|
||||
let importanceValue = overrides.importance ?? importance;
|
||||
if (Array.isArray(importanceValue)) {
|
||||
importanceValue = importanceValue.length === 0
|
||||
? 'all'
|
||||
: importanceValue.join(',');
|
||||
}
|
||||
|
||||
const result = {
|
||||
// 基础参数(overrides 优先级高于本地状态)
|
||||
sort: actualSort,
|
||||
importance: importanceValue,
|
||||
|
||||
|
||||
// 搜索参数: 统一使用 q 参数进行搜索(话题/股票/关键词)
|
||||
q: (overrides.q ?? filters.q) ?? '',
|
||||
// 行业代码: 取选中路径的最后一级(最具体的行业代码)
|
||||
industry_code: overrides.industry_code ?? (industryValue?.[industryValue.length - 1] || ''),
|
||||
|
||||
// 交易时段筛选参数
|
||||
start_date: overrides.start_date ?? (tradingTimeRange?.start_date || ''),
|
||||
end_date: overrides.end_date ?? (tradingTimeRange?.end_date || ''),
|
||||
recent_days: overrides.recent_days ?? (tradingTimeRange?.recent_days || ''),
|
||||
|
||||
// 最终 overrides 具有最高优先级
|
||||
...overrides,
|
||||
page: 1,
|
||||
per_page: overrides.mode === 'four-row' ? 30: 10
|
||||
};
|
||||
|
||||
// 删除可能来自 overrides 的旧 per_page 值(将由 pageSize 重新设置)
|
||||
delete result.per_page;
|
||||
|
||||
// 添加 return_type 参数(如果需要)
|
||||
if (returnType) {
|
||||
result.return_type = returnType;
|
||||
}
|
||||
|
||||
// 添加 mode 和 per_page 参数(如果提供了的话)
|
||||
if (mode !== undefined && mode !== null) {
|
||||
result.mode = mode;
|
||||
}
|
||||
if (pageSize !== undefined && pageSize !== null) {
|
||||
result.per_page = pageSize; // 后端实际使用的参数
|
||||
}
|
||||
|
||||
logger.debug('UnifiedSearchBox', '🔧 buildFilterParams - 输出结果', result);
|
||||
return result;
|
||||
}, [sort, importance, filters.q, industryValue, tradingTimeRange, mode, pageSize]);
|
||||
|
||||
// ✅ 重置筛选 - 清空所有筛选器并触发搜索
|
||||
const handleReset = () => {
|
||||
console.log('%c🔄 [重置] 开始重置筛选条件', 'color: #FF4D4F; font-weight: bold;');
|
||||
|
||||
// 重置所有筛选器状态
|
||||
setInputValue(''); // 清空输入框
|
||||
setStockOptions([]);
|
||||
setIndustryValue([]);
|
||||
setSort('new');
|
||||
setImportance([]); // 改为空数组
|
||||
setTradingTimeRange(null); // 清空交易时段筛选
|
||||
|
||||
// 🎯 追踪筛选重置
|
||||
if (trackingFunctions.trackNewsFilterApplied) {
|
||||
trackingFunctions.trackNewsFilterApplied({
|
||||
filterType: 'reset',
|
||||
filterValue: 'all_filters_cleared',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 输出重置后的完整参数
|
||||
const resetParams = {
|
||||
q: '',
|
||||
industry_code: '',
|
||||
sort: 'new',
|
||||
importance: 'all', // 传给后端时转为'all'
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
recent_days: '',
|
||||
page: 1,
|
||||
_forceRefresh: Date.now() // 添加强制刷新标志,确保每次重置都触发更新
|
||||
};
|
||||
|
||||
console.log('%c🔄 [重置] 重置参数', 'color: #FF4D4F;', resetParams);
|
||||
logger.debug('UnifiedSearchBox', '重置筛选', resetParams);
|
||||
|
||||
console.log('%c🔄 [重置] 调用 onSearch', 'color: #FF4D4F;', typeof onSearch);
|
||||
onSearch(resetParams);
|
||||
|
||||
console.log('%c✅ [重置] 重置完成', 'color: #52C41A; font-weight: bold;');
|
||||
};
|
||||
|
||||
// 生成已选条件标签(包含所有筛选条件) - 从 filters 和本地状态读取
|
||||
const filterTags = useMemo(() => {
|
||||
const tags = [];
|
||||
|
||||
// 搜索关键词标签 - 从 filters.q 读取
|
||||
if (filters.q) {
|
||||
tags.push({ key: 'search', label: `搜索: ${filters.q}` });
|
||||
}
|
||||
|
||||
// 行业标签
|
||||
if (industryValue && industryValue.length > 0 && industryData) {
|
||||
// 递归查找每个层级的 label
|
||||
const findLabel = (code, data) => {
|
||||
for (const item of data) {
|
||||
if (code.startsWith(item.value)) {
|
||||
if (item.value === code) {
|
||||
return item.label;
|
||||
} else {
|
||||
return findLabel(code, item.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 只显示最后一级的 label
|
||||
const lastLevelCode = industryValue[industryValue.length - 1];
|
||||
const lastLevelLabel = findLabel(lastLevelCode, industryData);
|
||||
|
||||
tags.push({
|
||||
key: 'industry',
|
||||
label: `行业: ${lastLevelLabel}`
|
||||
});
|
||||
}
|
||||
|
||||
// 交易时段筛选标签
|
||||
if (tradingTimeRange?.label) {
|
||||
tags.push({
|
||||
key: 'trading_time',
|
||||
label: `时间: ${tradingTimeRange.label}`
|
||||
});
|
||||
}
|
||||
|
||||
// 重要性标签(多选合并显示为单个标签)
|
||||
if (importance && importance.length > 0) {
|
||||
const importanceMap = { 'S': '极高', 'A': '高', 'B': '中', 'C': '低' };
|
||||
const importanceLabel = importance.map(imp => importanceMap[imp] || imp).join(', ');
|
||||
tags.push({ key: 'importance', label: `重要性: ${importanceLabel}` });
|
||||
}
|
||||
|
||||
// 排序标签(排除默认值 'new')
|
||||
if (sort && sort !== 'new') {
|
||||
let sortLabel;
|
||||
if (sort === 'hot') sortLabel = '最热';
|
||||
else if (sort === 'importance') sortLabel = '重要性';
|
||||
else if (sort === 'returns_avg') sortLabel = '平均收益率';
|
||||
else if (sort === 'returns_week') sortLabel = '周收益率';
|
||||
else sortLabel = sort;
|
||||
tags.push({ key: 'sort', label: `排序: ${sortLabel}` });
|
||||
}
|
||||
|
||||
return tags;
|
||||
}, [filters.q, industryValue, importance, sort, tradingTimeRange]);
|
||||
|
||||
// ✅ 移除单个标签 - 构建新参数并触发搜索
|
||||
const handleRemoveTag = (key) => {
|
||||
logger.debug('UnifiedSearchBox', '移除标签', { key });
|
||||
|
||||
// 取消所有待执行的防抖搜索(避免旧的防抖覆盖删除操作)
|
||||
if (debouncedSearchRef.current) {
|
||||
debouncedSearchRef.current.cancel();
|
||||
}
|
||||
|
||||
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 === 'trading_time') {
|
||||
// 清除交易时段筛选
|
||||
setTradingTimeRange(null);
|
||||
const params = buildFilterParams({
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
recent_days: ''
|
||||
});
|
||||
triggerSearch(params);
|
||||
} else if (key === 'importance') {
|
||||
// 重置重要性为空数组(传给后端为'all')
|
||||
setImportance([]);
|
||||
const params = buildFilterParams({ importance: 'all' });
|
||||
triggerSearch(params);
|
||||
} else if (key === 'sort') {
|
||||
// 重置排序为默认值
|
||||
setSort('new');
|
||||
const params = buildFilterParams({ sort: 'new' });
|
||||
triggerSearch(params);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{padding: '8px'}}>
|
||||
{/* 第三行:行业 + 重要性 + 排序 */}
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} size="middle">
|
||||
{/* 左侧:筛选器组 */}
|
||||
<Space size="small" wrap>
|
||||
<span style={{ fontSize: 12, 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: 160 }}
|
||||
size="small"
|
||||
/>
|
||||
|
||||
{/* 重要性 */}
|
||||
<Space size="small">
|
||||
<span style={{ fontSize: 12, color: '#666' }}>重要性:</span>
|
||||
<AntSelect
|
||||
mode="multiple"
|
||||
value={importance}
|
||||
onChange={handleImportanceChange}
|
||||
style={{ width: 120 }}
|
||||
size="small"
|
||||
placeholder="全部"
|
||||
maxTagCount={3}
|
||||
>
|
||||
<Option value="S">极高</Option>
|
||||
<Option value="A">高</Option>
|
||||
<Option value="B">中</Option>
|
||||
<Option value="C">低</Option>
|
||||
</AntSelect>
|
||||
</Space>
|
||||
|
||||
{/* 搜索图标(可点击) + 搜索框 */}
|
||||
<Space.Compact style={{ flex: 1, minWidth: 250 }}>
|
||||
<SearchOutlined
|
||||
onClick={handleMainSearch}
|
||||
style={{
|
||||
fontSize: 14,
|
||||
padding: '5px 8px',
|
||||
background: '#e6f7ff',
|
||||
borderRadius: '6px 0 0 6px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: '#1890ff',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#096dd9';
|
||||
e.currentTarget.style.background = '#bae7ff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = '#1890ff';
|
||||
e.currentTarget.style.background = '#e6f7ff';
|
||||
}}
|
||||
/>
|
||||
<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="small"
|
||||
notFoundContent={inputValue && stockOptions.length === 0 ? "未找到匹配的股票" : null}
|
||||
/>
|
||||
</Space.Compact>
|
||||
|
||||
{/* 重置按钮 - 现代化设计 */}
|
||||
<Button
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={handleReset}
|
||||
size="small"
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
border: '1px solid #d9d9d9',
|
||||
backgroundColor: '#fff',
|
||||
color: '#666',
|
||||
fontWeight: 500,
|
||||
padding: '4px 10px',
|
||||
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: 12, color: '#666' }}>排序:</span>
|
||||
<AntSelect
|
||||
value={sort}
|
||||
onChange={handleSortChange}
|
||||
style={{ width: 100 }}
|
||||
size="small"
|
||||
>
|
||||
<Option value="new">最新</Option>
|
||||
<Option value="hot">最热</Option>
|
||||
<Option value="importance">重要性</Option>
|
||||
<Option value="returns_avg">平均收益率</Option>
|
||||
<Option value="returns_week">周收益率</Option>
|
||||
</AntSelect>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
{/* 第一行:筛选 + 时间按钮 + 搜索图标 + 搜索框 */}
|
||||
<Space wrap style={{ width: '100%', marginBottom: 4, marginTop: 6 }} size="middle">
|
||||
<span style={{ fontSize: 14, color: '#666', fontWeight: 'bold' }}>时间筛选:</span>
|
||||
|
||||
{/* 交易时段筛选 */}
|
||||
<TradingTimeFilter
|
||||
value={tradingTimeRange?.key || null}
|
||||
onChange={handleTradingTimeChange}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
{/* 第二行:热门概念 */}
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<PopularKeywords
|
||||
keywords={popularKeywords}
|
||||
onKeywordClick={handleKeywordClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedSearchBox;
|
||||
@@ -1,10 +1,12 @@
|
||||
// src/views/Community/hooks/useCommunityEvents.js
|
||||
// 新闻催化分析页面事件追踪 Hook
|
||||
// 性能优化:使用 requestIdleCallback 延迟非关键事件追踪
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { usePostHogTrack } from '@/hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '@/lib/constants';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { smartTrack } from '@/utils/trackingHelpers';
|
||||
|
||||
/**
|
||||
* 新闻催化分析(Community)事件追踪 Hook
|
||||
@@ -15,9 +17,9 @@ import { logger } from '../../../utils/logger';
|
||||
export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
const { track } = usePostHogTrack();
|
||||
|
||||
// 🎯 页面浏览事件 - 页面加载时触发
|
||||
// 🎯 页面浏览事件 - 页面加载时触发(空闲时追踪)
|
||||
useEffect(() => {
|
||||
track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
|
||||
smartTrack(track, RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
logger.debug('useCommunityEvents', '📰 Community Page Viewed');
|
||||
@@ -33,7 +35,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
* @param {string} params.industryFilter - 行业筛选
|
||||
*/
|
||||
const trackNewsListViewed = useCallback((params = {}) => {
|
||||
track(RETENTION_EVENTS.NEWS_LIST_VIEWED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_LIST_VIEWED, {
|
||||
total_count: params.totalCount || 0,
|
||||
sort_by: params.sortBy || 'new',
|
||||
importance_filter: params.importance || 'all',
|
||||
@@ -60,7 +62,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_ARTICLE_CLICKED, {
|
||||
news_id: news.id,
|
||||
news_title: news.title || '',
|
||||
importance: news.importance || 'unknown',
|
||||
@@ -90,7 +92,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_DETAIL_OPENED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_DETAIL_OPENED, {
|
||||
news_id: news.id,
|
||||
news_title: news.title || '',
|
||||
importance: news.importance || 'unknown',
|
||||
@@ -115,7 +117,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_TAB_CLICKED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_TAB_CLICKED, {
|
||||
tab_name: tabName,
|
||||
news_id: newsId,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -136,7 +138,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
* @param {string} filters.industryCode - 行业代码
|
||||
*/
|
||||
const trackNewsFilterApplied = useCallback((filters = {}) => {
|
||||
track(RETENTION_EVENTS.NEWS_FILTER_APPLIED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_FILTER_APPLIED, {
|
||||
importance: filters.importance || 'all',
|
||||
date_range: filters.dateRange || 'all',
|
||||
industry_classification: filters.industryClassification || 'all',
|
||||
@@ -159,7 +161,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.NEWS_SORTED, {
|
||||
smartTrack(track, RETENTION_EVENTS.NEWS_SORTED, {
|
||||
sort_by: sortBy,
|
||||
previous_sort: previousSort,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -179,7 +181,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
const trackNewsSearched = useCallback((query, resultCount = 0) => {
|
||||
if (!query) return;
|
||||
|
||||
track(RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
||||
smartTrack(track, RETENTION_EVENTS.SEARCH_QUERY_SUBMITTED, {
|
||||
query,
|
||||
result_count: resultCount,
|
||||
has_results: resultCount > 0,
|
||||
@@ -187,9 +189,9 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 如果没有搜索结果,额外追踪
|
||||
// 如果没有搜索结果,额外追踪(高优先级,立即发送)
|
||||
if (resultCount === 0) {
|
||||
track(RETENTION_EVENTS.SEARCH_NO_RESULTS, {
|
||||
smartTrack(track, RETENTION_EVENTS.SEARCH_NO_RESULTS, {
|
||||
query,
|
||||
context: 'community_news',
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -215,7 +217,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.STOCK_CLICKED, {
|
||||
smartTrack(track, RETENTION_EVENTS.STOCK_CLICKED, {
|
||||
stock_code: stock.code,
|
||||
stock_name: stock.name || '',
|
||||
source: 'news_related_stocks',
|
||||
@@ -242,7 +244,7 @@ export const useCommunityEvents = ({ navigate } = {}) => {
|
||||
return;
|
||||
}
|
||||
|
||||
track(RETENTION_EVENTS.CONCEPT_CLICKED, {
|
||||
smartTrack(track, RETENTION_EVENTS.CONCEPT_CLICKED, {
|
||||
concept_code: concept.code,
|
||||
concept_name: concept.name || '',
|
||||
source: 'news_related_concepts',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
fetchPopularKeywords,
|
||||
fetchHotEvents
|
||||
} from '../../store/slices/communityDataSlice';
|
||||
} from '@/store/slices/communityDataSlice';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
@@ -32,9 +32,10 @@ import { useEventData } from './hooks/useEventData';
|
||||
import { useEventFilters } from './hooks/useEventFilters';
|
||||
import { useCommunityEvents } from './hooks/useCommunityEvents';
|
||||
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { PROFESSIONAL_COLORS } from '../../constants/professionalTheme';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { useNotification } from '@/contexts/NotificationContext';
|
||||
import { PROFESSIONAL_COLORS } from '@/constants/professionalTheme';
|
||||
import { flushPendingEventsBeforeUnload } from '@/utils/trackingHelpers';
|
||||
|
||||
// 导航栏已由 MainLayout 提供,无需在此导入
|
||||
|
||||
@@ -96,6 +97,15 @@ const Community = () => {
|
||||
dispatch(fetchHotEvents());
|
||||
}, [dispatch]);
|
||||
|
||||
// ⚡ 页面卸载前刷新待发送的 PostHog 事件(性能优化)
|
||||
useEffect(() => {
|
||||
window.addEventListener('beforeunload', flushPendingEventsBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', flushPendingEventsBeforeUnload);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 🎯 追踪新闻列表查看(当事件列表加载完成后)
|
||||
useEffect(() => {
|
||||
if (events && events.length > 0 && !loading) {
|
||||
|
||||
504
src/views/Dashboard/components/CalendarPanel.tsx
Normal file
504
src/views/Dashboard/components/CalendarPanel.tsx
Normal file
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* CalendarPanel - 投资日历面板组件
|
||||
* 使用 FullCalendar 展示投资计划、复盘等事件
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Tooltip,
|
||||
Icon,
|
||||
Input,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiStar,
|
||||
FiTrendingUp,
|
||||
} from 'react-icons/fi';
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import { DateClickArg } from '@fullcalendar/interaction';
|
||||
import { EventClickArg } from '@fullcalendar/common';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import type { InvestmentEvent, EventType } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 新事件表单数据类型
|
||||
*/
|
||||
interface NewEventForm {
|
||||
title: string;
|
||||
description: string;
|
||||
type: EventType;
|
||||
importance: number;
|
||||
stocks: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* FullCalendar 事件类型
|
||||
*/
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
date: string;
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
extendedProps: InvestmentEvent & {
|
||||
isSystem: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CalendarPanel 组件
|
||||
* 日历视图面板,显示所有投资事件
|
||||
*/
|
||||
export const CalendarPanel: React.FC = () => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
setActiveTab,
|
||||
toast,
|
||||
borderColor,
|
||||
secondaryText,
|
||||
} = usePlanningData();
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen: isAddOpen, onOpen: onAddOpen, onClose: onAddClose } = useDisclosure();
|
||||
|
||||
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(null);
|
||||
const [selectedDateEvents, setSelectedDateEvents] = useState<InvestmentEvent[]>([]);
|
||||
const [newEvent, setNewEvent] = useState<NewEventForm>({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
|
||||
// 转换数据为 FullCalendar 格式
|
||||
const calendarEvents: CalendarEvent[] = allEvents.map(event => ({
|
||||
...event,
|
||||
id: `${event.source || 'user'}-${event.id}`,
|
||||
title: event.title,
|
||||
start: event.event_date,
|
||||
date: event.event_date,
|
||||
backgroundColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
|
||||
borderColor: event.source === 'future' ? '#3182CE' : event.type === 'plan' ? '#8B5CF6' : '#38A169',
|
||||
extendedProps: {
|
||||
...event,
|
||||
isSystem: event.source === 'future',
|
||||
}
|
||||
}));
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = (info: DateClickArg): void => {
|
||||
const clickedDate = dayjs(info.date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(event =>
|
||||
dayjs(event.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 处理事件点击
|
||||
const handleEventClick = (info: EventClickArg): void => {
|
||||
const event = info.event;
|
||||
const clickedDate = dayjs(event.start);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(ev =>
|
||||
dayjs(ev.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 添加新事件
|
||||
const handleAddEvent = async (): Promise<void> => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
|
||||
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
|
||||
};
|
||||
|
||||
const response = await fetch(base + '/api/account/calendar/events', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(eventData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
logger.info('CalendarPanel', '添加事件成功', {
|
||||
eventTitle: eventData.title,
|
||||
eventDate: eventData.event_date
|
||||
});
|
||||
toast({
|
||||
title: '添加成功',
|
||||
description: '投资计划已添加',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
onAddClose();
|
||||
loadAllData();
|
||||
setNewEvent({
|
||||
title: '',
|
||||
description: '',
|
||||
type: 'plan',
|
||||
importance: 3,
|
||||
stocks: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('CalendarPanel', 'handleAddEvent', error, {
|
||||
eventTitle: newEvent?.title
|
||||
});
|
||||
toast({
|
||||
title: '添加失败',
|
||||
description: '无法添加投资计划',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除事件
|
||||
const handleDeleteEvent = async (eventId: number): Promise<void> => {
|
||||
if (!eventId) {
|
||||
logger.warn('CalendarPanel', '删除事件失败: 缺少事件 ID', { eventId });
|
||||
toast({
|
||||
title: '无法删除',
|
||||
description: '缺少事件 ID',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/calendar/events/${eventId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('CalendarPanel', '删除事件成功', { eventId });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadAllData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('CalendarPanel', 'handleDeleteEvent', error, { eventId });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转到计划或复盘标签页
|
||||
const handleViewDetails = (event: InvestmentEvent): void => {
|
||||
if (event.type === 'plan') {
|
||||
setActiveTab(1); // 跳转到"我的计划"标签页
|
||||
} else if (event.type === 'review') {
|
||||
setActiveTab(2); // 跳转到"我的复盘"标签页
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Flex justify="flex-end" mb={4}>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => {
|
||||
if (!selectedDate) setSelectedDate(dayjs());
|
||||
onAddOpen();
|
||||
}}
|
||||
>
|
||||
添加计划
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center h="560px">
|
||||
<Spinner size="xl" color="purple.500" />
|
||||
</Center>
|
||||
) : (
|
||||
<Box height={{ base: '500px', md: '600px' }}>
|
||||
<FullCalendar
|
||||
plugins={[dayGridPlugin, interactionPlugin]}
|
||||
initialView="dayGridMonth"
|
||||
locale="zh-cn"
|
||||
headerToolbar={{
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: ''
|
||||
}}
|
||||
events={calendarEvents}
|
||||
dateClick={handleDateClick}
|
||||
eventClick={handleEventClick}
|
||||
height="100%"
|
||||
dayMaxEvents={3}
|
||||
moreLinkText="更多"
|
||||
buttonText={{
|
||||
today: '今天',
|
||||
month: '月',
|
||||
week: '周'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 查看事件详情 Modal */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{selectedDate && selectedDate.format('YYYY年MM月DD日')} 的事件
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
{selectedDateEvents.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack>
|
||||
<Text color={secondaryText}>当天没有事件</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onAddOpen();
|
||||
}}
|
||||
>
|
||||
添加投资计划
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
{selectedDateEvents.map((event, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
p={4}
|
||||
borderRadius="md"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<Flex justify="space-between" align="start" mb={2}>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{event.title}
|
||||
</Text>
|
||||
{event.source === 'future' ? (
|
||||
<Badge colorScheme="blue" variant="subtle">系统事件</Badge>
|
||||
) : event.type === 'plan' ? (
|
||||
<Badge colorScheme="purple" variant="subtle">我的计划</Badge>
|
||||
) : (
|
||||
<Badge colorScheme="green" variant="subtle">我的复盘</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
{event.importance && (
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiStar} color="yellow.500" />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
重要度: {event.importance}/5
|
||||
</Text>
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
<HStack>
|
||||
{!event.source || event.source === 'user' ? (
|
||||
<>
|
||||
<Tooltip label="查看详情">
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="blue"
|
||||
onClick={() => handleViewDetails(event)}
|
||||
aria-label="查看详情"
|
||||
/>
|
||||
</Tooltip>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDeleteEvent(event.id)}
|
||||
aria-label="删除事件"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{event.description && (
|
||||
<Text fontSize="sm" color={secondaryText} mb={2}>
|
||||
{event.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{event.stocks && event.stocks.length > 0 && (
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
<Text fontSize="sm" color={secondaryText}>相关股票:</Text>
|
||||
{event.stocks.map((stock, i) => (
|
||||
<Tag key={i} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* 添加投资计划 Modal */}
|
||||
{isAddOpen && (
|
||||
<Modal isOpen={isAddOpen} onClose={onAddClose} size="lg" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
添加投资计划
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={newEvent.title}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, title: e.target.value })}
|
||||
placeholder="例如:关注半导体板块"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>描述</FormLabel>
|
||||
<Textarea
|
||||
value={newEvent.description}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, description: e.target.value })}
|
||||
placeholder="详细描述您的投资计划..."
|
||||
rows={3}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>类型</FormLabel>
|
||||
<Select
|
||||
value={newEvent.type}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, type: e.target.value as EventType })}
|
||||
>
|
||||
<option value="plan">投资计划</option>
|
||||
<option value="review">投资复盘</option>
|
||||
<option value="reminder">提醒事项</option>
|
||||
<option value="analysis">分析任务</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>重要度</FormLabel>
|
||||
<Select
|
||||
value={newEvent.importance}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, importance: parseInt(e.target.value) })}
|
||||
>
|
||||
<option value={5}>⭐⭐⭐⭐⭐ 非常重要</option>
|
||||
<option value={4}>⭐⭐⭐⭐ 重要</option>
|
||||
<option value={3}>⭐⭐⭐ 一般</option>
|
||||
<option value={2}>⭐⭐ 次要</option>
|
||||
<option value={1}>⭐ 不重要</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票(用逗号分隔)</FormLabel>
|
||||
<Input
|
||||
value={newEvent.stocks}
|
||||
onChange={(e) => setNewEvent({ ...newEvent, stocks: e.target.value })}
|
||||
placeholder="例如:600519,000858,002415"
|
||||
/>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onAddClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="purple"
|
||||
onClick={handleAddEvent}
|
||||
isDisabled={!newEvent.title}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -52,13 +52,13 @@ import {
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import './InvestmentCalendar.css';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
export default function InvestmentCalendarChakra() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
@@ -140,12 +140,12 @@ export default function InvestmentCalendarChakra() {
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = (info) => {
|
||||
const clickedDate = moment(info.date);
|
||||
const clickedDate = dayjs(info.date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
// 筛选当天的事件
|
||||
const dayEvents = events.filter(event =>
|
||||
moment(event.start).isSame(clickedDate, 'day')
|
||||
dayjs(event.start).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
@@ -154,7 +154,7 @@ export default function InvestmentCalendarChakra() {
|
||||
// 处理事件点击
|
||||
const handleEventClick = (info) => {
|
||||
const event = info.event;
|
||||
const clickedDate = moment(event.start);
|
||||
const clickedDate = dayjs(event.start);
|
||||
setSelectedDate(clickedDate);
|
||||
setSelectedDateEvents([{
|
||||
title: event.title,
|
||||
@@ -173,7 +173,7 @@ export default function InvestmentCalendarChakra() {
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : moment().format('YYYY-MM-DD')),
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
|
||||
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
|
||||
};
|
||||
|
||||
@@ -274,7 +274,7 @@ export default function InvestmentCalendarChakra() {
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(moment()); onAddOpen(); }}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(dayjs()); onAddOpen(); }}
|
||||
>
|
||||
添加计划
|
||||
</Button>
|
||||
|
||||
@@ -66,13 +66,13 @@ import {
|
||||
import FullCalendar from '@fullcalendar/react';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
import '../components/InvestmentCalendar.css';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
// 创建 Context 用于跨标签页共享数据
|
||||
const PlanningDataContext = createContext();
|
||||
@@ -232,11 +232,11 @@ function CalendarPanel() {
|
||||
|
||||
// 处理日期点击
|
||||
const handleDateClick = (info) => {
|
||||
const clickedDate = moment(info.date);
|
||||
const clickedDate = dayjs(info.date);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(event =>
|
||||
moment(event.event_date).isSame(clickedDate, 'day')
|
||||
dayjs(event.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
@@ -245,11 +245,11 @@ function CalendarPanel() {
|
||||
// 处理事件点击
|
||||
const handleEventClick = (info) => {
|
||||
const event = info.event;
|
||||
const clickedDate = moment(event.start);
|
||||
const clickedDate = dayjs(event.start);
|
||||
setSelectedDate(clickedDate);
|
||||
|
||||
const dayEvents = allEvents.filter(ev =>
|
||||
moment(ev.event_date).isSame(clickedDate, 'day')
|
||||
dayjs(ev.event_date).isSame(clickedDate, 'day')
|
||||
);
|
||||
setSelectedDateEvents(dayEvents);
|
||||
onOpen();
|
||||
@@ -262,7 +262,7 @@ function CalendarPanel() {
|
||||
|
||||
const eventData = {
|
||||
...newEvent,
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : moment().format('YYYY-MM-DD')),
|
||||
event_date: (selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD')),
|
||||
stocks: newEvent.stocks.split(',').map(s => s.trim()).filter(s => s),
|
||||
};
|
||||
|
||||
@@ -368,7 +368,7 @@ function CalendarPanel() {
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(moment()); onAddOpen(); }}
|
||||
onClick={() => { if (!selectedDate) setSelectedDate(dayjs()); onAddOpen(); }}
|
||||
>
|
||||
添加计划
|
||||
</Button>
|
||||
@@ -619,7 +619,7 @@ function PlansPanel() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
@@ -638,13 +638,13 @@ function PlansPanel() {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...item,
|
||||
date: moment(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
content: item.description || item.content || '',
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
@@ -795,7 +795,7 @@ function PlansPanel() {
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{moment(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
@@ -1043,7 +1043,7 @@ function ReviewsPanel() {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'review',
|
||||
@@ -1062,13 +1062,13 @@ function ReviewsPanel() {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...item,
|
||||
date: moment(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
content: item.description || item.content || '',
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'review',
|
||||
@@ -1205,7 +1205,7 @@ function ReviewsPanel() {
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{moment(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
</HStack>
|
||||
</VStack>
|
||||
203
src/views/Dashboard/components/InvestmentPlanningCenter.tsx
Normal file
203
src/views/Dashboard/components/InvestmentPlanningCenter.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* InvestmentPlanningCenter - 投资规划中心主组件 (TypeScript 重构版)
|
||||
*
|
||||
* 性能优化:
|
||||
* - 使用 React.lazy() 懒加载子面板,减少初始加载时间
|
||||
* - 从 1421 行拆分为 5 个独立模块,提升可维护性
|
||||
* - 使用 TypeScript 提供类型安全
|
||||
*
|
||||
* 组件架构:
|
||||
* - InvestmentPlanningCenter (主组件,~200 行)
|
||||
* - CalendarPanel (日历面板,懒加载)
|
||||
* - PlansPanel (计划面板,懒加载)
|
||||
* - ReviewsPanel (复盘面板,懒加载)
|
||||
* - PlanningContext (数据共享层)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, Suspense, lazy } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardBody,
|
||||
Heading,
|
||||
HStack,
|
||||
Flex,
|
||||
Icon,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Spinner,
|
||||
Center,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiCalendar,
|
||||
FiTarget,
|
||||
FiFileText,
|
||||
} from 'react-icons/fi';
|
||||
|
||||
import { PlanningDataProvider } from './PlanningContext';
|
||||
import type { InvestmentEvent, PlanningContextValue } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
import './InvestmentCalendar.css';
|
||||
|
||||
// 懒加载子面板组件(实现代码分割)
|
||||
const CalendarPanel = lazy(() =>
|
||||
import('./CalendarPanel').then(module => ({ default: module.CalendarPanel }))
|
||||
);
|
||||
const PlansPanel = lazy(() =>
|
||||
import('./PlansPanel').then(module => ({ default: module.PlansPanel }))
|
||||
);
|
||||
const ReviewsPanel = lazy(() =>
|
||||
import('./ReviewsPanel').then(module => ({ default: module.ReviewsPanel }))
|
||||
);
|
||||
|
||||
/**
|
||||
* 面板加载占位符
|
||||
*/
|
||||
const PanelLoadingFallback: React.FC = () => (
|
||||
<Center py={12}>
|
||||
<Spinner size="xl" color="purple.500" thickness="4px" />
|
||||
</Center>
|
||||
);
|
||||
|
||||
/**
|
||||
* InvestmentPlanningCenter 主组件
|
||||
*/
|
||||
const InvestmentPlanningCenter: React.FC = () => {
|
||||
const toast = useToast();
|
||||
|
||||
// 颜色主题
|
||||
const bgColor = useColorModeValue('white', 'gray.800');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const secondaryText = useColorModeValue('gray.600', 'gray.400');
|
||||
const cardBg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
// 全局数据状态
|
||||
const [allEvents, setAllEvents] = useState<InvestmentEvent[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [activeTab, setActiveTab] = useState<number>(0);
|
||||
|
||||
/**
|
||||
* 加载所有事件数据(日历事件 + 计划 + 复盘)
|
||||
*/
|
||||
const loadAllData = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + '/api/account/calendar/events', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setAllEvents(data.data || []);
|
||||
logger.debug('InvestmentPlanningCenter', '数据加载成功', {
|
||||
count: data.data?.length || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('InvestmentPlanningCenter', 'loadAllData', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 组件挂载时加载数据
|
||||
useEffect(() => {
|
||||
loadAllData();
|
||||
}, [loadAllData]);
|
||||
|
||||
// 提供给子组件的 Context 值
|
||||
const contextValue: PlanningContextValue = {
|
||||
allEvents,
|
||||
setAllEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
setLoading,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
toast,
|
||||
bgColor,
|
||||
borderColor,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
};
|
||||
|
||||
// 计算各类型事件数量
|
||||
const planCount = allEvents.filter(e => e.type === 'plan').length;
|
||||
const reviewCount = allEvents.filter(e => e.type === 'review').length;
|
||||
|
||||
return (
|
||||
<PlanningDataProvider value={contextValue}>
|
||||
<Card bg={bgColor} shadow="md">
|
||||
<CardHeader pb={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<HStack>
|
||||
<Icon as={FiTarget} color="purple.500" boxSize={5} />
|
||||
<Heading size="md">投资规划中心</Heading>
|
||||
</HStack>
|
||||
</Flex>
|
||||
</CardHeader>
|
||||
<CardBody pt={0}>
|
||||
<Tabs
|
||||
index={activeTab}
|
||||
onChange={setActiveTab}
|
||||
variant="enclosed"
|
||||
colorScheme="purple"
|
||||
>
|
||||
<TabList>
|
||||
<Tab>
|
||||
<Icon as={FiCalendar} mr={2} />
|
||||
日历视图
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FiTarget} mr={2} />
|
||||
我的计划 ({planCount})
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={FiFileText} mr={2} />
|
||||
我的复盘 ({reviewCount})
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 日历视图面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<CalendarPanel />
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
|
||||
{/* 计划列表面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<PlansPanel />
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
|
||||
{/* 复盘列表面板 */}
|
||||
<TabPanel px={0}>
|
||||
<Suspense fallback={<PanelLoadingFallback />}>
|
||||
<ReviewsPanel />
|
||||
</Suspense>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</PlanningDataProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvestmentPlanningCenter;
|
||||
@@ -60,12 +60,12 @@ import {
|
||||
FiXCircle,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
import moment from 'moment';
|
||||
import 'moment/locale/zh-cn';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { getApiBase } from '../../../utils/apiConfig';
|
||||
|
||||
moment.locale('zh-cn');
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
@@ -83,7 +83,7 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
@@ -134,12 +134,12 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
...item,
|
||||
date: moment(item.date).format('YYYY-MM-DD'),
|
||||
date: dayjs(item.date).format('YYYY-MM-DD'),
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: moment().format('YYYY-MM-DD'),
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: itemType,
|
||||
@@ -291,7 +291,7 @@ export default function InvestmentPlansAndReviews({ type = 'both' }) {
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{moment(item.date).format('YYYY年MM月DD日')}
|
||||
{dayjs(item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from 'react-icons/fi';
|
||||
import { eventService } from '../../../services/eventService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export default function MyFutureEvents({ limit = 5 }) {
|
||||
const [futureEvents, setFutureEvents] = useState([]);
|
||||
@@ -51,7 +51,7 @@ export default function MyFutureEvents({ limit = 5 }) {
|
||||
if (response.success) {
|
||||
// 按时间排序,最近的在前
|
||||
const sortedEvents = (response.data || []).sort((a, b) =>
|
||||
moment(a.calendar_time).valueOf() - moment(b.calendar_time).valueOf()
|
||||
dayjs(a.calendar_time).valueOf() - dayjs(b.calendar_time).valueOf()
|
||||
);
|
||||
setFutureEvents(sortedEvents);
|
||||
logger.debug('MyFutureEvents', '未来事件加载成功', {
|
||||
@@ -98,8 +98,8 @@ export default function MyFutureEvents({ limit = 5 }) {
|
||||
|
||||
// 格式化时间
|
||||
const formatEventTime = (time) => {
|
||||
const eventTime = moment(time);
|
||||
const now = moment();
|
||||
const eventTime = dayjs(time);
|
||||
const now = dayjs();
|
||||
const daysDiff = eventTime.diff(now, 'days');
|
||||
|
||||
if (daysDiff === 0) {
|
||||
|
||||
60
src/views/Dashboard/components/PlanningContext.tsx
Normal file
60
src/views/Dashboard/components/PlanningContext.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* InvestmentPlanningCenter Context
|
||||
* 用于在日历、计划、复盘三个面板间共享数据和状态
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import type { PlanningContextValue } from '@/types';
|
||||
|
||||
/**
|
||||
* Planning Data Context
|
||||
* 提供投资规划数据和操作方法
|
||||
*/
|
||||
const PlanningDataContext = createContext<PlanningContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* PlanningDataProvider Props
|
||||
*/
|
||||
interface PlanningDataProviderProps {
|
||||
/** Context 值 */
|
||||
value: PlanningContextValue;
|
||||
/** 子组件 */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* PlanningDataProvider 组件
|
||||
* 包裹需要访问投资规划数据的组件
|
||||
*/
|
||||
export const PlanningDataProvider: React.FC<PlanningDataProviderProps> = ({ value, children }) => {
|
||||
return (
|
||||
<PlanningDataContext.Provider value={value}>
|
||||
{children}
|
||||
</PlanningDataContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* usePlanningData Hook
|
||||
* 在子组件中访问投资规划数据
|
||||
*
|
||||
* @throws {Error} 如果在 PlanningDataProvider 外部调用
|
||||
* @returns {PlanningContextValue} Context 值
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function CalendarPanel() {
|
||||
* const { allEvents, loading, toast } = usePlanningData();
|
||||
* // ...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const usePlanningData = (): PlanningContextValue => {
|
||||
const context = useContext(PlanningDataContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('usePlanningData 必须在 PlanningDataProvider 内部使用');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
506
src/views/Dashboard/components/PlansPanel.tsx
Normal file
506
src/views/Dashboard/components/PlansPanel.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* PlansPanel - 投资计划列表面板组件
|
||||
* 显示、编辑和管理投资计划
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Grid,
|
||||
Card,
|
||||
CardBody,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Icon,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
TagCloseButton,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiSave,
|
||||
FiTarget,
|
||||
FiCalendar,
|
||||
FiTrendingUp,
|
||||
FiHash,
|
||||
FiCheckCircle,
|
||||
FiXCircle,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import type { InvestmentEvent, PlanFormData, EventStatus } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 状态信息接口
|
||||
*/
|
||||
interface StatusInfo {
|
||||
icon: React.ComponentType;
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PlansPanel 组件
|
||||
* 计划列表面板,显示所有投资计划
|
||||
*/
|
||||
export const PlansPanel: React.FC = () => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
toast,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
borderColor,
|
||||
} = usePlanningData();
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editingItem, setEditingItem] = useState<InvestmentEvent | null>(null);
|
||||
const [formData, setFormData] = useState<PlanFormData>({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
const [stockInput, setStockInput] = useState<string>('');
|
||||
const [tagInput, setTagInput] = useState<string>('');
|
||||
|
||||
// 筛选计划列表(排除系统事件)
|
||||
const plans = allEvents.filter(event => event.type === 'plan' && event.source !== 'future');
|
||||
|
||||
// 打开编辑/新建模态框
|
||||
const handleOpenModal = (item: InvestmentEvent | null = null): void => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
title: item.title,
|
||||
content: item.description || item.content || '',
|
||||
type: 'plan',
|
||||
stocks: item.stocks || [],
|
||||
tags: item.tags || [],
|
||||
status: item.status || 'active',
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'plan',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 保存数据
|
||||
const handleSave = async (): Promise<void> => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const url = editingItem
|
||||
? base + `/api/account/investment-plans/${editingItem.id}`
|
||||
: base + '/api/account/investment-plans';
|
||||
|
||||
const method = editingItem ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('PlansPanel', `${editingItem ? '更新' : '创建'}成功`, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData.title,
|
||||
});
|
||||
toast({
|
||||
title: editingItem ? '更新成功' : '创建成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
onClose();
|
||||
loadAllData();
|
||||
} else {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PlansPanel', 'handleSave', error, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData?.title
|
||||
});
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: '无法保存数据',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
const handleDelete = async (id: number): Promise<void> => {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('PlansPanel', '删除成功', { itemId: id });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadAllData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PlansPanel', 'handleDelete', error, { itemId: id });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 添加股票
|
||||
const handleAddStock = (): void => {
|
||||
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
stocks: [...formData.stocks, stockInput.trim()],
|
||||
});
|
||||
setStockInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 添加标签
|
||||
const handleAddTag = (): void => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: [...formData.tags, tagInput.trim()],
|
||||
});
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态信息
|
||||
const getStatusInfo = (status?: EventStatus): StatusInfo => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return { icon: FiCheckCircle, color: 'green', text: '已完成' };
|
||||
case 'cancelled':
|
||||
return { icon: FiXCircle, color: 'red', text: '已取消' };
|
||||
default:
|
||||
return { icon: FiAlertCircle, color: 'blue', text: '进行中' };
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单个卡片
|
||||
const renderCard = (item: InvestmentEvent): JSX.Element => {
|
||||
const statusInfo = getStatusInfo(item.status);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
bg={cardBg}
|
||||
shadow="sm"
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Icon as={FiTarget} color="purple.500" />
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{item.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
variant="subtle"
|
||||
>
|
||||
{statusInfo.text}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenModal(item)}
|
||||
aria-label="编辑计划"
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
aria-label="删除计划"
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{(item.content || item.description) && (
|
||||
<Text fontSize="sm" color={textColor} noOfLines={3}>
|
||||
{item.content || item.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{item.stocks && item.stocks.length > 0 && (
|
||||
<>
|
||||
{item.stocks.map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<>
|
||||
{item.tags.map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="purple" variant="subtle">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null)}
|
||||
>
|
||||
新建计划
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color="purple.500" />
|
||||
</Center>
|
||||
) : plans.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiTarget} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText}>暂无投资计划</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="purple"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null)}
|
||||
>
|
||||
创建第一个计划
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||
{plans.map(renderCard)}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* 编辑/新建模态框 */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{editingItem ? '编辑' : '新建'}投资计划
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>日期</FormLabel>
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Icon as={FiCalendar} color={secondaryText} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="例如:布局新能源板块"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>内容</FormLabel>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder="详细描述您的投资计划..."
|
||||
rows={6}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={stockInput}
|
||||
onChange={(e) => setStockInput(e.target.value)}
|
||||
placeholder="输入股票代码"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
|
||||
/>
|
||||
<Button onClick={handleAddStock}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{(formData.stocks || []).map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
stocks: formData.stocks.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>标签</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="输入标签"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
|
||||
/>
|
||||
<Button onClick={handleAddTag}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{(formData.tags || []).map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="purple">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
tags: formData.tags.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>状态</FormLabel>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as EventStatus })}
|
||||
>
|
||||
<option value="active">进行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="purple"
|
||||
onClick={handleSave}
|
||||
isDisabled={!formData.title || !formData.date}
|
||||
leftIcon={<FiSave />}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
506
src/views/Dashboard/components/ReviewsPanel.tsx
Normal file
506
src/views/Dashboard/components/ReviewsPanel.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* ReviewsPanel - 投资复盘列表面板组件
|
||||
* 显示、编辑和管理投资复盘
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
Grid,
|
||||
Card,
|
||||
CardBody,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
useDisclosure,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Spinner,
|
||||
Center,
|
||||
Icon,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Textarea,
|
||||
Select,
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagLeftIcon,
|
||||
TagCloseButton,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FiPlus,
|
||||
FiEdit2,
|
||||
FiTrash2,
|
||||
FiSave,
|
||||
FiFileText,
|
||||
FiCalendar,
|
||||
FiTrendingUp,
|
||||
FiHash,
|
||||
FiCheckCircle,
|
||||
FiXCircle,
|
||||
FiAlertCircle,
|
||||
} from 'react-icons/fi';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
|
||||
import { usePlanningData } from './PlanningContext';
|
||||
import type { InvestmentEvent, PlanFormData, EventStatus } from '@/types';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { getApiBase } from '@/utils/apiConfig';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
/**
|
||||
* 状态信息接口
|
||||
*/
|
||||
interface StatusInfo {
|
||||
icon: React.ComponentType;
|
||||
color: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ReviewsPanel 组件
|
||||
* 复盘列表面板,显示所有投资复盘
|
||||
*/
|
||||
export const ReviewsPanel: React.FC = () => {
|
||||
const {
|
||||
allEvents,
|
||||
loadAllData,
|
||||
loading,
|
||||
toast,
|
||||
textColor,
|
||||
secondaryText,
|
||||
cardBg,
|
||||
borderColor,
|
||||
} = usePlanningData();
|
||||
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editingItem, setEditingItem] = useState<InvestmentEvent | null>(null);
|
||||
const [formData, setFormData] = useState<PlanFormData>({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'review',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
const [stockInput, setStockInput] = useState<string>('');
|
||||
const [tagInput, setTagInput] = useState<string>('');
|
||||
|
||||
// 筛选复盘列表(排除系统事件)
|
||||
const reviews = allEvents.filter(event => event.type === 'review' && event.source !== 'future');
|
||||
|
||||
// 打开编辑/新建模态框
|
||||
const handleOpenModal = (item: InvestmentEvent | null = null): void => {
|
||||
if (item) {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
date: dayjs(item.event_date || item.date).format('YYYY-MM-DD'),
|
||||
title: item.title,
|
||||
content: item.description || item.content || '',
|
||||
type: 'review',
|
||||
stocks: item.stocks || [],
|
||||
tags: item.tags || [],
|
||||
status: item.status || 'active',
|
||||
});
|
||||
} else {
|
||||
setEditingItem(null);
|
||||
setFormData({
|
||||
date: dayjs().format('YYYY-MM-DD'),
|
||||
title: '',
|
||||
content: '',
|
||||
type: 'review',
|
||||
stocks: [],
|
||||
tags: [],
|
||||
status: 'active',
|
||||
});
|
||||
}
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 保存数据
|
||||
const handleSave = async (): Promise<void> => {
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const url = editingItem
|
||||
? base + `/api/account/investment-plans/${editingItem.id}`
|
||||
: base + '/api/account/investment-plans';
|
||||
|
||||
const method = editingItem ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('ReviewsPanel', `${editingItem ? '更新' : '创建'}成功`, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData.title,
|
||||
});
|
||||
toast({
|
||||
title: editingItem ? '更新成功' : '创建成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
onClose();
|
||||
loadAllData();
|
||||
} else {
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('ReviewsPanel', 'handleSave', error, {
|
||||
itemId: editingItem?.id,
|
||||
title: formData?.title
|
||||
});
|
||||
toast({
|
||||
title: '保存失败',
|
||||
description: '无法保存数据',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 删除数据
|
||||
const handleDelete = async (id: number): Promise<void> => {
|
||||
if (!window.confirm('确定要删除吗?')) return;
|
||||
|
||||
try {
|
||||
const base = getApiBase();
|
||||
|
||||
const response = await fetch(base + `/api/account/investment-plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
logger.info('ReviewsPanel', '删除成功', { itemId: id });
|
||||
toast({
|
||||
title: '删除成功',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
loadAllData();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('ReviewsPanel', 'handleDelete', error, { itemId: id });
|
||||
toast({
|
||||
title: '删除失败',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 添加股票
|
||||
const handleAddStock = (): void => {
|
||||
if (stockInput.trim() && !formData.stocks.includes(stockInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
stocks: [...formData.stocks, stockInput.trim()],
|
||||
});
|
||||
setStockInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 添加标签
|
||||
const handleAddTag = (): void => {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: [...formData.tags, tagInput.trim()],
|
||||
});
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态信息
|
||||
const getStatusInfo = (status?: EventStatus): StatusInfo => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return { icon: FiCheckCircle, color: 'green', text: '已完成' };
|
||||
case 'cancelled':
|
||||
return { icon: FiXCircle, color: 'red', text: '已取消' };
|
||||
default:
|
||||
return { icon: FiAlertCircle, color: 'blue', text: '进行中' };
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单个卡片
|
||||
const renderCard = (item: InvestmentEvent): JSX.Element => {
|
||||
const statusInfo = getStatusInfo(item.status);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.id}
|
||||
bg={cardBg}
|
||||
shadow="sm"
|
||||
_hover={{ shadow: 'md' }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<HStack>
|
||||
<Icon as={FiFileText} color="green.500" />
|
||||
<Text fontWeight="bold" fontSize="lg">
|
||||
{item.title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack spacing={2}>
|
||||
<Icon as={FiCalendar} boxSize={3} color={secondaryText} />
|
||||
<Text fontSize="sm" color={secondaryText}>
|
||||
{dayjs(item.event_date || item.date).format('YYYY年MM月DD日')}
|
||||
</Text>
|
||||
<Badge
|
||||
colorScheme={statusInfo.color}
|
||||
variant="subtle"
|
||||
>
|
||||
{statusInfo.text}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<HStack>
|
||||
<IconButton
|
||||
icon={<FiEdit2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleOpenModal(item)}
|
||||
aria-label="编辑复盘"
|
||||
/>
|
||||
<IconButton
|
||||
icon={<FiTrash2 />}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
aria-label="删除复盘"
|
||||
/>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{(item.content || item.description) && (
|
||||
<Text fontSize="sm" color={textColor} noOfLines={3}>
|
||||
{item.content || item.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack spacing={2} flexWrap="wrap">
|
||||
{item.stocks && item.stocks.length > 0 && (
|
||||
<>
|
||||
{item.stocks.map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue" variant="subtle">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{item.tags && item.tags.length > 0 && (
|
||||
<>
|
||||
{item.tags.map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="green" variant="subtle">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
</Tag>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Flex justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null)}
|
||||
>
|
||||
新建复盘
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{loading ? (
|
||||
<Center py={8}>
|
||||
<Spinner size="xl" color="green.500" />
|
||||
</Center>
|
||||
) : reviews.length === 0 ? (
|
||||
<Center py={8}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FiFileText} boxSize={12} color="gray.300" />
|
||||
<Text color={secondaryText}>暂无投资复盘</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="green"
|
||||
leftIcon={<FiPlus />}
|
||||
onClick={() => handleOpenModal(null)}
|
||||
>
|
||||
创建第一个复盘
|
||||
</Button>
|
||||
</VStack>
|
||||
</Center>
|
||||
) : (
|
||||
<Grid templateColumns={{ base: '1fr', md: 'repeat(2, 1fr)' }} gap={4}>
|
||||
{reviews.map(renderCard)}
|
||||
</Grid>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
{/* 编辑/新建模态框 */}
|
||||
{isOpen && (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl" closeOnOverlayClick={false} closeOnEsc={true}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
{editingItem ? '编辑' : '新建'}投资复盘
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<VStack spacing={4}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>日期</FormLabel>
|
||||
<InputGroup>
|
||||
<InputLeftElement pointerEvents="none">
|
||||
<Icon as={FiCalendar} color={secondaryText} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.date}
|
||||
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
||||
/>
|
||||
</InputGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>标题</FormLabel>
|
||||
<Input
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
placeholder="例如:本周操作复盘"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>内容</FormLabel>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
placeholder="详细记录您的投资复盘..."
|
||||
rows={6}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>相关股票</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={stockInput}
|
||||
onChange={(e) => setStockInput(e.target.value)}
|
||||
placeholder="输入股票代码"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddStock()}
|
||||
/>
|
||||
<Button onClick={handleAddStock}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{(formData.stocks || []).map((stock, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="blue">
|
||||
<TagLeftIcon as={FiTrendingUp} />
|
||||
<TagLabel>{stock}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
stocks: formData.stocks.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>标签</FormLabel>
|
||||
<HStack>
|
||||
<Input
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
placeholder="输入标签"
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleAddTag()}
|
||||
/>
|
||||
<Button onClick={handleAddTag}>添加</Button>
|
||||
</HStack>
|
||||
<HStack mt={2} spacing={2} flexWrap="wrap">
|
||||
{(formData.tags || []).map((tag, idx) => (
|
||||
<Tag key={idx} size="sm" colorScheme="green">
|
||||
<TagLeftIcon as={FiHash} />
|
||||
<TagLabel>{tag}</TagLabel>
|
||||
<TagCloseButton
|
||||
onClick={() => setFormData({
|
||||
...formData,
|
||||
tags: formData.tags.filter((_, i) => i !== idx)
|
||||
})}
|
||||
/>
|
||||
</Tag>
|
||||
))}
|
||||
</HStack>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>状态</FormLabel>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as EventStatus })}
|
||||
>
|
||||
<option value="active">进行中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</VStack>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" mr={3} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="green"
|
||||
onClick={handleSave}
|
||||
isDisabled={!formData.title || !formData.date}
|
||||
leftIcon={<FiSave />}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
Divider
|
||||
} from '@chakra-ui/react';
|
||||
import { FaEye, FaExternalLinkAlt, FaChartLine, FaCalendarAlt } from 'react-icons/fa';
|
||||
import moment from 'moment';
|
||||
import dayjs from 'dayjs';
|
||||
import tradingDayUtils from '../../../utils/tradingDayUtils'; // 引入交易日工具
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
|
||||
@@ -326,7 +326,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
if (typeof tradeDate === 'string') {
|
||||
formattedTradeDate = tradeDate;
|
||||
} else if (tradeDate instanceof Date) {
|
||||
formattedTradeDate = moment(tradeDate).format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs(tradeDate).format('YYYY-MM-DD');
|
||||
} else if (moment.isMoment(tradeDate)) {
|
||||
formattedTradeDate = tradeDate.format('YYYY-MM-DD');
|
||||
} else {
|
||||
@@ -334,7 +334,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
tradeDate,
|
||||
tradeDateType: typeof tradeDate
|
||||
});
|
||||
formattedTradeDate = moment().format('YYYY-MM-DD');
|
||||
formattedTradeDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
@@ -414,18 +414,18 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
|
||||
// 检查是否是Date对象
|
||||
if (eventTime instanceof Date) {
|
||||
eventMoment = moment(eventTime);
|
||||
eventMoment = dayjs(eventTime);
|
||||
} else if (typeof eventTime === 'string') {
|
||||
eventMoment = moment(eventTime);
|
||||
eventMoment = dayjs(eventTime);
|
||||
} else if (typeof eventTime === 'number') {
|
||||
eventMoment = moment(eventTime);
|
||||
eventMoment = dayjs(eventTime);
|
||||
} else {
|
||||
logger.warn('RelatedConcepts', '未知的事件时间格式', {
|
||||
eventTime,
|
||||
eventTimeType: typeof eventTime,
|
||||
eventId
|
||||
});
|
||||
eventMoment = moment();
|
||||
eventMoment = dayjs();
|
||||
}
|
||||
|
||||
// 确保moment对象有效
|
||||
@@ -434,7 +434,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
eventTime,
|
||||
eventId
|
||||
});
|
||||
eventMoment = moment();
|
||||
eventMoment = dayjs();
|
||||
}
|
||||
|
||||
formattedDate = eventMoment.format('YYYY-MM-DD');
|
||||
@@ -448,7 +448,7 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
if (typeof nextTradingDay === 'string') {
|
||||
formattedDate = nextTradingDay;
|
||||
} else if (nextTradingDay instanceof Date) {
|
||||
formattedDate = moment(nextTradingDay).format('YYYY-MM-DD');
|
||||
formattedDate = dayjs(nextTradingDay).format('YYYY-MM-DD');
|
||||
} else {
|
||||
logger.warn('RelatedConcepts', '交易日工具返回了无效格式', {
|
||||
nextTradingDay,
|
||||
@@ -476,16 +476,16 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
if (typeof currentTradingDay === 'string') {
|
||||
formattedDate = currentTradingDay;
|
||||
} else if (currentTradingDay instanceof Date) {
|
||||
formattedDate = moment(currentTradingDay).format('YYYY-MM-DD');
|
||||
formattedDate = dayjs(currentTradingDay).format('YYYY-MM-DD');
|
||||
} else {
|
||||
logger.warn('RelatedConcepts', '当前交易日工具返回了无效格式', {
|
||||
currentTradingDay,
|
||||
eventId
|
||||
});
|
||||
formattedDate = moment().format('YYYY-MM-DD');
|
||||
formattedDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
} else {
|
||||
formattedDate = moment().format('YYYY-MM-DD');
|
||||
formattedDate = dayjs().format('YYYY-MM-DD');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,9 +558,9 @@ const RelatedConcepts = ({ eventTitle, eventTime, eventId, loading: externalLoad
|
||||
<FaCalendarAlt color={textColor} />
|
||||
<Text fontSize="sm" color={textColor}>
|
||||
涨跌幅数据日期:{effectiveTradingDate}
|
||||
{eventTime && effectiveTradingDate !== moment(eventTime).format('YYYY-MM-DD') && (
|
||||
{eventTime && effectiveTradingDate !== dayjs(eventTime).format('YYYY-MM-DD') && (
|
||||
<Text as="span" ml={2} fontSize="xs">
|
||||
(事件发生于 {typeof eventTime === 'object' ? moment(eventTime).format('YYYY-MM-DD HH:mm') : eventTime},显示下一交易日数据)
|
||||
(事件发生于 {typeof eventTime === 'object' ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : eventTime},显示下一交易日数据)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
@@ -195,9 +195,12 @@ const EnhancedCalendar = ({
|
||||
onClick={() => onDateChange(date)}
|
||||
transition="all 0.2s"
|
||||
cursor="pointer"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text
|
||||
fontSize={compact ? 'md' : 'lg'}
|
||||
fontSize={compact ? 'lg' : 'xl'}
|
||||
fontWeight={isToday || isSelected ? 'bold' : 'normal'}
|
||||
color={isSelected ? 'blue.600' : 'gray.700'}
|
||||
>
|
||||
@@ -206,13 +209,13 @@ const EnhancedCalendar = ({
|
||||
{hasData && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top="2px"
|
||||
right="2px"
|
||||
top="4px"
|
||||
right="4px"
|
||||
size={compact ? 'sm' : 'md'}
|
||||
colorScheme={getDateBadgeColor(dateData.count)}
|
||||
fontSize={compact ? '10px' : '11px'}
|
||||
fontSize={compact ? '9px' : '10px'}
|
||||
px={compact ? 1 : 2}
|
||||
minW={compact ? '22px' : '28px'}
|
||||
minW={compact ? '20px' : '24px'}
|
||||
borderRadius="full"
|
||||
>
|
||||
{dateData.count}
|
||||
@@ -221,7 +224,7 @@ const EnhancedCalendar = ({
|
||||
{isToday && (
|
||||
<Text
|
||||
position="absolute"
|
||||
bottom="2px"
|
||||
bottom="4px"
|
||||
left="50%"
|
||||
transform="translateX(-50%)"
|
||||
fontSize={compact ? '9px' : '10px'}
|
||||
|
||||
@@ -444,7 +444,6 @@ export default function LimitAnalyse() {
|
||||
borderColor="whiteAlpha.300"
|
||||
backdropFilter="saturate(180%) blur(10px)"
|
||||
w="full"
|
||||
minH="420px"
|
||||
>
|
||||
<CardBody p={4}>
|
||||
<EnhancedCalendar
|
||||
@@ -453,8 +452,9 @@ export default function LimitAnalyse() {
|
||||
availableDates={availableDates}
|
||||
compact
|
||||
hideSelectionInfo
|
||||
hideLegend
|
||||
width="100%"
|
||||
cellHeight={10}
|
||||
cellHeight={16}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
@@ -1,40 +1,9 @@
|
||||
import {
|
||||
Flex,
|
||||
Container,
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
Text
|
||||
} from '@chakra-ui/react';
|
||||
import { ChevronRightIcon } from '@chakra-ui/icons';
|
||||
import React from 'react';
|
||||
import SubscriptionContent from 'components/Subscription/SubscriptionContent';
|
||||
import SubscriptionContentNew from 'components/Subscription/SubscriptionContentNew';
|
||||
|
||||
function Subscription() {
|
||||
return (
|
||||
<Flex direction='column'>
|
||||
<Container maxW="container.xl" px={{ base: 4, md: 6 }} py={{ base: 4, md: 6 }}>
|
||||
{/* 面包屑导航 */}
|
||||
<Breadcrumb
|
||||
spacing='8px'
|
||||
separator={<ChevronRightIcon color='gray.500' />}
|
||||
mb={6}
|
||||
fontSize='sm'
|
||||
>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href='/home'>首页</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href='/home/pages/account'>个人中心</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbItem isCurrentPage>
|
||||
<Text color='gray.500'>订阅管理</Text>
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
|
||||
<SubscriptionContent />
|
||||
</Container>
|
||||
</Flex>
|
||||
<SubscriptionContentNew />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
221
src/views/Pages/Account/subscription-content.tsx
Normal file
221
src/views/Pages/Account/subscription-content.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
export const subscriptionConfig = {
|
||||
plans: [
|
||||
{
|
||||
name: 'free',
|
||||
displayName: '基础版',
|
||||
description: '免费体验核心功能,7项实用工具',
|
||||
icon: 'star',
|
||||
price: 0,
|
||||
badge: '免费',
|
||||
badgeColor: 'gray',
|
||||
cardBorder: 'gray',
|
||||
features: [
|
||||
{ name: '新闻信息流', enabled: true },
|
||||
{ name: '历史事件对比', enabled: true, limit: 'TOP3' },
|
||||
{ name: '事件传导链分析(AI)', enabled: true, limit: '有限体验' },
|
||||
{ name: 'AI复盘功能', enabled: true },
|
||||
{ name: '企业概览', enabled: true, limit: '限制预览' },
|
||||
{ name: '个股深度分析(AI)', enabled: true, limit: '10家/月' },
|
||||
{ name: '概念中心(548大概念)', enabled: true, limit: 'TOP5' },
|
||||
{ name: '涨停板块数据分析', enabled: true },
|
||||
{ name: '个股涨停分析', enabled: true },
|
||||
{ name: '事件-相关标的分析', enabled: false },
|
||||
{ name: '相关概念展示', enabled: false },
|
||||
{ name: '高效数据筛选工具', enabled: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'pro',
|
||||
displayName: 'Pro 专业版',
|
||||
description: '为专业投资者打造,解锁高级分析功能',
|
||||
icon: 'gem',
|
||||
badge: '推荐',
|
||||
badgeColor: 'gold',
|
||||
cardBorder: 'gold',
|
||||
highlight: false,
|
||||
pricingOptions: [
|
||||
{
|
||||
cycleKey: 'monthly',
|
||||
label: '月付',
|
||||
months: 1,
|
||||
price: 299,
|
||||
originalPrice: null,
|
||||
discountPercent: 0,
|
||||
},
|
||||
{
|
||||
cycleKey: 'quarterly',
|
||||
label: '季付',
|
||||
months: 3,
|
||||
price: 799,
|
||||
originalPrice: 897,
|
||||
discountPercent: 11,
|
||||
},
|
||||
{
|
||||
cycleKey: 'semiannual',
|
||||
label: '半年付',
|
||||
months: 6,
|
||||
price: 1499,
|
||||
originalPrice: 1794,
|
||||
discountPercent: 16,
|
||||
},
|
||||
{
|
||||
cycleKey: 'yearly',
|
||||
label: '年付',
|
||||
months: 12,
|
||||
price: 2699,
|
||||
originalPrice: 3588,
|
||||
discountPercent: 25,
|
||||
},
|
||||
],
|
||||
features: [
|
||||
{ name: '新闻信息流', enabled: true },
|
||||
{ name: '历史事件对比', enabled: true },
|
||||
{ name: '事件传导链分析(AI)', enabled: true },
|
||||
{ name: '事件-相关标的分析', enabled: true },
|
||||
{ name: '相关概念展示', enabled: true },
|
||||
{ name: 'AI复盘功能', enabled: true },
|
||||
{ name: '企业概览', enabled: true },
|
||||
{ name: '个股深度分析(AI)', enabled: true, limit: '50家/月' },
|
||||
{ name: '高效数据筛选工具', enabled: true },
|
||||
{ name: '概念中心(548大概念)', enabled: true },
|
||||
{ name: '历史时间轴查询', enabled: true, limit: '100天' },
|
||||
{ name: '涨停板块数据分析', enabled: true },
|
||||
{ name: '个股涨停分析', enabled: true },
|
||||
{ name: '板块深度分析(AI)', enabled: false },
|
||||
{ name: '概念高频更新', enabled: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'max',
|
||||
displayName: 'Max 旗舰版',
|
||||
description: '旗舰级体验,无限制使用所有功能',
|
||||
icon: 'crown',
|
||||
badge: '最受欢迎',
|
||||
badgeColor: 'gold',
|
||||
cardBorder: 'gold',
|
||||
highlight: true,
|
||||
pricingOptions: [
|
||||
{
|
||||
cycleKey: 'monthly',
|
||||
label: '月付',
|
||||
months: 1,
|
||||
price: 599,
|
||||
originalPrice: null,
|
||||
discountPercent: 0,
|
||||
},
|
||||
{
|
||||
cycleKey: 'quarterly',
|
||||
label: '季付',
|
||||
months: 3,
|
||||
price: 1599,
|
||||
originalPrice: 1797,
|
||||
discountPercent: 11,
|
||||
},
|
||||
{
|
||||
cycleKey: 'semiannual',
|
||||
label: '半年付',
|
||||
months: 6,
|
||||
price: 2999,
|
||||
originalPrice: 3594,
|
||||
discountPercent: 17,
|
||||
},
|
||||
{
|
||||
cycleKey: 'yearly',
|
||||
label: '年付',
|
||||
months: 12,
|
||||
price: 5399,
|
||||
originalPrice: 7188,
|
||||
discountPercent: 25,
|
||||
},
|
||||
],
|
||||
features: [
|
||||
{ name: '新闻信息流', enabled: true },
|
||||
{ name: '历史事件对比', enabled: true },
|
||||
{ name: '事件传导链分析(AI)', enabled: true },
|
||||
{ name: '事件-相关标的分析', enabled: true },
|
||||
{ name: '相关概念展示', enabled: true },
|
||||
{ name: '板块深度分析(AI)', enabled: true },
|
||||
{ name: 'AI复盘功能', enabled: true },
|
||||
{ name: '企业概览', enabled: true },
|
||||
{ name: '个股深度分析(AI)', enabled: true, limit: '无限制' },
|
||||
{ name: '高效数据筛选工具', enabled: true },
|
||||
{ name: '概念中心(548大概念)', enabled: true },
|
||||
{ name: '历史时间轴查询', enabled: true, limit: '无限制' },
|
||||
{ name: '概念高频更新', enabled: true },
|
||||
{ name: '涨停板块数据分析', enabled: true },
|
||||
{ name: '个股涨停分析', enabled: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
faqs: [
|
||||
{
|
||||
question: '如何取消订阅?',
|
||||
answer: '您可以随时在账户设置中取消订阅。取消后,您的订阅将在当前计费周期结束时到期,期间您仍可继续使用付费功能。取消后不会立即扣款,也不会自动续费。',
|
||||
},
|
||||
{
|
||||
question: '支持哪些支付方式?',
|
||||
answer: '我们目前支持微信支付。扫描支付二维码后,系统会自动检测支付状态并激活您的订阅。支付过程安全可靠,所有交易都经过加密处理。',
|
||||
},
|
||||
{
|
||||
question: '升级或切换套餐时,原套餐的费用怎么办?',
|
||||
answer: '当您升级套餐或切换计费周期时,系统会自动计算您当前订阅的剩余价值并用于抵扣新套餐的费用。\n\n计算方式:\n• 剩余价值 = 原套餐价格 × (剩余天数 / 总天数)\n• 实付金额 = 新套餐价格 - 剩余价值 - 优惠码折扣\n\n例如:您购买了年付Pro版(¥2699),使用了180天后升级到Max版(¥5399/年),剩余价值约¥1350将自动抵扣,实付约¥4049。',
|
||||
},
|
||||
{
|
||||
question: '可以在不同计费周期之间切换吗?',
|
||||
answer: '可以。您可以随时更改计费周期。如果从短期切换到长期,系统会计算剩余价值并应用到新的订阅中。长期套餐(季付、半年付、年付)可享受更大的折扣优惠。',
|
||||
},
|
||||
{
|
||||
question: '是否支持退款?',
|
||||
answer: '为了保障服务质量和维护公平的商业环境,我们不支持退款。\n\n建议您在订阅前:\n• 充分了解各套餐的功能差异\n• 使用免费版体验基础功能\n• 根据实际需求选择合适的计费周期\n• 如有疑问可联系客服咨询\n\n提示:选择长期套餐(如半年付、年付)可享受更大折扣,性价比更高。',
|
||||
},
|
||||
{
|
||||
question: 'Pro版和Max版有什么区别?',
|
||||
answer: 'Pro版适合个人专业用户,提供高级图表、历史数据分析等功能,有一定的使用限制。Max版则是为重度用户设计,提供无限制的数据查询、板块深度分析、概念高频更新等独家功能,并享有优先技术支持。',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// 主题颜色配置 - 黑金配色
|
||||
export const themeColors = {
|
||||
// 背景渐变
|
||||
bgGradient: 'linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%)',
|
||||
bgRadialGold: 'radial-gradient(circle at center, rgba(212, 175, 55, 0.1) 0%, transparent 70%)',
|
||||
|
||||
// 主色调
|
||||
primary: {
|
||||
gold: '#D4AF37', // 金色
|
||||
goldLight: '#F4E3A7', // 浅金色
|
||||
goldDark: '#B8941F', // 深金色
|
||||
},
|
||||
|
||||
// 背景色
|
||||
bg: {
|
||||
primary: '#0a0a0a', // 主背景(纯黑)
|
||||
secondary: '#1a1a1a', // 次级背景(深黑)
|
||||
card: '#1e1e1e', // 卡片背景
|
||||
cardHover: '#252525', // 卡片悬停
|
||||
},
|
||||
|
||||
// 文字颜色
|
||||
text: {
|
||||
primary: '#ffffff', // 主文字(纯白)
|
||||
secondary: '#b8b8b8', // 次级文字(灰白)
|
||||
muted: '#808080', // 弱化文字(灰)
|
||||
gold: '#D4AF37', // 金色文字
|
||||
},
|
||||
|
||||
// 边框颜色
|
||||
border: {
|
||||
default: 'rgba(255, 255, 255, 0.1)',
|
||||
gold: 'rgba(212, 175, 55, 0.3)',
|
||||
goldGlow: 'rgba(212, 175, 55, 0.5)',
|
||||
},
|
||||
|
||||
// 状态颜色
|
||||
status: {
|
||||
active: '#00ff88', // 激活(绿色)
|
||||
inactive: '#ff4444', // 未激活(红色)
|
||||
warning: '#ff9900', // 警告(橙色)
|
||||
},
|
||||
};
|
||||
35
src/views/Pricing/content.tsx
Normal file
35
src/views/Pricing/content.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
export const pricing = [
|
||||
{
|
||||
title: "STARTER",
|
||||
price: 99,
|
||||
features: [
|
||||
"1 Active Bot",
|
||||
"1,000 Conversations per month",
|
||||
"Web & WhatsApp Integration",
|
||||
"Basic Dashboard & Chat Reports",
|
||||
"Email Support",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "PRO",
|
||||
price: 149,
|
||||
features: [
|
||||
"Up to 5 Active Bots",
|
||||
"10,000 Conversations per month",
|
||||
"Multi-Channel (Web, WhatsApp, IG, Telegram)",
|
||||
"Custom Workflows & Automation",
|
||||
"Real-Time Reports & Zapier Integration",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "ENTERPRISE",
|
||||
price: 199,
|
||||
features: [
|
||||
"Unlimited Bots & Chats",
|
||||
"Role-Based Access & Team Management",
|
||||
"Integration to CRM & Custom APIs",
|
||||
"Advanced AI Training (LLM/NLP)",
|
||||
"Dedicated Onboarding Team",
|
||||
],
|
||||
},
|
||||
];
|
||||
129
src/views/Pricing/index.tsx
Normal file
129
src/views/Pricing/index.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { motion } from "framer-motion";
|
||||
import Button from "@/components/Button2";
|
||||
|
||||
import { pricing } from "./content";
|
||||
|
||||
const Pricing = () => (
|
||||
<div
|
||||
id="pricing"
|
||||
className="pt-34.5 pb-25 max-2xl:pt-25 max-lg:py-20 max-md:py-15"
|
||||
>
|
||||
<div className="center">
|
||||
<motion.div
|
||||
className="max-w-175 mx-auto mb-17.5 text-center max-xl:mb-14 max-md:mb-8"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.7 }}
|
||||
viewport={{ amount: 0.7 }}
|
||||
>
|
||||
<div className="label mb-3 max-md:mb-1.5">Pricing</div>
|
||||
<div className="bg-radial-white-2 bg-clip-text text-transparent text-title-1 max-lg:text-title-2 max-md:text-title-1-mobile">
|
||||
Start Automation Today
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="flex gap-4 max-lg:-mx-10 max-lg:px-10 max-lg:overflow-x-auto max-lg:scrollbar-none max-md:-mx-5 max-md:px-5"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.7 }}
|
||||
viewport={{ amount: 0.35 }}
|
||||
>
|
||||
{pricing.map((item, index) => (
|
||||
<div
|
||||
className={`relative flex flex-col flex-1 rounded-[1.25rem] overflow-hidden after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none max-lg:shrink-0 max-lg:flex-auto max-lg:w-84 ${
|
||||
item.title === "PRO"
|
||||
? "shadow-2 before:absolute before:-top-20 before:left-1/2 before:z-1 before:-translate-x-1/2 before:w-65 before:h-57 before:bg-green/10 before:rounded-full before:blur-[3.375rem]"
|
||||
: "shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset]"
|
||||
}`}
|
||||
key={index}
|
||||
>
|
||||
{item.title === "PRO" && (
|
||||
<div className="absolute -top-36 left-13 w-105 mask-radial-at-center mask-radial-from-20% mask-radial-to-52%">
|
||||
<video
|
||||
className="w-full"
|
||||
src="/videos/video-1.mp4"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`relative z-2 pt-8 px-8.5 pb-10 text-title-4 max-md:text-title-5 ${
|
||||
item.title === "PRO"
|
||||
? "bg-[#175673]/20 rounded-t-[1.25rem] text-green"
|
||||
: "text-white"
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
<div
|
||||
className={`relative z-3 flex flex-col grow -mt-5 p-3.5 pb-8.25 rounded-[1.25rem] after:absolute after:inset-0 after:border after:border-line after:rounded-[1.25rem] after:pointer-events-none ${
|
||||
item.title === "PRO"
|
||||
? "backdrop-blur-[2rem] shadow-2 bg-white/7"
|
||||
: "backdrop-blur-[1.25rem] bg-white/1"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`relative mb-8 p-5 rounded-[0.8125rem] backdrop-blur-[1.25rem] shadow-2 after:absolute after:inset-0 after:border after:border-line after:rounded-[0.8125rem] after:pointer-events-none ${
|
||||
item.title === "PRO"
|
||||
? "bg-line"
|
||||
: "bg-white/2"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-end gap-3 mb-4">
|
||||
<div className="bg-radial-white-2 bg-clip-text text-transparent text-title-1 leading-[3.1rem] max-xl:text-title-2 max-xl:leading-[2.4rem]">
|
||||
${item.price}
|
||||
</div>
|
||||
<div className="text-title-5">/Month</div>
|
||||
</div>
|
||||
<Button
|
||||
className={`w-full bg-line ${
|
||||
item.title !== "PRO"
|
||||
? "!text-description hover:!text-white"
|
||||
: ""
|
||||
}`}
|
||||
isPrimary={item.title === "PRO"}
|
||||
isSecondary={item.title !== "PRO"}
|
||||
>
|
||||
{item.title === "STARTER"
|
||||
? "Start with Beginner"
|
||||
: item.title === "PRO"
|
||||
? "Choose Pro Plan"
|
||||
: "Contact for Enterprise"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-6.5 px-3.5 max-xl:px-0 max-xl:gap-5 max-md:px-3.5">
|
||||
{item.features.map((feature, index) => (
|
||||
<div
|
||||
className="flex items-center gap-2.5 text-description max-xl:text-description-2 max-md:text-description-mobile"
|
||||
key={index}
|
||||
>
|
||||
<div className="flex justify-center items-center shrink-0 size-5 bg-green rounded-full shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.20)_inset,_0_0_0.625rem_0_rgba(255,255,255,0.50)_inset]">
|
||||
<svg
|
||||
className="size-5 fill-black"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M13.47 6.97A.75.75 0 0 1 14.53 8.03l-5 5a.75.75 0 0 1-1.061 0l-3-3A.75.75 0 0 1 6.53 8.97L9 11.439l4.47-4.469z" />
|
||||
</svg>
|
||||
</div>
|
||||
{feature}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
<div className="mt-13.5 text-center max-md:mt-8 max-md:text-title-3-mobile">
|
||||
Free 7 Day Trial
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Pricing;
|
||||
100
update_pricing_options.sql
Normal file
100
update_pricing_options.sql
Normal file
@@ -0,0 +1,100 @@
|
||||
-- ============================================
|
||||
-- 更新订阅套餐价格配置
|
||||
-- 用途:为 subscription_plans 表添加季付、半年付价格
|
||||
-- 日期:2025-11-19
|
||||
-- ============================================
|
||||
|
||||
-- 更新 Pro 专业版的 pricing_options
|
||||
UPDATE subscription_plans
|
||||
SET pricing_options = JSON_ARRAY(
|
||||
JSON_OBJECT(
|
||||
'months', 1,
|
||||
'price', 299.00,
|
||||
'label', '月付',
|
||||
'cycle_key', 'monthly',
|
||||
'discount_percent', 0
|
||||
),
|
||||
JSON_OBJECT(
|
||||
'months', 3,
|
||||
'price', 799.00,
|
||||
'label', '季付',
|
||||
'cycle_key', 'quarterly',
|
||||
'discount_percent', 11,
|
||||
'original_price', 897.00
|
||||
),
|
||||
JSON_OBJECT(
|
||||
'months', 6,
|
||||
'price', 1499.00,
|
||||
'label', '半年付',
|
||||
'cycle_key', 'semiannual',
|
||||
'discount_percent', 16,
|
||||
'original_price', 1794.00
|
||||
),
|
||||
JSON_OBJECT(
|
||||
'months', 12,
|
||||
'price', 2699.00,
|
||||
'label', '年付',
|
||||
'cycle_key', 'yearly',
|
||||
'discount_percent', 25,
|
||||
'original_price', 3588.00
|
||||
)
|
||||
)
|
||||
WHERE name = 'pro';
|
||||
|
||||
-- 更新 Max 旗舰版的 pricing_options
|
||||
UPDATE subscription_plans
|
||||
SET pricing_options = JSON_ARRAY(
|
||||
JSON_OBJECT(
|
||||
'months', 1,
|
||||
'price', 599.00,
|
||||
'label', '月付',
|
||||
'cycle_key', 'monthly',
|
||||
'discount_percent', 0
|
||||
),
|
||||
JSON_OBJECT(
|
||||
'months', 3,
|
||||
'price', 1599.00,
|
||||
'label', '季付',
|
||||
'cycle_key', 'quarterly',
|
||||
'discount_percent', 11,
|
||||
'original_price', 1797.00
|
||||
),
|
||||
JSON_OBJECT(
|
||||
'months', 6,
|
||||
'price', 2999.00,
|
||||
'label', '半年付',
|
||||
'cycle_key', 'semiannual',
|
||||
'discount_percent', 17,
|
||||
'original_price', 3594.00
|
||||
),
|
||||
JSON_OBJECT(
|
||||
'months', 12,
|
||||
'price', 5399.00,
|
||||
'label', '年付',
|
||||
'cycle_key', 'yearly',
|
||||
'discount_percent', 25,
|
||||
'original_price', 7188.00
|
||||
)
|
||||
)
|
||||
WHERE name = 'max';
|
||||
|
||||
-- 验证更新结果
|
||||
SELECT
|
||||
name AS '套餐',
|
||||
display_name AS '显示名称',
|
||||
pricing_options AS '价格配置'
|
||||
FROM subscription_plans
|
||||
WHERE name IN ('pro', 'max');
|
||||
|
||||
-- 完成提示
|
||||
SELECT '价格配置已更新!' AS '状态';
|
||||
SELECT '新价格:' AS '';
|
||||
SELECT ' Pro 月付: ¥299' AS '';
|
||||
SELECT ' Pro 季付: ¥799 (省11%)' AS '';
|
||||
SELECT ' Pro 半年付: ¥1499 (省16%)' AS '';
|
||||
SELECT ' Pro 年付: ¥2699 (省25%)' AS '';
|
||||
SELECT '' AS '';
|
||||
SELECT ' Max 月付: ¥599' AS '';
|
||||
SELECT ' Max 季付: ¥1599 (省11%)' AS '';
|
||||
SELECT ' Max 半年付: ¥2999 (省17%)' AS '';
|
||||
SELECT ' Max 年付: ¥5399 (省25%)' AS '';
|
||||
Reference in New Issue
Block a user