ether-admin/src/views/Dashboard.vue

435 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { getProjects } from '@/api/project'
import { getRoles } from '@/api/role'
import { getSpaceTree } from '@/api/space'
import { getUsers } from '@/api/user'
import type { WorkOrder } from '@/api/work-order'
import { getWorkOrders } from '@/api/work-order'
import {
ApartmentOutlined,
BarChartOutlined,
ProjectOutlined,
SettingOutlined,
SoundOutlined,
TeamOutlined,
ThunderboltOutlined,
ToolOutlined,
UserOutlined,
WarningOutlined
} from '@ant-design/icons-vue'
import { Col, Empty, Row, Spin } from 'ant-design-vue'
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
// 加载状态
const loading = ref(true)
const error = ref<string | null>(null)
// 统计数据
const userCount = ref(0)
const roleCount = ref(0)
const projectCount = ref(0)
const spaceNodeCount = ref(0)
// 待办任务
const pendingWorkOrders = ref<WorkOrder[]>([])
const pendingCount = computed(() => pendingWorkOrders.value.length)
// 系统公告模拟数据后续可替换为真实API
const notices = ref([
{ title: '系统将于今晚进行例行维护', time: '2小时前' },
{ title: '新版本功能上线通知', time: '昨天' },
{ title: '物业费缴纳提醒', time: '3天前' }
])
// 快捷入口
const actions = [
{ title: '用户管理', path: '/users', icon: UserOutlined },
{ title: '工单处理', path: '/workorders', icon: ToolOutlined },
{ title: '设备管理', path: '/equipment', icon: SettingOutlined },
{ title: '项目列表', path: '/projects', icon: ProjectOutlined }
]
// 图表数据(周工单统计)
const weeklyStats = ref<number[]>([0, 0, 0, 0, 0, 0, 0])
const displayHeights = ref<number[]>([0, 0, 0, 0, 0, 0, 0])
const chartAnimationComplete = ref(false)
// 动画函数
const easeOutQuart = (t: number): number => {
return 1 - Math.pow(1 - t, 4)
}
const animateChart = () => {
const duration = 1200
const startTime = performance.now()
const maxValue = Math.max(...weeklyStats.value, 1)
const tick = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
const easedProgress = easeOutQuart(progress)
displayHeights.value = weeklyStats.value.map(v => (v / maxValue) * 100 * easedProgress)
if (progress < 1) {
requestAnimationFrame(tick)
} else {
chartAnimationComplete.value = true
}
}
requestAnimationFrame(tick)
}
// 获取仪表盘数据
const fetchDashboardData = async () => {
loading.value = true
error.value = null
try {
// 并行获取基础统计数据
const [usersRes, rolesRes, projectsRes] = await Promise.all([
getUsers({ page: 0, size: 1 }).catch(() => ({ data: { data: { totalElements: 0 } } })),
getRoles({ page: 0, size: 1 }).catch(() => ({ data: { data: { totalElements: 0 } } })),
getProjects().catch(() => ({ data: { data: [] } }))
])
userCount.value = usersRes.data?.data?.totalElements || 0
roleCount.value = rolesRes.data?.data?.totalElements || 0
projectCount.value = projectsRes.data?.data?.length || 0
// 获取空间节点数(取第一个项目)
if (projectsRes.data?.data?.length > 0) {
const firstProject = projectsRes.data.data[0]
const spaceRes = await getSpaceTree(firstProject.id).catch(() => ({ data: { data: [] } }))
// 计算树形结构中的总节点数
const countNodes = (nodes: any[]): number => {
return nodes.reduce((count, node) => {
return count + 1 + (node.children ? countNodes(node.children) : 0)
}, 0)
}
spaceNodeCount.value = countNodes(spaceRes.data?.data || [])
}
// 获取待处理工单
const workOrderRes = await getWorkOrders({
status: 'PENDING'
}).catch(() => ({ data: { data: [] } }))
pendingWorkOrders.value = (workOrderRes.data?.data || []).slice(0, 5)
// 获取本周工单统计数据模拟7天数据
const today = new Date()
const weekData: number[] = []
for (let i = 6; i >= 0; i--) {
const date = new Date(today)
date.setDate(date.getDate() - i)
// 这里可以调用真实的日统计API
weekData.push(Math.floor(Math.random() * 50) + 20) // 临时模拟数据
}
weeklyStats.value = weekData
// 启动图表动画
setTimeout(animateChart, 300)
} catch (err) {
error.value = '加载仪表盘数据失败'
console.error('Dashboard load error:', err)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchDashboardData()
})
// 格式化日期
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (days === 0) return '今天'
if (days === 1) return '昨天'
if (days < 7) return `${days}天前`
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
</script>
<template>
<div class="page-container">
<!-- 页面标题 -->
<div class="page-header">
<h2 class="page-title">仪表盘</h2>
<span class="header-date">{{ new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' }) }}</span>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<Spin size="large" tip="加载中..." />
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<Empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="error">
<template #extra>
<a-button type="primary" @click="fetchDashboardData">重新加载</a-button>
</template>
</Empty>
</div>
<!-- 主内容 -->
<template v-else>
<!-- 统计卡片 -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-icon stat-icon-primary">
<UserOutlined />
</div>
<div class="stat-content">
<div class="stat-label">用户总数</div>
<div class="stat-value">{{ userCount.toLocaleString() }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon stat-icon-success">
<TeamOutlined />
</div>
<div class="stat-content">
<div class="stat-label">角色总数</div>
<div class="stat-value">{{ roleCount }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon stat-icon-warning">
<ProjectOutlined />
</div>
<div class="stat-content">
<div class="stat-label">项目总数</div>
<div class="stat-value">{{ projectCount }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon stat-icon-purple">
<ApartmentOutlined />
</div>
<div class="stat-content">
<div class="stat-label">空间节点</div>
<div class="stat-value">{{ spaceNodeCount }}</div>
</div>
</div>
</div>
<!-- 图表 + 待办 -->
<Row :gutter="24" class="content-row">
<Col :xs="24" :lg="16">
<div class="card">
<h3 class="card-title">
<BarChartOutlined /> 本周工单趋势
</h3>
<div class="chart">
<div v-for="(_, i) in weeklyStats" :key="i" class="bar-item">
<div class="bar" :style="{ height: displayHeights[i] + '%' }"></div>
<span class="bar-label">周{{ ['一', '二', '三', '四', '五', '六', '日'][i] }}</span>
</div>
</div>
</div>
</Col>
<Col :xs="24" :lg="8">
<div class="card">
<h3 class="card-title">
<WarningOutlined /> 待处理工单
<span v-if="pendingCount > 0" class="pending-badge">{{ pendingCount }}</span>
</h3>
<div v-if="pendingWorkOrders.length > 0" class="list-container">
<div
v-for="item in pendingWorkOrders"
:key="item.id"
class="list-item work-order-item"
@click="router.push('/workorders')"
>
<div class="work-order-info">
<span class="list-item-title">{{ item.title || item.workNo }}</span>
<span class="work-order-type">{{ item.type }}</span>
</div>
<span class="list-item-meta">{{ formatDate(item.createdAt || '') }}</span>
</div>
</div>
<Empty v-else :image="Empty.PRESENTED_IMAGE_SIMPLE" description="暂无待处理工单" />
</div>
</Col>
</Row>
<!-- 快捷入口 + 公告 -->
<Row :gutter="24" class="content-row">
<Col :xs="24" :lg="12">
<div class="card">
<h3 class="card-title">
<ThunderboltOutlined /> 快捷入口
</h3>
<div class="action-list">
<div v-for="a in actions" :key="a.title" class="action-item" @click="router.push(a.path)">
<component :is="a.icon" class="action-icon" />
<span class="action-title">{{ a.title }}</span>
</div>
</div>
</div>
</Col>
<Col :xs="24" :lg="12">
<div class="card">
<h3 class="card-title">
<SoundOutlined /> 系统公告
</h3>
<div class="list-container">
<div v-for="n in notices" :key="n.title" class="list-item">
<span class="list-item-title">{{ n.title }}</span>
<span class="list-item-meta">{{ n.time }}</span>
</div>
</div>
</div>
</Col>
</Row>
</template>
</div>
</template>
<style scoped>
/* 加载和错误状态 */
.loading-container,
.error-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
/* 页面标题 */
.header-date {
color: var(--color-text-placeholder, #8c8c8c);
font-size: 14px;
}
/* 统计卡片样式 */
.stat-icon {
width: 48px;
height: 48px;
border-radius: var(--radius-md, 8px);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.stat-icon-primary {
background: var(--color-primary-bg, #e6f7ff);
color: var(--color-primary, #1890ff);
}
.stat-icon-success {
background: var(--color-success-bg, #f6ffed);
color: var(--color-success, #52c41a);
}
.stat-icon-warning {
background: var(--color-warning-bg, #fff7e6);
color: var(--color-warning, #faad14);
}
.stat-icon-purple {
background: var(--color-purple-bg, #f9f0ff);
color: var(--color-purple, #722ed1);
}
/* 图表样式 */
.chart {
height: 200px;
display: flex;
align-items: flex-end;
justify-content: space-around;
padding-bottom: 24px;
}
.bar-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
height: 100%;
justify-content: flex-end;
}
.bar {
width: 32px;
background: var(--color-primary, #1890ff);
border-radius: var(--radius-sm, 4px) var(--radius-sm, 4px) 0 0;
transition: height 0.3s ease;
opacity: 0.8;
}
.bar:hover {
opacity: 1;
}
.bar-label {
margin-top: 12px;
font-size: 13px;
color: var(--color-text-placeholder, #8c8c8c);
}
/* 待办工单样式 */
.pending-badge {
margin-left: 8px;
padding: 2px 8px;
background: var(--color-error, #f5222d);
color: #fff;
font-size: 12px;
border-radius: 10px;
}
.work-order-item {
cursor: pointer;
transition: background-color 0.2s;
padding: 12px;
margin: 0 -12px;
border-radius: var(--radius-sm, 4px);
}
.work-order-item:hover {
background-color: var(--color-bg-filter, #fafafa);
}
.work-order-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.work-order-type {
font-size: 12px;
color: var(--color-text-placeholder, #8c8c8c);
}
/* 快捷入口 */
.action-item {
cursor: pointer;
transition: all 0.2s;
}
.action-item:hover {
background: var(--color-primary-light, #40a9ff);
color: #fff;
}
.action-item:hover .action-icon {
color: #fff;
}
</style>