This commit is contained in:
HuangHai
2026-01-20 07:31:23 +08:00
parent b20b778f5d
commit 6263487425
5 changed files with 483 additions and 113 deletions

View File

@@ -97,27 +97,30 @@ async def export_prices_zip():
@router.get("/api/ai/pricing/strategy-summary")
async def ai_pricing_strategy_summary():
resp = await get_operators_hourly_prices()
data = resp.get("operators", [])
text_data = []
for item in data:
text_data.append({"operator": item.get("operator"), "series": item.get("series")})
text = (
"下面是四家供应商新电途、特来电、驿来特、艾特吉易充基于最新爬取数据计算出的平均24小时分时电价\n"
f"{text_data}\n"
"请根据这些数据,综合分析各司的定价策略差异,重点对比我司(驿来特)与其他供应商的分时电价水平,"
"指出我司在不同时段可能存在的潜在问题和风险例如明显偏贵、价格结构不合理等并给出2-3条可执行的优化建议。"
"回答控制在600字以内。"
)
chunks: List[str] = []
async for chunk in get_llm_response(
text,
stream=False,
system_prompt="你是熟悉中国充电桩行业的电价策略分析顾问。",
):
chunks.append(chunk)
summary_text = "".join(chunks)
return JSONResponse({"summary": summary_text})
async def generate_stream():
resp = await get_operators_hourly_prices()
data = resp.get("operators", [])
text_data = []
for item in data:
text_data.append({"operator": item.get("operator"), "series": item.get("series")})
prompt = (
"下面是四家供应商新电途、特来电、驿来特、艾特吉易充基于最新爬取数据计算出的平均24小时分时电价\n"
f"{text_data}\n"
"请根据这些数据,综合分析各司的定价策略差异,重点对比我司(驿来特)与其他供应商的分时电价水平,"
"指出我司在不同时段可能存在的潜在问题和风险例如明显偏贵、价格结构不合理等并给出2-3条可执行的优化建议。"
"请使用Markdown格式输出重点可以使用加粗如有公式可使用LaTeX格式。"
"回答控制在800字以内。"
)
async for chunk in get_llm_response(
prompt,
stream=True,
system_prompt="你是熟悉中国充电桩行业的电价策略分析顾问。",
):
yield chunk
return StreamingResponse(generate_stream(), media_type="text/event-stream")
@router.get("/api/ylt/stations", response_model=List[StationBase])

View File

@@ -1,28 +1,296 @@
body{margin:0;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#050816;color:#e5e7eb;}
#app{display:flex;flex-direction:row;height:100vh;}
.left-panel{flex:3;padding:16px;box-sizing:border-box;display:flex;flex-direction:column;gap:12px;}
.right-panel{flex:2;padding:16px;box-sizing:border-box;border-left:1px solid #111827;background:#020617;}
.controls{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:4px;align-items:center;}
.controls input{padding:6px 8px;border-radius:4px;border:1px solid #374151;background:#020617;color:#e5e7eb;}
.controls button{padding:6px 12px;border-radius:4px;border:none;background:#2563eb;color:#f9fafb;cursor:pointer;}
.controls button:disabled{opacity:.5;cursor:not-allowed;}
.controls-spacer{flex:1;}
.station-list{flex:0 0 220px;border:1px solid #111827;border-radius:8px;background:#020617;overflow:hidden;display:flex;flex-direction:column;}
.station-list-header{padding:8px 12px;border-bottom:1px solid #111827;font-size:14px;background:#020617;}
.station-table{width:100%;border-collapse:collapse;font-size:13px;}
.station-table th,.station-table td{padding:6px 8px;border-bottom:1px solid #111827;}
.station-table th{background:#020617;color:#9ca3af;text-align:left;}
.station-row{cursor:pointer;}
.station-row:hover{background:#111827;}
.station-row.active{background:#1d283a;}
#chart{flex:1;border:1px solid #111827;border-radius:8px;background:#0b1120;}
.price-table{flex:0 0 260px;border:1px solid #111827;border-radius:8px;background:#020617;padding:8px;box-sizing:border-box;overflow:auto;}
.price-table-title{font-size:14px;margin-bottom:4px;}
.price-table-inner{width:100%;border-collapse:collapse;font-size:12px;}
.price-table-inner th,.price-table-inner td{padding:4px 6px;border-bottom:1px solid #111827;}
.price-table-inner th{background:#020617;color:#9ca3af;text-align:right;}
.price-table-inner th:first-child,.price-table-inner td:first-child{text-align:left;}
.ai-title{font-size:16px;margin-bottom:8px;}
.ai-box{height:calc(100% - 40px);border-radius:8px;border:1px solid #111827;background:#020617;padding:12px;box-sizing:border-box;white-space:pre-wrap;overflow:auto;font-size:14px;line-height:1.6;}
.label{font-size:14px;margin-right:8px;}
/* Modern Dark Theme - Slate/Blue Palette */
:root {
--bg-color: #0f172a; /* Slate 900 */
--card-bg: #1e293b; /* Slate 800 */
--card-border: #334155; /* Slate 700 */
--text-primary: #f1f5f9; /* Slate 100 */
--text-secondary: #94a3b8; /* Slate 400 */
--accent-color: #3b82f6; /* Blue 500 */
--accent-hover: #2563eb; /* Blue 600 */
--success-color: #10b981; /* Emerald 500 */
--table-header-bg: #0f172a;
--table-row-hover: #334155;
--scrollbar-thumb: #475569;
--scrollbar-track: #0f172a;
}
body {
margin: 0;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: var(--bg-color);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
background-image: radial-gradient(circle at 50% 0%, #1e293b 0%, #0f172a 100%);
}
#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;
}

View File

@@ -4,55 +4,75 @@
<meta charset="utf-8">
<title>多供应商分时电价分析大屏</title>
<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>
<body>
<div id="app">
<div class="left-panel">
<div class="controls">
<span class="label">选择供应商</span>
<select v-model="selectedOperator" @change="loadOperatorPrices">
<option v-for="op in operators" :key="op.value" :value="op.value">{{ op.label }}</option>
</select>
<button @click="loadAllOperatorsPrices" :disabled="loading">查询四家最新24小时电价</button>
<div class="controls-spacer"></div>
<button @click="exportAllPrices" :disabled="exporting">一键导出各供应商电价</button>
</div>
<div class="station-list">
<div class="station-list-header">四家供应商24小时平均电价对比</div>
<table class="station-table">
<thead>
<tr>
<th style="width:20%;">小时</th>
<th v-for="op in operators" :key="op.value" style="width:20%;">{{ op.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in priceTableRows" :key="idx">
<td>{{ row.hour }}</td>
<td v-for="cell in row.values" :key="cell.operator">{{ formatCell(cell.price) }}</td>
</tr>
<tr v-if="priceTableRows.length===0">
<td :colspan="operators.length + 1">暂无数据,请先查询</td>
</tr>
</tbody>
</table>
</div>
<div id="chart"></div>
</div>
<div class="right-panel">
<div class="ai-title">AI 辅助分析</div>
<div class="ai-box">
<div class="ai-question">
<div class="label">默认问题</div>
<div class="question-text">
请根据爬取的各供应商分时电价等信息,对各司的定价策略,
与我司(驿来特)的定价策略进行综合对比,分析我司可能存在的潜在问题。
</div>
</div>
<button @click="startAiAnalysis" :disabled="aiLoading">发起AI综合分析</button>
<div class="ai-result">{{ aiText || placeholder }}</div>
</div>
</div>
<header class="dashboard-header">
<div class="dashboard-title">⚡ 充电站电价分析驾驶舱</div>
<div class="controls">
<button class="btn-primary" @click="exportAllPrices" :disabled="exporting">
<span v-if="!exporting">📊 一键导出分析报告</span>
<span v-else>⏳ 导出中...</span>
</button>
</div>
</header>
<div class="main-content">
<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">智能决策分析助手</div>
<div class="ai-box" ref="aiBoxRef">
<div class="ai-question">
<div class="label">当前分析任务</div>
<div class="question-text">
请根据爬取的各供应商分时电价等信息,对各司的定价策略,
与我司(驿来特)的定价策略进行综合对比,分析我司可能存在的潜在问题。
</div>
</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>
<script src="js/vue.global.js"></script>
<script src="js/axios.min.js"></script>

View File

@@ -13,7 +13,9 @@ const selectedOperator = ref("驿来特")
const aiText = ref("")
const placeholder = ref("点击左侧按钮查询四家供应商最新24小时分时电价再使用右侧AI综合分析功能对各司定价策略进行对比。")
const loading = ref(false)
const exporting = ref(false)
const aiLoading = ref(false)
const aiBoxRef = ref(null)
const priceTableRows = ref([])
const hourlyPricesByOperator = ref({})
let chartInstance = null
@@ -47,15 +49,37 @@ seriesData.push(list[h] !== undefined ? list[h] : null)
return {name: op.label, type:"line", smooth:true, data:seriesData}
})
const hours = rows.map(r => r.hour)
const option = {
tooltip:{trigger:"axis"},
legend:{data:operators.value.map(o=>o.label),textStyle:{color:"#e5e7eb"}},
xAxis:{type:"category",data:hours,axisLine:{lineStyle:{color:"#4b5563"}},axisLabel:{color:"#9ca3af"}},
yAxis:{type:"value",name:"元/度",axisLine:{lineStyle:{color:"#4b5563"}},axisLabel:{color:"#9ca3af"},splitLine:{lineStyle:{color:"#111827"}}},
grid:{left:40,right:20,top:30,bottom:30},
series:series
}
chartInstance.setOption(option)
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: "axis",
backgroundColor: 'rgba(30, 41, 59, 0.9)',
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
}
chartInstance.setOption(option)
}
}
@@ -78,14 +102,9 @@ loading.value = false
}
}
const loadOperatorPrices = async () => {
if (!selectedOperator.value) return
await loadAllOperatorsPrices()
}
const exportAllPrices = async () => {
try{
loading.value = true
exporting.value = true
const res = await axios.get(apiBase.value + "/api/export/prices-zip",{responseType:"blob"})
const blob = new Blob([res.data],{type:"application/zip"})
const url = window.URL.createObjectURL(blob)
@@ -99,7 +118,7 @@ window.URL.revokeObjectURL(url)
}catch(e){
console.error(e)
}finally{
loading.value = false
exporting.value = false
}
}
@@ -108,15 +127,68 @@ if (aiLoading.value) return
aiText.value = ""
aiLoading.value = true
try{
const res = await axios.get(apiBase.value + "/api/ai/pricing/strategy-summary")
aiText.value = res.data && res.data.summary ? res.data.summary : ""
const response = await fetch(apiBase.value + "/api/ai/pricing/strategy-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){
console.error(e)
aiText.value += "\n(分析过程出错: " + e.message + ")"
}finally{
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(()=>{
initChart()
loadAllOperatorsPrices()
@@ -131,6 +203,13 @@ return v.toFixed(2)
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")