ACS
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<BaseEChart :options="chartOptions" v-bind="$attrs" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import BaseEChart from './BaseEChart.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'BarChart'
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
// 数据项
|
||||
data: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
// x轴数据
|
||||
xAxis: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
// 是否显示背景
|
||||
showBackground: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 系列配置
|
||||
series: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 图表标题
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 柱子宽度
|
||||
barWidth: {
|
||||
type: Number,
|
||||
default: 30
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = computed(() => {
|
||||
const series = props.series.length > 0 ? props.series : [{
|
||||
name: '数值',
|
||||
data: props.data,
|
||||
type: 'bar',
|
||||
barWidth: props.barWidth,
|
||||
showBackground: props.showBackground,
|
||||
backgroundStyle: {
|
||||
color: 'rgba(180, 180, 180, 0.1)'
|
||||
},
|
||||
itemStyle: {
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
}
|
||||
}]
|
||||
|
||||
return {
|
||||
title: props.title ? {
|
||||
text: props.title,
|
||||
left: 'center',
|
||||
top: 10
|
||||
} : null,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
top: props.title ? '60px' : '40px',
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.xAxis,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#E5E7EB'
|
||||
}
|
||||
},
|
||||
axisTick: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#E5E7EB'
|
||||
}
|
||||
},
|
||||
axisLine: {
|
||||
show: false
|
||||
},
|
||||
axisTick: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
series
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div ref="chartRef" :style="{ width: width, height: height }" class="chart-container"></div>
|
||||
</template>
|
||||
|
||||
<script setup name="BaseEChart">
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
import { useElementSize, useResizeObserver } from '@vueuse/core'
|
||||
|
||||
defineOptions({
|
||||
name: 'BaseEChart'
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
options: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
width: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '350px'
|
||||
},
|
||||
theme: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const chartRef = ref(null)
|
||||
let chartInstance = null
|
||||
|
||||
// 初始化图表
|
||||
const initChart = () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
}
|
||||
|
||||
if (!chartRef.value) return
|
||||
|
||||
chartInstance = echarts.init(chartRef.value, props.theme)
|
||||
chartInstance.setOption(props.options)
|
||||
}
|
||||
|
||||
// 监听容器大小变化
|
||||
useResizeObserver(chartRef, () => {
|
||||
if (chartInstance) {
|
||||
chartInstance.resize()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听配置变化
|
||||
watch(
|
||||
() => props.options,
|
||||
(newOptions) => {
|
||||
if (chartInstance) {
|
||||
chartInstance.setOption(newOptions)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
initChart()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露更新方法
|
||||
defineExpose({
|
||||
getChart: () => chartInstance,
|
||||
resize: () => chartInstance?.resize()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-container {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<BaseEChart :options="chartOptions" v-bind="$attrs" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import BaseEChart from './BaseEChart.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'LineChart'
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
// 数据项
|
||||
data: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
// x轴数据
|
||||
xAxis: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
// 是否平滑曲线
|
||||
smooth: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 是否显示面积
|
||||
area: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 系列配置
|
||||
series: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 图表标题
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = computed(() => {
|
||||
const series = props.series.length > 0 ? props.series : [{
|
||||
name: '数值',
|
||||
data: props.data,
|
||||
type: 'line',
|
||||
smooth: props.smooth,
|
||||
areaStyle: props.area ? {} : null,
|
||||
showSymbol: true,
|
||||
symbolSize: 6,
|
||||
lineStyle: {
|
||||
width: 2
|
||||
}
|
||||
}]
|
||||
|
||||
return {
|
||||
title: props.title ? {
|
||||
text: props.title,
|
||||
left: 'center',
|
||||
top: 10
|
||||
} : null,
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross'
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
top: props.title ? '60px' : '40px',
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: props.xAxis,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#E5E7EB'
|
||||
}
|
||||
},
|
||||
axisTick: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: '#E5E7EB'
|
||||
}
|
||||
},
|
||||
axisLine: {
|
||||
show: false
|
||||
},
|
||||
axisTick: {
|
||||
show: false
|
||||
}
|
||||
},
|
||||
series
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<BaseEChart :options="chartOptions" v-bind="$attrs" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import BaseEChart from './BaseEChart.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'PieChart'
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
// 数据项 [{name: '名称', value: 100}]
|
||||
data: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
// 是否显示为环形图
|
||||
ring: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 图表标题
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 半径设置
|
||||
radius: {
|
||||
type: [String, Array],
|
||||
default: '70%'
|
||||
},
|
||||
// 是否显示图例
|
||||
showLegend: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 图例位置
|
||||
legendPosition: {
|
||||
type: String,
|
||||
default: 'right' // 'right', 'bottom'
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = computed(() => {
|
||||
return {
|
||||
title: props.title ? {
|
||||
text: props.title,
|
||||
left: 'center',
|
||||
top: 10
|
||||
} : null,
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: props.showLegend ? {
|
||||
orient: props.legendPosition === 'right' ? 'vertical' : 'horizontal',
|
||||
[props.legendPosition]: '5%',
|
||||
top: props.legendPosition === 'right' ? 'middle' : 'bottom',
|
||||
itemWidth: 10,
|
||||
itemHeight: 10,
|
||||
icon: 'circle'
|
||||
} : undefined,
|
||||
series: [
|
||||
{
|
||||
name: props.title || '数据占比',
|
||||
type: 'pie',
|
||||
radius: props.ring ? ['50%', '70%'] : props.radius,
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: {
|
||||
borderRadius: 4,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '{b}: {d}%'
|
||||
},
|
||||
labelLine: {
|
||||
show: true
|
||||
},
|
||||
data: props.data
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps({
|
||||
msg: String,
|
||||
})
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<div class="qrcode-container">
|
||||
<canvas ref="qrcodeCanvas"></canvas>
|
||||
<div v-if="!text" class="qrcode-placeholder">请提供文本以生成二维码</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
const props = defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: ''
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 128
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const qrcodeCanvas = ref(null)
|
||||
|
||||
const generateQRCode = async () => {
|
||||
if (!props.text || !qrcodeCanvas.value) {
|
||||
// 如果没有文本或者canvas未准备好,则清空canvas
|
||||
const ctx = qrcodeCanvas.value?.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, qrcodeCanvas.value.width, qrcodeCanvas.value.height);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const defaultOptions = {
|
||||
width: props.size,
|
||||
height: props.size,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff'
|
||||
},
|
||||
errorCorrectionLevel: 'M' // 容错级别 L M Q H
|
||||
};
|
||||
|
||||
const finalOptions = { ...defaultOptions, ...props.options };
|
||||
|
||||
await QRCode.toCanvas(qrcodeCanvas.value, props.text, finalOptions);
|
||||
} catch (err) {
|
||||
console.error('生成二维码失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
generateQRCode()
|
||||
})
|
||||
|
||||
watch(() => [props.text, props.size, props.options], () => {
|
||||
generateQRCode()
|
||||
}, { deep: true })
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qrcode-container {
|
||||
display: inline-flex; /* 使容器适应内容大小 */
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
padding: 8px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block; /* 移除canvas默认的底部空间 */
|
||||
}
|
||||
|
||||
.qrcode-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="text-truncate" :title="showTooltip ? text : undefined">
|
||||
{{ truncatedText }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TextTruncate',
|
||||
props: {
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: ''
|
||||
},
|
||||
maxLength: {
|
||||
type: Number,
|
||||
default: 20
|
||||
},
|
||||
ellipsis: {
|
||||
type: String,
|
||||
default: '...'
|
||||
},
|
||||
showTooltip: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
truncatedText() {
|
||||
if (!this.text || this.text.length <= this.maxLength) {
|
||||
return this.text;
|
||||
}
|
||||
return this.text.slice(0, this.maxLength) + this.ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<!-- 侧边栏 -->
|
||||
<div class="sidebar">
|
||||
<div class="logo-container">
|
||||
<h1 class="title">零零七云计算后台控制面板</h1>
|
||||
</div>
|
||||
<el-scrollbar>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
class="sidebar-menu"
|
||||
background-color="#ffffff"
|
||||
text-color="#333333"
|
||||
active-text-color="#1890ff"
|
||||
:unique-opened="true"
|
||||
router
|
||||
>
|
||||
<sidebar-menu-item v-for="menu in menus" :key="menu.path" :menu="menu" />
|
||||
</el-menu>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<!-- 主区域 -->
|
||||
<div class="main-container">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="navbar">
|
||||
<div class="navbar-left">
|
||||
<breadcrumb />
|
||||
</div>
|
||||
<div class="navbar-right">
|
||||
<div class="navbar-item">
|
||||
<el-tooltip content="全屏" placement="bottom">
|
||||
<el-button type="text" class="header-btn" @click="toggleFullScreen">
|
||||
<el-icon :size="18"><full-screen /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item">
|
||||
<el-dropdown trigger="click">
|
||||
<div class="avatar-container">
|
||||
<el-avatar :size="32" src="https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png" />
|
||||
<span class="username">{{ userStore.userInfo.user_name }}</span>
|
||||
<el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="router.push('/profile')">
|
||||
<el-icon><user /></el-icon>个人信息
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item @click="router.push('/change-password')">
|
||||
<el-icon><key /></el-icon>修改密码
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="handleLogout">
|
||||
<el-icon><switch-button /></el-icon>退出登录
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签页 -->
|
||||
<tags-view />
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="content-container">
|
||||
<el-config-provider :locale="zhCn">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<keep-alive>
|
||||
<component :is="Component" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-config-provider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import SidebarMenuItem from './SidebarMenuItem.vue'
|
||||
import Breadcrumb from './Breadcrumb.vue'
|
||||
import TagsView from './TagsView.vue'
|
||||
import { menus as menuConfig } from '@/config/menus'
|
||||
import {
|
||||
FullScreen,
|
||||
ArrowDown,
|
||||
User,
|
||||
Key,
|
||||
SwitchButton
|
||||
} from '@element-plus/icons-vue'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import {useUserStore} from "@/store/userStore.js";
|
||||
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 侧边栏菜单数据
|
||||
const menus = ref(menuConfig)
|
||||
|
||||
// 获取当前激活的菜单项
|
||||
const activeMenu = computed(() => {
|
||||
return route.path
|
||||
})
|
||||
|
||||
// 切换全屏
|
||||
const toggleFullScreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen()
|
||||
} else {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
const handleLogout = () => {
|
||||
ElMessageBox.confirm('确定要退出登录吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(() => {
|
||||
localStorage.removeItem('token')
|
||||
router.push('/login')
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 侧边栏样式 */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
height: 100%;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
color: #333;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border-right: none;
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
/* 主容器样式 */
|
||||
.main-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #f0f2f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 顶部导航栏样式 */
|
||||
.navbar {
|
||||
height: 60px;
|
||||
padding: 0 15px;
|
||||
background-color: #fff;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.navbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.navbar-item {
|
||||
padding: 0 10px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.header-btn:hover {
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0 12px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.avatar-container:hover {
|
||||
background-color: rgba(0, 0, 0, 0.025);
|
||||
}
|
||||
|
||||
.username {
|
||||
margin: 0 8px;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 内容区域样式 */
|
||||
.content-container {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
background-color: #f0f2f5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
:deep(.el-dropdown-menu__item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.el-dropdown-menu__item i) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="index" :to="item.path">
|
||||
<el-icon v-if="item.icon" class="breadcrumb-icon">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
{{ item.title }}
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
HomeFilled,
|
||||
Setting,
|
||||
User,
|
||||
Document,
|
||||
List,
|
||||
PieChart,
|
||||
DataAnalysis,
|
||||
DataBoard
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 图标映射
|
||||
const iconMap = {
|
||||
'dashboard': 'DataBoard',
|
||||
'system': 'Setting',
|
||||
'users': 'User',
|
||||
'content': 'Document',
|
||||
'articles': 'Document',
|
||||
'categories': 'List',
|
||||
'tags': 'List',
|
||||
'statistics': 'PieChart',
|
||||
'visits': 'DataAnalysis',
|
||||
'performance': 'DataAnalysis'
|
||||
}
|
||||
|
||||
// 生成面包屑数据
|
||||
const breadcrumbs = computed(() => {
|
||||
// 当前路由的完整路径
|
||||
const currentPath = route.path
|
||||
|
||||
// 按照路径层级生成面包屑
|
||||
const pathSegments = currentPath.split('/').filter(segment => segment !== '')
|
||||
const result = []
|
||||
|
||||
// 添加首页
|
||||
result.push({
|
||||
path: '/dashboard',
|
||||
title: '首页',
|
||||
icon: 'HomeFilled'
|
||||
})
|
||||
|
||||
// 构建剩余的面包屑
|
||||
let currentPathBuilder = ''
|
||||
|
||||
for (let i = 0; i < pathSegments.length; i++) {
|
||||
const segment = pathSegments[i]
|
||||
currentPathBuilder += '/' + segment
|
||||
|
||||
// 查找匹配的路由
|
||||
const matchedRoute = router.getRoutes().find(route => route.path === currentPathBuilder)
|
||||
const segmentIcon = iconMap[segment]
|
||||
|
||||
if (matchedRoute && matchedRoute.meta.title) {
|
||||
result.push({
|
||||
path: currentPathBuilder,
|
||||
title: matchedRoute.meta.title,
|
||||
icon: segmentIcon
|
||||
})
|
||||
} else if (segment) {
|
||||
// 如果没有匹配的路由,但有路径段,也添加到面包屑
|
||||
result.push({
|
||||
path: currentPathBuilder,
|
||||
title: segment.charAt(0).toUpperCase() + segment.slice(1),
|
||||
icon: segmentIcon
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.breadcrumb-icon {
|
||||
margin-right: 6px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:deep(.el-breadcrumb__item) {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.el-breadcrumb__inner) {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.el-breadcrumb__inner a) {
|
||||
color: #606266;
|
||||
font-weight: normal;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
:deep(.el-breadcrumb__inner a:hover) {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
:deep(.el-breadcrumb__separator) {
|
||||
margin: 0 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<el-sub-menu v-if="hasChildren" :index="menu.path">
|
||||
<template #title>
|
||||
<el-icon v-if="menu.icon || menu.meta?.icon">
|
||||
<component :is="menu.icon || menu.meta?.icon" />
|
||||
</el-icon>
|
||||
<span>{{ menu.title || menu.meta?.title }}</span>
|
||||
</template>
|
||||
<sidebar-menu-item
|
||||
v-for="child in menu.children"
|
||||
:key="child.path"
|
||||
:menu="child"
|
||||
/>
|
||||
</el-sub-menu>
|
||||
<el-menu-item v-else :index="menu.path">
|
||||
<el-icon v-if="menu.icon || menu.meta?.icon">
|
||||
<component :is="menu.icon || menu.meta?.icon" />
|
||||
</el-icon>
|
||||
<template #title>{{ menu.title || menu.meta?.title }}</template>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import * as ElementPlusIcons from '@element-plus/icons-vue'
|
||||
|
||||
// 接收菜单项属性
|
||||
const props = defineProps({
|
||||
menu: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// 判断是否有子菜单
|
||||
const hasChildren = computed(() => {
|
||||
return props.menu.children && props.menu.children.length > 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.el-menu-item, :deep(.el-sub-menu__title) {
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
:deep(.el-sub-menu .el-menu-item) {
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
padding-left: 55px !important;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
margin-right: 10px;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
/* 激活菜单项特效 */
|
||||
.el-menu-item.is-active {
|
||||
position: relative;
|
||||
background-color: #e6f7ff !important;
|
||||
color: #1890ff !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-menu-item.is-active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
background-color: #1890ff;
|
||||
}
|
||||
|
||||
:deep(.el-sub-menu.is-active > .el-sub-menu__title) {
|
||||
color: #1890ff !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-menu-item:hover, :deep(.el-sub-menu__title:hover) {
|
||||
background-color: #f5f7fa !important;
|
||||
}
|
||||
|
||||
/* 修复图标颜色 */
|
||||
.el-menu-item.is-active .el-icon, :deep(.el-sub-menu.is-active > .el-sub-menu__title .el-icon) {
|
||||
color: #1890ff !important;
|
||||
}
|
||||
|
||||
/* 修复箭头颜色 */
|
||||
:deep(.el-sub-menu.is-active > .el-sub-menu__title .el-sub-menu__icon-arrow) {
|
||||
color: #1890ff !important;
|
||||
}
|
||||
|
||||
/* 子菜单样式 */
|
||||
:deep(.el-menu--inline) {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<div class="tags-view-container">
|
||||
<div class="tags-view-wrapper">
|
||||
<div class="tags-view-scroll">
|
||||
<router-link
|
||||
v-for="tag in visitedViews"
|
||||
:key="tag.path"
|
||||
:class="isActive(tag) ? 'active-tag' : 'tag'"
|
||||
:to="{ path: tag.path, query: tag.query }"
|
||||
@contextmenu.prevent="openMenu($event, tag)"
|
||||
>
|
||||
<el-icon v-if="tag.meta.icon" class="tag-icon">
|
||||
<component :is="tag.meta.icon" />
|
||||
</el-icon>
|
||||
<span class="tag-title">{{ tag.meta.title }}</span>
|
||||
<el-icon
|
||||
class="tag-close"
|
||||
@click.prevent.stop="closeSelectedTag(tag)"
|
||||
v-if="!isAffix(tag)"
|
||||
>
|
||||
<close />
|
||||
</el-icon>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右键菜单 -->
|
||||
<ul v-show="visible" :style="{left: left+'px', top: top+'px'}" class="contextmenu">
|
||||
<li @click="refreshSelectedTag(selectedTag)">
|
||||
<el-icon><refresh /></el-icon>
|
||||
<span>刷新页面</span>
|
||||
</li>
|
||||
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
|
||||
<el-icon><close /></el-icon>
|
||||
<span>关闭当前</span>
|
||||
</li>
|
||||
<li @click="closeOthersTags">
|
||||
<el-icon><circle-close /></el-icon>
|
||||
<span>关闭其他</span>
|
||||
</li>
|
||||
<li @click="closeLeftTags">
|
||||
<el-icon><back /></el-icon>
|
||||
<span>关闭左侧</span>
|
||||
</li>
|
||||
<li @click="closeRightTags">
|
||||
<el-icon><right /></el-icon>
|
||||
<span>关闭右侧</span>
|
||||
</li>
|
||||
<li @click="closeAllTags">
|
||||
<el-icon><remove /></el-icon>
|
||||
<span>关闭所有</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Close, Refresh, CircleClose, Back, Right, Remove } from '@element-plus/icons-vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// 固定标签
|
||||
const affixTags = ref([])
|
||||
// 访问过的标签
|
||||
const visitedViews = ref([])
|
||||
// 右键菜单
|
||||
const visible = ref(false)
|
||||
const top = ref(0)
|
||||
const left = ref(0)
|
||||
const selectedTag = ref({})
|
||||
|
||||
// 初始化标签
|
||||
const initTags = () => {
|
||||
// 如果当前路由不在访问过的标签中,添加它
|
||||
if (route.name) {
|
||||
addVisitedView(route)
|
||||
}
|
||||
// 添加固定标签(仪表盘)
|
||||
const dashboardRoute = router.getRoutes().find(r => r.name === 'Dashboard')
|
||||
if (dashboardRoute) {
|
||||
affixTags.value.push(dashboardRoute)
|
||||
addVisitedView(dashboardRoute)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加访问过的标签
|
||||
const addVisitedView = (view) => {
|
||||
if (visitedViews.value.some(v => v.path === view.path)) return
|
||||
|
||||
// 过滤404和登录页
|
||||
if (view.name === 'NotFound' || view.name === 'Login') return
|
||||
|
||||
visitedViews.value.push(
|
||||
Object.assign({}, view, {
|
||||
title: view.meta.title || 'unknown'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// 刷新选中的标签
|
||||
const refreshSelectedTag = (view) => {
|
||||
// 路由刷新的原理是先获取当前路由的全部信息,然后将路由重定向到一个空白页,
|
||||
// 然后立即再将路由重定向回原路由,实现刷新效果
|
||||
const { fullPath } = view
|
||||
router.replace('/redirect' + fullPath)
|
||||
}
|
||||
|
||||
// 关闭选中的标签
|
||||
const closeSelectedTag = (view) => {
|
||||
// 从访问过的标签中移除
|
||||
const index = visitedViews.value.findIndex(v => v.path === view.path)
|
||||
if (index > -1) {
|
||||
visitedViews.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 如果关闭的是当前标签,则跳转到下一个标签
|
||||
if (isActive(view)) {
|
||||
toLastView(visitedViews.value, view)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭其他标签
|
||||
const closeOthersTags = () => {
|
||||
// 保留固定标签和当前选中的标签
|
||||
visitedViews.value = visitedViews.value.filter(v => {
|
||||
return isAffix(v) || v.path === selectedTag.value.path
|
||||
})
|
||||
|
||||
if (!isActive(selectedTag.value)) {
|
||||
router.push(selectedTag.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭左侧标签
|
||||
const closeLeftTags = () => {
|
||||
const selectedIndex = visitedViews.value.findIndex(v => v.path === selectedTag.value.path)
|
||||
if (selectedIndex === -1) return
|
||||
|
||||
// 保留固定标签和右侧标签
|
||||
visitedViews.value = visitedViews.value.filter((v, i) => {
|
||||
return isAffix(v) || i >= selectedIndex
|
||||
})
|
||||
|
||||
if (!isActive(selectedTag.value)) {
|
||||
router.push(selectedTag.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭右侧标签
|
||||
const closeRightTags = () => {
|
||||
const selectedIndex = visitedViews.value.findIndex(v => v.path === selectedTag.value.path)
|
||||
if (selectedIndex === -1) return
|
||||
|
||||
// 保留固定标签和左侧标签
|
||||
visitedViews.value = visitedViews.value.filter((v, i) => {
|
||||
return isAffix(v) || i <= selectedIndex
|
||||
})
|
||||
|
||||
if (!isActive(selectedTag.value)) {
|
||||
router.push(selectedTag.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭所有标签
|
||||
const closeAllTags = () => {
|
||||
// 仅保留固定标签
|
||||
visitedViews.value = visitedViews.value.filter(v => isAffix(v))
|
||||
|
||||
// 跳转到第一个标签或首页
|
||||
toLastView(visitedViews.value)
|
||||
}
|
||||
|
||||
// 跳转到最后一个标签或首页
|
||||
const toLastView = (visitedViews, view) => {
|
||||
const latestView = visitedViews.slice(-1)[0]
|
||||
if (latestView) {
|
||||
router.push(latestView)
|
||||
} else {
|
||||
// 如果没有标签,则跳转到首页
|
||||
if (view && view.name === 'Dashboard') {
|
||||
// 如果当前是首页,则刷新页面
|
||||
router.push('/redirect' + '/dashboard')
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否是当前激活的标签
|
||||
const isActive = (tag) => {
|
||||
return tag.path === route.path
|
||||
}
|
||||
|
||||
// 判断是否是固定标签
|
||||
const isAffix = (tag) => {
|
||||
return affixTags.value.some(t => t.path === tag.path)
|
||||
}
|
||||
|
||||
// 打开右键菜单
|
||||
const openMenu = (e, tag) => {
|
||||
const menuMinWidth = 125
|
||||
const offsetLeft = e.clientX
|
||||
const offsetWidth = document.body.clientWidth
|
||||
const maxLeft = offsetWidth - menuMinWidth
|
||||
|
||||
left.value = offsetLeft > maxLeft ? maxLeft : offsetLeft
|
||||
top.value = e.clientY
|
||||
|
||||
visible.value = true
|
||||
selectedTag.value = tag
|
||||
}
|
||||
|
||||
// 关闭右键菜单
|
||||
const closeMenu = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 监听路由变化,添加标签
|
||||
watch(route, (newRoute) => {
|
||||
if (newRoute.name) {
|
||||
addVisitedView(newRoute)
|
||||
}
|
||||
})
|
||||
|
||||
// 点击其他区域关闭右键菜单
|
||||
const handleClickOutside = () => {
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initTags()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tags-view-container {
|
||||
height: 40px;
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tags-view-wrapper {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tags-view-wrapper::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tags-view-scroll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.tag, .active-tag {
|
||||
height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
margin-right: 5px;
|
||||
border-radius: 2px;
|
||||
font-size: 12px;
|
||||
color: #333333;
|
||||
background-color: #f4f4f5;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
transition: all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
color: #1890ff;
|
||||
background-color: #e6f7ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.active-tag {
|
||||
color: #1890ff;
|
||||
background-color: #e6f7ff;
|
||||
border-color: #1890ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tag-icon {
|
||||
margin-right: 4px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.tag-title {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag-close {
|
||||
margin-left: 5px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tag:hover .tag-close {
|
||||
color: #666;
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.active-tag .tag-close {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.active-tag:hover .tag-close {
|
||||
background-color: rgba(24, 144, 255, 0.1);
|
||||
}
|
||||
|
||||
.contextmenu {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
background-color: #fff;
|
||||
list-style-type: none;
|
||||
padding: 6px 0;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
font-size: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.contextmenu li {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.contextmenu li:hover {
|
||||
background-color: #f5f7fa;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.contextmenu li .el-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user