v2
This commit is contained in:
120
ui/src/views/portal/About.vue
Normal file
120
ui/src/views/portal/About.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div class="about-container">
|
||||
<div class="about-header glass">
|
||||
<img src="/logo.png" alt="Logo" class="about-logo" />
|
||||
<h1 class="about-title">Hertz Admin</h1>
|
||||
<p class="about-subtitle">现代化全栈权限管理系统解决方案</p>
|
||||
</div>
|
||||
|
||||
<div class="about-content">
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="14">
|
||||
<el-card class="info-card glass" header="项目简介">
|
||||
<p class="intro-text">
|
||||
Hertz Admin 是一个基于 <strong>Spring Boot 3</strong> 和 <strong>Vue 3</strong> 构建的前后端分离权限管理系统。
|
||||
它集成了最新的技术栈,提供了一套完整的 RBAC(基于角色的访问控制)解决方案,支持动态菜单、系统监控、AI 对话等功能。
|
||||
旨在帮助开发者快速搭建企业级后台管理系统。
|
||||
</p>
|
||||
</el-card>
|
||||
|
||||
<el-card class="info-card glass mt-4" header="核心技术栈">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="后端框架">Spring Boot 3.4</el-descriptions-item>
|
||||
<el-descriptions-item label="ORM 框架">MyBatis-Plus 3.5</el-descriptions-item>
|
||||
<el-descriptions-item label="安全框架">Spring Security + JWT</el-descriptions-item>
|
||||
<el-descriptions-item label="AI 集成">Spring AI (Ollama)</el-descriptions-item>
|
||||
<el-descriptions-item label="前端框架">Vue 3 (Composition API)</el-descriptions-item>
|
||||
<el-descriptions-item label="UI 组件库">Element Plus</el-descriptions-item>
|
||||
<el-descriptions-item label="构建工具">Maven & Vite</el-descriptions-item>
|
||||
<el-descriptions-item label="数据库">MySQL 8.0 + Redis</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="10">
|
||||
<el-card class="info-card glass" header="主要功能">
|
||||
<el-timeline>
|
||||
<el-timeline-item type="primary" :hollow="true" timestamp="用户权限">
|
||||
RBAC 模型,支持用户、角色、菜单(按钮级)权限控制
|
||||
</el-timeline-item>
|
||||
<el-timeline-item type="success" :hollow="true" timestamp="动态路由">
|
||||
基于后端权限动态生成前端路由菜单
|
||||
</el-timeline-item>
|
||||
<el-timeline-item type="warning" :hollow="true" timestamp="AI 助手">
|
||||
集成 LLM 大模型,提供智能对话与辅助功能
|
||||
</el-timeline-item>
|
||||
<el-timeline-item type="info" :hollow="true" timestamp="系统监控">
|
||||
实时监控服务器 CPU、内存、JVM 及磁盘状态
|
||||
</el-timeline-item>
|
||||
<el-timeline-item type="danger" :hollow="true" timestamp="安全机制">
|
||||
JWT 认证、验证码、全局异常处理、操作日志
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.about-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.about-header {
|
||||
text-align: center;
|
||||
padding: 48px 24px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(240,249,255,0.9) 100%);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.about-logo {
|
||||
width: 80px;
|
||||
height: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.about-title {
|
||||
font-size: 32px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 12px;
|
||||
background: linear-gradient(135deg, var(--ios-primary) 0%, #00C6FB 100%);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.about-subtitle {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.intro-text {
|
||||
line-height: 1.8;
|
||||
color: #555;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:deep(.el-timeline-item__content) {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
838
ui/src/views/portal/Chat.vue
Normal file
838
ui/src/views/portal/Chat.vue
Normal file
@@ -0,0 +1,838 @@
|
||||
<template>
|
||||
<el-container class="chat-container">
|
||||
<el-aside width="300px" class="chat-sidebar" :class="{ 'mobile-hidden': isMobile && !showSidebar }">
|
||||
<div class="sidebar-header">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索历史对话..."
|
||||
prefix-icon="Search"
|
||||
class="search-input"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<el-button type="primary" class="new-chat-btn" @click="createNewChat" circle>
|
||||
<el-icon><Plus /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<el-scrollbar class="conversation-list">
|
||||
<div
|
||||
v-for="conv in conversations"
|
||||
:key="conv.id"
|
||||
class="conversation-item"
|
||||
:class="{ active: currentConversationId === conv.id, 'active-edit': editingConversationId === conv.id }"
|
||||
@click="selectConversation(conv)"
|
||||
>
|
||||
<div class="conv-content">
|
||||
<el-input
|
||||
v-if="editingConversationId === conv.id"
|
||||
v-model="editingTitle"
|
||||
size="small"
|
||||
@click.stop
|
||||
@keydown.enter="saveEdit(conv)"
|
||||
@blur="saveEdit(conv)"
|
||||
class="edit-input"
|
||||
/>
|
||||
<span v-else class="conv-title">{{ conv.title }}</span>
|
||||
<span class="conv-time">{{ formatTime(conv.updatedAt) }}</span>
|
||||
</div>
|
||||
<div class="conv-actions">
|
||||
<el-button
|
||||
v-if="editingConversationId !== conv.id"
|
||||
type="primary"
|
||||
link
|
||||
icon="Edit"
|
||||
class="action-btn"
|
||||
@click.stop="startEdit(conv)"
|
||||
/>
|
||||
<el-button
|
||||
v-if="editingConversationId !== conv.id"
|
||||
type="danger"
|
||||
link
|
||||
icon="Delete"
|
||||
class="action-btn"
|
||||
@click.stop="deleteConversation(conv.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</el-aside>
|
||||
|
||||
<el-main class="chat-main" :class="{ 'mobile-full': isMobile && !showSidebar }">
|
||||
|
||||
<!-- New Chat / Empty State View -->
|
||||
<div v-if="isNewChat" class="empty-chat-container">
|
||||
<div class="welcome-text">{{ welcomeMessage }}</div>
|
||||
<div class="chat-input-wrapper center-input">
|
||||
<el-input
|
||||
ref="chatInputRef"
|
||||
v-model="inputMessage"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 1, maxRows: 4 }"
|
||||
placeholder="有问题,尽管问"
|
||||
class="custom-input"
|
||||
resize="none"
|
||||
@keydown.enter.prevent="sendMessage"
|
||||
:disabled="isStreaming"
|
||||
/>
|
||||
<div class="input-actions">
|
||||
<!-- <el-button circle text>
|
||||
<el-icon><Microphone /></el-icon>
|
||||
</el-button> -->
|
||||
<el-button type="primary" circle class="send-btn-inner" @click="sendMessage" :loading="isStreaming" :disabled="!inputMessage.trim()">
|
||||
<el-icon><Top /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Chat View -->
|
||||
<template v-else>
|
||||
<div class="chat-header">
|
||||
<el-button v-if="isMobile" icon="Menu" text @click="toggleSidebar" />
|
||||
<span class="chat-title">{{ currentConversationTitle }}</span>
|
||||
</div>
|
||||
|
||||
<el-scrollbar ref="messageScroll" class="message-area">
|
||||
<div v-for="(msg, index) in messages" :key="index" class="message-wrapper" :class="msg.role">
|
||||
<div class="message-avatar">
|
||||
<el-avatar :icon="msg.role === 'user' ? 'User' : 'Service'" :src="msg.role === 'user' ? userAvatar : assistantAvatar" />
|
||||
</div>
|
||||
<div class="message-content">
|
||||
<div class="bubble markdown-body" v-html="renderMessage(msg.content)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
|
||||
<div class="input-area">
|
||||
<div class="chat-input-wrapper full-width">
|
||||
<el-input
|
||||
ref="chatInputRef"
|
||||
v-model="inputMessage"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 1, maxRows: 4 }"
|
||||
class="custom-input"
|
||||
resize="none"
|
||||
@keydown.enter.prevent="sendMessage"
|
||||
:disabled="isStreaming"
|
||||
/>
|
||||
<div class="input-actions">
|
||||
<el-button type="primary" circle class="send-btn-inner" @click="sendMessage" :loading="isStreaming" :disabled="!inputMessage.trim()">
|
||||
<el-icon><Top /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed, nextTick } from 'vue'
|
||||
import { chatApi } from '@/api/chat'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Search, Delete, User, Service, Menu, Edit, Top, Microphone } from '@element-plus/icons-vue'
|
||||
import { marked } from 'marked'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const conversations = ref([])
|
||||
const messages = ref([])
|
||||
const currentConversationId = ref(null)
|
||||
const currentConversationTitle = ref('新对话')
|
||||
const inputMessage = ref('')
|
||||
const searchQuery = ref('')
|
||||
const isStreaming = ref(false)
|
||||
const showSidebar = ref(true)
|
||||
const messageScroll = ref(null)
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
const editingConversationId = ref(null)
|
||||
const editingTitle = ref('')
|
||||
const editInputRef = ref(null)
|
||||
const chatInputRef = ref(null)
|
||||
|
||||
const isNewChat = computed(() => messages.value.length === 0)
|
||||
|
||||
const welcomeMessages = [
|
||||
"有什么可以帮忙的?",
|
||||
"今天想聊点什么?",
|
||||
"无论您遇到什么问题,我都在这里。",
|
||||
"准备好开始新的探索了吗?",
|
||||
"嗨!有什么我能为您做的吗?",
|
||||
"期待与您的交流,请随时提问。"
|
||||
]
|
||||
const welcomeMessage = ref(welcomeMessages[Math.floor(Math.random() * welcomeMessages.length)])
|
||||
|
||||
const userAvatar = computed(() => authStore.avatarPath ? (import.meta.env.VITE_API_BASE || 'http://localhost:8080') + authStore.avatarPath : '')
|
||||
const assistantAvatar = '/favicon.png'
|
||||
|
||||
const renderMessage = (content) => {
|
||||
if (!content) return ''
|
||||
try {
|
||||
// Pre-process content to fix common LLM formatting issues
|
||||
|
||||
// 1. Fix bold text followed immediately by a list item (e.g. **Title**1. Item)
|
||||
let processed = content.replace(/(\*\*[^*]+\*\*)(\s*)(\d+\.)/g, "$1\n\n$3")
|
||||
|
||||
// 2. Fix headers not having a newline before them
|
||||
processed = processed.replace(/([^\n])\s*(#{1,6}\s)/g, "$1\n\n$2")
|
||||
|
||||
// 3. Fix horizontal rules not having a newline before them
|
||||
processed = processed.replace(/([^\n])\s*(---+\s*$)/gm, "$1\n\n$2")
|
||||
|
||||
return marked.parse(processed, { breaks: true })
|
||||
} catch (e) {
|
||||
console.error('Markdown parsing error:', e)
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
if (!isMobile.value) showSidebar.value = true
|
||||
})
|
||||
|
||||
const toggleSidebar = () => {
|
||||
showSidebar.value = !showSidebar.value
|
||||
}
|
||||
|
||||
const loadConversations = async () => {
|
||||
try {
|
||||
const res = await chatApi.getConversations()
|
||||
conversations.value = res || []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const selectConversation = async (conv) => {
|
||||
currentConversationId.value = conv.id
|
||||
currentConversationTitle.value = conv.title
|
||||
if (isMobile.value) showSidebar.value = false
|
||||
|
||||
try {
|
||||
const res = await chatApi.getMessages(conv.id)
|
||||
messages.value = res || []
|
||||
scrollToBottom()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const createNewChat = () => {
|
||||
currentConversationId.value = null
|
||||
currentConversationTitle.value = '新对话'
|
||||
messages.value = []
|
||||
if (isMobile.value) showSidebar.value = false
|
||||
welcomeMessage.value = welcomeMessages[Math.floor(Math.random() * welcomeMessages.length)]
|
||||
nextTick(() => {
|
||||
chatInputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const deleteConversation = (id) => {
|
||||
ElMessageBox.confirm('确定要删除这个对话吗?', '提示', {
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
await chatApi.deleteConversation(id)
|
||||
ElMessage.success('删除成功')
|
||||
if (currentConversationId.value === id) {
|
||||
currentConversationId.value = null
|
||||
messages.value = []
|
||||
currentConversationTitle.value = ''
|
||||
}
|
||||
loadConversations()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.value) {
|
||||
loadConversations()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await chatApi.searchConversations(searchQuery.value)
|
||||
conversations.value = res || []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (conv) => {
|
||||
editingConversationId.value = conv.id
|
||||
editingTitle.value = conv.title
|
||||
nextTick(() => {
|
||||
// Try to focus the input
|
||||
const input = document.querySelector('.conversation-item.active-edit input')
|
||||
if (input) input.focus()
|
||||
})
|
||||
}
|
||||
|
||||
const saveEdit = async (conv) => {
|
||||
if (!editingConversationId.value) return
|
||||
|
||||
const newTitle = editingTitle.value
|
||||
|
||||
// If title is empty or unchanged, just cancel edit
|
||||
if (!newTitle.trim() || newTitle === conv.title) {
|
||||
if (editingConversationId.value === conv.id) {
|
||||
editingConversationId.value = null
|
||||
editingTitle.value = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await chatApi.updateConversation(conv.id, newTitle)
|
||||
conv.title = newTitle
|
||||
if (currentConversationId.value === conv.id) {
|
||||
currentConversationTitle.value = newTitle
|
||||
}
|
||||
ElMessage.success('标题已更新')
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ElMessage.error('更新失败')
|
||||
} finally {
|
||||
if (editingConversationId.value === conv.id) {
|
||||
editingConversationId.value = null
|
||||
editingTitle.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!inputMessage.value.trim() || isStreaming.value) return
|
||||
|
||||
const content = inputMessage.value
|
||||
inputMessage.value = ''
|
||||
|
||||
if (!currentConversationId.value) {
|
||||
try {
|
||||
const res = await chatApi.createConversation(content.substring(0, 20))
|
||||
await loadConversations()
|
||||
currentConversationId.value = res.id
|
||||
currentConversationTitle.value = res.title
|
||||
} catch(e) {
|
||||
ElMessage.error('创建对话失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
messages.value.push({ role: 'user', content })
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
await chatApi.saveMessage(currentConversationId.value, 'user', content)
|
||||
} catch (e) {
|
||||
console.error('Failed to save user message')
|
||||
}
|
||||
|
||||
isStreaming.value = true
|
||||
const assistantMsg = reactive({ role: 'assistant', content: '' })
|
||||
messages.value.push(assistantMsg)
|
||||
|
||||
try {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8080'
|
||||
const response = await fetch(`${baseUrl}/api/ai/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${authStore.token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: content,
|
||||
temperature: 0.7,
|
||||
conversationId: currentConversationId.value
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error(response.statusText)
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
buffer += chunk
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop()
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
const data = line.slice(5)
|
||||
assistantMsg.content += data
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await chatApi.saveMessage(currentConversationId.value, 'assistant', assistantMsg.content)
|
||||
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
assistantMsg.content += " [已停止]"
|
||||
} else {
|
||||
console.error(e)
|
||||
// Only show error in chat bubble if we haven't received any content yet
|
||||
if (!assistantMsg.content) {
|
||||
ElMessage.error('AI 响应失败: ' + e.message)
|
||||
assistantMsg.content += " [连接失败]"
|
||||
} else {
|
||||
// If we have content, it means stream worked but maybe save failed
|
||||
ElMessage.warning('消息已接收,但保存失败: ' + e.message)
|
||||
console.error('Failed to save assistant message or stream error', e)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isStreaming.value = false
|
||||
nextTick(() => {
|
||||
chatInputRef.value?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (messageScroll.value) {
|
||||
const wrap = messageScroll.value.wrapRef
|
||||
wrap.scrollTop = wrap.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const formatTime = (timeStr) => {
|
||||
if (!timeStr) return ''
|
||||
return new Date(timeStr).toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConversations()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-container {
|
||||
height: calc(85vh - 60px);
|
||||
background-color: #fff;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.chat-sidebar {
|
||||
background-color: #f9fafc;
|
||||
border-right: 1px solid #eaeaea;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
height: auto;
|
||||
padding: 20px 15px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background-color: #f9fafc;
|
||||
}
|
||||
|
||||
.new-chat-btn {
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 6px rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.search-input :deep(.el-input__wrapper) {
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 0 0 1px #dcdfe6 inset;
|
||||
}
|
||||
|
||||
.conversation-list {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
padding: 12px 15px;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: all 0.25s ease;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background-color: #d6d2d296;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background-color: #fff;
|
||||
border-color: var(--ios-primary);
|
||||
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
|
||||
.conversation-item:hover .conv-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.conv-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
max-width: 190px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.conv-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.conv-actions {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
font-size: 16px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: var(--ios-primary);
|
||||
}
|
||||
|
||||
.edit-input {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.conv-time {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
height: 60px;
|
||||
/* border-bottom: 1px solid #eaeaea; */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
background: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.message-area {
|
||||
flex: 1;
|
||||
padding: 0px 30px;
|
||||
/* margin-top: -20px; */
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
display: flex;
|
||||
margin-bottom: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-wrapper.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
margin: 0 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
max-width: 75%;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 12px 18px;
|
||||
border-radius: 12px;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Markdown Styles */
|
||||
.bubble :deep(p) {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.bubble :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.bubble :deep(h1), .bubble :deep(h2), .bubble :deep(h3), .bubble :deep(h4), .bubble :deep(h5), .bubble :deep(h6) {
|
||||
margin: 10px 0 8px 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.bubble :deep(h1) { font-size: 1.5em; border-bottom: 1px solid #eaeaea; padding-bottom: 5px; }
|
||||
.bubble :deep(h2) { font-size: 1.4em; border-bottom: 1px solid #eaeaea; padding-bottom: 4px; }
|
||||
.bubble :deep(h3) { font-size: 1.3em; }
|
||||
.bubble :deep(h4) { font-size: 1.2em; }
|
||||
.bubble :deep(h5) { font-size: 1.1em; }
|
||||
.bubble :deep(h6) { font-size: 1.0em; color: #606266; }
|
||||
|
||||
.bubble :deep(ul) {
|
||||
margin: 5px 0 10px 20px;
|
||||
padding-left: 20px;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.bubble :deep(ol) {
|
||||
margin: 5px 0 10px 20px;
|
||||
padding-left: 20px;
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.bubble :deep(li) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.bubble :deep(pre) {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-family: monospace;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.bubble :deep(code) {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.bubble :deep(pre code) {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bubble :deep(blockquote) {
|
||||
border-left: 3px solid rgba(0, 0, 0, 0.2);
|
||||
margin: 10px 0;
|
||||
padding-left: 10px;
|
||||
color: inherit;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.bubble :deep(hr) {
|
||||
height: 1px;
|
||||
background-color: #e0e0e0;
|
||||
border: none;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.bubble :deep(table) {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.bubble :deep(th), .bubble :deep(td) {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bubble :deep(th) {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Adjust colors for user bubble (dark background) */
|
||||
.message-wrapper.user .bubble :deep(pre),
|
||||
.message-wrapper.user .bubble :deep(code) {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.message-wrapper.user .bubble :deep(blockquote) {
|
||||
border-left-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.message-wrapper.user .bubble :deep(a) {
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Adjust colors for assistant bubble (light background) */
|
||||
.message-wrapper.assistant .bubble :deep(a) {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.message-wrapper.user .bubble {
|
||||
background: #EBF5FF;
|
||||
color: #000;
|
||||
border-radius: 12px 12px 0 12px;
|
||||
}
|
||||
|
||||
.message-wrapper.assistant .bubble {
|
||||
background-color: #f4f6f8;
|
||||
color: #2c3e50;
|
||||
border-radius: 12px 12px 12px 0;
|
||||
border: 1px solid #eaecf0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
padding: 20px 30px;
|
||||
background: #fff;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-chat-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: 100px; /* Offset a bit */
|
||||
}
|
||||
|
||||
.welcome-text {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.chat-input-wrapper {
|
||||
background-color: #f4f4f4;
|
||||
border-radius: 28px;
|
||||
padding: 8px 16px;
|
||||
display: flex;
|
||||
align-items: flex-end; /* Align bottom for textarea growth */
|
||||
gap: 10px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.chat-input-wrapper:focus-within {
|
||||
background-color: #fff;
|
||||
border-color: #dcdfe6;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.center-input {
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
max-width: 800px; /* limit width on wide screens */
|
||||
}
|
||||
|
||||
.input-prefix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 40px; /* Match button height */
|
||||
color: #909399;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.custom-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.custom-input :deep(.el-textarea__inner) {
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
padding: 10px 0;
|
||||
resize: none;
|
||||
min-height: 24px;
|
||||
line-height: 1.5;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 4px; /* Align with text */
|
||||
}
|
||||
|
||||
.send-btn-inner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
background-color: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary);
|
||||
color: #fff;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.send-btn-inner:disabled {
|
||||
background-color: #e4e7ed;
|
||||
border-color: #e4e7ed;
|
||||
color: #a8abb2;
|
||||
}
|
||||
|
||||
/* Remove old styles */
|
||||
/* .input-area :deep(.el-textarea__inner) ... */
|
||||
/* .send-btn ... */
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.chat-sidebar {
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
height: 100%;
|
||||
width: 80%;
|
||||
box-shadow: 2px 0 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
88
ui/src/views/portal/Home.vue
Normal file
88
ui/src/views/portal/Home.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="home-container">
|
||||
<div class="hero-section glass">
|
||||
<h1 class="hero-title">Hertz Admin</h1>
|
||||
<p class="hero-subtitle">基于 Spring Boot 3 与 Vue 3 的现代化权限管理系统</p>
|
||||
<div class="hero-actions">
|
||||
<el-button type="primary" size="large" @click="router.push('/login')">立即开始</el-button>
|
||||
<el-button size="large" @click="router.push('/portal/about')">了解更多</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="features-grid">
|
||||
<el-card class="feature-card glass">
|
||||
<h3>RBAC 权限</h3>
|
||||
<p>基于角色的访问控制,细粒度的权限管理。</p>
|
||||
</el-card>
|
||||
<el-card class="feature-card glass">
|
||||
<h3>动态菜单</h3>
|
||||
<p>根据用户角色动态生成侧边栏菜单。</p>
|
||||
</el-card>
|
||||
<el-card class="feature-card glass">
|
||||
<h3>现代化 UI</h3>
|
||||
<p>采用 Element Plus 与 iOS 风格设计。</p>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
text-align: center;
|
||||
padding: 80px 40px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.8) 0%, rgba(240,249,255,0.8) 100%);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 16px;
|
||||
background: linear-gradient(135deg, var(--ios-primary) 0%, #00C6FB 100%);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 20px;
|
||||
color: #666;
|
||||
margin: 0 0 32px;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--ios-primary);
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
287
ui/src/views/portal/Monitor.vue
Normal file
287
ui/src/views/portal/Monitor.vue
Normal file
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<div class="monitor-container">
|
||||
<div class="monitor-header">
|
||||
<h2>系统监控</h2>
|
||||
<el-button type="primary" :icon="Icons.Refresh" @click="fetchData" :loading="loading">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div class="monitor-content">
|
||||
<!-- CPU & Memory Row -->
|
||||
<div class="monitor-row">
|
||||
<el-card class="monitor-card glass">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><component :is="Icons.Cpu" /></el-icon>
|
||||
<span>CPU</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="metric-content" v-if="serverInfo">
|
||||
<el-progress type="dashboard" :percentage="Number((serverInfo.cpu.used).toFixed(2))" :color="getColors" />
|
||||
<div class="metric-details">
|
||||
<div class="detail-item">
|
||||
<span>核心数:</span>
|
||||
<span>{{ serverInfo.cpu.cpuNum }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span>系统使用:</span>
|
||||
<span>{{ serverInfo.cpu.sys }}%</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span>用户使用:</span>
|
||||
<span>{{ serverInfo.cpu.used }}%</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span>空闲:</span>
|
||||
<span>{{ serverInfo.cpu.free }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-skeleton :rows="5" animated v-else />
|
||||
</el-card>
|
||||
|
||||
<el-card class="monitor-card glass">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><component :is="Icons.Memo" /></el-icon>
|
||||
<span>内存</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="metric-content" v-if="serverInfo">
|
||||
<el-progress type="dashboard" :percentage="Number((serverInfo.mem.usage).toFixed(2))" :color="getColors" />
|
||||
<div class="metric-details">
|
||||
<div class="detail-item">
|
||||
<span>总内存:</span>
|
||||
<span>{{ serverInfo.mem.total }}GB</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span>已用:</span>
|
||||
<span>{{ serverInfo.mem.used }}GB</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span>剩余:</span>
|
||||
<span>{{ serverInfo.mem.free }}GB</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span>使用率:</span>
|
||||
<span>{{ serverInfo.mem.usage }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-skeleton :rows="5" animated v-else />
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- JVM Row -->
|
||||
<!-- <el-card class="monitor-card glass full-width">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><component :is="Icons.Platform" /></el-icon>
|
||||
<span>JVM 信息</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions border :column="4">
|
||||
<el-descriptions-item label="启动时间">{{ serverInfo.jvm.startTime }}</el-descriptions-item>
|
||||
<el-descriptions-item label="安装路径">{{ serverInfo.jvm.home }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目路径">{{ serverInfo.sys.userDir }}</el-descriptions-item>
|
||||
<el-descriptions-item label="GC次数">{{ serverInfo.jvm.gcCount }} 次</el-descriptions-item>
|
||||
<el-descriptions-item label="GC耗时">{{ serverInfo.jvm.gcTime }} ms</el-descriptions-item>
|
||||
<el-descriptions-item label="运行参数" :span="4">{{ serverInfo.jvm.inputArgs }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="jvm-metrics mt-4">
|
||||
<div class="metric-bar">
|
||||
<span>JVM内存使用 ({{ serverInfo.jvm.usage }}%)</span>
|
||||
<el-progress :text-inside="true" :stroke-width="20" :percentage="Number((serverInfo.jvm.usage))" :color="getColors" />
|
||||
<div class="metric-sub-text">
|
||||
Total: {{ serverInfo.jvm.total }}MB | Used: {{ serverInfo.jvm.used }}MB | Free: {{ serverInfo.jvm.free }}MB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card> -->
|
||||
|
||||
<!-- Server Info Row -->
|
||||
<el-card class="monitor-card glass full-width">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><component :is="Icons.Monitor" /></el-icon>
|
||||
<span>服务器信息</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions border :column="2" v-if="serverInfo">
|
||||
<el-descriptions-item label="服务器名称">{{ serverInfo.sys.computerName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作系统">{{ serverInfo.sys.osName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务器IP">{{ serverInfo.sys.computerIp }}</el-descriptions-item>
|
||||
<el-descriptions-item label="系统架构">{{ serverInfo.sys.osArch }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-skeleton :rows="3" animated v-else />
|
||||
</el-card>
|
||||
|
||||
<!-- Disk Info Row -->
|
||||
<el-card class="monitor-card glass full-width">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><component :is="Icons.Coin" /></el-icon>
|
||||
<span>磁盘状态</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-table :data="serverInfo ? serverInfo.disks : []" style="width: 100%" v-if="serverInfo">
|
||||
<el-table-column prop="dirName" label="盘符路径" width="180" />
|
||||
<el-table-column prop="sysTypeName" label="文件系统" width="120" />
|
||||
<el-table-column prop="typeName" label="盘符类型" width="120" />
|
||||
<el-table-column prop="total" label="总大小" width="120" />
|
||||
<el-table-column prop="free" label="可用大小" width="120" />
|
||||
<el-table-column prop="used" label="已用大小" width="120" />
|
||||
<el-table-column label="使用率" width="180">
|
||||
<template #default="scope">
|
||||
<el-progress :percentage="Number(scope.row.usage)" :color="getColors" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-skeleton :rows="5" animated v-else />
|
||||
</el-card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getServerInfo } from '../../api/monitor'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as Icons from '@element-plus/icons-vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const serverInfo = ref(null)
|
||||
|
||||
const getColors = [
|
||||
{ color: '#5cb87a', percentage: 60 },
|
||||
{ color: '#e6a23c', percentage: 80 },
|
||||
{ color: '#f56c6c', percentage: 100 },
|
||||
]
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getServerInfo()
|
||||
if (res.code === 0) {
|
||||
serverInfo.value = res.data
|
||||
} else {
|
||||
ElMessage.error(res.message || '获取系统信息失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error)
|
||||
if (error.response) {
|
||||
const { status } = error.response
|
||||
const statusMap = {
|
||||
400: '请求参数错误',
|
||||
401: '登录状态已失效,请重新登录',
|
||||
403: '您没有权限访问该资源',
|
||||
404: '请求的资源不存在',
|
||||
408: '请求超时,请稍后重试',
|
||||
500: '服务器内部错误,请联系管理员',
|
||||
502: '网关错误',
|
||||
503: '服务不可用',
|
||||
504: '网关超时'
|
||||
}
|
||||
|
||||
} else {
|
||||
ElMessage.error('网络连接失败,请检查您的网络设置')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monitor-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.monitor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.monitor-header h2 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.monitor-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.monitor-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.monitor-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metric-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.metric-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.detail-item span:last-child {
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.metric-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metric-sub-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user