Files
aiData/static/js/app.js
HuangHai e856ceadd5 'commit'
2026-01-20 08:49:28 +08:00

566 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { createApp, ref, computed, watch, nextTick, onMounted } = Vue;
createApp({
setup() {
// ==========================================
// Common State
// ==========================================
const activeTab = ref('dashboard');
// Ad Overlay State
const showAd = ref(true);
const adCountdown = ref(10);
let adTimer = null;
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 chartType = ref('line');
let chartInterval = null;
const initChart = () => {
const dom = document.getElementById("chart");
if (dom && !chartInstance) {
chartInstance = echarts.init(dom);
}
};
const renderChart = () => {
if (!chartInstance) return;
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);
}
if (chartType.value === 'bar') {
return {
name: op.label,
type: "bar",
barGap: 0,
emphasis: { focus: 'series' },
data: seriesData
};
} else {
return {
name: op.label,
type: "line",
smooth: true,
emphasis: { focus: 'series' },
data: seriesData
};
}
});
const hours = [];
for (let h = 0; h < 24; h++) {
hours.push(h.toString().padStart(2,"0") + ":00");
}
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: "axis",
backgroundColor: 'rgba(30, 41, 59, 0.9)',
borderColor: '#334155',
textStyle: { color: '#f1f5f9' },
axisPointer: { type: chartType.value === 'bar' ? 'shadow' : 'line' }
},
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, true);
};
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;
renderChart();
};
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 = `<p style="color: #ef4444; padding: 10px;">图表渲染失败: ${e.message}</p>`;
}
});
});
};
// ==========================================
// 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 `<div class="echarts-container" id="${id}" style="width: 100%; height: 400px; margin: 20px 0; background: rgba(30, 41, 59, 0.5); border: 1px solid rgba(148, 163, 184, 0.1); border-radius: 12px; padding: 20px;" data-config="${encodeURIComponent(code)}"></div>`;
}
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}
],
ignoredTags: ["script", "noscript", "style", "textarea"], // Removed 'pre', 'code' to allow math in code blocks
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}
],
ignoredTags: ["script", "noscript", "style", "textarea"], // Removed 'pre', 'code' to allow math in code blocks
throwOnError: false
});
}
});
if (newVal && newVal.includes('```echarts')) {
renderCharts();
}
});
const closeAd = () => {
showAd.value = false;
if (adTimer) clearInterval(adTimer);
};
// ==========================================
// Lifecycle
// ==========================================
onMounted(() => {
// Dashboard init
initChart();
loadAllOperatorsPrices();
startAiAnalysis();
// Start Chart Type Carousel (10s interval)
if (chartInterval) clearInterval(chartInterval);
chartInterval = setInterval(() => {
chartType.value = chartType.value === 'line' ? 'bar' : 'line';
renderChart();
}, 10000);
// Ad Auto Close Timer
adTimer = setInterval(() => {
adCountdown.value--;
if (adCountdown.value <= 0) {
closeAd();
}
}, 1000);
// 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,
// Ad
showAd, adCountdown, closeAd
};
}
}).use(ElementPlus).mount("#app");