const { createApp, ref, computed, watch, nextTick, onMounted } = Vue; createApp({ setup() { // ========================================== // Common State // ========================================== const activeTab = ref('dashboard'); const apiBase = ref(window.location.origin || "http://localhost:8000"); const isMobile = ref(window.innerWidth <= 768); // Handle window resize window.addEventListener('resize', () => { isMobile.value = window.innerWidth <= 768; if (chartInstance) chartInstance.resize(); }); // ========================================== // Dashboard State & Logic // ========================================== const operators = ref([ {label:"新电途",value:"新电途"}, {label:"特来电",value:"特来电"}, {label:"驿来特",value:"驿来特"}, {label:"艾特吉易充",value:"艾特吉易充"} ]); const selectedOperator = ref("驿来特"); const aiText = ref(""); const placeholder = ref(""); const loading = ref(false); const exporting = ref(false); const exportingReport = ref(false); const aiLoading = ref(false); const aiBoxRef = ref(null); const priceTableRows = ref([]); const hourlyPricesByOperator = ref({}); let chartInstance = null; const initChart = () => { const dom = document.getElementById("chart"); if (dom && !chartInstance) { chartInstance = echarts.init(dom); } }; const buildPriceTable = () => { const rows = []; for (let h = 0; h < 24; h++) { const row = {hour: (h.toString().padStart(2,"0") + ":00"), values: []}; operators.value.forEach(op => { const series = hourlyPricesByOperator.value[op.value] || []; const price = series[h] !== undefined ? series[h] : null; row.values.push({operator: op.value, price}); }); rows.push(row); } priceTableRows.value = rows; if (chartInstance) { const series = operators.value.map(op => { const seriesData = []; for (let h = 0; h < 24; h++) { const list = hourlyPricesByOperator.value[op.value] || []; 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 = { 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); } }; const loadAllOperatorsPrices = async () => { loading.value = true; try{ const res = await axios.get(apiBase.value + "/api/operators/hourly-prices"); if (res && res.data && Array.isArray(res.data.operators)) { const dict = {}; res.data.operators.forEach(item => { dict[item.operator] = item.series || []; }); hourlyPricesByOperator.value = dict; buildPriceTable(); } }catch(e){ console.error(e); }finally{ loading.value = false; } }; const exportAllPrices = async () => { try{ 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); const a = document.createElement("a"); a.href = url; a.download = "多供应商电价导出.zip"; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); }catch(e){ console.error(e); }finally{ exporting.value = false; } }; const exportAiReport = async () => { if (!aiText.value) { alert("请先生成AI分析报告"); return; } try { exportingReport.value = true; const res = await axios.post(apiBase.value + "/api/export/ai-report-docx", { content: aiText.value }, {responseType: "blob"}); const blob = new Blob([res.data], {type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = "AI分析报告.docx"; document.body.appendChild(a); a.click(); document.body.removeChild(a); window.URL.revokeObjectURL(url); } catch (e) { console.error(e); alert("导出失败,请检查是否安装Pandoc"); } finally { exportingReport.value = false; } }; const startAiAnalysis = async () => { if (aiLoading.value) return; aiText.value = ""; aiLoading.value = true; try{ 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 formatCell = v => { if (v === null || v === undefined || v === "") return "-"; if (typeof v === "number") { if (Number.isNaN(v)) return "-"; return v.toFixed(2); } return v; }; 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 }; // ========================================== // Degree Query State & Logic // ========================================== const userQuery = ref(''); const queryLoading = ref(false); const queryResult = ref(''); const eventSource = ref(null); const rowsAcc = ref([]); const bufferRow = ref(null); const examples = [ "查询12月份充电量TOP 10场站的充电情况", "查询净月商贸城站12月的充电量", "查询12月企业充电量排名的TOP 10", "查询所有场站的近3个月的充电情况,找出变化最大的前10名" ]; // Helpers const isPlainObject = (obj) => Object.prototype.toString.call(obj) === '[object Object]'; const colSynonyms = { name: ['name', 'station', 'station_name', 'site', '场站', '场站名称', '站点', '站名'], total: ['total', 'total_kwh', 'sum_kwh', 'energy_total', '总电量', '总充电量', '总度', '总度数'], peak: ['peak', 'peak_kwh', 'energy_peak', '峰', '峰时电量'], flat: ['flat', 'flat_kwh', 'energy_flat', '平', '平时电量'], valley: ['valley', 'valley_kwh', 'energy_valley', '谷', '谷时电量'], }; const pickKey = (obj, keys) => keys.find(k => Object.prototype.hasOwnProperty.call(obj, k)); const pickVal = (obj, keys) => { const k = pickKey(obj, keys); return k ? obj[k] : undefined; }; const formatNumber = (n) => { if (n === null || n === undefined || n === '') return ''; const v = parseFloat(String(n).replace(/,/g, '')); if (isNaN(v)) return String(n); return v.toLocaleString('zh-CN', { maximumFractionDigits: 2 }); }; const pushRow = (row) => { if (!row || !row.name) return; rowsAcc.value.push(row); const r = row; const line = `| ${r.name ?? ''} | ${formatNumber(r.total)} | ${formatNumber(r.peak)} | ${formatNumber(r.flat)} | ${formatNumber(r.valley)} |`; if (rowsAcc.value.length === 1) { const header = `| 场站名称 | 总电量 | 峰 | 平 | 谷 |\n| --- | ---: | ---: | ---: | ---: |`; if (queryResult.value && !queryResult.value.endsWith('\n')) { queryResult.value += '\n\n'; } else if (queryResult.value) { queryResult.value += '\n'; } queryResult.value += header + '\n'; } queryResult.value += line + '\n'; }; const toSelectedRow = (row) => { if (!isPlainObject(row)) return null; const name = pickVal(row, colSynonyms.name); const total = pickVal(row, colSynonyms.total); const peak = pickVal(row, colSynonyms.peak); const flat = pickVal(row, colSynonyms.flat); const valley = pickVal(row, colSynonyms.valley); if (name === undefined) return null; return { name, total, peak, flat, valley }; }; // Degree Search Logic const handleDegreeSearch = () => { if (!userQuery.value.trim()) { if (typeof ElementPlus !== 'undefined') ElementPlus.ElMessage.warning('请输入您的问题'); else alert('请输入您的问题'); return; } if (queryLoading.value) { if (eventSource.value) { eventSource.value.close(); } } queryLoading.value = true; queryResult.value = ''; rowsAcc.value = []; bufferRow.value = null; // Construct the SSE URL const url = `${apiBase.value}/degree/chat?q=${encodeURIComponent(userQuery.value)}`; try { const es = new EventSource(url); eventSource.value = es; es.onmessage = (event) => { const eventData = event.data.trim(); if (eventData === '[DONE]') { queryLoading.value = false; es.close(); return; } try { const data = JSON.parse(event.data); let chunk = ''; if (data.content) chunk = data.content; else if (data.markdown) chunk = data.markdown; else if (data.chunk) chunk = data.chunk; else if (typeof data === 'string') chunk = data; if (chunk) { queryResult.value += chunk; } } catch (e) { if (eventData && eventData !== '[DONE]') { queryResult.value += event.data; } } }; es.onerror = (err) => { console.error('SSE Error:', err); queryLoading.value = false; es.close(); if (!queryResult.value) { queryResult.value = '查询过程出现异常,未能获取到数据'; } }; } catch (err) { console.error('Failed to create EventSource:', err); queryLoading.value = false; if (typeof ElementPlus !== 'undefined') ElementPlus.ElMessage.error('无法建立连接'); else alert('无法建立连接'); } }; const stopDegreeGeneration = () => { if (eventSource.value) { eventSource.value.close(); eventSource.value = null; } queryLoading.value = false; }; const setExample = (text) => { userQuery.value = text; handleDegreeSearch(); }; const renderCharts = () => { if (typeof echarts === 'undefined') return; nextTick(() => { const containers = document.querySelectorAll('.echarts-container'); containers.forEach(container => { if (container.getAttribute('data-rendered') === 'true') return; try { const configStr = decodeURIComponent(container.getAttribute('data-config')).trim(); if (!configStr.startsWith('{') || !configStr.endsWith('}')) return; const config = JSON.parse(configStr); let chart = echarts.getInstanceByDom(container); if (!chart) { chart = echarts.init(container, 'dark'); window.addEventListener('resize', () => chart.resize()); } chart.setOption(config); container.setAttribute('data-rendered', 'true'); container.style.opacity = '1'; } catch (e) { if (queryLoading.value) return; console.error('ECharts rendering error:', e); container.innerHTML = `

图表渲染失败: ${e.message}

`; } }); }); }; // ========================================== // Computed Properties (Markdown) // ========================================== marked.use({ gfm: true, breaks: true, mangle: false, headerIds: false, renderer: { code(code, language) { if (language === 'echarts') { const id = 'chart-' + Math.random().toString(36).substr(2, 9); return `
`; } return false; // Use default for others } } }); const renderedAiText = computed(() => { if (!aiText.value) return ""; try { return marked.parse(aiText.value); } catch (e) { return aiText.value; } }); const renderedResult = computed(() => { if (!queryResult.value) return ''; try { const cleanText = queryResult.value.replace(/\\([\*_`#\[\]\(\)!>-])/g, '$1'); return marked.parse(cleanText); } catch (e) { console.error('Markdown parsing error:', e); return queryResult.value; } }); // ========================================== // Watchers // ========================================== watch(aiText, () => { 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 }); } }); }); watch(queryResult, (newVal) => { nextTick(() => { const el = document.querySelector('.markdown-body'); 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 }); } }); if (newVal && newVal.includes('```echarts')) { renderCharts(); } }); // ========================================== // Lifecycle // ========================================== onMounted(() => { // Dashboard init initChart(); loadAllOperatorsPrices(); startAiAnalysis(); // Degree QR Code if (!isMobile.value && typeof QRCode !== 'undefined') { const qrEl = document.getElementById("qrcode"); if (qrEl) { qrEl.innerHTML = ''; new QRCode(qrEl, { text: window.location.href, width: 256, height: 256, colorDark: "#000000", colorLight: "#ffffff", correctLevel: QRCode.CorrectLevel.M }); } } }); return { activeTab, apiBase, isMobile, // Dashboard operators, selectedOperator, aiText, placeholder, loading, exporting, exportingReport, aiLoading, priceTableRows, aiBoxRef, loadAllOperatorsPrices, exportAllPrices, exportAiReport, startAiAnalysis, formatCell, getPriceColor, renderedAiText, // Degree userQuery, queryLoading, queryResult, examples, handleDegreeSearch, setExample, renderedResult, stopDegreeGeneration }; } }).use(ElementPlus).mount("#app");