6 changed files with 1962 additions and 312 deletions
@ -0,0 +1,208 @@
|
||||
import request from "@/utils/request"; |
||||
import Cookies from 'js-cookie'; |
||||
|
||||
/** |
||||
* 创建AI报表任务 |
||||
* @param {string} prompt - 用户输入的提示词 |
||||
* @returns {Promise<{taskId: string}>} |
||||
*/ |
||||
export function createReportTask(prompt) { |
||||
return request({ |
||||
url: "/ai/reports/async", |
||||
method: "post", |
||||
data: prompt, |
||||
headers: { |
||||
"Content-Type": "text/plain", |
||||
}, |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* 下载AI报表(带token认证) |
||||
* @param {string} downloadUrl - 下载URL |
||||
* @returns {Promise<{ blob: Blob, fileName: string }>} |
||||
*/ |
||||
export function downloadReport(downloadUrl) { |
||||
const token = Cookies.get("Admin-Token"); |
||||
const baseURL = process.env.VUE_APP_BASE_API; |
||||
const url = downloadUrl.startsWith('http') ? downloadUrl : `${baseURL}${downloadUrl}`; |
||||
|
||||
return fetch(url, { |
||||
method: 'GET', |
||||
headers: { |
||||
'Authorization': `Bearer ${token}`, |
||||
}, |
||||
}).then(response => { |
||||
if (!response.ok) { |
||||
throw new Error(`Download failed with status: ${response.status}`); |
||||
} |
||||
|
||||
// 解析Content-Disposition头获取文件名
|
||||
let fileName = 'report.docx'; // 默认文件名
|
||||
const contentDisposition = response.headers.get('Content-Disposition'); |
||||
|
||||
if (contentDisposition) { |
||||
// 尝试解析 filename*=UTF-8'' 格式
|
||||
const utf8FilenameMatch = contentDisposition.match(/filename\*=UTF-8''(.+)/); |
||||
if (utf8FilenameMatch) { |
||||
try { |
||||
fileName = decodeURIComponent(utf8FilenameMatch[1]); |
||||
} catch (e) { |
||||
console.error('Failed to decode filename:', e); |
||||
} |
||||
} else { |
||||
// 尝试解析 filename="..." 或 filename=... 格式
|
||||
const filenameMatch = contentDisposition.match(/filename="?([^"]+)"?/); |
||||
if (filenameMatch) { |
||||
fileName = filenameMatch[1]; |
||||
} |
||||
} |
||||
} |
||||
|
||||
return response.blob().then(blob => ({ blob, fileName })); |
||||
}); |
||||
} |
||||
|
||||
/** |
||||
* 创建SSE连接(使用fetch实现,支持Authorization请求头) |
||||
* @param {string} taskId - 任务ID |
||||
* @param {Object} callbacks - 回调函数集合 |
||||
* @param {Function} callbacks.onReasoning - 推理内容回调 |
||||
* @param {Function} callbacks.onCompleted - 完成回调 |
||||
* @param {Function} callbacks.onFailed - 失败回调 |
||||
* @param {Function} callbacks.onError - 错误回调 |
||||
* @param {number} timeout - 超时时间(毫秒),默认30000(30秒) |
||||
* @returns {Object} SSE连接实例,包含close方法 |
||||
*/ |
||||
export function createSSEConnection(taskId, callbacks, timeout = 30000) { |
||||
const { |
||||
onReasoning = () => {}, |
||||
onCompleted = () => {}, |
||||
onFailed = () => {}, |
||||
onError = () => {}, |
||||
} = callbacks; |
||||
|
||||
const token = Cookies.get("Admin-Token"); |
||||
const baseURL = process.env.VUE_APP_BASE_API; |
||||
const url = `${baseURL}/ai/reports/async/${taskId}/subscribe`; |
||||
|
||||
let controller = new AbortController(); |
||||
let timeoutId = null; |
||||
let isCompleted = false; |
||||
|
||||
const connect = async () => { |
||||
// 设置超时
|
||||
timeoutId = setTimeout(() => { |
||||
if (!isCompleted) { |
||||
controller.abort(); |
||||
const error = new Error(`SSE connection timeout after ${timeout}ms`); |
||||
onError(error); |
||||
} |
||||
}, timeout); |
||||
|
||||
try { |
||||
const response = await fetch(url, { |
||||
method: 'GET', |
||||
headers: { |
||||
'Authorization': `Bearer ${token}`, |
||||
'Accept': 'text/event-stream', |
||||
}, |
||||
signal: controller.signal, |
||||
}); |
||||
|
||||
if (!response.ok) { |
||||
throw new Error(`HTTP error! status: ${response.status}`); |
||||
} |
||||
|
||||
const reader = response.body.getReader(); |
||||
const decoder = new TextDecoder(); |
||||
let buffer = ''; |
||||
let lastDataTime = Date.now(); |
||||
|
||||
// 数据超时检测(如果超过超时时间没有收到新数据)
|
||||
const dataTimeoutId = setInterval(() => { |
||||
if (!isCompleted && Date.now() - lastDataTime > timeout) { |
||||
clearInterval(dataTimeoutId); |
||||
controller.abort(); |
||||
const error = new Error(`SSE no data received for ${timeout}ms`); |
||||
onError(error); |
||||
} |
||||
}, 5000); |
||||
|
||||
let currentEventType = null; |
||||
let currentData = ''; |
||||
|
||||
while (true) { |
||||
const { done, value } = await reader.read(); |
||||
if (done) break; |
||||
|
||||
lastDataTime = Date.now(); |
||||
buffer += decoder.decode(value, { stream: true }); |
||||
const lines = buffer.split('\n'); |
||||
buffer = lines.pop() || ''; |
||||
|
||||
for (const line of lines) { |
||||
const trimmedLine = line.trim(); |
||||
if (trimmedLine.startsWith('event:')) { |
||||
currentEventType = trimmedLine.slice(6).trim(); |
||||
} else if (trimmedLine.startsWith('data:')) { |
||||
currentData = trimmedLine.slice(5).trim(); |
||||
if (currentEventType && currentData) { |
||||
if (currentEventType.toLowerCase() === 'reasoning') { |
||||
// reasoning 数据通常是文本内容,直接传递
|
||||
onReasoning(currentData); |
||||
} else if (currentEventType.toLowerCase() === 'completed') { |
||||
// COMPLETED 数据可能是JSON对象,尝试解析
|
||||
try { |
||||
const parsedData = JSON.parse(currentData); |
||||
onCompleted(parsedData); |
||||
} catch (e) { |
||||
onCompleted(currentData); |
||||
} |
||||
isCompleted = true; |
||||
clearTimeout(timeoutId); |
||||
clearInterval(dataTimeoutId); |
||||
} else if (currentEventType.toLowerCase() === 'failed') { |
||||
try { |
||||
const parsedData = JSON.parse(currentData); |
||||
onFailed(parsedData); |
||||
} catch (e) { |
||||
onFailed(currentData); |
||||
} |
||||
isCompleted = true; |
||||
clearTimeout(timeoutId); |
||||
clearInterval(dataTimeoutId); |
||||
} |
||||
} |
||||
} else if (trimmedLine === '') { |
||||
// 空行,事件分隔符,重置事件和数据
|
||||
currentEventType = null; |
||||
currentData = ''; |
||||
} |
||||
} |
||||
} |
||||
} catch (error) { |
||||
if (error.name === 'AbortError') { |
||||
if (!isCompleted) { |
||||
console.log('SSE connection closed'); |
||||
} |
||||
} else { |
||||
console.error('SSE error:', error); |
||||
onError(error); |
||||
} |
||||
} finally { |
||||
clearTimeout(timeoutId); |
||||
isCompleted = true; |
||||
} |
||||
}; |
||||
|
||||
connect(); |
||||
|
||||
return { |
||||
close: () => { |
||||
clearTimeout(timeoutId); |
||||
isCompleted = true; |
||||
controller.abort(); |
||||
}, |
||||
}; |
||||
} |
||||
@ -0,0 +1,472 @@
|
||||
<template> |
||||
<div class="chat-container"> |
||||
<!-- 消息列表 --> |
||||
<div class="message-list" ref="messageList"> |
||||
<div |
||||
v-for="(msg, index) in messages" |
||||
:key="index" |
||||
class="message-wrapper" |
||||
:class="{ 'user-message': msg.role === 'user', 'assistant-message': msg.role === 'assistant' }" |
||||
> |
||||
<!-- 头像(可自定义图标) --> |
||||
<div class="avatar"> |
||||
<img :src="logo" alt="AI" @error="handleImageError" /> |
||||
</div> |
||||
|
||||
<!-- 消息内容 --> |
||||
<div class="bubble"> |
||||
<div class="message-content"> |
||||
<!-- 如果是JSON格式,用代码块显示 --> |
||||
<pre v-if="isJsonContent(msg.content)" class="json-content"><code>{{ formatJson(msg.content) }}</code></pre> |
||||
<!-- 否则用普通文本显示 --> |
||||
<div v-else v-html="formattedContent(msg)"></div> |
||||
</div> |
||||
<!-- 如果是AI消息且包含下载链接,显示下载按钮 --> |
||||
<div v-if="msg.role === 'assistant' && msg.downloadUrl" class="download-area"> |
||||
<a @click.prevent="handleDownload(msg.downloadUrl, index)" href="#" class="download-btn"> |
||||
{{ msg.downloading ? '下载中...' : '📥 下载报表' }} |
||||
</a> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- 底部输入区 --> |
||||
<div class="input-area"> |
||||
<textarea |
||||
v-model="userInput" |
||||
@keydown.enter.prevent="sendMessage" |
||||
placeholder="输入你的需求,例如:生成昨天机房的能效报表,PDF格式..." |
||||
rows="1" |
||||
></textarea> |
||||
<button @click="sendMessage" :disabled="!userInput.trim() || loading">发送</button> |
||||
</div> |
||||
</div> |
||||
</template> |
||||
|
||||
<script> |
||||
import logo from "@/assets/logo/logo.png"; |
||||
import { createReportTask, createSSEConnection, downloadReport } from "@/api/ai"; |
||||
|
||||
export default { |
||||
name: 'ChatReport', |
||||
data() { |
||||
return { |
||||
userInput: '', |
||||
messages: [ |
||||
{ |
||||
role: 'assistant', |
||||
content: '您好!我是AI助手,可以帮助您生成机房能效报表。请告诉我您需要什么帮助?', |
||||
downloadUrl: '' |
||||
} |
||||
], // { role: 'user'/'assistant', content: '', downloadUrl: '' } |
||||
loading: false, |
||||
currentEventSource: null, // 当前SSE连接 |
||||
logo: logo, |
||||
}; |
||||
}, |
||||
mounted() { |
||||
this.$nextTick(() => { |
||||
this.scrollToBottom(); |
||||
}); |
||||
}, |
||||
computed: { |
||||
// 格式化消息内容(简单将文本中的换行转为<br>) |
||||
formattedContent() { |
||||
return (msg) => { |
||||
if (!msg.content) return ''; |
||||
return msg.content.replace(/\n/g, '<br>'); |
||||
}; |
||||
}, |
||||
|
||||
// 判断内容是否为JSON格式 |
||||
isJsonContent() { |
||||
return (content) => { |
||||
if (!content) return false; |
||||
content = content.trim(); |
||||
return content.startsWith('{') && content.endsWith('}'); |
||||
}; |
||||
}, |
||||
|
||||
// 格式化JSON |
||||
formatJson() { |
||||
return (content) => { |
||||
try { |
||||
const parsed = JSON.parse(content); |
||||
return JSON.stringify(parsed, null, 2); |
||||
} catch (e) { |
||||
return content; |
||||
} |
||||
}; |
||||
}, |
||||
}, |
||||
methods: { |
||||
// 发送消息 |
||||
sendMessage() { |
||||
const text = this.userInput.trim(); |
||||
if (!text || this.loading) return; |
||||
|
||||
// 关闭之前的SSE连接 |
||||
if (this.currentEventSource) { |
||||
this.currentEventSource.close(); |
||||
this.currentEventSource = null; |
||||
} |
||||
|
||||
// 添加用户消息 |
||||
this.messages.push({ role: 'user', content: text }); |
||||
// 添加一条空的AI消息占位,等待流式填充 |
||||
const aiMsgIndex = this.messages.length; |
||||
this.messages.push({ role: 'assistant', content: '', downloadUrl: '' }); |
||||
this.scrollToBottom(); |
||||
|
||||
// 清空输入框 |
||||
this.userInput = ''; |
||||
this.loading = true; |
||||
|
||||
// 调用后端创建任务 |
||||
createReportTask(text) |
||||
.then(data => { |
||||
const taskId = data.taskId; |
||||
// 建立SSE连接 |
||||
this.connectSSE(taskId, aiMsgIndex); |
||||
}) |
||||
.catch(err => { |
||||
this.handleError('创建任务失败:' + (err.message || err), aiMsgIndex); |
||||
}); |
||||
}, |
||||
|
||||
// 关闭弹窗 |
||||
closeDialog() { |
||||
this.$emit('close'); |
||||
}, |
||||
|
||||
// 图片加载错误处理 |
||||
handleImageError() { |
||||
console.log('图片加载失败,使用默认样式'); |
||||
}, |
||||
|
||||
// 下载报表 |
||||
async handleDownload(downloadUrl, msgIndex) { |
||||
try { |
||||
this.$set(this.messages[msgIndex], 'downloading', true); |
||||
|
||||
const { blob, fileName } = await downloadReport(downloadUrl); |
||||
|
||||
// 创建下载链接并触发下载 |
||||
const url = window.URL.createObjectURL(blob); |
||||
const a = document.createElement('a'); |
||||
a.href = url; |
||||
a.download = fileName; |
||||
document.body.appendChild(a); |
||||
a.click(); |
||||
document.body.removeChild(a); |
||||
window.URL.revokeObjectURL(url); |
||||
|
||||
this.$set(this.messages[msgIndex], 'downloading', false); |
||||
} catch (error) { |
||||
console.error('Download failed:', error); |
||||
this.$message.error('下载失败:' + (error.message || error)); |
||||
this.$set(this.messages[msgIndex], 'downloading', false); |
||||
} |
||||
}, |
||||
|
||||
// 连接SSE |
||||
connectSSE(taskId, msgIndex) { |
||||
const eventSource = createSSEConnection(taskId, { |
||||
onReasoning: (data) => { |
||||
// 收集所有数据,等待完成后统一实现打字机效果 |
||||
// 先存储完整内容 |
||||
if (!this.messages[msgIndex].fullContent) { |
||||
this.$set(this.messages[msgIndex], 'fullContent', data); |
||||
} else { |
||||
this.$set(this.messages[msgIndex], 'fullContent', this.messages[msgIndex].fullContent + data); |
||||
} |
||||
}, |
||||
onCompleted: (downloadUrl) => { |
||||
// 开始打字机效果 |
||||
const fullContent = this.messages[msgIndex].fullContent || ''; |
||||
this.typewriterEffect(msgIndex, fullContent); |
||||
|
||||
// 设置下载链接 |
||||
this.$set(this.messages[msgIndex], 'downloadUrl', downloadUrl); |
||||
this.loading = false; |
||||
eventSource.close(); |
||||
this.currentEventSource = null; |
||||
}, |
||||
onFailed: (errorMsg) => { |
||||
this.handleError('生成失败:' + errorMsg, msgIndex); |
||||
eventSource.close(); |
||||
this.currentEventSource = null; |
||||
}, |
||||
onError: (error) => { |
||||
console.error('SSE error', error); |
||||
// 如果连接意外关闭且未收到完成事件,显示错误 |
||||
if (this.loading) { |
||||
this.handleError('连接中断,请重试', msgIndex); |
||||
} |
||||
eventSource.close(); |
||||
this.currentEventSource = null; |
||||
}, |
||||
}); |
||||
|
||||
this.currentEventSource = eventSource; |
||||
}, |
||||
|
||||
// 打字机效果 |
||||
typewriterEffect(msgIndex, text, index = 0) { |
||||
if (index >= text.length) { |
||||
return; |
||||
} |
||||
|
||||
this.$set(this.messages[msgIndex], 'content', text.substring(0, index + 1)); |
||||
this.scrollToBottom(); |
||||
|
||||
// 1ms后显示下一个字符 |
||||
setTimeout(() => { |
||||
this.typewriterEffect(msgIndex, text, index + 1); |
||||
}, 1); |
||||
}, |
||||
|
||||
// 处理错误 |
||||
handleError(errorText, msgIndex) { |
||||
// 更新AI消息内容为错误提示 |
||||
this.$set(this.messages[msgIndex], 'content', errorText); |
||||
this.loading = false; |
||||
if (this.currentEventSource) { |
||||
this.currentEventSource.close(); |
||||
this.currentEventSource = null; |
||||
} |
||||
this.scrollToBottom(); |
||||
}, |
||||
|
||||
// 滚动到底部 |
||||
scrollToBottom() { |
||||
this.$nextTick(() => { |
||||
const container = this.$refs.messageList; |
||||
if (container) { |
||||
container.scrollTop = container.scrollHeight; |
||||
} |
||||
}); |
||||
}, |
||||
}, |
||||
beforeDestroy() { |
||||
// 组件销毁前关闭SSE连接 |
||||
if (this.currentEventSource) { |
||||
this.currentEventSource.close(); |
||||
this.currentEventSource = null; |
||||
} |
||||
}, |
||||
}; |
||||
</script> |
||||
|
||||
<style scoped> |
||||
.chat-container { |
||||
display: flex; |
||||
flex-direction: column; |
||||
height: 100%; |
||||
min-height: 400px; |
||||
background: linear-gradient(135deg, #1a2a3a 0%, #2d3f52 100%); |
||||
position: relative; |
||||
} |
||||
|
||||
.message-list { |
||||
flex: 1; |
||||
overflow-y: auto; |
||||
padding: 15px; |
||||
min-height: 0; |
||||
scrollbar-width: thin; |
||||
scrollbar-color: #4a6fa5 #1a2a3a; |
||||
} |
||||
|
||||
.message-list::-webkit-scrollbar { |
||||
width: 6px; |
||||
} |
||||
|
||||
.message-list::-webkit-scrollbar-track { |
||||
background: #1a2a3a; |
||||
} |
||||
|
||||
.message-list::-webkit-scrollbar-thumb { |
||||
background: #4a6fa5; |
||||
border-radius: 3px; |
||||
} |
||||
|
||||
.message-wrapper { |
||||
display: flex; |
||||
margin-bottom: 15px; |
||||
} |
||||
|
||||
.user-message { |
||||
flex-direction: row-reverse; |
||||
} |
||||
|
||||
.avatar { |
||||
width: 44px; |
||||
height: 44px; |
||||
margin: 0 10px; |
||||
flex-shrink: 0; |
||||
min-width: 44px; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
} |
||||
|
||||
.avatar img { |
||||
width: 100%; |
||||
height: 100%; |
||||
border-radius: 50%; |
||||
object-fit: contain; |
||||
background: linear-gradient(135deg, #0ac1c7 0%, #09a8ae 100%); |
||||
border: 2px solid #0ac1c7; |
||||
box-shadow: 0 0 15px rgba(10, 193, 199, 0.6), 0 0 30px rgba(10, 193, 199, 0.3); |
||||
image-rendering: -webkit-optimize-contrast; |
||||
image-rendering: crisp-edges; |
||||
image-rendering: pixelated; |
||||
padding: 8px; |
||||
box-sizing: border-box; |
||||
} |
||||
|
||||
/* 用户头像样式 */ |
||||
.user-message .avatar img { |
||||
background: linear-gradient(135deg, #4a6fa5 0%, #3d5a80 100%); |
||||
border-color: #4a6fa5; |
||||
box-shadow: 0 0 15px rgba(74, 111, 165, 0.6), 0 0 30px rgba(74, 111, 165, 0.3); |
||||
} |
||||
|
||||
.bubble { |
||||
max-width: 70%; |
||||
padding: 12px 16px; |
||||
border-radius: 16px; |
||||
background: rgba(45, 63, 82, 0.8); |
||||
border: 1px solid rgba(10, 193, 199, 0.3); |
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); |
||||
word-wrap: break-word; |
||||
font-size: 14px; |
||||
backdrop-filter: blur(10px); |
||||
} |
||||
|
||||
.user-message .bubble { |
||||
background: linear-gradient(135deg, #0ac1c7 0%, #09a8ae 100%); |
||||
border-color: #0ac1c7; |
||||
} |
||||
|
||||
.message-content { |
||||
line-height: 1.6; |
||||
color: #e0e6ed; |
||||
word-wrap: break-word; |
||||
} |
||||
|
||||
.user-message .message-content { |
||||
color: #ffffff; |
||||
} |
||||
|
||||
/* JSON内容样式 */ |
||||
.json-content { |
||||
background: rgba(0, 0, 0, 0.4); |
||||
border-radius: 8px; |
||||
padding: 12px; |
||||
margin: 0; |
||||
overflow-x: auto; |
||||
font-family: 'Courier New', Courier, monospace; |
||||
font-size: 13px; |
||||
line-height: 1.5; |
||||
color: #e0e6ed; |
||||
white-space: pre-wrap; |
||||
word-wrap: break-word; |
||||
border: 1px solid rgba(10, 193, 199, 0.2); |
||||
} |
||||
|
||||
.json-content code { |
||||
color: #e0e6ed; |
||||
} |
||||
|
||||
.download-area { |
||||
margin-top: 10px; |
||||
text-align: center; |
||||
} |
||||
|
||||
.download-btn { |
||||
display: inline-block; |
||||
padding: 8px 20px; |
||||
background: linear-gradient(135deg, #0ac1c7 0%, #09a8ae 100%); |
||||
color: white; |
||||
text-decoration: none; |
||||
border-radius: 20px; |
||||
font-size: 13px; |
||||
transition: all 0.3s ease; |
||||
border: 1px solid #0ac1c7; |
||||
box-shadow: 0 2px 8px rgba(10, 193, 199, 0.3); |
||||
} |
||||
|
||||
.download-btn:hover { |
||||
background: linear-gradient(135deg, #09a8ae 0%, #088a90 100%); |
||||
transform: translateY(-2px); |
||||
box-shadow: 0 4px 12px rgba(10, 193, 199, 0.5); |
||||
} |
||||
|
||||
.input-area { |
||||
display: flex; |
||||
padding: 15px; |
||||
background: rgba(26, 42, 58, 0.95); |
||||
border-top: 1px solid rgba(10, 193, 199, 0.2); |
||||
flex-shrink: 0; |
||||
height: auto; |
||||
backdrop-filter: blur(10px); |
||||
} |
||||
|
||||
.input-area textarea { |
||||
flex: 1; |
||||
padding: 12px 16px; |
||||
border: 1px solid rgba(10, 193, 199, 0.3); |
||||
border-radius: 24px; |
||||
resize: none; |
||||
outline: none; |
||||
font-size: 14px; |
||||
line-height: 1.4; |
||||
max-height: 80px; |
||||
min-height: 44px; |
||||
background: rgba(45, 63, 82, 0.6); |
||||
color: #e0e6ed; |
||||
transition: all 0.3s ease; |
||||
} |
||||
|
||||
.input-area textarea::placeholder { |
||||
color: #6b8a9e; |
||||
} |
||||
|
||||
.input-area textarea:focus { |
||||
border-color: #0ac1c7; |
||||
box-shadow: 0 0 10px rgba(10, 193, 199, 0.3); |
||||
background: rgba(45, 63, 82, 0.8); |
||||
} |
||||
|
||||
.input-area button { |
||||
margin-left: 12px; |
||||
padding: 0 24px; |
||||
background: linear-gradient(135deg, #0ac1c7 0%, #09a8ae 100%); |
||||
color: white; |
||||
border: none; |
||||
border-radius: 24px; |
||||
font-size: 14px; |
||||
cursor: pointer; |
||||
transition: all 0.3s ease; |
||||
white-space: nowrap; |
||||
height: 44px; |
||||
align-self: center; |
||||
font-weight: 500; |
||||
box-shadow: 0 2px 8px rgba(10, 193, 199, 0.3); |
||||
} |
||||
|
||||
.input-area button:hover:not(:disabled) { |
||||
background: linear-gradient(135deg, #09a8ae 0%, #088a90 100%); |
||||
transform: translateY(-2px); |
||||
box-shadow: 0 4px 12px rgba(10, 193, 199, 0.5); |
||||
} |
||||
|
||||
.input-area button:disabled { |
||||
background: #4a6fa5; |
||||
cursor: not-allowed; |
||||
opacity: 0.6; |
||||
box-shadow: none; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,693 @@
|
||||
<template> |
||||
<div class="app-container"> |
||||
<div class="special-div"> |
||||
<div class="special-top"> |
||||
<div class="special-title">项目简介</div> |
||||
</div> |
||||
<div class="project-all"> |
||||
<img |
||||
class="project-img" |
||||
:src="imgUrl + projectObj.logo" |
||||
@click="showDialog" |
||||
alt="Base64 Image" |
||||
/> |
||||
<div class="project-li"> |
||||
<div class="list-con"> |
||||
<div class="project-left"> |
||||
<img |
||||
class="left-icon" |
||||
src="../assets/images/project-icon1.png" |
||||
alt="" |
||||
/> |
||||
<div class="project-name">项目名称</div> |
||||
</div> |
||||
<div class="project-right">{{ projectObj.proName }}</div> |
||||
</div> |
||||
<div class="list-con"> |
||||
<div class="project-left"> |
||||
<img |
||||
class="left-icon" |
||||
src="../assets/images/project-icon2.png" |
||||
alt="" |
||||
/> |
||||
<div class="project-name">建筑面积</div> |
||||
</div> |
||||
<div class="project-right">{{ projectObj.buildingArea }}m³</div> |
||||
</div> |
||||
<div class="list-con"> |
||||
<div class="project-left"> |
||||
<img |
||||
class="left-icon" |
||||
src="../assets/images/project-icon3.png" |
||||
alt="" |
||||
/> |
||||
<div class="project-name">运营地址</div> |
||||
</div> |
||||
<div class="project-right">{{ projectObj.proAddr }}</div> |
||||
</div> |
||||
</div> |
||||
<div class="project-li"> |
||||
<div class="list-con"> |
||||
<div class="project-left"> |
||||
<img |
||||
class="left-icon" |
||||
src="../assets/images/project-icon3.png" |
||||
alt="" |
||||
/> |
||||
<div class="project-name">项目运行开始时间</div> |
||||
</div> |
||||
<div class="project-right">{{ projectObj.operateStartTime }}</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div class="project-data"> |
||||
<div class="special-div" style="width: 49.4%"> |
||||
<div class="special-top"> |
||||
<div class="special-title">出水温度</div> |
||||
</div> |
||||
<div class="overview"> |
||||
<div class="overview-li" @click="goEnergy"> |
||||
<div>离心机高温出水温度</div> |
||||
<div |
||||
class="overview-details" |
||||
v-html="getTempDisplay('离心机高温出水温度', outWaterTemperature)" |
||||
></div> |
||||
</div> |
||||
<div class="overview-li" @click="goEnergy"> |
||||
<div>中温换热出水温度</div> |
||||
<div |
||||
class="overview-details" |
||||
v-html="getTempDisplay('中温换热出水温度', outWaterTemperature)" |
||||
></div> |
||||
</div> |
||||
<div class="overview-li" @click="goEnergy"> |
||||
<div>低温1换热出水温度</div> |
||||
<div |
||||
class="overview-details" |
||||
v-html="getTempDisplay('低温1换热出水温度', outWaterTemperature)" |
||||
></div> |
||||
</div> |
||||
<div class="overview-li" @click="goEnergy"> |
||||
<div>低温2换热出水温度</div> |
||||
<div |
||||
class="overview-details" |
||||
v-html="getTempDisplay('低温2换热出水温度', outWaterTemperature)" |
||||
></div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div class="special-div" style="width: 49.4%"> |
||||
<div class="special-top"> |
||||
<div class="special-title">热量数据</div> |
||||
</div> |
||||
<div class="overview"> |
||||
<div class="overview-li" @click="goEnergy"> |
||||
<div>生产积累热量</div> |
||||
<div class="overview-details"> |
||||
{{ heatData.productionHeatSum }}kwh |
||||
</div> |
||||
</div> |
||||
<div class="overview-li" @click="goEnergy"> |
||||
<div>散热累计热量</div> |
||||
<div class="overview-details"> |
||||
{{ heatData.dissipationHeatSum }}kwh |
||||
</div> |
||||
</div> |
||||
<div class="overview-li" @click="goEnergy"> |
||||
<div>总热量回收</div> |
||||
<div class="overview-details"> |
||||
{{ heatData.totalHeatRecoverySum }}kwh |
||||
</div> |
||||
</div> |
||||
<div class="overview-li" @click="goEnergy"> |
||||
<div>热利用率</div> |
||||
<div class="overview-details">{{ heatData.heatUtilization }}%</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div class="project-bie"> |
||||
<div class="special-div" style="width: 49.4%"> |
||||
<div class="special-top"> |
||||
<div class="special-title">系统数据</div> |
||||
</div> |
||||
<div class="hot-tem"> |
||||
<div class="tem-li"> |
||||
<div class="tem-title">离心机入口温度</div> |
||||
<div |
||||
class="tem-detail" |
||||
v-html="getTempDisplay('离心机入口温度', systemData)" |
||||
></div> |
||||
</div> |
||||
<div class="tem-li"> |
||||
<div class="tem-title">离心机出水温度</div> |
||||
<div |
||||
class="tem-detail" |
||||
v-html="getTempDisplay('离心机出水温度', systemData)" |
||||
></div> |
||||
</div> |
||||
<div class="tem-li"> |
||||
<div class="tem-title">保障进水温度</div> |
||||
<div |
||||
class="tem-detail" |
||||
v-html="getTempDisplay('保障进水温度', systemData)" |
||||
></div> |
||||
</div> |
||||
</div> |
||||
<!-- <div class="pressure"> |
||||
<view-cold-sys |
||||
:subData="getDeviceData('离心机进水压力')" |
||||
:title="'离心机进水压力'" |
||||
></view-cold-sys> |
||||
<view-cold-sys |
||||
:subData="getDeviceData('离心机出水压力')" |
||||
:title="'离心机出水压力'" |
||||
></view-cold-sys> |
||||
<view-cold-sys |
||||
:subData="getDeviceData('离心机压差')" |
||||
:title="'离心机压差'" |
||||
></view-cold-sys> |
||||
</div> --> |
||||
</div> |
||||
<div class="bir-right"> |
||||
<div class="special-div"> |
||||
<div class="special-top"> |
||||
<div class="special-title">阀门开度</div> |
||||
</div> |
||||
<hot-water :subData="valveData"></hot-water> |
||||
</div> |
||||
<div class="later-data"> |
||||
<div class="special-div"> |
||||
<div class="special-top"> |
||||
<div class="special-title">热回收数据</div> |
||||
</div> |
||||
<div class="hot-data"> |
||||
<div class="hot-li"> |
||||
<div class="hot-title">瞬时热量:</div> |
||||
<div class="hot-detail">{{heatRecoveryData.instantaneousHeatSum}}kw</div> |
||||
</div> |
||||
<div class="hot-li"> |
||||
<div class="hot-title">日累计热量:</div> |
||||
<div class="hot-detail">{{heatRecoveryData.dailyAccumulatedHeat}}kwh</div> |
||||
</div> |
||||
<div class="hot-li"> |
||||
<div class="hot-title">累计热量:</div> |
||||
<div class="hot-detail">{{heatRecoveryData.accumulatedHeatSum}}kwh</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div class="special-div"> |
||||
<div class="special-top"> |
||||
<div class="special-title">应用测数据</div> |
||||
</div> |
||||
<div class="hot-data"> |
||||
<div class="hot-li"> |
||||
<div class="hot-title">瞬时热量:</div> |
||||
<div class="hot-detail">{{applicationData.instantaneousHeatSum}}kw</div> |
||||
</div> |
||||
<div class="hot-li"> |
||||
<div class="hot-title">日累计热量:</div> |
||||
<div class="hot-detail">{{applicationData.dailyAccumulatedHeat}}kwh</div> |
||||
</div> |
||||
<div class="hot-li"> |
||||
<div class="hot-title">累计热量:</div> |
||||
<div class="hot-detail">{{applicationData.accumulatedHeatSum}}kwh</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<el-dialog |
||||
:visible.sync="dialogVisible" |
||||
title="上传图片" |
||||
width="500px" |
||||
@close="addExpenseClose" |
||||
> |
||||
<!-- 使用 el-upload 组件 --> |
||||
<el-upload |
||||
class="upload-demo" |
||||
ref="uploadComponent" |
||||
action="#" |
||||
list-type="picture-card" |
||||
:on-preview="handlePictureCardPreview" |
||||
:on-remove="handleRemove" |
||||
:before-upload="beforeUpload" |
||||
:limit="1" |
||||
:on-exceed="handleExceed" |
||||
:on-change="handleFileChange" |
||||
accept="image/png, image/jpeg" |
||||
:http-request="customHttpRequest" |
||||
> |
||||
<i class="el-icon-plus"></i> |
||||
</el-upload> |
||||
<div slot="footer" class="dialog-footer"> |
||||
<!-- 取消按钮 --> |
||||
<el-button @click="dialogVisible = false">取消</el-button> |
||||
<!-- 确定按钮 --> |
||||
<el-button type="primary" @click="uploadFile">确定</el-button> |
||||
</div> |
||||
</el-dialog> |
||||
</div> |
||||
</template> |
||||
|
||||
<script> |
||||
import { imgUrl } from "@/utils/global"; |
||||
import { introduction, changeLogo, ersDatas } from "@/api/index"; |
||||
// import ViewColdSys from "./components/viewColdSys.vue"; |
||||
import HotWater from "./components/hotWater.vue"; |
||||
|
||||
export default { |
||||
components: { |
||||
// ViewColdSys, |
||||
HotWater, |
||||
}, |
||||
data() { |
||||
return { |
||||
imgUrl: "", |
||||
projectObj: { |
||||
proName: "", |
||||
logo: "", |
||||
proAddr: "", |
||||
buildingArea: "", |
||||
operateStartTime: "", |
||||
}, |
||||
heatData: {}, //热量数据 |
||||
systemData: [], //系统数据 |
||||
valveData: [], //阀门开度 |
||||
outWaterTemperature: [], //出水温度 |
||||
applicationData: {}, //应用测数据 |
||||
heatRecoveryData: {}, //热回收数据 |
||||
coldSys: [], |
||||
hotWaterSys: [], |
||||
dialogVisible: false, |
||||
selectedFile: null, |
||||
viewImageUrl: "", |
||||
}; |
||||
}, |
||||
mounted() { |
||||
this.getProject(); |
||||
this.getErsData(); |
||||
}, |
||||
methods: { |
||||
// 全屏操作 |
||||
requestFullscreen() { |
||||
const element = document.documentElement; |
||||
console.log("全屏了吗"); |
||||
if (element.requestFullscreen) { |
||||
element.requestFullscreen(); |
||||
} else if (element.mozRequestFullScreen) { |
||||
// Firefox |
||||
element.mozRequestFullScreen(); |
||||
} else if (element.webkitRequestFullscreen) { |
||||
// Chrome, Safari and Opera |
||||
element.webkitRequestFullscreen(); |
||||
} else if (element.msRequestFullscreen) { |
||||
// IE/Edge |
||||
element.msRequestFullscreen(); |
||||
} |
||||
}, |
||||
// 获取项目简介数据 |
||||
getProject() { |
||||
this.imgUrl = imgUrl; |
||||
introduction().then((res) => { |
||||
console.log("项目资料", res); |
||||
if (res.code == 200) { |
||||
this.projectObj = res.rows[0]; |
||||
} |
||||
}); |
||||
}, |
||||
// 显示上传图片弹框 |
||||
showDialog() { |
||||
this.dialogVisible = true; |
||||
}, |
||||
// 处理文件选择事件 |
||||
handleFileChange(file, fileList) { |
||||
this.selectedFile = file.raw; |
||||
}, |
||||
// 关闭弹框 |
||||
addExpenseClose() { |
||||
// 清除上传文件 |
||||
this.$refs.uploadComponent.clearFiles(); |
||||
this.viewImageUrl = ""; |
||||
}, |
||||
// 重置 |
||||
reset() { |
||||
this.dialogData = {}; |
||||
// 清除上传文件 |
||||
this.$refs.uploadComponent.clearFiles(); |
||||
this.viewImageUrl = ""; |
||||
}, |
||||
// 图片移除 |
||||
handleRemove(file, fileList) { |
||||
console.log(file, fileList); |
||||
this.selectedFile = {}; |
||||
}, |
||||
// 图片预览 |
||||
handlePictureCardPreview(file) { |
||||
this.dialogVisible = true; |
||||
this.viewImageUrl = file.url; |
||||
}, |
||||
// 上传成功 |
||||
handleUploadSuccess(response, file, fileList) {}, |
||||
// 处理文件 |
||||
handleFileChange(file, fileList) { |
||||
console.log("file", file); |
||||
this.selectedFile = file.raw; |
||||
}, |
||||
processFile(file) { |
||||
// 在这里你可以对 file.raw 进行任何本地处理 |
||||
console.log("处理的文件", file); |
||||
// 例如:读取文件内容 |
||||
const reader = new FileReader(); |
||||
reader.onload = (event) => { |
||||
console.log("文件内容", event.target.result); |
||||
}; |
||||
reader.readAsDataURL(file); |
||||
}, |
||||
customHttpRequest(options) { |
||||
// 自定义上传逻辑,不进行实际的网络请求 |
||||
const file = options.file; |
||||
}, |
||||
beforeUpload(file) { |
||||
const isJpgOrPng = |
||||
file.type === "image/jpeg" || file.type === "image/png"; |
||||
if (!isJpgOrPng) { |
||||
this.$message.error("上传图片只能是 JPG 或 PNG 格式!"); |
||||
} |
||||
const isLt2M = file.size / 1024 / 1024 < 2; |
||||
if (!isLt2M) { |
||||
this.$message.error("上传图片大小不能超过 2MB!"); |
||||
} |
||||
return isJpgOrPng && isLt2M; |
||||
}, |
||||
handleExceed(files, fileList) { |
||||
this.$message.warning(`上传文件数量超过限制, 当前限制为 1 张`); |
||||
}, |
||||
// 上传 |
||||
uploadFile() { |
||||
console.log("这里进行请求"); |
||||
let data = { |
||||
proId: 0, |
||||
logo: this.selectedFile, |
||||
}; |
||||
changeLogo(data).then((res) => { |
||||
if (res.code == 200) { |
||||
this.$modal.msgSuccess("上传成功"); |
||||
this.getProject(); |
||||
this.dialogVisible = false; |
||||
} else { |
||||
this.$message.error("上传失败"); |
||||
this.dialogVisible = false; |
||||
} |
||||
}); |
||||
}, |
||||
// 查询数据 |
||||
getErsData() { |
||||
let data = { |
||||
systemType: "7", |
||||
}; |
||||
ersDatas(data).then((res) => { |
||||
if (res.code == 200) { |
||||
console.log("首页返回数据----------", res.rows[0]); |
||||
this.heatData = res.rows[0].heatData; |
||||
this.systemData = res.rows[0].systemData; |
||||
this.valveData = res.rows[0].valveData; |
||||
this.outWaterTemperature = res.rows[0].outWaterTemperature; |
||||
this.applicationData = res.rows[0].applicationData; |
||||
this.heatRecoveryData = res.rows[0].heatRecoveryData; |
||||
// 计算并添加离心机压差 |
||||
this.addCentrifugePressureDiff(); |
||||
console.log("deviceName-1", this.systemData); |
||||
} |
||||
}); |
||||
}, |
||||
// 计算离心机压差并添加到systemData |
||||
addCentrifugePressureDiff() { |
||||
// 查找进水压力和出水压力对象 |
||||
const inletPressure = this.systemData.find( |
||||
(item) => item.deviceTypeName === "离心机进水压力" |
||||
); |
||||
|
||||
const outletPressure = this.systemData.find( |
||||
(item) => item.deviceTypeName === "离心机出水压力" |
||||
); |
||||
|
||||
// 如果两个对象都存在 |
||||
if (inletPressure && outletPressure) { |
||||
// 计算压差(出水压力 - 进水压力) |
||||
const pressureDiff = |
||||
parseFloat(outletPressure.curValue) - |
||||
parseFloat(inletPressure.curValue); |
||||
|
||||
// 判断状态:如果进水或出水压力任一状态为1,则压差状态为1(异常) |
||||
const status = |
||||
inletPressure.status === 1 || outletPressure.status === 1 ? 1 : 0; |
||||
|
||||
// 创建压差对象 |
||||
const pressureDiffObj = { |
||||
paramType: "14", // 可以设置为压差类型,如果没有特定值可以设一个默认 |
||||
curTime: null, |
||||
orderNum: this.systemData.length + 1, // 顺序号设为数组长度+1 |
||||
deviceTypeName: "离心机压差", |
||||
otherName: "压差计算值", |
||||
deviceName: "离心机压差", |
||||
curValue: parseFloat(pressureDiff.toFixed(2)), // 保留两位小数 |
||||
status: status, |
||||
}; |
||||
|
||||
// 将压差对象添加到systemData中 |
||||
this.systemData.push(pressureDiffObj); |
||||
console.log("添加离心机压差对象:", pressureDiffObj); |
||||
} else { |
||||
console.warn("找不到进水压力或出水压力数据"); |
||||
} |
||||
}, |
||||
// 处理系统数据 |
||||
getTempDisplay(deviceName, data) { |
||||
// console.log("this.systemData",this.systemData) |
||||
// console.log("deviceName",deviceName) |
||||
const item = data.find((item) => item.deviceTypeName === deviceName); |
||||
// console.log("item", item); |
||||
|
||||
if (!item) return "--℃"; |
||||
|
||||
if (item.status === 1) { |
||||
return '<span style="color: red">异常</span>'; |
||||
} |
||||
return `${item.curValue}℃`; |
||||
}, |
||||
// 处理系统数据-仪表盘 |
||||
getDeviceData(deviceTypeName) { |
||||
return ( |
||||
this.systemData.find( |
||||
(item) => item.deviceTypeName === deviceTypeName |
||||
) || {} |
||||
); |
||||
}, |
||||
goEnergy() { |
||||
// this.$router.push("/comprehensiveEnergy/systemEnergy"); |
||||
}, |
||||
}, |
||||
}; |
||||
</script> |
||||
|
||||
<style lang="scss" scoped> |
||||
.project-all { |
||||
width: 100%; |
||||
display: flex; |
||||
flex-direction: row; |
||||
align-items: flex-start; |
||||
padding: 0.35rem; |
||||
.project-img { |
||||
width: 1.4rem; |
||||
height: 1.4rem; |
||||
border-radius: 0.1rem; |
||||
border: solid 1px #0163a8; |
||||
margin-right: 0.4rem; |
||||
cursor: pointer; |
||||
} |
||||
.project-li { |
||||
width: calc((100% - 0.7rem) / 2); |
||||
display: flex; |
||||
flex-direction: column; |
||||
.list-con { |
||||
width: 100%; |
||||
display: flex; |
||||
flex-direction: row; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
.project-left { |
||||
display: flex; |
||||
flex-direction: row; |
||||
align-items: center; |
||||
.left-icon { |
||||
width: 0.2rem; |
||||
height: 0.2rem; |
||||
} |
||||
.project-name { |
||||
font-family: SourceHanSansCN-Regular; |
||||
font-size: 0.18rem; |
||||
line-height: 0.4rem; |
||||
color: #ffffff; |
||||
opacity: 0.8; |
||||
margin-left: 0.15rem; |
||||
} |
||||
} |
||||
.project-right { |
||||
font-family: SourceHanSansCN-Regular; |
||||
font-size: 0.18rem; |
||||
line-height: 0.4rem; |
||||
color: #ffffff; |
||||
} |
||||
} |
||||
} |
||||
.project-li:nth-last-child(1) { |
||||
margin-left: 1.5rem; |
||||
} |
||||
} |
||||
.project-data { |
||||
display: flex; |
||||
flex-direction: row; |
||||
align-items: stretch; |
||||
justify-content: space-between; |
||||
margin: 16px 0; |
||||
.overview { |
||||
width: 100%; |
||||
padding: 30px 20px 5px 20px; |
||||
display: flex; |
||||
flex-direction: row; |
||||
align-items: center; |
||||
justify-content: space-between; |
||||
flex-wrap: wrap; |
||||
.overview-li { |
||||
cursor: pointer; |
||||
width: 49%; |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: center; |
||||
justify-content: space-between; |
||||
background-position: center center; |
||||
margin-bottom: 15px; |
||||
font-family: SourceHanSansCN-Regular; |
||||
font-size: 15px; |
||||
color: #ffffff; |
||||
/* 从 rgba(41, 128, 185, 0.8) 渐变到透明 */ |
||||
background: linear-gradient( |
||||
to bottom, |
||||
rgba(31, 100, 146, 0.6) 0%, |
||||
rgba(33, 65, 87, 0.3) 50%, |
||||
rgba(40, 62, 77, 0) 100% |
||||
); |
||||
border-radius: 8px; |
||||
padding: 5px; |
||||
color: white; |
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); |
||||
.overview-details { |
||||
font-family: DIN-Bold; |
||||
font-size: 20px; |
||||
color: #15e1fd; |
||||
margin-top: 5px; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
.chartsDiv { |
||||
width: 100%; |
||||
height: 4rem; |
||||
} |
||||
.project-bie { |
||||
width: 100%; |
||||
display: flex; |
||||
flex-direction: row; |
||||
align-items: stretch; |
||||
justify-content: space-between; |
||||
.pressure { |
||||
width: 100%; |
||||
display: flex; |
||||
flex-direction: row; |
||||
align-items: center; |
||||
margin-top: 25px; |
||||
} |
||||
.hot-tem { |
||||
width: 100%; |
||||
padding: 0 20px; |
||||
display: flex; |
||||
flex-direction: row; |
||||
justify-content: space-between; |
||||
align-items: stretch; |
||||
flex-wrap: wrap; |
||||
margin-top: 25px; |
||||
.tem-li { |
||||
background-color: rgba(93, 125, 143, 0.5); |
||||
width: 32%; |
||||
padding: 10px; |
||||
border-radius: 10px; |
||||
display: flex; |
||||
flex-direction: column; |
||||
justify-content: space-between; |
||||
margin-bottom: 20px; |
||||
.tem-title { |
||||
font-size: 15px; |
||||
font-weight: 600; |
||||
color: #ffffff; |
||||
} |
||||
.tem-detail { |
||||
margin-top: 8px; |
||||
font-size: 20px; |
||||
font-weight: 600; |
||||
color: #f89615; |
||||
text-align: center; |
||||
} |
||||
} |
||||
} |
||||
.bir-right { |
||||
width: 49.4%; |
||||
display: flex; |
||||
flex-direction: column; |
||||
justify-content: space-between; |
||||
.special-div { |
||||
width: 100%; |
||||
margin-bottom: 20px; |
||||
} |
||||
.later-data { |
||||
width: 100%; |
||||
display: flex; |
||||
flex-direction: row; |
||||
justify-content: space-between; |
||||
.special-div { |
||||
width: 49%; |
||||
margin-bottom: 0; |
||||
} |
||||
.hot-data { |
||||
width: 100%; |
||||
padding: 25px 25px 5px 25px; |
||||
.hot-li { |
||||
width: 100%; |
||||
display: flex; |
||||
flex-direction: row; |
||||
align-items: center; |
||||
justify-content: space-between; |
||||
margin-bottom: 20px; |
||||
.hot-title { |
||||
font-size: 15px; |
||||
font-weight: 600; |
||||
color: #ffffff; |
||||
} |
||||
.hot-detail { |
||||
font-size: 20px; |
||||
font-weight: 600; |
||||
color: #f5f127; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@media (max-width: 1485px) { |
||||
} |
||||
// 媒体查询,适配大于2000px分辨率的大屏样式 |
||||
@media (min-width: 2000px) { |
||||
} |
||||
</style> |
||||
Loading…
Reference in new issue