feat: 初始化智能物联网设备管理系统前后端基础项目
完成项目整体结构搭建,包含: 1. 前端Vue3+TS+Vite+Antd项目基础配置与核心依赖 2. 后端MidwayJS+TypeORM+JWT权限认证框架 3. 完整的用户、角色、权限、日志数据库表结构 4. 核心业务模块API与基础服务实现 5. 全局样式、路由、状态管理配置
This commit is contained in:
commit
3d7eb12103
89 files changed
+16085
No files matched your search
@@ -0,0 +1,606 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<div class="dashboard-header">
|
||||
<h2 class="dashboard-title">系统运营概况</h2>
|
||||
<div class="header-actions">
|
||||
<span class="update-time">最后更新: {{ lastUpdateTime }}</span>
|
||||
<a-button type="primary" :loading="loading" @click="handleRefresh">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新数据
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading" tip="加载中...">
|
||||
<div class="dashboard-content">
|
||||
<a-row :gutter="[16, 16]" class="stat-cards">
|
||||
<a-col :xs="24" :sm="12" :md="6" v-for="card in statCards" :key="card.key">
|
||||
<a-card :hoverable="true" class="stat-card" :class="`stat-card-${card.key}`">
|
||||
<div class="stat-card-content">
|
||||
<div class="stat-icon-wrapper" :style="{ background: card.iconBg }">
|
||||
<component :is="card.icon" class="stat-icon" />
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-value" :style="{ color: card.color }">
|
||||
{{ stats?.[card.key] ?? '--' }}
|
||||
</div>
|
||||
<div class="stat-title">{{ card.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="[16, 16]" class="chart-section">
|
||||
<a-col :xs="24" :lg="14">
|
||||
<a-card title="设备趋势" class="chart-card">
|
||||
<div ref="lineChartRef" class="chart-container"></div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="10">
|
||||
<a-card title="设备类型分布" class="chart-card">
|
||||
<div ref="pieChartRef" class="chart-container"></div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="[16, 16]" class="list-section">
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card title="最近告警" class="list-card">
|
||||
<template #extra>
|
||||
<a-badge :count="stats?.alertToday || 0" :number-style="{ backgroundColor: '#ff4d4f' }" />
|
||||
</template>
|
||||
<div class="alert-list">
|
||||
<div
|
||||
v-for="alert in stats?.recentAlerts"
|
||||
:key="alert.id"
|
||||
class="alert-item"
|
||||
>
|
||||
<div class="alert-header">
|
||||
<a-tag :color="getAlertLevelColor(alert.level)">
|
||||
{{ getAlertLevelText(alert.level) }}
|
||||
</a-tag>
|
||||
<span class="alert-device">{{ alert.device }}</span>
|
||||
<a-tag :color="alert.status === 'pending' ? 'orange' : 'green'" class="alert-status">
|
||||
{{ alert.status === 'pending' ? '待处理' : '已解决' }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<div class="alert-body">
|
||||
<span class="alert-type">{{ alert.type }}</span>
|
||||
<span class="alert-time">{{ alert.time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-empty v-if="!stats?.recentAlerts?.length" description="暂无告警信息" />
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="12">
|
||||
<a-card title="最近活动" class="list-card">
|
||||
<a-timeline class="activity-timeline">
|
||||
<a-timeline-item
|
||||
v-for="activity in stats?.recentActivities"
|
||||
:key="activity.id"
|
||||
:color="getActivityColor(activity.module)"
|
||||
>
|
||||
<div class="activity-content">
|
||||
<div class="activity-header">
|
||||
<span class="activity-user">{{ activity.user }}</span>
|
||||
<a-tag color="blue" class="activity-module">{{ activity.module }}</a-tag>
|
||||
</div>
|
||||
<div class="activity-action">{{ activity.action }}</div>
|
||||
<div class="activity-meta">
|
||||
<span class="activity-time">{{ activity.time }}</span>
|
||||
<span class="activity-ip">IP: {{ activity.ip }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
<a-empty v-if="!stats?.recentActivities?.length" description="暂无活动记录" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick, markRaw } from 'vue';
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Row,
|
||||
Statistic,
|
||||
Timeline,
|
||||
TimelineItem,
|
||||
Tag,
|
||||
Button,
|
||||
Badge,
|
||||
Spin,
|
||||
Empty,
|
||||
message,
|
||||
} from 'ant-design-vue';
|
||||
import {
|
||||
DeviceOutlined,
|
||||
CheckCircleOutlined,
|
||||
AlertOutlined,
|
||||
TeamOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import * as echarts from 'echarts';
|
||||
import { dashboardApi, type DashboardStats } from '@/api/dashboard';
|
||||
|
||||
const ACard = Card;
|
||||
const ACol = Col;
|
||||
const ARow = Row;
|
||||
const ATimeline = Timeline;
|
||||
const ATimelineItem = TimelineItem;
|
||||
|
||||
const stats = ref<DashboardStats | null>(null);
|
||||
const loading = ref(false);
|
||||
const lineChartRef = ref<HTMLDivElement>();
|
||||
const pieChartRef = ref<HTMLDivElement>();
|
||||
let lineChartInstance: echarts.ECharts | null = null;
|
||||
let pieChartInstance: echarts.ECharts | null = null;
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const lastUpdateTime = ref('--');
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
key: 'deviceTotal',
|
||||
title: '设备总数',
|
||||
icon: markRaw(DeviceOutlined),
|
||||
color: '#1890ff',
|
||||
iconBg: '#e6f7ff',
|
||||
},
|
||||
{
|
||||
key: 'deviceOnline',
|
||||
title: '在线设备',
|
||||
icon: markRaw(CheckCircleOutlined),
|
||||
color: '#52c41a',
|
||||
iconBg: '#f6ffed',
|
||||
},
|
||||
{
|
||||
key: 'alertToday',
|
||||
title: '今日告警',
|
||||
icon: markRaw(AlertOutlined),
|
||||
color: '#ff4d4f',
|
||||
iconBg: '#fff2f0',
|
||||
},
|
||||
{
|
||||
key: 'activeUsers',
|
||||
title: '活跃用户',
|
||||
icon: markRaw(TeamOutlined),
|
||||
color: '#faad14',
|
||||
iconBg: '#fffbe6',
|
||||
},
|
||||
];
|
||||
|
||||
onMounted(async () => {
|
||||
await loadStats();
|
||||
await nextTick();
|
||||
initCharts();
|
||||
startAutoRefresh();
|
||||
window.addEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh();
|
||||
disposeCharts();
|
||||
window.removeEventListener('resize', handleResize);
|
||||
});
|
||||
|
||||
async function loadStats() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await dashboardApi.getStats();
|
||||
if (res.code === 200) {
|
||||
stats.value = res.data;
|
||||
lastUpdateTime.value = new Date().toLocaleTimeString('zh-CN');
|
||||
updateCharts();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载仪表盘数据失败:', error);
|
||||
message.error('加载数据失败,请稍后重试');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
loadStats();
|
||||
}
|
||||
|
||||
function initCharts() {
|
||||
if (lineChartRef.value) {
|
||||
lineChartInstance = echarts.init(lineChartRef.value);
|
||||
}
|
||||
if (pieChartRef.value) {
|
||||
pieChartInstance = echarts.init(pieChartRef.value);
|
||||
}
|
||||
}
|
||||
|
||||
function updateCharts() {
|
||||
if (!stats.value) return;
|
||||
|
||||
if (lineChartInstance && stats.value.deviceTrend) {
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' },
|
||||
},
|
||||
legend: {
|
||||
data: ['在线设备', '离线设备'],
|
||||
top: 0,
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: stats.value.deviceTrend.map(d => d.date.slice(5)),
|
||||
axisLine: { lineStyle: { color: '#d9d9d9' } },
|
||||
axisLabel: { color: '#666' },
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
splitLine: { lineStyle: { color: '#f0f0f0', type: 'dashed' } },
|
||||
axisLabel: { color: '#666' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '在线设备',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
data: stats.value.deviceTrend.map(d => d.online),
|
||||
itemStyle: { color: '#52c41a' },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(82, 196, 26, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(82, 196, 26, 0.05)' },
|
||||
]),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '离线设备',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
data: stats.value.deviceTrend.map(d => d.offline),
|
||||
itemStyle: { color: '#ff4d4f' },
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(255, 77, 79, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(255, 77, 79, 0.05)' },
|
||||
]),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
lineChartInstance.setOption(option, true);
|
||||
}
|
||||
|
||||
if (pieChartInstance && stats.value.deviceTypeDistribution) {
|
||||
const option = {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c}台 ({d}%)',
|
||||
},
|
||||
legend: {
|
||||
orient: 'vertical',
|
||||
left: 'left',
|
||||
top: 'center',
|
||||
textStyle: { color: '#666' },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['45%', '70%'],
|
||||
center: ['60%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 6,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '{b}\n{d}%',
|
||||
fontSize: 12,
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
labelLine: {
|
||||
length: 15,
|
||||
length2: 10,
|
||||
},
|
||||
data: stats.value.deviceTypeDistribution.map((item, index) => ({
|
||||
...item,
|
||||
itemStyle: {
|
||||
color: ['#1890ff', '#52c41a', '#faad14', '#722ed1', '#13c2c2'][index],
|
||||
},
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
pieChartInstance.setOption(option, true);
|
||||
}
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
lineChartInstance?.resize();
|
||||
pieChartInstance?.resize();
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
refreshTimer = setInterval(loadStats, 30000);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function disposeCharts() {
|
||||
lineChartInstance?.dispose();
|
||||
pieChartInstance?.dispose();
|
||||
lineChartInstance = null;
|
||||
pieChartInstance = null;
|
||||
}
|
||||
|
||||
function getAlertLevelColor(level: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
high: 'red',
|
||||
medium: 'orange',
|
||||
low: 'blue',
|
||||
};
|
||||
return colors[level] || 'default';
|
||||
}
|
||||
|
||||
function getAlertLevelText(level: string): string {
|
||||
const texts: Record<string, string> = {
|
||||
high: '严重',
|
||||
medium: '警告',
|
||||
low: '提示',
|
||||
};
|
||||
return texts[level] || level;
|
||||
}
|
||||
|
||||
function getActivityColor(module: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
认证: 'blue',
|
||||
设备管理: 'green',
|
||||
用户管理: 'purple',
|
||||
监控中心: 'red',
|
||||
数据分析: 'orange',
|
||||
系统: 'cyan',
|
||||
系统设置: 'geekblue',
|
||||
};
|
||||
return colors[module] || 'gray';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.dashboard {
|
||||
padding: 24px;
|
||||
min-height: calc(100vh - 120px);
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.dashboard-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.update-time {
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
.stat-cards {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.stat-card {
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
|
||||
.stat-icon-wrapper {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
.stat-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.chart-card {
|
||||
border-radius: 8px;
|
||||
|
||||
.chart-container {
|
||||
height: 350px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-section {
|
||||
.list-card {
|
||||
border-radius: 8px;
|
||||
min-height: 400px;
|
||||
|
||||
.alert-list {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #d9d9d9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.alert-item {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.alert-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.alert-device {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.alert-status {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-left: 4px;
|
||||
|
||||
.alert-type {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.alert-time {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.activity-timeline {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding-top: 8px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #d9d9d9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.activity-content {
|
||||
.activity-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
.activity-user {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.activity-module {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.activity-action {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.activity-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user