Files
aiData/static/js/app.js
HuangHai 9efe1d3872 'commit'
2026-01-21 07:29:31 +08:00

778 lines
31 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(15);
let adTimer = null;
const apiBase = ref(window.location.origin || "http://localhost:8000");
const isMobile = ref(window.innerWidth <= 768);
// Douyin State
const shareText = ref('');
const douyinLoading = ref(false);
const douyinRecords = ref([]);
let douyinTimer = null;
// Douyin Summary State
const showSummaryDialog = ref(false);
const summaryLoading = ref(false);
const summaryText = ref('');
// 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,
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;
}
});
// 内置简易 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>') // Bold
.replace(/`(.*?)`/g, '<code style="background:#f1f5f9; padding:2px 4px; border-radius:4px;">$1</code>'); // Code
};
for (let line of lines) {
let trimmed = line.trim();
// 忽略空行
if (!trimmed) {
continue;
}
// 标题处理 (###)
if (trimmed.startsWith('### ')) {
if (inList) { html += '</ul>'; inList = false; }
html += `<h3 style="margin-top:16px; margin-bottom:8px; font-weight:bold; font-size:1.1em;">${parseInline(trimmed.substring(4))}</h3>`;
}
// 列表项处理 (- 或 1.)
else if (trimmed.startsWith('- ') || /^\d+\./.test(trimmed)) {
if (!inList) { html += '<ul style="padding-left:20px; margin:0;">'; inList = true; }
let content = trimmed.replace(/^(- |\d+\. )/, '');
html += `<li style="margin-bottom:4px;">${parseInline(content)}</li>`;
}
// 普通段落
else {
if (inList) { html += '</ul>'; inList = false; }
html += `<p style="margin-bottom:8px;">${parseInline(trimmed)}</p>`;
}
}
if (inList) html += '</ul>';
return html;
};
const renderedSummary = computed(() => {
if (!summaryText.value) return '';
// 优先使用内置解析器,确保绝对可用
try {
return simpleMarkdown(summaryText.value);
} catch (e) {
console.error("Simple markdown error:", e);
return summaryText.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);
};
// Douyin Methods
const startParsing = async () => {
if (!shareText.value.trim()) return;
douyinLoading.value = true;
try {
const response = await axios.post(apiBase.value + '/api/parse', { text: shareText.value });
if (response.data.id || (response.data.ids && response.data.ids.length > 0)) {
shareText.value = '';
fetchDouyinRecords();
if (typeof ElementPlus !== 'undefined') {
const count = response.data.ids ? response.data.ids.length : 1;
ElementPlus.ElMessage.success(`成功提交 ${count} 个解析任务`);
}
}
} catch (error) {
console.error('Error:', error);
if (typeof ElementPlus !== 'undefined') ElementPlus.ElMessage.error('解析请求失败');
} finally {
douyinLoading.value = false;
}
};
const fetchDouyinRecords = async (isManual) => {
try {
const response = await axios.get(apiBase.value + '/api/records');
const newRecords = response.data;
douyinRecords.value = newRecords.map(newRec => {
const oldRec = douyinRecords.value.find(r => r.id === newRec.id);
return {
...newRec,
expanded: oldRec ? oldRec.expanded : false,
showOriginal: oldRec ? oldRec.showOriginal : false
};
});
if (isManual === true || (isManual && isManual.type === 'click')) {
if (typeof ElementPlus !== 'undefined') ElementPlus.ElMessage.success('列表已刷新');
}
} catch (error) {
console.error('Error fetching records:', error);
if (isManual === true || (isManual && isManual.type === 'click')) {
if (typeof ElementPlus !== 'undefined') ElementPlus.ElMessage.error('刷新失败');
}
}
};
const deleteRecord = async (id) => {
if (!confirm('确定要删除这条记录吗?')) return;
try {
await axios.delete(apiBase.value + `/api/records/${id}`);
fetchDouyinRecords();
if (typeof ElementPlus !== 'undefined') ElementPlus.ElMessage.success('删除成功');
} catch (error) {
console.error('Error deleting:', error);
if (typeof ElementPlus !== 'undefined') ElementPlus.ElMessage.error('删除失败');
}
};
const openSummaryDialog = () => {
showSummaryDialog.value = true;
// Optionally auto-start if empty
if (!summaryText.value) {
fetchDouyinSummary();
}
};
const handleSummaryClose = () => {
showSummaryDialog.value = false;
};
const fetchDouyinSummary = async () => {
if (summaryLoading.value) return;
summaryText.value = "";
summaryLoading.value = true;
try {
// Pass empty IDs to summarize all/latest
const response = await fetch(apiBase.value + "/api/douyin/summary", {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ids: []})
});
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});
summaryText.value += chunk;
// Auto scroll? The user can scroll the dialog.
}
} catch (e) {
console.error(e);
summaryText.value += "\n(总结过程出错: " + e.message + ")";
} finally {
summaryLoading.value = false;
}
};
const statusColor = (status) => {
switch(status) {
case 'COMPLETED': return '#10b981';
case 'FAILED': return '#ef4444';
case 'PROCESSING': return '#3b82f6';
default: return '#9ca3af';
}
};
const statusType = (status) => {
switch(status) {
case 'COMPLETED': return 'success';
case 'FAILED': return 'danger';
case 'PROCESSING': return 'primary';
default: return 'info';
}
};
const formatDate = (dateStr) => {
if (!dateStr) return '';
return new Date(dateStr).toLocaleString();
};
// ==========================================
// Lifecycle
// ==========================================
onMounted(() => {
// Dashboard init
initChart();
loadAllOperatorsPrices();
// startAiAnalysis(); // Removed auto-start
placeholder.value = "请点击右上角“开始分析”按钮以获取报告。";
// Douyin init
fetchDouyinRecords();
if (douyinTimer) clearInterval(douyinTimer);
douyinTimer = setInterval(() => {
// Only poll if tab is active
if (activeTab.value === 'douyin') {
fetchDouyinRecords();
}
}, 3000);
// 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,
// Douyin
shareText, douyinLoading, douyinRecords,
startParsing, fetchDouyinRecords, deleteRecord, statusColor, statusType, formatDate,
// Douyin Summary
showSummaryDialog,
summaryLoading,
summaryText,
renderedSummary,
openSummaryDialog,
handleSummaryClose,
fetchDouyinSummary,
// Ad
showAd, adCountdown, closeAd
};
}
}).use(ElementPlus).mount("#app");