diff --git a/Apps/AiTeJiYiChong/FirstPageKit.py b/Apps/AiTeJiYiChong/FirstPageKit.py
index 59d39ff..834a28b 100644
--- a/Apps/AiTeJiYiChong/FirstPageKit.py
+++ b/Apps/AiTeJiYiChong/FirstPageKit.py
@@ -57,6 +57,11 @@ LLM_NON_STATION_NAME_KEYWORDS = [
"收费停车",
"限时免费停车",
"停车费",
+ "免费停车",
+ "免停车费",
+ "停车优惠",
+ "停车三小时",
+ "免费停车三小时",
"END",
"End",
"end",
@@ -192,6 +197,7 @@ async def run_ocr_rect(image_path, log_path=None):
"- 该距离文本所在行的左侧、且在同一卡片中的那一行文字,就是对应的场站标题 station_name;\n"
"- 忙闲信息通常出现在卡片右侧的彩色小块中,例如 \"快 3/4\"、\"慢 0/0\"、\"超 1/4\";\n"
"- 即使 station_name 中不包含“充电站”“超快充”等字样,但只要与某个 \"x.xkm\" 行在同一卡片区域内,也应视为一个完整的场站名称。\n"
+ "- 包含“免费停车”“免停车费”“停车优惠”等停车收费文案的文本不允许作为 station_name,如果模型误把这类文本当作场站名称,应当直接忽略该卡片或把这些文本视为停车信息而不是名称。\n"
"要求:\n"
"- 场站按从上到下排序;\n"
"- station_name 不能取距离行本身(如 \"1.4km\"),而是要取与之成一对的标题行;\n"
diff --git a/Apps/AiTeJiYiChong/__pycache__/FirstPageKit.cpython-310.pyc b/Apps/AiTeJiYiChong/__pycache__/FirstPageKit.cpython-310.pyc
index 752dfda..d41cef2 100644
Binary files a/Apps/AiTeJiYiChong/__pycache__/FirstPageKit.cpython-310.pyc and b/Apps/AiTeJiYiChong/__pycache__/FirstPageKit.cpython-310.pyc differ
diff --git a/Config/Config.py b/Config/Config.py
index c9a91dd..674d9e7 100644
--- a/Config/Config.py
+++ b/Config/Config.py
@@ -1,16 +1,16 @@
# 黄海在公司内网开发时的配置信息
-# 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 = "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"
+# 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"
@@ -53,7 +53,7 @@ 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
+# 分时价格全局配置
+# False: 默认按各个供应商自己的 PRICE_FLATTEN_TO_24H 决定
+# True: 强制所有供应商都铺平成 24 小时整点数组
+PRICE_FLATTEN_TO_24H_GLOBAL = True
diff --git a/Config/__pycache__/Config.cpython-310.pyc b/Config/__pycache__/Config.cpython-310.pyc
index 9e60031..f935182 100644
Binary files a/Config/__pycache__/Config.cpython-310.pyc and b/Config/__pycache__/Config.cpython-310.pyc differ
diff --git a/Controller/YltAnalyticsController.py b/Controller/YltAnalyticsController.py
index fa091ca..d205488 100644
--- a/Controller/YltAnalyticsController.py
+++ b/Controller/YltAnalyticsController.py
@@ -4,11 +4,15 @@ import json
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, HTTPException
-from fastapi.responses import StreamingResponse
+from fastapi.responses import StreamingResponse, FileResponse, JSONResponse
from DbKit.Db import Db
from Config.Config import DB_URL
from Util.LlmUtil import get_llm_response
+from Tools.T6_Export import export_excel, DorisExcelExporter, extract_hourly_prices_from_schedule
+import tempfile
+import os
+import zipfile
from Model.YltAnalyticsModel import (
StationBase,
CompetitorStation,
@@ -33,6 +37,89 @@ async def close_db():
await db.close()
+@router.get("/api/operators/hourly-prices")
+async def get_operators_hourly_prices():
+ operators = ["新电途", "特来电", "驿来特", "艾特吉易充"]
+ exporter = DorisExcelExporter(db_url=DB_URL)
+ await exporter.init()
+ try:
+ result = []
+ for op in operators:
+ rows = await exporter.fetch_current_station_rows(op)
+ if not rows:
+ result.append({"operator": op, "series": [None] * 24})
+ continue
+ sums = [0.0] * 24
+ counts = [0] * 24
+ for row in rows:
+ schedule_json = row.get("schedule_json")
+ series = extract_hourly_prices_from_schedule(schedule_json)
+ for i in range(24):
+ v = series[i]
+ if v is None:
+ continue
+ sums[i] += float(v)
+ counts[i] += 1
+ avg_series = []
+ for i in range(24):
+ c = counts[i]
+ if c > 0:
+ avg_series.append(sums[i] / c)
+ else:
+ avg_series.append(None)
+ result.append({"operator": op, "series": avg_series})
+ finally:
+ await exporter.close()
+ return {"operators": result}
+
+
+@router.get("/api/export/prices-zip")
+async def export_prices_zip():
+ operators = ["新电途", "特来电", "驿来特", "艾特吉易充"]
+ tmp_dir = tempfile.mkdtemp(prefix="price_export_")
+ excel_paths = []
+ for op in operators:
+ filename = f"{op}_{asyncio.get_event_loop().time():.0f}.xlsx"
+ output_path = os.path.join(tmp_dir, filename)
+ await export_excel(op, output_path)
+ excel_paths.append(output_path)
+ zip_path = os.path.join(tmp_dir, "prices_export.zip")
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
+ for p in excel_paths:
+ arcname = os.path.basename(p)
+ zf.write(p, arcname=arcname)
+ return FileResponse(
+ zip_path,
+ media_type="application/zip",
+ filename="多供应商电价导出.zip",
+ )
+
+
+@router.get("/api/ai/pricing/strategy-summary")
+async def ai_pricing_strategy_summary():
+ resp = await get_operators_hourly_prices()
+ data = resp.get("operators", [])
+ text_data = []
+ for item in data:
+ text_data.append({"operator": item.get("operator"), "series": item.get("series")})
+ text = (
+ "下面是四家供应商(新电途、特来电、驿来特、艾特吉易充)基于最新爬取数据计算出的平均24小时分时电价:\n"
+ f"{text_data}\n"
+ "请根据这些数据,综合分析各司的定价策略差异,重点对比我司(驿来特)与其他供应商的分时电价水平,"
+ "指出我司在不同时段可能存在的潜在问题和风险(例如明显偏贵、价格结构不合理等),并给出2-3条可执行的优化建议。"
+ "回答控制在600字以内。"
+ )
+ chunks: List[str] = []
+ async for chunk in get_llm_response(
+ text,
+ stream=False,
+ system_prompt="你是熟悉中国充电桩行业的电价策略分析顾问。",
+ ):
+ chunks.append(chunk)
+ summary_text = "".join(chunks)
+ return JSONResponse({"summary": summary_text})
+
+
@router.get("/api/ylt/stations", response_model=List[StationBase])
async def list_ylt_stations(q: Optional[str] = None):
base_sql = """
diff --git a/Controller/__pycache__/YltAnalyticsController.cpython-310.pyc b/Controller/__pycache__/YltAnalyticsController.cpython-310.pyc
index 2e09b33..5bfd980 100644
Binary files a/Controller/__pycache__/YltAnalyticsController.cpython-310.pyc and b/Controller/__pycache__/YltAnalyticsController.cpython-310.pyc differ
diff --git a/Tools/T6_Export.py b/Tools/T6_Export.py
index f04c41c..e05d03a 100644
--- a/Tools/T6_Export.py
+++ b/Tools/T6_Export.py
@@ -174,7 +174,7 @@ def _set_column_widths(ws, widths: dict[int, float]):
ws.column_dimensions[get_column_letter(col_idx)].width = width
-class DorisExcelExporter:
+class DorisExcelExporter:
def __init__(self, db_url: str):
self.db = Db(db_url=db_url)
@@ -188,7 +188,7 @@ class DorisExcelExporter:
if getattr(self.db, "engine", None):
await self.db.engine.dispose()
- async def fetch_current_station_rows(self, operator: str) -> List[Dict[str, Any]]:
+ async def fetch_current_station_rows(self, operator: str) -> List[Dict[str, Any]]:
sql = """
SELECT
p.station_hash,
@@ -214,7 +214,32 @@ class DorisExcelExporter:
WHERE p.is_current = 1 AND p.operator = :operator
ORDER BY p.station_name
"""
- return await self.db.find(sql, {"operator": operator})
+ return await self.db.find(sql, {"operator": operator})
+
+
+def extract_hourly_prices_from_schedule(schedule_json: Any) -> List[Optional[float]]:
+ items = _parse_schedule(schedule_json)
+ series: List[Optional[float]] = [None] * 24
+ if not items:
+ return series
+ for idx, item in enumerate(items):
+ if not isinstance(item, dict):
+ continue
+ price = _to_float(item.get("price_kwh") if "price_kwh" in item else item.get("price"))
+ if price is None:
+ continue
+ start = item.get("start") or item.get("begin") or item.get("start_time")
+ h = None
+ if isinstance(start, str) and ":" in start:
+ try:
+ h = int(start.split(":", 1)[0])
+ except Exception:
+ h = None
+ if h is None:
+ h = idx
+ if 0 <= h < 24:
+ series[h] = float(price)
+ return series
def _build_station_sheet(ws, row: Dict[str, Any]):
diff --git a/Tools/__pycache__/T6_Export.cpython-310.pyc b/Tools/__pycache__/T6_Export.cpython-310.pyc
new file mode 100644
index 0000000..65261b3
Binary files /dev/null and b/Tools/__pycache__/T6_Export.cpython-310.pyc differ
diff --git a/static/index.html b/static/index.html
index 1d4931d..056334f 100644
--- a/static/index.html
+++ b/static/index.html
@@ -2,64 +2,56 @@
-驿来特价格分析大屏
+多供应商分时电价分析大屏
-
搜索驿来特场站
-
-
+
选择供应商
+
+
-
当前场站:{{ selectedStation ? selectedStation.station_name : "未选择" }}
-
+
-
+
-| 场站名称 |
-地址 |
-当前价 |
-操作 |
+小时 |
+{{ op.label }} |
-
-| {{ s.station_name }} |
-{{ s.address }} |
-{{ formatCell(s.current_price) }} |
- |
+
+| {{ row.hour }} |
+{{ formatCell(cell.price) }} |
-
-| 暂无数据,请先查询场站 |
+
+| 暂无数据,请先查询 |
-
-
24 小时分时电价表(元/度)
-
-
-
-| {{ col }} |
-
-
-
-
-| {{ formatCell(cell) }} |
-
-
-
-
AI 辅助分析
-
{{ aiText || placeholder }}
+
+
+
默认问题
+
+请根据爬取的各供应商分时电价等信息,对各司的定价策略,
+与我司(驿来特)的定价策略进行综合对比,分析我司可能存在的潜在问题。
+
+
+
+
{{ aiText || placeholder }}
+
diff --git a/static/js/app.js b/static/js/app.js
index efe6c03..13d0135 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -1,128 +1,127 @@
const {createApp,onMounted,ref} = Vue
+
createApp({
setup(){
const apiBase = ref(window.location.origin || "http://localhost:8000")
-const stationHash = ref("")
+const operators = ref([
+{label:"新电途",value:"新电途"},
+{label:"特来电",value:"特来电"},
+{label:"驿来特",value:"驿来特"},
+{label:"艾特吉易充",value:"艾特吉易充"}
+])
+const selectedOperator = ref("驿来特")
const aiText = ref("")
-const placeholder = ref("在左侧列表中选择驿来特场站,点击“查看价差”加载24小时分时电价与竞品对比,再点击“AI价差分析”获取模型给出的策略建议。")
+const placeholder = ref("点击左侧按钮查询四家供应商最新24小时分时电价,再使用右侧AI综合分析功能对各司定价策略进行对比。")
const loading = ref(false)
const aiLoading = ref(false)
-const stations = ref([])
-const searchKeyword = ref("")
-const selectedStation = ref(null)
-const priceTableColumns = ref([])
const priceTableRows = ref([])
+const hourlyPricesByOperator = ref({})
let chartInstance = null
-let es = null
+
const initChart = () => {
const dom = document.getElementById("chart")
if (dom && !chartInstance) {
chartInstance = echarts.init(dom)
}
}
-const loadStations = async () => {
-try{
-const url = apiBase.value + "/api/ylt/stations"
-const params = searchKeyword.value ? {params:{q:searchKeyword.value}} : {}
-const res = await axios.get(url,params)
-stations.value = Array.isArray(res.data) ? res.data : []
-if (!selectedStation.value && stations.value.length > 0) {
-selectStation(stations.value[0])
-}
-}catch(e){
-console.error(e)
-}
-}
-const buildPriceTable = data => {
-const cols = []
-cols.push("时段")
-cols.push("驿来特")
-data.competitors.forEach(c => {
-cols.push(c.operator)
-})
-priceTableColumns.value = cols
+
+const buildPriceTable = () => {
const rows = []
-for (let i = 0; i < data.hours.length; i++) {
-const row = []
-row.push(data.hours[i] + ":00")
-row.push(data.ylt.series[i])
-data.competitors.forEach(c => {
-row.push(c.series[i])
+for (let h = 0; h < 24; h++) {
+const row = {hour: (h.toString().padStart(2,"0") + ":00"), values: []}
+operators.value.forEach(op => {
+const series = hourlyPricesByOperator.value[op.value] || []
+const price = series[h] !== undefined ? series[h] : null
+row.values.push({operator: op.value, price})
})
rows.push(row)
}
priceTableRows.value = rows
+if (chartInstance) {
+const series = operators.value.map(op => {
+const seriesData = []
+for (let h = 0; h < 24; h++) {
+const list = hourlyPricesByOperator.value[op.value] || []
+seriesData.push(list[h] !== undefined ? list[h] : null)
}
-const loadComparison = async () => {
-if (!stationHash.value) return
-loading.value = true
-try{
-const url = apiBase.value + "/api/ylt/pricing/comparison/" + stationHash.value
-const res = await axios.get(url)
-const data = res.data
-const hours = data.hours.map(h => h + ":00")
-const series = []
-series.push({
-name:"驿来特",
-type:"line",
-smooth:true,
-data:data.ylt.series
+return {name: op.label, type:"line", smooth:true, data:seriesData}
})
-data.competitors.forEach(c=>{
-series.push({
-name:c.operator,
-type:"line",
-smooth:true,
-data:c.series
-})
-})
-buildPriceTable(data)
+const hours = rows.map(r => r.hour)
const option = {
tooltip:{trigger:"axis"},
-legend:{data:series.map(s=>s.name),textStyle:{color:"#e5e7eb"}},
+legend:{data:operators.value.map(o=>o.label),textStyle:{color:"#e5e7eb"}},
xAxis:{type:"category",data:hours,axisLine:{lineStyle:{color:"#4b5563"}},axisLabel:{color:"#9ca3af"}},
yAxis:{type:"value",name:"元/度",axisLine:{lineStyle:{color:"#4b5563"}},axisLabel:{color:"#9ca3af"},splitLine:{lineStyle:{color:"#111827"}}},
grid:{left:40,right:20,top:30,bottom:30},
series:series
}
-initChart()
chartInstance.setOption(option)
+}
+}
+
+const loadAllOperatorsPrices = async () => {
+loading.value = true
+try{
+const res = await axios.get(apiBase.value + "/api/operators/hourly-prices")
+if (res && res.data && Array.isArray(res.data.operators)) {
+const dict = {}
+res.data.operators.forEach(item => {
+dict[item.operator] = item.series || []
+})
+hourlyPricesByOperator.value = dict
+buildPriceTable()
+}
}catch(e){
console.error(e)
}finally{
loading.value = false
}
}
-const selectStation = s => {
-selectedStation.value = s
-stationHash.value = s.station_hash
-loadComparison()
+
+const loadOperatorPrices = async () => {
+if (!selectedOperator.value) return
+await loadAllOperatorsPrices()
}
-const startAiStream = () => {
-if (!selectedStation.value || !stationHash.value) return
-if (es) {
-es.close()
-es = null
+
+const exportAllPrices = async () => {
+try{
+loading.value = true
+const res = await axios.get(apiBase.value + "/api/export/prices-zip",{responseType:"blob"})
+const blob = new Blob([res.data],{type:"application/zip"})
+const url = window.URL.createObjectURL(blob)
+const a = document.createElement("a")
+a.href = url
+a.download = "多供应商电价导出.zip"
+document.body.appendChild(a)
+a.click()
+document.body.removeChild(a)
+window.URL.revokeObjectURL(url)
+}catch(e){
+console.error(e)
+}finally{
+loading.value = false
}
+}
+
+const startAiAnalysis = async () => {
+if (aiLoading.value) return
aiText.value = ""
aiLoading.value = true
-const url = apiBase.value + "/api/ylt/pricing/comparison/" + stationHash.value + "/sse"
-es = new EventSource(url)
-es.onmessage = ev => {
-aiText.value += ev.data
-}
-es.onerror = () => {
+try{
+const res = await axios.get(apiBase.value + "/api/ai/pricing/strategy-summary")
+aiText.value = res.data && res.data.summary ? res.data.summary : ""
+}catch(e){
+console.error(e)
+}finally{
aiLoading.value = false
-if (es) {
-es.close()
-es = null
-}
}
}
+
onMounted(()=>{
initChart()
-loadStations()
+loadAllOperatorsPrices()
})
+
const formatCell = v => {
if (v === null || v === undefined || v === "") return "-"
if (typeof v === "number") {
@@ -131,6 +130,7 @@ return v.toFixed(2)
}
return v
}
-return {stationHash,aiText,placeholder,loading,aiLoading,stations,searchKeyword,selectedStation,priceTableColumns,priceTableRows,loadStations,loadComparison,selectStation,startAiStream,formatCell}
+
+return {apiBase,operators,selectedOperator,aiText,placeholder,loading,aiLoading,priceTableRows,loadOperatorPrices,loadAllOperatorsPrices,exportAllPrices,startAiAnalysis,formatCell}
}
}).mount("#app")