Files
aiData/static/js/dashboard.js
HuangHai e51dc18d06 'commit'
2026-01-21 08:41:47 +08:00

443 lines
16 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 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();
if (trendChartInstance) trendChartInstance.resize();
});
// ==========================================
// Dashboard State & Logic
// ==========================================
const operators = ref([
{label:"新电途",value:"新电途"},
{label:"特来电",value:"特来电"},
{label:"驿来特",value:"驿来特"},
{label:"艾特吉易充",value:"艾特吉易充"}
]);
// Default select all or specific one? App.js had "驿来特"
const selectedOperator = ref("驿来特");
const aiText = 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;
let trendChartInstance = null;
const chartType = ref('line');
const trendDays = ref(7);
// ECharts Initialization
const initChart = () => {
const dom = document.getElementById("chart");
if (dom && !chartInstance) {
if (typeof echarts === 'undefined') {
console.error("ECharts not loaded");
return;
}
chartInstance = echarts.init(dom);
}
};
const initTrendChart = () => {
const dom = document.getElementById("trendChart");
if (dom && !trendChartInstance) {
if (typeof echarts === 'undefined') {
console.error("ECharts not loaded");
return;
}
trendChartInstance = 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 loadTrendData = async () => {
try {
const res = await axios.get(apiBase.value + "/api/operators/price-trends?days=" + trendDays.value);
if (res && res.data) {
renderTrendChart(res.data);
}
} catch (e) {
console.error("Failed to load trend data:", e);
}
};
const renderTrendChart = (data) => {
if (!trendChartInstance) initTrendChart();
if (!trendChartInstance) return;
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: "axis",
backgroundColor: 'rgba(30, 41, 59, 0.9)',
borderColor: '#334155',
textStyle: { color: '#f1f5f9' }
},
legend: {
data: data.series.map(s => s.name),
textStyle: { color: "#94a3b8" },
top: 10
},
grid: { left: 50, right: 30, top: 60, bottom: 40 },
xAxis: {
type: "category",
data: data.dates.map(d => d.split('-').slice(1).join('/')), // Show MM/DD
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' } },
min: (value) => (value.min * 0.95).toFixed(2),
max: (value) => (value.max * 1.05).toFixed(2)
},
series: data.series.map(s => ({
name: s.name,
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
data: s.data,
emphasis: { focus: 'series' }
}))
};
trendChartInstance.setOption(option, true);
};
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
};
// Configure Marked
if (typeof marked !== 'undefined') {
marked.use({
gfm: true,
breaks: true
});
}
// 降级用的简易 Markdown 解析器
const simpleMarkdown = (text) => {
if (!text) return '';
let lines = text.split('\n');
let html = '';
let inList = false;
const parseInline = (str) => {
return str
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/`(.*?)`/g, '<code style="background:rgba(148, 163, 184, 0.1); padding:2px 4px; border-radius:4px;">$1</code>');
};
for (let line of lines) {
let trimmed = line.trim();
if (!trimmed) {
if (inList) { html += '</ul>'; inList = false; }
html += '<br>';
continue;
}
if (trimmed.startsWith('### ')) {
if (inList) { html += '</ul>'; inList = false; }
html += `<h3>${parseInline(trimmed.substring(4))}</h3>`;
} else if (trimmed.startsWith('- ') || /^\d+\./.test(trimmed)) {
if (!inList) { html += '<ul>'; inList = true; }
let content = trimmed.replace(/^(- |\d+\. )/, '');
html += `<li>${parseInline(content)}</li>`;
} else {
if (inList) { html += '</ul>'; inList = false; }
html += `<p>${parseInline(trimmed)}</p>`;
}
}
if (inList) html += '</ul>';
return html;
};
// 增强的 Markdown & LaTeX 解析器
const renderMarkdownAndLatex = (text) => {
if (!text) return '';
try {
// 1. 处理 LaTeX (简单替换,先处理 $$ 再处理 $)
let processedText = text;
// 处理块级 LaTeX: $$ ... $$
processedText = processedText.replace(/\$\$\s*([\s\S]*?)\s*\$\$/g, (match, formula) => {
try {
if (typeof katex !== 'undefined') {
return '<div class="katex-block">' + katex.renderToString(formula, { displayMode: true, throwOnError: false }) + '</div>';
}
return match;
} catch (e) {
return match;
}
});
// 处理行内 LaTeX: $ ... $
processedText = processedText.replace(/\$([^\$\n]+?)\$/g, (match, formula) => {
try {
if (typeof katex !== 'undefined') {
return katex.renderToString(formula, { displayMode: false, throwOnError: false });
}
return match;
} catch (e) {
return match;
}
});
// 2. 使用 marked 解析 Markdown
if (typeof marked !== 'undefined') {
return marked.parse(processedText);
} else {
// 降级使用之前的 simpleMarkdown
return simpleMarkdown(processedText);
}
} catch (e) {
console.error('Markdown/LaTeX rendering error:', e);
return text;
}
};
const renderedAiText = computed(() => {
return renderMarkdownAndLatex(aiText.value);
});
onMounted(() => {
initChart();
initTrendChart();
loadAllOperatorsPrices();
loadTrendData();
});
return {
operators,
loading,
exporting,
exportingReport,
aiLoading,
aiText,
aiBoxRef,
priceTableRows,
hourlyPricesByOperator,
chartType,
trendDays,
// Actions
loadAllOperatorsPrices,
loadTrendData,
exportAllPrices,
exportAiReport,
startAiAnalysis,
formatCell,
getPriceColor,
renderedAiText
};
}
}).mount('#app');