Files
aiData/static/js/degree.js
HuangHai 34501faafb 'commit'
2026-01-20 08:09:13 +08:00

453 lines
20 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;
const app = createApp({
setup() {
const query = ref('');
const loading = ref(false);
const result = ref('');
const eventSource = ref(null);
const isMobile = ref(window.innerWidth <= 768);
// Handle window resize
window.addEventListener('resize', () => {
isMobile.value = window.innerWidth <= 768;
});
// Initialize QR Code
onMounted(() => {
if (!isMobile.value && typeof QRCode !== 'undefined') {
new QRCode(document.getElementById("qrcode"), {
text: window.location.href,
width: 128,
height: 128,
colorDark : "#000000",
colorLight : "#ffffff",
correctLevel : QRCode.CorrectLevel.H
});
}
});
// Examples for the user
const examples = [
"查询12月份充电量TOP 10场站的充电情况",
"查询净月商贸城站12月的充电量",
"查询12月企业充电量排名的TOP 10",
"查询所有场站的近3个月的充电情况找出变化最大的前10名"
];
// Helpers: convert payloads to readable markdown
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 rowsAcc = ref([]);
const bufferRow = ref(null);
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 buildTableFromRows = (rows) => {
if (!rows || rows.length === 0) return '';
const header = `| 场站名称 | 总电量 | 峰 | 平 | 谷 |\n| --- | ---: | ---: | ---: | ---: |`;
const lines = rows.map(r => `| ${r.name ?? ''} | ${formatNumber(r.total)} | ${formatNumber(r.peak)} | ${formatNumber(r.flat)} | ${formatNumber(r.valley)} |`).join('\n');
return `${header}\n${lines}`;
};
const pushRow = (row) => {
if (!row || !row.name) return;
rowsAcc.value.push(row);
// Append the new row to result.value instead of rebuilding the whole table
// This allows mixed content (raw markdown + parsed rows) to coexist
const r = row;
const line = `| ${r.name ?? ''} | ${formatNumber(r.total)} | ${formatNumber(r.peak)} | ${formatNumber(r.flat)} | ${formatNumber(r.valley)} |`;
// If this is the first row, add the header
if (rowsAcc.value.length === 1) {
const header = `| 场站名称 | 总电量 | 峰 | 平 | 谷 |\n| --- | ---: | ---: | ---: | ---: |`;
// If result already has content (e.g. summary text), append header with newline
if (result.value && !result.value.endsWith('\n')) {
result.value += '\n\n';
} else if (result.value) {
result.value += '\n';
}
result.value += header + '\n';
}
result.value += line + '\n';
};
const ingestColumnsRows = (data) => {
const nameCol = data.columns.find(c => colSynonyms.name.includes(c));
const totalCol = data.columns.find(c => colSynonyms.total.includes(c));
const peakCol = data.columns.find(c => colSynonyms.peak.includes(c));
const flatCol = data.columns.find(c => colSynonyms.flat.includes(c));
const valleyCol = data.columns.find(c => colSynonyms.valley.includes(c));
data.rows.forEach(r => pushRow({
name: r[nameCol],
total: r[totalCol],
peak: r[peakCol],
flat: r[flatCol],
valley: r[valleyCol],
}));
};
const ingestDataArray = (arr) => {
arr.forEach(obj => {
const row = toSelectedRow(obj);
if (row) pushRow(row);
});
};
const tryStationName = (line) => {
const m1 = line.match(/(?:站点|场站|充电站|站名)[:]\s*([^\s,]+)/);
if (m1) return m1[1];
const m2 = line.match(/^\s*(?:\d+[\.\)]\s*)?(.+?(?:充电站|场站|站))(?:.*)?\s*$/);
if (m2) return m2[1];
return null;
};
const tryMetric = (line, label) => {
const re = new RegExp(label + '\\s*[:]\\s*([\\d.,]+)');
const m = line.match(re);
if (m) return parseFloat(m[1].replace(/,/g, ''));
return null;
};
const ingestChunkText = (text) => {
if (!text || /^\s*$/.test(text)) return false;
let consumed = false;
const lines = String(text).split(/[\r\n]+/);
lines.forEach(line => {
const name = tryStationName(line);
if (name) {
if (bufferRow.value && (bufferRow.value.total != null || bufferRow.value.peak != null || bufferRow.value.flat != null || bufferRow.value.valley != null)) {
pushRow(bufferRow.value);
}
bufferRow.value = { name, total: null, peak: null, flat: null, valley: null };
consumed = true;
return;
}
if (!bufferRow.value) return;
const total = tryMetric(line, '(?:总(?:充电)?电量|总电量)');
const peak = tryMetric(line, '(?:峰(?:时)?电量|峰)');
const flat = tryMetric(line, '(?:平(?:时)?电量|平)');
const valley = tryMetric(line, '(?:谷(?:时)?电量|谷)');
if (total != null) { bufferRow.value.total = total; consumed = true; }
if (peak != null) { bufferRow.value.peak = peak; consumed = true; }
if (flat != null) { bufferRow.value.flat = flat; consumed = true; }
if (valley != null) { bufferRow.value.valley = valley; consumed = true; }
if (bufferRow.value.name && (bufferRow.value.total != null || bufferRow.value.peak != null || bufferRow.value.flat != null || bufferRow.value.valley != null)) {
pushRow(bufferRow.value);
bufferRow.value = null;
}
});
return consumed;
};
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 };
};
const toMarkdownTableFromArray = (arr) => {
if (!Array.isArray(arr) || arr.length === 0) return '';
const selected = arr.map(toSelectedRow).filter(Boolean);
if (selected.length === 0) {
// 如果不能识别出目标字段避免展示冗余JSON
return '';
}
const header = `| 场站名称 | 总电量 | 峰 | 平 | 谷 |\n| --- | ---: | ---: | ---: | ---: |`;
const rows = selected.map(r => {
const fmt = (v) => v === null || v === undefined ? '' : String(v);
return `| ${fmt(r.name)} | ${fmt(r.total)} | ${fmt(r.peak)} | ${fmt(r.flat)} | ${fmt(r.valley)} |`;
}).join('\n');
return `${header}\n${rows}`;
};
const toMarkdownTableFromObject = (obj) => {
const selected = toSelectedRow(obj);
if (!selected) return '';
const header = `| 场站名称 | 总电量 | 峰 | 平 | 谷 |\n| --- | ---: | ---: | ---: | ---: |`;
const fmt = (v) => v === null || v === undefined ? '' : String(v);
const row = `| ${fmt(selected.name)} | ${fmt(selected.total)} | ${fmt(selected.peak)} | ${fmt(selected.flat)} | ${fmt(selected.valley)} |`;
return `${header}\n${row}`;
};
const payloadToMarkdown = (data) => {
if (data == null) return '';
if (typeof data === 'string') {
// 文本直接作为markdown但避免无用状态文本
const trimmed = data.trim();
if (/^(analyzing|generating|loading)/i.test(trimmed)) return '';
return trimmed;
}
if (isPlainObject(data)) {
if (typeof data.markdown === 'string') return data.markdown;
if (typeof data.content === 'string') return data.content;
if (typeof data.text === 'string') return data.text;
// 过滤仅含无用状态字段的对象
const keys = Object.keys(data);
const statusKeys = ['step', 'message', 'chunk', 'status'];
if (keys.every(k => statusKeys.includes(k))) return '';
if (Array.isArray(data.rows) && Array.isArray(data.columns)) {
// 映射选择列
const colMap = {
name: data.columns.find(c => colSynonyms.name.includes(c)),
total: data.columns.find(c => colSynonyms.total.includes(c)),
peak: data.columns.find(c => colSynonyms.peak.includes(c)),
flat: data.columns.find(c => colSynonyms.flat.includes(c)),
valley: data.columns.find(c => colSynonyms.valley.includes(c)),
};
const header = `| 场站名称 | 总电量 | 峰 | 平 | 谷 |\n| --- | ---: | ---: | ---: | ---: |`;
const rows = data.rows.map(r => {
const fmt = (v) => v === null || v === undefined ? '' : String(v);
return `| ${fmt(r[colMap.name])} | ${fmt(r[colMap.total])} | ${fmt(r[colMap.peak])} | ${fmt(r[colMap.flat])} | ${fmt(r[colMap.valley])} |`;
}).join('\n');
return `${header}\n${rows}`;
}
if (Array.isArray(data.data)) {
return toMarkdownTableFromArray(data.data);
}
// Fallback: 仅当识别出目标字段时才展示;否则忽略
return toMarkdownTableFromObject(data);
}
if (Array.isArray(data)) {
return toMarkdownTableFromArray(data);
}
return String(data);
};
// 配置 marked
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; // 返回 false 使用默认渲染器
}
}
});
// Convert markdown to HTML
const renderedResult = computed(() => {
if (!result.value) return '';
try {
// 修复 LLM 常见的错误转义问题,例如 \*\*加粗\*\*
const cleanText = result.value.replace(/\\([\*_`#\[\]\(\)!>-])/g, '$1');
return marked.parse(cleanText);
} catch (e) {
console.error('Markdown parsing error:', e);
return result.value;
}
});
// 渲染图表函数
const renderCharts = () => {
if (typeof echarts === 'undefined') {
console.warn('ECharts is not loaded yet.');
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();
// 检查 JSON 是否完整(简单判断:以 { 开始,以 } 结束)
if (!configStr.startsWith('{') || !configStr.endsWith('}')) {
return; // 还在流式传输中,不完整则跳过
}
const config = JSON.parse(configStr);
// 如果容器还没有 init则初始化
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 (loading.value) return;
console.error('ECharts rendering error:', e);
container.innerHTML = `<p style="color: #ef4444; padding: 10px;">图表渲染失败: ${e.message}</p>`;
}
});
});
};
// 监听结果变化,尝试渲染图表
watch(result, (newVal) => {
if (newVal && newVal.includes('```echarts')) {
renderCharts();
}
});
const setExample = (text) => {
query.value = text;
handleSearch();
};
const handleSearch = () => {
if (!query.value.trim()) {
ElementPlus.ElMessage.warning('请输入您的问题');
return;
}
if (loading.value) {
// If already loading, stop the previous request?
// For now, let's just ignore or maybe cancel.
// closing existing connection
if (eventSource.value) {
eventSource.value.close();
}
}
loading.value = true;
result.value = '';
// Construct the SSE URL
// Assuming the backend endpoint is /api/ai/query or similar.
// We use a relative path so it works if served from the same domain.
// Adjust this URL based on actual backend route.
const url = `/degree/chat?q=${encodeURIComponent(query.value)}`;
try {
const es = new EventSource(url);
eventSource.value = es;
es.onopen = () => {
console.log('Connection opened');
};
es.onmessage = (event) => {
const eventData = event.data.trim();
if (eventData === '[DONE]') {
loading.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) {
// 保持原始文本,不对 markdown 符号进行手动转义处理
// marked 会处理大部分情况
result.value += chunk;
}
// 处理状态更新
if (data.step || data.message) {
// 可以根据需要处理状态,比如更新进度条等
// 目前主要依赖流式内容展示
}
} catch (e) {
// 非 JSON 内容直接追加
if (eventData && eventData !== '[DONE]') {
result.value += event.data;
}
}
};
es.onerror = (err) => {
console.error('SSE Error:', err);
loading.value = false;
es.close();
// 如果之前已经有结果,就保持现状;否则给出简单提示
if (!result.value) {
result.value = '查询过程出现异常,未能获取到数据';
}
};
// Listen for a specific 'done' event if the backend sends one
es.addEventListener('done', () => {
if (bufferRow.value && (bufferRow.value.total != null || bufferRow.value.peak != null || bufferRow.value.flat != null || bufferRow.value.valley != null)) {
pushRow(bufferRow.value);
bufferRow.value = null;
}
if (!result.value) {
result.value = '未查询到数据';
}
es.close();
loading.value = false;
});
} catch (err) {
console.error('Failed to create EventSource:', err);
loading.value = false;
ElementPlus.ElMessage.error('无法建立连接');
}
};
const stopGeneration = () => {
if (eventSource.value) {
eventSource.value.close();
eventSource.value = null;
}
loading.value = false;
ElementPlus.ElMessage.info('已停止生成');
};
onMounted(() => {
// 生成二维码
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 {
query,
loading,
result,
renderedResult,
examples,
isMobile,
handleSearch,
setExample,
stopGeneration
};
}
});
app.use(ElementPlus);
app.mount('#app');