435 lines
12 KiB
Vue
435 lines
12 KiB
Vue
<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>
|