This commit is contained in:
HuangHai
2026-01-20 08:09:13 +08:00
parent b0b4533f57
commit 34501faafb
20 changed files with 2360 additions and 859 deletions

View File

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

View 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")

View File

@@ -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
View 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:0018: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:0021: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:0018: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:0021: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
View 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()

Binary file not shown.

View File

@@ -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__":

View File

@@ -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:
# 使用固定格式的keyuser_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()

View File

@@ -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()

421
static/css/degree.css Normal file
View 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; /* 稍亮的条纹背景 */
}
}

File diff suppressed because one or more lines are too long

724
static/css/style.css Normal file
View 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
View 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
View 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

File diff suppressed because one or more lines are too long