This commit is contained in:
HuangHai
2026-01-18 16:02:40 +08:00
parent a8ad244ecf
commit 5c0a1a67ac
8 changed files with 786 additions and 202 deletions

View File

@@ -0,0 +1,453 @@
import math
import asyncio
import json
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from DbKit.Db import Db
from Config.Config import DB_URL
from Util.LlmUtil import get_llm_response
from Model.YltAnalyticsModel import (
StationBase,
CompetitorStation,
GeoCompetitionResponse,
GeoCompetitionSummary,
PriceSeries,
PriceComparisonResponse,
PriceComparisonSummary,
)
router = APIRouter()
db = Db(db_url=DB_URL)
async def init_db():
await db.init_db()
async def close_db():
await db.close()
@router.get("/api/ylt/stations", response_model=List[StationBase])
async def list_ylt_stations(q: Optional[str] = None):
base_sql = """
SELECT
p.station_hash,
p.operator,
p.station_name,
p.address,
p.coord_x,
p.coord_y,
s.current_price
FROM t_station_profile_scd p
LEFT JOIN t_station_status_scd s
ON p.station_hash = s.station_hash AND s.is_current = 1
WHERE p.is_current = 1
AND p.operator = '驿来特'
"""
params: Dict[str, Any] = {}
if q:
base_sql += " AND (p.station_name LIKE :kw OR p.address LIKE :kw)"
params["kw"] = f"%{q}%"
base_sql += " ORDER BY p.station_name"
rows = await db.find(base_sql, params)
result: List[StationBase] = []
for r in rows:
result.append(
StationBase(
station_hash=r.get("station_hash"),
operator=r.get("operator"),
station_name=r.get("station_name"),
address=r.get("address"),
coord_x=r.get("coord_x"),
coord_y=r.get("coord_y"),
current_price=r.get("current_price"),
)
)
return result
def haversine_km(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
r = 6371.0
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
d_phi = math.radians(lat2 - lat1)
d_lambda = math.radians(lon2 - lon1)
a = math.sin(d_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return r * c
async def fetch_current_stations() -> List[dict]:
sql = """
SELECT
p.station_hash,
p.operator,
p.station_name,
p.address,
p.coord_x,
p.coord_y,
s.current_price
FROM t_station_profile_scd p
LEFT JOIN t_station_status_scd s
ON p.station_hash = s.station_hash AND s.is_current = 1
WHERE p.is_current = 1
"""
return await db.find(sql, {})
async def build_geo_competition(station_hash: str, radius_km: float = 3.0) -> GeoCompetitionResponse:
rows = await fetch_current_stations()
if not rows:
raise HTTPException(status_code=404, detail="no station data")
base_row = None
for r in rows:
if r.get("station_hash") == station_hash and r.get("operator") == "驿来特":
base_row = r
break
if base_row is None:
raise HTTPException(status_code=404, detail="base station not found for 驿来特")
base_lon = base_row.get("coord_x")
base_lat = base_row.get("coord_y")
if base_lon is None or base_lat is None:
raise HTTPException(status_code=400, detail="base station has no coordinates")
competitors: List[CompetitorStation] = []
ylt_price = base_row.get("current_price")
cheaper = 0
same = 0
more_expensive = 0
min_price: Optional[float] = None
max_price: Optional[float] = None
for r in rows:
if r.get("operator") == "驿来特":
continue
lon = r.get("coord_x")
lat = r.get("coord_y")
if lon is None or lat is None:
continue
dist = haversine_km(base_lon, base_lat, lon, lat)
if dist > radius_km:
continue
price = r.get("current_price")
competitors.append(
CompetitorStation(
station_hash=r.get("station_hash"),
operator=r.get("operator"),
station_name=r.get("station_name"),
distance_km=round(dist, 3),
current_price=price,
)
)
if price is not None:
if min_price is None or price < min_price:
min_price = price
if max_price is None or price > max_price:
max_price = price
if ylt_price is not None:
if price < ylt_price:
cheaper += 1
elif price > ylt_price:
more_expensive += 1
else:
same += 1
base_station = StationBase(
station_hash=base_row.get("station_hash"),
operator=base_row.get("operator"),
station_name=base_row.get("station_name"),
address=base_row.get("address"),
coord_x=base_lon,
coord_y=base_lat,
current_price=ylt_price,
)
return GeoCompetitionResponse(
base_station=base_station,
competitors=competitors,
ylt_price=ylt_price,
min_competitor_price=min_price,
max_competitor_price=max_price,
cheaper_count=cheaper,
same_count=same,
more_expensive_count=more_expensive,
)
async def fetch_station_schedule_json(station_hash: str) -> Optional[str]:
sql = """
SELECT schedule_json
FROM t_station_price_schedule_scd
WHERE station_hash = :h AND is_current = 1
ORDER BY valid_start_time DESC
LIMIT 1
"""
rows = await db.find(sql, {"h": station_hash})
if not rows:
return None
value = rows[0].get("schedule_json")
if value is None:
return None
if isinstance(value, str):
return value
try:
return json.dumps(value, ensure_ascii=False)
except Exception:
return None
def extract_price_from_item(item: Dict[str, Any]) -> Optional[float]:
if not isinstance(item, dict):
return None
for key in ("price", "price_kwh", "priceKwh", "total_price", "totalPrice"):
v = item.get(key)
if isinstance(v, (int, float)):
return float(v)
elec = item.get("elec_price")
service = item.get("service_price")
if isinstance(elec, (int, float)) and isinstance(service, (int, float)):
return float(elec) + float(service)
elec2 = item.get("electric_fee_kwh")
service2 = item.get("service_fee_kwh")
if isinstance(elec2, (int, float)) and isinstance(service2, (int, float)):
return float(elec2) + float(service2)
elec3 = item.get("ele_fee")
service3 = item.get("ser_fee")
if isinstance(elec3, (int, float)) and isinstance(service3, (int, float)):
return float(elec3) + float(service3)
return None
def parse_hour_from_item(item: Dict[str, Any], default_index: int) -> Optional[int]:
start = item.get("start")
if isinstance(start, str) and ":" in start:
parts = start.split(":")
try:
h = int(parts[0])
if 0 <= h <= 23:
return h
except Exception:
pass
end = item.get("end")
if isinstance(end, str) and ":" in end:
parts = end.split(":")
try:
h2 = int(parts[0])
if 0 < h2 <= 24:
return h2 - 1
except Exception:
pass
if 0 <= default_index <= 23:
return default_index
return None
def extract_hourly_prices(schedule_json_str: str) -> List[Optional[float]]:
series: List[Optional[float]] = [None] * 24
if not schedule_json_str:
return series
try:
data = json.loads(schedule_json_str)
except Exception:
return series
if not isinstance(data, list):
return series
for idx, item in enumerate(data):
price = extract_price_from_item(item)
if price is None:
continue
hour_idx = parse_hour_from_item(item, idx)
if hour_idx is None or not (0 <= hour_idx < 24):
continue
series[hour_idx] = float(price)
return series
async def build_price_comparison(station_hash: str) -> PriceComparisonResponse:
geo = await build_geo_competition(station_hash)
base_station = geo.base_station
base_schedule_str = await fetch_station_schedule_json(base_station.station_hash)
if base_schedule_str is None:
raise HTTPException(status_code=404, detail="no price schedule for YLT station")
ylt_series = extract_hourly_prices(base_schedule_str)
hours = list(range(24))
operator_series_sum: Dict[str, List[float]] = {}
operator_series_count: Dict[str, List[int]] = {}
for comp in geo.competitors:
schedule_str = await fetch_station_schedule_json(comp.station_hash)
if not schedule_str:
continue
series = extract_hourly_prices(schedule_str)
op = comp.operator
if op not in operator_series_sum:
operator_series_sum[op] = [0.0] * 24
operator_series_count[op] = [0] * 24
sums = operator_series_sum[op]
counts = operator_series_count[op]
for i in range(24):
v = series[i]
if v is None:
continue
sums[i] += v
counts[i] += 1
competitors_series: List[PriceSeries] = []
for op, sums in operator_series_sum.items():
counts = operator_series_count[op]
avg_series: List[Optional[float]] = []
for i in range(24):
c = counts[i]
if c > 0:
avg_series.append(sums[i] / c)
else:
avg_series.append(None)
competitors_series.append(PriceSeries(operator=op, series=avg_series))
diffs: List[float] = []
for i in range(24):
y = ylt_series[i]
if y is None:
continue
competitor_prices: List[float] = []
for s in competitors_series:
v = s.series[i]
if v is not None:
competitor_prices.append(float(v))
if not competitor_prices:
continue
min_comp = min(competitor_prices)
diffs.append(y - min_comp)
min_diff = min(diffs) if diffs else None
max_diff = max(diffs) if diffs else None
avg_diff = sum(diffs) / len(diffs) if diffs else None
ylt_price_series = PriceSeries(operator=base_station.operator, series=ylt_series)
return PriceComparisonResponse(
hours=hours,
ylt=ylt_price_series,
competitors=competitors_series,
min_diff=min_diff,
max_diff=max_diff,
avg_diff=avg_diff,
)
@router.get("/health")
async def health():
return {"status": "ok"}
@router.get("/api/ylt/geo/competitors/{station_hash}", response_model=GeoCompetitionResponse)
async def get_geo_competitors(station_hash: str):
return await build_geo_competition(station_hash)
@router.get("/api/ylt/geo/competitors/{station_hash}/summary", response_model=GeoCompetitionSummary)
async def get_geo_competitors_summary(station_hash: str):
data = await build_geo_competition(station_hash)
base = data.base_station
total_comp = len(data.competitors)
cheaper = data.cheaper_count
same = data.same_count
more_expensive = data.more_expensive_count
ylt_price = data.ylt_price
min_price = data.min_competitor_price
max_price = data.max_competitor_price
summary_input = {
"station_name": base.station_name,
"operator": base.operator,
"ylt_price": ylt_price,
"competitor_count": total_comp,
"cheaper_count": cheaper,
"same_count": same,
"more_expensive_count": more_expensive,
"min_competitor_price": min_price,
"max_competitor_price": max_price,
}
text = (
"请作为驿来特价格策略分析顾问用简明中文解释当前场站在3公里范围内的价格竞争情况"
"给出可操作的价格调整或产品策略建议控制在300字以内。以下是结构化数据\n"
f"{summary_input}"
)
chunks: List[str] = []
async for chunk in get_llm_response(
text,
stream=False,
system_prompt="你是驿来特电价和选址策略顾问。",
):
chunks.append(chunk)
summary_text = "".join(chunks)
return GeoCompetitionSummary(summary=summary_text)
@router.get("/api/ylt/pricing/comparison/{station_hash}", response_model=PriceComparisonResponse)
async def get_price_comparison(station_hash: str):
return await build_price_comparison(station_hash)
@router.get("/api/ylt/pricing/comparison/{station_hash}/summary", response_model=PriceComparisonSummary)
async def get_price_comparison_summary(station_hash: str):
data = await build_price_comparison(station_hash)
ylt_series = data.ylt.series
text_data = {
"hours": data.hours,
"ylt_prices": ylt_series,
"competitors": [
{"operator": s.operator, "series": s.series} for s in data.competitors
],
"min_diff": data.min_diff,
"max_diff": data.max_diff,
"avg_diff": data.avg_diff,
}
text = (
"请作为驿来特价格策略分析顾问,对下列分时电价数据进行比较分析:\n"
"1) 解释驿来特与三家竞品在一天24小时内的价格差距特征\n"
"2) 指出在哪些时间段我们明显偏贵、在哪些时间段有优势;\n"
"3) 给出2到3条可执行的调价或营销策略建议\n"
"控制在400字以内。数据如下\n"
f"{text_data}"
)
chunks: List[str] = []
async for chunk in get_llm_response(
text,
stream=False,
system_prompt="你是驿来特电价策略分析顾问。",
):
chunks.append(chunk)
summary_text = "".join(chunks)
return PriceComparisonSummary(summary=summary_text)
@router.get("/api/ylt/pricing/comparison/{station_hash}/sse")
async def stream_price_comparison_summary(station_hash: str):
data = await build_price_comparison(station_hash)
text_data = {
"hours": data.hours,
"ylt_prices": data.ylt.series,
"competitors": [
{"operator": s.operator, "series": s.series} for s in data.competitors
],
"min_diff": data.min_diff,
"max_diff": data.max_diff,
"avg_diff": data.avg_diff,
}
text = (
"请作为驿来特价格策略分析顾问,对下列分时电价数据进行比较分析:\n"
"1) 解释驿来特与三家竞品在一天24小时内的价格差距特征\n"
"2) 指出在哪些时间段我们明显偏贵、在哪些时间段有优势;\n"
"3) 给出2到3条可执行的调价或营销策略建议\n"
"控制在400字以内。数据如下\n"
f"{text_data}"
)
async def event_generator():
async for chunk in get_llm_response(
text,
stream=True,
system_prompt="你是驿来特电价策略分析顾问。",
):
if chunk is None:
continue
yield f"data: {chunk}\n\n"
yield "event: end\ndata: [DONE]\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")

View File

@@ -0,0 +1,55 @@
from typing import List, Optional
from pydantic import BaseModel
class StationBase(BaseModel):
station_hash: str
operator: str
station_name: str
address: Optional[str]
coord_x: Optional[float]
coord_y: Optional[float]
current_price: Optional[float]
class CompetitorStation(BaseModel):
station_hash: str
operator: str
station_name: str
distance_km: float
current_price: Optional[float]
class GeoCompetitionResponse(BaseModel):
base_station: StationBase
competitors: List[CompetitorStation]
ylt_price: Optional[float]
min_competitor_price: Optional[float]
max_competitor_price: Optional[float]
cheaper_count: int
same_count: int
more_expensive_count: int
class GeoCompetitionSummary(BaseModel):
summary: str
class PriceSeries(BaseModel):
operator: str
series: List[Optional[float]]
class PriceComparisonResponse(BaseModel):
hours: List[int]
ylt: PriceSeries
competitors: List[PriceSeries]
min_diff: Optional[float]
max_diff: Optional[float]
avg_diff: Optional[float]
class PriceComparisonSummary(BaseModel):
summary: str

Binary file not shown.

View File

View File

@@ -1,222 +1,63 @@
import math
import asyncio
from typing import List, Optional
import os
import sys
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
project_root = os.path.dirname(os.path.abspath(__file__))
if project_root not in sys.path:
sys.path.append(project_root)
from DbKit.Db import Db
from Config.Config import DB_URL
from Util.LlmUtil import get_llm_response
def setup_api_logger():
logger = logging.getLogger("YLT_Analytics_Api")
logger.setLevel(logging.INFO)
logger.propagate = False
if logger.handlers:
return logger
log_dir = os.path.join(project_root, "Logs")
os.makedirs(log_dir, exist_ok=True)
log_file = os.path.join(log_dir, "YLT_Analytics_Api.log")
file_handler = logging.FileHandler(log_file, encoding="utf-8")
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
logger.addHandler(console_handler)
return logger
class StationBase(BaseModel):
station_hash: str
operator: str
station_name: str
address: Optional[str]
coord_x: Optional[float]
coord_y: Optional[float]
current_price: Optional[float]
logger = setup_api_logger()
logger.info("YLT_Analytics_Api module imported")
from Controller.YltAnalyticsController import router, init_db, close_db
class CompetitorStation(BaseModel):
station_hash: str
operator: str
station_name: str
distance_km: float
current_price: Optional[float]
class GeoCompetitionResponse(BaseModel):
base_station: StationBase
competitors: List[CompetitorStation]
ylt_price: Optional[float]
min_competitor_price: Optional[float]
max_competitor_price: Optional[float]
cheaper_count: int
same_count: int
more_expensive_count: int
class GeoCompetitionSummary(BaseModel):
summary: str
app = FastAPI(title="YLT Analytics API")
db = Db(db_url=DB_URL)
async def init_db():
await db.init_db()
async def close_db():
await db.close()
@app.on_event("startup")
async def on_startup():
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("YLT Analytics API startup")
await init_db()
@app.on_event("shutdown")
async def on_shutdown():
try:
yield
finally:
logger.info("YLT Analytics API shutdown")
await close_db()
def haversine_km(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
r = 6371.0
phi1 = math.radians(lat1)
phi2 = math.radians(lat2)
d_phi = math.radians(lat2 - lat1)
d_lambda = math.radians(lon2 - lon1)
a = math.sin(d_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
return r * c
app = FastAPI(title="YLT Analytics API", lifespan=lifespan)
static_dir = os.path.join(project_root, "static")
app.mount("/static", StaticFiles(directory=static_dir), name="static")
async def fetch_current_stations() -> List[dict]:
sql = """
SELECT
p.station_hash,
p.operator,
p.station_name,
p.address,
p.coord_x,
p.coord_y,
s.current_price
FROM t_station_profile_scd p
LEFT JOIN t_station_status_scd s
ON p.station_hash = s.station_hash AND s.is_current = 1
WHERE p.is_current = 1
"""
return await db.find(sql, {})
app.include_router(router)
async def build_geo_competition(station_hash: str, radius_km: float = 3.0) -> GeoCompetitionResponse:
rows = await fetch_current_stations()
if not rows:
raise HTTPException(status_code=404, detail="no station data")
base_row = None
for r in rows:
if r.get("station_hash") == station_hash and r.get("operator") == "驿来特":
base_row = r
break
if base_row is None:
raise HTTPException(status_code=404, detail="base station not found for 驿来特")
base_lon = base_row.get("coord_x")
base_lat = base_row.get("coord_y")
if base_lon is None or base_lat is None:
raise HTTPException(status_code=400, detail="base station has no coordinates")
competitors: List[CompetitorStation] = []
ylt_price = base_row.get("current_price")
cheaper = 0
same = 0
more_expensive = 0
min_price = None
max_price = None
for r in rows:
if r.get("operator") == "驿来特":
continue
lon = r.get("coord_x")
lat = r.get("coord_y")
if lon is None or lat is None:
continue
dist = haversine_km(base_lon, base_lat, lon, lat)
if dist > radius_km:
continue
price = r.get("current_price")
competitors.append(
CompetitorStation(
station_hash=r.get("station_hash"),
operator=r.get("operator"),
station_name=r.get("station_name"),
distance_km=round(dist, 3),
current_price=price,
)
)
if price is not None:
if min_price is None or price < min_price:
min_price = price
if max_price is None or price > max_price:
max_price = price
if ylt_price is not None:
if price < ylt_price:
cheaper += 1
elif price > ylt_price:
more_expensive += 1
else:
same += 1
base_station = StationBase(
station_hash=base_row.get("station_hash"),
operator=base_row.get("operator"),
station_name=base_row.get("station_name"),
address=base_row.get("address"),
coord_x=base_lon,
coord_y=base_lat,
current_price=ylt_price,
)
return GeoCompetitionResponse(
base_station=base_station,
competitors=competitors,
ylt_price=ylt_price,
min_competitor_price=min_price,
max_competitor_price=max_price,
cheaper_count=cheaper,
same_count=same,
more_expensive_count=more_expensive,
)
@app.get("/health")
async def health():
return {"status": "ok"}
@app.get("/api/ylt/geo/competitors/{station_hash}", response_model=GeoCompetitionResponse)
async def get_geo_competitors(station_hash: str):
return await build_geo_competition(station_hash)
@app.get("/api/ylt/geo/competitors/{station_hash}/summary", response_model=GeoCompetitionSummary)
async def get_geo_competitors_summary(station_hash: str):
data = await build_geo_competition(station_hash)
base = data.base_station
total_comp = len(data.competitors)
cheaper = data.cheaper_count
same = data.same_count
more_expensive = data.more_expensive_count
ylt_price = data.ylt_price
min_price = data.min_competitor_price
max_price = data.max_competitor_price
summary_input = {
"station_name": base.station_name,
"operator": base.operator,
"ylt_price": ylt_price,
"competitor_count": total_comp,
"cheaper_count": cheaper,
"same_count": same,
"more_expensive_count": more_expensive,
"min_competitor_price": min_price,
"max_competitor_price": max_price,
}
text = (
"请作为驿来特价格策略分析顾问用简明中文解释当前场站在3公里范围内的价格竞争情况"
"给出可操作的价格调整或产品策略建议控制在300字以内。以下是结构化数据\n"
f"{summary_input}"
)
chunks = []
async for chunk in get_llm_response(text, stream=False, system_prompt="你是驿来特电价和选址策略顾问。"):
chunks.append(chunk)
summary_text = "".join(chunks)
return GeoCompetitionSummary(summary=summary_text)
if __name__ == "__main__":
logger.info("Starting YLT Analytics API with uvicorn on http://0.0.0.0:8000")
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

Binary file not shown.

235
static/index.html Normal file
View File

@@ -0,0 +1,235 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>驿来特价格分析大屏</title>
<style>
body{margin:0;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#050816;color:#e5e7eb;}
#app{display:flex;flex-direction:row;height:100vh;}
.left-panel{flex:3;padding:16px;box-sizing:border-box;display:flex;flex-direction:column;gap:12px;}
.right-panel{flex:2;padding:16px;box-sizing:border-box;border-left:1px solid #111827;background:#020617;}
.controls{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:4px;align-items:center;}
.controls input{padding:6px 8px;border-radius:4px;border:1px solid #374151;background:#020617;color:#e5e7eb;}
.controls button{padding:6px 12px;border-radius:4px;border:none;background:#2563eb;color:#f9fafb;cursor:pointer;}
.controls button:disabled{opacity:.5;cursor:not-allowed;}
.controls-spacer{flex:1;}
.station-list{flex:0 0 220px;border:1px solid #111827;border-radius:8px;background:#020617;overflow:hidden;display:flex;flex-direction:column;}
.station-list-header{padding:8px 12px;border-bottom:1px solid #111827;font-size:14px;background:#020617;}
.station-table{width:100%;border-collapse:collapse;font-size:13px;}
.station-table th,.station-table td{padding:6px 8px;border-bottom:1px solid #111827;}
.station-table th{background:#020617;color:#9ca3af;text-align:left;}
.station-row{cursor:pointer;}
.station-row:hover{background:#111827;}
.station-row.active{background:#1d283a;}
#chart{flex:1;border:1px solid #111827;border-radius:8px;background:#0b1120;}
.price-table{flex:0 0 260px;border:1px solid #111827;border-radius:8px;background:#020617;padding:8px;box-sizing:border-box;overflow:auto;}
.price-table-title{font-size:14px;margin-bottom:4px;}
.price-table-inner{width:100%;border-collapse:collapse;font-size:12px;}
.price-table-inner th,.price-table-inner td{padding:4px 6px;border-bottom:1px solid #111827;}
.price-table-inner th{background:#020617;color:#9ca3af;text-align:right;}
.price-table-inner th:first-child,.price-table-inner td:first-child{text-align:left;}
.ai-title{font-size:16px;margin-bottom:8px;}
.ai-box{height:calc(100% - 40px);border-radius:8px;border:1px solid #111827;background:#020617;padding:12px;box-sizing:border-box;white-space:pre-wrap;overflow:auto;font-size:14px;line-height:1.6;}
.label{font-size:14px;margin-right:8px;}
</style>
</head>
<body>
<div id="app">
<div class="left-panel">
<div class="controls">
<span class="label">API</span>
<input v-model="apiBase" style="min-width:220px;">
<span class="label">搜索驿来特场站</span>
<input v-model="searchKeyword" placeholder="输入名称或地址关键字" style="min-width:220px;">
<button @click="loadStations">查询场站</button>
<div class="controls-spacer"></div>
<span class="label">当前场站:{{ selectedStation ? selectedStation.station_name : "未选择" }}</span>
<button @click="startAiStream" :disabled="!selectedStation || aiLoading">AI价差分析</button>
</div>
<div class="station-list">
<div class="station-list-header">驿来特场站列表</div>
<table class="station-table">
<thead>
<tr>
<th style="width:32%;">场站名称</th>
<th style="width:48%;">地址</th>
<th style="width:10%;">当前价</th>
<th style="width:10%;">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="s in stations" :key="s.station_hash" class="station-row" :class="{active:selectedStation && selectedStation.station_hash===s.station_hash}" @click="selectStation(s)">
<td>{{ s.station_name }}</td>
<td>{{ s.address }}</td>
<td>{{ formatCell(s.current_price) }}</td>
<td><button @click.stop="selectStation(s)" :disabled="loading">查看价差</button></td>
</tr>
<tr v-if="stations.length===0">
<td colspan="4">暂无数据,请先查询场站</td>
</tr>
</tbody>
</table>
</div>
<div id="chart"></div>
<div class="price-table" v-if="priceTableColumns.length">
<div class="price-table-title">24 小时分时电价表(元/度)</div>
<table class="price-table-inner">
<thead>
<tr>
<th v-for="col in priceTableColumns" :key="col">{{ col }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row,idx) in priceTableRows" :key="idx">
<td v-for="(cell,j) in row" :key="j">{{ formatCell(cell) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="right-panel">
<div class="ai-title">AI 辅助分析</div>
<div class="ai-box">{{ aiText || placeholder }}</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<script>
const {createApp,onMounted,ref} = Vue
createApp({
setup(){
const apiBase = ref(window.location.origin || "http://localhost:8000")
const stationHash = ref("")
const aiText = ref("")
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([])
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 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])
})
rows.push(row)
}
priceTableRows.value = rows
}
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
})
data.competitors.forEach(c=>{
series.push({
name:c.operator,
type:"line",
smooth:true,
data:c.series
})
})
buildPriceTable(data)
const option = {
tooltip:{trigger:"axis"},
legend:{data:series.map(s=>s.name),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)
}finally{
loading.value = false
}
}
const selectStation = s => {
selectedStation.value = s
stationHash.value = s.station_hash
loadComparison()
}
const startAiStream = () => {
if (!selectedStation.value || !stationHash.value) return
if (es) {
es.close()
es = null
}
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 = () => {
aiLoading.value = false
if (es) {
es.close()
es = null
}
}
}
onMounted(()=>{
initChart()
loadStations()
})
const formatCell = v => {
if (v === null || v === undefined || v === "") return "-"
if (typeof v === "number") {
if (Number.isNaN(v)) return "-"
return v.toFixed(2)
}
return v
}
return {apiBase,stationHash,aiText,placeholder,loading,aiLoading,stations,searchKeyword,selectedStation,priceTableColumns,priceTableRows,loadStations,loadComparison,selectStation,startAiStream,formatCell}
}
}).mount("#app")
</script>
</body>
</html>