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") @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])

View File

@@ -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;
}

View File

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

View File

@@ -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
@@ -47,15 +49,37 @@ seriesData.push(list[h] !== undefined ? list[h] : null)
return {name: op.label, type:"line", smooth:true, data:seriesData} 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',
series:series textStyle: { color: '#f1f5f9' }
} },
chartInstance.setOption(option) 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 () => { 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")