'commit'
This commit is contained in:
@@ -97,27 +97,30 @@ async def export_prices_zip():
|
|||||||
|
|
||||||
@router.get("/api/ai/pricing/strategy-summary")
|
@router.get("/api/ai/pricing/strategy-summary")
|
||||||
async def ai_pricing_strategy_summary():
|
async def ai_pricing_strategy_summary():
|
||||||
|
async def generate_stream():
|
||||||
resp = await get_operators_hourly_prices()
|
resp = await get_operators_hourly_prices()
|
||||||
data = resp.get("operators", [])
|
data = resp.get("operators", [])
|
||||||
text_data = []
|
text_data = []
|
||||||
for item in data:
|
for item in data:
|
||||||
text_data.append({"operator": item.get("operator"), "series": item.get("series")})
|
text_data.append({"operator": item.get("operator"), "series": item.get("series")})
|
||||||
text = (
|
|
||||||
|
prompt = (
|
||||||
"下面是四家供应商(新电途、特来电、驿来特、艾特吉易充)基于最新爬取数据计算出的平均24小时分时电价:\n"
|
"下面是四家供应商(新电途、特来电、驿来特、艾特吉易充)基于最新爬取数据计算出的平均24小时分时电价:\n"
|
||||||
f"{text_data}\n"
|
f"{text_data}\n"
|
||||||
"请根据这些数据,综合分析各司的定价策略差异,重点对比我司(驿来特)与其他供应商的分时电价水平,"
|
"请根据这些数据,综合分析各司的定价策略差异,重点对比我司(驿来特)与其他供应商的分时电价水平,"
|
||||||
"指出我司在不同时段可能存在的潜在问题和风险(例如明显偏贵、价格结构不合理等),并给出2-3条可执行的优化建议。"
|
"指出我司在不同时段可能存在的潜在问题和风险(例如明显偏贵、价格结构不合理等),并给出2-3条可执行的优化建议。"
|
||||||
"回答控制在600字以内。"
|
"请使用Markdown格式输出,重点可以使用加粗,如有公式可使用LaTeX格式。"
|
||||||
|
"回答控制在800字以内。"
|
||||||
)
|
)
|
||||||
chunks: List[str] = []
|
|
||||||
async for chunk in get_llm_response(
|
async for chunk in get_llm_response(
|
||||||
text,
|
prompt,
|
||||||
stream=False,
|
stream=True,
|
||||||
system_prompt="你是熟悉中国充电桩行业的电价策略分析顾问。",
|
system_prompt="你是熟悉中国充电桩行业的电价策略分析顾问。",
|
||||||
):
|
):
|
||||||
chunks.append(chunk)
|
yield chunk
|
||||||
summary_text = "".join(chunks)
|
|
||||||
return JSONResponse({"summary": summary_text})
|
return StreamingResponse(generate_stream(), media_type="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/ylt/stations", response_model=List[StationBase])
|
@router.get("/api/ylt/stations", response_model=List[StationBase])
|
||||||
|
|||||||
Binary file not shown.
@@ -1,28 +1,296 @@
|
|||||||
body{margin:0;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#050816;color:#e5e7eb;}
|
/* Modern Dark Theme - Slate/Blue Palette */
|
||||||
#app{display:flex;flex-direction:row;height:100vh;}
|
:root {
|
||||||
.left-panel{flex:3;padding:16px;box-sizing:border-box;display:flex;flex-direction:column;gap:12px;}
|
--bg-color: #0f172a; /* Slate 900 */
|
||||||
.right-panel{flex:2;padding:16px;box-sizing:border-box;border-left:1px solid #111827;background:#020617;}
|
--card-bg: #1e293b; /* Slate 800 */
|
||||||
.controls{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:4px;align-items:center;}
|
--card-border: #334155; /* Slate 700 */
|
||||||
.controls input{padding:6px 8px;border-radius:4px;border:1px solid #374151;background:#020617;color:#e5e7eb;}
|
--text-primary: #f1f5f9; /* Slate 100 */
|
||||||
.controls button{padding:6px 12px;border-radius:4px;border:none;background:#2563eb;color:#f9fafb;cursor:pointer;}
|
--text-secondary: #94a3b8; /* Slate 400 */
|
||||||
.controls button:disabled{opacity:.5;cursor:not-allowed;}
|
--accent-color: #3b82f6; /* Blue 500 */
|
||||||
.controls-spacer{flex:1;}
|
--accent-hover: #2563eb; /* Blue 600 */
|
||||||
.station-list{flex:0 0 220px;border:1px solid #111827;border-radius:8px;background:#020617;overflow:hidden;display:flex;flex-direction:column;}
|
--success-color: #10b981; /* Emerald 500 */
|
||||||
.station-list-header{padding:8px 12px;border-bottom:1px solid #111827;font-size:14px;background:#020617;}
|
--table-header-bg: #0f172a;
|
||||||
.station-table{width:100%;border-collapse:collapse;font-size:13px;}
|
--table-row-hover: #334155;
|
||||||
.station-table th,.station-table td{padding:6px 8px;border-bottom:1px solid #111827;}
|
--scrollbar-thumb: #475569;
|
||||||
.station-table th{background:#020617;color:#9ca3af;text-align:left;}
|
--scrollbar-track: #0f172a;
|
||||||
.station-row{cursor:pointer;}
|
}
|
||||||
.station-row:hover{background:#111827;}
|
|
||||||
.station-row.active{background:#1d283a;}
|
body {
|
||||||
#chart{flex:1;border:1px solid #111827;border-radius:8px;background:#0b1120;}
|
margin: 0;
|
||||||
.price-table{flex:0 0 260px;border:1px solid #111827;border-radius:8px;background:#020617;padding:8px;box-sizing:border-box;overflow:auto;}
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
.price-table-title{font-size:14px;margin-bottom:4px;}
|
background-color: var(--bg-color);
|
||||||
.price-table-inner{width:100%;border-collapse:collapse;font-size:12px;}
|
color: var(--text-primary);
|
||||||
.price-table-inner th,.price-table-inner td{padding:4px 6px;border-bottom:1px solid #111827;}
|
height: 100vh;
|
||||||
.price-table-inner th{background:#020617;color:#9ca3af;text-align:right;}
|
overflow: hidden;
|
||||||
.price-table-inner th:first-child,.price-table-inner td:first-child{text-align:left;}
|
background-image: radial-gradient(circle at 50% 0%, #1e293b 0%, #0f172a 100%);
|
||||||
.ai-title{font-size:16px;margin-bottom:8px;}
|
}
|
||||||
.ai-box{height:calc(100% - 40px);border-radius:8px;border:1px solid #111827;background:#020617;padding:12px;box-sizing:border-box;white-space:pre-wrap;overflow:auto;font-size:14px;line-height:1.6;}
|
|
||||||
.label{font-size:14px;margin-right:8px;}
|
#app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column; /* Changed to column to include header */
|
||||||
|
height: 100vh;
|
||||||
|
padding: 16px;
|
||||||
|
gap: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header Styling */
|
||||||
|
.dashboard-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(to right, #60a5fa, #a78bfa);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Layout */
|
||||||
|
.main-content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 0; /* Important for nested scrolling */
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel {
|
||||||
|
flex: 3;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panel {
|
||||||
|
flex: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
padding: 20px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, var(--accent-color), var(--accent-hover));
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 6px rgba(59, 130, 246, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
filter: grayscale(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Station List / Table */
|
||||||
|
.station-list {
|
||||||
|
flex: 1; /* Give it space */
|
||||||
|
min-height: 0;
|
||||||
|
@apply card; /* Conceptually using card styles */
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-list-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: rgba(15, 23, 42, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate; /* Changed for border-radius support if needed, but sticky needs collapse usually or specific handling. Keeping collapse for alignment. */
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-table th {
|
||||||
|
background-color: var(--table-header-bg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
padding: 12px 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-table tbody tr:hover td {
|
||||||
|
background-color: var(--table-row-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.station-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chart */
|
||||||
|
#chart {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 300px;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* AI Analysis Section */
|
||||||
|
.ai-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-title::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 4px;
|
||||||
|
height: 18px;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-box {
|
||||||
|
flex: 1;
|
||||||
|
background-color: rgba(15, 23, 42, 0.3); /* Slightly darker inner bg */
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #cbd5e1; /* Slate 300 */
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--scrollbar-thumb) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-question {
|
||||||
|
background-color: rgba(59, 130, 246, 0.1);
|
||||||
|
border-left: 3px solid var(--accent-color);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-question .label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-question .question-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-result {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown Styling in AI Result */
|
||||||
|
.ai-result h1, .ai-result h2, .ai-result h3 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ai-result strong {
|
||||||
|
color: #60a5fa; /* Blue 400 */
|
||||||
|
}
|
||||||
|
.ai-result ul {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
.ai-result li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.ai-result p {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar Webkit */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scrollbar-thumb);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,53 +4,73 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>多供应商分时电价分析大屏</title>
|
<title>多供应商分时电价分析大屏</title>
|
||||||
<link rel="stylesheet" href="css/app.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/marked/12.0.0/marked.min.js"></script>
|
||||||
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<div class="left-panel">
|
<header class="dashboard-header">
|
||||||
|
<div class="dashboard-title">⚡ 充电站电价分析驾驶舱</div>
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<span class="label">选择供应商</span>
|
<button class="btn-primary" @click="exportAllPrices" :disabled="exporting">
|
||||||
<select v-model="selectedOperator" @change="loadOperatorPrices">
|
<span v-if="!exporting">📊 一键导出分析报告</span>
|
||||||
<option v-for="op in operators" :key="op.value" :value="op.value">{{ op.label }}</option>
|
<span v-else>⏳ 导出中...</span>
|
||||||
</select>
|
</button>
|
||||||
<button @click="loadAllOperatorsPrices" :disabled="loading">查询四家最新24小时电价</button>
|
|
||||||
<div class="controls-spacer"></div>
|
|
||||||
<button @click="exportAllPrices" :disabled="exporting">一键导出各供应商电价</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="main-content">
|
||||||
|
<div class="left-panel">
|
||||||
<div class="station-list">
|
<div class="station-list">
|
||||||
<div class="station-list-header">四家供应商24小时平均电价对比</div>
|
<div class="station-list-header">全网供应商24小时电价监控</div>
|
||||||
|
<div class="station-table-container">
|
||||||
<table class="station-table">
|
<table class="station-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:20%;">小时</th>
|
<th style="width:15%;">时段</th>
|
||||||
<th v-for="op in operators" :key="op.value" style="width:20%;">{{ op.label }}</th>
|
<th v-for="op in operators" :key="op.value" :style="{width: (85/operators.length) + '%'}">{{ op.label }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(row, idx) in priceTableRows" :key="idx">
|
<tr v-for="(row, idx) in priceTableRows" :key="idx">
|
||||||
<td>{{ row.hour }}</td>
|
<td style="font-family: monospace; color: var(--text-secondary);">{{ row.hour }}</td>
|
||||||
<td v-for="cell in row.values" :key="cell.operator">{{ formatCell(cell.price) }}</td>
|
<td v-for="cell in row.values" :key="cell.operator" :style="{color: getPriceColor(cell.price)}">
|
||||||
|
{{ formatCell(cell.price) }}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="priceTableRows.length===0">
|
<tr v-if="priceTableRows.length===0">
|
||||||
<td :colspan="operators.length + 1">暂无数据,请先查询</td>
|
<td :colspan="operators.length + 1" style="text-align:center; padding: 32px; color: var(--text-secondary);">
|
||||||
|
数据加载中...
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div id="chart"></div>
|
<div id="chart"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="right-panel">
|
<div class="right-panel">
|
||||||
<div class="ai-title">AI 辅助分析</div>
|
<div class="ai-title">智能决策分析助手</div>
|
||||||
<div class="ai-box">
|
<div class="ai-box" ref="aiBoxRef">
|
||||||
<div class="ai-question">
|
<div class="ai-question">
|
||||||
<div class="label">默认问题</div>
|
<div class="label">当前分析任务</div>
|
||||||
<div class="question-text">
|
<div class="question-text">
|
||||||
请根据爬取的各供应商分时电价等信息,对各司的定价策略,
|
请根据爬取的各供应商分时电价等信息,对各司的定价策略,
|
||||||
与我司(驿来特)的定价策略进行综合对比,分析我司可能存在的潜在问题。
|
与我司(驿来特)的定价策略进行综合对比,分析我司可能存在的潜在问题。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button @click="startAiAnalysis" :disabled="aiLoading">发起AI综合分析</button>
|
|
||||||
<div class="ai-result">{{ aiText || placeholder }}</div>
|
<div style="text-align: center; margin: 10px 0;">
|
||||||
|
<button class="btn-primary" @click="startAiAnalysis" :disabled="aiLoading" style="width: 100%;">
|
||||||
|
{{ aiLoading ? '✨ AI 正在深入分析...' : '🚀 发起 AI 综合诊断' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ai-result" v-html="renderedAiText || placeholder" style="opacity: 0.9;"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
109
static/js/app.js
109
static/js/app.js
@@ -13,7 +13,9 @@ const selectedOperator = ref("驿来特")
|
|||||||
const aiText = ref("")
|
const aiText = ref("")
|
||||||
const placeholder = ref("点击左侧按钮查询四家供应商最新24小时分时电价,再使用右侧AI综合分析功能对各司定价策略进行对比。")
|
const placeholder = ref("点击左侧按钮查询四家供应商最新24小时分时电价,再使用右侧AI综合分析功能对各司定价策略进行对比。")
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const exporting = ref(false)
|
||||||
const aiLoading = ref(false)
|
const aiLoading = ref(false)
|
||||||
|
const aiBoxRef = ref(null)
|
||||||
const priceTableRows = ref([])
|
const priceTableRows = ref([])
|
||||||
const hourlyPricesByOperator = ref({})
|
const hourlyPricesByOperator = ref({})
|
||||||
let chartInstance = null
|
let chartInstance = null
|
||||||
@@ -48,11 +50,33 @@ return {name: op.label, type:"line", smooth:true, data:seriesData}
|
|||||||
})
|
})
|
||||||
const hours = rows.map(r => r.hour)
|
const hours = rows.map(r => r.hour)
|
||||||
const option = {
|
const option = {
|
||||||
tooltip:{trigger:"axis"},
|
backgroundColor: 'transparent',
|
||||||
legend:{data:operators.value.map(o=>o.label),textStyle:{color:"#e5e7eb"}},
|
tooltip: {
|
||||||
xAxis:{type:"category",data:hours,axisLine:{lineStyle:{color:"#4b5563"}},axisLabel:{color:"#9ca3af"}},
|
trigger: "axis",
|
||||||
yAxis:{type:"value",name:"元/度",axisLine:{lineStyle:{color:"#4b5563"}},axisLabel:{color:"#9ca3af"},splitLine:{lineStyle:{color:"#111827"}}},
|
backgroundColor: 'rgba(30, 41, 59, 0.9)',
|
||||||
grid:{left:40,right:20,top:30,bottom:30},
|
borderColor: '#334155',
|
||||||
|
textStyle: { color: '#f1f5f9' }
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
data: operators.value.map(o => o.label),
|
||||||
|
textStyle: { color: "#94a3b8" },
|
||||||
|
bottom: 0
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: "category",
|
||||||
|
data: hours,
|
||||||
|
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' } }
|
||||||
|
},
|
||||||
|
grid: { left: 50, right: 30, top: 40, bottom: 40 },
|
||||||
series: series
|
series: series
|
||||||
}
|
}
|
||||||
chartInstance.setOption(option)
|
chartInstance.setOption(option)
|
||||||
@@ -78,14 +102,9 @@ loading.value = false
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadOperatorPrices = async () => {
|
|
||||||
if (!selectedOperator.value) return
|
|
||||||
await loadAllOperatorsPrices()
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportAllPrices = async () => {
|
const exportAllPrices = async () => {
|
||||||
try{
|
try{
|
||||||
loading.value = true
|
exporting.value = true
|
||||||
const res = await axios.get(apiBase.value + "/api/export/prices-zip",{responseType:"blob"})
|
const res = await axios.get(apiBase.value + "/api/export/prices-zip",{responseType:"blob"})
|
||||||
const blob = new Blob([res.data],{type:"application/zip"})
|
const blob = new Blob([res.data],{type:"application/zip"})
|
||||||
const url = window.URL.createObjectURL(blob)
|
const url = window.URL.createObjectURL(blob)
|
||||||
@@ -99,7 +118,7 @@ window.URL.revokeObjectURL(url)
|
|||||||
}catch(e){
|
}catch(e){
|
||||||
console.error(e)
|
console.error(e)
|
||||||
}finally{
|
}finally{
|
||||||
loading.value = false
|
exporting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,15 +127,68 @@ if (aiLoading.value) return
|
|||||||
aiText.value = ""
|
aiText.value = ""
|
||||||
aiLoading.value = true
|
aiLoading.value = true
|
||||||
try{
|
try{
|
||||||
const res = await axios.get(apiBase.value + "/api/ai/pricing/strategy-summary")
|
const response = await fetch(apiBase.value + "/api/ai/pricing/strategy-summary")
|
||||||
aiText.value = res.data && res.data.summary ? res.data.summary : ""
|
if (!response.body) return
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const {done, value} = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
const chunk = decoder.decode(value, {stream: true})
|
||||||
|
aiText.value += chunk
|
||||||
|
await Vue.nextTick()
|
||||||
|
if (aiBoxRef.value) {
|
||||||
|
aiBoxRef.value.scrollTop = aiBoxRef.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
}catch(e){
|
}catch(e){
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
aiText.value += "\n(分析过程出错: " + e.message + ")"
|
||||||
}finally{
|
}finally{
|
||||||
aiLoading.value = false
|
aiLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const renderedAiText = Vue.computed(() => {
|
||||||
|
if (!aiText.value) return ""
|
||||||
|
try {
|
||||||
|
// 配置 marked
|
||||||
|
marked.setOptions({
|
||||||
|
breaks: true,
|
||||||
|
gfm: true
|
||||||
|
});
|
||||||
|
let html = marked.parse(aiText.value);
|
||||||
|
// 简单处理LaTeX (如果有需要的话,可以引入更复杂的处理,这里假设Katex auto-render会处理或者手动处理)
|
||||||
|
// 由于是computed,我们返回html,然后通过v-html渲染。
|
||||||
|
// v-html更新后,Vue不保证脚本执行。
|
||||||
|
// 我们可以在updated hook里调用renderMathInElement,或者这里仅仅返回html。
|
||||||
|
// 为了支持Latex,我们可以在watch aiText时手动更新DOM,或者使用nextTick。
|
||||||
|
// 简易方案:返回HTML,由Vue渲染。如果需要Latex渲染,可以在watch里做。
|
||||||
|
return html;
|
||||||
|
} catch (e) {
|
||||||
|
return aiText.value;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 aiText 变化以渲染 Latex
|
||||||
|
Vue.watch(aiText, () => {
|
||||||
|
Vue.nextTick(() => {
|
||||||
|
const el = document.querySelector('.ai-result');
|
||||||
|
if (el && window.renderMathInElement) {
|
||||||
|
renderMathInElement(el, {
|
||||||
|
delimiters: [
|
||||||
|
{left: '$$', right: '$$', display: true},
|
||||||
|
{left: '$', right: '$', display: false},
|
||||||
|
{left: '\\(', right: '\\)', display: false},
|
||||||
|
{left: '\\[', right: '\\]', display: true}
|
||||||
|
],
|
||||||
|
throwOnError: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(()=>{
|
onMounted(()=>{
|
||||||
initChart()
|
initChart()
|
||||||
loadAllOperatorsPrices()
|
loadAllOperatorsPrices()
|
||||||
@@ -131,6 +203,13 @@ return v.toFixed(2)
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
return {apiBase,operators,selectedOperator,aiText,placeholder,loading,aiLoading,priceTableRows,loadOperatorPrices,loadAllOperatorsPrices,exportAllPrices,startAiAnalysis,formatCell}
|
const getPriceColor = (price) => {
|
||||||
|
if (price === null || price === undefined) return 'inherit'
|
||||||
|
if (price > 1.2) return '#ef4444' // Red-500
|
||||||
|
if (price < 0.8) return '#10b981' // Emerald-500
|
||||||
|
return '#f1f5f9' // Slate-100
|
||||||
|
}
|
||||||
|
|
||||||
|
return {apiBase,operators,aiText,placeholder,loading,exporting,aiLoading,priceTableRows,loadAllOperatorsPrices,exportAllPrices,startAiAnalysis,formatCell,getPriceColor,renderedAiText,aiBoxRef}
|
||||||
}
|
}
|
||||||
}).mount("#app")
|
}).mount("#app")
|
||||||
|
|||||||
Reference in New Issue
Block a user