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

@@ -7,357 +7,263 @@ import asyncio
from datetime import datetime from datetime import datetime
from typing import List, Optional 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, OBS_SERVER
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 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 class ParseRequest(BaseModel):
def get_db_connection(): text: str
return pymysql.connect(
host=DORIS_HOST, class SummaryRequest(BaseModel):
port=DORIS_PORT, ids: List[str] = []
user=DORIS_USER,
password=DORIS_PWD, async def process_video_task(url: str, request_id: str, share_text: str = ""):
database=DORIS_DATABASE, logger.info(f"Processing task {request_id}")
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor model = DouYinModel()
) # 1. Update status
await model.update_status(request_id, "PROCESSING")
class ParseRequest(BaseModel):
text: str temp_dir = os.path.abspath(f"temp_{request_id}")
try:
class SummaryRequest(BaseModel): if not os.path.exists(temp_dir):
ids: List[str] = [] os.makedirs(temp_dir)
def update_status(id, status, error_msg=None): # 2. Parse & Download
try: downloader = DouYinDownloader()
conn = get_db_connection() # url is passed directly now
cursor = conn.cursor() if not url:
if error_msg: raise Exception("No valid URL found")
sql = "UPDATE t_douyin_record SET status=%s, error_msg=%s WHERE id=%s"
cursor.execute(sql, (status, error_msg, id)) logger.info(f"Downloading from {url}")
else: # Run download in thread to avoid blocking main loop
sql = "UPDATE t_douyin_record SET status=%s WHERE id=%s" local_video_path, title = await asyncio.to_thread(downloader.download_video, url, temp_dir)
cursor.execute(sql, (status, id))
conn.commit() # Title handling strategy:
conn.close() # Priority 1: Extracted from share text (if available and valid)
except Exception as e: # Priority 2: Extracted from video download (often "Unknown Title")
logger.error(f"DB Error update_status: {e}") # Priority 3: Generated by LLM (done later)
def update_record(id, title, obs_url, transcript, status): extracted_title = downloader.extract_title_from_text(share_text)
try: logger.info(f"Extracted title from text: {extracted_title}")
# Truncate title to 100 chars to fit DB schema (approx 400 bytes max for utf8mb4)
if title and len(title) > 100: # If we have a valid extracted title, use it.
title = title[:100] + "..." # But if we don't have a title yet (or it's Unknown), we definitely want to use extracted_title.
# Even if we have a title from yt-dlp, if it's just "Unknown Title", we prefer extracted one.
conn = get_db_connection() if extracted_title and extracted_title != "Unknown Title":
cursor = conn.cursor() title = extracted_title
sql = """ elif not title:
UPDATE t_douyin_record title = "Unknown Title"
SET video_name=%s, obs_url=%s, transcript=%s, status=%s
WHERE id=%s if not local_video_path or not os.path.exists(local_video_path):
""" raise Exception("Download failed")
cursor.execute(sql, (title, obs_url, transcript, status, id))
conn.commit() # 3. Upload Video to OBS (Long term storage)
conn.close() logger.info("Uploading video to OBS...")
except Exception as e: uploader = ObsUploader()
logger.error(f"DB Error update_record: {e}") video_filename = os.path.basename(local_video_path)
obs_video_key = f"{OBS_CLOUD_PREFIX}/DouYin/{video_filename}"
async def process_video_task(url: str, request_id: str, share_text: str = ""):
logger.info(f"Processing task {request_id}") success, _ = await asyncio.to_thread(uploader.upload_file, obs_video_key, local_video_path, OBS_BUCKET)
if not success:
# 1. Update status raise Exception("OBS Upload failed")
await asyncio.to_thread(update_status, request_id, "PROCESSING")
# Construct public URL (Assuming standard OBS pattern or Config logic)
temp_dir = os.path.abspath(f"temp_{request_id}") obs_url = f"https://{OBS_BUCKET}.{OBS_SERVER}/{obs_video_key}"
try:
if not os.path.exists(temp_dir): # 4. Convert to MP3
os.makedirs(temp_dir) logger.info("Converting to MP3...")
mp3_path = os.path.splitext(local_video_path)[0] + ".mp3"
# 2. Parse & Download cmd = [
downloader = DouYinDownloader() "ffmpeg", "-y", "-i", local_video_path,
# url is passed directly now "-acodec", "libmp3lame", "-ar", "16000", "-ac", "1", "-q:a", "2",
if not url: mp3_path
raise Exception("No valid URL found") ]
# Run ffmpeg in thread
logger.info(f"Downloading from {url}") result = await asyncio.to_thread(subprocess.run, cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
# Run download in thread to avoid blocking main loop if result.returncode != 0:
local_video_path, title = await asyncio.to_thread(downloader.download_video, url, temp_dir) raise Exception(f"FFmpeg failed: {result.stderr.decode()}")
# Title handling strategy: # 5. ASR (Upload MP3 to tmp and transcribe)
# Priority 1: Extracted from share text (if available and valid) logger.info("Transcribing...")
# Priority 2: Extracted from video download (often "Unknown Title") asr = ASRClient()
# Priority 3: Generated by LLM (done later) # Run ASR in thread
transcript = await asyncio.to_thread(asr.upload_and_transcribe_sync, mp3_path)
extracted_title = downloader.extract_title_from_text(share_text)
logger.info(f"Extracted title from text: {extracted_title}") if not transcript:
raise Exception("Transcription failed (returned empty)")
# If we have a valid extracted title, use it.
# But if we don't have a title yet (or it's Unknown), we definitely want to use extracted_title. # 6. LLM Title Generation (Enhancement)
# Even if we have a title from yt-dlp, if it's just "Unknown Title", we prefer extracted one. # If the title is still Unknown or weak, OR if we just want to ensure we have a good title.
if extracted_title and extracted_title != "Unknown Title": # The user said: "Alternatively, call LlmUtil.py to summarize title".
title = extracted_title # Let's do it if title is Unknown or matches default filename pattern, OR if extracted title was also missing.
elif not title: if (not title or title == "Unknown Title" or title == "Unknown"):
title = "Unknown Title" try:
logger.info("Generating title from transcript via LLM...")
if not local_video_path or not os.path.exists(local_video_path): prompt = f"请根据以下视频文案总结一个简短的标题(20字以内),不要包含任何解释性文字,直接返回标题:\n\n{transcript[:1000]}"
raise Exception("Download failed")
llm_title_chunks = []
# 3. Upload Video to OBS (Long term storage) # get_llm_response is already async
logger.info("Uploading video to OBS...") async for chunk in get_llm_response(prompt, stream=False):
uploader = ObsUploader() llm_title_chunks.append(chunk)
video_filename = os.path.basename(local_video_path) llm_title = "".join(llm_title_chunks)
obs_video_key = f"{OBS_CLOUD_PREFIX}/DouYin/{video_filename}"
if llm_title:
success, _ = await asyncio.to_thread(uploader.upload_file, obs_video_key, local_video_path, OBS_BUCKET) # Clean up quotes if any
if not success: llm_title = llm_title.strip().strip('"').strip('“').strip('”')
raise Exception("OBS Upload failed") logger.info(f"LLM generated title: {llm_title}")
# We overwrite the title if LLM succeeds
# Construct public URL (Assuming standard OBS pattern or Config logic) title = llm_title
obs_url = f"https://{OBS_BUCKET}.{OBS_SERVER}/{obs_video_key}" except Exception as llm_e:
logger.warning(f"LLM Title generation failed: {llm_e}")
# 4. Convert to MP3
logger.info("Converting to MP3...") # 7. Save to DB (Update)
mp3_path = os.path.splitext(local_video_path)[0] + ".mp3" logger.info("Saving to DB...")
cmd = [ await model.update_record(request_id, title, obs_url, transcript, "COMPLETED")
"ffmpeg", "-y", "-i", local_video_path, logger.info(f"Task {request_id} completed successfully.")
"-acodec", "libmp3lame", "-ar", "16000", "-ac", "1", "-q:a", "2",
mp3_path except Exception as e:
] logger.error(f"Task {request_id} failed: {e}", exc_info=True)
# Run ffmpeg in thread await model.update_status(request_id, "FAILED", str(e))
result = await asyncio.to_thread(subprocess.run, cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) finally:
if result.returncode != 0: # 8. Cleanup
raise Exception(f"FFmpeg failed: {result.stderr.decode()}") if os.path.exists(temp_dir):
try:
# 5. ASR (Upload MP3 to tmp and transcribe) # shutil.rmtree is sync, wrap it
logger.info("Transcribing...") await asyncio.to_thread(shutil.rmtree, temp_dir, ignore_errors=True)
asr = ASRClient() except Exception as e:
# Run ASR in thread logger.error(f"Cleanup failed: {e}")
transcript = await asyncio.to_thread(asr.upload_and_transcribe_sync, mp3_path)
@router.post("/api/parse")
if not transcript: async def parse(request: ParseRequest, background_tasks: BackgroundTasks):
raise Exception("Transcription failed (returned empty)") downloader = DouYinDownloader()
urls = downloader.extract_urls(request.text)
# 6. LLM Title Generation (Enhancement)
# If the title is still Unknown or weak, OR if we just want to ensure we have a good title. if not urls:
# The user said: "Alternatively, call LlmUtil.py to summarize title". # If no URLs found, try using the text as is (might be a direct link not caught by regex)
# Let's do it if title is Unknown or matches default filename pattern, OR if extracted title was also missing. # But regex is quite broad. Let's just fail or try one.
if (not title or title == "Unknown Title" or title == "Unknown"): # Let's assume text might be the URL if it's clean.
try: if request.text.startswith("http"):
logger.info("Generating title from transcript via LLM...") urls = [request.text]
prompt = f"请根据以下视频文案总结一个简短的标题(20字以内),不要包含任何解释性文字,直接返回标题:\n\n{transcript[:1000]}" else:
raise HTTPException(status_code=400, detail="No valid URLs found")
llm_title_chunks = []
# get_llm_response is already async created_ids = []
async for chunk in get_llm_response(prompt, stream=False): try:
llm_title_chunks.append(chunk) model = DouYinModel()
llm_title = "".join(llm_title_chunks) for url in urls:
req_id = str(uuid.uuid4())
if llm_title: await model.insert_record(req_id, url)
# Clean up quotes if any created_ids.append(req_id)
llm_title = llm_title.strip().strip('"').strip('“').strip('”') # Pass request.text (the full share text) so we can extract title from it
logger.info(f"LLM generated title: {llm_title}") background_tasks.add_task(process_video_task, url, req_id, request.text)
# We overwrite the title if LLM succeeds
title = llm_title except Exception as e:
except Exception as llm_e: raise HTTPException(status_code=500, detail=f"DB Init Error: {e}")
logger.warning(f"LLM Title generation failed: {llm_e}")
return {"id": created_ids[0] if created_ids else None, "ids": created_ids, "status": "PENDING"}
# 7. Save to DB (Update)
logger.info("Saving to DB...") @router.get("/api/records")
await asyncio.to_thread(update_record, request_id, title, obs_url, transcript, "COMPLETED") async def get_records():
logger.info(f"Task {request_id} completed successfully.") try:
model = DouYinModel()
except Exception as e: records = await model.get_records()
logger.error(f"Task {request_id} failed: {e}", exc_info=True) return records
await asyncio.to_thread(update_status, request_id, "FAILED", str(e)) except Exception as e:
finally: logger.error(f"Get records error: {e}", exc_info=True)
# 8. Cleanup return []
if os.path.exists(temp_dir):
try: @router.delete("/api/records/{id}")
# shutil.rmtree is sync, wrap it async def delete_record(id: str):
await asyncio.to_thread(shutil.rmtree, temp_dir, ignore_errors=True) try:
except Exception as e: model = DouYinModel()
logger.error(f"Cleanup failed: {e}") await model.delete_record(id)
return {"status": "deleted"}
@router.post("/api/parse") except Exception as e:
def parse(request: ParseRequest, background_tasks: BackgroundTasks): raise HTTPException(status_code=500, detail=str(e))
downloader = DouYinDownloader()
urls = downloader.extract_urls(request.text) @router.post("/api/douyin/summary")
async def generate_summary(request: SummaryRequest):
if not urls: try:
# If no URLs found, try using the text as is (might be a direct link not caught by regex) # Fetch transcripts
# But regex is quite broad. Let's just fail or try one. model = DouYinModel()
# Let's assume text might be the URL if it's clean. records = await model.get_transcripts(ids=request.ids)
if request.text.startswith("http"):
urls = [request.text] if not records:
else: # If no records, just return a simple message stream
raise HTTPException(status_code=400, detail="No valid URLs found") async def empty_stream():
yield "未找到可总结的已完成记录,请先解析视频。"
created_ids = [] return StreamingResponse(empty_stream(), media_type="text/event-stream")
try:
conn = get_db_connection() # Prepare text
cursor = conn.cursor() full_text = ""
for r in records:
for url in urls: if r['transcript']:
req_id = str(uuid.uuid4()) full_text += f"【标题:{r['video_name']}】\n内容:{r['transcript']}\n\n"
sql = """
INSERT INTO t_douyin_record (id, original_text, status, create_time) if not full_text:
VALUES (%s, %s, 'PENDING', %s) async def empty_text_stream():
""" yield "记录中没有有效的文案内容。"
cursor.execute(sql, (req_id, url, datetime.now())) return StreamingResponse(empty_text_stream(), media_type="text/event-stream")
created_ids.append(req_id)
# Pass request.text (the full share text) so we can extract title from it # Prompt
background_tasks.add_task(process_video_task, url, req_id, request.text) prompt = f"""
请对以下充电行业相关的视频内容进行知识精华提取。
conn.commit() 要求:
conn.close() 1. 忽略无关闲聊和口语化表达;
except Exception as e: 2. 按条目列出核心知识点,不要长篇大论;
raise HTTPException(status_code=500, detail=f"DB Init Error: {e}") 3. 保持简洁专业,只保留干货;
4. 返回格式为Markdown列表。
return {"id": created_ids[0] if created_ids else None, "ids": created_ids, "status": "PENDING"}
内容如下:
@router.get("/api/records") {full_text[:15000]}
def get_records(): """
try:
conn = get_db_connection() # Limit context to avoid errors, 15000 chars is roughly safe for most models,
cursor = conn.cursor() # but if using a small model, might need less. Assuming robust model.
cursor.execute("SELECT * FROM t_douyin_record ORDER BY create_time DESC LIMIT 50")
records = cursor.fetchall() return StreamingResponse(get_llm_response(prompt), media_type="text/event-stream")
conn.close()
except Exception as e:
# Manually handle datetime serialization to be safe logger.error(f"Summary generation failed: {e}")
for r in records: raise HTTPException(status_code=500, detail=str(e))
if 'create_time' in r and r['create_time']:
r['create_time'] = r['create_time'].strftime("%Y-%m-%d %H:%M:%S") async def recover_pending_tasks():
if 'update_time' in r and r['update_time']: """
r['update_time'] = r['update_time'].strftime("%Y-%m-%d %H:%M:%S") Check for tasks stuck in PENDING or PROCESSING state (due to server restart)
and restart them.
return records """
except Exception as e: logger.info("Scanning for interrupted Douyin tasks...")
logger.error(f"Get records error: {e}", exc_info=True) try:
return [] model = DouYinModel()
tasks = await model.get_interrupted_tasks()
@router.delete("/api/records/{id}")
def delete_record(id: str): if not tasks:
try: logger.info("No interrupted tasks found.")
conn = get_db_connection() return
cursor = conn.cursor()
cursor.execute("DELETE FROM t_douyin_record WHERE id=%s", (id,)) logger.info(f"Found {len(tasks)} interrupted tasks. Restarting...")
conn.commit() for task in tasks:
conn.close() req_id = task['id']
return {"status": "deleted"} url = task['original_text']
except Exception as e: # Restart task in background
raise HTTPException(status_code=500, detail=str(e)) # Note: We lost the original share text for title extraction,
# so we pass empty string. It will use the URL or 'Unknown Title'.
@router.post("/api/douyin/summary") # If LLM is enabled, it might fix the title later.
async def generate_summary(request: SummaryRequest): asyncio.create_task(process_video_task(url, req_id, share_text=""))
try:
# Fetch transcripts except Exception as e:
conn = get_db_connection()
cursor = conn.cursor()
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 no records, just return a simple message stream
async def empty_stream():
yield "未找到可总结的已完成记录,请先解析视频。"
return StreamingResponse(empty_stream(), media_type="text/event-stream")
# Prepare text
full_text = ""
for r in records:
if r['transcript']:
full_text += f"【标题:{r['video_name']}】\n内容:{r['transcript']}\n\n"
if not full_text:
async def empty_text_stream():
yield "记录中没有有效的文案内容。"
return StreamingResponse(empty_text_stream(), media_type="text/event-stream")
# Prompt
prompt = f"""
请对以下充电行业相关的视频内容进行知识精华提取。
要求:
1. 忽略无关闲聊和口语化表达;
2. 按条目列出核心知识点,不要长篇大论;
3. 保持简洁专业,只保留干货;
4. 返回格式为Markdown列表。
内容如下:
{full_text[:15000]}
"""
# Limit context to avoid errors, 15000 chars is roughly safe for most models,
# but if using a small model, might need less. Assuming robust model.
return StreamingResponse(get_llm_response(prompt), media_type="text/event-stream")
except Exception as e:
logger.error(f"Summary generation failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
async def recover_pending_tasks():
"""
Check for tasks stuck in PENDING or PROCESSING state (due to server restart)
and restart them.
"""
logger.info("Scanning for interrupted Douyin tasks...")
try:
# Use asyncio.to_thread for DB operation
def fetch_pending():
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:
logger.info("No interrupted tasks found.")
return
logger.info(f"Found {len(tasks)} interrupted tasks. Restarting...")
for task in tasks:
req_id = task['id']
url = task['original_text']
# Restart task in background
# Note: We lost the original share text for title extraction,
# so we pass empty string. It will use the URL or 'Unknown Title'.
# If LLM is enabled, it might fix the title later.
asyncio.create_task(process_video_task(url, req_id, share_text=""))
except Exception as e:
logger.error(f"Failed to recover tasks: {e}", exc_info=True) logger.error(f"Failed to recover tasks: {e}", exc_info=True)

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

@@ -489,6 +489,10 @@ class SqlTemplateLoader:
template_map_str = await redisKit.get_data(REDIS_SQL_TEMPLATE_MAP_KEY) template_map_str = await redisKit.get_data(REDIS_SQL_TEMPLATE_MAP_KEY)
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:
@@ -108,6 +116,34 @@ class RedisKit:
except Exception as e: except Exception as e:
logger.error(f"保存数据到Redis失败(key={key}): {e}") logger.error(f"保存数据到Redis失败(key={key}): {e}")
return False return False
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> # ȫ<><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>