Compare commits
30 Commits
1fc9f4790f
...
feature_20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53fbda44e6 | ||
|
|
540b938525 | ||
|
|
8fe11efcd7 | ||
|
|
e753437b86 | ||
|
|
a6f69418f6 | ||
|
|
dfdd2f4134 | ||
|
|
4c79871ab4 | ||
| f8eb268341 | |||
| 665f5e8416 | |||
| be2da54d82 | |||
| 8bf4a0b6c6 | |||
| 412b2c03ed | |||
| 899500007d | |||
| d3879b3840 | |||
| 80fe74c041 | |||
| 78f7dca1f6 | |||
| 03aee75235 | |||
| 8eff6b1a95 | |||
| 80676dd622 | |||
| 082e644534 | |||
| b0b227a5ef | |||
| 691c4f6eb1 | |||
| d5a55c4e02 | |||
| 27cdf0aecd | |||
| 4a1157c0b6 | |||
| f515dc94f4 | |||
| 683e261756 | |||
| 8bdfd0389c | |||
| eae495ac34 | |||
| 958cedefb8 |
@@ -18,10 +18,3 @@ REACT_APP_ENABLE_MOCK=false
|
||||
|
||||
# 开发环境标识
|
||||
REACT_APP_ENV=development
|
||||
|
||||
# PostHog 配置(开发环境)
|
||||
# 留空 = 仅控制台 debug
|
||||
# 填入 Key = 控制台 + PostHog Cloud 双模式
|
||||
REACT_APP_POSTHOG_KEY=
|
||||
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
||||
REACT_APP_ENABLE_SESSION_RECORDING=false
|
||||
|
||||
11
.env.mock
11
.env.mock
@@ -35,14 +35,3 @@ REACT_APP_ENABLE_MOCK=true
|
||||
|
||||
# Mock 环境标识
|
||||
REACT_APP_ENV=mock
|
||||
|
||||
# PostHog 配置(Mock 环境)
|
||||
# 留空 = 仅控制台 debug
|
||||
# 填入 Key = 控制台 + PostHog Cloud 双模式
|
||||
REACT_APP_POSTHOG_KEY=phc_xKlRyG69Bx7hgOdFeCeLUvQWvSjw18ZKFgCwCeYezWF
|
||||
REACT_APP_POSTHOG_HOST=https://app.posthog.com
|
||||
REACT_APP_ENABLE_SESSION_RECORDING=false
|
||||
|
||||
# PostHog Debug 模式(Mock 环境永久启用)
|
||||
# 在浏览器 Console 中打印详细的事件追踪日志
|
||||
REACT_APP_POSTHOG_DEBUG=true
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
# ========================================
|
||||
# 生产环境配置
|
||||
# ========================================
|
||||
# 使用方式: npm run build
|
||||
#
|
||||
# 工作原理:
|
||||
# 1. 此文件专门用于生产环境构建
|
||||
# 2. 构建时会将环境变量嵌入到打包文件中
|
||||
# 3. 确保 PostHog 等服务使用正确的生产配置
|
||||
# ========================================
|
||||
|
||||
# 环境标识
|
||||
REACT_APP_ENV=production
|
||||
@@ -17,13 +10,8 @@ NODE_ENV=production
|
||||
REACT_APP_ENABLE_MOCK=false
|
||||
|
||||
# 🔧 调试模式(生产环境临时调试用)
|
||||
# 开启后会在全局暴露 window.__DEBUG__ 和 window.__TEST_NOTIFICATION__ 调试 API
|
||||
# ⚠️ 警告: 调试模式会记录所有 API 请求/响应,调试完成后请立即关闭!
|
||||
# 使用方法:
|
||||
# 1. 设置为 true 并重新构建
|
||||
# 2. 在浏览器控制台使用 window.__DEBUG__.help() 查看命令
|
||||
# 3. 调试完成后设置为 false 并重新构建
|
||||
REACT_APP_ENABLE_DEBUG=true
|
||||
# 开启后会在全局暴露 window.__DEBUG__
|
||||
REACT_APP_ENABLE_DEBUG=false
|
||||
|
||||
# 后端 API 地址(生产环境)
|
||||
REACT_APP_API_URL=http://49.232.185.254:5001
|
||||
@@ -49,20 +37,3 @@ TSC_COMPILE_ON_ERROR=true
|
||||
IMAGE_INLINE_SIZE_LIMIT=10000
|
||||
# Node.js 内存限制(适用于大型项目)
|
||||
NODE_OPTIONS=--max_old_space_size=4096
|
||||
|
||||
# ========================================
|
||||
# Bytedesk 客服系统配置
|
||||
# ========================================
|
||||
# Bytedesk 服务器地址(使用相对路径,通过 Nginx 代理)
|
||||
# ⚠️ 重要:生产环境必须使用相对路径,避免 Mixed Content 错误
|
||||
# Nginx 配置:location /bytedesk-api/ { proxy_pass http://43.143.189.195/; }
|
||||
REACT_APP_BYTEDESK_API_URL=/bytedesk-api
|
||||
|
||||
# 组织 UUID(从管理后台 -> 设置 -> 组织信息 -> 组织UUID)
|
||||
REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
|
||||
# 工作组 UUID(从管理后台 -> 客服管理 -> 工作组 -> 工作组UUID)
|
||||
REACT_APP_BYTEDESK_SID=df_wg_uid
|
||||
|
||||
# 客服类型(2=人工客服, 1=机器人)
|
||||
REACT_APP_BYTEDESK_TYPE=2
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -48,6 +48,8 @@ Thumbs.db
|
||||
*.md
|
||||
!README.md
|
||||
!CLAUDE.md
|
||||
!docs/**/*.md
|
||||
|
||||
# 忽略 docs 目录(开发文档不提交到 Git)
|
||||
docs/
|
||||
|
||||
src/assets/img/original-backup/
|
||||
|
||||
220
app.py
220
app.py
@@ -570,6 +570,28 @@ class User(UserMixin, db.Model):
|
||||
'is_authenticated': True
|
||||
}
|
||||
|
||||
# 获取用户订阅信息(从 user_subscriptions 表)
|
||||
subscription = UserSubscription.query.filter_by(user_id=self.id).first()
|
||||
if subscription:
|
||||
data.update({
|
||||
'subscription_type': subscription.subscription_type,
|
||||
'subscription_status': subscription.subscription_status,
|
||||
'billing_cycle': subscription.billing_cycle,
|
||||
'start_date': subscription.start_date.isoformat() if subscription.start_date else None,
|
||||
'end_date': subscription.end_date.isoformat() if subscription.end_date else None,
|
||||
'auto_renewal': subscription.auto_renewal
|
||||
})
|
||||
else:
|
||||
# 无订阅时使用默认值
|
||||
data.update({
|
||||
'subscription_type': 'free',
|
||||
'subscription_status': 'inactive',
|
||||
'billing_cycle': None,
|
||||
'start_date': None,
|
||||
'end_date': None,
|
||||
'auto_renewal': False
|
||||
})
|
||||
|
||||
# 敏感信息只在需要时包含
|
||||
if include_sensitive:
|
||||
data.update({
|
||||
@@ -1323,40 +1345,126 @@ def calculate_subscription_price_simple(user_id, to_plan_name, to_cycle, promo_c
|
||||
if price <= 0:
|
||||
return {'error': f'{to_cycle} 周期价格未配置'}
|
||||
|
||||
# 4. 判断是新购还是续费
|
||||
# 4. 判断订阅类型和计算价格
|
||||
is_renewal = False
|
||||
is_upgrade = False
|
||||
is_downgrade = False
|
||||
subscription_type = 'new'
|
||||
current_plan = None
|
||||
current_cycle = None
|
||||
remaining_value = 0
|
||||
final_price = price
|
||||
|
||||
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. 构建结果(续费和新购价格完全一致)
|
||||
if current_plan == to_plan_name:
|
||||
# 同级续费:延长时长,全价购买
|
||||
is_renewal = True
|
||||
subscription_type = 'renew'
|
||||
elif current_plan == 'pro' and to_plan_name == 'max':
|
||||
# 升级:Pro → Max,需要计算差价
|
||||
is_upgrade = True
|
||||
subscription_type = 'upgrade'
|
||||
|
||||
# 计算当前订阅的剩余价值
|
||||
if current_sub.end_date and current_sub.end_date > datetime.utcnow():
|
||||
# 获取当前套餐的原始价格
|
||||
current_plan_obj = SubscriptionPlan.query.filter_by(name=current_plan, is_active=True).first()
|
||||
if current_plan_obj:
|
||||
current_price = None
|
||||
|
||||
# 优先从 pricing_options 获取价格
|
||||
if current_plan_obj.pricing_options:
|
||||
try:
|
||||
pricing_opts = json.loads(current_plan_obj.pricing_options)
|
||||
|
||||
# 如果 current_cycle 为空或无效,根据剩余天数推断计费周期
|
||||
if not current_cycle or current_cycle.strip() == '':
|
||||
remaining_days_total = (current_sub.end_date - current_sub.start_date).days if current_sub.start_date else 365
|
||||
|
||||
# 根据总天数推断计费周期
|
||||
if remaining_days_total <= 35:
|
||||
inferred_cycle = 'monthly'
|
||||
elif remaining_days_total <= 100:
|
||||
inferred_cycle = 'quarterly'
|
||||
elif remaining_days_total <= 200:
|
||||
inferred_cycle = 'semiannual'
|
||||
else:
|
||||
inferred_cycle = 'yearly'
|
||||
else:
|
||||
inferred_cycle = current_cycle
|
||||
|
||||
for opt in pricing_opts:
|
||||
if opt.get('cycle_key') == inferred_cycle:
|
||||
current_price = float(opt.get('price', 0))
|
||||
current_cycle = inferred_cycle # 更新周期信息
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
# 如果 pricing_options 中没找到,使用 yearly_price 作为默认
|
||||
if current_price is None or current_price <= 0:
|
||||
current_price = float(current_plan_obj.yearly_price) if current_plan_obj.yearly_price else 0
|
||||
current_cycle = 'yearly'
|
||||
|
||||
if current_price and current_price > 0:
|
||||
# 计算剩余天数
|
||||
remaining_days = (current_sub.end_date - datetime.utcnow()).days
|
||||
|
||||
# 计算总天数
|
||||
cycle_days_map = {
|
||||
'monthly': 30,
|
||||
'quarterly': 90,
|
||||
'semiannual': 180,
|
||||
'yearly': 365
|
||||
}
|
||||
total_days = cycle_days_map.get(current_cycle, 365)
|
||||
|
||||
# 计算剩余价值
|
||||
if total_days > 0 and remaining_days > 0:
|
||||
remaining_value = current_price * (remaining_days / total_days)
|
||||
# 实付金额 = 新套餐价格 - 剩余价值
|
||||
final_price = max(0, price - remaining_value)
|
||||
|
||||
# 如果剩余价值 >= 新套餐价格,标记为免费升级
|
||||
if remaining_value >= price:
|
||||
final_price = 0
|
||||
elif current_plan == 'max' and to_plan_name == 'pro':
|
||||
# 降级:Max → Pro,到期后切换,全价购买
|
||||
is_downgrade = True
|
||||
subscription_type = 'downgrade'
|
||||
else:
|
||||
# 其他情况视为新购
|
||||
subscription_type = 'new'
|
||||
|
||||
# 5. 构建结果
|
||||
result = {
|
||||
'is_renewal': is_renewal,
|
||||
'is_upgrade': is_upgrade,
|
||||
'is_downgrade': is_downgrade,
|
||||
'subscription_type': subscription_type,
|
||||
'current_plan': current_plan,
|
||||
'current_cycle': current_cycle,
|
||||
'new_plan_price': price,
|
||||
'original_price': price, # 新套餐原价
|
||||
'remaining_value': remaining_value, # 当前订阅剩余价值(仅升级时有效)
|
||||
'original_amount': price,
|
||||
'discount_amount': 0,
|
||||
'final_amount': price,
|
||||
'final_amount': final_price,
|
||||
'promo_code': None,
|
||||
'promo_error': None
|
||||
}
|
||||
|
||||
# 6. 应用优惠码
|
||||
# 6. 应用优惠码(基于差价后的金额)
|
||||
if promo_code and promo_code.strip():
|
||||
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, price, user_id)
|
||||
# 优惠码作用于差价后的金额
|
||||
promo, error = validate_promo_code(promo_code, to_plan_name, to_cycle, final_price, user_id)
|
||||
if promo:
|
||||
discount = calculate_discount(promo, price)
|
||||
discount = calculate_discount(promo, final_price)
|
||||
result['discount_amount'] = float(discount)
|
||||
result['final_amount'] = price - float(discount)
|
||||
result['final_amount'] = final_price - float(discount)
|
||||
result['promo_code'] = promo.code
|
||||
elif error:
|
||||
result['promo_error'] = error
|
||||
@@ -1682,6 +1790,89 @@ def calculate_subscription_price():
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/subscription/free-upgrade', methods=['POST'])
|
||||
@login_required
|
||||
def free_upgrade_subscription():
|
||||
"""
|
||||
免费升级订阅(当剩余价值 >= 新套餐价格时)
|
||||
|
||||
Request Body:
|
||||
{
|
||||
"plan_name": "max",
|
||||
"billing_cycle": "yearly"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
plan_name = data.get('plan_name')
|
||||
billing_cycle = data.get('billing_cycle')
|
||||
|
||||
if not plan_name or not billing_cycle:
|
||||
return jsonify({'success': False, 'error': '参数不完整'}), 400
|
||||
|
||||
user_id = current_user.id
|
||||
|
||||
# 计算价格,验证是否可以免费升级
|
||||
price_result = calculate_subscription_price_simple(user_id, plan_name, billing_cycle, None)
|
||||
|
||||
if 'error' in price_result:
|
||||
return jsonify({'success': False, 'error': price_result['error']}), 400
|
||||
|
||||
# 检查是否为升级且实付金额为0
|
||||
if not price_result.get('is_upgrade') or price_result.get('final_amount', 1) > 0:
|
||||
return jsonify({'success': False, 'error': '当前情况不符合免费升级条件'}), 400
|
||||
|
||||
# 获取当前订阅
|
||||
subscription = UserSubscription.query.filter_by(user_id=user_id).first()
|
||||
if not subscription:
|
||||
return jsonify({'success': False, 'error': '未找到订阅记录'}), 404
|
||||
|
||||
# 计算新的到期时间(按剩余价值折算)
|
||||
remaining_value = price_result.get('remaining_value', 0)
|
||||
new_plan_price = price_result.get('new_plan_price', 0)
|
||||
|
||||
if new_plan_price > 0:
|
||||
# 计算可以兑换的新套餐天数
|
||||
value_ratio = remaining_value / new_plan_price
|
||||
|
||||
cycle_days_map = {
|
||||
'monthly': 30,
|
||||
'quarterly': 90,
|
||||
'semiannual': 180,
|
||||
'yearly': 365
|
||||
}
|
||||
new_cycle_days = cycle_days_map.get(billing_cycle, 365)
|
||||
|
||||
# 新的到期天数 = 周期天数 × 价值比例
|
||||
new_days = int(new_cycle_days * value_ratio)
|
||||
|
||||
# 更新订阅信息
|
||||
subscription.subscription_type = plan_name
|
||||
subscription.billing_cycle = billing_cycle
|
||||
subscription.start_date = datetime.utcnow()
|
||||
subscription.end_date = datetime.utcnow() + timedelta(days=new_days)
|
||||
subscription.subscription_status = 'active'
|
||||
subscription.updated_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'升级成功!您的{plan_name.upper()}版本将持续{new_days}天',
|
||||
'data': {
|
||||
'subscription_type': plan_name,
|
||||
'end_date': subscription.end_date.isoformat(),
|
||||
'days': new_days
|
||||
}
|
||||
})
|
||||
else:
|
||||
return jsonify({'success': False, 'error': '价格计算异常'}), 500
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'success': False, 'error': f'升级失败: {str(e)}'}), 500
|
||||
|
||||
|
||||
@app.route('/api/payment/create-order', methods=['POST'])
|
||||
def create_payment_order():
|
||||
"""
|
||||
@@ -1715,6 +1906,15 @@ def create_payment_order():
|
||||
amount = price_result['final_amount']
|
||||
subscription_type = price_result.get('subscription_type', 'new') # new 或 renew
|
||||
|
||||
# 检查是否为免费升级(金额为0)
|
||||
if amount <= 0 and price_result.get('is_upgrade'):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': '当前剩余价值可直接免费升级,请使用免费升级功能',
|
||||
'should_free_upgrade': True,
|
||||
'price_info': price_result
|
||||
}), 400
|
||||
|
||||
# 创建订单
|
||||
try:
|
||||
order = PaymentOrder(
|
||||
|
||||
968
category_tree_openapi.json
Normal file
968
category_tree_openapi.json
Normal file
@@ -0,0 +1,968 @@
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"info": {
|
||||
"title": "化工商品分类树API",
|
||||
"description": "提供SMM和Mysteel化工商品数据的分类树状结构API接口。\n\n## 功能特点\n- 树状数据在服务启动时加载到内存,响应速度快\n- 支持获取完整分类树或按路径查询特定节点\n- SMM数据: 127,509个指标, 最大深度8层\n- Mysteel数据: 272,450个指标, 最大深度10层\n\n## 数据结构\n每个树节点包含:\n- name: 节点名称\n- path: 完整路径(用|分隔)\n- level: 层级深度\n- children: 子节点数组\n- metrics: 该节点下的指标列表(仅叶子节点)\n",
|
||||
"version": "1.0.0",
|
||||
"contact": {
|
||||
"name": "API Support"
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://localhost:18827",
|
||||
"description": "本地开发服务器"
|
||||
},
|
||||
{
|
||||
"url": "http://222.128.1.157:18827",
|
||||
"description": "生产服务器"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"name": "搜索",
|
||||
"description": "指标搜索相关接口"
|
||||
},
|
||||
{
|
||||
"name": "分类树",
|
||||
"description": "分类树状结构相关接口"
|
||||
},
|
||||
{
|
||||
"name": "数据查询",
|
||||
"description": "指标时间序列数据查询接口"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/search": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"搜索"
|
||||
],
|
||||
"summary": "搜索化工商品指标",
|
||||
"description": "基于Elasticsearch的多关键词模糊搜索,支持智能分词和相关度排序。\n\n## 功能特点\n- **多关键词搜索**: 支持空格分隔多个关键词,自动AND逻辑组合\n- **模糊匹配**: 自动容错1-2个字符的拼写错误\n- **多字段匹配**: 同时搜索指标名称、分类路径等多个字段\n- **相关度排序**: 自动按匹配度评分排序,最相关的结果排在前面\n- **灵活过滤**: 支持按数据源(SMM/Mysteel)和频率(日/周/月)过滤\n\n## 搜索字段权重\n- 指标名称(metric_name): 权重最高 (3x)\n- 分类层级(category_levels): 权重中等 (2x)\n- 分类路径(category_path): 权重中等 (2x)\n\n## 使用场景\n- 用户输入关键词快速查找指标\n- 自动补全和搜索建议\n- 按类别和数据源筛选指标\n\n## 搜索示例\n- 搜索\"电解液 产量\": 查找包含\"电解液\"和\"产量\"的指标\n- 搜索\"硫酸钴\": 查找所有硫酸钴相关指标\n- 搜索\"焦炭 价格 日\": 查找焦炭日度价格数据\n",
|
||||
"operationId": "searchMetrics",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "keywords",
|
||||
"in": "query",
|
||||
"description": "搜索关键词,支持空格分隔多个词。\n\n示例:\n- \"电解液 产量\" - 查找同时包含这两个词的指标\n- \"硫酸钴\" - 查找硫酸钴相关指标\n- \"焦炭 价格\" - 查找焦炭价格数据\n",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "电解液 产量"
|
||||
},
|
||||
{
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"description": "数据源过滤(可选)。\n\n- SMM: 上海有色网数据\n- Mysteel: 我的钢铁网数据\n- 不指定: 搜索所有数据源\n",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"SMM",
|
||||
"Mysteel"
|
||||
]
|
||||
},
|
||||
"example": "SMM"
|
||||
},
|
||||
{
|
||||
"name": "frequency",
|
||||
"in": "query",
|
||||
"description": "数据频率过滤(可选)。\n\n- 日: 日度数据\n- 周: 周度数据\n- 月: 月度数据\n- 不指定: 搜索所有频率\n",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"日",
|
||||
"周",
|
||||
"月"
|
||||
]
|
||||
},
|
||||
"example": "日"
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"in": "query",
|
||||
"description": "返回结果数量限制",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 1000,
|
||||
"default": 100
|
||||
},
|
||||
"example": 10
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功返回搜索结果",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SearchResponse"
|
||||
},
|
||||
"examples": {
|
||||
"基础搜索示例": {
|
||||
"value": {
|
||||
"total": 50,
|
||||
"query": "电解液 产量",
|
||||
"results": [
|
||||
{
|
||||
"source": "SMM",
|
||||
"metric_id": "12345",
|
||||
"metric_name": "SMM中国电解液月度产量",
|
||||
"unit": "吨",
|
||||
"frequency": "月",
|
||||
"category_path": "新能源|电解液|产量|SMM中国电解液月度产量",
|
||||
"description": "",
|
||||
"score": 15.8
|
||||
},
|
||||
{
|
||||
"source": "SMM",
|
||||
"metric_id": "12346",
|
||||
"metric_name": "SMM中国电解液周度产量",
|
||||
"unit": "吨",
|
||||
"frequency": "周",
|
||||
"category_path": "新能源|电解液|产量|SMM中国电解液周度产量",
|
||||
"description": "",
|
||||
"score": 14.2
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"过滤搜索示例": {
|
||||
"value": {
|
||||
"total": 15,
|
||||
"query": "硫酸钴",
|
||||
"results": [
|
||||
{
|
||||
"source": "SMM",
|
||||
"metric_id": "23456",
|
||||
"metric_name": "SMM中国硫酸钴月度产量",
|
||||
"unit": "吨",
|
||||
"frequency": "月",
|
||||
"category_path": "小金属|钴|钴化合物|硫酸钴|产量|SMM中国硫酸钴月度产量",
|
||||
"description": "",
|
||||
"score": 18.5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "请求参数错误",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"detail": "keywords参数不能为空"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "服务器内部错误",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"detail": "搜索服务暂时不可用"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/category-tree": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"分类树"
|
||||
],
|
||||
"summary": "获取分类树(支持深度控制)",
|
||||
"description": "获取指定数据源的分类树状结构,支持深度控制。\n\n## 使用场景\n- 前端树形组件初始化(默认只加载第一层)\n- 懒加载:用户展开时再加载下一层\n- 级联选择器数据源\n\n## 默认行为\n- **默认只返回第一层** (max_depth=1),大幅减少数据传输量\n- SMM第一层约43个节点,Mysteel第一层约2个节点\n- 完整树数据量: SMM约53MB, Mysteel约152MB\n\n## 推荐用法\n1. 首次加载:不传max_depth(默认1层)\n2. 用户点击节点:调用 /api/category-tree/node 获取子节点\n",
|
||||
"operationId": "getCategoryTree",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"description": "数据源类型",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"SMM",
|
||||
"Mysteel"
|
||||
]
|
||||
},
|
||||
"example": "SMM"
|
||||
},
|
||||
{
|
||||
"name": "max_depth",
|
||||
"in": "query",
|
||||
"description": "返回的最大层级深度\n- 1: 只返回第一层(默认,推荐)\n- 2: 返回前两层\n- 999: 返回完整树(不推荐,数据量大)\n",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
"default": 1
|
||||
},
|
||||
"example": 1
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功返回分类树",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CategoryTreeResponse"
|
||||
},
|
||||
"examples": {
|
||||
"SMM示例": {
|
||||
"value": {
|
||||
"source": "SMM",
|
||||
"total_metrics": 127509,
|
||||
"tree": [
|
||||
{
|
||||
"name": "农业食品农资",
|
||||
"path": "农业食品农资",
|
||||
"level": 1,
|
||||
"children": [
|
||||
{
|
||||
"name": "饲料",
|
||||
"path": "农业食品农资|饲料",
|
||||
"level": 2,
|
||||
"children": [],
|
||||
"metrics": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Mysteel示例": {
|
||||
"value": {
|
||||
"source": "Mysteel",
|
||||
"total_metrics": 272450,
|
||||
"tree": [
|
||||
{
|
||||
"name": "钢铁产业",
|
||||
"path": "钢铁产业",
|
||||
"level": 1,
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "未找到指定数据源",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"detail": "未找到数据源 'XXX' 的树状数据。可用数据源: SMM, Mysteel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "服务器内部错误",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/category-tree/node": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"分类树"
|
||||
],
|
||||
"summary": "获取特定节点及其子树",
|
||||
"description": "根据路径获取树中的特定节点及其所有子节点。\n\n## 使用场景\n- 懒加载:用户点击节点时动态加载子节点\n- 子树查询:获取某个分类下的所有数据\n- 面包屑导航:根据路径定位节点\n\n## 路径格式\n使用竖线(|)分隔层级,例如:\n- 一级: \"钴\"\n- 二级: \"钴|钴化合物\"\n- 三级: \"钴|钴化合物|硫酸钴\"\n",
|
||||
"operationId": "getCategoryTreeNode",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "path",
|
||||
"in": "query",
|
||||
"description": "节点完整路径,用竖线(|)分隔",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "钴|钴化合物|硫酸钴"
|
||||
},
|
||||
{
|
||||
"name": "source",
|
||||
"in": "query",
|
||||
"description": "数据源类型",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"SMM",
|
||||
"Mysteel"
|
||||
]
|
||||
},
|
||||
"example": "SMM"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功返回节点数据",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TreeNode"
|
||||
},
|
||||
"example": {
|
||||
"name": "硫酸钴",
|
||||
"path": "钴|钴化合物|硫酸钴",
|
||||
"level": 3,
|
||||
"children": [
|
||||
{
|
||||
"name": "产量",
|
||||
"path": "钴|钴化合物|硫酸钴|产量",
|
||||
"level": 4,
|
||||
"children": [],
|
||||
"metrics": [
|
||||
{
|
||||
"metric_id": "12345",
|
||||
"metric_name": "SMM中国硫酸钴月度产量",
|
||||
"source": "SMM",
|
||||
"frequency": "月",
|
||||
"unit": "吨",
|
||||
"description": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "未找到指定路径的节点或数据源",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"examples": {
|
||||
"节点不存在": {
|
||||
"value": {
|
||||
"detail": "未找到路径 '钴|不存在的节点' 对应的节点"
|
||||
}
|
||||
},
|
||||
"数据源不存在": {
|
||||
"value": {
|
||||
"detail": "未找到数据源 'XXX' 的树状数据。可用数据源: SMM, Mysteel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "服务器内部错误",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/metric-data": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"数据查询"
|
||||
],
|
||||
"summary": "获取指标时间序列数据",
|
||||
"description": "根据指标ID查询历史时间序列数据,自动识别数据源(SMM或Mysteel)。\n\n## 功能特点\n- **自动识别数据源**: 无需指定source参数,系统自动查找\n- **灵活的日期范围**: 支持可选的开始/结束日期过滤\n- **数据限制**: 支持limit参数控制返回数据量\n\n## 日期格式支持\n- YYYY-MM-DD (推荐): \"2024-01-01\"\n- YYYYMMDD: \"20240101\"\n- YYYYMMDDHHmmss: \"20240101000000\"(只取日期部分)\n\n## 使用场景\n- 用户点击树节点查看指标数据\n- 图表展示时间序列数据\n- 数据导出和分析\n",
|
||||
"operationId": "getMetricData",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "metric_id",
|
||||
"in": "query",
|
||||
"description": "指标唯一ID",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "12345"
|
||||
},
|
||||
{
|
||||
"name": "start_date",
|
||||
"in": "query",
|
||||
"description": "开始日期(可选),格式 YYYY-MM-DD 或 YYYYMMDD",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "2024-01-01"
|
||||
},
|
||||
{
|
||||
"name": "end_date",
|
||||
"in": "query",
|
||||
"description": "结束日期(可选),格式 YYYY-MM-DD 或 YYYYMMDD",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "2024-12-31"
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "返回数据条数限制(1-10000)",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 10000,
|
||||
"default": 100
|
||||
},
|
||||
"example": 100
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "成功返回指标数据",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/MetricDataResponse"
|
||||
},
|
||||
"examples": {
|
||||
"SMM数据示例": {
|
||||
"value": {
|
||||
"metric_id": "12345",
|
||||
"metric_name": "SMM中国硫酸钴月度产量",
|
||||
"source": "SMM",
|
||||
"frequency": "月",
|
||||
"unit": "吨",
|
||||
"data": [
|
||||
{
|
||||
"date": "2024-12-01",
|
||||
"value": 12500.5
|
||||
},
|
||||
{
|
||||
"date": "2024-11-01",
|
||||
"value": 12300.0
|
||||
},
|
||||
{
|
||||
"date": "2024-10-01",
|
||||
"value": 12100.8
|
||||
}
|
||||
],
|
||||
"total_count": 120
|
||||
}
|
||||
},
|
||||
"Mysteel数据示例": {
|
||||
"value": {
|
||||
"metric_id": "A0101010",
|
||||
"metric_name": "唐山焦炭价格",
|
||||
"source": "MYSTEEL",
|
||||
"frequency": "日",
|
||||
"unit": "元/吨",
|
||||
"data": [
|
||||
{
|
||||
"date": "2024-12-20",
|
||||
"value": 2350.0
|
||||
},
|
||||
{
|
||||
"date": "2024-12-19",
|
||||
"value": 2340.0
|
||||
}
|
||||
],
|
||||
"total_count": 365
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "未找到指定指标",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"detail": "未找到指标: metric_id=99999"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "请求参数错误",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"detail": "limit参数必须在1-10000之间"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "服务器内部错误",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorResponse"
|
||||
},
|
||||
"example": {
|
||||
"detail": "查询数据失败: [具体错误信息]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"SearchResponse": {
|
||||
"type": "object",
|
||||
"description": "搜索结果响应对象",
|
||||
"required": [
|
||||
"total",
|
||||
"query",
|
||||
"results"
|
||||
],
|
||||
"properties": {
|
||||
"total": {
|
||||
"type": "integer",
|
||||
"description": "搜索结果总数",
|
||||
"example": 50
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "查询关键词",
|
||||
"example": "电解液 产量"
|
||||
},
|
||||
"results": {
|
||||
"type": "array",
|
||||
"description": "指标列表(按相关度评分降序)",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MetricInfo"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"MetricInfo": {
|
||||
"type": "object",
|
||||
"description": "指标信息对象",
|
||||
"required": [
|
||||
"source",
|
||||
"metric_id",
|
||||
"metric_name",
|
||||
"unit",
|
||||
"frequency",
|
||||
"category_path"
|
||||
],
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "数据源",
|
||||
"enum": [
|
||||
"SMM",
|
||||
"Mysteel"
|
||||
],
|
||||
"example": "SMM"
|
||||
},
|
||||
"metric_id": {
|
||||
"type": "string",
|
||||
"description": "指标唯一ID",
|
||||
"example": "12345"
|
||||
},
|
||||
"metric_name": {
|
||||
"type": "string",
|
||||
"description": "指标名称",
|
||||
"example": "SMM中国硫酸钴月度产量"
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"description": "数据单位",
|
||||
"example": "吨"
|
||||
},
|
||||
"frequency": {
|
||||
"type": "string",
|
||||
"description": "数据频率",
|
||||
"enum": [
|
||||
"日",
|
||||
"周",
|
||||
"月"
|
||||
],
|
||||
"example": "月"
|
||||
},
|
||||
"category_path": {
|
||||
"type": "string",
|
||||
"description": "完整分类路径(用|分隔)",
|
||||
"example": "小金属|钴|钴化合物|硫酸钴|产量|SMM中国硫酸钴月度产量"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "指标描述备注",
|
||||
"example": ""
|
||||
},
|
||||
"score": {
|
||||
"type": "number",
|
||||
"description": "搜索相关度评分(仅搜索结果返回)",
|
||||
"nullable": true,
|
||||
"example": 15.8
|
||||
}
|
||||
}
|
||||
},
|
||||
"CategoryTreeResponse": {
|
||||
"type": "object",
|
||||
"description": "分类树响应对象",
|
||||
"required": [
|
||||
"source",
|
||||
"total_metrics",
|
||||
"tree"
|
||||
],
|
||||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "数据源名称",
|
||||
"enum": [
|
||||
"SMM",
|
||||
"Mysteel"
|
||||
],
|
||||
"example": "SMM"
|
||||
},
|
||||
"total_metrics": {
|
||||
"type": "integer",
|
||||
"description": "总指标数量",
|
||||
"example": 127509
|
||||
},
|
||||
"tree": {
|
||||
"type": "array",
|
||||
"description": "树的根节点列表",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TreeNode"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TreeNode": {
|
||||
"type": "object",
|
||||
"description": "树节点对象",
|
||||
"required": [
|
||||
"name",
|
||||
"path",
|
||||
"level",
|
||||
"has_children"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "节点名称",
|
||||
"example": "钴"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "节点完整路径,用竖线分隔",
|
||||
"example": "钴|钴化合物|硫酸钴"
|
||||
},
|
||||
"level": {
|
||||
"type": "integer",
|
||||
"description": "节点层级深度(从1开始)",
|
||||
"minimum": 1,
|
||||
"example": 3
|
||||
},
|
||||
"has_children": {
|
||||
"type": "boolean",
|
||||
"description": "是否有子节点(用于前端判断是否可展开)",
|
||||
"example": true
|
||||
},
|
||||
"children": {
|
||||
"type": "array",
|
||||
"description": "子节点列表(根据max_depth可能为空数组)",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TreeNode"
|
||||
}
|
||||
},
|
||||
"metrics": {
|
||||
"type": "array",
|
||||
"description": "该节点下的指标列表(通常只有叶子节点有)",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TreeMetric"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TreeMetric": {
|
||||
"type": "object",
|
||||
"description": "树节点中的指标信息",
|
||||
"required": [
|
||||
"metric_id",
|
||||
"metric_name",
|
||||
"source",
|
||||
"frequency",
|
||||
"unit"
|
||||
],
|
||||
"properties": {
|
||||
"metric_id": {
|
||||
"type": "string",
|
||||
"description": "指标唯一ID",
|
||||
"example": "12345"
|
||||
},
|
||||
"metric_name": {
|
||||
"type": "string",
|
||||
"description": "指标名称",
|
||||
"example": "SMM中国硫酸钴月度产量"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "数据源",
|
||||
"enum": [
|
||||
"SMM",
|
||||
"Mysteel"
|
||||
],
|
||||
"example": "SMM"
|
||||
},
|
||||
"frequency": {
|
||||
"type": "string",
|
||||
"description": "数据频率",
|
||||
"enum": [
|
||||
"日",
|
||||
"周",
|
||||
"月"
|
||||
],
|
||||
"example": "月"
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"description": "指标单位",
|
||||
"example": "吨"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "指标描述",
|
||||
"example": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"MetricDataResponse": {
|
||||
"type": "object",
|
||||
"description": "指标数据查询响应对象",
|
||||
"required": [
|
||||
"metric_id",
|
||||
"metric_name",
|
||||
"source",
|
||||
"frequency",
|
||||
"unit",
|
||||
"data",
|
||||
"total_count"
|
||||
],
|
||||
"properties": {
|
||||
"metric_id": {
|
||||
"type": "string",
|
||||
"description": "指标唯一ID",
|
||||
"example": "12345"
|
||||
},
|
||||
"metric_name": {
|
||||
"type": "string",
|
||||
"description": "指标名称",
|
||||
"example": "SMM中国硫酸钴月度产量"
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "数据源",
|
||||
"enum": [
|
||||
"SMM",
|
||||
"MYSTEEL"
|
||||
],
|
||||
"example": "SMM"
|
||||
},
|
||||
"frequency": {
|
||||
"type": "string",
|
||||
"description": "数据频率",
|
||||
"enum": [
|
||||
"日",
|
||||
"周",
|
||||
"月"
|
||||
],
|
||||
"example": "月"
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"description": "数据单位",
|
||||
"example": "吨"
|
||||
},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"description": "时间序列数据点列表(按日期倒序)",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/DataPoint"
|
||||
}
|
||||
},
|
||||
"total_count": {
|
||||
"type": "integer",
|
||||
"description": "符合条件的数据总条数",
|
||||
"example": 120
|
||||
}
|
||||
}
|
||||
},
|
||||
"DataPoint": {
|
||||
"type": "object",
|
||||
"description": "单个数据点",
|
||||
"required": [
|
||||
"date",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"date": {
|
||||
"type": "string",
|
||||
"description": "日期,格式 YYYY-MM-DD",
|
||||
"example": "2024-01-01"
|
||||
},
|
||||
"value": {
|
||||
"type": "number",
|
||||
"description": "数值(可能为null)",
|
||||
"nullable": true,
|
||||
"example": 1234.56
|
||||
}
|
||||
}
|
||||
},
|
||||
"ErrorResponse": {
|
||||
"type": "object",
|
||||
"description": "错误响应对象",
|
||||
"required": [
|
||||
"detail"
|
||||
],
|
||||
"properties": {
|
||||
"detail": {
|
||||
"type": "string",
|
||||
"description": "错误详细信息",
|
||||
"example": "未找到数据源 'XXX' 的树状数据"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"SMM完整树示例": {
|
||||
"summary": "SMM完整树结构示例",
|
||||
"value": {
|
||||
"source": "SMM",
|
||||
"total_metrics": 127509,
|
||||
"tree": [
|
||||
{
|
||||
"name": "农业食品农资",
|
||||
"path": "农业食品农资",
|
||||
"level": 1,
|
||||
"children": [
|
||||
{
|
||||
"name": "饲料",
|
||||
"path": "农业食品农资|饲料",
|
||||
"level": 2,
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "小金属",
|
||||
"path": "小金属",
|
||||
"level": 1,
|
||||
"children": [
|
||||
{
|
||||
"name": "钴",
|
||||
"path": "小金属|钴",
|
||||
"level": 2,
|
||||
"children": [
|
||||
{
|
||||
"name": "钴化合物",
|
||||
"path": "小金属|钴|钴化合物",
|
||||
"level": 3,
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"Mysteel完整树示例": {
|
||||
"summary": "Mysteel完整树结构示例",
|
||||
"value": {
|
||||
"source": "Mysteel",
|
||||
"total_metrics": 272450,
|
||||
"tree": [
|
||||
{
|
||||
"name": "钢铁产业",
|
||||
"path": "钢铁产业",
|
||||
"level": 1,
|
||||
"children": [
|
||||
{
|
||||
"name": "原材料",
|
||||
"path": "钢铁产业|原材料",
|
||||
"level": 2,
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"节点查询示例": {
|
||||
"summary": "节点查询返回示例",
|
||||
"value": {
|
||||
"name": "钴化合物",
|
||||
"path": "小金属|钴|钴化合物",
|
||||
"level": 3,
|
||||
"children": [
|
||||
{
|
||||
"name": "硫酸钴",
|
||||
"path": "小金属|钴|钴化合物|硫酸钴",
|
||||
"level": 4,
|
||||
"metrics": [
|
||||
{
|
||||
"metric_id": "12345",
|
||||
"metric_name": "SMM中国硫酸钴月度产量",
|
||||
"source": "SMM",
|
||||
"frequency": "月",
|
||||
"unit": "吨",
|
||||
"description": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,13 @@ module.exports = {
|
||||
priority: 30,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
// TradingView Lightweight Charts 单独分离(避免被压缩破坏)
|
||||
lightweightCharts: {
|
||||
test: /[\\/]node_modules[\\/]lightweight-charts[\\/]/,
|
||||
name: 'lightweight-charts',
|
||||
priority: 26,
|
||||
reuseExistingChunk: true,
|
||||
},
|
||||
// 大型图表库分离(echarts, d3, apexcharts 等)
|
||||
charts: {
|
||||
test: /[\\/]node_modules[\\/](echarts|echarts-for-react|apexcharts|react-apexcharts|recharts|d3|d3-.*)[\\/]/,
|
||||
@@ -96,8 +103,43 @@ module.exports = {
|
||||
moduleIds: 'deterministic',
|
||||
// 最小化配置
|
||||
minimize: true,
|
||||
minimizer: [
|
||||
...webpackConfig.optimization.minimizer,
|
||||
],
|
||||
};
|
||||
|
||||
// 配置 Terser 插件,保留 lightweight-charts 的方法名
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
webpackConfig.optimization.minimizer = webpackConfig.optimization.minimizer.map(plugin => {
|
||||
if (plugin.constructor.name === 'TerserPlugin') {
|
||||
const originalOptions = plugin.options || {};
|
||||
const originalTerserOptions = originalOptions.terserOptions || {};
|
||||
const originalMangle = originalTerserOptions.mangle || {};
|
||||
|
||||
// 只保留 TerserPlugin 有效的配置项
|
||||
const validOptions = {
|
||||
test: originalOptions.test,
|
||||
include: originalOptions.include,
|
||||
exclude: originalOptions.exclude,
|
||||
extractComments: originalOptions.extractComments,
|
||||
parallel: originalOptions.parallel,
|
||||
minify: originalOptions.minify,
|
||||
terserOptions: {
|
||||
...originalTerserOptions,
|
||||
keep_classnames: /^(IChartApi|ISeriesApi|Re)$/, // 保留 lightweight-charts 的类名
|
||||
keep_fnames: /^(createChart|addLineSeries|addSeries)$/, // 保留关键方法名
|
||||
mangle: {
|
||||
...originalMangle,
|
||||
reserved: ['createChart', 'addLineSeries', 'addSeries', 'IChartApi', 'ISeriesApi'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return new TerserPlugin(validOptions);
|
||||
}
|
||||
return plugin;
|
||||
});
|
||||
|
||||
// 生产环境禁用 source map 以加快构建(可节省 40-60% 时间)
|
||||
webpackConfig.devtool = false;
|
||||
} else {
|
||||
|
||||
@@ -1,427 +0,0 @@
|
||||
-- ============================================
|
||||
-- 订阅支付系统数据库迁移 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 '';
|
||||
918
docs/BYTEDESK_INTEGRATION_GUIDE.md
Normal file
918
docs/BYTEDESK_INTEGRATION_GUIDE.md
Normal file
@@ -0,0 +1,918 @@
|
||||
# Bytedesk客服系统 - 前端工程师集成手册
|
||||
|
||||
**版本**: v1.0
|
||||
**最后更新**: 2025-01-07
|
||||
**适用项目**: vf_react
|
||||
**后端服务器**: http://43.143.189.195
|
||||
|
||||
---
|
||||
|
||||
## 📋 目录
|
||||
|
||||
- [1. 集成概述](#1-集成概述)
|
||||
- [2. 快速开始(5分钟集成)](#2-快速开始5分钟集成)
|
||||
- [3. 详细集成步骤](#3-详细集成步骤)
|
||||
- [4. 配置说明](#4-配置说明)
|
||||
- [5. 高级功能](#5-高级功能)
|
||||
- [6. 样式定制](#6-样式定制)
|
||||
- [7. 故障排查](#7-故障排查)
|
||||
- [8. 常见问题FAQ](#8-常见问题faq)
|
||||
- [9. 性能优化](#9-性能优化)
|
||||
- [10. 安全注意事项](#10-安全注意事项)
|
||||
|
||||
---
|
||||
|
||||
## 1. 集成概述
|
||||
|
||||
### 1.1 什么是Bytedesk客服系统?
|
||||
|
||||
Bytedesk是一个开源的在线客服系统,为您的网站提供实时客户服务功能。本手册将指导您将Bytedesk客服Widget集成到vf_react项目中。
|
||||
|
||||
### 1.2 集成架构
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ vf_react前端项目 │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ App.jsx │ │
|
||||
│ │ ┌──────────────────────────────────────────────┐ │ │
|
||||
│ │ │ BytedeskWidget组件 │ │ │
|
||||
│ │ │ - 动态加载客服脚本 │ │ │
|
||||
│ │ │ - 显示悬浮客服图标 │ │ │
|
||||
│ │ │ - 处理用户交互 │ │ │
|
||||
│ │ └──────────────────────────────────────────────┘ │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ HTTP/WebSocket
|
||||
↓
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ Bytedesk后端服务 (43.143.189.195) │
|
||||
│ - API接口: :9003 │
|
||||
│ - WebSocket: :9885 │
|
||||
│ - Nginx反向代理: :80 │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.3 集成特点
|
||||
|
||||
- ✅ **零侵入**: 不修改vf_react原有代码逻辑
|
||||
- ✅ **即插即用**: 复制文件 + 修改配置即可使用
|
||||
- ✅ **样式隔离**: 使用Shadow DOM,不影响全局样式
|
||||
- ✅ **异步加载**: 不阻塞页面渲染
|
||||
- ✅ **跨页面**: 在所有页面显示客服图标
|
||||
- ✅ **响应式**: 自动适配移动端和PC端
|
||||
|
||||
---
|
||||
|
||||
## 2. 快速开始(5分钟集成)
|
||||
|
||||
### 步骤1: 复制集成文件
|
||||
|
||||
将`bytedesk-integration`文件夹复制到vf_react项目的`src/`目录下:
|
||||
|
||||
```bash
|
||||
# 在vf_react项目根目录执行
|
||||
cd D:\【Git】\vf_react
|
||||
cp -r bytedesk-integration src/
|
||||
```
|
||||
|
||||
文件结构:
|
||||
```
|
||||
vf_react/
|
||||
├── src/
|
||||
│ ├── bytedesk-integration/ # 客服集成文件夹
|
||||
│ │ ├── components/
|
||||
│ │ │ └── BytedeskWidget.jsx # 客服Widget组件
|
||||
│ │ ├── config/
|
||||
│ │ │ └── bytedesk.config.js # 配置文件
|
||||
│ │ ├── App.jsx.example # 集成示例代码
|
||||
│ │ ├── .env.bytedesk.example # 环境变量示例
|
||||
│ │ └── 前端工程师集成手册.md # 本手册
|
||||
│ ├── App.jsx # 您的主App文件
|
||||
│ └── ...
|
||||
└── package.json
|
||||
```
|
||||
|
||||
### 步骤2: 配置环境变量
|
||||
|
||||
复制环境变量模板到项目根目录并配置:
|
||||
|
||||
```bash
|
||||
# 复制模板
|
||||
cp src/bytedesk-integration/.env.bytedesk.example .env.local
|
||||
|
||||
# 编辑配置文件
|
||||
vim .env.local
|
||||
```
|
||||
|
||||
**必需配置项**(在.env.local中):
|
||||
```bash
|
||||
# Bytedesk服务器地址
|
||||
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
|
||||
|
||||
# 组织ID(由管理员提供)
|
||||
REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
|
||||
# 工作组ID(由管理员提供)
|
||||
REACT_APP_BYTEDESK_SID=df_wg_aftersales
|
||||
```
|
||||
|
||||
> **注意**: ORG和SID需要从管理员处获取,或登录后台http://43.143.189.195/admin/查看。
|
||||
|
||||
### 步骤3: 集成到App.jsx
|
||||
|
||||
打开`src/App.jsx`,参考`App.jsx.example`添加以下代码:
|
||||
|
||||
```jsx
|
||||
// 1. 导入组件和配置(在文件顶部添加)
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
// 2. 获取配置
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的原有代码保持不变 */}
|
||||
|
||||
{/* 3. 添加客服Widget(在return的JSX最后添加) */}
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
```
|
||||
|
||||
### 步骤4: 启动项目测试
|
||||
|
||||
```bash
|
||||
# 安装依赖(如果需要)
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npm start
|
||||
```
|
||||
|
||||
打开浏览器,您应该在页面右下角看到客服图标(💬)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 详细集成步骤
|
||||
|
||||
### 3.1 文件说明
|
||||
|
||||
#### BytedeskWidget.jsx
|
||||
React组件,负责加载和管理Bytedesk客服Widget。
|
||||
|
||||
**主要功能**:
|
||||
- 动态加载客服脚本(https://www.weiyuai.cn/embed/bytedesk-web.js)
|
||||
- 初始化客服Widget
|
||||
- 生命周期管理(加载、卸载、清理)
|
||||
- 错误处理
|
||||
|
||||
**Props**:
|
||||
```typescript
|
||||
interface BytedeskWidgetProps {
|
||||
config: Object; // 配置对象(必需)
|
||||
autoLoad?: boolean; // 是否自动加载(默认true)
|
||||
onLoad?: (bytedesk) => void; // 加载成功回调
|
||||
onError?: (error) => void; // 加载失败回调
|
||||
}
|
||||
```
|
||||
|
||||
#### bytedesk.config.js
|
||||
配置文件,包含客服系统的所有配置项。
|
||||
|
||||
**主要函数**:
|
||||
- `getBytedeskConfig()`: 获取基础配置
|
||||
- `getBytedeskConfigWithUser(user)`: 获取带用户信息的配置
|
||||
- `shouldShowCustomerService(pathname)`: 判断是否在当前页面显示客服
|
||||
|
||||
### 3.2 集成方式选择
|
||||
|
||||
根据您的需求,选择合适的集成方式:
|
||||
|
||||
#### 方式一: 全局集成(推荐)
|
||||
|
||||
**适用场景**: 所有页面都需要客服功能
|
||||
|
||||
```jsx
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的页面内容 */}
|
||||
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 方式二: 按页面显示
|
||||
|
||||
**适用场景**: 只在特定页面显示客服(如排除登录页、支付页)
|
||||
|
||||
```jsx
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
const showBytedesk = shouldShowCustomerService(location.pathname);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的页面内容 */}
|
||||
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
自定义页面规则(修改`bytedesk.config.js`):
|
||||
|
||||
```javascript
|
||||
export const shouldShowCustomerService = (pathname) => {
|
||||
// 在以下页面显示客服
|
||||
const allowedPages = [
|
||||
'/',
|
||||
'/home',
|
||||
'/products',
|
||||
'/pricing',
|
||||
];
|
||||
|
||||
// 在以下页面隐藏客服
|
||||
const blockedPages = [
|
||||
'/login',
|
||||
'/register',
|
||||
'/payment',
|
||||
];
|
||||
|
||||
if (blockedPages.some(page => pathname.startsWith(page))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return allowedPages.some(page => pathname.startsWith(page));
|
||||
};
|
||||
```
|
||||
|
||||
#### 方式三: 带用户信息集成
|
||||
|
||||
**适用场景**: 需要将登录用户信息传递给客服端
|
||||
|
||||
```jsx
|
||||
import { useContext } from 'react';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfigWithUser } from './bytedesk-integration/config/bytedesk.config';
|
||||
import { AuthContext } from './contexts/AuthContext';
|
||||
|
||||
function App() {
|
||||
const { user } = useContext(AuthContext);
|
||||
const bytedeskConfig = getBytedeskConfigWithUser(user);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的页面内容 */}
|
||||
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
用户信息格式:
|
||||
```javascript
|
||||
const user = {
|
||||
id: '12345', // 用户ID(必需)
|
||||
name: '张三', // 用户名
|
||||
email: 'user@example.com', // 邮箱
|
||||
mobile: '13800138000', // 手机号
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 配置说明
|
||||
|
||||
### 4.1 环境变量配置
|
||||
|
||||
在`.env.local`文件中配置(项目根目录):
|
||||
|
||||
```bash
|
||||
# ========== 必需配置 ==========
|
||||
|
||||
# 后端服务地址
|
||||
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
|
||||
|
||||
# 组织ID
|
||||
REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
|
||||
# 工作组ID
|
||||
REACT_APP_BYTEDESK_SID=df_wg_aftersales
|
||||
|
||||
# ========== 可选配置 ==========
|
||||
|
||||
# 客服类型 (2=人工客服, 1=机器人)
|
||||
REACT_APP_BYTEDESK_TYPE=2
|
||||
|
||||
# 语言 (zh-cn, en, ja, ko)
|
||||
REACT_APP_BYTEDESK_LOCALE=zh-cn
|
||||
|
||||
# 图标位置 (bottom-right, bottom-left, top-right, top-left)
|
||||
REACT_APP_BYTEDESK_PLACEMENT=bottom-right
|
||||
|
||||
# 图标边距(像素)
|
||||
REACT_APP_BYTEDESK_MARGIN_BOTTOM=20
|
||||
REACT_APP_BYTEDESK_MARGIN_SIDE=20
|
||||
|
||||
# 主题模式 (system, light, dark)
|
||||
REACT_APP_BYTEDESK_THEME_MODE=system
|
||||
|
||||
# 主题色
|
||||
REACT_APP_BYTEDESK_THEME_COLOR=#0066FF
|
||||
|
||||
# 自动弹出(不推荐)
|
||||
REACT_APP_BYTEDESK_AUTO_POPUP=false
|
||||
```
|
||||
|
||||
### 4.2 代码配置
|
||||
|
||||
在`bytedesk.config.js`中直接修改:
|
||||
|
||||
```javascript
|
||||
export const bytedeskConfig = {
|
||||
// API服务地址
|
||||
apiUrl: 'http://43.143.189.195',
|
||||
htmlUrl: 'http://43.143.189.195/chat/',
|
||||
|
||||
// 客服图标位置
|
||||
placement: 'bottom-right',
|
||||
|
||||
// 边距设置
|
||||
marginBottom: 20,
|
||||
marginSide: 20,
|
||||
|
||||
// 自动弹出
|
||||
autoPopup: false,
|
||||
|
||||
// 语言设置
|
||||
locale: 'zh-cn',
|
||||
|
||||
// 客服图标配置
|
||||
bubbleConfig: {
|
||||
show: true,
|
||||
icon: '💬', // 可以使用emoji或图片URL
|
||||
title: '在线客服',
|
||||
subtitle: '点击咨询',
|
||||
},
|
||||
|
||||
// 主题配置
|
||||
theme: {
|
||||
mode: 'system', // light | dark | system
|
||||
backgroundColor: '#0066FF',
|
||||
textColor: '#ffffff',
|
||||
},
|
||||
|
||||
// 聊天配置
|
||||
chatConfig: {
|
||||
org: 'df_org_uid',
|
||||
t: '2', // 2=人工客服, 1=机器人
|
||||
sid: 'df_wg_aftersales',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 高级功能
|
||||
|
||||
### 5.1 多工作组支持
|
||||
|
||||
根据页面显示不同工作组的客服:
|
||||
|
||||
```javascript
|
||||
// bytedesk.config.js
|
||||
export const getBytedeskConfigByPath = (pathname) => {
|
||||
const config = getBytedeskConfig();
|
||||
|
||||
// 根据路径选择工作组
|
||||
if (pathname.startsWith('/sales')) {
|
||||
return {
|
||||
...config,
|
||||
chatConfig: {
|
||||
...config.chatConfig,
|
||||
sid: 'df_wg_sales', // 销售组
|
||||
},
|
||||
};
|
||||
} else if (pathname.startsWith('/support')) {
|
||||
return {
|
||||
...config,
|
||||
chatConfig: {
|
||||
...config.chatConfig,
|
||||
sid: 'df_wg_support', // 技术支持组
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return config; // 默认售后组
|
||||
};
|
||||
```
|
||||
|
||||
使用示例:
|
||||
```jsx
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { getBytedeskConfigByPath } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
const location = useLocation();
|
||||
const bytedeskConfig = getBytedeskConfigByPath(location.pathname);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 条件性显示
|
||||
|
||||
根据用户登录状态或角色显示客服:
|
||||
|
||||
```jsx
|
||||
function App() {
|
||||
const { user } = useContext(AuthContext);
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
// 只为普通用户显示客服(管理员不显示)
|
||||
const showBytedesk = user && user.role === 'customer';
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 事件回调
|
||||
|
||||
监听客服系统的加载状态:
|
||||
|
||||
```jsx
|
||||
function App() {
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
const handleLoad = (bytedesk) => {
|
||||
console.log('客服系统加载成功', bytedesk);
|
||||
// 可以在这里执行自定义逻辑
|
||||
// 例如: 发送统计事件
|
||||
};
|
||||
|
||||
const handleError = (error) => {
|
||||
console.error('客服系统加载失败', error);
|
||||
// 可以在这里显示降级方案
|
||||
// 例如: 显示备用联系方式
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 自定义触发按钮
|
||||
|
||||
隐藏默认图标,使用自定义按钮:
|
||||
|
||||
```jsx
|
||||
import { useState } from 'react';
|
||||
|
||||
function App() {
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
|
||||
// 隐藏默认图标
|
||||
const bytedeskConfig = {
|
||||
...getBytedeskConfig(),
|
||||
bubbleConfig: {
|
||||
show: false, // 隐藏默认图标
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 自定义按钮 */}
|
||||
<button
|
||||
onClick={() => setShowBytedesk(true)}
|
||||
className="custom-service-btn"
|
||||
>
|
||||
联系客服
|
||||
</button>
|
||||
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget config={bytedeskConfig} autoLoad={true} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 样式定制
|
||||
|
||||
### 6.1 修改主题色
|
||||
|
||||
在配置中修改主题色:
|
||||
|
||||
```javascript
|
||||
// bytedesk.config.js
|
||||
theme: {
|
||||
mode: 'light',
|
||||
backgroundColor: '#FF6600', // 您的品牌色
|
||||
textColor: '#ffffff',
|
||||
},
|
||||
```
|
||||
|
||||
### 6.2 修改图标位置
|
||||
|
||||
```javascript
|
||||
// bytedesk.config.js
|
||||
placement: 'bottom-left', // 左下角
|
||||
marginBottom: 30, // 距底部30px
|
||||
marginSide: 30, // 距左侧30px
|
||||
```
|
||||
|
||||
### 6.3 使用自定义图标
|
||||
|
||||
使用图片URL替换emoji:
|
||||
|
||||
```javascript
|
||||
// bytedesk.config.js
|
||||
bubbleConfig: {
|
||||
show: true,
|
||||
icon: 'https://yourdomain.com/images/service-icon.png',
|
||||
title: '在线客服',
|
||||
subtitle: '点击咨询',
|
||||
},
|
||||
```
|
||||
|
||||
### 6.4 样式不冲突
|
||||
|
||||
Bytedesk Widget使用Shadow DOM技术,样式完全隔离,不会影响您的全局CSS。
|
||||
|
||||
---
|
||||
|
||||
## 7. 故障排查
|
||||
|
||||
### 7.1 客服图标不显示
|
||||
|
||||
**可能原因**:
|
||||
1. 环境变量未配置
|
||||
2. 配置文件路径错误
|
||||
3. 后端服务未启动
|
||||
4. 脚本加载失败
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 1. 检查.env.local文件是否存在
|
||||
ls -la .env.local
|
||||
|
||||
# 2. 检查环境变量是否加载
|
||||
console.log(process.env.REACT_APP_BYTEDESK_API_URL);
|
||||
|
||||
# 3. 检查后端服务状态
|
||||
curl http://43.143.189.195/api/health
|
||||
|
||||
# 4. 查看浏览器控制台错误
|
||||
# 打开浏览器开发者工具 -> Console标签页
|
||||
```
|
||||
|
||||
### 7.2 连接不上后端
|
||||
|
||||
**检查清单**:
|
||||
```bash
|
||||
# 1. 后端服务是否运行
|
||||
# 联系后端工程师确认docker容器状态
|
||||
|
||||
# 2. 防火墙是否开放
|
||||
# 确认80端口可访问
|
||||
|
||||
# 3. CORS配置
|
||||
# 后端需要在.env.production中添加您的前端地址:
|
||||
# BYTEDESK_CORS_ALLOWED_ORIGINS=http://your-frontend-domain.com
|
||||
```
|
||||
|
||||
### 7.3 ORG或SID错误
|
||||
|
||||
**获取正确配置**:
|
||||
1. 登录管理后台: http://43.143.189.195/admin/
|
||||
2. 导航到"设置" -> "组织信息",复制`组织UID`
|
||||
3. 导航到"客服管理" -> "工作组",复制`工作组ID`
|
||||
4. 更新`.env.local`文件
|
||||
5. 重启开发服务器: `npm start`
|
||||
|
||||
### 7.4 开发环境正常,生产环境异常
|
||||
|
||||
**检查清单**:
|
||||
```bash
|
||||
# 1. 确认生产环境的环境变量
|
||||
# 查看构建时的配置
|
||||
|
||||
# 2. 检查CORS配置
|
||||
# 后端需要添加生产域名到CORS白名单
|
||||
|
||||
# 3. 检查HTTPS/HTTP
|
||||
# 如果前端使用HTTPS,后端也应使用HTTPS
|
||||
|
||||
# 4. 查看生产环境日志
|
||||
npm run build
|
||||
# 检查构建产物中的配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 常见问题FAQ
|
||||
|
||||
### Q1: 客服系统会影响页面性能吗?
|
||||
|
||||
**A**: 不会。客服脚本采用异步加载,不会阻塞页面渲染。Widget总大小约50KB(gzip后),首次加载后会被浏览器缓存。
|
||||
|
||||
### Q2: 可以在移动端使用吗?
|
||||
|
||||
**A**: 可以。Bytedesk Widget完全响应式,自动适配移动端和PC端。
|
||||
|
||||
### Q3: 是否支持离线消息?
|
||||
|
||||
**A**: 支持。用户在客服离线时发送的消息会被保存,客服上线后可以查看。
|
||||
|
||||
### Q4: 可以集成到React Native吗?
|
||||
|
||||
**A**: BytedeskWidget是为Web设计的。React Native需要使用Bytedesk的原生SDK(另外提供)。
|
||||
|
||||
### Q5: 如何隐藏特定页面的客服?
|
||||
|
||||
**A**: 使用`shouldShowCustomerService`函数(见3.2节"方式二")。
|
||||
|
||||
### Q6: 可以同时配置多个工作组吗?
|
||||
|
||||
**A**: 可以。参考5.1节"多工作组支持"。
|
||||
|
||||
### Q7: 用户信息是否安全?
|
||||
|
||||
**A**: 是的。所有通信使用WebSocket加密传输,用户信息不会被第三方获取。建议生产环境使用HTTPS。
|
||||
|
||||
### Q8: 是否需要付费?
|
||||
|
||||
**A**: Bytedesk社区版(当前使用)完全免费,License有效期至2040年12月31日。
|
||||
|
||||
---
|
||||
|
||||
## 9. 性能优化
|
||||
|
||||
### 9.1 按需加载
|
||||
|
||||
只在需要时加载客服系统:
|
||||
|
||||
```jsx
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
function App() {
|
||||
const [loadBytedesk, setLoadBytedesk] = useState(false);
|
||||
|
||||
// 延迟5秒加载(页面渲染完成后)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setLoadBytedesk(true);
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的页面内容 */}
|
||||
|
||||
{loadBytedesk && (
|
||||
<BytedeskWidget config={getBytedeskConfig()} autoLoad={true} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 Lazy Import
|
||||
|
||||
使用React.lazy延迟导入组件:
|
||||
|
||||
```jsx
|
||||
import { lazy, Suspense } from 'react';
|
||||
|
||||
const BytedeskWidget = lazy(() => import('./bytedesk-integration/components/BytedeskWidget'));
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
{/* 您的页面内容 */}
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<BytedeskWidget config={getBytedeskConfig()} autoLoad={true} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 9.3 缓存优化
|
||||
|
||||
客服脚本会自动被浏览器缓存,无需额外配置。
|
||||
|
||||
---
|
||||
|
||||
## 10. 安全注意事项
|
||||
|
||||
### 10.1 环境变量安全
|
||||
|
||||
```bash
|
||||
# ❌ 错误: 不要在代码中硬编码配置
|
||||
const config = {
|
||||
apiUrl: 'http://43.143.189.195',
|
||||
org: 'df_org_uid',
|
||||
};
|
||||
|
||||
# ✅ 正确: 使用环境变量
|
||||
const config = {
|
||||
apiUrl: process.env.REACT_APP_BYTEDESK_API_URL,
|
||||
org: process.env.REACT_APP_BYTEDESK_ORG,
|
||||
};
|
||||
```
|
||||
|
||||
### 10.2 敏感信息保护
|
||||
|
||||
```javascript
|
||||
// ❌ 不要传递敏感信息
|
||||
const user = {
|
||||
id: '12345',
|
||||
password: 'user-password', // 不要传递密码
|
||||
creditCard: '1234-5678', // 不要传递信用卡
|
||||
};
|
||||
|
||||
// ✅ 只传递必要信息
|
||||
const user = {
|
||||
id: '12345',
|
||||
name: '张三',
|
||||
email: 'user@example.com',
|
||||
};
|
||||
```
|
||||
|
||||
### 10.3 HTTPS使用
|
||||
|
||||
生产环境强烈建议使用HTTPS:
|
||||
|
||||
```bash
|
||||
# 开发环境
|
||||
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
|
||||
|
||||
# 生产环境
|
||||
REACT_APP_BYTEDESK_API_URL=https://kefu.yourdomain.com
|
||||
```
|
||||
|
||||
### 10.4 内容安全策略(CSP)
|
||||
|
||||
如果您的项目使用CSP,需要允许以下域名:
|
||||
|
||||
```html
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
default-src 'self';
|
||||
script-src 'self' https://www.weiyuai.cn;
|
||||
connect-src 'self' http://43.143.189.195;
|
||||
img-src 'self' data: http://43.143.189.195;
|
||||
"/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 获取帮助
|
||||
|
||||
### 11.1 联系方式
|
||||
|
||||
- **技术支持**: 访问 http://43.143.189.195/chat/ 在线咨询
|
||||
- **管理员**: 联系您的项目管理员获取ORG和SID
|
||||
- **后端工程师**: 联系后端团队确认服务器状态
|
||||
|
||||
### 11.2 日志查看
|
||||
|
||||
```javascript
|
||||
// 在浏览器控制台查看Bytedesk日志
|
||||
// 日志前缀为 [Bytedesk]
|
||||
|
||||
// 示例:
|
||||
[Bytedesk] 开始加载客服Widget...
|
||||
[Bytedesk] Widget脚本加载成功
|
||||
[Bytedesk] 初始化Widget
|
||||
[Bytedesk] Widget初始化成功
|
||||
```
|
||||
|
||||
### 11.3 调试技巧
|
||||
|
||||
```javascript
|
||||
// 1. 检查配置是否正确
|
||||
console.log('Bytedesk配置:', getBytedeskConfig());
|
||||
|
||||
// 2. 检查环境变量
|
||||
console.log('API URL:', process.env.REACT_APP_BYTEDESK_API_URL);
|
||||
console.log('ORG:', process.env.REACT_APP_BYTEDESK_ORG);
|
||||
console.log('SID:', process.env.REACT_APP_BYTEDESK_SID);
|
||||
|
||||
// 3. 检查Widget是否加载
|
||||
console.log('BytedeskWeb对象:', window.BytedeskWeb);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. 版本历史
|
||||
|
||||
| 版本 | 日期 | 更新内容 |
|
||||
|------|------|---------|
|
||||
| v1.0 | 2025-01-07 | 初始版本,支持基础集成功能 |
|
||||
|
||||
---
|
||||
|
||||
## 13. 附录
|
||||
|
||||
### 13.1 完整配置示例
|
||||
|
||||
```javascript
|
||||
// bytedesk.config.js - 完整配置
|
||||
export const bytedeskConfig = {
|
||||
apiUrl: 'http://43.143.189.195',
|
||||
htmlUrl: 'http://43.143.189.195/chat/',
|
||||
placement: 'bottom-right',
|
||||
marginBottom: 20,
|
||||
marginSide: 20,
|
||||
autoPopup: false,
|
||||
locale: 'zh-cn',
|
||||
bubbleConfig: {
|
||||
show: true,
|
||||
icon: '💬',
|
||||
title: '在线客服',
|
||||
subtitle: '点击咨询',
|
||||
},
|
||||
theme: {
|
||||
mode: 'system',
|
||||
backgroundColor: '#0066FF',
|
||||
textColor: '#ffffff',
|
||||
},
|
||||
chatConfig: {
|
||||
org: 'df_org_uid',
|
||||
t: '2',
|
||||
sid: 'df_wg_aftersales',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 13.2 文件清单
|
||||
|
||||
集成所需的所有文件:
|
||||
|
||||
```
|
||||
bytedesk-integration/
|
||||
├── components/
|
||||
│ └── BytedeskWidget.jsx # React组件(必需)
|
||||
├── config/
|
||||
│ └── bytedesk.config.js # 配置文件(必需)
|
||||
├── App.jsx.example # 集成示例(参考)
|
||||
├── .env.bytedesk.example # 环境变量示例(参考)
|
||||
└── 前端工程师集成手册.md # 本手册(参考)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**祝您集成顺利!**
|
||||
|
||||
如有任何问题,请随时联系技术支持。
|
||||
@@ -1,841 +0,0 @@
|
||||
# PostHog 事件追踪实施总结
|
||||
|
||||
## ✅ 已完成的追踪
|
||||
|
||||
### 1. Home 页面(首页/落地页)
|
||||
|
||||
**已实施的追踪事件**:
|
||||
|
||||
#### 📄 页面浏览
|
||||
- **事件**: `LANDING_PAGE_VIEWED`
|
||||
- **触发时机**: 页面加载
|
||||
- **属性**:
|
||||
- `timestamp` - 访问时间
|
||||
- `is_authenticated` - 是否已登录
|
||||
- `user_id` - 用户ID(如果已登录)
|
||||
|
||||
#### 🎯 功能卡片点击
|
||||
- **事件**: `FEATURE_CARD_CLICKED`
|
||||
- **触发时机**: 用户点击任何功能卡片
|
||||
- **属性**:
|
||||
- `feature_id` - 功能ID(news-catalyst, concepts, stocks, etc.)
|
||||
- `feature_title` - 功能标题
|
||||
- `feature_url` - 目标URL
|
||||
- `is_featured` - 是否为推荐功能(新闻中心为 true)
|
||||
- `link_type` - 链接类型(internal/external)
|
||||
|
||||
**追踪的6个核心功能**:
|
||||
1. **新闻中心** (`news-catalyst`) - 推荐功能,黄色边框
|
||||
2. **概念中心** (`concepts`)
|
||||
3. **个股信息汇总** (`stocks`)
|
||||
4. **涨停板块分析** (`limit-analyse`)
|
||||
5. **个股罗盘** (`company`)
|
||||
6. **模拟盘交易** (`trading-simulation`)
|
||||
|
||||
---
|
||||
|
||||
### 2. StockOverview 页面(个股中心)✅ 已完成
|
||||
|
||||
**注意**:个股中心页面已完全实现 PostHog 追踪,通过 `src/views/StockOverview/hooks/useStockOverviewEvents.js` Hook。
|
||||
|
||||
**已实施的追踪事件**:
|
||||
|
||||
#### 📄 页面浏览
|
||||
- **事件**: `STOCK_OVERVIEW_VIEWED`
|
||||
- **触发时机**: 页面加载
|
||||
- **属性**:
|
||||
- `timestamp` - 访问时间
|
||||
|
||||
#### 📊 市场统计数据查看
|
||||
- **事件**: `STOCK_LIST_VIEWED`
|
||||
- **触发时机**: 加载市场统计数据
|
||||
- **属性**:
|
||||
- `total_market_cap` - 总市值
|
||||
- `total_volume` - 总成交量
|
||||
- `rising_stocks` - 上涨股票数
|
||||
- `falling_stocks` - 下跌股票数
|
||||
- `data_date` - 数据日期
|
||||
|
||||
#### 🔍 搜索追踪
|
||||
- **事件**: `SEARCH_INITIATED` / `STOCK_SEARCHED`
|
||||
- **触发时机**: 用户输入搜索、完成搜索
|
||||
- **属性**:
|
||||
- `query` - 搜索关键词
|
||||
- `result_count` - 搜索结果数量
|
||||
- `has_results` - 是否有结果
|
||||
- `context` - 固定为 'stock_overview'
|
||||
|
||||
#### 🎯 搜索结果点击
|
||||
- **事件**: `SEARCH_RESULT_CLICKED`
|
||||
- **触发时机**: 用户点击搜索结果
|
||||
- **属性**:
|
||||
- `stock_code` - 股票代码
|
||||
- `stock_name` - 股票名称
|
||||
- `exchange` - 交易所
|
||||
- `position` - 在搜索结果中的位置
|
||||
- `context` - 固定为 'stock_overview'
|
||||
|
||||
#### 🔥 概念卡片点击
|
||||
- **事件**: `CONCEPT_CLICKED`
|
||||
- **触发时机**: 用户点击热门概念卡片
|
||||
- **属性**:
|
||||
- `concept_name` - 概念名称
|
||||
- `concept_code` - 概念代码
|
||||
- `change_percent` - 涨跌幅
|
||||
- `stock_count` - 股票数量
|
||||
- `rank` - 排名
|
||||
- `source` - 固定为 'daily_hot_concepts'
|
||||
|
||||
#### 🏷️ 概念股票标签点击
|
||||
- **事件**: `CONCEPT_STOCK_CLICKED`
|
||||
- **触发时机**: 点击概念下的股票标签
|
||||
- **属性**:
|
||||
- `stock_code` - 股票代码
|
||||
- `stock_name` - 股票名称
|
||||
- `concept_name` - 所属概念
|
||||
- `source` - 固定为 'daily_hot_concepts_tag'
|
||||
|
||||
#### 📊 热力图股票点击
|
||||
- **事件**: `STOCK_CLICKED`
|
||||
- **触发时机**: 点击热力图中的股票
|
||||
- **属性**:
|
||||
- `stock_code` - 股票代码
|
||||
- `stock_name` - 股票名称
|
||||
- `change_percent` - 涨跌幅
|
||||
- `market_cap_range` - 市值区间
|
||||
- `source` - 固定为 'market_heatmap'
|
||||
|
||||
#### 📅 日期选择变化
|
||||
- **事件**: `SEARCH_FILTER_APPLIED`
|
||||
- **触发时机**: 用户选择不同的交易日期
|
||||
- **属性**:
|
||||
- `filter_type` - 固定为 'date'
|
||||
- `filter_value` - 新选择的日期
|
||||
- `previous_value` - 之前的日期
|
||||
- `context` - 固定为 'stock_overview'
|
||||
|
||||
**实施方式**: Custom Hook (`useStockOverviewEvents.js`) 已集成
|
||||
|
||||
---
|
||||
|
||||
### 3. Concept 页面(概念中心)
|
||||
|
||||
**已实施的追踪事件**:
|
||||
|
||||
#### 📄 页面浏览
|
||||
- **事件**: `CONCEPT_CENTER_VIEWED`
|
||||
- **触发时机**: 页面加载
|
||||
- **属性**:
|
||||
- `timestamp` - 访问时间
|
||||
|
||||
#### 🔍 搜索查询
|
||||
- **事件**: `SEARCH_QUERY_SUBMITTED`
|
||||
- **触发时机**: 用户搜索概念
|
||||
- **属性**:
|
||||
- `query` - 搜索关键词
|
||||
- `category` - 固定为 'concept'
|
||||
- `result_count` - 搜索结果数量
|
||||
- `has_results` - 是否有结果
|
||||
|
||||
#### 🎚️ 筛选追踪
|
||||
- **事件**: `SEARCH_FILTER_APPLIED`
|
||||
- **触发时机**: 用户更改筛选条件
|
||||
- **属性**:
|
||||
- `filter_type` - 筛选类型(sort/date)
|
||||
- `filter_value` - 筛选值
|
||||
- `previous_value` - 之前的值
|
||||
- `context` - 固定为 'concept_center'
|
||||
|
||||
**支持的筛选类型**:
|
||||
1. **排序** (`sort`): 涨跌幅/相关度/股票数量/概念名称
|
||||
2. **日期范围** (`date`): 选择交易日期
|
||||
|
||||
#### 🎯 概念卡片点击
|
||||
- **事件**: `CONCEPT_CLICKED`
|
||||
- **触发时机**: 用户点击概念卡片
|
||||
- **属性**:
|
||||
- `concept_id` - 概念ID
|
||||
- `concept_name` - 概念名称
|
||||
- `change_percent` - 涨跌幅
|
||||
- `stock_count` - 股票数量
|
||||
- `position` - 在列表中的位置
|
||||
- `source` - 固定为 'concept_center_list'
|
||||
|
||||
#### 👀 查看个股
|
||||
- **事件**: `CONCEPT_STOCKS_VIEWED`
|
||||
- **触发时机**: 用户点击"查看个股"按钮
|
||||
- **属性**:
|
||||
- `concept_name` - 概念名称
|
||||
- `stock_count` - 股票数量
|
||||
- `source` - 固定为 'concept_center'
|
||||
|
||||
#### 🏷️ 概念股票点击
|
||||
- **事件**: `CONCEPT_STOCK_CLICKED`
|
||||
- **触发时机**: 点击概念股票表格中的股票
|
||||
- **属性**:
|
||||
- `stock_code` - 股票代码
|
||||
- `stock_name` - 股票名称
|
||||
- `concept_name` - 所属概念
|
||||
- `source` - 固定为 'concept_center_stock_table'
|
||||
|
||||
#### 📊 历史时间轴查看
|
||||
- **事件**: `CONCEPT_TIMELINE_VIEWED`
|
||||
- **触发时机**: 用户点击"历史时间轴"按钮
|
||||
- **属性**:
|
||||
- `concept_id` - 概念ID
|
||||
- `concept_name` - 概念名称
|
||||
- `source` - 固定为 'concept_center'
|
||||
|
||||
#### 📄 翻页追踪
|
||||
- **事件**: `NEWS_LIST_VIEWED`
|
||||
- **触发时机**: 用户翻页
|
||||
- **属性**:
|
||||
- `page` - 页码
|
||||
- `filters` - 当前筛选条件
|
||||
- `sort` - 排序方式
|
||||
- `has_query` - 是否有搜索词
|
||||
- `date` - 日期
|
||||
- `context` - 固定为 'concept_center'
|
||||
|
||||
#### 🔄 视图模式切换
|
||||
- **事件**: `VIEW_MODE_CHANGED`
|
||||
- **触发时机**: 用户切换网格/列表视图
|
||||
- **属性**:
|
||||
- `view_mode` - 新视图模式(grid/list)
|
||||
- `previous_mode` - 之前的模式
|
||||
- `context` - 固定为 'concept_center'
|
||||
|
||||
---
|
||||
|
||||
### 4. Company 页面(公司详情/个股罗盘)
|
||||
|
||||
**已实施的追踪事件**:
|
||||
|
||||
#### 📄 页面浏览
|
||||
- **事件**: `COMPANY_PAGE_VIEWED`
|
||||
- **触发时机**: 页面加载
|
||||
- **属性**:
|
||||
- `timestamp` - 访问时间
|
||||
- `stock_code` - 当前查看的股票代码
|
||||
|
||||
#### 🔍 股票搜索
|
||||
- **事件**: `STOCK_SEARCHED`
|
||||
- **触发时机**: 用户输入股票代码并查询
|
||||
- **属性**:
|
||||
- `query` - 搜索的股票代码
|
||||
- `stock_code` - 股票代码
|
||||
- `previous_stock_code` - 之前查看的股票代码
|
||||
- `context` - 固定为 'company_page'
|
||||
|
||||
#### 🔄 Tab 切换
|
||||
- **事件**: `TAB_CHANGED`
|
||||
- **触发时机**: 用户切换不同的 Tab
|
||||
- **属性**:
|
||||
- `tab_index` - Tab 索引(0-3)
|
||||
- `tab_name` - Tab 名称(公司概览/股票行情/财务全景/盈利预测)
|
||||
- `previous_tab_index` - 之前的 Tab 索引
|
||||
- `stock_code` - 当前股票代码
|
||||
- `context` - 固定为 'company_page'
|
||||
|
||||
**支持的 Tab**:
|
||||
1. **公司概览** (index 0): 公司基本信息
|
||||
2. **股票行情** (index 1): 实时行情数据
|
||||
3. **财务全景** (index 2): 财务报表分析
|
||||
4. **盈利预测** (index 3): 盈利预测数据
|
||||
|
||||
#### ⭐ 自选股管理
|
||||
- **事件**: `WATCHLIST_ADDED` / `WATCHLIST_REMOVED`
|
||||
- **触发时机**: 用户添加/移除自选股
|
||||
- **属性**:
|
||||
- `stock_code` - 股票代码
|
||||
- `source` - 固定为 'company_page'
|
||||
|
||||
---
|
||||
|
||||
### 5. Community 页面(新闻催化分析)
|
||||
|
||||
**已实施的追踪事件**:
|
||||
|
||||
#### 📄 页面浏览
|
||||
- **事件**: `COMMUNITY_PAGE_VIEWED`
|
||||
- **触发时机**: 页面加载
|
||||
- **属性**:
|
||||
- `timestamp` - 访问时间
|
||||
- `has_hot_events` - 是否有热点事件
|
||||
- `has_keywords` - 是否有热门关键词
|
||||
|
||||
#### 🔍 搜索追踪
|
||||
- **事件**: `SEARCH_QUERY_SUBMITTED`
|
||||
- **触发时机**: 用户输入搜索关键词
|
||||
- **属性**:
|
||||
- `query` - 搜索关键词
|
||||
- `category` - 分类(固定为 'news')
|
||||
- `previous_query` - 上一次搜索词
|
||||
|
||||
#### 🎚️ 筛选追踪
|
||||
- **事件**: `SEARCH_FILTER_APPLIED`
|
||||
- **触发时机**: 用户更改筛选条件
|
||||
- **属性**:
|
||||
- `filter_type` - 筛选类型(sort/importance/date_range/industry)
|
||||
- `filter_value` - 筛选值
|
||||
- `previous_value` - 上一次的值
|
||||
|
||||
**支持的筛选类型**:
|
||||
1. **排序** (`sort`): 最新/最热/重要性
|
||||
2. **重要性** (`importance`): 全部/高/中/低
|
||||
3. **时间范围** (`date_range`): 今天/近7天/近30天
|
||||
4. **行业** (`industry`): 各行业代码
|
||||
|
||||
#### 🗞️ 新闻点击追踪
|
||||
- **事件**: `NEWS_ARTICLE_CLICKED`
|
||||
- **触发时机**: 用户点击新闻事件
|
||||
- **属性**:
|
||||
- `event_id` - 事件ID
|
||||
- `event_title` - 事件标题
|
||||
- `importance` - 重要性等级
|
||||
- `source` - 来源(固定为 'community_page')
|
||||
- `has_stocks` - 是否包含相关股票
|
||||
- `has_concepts` - 是否包含相关概念
|
||||
|
||||
#### 📖 详情查看追踪
|
||||
- **事件**: `NEWS_DETAIL_OPENED`
|
||||
- **触发时机**: 用户点击"查看详情"
|
||||
- **属性**:
|
||||
- `event_id` - 事件ID
|
||||
- `source` - 来源(固定为 'community_page')
|
||||
|
||||
#### 📄 翻页追踪
|
||||
- **事件**: `NEWS_LIST_VIEWED`
|
||||
- **触发时机**: 用户翻页
|
||||
- **属性**:
|
||||
- `page` - 页码
|
||||
- `filters` - 当前筛选条件
|
||||
- `sort` - 排序方式
|
||||
- `importance` - 重要性
|
||||
- `has_query` - 是否有搜索词
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 实施方式
|
||||
|
||||
### 方案:Custom Hook 集成(推荐)
|
||||
|
||||
**优势**:
|
||||
- ✅ 集中管理,易于维护
|
||||
- ✅ 自动追踪,无需修改组件
|
||||
- ✅ 符合关注点分离原则
|
||||
- ✅ 便于测试和调试
|
||||
|
||||
### 修改的文件
|
||||
|
||||
#### 0. `src/views/StockOverview/hooks/useStockOverviewEvents.js` ✅
|
||||
|
||||
**文件已存在**,无需修改。已完整实现个股中心的所有追踪事件。
|
||||
|
||||
#### 1. `src/views/Concept/hooks/useConceptEvents.js`
|
||||
|
||||
**新建 Hook 文件**:
|
||||
```javascript
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
```
|
||||
|
||||
**提供的追踪函数**:
|
||||
- `trackConceptSearched()` - 搜索概念
|
||||
- `trackFilterApplied()` - 筛选变化
|
||||
- `trackConceptClicked()` - 概念点击
|
||||
- `trackConceptStocksViewed()` - 查看个股
|
||||
- `trackConceptStockClicked()` - 点击概念股票
|
||||
- `trackConceptTimelineViewed()` - 历史时间轴
|
||||
- `trackPageChange()` - 翻页
|
||||
- `trackViewModeChanged()` - 视图切换
|
||||
|
||||
#### 2. `src/views/Company/hooks/useCompanyEvents.js`
|
||||
|
||||
**新建 Hook 文件**:
|
||||
```javascript
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
```
|
||||
|
||||
**提供的追踪函数**:
|
||||
- `trackStockSearched()` - 股票搜索
|
||||
- `trackTabChanged()` - Tab 切换
|
||||
- `trackWatchlistAdded()` - 加入自选
|
||||
- `trackWatchlistRemoved()` - 移除自选
|
||||
|
||||
#### 3. `src/views/Company/index.js`
|
||||
|
||||
**添加的导入**:
|
||||
```javascript
|
||||
import { useCompanyEvents } from './hooks/useCompanyEvents';
|
||||
```
|
||||
|
||||
**添加的 Hook**:
|
||||
```javascript
|
||||
const {
|
||||
trackStockSearched,
|
||||
trackTabChanged,
|
||||
trackWatchlistAdded,
|
||||
trackWatchlistRemoved,
|
||||
} = useCompanyEvents({ stockCode });
|
||||
```
|
||||
|
||||
**添加的 State**:
|
||||
```javascript
|
||||
const [currentTabIndex, setCurrentTabIndex] = useState(0);
|
||||
```
|
||||
|
||||
**修改的函数**:
|
||||
1. **`handleSearch`**: 追踪股票搜索
|
||||
2. **`handleWatchlistToggle`**: 追踪自选股添加/移除
|
||||
3. **Tabs `onChange`**: 追踪 Tab 切换
|
||||
|
||||
#### 4. `src/views/Concept/index.js`
|
||||
|
||||
**添加的导入**:
|
||||
```javascript
|
||||
import { useConceptEvents } from './hooks/useConceptEvents';
|
||||
```
|
||||
|
||||
**添加的 Hook**:
|
||||
```javascript
|
||||
const {
|
||||
trackConceptSearched,
|
||||
trackFilterApplied,
|
||||
trackConceptClicked,
|
||||
trackConceptStocksViewed,
|
||||
trackConceptStockClicked,
|
||||
trackConceptTimelineViewed,
|
||||
trackPageChange,
|
||||
trackViewModeChanged,
|
||||
} = useConceptEvents({ navigate });
|
||||
```
|
||||
|
||||
**修改的函数**:
|
||||
1. **`handleSearch`**: 追踪搜索查询
|
||||
2. **`handleSortChange`**: 追踪排序变化
|
||||
3. **`handleDateChange`**: 追踪日期变化
|
||||
4. **`handlePageChange`**: 追踪翻页
|
||||
5. **`handleConceptClick`**: 追踪概念点击
|
||||
6. **`handleViewStocks`**: 追踪查看个股
|
||||
7. **`handleViewContent`**: 追踪历史时间轴
|
||||
8. **视图切换按钮**: 追踪网格/列表切换
|
||||
|
||||
#### 3. `src/views/Home/HomePage.js`
|
||||
|
||||
**添加的导入**:
|
||||
```javascript
|
||||
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
|
||||
import { ACQUISITION_EVENTS } from '../../lib/constants';
|
||||
```
|
||||
|
||||
**添加的 Hook**:
|
||||
```javascript
|
||||
const { track } = usePostHogTrack();
|
||||
```
|
||||
|
||||
**添加的 useEffect**(页面浏览追踪):
|
||||
```javascript
|
||||
useEffect(() => {
|
||||
track(ACQUISITION_EVENTS.LANDING_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
is_authenticated: isAuthenticated,
|
||||
user_id: user?.id || null,
|
||||
});
|
||||
}, [track, isAuthenticated, user?.id]);
|
||||
```
|
||||
|
||||
**修改的函数**:
|
||||
- **`handleProductClick`**: 从接收 URL 改为接收完整 feature 对象,添加追踪逻辑
|
||||
|
||||
**修改后的代码**:
|
||||
```javascript
|
||||
const handleProductClick = useCallback((feature) => {
|
||||
// 🎯 PostHog 追踪:功能卡片点击
|
||||
track(ACQUISITION_EVENTS.FEATURE_CARD_CLICKED, {
|
||||
feature_id: feature.id,
|
||||
feature_title: feature.title,
|
||||
feature_url: feature.url,
|
||||
is_featured: feature.featured || false,
|
||||
link_type: feature.url.startsWith('http') ? 'external' : 'internal',
|
||||
});
|
||||
|
||||
// 原有导航逻辑
|
||||
if (feature.url.startsWith('http')) {
|
||||
window.open(feature.url, '_blank');
|
||||
} else {
|
||||
navigate(feature.url);
|
||||
}
|
||||
}, [track, navigate]);
|
||||
```
|
||||
|
||||
**更新的 onClick 事件**:
|
||||
```javascript
|
||||
// 从
|
||||
onClick={() => handleProductClick(coreFeatures[0].url)}
|
||||
|
||||
// 改为
|
||||
onClick={() => handleProductClick(coreFeatures[0])}
|
||||
```
|
||||
|
||||
#### 1. `src/views/Community/hooks/useEventFilters.js`
|
||||
|
||||
**添加的导入**:
|
||||
```javascript
|
||||
import { usePostHogTrack } from '../../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../../lib/constants';
|
||||
```
|
||||
|
||||
**添加的Hook**:
|
||||
```javascript
|
||||
const { track } = usePostHogTrack();
|
||||
```
|
||||
|
||||
**修改的函数**:
|
||||
1. **`updateFilters`**: 追踪搜索和筛选
|
||||
2. **`handlePageChange`**: 追踪翻页
|
||||
3. **`handleEventClick`**: 追踪新闻点击
|
||||
4. **`handleViewDetail`**: 追踪详情查看
|
||||
|
||||
#### 2. `src/views/Community/index.js`
|
||||
|
||||
**添加的导入**:
|
||||
```javascript
|
||||
import { usePostHogTrack } from '../../hooks/usePostHogRedux';
|
||||
import { RETENTION_EVENTS } from '../../lib/constants';
|
||||
```
|
||||
|
||||
**添加的Hook**:
|
||||
```javascript
|
||||
const { track } = usePostHogTrack();
|
||||
```
|
||||
|
||||
**添加的useEffect**:
|
||||
```javascript
|
||||
useEffect(() => {
|
||||
track(RETENTION_EVENTS.COMMUNITY_PAGE_VIEWED, {
|
||||
timestamp: new Date().toISOString(),
|
||||
has_hot_events: hotEvents && hotEvents.length > 0,
|
||||
has_keywords: popularKeywords && popularKeywords.length > 0,
|
||||
});
|
||||
}, [track]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 追踪效果示例
|
||||
|
||||
### 用户行为路径示例
|
||||
|
||||
**首页转化路径**:
|
||||
```
|
||||
1. 游客访问首页
|
||||
→ 触发: LANDING_PAGE_VIEWED
|
||||
→ 属性: { is_authenticated: false, user_id: null }
|
||||
|
||||
2. 点击"新闻中心"功能卡片
|
||||
→ 触发: FEATURE_CARD_CLICKED
|
||||
→ 属性: { feature_id: "news-catalyst", feature_title: "新闻中心", is_featured: true, link_type: "internal" }
|
||||
|
||||
3. 进入 Community 页面
|
||||
→ 触发: COMMUNITY_PAGE_VIEWED
|
||||
```
|
||||
|
||||
**Community 页面行为路径**:
|
||||
```
|
||||
1. 用户进入 Community 页面
|
||||
→ 触发: COMMUNITY_PAGE_VIEWED
|
||||
|
||||
2. 用户搜索 "人工智能"
|
||||
→ 触发: SEARCH_QUERY_SUBMITTED
|
||||
→ 属性: { query: "人工智能", category: "news" }
|
||||
|
||||
3. 用户筛选 "重要性:高"
|
||||
→ 触发: SEARCH_FILTER_APPLIED
|
||||
→ 属性: { filter_type: "importance", filter_value: "high" }
|
||||
|
||||
4. 用户点击第一条新闻
|
||||
→ 触发: NEWS_ARTICLE_CLICKED
|
||||
→ 属性: { event_id: "123", event_title: "...", importance: "high", source: "community_page" }
|
||||
|
||||
5. 用户翻到第2页
|
||||
→ 触发: NEWS_LIST_VIEWED
|
||||
→ 属性: { page: 2, filters: { sort: "new", importance: "high", has_query: true } }
|
||||
|
||||
6. 用户点击"查看详情"
|
||||
→ 触发: NEWS_DETAIL_OPENED
|
||||
→ 属性: { event_id: "456", source: "community_page" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 测试方法
|
||||
|
||||
### 1. 使用 Redux DevTools
|
||||
|
||||
1. 打开应用:`npm start`
|
||||
2. 打开浏览器 Redux DevTools
|
||||
3. 筛选 `posthog/trackEvent` actions
|
||||
4. 执行各种操作
|
||||
5. 查看追踪的事件和属性
|
||||
|
||||
### 2. 控制台日志
|
||||
|
||||
开发环境下,PostHog 会自动输出日志:
|
||||
|
||||
```
|
||||
📍 Event tracked: Community Page Viewed { timestamp: "...", has_hot_events: true }
|
||||
📍 Event tracked: Search Query Submitted { query: "人工智能", category: "news" }
|
||||
📍 Event tracked: Search Filter Applied { filter_type: "importance", filter_value: "high" }
|
||||
```
|
||||
|
||||
### 3. PostHog Dashboard
|
||||
|
||||
1. 登录 PostHog 后台
|
||||
2. 查看 "Events" 页面
|
||||
3. 筛选 Community 相关事件:
|
||||
- `Community Page Viewed`
|
||||
- `Search Query Submitted`
|
||||
- `Search Filter Applied`
|
||||
- `News Article Clicked`
|
||||
- `News List Viewed`
|
||||
|
||||
---
|
||||
|
||||
## 📈 数据分析建议
|
||||
|
||||
### 1. 搜索行为分析
|
||||
|
||||
**问题**: 用户最常搜索什么?
|
||||
|
||||
**方法**:
|
||||
- 筛选 `SEARCH_QUERY_SUBMITTED` 事件
|
||||
- 按 `query` 属性分组
|
||||
- 查看 Top 关键词
|
||||
|
||||
### 2. 筛选偏好分析
|
||||
|
||||
**问题**: 用户更喜欢什么排序方式?
|
||||
|
||||
**方法**:
|
||||
- 筛选 `SEARCH_FILTER_APPLIED` 事件
|
||||
- 按 `filter_type: "sort"` 筛选
|
||||
- 按 `filter_value` 分组统计
|
||||
|
||||
### 3. 新闻热度分析
|
||||
|
||||
**问题**: 哪些新闻最受欢迎?
|
||||
|
||||
**方法**:
|
||||
- 筛选 `NEWS_ARTICLE_CLICKED` 事件
|
||||
- 按 `event_id` 分组
|
||||
- 统计点击次数
|
||||
|
||||
### 4. 用户旅程分析
|
||||
|
||||
**问题**: 用户从搜索到点击的转化率?
|
||||
|
||||
**方法**:
|
||||
- 创建漏斗:
|
||||
1. `COMMUNITY_PAGE_VIEWED`
|
||||
2. `SEARCH_QUERY_SUBMITTED`
|
||||
3. `NEWS_ARTICLE_CLICKED`
|
||||
- 分析每一步的流失率
|
||||
|
||||
---
|
||||
|
||||
## 🔧 扩展计划
|
||||
|
||||
### 下一步:其他页面追踪
|
||||
|
||||
按优先级排序:
|
||||
|
||||
1. **Concept(概念中心)** ⭐⭐⭐
|
||||
- 搜索概念
|
||||
- 点击概念卡片
|
||||
- 查看概念详情
|
||||
- 点击概念内股票
|
||||
|
||||
2. **StockOverview(个股中心)** ⭐⭐⭐
|
||||
- 搜索股票
|
||||
- 点击股票卡片
|
||||
- 查看股票详情
|
||||
- 切换 Tab
|
||||
|
||||
3. **LimitAnalyse(涨停分析)** ⭐⭐
|
||||
- 进入页面
|
||||
- 点击涨停板块
|
||||
- 展开板块详情
|
||||
- 点击涨停个股
|
||||
|
||||
4. **TradingSimulation(模拟盘)** ⭐⭐
|
||||
- 进入模拟盘
|
||||
- 下单操作
|
||||
- 查看持仓
|
||||
- 查看历史
|
||||
|
||||
5. **Company(公司详情)** ⭐
|
||||
- 查看公司概览
|
||||
- 查看财务全景
|
||||
- 查看盈利预测
|
||||
- Tab 切换
|
||||
|
||||
---
|
||||
|
||||
## 💡 最佳实践
|
||||
|
||||
### 1. 属性命名规范
|
||||
|
||||
- 使用 **snake_case** 命名(与 PostHog 推荐一致)
|
||||
- 属性名要 **描述性强**,易于理解
|
||||
- 使用 **布尔值** 表示是/否(has_xxx, is_xxx)
|
||||
- 使用 **枚举值** 表示类别(filter_type: "sort")
|
||||
|
||||
### 2. 事件追踪原则
|
||||
|
||||
- **追踪用户意图**,而不仅仅是点击
|
||||
- **添加上下文**,帮助分析(previous_value, source)
|
||||
- **保持一致性**,相似事件使用相似属性
|
||||
- **避免敏感信息**,不追踪用户隐私数据
|
||||
|
||||
### 3. 性能优化
|
||||
|
||||
- 使用 **`usePostHogTrack`** 而不是 `usePostHogRedux`
|
||||
- 更轻量,只订阅追踪功能
|
||||
- 避免不必要的重渲染
|
||||
- 在 **Custom Hooks** 中集成,而不是每个组件
|
||||
- 集中管理,易于维护
|
||||
- 减少重复代码
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 依赖管理
|
||||
|
||||
确保 `useCallback` 的依赖数组包含 `track`:
|
||||
|
||||
```javascript
|
||||
// ✅ 正确
|
||||
const handleClick = useCallback(() => {
|
||||
track(EVENT_NAME, { ... });
|
||||
}, [track]);
|
||||
|
||||
// ❌ 错误(缺少 track)
|
||||
const handleClick = useCallback(() => {
|
||||
track(EVENT_NAME, { ... });
|
||||
}, []);
|
||||
```
|
||||
|
||||
### 2. 事件去重
|
||||
|
||||
避免重复追踪相同事件:
|
||||
|
||||
```javascript
|
||||
// ✅ 正确(只在值变化时追踪)
|
||||
if (newFilters.sort !== filters.sort) {
|
||||
track(SEARCH_FILTER_APPLIED, { ... });
|
||||
}
|
||||
|
||||
// ❌ 错误(每次都追踪)
|
||||
track(SEARCH_FILTER_APPLIED, { ... });
|
||||
```
|
||||
|
||||
### 3. 空值处理
|
||||
|
||||
使用安全的属性访问:
|
||||
|
||||
```javascript
|
||||
// ✅ 正确
|
||||
has_stocks: !!(event.related_stocks && event.related_stocks.length > 0)
|
||||
|
||||
// ❌ 错误(可能报错)
|
||||
has_stocks: event.related_stocks.length > 0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 参考资料
|
||||
|
||||
- **PostHog Events 文档**: https://posthog.com/docs/data/events
|
||||
- **PostHog Properties 文档**: https://posthog.com/docs/data/properties
|
||||
- **Redux PostHog 集成**: `POSTHOG_REDUX_INTEGRATION.md`
|
||||
- **事件常量定义**: `src/lib/constants.js`
|
||||
|
||||
---
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
### 已实现的功能
|
||||
|
||||
- ✅ Home 页面追踪(2个事件)
|
||||
- ✅ StockOverview 页面完整追踪(10个事件)✨ 已完成
|
||||
- ✅ Concept 页面完整追踪(9个事件)
|
||||
- ✅ Company 页面完整追踪(5个事件)
|
||||
- ✅ Community 页面完整追踪(7个事件)
|
||||
- ✅ Custom Hook 集成方案
|
||||
- ✅ Redux DevTools 调试支持
|
||||
- ✅ 详细的事件属性
|
||||
|
||||
### 追踪的用户行为
|
||||
|
||||
**Home 页面**:
|
||||
1. **页面访问** - 了解流量来源、登录转化率
|
||||
2. **功能卡片点击** - 识别最受欢迎的功能
|
||||
3. **推荐功能效果** - 分析特色功能(新闻中心)的点击率
|
||||
|
||||
**StockOverview 页面** ✨:
|
||||
1. **页面访问** - 了解个股中心流量
|
||||
2. **搜索行为** - 股票搜索、搜索结果点击
|
||||
3. **概念交互** - 热门概念点击、概念股票标签点击
|
||||
4. **热力图交互** - 热力图中股票点击
|
||||
5. **数据筛选** - 日期选择变化
|
||||
6. **市场统计** - 市场数据查看
|
||||
|
||||
**Concept 页面**:
|
||||
1. **页面访问** - 了解概念中心流量
|
||||
2. **搜索行为** - 概念搜索、搜索结果数量
|
||||
3. **筛选偏好** - 排序方式、日期选择
|
||||
4. **概念交互** - 概念点击、位置追踪
|
||||
5. **个股查看** - 查看个股、股票点击
|
||||
6. **时间轴查看** - 历史时间轴
|
||||
7. **翻页行为** - 优化分页逻辑
|
||||
8. **视图切换** - 网格/列表偏好
|
||||
|
||||
**Company 页面**:
|
||||
1. **页面访问** - 了解公司详情页流量
|
||||
2. **股票搜索** - 用户查询哪些股票
|
||||
3. **Tab 切换** - 用户最关注哪个 Tab(概览/行情/财务/预测)
|
||||
4. **自选股管理** - 自选股添加/移除行为
|
||||
5. **股票切换** - 分析用户查看股票的路径
|
||||
|
||||
**Community 页面**:
|
||||
1. **页面访问** - 了解流量来源
|
||||
2. **搜索行为** - 了解用户需求
|
||||
3. **筛选偏好** - 优化默认设置
|
||||
4. **内容点击** - 识别热门内容
|
||||
5. **详情查看** - 分析用户兴趣
|
||||
6. **翻页行为** - 优化分页逻辑
|
||||
|
||||
### 下一步计划
|
||||
|
||||
1. ~~在关键页面实施追踪(Home, StockOverview, Concept, Company, Community)~~ ✅ 已完成
|
||||
2. **下一步**:其他页面追踪
|
||||
- LimitAnalyse(涨停分析)⭐⭐
|
||||
- TradingSimulation(模拟盘)⭐⭐
|
||||
3. 创建 PostHog Dashboard 和 Insights
|
||||
4. 设置用户行为漏斗分析
|
||||
5. 配置 Feature Flags 进行 A/B 测试
|
||||
|
||||
---
|
||||
|
||||
**Home, StockOverview, Concept, Company, Community 页面追踪全部完成!** 🚀
|
||||
|
||||
现在你可以在 PostHog 后台看到完整的用户行为数据:
|
||||
- **首页** → **个股中心/概念中心/公司详情/新闻中心** 的完整转化路径
|
||||
- **搜索行为**、**筛选偏好**、**内容点击** 的详细数据
|
||||
- **Tab 切换**、**视图切换**、**翻页行为** 的用户习惯分析
|
||||
- **自选股管理** 的用户行为追踪
|
||||
|
||||
共追踪 **33个事件**,覆盖 **5个核心页面**。
|
||||
339
index.pug
339
index.pug
@@ -1,339 +0,0 @@
|
||||
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)
|
||||
@@ -1,631 +0,0 @@
|
||||
# -*- 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
|
||||
}
|
||||
@@ -1,669 +0,0 @@
|
||||
# -*- 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
|
||||
BIN
public/logo.png
Normal file
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
public/og-image.jpg
Normal file
BIN
public/og-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
@@ -1,237 +0,0 @@
|
||||
/**
|
||||
* vf_react App.jsx集成示例
|
||||
*
|
||||
* 本文件展示如何在vf_react项目中集成Bytedesk客服系统
|
||||
*
|
||||
* 集成步骤:
|
||||
* 1. 将bytedesk-integration文件夹复制到src/目录
|
||||
* 2. 在App.jsx中导入BytedeskWidget和配置
|
||||
* 3. 添加BytedeskWidget组件(代码如下)
|
||||
* 4. 配置.env文件(参考.env.bytedesk.example)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom'; // 如果使用react-router
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
// ============================================================================
|
||||
// 方案一: 全局集成(推荐)
|
||||
// 适用场景: 客服系统需要在所有页面显示
|
||||
// ============================================================================
|
||||
|
||||
function App() {
|
||||
// ========== vf_react原有代码保持不变 ==========
|
||||
// 这里是您原有的App.jsx代码
|
||||
// 例如: const [user, setUser] = useState(null);
|
||||
// 例如: const [theme, setTheme] = useState('light');
|
||||
// ... 保持原有逻辑不变 ...
|
||||
|
||||
// ========== Bytedesk集成代码开始 ==========
|
||||
|
||||
const location = useLocation(); // 获取当前路径
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
|
||||
// 根据页面路径决定是否显示客服
|
||||
useEffect(() => {
|
||||
const shouldShow = shouldShowCustomerService(location.pathname);
|
||||
setShowBytedesk(shouldShow);
|
||||
}, [location.pathname]);
|
||||
|
||||
// 获取Bytedesk配置
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
// 客服加载成功回调
|
||||
const handleBytedeskLoad = (bytedesk) => {
|
||||
console.log('[App] Bytedesk客服系统加载成功', bytedesk);
|
||||
};
|
||||
|
||||
// 客服加载失败回调
|
||||
const handleBytedeskError = (error) => {
|
||||
console.error('[App] Bytedesk客服系统加载失败', error);
|
||||
};
|
||||
|
||||
// ========== Bytedesk集成代码结束 ==========
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{/* ========== vf_react原有内容保持不变 ========== */}
|
||||
{/* 这里是您原有的App.jsx JSX代码 */}
|
||||
{/* 例如: <Header /> */}
|
||||
{/* 例如: <Router> <Routes> ... </Routes> </Router> */}
|
||||
{/* ... 保持原有结构不变 ... */}
|
||||
|
||||
{/* ========== Bytedesk客服Widget ========== */}
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
onLoad={handleBytedeskLoad}
|
||||
onError={handleBytedeskError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 方案二: 带用户信息集成
|
||||
// 适用场景: 需要将登录用户信息传递给客服端
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfigWithUser, shouldShowCustomerService } from './bytedesk-integration/config/bytedesk.config';
|
||||
import { AuthContext } from './contexts/AuthContext'; // 假设您有用户认证Context
|
||||
|
||||
function App() {
|
||||
// 获取登录用户信息
|
||||
const { user } = useContext(AuthContext);
|
||||
|
||||
const location = useLocation();
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldShow = shouldShowCustomerService(location.pathname);
|
||||
setShowBytedesk(shouldShow);
|
||||
}, [location.pathname]);
|
||||
|
||||
// 根据用户信息生成配置
|
||||
const bytedeskConfig = user
|
||||
? getBytedeskConfigWithUser(user)
|
||||
: getBytedeskConfig();
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
// ... 您的原有代码 ...
|
||||
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
*/
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 方案三: 条件性加载
|
||||
// 适用场景: 只在特定条件下显示客服(如用户已登录、特定用户角色等)
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 只有在用户登录且为普通用户时显示客服
|
||||
if (user && user.role === 'customer') {
|
||||
setShowBytedesk(true);
|
||||
} else {
|
||||
setShowBytedesk(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
// ... 您的原有代码 ...
|
||||
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
*/
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 方案四: 动态控制显示/隐藏
|
||||
// 适用场景: 需要通过按钮或其他交互控制客服显示
|
||||
// ============================================================================
|
||||
|
||||
/*
|
||||
import React, { useState } from 'react';
|
||||
import BytedeskWidget from './bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig } from './bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
function App() {
|
||||
const [showBytedesk, setShowBytedesk] = useState(false);
|
||||
const bytedeskConfig = getBytedeskConfig();
|
||||
|
||||
const toggleBytedesk = () => {
|
||||
setShowBytedesk(prev => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
// ... 您的原有代码 ...
|
||||
|
||||
{/* 自定义客服按钮 *\/}
|
||||
<button onClick={toggleBytedesk} className="custom-service-button">
|
||||
{showBytedesk ? '关闭客服' : '联系客服'}
|
||||
</button>
|
||||
|
||||
{/* 客服Widget *\/}
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={bytedeskConfig}
|
||||
autoLoad={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
*/
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// 重要提示
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 1. CSS样式兼容性
|
||||
* - Bytedesk Widget使用Shadow DOM,不会影响您的全局样式
|
||||
* - Widget的样式可通过config中的theme配置调整
|
||||
*
|
||||
* 2. 性能优化
|
||||
* - Widget脚本采用异步加载,不会阻塞页面渲染
|
||||
* - 建议在非关键页面(如登录、支付页)隐藏客服
|
||||
*
|
||||
* 3. 错误处理
|
||||
* - 如果客服脚本加载失败,不会影响主应用
|
||||
* - 建议添加onError回调进行错误监控
|
||||
*
|
||||
* 4. 调试模式
|
||||
* - 查看浏览器控制台的[Bytedesk]前缀日志
|
||||
* - 检查Network面板确认脚本加载成功
|
||||
*
|
||||
* 5. 生产部署
|
||||
* - 确保.env文件配置正确(特别是REACT_APP_BYTEDESK_API_URL)
|
||||
* - 确保CORS已在后端配置(允许您的前端域名)
|
||||
* - 在管理后台配置正确的工作组ID(sid)
|
||||
*/
|
||||
@@ -1,27 +1,10 @@
|
||||
/**
|
||||
* Bytedesk客服配置文件
|
||||
* 通过代理访问 Bytedesk 服务器(解决 HTTPS 混合内容问题)
|
||||
*
|
||||
* 环境变量配置(.env文件):
|
||||
* REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
* REACT_APP_BYTEDESK_SID=df_wg_uid
|
||||
*
|
||||
* 架构说明:
|
||||
* - iframe 使用完整域名:https://valuefrontier.cn/bytedesk/chat/
|
||||
* - 使用 HTTPS 协议,解决生产环境 Mixed Content 错误
|
||||
* - 本地:CRACO 代理 /bytedesk → valuefrontier.cn/bytedesk
|
||||
* - 生产:前端 Nginx 代理 /bytedesk → 43.143.189.195
|
||||
* - baseUrl 保持官方 CDN(用于加载 SDK 外部模块)
|
||||
*
|
||||
* ⚠️ 注意:需要前端 Nginx 配置 /bytedesk/ 代理规则
|
||||
*/
|
||||
|
||||
// 从环境变量读取配置
|
||||
const BYTEDESK_ORG = process.env.REACT_APP_BYTEDESK_ORG || 'df_org_uid';
|
||||
const BYTEDESK_SID = process.env.REACT_APP_BYTEDESK_SID || 'df_wg_uid';
|
||||
|
||||
/**
|
||||
* Bytedesk客服基础配置
|
||||
- iframe 使用完整域名:https://valuefrontier.cn/bytedesk/chat/
|
||||
- 使用 HTTPS 协议,解决生产环境 Mixed Content 错误
|
||||
- 生产:前端 Nginx 代理 /bytedesk → 43.143.189.195
|
||||
- baseUrl 保持官方 CDN(用于加载 SDK 外部模块)
|
||||
*/
|
||||
export const bytedeskConfig = {
|
||||
// API服务地址(如果 SDK 需要调用 API)
|
||||
@@ -61,9 +44,9 @@ export const bytedeskConfig = {
|
||||
|
||||
// 聊天配置(必需)
|
||||
chatConfig: {
|
||||
org: BYTEDESK_ORG, // 组织ID
|
||||
org: df_org_uid, // 组织ID
|
||||
t: '1', // 类型: 1=人工客服, 2=机器人
|
||||
sid: BYTEDESK_SID, // 工作组ID
|
||||
sid: df_wg_uid, // 工作组ID
|
||||
},
|
||||
};
|
||||
|
||||
@@ -111,45 +94,8 @@ export const getBytedeskConfigWithUser = (user) => {
|
||||
return config;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据页面路径判断是否显示客服
|
||||
*
|
||||
* @param {string} pathname - 当前页面路径
|
||||
* @returns {boolean} 是否显示客服
|
||||
*/
|
||||
export const shouldShowCustomerService = (pathname) => {
|
||||
// 在以下页面隐藏客服(黑名单)
|
||||
const blockedPages = [
|
||||
// '/home', // 登录页
|
||||
];
|
||||
|
||||
// 检查是否在黑名单
|
||||
if (blockedPages.some(page => pathname.startsWith(page))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 默认所有页面都显示客服
|
||||
return true;
|
||||
|
||||
/* ============================================
|
||||
白名单模式(备用,需要时取消注释)
|
||||
============================================
|
||||
const allowedPages = [
|
||||
'/', // 首页
|
||||
'/home', // 主页
|
||||
'/products', // 产品页
|
||||
'/pricing', // 价格页
|
||||
'/contact', // 联系我们
|
||||
];
|
||||
|
||||
// 只在白名单页面显示客服
|
||||
return allowedPages.some(page => pathname.startsWith(page));
|
||||
============================================ */
|
||||
};
|
||||
|
||||
export default {
|
||||
bytedeskConfig,
|
||||
getBytedeskConfig,
|
||||
getBytedeskConfigWithUser,
|
||||
shouldShowCustomerService,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import ScrollToTop from './ScrollToTop';
|
||||
|
||||
// Bytedesk客服组件
|
||||
import BytedeskWidget from '../bytedesk-integration/components/BytedeskWidget';
|
||||
import { getBytedeskConfig, shouldShowCustomerService } from '../bytedesk-integration/config/bytedesk.config';
|
||||
import { getBytedeskConfig } from '../bytedesk-integration/config/bytedesk.config';
|
||||
|
||||
/**
|
||||
* ConnectionStatusBar 包装组件
|
||||
@@ -74,7 +74,6 @@ function ConnectionStatusBarWrapper() {
|
||||
*/
|
||||
export function GlobalComponents() {
|
||||
const location = useLocation();
|
||||
const showBytedesk = shouldShowCustomerService(location.pathname);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -91,12 +90,10 @@ export function GlobalComponents() {
|
||||
<NotificationContainer />
|
||||
|
||||
{/* Bytedesk在线客服 - 根据路径条件性显示 */}
|
||||
{showBytedesk && (
|
||||
<BytedeskWidget
|
||||
config={getBytedeskConfig()}
|
||||
autoLoad={true}
|
||||
/>
|
||||
)}
|
||||
<BytedeskWidget
|
||||
config={getBytedeskConfig()}
|
||||
autoLoad={true}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,11 @@ let isInitialized = false;
|
||||
* Should be called once when the app starts
|
||||
*/
|
||||
export const initPostHog = () => {
|
||||
// 开发环境禁用 PostHog(减少日志噪音,仅生产环境启用)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止重复初始化
|
||||
if (isInitializing || isInitialized) {
|
||||
console.log('📊 PostHog 已初始化或正在初始化中,跳过重复调用');
|
||||
@@ -33,79 +38,68 @@ export const initPostHog = () => {
|
||||
posthog.init(apiKey, {
|
||||
api_host: apiHost,
|
||||
|
||||
// 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
|
||||
// 📄 页面浏览追踪
|
||||
capture_pageview: true, // 自动捕获页面浏览事件
|
||||
capture_pageleave: true, // 自动捕获用户离开页面事件
|
||||
|
||||
// Session Recording Configuration
|
||||
// 📹 会话录制配置(Session Recording)
|
||||
session_recording: {
|
||||
enabled: process.env.REACT_APP_ENABLE_SESSION_RECORDING === 'true',
|
||||
|
||||
// Privacy: Mask sensitive input fields
|
||||
// 🔒 隐私保护:遮蔽敏感输入字段(录制时会自动打码)
|
||||
maskInputOptions: {
|
||||
password: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
'data-sensitive': true, // Custom attribute for sensitive fields
|
||||
password: true, // 遮蔽密码输入框
|
||||
email: true, // 遮蔽邮箱输入框
|
||||
phone: true, // 遮蔽手机号输入框
|
||||
'data-sensitive': true, // 遮蔽带有 data-sensitive 属性的字段(可在 HTML 中自定义)
|
||||
},
|
||||
|
||||
// Record canvas for charts/graphs
|
||||
// 📊 录制 Canvas 画布内容(用于记录图表、图形等可视化内容)
|
||||
recordCanvas: true,
|
||||
|
||||
// Network payload capture (useful for debugging API issues)
|
||||
// 🌐 网络请求数据捕获(用于调试 API 问题)
|
||||
networkPayloadCapture: {
|
||||
recordHeaders: true,
|
||||
recordBody: true,
|
||||
// Don't record sensitive endpoints
|
||||
recordHeaders: true, // 捕获请求头
|
||||
recordBody: true, // 捕获请求体
|
||||
// 🚫 敏感接口黑名单(不记录以下接口的数据)
|
||||
urlBlocklist: [
|
||||
'/api/auth/session',
|
||||
'/api/auth/login',
|
||||
'/api/auth/register',
|
||||
'/api/payment',
|
||||
'/api/auth/session', // 会话接口
|
||||
'/api/auth/login', // 登录接口
|
||||
'/api/auth/register', // 注册接口
|
||||
'/api/payment', // 支付接口
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// Performance optimization
|
||||
batch_size: 10, // Send events in batches of 10
|
||||
batch_interval_ms: 3000, // Or every 3 seconds
|
||||
// ⚡ 性能优化:批量发送事件
|
||||
batch_size: 10, // 每 10 个事件发送一次
|
||||
batch_interval_ms: 3000, // 或每 3 秒发送一次(两个条件满足其一即发送)
|
||||
|
||||
// Privacy settings
|
||||
respect_dnt: true, // Respect Do Not Track browser setting
|
||||
persistence: 'localStorage+cookie', // Use both for reliability
|
||||
// 🔐 隐私设置
|
||||
respect_dnt: true, // 尊重浏览器的"禁止追踪"(Do Not Track)设置
|
||||
persistence: 'localStorage+cookie', // 同时使用 localStorage 和 Cookie 存储(提高可靠性)
|
||||
|
||||
// Feature flags (for A/B testing)
|
||||
// 🚩 功能开关(Feature Flags)- 用于 A/B 测试和灰度发布
|
||||
bootstrap: {
|
||||
featureFlags: {},
|
||||
featureFlags: {}, // 初始功能开关配置(可从服务端动态加载)
|
||||
},
|
||||
|
||||
// Autocapture settings
|
||||
// 🖱️ 自动捕获设置(Autocapture)
|
||||
autocapture: {
|
||||
// Automatically capture clicks on buttons, links, etc.
|
||||
// 自动捕获用户交互事件(点击、提交、修改等)
|
||||
dom_event_allowlist: ['click', 'submit', 'change'],
|
||||
|
||||
// Capture additional element properties
|
||||
capture_copied_text: false, // Don't capture copied text (privacy)
|
||||
},
|
||||
|
||||
// Development debugging
|
||||
loaded: (posthogInstance) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('✅ PostHog initialized successfully');
|
||||
// posthogInstance.debug(); // 已关闭:减少控制台日志噪音
|
||||
}
|
||||
// 捕获额外的元素属性
|
||||
capture_copied_text: false, // 不捕获用户复制的文本(隐私保护)
|
||||
},
|
||||
});
|
||||
|
||||
isInitialized = true;
|
||||
console.log('📊 PostHog Analytics initialized');
|
||||
} catch (error) {
|
||||
// 忽略 AbortError(通常由热重载或快速导航引起)
|
||||
if (error.name === 'AbortError') {
|
||||
console.log('⚠️ PostHog 初始化请求被中断(可能是热重载),这是正常的');
|
||||
return;
|
||||
}
|
||||
console.error('❌ PostHog initialization failed:', error);
|
||||
} finally {
|
||||
isInitializing = false;
|
||||
}
|
||||
@@ -142,8 +136,6 @@ export const identifyUser = (userId, userProperties = {}) => {
|
||||
last_login: new Date().toISOString(),
|
||||
...userProperties,
|
||||
});
|
||||
|
||||
// console.log('👤 User identified:', userId); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ User identification failed:', error);
|
||||
}
|
||||
@@ -158,7 +150,6 @@ export const identifyUser = (userId, userProperties = {}) => {
|
||||
export const setUserProperties = (properties) => {
|
||||
try {
|
||||
posthog.people.set(properties);
|
||||
// console.log('📝 User properties updated'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to update user properties:', error);
|
||||
}
|
||||
@@ -176,10 +167,6 @@ export const trackEvent = (eventName, properties = {}) => {
|
||||
...properties,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// if (process.env.NODE_ENV === 'development') {
|
||||
// console.log('📍 Event tracked:', eventName, properties);
|
||||
// } // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Event tracking failed:', error);
|
||||
}
|
||||
@@ -225,9 +212,6 @@ export const trackPageView = (pagePath, properties = {}) => {
|
||||
...properties,
|
||||
});
|
||||
|
||||
// if (process.env.NODE_ENV === 'development') {
|
||||
// console.log('📄 Page view tracked:', pagePath);
|
||||
// } // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Page view tracking failed:', error);
|
||||
}
|
||||
@@ -240,7 +224,6 @@ export const trackPageView = (pagePath, properties = {}) => {
|
||||
export const resetUser = () => {
|
||||
try {
|
||||
posthog.reset();
|
||||
// console.log('🔄 User session reset'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Session reset failed:', error);
|
||||
}
|
||||
@@ -252,7 +235,6 @@ export const resetUser = () => {
|
||||
export const optOut = () => {
|
||||
try {
|
||||
posthog.opt_out_capturing();
|
||||
// console.log('🚫 User opted out of tracking'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-out failed:', error);
|
||||
}
|
||||
@@ -264,7 +246,6 @@ export const optOut = () => {
|
||||
export const optIn = () => {
|
||||
try {
|
||||
posthog.opt_in_capturing();
|
||||
// console.log('✅ User opted in to tracking'); // 已关闭:减少日志
|
||||
} catch (error) {
|
||||
console.error('❌ Opt-in failed:', error);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,17 @@ export const homeRoutes = [
|
||||
}
|
||||
},
|
||||
|
||||
// 数据浏览器 - /home/data-browser
|
||||
{
|
||||
path: 'data-browser',
|
||||
component: lazyComponents.DataBrowser,
|
||||
protection: PROTECTION_MODES.MODAL,
|
||||
meta: {
|
||||
title: '数据浏览器',
|
||||
description: '化工商品数据分类树浏览器'
|
||||
}
|
||||
},
|
||||
|
||||
// 回退路由 - 匹配任何未定义的 /home/* 路径
|
||||
{
|
||||
path: '*',
|
||||
|
||||
@@ -42,6 +42,9 @@ export const lazyComponents = {
|
||||
// 价值论坛模块
|
||||
ValueForum: React.lazy(() => import('../views/ValueForum')),
|
||||
ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')),
|
||||
|
||||
// 数据浏览器模块
|
||||
DataBrowser: React.lazy(() => import('../views/DataBrowser')),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -69,4 +72,5 @@ export const {
|
||||
AgentChat,
|
||||
ValueForum,
|
||||
ForumPostDetail,
|
||||
DataBrowser,
|
||||
} = lazyComponents;
|
||||
|
||||
280
src/services/categoryService.ts
Normal file
280
src/services/categoryService.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
/**
|
||||
* 商品分类树数据服务
|
||||
* 对接化工商品数据分类树API
|
||||
* API文档: category_tree_openapi.json
|
||||
*/
|
||||
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
|
||||
// 类型定义
|
||||
export interface TreeMetric {
|
||||
metric_id: string;
|
||||
metric_name: string;
|
||||
source: 'SMM' | 'Mysteel';
|
||||
frequency: string;
|
||||
unit: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
level: number;
|
||||
children?: TreeNode[];
|
||||
metrics?: TreeMetric[];
|
||||
}
|
||||
|
||||
export interface CategoryTreeResponse {
|
||||
source: 'SMM' | 'Mysteel';
|
||||
total_metrics: number;
|
||||
tree: TreeNode[];
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface MetricDataPoint {
|
||||
date: string;
|
||||
value: number | null;
|
||||
}
|
||||
|
||||
export interface MetricDataResponse {
|
||||
metric_id: string;
|
||||
metric_name: string;
|
||||
source: string;
|
||||
frequency: string;
|
||||
unit: string;
|
||||
data: MetricDataPoint[];
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分类树(支持深度控制)
|
||||
* @param source 数据源类型 ('SMM' | 'Mysteel')
|
||||
* @param maxDepth 返回的最大层级深度(默认1层,推荐懒加载)
|
||||
* @returns 分类树数据
|
||||
*/
|
||||
export const fetchCategoryTree = async (
|
||||
source: 'SMM' | 'Mysteel',
|
||||
maxDepth: number = 1
|
||||
): Promise<CategoryTreeResponse> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/category-api/api/category-tree?source=${source}&max_depth=${maxDepth}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData: ErrorResponse = await response.json();
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data: CategoryTreeResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('fetchCategoryTree error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取特定节点及其子树
|
||||
* @param path 节点完整路径(用 | 分隔)
|
||||
* @param source 数据源类型 ('SMM' | 'Mysteel')
|
||||
* @returns 节点数据及其子树
|
||||
*/
|
||||
export const fetchCategoryNode = async (
|
||||
path: string,
|
||||
source: 'SMM' | 'Mysteel'
|
||||
): Promise<TreeNode> => {
|
||||
try {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const response = await fetch(
|
||||
`/category-api/api/category-tree/node?path=${encodedPath}&source=${source}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData: ErrorResponse = await response.json();
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data: TreeNode = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('fetchCategoryNode error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export interface MetricSearchResult {
|
||||
source: string;
|
||||
metric_id: string;
|
||||
metric_name: string;
|
||||
unit: string;
|
||||
frequency: string;
|
||||
category_path: string;
|
||||
description?: string;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
total: number;
|
||||
results: MetricSearchResult[];
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索指标
|
||||
* @param keywords 搜索关键词(支持空格分隔多个词)
|
||||
* @param source 数据源过滤(可选)
|
||||
* @param frequency 频率过滤(可选)
|
||||
* @param size 返回结果数量(默认100)
|
||||
* @returns 搜索结果
|
||||
*/
|
||||
export const searchMetrics = async (
|
||||
keywords: string,
|
||||
source?: 'SMM' | 'Mysteel',
|
||||
frequency?: string,
|
||||
size: number = 100
|
||||
): Promise<SearchResponse> => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
keywords,
|
||||
size: size.toString(),
|
||||
});
|
||||
|
||||
if (source) params.append('source', source);
|
||||
if (frequency) params.append('frequency', frequency);
|
||||
|
||||
const response = await fetch(`/category-api/api/search?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData: ErrorResponse = await response.json();
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data: SearchResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('searchMetrics error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从树中提取所有指标(用于前端搜索)
|
||||
* @param nodes 树节点数组
|
||||
* @returns 所有指标的扁平化数组
|
||||
*/
|
||||
export const extractAllMetrics = (nodes: TreeNode[]): TreeMetric[] => {
|
||||
const metrics: TreeMetric[] = [];
|
||||
|
||||
const traverse = (node: TreeNode) => {
|
||||
if (node.metrics && node.metrics.length > 0) {
|
||||
metrics.push(...node.metrics);
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
node.children.forEach(traverse);
|
||||
}
|
||||
};
|
||||
|
||||
nodes.forEach(traverse);
|
||||
return metrics;
|
||||
};
|
||||
|
||||
/**
|
||||
* 在树中查找节点
|
||||
* @param nodes 树节点数组
|
||||
* @param path 节点路径
|
||||
* @returns 找到的节点或 null
|
||||
*/
|
||||
export const findNodeByPath = (nodes: TreeNode[], path: string): TreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.path === path) {
|
||||
return node;
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findNodeByPath(node.children, path);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取节点的所有父节点路径
|
||||
* @param path 节点路径(用 | 分隔)
|
||||
* @returns 父节点路径数组
|
||||
*/
|
||||
export const getParentPaths = (path: string): string[] => {
|
||||
const parts = path.split('|');
|
||||
const parentPaths: string[] = [];
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
parentPaths.push(parts.slice(0, i).join('|'));
|
||||
}
|
||||
|
||||
return parentPaths;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取指标数据详情
|
||||
* @param metricId 指标ID
|
||||
* @param startDate 开始日期(可选,格式:YYYY-MM-DD)
|
||||
* @param endDate 结束日期(可选,格式:YYYY-MM-DD)
|
||||
* @param limit 返回数据条数(可选,默认100)
|
||||
* @returns 指标数据
|
||||
*/
|
||||
export const fetchMetricData = async (
|
||||
metricId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit: number = 100
|
||||
): Promise<MetricDataResponse> => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
metric_id: metricId,
|
||||
limit: limit.toString(),
|
||||
});
|
||||
|
||||
if (startDate) params.append('start_date', startDate);
|
||||
if (endDate) params.append('end_date', endDate);
|
||||
|
||||
const response = await fetch(`/category-api/api/metric-data?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData: ErrorResponse = await response.json();
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data: MetricDataResponse = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('fetchMetricData error:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
376
src/views/DataBrowser/MetricDataModal.tsx
Normal file
376
src/views/DataBrowser/MetricDataModal.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Box,
|
||||
Tabs,
|
||||
TabList,
|
||||
TabPanels,
|
||||
Tab,
|
||||
TabPanel,
|
||||
Table,
|
||||
Thead,
|
||||
Tbody,
|
||||
Tr,
|
||||
Th,
|
||||
Td,
|
||||
Text,
|
||||
HStack,
|
||||
VStack,
|
||||
Badge,
|
||||
Spinner,
|
||||
Flex,
|
||||
Icon,
|
||||
Button,
|
||||
Input,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { FaTable, FaChartLine, FaCalendar, FaDownload } from 'react-icons/fa';
|
||||
import { fetchMetricData, MetricDataResponse, TreeMetric } from '@services/categoryService';
|
||||
import TradingViewChart from './TradingViewChart';
|
||||
|
||||
// 黑金主题配色
|
||||
const themeColors = {
|
||||
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)',
|
||||
},
|
||||
primary: {
|
||||
gold: '#D4AF37',
|
||||
goldLight: '#F4E3A7',
|
||||
goldDark: '#B8941F',
|
||||
},
|
||||
};
|
||||
|
||||
interface MetricDataModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
metric: TreeMetric;
|
||||
}
|
||||
|
||||
const MetricDataModal: React.FC<MetricDataModalProps> = ({ isOpen, onClose, metric }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [metricData, setMetricData] = useState<MetricDataResponse | null>(null);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [limit, setLimit] = useState(100);
|
||||
const toast = useToast();
|
||||
|
||||
// 加载数据
|
||||
useEffect(() => {
|
||||
if (isOpen && metric) {
|
||||
loadMetricData();
|
||||
}
|
||||
}, [isOpen, metric]);
|
||||
|
||||
const loadMetricData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchMetricData(metric.metric_id, startDate, endDate, limit);
|
||||
setMetricData(data);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: '无法加载指标数据',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 数据已经在 metricData 中,直接传递给 TradingViewChart
|
||||
|
||||
// 导出CSV
|
||||
const handleExportCSV = () => {
|
||||
if (!metricData || !metricData.data) return;
|
||||
|
||||
const csvContent = [
|
||||
['日期', '数值', '单位'].join(','),
|
||||
...metricData.data.map((item) => [item.date, item.value ?? '', metricData.unit || ''].join(',')),
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `${metricData.metric_name}_${Date.now()}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
toast({
|
||||
title: '导出成功',
|
||||
description: 'CSV 文件已下载',
|
||||
status: 'success',
|
||||
duration: 2000,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="6xl" isCentered>
|
||||
<ModalOverlay bg="blackAlpha.800" />
|
||||
<ModalContent
|
||||
bg={themeColors.bg.card}
|
||||
borderWidth="1px"
|
||||
borderColor={themeColors.border.gold}
|
||||
maxH="90vh"
|
||||
>
|
||||
<ModalHeader
|
||||
bg={themeColors.bg.secondary}
|
||||
borderBottomWidth="1px"
|
||||
borderBottomColor={themeColors.border.gold}
|
||||
>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Text color={themeColors.text.gold} fontSize="lg" fontWeight="bold">
|
||||
{metric.metric_name}
|
||||
</Text>
|
||||
<HStack spacing={2}>
|
||||
<Badge bg={metric.source === 'SMM' ? 'blue.500' : 'green.500'} color="white">
|
||||
{metric.source}
|
||||
</Badge>
|
||||
<Badge bg={themeColors.border.gold} color={themeColors.primary.gold}>
|
||||
{metric.frequency}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<HStack spacing={4} fontSize="sm" color={themeColors.text.secondary}>
|
||||
<Text>ID: {metric.metric_id}</Text>
|
||||
{metric.unit && <Text>单位: {metric.unit}</Text>}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton color={themeColors.text.secondary} />
|
||||
|
||||
<ModalBody p={0}>
|
||||
{loading ? (
|
||||
<Flex justify="center" align="center" py={20}>
|
||||
<VStack spacing={4}>
|
||||
<Spinner size="xl" color={themeColors.primary.gold} thickness="4px" />
|
||||
<Text color={themeColors.text.secondary}>加载数据中...</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
{/* 筛选工具栏 */}
|
||||
<Box
|
||||
p={4}
|
||||
bg={themeColors.bg.secondary}
|
||||
borderBottomWidth="1px"
|
||||
borderBottomColor={themeColors.border.default}
|
||||
>
|
||||
<HStack spacing={4} wrap="wrap">
|
||||
<HStack flex="1" minW="200px">
|
||||
<Icon as={FaCalendar} color={themeColors.text.muted} />
|
||||
<Input
|
||||
type="date"
|
||||
size="sm"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
bg={themeColors.bg.card}
|
||||
borderColor={themeColors.border.default}
|
||||
color={themeColors.text.primary}
|
||||
_focus={{ borderColor: themeColors.primary.gold }}
|
||||
/>
|
||||
<Text color={themeColors.text.muted}>至</Text>
|
||||
<Input
|
||||
type="date"
|
||||
size="sm"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
bg={themeColors.bg.card}
|
||||
borderColor={themeColors.border.default}
|
||||
color={themeColors.text.primary}
|
||||
_focus={{ borderColor: themeColors.primary.gold }}
|
||||
/>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<Text color={themeColors.text.muted} fontSize="sm">
|
||||
限制:
|
||||
</Text>
|
||||
<Input
|
||||
type="number"
|
||||
size="sm"
|
||||
w="100px"
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(parseInt(e.target.value) || 100)}
|
||||
bg={themeColors.bg.card}
|
||||
borderColor={themeColors.border.default}
|
||||
color={themeColors.text.primary}
|
||||
_focus={{ borderColor: themeColors.primary.gold }}
|
||||
/>
|
||||
</HStack>
|
||||
<Button
|
||||
size="sm"
|
||||
bg={themeColors.primary.gold}
|
||||
color={themeColors.bg.primary}
|
||||
_hover={{ bg: themeColors.primary.goldLight }}
|
||||
onClick={loadMetricData}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
borderColor={themeColors.border.gold}
|
||||
color={themeColors.text.gold}
|
||||
leftIcon={<FaDownload />}
|
||||
onClick={handleExportCSV}
|
||||
isDisabled={!metricData || !metricData.data || metricData.data.length === 0}
|
||||
>
|
||||
导出CSV
|
||||
</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* 数据展示 */}
|
||||
{metricData && (
|
||||
<Tabs
|
||||
colorScheme="yellow"
|
||||
variant="enclosed"
|
||||
bg={themeColors.bg.primary}
|
||||
>
|
||||
<TabList borderBottomColor={themeColors.border.default}>
|
||||
<Tab
|
||||
color={themeColors.text.secondary}
|
||||
_selected={{
|
||||
color: themeColors.text.gold,
|
||||
borderColor: themeColors.border.gold,
|
||||
bg: themeColors.bg.card,
|
||||
}}
|
||||
>
|
||||
<Icon as={FaChartLine} mr={2} />
|
||||
折线图
|
||||
</Tab>
|
||||
<Tab
|
||||
color={themeColors.text.secondary}
|
||||
_selected={{
|
||||
color: themeColors.text.gold,
|
||||
borderColor: themeColors.border.gold,
|
||||
bg: themeColors.bg.card,
|
||||
}}
|
||||
>
|
||||
<Icon as={FaTable} mr={2} />
|
||||
数据表格
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* 折线图 - 使用 TradingView Lightweight Charts */}
|
||||
<TabPanel p={4}>
|
||||
{metricData && metricData.data.length > 0 ? (
|
||||
<TradingViewChart
|
||||
data={metricData.data}
|
||||
metricName={metricData.metric_name}
|
||||
unit={metricData.unit}
|
||||
frequency={metricData.frequency}
|
||||
/>
|
||||
) : (
|
||||
<Flex justify="center" align="center" py={20}>
|
||||
<Text color={themeColors.text.muted}>暂无数据</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
{/* 数据表格 */}
|
||||
<TabPanel p={0}>
|
||||
<Box maxH="500px" overflowY="auto">
|
||||
<Table variant="simple" size="sm">
|
||||
<Thead
|
||||
position="sticky"
|
||||
top={0}
|
||||
bg={themeColors.bg.secondary}
|
||||
zIndex={1}
|
||||
>
|
||||
<Tr>
|
||||
<Th
|
||||
color={themeColors.text.gold}
|
||||
borderColor={themeColors.border.default}
|
||||
>
|
||||
序号
|
||||
</Th>
|
||||
<Th
|
||||
color={themeColors.text.gold}
|
||||
borderColor={themeColors.border.default}
|
||||
>
|
||||
日期
|
||||
</Th>
|
||||
<Th
|
||||
color={themeColors.text.gold}
|
||||
borderColor={themeColors.border.default}
|
||||
isNumeric
|
||||
>
|
||||
数值 {metricData.unit && `(${metricData.unit})`}
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{metricData.data.map((item, index) => (
|
||||
<Tr
|
||||
key={index}
|
||||
_hover={{ bg: themeColors.bg.cardHover }}
|
||||
>
|
||||
<Td
|
||||
color={themeColors.text.muted}
|
||||
borderColor={themeColors.border.default}
|
||||
>
|
||||
{index + 1}
|
||||
</Td>
|
||||
<Td
|
||||
color={themeColors.text.secondary}
|
||||
borderColor={themeColors.border.default}
|
||||
>
|
||||
{item.date}
|
||||
</Td>
|
||||
<Td
|
||||
color={themeColors.text.primary}
|
||||
borderColor={themeColors.border.default}
|
||||
isNumeric
|
||||
fontWeight="bold"
|
||||
>
|
||||
{item.value !== null ? item.value.toLocaleString() : '-'}
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Box>
|
||||
{metricData.data.length === 0 && (
|
||||
<Flex justify="center" align="center" py={20}>
|
||||
<Text color={themeColors.text.muted}>暂无数据</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricDataModal;
|
||||
495
src/views/DataBrowser/TradingViewChart.tsx
Normal file
495
src/views/DataBrowser/TradingViewChart.tsx
Normal file
@@ -0,0 +1,495 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Flex,
|
||||
Icon,
|
||||
useColorMode,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import { createChart, LineSeries } from 'lightweight-charts';
|
||||
import type { IChartApi, ISeriesApi, LineData, Time } from 'lightweight-charts';
|
||||
import {
|
||||
FaExpand,
|
||||
FaCompress,
|
||||
FaCamera,
|
||||
FaRedo,
|
||||
FaCog,
|
||||
} from 'react-icons/fa';
|
||||
import { MetricDataPoint } from '@services/categoryService';
|
||||
|
||||
// 黑金主题配色
|
||||
const themeColors = {
|
||||
bg: {
|
||||
primary: '#0a0a0a',
|
||||
secondary: '#1a1a1a',
|
||||
card: '#1e1e1e',
|
||||
},
|
||||
text: {
|
||||
primary: '#ffffff',
|
||||
secondary: '#b8b8b8',
|
||||
muted: '#808080',
|
||||
gold: '#D4AF37',
|
||||
},
|
||||
border: {
|
||||
default: 'rgba(255, 255, 255, 0.1)',
|
||||
gold: 'rgba(212, 175, 55, 0.3)',
|
||||
},
|
||||
primary: {
|
||||
gold: '#D4AF37',
|
||||
goldLight: '#F4E3A7',
|
||||
},
|
||||
};
|
||||
|
||||
interface TradingViewChartProps {
|
||||
data: MetricDataPoint[];
|
||||
metricName: string;
|
||||
unit: string;
|
||||
frequency: string;
|
||||
}
|
||||
|
||||
type TimeRange = '1M' | '3M' | '6M' | '1Y' | 'YTD' | 'ALL';
|
||||
|
||||
const TradingViewChart: React.FC<TradingViewChartProps> = ({
|
||||
data,
|
||||
metricName,
|
||||
unit,
|
||||
frequency,
|
||||
}) => {
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const lineSeriesRef = useRef<ISeriesApi<'Line'> | null>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [selectedRange, setSelectedRange] = useState<TimeRange>('ALL');
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
// 初始化图表
|
||||
useEffect(() => {
|
||||
if (!chartContainerRef.current || data.length === 0) return;
|
||||
|
||||
try {
|
||||
// 创建图表 (lightweight-charts 5.0 标准 API)
|
||||
const chart = createChart(chartContainerRef.current, {
|
||||
width: chartContainerRef.current.clientWidth,
|
||||
height: 500,
|
||||
layout: {
|
||||
background: { type: 'solid', color: themeColors.bg.card },
|
||||
textColor: themeColors.text.secondary,
|
||||
},
|
||||
grid: {
|
||||
vertLines: {
|
||||
color: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
horzLines: {
|
||||
color: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
},
|
||||
crosshair: {
|
||||
vertLine: {
|
||||
color: themeColors.primary.gold,
|
||||
width: 1,
|
||||
style: 3, // 虚线
|
||||
labelBackgroundColor: themeColors.primary.gold,
|
||||
},
|
||||
horzLine: {
|
||||
color: themeColors.primary.gold,
|
||||
width: 1,
|
||||
style: 3,
|
||||
labelBackgroundColor: themeColors.primary.gold,
|
||||
},
|
||||
},
|
||||
rightPriceScale: {
|
||||
borderColor: themeColors.border.default,
|
||||
},
|
||||
timeScale: {
|
||||
borderColor: themeColors.border.default,
|
||||
timeVisible: true,
|
||||
secondsVisible: false,
|
||||
rightOffset: 12,
|
||||
barSpacing: 3,
|
||||
fixLeftEdge: false,
|
||||
lockVisibleTimeRangeOnResize: true,
|
||||
rightBarStaysOnScroll: true,
|
||||
borderVisible: true,
|
||||
visible: true,
|
||||
},
|
||||
localization: {
|
||||
timeFormatter: (time: any) => {
|
||||
// 格式化时间显示
|
||||
const date = new Date(time * 1000);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
},
|
||||
},
|
||||
handleScroll: {
|
||||
mouseWheel: true,
|
||||
pressedMouseMove: true,
|
||||
},
|
||||
handleScale: {
|
||||
axisPressedMouseMove: true,
|
||||
mouseWheel: true,
|
||||
pinch: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 创建折线系列 (lightweight-charts 5.0 使用 addSeries 方法)
|
||||
// 第一个参数是 series 类本身(不是实例)
|
||||
const lineSeries = chart.addSeries(LineSeries, {
|
||||
color: themeColors.primary.gold,
|
||||
lineWidth: 2,
|
||||
crosshairMarkerVisible: true,
|
||||
crosshairMarkerRadius: 6,
|
||||
crosshairMarkerBorderColor: themeColors.primary.goldLight,
|
||||
crosshairMarkerBackgroundColor: themeColors.primary.gold,
|
||||
lastValueVisible: true,
|
||||
priceLineVisible: true,
|
||||
priceLineColor: themeColors.primary.gold,
|
||||
priceLineWidth: 1,
|
||||
priceLineStyle: 3, // 虚线
|
||||
title: metricName,
|
||||
});
|
||||
|
||||
// 转换数据格式
|
||||
// lightweight-charts 5.0 需要 YYYY-MM-DD 格式的字符串作为 time
|
||||
const chartData: LineData[] = data
|
||||
.filter((item) => item.value !== null)
|
||||
.map((item) => {
|
||||
// 确保日期格式为 YYYY-MM-DD
|
||||
const dateStr = item.date.trim();
|
||||
return {
|
||||
time: dateStr as Time,
|
||||
value: item.value as number,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
// 确保时间从左到右递增
|
||||
const timeA = new Date(a.time as string).getTime();
|
||||
const timeB = new Date(b.time as string).getTime();
|
||||
return timeA - timeB;
|
||||
});
|
||||
|
||||
console.log('📊 转换后的图表数据(前3条):', chartData.slice(0, 3));
|
||||
console.log('📊 数据总数:', chartData.length);
|
||||
|
||||
// 设置数据
|
||||
lineSeries.setData(chartData);
|
||||
|
||||
// 自动缩放到合适的视图
|
||||
chart.timeScale().fitContent();
|
||||
|
||||
chartRef.current = chart;
|
||||
lineSeriesRef.current = lineSeries;
|
||||
|
||||
// 响应式调整
|
||||
const handleResize = () => {
|
||||
if (chartContainerRef.current && chart) {
|
||||
chart.applyOptions({
|
||||
width: chartContainerRef.current.clientWidth,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
chart.remove();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ TradingView Chart 初始化失败:', error);
|
||||
console.error('Error details:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
createChartType: typeof createChart,
|
||||
LineSeriesType: typeof LineSeries,
|
||||
});
|
||||
// 重新抛出错误让 ErrorBoundary 捕获
|
||||
throw error;
|
||||
}
|
||||
}, [data, metricName]);
|
||||
|
||||
// 时间范围筛选
|
||||
const handleTimeRangeChange = (range: TimeRange) => {
|
||||
setSelectedRange(range);
|
||||
|
||||
if (!chartRef.current || data.length === 0) return;
|
||||
|
||||
const now = new Date();
|
||||
let startDate: Date;
|
||||
|
||||
switch (range) {
|
||||
case '1M':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
||||
break;
|
||||
case '3M':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
|
||||
break;
|
||||
case '6M':
|
||||
startDate = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate());
|
||||
break;
|
||||
case '1Y':
|
||||
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
||||
break;
|
||||
case 'YTD':
|
||||
startDate = new Date(now.getFullYear(), 0, 1); // 当年1月1日
|
||||
break;
|
||||
case 'ALL':
|
||||
default:
|
||||
chartRef.current.timeScale().fitContent();
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置可见范围
|
||||
const startTimestamp = startDate.getTime() / 1000;
|
||||
const endTimestamp = now.getTime() / 1000;
|
||||
|
||||
chartRef.current.timeScale().setVisibleRange({
|
||||
from: startTimestamp as Time,
|
||||
to: endTimestamp as Time,
|
||||
});
|
||||
};
|
||||
|
||||
// 重置缩放
|
||||
const handleReset = () => {
|
||||
if (chartRef.current) {
|
||||
chartRef.current.timeScale().fitContent();
|
||||
setSelectedRange('ALL');
|
||||
}
|
||||
};
|
||||
|
||||
// 截图功能
|
||||
const handleScreenshot = () => {
|
||||
if (!chartRef.current) return;
|
||||
|
||||
const canvas = chartContainerRef.current?.querySelector('canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return;
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${metricName}_${new Date().toISOString().split('T')[0]}.png`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
};
|
||||
|
||||
// 全屏切换
|
||||
const toggleFullscreen = () => {
|
||||
if (!chartContainerRef.current) return;
|
||||
|
||||
if (!isFullscreen) {
|
||||
if (chartContainerRef.current.requestFullscreen) {
|
||||
chartContainerRef.current.requestFullscreen();
|
||||
}
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
}
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
// 计算统计数据
|
||||
const stats = React.useMemo(() => {
|
||||
const values = data.filter((item) => item.value !== null).map((item) => item.value as number);
|
||||
|
||||
if (values.length === 0) {
|
||||
return { min: 0, max: 0, avg: 0, latest: 0, change: 0, changePercent: 0 };
|
||||
}
|
||||
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const avg = values.reduce((sum, val) => sum + val, 0) / values.length;
|
||||
const latest = values[values.length - 1];
|
||||
const first = values[0];
|
||||
const change = latest - first;
|
||||
const changePercent = first !== 0 ? (change / first) * 100 : 0;
|
||||
|
||||
return { min, max, avg, latest, change, changePercent };
|
||||
}, [data]);
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num: number) => {
|
||||
if (Math.abs(num) >= 1e9) {
|
||||
return (num / 1e9).toFixed(2) + 'B';
|
||||
}
|
||||
if (Math.abs(num) >= 1e6) {
|
||||
return (num / 1e6).toFixed(2) + 'M';
|
||||
}
|
||||
if (Math.abs(num) >= 1e3) {
|
||||
return (num / 1e3).toFixed(2) + 'K';
|
||||
}
|
||||
return num.toFixed(2);
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack align="stretch" spacing={4} w="100%">
|
||||
{/* 工具栏 */}
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={4}>
|
||||
{/* 时间范围选择 */}
|
||||
<ButtonGroup size="sm" isAttached variant="outline">
|
||||
{(['1M', '3M', '6M', '1Y', 'YTD', 'ALL'] as TimeRange[]).map((range) => (
|
||||
<Button
|
||||
key={range}
|
||||
onClick={() => handleTimeRangeChange(range)}
|
||||
bg={selectedRange === range ? themeColors.primary.gold : 'transparent'}
|
||||
color={
|
||||
selectedRange === range ? themeColors.bg.primary : themeColors.text.secondary
|
||||
}
|
||||
borderColor={themeColors.border.gold}
|
||||
_hover={{
|
||||
bg: selectedRange === range ? themeColors.primary.goldLight : themeColors.bg.card,
|
||||
}}
|
||||
>
|
||||
{range}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
|
||||
{/* 图表操作 */}
|
||||
<HStack spacing={2}>
|
||||
<Tooltip label="重置视图">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color={themeColors.text.secondary}
|
||||
_hover={{ color: themeColors.primary.gold }}
|
||||
onClick={handleReset}
|
||||
>
|
||||
<Icon as={FaRedo} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="截图">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color={themeColors.text.secondary}
|
||||
_hover={{ color: themeColors.primary.gold }}
|
||||
onClick={handleScreenshot}
|
||||
>
|
||||
<Icon as={FaCamera} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color={themeColors.text.secondary}
|
||||
_hover={{ color: themeColors.primary.gold }}
|
||||
onClick={toggleFullscreen}
|
||||
>
|
||||
<Icon as={isFullscreen ? FaCompress : FaExpand} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</HStack>
|
||||
</Flex>
|
||||
|
||||
{/* 统计数据 */}
|
||||
<Flex
|
||||
justify="space-around"
|
||||
align="center"
|
||||
bg={themeColors.bg.secondary}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={themeColors.border.default}
|
||||
wrap="wrap"
|
||||
gap={4}
|
||||
>
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
最新值
|
||||
</Text>
|
||||
<Text color={themeColors.text.gold} fontSize="lg" fontWeight="bold">
|
||||
{formatNumber(stats.latest)} {unit}
|
||||
</Text>
|
||||
<Text
|
||||
color={stats.change >= 0 ? '#00ff88' : '#ff4444'}
|
||||
fontSize="xs"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{stats.change >= 0 ? '+' : ''}
|
||||
{formatNumber(stats.change)} ({stats.changePercent.toFixed(2)}%)
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
平均值
|
||||
</Text>
|
||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||||
{formatNumber(stats.avg)} {unit}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
最高值
|
||||
</Text>
|
||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||||
{formatNumber(stats.max)} {unit}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
最低值
|
||||
</Text>
|
||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||||
{formatNumber(stats.min)} {unit}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
数据点数
|
||||
</Text>
|
||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||||
{data.filter((item) => item.value !== null).length}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<VStack spacing={0}>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
频率
|
||||
</Text>
|
||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
||||
{frequency}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
|
||||
{/* 图表容器 */}
|
||||
<Box
|
||||
ref={chartContainerRef}
|
||||
w="100%"
|
||||
h="500px"
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={themeColors.border.gold}
|
||||
overflow="hidden"
|
||||
position="relative"
|
||||
bg={themeColors.bg.card}
|
||||
/>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<Flex justify="space-between" align="center" fontSize="xs" color={themeColors.text.muted}>
|
||||
<HStack spacing={4}>
|
||||
<Text>💡 提示:滚动鼠标滚轮缩放,拖拽移动视图</Text>
|
||||
</HStack>
|
||||
<Text>数据来源: {metricName}</Text>
|
||||
</Flex>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradingViewChart;
|
||||
868
src/views/DataBrowser/index.tsx
Normal file
868
src/views/DataBrowser/index.tsx
Normal file
@@ -0,0 +1,868 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Flex,
|
||||
Text,
|
||||
Input,
|
||||
Button,
|
||||
VStack,
|
||||
HStack,
|
||||
Badge,
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
Icon,
|
||||
Spinner,
|
||||
useToast,
|
||||
Card,
|
||||
CardBody,
|
||||
Divider,
|
||||
SimpleGrid,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FaDatabase,
|
||||
FaFolder,
|
||||
FaFolderOpen,
|
||||
FaFile,
|
||||
FaSearch,
|
||||
FaHome,
|
||||
FaChevronRight,
|
||||
FaChevronDown,
|
||||
FaTimes,
|
||||
FaEye,
|
||||
} from 'react-icons/fa';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
fetchCategoryTree,
|
||||
fetchCategoryNode,
|
||||
searchMetrics,
|
||||
TreeNode,
|
||||
TreeMetric,
|
||||
CategoryTreeResponse,
|
||||
MetricSearchResult,
|
||||
SearchResponse
|
||||
} from '@services/categoryService';
|
||||
import MetricDataModal from './MetricDataModal';
|
||||
|
||||
// 黑金主题配色
|
||||
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)',
|
||||
},
|
||||
};
|
||||
|
||||
const MotionBox = motion(Box);
|
||||
const MotionCard = motion(Card);
|
||||
|
||||
// 树节点组件(支持懒加载)
|
||||
const TreeNodeComponent: React.FC<{
|
||||
node: TreeNode;
|
||||
source: 'SMM' | 'Mysteel';
|
||||
onNodeClick: (node: TreeNode) => void;
|
||||
expandedNodes: Set<string>;
|
||||
onToggleExpand: (node: TreeNode) => Promise<void>;
|
||||
searchQuery: string;
|
||||
loadingNodes: Set<string>;
|
||||
}> = ({ node, source, onNodeClick, expandedNodes, onToggleExpand, searchQuery, loadingNodes }) => {
|
||||
const isExpanded = expandedNodes.has(node.path);
|
||||
const isLoading = loadingNodes.has(node.path);
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const hasMetrics = node.metrics && node.metrics.length > 0;
|
||||
|
||||
// 高亮搜索关键词
|
||||
const highlightText = (text: string) => {
|
||||
if (!searchQuery) return text;
|
||||
const parts = text.split(new RegExp(`(${searchQuery})`, 'gi'));
|
||||
return parts.map((part, index) =>
|
||||
part.toLowerCase() === searchQuery.toLowerCase() ? (
|
||||
<Text as="span" key={index} color={themeColors.primary.gold} fontWeight="bold">
|
||||
{part}
|
||||
</Text>
|
||||
) : (
|
||||
part
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Flex
|
||||
align="center"
|
||||
p={2}
|
||||
pl={node.level * 4}
|
||||
cursor="pointer"
|
||||
bg={isExpanded ? themeColors.bg.cardHover : 'transparent'}
|
||||
_hover={{ bg: themeColors.bg.cardHover }}
|
||||
borderRadius="md"
|
||||
transition="all 0.2s"
|
||||
onClick={() => {
|
||||
onToggleExpand(node);
|
||||
onNodeClick(node);
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner size="xs" color={themeColors.primary.gold} mr={2} />
|
||||
) : hasChildren || !hasMetrics ? (
|
||||
<Icon
|
||||
as={isExpanded ? FaChevronDown : FaChevronRight}
|
||||
color={themeColors.text.muted}
|
||||
mr={2}
|
||||
fontSize="xs"
|
||||
/>
|
||||
) : (
|
||||
<Box w="16px" mr={2} />
|
||||
)}
|
||||
|
||||
<Icon
|
||||
as={hasChildren || !hasMetrics ? (isExpanded ? FaFolderOpen : FaFolder) : FaFile}
|
||||
color={hasChildren || !hasMetrics ? themeColors.primary.gold : themeColors.text.secondary}
|
||||
mr={2}
|
||||
/>
|
||||
|
||||
<Text color={themeColors.text.primary} fontSize="sm">
|
||||
{highlightText(node.name)}
|
||||
</Text>
|
||||
|
||||
{hasMetrics && (
|
||||
<Badge
|
||||
ml={2}
|
||||
bg={themeColors.border.gold}
|
||||
color={themeColors.primary.gold}
|
||||
fontSize="xs"
|
||||
>
|
||||
{node.metrics.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{isExpanded && hasChildren && (
|
||||
<Box>
|
||||
{node.children!.map((child) => (
|
||||
<TreeNodeComponent
|
||||
key={child.path}
|
||||
node={child}
|
||||
source={source}
|
||||
onNodeClick={onNodeClick}
|
||||
expandedNodes={expandedNodes}
|
||||
onToggleExpand={onToggleExpand}
|
||||
searchQuery={searchQuery}
|
||||
loadingNodes={loadingNodes}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 指标卡片组件(可点击查看详情)
|
||||
const MetricCard: React.FC<{ metric: TreeMetric; onClick: () => void }> = ({ metric, onClick }) => {
|
||||
return (
|
||||
<MotionCard
|
||||
bg={themeColors.bg.card}
|
||||
borderWidth="1px"
|
||||
borderColor={themeColors.border.default}
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
cursor="pointer"
|
||||
onClick={onClick}
|
||||
whileHover={{
|
||||
borderColor: themeColors.border.goldGlow,
|
||||
scale: 1.02,
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<CardBody>
|
||||
<VStack align="stretch" spacing={3}>
|
||||
<HStack justify="space-between">
|
||||
<Text color={themeColors.text.primary} fontWeight="bold" fontSize="sm" flex="1">
|
||||
{metric.metric_name}
|
||||
</Text>
|
||||
<Badge
|
||||
bg={metric.source === 'SMM' ? 'blue.500' : 'green.500'}
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
>
|
||||
{metric.source}
|
||||
</Badge>
|
||||
</HStack>
|
||||
|
||||
<Divider borderColor={themeColors.border.default} />
|
||||
|
||||
<SimpleGrid columns={2} spacing={2}>
|
||||
<Box>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
频率
|
||||
</Text>
|
||||
<Text color={themeColors.text.secondary} fontSize="sm">
|
||||
{metric.frequency}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
单位
|
||||
</Text>
|
||||
<Text color={themeColors.text.secondary} fontSize="sm">
|
||||
{metric.unit || '-'}
|
||||
</Text>
|
||||
</Box>
|
||||
</SimpleGrid>
|
||||
|
||||
{metric.description && (
|
||||
<Text color={themeColors.text.muted} fontSize="xs" noOfLines={2}>
|
||||
{metric.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<HStack justify="space-between">
|
||||
<Text color={themeColors.text.muted} fontSize="xs" fontFamily="monospace">
|
||||
ID: {metric.metric_id}
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color={themeColors.primary.gold}
|
||||
leftIcon={<FaEye />}
|
||||
_hover={{ bg: themeColors.bg.cardHover }}
|
||||
>
|
||||
查看数据
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</MotionCard>
|
||||
);
|
||||
};
|
||||
|
||||
const DataBrowser: React.FC = () => {
|
||||
const [selectedSource, setSelectedSource] = useState<'SMM' | 'Mysteel'>('SMM');
|
||||
const [treeData, setTreeData] = useState<CategoryTreeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResponse | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [currentNode, setCurrentNode] = useState<TreeNode | null>(null);
|
||||
const [breadcrumbs, setBreadcrumbs] = useState<string[]>([]);
|
||||
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
|
||||
const [loadingNodes, setLoadingNodes] = useState<Set<string>>(new Set());
|
||||
const [selectedMetric, setSelectedMetric] = useState<TreeMetric | null>(null);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
// 加载分类树(只加载第一层)
|
||||
useEffect(() => {
|
||||
loadCategoryTree();
|
||||
}, [selectedSource]);
|
||||
|
||||
const loadCategoryTree = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchCategoryTree(selectedSource, 1); // 只加载第一层
|
||||
setTreeData(data);
|
||||
setCurrentNode(null);
|
||||
setBreadcrumbs([]);
|
||||
setExpandedNodes(new Set());
|
||||
setSearchResults(null); // 清空搜索结果
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: '无法加载分类树数据',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 执行搜索
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
try {
|
||||
const results = await searchMetrics(searchQuery, selectedSource, undefined, 100);
|
||||
setSearchResults(results);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '搜索失败',
|
||||
description: '无法搜索指标数据',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 当搜索关键词变化时,自动搜索
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (searchQuery.trim()) {
|
||||
handleSearch();
|
||||
} else {
|
||||
setSearchResults(null);
|
||||
}
|
||||
}, 500); // 防抖 500ms
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, selectedSource]);
|
||||
|
||||
// 切换节点展开状态(懒加载子节点)
|
||||
const toggleNodeExpand = async (node: TreeNode) => {
|
||||
const isCurrentlyExpanded = expandedNodes.has(node.path);
|
||||
|
||||
if (isCurrentlyExpanded) {
|
||||
// 收起节点
|
||||
setExpandedNodes((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(node.path);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
// 展开节点 - 检查是否需要加载子节点
|
||||
const needsLoading = !node.children || node.children.length === 0;
|
||||
|
||||
if (needsLoading) {
|
||||
// 添加加载状态
|
||||
setLoadingNodes((prev) => new Set(prev).add(node.path));
|
||||
|
||||
try {
|
||||
// 从服务器加载子节点
|
||||
const nodeData = await fetchCategoryNode(node.path, selectedSource);
|
||||
|
||||
// 更新树数据
|
||||
setTreeData((prevData) => {
|
||||
if (!prevData) return prevData;
|
||||
|
||||
const updateNode = (nodes: TreeNode[]): TreeNode[] => {
|
||||
return nodes.map((n) => {
|
||||
if (n.path === node.path) {
|
||||
return { ...n, children: nodeData.children, metrics: nodeData.metrics };
|
||||
}
|
||||
if (n.children) {
|
||||
return { ...n, children: updateNode(n.children) };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
...prevData,
|
||||
tree: updateNode(prevData.tree),
|
||||
};
|
||||
});
|
||||
|
||||
// 更新当前节点(如果是当前选中的节点)
|
||||
if (currentNode && currentNode.path === node.path) {
|
||||
setCurrentNode(nodeData);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: '加载失败',
|
||||
description: '无法加载子节点数据',
|
||||
status: 'error',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
} finally {
|
||||
setLoadingNodes((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(node.path);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 展开节点
|
||||
setExpandedNodes((prev) => new Set(prev).add(node.path));
|
||||
}
|
||||
};
|
||||
|
||||
// 处理节点点击
|
||||
const handleNodeClick = (node: TreeNode) => {
|
||||
setCurrentNode(node);
|
||||
const pathParts = node.path.split('|');
|
||||
setBreadcrumbs(pathParts);
|
||||
};
|
||||
|
||||
// 处理面包屑导航
|
||||
const handleBreadcrumbClick = (index: number) => {
|
||||
if (index === -1) {
|
||||
setCurrentNode(null);
|
||||
setBreadcrumbs([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetPath = breadcrumbs.slice(0, index + 1).join('|');
|
||||
// 在树中查找对应节点
|
||||
const findNode = (nodes: TreeNode[], path: string): TreeNode | null => {
|
||||
for (const node of nodes) {
|
||||
if (node.path === path) return node;
|
||||
if (node.children) {
|
||||
const found = findNode(node.children, path);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (treeData) {
|
||||
const node = findNode(treeData.tree, targetPath);
|
||||
if (node) {
|
||||
handleNodeClick(node);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理指标点击
|
||||
const handleMetricClick = (metric: TreeMetric) => {
|
||||
setSelectedMetric(metric);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
// 显示的树节点(搜索时不显示树)
|
||||
const displayTree = useMemo(() => {
|
||||
if (searchQuery.trim()) {
|
||||
return []; // 搜索时不显示树
|
||||
}
|
||||
return treeData?.tree || [];
|
||||
}, [treeData, searchQuery]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
minH="100vh"
|
||||
bg={themeColors.bg.primary}
|
||||
bgGradient={themeColors.bgGradient}
|
||||
position="relative"
|
||||
pt={{ base: '120px', md: '75px' }}
|
||||
>
|
||||
{/* 金色光晕背景 */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="50%"
|
||||
transform="translateX(-50%)"
|
||||
width="100%"
|
||||
height="400px"
|
||||
bgGradient={themeColors.bgRadialGold}
|
||||
opacity={0.3}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
<Container maxW="container.xl" position="relative" zIndex={1}>
|
||||
{/* 标题区域 */}
|
||||
<MotionBox
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<VStack spacing={4} align="stretch" mb={8}>
|
||||
<HStack spacing={4}>
|
||||
<Icon as={FaDatabase} color={themeColors.primary.gold} boxSize={8} />
|
||||
<VStack align="start" spacing={0}>
|
||||
<Text
|
||||
fontSize="3xl"
|
||||
fontWeight="bold"
|
||||
color={themeColors.text.primary}
|
||||
textShadow={`0 0 20px ${themeColors.primary.gold}40`}
|
||||
>
|
||||
数据浏览器
|
||||
</Text>
|
||||
<Text color={themeColors.text.secondary} fontSize="sm">
|
||||
化工商品数据分类树 - 探索海量行业指标
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
{/* 数据源切换 */}
|
||||
<HStack spacing={4}>
|
||||
<Button
|
||||
size="sm"
|
||||
bg={selectedSource === 'SMM' ? themeColors.primary.gold : 'transparent'}
|
||||
color={selectedSource === 'SMM' ? themeColors.bg.primary : themeColors.text.secondary}
|
||||
borderWidth="1px"
|
||||
borderColor={selectedSource === 'SMM' ? themeColors.primary.gold : themeColors.border.default}
|
||||
_hover={{
|
||||
borderColor: themeColors.primary.gold,
|
||||
color: selectedSource === 'SMM' ? themeColors.bg.primary : themeColors.primary.gold,
|
||||
}}
|
||||
onClick={() => setSelectedSource('SMM')}
|
||||
>
|
||||
SMM {treeData && selectedSource === 'SMM' && `(${treeData.total_metrics.toLocaleString()} 指标)`}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
bg={selectedSource === 'Mysteel' ? themeColors.primary.gold : 'transparent'}
|
||||
color={selectedSource === 'Mysteel' ? themeColors.bg.primary : themeColors.text.secondary}
|
||||
borderWidth="1px"
|
||||
borderColor={selectedSource === 'Mysteel' ? themeColors.primary.gold : themeColors.border.default}
|
||||
_hover={{
|
||||
borderColor: themeColors.primary.gold,
|
||||
color: selectedSource === 'Mysteel' ? themeColors.bg.primary : themeColors.primary.gold,
|
||||
}}
|
||||
onClick={() => setSelectedSource('Mysteel')}
|
||||
>
|
||||
Mysteel {treeData && selectedSource === 'Mysteel' && `(${treeData.total_metrics.toLocaleString()} 指标)`}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</MotionBox>
|
||||
|
||||
{/* 搜索和过滤 */}
|
||||
<MotionBox
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.5 }}
|
||||
>
|
||||
<Card
|
||||
bg={themeColors.bg.card}
|
||||
borderWidth="1px"
|
||||
borderColor={themeColors.border.gold}
|
||||
mb={6}
|
||||
>
|
||||
<CardBody>
|
||||
<VStack spacing={3} align="stretch">
|
||||
<HStack spacing={4}>
|
||||
<Input
|
||||
placeholder="搜索分类或指标名称..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
bg={themeColors.bg.secondary}
|
||||
borderColor={themeColors.border.default}
|
||||
color={themeColors.text.primary}
|
||||
_placeholder={{ color: themeColors.text.muted }}
|
||||
_focus={{
|
||||
borderColor: themeColors.primary.gold,
|
||||
boxShadow: `0 0 0 1px ${themeColors.primary.gold}`,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
leftIcon={<FaSearch />}
|
||||
bg={themeColors.primary.gold}
|
||||
color={themeColors.bg.primary}
|
||||
_hover={{ bg: themeColors.primary.goldLight }}
|
||||
onClick={handleSearch}
|
||||
isLoading={searching}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
leftIcon={<FaTimes />}
|
||||
variant="ghost"
|
||||
color={themeColors.text.secondary}
|
||||
_hover={{ color: themeColors.text.primary }}
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{/* 搜索结果提示 */}
|
||||
{searchResults && (
|
||||
<Flex align="center" justify="space-between" py={2}>
|
||||
<Text color={themeColors.text.secondary} fontSize="sm">
|
||||
找到 <Text as="span" color={themeColors.primary.gold} fontWeight="bold">{searchResults.total}</Text> 个相关指标
|
||||
</Text>
|
||||
<Text color={themeColors.text.muted} fontSize="xs">
|
||||
关键词: "{searchResults.query}"
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
{searching && (
|
||||
<Flex align="center" justify="center" py={2}>
|
||||
<Spinner size="sm" color={themeColors.primary.gold} mr={2} />
|
||||
<Text color={themeColors.text.secondary} fontSize="sm">
|
||||
搜索中...
|
||||
</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</MotionBox>
|
||||
|
||||
{/* 面包屑导航 */}
|
||||
{breadcrumbs.length > 0 && (
|
||||
<MotionBox
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
mb={4}
|
||||
>
|
||||
<Card bg={themeColors.bg.card} borderWidth="1px" borderColor={themeColors.border.default}>
|
||||
<CardBody py={2}>
|
||||
<Breadcrumb
|
||||
spacing={2}
|
||||
separator={<Icon as={FaChevronRight} color={themeColors.text.muted} />}
|
||||
>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink
|
||||
color={themeColors.text.secondary}
|
||||
_hover={{ color: themeColors.primary.gold }}
|
||||
onClick={() => handleBreadcrumbClick(-1)}
|
||||
>
|
||||
<Icon as={FaHome} />
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<BreadcrumbItem key={index} isCurrentPage={index === breadcrumbs.length - 1}>
|
||||
<BreadcrumbLink
|
||||
color={index === breadcrumbs.length - 1 ? themeColors.primary.gold : themeColors.text.secondary}
|
||||
_hover={{ color: themeColors.primary.gold }}
|
||||
onClick={() => handleBreadcrumbClick(index)}
|
||||
>
|
||||
{crumb}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
))}
|
||||
</Breadcrumb>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</MotionBox>
|
||||
)}
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<Flex gap={6} direction={{ base: 'column', lg: 'row' }}>
|
||||
{/* 左侧:分类树 */}
|
||||
<MotionBox
|
||||
flex={{ base: '1', lg: '0 0 400px' }}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.5 }}
|
||||
>
|
||||
<Card
|
||||
bg={themeColors.bg.card}
|
||||
borderWidth="1px"
|
||||
borderColor={themeColors.border.gold}
|
||||
maxH="calc(100vh - 400px)"
|
||||
overflowY="auto"
|
||||
css={{
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '8px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: themeColors.bg.secondary,
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: themeColors.primary.gold,
|
||||
borderRadius: '4px',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardBody>
|
||||
{loading ? (
|
||||
<Flex justify="center" align="center" py={10}>
|
||||
<Spinner color={themeColors.primary.gold} size="xl" />
|
||||
</Flex>
|
||||
) : searchQuery.trim() ? (
|
||||
// 搜索模式:显示搜索结果列表
|
||||
<VStack align="stretch" spacing={1}>
|
||||
{searchResults && searchResults.results.length > 0 ? (
|
||||
searchResults.results.map((result) => (
|
||||
<Box
|
||||
key={result.metric_id}
|
||||
p={3}
|
||||
cursor="pointer"
|
||||
bg="transparent"
|
||||
_hover={{ bg: themeColors.bg.cardHover }}
|
||||
borderRadius="md"
|
||||
borderLeftWidth="3px"
|
||||
borderLeftColor="transparent"
|
||||
_hover={{ borderLeftColor: themeColors.primary.gold }}
|
||||
transition="all 0.2s"
|
||||
onClick={() => {
|
||||
// 转换搜索结果为 TreeMetric 格式
|
||||
const metric: TreeMetric = {
|
||||
metric_id: result.metric_id,
|
||||
metric_name: result.metric_name,
|
||||
source: result.source,
|
||||
frequency: result.frequency,
|
||||
unit: result.unit,
|
||||
description: result.description,
|
||||
};
|
||||
handleMetricClick(metric);
|
||||
}}
|
||||
>
|
||||
<VStack align="stretch" spacing={2}>
|
||||
<HStack justify="space-between">
|
||||
<Text color={themeColors.text.primary} fontSize="sm" fontWeight="bold" flex="1">
|
||||
{result.metric_name}
|
||||
</Text>
|
||||
<Badge
|
||||
bg={result.source === 'SMM' ? 'blue.500' : 'green.500'}
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
>
|
||||
{result.source}
|
||||
</Badge>
|
||||
</HStack>
|
||||
<HStack spacing={4} fontSize="xs" color={themeColors.text.muted}>
|
||||
<Text>路径: {result.category_path}</Text>
|
||||
<Text>频率: {result.frequency}</Text>
|
||||
<Text>单位: {result.unit || '-'}</Text>
|
||||
</HStack>
|
||||
{result.score && (
|
||||
<Text fontSize="xs" color={themeColors.text.muted}>
|
||||
相关度: {(result.score * 100).toFixed(0)}%
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
))
|
||||
) : searchResults ? (
|
||||
<Flex justify="center" align="center" py={10}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FaSearch} color={themeColors.text.muted} boxSize={12} />
|
||||
<Text color={themeColors.text.muted}>未找到匹配的指标</Text>
|
||||
<Text color={themeColors.text.muted} fontSize="sm">
|
||||
尝试使用不同的关键词
|
||||
</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
) : null}
|
||||
</VStack>
|
||||
) : (
|
||||
// 正常模式:显示分类树
|
||||
<VStack align="stretch" spacing={1}>
|
||||
{displayTree.map((node) => (
|
||||
<TreeNodeComponent
|
||||
key={node.path}
|
||||
node={node}
|
||||
source={selectedSource}
|
||||
onNodeClick={handleNodeClick}
|
||||
expandedNodes={expandedNodes}
|
||||
onToggleExpand={toggleNodeExpand}
|
||||
searchQuery=""
|
||||
loadingNodes={loadingNodes}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</MotionBox>
|
||||
|
||||
{/* 右侧:指标详情 */}
|
||||
<MotionBox
|
||||
flex="1"
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.5 }}
|
||||
>
|
||||
<Card
|
||||
bg={themeColors.bg.card}
|
||||
borderWidth="1px"
|
||||
borderColor={themeColors.border.gold}
|
||||
minH="400px"
|
||||
>
|
||||
<CardBody>
|
||||
{currentNode ? (
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<HStack justify="space-between">
|
||||
<VStack align="start" spacing={1}>
|
||||
<Text color={themeColors.text.primary} fontSize="2xl" fontWeight="bold">
|
||||
{currentNode.name}
|
||||
</Text>
|
||||
<Text color={themeColors.text.muted} fontSize="sm">
|
||||
层级 {currentNode.level} | 路径: {currentNode.path}
|
||||
</Text>
|
||||
</VStack>
|
||||
{currentNode.metrics && currentNode.metrics.length > 0 && (
|
||||
<Badge
|
||||
bg={themeColors.primary.gold}
|
||||
color={themeColors.bg.primary}
|
||||
fontSize="md"
|
||||
px={3}
|
||||
py={1}
|
||||
>
|
||||
{currentNode.metrics.length} 个指标
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<Divider borderColor={themeColors.border.gold} />
|
||||
|
||||
{currentNode.metrics && currentNode.metrics.length > 0 ? (
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4} mt={4}>
|
||||
{currentNode.metrics.map((metric) => (
|
||||
<MetricCard
|
||||
key={metric.metric_id}
|
||||
metric={metric}
|
||||
onClick={() => handleMetricClick(metric)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Flex justify="center" align="center" py={10}>
|
||||
<VStack spacing={3}>
|
||||
<Icon as={FaFolder} color={themeColors.text.muted} boxSize={12} />
|
||||
<Text color={themeColors.text.muted}>
|
||||
{currentNode.children && currentNode.children.length > 0
|
||||
? '该节点包含子分类,请展开查看'
|
||||
: '该节点暂无指标数据'}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Flex>
|
||||
)}
|
||||
</VStack>
|
||||
) : (
|
||||
<Flex justify="center" align="center" py={20}>
|
||||
<VStack spacing={4}>
|
||||
<Icon as={FaDatabase} color={themeColors.primary.gold} boxSize={16} />
|
||||
<Text color={themeColors.text.secondary} fontSize="lg" textAlign="center">
|
||||
选择左侧分类树节点查看详情
|
||||
</Text>
|
||||
{treeData && (
|
||||
<Text color={themeColors.text.muted} fontSize="sm">
|
||||
当前数据源共有 {treeData.total_metrics.toLocaleString()} 个指标
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</Flex>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</MotionBox>
|
||||
</Flex>
|
||||
</Container>
|
||||
|
||||
{/* 指标数据详情模态框 */}
|
||||
{selectedMetric && (
|
||||
<MetricDataModal isOpen={isOpen} onClose={onClose} metric={selectedMetric} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataBrowser;
|
||||
@@ -158,12 +158,16 @@ export const subscriptionConfig = {
|
||||
answer: '我们目前支持微信支付。扫描支付二维码后,系统会自动检测支付状态并激活您的订阅。支付过程安全可靠,所有交易都经过加密处理。',
|
||||
},
|
||||
{
|
||||
question: '升级或切换套餐时,原套餐的费用怎么办?',
|
||||
answer: '当您升级套餐或切换计费周期时,系统会自动计算您当前订阅的剩余价值并用于抵扣新套餐的费用。\n\n计算方式:\n• 剩余价值 = 原套餐价格 × (剩余天数 / 总天数)\n• 实付金额 = 新套餐价格 - 剩余价值 - 优惠码折扣\n\n例如:您购买了年付Pro版(¥2699),使用了180天后升级到Max版(¥5399/年),剩余价值约¥1350将自动抵扣,实付约¥4049。',
|
||||
question: '同级续费如何计费?',
|
||||
answer: '如果您是Pro用户续费Pro版本,或Max用户续费Max版本,支付后将在当前订阅到期日基础上延长相应时长。例如:您的Max年付版本还有30天到期,续费Max年付后,新的到期时间将延长至395天后(30天+365天)。',
|
||||
},
|
||||
{
|
||||
question: '可以在不同计费周期之间切换吗?',
|
||||
answer: '可以。您可以随时更改计费周期。如果从短期切换到长期,系统会计算剩余价值并应用到新的订阅中。长期套餐(季付、半年付、年付)可享受更大的折扣优惠。',
|
||||
question: 'Pro用户如何升级到Max?',
|
||||
answer: '从Pro升级到Max需要补差价,升级后立即生效。系统会根据您Pro订阅的剩余价值计算需要补缴的费用。支付成功后,您将立即获得Max版本的所有功能。\n\n特别说明:如果您的Pro订阅剩余价值超过或等于Max套餐的价格,系统将自动为您免费升级到Max版本,无需支付额外费用。升级后的有效期将根据剩余价值按比例计算。例如:您的Pro年付版本剩余价值为1200元,选择Max月付版本(998元/月),系统将为您提供约36天的Max版本使用时长(1200÷998×30天)。',
|
||||
},
|
||||
{
|
||||
question: 'Max用户可以切换到Pro吗?',
|
||||
answer: '可以。Max用户购买Pro套餐后,系统会在当前Max订阅到期后自动切换到Pro版本,并从到期日开始计算Pro的订阅时长。在Max到期前,您仍可继续使用Max的全部功能。',
|
||||
},
|
||||
{
|
||||
question: '是否支持退款?',
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
-- ============================================
|
||||
-- 更新订阅套餐价格配置
|
||||
-- 用途:为 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 '';
|
||||
@@ -35,6 +35,28 @@ server {
|
||||
ssl_certificate /etc/letsencrypt/live/valuefrontier.cn/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/valuefrontier.cn/privkey.pem;
|
||||
|
||||
|
||||
# ============================================
|
||||
# SEO 文件配置(robots.txt + sitemap.xml)
|
||||
# 优先级最高,放在最前面
|
||||
# ============================================
|
||||
|
||||
# robots.txt - 精确匹配(优先级最高)
|
||||
location = /robots.txt {
|
||||
root /var/www/valuefrontier;
|
||||
add_header Content-Type "text/plain; charset=utf-8";
|
||||
add_header Cache-Control "public, max-age=3600"; # 缓存 1 小时
|
||||
access_log off; # 减少日志记录
|
||||
}
|
||||
|
||||
# sitemap.xml - 精确匹配
|
||||
location = /sitemap.xml {
|
||||
root /var/www/valuefrontier;
|
||||
add_header Content-Type "application/xml; charset=utf-8";
|
||||
add_header Cache-Control "public, max-age=3600"; # 缓存 1 小时
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# --- 为React应用提供静态资源 ---
|
||||
location /static/ {
|
||||
alias /var/www/valuefrontier.cn/static/;
|
||||
@@ -404,6 +426,37 @@ server {
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
|
||||
# 商品分类树数据API代理(数据浏览器)
|
||||
location /category-api/ {
|
||||
proxy_pass http://222.128.1.157:18827/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# CORS 配置
|
||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
|
||||
|
||||
# 处理 OPTIONS 预检请求
|
||||
if ($request_method = 'OPTIONS') {
|
||||
return 204;
|
||||
}
|
||||
|
||||
# 超时设置(数据量大,需要较长超时时间)
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout 120s;
|
||||
|
||||
# 缓冲配置(支持大响应体)
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 128k;
|
||||
proxy_buffers 8 256k;
|
||||
proxy_busy_buffers_size 512k;
|
||||
}
|
||||
|
||||
# --- 新的静态官网静态资源(优先级最高) ---
|
||||
# 使用 ^~ 前缀确保优先匹配,不被后面的规则覆盖
|
||||
location ^~ /css/ {
|
||||
Reference in New Issue
Block a user