236 lines
8.0 KiB
HTML
236 lines
8.0 KiB
HTML
|
|
<!DOCTYPE html>
|
|||
|
|
<html>
|
|||
|
|
<head>
|
|||
|
|
<meta charset="utf-8">
|
|||
|
|
<title>驿来特价格分析大屏</title>
|
|||
|
|
<style>
|
|||
|
|
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;}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div id="app">
|
|||
|
|
<div class="left-panel">
|
|||
|
|
<div class="controls">
|
|||
|
|
<span class="label">API</span>
|
|||
|
|
<input v-model="apiBase" style="min-width:220px;">
|
|||
|
|
<span class="label">搜索驿来特场站</span>
|
|||
|
|
<input v-model="searchKeyword" placeholder="输入名称或地址关键字" style="min-width:220px;">
|
|||
|
|
<button @click="loadStations">查询场站</button>
|
|||
|
|
<div class="controls-spacer"></div>
|
|||
|
|
<span class="label">当前场站:{{ selectedStation ? selectedStation.station_name : "未选择" }}</span>
|
|||
|
|
<button @click="startAiStream" :disabled="!selectedStation || aiLoading">AI价差分析</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="station-list">
|
|||
|
|
<div class="station-list-header">驿来特场站列表</div>
|
|||
|
|
<table class="station-table">
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th style="width:32%;">场站名称</th>
|
|||
|
|
<th style="width:48%;">地址</th>
|
|||
|
|
<th style="width:10%;">当前价</th>
|
|||
|
|
<th style="width:10%;">操作</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
<tr v-for="s in stations" :key="s.station_hash" class="station-row" :class="{active:selectedStation && selectedStation.station_hash===s.station_hash}" @click="selectStation(s)">
|
|||
|
|
<td>{{ s.station_name }}</td>
|
|||
|
|
<td>{{ s.address }}</td>
|
|||
|
|
<td>{{ formatCell(s.current_price) }}</td>
|
|||
|
|
<td><button @click.stop="selectStation(s)" :disabled="loading">查看价差</button></td>
|
|||
|
|
</tr>
|
|||
|
|
<tr v-if="stations.length===0">
|
|||
|
|
<td colspan="4">暂无数据,请先查询场站</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
<div id="chart"></div>
|
|||
|
|
<div class="price-table" v-if="priceTableColumns.length">
|
|||
|
|
<div class="price-table-title">24 小时分时电价表(元/度)</div>
|
|||
|
|
<table class="price-table-inner">
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th v-for="col in priceTableColumns" :key="col">{{ col }}</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
<tr v-for="(row,idx) in priceTableRows" :key="idx">
|
|||
|
|
<td v-for="(cell,j) in row" :key="j">{{ formatCell(cell) }}</td>
|
|||
|
|
</tr>
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="right-panel">
|
|||
|
|
<div class="ai-title">AI 辅助分析</div>
|
|||
|
|
<div class="ai-box">{{ aiText || placeholder }}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
|||
|
|
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
|||
|
|
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
|
|||
|
|
<script>
|
|||
|
|
const {createApp,onMounted,ref} = Vue
|
|||
|
|
createApp({
|
|||
|
|
setup(){
|
|||
|
|
const apiBase = ref(window.location.origin || "http://localhost:8000")
|
|||
|
|
const stationHash = ref("")
|
|||
|
|
const aiText = ref("")
|
|||
|
|
const placeholder = ref("在左侧列表中选择驿来特场站,点击“查看价差”加载24小时分时电价与竞品对比,再点击“AI价差分析”获取模型给出的策略建议。")
|
|||
|
|
const loading = ref(false)
|
|||
|
|
const aiLoading = ref(false)
|
|||
|
|
const stations = ref([])
|
|||
|
|
const searchKeyword = ref("")
|
|||
|
|
const selectedStation = ref(null)
|
|||
|
|
const priceTableColumns = ref([])
|
|||
|
|
const priceTableRows = ref([])
|
|||
|
|
let chartInstance = null
|
|||
|
|
let es = null
|
|||
|
|
const initChart = () => {
|
|||
|
|
const dom = document.getElementById("chart")
|
|||
|
|
if (dom && !chartInstance) {
|
|||
|
|
chartInstance = echarts.init(dom)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const loadStations = async () => {
|
|||
|
|
try{
|
|||
|
|
const url = apiBase.value + "/api/ylt/stations"
|
|||
|
|
const params = searchKeyword.value ? {params:{q:searchKeyword.value}} : {}
|
|||
|
|
const res = await axios.get(url,params)
|
|||
|
|
stations.value = Array.isArray(res.data) ? res.data : []
|
|||
|
|
if (!selectedStation.value && stations.value.length > 0) {
|
|||
|
|
selectStation(stations.value[0])
|
|||
|
|
}
|
|||
|
|
}catch(e){
|
|||
|
|
console.error(e)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const buildPriceTable = data => {
|
|||
|
|
const cols = []
|
|||
|
|
cols.push("时段")
|
|||
|
|
cols.push("驿来特")
|
|||
|
|
data.competitors.forEach(c => {
|
|||
|
|
cols.push(c.operator)
|
|||
|
|
})
|
|||
|
|
priceTableColumns.value = cols
|
|||
|
|
const rows = []
|
|||
|
|
for (let i = 0; i < data.hours.length; i++) {
|
|||
|
|
const row = []
|
|||
|
|
row.push(data.hours[i] + ":00")
|
|||
|
|
row.push(data.ylt.series[i])
|
|||
|
|
data.competitors.forEach(c => {
|
|||
|
|
row.push(c.series[i])
|
|||
|
|
})
|
|||
|
|
rows.push(row)
|
|||
|
|
}
|
|||
|
|
priceTableRows.value = rows
|
|||
|
|
}
|
|||
|
|
const loadComparison = async () => {
|
|||
|
|
if (!stationHash.value) return
|
|||
|
|
loading.value = true
|
|||
|
|
try{
|
|||
|
|
const url = apiBase.value + "/api/ylt/pricing/comparison/" + stationHash.value
|
|||
|
|
const res = await axios.get(url)
|
|||
|
|
const data = res.data
|
|||
|
|
const hours = data.hours.map(h => h + ":00")
|
|||
|
|
const series = []
|
|||
|
|
series.push({
|
|||
|
|
name:"驿来特",
|
|||
|
|
type:"line",
|
|||
|
|
smooth:true,
|
|||
|
|
data:data.ylt.series
|
|||
|
|
})
|
|||
|
|
data.competitors.forEach(c=>{
|
|||
|
|
series.push({
|
|||
|
|
name:c.operator,
|
|||
|
|
type:"line",
|
|||
|
|
smooth:true,
|
|||
|
|
data:c.series
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
buildPriceTable(data)
|
|||
|
|
const option = {
|
|||
|
|
tooltip:{trigger:"axis"},
|
|||
|
|
legend:{data:series.map(s=>s.name),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
|
|||
|
|
}
|
|||
|
|
initChart()
|
|||
|
|
chartInstance.setOption(option)
|
|||
|
|
}finally{
|
|||
|
|
loading.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const selectStation = s => {
|
|||
|
|
selectedStation.value = s
|
|||
|
|
stationHash.value = s.station_hash
|
|||
|
|
loadComparison()
|
|||
|
|
}
|
|||
|
|
const startAiStream = () => {
|
|||
|
|
if (!selectedStation.value || !stationHash.value) return
|
|||
|
|
if (es) {
|
|||
|
|
es.close()
|
|||
|
|
es = null
|
|||
|
|
}
|
|||
|
|
aiText.value = ""
|
|||
|
|
aiLoading.value = true
|
|||
|
|
const url = apiBase.value + "/api/ylt/pricing/comparison/" + stationHash.value + "/sse"
|
|||
|
|
es = new EventSource(url)
|
|||
|
|
es.onmessage = ev => {
|
|||
|
|
aiText.value += ev.data
|
|||
|
|
}
|
|||
|
|
es.onerror = () => {
|
|||
|
|
aiLoading.value = false
|
|||
|
|
if (es) {
|
|||
|
|
es.close()
|
|||
|
|
es = null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
onMounted(()=>{
|
|||
|
|
initChart()
|
|||
|
|
loadStations()
|
|||
|
|
})
|
|||
|
|
const formatCell = v => {
|
|||
|
|
if (v === null || v === undefined || v === "") return "-"
|
|||
|
|
if (typeof v === "number") {
|
|||
|
|
if (Number.isNaN(v)) return "-"
|
|||
|
|
return v.toFixed(2)
|
|||
|
|
}
|
|||
|
|
return v
|
|||
|
|
}
|
|||
|
|
return {apiBase,stationHash,aiText,placeholder,loading,aiLoading,stations,searchKeyword,selectedStation,priceTableColumns,priceTableRows,loadStations,loadComparison,selectStation,startAiStream,formatCell}
|
|||
|
|
}
|
|||
|
|
}).mount("#app")
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|