'commit'
This commit is contained in:
453
Controller/YltAnalyticsController.py
Normal file
453
Controller/YltAnalyticsController.py
Normal 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")
|
||||||
BIN
Controller/__pycache__/YltAnalyticsController.cpython-310.pyc
Normal file
BIN
Controller/__pycache__/YltAnalyticsController.cpython-310.pyc
Normal file
Binary file not shown.
55
Model/YltAnalyticsModel.py
Normal file
55
Model/YltAnalyticsModel.py
Normal 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
|
||||||
|
|
||||||
BIN
Model/__pycache__/YltAnalyticsModel.cpython-310.pyc
Normal file
BIN
Model/__pycache__/YltAnalyticsModel.cpython-310.pyc
Normal file
Binary file not shown.
@@ -1,222 +1,63 @@
|
|||||||
import math
|
|
||||||
import asyncio
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from fastapi import FastAPI
|
||||||
from pydantic import BaseModel
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
project_root = os.path.dirname(os.path.abspath(__file__))
|
project_root = os.path.dirname(os.path.abspath(__file__))
|
||||||
if project_root not in sys.path:
|
if project_root not in sys.path:
|
||||||
sys.path.append(project_root)
|
sys.path.append(project_root)
|
||||||
|
|
||||||
from DbKit.Db import Db
|
|
||||||
from Config.Config import DB_URL
|
def setup_api_logger():
|
||||||
from Util.LlmUtil import get_llm_response
|
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):
|
logger = setup_api_logger()
|
||||||
station_hash: str
|
logger.info("YLT_Analytics_Api module imported")
|
||||||
operator: str
|
|
||||||
station_name: str
|
from Controller.YltAnalyticsController import router, init_db, close_db
|
||||||
address: Optional[str]
|
|
||||||
coord_x: Optional[float]
|
|
||||||
coord_y: Optional[float]
|
|
||||||
current_price: Optional[float]
|
|
||||||
|
|
||||||
|
|
||||||
class CompetitorStation(BaseModel):
|
@asynccontextmanager
|
||||||
station_hash: str
|
async def lifespan(app: FastAPI):
|
||||||
operator: str
|
logger.info("YLT Analytics API startup")
|
||||||
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():
|
|
||||||
await init_db()
|
await init_db()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
logger.info("YLT Analytics API shutdown")
|
||||||
|
await close_db()
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
app = FastAPI(title="YLT Analytics API", lifespan=lifespan)
|
||||||
async def on_shutdown():
|
|
||||||
await close_db()
|
static_dir = os.path.join(project_root, "static")
|
||||||
|
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||||
|
|
||||||
|
|
||||||
def haversine_km(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
|
app.include_router(router)
|
||||||
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]:
|
if __name__ == "__main__":
|
||||||
sql = """
|
logger.info("Starting YLT Analytics API with uvicorn on http://0.0.0.0:8000")
|
||||||
SELECT
|
import uvicorn
|
||||||
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 = 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)
|
|
||||||
|
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||||
|
|||||||
BIN
__pycache__/YLT_Analytics_Api.cpython-310.pyc
Normal file
BIN
__pycache__/YLT_Analytics_Api.cpython-310.pyc
Normal file
Binary file not shown.
235
static/index.html
Normal file
235
static/index.html
Normal 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>
|
||||||
Reference in New Issue
Block a user