This commit is contained in:
HuangHai
2026-01-21 08:41:47 +08:00
parent 306a3a4127
commit e51dc18d06
40 changed files with 1309 additions and 965 deletions

View File

@@ -10,77 +10,32 @@ from typing import List, Optional
from fastapi import APIRouter, HTTPException, BackgroundTasks from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
import pymysql
# Import custom modules # Import custom modules
from Config.Config import OBS_CLOUD_PREFIX, OBS_BUCKET, OBS_TMP_PREFIX, DORIS_HOST, DORIS_PORT, DORIS_USER, DORIS_PWD, DORIS_DATABASE, OBS_SERVER from Config.Config import OBS_CLOUD_PREFIX, OBS_BUCKET, OBS_TMP_PREFIX, OBS_SERVER
from Util.DouYinDownloader import DouYinDownloader from Util.DouYinDownloader import DouYinDownloader
from Util.ObsUtil import ObsUploader from Util.ObsUtil import ObsUploader
from Util.ASRClient import ASRClient from Util.ASRClient import ASRClient
from Util.LlmUtil import get_llm_response from Util.LlmUtil import get_llm_response
from Model.DouYinModel import DouYinModel
# Logger setup # Logger setup
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# Database connection
def get_db_connection():
return pymysql.connect(
host=DORIS_HOST,
port=DORIS_PORT,
user=DORIS_USER,
password=DORIS_PWD,
database=DORIS_DATABASE,
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
class ParseRequest(BaseModel): class ParseRequest(BaseModel):
text: str text: str
class SummaryRequest(BaseModel): class SummaryRequest(BaseModel):
ids: List[str] = [] ids: List[str] = []
def update_status(id, status, error_msg=None):
try:
conn = get_db_connection()
cursor = conn.cursor()
if error_msg:
sql = "UPDATE t_douyin_record SET status=%s, error_msg=%s WHERE id=%s"
cursor.execute(sql, (status, error_msg, id))
else:
sql = "UPDATE t_douyin_record SET status=%s WHERE id=%s"
cursor.execute(sql, (status, id))
conn.commit()
conn.close()
except Exception as e:
logger.error(f"DB Error update_status: {e}")
def update_record(id, title, obs_url, transcript, status):
try:
# Truncate title to 100 chars to fit DB schema (approx 400 bytes max for utf8mb4)
if title and len(title) > 100:
title = title[:100] + "..."
conn = get_db_connection()
cursor = conn.cursor()
sql = """
UPDATE t_douyin_record
SET video_name=%s, obs_url=%s, transcript=%s, status=%s
WHERE id=%s
"""
cursor.execute(sql, (title, obs_url, transcript, status, id))
conn.commit()
conn.close()
except Exception as e:
logger.error(f"DB Error update_record: {e}")
async def process_video_task(url: str, request_id: str, share_text: str = ""): async def process_video_task(url: str, request_id: str, share_text: str = ""):
logger.info(f"Processing task {request_id}") logger.info(f"Processing task {request_id}")
model = DouYinModel()
# 1. Update status # 1. Update status
await asyncio.to_thread(update_status, request_id, "PROCESSING") await model.update_status(request_id, "PROCESSING")
temp_dir = os.path.abspath(f"temp_{request_id}") temp_dir = os.path.abspath(f"temp_{request_id}")
try: try:
@@ -177,12 +132,12 @@ async def process_video_task(url: str, request_id: str, share_text: str = ""):
# 7. Save to DB (Update) # 7. Save to DB (Update)
logger.info("Saving to DB...") logger.info("Saving to DB...")
await asyncio.to_thread(update_record, request_id, title, obs_url, transcript, "COMPLETED") await model.update_record(request_id, title, obs_url, transcript, "COMPLETED")
logger.info(f"Task {request_id} completed successfully.") logger.info(f"Task {request_id} completed successfully.")
except Exception as e: except Exception as e:
logger.error(f"Task {request_id} failed: {e}", exc_info=True) logger.error(f"Task {request_id} failed: {e}", exc_info=True)
await asyncio.to_thread(update_status, request_id, "FAILED", str(e)) await model.update_status(request_id, "FAILED", str(e))
finally: finally:
# 8. Cleanup # 8. Cleanup
if os.path.exists(temp_dir): if os.path.exists(temp_dir):
@@ -193,7 +148,7 @@ async def process_video_task(url: str, request_id: str, share_text: str = ""):
logger.error(f"Cleanup failed: {e}") logger.error(f"Cleanup failed: {e}")
@router.post("/api/parse") @router.post("/api/parse")
def parse(request: ParseRequest, background_tasks: BackgroundTasks): async def parse(request: ParseRequest, background_tasks: BackgroundTasks):
downloader = DouYinDownloader() downloader = DouYinDownloader()
urls = downloader.extract_urls(request.text) urls = downloader.extract_urls(request.text)
@@ -204,60 +159,38 @@ def parse(request: ParseRequest, background_tasks: BackgroundTasks):
if request.text.startswith("http"): if request.text.startswith("http"):
urls = [request.text] urls = [request.text]
else: else:
raise HTTPException(status_code=400, detail="No valid URLs found") raise HTTPException(status_code=400, detail="No valid URLs found")
created_ids = [] created_ids = []
try: try:
conn = get_db_connection() model = DouYinModel()
cursor = conn.cursor()
for url in urls: for url in urls:
req_id = str(uuid.uuid4()) req_id = str(uuid.uuid4())
sql = """ await model.insert_record(req_id, url)
INSERT INTO t_douyin_record (id, original_text, status, create_time)
VALUES (%s, %s, 'PENDING', %s)
"""
cursor.execute(sql, (req_id, url, datetime.now()))
created_ids.append(req_id) created_ids.append(req_id)
# Pass request.text (the full share text) so we can extract title from it # Pass request.text (the full share text) so we can extract title from it
background_tasks.add_task(process_video_task, url, req_id, request.text) background_tasks.add_task(process_video_task, url, req_id, request.text)
conn.commit()
conn.close()
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"DB Init Error: {e}") raise HTTPException(status_code=500, detail=f"DB Init Error: {e}")
return {"id": created_ids[0] if created_ids else None, "ids": created_ids, "status": "PENDING"} return {"id": created_ids[0] if created_ids else None, "ids": created_ids, "status": "PENDING"}
@router.get("/api/records") @router.get("/api/records")
def get_records(): async def get_records():
try: try:
conn = get_db_connection() model = DouYinModel()
cursor = conn.cursor() records = await model.get_records()
cursor.execute("SELECT * FROM t_douyin_record ORDER BY create_time DESC LIMIT 50")
records = cursor.fetchall()
conn.close()
# Manually handle datetime serialization to be safe
for r in records:
if 'create_time' in r and r['create_time']:
r['create_time'] = r['create_time'].strftime("%Y-%m-%d %H:%M:%S")
if 'update_time' in r and r['update_time']:
r['update_time'] = r['update_time'].strftime("%Y-%m-%d %H:%M:%S")
return records return records
except Exception as e: except Exception as e:
logger.error(f"Get records error: {e}", exc_info=True) logger.error(f"Get records error: {e}", exc_info=True)
return [] return []
@router.delete("/api/records/{id}") @router.delete("/api/records/{id}")
def delete_record(id: str): async def delete_record(id: str):
try: try:
conn = get_db_connection() model = DouYinModel()
cursor = conn.cursor() await model.delete_record(id)
cursor.execute("DELETE FROM t_douyin_record WHERE id=%s", (id,))
conn.commit()
conn.close()
return {"status": "deleted"} return {"status": "deleted"}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@@ -266,20 +199,8 @@ def delete_record(id: str):
async def generate_summary(request: SummaryRequest): async def generate_summary(request: SummaryRequest):
try: try:
# Fetch transcripts # Fetch transcripts
conn = get_db_connection() model = DouYinModel()
cursor = conn.cursor() records = await model.get_transcripts(ids=request.ids)
if request.ids:
# Secure way to handle list in SQL
format_strings = ','.join(['%s'] * len(request.ids))
sql = f"SELECT video_name, transcript FROM t_douyin_record WHERE id IN ({format_strings}) AND status='COMPLETED'"
cursor.execute(sql, tuple(request.ids))
else:
# Default to latest 20
cursor.execute("SELECT video_name, transcript FROM t_douyin_record WHERE status='COMPLETED' ORDER BY create_time DESC LIMIT 20")
records = cursor.fetchall()
conn.close()
if not records: if not records:
# If no records, just return a simple message stream # If no records, just return a simple message stream
@@ -327,23 +248,8 @@ async def recover_pending_tasks():
""" """
logger.info("Scanning for interrupted Douyin tasks...") logger.info("Scanning for interrupted Douyin tasks...")
try: try:
# Use asyncio.to_thread for DB operation model = DouYinModel()
def fetch_pending(): tasks = await model.get_interrupted_tasks()
conn = get_db_connection()
cursor = conn.cursor()
# Select recent pending/processing tasks (limit 20 to avoid storm)
sql = """
SELECT id, original_text, status
FROM t_douyin_record
WHERE status IN ('PENDING', 'PROCESSING')
ORDER BY create_time DESC LIMIT 20
"""
cursor.execute(sql)
tasks = cursor.fetchall()
conn.close()
return tasks
tasks = await asyncio.to_thread(fetch_pending)
if not tasks: if not tasks:
logger.info("No interrupted tasks found.") logger.info("No interrupted tasks found.")

View File

@@ -9,7 +9,7 @@ from sqlalchemy.sql import text
from Util.BananaClient import BananaClient from Util.BananaClient import BananaClient
from Util.LlmUtil import get_llm_response from Util.LlmUtil import get_llm_response
from DbKit.Db import Db from Model.HaiBaoModel import HaiBaoModel
router = APIRouter(prefix="/haibao") router = APIRouter(prefix="/haibao")
logger = logging.getLogger("HaiBaoController") logger = logging.getLogger("HaiBaoController")
@@ -21,43 +21,8 @@ class GenerateRequest(BaseModel):
@router.on_event("startup") @router.on_event("startup")
async def startup_event(): async def startup_event():
"""初始化时检查并创建表""" """初始化时逻辑"""
db = Db() logger.info("海报控制器启动成功")
await db.init_db()
# Doris 建表语句
create_table_sql = """
CREATE TABLE IF NOT EXISTS haibao_history (
id VARCHAR(50) COMMENT "ID",
prompt TEXT COMMENT "提示词",
image_url VARCHAR(500) COMMENT "图片URL",
scheme_content TEXT COMMENT "文案方案",
created_at DATETIME COMMENT "创建时间"
)
DUPLICATE KEY(id)
DISTRIBUTED BY HASH(id) BUCKETS 1
PROPERTIES (
"replication_num" = "1"
);
"""
try:
# 使用 engine 直接执行 DDL
async with db.engine.begin() as conn:
await conn.execute(text(create_table_sql))
# 尝试添加列(如果表已存在但列不存在)
# Doris 不支持 IF NOT EXISTS for ADD COLUMN directly nicely in all versions without error if exists
# 所以这里简单捕获异常,如果列已存在则忽略
try:
alter_sql = "ALTER TABLE haibao_history ADD COLUMN scheme_content TEXT COMMENT '文案方案'"
await conn.execute(text(alter_sql))
except Exception as e:
# 忽略列已存在的错误
pass
logger.info("海报历史表检查/更新成功")
except Exception as e:
logger.error(f"海报历史表创建/更新失败: {e}")
class RefineRequest(BaseModel): class RefineRequest(BaseModel):
prompt: str prompt: str
@@ -184,26 +149,11 @@ async def generate_poster(req: GenerateRequest):
image_url, scheme_content = await asyncio.gather(generate_image_task(), generate_text_task()) image_url, scheme_content = await asyncio.gather(generate_image_task(), generate_text_task())
# 4. 保存到数据库 # 4. 保存到数据库
db = Db() model = HaiBaoModel()
record_id = str(uuid.uuid4()) record_id = str(uuid.uuid4())
created_at = datetime.now() created_at = datetime.now()
insert_sql = """ await model.insert_record(record_id, req.prompt, image_url, scheme_content, created_at)
INSERT INTO haibao_history (id, prompt, image_url, scheme_content, created_at)
VALUES (:id, :prompt, :image_url, :scheme_content, :created_at)
"""
params = {
"id": record_id,
"prompt": req.prompt,
"image_url": image_url,
"scheme_content": scheme_content,
"created_at": created_at
}
async with await db.get_session() as session:
async with session.begin():
await session.execute(text(insert_sql), params)
return { return {
"id": record_id, "id": record_id,
@@ -220,10 +170,9 @@ async def generate_poster(req: GenerateRequest):
@router.get("/history") @router.get("/history")
async def get_history(): async def get_history():
"""获取海报生成历史""" """获取海报生成历史"""
db = Db()
sql = "SELECT * FROM haibao_history ORDER BY created_at DESC LIMIT 50"
try: try:
result = await db.find(sql) model = HaiBaoModel()
result = await model.get_history(50)
formatted_result = [] formatted_result = []
for item in result: for item in result:

View File

@@ -6,7 +6,6 @@ from typing import List, Optional, Dict, Any
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse, FileResponse, JSONResponse from fastapi.responses import StreamingResponse, FileResponse, JSONResponse
from DbKit.Db import Db
from Config.Config import DB_URL from Config.Config import DB_URL
from Util.LlmUtil import get_llm_response from Util.LlmUtil import get_llm_response
from Tools.T6_Export import export_excel, DorisExcelExporter, extract_hourly_prices_from_schedule from Tools.T6_Export import export_excel, DorisExcelExporter, extract_hourly_prices_from_schedule
@@ -24,31 +23,35 @@ from Model.YltAnalyticsModel import (
PriceSeries, PriceSeries,
PriceComparisonResponse, PriceComparisonResponse,
PriceComparisonSummary, PriceComparisonSummary,
YltAnalyticsModel,
) )
from DbKit.Db import Db
router = APIRouter() router = APIRouter()
db = Db(db_url=DB_URL) # db = Db(db_url=DB_URL) # Removed direct db instance
async def init_db(): async def init_db():
db = Db()
await db.init_db() await db.init_db()
async def close_db(): async def close_db():
db = Db()
await db.close() await db.close()
@router.get("/api/operators/hourly-prices") @router.get("/api/operators/hourly-prices")
async def get_operators_hourly_prices(): async def get_operators_hourly_prices():
operators = ["新电途", "特来电", "驿来特", "艾特吉易充"] operators = ["新电途", "特来电", "驿来特", "艾特吉易充"]
exporter = DorisExcelExporter(db_url=DB_URL) model = YltAnalyticsModel()
await exporter.init()
try: try:
result = [] result = []
for op in operators: for op in operators:
rows = await exporter.fetch_current_station_rows(op) rows = await model.fetch_current_station_rows(op)
if not rows: if not rows:
result.append({"operator": op, "series": [None] * 24}) result.append({"operator": op, "series": [None] * 24})
continue continue
@@ -71,11 +74,72 @@ async def get_operators_hourly_prices():
else: else:
avg_series.append(None) avg_series.append(None)
result.append({"operator": op, "series": avg_series}) result.append({"operator": op, "series": avg_series})
finally: except Exception as e:
await exporter.close() raise HTTPException(status_code=500, detail=str(e))
return {"operators": result} return {"operators": result}
@router.get("/api/operators/price-trends")
async def get_operators_price_trends(days: int = 7):
operators = ["新电途", "特来电", "驿来特", "艾特吉易充"]
model = YltAnalyticsModel()
rows = await model.get_operators_price_trends(days)
# 数据结构: { operator: { date_str: [sums_of_24h, counts_of_24h] } }
trend_data = {}
for op in operators:
trend_data[op] = {}
for row in rows:
op = row.get("operator")
if op not in trend_data:
continue
d_str = str(row.get("date_str"))
schedule_json = row.get("schedule_json")
if d_str not in trend_data[op]:
trend_data[op][d_str] = {"sums": [0.0] * 24, "counts": [0] * 24}
series = extract_hourly_prices_from_schedule(schedule_json)
for i in range(24):
v = series[i]
if v is not None:
trend_data[op][d_str]["sums"][i] += float(v)
trend_data[op][d_str]["counts"][i] += 1
# 转换为 ECharts 友好格式
# 1. 获取所有日期并排序
all_dates = sorted(list(set(str(row.get("date_str")) for row in rows)))
# 2. 为每个运营商计算每天的平均价格(24小时的平均值)
series_result = []
for op in operators:
op_trend = []
for d in all_dates:
if d in trend_data[op]:
day_stats = trend_data[op][d]
day_avg_sum = 0.0
day_hour_count = 0
for i in range(24):
if day_stats["counts"][i] > 0:
day_avg_sum += (day_stats["sums"][i] / day_stats["counts"][i])
day_hour_count += 1
if day_hour_count > 0:
op_trend.append(round(day_avg_sum / day_hour_count, 4))
else:
op_trend.append(None)
else:
op_trend.append(None)
series_result.append({"name": op, "data": op_trend})
return {
"dates": all_dates,
"series": series_result
}
@router.get("/api/export/prices-zip") @router.get("/api/export/prices-zip")
async def export_prices_zip(): async def export_prices_zip():
operators = ["新电途", "特来电", "驿来特", "艾特吉易充"] operators = ["新电途", "特来电", "驿来特", "艾特吉易充"]
@@ -177,26 +241,8 @@ async def ai_pricing_strategy_summary():
@router.get("/api/ylt/stations", response_model=List[StationBase]) @router.get("/api/ylt/stations", response_model=List[StationBase])
async def list_ylt_stations(q: Optional[str] = None): async def list_ylt_stations(q: Optional[str] = None):
base_sql = """ model = YltAnalyticsModel()
SELECT rows = await model.list_ylt_stations(q)
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.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] = [] result: List[StationBase] = []
for r in rows: for r in rows:
result.append( result.append(
@@ -225,21 +271,8 @@ def haversine_km(lon1: float, lat1: float, lon2: float, lat2: float) -> float:
async def fetch_current_stations() -> List[dict]: async def fetch_current_stations() -> List[dict]:
sql = """ model = YltAnalyticsModel()
SELECT return await model.fetch_current_stations()
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: async def build_geo_competition(station_hash: str, radius_km: float = 3.0) -> GeoCompetitionResponse:
@@ -318,17 +351,8 @@ async def build_geo_competition(station_hash: str, radius_km: float = 3.0) -> Ge
async def fetch_station_schedule_json(station_hash: str) -> Optional[str]: async def fetch_station_schedule_json(station_hash: str) -> Optional[str]:
sql = """ model = YltAnalyticsModel()
SELECT schedule_json value = await model.fetch_station_schedule_json(station_hash)
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: if value is None:
return None return None
if isinstance(value, str): if isinstance(value, str):

View File

@@ -444,7 +444,15 @@ class Db:
pass pass
# 检查是否为SQL模板名称并处理 # 检查是否为SQL模板名称并处理
if self._is_sql_template(sql): # 即使不是模板名称,如果包含点号,我们也尝试在加载后检查它是否真的是模板
is_template = self._is_sql_template(sql)
if not is_template and '.' in sql:
# 如果包含点号但没在映射中找到,可能是因为缓存加载问题,尝试重新加载映射
if hasattr(self.sql_loader, '_build_template_map'):
self.sql_loader._build_template_map()
is_template = self._is_sql_template(sql)
if is_template:
try: try:
logger.debug(f"确认为SQL模板: {sql}") logger.debug(f"确认为SQL模板: {sql}")
@@ -620,7 +628,14 @@ class Db:
logger.debug(f"处理后的页码: {page_number}, 每页大小: {page_size}") logger.debug(f"处理后的页码: {page_number}, 每页大小: {page_size}")
# 使用_is_sql_template方法检查并获取SQL模板 # 使用_is_sql_template方法检查并获取SQL模板
if self._is_sql_template(sql): is_template = self._is_sql_template(sql)
if not is_template and '.' in sql:
# 如果包含点号但没在映射中找到,可能是因为缓存加载问题,尝试重新加载映射
if hasattr(self.sql_loader, '_build_template_map'):
self.sql_loader._build_template_map()
is_template = self._is_sql_template(sql)
if is_template:
try: try:
logger.debug(f"确认为SQL模板: {sql}") logger.debug(f"确认为SQL模板: {sql}")
@@ -840,6 +855,27 @@ class Db:
# 如果所有策略都失败,返回默认查询 # 如果所有策略都失败,返回默认查询
return "SELECT COUNT(*)" return "SELECT COUNT(*)"
async def check_column_exists(self, table_name, column_name):
"""检查表中是否存在指定列
Args:
table_name: 表名
column_name: 列名
Returns:
bool: 如果列存在返回True,否则返回False
"""
try:
# 兼容 Doris 和 MySQL 的语法
sql = f"SHOW COLUMNS FROM {table_name} LIKE :column_name"
params = {"column_name": column_name}
# 使用 find 方法执行查询
result = await self.find(sql, params)
return len(result) > 0
except Exception as e:
logger.debug(f"检查列是否存在时出错 (可能表不存在): {str(e)}")
return False
@db_retry() @db_retry()
async def execute_update(self, sql, params=None, session=None): async def execute_update(self, sql, params=None, session=None):
"""执行SQL更新操作(插入、更新、删除)(异步版本) """执行SQL更新操作(插入、更新、删除)(异步版本)

57
DbKit/Sql/DouYin.sql Normal file
View File

@@ -0,0 +1,57 @@
#namespace("DouYin")
#sql("updateStatus")
UPDATE t_douyin_record
SET status = #para(status)
#if(error_msg)
, error_msg = #para(error_msg)
#end
WHERE id = #para(id)
#end
#sql("updateRecord")
UPDATE t_douyin_record
SET video_name = #para(title),
obs_url = #para(obs_url),
transcript = #para(transcript),
status = #para(status)
WHERE id = #para(id)
#end
#sql("insertRecord")
INSERT INTO t_douyin_record (id, original_text, status, create_time)
VALUES (#para(id), #para(url), 'PENDING', #para(create_time))
#end
#sql("getRecords")
SELECT * FROM t_douyin_record
ORDER BY create_time DESC
LIMIT #para(limit)
#end
#sql("deleteRecord")
DELETE FROM t_douyin_record WHERE id = #para(id)
#end
#sql("getTranscriptsByIds")
SELECT video_name, transcript
FROM t_douyin_record
WHERE id IN (#para(ids))
AND status = 'COMPLETED'
#end
#sql("getLatestTranscripts")
SELECT video_name, transcript
FROM t_douyin_record
WHERE status = 'COMPLETED'
ORDER BY create_time DESC
LIMIT #para(limit)
#end
#sql("getInterruptedTasks")
SELECT id, original_text, status
FROM t_douyin_record
WHERE status IN ('PENDING', 'PROCESSING')
ORDER BY create_time DESC
LIMIT #para(limit)
#end
#end

12
DbKit/Sql/HaiBao.sql Normal file
View File

@@ -0,0 +1,12 @@
#namespace("HaiBao")
#sql("insertHistory")
INSERT INTO haibao_history (id, prompt, image_url, scheme_content, created_at)
VALUES (#para(id), #para(prompt), #para(image_url), #para(scheme_content), #para(created_at))
#end
#sql("getHistory")
SELECT * FROM haibao_history
ORDER BY created_at DESC
LIMIT #para(limit)
#end
#end

View File

@@ -0,0 +1,79 @@
#namespace("YltAnalytics")
#sql("getOperatorsPriceTrends")
SELECT
p.operator,
DATE(sc.valid_start_time) as date_str,
sc.schedule_json
FROM t_station_price_schedule_scd sc
JOIN t_station_profile_scd p ON sc.station_hash = p.station_hash AND p.is_current = 1
WHERE sc.valid_start_time >= DATE_SUB(CURDATE(), INTERVAL #para(days) DAY)
ORDER BY date_str ASC
#end
#sql("listYltStations")
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.operator = '驿来特'
#if(q)
AND (p.station_name LIKE #para(kw) OR p.address LIKE #para(kw))
#end
ORDER BY p.station_name
#end
#sql("fetchCurrentStations")
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
#end
#sql("fetchStationScheduleJson")
SELECT sc.schedule_json, p.operator
FROM t_station_price_schedule_scd sc
JOIN t_station_profile_scd p ON sc.station_hash = p.station_hash AND p.is_current = 1
WHERE sc.station_hash = #para(h) AND sc.is_current = 1
ORDER BY sc.valid_start_time DESC
LIMIT 1
#end
#sql("fetchCurrentStationRows")
SELECT
p.station_hash,
p.station_name,
p.address,
p.coord_x,
p.coord_y,
s.total_piles AS total_guns,
s.free_piles AS free_guns,
s.current_price,
s.pro_price,
s.parking_info,
s.distance,
s.valid_start_time AS status_update_time,
sc.schedule_json,
sc.valid_start_time AS schedule_update_time,
s.piles_detail_json
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
LEFT JOIN t_station_price_schedule_scd sc ON p.station_hash = sc.station_hash AND sc.is_current = 1
WHERE p.operator = #para(op) AND p.is_current = 1
ORDER BY p.station_name ASC
#end
#end

View File

@@ -490,6 +490,10 @@ class SqlTemplateLoader:
if template_map_str: if template_map_str:
self._template_map = json.loads(template_map_str) self._template_map = json.loads(template_map_str)
# 无论从Redis加载了什么,都尝试重新构建一次映射以确保一致性
if self.templates and not self._template_map:
self._build_template_map()
except Exception as e: except Exception as e:
logger.error(f"从Redis加载SQL模板数据失败: {str(e)}") logger.error(f"从Redis加载SQL模板数据失败: {str(e)}")
# 出错时不抛出异常,使用内存中的默认值 # 出错时不抛出异常,使用内存中的默认值

Binary file not shown.

79
Model/DouYinModel.py Normal file
View File

@@ -0,0 +1,79 @@
from DbKit.Db import Db
from datetime import datetime
class DouYinModel:
def __init__(self, db: Db = None):
self.db = db or Db()
async def init(self):
await self.db.init_db()
async def update_status(self, id, status, error_msg=None):
await self.db.init_db()
params = {
"id": id,
"status": status,
"error_msg": error_msg
}
return await self.db.execute_update("DouYin.updateStatus", params)
async def update_record(self, id, title, obs_url, transcript, status):
await self.db.init_db()
# Truncate title to 100 chars to fit DB schema
if title and len(title) > 100:
title = title[:100] + "..."
params = {
"id": id,
"title": title,
"obs_url": obs_url,
"transcript": transcript,
"status": status
}
return await self.db.execute_update("DouYin.updateRecord", params)
async def insert_record(self, id, url, create_time=None):
await self.db.init_db()
if create_time is None:
create_time = datetime.now()
params = {
"id": id,
"url": url,
"create_time": create_time
}
return await self.db.execute_update("DouYin.insertRecord", params)
async def get_records(self, limit=50):
await self.db.init_db()
params = {"limit": limit}
records = await self.db.find("DouYin.getRecords", params)
# Manually handle datetime serialization
for r in records:
if 'create_time' in r and r['create_time']:
r['create_time'] = r['create_time'].strftime("%Y-%m-%d %H:%M:%S")
if 'update_time' in r and r['update_time']:
r['update_time'] = r['update_time'].strftime("%Y-%m-%d %H:%M:%S")
return records
async def delete_record(self, id):
await self.db.init_db()
params = {"id": id}
return await self.db.execute_update("DouYin.deleteRecord", params)
async def get_transcripts(self, ids=None, limit=20):
await self.db.init_db()
if ids:
params = {"ids": ids}
return await self.db.find("DouYin.getTranscriptsByIds", params)
else:
params = {"limit": limit}
return await self.db.find("DouYin.getLatestTranscripts", params)
async def get_interrupted_tasks(self, limit=20):
await self.db.init_db()
params = {"limit": limit}
return await self.db.find("DouYin.getInterruptedTasks", params)
async def close(self):
await self.db.shutdown()

24
Model/HaiBaoModel.py Normal file
View File

@@ -0,0 +1,24 @@
import logging
from DbKit.Db import Db
logger = logging.getLogger("HaiBaoModel")
class HaiBaoModel:
def __init__(self):
self.db = Db()
async def insert_record(self, id, prompt, image_url, scheme_content, created_at):
"""插入生成记录"""
params = {
"id": id,
"prompt": prompt,
"image_url": image_url,
"scheme_content": scheme_content,
"created_at": created_at
}
return await self.db.execute_update("HaiBao.insertHistory", params)
async def get_history(self, limit=50):
"""获取历史记录"""
params = {"limit": limit}
return await self.db.find("HaiBao.getHistory", params)

View File

@@ -53,3 +53,32 @@ class PriceComparisonResponse(BaseModel):
class PriceComparisonSummary(BaseModel): class PriceComparisonSummary(BaseModel):
summary: str summary: str
from DbKit.Db import Db
class YltAnalyticsModel:
def __init__(self):
self.db = Db()
async def get_operators_price_trends(self, days: int):
return await self.db.find("YltAnalytics.getOperatorsPriceTrends", {"days": days})
async def list_ylt_stations(self, q: str = None):
params = {}
if q:
params["q"] = True
params["kw"] = f"%{q}%"
return await self.db.find("YltAnalytics.listYltStations", params)
async def fetch_current_stations(self):
return await self.db.find("YltAnalytics.fetchCurrentStations")
async def fetch_station_schedule_json(self, station_hash: str):
rows = await self.db.find("YltAnalytics.fetchStationScheduleJson", {"h": station_hash})
if not rows:
return None
return rows[0].get("schedule_json")
async def fetch_current_station_rows(self, operator: str):
return await self.db.find("YltAnalytics.fetchCurrentStationRows", {"op": operator})

Binary file not shown.

Binary file not shown.

View File

@@ -59,6 +59,8 @@ async def lifespan(app: FastAPI):
finally: finally:
logger.info("驿来特AI智能分析系统关闭...") logger.info("驿来特AI智能分析系统关闭...")
await close_db() await close_db()
# Close Redis connection
await RedisKit().close()
app = FastAPI(title="驿来特AI智能分析系统", lifespan=lifespan) app = FastAPI(title="驿来特AI智能分析系统", lifespan=lifespan)

View File

@@ -21,7 +21,7 @@ from Util import Win32Patch
Win32Patch.patch() Win32Patch.patch()
from Config.Config import DB_URL from Config.Config import DB_URL
from DbKit.Db import Db from Model.YltAnalyticsModel import YltAnalyticsModel
try: try:
from openpyxl import Workbook from openpyxl import Workbook
@@ -175,46 +175,17 @@ def _set_column_widths(ws, widths: dict[int, float]):
class DorisExcelExporter: class DorisExcelExporter:
def __init__(self, db_url: str): def __init__(self, db_url: str = None):
self.db = Db(db_url=db_url) self.model = YltAnalyticsModel()
async def init(self): async def init(self):
await self.db.init_db() pass # Model handles initialization if needed
async def close(self): async def close(self):
try: pass # Model handles closing if needed
await self.db.close()
finally:
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 = """ return await self.model.fetch_current_station_rows(operator)
SELECT
p.station_hash,
p.station_name,
p.address,
p.coord_x,
p.coord_y,
s.total_piles AS total_guns,
s.free_piles AS free_guns,
s.piles_detail_json,
s.current_price,
s.pro_price,
s.parking_info,
s.distance,
s.valid_start_time AS status_update_time,
pr.schedule_json,
pr.valid_start_time AS schedule_update_time
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
LEFT JOIN t_station_price_schedule_scd pr
ON p.station_hash = pr.station_hash AND pr.is_current = 1
WHERE p.is_current = 1 AND p.operator = :operator
ORDER BY p.station_name
"""
return await self.db.find(sql, {"operator": operator})
def extract_hourly_prices_from_schedule(schedule_json: Any) -> List[Optional[float]]: def extract_hourly_prices_from_schedule(schedule_json: Any) -> List[Optional[float]]:

View File

@@ -98,6 +98,14 @@ class RedisKit:
return False return False
async def set_data(self, key, value, expire=None): async def set_data(self, key, value, expire=None):
"""
异步保存数据到Redis
Args:
key (str): Redis键名
value (any): 要保存的值
expire (int, optional): 过期时间(秒)
"""
try: try:
await self._ensure_pool() await self._ensure_pool()
if expire: if expire:
@@ -109,5 +117,33 @@ class RedisKit:
logger.error(f"保存数据到Redis失败(key={key}): {e}") logger.error(f"保存数据到Redis失败(key={key}): {e}")
return False return False
# ȫ<><C8AB>ʵ<EFBFBD><CAB5> async def close(self):
"""
关闭Redis连接池
"""
if RedisKit._redis_pool is not None:
try:
# redis-py 4.x+ supports close()
await asyncio.to_thread(RedisKit._redis_pool.close)
RedisKit._redis_pool = None
logger.info("Redis连接池已关闭")
except Exception as e:
logger.error(f"关闭Redis连接池失败: {e}")
async def delete_data(self, key):
"""
异步删除Redis中的数据
Args:
key (str): Redis键名
"""
try:
await self._ensure_pool()
await asyncio.to_thread(RedisKit._redis_pool.delete, key)
return True
except Exception as e:
logger.error(f"从Redis删除数据失败(key={key}): {e}")
return False
# ȫ<><C8AB>ʵ<EFBFBD><CAB5>
redisKit = RedisKit() redisKit = RedisKit()

35
clear_sql_cache.py Normal file
View File

@@ -0,0 +1,35 @@
import asyncio
import os
import sys
# Add current directory to path so we can import Util
sys.path.append(os.getcwd())
from Util.RedisKit import redisKit
async def clear_cache():
print("Connecting to Redis...")
# redisKit will automatically ensure pool on first operation
keys_to_delete = [
"sql_templates:templates_loaded",
"sql_templates:all_templates",
"sql_templates:loaded_files",
"sql_templates:template_map"
]
for key in keys_to_delete:
print(f"Deleting key: {key}")
# Assuming delete_data exists in redisKit based on common naming convention
# If it doesn't, we can use a raw delete command
try:
await redisKit.delete_data(key)
except AttributeError:
conn = await redisKit.get_connection()
await asyncio.to_thread(conn.delete, key)
print("SQL template cache cleared successfully.")
if __name__ == "__main__":
asyncio.run(clear_cache())

View File

@@ -127,9 +127,21 @@ body {
min-width: 0; min-width: 0;
} }
/* Station List */ /* Station List & Trends */
.station-list { .station-list {
flex: 1; flex: 3;
min-height: 0;
background-color: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.trend-section {
flex: 2;
min-height: 0; min-height: 0;
background-color: var(--card-bg); background-color: var(--card-bg);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
@@ -230,23 +242,100 @@ body {
scrollbar-color: var(--scrollbar-thumb) transparent; scrollbar-color: var(--scrollbar-thumb) transparent;
} }
/* Markdown */ /* Markdown & LaTeX Rendering */
.markdown-body { .markdown-body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 16px; font-size: 14px;
line-height: 1.6; line-height: 1.8;
word-wrap: break-word; word-wrap: break-word;
color: #cbd5e1; color: #cbd5e1;
} }
.markdown-body h1, .markdown-body h2, .markdown-body h3,
.markdown-body h4, .markdown-body h5, .markdown-body h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
color: var(--text-primary);
}
.markdown-body h1 { font-size: 1.8em; border-bottom: 1px solid var(--card-border); padding-bottom: 0.3em; }
.markdown-body h2 { font-size: 1.4em; border-bottom: 1px solid var(--card-border); padding-bottom: 0.3em; }
.markdown-body h3 { font-size: 1.2em; }
.markdown-body p { margin-top: 0; margin-bottom: 16px; }
.markdown-body ul, .markdown-body ol { .markdown-body ul, .markdown-body ol {
padding-left: 2em; padding-left: 2em;
margin-top: 0; margin-top: 0;
margin-bottom: 16px; margin-bottom: 16px;
} }
.markdown-body li { margin: 0.25em 0; } .markdown-body li { margin: 0.25em 0; }
.markdown-body strong { font-weight: 600; color: #f1f5f9; }
.markdown-body h3 { font-size: 1.1em; font-weight: bold; margin-top: 16px; margin-bottom: 8px; color: #f1f5f9; } .markdown-body strong { font-weight: 600; color: #fff; }
.markdown-body blockquote {
padding: 0 1em;
color: #94a3b8;
border-left: 0.25em solid #3b82f6;
margin: 0 0 16px 0;
}
.markdown-body code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
background-color: rgba(148, 163, 184, 0.1);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
}
/* Markdown Table Styles */
.markdown-body table {
display: block;
width: 100%;
width: max-content;
max-width: 100%;
overflow: auto;
border-spacing: 0;
border-collapse: collapse;
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body table th {
font-weight: 600;
background-color: rgba(15, 23, 42, 0.8);
}
.markdown-body table th,
.markdown-body table td {
padding: 6px 13px;
border: 1px solid var(--card-border);
}
.markdown-body table tr {
background-color: transparent;
border-top: 1px solid var(--card-border);
}
.markdown-body table tr:nth-child(2n) {
background-color: rgba(30, 41, 59, 0.3);
}
/* LaTeX Styles */
.katex-block {
margin: 1em 0;
overflow-x: auto;
overflow-y: hidden;
padding: 8px 0;
}
.katex {
font-size: 1.1em !important;
}
/* Custom Scrollbar */ /* Custom Scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar { width: 8px; height: 8px; }

View File

@@ -239,22 +239,67 @@ body {
animation: blink 1s step-end infinite; animation: blink 1s step-end infinite;
} }
/* Markdown Styles */ /* Markdown & LaTeX Rendering */
.markdown-body { .markdown-body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 16px; font-size: 15px;
line-height: 1.6; line-height: 1.8;
word-wrap: break-word; word-wrap: break-word;
color: #334155; color: #cbd5e1;
} }
.markdown-body h1, .markdown-body h2, .markdown-body h3,
.markdown-body h4, .markdown-body h5, .markdown-body h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
color: var(--text-primary);
}
.markdown-body h1 { font-size: 1.8em; border-bottom: 1px solid var(--card-border); padding-bottom: 0.3em; }
.markdown-body h2 { font-size: 1.4em; border-bottom: 1px solid var(--card-border); padding-bottom: 0.3em; }
.markdown-body h3 { font-size: 1.2em; }
.markdown-body p { margin-top: 0; margin-bottom: 16px; }
.markdown-body ul, .markdown-body ol { .markdown-body ul, .markdown-body ol {
padding-left: 2em; padding-left: 2em;
margin-top: 0; margin-top: 0;
margin-bottom: 16px; margin-bottom: 16px;
} }
.markdown-body li { margin: 0.25em 0; } .markdown-body li { margin: 0.25em 0; }
.markdown-body strong { font-weight: 600; color: #0f172a; }
.markdown-body strong { font-weight: 600; color: #fff; }
.markdown-body blockquote {
padding: 0 1em;
color: #94a3b8;
border-left: 0.25em solid #3b82f6;
margin: 0 0 16px 0;
}
.markdown-body code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
background-color: rgba(148, 163, 184, 0.1);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
}
/* LaTeX Styles */
.katex-block {
margin: 1em 0;
overflow-x: auto;
overflow-y: hidden;
padding: 8px 0;
}
.katex {
font-size: 1.1em !important;
}
@keyframes blink { @keyframes blink {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }

View File

@@ -237,42 +237,99 @@ body {
50% { opacity: 0; } 50% { opacity: 0; }
} }
/* Markdown Styles */ /* Markdown & LaTeX Rendering */
.markdown-body { .markdown-body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 16px; font-size: 15px;
line-height: 1.6; line-height: 1.8;
word-wrap: break-word; word-wrap: break-word;
color: #e5e7eb; color: #cbd5e1;
} }
.markdown-body h1, .markdown-body h2, .markdown-body h3,
.markdown-body h4, .markdown-body h5, .markdown-body h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
color: var(--text-primary);
}
.markdown-body h1 { font-size: 1.8em; border-bottom: 1px solid var(--card-border); padding-bottom: 0.3em; }
.markdown-body h2 { font-size: 1.4em; border-bottom: 1px solid var(--card-border); padding-bottom: 0.3em; }
.markdown-body h3 { font-size: 1.2em; }
.markdown-body p { margin-top: 0; margin-bottom: 16px; }
.markdown-body ul, .markdown-body ol { .markdown-body ul, .markdown-body ol {
padding-left: 2em; padding-left: 2em;
margin-top: 0; margin-top: 0;
margin-bottom: 16px; margin-bottom: 16px;
} }
.markdown-body li { margin: 0.25em 0; } .markdown-body li { margin: 0.25em 0; }
.markdown-body strong { font-weight: 600; color: #60a5fa; }
.markdown-body strong { font-weight: 600; color: #fff; }
.markdown-body blockquote {
padding: 0 1em;
color: #94a3b8;
border-left: 0.25em solid #3b82f6;
margin: 0 0 16px 0;
}
.markdown-body code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
background-color: rgba(148, 163, 184, 0.1);
border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
}
/* Markdown Table Styles */
.markdown-body table { .markdown-body table {
display: block;
width: 100%; width: 100%;
width: max-content;
max-width: 100%;
overflow: auto;
border-spacing: 0;
border-collapse: collapse; border-collapse: collapse;
margin: 16px 0; margin-top: 0;
font-size: 14px; margin-bottom: 16px;
} }
.markdown-body th, .markdown-body td { .markdown-body table th {
padding: 12px;
border: 1px solid rgba(51, 65, 85, 0.9);
}
.markdown-body th {
background-color: rgba(30, 64, 175, 0.5);
font-weight: 600; font-weight: 600;
background-color: rgba(15, 23, 42, 0.8);
} }
.markdown-body tr:nth-child(even) { .markdown-body table th,
background-color: rgba(15, 23, 42, 0.5); .markdown-body table td {
padding: 10px 16px;
border: 1px solid var(--card-border);
}
.markdown-body table tr {
background-color: transparent;
border-top: 1px solid var(--card-border);
}
.markdown-body table tr:nth-child(2n) {
background-color: rgba(30, 41, 59, 0.3);
}
/* LaTeX Styles */
.katex-block {
margin: 1em 0;
overflow-x: auto;
overflow-y: hidden;
padding: 8px 0;
}
.katex {
font-size: 1.1em !important;
} }
/* ECharts Container in Result */ /* ECharts Container in Result */

View File

@@ -25,11 +25,11 @@
</header> </header>
<div class="main-content"> <div class="main-content">
<!-- Left Panel: Table --> <!-- Left Panel: Table & Trends -->
<div class="left-panel"> <div class="left-panel">
<div class="station-list"> <div class="station-list">
<div class="station-list-header"> <div class="station-list-header">
🕒 分时电价明细 📊 供应商实时价格对比
</div> </div>
<div class="station-table-container"> <div class="station-table-container">
<table class="station-table"> <table class="station-table">
@@ -50,6 +50,18 @@
</table> </table>
</div> </div>
</div> </div>
<!-- Trend Section -->
<div class="trend-section">
<div class="station-list-header" style="display: flex; justify-content: space-between; align-items: center;">
<span>📉 供应商价格变动趋势 (最近{{ trendDays }}天)</span>
<div style="display: flex; gap: 8px;">
<button class="btn-primary" style="padding: 2px 8px; font-size: 11px;" @click="trendDays = 3; loadTrendData()">3天</button>
<button class="btn-primary" style="padding: 2px 8px; font-size: 11px;" @click="trendDays = 7; loadTrendData()">7天</button>
</div>
</div>
<div id="trendChart" style="flex: 1; width: 100%; min-height: 250px;"></div>
</div>
</div> </div>
<!-- Right Panel: Chart & AI --> <!-- Right Panel: Chart & AI -->
@@ -66,15 +78,12 @@
<div style="height: 16px;"></div> <div style="height: 16px;"></div>
<div class="ai-title" style="justify-content: space-between;"> <div class="ai-title">
<span>🤖 AI 调价策略建议</span> <span>🤖 AI 调价策略建议</span>
<button class="btn-primary" style="padding: 4px 12px; font-size: 12px;" @click="startAiAnalysis" :disabled="aiLoading">
{{ aiLoading ? '分析中...' : '开始分析' }}
</button>
</div> </div>
<div class="ai-box" ref="aiBoxRef"> <div class="ai-box" ref="aiBoxRef">
<div v-if="!aiText && !aiLoading" style="text-align: center; color: #64748b; margin-top: 40px;"> <div v-if="!aiText && !aiLoading" style="text-align: center; color: #64748b; margin-top: 40px;">
点击“开始分析”获取AI智能定价建议 <span class="loading-dots">AI 正在深度分析中</span>
</div> </div>
<div v-else class="markdown-body" v-html="renderedAiText"></div> <div v-else class="markdown-body" v-html="renderedAiText"></div>
</div> </div>
@@ -85,6 +94,10 @@
<script src="js/vue.global.js"></script> <script src="js/vue.global.js"></script>
<script src="js/axios.min.js"></script> <script src="js/axios.min.js"></script>
<script src="js/echarts.min.js"></script> <script src="js/echarts.min.js"></script>
<!-- Markdown & LaTeX Support -->
<link rel="stylesheet" href="https://gcore.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<script src="https://gcore.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://gcore.jsdelivr.net/npm/marked@12.0.0/lib/marked.umd.js"></script>
<script src="js/dashboard.js"></script> <script src="js/dashboard.js"></script>
</body> </body>
</html> </html>

View File

@@ -163,6 +163,10 @@
<script src="js/element-plus.index.full.js"></script> <script src="js/element-plus.index.full.js"></script>
<script src="js/element-plus.zh-cn.min.js"></script> <script src="js/element-plus.zh-cn.min.js"></script>
<script src="js/axios.min.js"></script> <script src="js/axios.min.js"></script>
<!-- Markdown & LaTeX Support -->
<link rel="stylesheet" href="https://gcore.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<script src="https://gcore.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="https://gcore.jsdelivr.net/npm/marked@12.0.0/lib/marked.umd.js"></script>
<script src="js/douyin.js"></script> <script src="js/douyin.js"></script>
</body> </body>
</html> </html>

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>驿来特 - 智能海报生成工作台</title> <title>驿来特 - 智能海报生成工作台</title>
<link rel="stylesheet" href="/static/css/element-plus.index.css"> <link rel="stylesheet" href="css/element-plus.index.css">
<link rel="stylesheet" href="css/app.css"> <link rel="stylesheet" href="css/haibao.css">
<link rel="stylesheet" href="css/scheme.css"> <link rel="stylesheet" href="css/scheme.css">
</head> </head>
<body> <body>
@@ -17,7 +17,7 @@
<div class="header-subtitle">基于 AI 大模型,快速生成高质量企业宣传海报</div> <div class="header-subtitle">基于 AI 大模型,快速生成高质量企业宣传海报</div>
</div> </div>
<div> <div>
<a href="/static/index.html"> <a href="index.html">
<el-button plain>返回首页</el-button> <el-button plain>返回首页</el-button>
</a> </a>
</div> </div>
@@ -151,10 +151,10 @@
</div> </div>
<!-- Scripts --> <!-- Scripts -->
<script src="/static/js/vue.global.js"></script> <script src="js/vue.global.js"></script>
<script src="/static/js/element-plus.index.full.js"></script> <script src="js/element-plus.index.full.js"></script>
<script src="/static/js/element-plus.zh-cn.min.js"></script> <script src="js/element-plus.zh-cn.min.js"></script>
<script src="/static/js/axios.min.js"></script> <script src="js/axios.min.js"></script>
<script src="js/app.js"></script> <script src="js/haibao.js"></script>
</body> </body>
</html> </html>

View File

@@ -41,7 +41,7 @@
</a> </a>
<!-- HaiBao Card --> <!-- HaiBao Card -->
<a href="HaiBao/index.html" class="card haibao"> <a href="haibao.html" class="card haibao">
<div class="card-icon">🎨</div> <div class="card-icon">🎨</div>
<h2>智能海报生成</h2> <h2>智能海报生成</h2>
<p>一键生成精美的营销海报与数据战报。支持自定义模板与实时数据填充,提升品牌传播效率。</p> <p>一键生成精美的营销海报与数据战报。支持自定义模板与实时数据填充,提升品牌传播效率。</p>

View File

@@ -1,352 +0,0 @@
<!DOCTYPE html>
<html>
<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/app.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js"></script>
</head>
<body>
<div id="app">
<!-- Ad Overlay -->
<transition name="fade">
<div class="ad-overlay" v-if="showAd">
<div class="ad-content">
<div class="ad-header">
<h2>⚡ 系统特性介绍</h2>
</div>
<div class="ad-body">
<div class="ad-item">
<div class="ad-icon-wrapper">
<span class="ad-icon">📱</span>
</div>
<p>本系统采用 <strong>手机爬虫</strong> 获取4家充电供应商准实时各时段电价</p>
</div>
<div class="ad-item">
<div class="ad-icon-wrapper">
<span class="ad-icon">🧠</span>
</div>
<p>结合 <strong>数据仓库与AI技术</strong>,对我司电价进行智能分析,给出定价建议</p>
</div>
<div class="ad-item">
<div class="ad-icon-wrapper">
<span class="ad-icon">📊</span>
</div>
<p>对我司的各场站营业情况进行 <strong>分析,查询</strong></p>
</div>
<div class="ad-item">
<div class="ad-icon-wrapper">
<span class="ad-icon">🎨</span>
</div>
<p>新增 <strong>智能海报生成</strong> 功能,未来将结合业务数据,一键生成精美的数据战报与营销海报</p>
</div>
<div class="ad-item">
<div class="ad-icon-wrapper">
<span class="ad-icon">🎥</span>
</div>
<p>新增 <strong>抖音知识库</strong>:支持视频解析、知识获取与总结、博主专栏订阅,自动生成 <strong>充电企业知识日报</strong>,助力企业构建专属知识库</p>
</div>
<div class="ad-item">
<div class="ad-icon-wrapper">
<span class="ad-icon">🎯</span>
</div>
<p>未来:可以根据用户充电信息,形成用户画像,结合企业微信,实现 <strong>用户广告的精准推送</strong></p>
</div>
<div class="ad-item">
<div class="ad-icon-wrapper">
<span class="ad-icon">🧭</span>
</div>
<p>未来:基于 <strong>LBS位置服务</strong>,智能对比周边竞对场站的价格与配套(快充、休息室等),精准引导用户选择我司优势站点</p>
</div>
</div>
<div class="ad-footer">
<button class="ad-close-btn" @click="closeAd">
关闭 ({{ adCountdown }}s)
</button>
</div>
</div>
</div>
</transition>
<header class="dashboard-header">
<div class="dashboard-title">⚡ 驿来特AI智能数据分析平台</div>
<div class="nav-tabs">
<button class="nav-tab" :class="{active: activeTab==='dashboard'}" @click="activeTab='dashboard'">分时电价分析</button>
<button class="nav-tab" :class="{active: activeTab==='degree'}" @click="activeTab='degree'">智能数据查询</button>
<button class="nav-tab" :class="{active: activeTab==='douyin'}" @click="activeTab='douyin'">抖音知识库</button>
<a href="HaiBao/index.html" class="nav-tab" style="text-decoration: none; display: inline-block;">智能海报生成</a>
</div>
<div class="controls" v-if="activeTab==='dashboard'">
<button class="btn-primary" @click="exportAllPrices" :disabled="exporting">
<span v-if="!exporting">📊 导出分时段电价表</span>
<span v-else>⏳ 导出中...</span>
</button>
<button class="btn-primary" @click="exportAiReport" :disabled="exportingReport || !aiText" :title="!aiText ? '请先生成AI分析报告' : ''">
<span v-if="!exportingReport">📑 导出分析报告</span>
<span v-else>⏳ 生成中...</span>
</button>
</div>
<div class="controls" v-else>
<!-- Placeholder for degree tab specific controls if any -->
</div>
</header>
<!-- Dashboard Tab -->
<div class="main-content" v-show="activeTab==='dashboard'">
<div class="left-panel">
<div class="station-list">
<div class="station-list-header">全网供应商24小时电价监控</div>
<div class="station-table-container">
<table class="station-table">
<thead>
<tr>
<th style="width:15%;">时段</th>
<th v-for="op in operators" :key="op.value" :style="{width: (85/operators.length) + '%'}">{{ op.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in priceTableRows" :key="idx">
<td style="font-family: monospace; color: var(--text-secondary);">{{ row.hour }}</td>
<td v-for="cell in row.values" :key="cell.operator" :style="{color: getPriceColor(cell.price)}">
{{ formatCell(cell.price) }}
</td>
</tr>
<tr v-if="priceTableRows.length===0">
<td :colspan="operators.length + 1" style="text-align:center; padding: 32px; color: var(--text-secondary);">
数据加载中...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div id="chart"></div>
</div>
<div class="right-panel">
<div class="ai-title">
智能决策分析助手
<button class="btn-primary" @click="startAiAnalysis" :disabled="aiLoading" style="margin-left: auto; font-size: 0.8rem; padding: 4px 12px; height: auto;">
<span v-if="!aiLoading">🚀 开始分析</span>
<span v-else>⏳ 分析中...</span>
</button>
</div>
<div class="ai-box" ref="aiBoxRef">
<div class="ai-question">
<div class="label">当前分析任务</div>
<div class="question-text">
请根据爬取的各供应商分时电价等信息,对各司的定价策略,
与我司(驿来特)的定价策略进行综合对比,分析我司可能存在的潜在问题。
</div>
</div>
<div class="ai-result" v-html="renderedAiText || placeholder" style="opacity: 0.9;"></div>
</div>
</div>
</div>
<!-- Douyin Tab -->
<div class="douyin-container" v-show="activeTab==='douyin'" style="padding: 24px; max-width: 95%; margin: 0 auto;">
<!-- Summary Dialog -->
<el-dialog v-model="showSummaryDialog" title="💡 知识精华总结" width="60%" :before-close="handleSummaryClose">
<div class="dialog-content" style="max-height: 60vh; overflow-y: auto; padding: 10px;">
<div v-if="summaryLoading && !summaryText" style="text-align: center; padding: 20px; color: #6b7280;">
<p>⚡ AI正在阅读您的知识库并提炼精华,请稍候...</p>
</div>
<div class="markdown-body" v-html="renderedSummary" style="font-size: 15px; line-height: 1.8; color: #1f2937;"></div>
<span v-if="summaryLoading && summaryText" class="cursor-blink">|</span>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleSummaryClose">关闭</el-button>
<el-button type="primary" @click="fetchDouyinSummary" :loading="summaryLoading">
{{ summaryText ? '重新生成' : '开始总结' }}
</el-button>
</span>
</template>
</el-dialog>
<div class="douyin-header" style="text-align: center; margin-bottom: 40px;">
<h1 style="font-size: 2.5rem; font-weight: bold; color: #f1f5f9; margin-bottom: 0.5rem;">
抖音知识库
<button class="btn-primary" @click="fetchDouyinRecords" style="font-size: 1rem; padding: 4px 12px; margin-left: 12px; vertical-align: middle;">
🔄 刷新列表
</button>
<button class="btn-primary" @click="openSummaryDialog" style="font-size: 1rem; padding: 4px 12px; margin-left: 12px; vertical-align: middle; background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%); border: none;">
✨ 提取知识精华
</button>
</h1>
<p style="color: #94a3b8;">自动解析视频、提取文案,构建企业充电知识图谱</p>
</div>
<!-- Input Section -->
<el-card class="box-card" style="margin-bottom: 32px;">
<template #header>
<div class="card-header">
<span style="font-weight: bold; font-size: 1.2rem;">粘贴抖音分享链接</span>
</div>
</template>
<el-input
v-model="shareText"
:rows="15"
type="textarea"
placeholder="支持批量粘贴!
例如:
1.23 复制打开抖音,看看【...】 https://v.douyin.com/...
7.89 复制打开抖音,看看【...】 https://v.douyin.com/..."
style="margin-bottom: 20px; font-size: 16px;"
></el-input>
<div style="display: flex; justify-content: flex-end;">
<el-button type="primary" @click="startParsing" :loading="douyinLoading" :disabled="!shareText.trim()">
{{ douyinLoading ? '解析处理中...' : '开始解析' }}
</el-button>
</div>
</el-card>
<!-- Records List -->
<div class="records-list">
<el-card v-for="record in douyinRecords" :key="record.id" style="margin-bottom: 20px;" :body-style="{ padding: '0px' }">
<div style="display: flex; border-left: 4px solid;" :style="{borderColor: statusColor(record.status)}">
<div style="padding: 24px; flex: 1;">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 16px;">
<div>
<div style="display: flex; align-items: center; margin-bottom: 8px;">
<el-tag :type="statusType(record.status)" effect="dark" size="small" style="margin-right: 8px;">
{{ record.status }}
</el-tag>
<span style="color: #9ca3af; font-size: 12px;">
{{ formatDate(record.create_time) }}
</span>
</div>
<h3 style="font-size: 1.25rem; font-weight: bold; color: #1f2937; line-height: 1.4;">
{{ record.video_name || '处理中...' }}
</h3>
<a v-if="record.obs_url" :href="record.obs_url" target="_blank" style="color: #3b82f6; font-size: 14px; margin-top: 4px; display: inline-block; text-decoration: none;">
📺 点击观看视频
</a>
</div>
<el-button type="danger" circle @click="deleteRecord(record.id)" plain>
<span style="font-size: 12px;">Del</span>
</el-button>
</div>
<!-- Error Message -->
<div v-if="record.status === 'FAILED' && record.error_msg" style="margin-top: 12px; color: #ef4444; font-size: 13px; background: #fee2e2; padding: 8px; border-radius: 4px; word-break: break-all;">
<strong>Error:</strong> {{ record.error_msg }}
</div>
<!-- Transcript -->
<div v-if="record.transcript" style="background-color: #f9fafb; border-radius: 8px; padding: 16px; margin-top: 16px;">
<h4 style="font-size: 14px; font-weight: 600; color: #4b5563; margin-bottom: 8px; text-transform: uppercase;">视频文案</h4>
<p style="color: #374151; white-space: pre-wrap; font-size: 14px; line-height: 1.6;" :style="record.expanded ? {} : {display: '-webkit-box', '-webkit-line-clamp': '3', '-webkit-box-orient': 'vertical', overflow: 'hidden'}">
{{ record.transcript }}
</p>
<el-button type="primary" link @click="record.expanded = !record.expanded" style="margin-top: 8px; font-size: 12px;">
{{ record.expanded ? '收起' : '展开全文' }}
</el-button>
</div>
<!-- Original Text -->
<div style="margin-top: 16px;">
<div style="cursor: pointer; color: #9ca3af; font-size: 12px;" @click="record.showOriginal = !record.showOriginal">
{{ record.showOriginal ? '收起原始链接' : '查看原始链接信息' }}
</div>
<div v-if="record.showOriginal" style="color: #6b7280; font-size: 12px; background: #f3f4f6; padding: 8px; border-radius: 4px; margin-top: 4px;">
{{ record.original_text }}
</div>
</div>
</div>
</div>
</el-card>
<div v-if="douyinRecords.length === 0" style="text-align: center; color: #9ca3af; padding: 40px;">
<p>暂无记录,请粘贴链接开始解析</p>
</div>
</div>
</div>
<!-- Degree Query Tab -->
<div class="degree-container" v-show="activeTab==='degree'">
<!-- 悬浮二维码 -->
<div class="qr-sidebar">
<div id="qrcode" class="qr-code"></div>
<p class="qr-text">手机扫码访问</p>
</div>
<div class="query-section">
<h1 class="page-title">驿来特AI智能数据查询</h1>
<p class="page-subtitle">基于大语言模型,为您提供实时、精准的业务数据分析</p>
<div class="input-wrapper">
<el-input
v-model="userQuery"
placeholder="请输入您的问题,例如:帮我查询12月份充电量TOP 10场站的充电情况"
class="search-input"
size="large"
@keyup.enter="handleDegreeSearch"
:disabled="queryLoading"
>
<template #append>
<el-button @click="handleDegreeSearch" :loading="queryLoading" type="primary">
{{ queryLoading ? '分析中...' : '开始查询' }}
</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="queryResult || queryLoading">
<div class="result-header">
<div class="result-title">
<span>分析结果</span>
<el-tag size="small" type="success" effect="dark" v-if="!queryLoading">完成</el-tag>
<el-tag size="small" type="warning" effect="dark" v-if="queryLoading">生成中</el-tag>
</div>
<el-button
v-if="queryLoading"
type="danger"
link
@click="stopDegreeGeneration"
>
停止生成
</el-button>
</div>
<div class="markdown-body" v-html="renderedResult"></div>
<div v-if="queryLoading && !queryResult" class="loading-container">
<p>正在分析数据,请稍候...</p>
</div>
<span v-if="queryLoading" class="cursor-blink">|</span>
</div>
</div>
</div>
<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/qrcode.min.js"></script>
<script src="js/axios.min.js"></script>
<script src="js/echarts.min.js"></script>
<script src="js/app.js"></script>
</body>
</html>

View File

@@ -12,6 +12,7 @@ createApp({
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
isMobile.value = window.innerWidth <= 768; isMobile.value = window.innerWidth <= 768;
if (chartInstance) chartInstance.resize(); if (chartInstance) chartInstance.resize();
if (trendChartInstance) trendChartInstance.resize();
}); });
// ========================================== // ==========================================
@@ -35,7 +36,9 @@ createApp({
const priceTableRows = ref([]); const priceTableRows = ref([]);
const hourlyPricesByOperator = ref({}); const hourlyPricesByOperator = ref({});
let chartInstance = null; let chartInstance = null;
let trendChartInstance = null;
const chartType = ref('line'); const chartType = ref('line');
const trendDays = ref(7);
// ECharts Initialization // ECharts Initialization
const initChart = () => { const initChart = () => {
@@ -49,6 +52,17 @@ createApp({
} }
}; };
const initTrendChart = () => {
const dom = document.getElementById("trendChart");
if (dom && !trendChartInstance) {
if (typeof echarts === 'undefined') {
console.error("ECharts not loaded");
return;
}
trendChartInstance = echarts.init(dom);
}
};
const renderChart = () => { const renderChart = () => {
if (!chartInstance) return; if (!chartInstance) return;
@@ -152,6 +166,65 @@ createApp({
} }
}; };
const loadTrendData = async () => {
try {
const res = await axios.get(apiBase.value + "/api/operators/price-trends?days=" + trendDays.value);
if (res && res.data) {
renderTrendChart(res.data);
}
} catch (e) {
console.error("Failed to load trend data:", e);
}
};
const renderTrendChart = (data) => {
if (!trendChartInstance) initTrendChart();
if (!trendChartInstance) return;
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: "axis",
backgroundColor: 'rgba(30, 41, 59, 0.9)',
borderColor: '#334155',
textStyle: { color: '#f1f5f9' }
},
legend: {
data: data.series.map(s => s.name),
textStyle: { color: "#94a3b8" },
top: 10
},
grid: { left: 50, right: 30, top: 60, bottom: 40 },
xAxis: {
type: "category",
data: data.dates.map(d => d.split('-').slice(1).join('/')), // Show MM/DD
axisLine: { lineStyle: { color: "#475569" } },
axisLabel: { color: "#94a3b8" }
},
yAxis: {
type: "value",
name: "元/度",
nameTextStyle: { color: "#94a3b8" },
axisLine: { lineStyle: { color: "#475569" } },
axisLabel: { color: "#94a3b8" },
splitLine: { lineStyle: { color: "#334155", type: 'dashed' } },
min: (value) => (value.min * 0.95).toFixed(2),
max: (value) => (value.max * 1.05).toFixed(2)
},
series: data.series.map(s => ({
name: s.name,
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
data: s.data,
emphasis: { focus: 'series' }
}))
};
trendChartInstance.setOption(option, true);
};
const exportAllPrices = async () => { const exportAllPrices = async () => {
try{ try{
exporting.value = true; exporting.value = true;
@@ -244,48 +317,102 @@ createApp({
return '#f1f5f9'; // Slate-100 return '#f1f5f9'; // Slate-100
}; };
// 内置简易 Markdown 解析器 // Configure Marked
if (typeof marked !== 'undefined') {
marked.use({
gfm: true,
breaks: true
});
}
// 降级用的简易 Markdown 解析器
const simpleMarkdown = (text) => { const simpleMarkdown = (text) => {
if (!text) return ''; if (!text) return '';
let lines = text.split('\n'); let lines = text.split('\n');
let html = ''; let html = '';
let inList = false; let inList = false;
const parseInline = (str) => { const parseInline = (str) => {
return str return str
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.*?)`/g, '<code style="background:#f1f5f9; padding:2px 4px; border-radius:4px; color:#334155;">$1</code>'); .replace(/`(.*?)`/g, '<code style="background:rgba(148, 163, 184, 0.1); padding:2px 4px; border-radius:4px;">$1</code>');
}; };
for (let line of lines) { for (let line of lines) {
let trimmed = line.trim(); let trimmed = line.trim();
if (!trimmed) continue; if (!trimmed) {
if (inList) { html += '</ul>'; inList = false; }
html += '<br>';
continue;
}
if (trimmed.startsWith('### ')) { if (trimmed.startsWith('### ')) {
if (inList) { html += '</ul>'; inList = false; } if (inList) { html += '</ul>'; inList = false; }
html += `<h3>${parseInline(trimmed.substring(4))}</h3>`; html += `<h3>${parseInline(trimmed.substring(4))}</h3>`;
} } else if (trimmed.startsWith('- ') || /^\d+\./.test(trimmed)) {
else if (trimmed.startsWith('- ') || /^\d+\./.test(trimmed)) {
if (!inList) { html += '<ul>'; inList = true; } if (!inList) { html += '<ul>'; inList = true; }
let content = trimmed.replace(/^(- |\d+\. )/, ''); let content = trimmed.replace(/^(- |\d+\. )/, '');
html += `<li>${parseInline(content)}</li>`; html += `<li>${parseInline(content)}</li>`;
} } else {
else {
if (inList) { html += '</ul>'; inList = false; } if (inList) { html += '</ul>'; inList = false; }
html += `<p style="margin-bottom:8px;">${parseInline(trimmed)}</p>`; html += `<p>${parseInline(trimmed)}</p>`;
} }
} }
if (inList) html += '</ul>'; if (inList) html += '</ul>';
return html; return html;
}; };
// 增强的 Markdown & LaTeX 解析器
const renderMarkdownAndLatex = (text) => {
if (!text) return '';
try {
// 1. 处理 LaTeX (简单替换,先处理 $$ 再处理 $)
let processedText = text;
// 处理块级 LaTeX: $$ ... $$
processedText = processedText.replace(/\$\$\s*([\s\S]*?)\s*\$\$/g, (match, formula) => {
try {
if (typeof katex !== 'undefined') {
return '<div class="katex-block">' + katex.renderToString(formula, { displayMode: true, throwOnError: false }) + '</div>';
}
return match;
} catch (e) {
return match;
}
});
// 处理行内 LaTeX: $ ... $
processedText = processedText.replace(/\$([^\$\n]+?)\$/g, (match, formula) => {
try {
if (typeof katex !== 'undefined') {
return katex.renderToString(formula, { displayMode: false, throwOnError: false });
}
return match;
} catch (e) {
return match;
}
});
// 2. 使用 marked 解析 Markdown
if (typeof marked !== 'undefined') {
return marked.parse(processedText);
} else {
// 降级使用之前的 simpleMarkdown
return simpleMarkdown(processedText);
}
} catch (e) {
console.error('Markdown/LaTeX rendering error:', e);
return text;
}
};
const renderedAiText = computed(() => { const renderedAiText = computed(() => {
return simpleMarkdown(aiText.value); return renderMarkdownAndLatex(aiText.value);
}); });
onMounted(() => { onMounted(() => {
initChart(); initChart();
initTrendChart();
loadAllOperatorsPrices(); loadAllOperatorsPrices();
loadTrendData();
}); });
return { return {
@@ -299,9 +426,11 @@ createApp({
priceTableRows, priceTableRows,
hourlyPricesByOperator, hourlyPricesByOperator,
chartType, chartType,
trendDays,
// Actions // Actions
loadAllOperatorsPrices, loadAllOperatorsPrices,
loadTrendData,
exportAllPrices, exportAllPrices,
exportAiReport, exportAiReport,
startAiAnalysis, startAiAnalysis,

View File

@@ -15,53 +15,96 @@ createApp({
const summaryLoading = ref(false); const summaryLoading = ref(false);
const summaryText = ref(''); const summaryText = ref('');
// Simple Markdown Parser (Zero Dependency) // 降级用的简易 Markdown 解析器
const simpleMarkdown = (text) => { const simpleMarkdown = (text) => {
if (!text) return ''; if (!text) return '';
let lines = text.split('\n'); let lines = text.split('\n');
let html = ''; let html = '';
let inList = false; let inList = false;
// Helper: Parse inline styles
const parseInline = (str) => { const parseInline = (str) => {
return str return str
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') // Bold .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.*?)`/g, '<code style="background:#f1f5f9; padding:2px 4px; border-radius:4px;">$1</code>'); // Code .replace(/`(.*?)`/g, '<code style="background:rgba(148, 163, 184, 0.1); padding:2px 4px; border-radius:4px;">$1</code>');
}; };
for (let line of lines) { for (let line of lines) {
let trimmed = line.trim(); let trimmed = line.trim();
if (!trimmed) continue; if (!trimmed) {
if (inList) { html += '</ul>'; inList = false; }
// Headers html += '<br>';
continue;
}
if (trimmed.startsWith('### ')) { if (trimmed.startsWith('### ')) {
if (inList) { html += '</ul>'; inList = false; } if (inList) { html += '</ul>'; inList = false; }
html += `<h3 style="margin-top:16px; margin-bottom:8px; font-weight:bold; font-size:1.1em;">${parseInline(trimmed.substring(4))}</h3>`; html += `<h3>${parseInline(trimmed.substring(4))}</h3>`;
} } else if (trimmed.startsWith('- ') || /^\d+\./.test(trimmed)) {
// Lists if (!inList) { html += '<ul>'; inList = true; }
else if (trimmed.startsWith('- ') || /^\d+\./.test(trimmed)) {
if (!inList) { html += '<ul style="padding-left:20px; margin:0;">'; inList = true; }
let content = trimmed.replace(/^(- |\d+\. )/, ''); let content = trimmed.replace(/^(- |\d+\. )/, '');
html += `<li style="margin-bottom:4px;">${parseInline(content)}</li>`; html += `<li>${parseInline(content)}</li>`;
} } else {
// Paragraphs
else {
if (inList) { html += '</ul>'; inList = false; } if (inList) { html += '</ul>'; inList = false; }
html += `<p style="margin-bottom:8px;">${parseInline(trimmed)}</p>`; html += `<p>${parseInline(trimmed)}</p>`;
} }
} }
if (inList) html += '</ul>'; if (inList) html += '</ul>';
return html; return html;
}; };
// Configure Marked
if (typeof marked !== 'undefined') {
marked.use({
gfm: true,
breaks: true
});
}
// 增强的 Markdown & LaTeX 解析器
const renderMarkdownAndLatex = (text) => {
if (!text) return '';
try {
// 1. 处理 LaTeX (简单替换,先处理 $$ 再处理 $)
let processedText = text;
// 处理块级 LaTeX: $$ ... $$
processedText = processedText.replace(/\$\$\s*([\s\S]*?)\s*\$\$/g, (match, formula) => {
try {
if (typeof katex !== 'undefined') {
return '<div class="katex-block">' + katex.renderToString(formula, { displayMode: true, throwOnError: false }) + '</div>';
}
return match;
} catch (e) {
return match;
}
});
// 处理行内 LaTeX: $ ... $
processedText = processedText.replace(/\$([^\$\n]+?)\$/g, (match, formula) => {
try {
if (typeof katex !== 'undefined') {
return katex.renderToString(formula, { displayMode: false, throwOnError: false });
}
return match;
} catch (e) {
return match;
}
});
// 2. 使用 marked 解析 Markdown
if (typeof marked !== 'undefined') {
return marked.parse(processedText);
} else {
// 降级使用之前的 simpleMarkdown
return simpleMarkdown(processedText);
}
} catch (e) {
console.error('Markdown/LaTeX rendering error:', e);
return text;
}
};
const renderedSummary = computed(() => { const renderedSummary = computed(() => {
if (!summaryText.value) return ''; if (!summaryText.value) return '';
try { return renderMarkdownAndLatex(summaryText.value);
return simpleMarkdown(summaryText.value);
} catch (e) {
console.error("Simple markdown error:", e);
return summaryText.value;
}
}); });
// Methods // Methods

View File

@@ -168,34 +168,104 @@ createApp({
// Configure Marked // Configure Marked
if (typeof marked !== 'undefined') { if (typeof marked !== 'undefined') {
marked.use({ const renderer = new marked.Renderer();
gfm: true, const oldCode = renderer.code.bind(renderer);
breaks: true, renderer.code = function(code, language) {
renderer: { if (language === 'echarts') {
code(code, language) { const id = 'chart-' + Math.random().toString(36).substr(2, 9);
if (language === 'echarts') { 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>`;
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;
}
} }
return oldCode(code, language);
};
marked.setOptions({
renderer: renderer,
gfm: true,
breaks: true
}); });
} }
const renderedResult = computed(() => { // 降级用的简易 Markdown 解析器
if (!queryResult.value) return ''; const simpleMarkdown = (text) => {
try { if (!text) return '';
// Remove escapes that might break markdown let lines = text.split('\n');
const cleanText = queryResult.value.replace(/\\([\*_`#\[\]\(\)!>-])/g, '$1'); let html = '';
if (typeof marked !== 'undefined') { let inList = false;
return marked.parse(cleanText); const parseInline = (str) => {
return str
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.*?)`/g, '<code style="background:rgba(148, 163, 184, 0.1); padding:2px 4px; border-radius:4px;">$1</code>');
};
for (let line of lines) {
let trimmed = line.trim();
if (!trimmed) {
if (inList) { html += '</ul>'; inList = false; }
html += '<br>';
continue;
}
if (trimmed.startsWith('### ')) {
if (inList) { html += '</ul>'; inList = false; }
html += `<h3>${parseInline(trimmed.substring(4))}</h3>`;
} else if (trimmed.startsWith('- ') || /^\d+\./.test(trimmed)) {
if (!inList) { html += '<ul>'; inList = true; }
let content = trimmed.replace(/^(- |\d+\. )/, '');
html += `<li>${parseInline(content)}</li>`;
} else {
if (inList) { html += '</ul>'; inList = false; }
html += `<p>${parseInline(trimmed)}</p>`;
} }
return cleanText;
} catch (e) {
console.error('Markdown parsing error:', e);
return queryResult.value;
} }
if (inList) html += '</ul>';
return html;
};
// 增强的 Markdown & LaTeX 解析器
const renderMarkdownAndLatex = (text) => {
if (!text) return '';
try {
// 1. 处理 LaTeX (简单替换,先处理 $$ 再处理 $)
let processedText = text;
// 处理块级 LaTeX: $$ ... $$
processedText = processedText.replace(/\$\$\s*([\s\S]*?)\s*\$\$/g, (match, formula) => {
try {
if (typeof katex !== 'undefined') {
return '<div class="katex-block">' + katex.renderToString(formula, { displayMode: true, throwOnError: false }) + '</div>';
}
return match;
} catch (e) {
return match;
}
});
// 处理行内 LaTeX: $ ... $
processedText = processedText.replace(/\$([^\$\n]+?)\$/g, (match, formula) => {
try {
if (typeof katex !== 'undefined') {
return katex.renderToString(formula, { displayMode: false, throwOnError: false });
}
return match;
} catch (e) {
return match;
}
});
// 2. 使用 marked 解析 Markdown
if (typeof marked !== 'undefined') {
// 直接解析处理后的文本,marked 会处理 Markdown 转义
return marked.parse(processedText);
} else {
// 降级使用之前的 simpleMarkdown
return simpleMarkdown(processedText);
}
} catch (e) {
console.error('Markdown/LaTeX rendering error:', e);
return text;
}
};
const renderedResult = computed(() => {
return renderMarkdownAndLatex(queryResult.value);
}); });
watch(queryResult, () => { watch(queryResult, () => {

View File

@@ -82,6 +82,9 @@
<script src="js/element-plus.zh-cn.min.js"></script> <script src="js/element-plus.zh-cn.min.js"></script>
<script src="js/axios.min.js"></script> <script src="js/axios.min.js"></script>
<script src="js/echarts.min.js"></script> <script src="js/echarts.min.js"></script>
<!-- Markdown & LaTeX Support -->
<link rel="stylesheet" href="https://gcore.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<script src="https://gcore.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<script src="js/marked.min.js"></script> <script src="js/marked.min.js"></script>
<script src="js/query.js"></script> <script src="js/query.js"></script>
</body> </body>