'commit'
This commit is contained in:
123
Config/Config.py
123
Config/Config.py
@@ -1,59 +1,64 @@
|
||||
# 黄海在公司内网开发时的配置信息
|
||||
DORIS_HOST = "10.10.14.204"
|
||||
DORIS_PORT = 9030
|
||||
DORIS_FENODES = "10.10.14.204:8030"
|
||||
REDIS_HOST = '10.10.14.14'
|
||||
REDIS_PASSWORD = None # 如果没有密码则设为 None
|
||||
|
||||
# 黄海在家开发时的配置信息
|
||||
# DORIS_HOST = "www.hzkjai.com"
|
||||
# DORIS_PORT = 27025
|
||||
# DORIS_FENODES = "www.hzkjai.com:27024"
|
||||
# REDIS_HOST = '127.0.0.1'
|
||||
# REDIS_PASSWORD = "DsideaL147258369"
|
||||
|
||||
# 视觉模型配置
|
||||
VL_MODEL_NAME = "qwen3-vl-flash"
|
||||
VL_MODEL_NAME_AD = "qwen-vl-max"
|
||||
|
||||
# 华为云配置
|
||||
OBS_AK = "WAFBGJACKDOQZDH1MKZ1"
|
||||
OBS_SK = "dlWTUbqgCICaYJG3n0Rot4jXaen2HnfFtMVxiPEo"
|
||||
OBS_SERVER = "obs.cn-north-1.myhuaweicloud.com"
|
||||
OBS_BUCKET = "dsideal"
|
||||
OBS_CLOUD_PREFIX = "dsAi/cloudFiles"
|
||||
OBS_TMP_PREFIX = "dsAi/tmpFiles"
|
||||
CDN_DOMAIN = "video.edusoa.com"
|
||||
|
||||
# 阿里云API信息【YLT】
|
||||
ALY_LLM_API_KEY = "sk-f6da0c787eff4b0389e4ad03a35a911f"
|
||||
ALY_LLM_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
ALY_LLM_MODEL_NAME = "deepseek-v3.2"
|
||||
|
||||
|
||||
# Doris其它配置信息
|
||||
DORIS_USER = "root"
|
||||
DORIS_PWD = "DsideaL147258369"
|
||||
DORIS_DATABASE = "yltcharge"
|
||||
DB_URL = f"mysql+aiomysql://{DORIS_USER}:{DORIS_PWD}@{DORIS_HOST}:{DORIS_PORT}/{DORIS_DATABASE}?charset=utf8mb4"
|
||||
doris = {
|
||||
"url": f"jdbc:mysql://{DORIS_HOST}:{DORIS_PORT}/{DORIS_DATABASE}?rewriteBatchedStatements=true&batchsize=1000&serverTimezone=Asia/Shanghai",
|
||||
"user": DORIS_USER,
|
||||
"pwd": DORIS_PWD,
|
||||
"fenodes": DORIS_FENODES,
|
||||
"database": DORIS_DATABASE
|
||||
}
|
||||
|
||||
# REDIS 配置
|
||||
REDIS_DB = 2
|
||||
REDIS_DECODE_RESPONSES = True
|
||||
REDIS_PORT = 18890
|
||||
REDIS_MAX_CONNECTIONS = 200
|
||||
|
||||
# 临时图片存储路径
|
||||
TEMP_IMAGE_DIR = r"d:\dsWork\aiData\Output"
|
||||
|
||||
# 分时价格全局配置
|
||||
# False: 默认按各个供应商自己的 PRICE_FLATTEN_TO_24H 决定
|
||||
# True: 强制所有供应商都铺平成 24 小时整点数组
|
||||
PRICE_FLATTEN_TO_24H_GLOBAL = True
|
||||
# 黄海在公司内网开发时的配置信息
|
||||
DORIS_HOST = "10.10.14.204"
|
||||
DORIS_PORT = 9030
|
||||
DORIS_FENODES = "10.10.14.204:8030"
|
||||
REDIS_HOST = '10.10.14.14'
|
||||
REDIS_PASSWORD = None # 如果没有密码则设为 None
|
||||
|
||||
# 黄海在家开发时的配置信息
|
||||
# DORIS_HOST = "www.hzkjai.com"
|
||||
# DORIS_PORT = 27025
|
||||
# DORIS_FENODES = "www.hzkjai.com:27024"
|
||||
# REDIS_HOST = '127.0.0.1'
|
||||
# REDIS_PASSWORD = "DsideaL147258369"
|
||||
|
||||
# 视觉模型配置
|
||||
VL_MODEL_NAME = "qwen3-vl-flash"
|
||||
VL_MODEL_NAME_AD = "qwen-vl-max"
|
||||
|
||||
# 华为云配置
|
||||
OBS_AK = "WAFBGJACKDOQZDH1MKZ1"
|
||||
OBS_SK = "dlWTUbqgCICaYJG3n0Rot4jXaen2HnfFtMVxiPEo"
|
||||
OBS_SERVER = "obs.cn-north-1.myhuaweicloud.com"
|
||||
OBS_BUCKET = "dsideal"
|
||||
OBS_CLOUD_PREFIX = "dsAi/cloudFiles"
|
||||
OBS_TMP_PREFIX = "dsAi/tmpFiles"
|
||||
CDN_DOMAIN = "video.edusoa.com"
|
||||
|
||||
# 阿里云API信息【YLT】
|
||||
ALY_LLM_API_KEY = "sk-f6da0c787eff4b0389e4ad03a35a911f"
|
||||
ALY_LLM_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
ALY_LLM_MODEL_NAME = "deepseek-v3"
|
||||
|
||||
|
||||
# Doris其它配置信息
|
||||
DORIS_USER = "root"
|
||||
DORIS_PWD = "DsideaL147258369"
|
||||
DORIS_DATABASE = "yltcharge"
|
||||
DB_URL = f"mysql+aiomysql://{DORIS_USER}:{DORIS_PWD}@{DORIS_HOST}:{DORIS_PORT}/{DORIS_DATABASE}?charset=utf8mb4"
|
||||
doris = {
|
||||
"url": f"jdbc:mysql://{DORIS_HOST}:{DORIS_PORT}/{DORIS_DATABASE}?rewriteBatchedStatements=true&batchsize=1000&serverTimezone=Asia/Shanghai",
|
||||
"user": DORIS_USER,
|
||||
"pwd": DORIS_PWD,
|
||||
"fenodes": DORIS_FENODES,
|
||||
"database": DORIS_DATABASE
|
||||
}
|
||||
|
||||
# REDIS 配置
|
||||
REDIS_DB = 2
|
||||
REDIS_DECODE_RESPONSES = True
|
||||
REDIS_PORT = 18890
|
||||
REDIS_MAX_CONNECTIONS = 200
|
||||
|
||||
# 临时图片存储路径
|
||||
TEMP_IMAGE_DIR = r"d:\dsWork\aiData\Output"
|
||||
|
||||
# 分时价格全局配置
|
||||
# False: 默认按各个供应商自己的 PRICE_FLATTEN_TO_24H 决定
|
||||
# True: 强制所有供应商都铺平成 24 小时整点数组
|
||||
PRICE_FLATTEN_TO_24H_GLOBAL = True
|
||||
|
||||
# 兼容旧代码的 LLM 配置
|
||||
LLM_API_KEY = ALY_LLM_API_KEY
|
||||
LLM_BASE_URL = ALY_LLM_BASE_URL
|
||||
LLM_MODEL_NAME = ALY_LLM_MODEL_NAME
|
||||
|
||||
Binary file not shown.
291
Controller/DegreeController.py
Normal file
291
Controller/DegreeController.py
Normal file
@@ -0,0 +1,291 @@
|
||||
import calendar
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from Model.DegreeModel import DegreeModel
|
||||
from Util.LlmUtil import get_llm_response
|
||||
|
||||
|
||||
router = APIRouter(prefix="/degree")
|
||||
logger = logging.getLogger('DegreeController')
|
||||
|
||||
|
||||
async def _query_month(year: int, month: int, station_names: Optional[List[str]]):
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
start_time = f"{year:04d}-{month:02d}-01 00:00:00"
|
||||
end_time = f"{year:04d}-{month:02d}-{last_day:02d} 23:59:59"
|
||||
logger.info(
|
||||
f"query_month year={year}, month={month}, start_time={start_time}, end_time={end_time}, stations={station_names}"
|
||||
)
|
||||
model = DegreeModel()
|
||||
data = await model.get_degree(station_names=station_names, start_time=start_time, end_time=end_time)
|
||||
return {"count": len(data), "list": data}
|
||||
|
||||
@router.get("/by-month")
|
||||
async def by_month(year: int, month: int, station_names: Optional[List[str]] = Query(None)):
|
||||
return await _query_month(year, month, station_names)
|
||||
|
||||
@router.get("/chat")
|
||||
async def chat(q: str):
|
||||
"""
|
||||
SSE接口:接收自然语言查询,调用DeepSeek解析意图,查询数据,并流式返回结果
|
||||
"""
|
||||
async def event_generator():
|
||||
yield f"data: {json.dumps({'step': 'start', 'message': '正在初始化查询...'}, ensure_ascii=False)}\n\n"
|
||||
print(f"Starting SSE for query: {q}")
|
||||
# 1. 解析用户意图
|
||||
now = datetime.now()
|
||||
system_prompt = f"""
|
||||
你是一个数据查询助手。当前时间是: {now.strftime('%Y-%m-%d %H:%M:%S')}。
|
||||
用户想查询场站充电度数报表。
|
||||
请从用户的输入中提取查询参数:
|
||||
- query_type (str): 查询类型。可选值:
|
||||
- "month": 查询特定月份的数据(默认)。
|
||||
- "trend": 查询近三个月的趋势变化数据。
|
||||
- "company": 查询按企业充电量排名的TOP 10数据。
|
||||
- year (int): 年份。如果用户未指定且为"month"或"company"类型,根据当前时间推断。
|
||||
- month (int): 月份。
|
||||
- station_names (list[str] or null): 场站名称列表。如果查询所有场站,则为null。
|
||||
|
||||
用户输入: "{q}"
|
||||
|
||||
请仅返回一个JSON字符串,格式如下:
|
||||
{{"query_type": "month", "year": 2025, "month": 12, "station_names": null}}
|
||||
或者:
|
||||
{{"query_type": "trend", "station_names": null}}
|
||||
或者:
|
||||
{{"query_type": "company", "year": 2025, "month": 12}}
|
||||
不要包含Markdown代码块或其他文本。
|
||||
"""
|
||||
|
||||
# 获取意图解析结果(非流式读取)
|
||||
intent_json_str = ""
|
||||
try:
|
||||
print("Requesting intent analysis from LLM...")
|
||||
async for chunk in get_llm_response(system_prompt, stream=True):
|
||||
intent_json_str += chunk
|
||||
print(f"Intent analysis received: {intent_json_str}")
|
||||
except Exception as e:
|
||||
print(f"Error during intent analysis: {e}")
|
||||
yield f"data: {json.dumps({'step': 'error', 'message': f'意图解析失败: {str(e)}'})}\n\n"
|
||||
return
|
||||
|
||||
try:
|
||||
# 清理可能的markdown标记
|
||||
intent_json_str = intent_json_str.replace("```json", "").replace("```", "").strip()
|
||||
params = json.loads(intent_json_str)
|
||||
|
||||
query_type = params.get("query_type", "month")
|
||||
year = params.get("year")
|
||||
month = params.get("month")
|
||||
station_names = params.get("station_names")
|
||||
|
||||
model = DegreeModel()
|
||||
|
||||
# 1.1 如果用户指定了场站,进行场站名称匹配
|
||||
if station_names:
|
||||
yield f"data: {json.dumps({'step': 'analyzing', 'message': '正在匹配场站信息...'}, ensure_ascii=False)}\n\n"
|
||||
all_stations = await model.getStationList()
|
||||
all_station_names = [s['station_name'] for s in all_stations]
|
||||
|
||||
match_prompt = f"""
|
||||
你是一个专业的场站名称匹配助手。
|
||||
用户想查询以下场站的数据:{station_names}
|
||||
数据库中存在的场站列表如下:
|
||||
{all_station_names}
|
||||
|
||||
请从数据库场站列表中找出与用户描述最匹配的场站名称。
|
||||
匹配规则:
|
||||
1. 如果用户描述的名称在列表中能找到完全一致的,直接返回该名称。
|
||||
2. 如果用户描述的是简称或部分名称(如“净月商贸城”匹配“驿来特充电站(净月商贸城)”),请返回列表中的完整名称。
|
||||
3. 如果有多个匹配项,请全部返回。
|
||||
4. 如果完全没有找到匹配的场站,请返回空列表 []。
|
||||
|
||||
请仅返回一个JSON数组字符串,例如:["场站1", "场站2"]
|
||||
不要包含Markdown代码块或其他文本。
|
||||
"""
|
||||
|
||||
match_result_str = ""
|
||||
async for chunk in get_llm_response_async(match_prompt, stream=True):
|
||||
match_result_str += chunk
|
||||
|
||||
# 清理并解析匹配结果
|
||||
match_result_str = match_result_str.replace("```json", "").replace("```", "").strip()
|
||||
matched_names = json.loads(match_result_str)
|
||||
|
||||
if not matched_names:
|
||||
yield f"data: {json.dumps({'step': 'error', 'message': f'未找到与“{station_names}”相关的场站,请确认场站名称是否正确。'}, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
|
||||
station_names = matched_names
|
||||
logger.info(f"Matched station names: {station_names}")
|
||||
|
||||
# 2. 执行数据查询
|
||||
yield f"data: {json.dumps({'step': 'querying', 'message': '正在从数据库提取数据...'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
if query_type == "trend":
|
||||
from dateutil.relativedelta import relativedelta
|
||||
m3_dt = now
|
||||
m2_dt = now - relativedelta(months=1)
|
||||
m1_dt = now - relativedelta(months=2)
|
||||
month3 = m3_dt.strftime('%Y-%m')
|
||||
month2 = m2_dt.strftime('%Y-%m')
|
||||
month1 = m1_dt.strftime('%Y-%m')
|
||||
|
||||
data = await model.get_trend_change()
|
||||
summary_prompt = f"""
|
||||
你是一个专业的充电数据分析师。
|
||||
用户查询了近三个月所有场站的充电趋势及变化。
|
||||
数据库返回的数据如下(JSON格式):
|
||||
{json.dumps(data, ensure_ascii=False)}
|
||||
|
||||
请根据这些数据:
|
||||
1. 简要分析整体趋势。
|
||||
2. 识别出变化(增长或减少)最显著的场站。
|
||||
3. 使用Markdown表格展示数据,列名应为:场站名称, 第1月电量, 第2月电量, 第3月电量, 净变化, 绝对变化。
|
||||
4. **重要**:提供一个 ECharts 6.0 柱状图或折线图来展示 TOP 10 变化最大的场站趋势。
|
||||
- **配色要求**:请使用明亮且对比鲜明的配色方案,避免单调。
|
||||
- 请确保图表在深色背景下清晰可见。
|
||||
请使用以下格式输出 ECharts 配置(JSON 格式):
|
||||
```echarts
|
||||
{{
|
||||
"title": {{ "text": "充电量趋势变化 TOP 10", "left": "center", "textStyle": {{ "color": "#f8fafc" }} }},
|
||||
"tooltip": {{ "trigger": "axis", "axisPointer": {{ "type": "shadow" }} }},
|
||||
"legend": {{ "data": ["{month1}", "{month2}", "{month3}"], "bottom": 0, "textStyle": {{ "color": "#94a3b8" }} }},
|
||||
"grid": {{ "left": "3%", "right": "4%", "bottom": "10%", "containLabel": true }},
|
||||
"xAxis": {{ "type": "category", "data": ["场站A", "场站B", ...], "axisLabel": {{ "color": "#94a3b8" }} }},
|
||||
"yAxis": {{ "type": "value", "axisLabel": {{ "color": "#94a3b8" }}, "splitLine": {{ "lineStyle": {{ "color": "rgba(148, 163, 184, 0.1)" }} }} }},
|
||||
"series": [
|
||||
{{ "name": "{month1}", "type": "bar", "data": [100, 200, ...], "itemStyle": {{ "color": "#6366f1", "borderRadius": [4, 4, 0, 0] }} }},
|
||||
{{ "name": "{month2}", "type": "bar", "data": [120, 180, ...], "itemStyle": {{ "color": "#8b5cf6", "borderRadius": [4, 4, 0, 0] }} }},
|
||||
{{ "name": "{month3}", "type": "bar", "data": [150, 210, ...], "itemStyle": {{ "color": "#ec4899", "borderRadius": [4, 4, 0, 0] }} }}
|
||||
]
|
||||
}}
|
||||
```
|
||||
5. 最后给出一个简单的总结。
|
||||
|
||||
直接输出分析内容,不要解释。
|
||||
"""
|
||||
elif query_type == "company":
|
||||
if not year or not month:
|
||||
from dateutil.relativedelta import relativedelta
|
||||
prev = now - relativedelta(months=1)
|
||||
year = year or prev.year
|
||||
month = month or prev.month
|
||||
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
start_time = f"{year:04d}-{month:02d}-01 00:00:00"
|
||||
end_time = f"{year:04d}-{month:02d}-{last_day:02d} 23:59:59"
|
||||
|
||||
data = await model.get_company_top10(start_time=start_time, end_time=end_time)
|
||||
summary_prompt = f"""
|
||||
你是一个专业的充电数据分析师。
|
||||
用户查询了 {year}年{month}月 企业充电量排名的 TOP 10 数据。
|
||||
数据库返回的数据如下(JSON格式):
|
||||
{json.dumps(data, ensure_ascii=False)}
|
||||
|
||||
请根据这些数据:
|
||||
1. 简要分析该月企业充电量的整体情况。
|
||||
2. 指出充电量排名前三的企业。
|
||||
3. 使用Markdown表格展示数据,列名应为:企业名称, 总充电量, 峰电量, 平电量, 谷电量。
|
||||
4. **重要**:提供一个 ECharts 6.0 柱状图展示 TOP 10 企业的充电量对比。
|
||||
- **核心要求**:每个企业应展示 **4个柱子**,分别对应:**总量 (Total)**、**峰电量 (Peak)**、**平电量 (Flat)**、**谷电量 (Valley)**。
|
||||
- **配色要求**:请使用丰富的配色方案,避免单调。建议:总量使用深蓝色 (#3b82f6),峰电量使用橙红色 (#ef4444),平电量使用绿色 (#10b981),谷电量使用黄色 (#f59e0b)。
|
||||
- 请确保图例 (Legend) 清晰可见。
|
||||
- 请确保图表在深色背景下清晰可见。
|
||||
请使用以下格式输出 ECharts 配置(JSON 格式):
|
||||
```echarts
|
||||
{{
|
||||
"title": {{ "text": "{year}年{month}月企业充电量 TOP 10", "left": "center", "textStyle": {{ "color": "#f8fafc" }} }},
|
||||
"tooltip": {{ "trigger": "axis", "axisPointer": {{ "type": "shadow" }} }},
|
||||
"legend": {{ "data": ["总量", "峰电量", "平电量", "谷电量"], "bottom": 0, "textStyle": {{ "color": "#94a3b8" }} }},
|
||||
"grid": {{ "left": "3%", "right": "4%", "bottom": "10%", "containLabel": true }},
|
||||
"xAxis": {{ "type": "category", "data": ["企业A", "企业B", ...], "axisLabel": {{ "color": "#94a3b8", "rotate": 30 }} }},
|
||||
"yAxis": {{ "type": "value", "axisLabel": {{ "color": "#94a3b8" }}, "splitLine": {{ "lineStyle": {{ "color": "rgba(148, 163, 184, 0.1)" }} }} }},
|
||||
"series": [
|
||||
{{ "name": "总量", "type": "bar", "data": [100, 200, ...], "itemStyle": {{ "color": "#3b82f6", "borderRadius": [4, 4, 0, 0] }} }},
|
||||
{{ "name": "峰电量", "type": "bar", "data": [40, 80, ...], "itemStyle": {{ "color": "#ef4444", "borderRadius": [4, 4, 0, 0] }} }},
|
||||
{{ "name": "平电量", "type": "bar", "data": [30, 70, ...], "itemStyle": {{ "color": "#10b981", "borderRadius": [4, 4, 0, 0] }} }},
|
||||
{{ "name": "谷电量", "type": "bar", "data": [30, 50, ...], "itemStyle": {{ "color": "#f59e0b", "borderRadius": [4, 4, 0, 0] }} }}
|
||||
]
|
||||
}}
|
||||
```
|
||||
5. 最后给出一个简单的分析总结。
|
||||
|
||||
直接输出分析内容,不要解释。
|
||||
"""
|
||||
else:
|
||||
# 默认按月查询
|
||||
if not year or not month:
|
||||
from dateutil.relativedelta import relativedelta
|
||||
prev = now - relativedelta(months=1)
|
||||
year = year or prev.year
|
||||
month = month or prev.month
|
||||
|
||||
last_day = calendar.monthrange(year, month)[1]
|
||||
start_time = f"{year:04d}-{month:02d}-01 00:00:00"
|
||||
end_time = f"{year:04d}-{month:02d}-{last_day:02d} 23:59:59"
|
||||
|
||||
data = await model.get_degree(station_names=station_names, start_time=start_time, end_time=end_time)
|
||||
|
||||
summary_prompt = f"""
|
||||
你是一个专业的充电数据分析师。
|
||||
用户查询了 {year}年{month}月 的场站充电数据。
|
||||
数据库返回的数据如下(JSON格式):
|
||||
{json.dumps(data, ensure_ascii=False)}
|
||||
|
||||
请根据这些数据:
|
||||
1. 简要总结该月的充电情况(总电量、峰平谷占比等)。
|
||||
2. 如果是TOP 10查询,指出电量最高的几个场站。
|
||||
3. 使用Markdown表格展示数据。
|
||||
4. **重要**:提供一个 ECharts 6.0 图表。
|
||||
- 无论是单个场站还是多个场站,请使用 **柱状图 (Bar Chart)**。
|
||||
- **核心要求**:每个场站应展示 **4个柱子**,分别对应:**总量 (Total)**、**峰电量 (Peak)**、**平电量 (Flat)**、**谷电量 (Valley)**。
|
||||
- **配色要求**:请使用丰富的配色方案,避免单调。建议:总量使用深蓝色 (#1e40af),峰电量使用橙红色 (#ef4444),平电量使用绿色 (#10b981),谷电量使用黄色 (#f59e0b)。
|
||||
- 请确保图例 (Legend) 清晰可见。
|
||||
请使用以下格式输出 ECharts 配置(JSON 格式):
|
||||
```echarts
|
||||
{{
|
||||
"title": {{ "text": "{year}年{month}月充电量对比", "left": "center", "textStyle": {{ "color": "#f8fafc" }} }},
|
||||
"tooltip": {{ "trigger": "axis", "axisPointer": {{ "type": "shadow" }} }},
|
||||
"legend": {{ "data": ["总量", "峰电量", "平电量", "谷电量"], "bottom": 0, "textStyle": {{ "color": "#94a3b8" }} }},
|
||||
"grid": {{ "left": "3%", "right": "4%", "bottom": "10%", "containLabel": true }},
|
||||
"xAxis": {{ "type": "category", "data": ["场站1", "场站2", ...], "axisLabel": {{ "color": "#94a3b8" }} }},
|
||||
"yAxis": {{ "type": "value", "axisLabel": {{ "color": "#94a3b8" }}, "splitLine": {{ "lineStyle": {{ "color": "rgba(148, 163, 184, 0.1)" }} }} }},
|
||||
"series": [
|
||||
{{ "name": "总量", "type": "bar", "data": [100, 200, ...], "itemStyle": {{ "color": "#3b82f6", "borderRadius": [4, 4, 0, 0] }} }},
|
||||
{{ "name": "峰电量", "type": "bar", "data": [40, 80, ...], "itemStyle": {{ "color": "#ef4444", "borderRadius": [4, 4, 0, 0] }} }},
|
||||
{{ "name": "平电量", "type": "bar", "data": [30, 70, ...], "itemStyle": {{ "color": "#10b981", "borderRadius": [4, 4, 0, 0] }} }},
|
||||
{{ "name": "谷电量", "type": "bar", "data": [30, 50, ...], "itemStyle": {{ "color": "#f59e0b", "borderRadius": [4, 4, 0, 0] }} }}
|
||||
]
|
||||
}}
|
||||
```
|
||||
5. 给出一个简单的分析结论。
|
||||
|
||||
直接输出分析内容,不要解释。
|
||||
"""
|
||||
|
||||
# 3. 生成总结并返回结果
|
||||
yield f"data: {json.dumps({'step': 'summarizing', 'message': '正在生成分析总结...'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
try:
|
||||
async for chunk in get_llm_response(summary_prompt, stream=True):
|
||||
yield f"data: {json.dumps({'content': chunk}, ensure_ascii=False)}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
except Exception as e:
|
||||
logger.error(f"Inner processing error: {e}", exc_info=True)
|
||||
print(f"Inner processing error: {e}")
|
||||
yield f"data: {json.dumps({'step': 'error', 'message': f'处理数据时发生错误: {str(e)}'})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Chat processing error: {e}", exc_info=True)
|
||||
print(f"Chat processing error: {e}") # Debug print
|
||||
yield f"data: {json.dumps({'step': 'error', 'message': f'处理请求时发生错误: {str(e)}'})}\n\n"
|
||||
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
||||
BIN
Controller/__pycache__/DegreeController.cpython-310.pyc
Normal file
BIN
Controller/__pycache__/DegreeController.cpython-310.pyc
Normal file
Binary file not shown.
14
DbKit/Db.py
14
DbKit/Db.py
@@ -92,13 +92,21 @@ class Db:
|
||||
Args:
|
||||
db_url: 数据库连接URL,优先级高于初始化时提供的URL
|
||||
"""
|
||||
if self.engine:
|
||||
return
|
||||
|
||||
# 确定使用的数据库连接URL
|
||||
final_url = db_url or self.db_url
|
||||
if not final_url:
|
||||
# 如果没有提供URL,使用配置文件中的参数构建连接URL
|
||||
from Config import POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DATABASE
|
||||
# 转换为异步连接URL
|
||||
final_url = f"postgresql+asyncpg://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DATABASE}"
|
||||
try:
|
||||
from Config.Config import DB_URL
|
||||
final_url = DB_URL
|
||||
except ImportError:
|
||||
# 兼容旧配置或Postgres配置
|
||||
from Config.Config import POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DATABASE
|
||||
# 转换为异步连接URL
|
||||
final_url = f"postgresql+asyncpg://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DATABASE}"
|
||||
else:
|
||||
# 如果是同步URL,转换为异步URL
|
||||
if final_url.startswith("postgresql://"):
|
||||
|
||||
149
DbKit/Sql/Degree.sql
Normal file
149
DbKit/Sql/Degree.sql
Normal file
@@ -0,0 +1,149 @@
|
||||
#namespace("Degree")
|
||||
-- 指定时间范围内,指定场站的充电度数,有峰平谷和总量
|
||||
#sql("getDegree")
|
||||
WITH station_list AS (
|
||||
SELECT id AS station_id, station_name
|
||||
FROM t_station
|
||||
WHERE 1=1
|
||||
#if(station_names)
|
||||
AND station_name IN (#para(station_names))
|
||||
#end
|
||||
),
|
||||
charge_data AS (
|
||||
-- 12月指定场站的充电信息,计算每条记录的充电量和时段
|
||||
SELECT
|
||||
teco.station_id,
|
||||
sl.station_name,
|
||||
teco.charge_end_degree - teco.charge_begin_degree AS charge_amount,
|
||||
HOUR(teco.charge_begin_time) AS hour_of_day
|
||||
FROM t_equipment_charge_order teco
|
||||
INNER JOIN station_list sl ON teco.station_id = sl.station_id
|
||||
WHERE 1=1
|
||||
#if(start_time)
|
||||
AND teco.charge_begin_time >= #para(start_time)
|
||||
#end
|
||||
#if(end_time)
|
||||
AND teco.charge_begin_time <= #para(end_time)
|
||||
#end
|
||||
)
|
||||
-- 统计各场站峰、平、谷时段的充电量(保留3位小数)
|
||||
SELECT
|
||||
station_id,
|
||||
station_name,
|
||||
-- 峰时段:8:00-11:00,18:00-21:00
|
||||
ROUND(SUM(CASE
|
||||
WHEN (hour_of_day >= 8 AND hour_of_day < 11)
|
||||
OR (hour_of_day >= 18 AND hour_of_day < 21)
|
||||
THEN charge_amount ELSE 0
|
||||
END), 3) AS peak_charging_amount,
|
||||
|
||||
-- 平时段:11:00-18:00,21:00-22:00
|
||||
ROUND(SUM(CASE
|
||||
WHEN (hour_of_day >= 11 AND hour_of_day < 18)
|
||||
OR (hour_of_day >= 21 AND hour_of_day < 22)
|
||||
THEN charge_amount ELSE 0
|
||||
END), 3) AS flat_charging_amount,
|
||||
|
||||
-- 谷时段:22:00-次日8:00(跨天处理)
|
||||
ROUND(SUM(CASE
|
||||
WHEN hour_of_day >= 22 OR hour_of_day < 8
|
||||
THEN charge_amount ELSE 0
|
||||
END), 3) AS valley_charging_amount,
|
||||
|
||||
-- 充电量总量
|
||||
ROUND(SUM(charge_amount), 3) AS total_charging_amount
|
||||
FROM charge_data
|
||||
GROUP BY station_id, station_name
|
||||
ORDER BY total_charging_amount DESC
|
||||
#if(!station_names)
|
||||
LIMIT 10
|
||||
#end
|
||||
;
|
||||
#end
|
||||
|
||||
#sql("getStationList")
|
||||
select id,station_name from t_station
|
||||
#end
|
||||
|
||||
-- 查询近3个月的充电趋势及变化
|
||||
#sql("getTrendChange")
|
||||
WITH monthly_data AS (
|
||||
SELECT
|
||||
ts.station_name,
|
||||
DATE_FORMAT(teco.charge_begin_time, '%Y-%m') as month_str,
|
||||
SUM(teco.charge_end_degree - teco.charge_begin_degree) as monthly_total
|
||||
FROM t_equipment_charge_order teco
|
||||
JOIN t_station ts ON teco.station_id = ts.id
|
||||
WHERE teco.charge_begin_time >= #para(start_time)
|
||||
AND teco.charge_begin_time <= #para(end_time)
|
||||
GROUP BY ts.station_name, month_str
|
||||
),
|
||||
pivoted_data AS (
|
||||
SELECT
|
||||
station_name,
|
||||
MAX(CASE WHEN month_str = #para(month1) THEN monthly_total ELSE 0 END) as m1_total,
|
||||
MAX(CASE WHEN month_str = #para(month2) THEN monthly_total ELSE 0 END) as m2_total,
|
||||
MAX(CASE WHEN month_str = #para(month3) THEN monthly_total ELSE 0 END) as m3_total
|
||||
FROM monthly_data
|
||||
GROUP BY station_name
|
||||
)
|
||||
SELECT
|
||||
station_name,
|
||||
ROUND(m1_total, 2) as month1_total,
|
||||
ROUND(m2_total, 2) as month2_total,
|
||||
ROUND(m3_total, 2) as month3_total,
|
||||
ROUND(m3_total - m1_total, 2) as net_change,
|
||||
ROUND(ABS(m3_total - m1_total), 2) as abs_change
|
||||
FROM pivoted_data
|
||||
ORDER BY abs_change DESC
|
||||
LIMIT 10
|
||||
#end
|
||||
|
||||
-- 获取指定时间内,按企业充电量由大到小TOP 10
|
||||
#sql("getCompanyTop10")
|
||||
WITH charge_data AS (
|
||||
SELECT
|
||||
tc.company_name,
|
||||
teco.charge_degree as charge_amount,
|
||||
HOUR(teco.charge_begin_time) AS hour_of_day
|
||||
FROM t_equipment_charge_order teco
|
||||
INNER JOIN t_user as tu ON teco.user_id = tu.id
|
||||
INNER JOIN t_company tc ON tc.id = tu.user_owner_id
|
||||
WHERE 1=1
|
||||
#if(start_time)
|
||||
AND teco.charge_begin_time >= #para(start_time)
|
||||
#end
|
||||
#if(end_time)
|
||||
AND teco.charge_begin_time <= #para(end_time)
|
||||
#end
|
||||
)
|
||||
SELECT
|
||||
company_name,
|
||||
-- 峰时段:8:00-11:00,18:00-21:00
|
||||
ROUND(SUM(CASE
|
||||
WHEN (hour_of_day >= 8 AND hour_of_day < 11)
|
||||
OR (hour_of_day >= 18 AND hour_of_day < 21)
|
||||
THEN charge_amount ELSE 0
|
||||
END), 2) AS peak_charging_amount,
|
||||
|
||||
-- 平时段:11:00-18:00,21:00-22:00
|
||||
ROUND(SUM(CASE
|
||||
WHEN (hour_of_day >= 11 AND hour_of_day < 18)
|
||||
OR (hour_of_day >= 21 AND hour_of_day < 22)
|
||||
THEN charge_amount ELSE 0
|
||||
END), 2) AS flat_charging_amount,
|
||||
|
||||
-- 谷时段:22:00-次日8:00(跨天处理)
|
||||
ROUND(SUM(CASE
|
||||
WHEN hour_of_day >= 22 OR hour_of_day < 8
|
||||
THEN charge_amount ELSE 0
|
||||
END), 2) AS valley_charging_amount,
|
||||
|
||||
-- 总量
|
||||
ROUND(SUM(charge_amount), 2) as total_charging_amount
|
||||
FROM charge_data
|
||||
GROUP BY company_name
|
||||
ORDER BY total_charging_amount DESC
|
||||
LIMIT 10
|
||||
#end
|
||||
#end
|
||||
Binary file not shown.
95
Model/DegreeModel.py
Normal file
95
Model/DegreeModel.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from DbKit.Db import Db
|
||||
|
||||
class DegreeModel:
|
||||
def __init__(self, db: Db = None):
|
||||
self.db = db or Db()
|
||||
|
||||
async def get_degree(self, station_names=None, start_time=None, end_time=None):
|
||||
await self.db.init_db()
|
||||
params = {}
|
||||
if station_names:
|
||||
params["station_names"] = station_names
|
||||
if start_time:
|
||||
params["start_time"] = start_time
|
||||
if end_time:
|
||||
params["end_time"] = end_time
|
||||
|
||||
# 打印 SQL 模板内容
|
||||
sql_identifier = "Degree.getDegree"
|
||||
sql_template = await self.db.sql_loader.get_sql(sql_identifier)
|
||||
print(f"\n--- SQL Template ({sql_identifier}) ---")
|
||||
print(sql_template)
|
||||
|
||||
# 打印填充参数后的 SQL
|
||||
processed_sql = await self.db.get_sql(sql_identifier, params)
|
||||
print(f"\n--- Processed SQL with Params ---")
|
||||
print(processed_sql)
|
||||
print("-" * 40)
|
||||
|
||||
return await self.db.find(sql_identifier, params)
|
||||
|
||||
async def getStationList(self):
|
||||
await self.db.init_db()
|
||||
sql_identifier = "Degree.getStationList"
|
||||
return await self.db.find(sql_identifier)
|
||||
|
||||
async def get_trend_change(self, end_time=None):
|
||||
"""查询近3个月的充电量变化"""
|
||||
await self.db.init_db()
|
||||
from datetime import datetime
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
if end_time:
|
||||
end_dt = datetime.strptime(end_time, '%Y-%m-%d %H:%M:%S')
|
||||
else:
|
||||
end_dt = datetime.now()
|
||||
|
||||
# 计算过去三个月的月份字符串 (M, M-1, M-2)
|
||||
m3_dt = end_dt
|
||||
m2_dt = end_dt - relativedelta(months=1)
|
||||
m1_dt = end_dt - relativedelta(months=2)
|
||||
|
||||
month3 = m3_dt.strftime('%Y-%m')
|
||||
month2 = m2_dt.strftime('%Y-%m')
|
||||
month1 = m1_dt.strftime('%Y-%m')
|
||||
|
||||
# 统计范围从 m1 月的第一天开始
|
||||
start_time = m1_dt.replace(day=1, hour=0, minute=0, second=0).strftime('%Y-%m-%d %H:%M:%S')
|
||||
end_time_str = end_dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
params = {
|
||||
"start_time": start_time,
|
||||
"end_time": end_time_str,
|
||||
"month1": month1,
|
||||
"month2": month2,
|
||||
"month3": month3
|
||||
}
|
||||
|
||||
sql_identifier = "Degree.getTrendChange"
|
||||
return await self.db.find(sql_identifier, params)
|
||||
|
||||
async def get_company_top10(self, start_time=None, end_time=None):
|
||||
"""获取指定时间内,按企业充电量由大到小TOP 10"""
|
||||
await self.db.init_db()
|
||||
params = {}
|
||||
if start_time:
|
||||
params["start_time"] = start_time
|
||||
if end_time:
|
||||
params["end_time"] = end_time
|
||||
|
||||
sql_identifier = "Degree.getCompanyTop10"
|
||||
|
||||
# 打印调试信息
|
||||
sql_template = await self.db.sql_loader.get_sql(sql_identifier)
|
||||
print(f"\n--- SQL Template ({sql_identifier}) ---")
|
||||
print(sql_template)
|
||||
|
||||
processed_sql = await self.db.get_sql(sql_identifier, params)
|
||||
print(f"\n--- Processed SQL with Params ---")
|
||||
print(processed_sql)
|
||||
print("-" * 40)
|
||||
|
||||
return await self.db.find(sql_identifier, params)
|
||||
|
||||
async def close(self):
|
||||
await self.db.shutdown()
|
||||
BIN
Model/__pycache__/DegreeModel.cpython-310.pyc
Normal file
BIN
Model/__pycache__/DegreeModel.cpython-310.pyc
Normal file
Binary file not shown.
@@ -12,7 +12,7 @@ if project_root not in sys.path:
|
||||
|
||||
|
||||
def setup_api_logger():
|
||||
logger = logging.getLogger("YLT_Analytics_Api")
|
||||
logger = logging.getLogger("Start")
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
if logger.handlers:
|
||||
@@ -33,13 +33,19 @@ def setup_api_logger():
|
||||
logger = setup_api_logger()
|
||||
logger.info("YLT_Analytics_Api module imported")
|
||||
|
||||
from Controller.YltAnalyticsController import router, init_db, close_db
|
||||
from Controller.YltAnalyticsController import router as ylt_router, init_db, close_db
|
||||
from Controller.DegreeController import router as degree_router
|
||||
from Util.Win32Patch import patch
|
||||
from Util.RedisKit import RedisKit
|
||||
|
||||
patch()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("YLT Analytics API startup")
|
||||
await init_db()
|
||||
# Initialize Redis connection
|
||||
await RedisKit().get_connection()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
@@ -53,7 +59,8 @@ static_dir = os.path.join(project_root, "static")
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
|
||||
app.include_router(router)
|
||||
app.include_router(ylt_router)
|
||||
app.include_router(degree_router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
782
Util/RedisKit.py
782
Util/RedisKit.py
@@ -1,29 +1,11 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
import json
|
||||
|
||||
import redis
|
||||
try:
|
||||
from Config.Config import (
|
||||
REDIS_HOST, REDIS_PORT, REDIS_DB, REDIS_PASSWORD,
|
||||
REDIS_DECODE_RESPONSES, REDIS_MAX_CONNECTIONS
|
||||
)
|
||||
except ModuleNotFoundError:
|
||||
import importlib.util
|
||||
import os
|
||||
_root_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_config_path = os.path.join(_root_dir, "Config", "Config.py")
|
||||
_spec = importlib.util.spec_from_file_location("project_config_fallback", _config_path)
|
||||
_cfg = importlib.util.module_from_spec(_spec)
|
||||
assert _spec.loader is not None
|
||||
_spec.loader.exec_module(_cfg)
|
||||
REDIS_HOST = _cfg.REDIS_HOST
|
||||
REDIS_PORT = _cfg.REDIS_PORT
|
||||
REDIS_DB = _cfg.REDIS_DB
|
||||
REDIS_PASSWORD = getattr(_cfg, "REDIS_PASSWORD", None)
|
||||
REDIS_DECODE_RESPONSES = _cfg.REDIS_DECODE_RESPONSES
|
||||
REDIS_MAX_CONNECTIONS = _cfg.REDIS_MAX_CONNECTIONS
|
||||
from Config.Config import REDIS_HOST, REDIS_PORT, REDIS_DB, REDIS_PASSWORD, REDIS_DECODE_RESPONSES
|
||||
# 创建logger实例
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,14 +23,13 @@ class RedisKit:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
async def _ensure_pool(cls):
|
||||
async def _ensure_pool(self):
|
||||
"""
|
||||
确保Redis连接池已创建
|
||||
"""
|
||||
if cls._redis_pool is None:
|
||||
async with cls._lock:
|
||||
if cls._redis_pool is None:
|
||||
if RedisKit._redis_pool is None:
|
||||
async with RedisKit._lock:
|
||||
if RedisKit._redis_pool is None:
|
||||
try:
|
||||
# 创建同步Redis连接池
|
||||
sync_pool = redis.ConnectionPool(
|
||||
@@ -56,32 +37,30 @@ class RedisKit:
|
||||
port=REDIS_PORT,
|
||||
db=REDIS_DB,
|
||||
password=REDIS_PASSWORD,
|
||||
decode_responses=REDIS_DECODE_RESPONSES,
|
||||
max_connections=REDIS_MAX_CONNECTIONS
|
||||
decode_responses=REDIS_DECODE_RESPONSES
|
||||
)
|
||||
# 创建Redis连接实例
|
||||
cls._redis_pool = redis.Redis(
|
||||
RedisKit._redis_pool = redis.Redis(
|
||||
connection_pool=sync_pool,
|
||||
encoding='utf-8',
|
||||
decode_responses=REDIS_DECODE_RESPONSES
|
||||
)
|
||||
# 测试连接
|
||||
await asyncio.to_thread(cls._redis_pool.ping)
|
||||
await asyncio.to_thread(RedisKit._redis_pool.ping)
|
||||
logger.info("Redis连接池创建成功")
|
||||
except Exception as e:
|
||||
logger.error(f"Redis连接池创建失败: {e}")
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
async def get_connection(cls):
|
||||
async def get_connection(self):
|
||||
"""
|
||||
获取Redis连接实例
|
||||
|
||||
Returns:
|
||||
redis.Redis: Redis连接实例
|
||||
"""
|
||||
await cls._ensure_pool()
|
||||
return cls._redis_pool
|
||||
await self._ensure_pool()
|
||||
return RedisKit._redis_pool
|
||||
|
||||
async def get_data(self, key):
|
||||
"""
|
||||
@@ -119,741 +98,16 @@ class RedisKit:
|
||||
return False
|
||||
|
||||
async def set_data(self, key, value, expire=None):
|
||||
"""
|
||||
异步向Redis中写入数据
|
||||
|
||||
Args:
|
||||
key (str): Redis键名
|
||||
value (str): 要存储的值
|
||||
expire (int, optional): 过期时间(秒),默认不过期
|
||||
|
||||
Returns:
|
||||
bool: 写入成功返回True,失败返回False
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
if expire:
|
||||
result = await asyncio.to_thread(RedisKit._redis_pool.setex, key, expire, value)
|
||||
await asyncio.to_thread(RedisKit._redis_pool.set, key, value, ex=expire)
|
||||
else:
|
||||
result = await asyncio.to_thread(RedisKit._redis_pool.set, key, value)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"向Redis写入数据失败(key={key}): {e}")
|
||||
return False
|
||||
|
||||
async def delete_data(self, key):
|
||||
"""
|
||||
异步从Redis中删除数据
|
||||
|
||||
Args:
|
||||
key (str): Redis键名
|
||||
|
||||
Returns:
|
||||
bool: 删除成功返回True,失败返回False
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
result = await asyncio.to_thread(RedisKit._redis_pool.delete, key)
|
||||
return result > 0 # delete返回删除的键数量,大于0表示删除成功
|
||||
except Exception as e:
|
||||
logger.error(f"从Redis删除数据失败(key={key}): {e}")
|
||||
return False
|
||||
|
||||
async def keys(self, pattern='*'):
|
||||
"""
|
||||
异步获取匹配模式的所有键
|
||||
|
||||
Args:
|
||||
pattern (str): 匹配模式
|
||||
|
||||
Returns:
|
||||
list: 匹配的键列表
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
return await asyncio.to_thread(RedisKit._redis_pool.keys, pattern)
|
||||
except Exception as e:
|
||||
logger.error(f"获取Redis键列表失败(pattern={pattern}): {e}")
|
||||
return []
|
||||
|
||||
async def delete(self, *names):
|
||||
"""
|
||||
异步删除一个或多个键
|
||||
|
||||
Args:
|
||||
*names: 要删除的键名
|
||||
|
||||
Returns:
|
||||
int: 删除的键数量
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
if not names:
|
||||
return 0
|
||||
return await asyncio.to_thread(RedisKit._redis_pool.delete, *names)
|
||||
except Exception as e:
|
||||
logger.error(f"删除Redis键失败: {e}")
|
||||
return 0
|
||||
|
||||
async def publish(self, channel, message):
|
||||
"""
|
||||
异步发布消息到Redis频道
|
||||
|
||||
Args:
|
||||
channel (str): Redis频道名称
|
||||
message (str): 要发布的消息
|
||||
|
||||
Returns:
|
||||
int: 接收消息的订阅者数量
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
result = await asyncio.to_thread(RedisKit._redis_pool.publish, channel, message)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Redis发布消息失败(channel={channel}): {e}")
|
||||
return 0
|
||||
|
||||
async def subscribe(self, channel):
|
||||
"""
|
||||
异步订阅Redis频道
|
||||
|
||||
Args:
|
||||
channel (str): Redis频道名称
|
||||
|
||||
Returns:
|
||||
redis.client.PubSub: PubSub对象,用于接收消息
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
pubsub = RedisKit._redis_pool.pubsub()
|
||||
await asyncio.to_thread(pubsub.subscribe, channel)
|
||||
return pubsub
|
||||
except Exception as e:
|
||||
logger.error(f"Redis订阅频道失败(channel={channel}): {e}")
|
||||
return None
|
||||
|
||||
async def save_user_token(self, person_id, platform, token, expire=86400):
|
||||
"""
|
||||
异步保存用户的token到Redis
|
||||
|
||||
Args:
|
||||
person_id (str): 用户ID
|
||||
platform (str): 平台
|
||||
token (str): 生成的token
|
||||
expire (int): 过期时间(秒),默认24小时
|
||||
|
||||
Returns:
|
||||
bool: 保存成功返回True,失败返回False
|
||||
"""
|
||||
try:
|
||||
# 使用固定格式的key:user_token:{person_id}:{platform}
|
||||
key = f"user_token:{person_id}:{platform}"
|
||||
return await self.set_data(key, token, expire)
|
||||
except Exception as e:
|
||||
logger.error(f"保存用户token失败(person_id={person_id}, platform={platform}): {e}")
|
||||
return False
|
||||
|
||||
async def get_user_token(self, person_id, platform):
|
||||
"""
|
||||
异步获取用户在Redis中存储的token
|
||||
|
||||
Args:
|
||||
person_id (str): 用户ID
|
||||
platform (str): 平台
|
||||
|
||||
Returns:
|
||||
str: 用户的token,如果不存在或过期则返回None
|
||||
"""
|
||||
try:
|
||||
key = f"user_token:{person_id}:{platform}"
|
||||
return await self.get_data(key)
|
||||
except Exception as e:
|
||||
logger.error(f"获取用户token失败(person_id={person_id}, platform={platform}): {e}")
|
||||
return None
|
||||
|
||||
async def validate_user_token(self, person_id, platform, token):
|
||||
"""
|
||||
异步验证用户的token是否与Redis中存储的一致
|
||||
|
||||
Args:
|
||||
person_id (str): 用户ID
|
||||
platform (str): 平台、终端
|
||||
token (str): 待验证的token
|
||||
|
||||
Returns:
|
||||
bool: token有效返回True,无效返回False
|
||||
"""
|
||||
try:
|
||||
stored_token = await self.get_user_token(person_id, platform)
|
||||
# 如果没有存储的token,或者存储的token与当前token不一致,则token无效
|
||||
return stored_token is not None and stored_token == token
|
||||
except Exception as e:
|
||||
logger.error(f"验证用户token失败(person_id={person_id}, platform={platform}): {e}")
|
||||
return False
|
||||
|
||||
async def delete_user_token(self, person_id, platform=None):
|
||||
"""
|
||||
异步删除用户在Redis中存储的token
|
||||
|
||||
Args:
|
||||
person_id (str): 用户ID
|
||||
platform (str, optional): 平台,如果为None则删除所有平台的token
|
||||
|
||||
Returns:
|
||||
bool: 删除成功返回True,失败返回False
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
if platform:
|
||||
# 删除特定平台的token
|
||||
key = f"user_token:{person_id}:{platform}"
|
||||
return await self.delete_data(key)
|
||||
else:
|
||||
# 删除所有平台的token(使用模式匹配)
|
||||
pattern = f"user_token:{person_id}:*"
|
||||
cursor = b'0'
|
||||
deleted_count = 0
|
||||
|
||||
# 使用scan命令查找所有匹配的键
|
||||
while cursor:
|
||||
cursor, keys = await asyncio.to_thread(
|
||||
RedisKit._redis_pool.scan,
|
||||
cursor=cursor,
|
||||
match=pattern,
|
||||
count=100
|
||||
)
|
||||
if keys:
|
||||
deleted = await asyncio.to_thread(
|
||||
RedisKit._redis_pool.delete, *keys
|
||||
)
|
||||
deleted_count += deleted
|
||||
|
||||
return deleted_count > 0
|
||||
except Exception as e:
|
||||
logger.error(f"删除用户token失败(person_id={person_id}): {e}")
|
||||
return False
|
||||
|
||||
async def list_push(self, key, value, expire=None):
|
||||
"""
|
||||
异步向Redis List中推入数据(LPUSH)
|
||||
|
||||
Args:
|
||||
key (str): Redis键名
|
||||
value (str): 要推入的值
|
||||
expire (int, optional): 过期时间(秒),如果设置则同时为key设置过期时间
|
||||
|
||||
Returns:
|
||||
int: 推入后list的长度,失败返回-1
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
result = await asyncio.to_thread(RedisKit._redis_pool.lpush, key, value)
|
||||
|
||||
# 如果设置了过期时间,为key设置TTL
|
||||
if expire and result > 0:
|
||||
await asyncio.to_thread(RedisKit._redis_pool.expire, key, expire)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"向Redis List推入数据失败(key={key}): {e}")
|
||||
return -1
|
||||
|
||||
async def list_pop(self, key):
|
||||
"""
|
||||
异步从Redis List中弹出数据(RPOP)
|
||||
|
||||
Args:
|
||||
key (str): Redis键名
|
||||
|
||||
Returns:
|
||||
str: 弹出的值,如果list为空或失败返回None
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
result = await asyncio.to_thread(RedisKit._redis_pool.rpop, key)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"从Redis List弹出数据失败(key={key}): {e}")
|
||||
return None
|
||||
|
||||
async def list_length(self, key):
|
||||
"""
|
||||
异步获取Redis List的长度
|
||||
|
||||
Args:
|
||||
key (str): Redis键名
|
||||
|
||||
Returns:
|
||||
int: list的长度,如果失败返回-1
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
result = await asyncio.to_thread(RedisKit._redis_pool.llen, key)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"获取Redis List长度失败(key={key}): {e}")
|
||||
return -1
|
||||
|
||||
async def list_get_all(self, key):
|
||||
"""
|
||||
异步获取Redis List中的所有数据(LRANGE 0 -1)
|
||||
|
||||
Args:
|
||||
key (str): Redis键名
|
||||
|
||||
Returns:
|
||||
list: list中的所有数据,如果失败返回空列表
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
result = await asyncio.to_thread(RedisKit._redis_pool.lrange, key, 0, -1)
|
||||
return result if result else []
|
||||
except Exception as e:
|
||||
logger.error(f"获取Redis List所有数据失败(key={key}): {e}")
|
||||
return []
|
||||
|
||||
async def list_clear(self, key):
|
||||
"""
|
||||
异步清空Redis List(删除key)
|
||||
|
||||
Args:
|
||||
key (str): Redis键名
|
||||
|
||||
Returns:
|
||||
bool: 清空成功返回True,失败返回False
|
||||
"""
|
||||
try:
|
||||
return await self.delete_data(key)
|
||||
except Exception as e:
|
||||
logger.error(f"清空Redis List失败(key={key}): {e}")
|
||||
return False
|
||||
|
||||
async def set_is_member(self, key, value):
|
||||
"""
|
||||
异步检查值是否在Redis集合中
|
||||
|
||||
Args:
|
||||
key (str): Redis键名
|
||||
value: 要检查的值
|
||||
|
||||
Returns:
|
||||
bool: 值在集合中返回True,不在或失败返回False
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
result = await asyncio.to_thread(RedisKit._redis_pool.sismember, key, value)
|
||||
return bool(result)
|
||||
except Exception as e:
|
||||
logger.error(f"检查值是否在Redis集合中失败(key={key}): {e}")
|
||||
return False
|
||||
|
||||
async def set_add(self, key, value):
|
||||
"""
|
||||
异步向Redis集合中添加值
|
||||
|
||||
Args:
|
||||
key (str): Redis键名
|
||||
value: 要添加的值
|
||||
|
||||
Returns:
|
||||
bool: 添加成功返回True,失败返回False
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
result = await asyncio.to_thread(RedisKit._redis_pool.sadd, key, value)
|
||||
return result > 0 # sadd返回添加的元素数量,大于0表示添加成功
|
||||
except Exception as e:
|
||||
logger.error(f"向Redis集合添加值失败(key={key}): {e}")
|
||||
return False
|
||||
|
||||
async def set_remove(self, key, value):
|
||||
"""
|
||||
异步从Redis集合中移除值
|
||||
|
||||
Args:
|
||||
key (str): Redis键名
|
||||
value: 要移除的值
|
||||
|
||||
Returns:
|
||||
bool: 移除成功返回True,失败返回False
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
result = await asyncio.to_thread(RedisKit._redis_pool.srem, key, value)
|
||||
return result > 0 # srem返回移除的元素数量,大于0表示移除成功
|
||||
except Exception as e:
|
||||
logger.error(f"从Redis集合移除值失败(key={key}): {e}")
|
||||
return False
|
||||
|
||||
async def set_cardinality(self, key):
|
||||
"""
|
||||
异步获取Redis集合的成员数量
|
||||
|
||||
Args:
|
||||
key (str): Redis键名
|
||||
|
||||
Returns:
|
||||
int: 集合的成员数量,如果失败返回0
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
result = await asyncio.to_thread(RedisKit._redis_pool.scard, key)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"获取Redis集合成员数量失败(key={key}): {e}")
|
||||
return 0
|
||||
|
||||
# 分布式锁相关方法
|
||||
async def acquire_lock(self, lock_name, timeout=300):
|
||||
"""
|
||||
获取分布式锁
|
||||
|
||||
Args:
|
||||
lock_name (str): 锁的名称
|
||||
timeout (int): 锁的超时时间(秒),默认300秒
|
||||
|
||||
Returns:
|
||||
str: 锁ID,如果获取失败返回None
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
# 生成唯一锁ID
|
||||
lock_id = str(uuid.uuid4())
|
||||
key = f"lock:{lock_name}"
|
||||
|
||||
# 使用Redis原子操作设置锁
|
||||
result = await asyncio.to_thread(
|
||||
RedisKit._redis_pool.setnx, key, lock_id
|
||||
)
|
||||
|
||||
if result:
|
||||
# 设置锁的过期时间
|
||||
await asyncio.to_thread(
|
||||
RedisKit._redis_pool.expire, key, timeout
|
||||
)
|
||||
logger.info(f"获取分布式锁成功: {lock_name}, lock_id: {lock_id}")
|
||||
return lock_id
|
||||
else:
|
||||
logger.info(f"获取分布式锁失败: {lock_name} (锁已存在)")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"获取分布式锁失败: {lock_name}, 错误: {e}")
|
||||
return None
|
||||
|
||||
async def release_lock(self, lock_name, lock_id):
|
||||
"""
|
||||
释放分布式锁
|
||||
|
||||
Args:
|
||||
lock_name (str): 锁的名称
|
||||
lock_id (str): 锁ID
|
||||
|
||||
Returns:
|
||||
bool: 是否成功释放锁
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
key = f"lock:{lock_name}"
|
||||
|
||||
# 使用Lua脚本原子性检查并释放锁
|
||||
lua_script = """
|
||||
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('del', KEYS[1])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
"""
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
RedisKit._redis_pool.eval, lua_script, 1, key, lock_id
|
||||
)
|
||||
|
||||
if result == 1:
|
||||
logger.info(f"释放分布式锁成功: {lock_name}, lock_id: {lock_id}")
|
||||
return True
|
||||
else:
|
||||
logger.info(f"释放分布式锁失败: {lock_name}, lock_id: {lock_id} (锁不存在或已被其他进程持有)")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"释放分布式锁失败: {lock_name}, 错误: {e}")
|
||||
return False
|
||||
|
||||
async def renew_lock(self, lock_name, lock_id, timeout=300):
|
||||
"""
|
||||
续约分布式锁
|
||||
|
||||
Args:
|
||||
lock_name (str): 锁的名称
|
||||
lock_id (str): 锁ID
|
||||
timeout (int): 续约时间(秒),默认300秒
|
||||
|
||||
Returns:
|
||||
bool: 是否成功续约
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
key = f"lock:{lock_name}"
|
||||
|
||||
# 使用Lua脚本原子性检查并续约锁
|
||||
lua_script = """
|
||||
if redis.call('get', KEYS[1]) == ARGV[1] then
|
||||
return redis.call('expire', KEYS[1], ARGV[2])
|
||||
else
|
||||
return 0
|
||||
end
|
||||
"""
|
||||
|
||||
result = await asyncio.to_thread(
|
||||
RedisKit._redis_pool.eval, lua_script, 1, key, lock_id, timeout
|
||||
)
|
||||
|
||||
if result == 1:
|
||||
logger.info(f"续约分布式锁成功: {lock_name}, lock_id: {lock_id}")
|
||||
return True
|
||||
else:
|
||||
logger.info(f"续约分布式锁失败: {lock_name}, lock_id: {lock_id} (锁不存在或已被其他进程持有)")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"续约分布式锁失败: {lock_name}, 错误: {e}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# 任务进度监控相关方法
|
||||
async def set_task_progress(self, task_id, progress, status="processing", message=""):
|
||||
"""
|
||||
设置任务进度
|
||||
|
||||
Args:
|
||||
task_id (str): 任务ID
|
||||
progress (int): 进度百分比(0-100)
|
||||
status (str): 任务状态(waiting, processing, completed, failed)
|
||||
message (str): 任务消息
|
||||
|
||||
Returns:
|
||||
bool: 设置成功返回True,失败返回False
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
key = f"task:progress:{task_id}"
|
||||
|
||||
# 构造进度数据
|
||||
progress_data = {
|
||||
"task_id": task_id,
|
||||
"progress": min(100, max(0, progress)), # 确保进度在0-100之间
|
||||
"status": status,
|
||||
"message": message,
|
||||
"update_time": asyncio.get_event_loop().time()
|
||||
}
|
||||
|
||||
# 序列化进度数据
|
||||
progress_json = json.dumps(progress_data, ensure_ascii=False)
|
||||
|
||||
# 保存到Redis
|
||||
await asyncio.to_thread(
|
||||
RedisKit._redis_pool.set, key, progress_json
|
||||
)
|
||||
|
||||
# 设置过期时间为24小时
|
||||
await asyncio.to_thread(
|
||||
RedisKit._redis_pool.expire, key, 86400
|
||||
)
|
||||
|
||||
logger.info(f"设置任务进度成功: {task_id}, 进度: {progress}%, 状态: {status}")
|
||||
await asyncio.to_thread(RedisKit._redis_pool.set, key, value)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"设置任务进度失败: {task_id}, 错误: {e}")
|
||||
logger.error(f"保存数据到Redis失败(key={key}): {e}")
|
||||
return False
|
||||
|
||||
async def get_task_progress(self, task_id):
|
||||
"""
|
||||
获取任务进度
|
||||
|
||||
Args:
|
||||
task_id (str): 任务ID
|
||||
|
||||
Returns:
|
||||
dict: 任务进度数据,如果不存在返回None
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
key = f"task:progress:{task_id}"
|
||||
|
||||
# 从Redis获取进度数据
|
||||
progress_json = await asyncio.to_thread(
|
||||
RedisKit._redis_pool.get, key
|
||||
)
|
||||
|
||||
if progress_json:
|
||||
return json.loads(progress_json)
|
||||
else:
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"获取任务进度失败: {task_id}, 错误: {e}")
|
||||
return None
|
||||
|
||||
async def complete_task(self, task_id, success=True, message=""):
|
||||
"""
|
||||
完成任务
|
||||
|
||||
Args:
|
||||
task_id (str): 任务ID
|
||||
success (bool): 任务是否成功完成
|
||||
message (str): 完成消息
|
||||
|
||||
Returns:
|
||||
bool: 设置成功返回True,失败返回False
|
||||
"""
|
||||
status = "completed" if success else "failed"
|
||||
progress = 100 if success else 0
|
||||
return await self.set_task_progress(task_id, progress, status, message)
|
||||
|
||||
async def delete_task_progress(self, task_id):
|
||||
"""
|
||||
删除任务进度记录
|
||||
|
||||
Args:
|
||||
task_id (str): 任务ID
|
||||
|
||||
Returns:
|
||||
bool: 删除成功返回True,失败返回False
|
||||
"""
|
||||
try:
|
||||
await self._ensure_pool()
|
||||
key = f"task:progress:{task_id}"
|
||||
|
||||
# 从Redis删除进度记录
|
||||
result = await asyncio.to_thread(
|
||||
RedisKit._redis_pool.delete, key
|
||||
)
|
||||
|
||||
logger.info(f"删除任务进度记录成功: {task_id}")
|
||||
return result > 0
|
||||
except Exception as e:
|
||||
logger.error(f"删除任务进度记录失败: {task_id}, 错误: {e}")
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
async def close(cls):
|
||||
"""
|
||||
关闭Redis连接池
|
||||
"""
|
||||
if cls._redis_pool:
|
||||
# 同步Redis连接池不需要等待关闭
|
||||
cls._redis_pool.connection_pool.disconnect()
|
||||
cls._redis_pool = None
|
||||
logger.info("Redis连接池已关闭")
|
||||
|
||||
|
||||
# 创建全局实例
|
||||
redisKit = RedisKit()
|
||||
|
||||
|
||||
async def main():
|
||||
"""
|
||||
测试异步Redis操作的main函数
|
||||
"""
|
||||
print("开始测试异步Redis工具类...")
|
||||
|
||||
try:
|
||||
# 测试基本数据操作
|
||||
print("\n=== 测试基本数据操作 ===")
|
||||
|
||||
# 测试设置和获取数据
|
||||
test_key = "test_key"
|
||||
test_value = "test_value_123"
|
||||
|
||||
result = await redisKit.set_data(test_key, test_value, expire=60)
|
||||
print(f"设置数据结果: {result}")
|
||||
|
||||
get_value = await redisKit.get_data(test_key)
|
||||
print(f"获取数据结果: {get_value}")
|
||||
|
||||
# 测试用户token操作
|
||||
print("\n=== 测试用户token操作 ===")
|
||||
|
||||
# 保存用户token
|
||||
person_id = "user123"
|
||||
platform = "web"
|
||||
token = "jwt_token_xyz789"
|
||||
|
||||
save_result = await redisKit.save_user_token(person_id, platform, token, expire=120)
|
||||
print(f"保存token结果: {save_result}")
|
||||
|
||||
# 获取用户token
|
||||
stored_token = await redisKit.get_user_token(person_id, platform)
|
||||
print(f"获取token结果: {stored_token}")
|
||||
|
||||
# 验证token
|
||||
validate_result = await redisKit.validate_user_token(person_id, platform, token)
|
||||
print(f"验证token结果: {validate_result}")
|
||||
|
||||
# 验证错误的token
|
||||
wrong_token = "wrong_token"
|
||||
wrong_validate_result = await redisKit.validate_user_token(person_id, platform, wrong_token)
|
||||
print(f"验证错误token结果: {wrong_validate_result}")
|
||||
|
||||
# 测试删除操作
|
||||
print("\n=== 测试删除操作 ===")
|
||||
|
||||
# 删除测试数据
|
||||
delete_result = await redisKit.delete_data(test_key)
|
||||
print(f"删除数据结果: {delete_result}")
|
||||
|
||||
# 删除用户token
|
||||
delete_token_result = await redisKit.delete_user_token(person_id, platform)
|
||||
print(f"删除token结果: {delete_token_result}")
|
||||
|
||||
# 测试批量token删除功能
|
||||
print("\n=== 测试批量token删除功能 ===")
|
||||
|
||||
# 保存多个平台的token
|
||||
platforms = ["android", "ios", "web"]
|
||||
for i, platform in enumerate(platforms):
|
||||
await redisKit.save_user_token(f"user{i + 1}", platform, f"token{i + 1}", expire=300)
|
||||
print(f"保存用户{i+1}的{platform}平台token")
|
||||
|
||||
# 删除特定用户的所有token
|
||||
user_to_delete = "user2"
|
||||
batch_delete_result = await redisKit.delete_user_token(user_to_delete)
|
||||
print(f"批量删除{user_to_delete}的所有token结果: {batch_delete_result}")
|
||||
|
||||
# 验证删除结果
|
||||
for platform in platforms:
|
||||
token_check = await redisKit.get_user_token(user_to_delete, platform)
|
||||
print(f"删除后{user_to_delete}-{platform}平台的token状态: {'已删除' if token_check is None else '存在'}")
|
||||
|
||||
print("\n=== 所有测试完成 ===")
|
||||
|
||||
except Exception as e:
|
||||
print(f"测试过程中发生错误: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
finally:
|
||||
# 清理测试数据并关闭连接池
|
||||
print("\n清理测试数据并关闭连接池...")
|
||||
|
||||
# 清理测试用的数据
|
||||
test_keys_to_cleanup = ["test_key"]
|
||||
for key in test_keys_to_cleanup:
|
||||
await redisKit.delete_data(key)
|
||||
|
||||
# 清理测试用户token
|
||||
cleanup_users = ["user123", "user1", "user2", "user3"]
|
||||
for user in cleanup_users:
|
||||
try:
|
||||
await redisKit.delete_user_token(user)
|
||||
except:
|
||||
pass # 忽略清理过程中的错误
|
||||
|
||||
# 关闭Redis连接池
|
||||
await redisKit.close()
|
||||
print("Redis连接池已关闭")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
# ȫ<><C8AB>ʵ<EFBFBD><CAB5>
|
||||
redisKit = RedisKit()
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from DbKit.Db import Db
|
||||
# 配置日志
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def patch():
|
||||
# 在Windows上使用SelectorEventLoopPolicy可以减少SSL错误
|
||||
if sys.platform == 'win32':
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
# 为Db类添加临时的异步上下文管理器支持
|
||||
def patch_db_context_manager():
|
||||
# 为Db类添加异步上下文管理器支持
|
||||
if not hasattr(Db, '__aenter__'):
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
# 简单的清理,不做实际操作,避免事件循环问题
|
||||
return False
|
||||
|
||||
# 动态添加方法
|
||||
Db.__aenter__ = __aenter__
|
||||
Db.__aexit__ = __aexit__
|
||||
|
||||
# 应用补丁
|
||||
patch_db_context_manager()
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from DbKit.Db import Db
|
||||
# 配置日志
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def patch():
|
||||
# 在Windows上使用SelectorEventLoopPolicy可以减少SSL错误
|
||||
if sys.platform == 'win32':
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
|
||||
# 为Db类添加临时的异步上下文管理器支持
|
||||
def patch_db_context_manager():
|
||||
# 为Db类添加异步上下文管理器支持
|
||||
if not hasattr(Db, '__aenter__'):
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
# 简单的清理,不做实际操作,避免事件循环问题
|
||||
return False
|
||||
|
||||
# 动态添加方法
|
||||
Db.__aenter__ = __aenter__
|
||||
Db.__aexit__ = __aexit__
|
||||
|
||||
# 应用补丁
|
||||
patch_db_context_manager()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
421
static/css/degree.css
Normal file
421
static/css/degree.css
Normal file
@@ -0,0 +1,421 @@
|
||||
body {
|
||||
background: radial-gradient(circle at top, #1e293b 0, #020617 55%, #000 100%);
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.8);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
.logo {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.degree-container {
|
||||
max-width: 1000px;
|
||||
margin: 40px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.query-section {
|
||||
background: rgba(15, 23, 42, 0.96);
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.9);
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
background: linear-gradient(135deg, #fff 0%, #94a3b8 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin-bottom: 32px;
|
||||
color: #94a3b8;
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 搜索框样式优化 */
|
||||
.search-input.el-input {
|
||||
--el-input-border-color: rgba(148, 163, 184, 0.5);
|
||||
--el-input-hover-border-color: rgba(56, 189, 248, 0.9);
|
||||
--el-input-focus-border-color: rgba(56, 189, 248, 0.9);
|
||||
--el-input-bg-color: rgba(15, 23, 42, 0.9);
|
||||
--el-input-text-color: #e5e7eb;
|
||||
}
|
||||
|
||||
.search-input .el-input__wrapper {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
box-shadow: 0 2px 18px rgba(15, 23, 42, 0.6);
|
||||
background-color: rgba(15, 23, 42, 0.9);
|
||||
border: 1px solid rgba(148, 163, 184, 0.5);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-input.el-input-group--append .el-input__wrapper {
|
||||
border-radius: 24px 0 0 24px;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.search-input:not(.el-input-group--append) .el-input__wrapper {
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.search-input .el-input-group__append {
|
||||
background-color: rgba(56, 189, 248, 0.9);
|
||||
border: 1px solid rgba(56, 189, 248, 0.9);
|
||||
border-left: none;
|
||||
border-radius: 0 24px 24px 0;
|
||||
padding: 0;
|
||||
box-shadow: 0 2px 18px rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
|
||||
.search-input .el-input-group__append .el-button,
|
||||
.search-input .el-input-group__append .el-button.el-button--primary {
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0 30px;
|
||||
height: 48px;
|
||||
color: #fff;
|
||||
background-color: transparent;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
transition: all 0.2s;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.search-input .el-input-group__append .el-button:hover,
|
||||
.search-input .el-input-group__append .el-button.el-button--primary:hover {
|
||||
background-color: rgba(14, 165, 233, 1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.search-input .el-input-group__append .el-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.search-input .el-input__wrapper:hover,
|
||||
.search-input .el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.9), 0 20px 40px rgba(15, 23, 42, 0.9);
|
||||
border-color: rgba(56, 189, 248, 0.9);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.search-input .el-input-group__append:hover {
|
||||
border-color: rgba(56, 189, 248, 0.9);
|
||||
}
|
||||
|
||||
.search-input .el-input__inner {
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.search-input .el-input__inner::placeholder {
|
||||
color: rgba(148, 163, 184, 0.8);
|
||||
}
|
||||
|
||||
.example-tags {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.example-tag {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-color: rgba(148, 163, 184, 0.6);
|
||||
color: #cbd5f5;
|
||||
background-color: rgba(15, 23, 42, 0.8);
|
||||
}
|
||||
|
||||
.example-tag:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(56, 189, 248, 0.9);
|
||||
color: #e0f2fe;
|
||||
background-color: rgba(15, 23, 42, 1);
|
||||
}
|
||||
|
||||
.result-section {
|
||||
background: rgba(15, 23, 42, 0.96);
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.9);
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
font-size: 16px;
|
||||
line-height: 1.7;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(51, 65, 85, 0.9);
|
||||
}
|
||||
|
||||
.markdown-body th {
|
||||
background-color: rgba(30, 64, 175, 0.95);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-body tr:nth-child(even) {
|
||||
background-color: rgba(15, 23, 42, 0.9);
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
color: rgba(148, 163, 184, 0.9);
|
||||
}
|
||||
|
||||
/* Cursor blink animation for streaming effect */
|
||||
.cursor-blink {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
background-color: #38bdf8;
|
||||
vertical-align: text-bottom;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* 二维码容器 */
|
||||
.qr-sidebar {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.qr-sidebar:hover {
|
||||
transform: translateY(-5px);
|
||||
border-color: rgba(56, 189, 248, 0.5);
|
||||
background: rgba(15, 23, 42, 1);
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
background: #fff;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.qr-code canvas, .qr-code img {
|
||||
max-width: 100% !important;
|
||||
max-height: 100% !important;
|
||||
height: auto !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.qr-text {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media screen and (max-width: 1200px) {
|
||||
.qr-sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ECharts 图表容器 */
|
||||
.echarts-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
margin: 24px 0;
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
border: 1px solid rgba(56, 189, 248, 0.1);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.echarts-container:hover {
|
||||
border-color: rgba(56, 189, 248, 0.3);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.echarts-container {
|
||||
height: 300px;
|
||||
margin: 16px 0;
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
.degree-container {
|
||||
margin-top: 20px !important;
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.query-section {
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.search-input .el-input__inner {
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-input .el-input-group__append .el-button {
|
||||
padding: 0 12px;
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.example-tags {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.example-tag {
|
||||
font-size: 12px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.result-section {
|
||||
padding: 15px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.markdown-body table {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 16px 0;
|
||||
border-collapse: collapse;
|
||||
/* 增加一个细小的滚动条样式,提示可以滚动 */
|
||||
}
|
||||
|
||||
.markdown-body table::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.markdown-body table::-webkit-scrollbar-thumb {
|
||||
background: rgba(56, 189, 248, 0.5);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.markdown-body th,
|
||||
.markdown-body td {
|
||||
white-space: nowrap;
|
||||
padding: 10px 15px;
|
||||
font-size: 13px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
/* 手机端首列固定 */
|
||||
.markdown-body table tr th:first-child,
|
||||
.markdown-body table tr td:first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
box-shadow: 2px 0 5px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.markdown-body table tr th:first-child {
|
||||
background-color: #1e40af !important; /* 统一使用深蓝色 */
|
||||
}
|
||||
|
||||
.markdown-body table tr:nth-child(odd) td:first-child {
|
||||
background-color: #0f172a !important; /* 与容器背景一致 */
|
||||
}
|
||||
|
||||
.markdown-body table tr:nth-child(even) td:first-child {
|
||||
background-color: #1e293b !important; /* 稍亮的条纹背景 */
|
||||
}
|
||||
}
|
||||
1
static/css/element-plus.index.css
Normal file
1
static/css/element-plus.index.css
Normal file
File diff suppressed because one or more lines are too long
724
static/css/style.css
Normal file
724
static/css/style.css
Normal file
@@ -0,0 +1,724 @@
|
||||
:root {
|
||||
--primary-blue: #0066FF;
|
||||
--secondary-green: #00C389;
|
||||
--text-dark: #333333;
|
||||
--text-light: #666666;
|
||||
--bg-light: #F5F7FA;
|
||||
--white: #FFFFFF;
|
||||
--shadow: 0 4px 20px rgba(0, 102, 255, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text-dark);
|
||||
background-color: var(--white);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 70px;
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.logo i {
|
||||
margin-right: 10px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
font-size: 16px;
|
||||
color: var(--text-dark);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.nav-item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -5px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 2px;
|
||||
background-color: var(--primary-blue);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 20px;
|
||||
border-radius: 25px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-blue);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #0052CC;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 102, 255, 0.3);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--primary-blue);
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background-color: var(--primary-blue);
|
||||
color: var(--white);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Hero区域 */
|
||||
.hero {
|
||||
margin-top: 70px;
|
||||
height: calc(100vh - 70px);
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e9f1fb 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 80px;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 500px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
color: var(--primary-blue);
|
||||
line-height: 1.2;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 24px;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.hero-cta {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
position: relative;
|
||||
width: 50%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-image img {
|
||||
max-width: 100%;
|
||||
max-height: 80%;
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* 轮播图样式 */
|
||||
.carousel-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.carousel {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.carousel-item {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 80px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.carousel-item.active {
|
||||
opacity: 1;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 轮播控制按钮 */
|
||||
.carousel-control {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 20;
|
||||
color: var(--primary-blue);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.carousel-control:hover {
|
||||
background-color: var(--white);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
.carousel-control.prev {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.carousel-control.next {
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
/* 轮播指示点 */
|
||||
.carousel-indicators {
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.carousel-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.carousel-indicator.active {
|
||||
background-color: var(--white);
|
||||
width: 30px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-20px); }
|
||||
100% { transform: translateY(0px); }
|
||||
}
|
||||
|
||||
/* 波浪背景 - 使用CSS替代图片 */
|
||||
.wave {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
background: linear-gradient(135deg, rgba(0,102,255,0.1) 0%, rgba(0,195,137,0.1) 100%);
|
||||
animation: wave-animation 15s linear infinite;
|
||||
border-radius: 50% 50% 0 0;
|
||||
}
|
||||
|
||||
@keyframes wave-animation {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-1000px); }
|
||||
}
|
||||
|
||||
/* 核心功能区 */
|
||||
.features {
|
||||
padding: 80px 40px;
|
||||
background-color: var(--white);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: var(--primary-blue);
|
||||
margin-bottom: 60px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.section-title::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
background-color: var(--secondary-green);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 40px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 新增特色服务区块样式 */
|
||||
.services {
|
||||
padding: 80px 40px;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
|
||||
.services-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 40px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
background-color: var(--white);
|
||||
border-radius: 15px;
|
||||
padding: 30px;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.service-card:hover {
|
||||
transform: translateY(-10px);
|
||||
box-shadow: 0 10px 30px rgba(0, 102, 255, 0.15);
|
||||
}
|
||||
|
||||
.service-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 5px;
|
||||
height: 100%;
|
||||
background-color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.service-icon {
|
||||
font-size: 48px;
|
||||
color: var(--primary-blue);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.service-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.service-description {
|
||||
font-size: 16px;
|
||||
color: var(--text-light);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background-color: var(--white);
|
||||
border-radius: 15px;
|
||||
padding: 30px;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-10px);
|
||||
box-shadow: 0 10px 30px rgba(0, 102, 255, 0.15);
|
||||
}
|
||||
|
||||
.feature-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 5px;
|
||||
height: 100%;
|
||||
background-color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 48px;
|
||||
color: var(--primary-blue);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
.feature-description {
|
||||
font-size: 16px;
|
||||
color: var(--text-light);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* 联盟与学科工坊 */
|
||||
.alliance {
|
||||
padding: 80px 40px;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
|
||||
.alliance-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 40px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.alliance-banner {
|
||||
flex: 1;
|
||||
min-width: 400px;
|
||||
background-color: var(--white);
|
||||
border-radius: 15px;
|
||||
padding: 40px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.workshops {
|
||||
flex: 1;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
.workshop-tabs {
|
||||
background-color: var(--white);
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.tab-header {
|
||||
display: flex;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
padding: 15px 30px;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background-color: var(--primary-blue);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
/* 数据统计区 */
|
||||
.stats {
|
||||
padding: 80px 40px;
|
||||
background-color: var(--primary-blue);
|
||||
color: var(--white);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 40px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 48px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
animation: countUp 2s ease-out forwards;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 16px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@keyframes countUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 页脚 */
|
||||
.footer {
|
||||
background-color: var(--text-dark);
|
||||
color: var(--white);
|
||||
padding: 60px 40px 20px;
|
||||
}
|
||||
|
||||
.footer-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 40px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto 40px;
|
||||
}
|
||||
|
||||
.footer-column {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.footer-logo {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.footer-description {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.footer-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 20px;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.footer-links li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: var(--white);
|
||||
opacity: 0.8;
|
||||
text-decoration: none;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.hero {
|
||||
padding: 0 40px;
|
||||
}
|
||||
.hero-title {
|
||||
font-size: 36px;
|
||||
}
|
||||
.hero-subtitle {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.header {
|
||||
padding: 0 20px;
|
||||
}
|
||||
.nav-menu {
|
||||
display: none;
|
||||
}
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.hero-content {
|
||||
max-width: 100%;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
}
|
||||
.hero-cta {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.section-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
.features,
|
||||
.alliance,
|
||||
.stats {
|
||||
padding: 60px 20px;
|
||||
}
|
||||
.alliance-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.header {
|
||||
height: 60px;
|
||||
}
|
||||
.hero {
|
||||
margin-top: 60px;
|
||||
height: auto;
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
.hero-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
.hero-subtitle {
|
||||
font-size: 18px;
|
||||
}
|
||||
.btn {
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.feature-card {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* AI资源工坊页面样式 */
|
||||
.page-header {
|
||||
padding: 60px 0;
|
||||
background-color: var(--primary-blue);
|
||||
color: white;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.resource-generator {
|
||||
background-color: white;
|
||||
border-radius: 10px;
|
||||
padding: 30px;
|
||||
margin-bottom: 40px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.generator-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.resource-library {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.resource-filters {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.resources-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 25px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.resource-card {
|
||||
border: 1px solid #eee;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
92
static/degree.html
Normal file
92
static/degree.html
Normal file
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>驿来特AI数据查询功能示例</title>
|
||||
<link rel="stylesheet" href="css/element-plus.index.css">
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
<link rel="stylesheet" href="css/degree.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- 悬浮二维码 -->
|
||||
<div class="qr-sidebar">
|
||||
<div id="qrcode" class="qr-code"></div>
|
||||
<p class="qr-text">手机扫码访问</p>
|
||||
</div>
|
||||
|
||||
<div class="degree-container" style="margin-top: 100px;">
|
||||
|
||||
<div class="query-section">
|
||||
<h1 class="page-title">驿来特AI智能数据查询</h1>
|
||||
<p class="page-subtitle">基于大语言模型,为您提供实时、精准的业务数据分析</p>
|
||||
|
||||
<div class="input-wrapper">
|
||||
<el-input
|
||||
v-model="query"
|
||||
placeholder="请输入您的问题,例如:帮我查询12月份充电量TOP 10场站的充电情况"
|
||||
class="search-input"
|
||||
size="large"
|
||||
@keyup.enter="handleSearch"
|
||||
:disabled="loading"
|
||||
>
|
||||
<template #append>
|
||||
<el-button @click="handleSearch" :loading="loading" type="primary">
|
||||
{{ loading ? '分析中...' : '开始查询' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
|
||||
<div class="example-tags">
|
||||
<el-tag
|
||||
v-for="(text, index) in examples"
|
||||
:key="index"
|
||||
class="example-tag"
|
||||
effect="plain"
|
||||
round
|
||||
size="large"
|
||||
@click="setExample(text)"
|
||||
>
|
||||
{{ text }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="result-section" v-if="result || loading">
|
||||
<div class="result-header">
|
||||
<div class="result-title">
|
||||
<span>分析结果</span>
|
||||
<el-tag size="small" type="success" effect="dark" v-if="!loading">完成</el-tag>
|
||||
<el-tag size="small" type="warning" effect="dark" v-if="loading">生成中</el-tag>
|
||||
</div>
|
||||
<el-button
|
||||
v-if="loading"
|
||||
type="danger"
|
||||
link
|
||||
@click="stopGeneration"
|
||||
>
|
||||
停止生成
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="markdown-body" v-html="renderedResult"></div>
|
||||
<div v-if="loading && !result" class="loading-container">
|
||||
<p>正在分析数据,请稍候...</p>
|
||||
</div>
|
||||
<span v-if="loading" class="cursor-blink">|</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="js/vue.global.js"></script>
|
||||
<script src="js/element-plus.index.full.js"></script>
|
||||
<script src="js/element-plus.zh-cn.min.js"></script>
|
||||
<script src="js/marked.min.js"></script>
|
||||
<script src="js/qrcode.min.js"></script>
|
||||
<script src="js/echarts.min.js"></script>
|
||||
<script src="js/degree.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
452
static/js/degree.js
Normal file
452
static/js/degree.js
Normal file
@@ -0,0 +1,452 @@
|
||||
const { createApp, ref, computed, watch, nextTick, onMounted } = Vue;
|
||||
|
||||
const app = createApp({
|
||||
setup() {
|
||||
const query = ref('');
|
||||
const loading = ref(false);
|
||||
const result = ref('');
|
||||
const eventSource = ref(null);
|
||||
const isMobile = ref(window.innerWidth <= 768);
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => {
|
||||
isMobile.value = window.innerWidth <= 768;
|
||||
});
|
||||
|
||||
// Initialize QR Code
|
||||
onMounted(() => {
|
||||
if (!isMobile.value && typeof QRCode !== 'undefined') {
|
||||
new QRCode(document.getElementById("qrcode"), {
|
||||
text: window.location.href,
|
||||
width: 128,
|
||||
height: 128,
|
||||
colorDark : "#000000",
|
||||
colorLight : "#ffffff",
|
||||
correctLevel : QRCode.CorrectLevel.H
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Examples for the user
|
||||
const examples = [
|
||||
"查询12月份充电量TOP 10场站的充电情况",
|
||||
"查询净月商贸城站12月的充电量",
|
||||
"查询12月企业充电量排名的TOP 10",
|
||||
"查询所有场站的近3个月的充电情况,找出变化最大的前10名"
|
||||
];
|
||||
|
||||
// Helpers: convert payloads to readable markdown
|
||||
const isPlainObject = (obj) => Object.prototype.toString.call(obj) === '[object Object]';
|
||||
const colSynonyms = {
|
||||
name: ['name', 'station', 'station_name', 'site', '场站', '场站名称', '站点', '站名'],
|
||||
total: ['total', 'total_kwh', 'sum_kwh', 'energy_total', '总电量', '总充电量', '总度', '总度数'],
|
||||
peak: ['peak', 'peak_kwh', 'energy_peak', '峰', '峰时电量'],
|
||||
flat: ['flat', 'flat_kwh', 'energy_flat', '平', '平时电量'],
|
||||
valley: ['valley', 'valley_kwh', 'energy_valley', '谷', '谷时电量'],
|
||||
};
|
||||
const pickKey = (obj, keys) => keys.find(k => Object.prototype.hasOwnProperty.call(obj, k));
|
||||
const pickVal = (obj, keys) => {
|
||||
const k = pickKey(obj, keys);
|
||||
return k ? obj[k] : undefined;
|
||||
};
|
||||
const rowsAcc = ref([]);
|
||||
const bufferRow = ref(null);
|
||||
const formatNumber = (n) => {
|
||||
if (n === null || n === undefined || n === '') return '';
|
||||
const v = parseFloat(String(n).replace(/,/g, ''));
|
||||
if (isNaN(v)) return String(n);
|
||||
return v.toLocaleString('zh-CN', { maximumFractionDigits: 2 });
|
||||
};
|
||||
const buildTableFromRows = (rows) => {
|
||||
if (!rows || rows.length === 0) return '';
|
||||
const header = `| 场站名称 | 总电量 | 峰 | 平 | 谷 |\n| --- | ---: | ---: | ---: | ---: |`;
|
||||
const lines = rows.map(r => `| ${r.name ?? ''} | ${formatNumber(r.total)} | ${formatNumber(r.peak)} | ${formatNumber(r.flat)} | ${formatNumber(r.valley)} |`).join('\n');
|
||||
return `${header}\n${lines}`;
|
||||
};
|
||||
const pushRow = (row) => {
|
||||
if (!row || !row.name) return;
|
||||
rowsAcc.value.push(row);
|
||||
// Append the new row to result.value instead of rebuilding the whole table
|
||||
// This allows mixed content (raw markdown + parsed rows) to coexist
|
||||
const r = row;
|
||||
const line = `| ${r.name ?? ''} | ${formatNumber(r.total)} | ${formatNumber(r.peak)} | ${formatNumber(r.flat)} | ${formatNumber(r.valley)} |`;
|
||||
|
||||
// If this is the first row, add the header
|
||||
if (rowsAcc.value.length === 1) {
|
||||
const header = `| 场站名称 | 总电量 | 峰 | 平 | 谷 |\n| --- | ---: | ---: | ---: | ---: |`;
|
||||
// If result already has content (e.g. summary text), append header with newline
|
||||
if (result.value && !result.value.endsWith('\n')) {
|
||||
result.value += '\n\n';
|
||||
} else if (result.value) {
|
||||
result.value += '\n';
|
||||
}
|
||||
result.value += header + '\n';
|
||||
}
|
||||
result.value += line + '\n';
|
||||
};
|
||||
const ingestColumnsRows = (data) => {
|
||||
const nameCol = data.columns.find(c => colSynonyms.name.includes(c));
|
||||
const totalCol = data.columns.find(c => colSynonyms.total.includes(c));
|
||||
const peakCol = data.columns.find(c => colSynonyms.peak.includes(c));
|
||||
const flatCol = data.columns.find(c => colSynonyms.flat.includes(c));
|
||||
const valleyCol = data.columns.find(c => colSynonyms.valley.includes(c));
|
||||
data.rows.forEach(r => pushRow({
|
||||
name: r[nameCol],
|
||||
total: r[totalCol],
|
||||
peak: r[peakCol],
|
||||
flat: r[flatCol],
|
||||
valley: r[valleyCol],
|
||||
}));
|
||||
};
|
||||
const ingestDataArray = (arr) => {
|
||||
arr.forEach(obj => {
|
||||
const row = toSelectedRow(obj);
|
||||
if (row) pushRow(row);
|
||||
});
|
||||
};
|
||||
const tryStationName = (line) => {
|
||||
const m1 = line.match(/(?:站点|场站|充电站|站名)[::]\s*([^\s,,]+)/);
|
||||
if (m1) return m1[1];
|
||||
const m2 = line.match(/^\s*(?:\d+[\.\)]\s*)?(.+?(?:充电站|场站|站))(?:(.*))?\s*$/);
|
||||
if (m2) return m2[1];
|
||||
return null;
|
||||
};
|
||||
const tryMetric = (line, label) => {
|
||||
const re = new RegExp(label + '\\s*[::]\\s*([\\d.,]+)');
|
||||
const m = line.match(re);
|
||||
if (m) return parseFloat(m[1].replace(/,/g, ''));
|
||||
return null;
|
||||
};
|
||||
const ingestChunkText = (text) => {
|
||||
if (!text || /^\s*$/.test(text)) return false;
|
||||
let consumed = false;
|
||||
const lines = String(text).split(/[\r\n]+/);
|
||||
lines.forEach(line => {
|
||||
const name = tryStationName(line);
|
||||
if (name) {
|
||||
if (bufferRow.value && (bufferRow.value.total != null || bufferRow.value.peak != null || bufferRow.value.flat != null || bufferRow.value.valley != null)) {
|
||||
pushRow(bufferRow.value);
|
||||
}
|
||||
bufferRow.value = { name, total: null, peak: null, flat: null, valley: null };
|
||||
consumed = true;
|
||||
return;
|
||||
}
|
||||
if (!bufferRow.value) return;
|
||||
const total = tryMetric(line, '(?:总(?:充电)?电量|总电量)');
|
||||
const peak = tryMetric(line, '(?:峰(?:时)?电量|峰)');
|
||||
const flat = tryMetric(line, '(?:平(?:时)?电量|平)');
|
||||
const valley = tryMetric(line, '(?:谷(?:时)?电量|谷)');
|
||||
if (total != null) { bufferRow.value.total = total; consumed = true; }
|
||||
if (peak != null) { bufferRow.value.peak = peak; consumed = true; }
|
||||
if (flat != null) { bufferRow.value.flat = flat; consumed = true; }
|
||||
if (valley != null) { bufferRow.value.valley = valley; consumed = true; }
|
||||
if (bufferRow.value.name && (bufferRow.value.total != null || bufferRow.value.peak != null || bufferRow.value.flat != null || bufferRow.value.valley != null)) {
|
||||
pushRow(bufferRow.value);
|
||||
bufferRow.value = null;
|
||||
}
|
||||
});
|
||||
return consumed;
|
||||
};
|
||||
const toSelectedRow = (row) => {
|
||||
if (!isPlainObject(row)) return null;
|
||||
const name = pickVal(row, colSynonyms.name);
|
||||
const total = pickVal(row, colSynonyms.total);
|
||||
const peak = pickVal(row, colSynonyms.peak);
|
||||
const flat = pickVal(row, colSynonyms.flat);
|
||||
const valley = pickVal(row, colSynonyms.valley);
|
||||
if (name === undefined) return null;
|
||||
return { name, total, peak, flat, valley };
|
||||
};
|
||||
const toMarkdownTableFromArray = (arr) => {
|
||||
if (!Array.isArray(arr) || arr.length === 0) return '';
|
||||
const selected = arr.map(toSelectedRow).filter(Boolean);
|
||||
if (selected.length === 0) {
|
||||
// 如果不能识别出目标字段,避免展示冗余JSON
|
||||
return '';
|
||||
}
|
||||
const header = `| 场站名称 | 总电量 | 峰 | 平 | 谷 |\n| --- | ---: | ---: | ---: | ---: |`;
|
||||
const rows = selected.map(r => {
|
||||
const fmt = (v) => v === null || v === undefined ? '' : String(v);
|
||||
return `| ${fmt(r.name)} | ${fmt(r.total)} | ${fmt(r.peak)} | ${fmt(r.flat)} | ${fmt(r.valley)} |`;
|
||||
}).join('\n');
|
||||
return `${header}\n${rows}`;
|
||||
};
|
||||
const toMarkdownTableFromObject = (obj) => {
|
||||
const selected = toSelectedRow(obj);
|
||||
if (!selected) return '';
|
||||
const header = `| 场站名称 | 总电量 | 峰 | 平 | 谷 |\n| --- | ---: | ---: | ---: | ---: |`;
|
||||
const fmt = (v) => v === null || v === undefined ? '' : String(v);
|
||||
const row = `| ${fmt(selected.name)} | ${fmt(selected.total)} | ${fmt(selected.peak)} | ${fmt(selected.flat)} | ${fmt(selected.valley)} |`;
|
||||
return `${header}\n${row}`;
|
||||
};
|
||||
const payloadToMarkdown = (data) => {
|
||||
if (data == null) return '';
|
||||
if (typeof data === 'string') {
|
||||
// 文本直接作为markdown,但避免无用状态文本
|
||||
const trimmed = data.trim();
|
||||
if (/^(analyzing|generating|loading)/i.test(trimmed)) return '';
|
||||
return trimmed;
|
||||
}
|
||||
if (isPlainObject(data)) {
|
||||
if (typeof data.markdown === 'string') return data.markdown;
|
||||
if (typeof data.content === 'string') return data.content;
|
||||
if (typeof data.text === 'string') return data.text;
|
||||
// 过滤仅含无用状态字段的对象
|
||||
const keys = Object.keys(data);
|
||||
const statusKeys = ['step', 'message', 'chunk', 'status'];
|
||||
if (keys.every(k => statusKeys.includes(k))) return '';
|
||||
if (Array.isArray(data.rows) && Array.isArray(data.columns)) {
|
||||
// 映射选择列
|
||||
const colMap = {
|
||||
name: data.columns.find(c => colSynonyms.name.includes(c)),
|
||||
total: data.columns.find(c => colSynonyms.total.includes(c)),
|
||||
peak: data.columns.find(c => colSynonyms.peak.includes(c)),
|
||||
flat: data.columns.find(c => colSynonyms.flat.includes(c)),
|
||||
valley: data.columns.find(c => colSynonyms.valley.includes(c)),
|
||||
};
|
||||
const header = `| 场站名称 | 总电量 | 峰 | 平 | 谷 |\n| --- | ---: | ---: | ---: | ---: |`;
|
||||
const rows = data.rows.map(r => {
|
||||
const fmt = (v) => v === null || v === undefined ? '' : String(v);
|
||||
return `| ${fmt(r[colMap.name])} | ${fmt(r[colMap.total])} | ${fmt(r[colMap.peak])} | ${fmt(r[colMap.flat])} | ${fmt(r[colMap.valley])} |`;
|
||||
}).join('\n');
|
||||
return `${header}\n${rows}`;
|
||||
}
|
||||
if (Array.isArray(data.data)) {
|
||||
return toMarkdownTableFromArray(data.data);
|
||||
}
|
||||
// Fallback: 仅当识别出目标字段时才展示;否则忽略
|
||||
return toMarkdownTableFromObject(data);
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return toMarkdownTableFromArray(data);
|
||||
}
|
||||
return String(data);
|
||||
};
|
||||
|
||||
// 配置 marked
|
||||
marked.use({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
mangle: false,
|
||||
headerIds: false,
|
||||
renderer: {
|
||||
code(code, language) {
|
||||
if (language === 'echarts') {
|
||||
const id = 'chart-' + Math.random().toString(36).substr(2, 9);
|
||||
return `<div class="echarts-container" id="${id}" style="width: 100%; height: 400px; margin: 20px 0; background: rgba(30, 41, 59, 0.5); border: 1px solid rgba(148, 163, 184, 0.1); border-radius: 12px; padding: 20px;" data-config="${encodeURIComponent(code)}"></div>`;
|
||||
}
|
||||
return false; // 返回 false 使用默认渲染器
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Convert markdown to HTML
|
||||
const renderedResult = computed(() => {
|
||||
if (!result.value) return '';
|
||||
try {
|
||||
// 修复 LLM 常见的错误转义问题,例如 \*\*加粗\*\*
|
||||
const cleanText = result.value.replace(/\\([\*_`#\[\]\(\)!>-])/g, '$1');
|
||||
return marked.parse(cleanText);
|
||||
} catch (e) {
|
||||
console.error('Markdown parsing error:', e);
|
||||
return result.value;
|
||||
}
|
||||
});
|
||||
|
||||
// 渲染图表函数
|
||||
const renderCharts = () => {
|
||||
if (typeof echarts === 'undefined') {
|
||||
console.warn('ECharts is not loaded yet.');
|
||||
return;
|
||||
}
|
||||
nextTick(() => {
|
||||
const containers = document.querySelectorAll('.echarts-container');
|
||||
containers.forEach(container => {
|
||||
// 如果已经成功渲染,不再重复渲染
|
||||
if (container.getAttribute('data-rendered') === 'true') return;
|
||||
|
||||
try {
|
||||
const configStr = decodeURIComponent(container.getAttribute('data-config')).trim();
|
||||
|
||||
// 检查 JSON 是否完整(简单判断:以 { 开始,以 } 结束)
|
||||
if (!configStr.startsWith('{') || !configStr.endsWith('}')) {
|
||||
return; // 还在流式传输中,不完整则跳过
|
||||
}
|
||||
|
||||
const config = JSON.parse(configStr);
|
||||
|
||||
// 如果容器还没有 init,则初始化
|
||||
let chart = echarts.getInstanceByDom(container);
|
||||
if (!chart) {
|
||||
chart = echarts.init(container, 'dark');
|
||||
// 响应式
|
||||
window.addEventListener('resize', () => chart.resize());
|
||||
}
|
||||
|
||||
chart.setOption(config);
|
||||
container.setAttribute('data-rendered', 'true');
|
||||
container.style.opacity = '1';
|
||||
} catch (e) {
|
||||
// 忽略流式传输中的解析错误
|
||||
if (loading.value) return;
|
||||
console.error('ECharts rendering error:', e);
|
||||
container.innerHTML = `<p style="color: #ef4444; padding: 10px;">图表渲染失败: ${e.message}</p>`;
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 监听结果变化,尝试渲染图表
|
||||
watch(result, (newVal) => {
|
||||
if (newVal && newVal.includes('```echarts')) {
|
||||
renderCharts();
|
||||
}
|
||||
});
|
||||
|
||||
const setExample = (text) => {
|
||||
query.value = text;
|
||||
handleSearch();
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!query.value.trim()) {
|
||||
ElementPlus.ElMessage.warning('请输入您的问题');
|
||||
return;
|
||||
}
|
||||
|
||||
if (loading.value) {
|
||||
// If already loading, stop the previous request?
|
||||
// For now, let's just ignore or maybe cancel.
|
||||
// closing existing connection
|
||||
if (eventSource.value) {
|
||||
eventSource.value.close();
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
result.value = '';
|
||||
|
||||
// Construct the SSE URL
|
||||
// Assuming the backend endpoint is /api/ai/query or similar.
|
||||
// We use a relative path so it works if served from the same domain.
|
||||
// Adjust this URL based on actual backend route.
|
||||
const url = `/degree/chat?q=${encodeURIComponent(query.value)}`;
|
||||
|
||||
try {
|
||||
const es = new EventSource(url);
|
||||
eventSource.value = es;
|
||||
|
||||
es.onopen = () => {
|
||||
console.log('Connection opened');
|
||||
};
|
||||
|
||||
es.onmessage = (event) => {
|
||||
const eventData = event.data.trim();
|
||||
if (eventData === '[DONE]') {
|
||||
loading.value = false;
|
||||
es.close();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// 处理流式内容
|
||||
let chunk = '';
|
||||
if (data.content) chunk = data.content;
|
||||
else if (data.markdown) chunk = data.markdown;
|
||||
else if (data.chunk) chunk = data.chunk;
|
||||
else if (typeof data === 'string') chunk = data;
|
||||
|
||||
if (chunk) {
|
||||
// 保持原始文本,不对 markdown 符号进行手动转义处理
|
||||
// marked 会处理大部分情况
|
||||
result.value += chunk;
|
||||
}
|
||||
|
||||
// 处理状态更新
|
||||
if (data.step || data.message) {
|
||||
// 可以根据需要处理状态,比如更新进度条等
|
||||
// 目前主要依赖流式内容展示
|
||||
}
|
||||
} catch (e) {
|
||||
// 非 JSON 内容直接追加
|
||||
if (eventData && eventData !== '[DONE]') {
|
||||
result.value += event.data;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = (err) => {
|
||||
console.error('SSE Error:', err);
|
||||
loading.value = false;
|
||||
es.close();
|
||||
|
||||
// 如果之前已经有结果,就保持现状;否则给出简单提示
|
||||
if (!result.value) {
|
||||
result.value = '查询过程出现异常,未能获取到数据';
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for a specific 'done' event if the backend sends one
|
||||
es.addEventListener('done', () => {
|
||||
if (bufferRow.value && (bufferRow.value.total != null || bufferRow.value.peak != null || bufferRow.value.flat != null || bufferRow.value.valley != null)) {
|
||||
pushRow(bufferRow.value);
|
||||
bufferRow.value = null;
|
||||
}
|
||||
if (!result.value) {
|
||||
result.value = '未查询到数据';
|
||||
}
|
||||
es.close();
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to create EventSource:', err);
|
||||
loading.value = false;
|
||||
ElementPlus.ElMessage.error('无法建立连接');
|
||||
}
|
||||
};
|
||||
|
||||
const stopGeneration = () => {
|
||||
if (eventSource.value) {
|
||||
eventSource.value.close();
|
||||
eventSource.value = null;
|
||||
}
|
||||
loading.value = false;
|
||||
ElementPlus.ElMessage.info('已停止生成');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 生成二维码
|
||||
const qrEl = document.getElementById('qrcode');
|
||||
if (qrEl) {
|
||||
// 清空可能存在的旧内容
|
||||
qrEl.innerHTML = '';
|
||||
new QRCode(qrEl, {
|
||||
text: window.location.href,
|
||||
width: 256, // 生成高分辨率图片
|
||||
height: 256,
|
||||
colorDark: "#000000",
|
||||
colorLight: "#ffffff",
|
||||
correctLevel: QRCode.CorrectLevel.M // 中等纠错等级,平衡密度和可靠性
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
query,
|
||||
loading,
|
||||
result,
|
||||
renderedResult,
|
||||
examples,
|
||||
isMobile,
|
||||
handleSearch,
|
||||
setExample,
|
||||
stopGeneration
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
app.use(ElementPlus);
|
||||
app.mount('#app');
|
||||
2
static/js/qrcode.min.js
vendored
Normal file
2
static/js/qrcode.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user