删除前端废弃组件和示例文件
This commit is contained in:
267
admin-system/src/App.vue
Normal file
267
admin-system/src/App.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<div v-if="isLoggedIn">
|
||||
<!-- 移动端布局 -->
|
||||
<div v-if="isMobile" class="mobile-layout">
|
||||
<MobileNav ref="mobileNavRef" />
|
||||
<div class="mobile-content">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端布局 -->
|
||||
<a-layout v-else style="min-height: 100vh">
|
||||
<a-layout-header class="header">
|
||||
<div class="logo">
|
||||
<a-button
|
||||
type="text"
|
||||
@click="settingsStore.toggleSidebar"
|
||||
style="color: white; margin-right: 8px;"
|
||||
>
|
||||
<menu-unfold-outlined v-if="sidebarCollapsed" />
|
||||
<menu-fold-outlined v-else />
|
||||
</a-button>
|
||||
宁夏智慧养殖监管平台
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<a href="https://nxzhyz.xiyumuge.com/" target="_blank" style="color: white; text-decoration: none; margin-right: 16px;">大屏展示</a>
|
||||
<span>欢迎, {{ userData?.username }}</span>
|
||||
<a-button type="link" @click="handleLogout" style="color: white;">退出</a-button>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
|
||||
<a-layout>
|
||||
<a-layout-sider
|
||||
width="200"
|
||||
style="background: #001529"
|
||||
:collapsed="sidebarCollapsed"
|
||||
collapsible
|
||||
>
|
||||
<DynamicMenu :collapsed="sidebarCollapsed" />
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout style="padding: 0 24px 24px">
|
||||
<a-layout-content
|
||||
:style="{ background: '#fff', padding: '24px', margin: '16px 0' }"
|
||||
>
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
|
||||
<a-layout-footer style="text-align: center">
|
||||
宁夏智慧养殖监管平台 ©2025
|
||||
</a-layout-footer>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</div>
|
||||
<div v-else>
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import DynamicMenu from './components/DynamicMenu.vue'
|
||||
import MobileNav from './components/MobileNav.vue'
|
||||
import { useUserStore, useSettingsStore } from './stores'
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
// 使用Pinia状态管理
|
||||
const userStore = useUserStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const router = useRouter()
|
||||
|
||||
// 移动端导航引用
|
||||
const mobileNavRef = ref()
|
||||
|
||||
// 响应式检测
|
||||
const isMobile = ref(false)
|
||||
|
||||
// 检测屏幕尺寸
|
||||
const checkScreenSize = () => {
|
||||
isMobile.value = window.innerWidth <= 768
|
||||
}
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => userStore.isLoggedIn)
|
||||
const userData = computed(() => userStore.userData)
|
||||
const sidebarCollapsed = computed(() => settingsStore.sidebarCollapsed)
|
||||
|
||||
// 注意:路由守卫已移至router/index.js
|
||||
|
||||
// 监听多标签页登录状态同步
|
||||
const handleStorageChange = (event) => {
|
||||
if (event.key === 'token' || event.key === 'user') {
|
||||
userStore.checkLoginStatus()
|
||||
}
|
||||
}
|
||||
|
||||
// 登出处理
|
||||
const handleLogout = () => {
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
userStore.checkLoginStatus()
|
||||
checkScreenSize()
|
||||
window.addEventListener('storage', handleStorageChange)
|
||||
window.addEventListener('resize', checkScreenSize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('storage', handleStorageChange)
|
||||
window.removeEventListener('resize', checkScreenSize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 桌面端样式 */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #001529;
|
||||
color: white;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* 移动端布局样式 */
|
||||
.mobile-layout {
|
||||
min-height: 100vh;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.mobile-content {
|
||||
padding: 12px;
|
||||
padding-top: 68px; /* 为固定头部留出空间 */
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
/* 移动端页面内容优化 */
|
||||
.mobile-layout :deep(.page-header) {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.search-area) {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.search-input) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.search-buttons) {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.search-buttons .ant-btn) {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
/* 移动端表格优化 */
|
||||
.mobile-layout :deep(.ant-table-wrapper) {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-table) {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-table-thead > tr > th) {
|
||||
padding: 8px 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-table-tbody > tr > td) {
|
||||
padding: 8px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 移动端模态框优化 */
|
||||
.mobile-layout :deep(.ant-modal) {
|
||||
margin: 0 !important;
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
top: 0 !important;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-modal-content) {
|
||||
border-radius: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-modal-body) {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-modal-footer) {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* 移动端卡片优化 */
|
||||
.mobile-layout :deep(.ant-card) {
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-card-body) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* 移动端按钮优化 */
|
||||
.mobile-layout :deep(.ant-btn) {
|
||||
min-height: 40px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-space) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-space-item) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mobile-layout :deep(.ant-space-item .ant-btn) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 响应式调试 */
|
||||
@media (max-width: 768px) {
|
||||
.debug-indicator {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: rgba(24, 144, 255, 0.9);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
z-index: 9999;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
344
admin-system/src/components/AlertStats.vue
Normal file
344
admin-system/src/components/AlertStats.vue
Normal file
@@ -0,0 +1,344 @@
|
||||
<template>
|
||||
<div class="alert-stats">
|
||||
<a-card :bordered="false" title="预警数据统计">
|
||||
<div class="stats-summary">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ totalAlerts }}</div>
|
||||
<div class="stat-label">预警总数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ criticalAlerts }}</div>
|
||||
<div class="stat-label">严重预警</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ resolvedRate }}%</div>
|
||||
<div class="stat-label">解决率</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="chart-container">
|
||||
<e-chart :options="alertTypeOptions" height="300px" />
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="alert-table">
|
||||
<a-table
|
||||
:dataSource="alertTableData"
|
||||
:columns="alertColumns"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'level'">
|
||||
<a-tag :color="getLevelColor(record.level)">
|
||||
{{ record.level }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag
|
||||
:color="getStatusColor(record.status)"
|
||||
style="cursor: pointer"
|
||||
@click="handleStatusUpdate(record)"
|
||||
>
|
||||
{{ getStatusLabel(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useDataStore } from '../stores'
|
||||
import EChart from './EChart.vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
// 使用数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 预警总数
|
||||
const totalAlerts = computed(() => dataStore.alerts.length)
|
||||
|
||||
// 严重预警数量
|
||||
const criticalAlerts = computed(() => {
|
||||
return dataStore.alerts.filter(alert => alert.level === '严重').length
|
||||
})
|
||||
|
||||
// 已解决预警数量
|
||||
const resolvedAlerts = computed(() => {
|
||||
return dataStore.alerts.filter(alert => alert.status === '已解决').length
|
||||
})
|
||||
|
||||
// 解决率
|
||||
const resolvedRate = computed(() => {
|
||||
if (totalAlerts.value === 0) return 0
|
||||
return Math.round((resolvedAlerts.value / totalAlerts.value) * 100)
|
||||
})
|
||||
|
||||
// 预警类型分布图表选项
|
||||
const alertTypeOptions = computed(() => {
|
||||
// 按类型分组统计
|
||||
const typeCount = {}
|
||||
dataStore.alerts.forEach(alert => {
|
||||
const chineseType = getTypeText(alert.type)
|
||||
typeCount[chineseType] = (typeCount[chineseType] || 0) + 1
|
||||
})
|
||||
|
||||
// 转换为图表数据格式
|
||||
const data = Object.keys(typeCount).map(type => ({
|
||||
value: typeCount[type],
|
||||
name: type
|
||||
}))
|
||||
|
||||
return {
|
||||
title: {
|
||||
text: '预警类型分布',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '预警类型',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: data
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 预警表格数据
|
||||
const alertTableData = computed(() => {
|
||||
return dataStore.alerts.map(alert => {
|
||||
const farm = dataStore.farms.find(f => f.id === alert.farm_id)
|
||||
const device = dataStore.devices.find(d => d.id === alert.device_id)
|
||||
return {
|
||||
key: alert.id,
|
||||
id: alert.id,
|
||||
type: getTypeText(alert.type),
|
||||
level: alert.level,
|
||||
message: alert.message,
|
||||
farmId: alert.farm_id,
|
||||
farmName: farm ? farm.name : `养殖场 #${alert.farm_id}`,
|
||||
deviceId: alert.device_id,
|
||||
deviceName: device ? device.name : `设备 #${alert.device_id}`,
|
||||
status: alert.status,
|
||||
timestamp: alert.created_at
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 预警表格列定义
|
||||
const alertColumns = [
|
||||
{
|
||||
title: '预警类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
filters: [
|
||||
{ text: '温度异常', value: '温度异常' },
|
||||
{ text: '湿度异常', value: '湿度异常' },
|
||||
{ text: '设备故障', value: '设备故障' },
|
||||
{ text: '电量不足', value: '电量不足' },
|
||||
{ text: '网络异常', value: '网络异常' }
|
||||
],
|
||||
onFilter: (value, record) => record.type === value
|
||||
},
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'level',
|
||||
key: 'level',
|
||||
filters: [
|
||||
{ text: '轻微', value: '轻微' },
|
||||
{ text: '一般', value: '一般' },
|
||||
{ text: '严重', value: '严重' }
|
||||
],
|
||||
onFilter: (value, record) => record.level === value
|
||||
},
|
||||
{
|
||||
title: '养殖场',
|
||||
dataIndex: 'farmName',
|
||||
key: 'farmName'
|
||||
},
|
||||
{
|
||||
title: '设备',
|
||||
dataIndex: 'deviceName',
|
||||
key: 'deviceName'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
filters: [
|
||||
{ text: '未处理', value: '未处理' },
|
||||
{ text: '处理中', value: '处理中' },
|
||||
{ text: '已解决', value: '已解决' }
|
||||
],
|
||||
onFilter: (value, record) => record.status === value
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
sorter: (a, b) => new Date(a.timestamp) - new Date(b.timestamp),
|
||||
defaultSortOrder: 'descend'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取级别颜色
|
||||
function getLevelColor(level) {
|
||||
switch (level) {
|
||||
case '轻微': return 'blue'
|
||||
case '一般': return 'orange'
|
||||
case '严重': return 'red'
|
||||
default: return 'blue'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
function getStatusColor(status) {
|
||||
switch (status) {
|
||||
case 'active': return 'error'
|
||||
case 'acknowledged': return 'processing'
|
||||
case 'resolved': return 'success'
|
||||
case '未处理': return 'error'
|
||||
case '处理中': return 'processing'
|
||||
case '已解决': return 'success'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
// 处理状态更新
|
||||
function handleStatusUpdate(record) {
|
||||
const statusOptions = [
|
||||
{ label: '活跃', value: 'active' },
|
||||
{ label: '已确认', value: 'acknowledged' },
|
||||
{ label: '已解决', value: 'resolved' }
|
||||
]
|
||||
|
||||
const currentIndex = statusOptions.findIndex(option => option.value === record.status)
|
||||
const nextIndex = (currentIndex + 1) % statusOptions.length
|
||||
const nextStatus = statusOptions[nextIndex]
|
||||
|
||||
Modal.confirm({
|
||||
title: '更新预警状态',
|
||||
content: `确定要将预警状态从"${getStatusLabel(record.status)}"更新为"${nextStatus.label}"吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
// 调用API更新预警状态
|
||||
await api.put(`/alerts/public/${record.id}/status`, {
|
||||
status: nextStatus.value
|
||||
})
|
||||
|
||||
// 更新本地数据
|
||||
const alertIndex = dataStore.alerts.findIndex(alert => alert.id === record.id)
|
||||
if (alertIndex !== -1) {
|
||||
dataStore.alerts[alertIndex].status = nextStatus.value
|
||||
}
|
||||
|
||||
message.success(`预警状态已更新为"${nextStatus.label}"`)
|
||||
} catch (error) {
|
||||
console.error('更新预警状态失败:', error)
|
||||
message.error('更新预警状态失败,请重试')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取类型文本(英文转中文)
|
||||
function getTypeText(type) {
|
||||
const texts = {
|
||||
temperature_alert: '温度异常',
|
||||
humidity_alert: '湿度异常',
|
||||
feed_alert: '饲料异常',
|
||||
health_alert: '健康异常',
|
||||
device_alert: '设备异常',
|
||||
temperature: '温度异常',
|
||||
humidity: '湿度异常',
|
||||
device_failure: '设备故障',
|
||||
animal_health: '动物健康',
|
||||
security: '安全警报',
|
||||
maintenance: '维护提醒',
|
||||
other: '其他'
|
||||
}
|
||||
return texts[type] || type
|
||||
}
|
||||
|
||||
// 获取状态标签
|
||||
function getStatusLabel(status) {
|
||||
switch (status) {
|
||||
case 'active': return '活跃'
|
||||
case 'acknowledged': return '已确认'
|
||||
case 'resolved': return '已解决'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alert-stats {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats-summary {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.alert-table {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
259
admin-system/src/components/AnimalStats.vue
Normal file
259
admin-system/src/components/AnimalStats.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<div class="animal-stats">
|
||||
<a-card :bordered="false" title="动物数据统计">
|
||||
<div class="stats-summary">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ totalAnimals }}</div>
|
||||
<div class="stat-label">动物总数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ animalTypes.length }}</div>
|
||||
<div class="stat-label">动物种类</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ averagePerFarm }}</div>
|
||||
<div class="stat-label">平均数量/场</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="chart-container">
|
||||
<e-chart :options="typeDistributionOptions" height="300px" />
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="animal-table">
|
||||
<a-table
|
||||
:dataSource="animalTableData"
|
||||
:columns="animalColumns"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'count'">
|
||||
<a-progress
|
||||
:percent="getPercentage(record.count, maxCount)"
|
||||
:stroke-color="getProgressColor(record.count, maxCount)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useDataStore } from '../stores'
|
||||
import EChart from './EChart.vue'
|
||||
|
||||
// 使用数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(async () => {
|
||||
console.log('AnimalStats: 组件挂载,开始加载数据')
|
||||
await dataStore.fetchAllData()
|
||||
console.log('AnimalStats: 数据加载完成')
|
||||
console.log('farms数据:', dataStore.farms)
|
||||
console.log('animals数据:', dataStore.animals)
|
||||
})
|
||||
|
||||
// 动物总数
|
||||
const totalAnimals = computed(() => {
|
||||
return dataStore.animals.reduce((total, animal) => total + (animal.count || 0), 0)
|
||||
})
|
||||
|
||||
// 动物种类
|
||||
const animalTypes = computed(() => {
|
||||
const types = new Set(dataStore.animals.map(animal => getTypeText(animal.type)))
|
||||
return Array.from(types)
|
||||
})
|
||||
|
||||
// 平均每个养殖场的动物数量
|
||||
const averagePerFarm = computed(() => {
|
||||
if (dataStore.farms.length === 0) return 0
|
||||
return Math.round(totalAnimals.value / dataStore.farms.length)
|
||||
})
|
||||
|
||||
// 动物类型分布图表选项
|
||||
const typeDistributionOptions = computed(() => {
|
||||
// 按类型分组统计
|
||||
const typeCount = {}
|
||||
dataStore.animals.forEach(animal => {
|
||||
const chineseType = getTypeText(animal.type)
|
||||
typeCount[chineseType] = (typeCount[chineseType] || 0) + (animal.count || 0)
|
||||
})
|
||||
|
||||
// 转换为图表数据格式
|
||||
const data = Object.keys(typeCount).map(type => ({
|
||||
value: typeCount[type],
|
||||
name: type
|
||||
}))
|
||||
|
||||
return {
|
||||
title: {
|
||||
text: '动物类型分布',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '动物类型',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: data
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 动物表格数据
|
||||
const animalTableData = computed(() => {
|
||||
// 按类型和养殖场分组统计
|
||||
const groupMap = new Map()
|
||||
|
||||
dataStore.animals.forEach(animal => {
|
||||
const key = `${animal.farm_id}-${animal.type}`
|
||||
if (!groupMap.has(key)) {
|
||||
const farm = dataStore.farms.find(f => f.id === animal.farm_id)
|
||||
groupMap.set(key, {
|
||||
key: key,
|
||||
farmId: animal.farm_id,
|
||||
farmName: farm ? farm.name : `养殖场 #${animal.farm_id}`,
|
||||
type: getTypeText(animal.type),
|
||||
count: 0
|
||||
})
|
||||
}
|
||||
groupMap.get(key).count += (animal.count || 0)
|
||||
})
|
||||
|
||||
// 转换为数组并按数量降序排序
|
||||
return Array.from(groupMap.values()).sort((a, b) => b.count - a.count)
|
||||
})
|
||||
|
||||
// 最大数量
|
||||
const maxCount = computed(() => {
|
||||
if (animalTableData.value.length === 0) return 1
|
||||
return Math.max(...animalTableData.value.map(item => item.count))
|
||||
})
|
||||
|
||||
// 动物表格列定义
|
||||
const animalColumns = [
|
||||
{
|
||||
title: '养殖场',
|
||||
dataIndex: 'farmName',
|
||||
key: 'farmName'
|
||||
},
|
||||
{
|
||||
title: '动物类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type'
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
sorter: (a, b) => a.count - b.count,
|
||||
defaultSortOrder: 'descend'
|
||||
}
|
||||
]
|
||||
|
||||
// 计算百分比
|
||||
function getPercentage(value, max) {
|
||||
return Math.round((value / max) * 100)
|
||||
}
|
||||
|
||||
// 获取类型文本(英文转中文)
|
||||
function getTypeText(type) {
|
||||
const texts = {
|
||||
pig: '猪',
|
||||
chicken: '鸡',
|
||||
cow: '牛',
|
||||
sheep: '羊',
|
||||
duck: '鸭',
|
||||
goose: '鹅',
|
||||
fish: '鱼',
|
||||
shrimp: '虾',
|
||||
cattle: '牛',
|
||||
poultry: '家禽',
|
||||
swine: '猪',
|
||||
livestock: '牲畜',
|
||||
other: '其他'
|
||||
}
|
||||
return texts[type] || type
|
||||
}
|
||||
|
||||
// 获取进度条颜色
|
||||
function getProgressColor(value, max) {
|
||||
const percentage = getPercentage(value, max)
|
||||
if (percentage < 30) return '#52c41a'
|
||||
if (percentage < 70) return '#1890ff'
|
||||
return '#ff4d4f'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.animal-stats {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats-summary {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.animal-table {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
569
admin-system/src/components/BaiduMap.vue
Normal file
569
admin-system/src/components/BaiduMap.vue
Normal file
@@ -0,0 +1,569 @@
|
||||
<template>
|
||||
<div class="baidu-map-wrapper">
|
||||
<div class="baidu-map-container" ref="mapContainer"></div>
|
||||
|
||||
<!-- 自定义缩放控制按钮 -->
|
||||
<div class="map-zoom-controls" v-if="isReady">
|
||||
<button class="zoom-btn zoom-in" @click="zoomIn" title="放大">
|
||||
<span class="zoom-icon">+</span>
|
||||
</button>
|
||||
<button class="zoom-btn zoom-out" @click="zoomOut" title="缩小">
|
||||
<span class="zoom-icon">−</span>
|
||||
</button>
|
||||
<button class="zoom-btn zoom-reset" @click="resetZoom" title="重置缩放">
|
||||
<span class="zoom-icon">⌂</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 缩放级别显示 -->
|
||||
<div class="zoom-level-display" v-if="isReady && showZoomLevel">
|
||||
缩放级别: {{ currentZoom }}
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="map-loading-overlay">
|
||||
<div class="loading-content">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>地图加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 新增:加载状态信息div -->
|
||||
<div v-if="statusText" class="map-status">{{ statusText }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { createMap, addMarkers, clearOverlays, setViewToFitMarkers } from '../utils/mapService'
|
||||
import { baiduMapTester } from '../utils/baiduMapTest'
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
// 地图标记数据
|
||||
markers: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 地图配置选项
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
// 地图高度
|
||||
height: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
// 是否显示缩放级别
|
||||
showZoomLevel: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['map-ready', 'marker-click', 'map-click'])
|
||||
|
||||
// 地图容器引用
|
||||
const mapContainer = ref(null)
|
||||
|
||||
// 组件状态
|
||||
const isLoading = ref(true)
|
||||
// 新增:加载状态文本
|
||||
const statusText = ref('')
|
||||
let clearStatusTimer = null
|
||||
// 新增:地图就绪标志
|
||||
const isReady = ref(false)
|
||||
|
||||
// 缩放相关状态
|
||||
const currentZoom = ref(10)
|
||||
const defaultZoom = ref(10)
|
||||
|
||||
// 地图实例和标记
|
||||
let baiduMap = null
|
||||
let mapMarkers = []
|
||||
let markersInitialized = false
|
||||
let clickListenerAdded = false
|
||||
|
||||
// 初始化地图
|
||||
async function initMap() {
|
||||
statusText.value = '正在初始化地图...'
|
||||
console.log('BaiduMap组件: 开始初始化地图')
|
||||
console.log('地图容器:', mapContainer.value)
|
||||
console.log('容器尺寸:', mapContainer.value?.offsetWidth, 'x', mapContainer.value?.offsetHeight)
|
||||
console.log('window.BMap是否存在:', typeof window.BMap)
|
||||
|
||||
if (!mapContainer.value) {
|
||||
console.error('地图容器不存在')
|
||||
statusText.value = '地图容器不存在'
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('BaiduMap组件: 调用createMap函数')
|
||||
statusText.value = '正在创建地图实例...'
|
||||
|
||||
// 确保容器有尺寸
|
||||
if (mapContainer.value.offsetWidth === 0 || mapContainer.value.offsetHeight === 0) {
|
||||
console.log('BaiduMap组件: 容器尺寸为0,等待容器渲染...')
|
||||
statusText.value = '等待容器渲染...'
|
||||
// 等待容器渲染
|
||||
await new Promise(resolve => setTimeout(resolve, 200))
|
||||
|
||||
// 再次检查容器尺寸
|
||||
if (mapContainer.value.offsetWidth === 0 || mapContainer.value.offsetHeight === 0) {
|
||||
// 强制设置容器尺寸
|
||||
mapContainer.value.style.width = '100%'
|
||||
mapContainer.value.style.height = '400px'
|
||||
mapContainer.value.style.minHeight = '400px'
|
||||
console.log('BaiduMap组件: 强制设置容器尺寸')
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
// 验证容器最终状态
|
||||
console.log('BaiduMap组件: 容器最终状态:', {
|
||||
offsetWidth: mapContainer.value.offsetWidth,
|
||||
offsetHeight: mapContainer.value.offsetHeight,
|
||||
clientWidth: mapContainer.value.clientWidth,
|
||||
clientHeight: mapContainer.value.clientHeight
|
||||
})
|
||||
|
||||
// 确保容器仍然存在且有效
|
||||
if (!mapContainer.value || !mapContainer.value.parentNode) {
|
||||
throw new Error('地图容器已从DOM中移除')
|
||||
}
|
||||
|
||||
// 等待下一个事件循环,确保DOM完全稳定
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// 再次验证容器
|
||||
if (!mapContainer.value || mapContainer.value.offsetWidth === 0 || mapContainer.value.offsetHeight === 0) {
|
||||
throw new Error('地图容器尺寸无效')
|
||||
}
|
||||
|
||||
// 创建地图实例
|
||||
baiduMap = await createMap(mapContainer.value, props.options)
|
||||
|
||||
// 验证地图实例是否有效
|
||||
if (!baiduMap || typeof baiduMap.addEventListener !== 'function') {
|
||||
throw new Error('地图实例创建失败或无效')
|
||||
}
|
||||
|
||||
console.log('BaiduMap组件: 地图创建成功', baiduMap)
|
||||
statusText.value = '地图实例创建成功,正在加载瓦片...'
|
||||
|
||||
// 监听地图完全加载完成事件
|
||||
baiduMap.addEventListener('tilesloaded', () => {
|
||||
console.log('BaiduMap组件: 地图瓦片加载完成')
|
||||
|
||||
// 只在第一次加载时初始化
|
||||
if (!markersInitialized) {
|
||||
console.log('BaiduMap组件: 首次加载,开始初始化')
|
||||
|
||||
// 标记地图就绪,防止兜底重复添加
|
||||
if (baiduMap) {
|
||||
baiduMap._markersAdded = true
|
||||
}
|
||||
|
||||
// 初始化缩放相关状态
|
||||
if (baiduMap && typeof baiduMap.getZoom === 'function') {
|
||||
currentZoom.value = baiduMap.getZoom()
|
||||
defaultZoom.value = currentZoom.value
|
||||
|
||||
// 添加缩放事件监听器
|
||||
baiduMap.addEventListener('zoomend', () => {
|
||||
if (baiduMap && typeof baiduMap.getZoom === 'function') {
|
||||
currentZoom.value = baiduMap.getZoom()
|
||||
console.log('缩放级别变化:', currentZoom.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 添加地图点击事件监听器(只添加一次)
|
||||
if (!clickListenerAdded) {
|
||||
baiduMap.addEventListener('click', function(e) {
|
||||
console.log('BaiduMap组件: 地图被点击', e)
|
||||
console.log('点击事件详情:', {
|
||||
type: e.type,
|
||||
lnglat: e.lnglat,
|
||||
point: e.point,
|
||||
pixel: e.pixel,
|
||||
overlay: e.overlay,
|
||||
target: e.target,
|
||||
currentTarget: e.currentTarget
|
||||
})
|
||||
|
||||
// 百度地图点击事件的标准处理方式
|
||||
let lng, lat
|
||||
if (e.lnglat) {
|
||||
lng = e.lnglat.lng
|
||||
lat = e.lnglat.lat
|
||||
} else if (e.point) {
|
||||
lng = e.point.lng
|
||||
lat = e.point.lat
|
||||
} else {
|
||||
// 如果事件对象没有直接的位置信息,尝试从地图获取
|
||||
const point = baiduMap.getPosition(e.pixel)
|
||||
if (point) {
|
||||
lng = point.lng
|
||||
lat = point.lat
|
||||
} else {
|
||||
console.warn('BaiduMap组件: 无法获取点击位置信息', e)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
console.log('解析到的坐标:', { lng, lat })
|
||||
|
||||
// 触发地图点击事件,传递点击位置信息
|
||||
emit('map-click', {
|
||||
lnglat: {
|
||||
lng: lng,
|
||||
lat: lat
|
||||
},
|
||||
pixel: e.pixel || { x: 0, y: 0 },
|
||||
overlay: e.overlay || null
|
||||
})
|
||||
})
|
||||
clickListenerAdded = true
|
||||
console.log('BaiduMap组件: 点击事件监听器已添加')
|
||||
}
|
||||
|
||||
isReady.value = true
|
||||
isLoading.value = false
|
||||
statusText.value = '地图加载完成'
|
||||
// 自动隐藏状态条
|
||||
if (clearStatusTimer) clearTimeout(clearStatusTimer)
|
||||
clearStatusTimer = setTimeout(() => { statusText.value = '' }, 1200)
|
||||
|
||||
// 地图就绪后,检查是否有待添加的标记
|
||||
if (props.markers && props.markers.length > 0) {
|
||||
console.log('BaiduMap组件: 地图就绪,添加标记', props.markers.length)
|
||||
addMarkersToMap()
|
||||
} else {
|
||||
console.log('BaiduMap组件: 没有标记数据,添加测试标记')
|
||||
const testMarkers = [{
|
||||
location: { lng: 106.27, lat: 38.47 },
|
||||
title: '测试标记',
|
||||
content: '这是一个测试标记点'
|
||||
}]
|
||||
addMarkers(baiduMap, testMarkers)
|
||||
}
|
||||
|
||||
markersInitialized = true
|
||||
}
|
||||
})
|
||||
|
||||
// 备用方案:如果事件监听失败,使用延时
|
||||
setTimeout(() => {
|
||||
if (baiduMap && !baiduMap._markersAdded) {
|
||||
console.log('BaiduMap组件: 备用方案 - 延时添加标记')
|
||||
if (baiduMap) {
|
||||
baiduMap._markersAdded = true
|
||||
}
|
||||
|
||||
// 备用方案中不重复添加点击事件监听器,避免重复触发
|
||||
console.log('BaiduMap组件: 备用方案 - 跳过重复的点击事件监听器')
|
||||
|
||||
isReady.value = true
|
||||
isLoading.value = false
|
||||
statusText.value = '地图加载完成(兼容模式)'
|
||||
if (clearStatusTimer) clearTimeout(clearStatusTimer)
|
||||
clearStatusTimer = setTimeout(() => { statusText.value = '' }, 1500)
|
||||
|
||||
if (props.markers && props.markers.length > 0) {
|
||||
console.log('BaiduMap组件: 备用方案 - 地图就绪,添加标记', props.markers.length)
|
||||
addMarkersToMap()
|
||||
} else {
|
||||
const testMarkers = [{
|
||||
location: { lng: 106.27, lat: 38.47 },
|
||||
title: '测试标记',
|
||||
content: '这是一个测试标记点'
|
||||
}]
|
||||
addMarkers(baiduMap, testMarkers)
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
// 触发地图就绪事件
|
||||
emit('map-ready', baiduMap)
|
||||
console.log('BaiduMap组件: 地图初始化完成')
|
||||
} catch (error) {
|
||||
console.error('初始化百度地图失败:', error)
|
||||
console.error('错误详情:', error.stack)
|
||||
statusText.value = '地图加载失败,正在诊断问题...'
|
||||
isLoading.value = false
|
||||
isReady.value = false
|
||||
baiduMap = null
|
||||
|
||||
// 运行诊断测试
|
||||
try {
|
||||
console.log('🔍 开始运行百度地图诊断测试...')
|
||||
const testResults = await baiduMapTester.runFullTest()
|
||||
const suggestions = baiduMapTester.getFixSuggestions()
|
||||
|
||||
console.log('📊 诊断结果:', testResults)
|
||||
console.log('💡 修复建议:', suggestions)
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
statusText.value = `地图加载失败: ${error.message}。请查看控制台获取修复建议。`
|
||||
} else {
|
||||
statusText.value = '地图加载失败,请检查网络或密钥配置'
|
||||
}
|
||||
} catch (diagnosticError) {
|
||||
console.error('诊断测试失败:', diagnosticError)
|
||||
statusText.value = '地图加载失败,请检查网络或密钥配置'
|
||||
}
|
||||
|
||||
// 清理状态文本
|
||||
if (clearStatusTimer) clearTimeout(clearStatusTimer)
|
||||
clearStatusTimer = setTimeout(() => { statusText.value = '' }, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加标记到地图
|
||||
function addMarkersToMap() {
|
||||
// 检查地图实例是否有效
|
||||
if (!baiduMap || !isReady.value) {
|
||||
console.warn('BaiduMap组件: 地图未就绪,跳过标记添加')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证地图实例的有效性
|
||||
if (typeof baiduMap.addEventListener !== 'function') {
|
||||
console.error('BaiduMap组件: 地图实例无效')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证BMap对象是否可用
|
||||
if (!window.BMap) {
|
||||
console.error('BaiduMap组件: BMap对象不可用')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('BaiduMap组件: 开始添加标记,数量:', props.markers?.length || 0)
|
||||
|
||||
// 清除现有标记
|
||||
clearOverlays(baiduMap)
|
||||
|
||||
// 验证标记数据
|
||||
if (!props.markers || !Array.isArray(props.markers) || props.markers.length === 0) {
|
||||
console.log('BaiduMap组件: 没有标记数据需要添加')
|
||||
return
|
||||
}
|
||||
|
||||
// 添加新标记
|
||||
mapMarkers = addMarkers(baiduMap, props.markers, (markerData, marker) => {
|
||||
// 触发标记点击事件
|
||||
emit('marker-click', markerData, marker)
|
||||
})
|
||||
|
||||
console.log('BaiduMap组件: 成功添加标记,数量:', mapMarkers.length)
|
||||
|
||||
// 调整视图以显示所有标记
|
||||
if (mapMarkers.length > 0) {
|
||||
const points = mapMarkers.map(marker => marker.getPosition()).filter(point => point)
|
||||
if (points.length > 0) {
|
||||
setViewToFitMarkers(baiduMap, points)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('BaiduMap组件: 添加标记失败:', error)
|
||||
console.error('错误详情:', error.stack)
|
||||
}
|
||||
}
|
||||
|
||||
// 缩放方法
|
||||
function zoomIn() {
|
||||
if (baiduMap && isReady.value) {
|
||||
const currentZoomLevel = baiduMap.getZoom()
|
||||
const newZoom = Math.min(currentZoomLevel + 1, 19)
|
||||
baiduMap.setZoom(newZoom)
|
||||
currentZoom.value = newZoom
|
||||
console.log('地图放大到级别:', newZoom)
|
||||
}
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
if (baiduMap && isReady.value) {
|
||||
const currentZoomLevel = baiduMap.getZoom()
|
||||
const newZoom = Math.max(currentZoomLevel - 1, 3)
|
||||
baiduMap.setZoom(newZoom)
|
||||
currentZoom.value = newZoom
|
||||
console.log('地图缩小到级别:', newZoom)
|
||||
}
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
if (baiduMap && isReady.value) {
|
||||
// 重置到默认缩放级别和中心点
|
||||
const defaultCenter = new window.BMap.Point(106.27, 38.47)
|
||||
baiduMap.centerAndZoom(defaultCenter, defaultZoom.value)
|
||||
currentZoom.value = defaultZoom.value
|
||||
console.log('地图重置到默认状态:', defaultZoom.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听标记数据变化
|
||||
watch(() => props.markers, (newMarkers) => {
|
||||
if (baiduMap && isReady.value && newMarkers) {
|
||||
addMarkersToMap()
|
||||
} else if (newMarkers && newMarkers.length > 0) {
|
||||
// 如果地图还未就绪但有标记数据,等待地图就绪后再添加
|
||||
console.log('BaiduMap组件: 地图未就绪,等待地图加载完成后添加标记')
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 重试机制
|
||||
let retryCount = 0
|
||||
const maxRetries = 3
|
||||
|
||||
async function retryInitMap() {
|
||||
if (retryCount >= maxRetries) {
|
||||
console.error('BaiduMap组件: 达到最大重试次数,停止重试')
|
||||
statusText.value = '地图初始化失败,请刷新页面重试'
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
retryCount++
|
||||
console.log(`BaiduMap组件: 第${retryCount}次重试初始化地图`)
|
||||
statusText.value = `正在重试初始化地图 (${retryCount}/${maxRetries})...`
|
||||
|
||||
// 清理之前的状态
|
||||
if (baiduMap) {
|
||||
baiduMap = null
|
||||
}
|
||||
|
||||
// 等待一段时间后重试
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount))
|
||||
|
||||
try {
|
||||
await initMap()
|
||||
} catch (error) {
|
||||
console.error(`BaiduMap组件: 第${retryCount}次重试失败:`, error)
|
||||
if (retryCount < maxRetries) {
|
||||
retryInitMap()
|
||||
} else {
|
||||
statusText.value = '地图初始化失败,请检查网络连接或刷新页面'
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时初始化地图
|
||||
onMounted(() => {
|
||||
console.log('BaiduMap组件: 组件已挂载')
|
||||
console.log('地图容器DOM:', mapContainer.value)
|
||||
console.log('地图容器ID:', mapContainer.value?.id)
|
||||
console.log('地图容器类名:', mapContainer.value?.className)
|
||||
|
||||
// 延迟初始化,确保DOM完全渲染
|
||||
setTimeout(async () => {
|
||||
console.log('BaiduMap组件: 延迟初始化地图')
|
||||
// 确保容器完全可见
|
||||
if (mapContainer.value) {
|
||||
mapContainer.value.style.visibility = 'visible'
|
||||
mapContainer.value.style.display = 'block'
|
||||
}
|
||||
|
||||
try {
|
||||
await initMap()
|
||||
} catch (error) {
|
||||
console.error('BaiduMap组件: 初始化失败,开始重试:', error)
|
||||
retryInitMap()
|
||||
}
|
||||
}, 200)
|
||||
})
|
||||
|
||||
// 组件卸载时清理资源
|
||||
onUnmounted(() => {
|
||||
if (baiduMap) {
|
||||
clearOverlays(baiduMap)
|
||||
baiduMap = null
|
||||
}
|
||||
isReady.value = false
|
||||
markersInitialized = false
|
||||
if (clearStatusTimer) {
|
||||
clearTimeout(clearStatusTimer)
|
||||
clearStatusTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露地图实例
|
||||
defineExpose({
|
||||
getMapInstance: () => baiduMap
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.baidu-map-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.baidu-map-container {
|
||||
width: 100%;
|
||||
height: v-bind(height);
|
||||
min-height: 400px;
|
||||
position: relative;
|
||||
border: 1px solid #ddd;
|
||||
background-color: #f5f5f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* 新增:地图状态信息样式 */
|
||||
.map-status {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: rgba(0,0,0,0.55);
|
||||
color: #fff;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
z-index: 1001;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #1890ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 10px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-content p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
217
admin-system/src/components/ChartPerformanceMonitor.vue
Normal file
217
admin-system/src/components/ChartPerformanceMonitor.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div class="chart-performance-monitor">
|
||||
<a-card title="图表性能监控" size="small" :bordered="false">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="text" size="small" @click="refreshStats">
|
||||
<template #icon><reload-outlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="clearCache" danger>
|
||||
<template #icon><delete-outlined /></template>
|
||||
清理缓存
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<div class="performance-stats">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-statistic
|
||||
title="图表实例"
|
||||
:value="stats.chartInstances"
|
||||
suffix="个"
|
||||
:value-style="{ color: '#3f8600' }"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-statistic
|
||||
title="数据缓存"
|
||||
:value="stats.dataCache"
|
||||
suffix="项"
|
||||
:value-style="{ color: '#1890ff' }"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-statistic
|
||||
title="内存使用"
|
||||
:value="totalMemoryUsage"
|
||||
suffix="MB"
|
||||
:precision="2"
|
||||
:value-style="{ color: '#722ed1' }"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-statistic
|
||||
title="缓存命中率"
|
||||
:value="cacheHitRate"
|
||||
suffix="%"
|
||||
:precision="1"
|
||||
:value-style="{ color: cacheHitRate > 70 ? '#3f8600' : '#faad14' }"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="performance-details">
|
||||
<a-descriptions size="small" :column="2">
|
||||
<a-descriptions-item label="渲染器">Canvas (硬件加速)</a-descriptions-item>
|
||||
<a-descriptions-item label="动画优化">启用 (300ms)</a-descriptions-item>
|
||||
<a-descriptions-item label="大数据优化">启用 (2000+)</a-descriptions-item>
|
||||
<a-descriptions-item label="防抖更新">启用 (100ms)</a-descriptions-item>
|
||||
<a-descriptions-item label="懒加载">支持</a-descriptions-item>
|
||||
<a-descriptions-item label="数据缓存TTL">2-5分钟</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
|
||||
<div class="performance-tips" v-if="showTips">
|
||||
<a-alert
|
||||
:message="performanceTip.title"
|
||||
:description="performanceTip.description"
|
||||
:type="performanceTip.type"
|
||||
show-icon
|
||||
closable
|
||||
@close="showTips = false"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ReloadOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import { getCacheStats, clearAllCache } from '../utils/chartService'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
// 性能统计数据
|
||||
const stats = ref({
|
||||
chartInstances: 0,
|
||||
dataCache: 0,
|
||||
memoryUsage: {
|
||||
charts: 0,
|
||||
data: 0
|
||||
}
|
||||
})
|
||||
|
||||
// 缓存命中统计
|
||||
const cacheHits = ref(0)
|
||||
const cacheRequests = ref(0)
|
||||
const showTips = ref(true)
|
||||
|
||||
// 计算总内存使用量
|
||||
const totalMemoryUsage = computed(() => {
|
||||
return stats.value.memoryUsage.charts + stats.value.memoryUsage.data
|
||||
})
|
||||
|
||||
// 计算缓存命中率
|
||||
const cacheHitRate = computed(() => {
|
||||
if (cacheRequests.value === 0) return 0
|
||||
return (cacheHits.value / cacheRequests.value) * 100
|
||||
})
|
||||
|
||||
// 性能提示
|
||||
const performanceTip = computed(() => {
|
||||
const hitRate = cacheHitRate.value
|
||||
const memoryUsage = totalMemoryUsage.value
|
||||
|
||||
if (memoryUsage > 50) {
|
||||
return {
|
||||
title: '内存使用较高',
|
||||
description: '建议清理缓存或减少图表实例数量以优化性能',
|
||||
type: 'warning'
|
||||
}
|
||||
} else if (hitRate < 50) {
|
||||
return {
|
||||
title: '缓存命中率较低',
|
||||
description: '考虑增加缓存时间或优化数据请求策略',
|
||||
type: 'info'
|
||||
}
|
||||
} else if (hitRate > 80) {
|
||||
return {
|
||||
title: '性能表现优秀',
|
||||
description: '图表缓存工作良好,加载速度已优化',
|
||||
type: 'success'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
title: '性能表现良好',
|
||||
description: '图表加载速度正常,缓存机制运行稳定',
|
||||
type: 'info'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 刷新统计数据
|
||||
function refreshStats() {
|
||||
stats.value = getCacheStats()
|
||||
// message.success('统计数据已刷新')
|
||||
}
|
||||
|
||||
// 清理缓存
|
||||
function clearCache() {
|
||||
clearAllCache()
|
||||
refreshStats()
|
||||
cacheHits.value = 0
|
||||
cacheRequests.value = 0
|
||||
message.success('缓存已清理')
|
||||
}
|
||||
|
||||
// 模拟缓存命中统计(实际项目中应该从chartService获取)
|
||||
function simulateCacheStats() {
|
||||
// 模拟缓存请求
|
||||
cacheRequests.value += Math.floor(Math.random() * 3) + 1
|
||||
// 模拟缓存命中
|
||||
cacheHits.value += Math.floor(Math.random() * 2) + 1
|
||||
}
|
||||
|
||||
// 定时器
|
||||
let statsTimer = null
|
||||
|
||||
// 组件挂载时开始监控
|
||||
onMounted(() => {
|
||||
refreshStats()
|
||||
|
||||
// 每5秒更新一次统计数据
|
||||
statsTimer = setInterval(() => {
|
||||
refreshStats()
|
||||
simulateCacheStats()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onUnmounted(() => {
|
||||
if (statsTimer) {
|
||||
clearInterval(statsTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-performance-monitor {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.performance-stats {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.performance-details {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.performance-tips {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-statistic-content) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
:deep(.ant-statistic-title) {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
272
admin-system/src/components/Dashboard.vue
Normal file
272
admin-system/src/components/Dashboard.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- <h1>系统概览</h1> -->
|
||||
|
||||
<div class="dashboard-stats">
|
||||
<a-card title="栏舍数量" :bordered="false">
|
||||
<template #extra><a-tag color="blue">总计</a-tag></template>
|
||||
<p class="stat-number">{{ penCount }}</p>
|
||||
</a-card>
|
||||
|
||||
<a-card title="牛只数量" :bordered="false">
|
||||
<template #extra><a-tag color="green">总计</a-tag></template>
|
||||
<p class="stat-number">{{ animalCount }}</p>
|
||||
</a-card>
|
||||
|
||||
<a-card title="智能设备" :bordered="false">
|
||||
<template #extra><a-tag color="orange">在线率</a-tag></template>
|
||||
<p class="stat-number">{{ deviceCount }}</p>
|
||||
</a-card>
|
||||
|
||||
<a-card title="智能预警" :bordered="false">
|
||||
<template #extra><a-tag color="red">本月</a-tag></template>
|
||||
<p class="stat-number clickable" @click="handleAlertClick">{{ alertCount }}</p>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-charts">
|
||||
<a-card title="养殖场分布" :bordered="false" class="chart-card">
|
||||
<baidu-map
|
||||
:markers="farmMarkers"
|
||||
height="350px"
|
||||
@marker-click="handleFarmClick"
|
||||
/>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 养殖场详情抽屉 -->
|
||||
<a-drawer
|
||||
:title="`养殖场详情 - ${selectedFarmId ? dataStore.farms.find(f => f.id == selectedFarmId)?.name : ''}`"
|
||||
:width="600"
|
||||
:visible="drawerVisible"
|
||||
@close="closeDrawer"
|
||||
:bodyStyle="{ paddingBottom: '80px' }"
|
||||
>
|
||||
<farm-detail v-if="selectedFarmId" :farm-id="selectedFarmId" />
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { RiseOutlined, FallOutlined } from '@ant-design/icons-vue'
|
||||
import { useDataStore } from '../stores'
|
||||
import { convertFarmsToMarkers } from '../utils/mapService'
|
||||
import BaiduMap from './BaiduMap.vue'
|
||||
import FarmDetail from './FarmDetail.vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
// 使用数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 使用路由
|
||||
const router = useRouter()
|
||||
|
||||
// 图表容器引用
|
||||
const trendChart = ref(null)
|
||||
|
||||
// 从store获取统计数据
|
||||
const statsData = computed(() => dataStore.stats)
|
||||
|
||||
// 从store获取计算属性
|
||||
const farmCount = computed(() => dataStore.farmCount)
|
||||
const animalCount = computed(() => dataStore.animalCount)
|
||||
const deviceCount = computed(() => dataStore.deviceCount)
|
||||
const alertCount = computed(() => dataStore.alertCount)
|
||||
const penCount = computed(() => dataStore.penCount)
|
||||
const deviceOnlineRate = computed(() => dataStore.deviceOnlineRate)
|
||||
|
||||
// 养殖场地图标记
|
||||
const farmMarkers = computed(() => {
|
||||
console.log('Dashboard: 计算farmMarkers')
|
||||
console.log('dataStore.farms:', dataStore.farms)
|
||||
const markers = convertFarmsToMarkers(dataStore.farms)
|
||||
console.log('转换后的markers:', markers)
|
||||
return markers
|
||||
})
|
||||
|
||||
// 抽屉控制
|
||||
const drawerVisible = ref(false)
|
||||
const selectedFarmId = ref(null)
|
||||
|
||||
// 处理养殖场标记点击事件
|
||||
function handleFarmClick(markerData) {
|
||||
// 设置选中的养殖场ID
|
||||
selectedFarmId.value = markerData.originalData.id
|
||||
// 打开抽屉
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
// 关闭抽屉
|
||||
function closeDrawer() {
|
||||
drawerVisible.value = false
|
||||
}
|
||||
|
||||
// 处理智能预警点击事件
|
||||
function handleAlertClick() {
|
||||
console.log('点击智能预警,跳转到智能耳标预警页面')
|
||||
router.push('/smart-alerts/eartag')
|
||||
}
|
||||
|
||||
// 图表实例
|
||||
let trendChartInstance = null
|
||||
|
||||
// 定时刷新器
|
||||
let refreshTimer = null
|
||||
|
||||
// 组件挂载后初始化数据和图表
|
||||
onMounted(async () => {
|
||||
console.log('Dashboard: 组件挂载,开始加载数据')
|
||||
|
||||
// 加载数据
|
||||
await dataStore.fetchAllData()
|
||||
|
||||
console.log('Dashboard: 数据加载完成')
|
||||
console.log('farms数据:', dataStore.farms)
|
||||
|
||||
// 初始化趋势图表
|
||||
initTrendChart()
|
||||
|
||||
// 添加窗口大小变化监听
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
// 启动定时刷新智能预警数据(每30秒刷新一次)
|
||||
startSmartAlertRefresh()
|
||||
})
|
||||
|
||||
// 组件卸载时清理资源
|
||||
onUnmounted(() => {
|
||||
// 移除窗口大小变化监听
|
||||
window.removeEventListener('resize', handleResize)
|
||||
|
||||
// 清理定时器
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
|
||||
// 销毁图表实例
|
||||
if (trendChartInstance) {
|
||||
trendChartInstance.dispose()
|
||||
trendChartInstance = null
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化趋势图表
|
||||
function initTrendChart() {
|
||||
if (!trendChart.value) return
|
||||
|
||||
// 导入图表服务
|
||||
import('../utils/chartService').then(({ createTrendChart }) => {
|
||||
// 使用空数据初始化图表,等待后端数据
|
||||
const trendData = {
|
||||
xAxis: [],
|
||||
series: []
|
||||
}
|
||||
|
||||
// 创建趋势图表
|
||||
trendChartInstance = createTrendChart(trendChart.value, trendData, {
|
||||
title: {
|
||||
text: '月度数据趋势',
|
||||
left: 'center'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 处理窗口大小变化
|
||||
function handleResize() {
|
||||
if (trendChartInstance) {
|
||||
trendChartInstance.resize()
|
||||
}
|
||||
}
|
||||
|
||||
// 启动智能预警数据定时刷新
|
||||
function startSmartAlertRefresh() {
|
||||
// 每30秒刷新一次智能预警数据
|
||||
refreshTimer = setInterval(async () => {
|
||||
try {
|
||||
console.log('定时刷新智能预警数据...')
|
||||
await dataStore.fetchSmartAlerts()
|
||||
console.log('智能预警数据刷新完成,当前预警数量:', dataStore.alertCount)
|
||||
} catch (error) {
|
||||
console.error('刷新智能预警数据失败:', error)
|
||||
}
|
||||
}, 30000) // 30秒
|
||||
}
|
||||
|
||||
// 停止智能预警数据定时刷新
|
||||
function stopSmartAlertRefresh() {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dashboard-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-number.clickable {
|
||||
cursor: pointer;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-number.clickable:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
color: #8c8c8c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dashboard-charts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.map-container,
|
||||
.chart-container {
|
||||
height: 350px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dashboard-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.dashboard-charts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
388
admin-system/src/components/DeviceStats.vue
Normal file
388
admin-system/src/components/DeviceStats.vue
Normal file
@@ -0,0 +1,388 @@
|
||||
<template>
|
||||
<div class="device-stats">
|
||||
<a-card :bordered="false" title="设备数据统计">
|
||||
<div class="stats-summary">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ totalDevices }}</div>
|
||||
<div class="stat-label">设备总数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" style="color: #52c41a;">{{ onlineDevices }}</div>
|
||||
<div class="stat-label">在线设备</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" style="color: #faad14;">{{ maintenanceDevices }}</div>
|
||||
<div class="stat-label">维护设备</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" style="color: #ff4d4f;">{{ offlineDevices }}</div>
|
||||
<div class="stat-label">离线设备</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ onlineRate }}%</div>
|
||||
<div class="stat-label">在线率</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="chart-container">
|
||||
<e-chart :options="deviceStatusOptions" height="300px" />
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="device-table">
|
||||
<a-table
|
||||
:dataSource="deviceTableData"
|
||||
:columns="deviceColumns"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.originalStatus)">
|
||||
{{ record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 设备统计组件 - 显示设备状态分布
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useDataStore } from '../stores'
|
||||
import EChart from './EChart.vue'
|
||||
|
||||
// 使用数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 组件挂载时确保数据已加载
|
||||
onMounted(async () => {
|
||||
console.log('=== DeviceStats组件挂载 ===')
|
||||
console.log('当前设备数量:', dataStore.devices.length)
|
||||
|
||||
try {
|
||||
console.log('开始获取设备数据...')
|
||||
|
||||
// 直接测试API调用
|
||||
console.log('直接测试API调用...')
|
||||
const { deviceService } = await import('../utils/dataService')
|
||||
const apiResult = await deviceService.getAllDevices()
|
||||
console.log('直接API调用结果:', apiResult)
|
||||
console.log('API返回数据类型:', typeof apiResult)
|
||||
console.log('API返回是否为数组:', Array.isArray(apiResult))
|
||||
console.log('API返回数据长度:', apiResult?.length || 0)
|
||||
|
||||
await dataStore.fetchDevices()
|
||||
console.log('设备数据获取完成:', dataStore.devices.length, '台设备')
|
||||
|
||||
// 详细输出设备数据
|
||||
console.log('设备数据详情:', dataStore.devices.slice(0, 3))
|
||||
|
||||
const statusCount = {}
|
||||
dataStore.devices.forEach(device => {
|
||||
statusCount[device.status] = (statusCount[device.status] || 0) + 1
|
||||
})
|
||||
console.log('设备状态分布:', statusCount)
|
||||
|
||||
// 输出计算属性的值
|
||||
console.log('计算属性值:')
|
||||
console.log('- totalDevices:', totalDevices.value)
|
||||
console.log('- onlineDevices:', onlineDevices.value)
|
||||
console.log('- maintenanceDevices:', maintenanceDevices.value)
|
||||
console.log('- offlineDevices:', offlineDevices.value)
|
||||
console.log('- onlineRate:', onlineRate.value)
|
||||
|
||||
console.log('=== DeviceStats组件挂载完成 ===')
|
||||
} catch (error) {
|
||||
console.error('获取设备数据失败:', error)
|
||||
console.error('错误堆栈:', error.stack)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听设备数据变化
|
||||
watch(() => dataStore.devices, (newDevices) => {
|
||||
console.log('设备数据更新:', newDevices.length, '台设备')
|
||||
const statusCount = {}
|
||||
newDevices.forEach(device => {
|
||||
statusCount[device.status] = (statusCount[device.status] || 0) + 1
|
||||
})
|
||||
console.log('当前状态分布:', statusCount)
|
||||
|
||||
// 输出前几个设备的详细信息
|
||||
if (newDevices.length > 0) {
|
||||
console.log('前3个设备详情:', newDevices.slice(0, 3).map(d => ({ id: d.id, name: d.name, status: d.status })))
|
||||
}
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
// 设备总数
|
||||
const totalDevices = computed(() => dataStore.devices.length)
|
||||
|
||||
// 在线设备数量
|
||||
const onlineDevices = computed(() => {
|
||||
return dataStore.devices.filter(device => device.status === 'online').length
|
||||
})
|
||||
|
||||
// 维护设备数量
|
||||
const maintenanceDevices = computed(() => {
|
||||
return dataStore.devices.filter(device => device.status === 'maintenance').length
|
||||
})
|
||||
|
||||
// 离线设备数量
|
||||
const offlineDevices = computed(() => {
|
||||
return dataStore.devices.filter(device => device.status === 'offline').length
|
||||
})
|
||||
|
||||
// 在线率
|
||||
const onlineRate = computed(() => {
|
||||
if (totalDevices.value === 0) return 0
|
||||
return Math.round((onlineDevices.value / totalDevices.value) * 100)
|
||||
})
|
||||
|
||||
// 设备状态分布图表选项
|
||||
const deviceStatusOptions = computed(() => {
|
||||
console.log('计算图表选项,当前设备数量:', dataStore.devices.length)
|
||||
|
||||
// 如果没有设备数据,显示空状态
|
||||
if (dataStore.devices.length === 0) {
|
||||
console.log('没有设备数据,显示空状态图表')
|
||||
return {
|
||||
title: {
|
||||
text: '设备状态分布',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '设备状态',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
data: [
|
||||
{
|
||||
value: 1,
|
||||
name: '暂无数据',
|
||||
itemStyle: {
|
||||
color: '#d9d9d9'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 按状态分组统计
|
||||
const statusCount = {}
|
||||
dataStore.devices.forEach(device => {
|
||||
const chineseStatus = getStatusText(device.status)
|
||||
statusCount[chineseStatus] = (statusCount[chineseStatus] || 0) + 1
|
||||
})
|
||||
|
||||
console.log('状态统计:', statusCount)
|
||||
|
||||
// 定义状态颜色映射
|
||||
const statusColors = {
|
||||
'在线': '#52c41a',
|
||||
'维护中': '#faad14',
|
||||
'离线': '#ff4d4f'
|
||||
}
|
||||
|
||||
// 转换为图表数据格式,并添加颜色
|
||||
const data = Object.keys(statusCount).map(status => ({
|
||||
value: statusCount[status],
|
||||
name: status,
|
||||
itemStyle: {
|
||||
color: statusColors[status] || '#d9d9d9'
|
||||
}
|
||||
}))
|
||||
|
||||
console.log('图表数据:', data)
|
||||
|
||||
return {
|
||||
title: {
|
||||
text: '设备状态分布',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c}台 ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0,
|
||||
itemGap: 20
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '设备状态',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: data
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 设备表格数据
|
||||
const deviceTableData = computed(() => {
|
||||
return dataStore.devices.map(device => {
|
||||
const farm = dataStore.farms.find(f => f.id === device.farm_id || f.id === device.farmId)
|
||||
return {
|
||||
key: device.id,
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
type: getTypeText(device.type),
|
||||
farmId: device.farm_id || device.farmId,
|
||||
farmName: farm ? farm.name : `养殖场 #${device.farm_id || device.farmId}`,
|
||||
status: getStatusText(device.status),
|
||||
originalStatus: device.status,
|
||||
lastUpdate: device.last_maintenance || device.lastUpdate || '未知'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 设备表格列定义
|
||||
const deviceColumns = [
|
||||
{
|
||||
title: '设备名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: '设备类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type'
|
||||
},
|
||||
{
|
||||
title: '养殖场',
|
||||
dataIndex: 'farmName',
|
||||
key: 'farmName'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
filters: [
|
||||
{ text: '在线', value: '在线' },
|
||||
{ text: '维护中', value: '维护中' },
|
||||
{ text: '离线', value: '离线' }
|
||||
],
|
||||
onFilter: (value, record) => record.status === value
|
||||
},
|
||||
{
|
||||
title: '最后更新',
|
||||
dataIndex: 'lastUpdate',
|
||||
key: 'lastUpdate',
|
||||
sorter: (a, b) => new Date(a.lastUpdate) - new Date(b.lastUpdate),
|
||||
defaultSortOrder: 'descend'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取类型文本(英文转中文)
|
||||
function getTypeText(type) {
|
||||
const texts = {
|
||||
temperature_sensor: '温度传感器',
|
||||
humidity_sensor: '湿度传感器',
|
||||
feed_dispenser: '饲料分配器',
|
||||
water_system: '水系统',
|
||||
ventilation_system: '通风系统',
|
||||
sensor: '传感器',
|
||||
camera: '摄像头',
|
||||
feeder: '喂食器',
|
||||
monitor: '监控器',
|
||||
controller: '控制器',
|
||||
other: '其他'
|
||||
}
|
||||
return texts[type] || type
|
||||
}
|
||||
|
||||
// 获取状态文本(英文转中文)
|
||||
function getStatusText(status) {
|
||||
switch (status) {
|
||||
case 'online': return '在线'
|
||||
case 'offline': return '离线'
|
||||
case 'maintenance': return '维护中'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态颜色(支持英文状态)
|
||||
function getStatusColor(status) {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
case '在线': return 'success'
|
||||
case 'offline':
|
||||
case '离线': return 'error'
|
||||
case 'maintenance':
|
||||
case '维护中': return 'processing'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.device-stats {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats-summary {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.device-table {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
544
admin-system/src/components/DynamicMenu.vue
Normal file
544
admin-system/src/components/DynamicMenu.vue
Normal file
@@ -0,0 +1,544 @@
|
||||
<template>
|
||||
<a-menu
|
||||
:selectedKeys="selectedKeys"
|
||||
:openKeys="openKeys"
|
||||
mode="inline"
|
||||
theme="dark"
|
||||
:inline-collapsed="collapsed"
|
||||
@click="handleMenuClick"
|
||||
@update:selectedKeys="selectedKeys = $event"
|
||||
@openChange="handleOpenChange"
|
||||
>
|
||||
<template v-for="menu in menuTree">
|
||||
<!-- 有子菜单的父菜单 -->
|
||||
<a-sub-menu v-if="menu.children && menu.children.length > 0" :key="menu.id">
|
||||
<template #icon>
|
||||
<component :is="menu.icon" v-if="menu.icon" />
|
||||
<HomeOutlined v-else />
|
||||
</template>
|
||||
<template #title>{{ menu.name }}</template>
|
||||
|
||||
<template v-for="subMenu in menu.children">
|
||||
<!-- 子菜单还有子菜单 -->
|
||||
<a-sub-menu v-if="subMenu.children && subMenu.children.length > 0" :key="subMenu.id">
|
||||
<template #icon>
|
||||
<component :is="subMenu.icon" v-if="subMenu.icon" />
|
||||
<SettingOutlined v-else />
|
||||
</template>
|
||||
<template #title>{{ subMenu.name }}</template>
|
||||
|
||||
<a-menu-item v-for="item in subMenu.children" :key="item.id">
|
||||
<template #icon>
|
||||
<component :is="item.icon" v-if="item.icon" />
|
||||
<FileOutlined v-else />
|
||||
</template>
|
||||
{{ item.name }}
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
|
||||
<!-- 子菜单是叶子节点 -->
|
||||
<a-menu-item v-else :key="subMenu.id">
|
||||
<template #icon>
|
||||
<component :is="subMenu.icon" v-if="subMenu.icon" />
|
||||
<FileOutlined v-else />
|
||||
</template>
|
||||
{{ subMenu.name }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
</a-sub-menu>
|
||||
|
||||
<!-- 没有子菜单的叶子菜单 -->
|
||||
<a-menu-item v-else :key="menu.id">
|
||||
<template #icon>
|
||||
<component :is="menu.icon" v-if="menu.icon" />
|
||||
<HomeOutlined v-else />
|
||||
</template>
|
||||
{{ menu.name }}
|
||||
</a-menu-item>
|
||||
</template>
|
||||
</a-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import {
|
||||
HomeOutlined,
|
||||
SettingOutlined,
|
||||
FileOutlined,
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
ShopOutlined,
|
||||
ShoppingCartOutlined,
|
||||
AlertOutlined,
|
||||
BarChartOutlined,
|
||||
EnvironmentOutlined,
|
||||
TeamOutlined,
|
||||
SafetyOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { menuService } from '../utils/dataService'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useUserStore } from '../stores'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['menu-click'])
|
||||
|
||||
// Router
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 响应式数据
|
||||
const selectedKeys = ref([])
|
||||
const openKeys = ref([])
|
||||
const menuTree = ref([])
|
||||
|
||||
// 图标映射
|
||||
const iconMap = {
|
||||
'DashboardOutlined': DashboardOutlined,
|
||||
'UserOutlined': UserOutlined,
|
||||
'ShopOutlined': ShopOutlined,
|
||||
'ShoppingCartOutlined': ShoppingCartOutlined,
|
||||
'AlertOutlined': AlertOutlined,
|
||||
'BarChartOutlined': BarChartOutlined,
|
||||
'EnvironmentOutlined': EnvironmentOutlined,
|
||||
'TeamOutlined': TeamOutlined,
|
||||
'SafetyOutlined': SafetyOutlined,
|
||||
'SettingOutlined': SettingOutlined,
|
||||
'FileOutlined': FileOutlined
|
||||
}
|
||||
|
||||
// 根据用户权限过滤菜单树
|
||||
const filterMenuTreeByPermission = (menuTree) => {
|
||||
console.log('开始权限过滤菜单树,原始菜单树数量:', menuTree.length)
|
||||
console.log('用户可访问菜单:', userStore.userData?.accessibleMenus)
|
||||
|
||||
if (!userStore.userData?.accessibleMenus || userStore.userData.accessibleMenus.length === 0) {
|
||||
console.log('用户无菜单权限,返回空数组')
|
||||
return []
|
||||
}
|
||||
|
||||
const filterMenu = (menu) => {
|
||||
// 检查当前菜单是否有权限
|
||||
const hasPermission = userStore.canAccessMenu(menu.menu_key)
|
||||
console.log(`菜单权限检查 ${menu.name} (${menu.menu_key}):`, hasPermission)
|
||||
|
||||
// 递归过滤子菜单
|
||||
const filteredChildren = menu.children ? menu.children.map(filterMenu).filter(child => child !== null) : []
|
||||
console.log(`子菜单过滤结果 ${menu.name}:`, filteredChildren.length, '个子菜单')
|
||||
|
||||
// 如果当前菜单有权限,或者有子菜单有权限,则保留
|
||||
if (hasPermission || filteredChildren.length > 0) {
|
||||
const result = {
|
||||
...menu,
|
||||
children: filteredChildren
|
||||
}
|
||||
console.log(`保留菜单 ${menu.name}:`, result)
|
||||
return result
|
||||
}
|
||||
|
||||
console.log(`过滤掉菜单 ${menu.name}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const filteredMenus = menuTree.map(filterMenu).filter(menu => menu !== null)
|
||||
|
||||
console.log('权限过滤完成,过滤后菜单树数量:', filteredMenus.length)
|
||||
return filteredMenus
|
||||
}
|
||||
|
||||
// 加载菜单数据
|
||||
const loadMenus = async () => {
|
||||
try {
|
||||
console.log('开始加载菜单数据...')
|
||||
const response = await menuService.getAllMenus()
|
||||
console.log('菜单API响应:', response)
|
||||
|
||||
let allMenus = []
|
||||
// 检查响应格式
|
||||
if (response && Array.isArray(response)) {
|
||||
// 直接是数组
|
||||
allMenus = response
|
||||
} else if (response && response.data && Array.isArray(response.data)) {
|
||||
// 包含data字段,直接是数组
|
||||
allMenus = response.data
|
||||
} else if (response && response.data && response.data.list && Array.isArray(response.data.list)) {
|
||||
// 包含data.list字段
|
||||
allMenus = response.data.list
|
||||
} else if (response && response.list && Array.isArray(response.list)) {
|
||||
// 直接包含list字段
|
||||
allMenus = response.list
|
||||
} else {
|
||||
console.warn('菜单数据格式不正确,使用默认菜单')
|
||||
console.warn('响应数据:', response)
|
||||
menuTree.value = getDefaultMenus()
|
||||
return
|
||||
}
|
||||
|
||||
// 先构建菜单树,再过滤权限
|
||||
const fullMenuTree = buildMenuTree(allMenus)
|
||||
console.log('构建的完整菜单树:', fullMenuTree)
|
||||
|
||||
// 根据用户权限过滤菜单树
|
||||
const filteredMenus = filterMenuTreeByPermission(fullMenuTree)
|
||||
console.log('权限过滤后的菜单树:', filteredMenus)
|
||||
|
||||
menuTree.value = filteredMenus
|
||||
console.log('菜单树构建完成:', menuTree.value)
|
||||
console.log('菜单树长度:', menuTree.value.length)
|
||||
|
||||
// 设置当前选中的菜单项
|
||||
setActiveMenu()
|
||||
} catch (error) {
|
||||
console.error('加载菜单数据失败:', error)
|
||||
message.error('加载菜单数据失败')
|
||||
|
||||
// 使用默认菜单作为后备
|
||||
console.log('使用默认菜单作为后备')
|
||||
menuTree.value = getDefaultMenus()
|
||||
console.log('默认菜单设置完成:', menuTree.value.length, '个菜单项')
|
||||
}
|
||||
}
|
||||
|
||||
// 构建菜单树
|
||||
const buildMenuTree = (menus) => {
|
||||
console.log('开始构建菜单树,原始菜单数量:', menus.length)
|
||||
|
||||
// 转换字段名
|
||||
const convertMenuFields = (menu) => {
|
||||
return {
|
||||
id: menu.id,
|
||||
name: menu.menu_name || menu.name,
|
||||
path: menu.menu_path || menu.path,
|
||||
icon: menu.icon,
|
||||
parent_id: menu.parent_id,
|
||||
sort_order: menu.sort_order || 0,
|
||||
menu_key: menu.menu_key,
|
||||
children: []
|
||||
}
|
||||
}
|
||||
|
||||
// 转换所有菜单项
|
||||
const convertedMenus = menus.map(convertMenuFields)
|
||||
console.log('转换后的菜单:', convertedMenus)
|
||||
|
||||
// 创建菜单映射表
|
||||
const menuMap = new Map()
|
||||
const rootMenus = []
|
||||
|
||||
// 先创建所有菜单项
|
||||
convertedMenus.forEach(menu => {
|
||||
menuMap.set(menu.id, menu)
|
||||
})
|
||||
|
||||
// 构建父子关系
|
||||
convertedMenus.forEach(menu => {
|
||||
if (menu.parent_id && menuMap.has(menu.parent_id)) {
|
||||
// 有父级,添加到父级的children中
|
||||
const parent = menuMap.get(menu.parent_id)
|
||||
parent.children.push(menu)
|
||||
} else {
|
||||
// 没有父级,是根级菜单
|
||||
rootMenus.push(menu)
|
||||
}
|
||||
})
|
||||
|
||||
// 对每个层级的菜单进行排序
|
||||
const sortMenus = (menuList) => {
|
||||
menuList.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0))
|
||||
menuList.forEach(menu => {
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
sortMenus(menu.children)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
sortMenus(rootMenus)
|
||||
|
||||
console.log('构建完成的菜单树:', rootMenus)
|
||||
console.log('根级菜单数量:', rootMenus.length)
|
||||
|
||||
return rootMenus
|
||||
}
|
||||
|
||||
// 设置当前激活的菜单项
|
||||
const setActiveMenu = () => {
|
||||
const currentPath = route.path
|
||||
console.log('当前路径:', currentPath)
|
||||
|
||||
// 查找匹配的菜单项
|
||||
const findMenuByPath = (menus, path) => {
|
||||
for (const menu of menus) {
|
||||
if (menu.path === path) {
|
||||
return menu
|
||||
}
|
||||
if (menu.children) {
|
||||
const found = findMenuByPath(menu.children, path)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const activeMenu = findMenuByPath(menuTree.value, currentPath)
|
||||
if (activeMenu && activeMenu.id != null) {
|
||||
selectedKeys.value = [activeMenu.id.toString()]
|
||||
|
||||
// 设置打开的父菜单
|
||||
const parentIds = getParentMenuIds(activeMenu.id, menuTree.value)
|
||||
openKeys.value = parentIds ? parentIds.map(id => id != null ? id.toString() : '').filter(id => id) : []
|
||||
}
|
||||
}
|
||||
|
||||
// 获取父菜单ID
|
||||
const getParentMenuIds = (menuId, menus, parentIds = []) => {
|
||||
for (const menu of menus) {
|
||||
if (menu && menu.id === menuId) {
|
||||
return parentIds
|
||||
}
|
||||
if (menu && menu.children) {
|
||||
const found = getParentMenuIds(menuId, menu.children, [...parentIds, menu.id])
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 处理菜单展开/收起
|
||||
const handleOpenChange = (keys) => {
|
||||
console.log('菜单展开状态变化:', keys)
|
||||
openKeys.value = keys
|
||||
}
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }) => {
|
||||
console.log('菜单点击:', key)
|
||||
|
||||
// 检查key是否为null或undefined
|
||||
if (!key) {
|
||||
console.warn('菜单点击事件中key为空:', key)
|
||||
return
|
||||
}
|
||||
|
||||
// 直接使用key作为ID,不再需要处理前缀
|
||||
const actualKey = key.toString()
|
||||
console.log('实际查找的key:', actualKey)
|
||||
|
||||
// 查找菜单项
|
||||
const findMenuById = (menus, id) => {
|
||||
for (const menu of menus) {
|
||||
// 检查menu和menu.id是否存在
|
||||
if (menu && menu.id != null && menu.id.toString() === id) {
|
||||
return menu
|
||||
}
|
||||
if (menu && menu.children) {
|
||||
const found = findMenuById(menu.children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const menuItem = findMenuById(menuTree.value, actualKey)
|
||||
if (menuItem && menuItem.path) {
|
||||
console.log('导航到:', menuItem.path)
|
||||
router.push(menuItem.path)
|
||||
emit('menu-click', menuItem)
|
||||
} else {
|
||||
console.warn('未找到菜单项:', actualKey)
|
||||
console.log('当前菜单树:', menuTree.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取默认菜单(作为后备)
|
||||
const getDefaultMenus = () => {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
name: '系统概览',
|
||||
path: '/dashboard',
|
||||
icon: 'DashboardOutlined',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '智能设备',
|
||||
path: '/smart-devices',
|
||||
icon: 'SettingOutlined',
|
||||
children: [
|
||||
{
|
||||
id: 21,
|
||||
name: '智能耳标',
|
||||
path: '/smart-devices/eartag',
|
||||
icon: 'FileOutlined',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
name: '智能项圈',
|
||||
path: '/smart-devices/collar',
|
||||
icon: 'FileOutlined',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 23,
|
||||
name: '智能主机',
|
||||
path: '/smart-devices/host',
|
||||
icon: 'FileOutlined',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 24,
|
||||
name: '电子围栏',
|
||||
path: '/smart-devices/fence',
|
||||
icon: 'FileOutlined',
|
||||
children: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '智能预警总览',
|
||||
path: '/smart-alerts',
|
||||
icon: 'AlertOutlined',
|
||||
children: [
|
||||
{
|
||||
id: 31,
|
||||
name: '智能耳标预警',
|
||||
path: '/smart-alerts/eartag',
|
||||
icon: 'FileOutlined',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 32,
|
||||
name: '智能项圈预警',
|
||||
path: '/smart-alerts/collar',
|
||||
icon: 'FileOutlined',
|
||||
children: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '牛只管理',
|
||||
path: '/cattle-management',
|
||||
icon: 'BugOutlined',
|
||||
children: [
|
||||
{
|
||||
id: 41,
|
||||
name: '牛只档案',
|
||||
path: '/cattle-management/archives',
|
||||
icon: 'FileOutlined',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 42,
|
||||
name: '栏舍设置',
|
||||
path: '/cattle-management/pens',
|
||||
icon: 'FileOutlined',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 43,
|
||||
name: '批次设置',
|
||||
path: '/cattle-management/batches',
|
||||
icon: 'FileOutlined',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 44,
|
||||
name: '转栏记录',
|
||||
path: '/cattle-management/transfer-records',
|
||||
icon: 'FileOutlined',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 45,
|
||||
name: '离栏记录',
|
||||
path: '/cattle-management/exit-records',
|
||||
icon: 'FileOutlined',
|
||||
children: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '养殖场管理',
|
||||
path: '/farm-management',
|
||||
icon: 'HomeOutlined',
|
||||
children: [
|
||||
{
|
||||
id: 51,
|
||||
name: '养殖场信息管理',
|
||||
path: '/farm-management/info',
|
||||
icon: 'FileOutlined',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 52,
|
||||
name: '栏舍管理',
|
||||
path: '/farm-management/pens',
|
||||
icon: 'FileOutlined',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 53,
|
||||
name: '用户管理',
|
||||
path: '/farm-management/users',
|
||||
icon: 'FileOutlined',
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 54,
|
||||
name: '角色权限管理',
|
||||
path: '/farm-management/role-permissions',
|
||||
icon: 'SafetyOutlined',
|
||||
children: []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: '系统管理',
|
||||
path: '/system',
|
||||
icon: 'SettingOutlined',
|
||||
children: []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 监听路由变化
|
||||
watch(() => route.path, () => {
|
||||
setActiveMenu()
|
||||
})
|
||||
|
||||
// 监听用户数据变化,当用户数据加载完成后再加载菜单
|
||||
watch(() => userStore.userData, (newUserData) => {
|
||||
console.log('用户数据变化,重新加载菜单:', newUserData)
|
||||
if (newUserData && newUserData.id) {
|
||||
loadMenus()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 组件挂载时也尝试加载菜单(作为后备)
|
||||
onMounted(() => {
|
||||
console.log('DynamicMenu组件挂载,当前用户数据:', userStore.userData)
|
||||
if (userStore.userData && userStore.userData.id) {
|
||||
loadMenus()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ant-menu {
|
||||
border-right: none;
|
||||
}
|
||||
</style>
|
||||
172
admin-system/src/components/EChart.vue
Normal file
172
admin-system/src/components/EChart.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="echart-container" ref="chartContainer" :style="{ height: height }"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import { createChart, handleResize, disposeChart, DataCache } from '../utils/chartService'
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
// 图表选项
|
||||
options: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
// 图表高度
|
||||
height: {
|
||||
type: String,
|
||||
default: '300px'
|
||||
},
|
||||
// 自动调整大小
|
||||
autoResize: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 缓存键
|
||||
cacheKey: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
// 启用缓存
|
||||
enableCache: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 缓存过期时间(毫秒)
|
||||
cacheTTL: {
|
||||
type: Number,
|
||||
default: 5 * 60 * 1000 // 5分钟
|
||||
}
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['chart-ready', 'chart-click'])
|
||||
|
||||
// 图表容器引用
|
||||
const chartContainer = ref(null)
|
||||
|
||||
// 图表实例
|
||||
let chartInstance = null
|
||||
|
||||
// 计算缓存键
|
||||
const computedCacheKey = computed(() => {
|
||||
if (!props.enableCache || !props.cacheKey) return null
|
||||
return `chart_${props.cacheKey}_${JSON.stringify(props.options).slice(0, 100)}`
|
||||
})
|
||||
|
||||
// 初始化图表
|
||||
function initChart() {
|
||||
if (!chartContainer.value) return
|
||||
|
||||
// 检查缓存
|
||||
if (props.enableCache && computedCacheKey.value) {
|
||||
const cachedData = DataCache.get(computedCacheKey.value)
|
||||
if (cachedData) {
|
||||
console.log('使用缓存的图表数据:', computedCacheKey.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建图表实例(使用缓存键)
|
||||
chartInstance = createChart(
|
||||
chartContainer.value,
|
||||
props.options,
|
||||
computedCacheKey.value
|
||||
)
|
||||
|
||||
// 添加点击事件监听
|
||||
chartInstance.on('click', (params) => {
|
||||
emit('chart-click', params)
|
||||
})
|
||||
|
||||
// 缓存图表配置
|
||||
if (props.enableCache && computedCacheKey.value) {
|
||||
DataCache.set(computedCacheKey.value, props.options, props.cacheTTL)
|
||||
}
|
||||
|
||||
// 触发图表就绪事件
|
||||
emit('chart-ready', chartInstance)
|
||||
}
|
||||
|
||||
// 更新图表选项(优化版本)
|
||||
function updateChart() {
|
||||
if (chartInstance) {
|
||||
// 使用notMerge=false和lazyUpdate=true来优化性能
|
||||
chartInstance.setOption(props.options, false, true)
|
||||
|
||||
// 更新缓存
|
||||
if (props.enableCache && computedCacheKey.value) {
|
||||
DataCache.set(computedCacheKey.value, props.options, props.cacheTTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 防抖更新函数
|
||||
let updateTimer = null
|
||||
function debouncedUpdate() {
|
||||
if (updateTimer) {
|
||||
clearTimeout(updateTimer)
|
||||
}
|
||||
updateTimer = setTimeout(() => {
|
||||
updateChart()
|
||||
}, 100) // 100ms防抖
|
||||
}
|
||||
|
||||
// 监听选项变化(优化版本)
|
||||
watch(() => props.options, (newOptions, oldOptions) => {
|
||||
if (chartInstance) {
|
||||
// 深度比较,避免不必要的更新
|
||||
const optionsChanged = JSON.stringify(newOptions) !== JSON.stringify(oldOptions)
|
||||
if (optionsChanged) {
|
||||
debouncedUpdate()
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 处理窗口大小变化
|
||||
function onResize() {
|
||||
handleResize(chartInstance)
|
||||
}
|
||||
|
||||
// 组件挂载时初始化图表
|
||||
onMounted(() => {
|
||||
initChart()
|
||||
|
||||
// 添加窗口大小变化监听
|
||||
if (props.autoResize) {
|
||||
window.addEventListener('resize', onResize)
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时清理资源
|
||||
onUnmounted(() => {
|
||||
// 清理防抖定时器
|
||||
if (updateTimer) {
|
||||
clearTimeout(updateTimer)
|
||||
updateTimer = null
|
||||
}
|
||||
|
||||
// 移除窗口大小变化监听
|
||||
if (props.autoResize) {
|
||||
window.removeEventListener('resize', onResize)
|
||||
}
|
||||
|
||||
// 销毁图表实例(传入缓存键以清理相关缓存)
|
||||
disposeChart(chartInstance, computedCacheKey.value)
|
||||
chartInstance = null
|
||||
})
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
getChartInstance: () => chartInstance,
|
||||
resize: () => handleResize(chartInstance)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.echart-container {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
background-color: #fff;
|
||||
}
|
||||
</style>
|
||||
174
admin-system/src/components/FarmDetail.vue
Normal file
174
admin-system/src/components/FarmDetail.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="farm-detail">
|
||||
<a-descriptions :title="farm.name" bordered :column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }">
|
||||
<a-descriptions-item label="养殖场ID">{{ farm.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="动物数量">{{ farm.animalCount || '未知' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="位置坐标">
|
||||
{{ farm.location ? `${farm.location.lat.toFixed(4)}, ${farm.location.lng.toFixed(4)}` : '未知' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="设备数量">
|
||||
{{ farmDevices.length }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="预警数量">
|
||||
<a-badge :count="farmAlerts.length" :number-style="{ backgroundColor: farmAlerts.length > 0 ? '#f5222d' : '#52c41a' }" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">
|
||||
{{ farm.createdAt ? new Date(farm.createdAt).toLocaleString() : '未知' }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider orientation="left">设备状态</a-divider>
|
||||
<a-table
|
||||
:dataSource="farmDevices"
|
||||
:columns="deviceColumns"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
size="small"
|
||||
:loading="loading.devices"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'online' ? 'green' : 'red'">
|
||||
{{ record.status === 'online' ? '在线' : '离线' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-divider orientation="left">预警信息</a-divider>
|
||||
<a-table
|
||||
:dataSource="farmAlerts"
|
||||
:columns="alertColumns"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
size="small"
|
||||
:loading="loading.alerts"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'level'">
|
||||
<a-tag :color="getAlertLevelColor(record.level)">
|
||||
{{ getAlertLevelText(record.level) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'created_at'">
|
||||
{{ new Date(record.created_at).toLocaleString() }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useDataStore } from '../stores'
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
// 养殖场ID
|
||||
farmId: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// 数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 加载状态
|
||||
const loading = ref({
|
||||
devices: false,
|
||||
alerts: false
|
||||
})
|
||||
|
||||
// 获取养殖场信息
|
||||
const farm = computed(() => {
|
||||
return dataStore.farms.find(f => f.id == props.farmId) || {}
|
||||
})
|
||||
|
||||
// 获取养殖场设备
|
||||
const farmDevices = computed(() => {
|
||||
return dataStore.devices.filter(d => d.farm_id == props.farmId)
|
||||
})
|
||||
|
||||
// 获取养殖场预警
|
||||
const farmAlerts = computed(() => {
|
||||
return dataStore.alerts.filter(a => a.farm_id == props.farmId)
|
||||
})
|
||||
|
||||
// 设备表格列定义
|
||||
const deviceColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
}
|
||||
]
|
||||
|
||||
// 预警表格列定义
|
||||
const alertColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
},
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'level',
|
||||
key: 'level',
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
}
|
||||
]
|
||||
|
||||
// 获取预警级别颜色
|
||||
function getAlertLevelColor(level) {
|
||||
switch(level) {
|
||||
case 'high': return 'red'
|
||||
case 'medium': return 'orange'
|
||||
case 'low': return 'blue'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取预警级别文本
|
||||
function getAlertLevelText(level) {
|
||||
switch(level) {
|
||||
case 'high': return '高'
|
||||
case 'medium': return '中'
|
||||
case 'low': return '低'
|
||||
default: return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
// 监听养殖场ID变化,确保数据已加载
|
||||
watch(() => props.farmId, async (newId) => {
|
||||
if (newId) {
|
||||
// 确保数据已加载
|
||||
if (dataStore.farms.length === 0) {
|
||||
await dataStore.fetchAllData()
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.farm-detail {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
180
admin-system/src/components/Menu.vue
Normal file
180
admin-system/src/components/Menu.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<a-menu
|
||||
mode="inline"
|
||||
:selected-keys="selectedKeys"
|
||||
:open-keys="openKeys"
|
||||
@openChange="handleOpenChange"
|
||||
:style="{ height: '100%', borderRight: 0 }"
|
||||
>
|
||||
<template v-for="route in menuRoutes">
|
||||
<!-- 有子菜单的路由 -->
|
||||
<a-sub-menu v-if="route.children && route.children.length > 0" :key="route.name">
|
||||
<template #title>
|
||||
<component :is="route.meta.icon" />
|
||||
<span>{{ route.meta.title }}</span>
|
||||
</template>
|
||||
<a-menu-item v-for="child in route.children" :key="child.name">
|
||||
<router-link :to="child.path">
|
||||
<span>{{ child.meta.title }}</span>
|
||||
</router-link>
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
|
||||
<!-- 普通菜单项 -->
|
||||
<a-menu-item v-else :key="`menu-${route.name}`">
|
||||
<router-link :to="route.path">
|
||||
<component :is="route.meta.icon" />
|
||||
<span>{{ route.meta.title }}</span>
|
||||
</router-link>
|
||||
</a-menu-item>
|
||||
</template>
|
||||
</a-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import * as Icons from '@ant-design/icons-vue'
|
||||
import { setupGlobalDebugger, debugMenuPermissions } from '../utils/menuDebugger'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 菜单状态
|
||||
const openKeys = ref([])
|
||||
|
||||
// 检查路由权限
|
||||
const hasRoutePermission = (routeItem) => {
|
||||
console.log('检查路由权限:', routeItem.name, routeItem.meta)
|
||||
console.log('用户数据:', userStore.userData)
|
||||
console.log('用户权限:', userStore.getUserPermissions())
|
||||
console.log('用户角色:', userStore.getUserRoleName())
|
||||
|
||||
// 检查权限
|
||||
if (routeItem.meta?.permission) {
|
||||
const hasPerm = userStore.hasPermission(routeItem.meta.permission)
|
||||
console.log('权限检查结果:', routeItem.meta.permission, hasPerm)
|
||||
return hasPerm
|
||||
}
|
||||
|
||||
// 检查角色权限
|
||||
if (routeItem.meta?.roles && routeItem.meta.roles.length > 0) {
|
||||
const hasRole = userStore.hasRole(routeItem.meta.roles)
|
||||
console.log('角色检查结果:', routeItem.meta.roles, hasRole)
|
||||
return hasRole
|
||||
}
|
||||
|
||||
// 检查菜单权限
|
||||
if (routeItem.meta?.menu) {
|
||||
const canAccess = userStore.canAccessMenu(routeItem.meta.menu)
|
||||
console.log('菜单权限检查结果:', routeItem.meta.menu, canAccess)
|
||||
return canAccess
|
||||
}
|
||||
|
||||
console.log('无权限要求,允许访问')
|
||||
return true
|
||||
}
|
||||
|
||||
// 获取所有需要在菜单中显示的路由
|
||||
const menuRoutes = computed(() => {
|
||||
console.log('所有路由:', router.options.routes)
|
||||
|
||||
const filteredRoutes = router.options.routes
|
||||
.filter(route => {
|
||||
console.log('检查路由:', route.name, route.meta)
|
||||
|
||||
// 只显示需要认证的路由,主路由需要图标,子路由不需要
|
||||
if (!route.meta || !route.meta.requiresAuth) {
|
||||
console.log('路由被过滤 - 无meta/requiresAuth:', route.name)
|
||||
return false
|
||||
}
|
||||
|
||||
// 主路由必须有图标,子路由不需要
|
||||
if (!route.meta.icon && (!route.children || route.children.length === 0)) {
|
||||
console.log('路由被过滤 - 主路由无图标:', route.name)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查主路由权限 - 优先检查菜单权限
|
||||
if (route.meta?.menu) {
|
||||
const canAccess = userStore.canAccessMenu(route.meta.menu)
|
||||
console.log(`菜单权限检查 ${route.name} (${route.meta.menu}):`, canAccess)
|
||||
if (!canAccess) {
|
||||
console.log('路由被过滤 - 无菜单权限:', route.name)
|
||||
return false
|
||||
}
|
||||
} else if (!hasRoutePermission(route)) {
|
||||
console.log('路由被过滤 - 无权限:', route.name)
|
||||
return false
|
||||
}
|
||||
|
||||
console.log('路由通过过滤:', route.name)
|
||||
return true
|
||||
})
|
||||
.map(route => {
|
||||
// 如果有子路由,过滤有权限的子路由
|
||||
if (route.children && route.children.length > 0) {
|
||||
console.log('处理子路由:', route.name, route.children)
|
||||
const accessibleChildren = route.children.filter(child => {
|
||||
const hasAccess = child.meta && hasRoutePermission(child)
|
||||
console.log('子路由权限检查:', child.name, hasAccess)
|
||||
return hasAccess
|
||||
})
|
||||
|
||||
console.log('可访问的子路由:', route.name, accessibleChildren)
|
||||
return {
|
||||
...route,
|
||||
children: accessibleChildren
|
||||
}
|
||||
}
|
||||
|
||||
return route
|
||||
})
|
||||
.filter(route => {
|
||||
// 如果是子菜单,至少要有一个可访问的子项
|
||||
if (route.children && route.children.length === 0) {
|
||||
console.log('子菜单无子项,过滤掉:', route.name)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
console.log('最终菜单路由:', filteredRoutes)
|
||||
return filteredRoutes
|
||||
})
|
||||
|
||||
// 当前选中的菜单项
|
||||
const selectedKeys = computed(() => {
|
||||
const currentRoute = route.name
|
||||
return [currentRoute]
|
||||
})
|
||||
|
||||
// 监听路由变化,自动展开对应的子菜单
|
||||
watch(() => route.name, (newRouteName) => {
|
||||
const currentRoute = router.options.routes.find(r =>
|
||||
r.children && r.children.some(child => child.name === newRouteName)
|
||||
)
|
||||
|
||||
if (currentRoute && !openKeys.value.includes(currentRoute.name)) {
|
||||
openKeys.value = [...openKeys.value, currentRoute.name]
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 处理子菜单展开/收起
|
||||
const handleOpenChange = (keys) => {
|
||||
openKeys.value = keys
|
||||
}
|
||||
|
||||
// 组件挂载时设置调试工具
|
||||
onMounted(() => {
|
||||
setupGlobalDebugger()
|
||||
|
||||
// 调试菜单权限
|
||||
if (userStore.userData) {
|
||||
console.log('🔍 开始调试菜单权限...')
|
||||
debugMenuPermissions(userStore.userData, router.options.routes)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
476
admin-system/src/components/MobileNav.vue
Normal file
476
admin-system/src/components/MobileNav.vue
Normal file
@@ -0,0 +1,476 @@
|
||||
<template>
|
||||
<div class="mobile-nav">
|
||||
<!-- 移动端头部 -->
|
||||
<div class="mobile-header">
|
||||
<button
|
||||
class="mobile-menu-button"
|
||||
@click="toggleSidebar"
|
||||
:aria-label="sidebarVisible ? '关闭菜单' : '打开菜单'"
|
||||
>
|
||||
<MenuOutlined v-if="!sidebarVisible" />
|
||||
<CloseOutlined v-else />
|
||||
</button>
|
||||
|
||||
<div class="mobile-title">
|
||||
{{ currentPageTitle }}
|
||||
</div>
|
||||
|
||||
<div class="mobile-user-info">
|
||||
<a-dropdown>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="profile">
|
||||
<UserOutlined />
|
||||
个人信息
|
||||
</a-menu-item>
|
||||
<a-menu-item key="settings">
|
||||
<SettingOutlined />
|
||||
系统设置
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item key="logout" @click="handleLogout">
|
||||
<LogoutOutlined />
|
||||
退出登录
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
<a-button type="text" size="small">
|
||||
<UserOutlined />
|
||||
</a-button>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端侧边栏遮罩 -->
|
||||
<div
|
||||
v-if="sidebarVisible"
|
||||
class="mobile-sidebar-overlay"
|
||||
@click="closeSidebar"
|
||||
></div>
|
||||
|
||||
<!-- 移动端侧边栏 -->
|
||||
<div
|
||||
class="mobile-sidebar"
|
||||
:class="{ 'sidebar-open': sidebarVisible }"
|
||||
>
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<span class="logo-text">智慧养殖监管平台</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
v-model:openKeys="openKeys"
|
||||
mode="inline"
|
||||
theme="light"
|
||||
@click="handleMenuClick"
|
||||
>
|
||||
<!-- 主要功能模块 -->
|
||||
<a-menu-item-group title="核心功能">
|
||||
<a-menu-item key="/" :icon="h(HomeOutlined)">
|
||||
<router-link to="/">首页</router-link>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="/dashboard" :icon="h(DashboardOutlined)">
|
||||
<router-link to="/dashboard">系统概览</router-link>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="/monitor" :icon="h(LineChartOutlined)">
|
||||
<router-link to="/monitor">实时监控</router-link>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="/analytics" :icon="h(BarChartOutlined)">
|
||||
<router-link to="/analytics">数据分析</router-link>
|
||||
</a-menu-item>
|
||||
</a-menu-item-group>
|
||||
|
||||
<!-- 管理功能模块 -->
|
||||
<a-menu-item-group title="管理功能">
|
||||
<a-menu-item key="/farms" :icon="h(HomeOutlined)">
|
||||
<router-link to="/farms">牛只管理</router-link>
|
||||
</a-menu-item>
|
||||
<a-sub-menu key="cattle-management" :icon="h(BugOutlined)">
|
||||
<template #title>牛只管理</template>
|
||||
<a-menu-item key="/cattle-management/archives">
|
||||
<router-link to="/cattle-management/archives">牛只档案</router-link>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="/cattle-management/pens">
|
||||
<router-link to="/cattle-management/pens">栏舍设置</router-link>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="/cattle-management/batches">
|
||||
<router-link to="/cattle-management/batches">批次设置</router-link>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="/cattle-management/transfer-records">
|
||||
<router-link to="/cattle-management/transfer-records">转栏记录</router-link>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="/cattle-management/exit-records">
|
||||
<router-link to="/cattle-management/exit-records">离栏记录</router-link>
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
<a-menu-item key="/devices" :icon="h(DesktopOutlined)">
|
||||
<router-link to="/devices">设备管理</router-link>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="/alerts" :icon="h(AlertOutlined)">
|
||||
<router-link to="/alerts">预警管理</router-link>
|
||||
</a-menu-item>
|
||||
</a-menu-item-group>
|
||||
|
||||
<!-- 业务功能模块 -->
|
||||
<!-- <a-menu-item-group title="业务功能">
|
||||
<a-menu-item key="/products" :icon="h(ShoppingOutlined)">
|
||||
<router-link to="/products">产品管理</router-link>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="/orders" :icon="h(ShoppingCartOutlined)">
|
||||
<router-link to="/orders">订单管理</router-link>
|
||||
</a-menu-item> -->
|
||||
<!-- <a-menu-item key="/reports" :icon="h(FileTextOutlined)">
|
||||
<router-link to="/reports">报表管理</router-link>
|
||||
</a-menu-item>
|
||||
</a-menu-item-group> -->
|
||||
|
||||
<!-- 系统管理模块 -->
|
||||
<!-- <a-menu-item-group title="系统管理" v-if="userStore.userData?.roles?.includes('admin')">
|
||||
<a-menu-item key="/users" :icon="h(UserOutlined)">
|
||||
<router-link to="/users">用户管理</router-link>
|
||||
</a-menu-item>
|
||||
</a-menu-item-group> -->
|
||||
</a-menu>
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏底部 -->
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-info">
|
||||
<a-avatar size="small" :icon="h(UserOutlined)" />
|
||||
<span class="username">{{ userStore.userData?.username }}</span>
|
||||
</div>
|
||||
<div class="version-info">
|
||||
v2.1.0
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, h } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
MenuOutlined,
|
||||
CloseOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
LogoutOutlined,
|
||||
HomeOutlined,
|
||||
DashboardOutlined,
|
||||
LineChartOutlined,
|
||||
BarChartOutlined,
|
||||
BugOutlined,
|
||||
DesktopOutlined,
|
||||
AlertOutlined,
|
||||
ShoppingOutlined,
|
||||
ShoppingCartOutlined,
|
||||
FileTextOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { useUserStore } from '../stores/user'
|
||||
|
||||
// Store 和路由
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 响应式数据
|
||||
const sidebarVisible = ref(false)
|
||||
const selectedKeys = ref([])
|
||||
const openKeys = ref([])
|
||||
|
||||
// 计算当前页面标题
|
||||
const currentPageTitle = computed(() => {
|
||||
const titleMap = {
|
||||
'/': '首页',
|
||||
'/dashboard': '系统概览',
|
||||
'/monitor': '实时监控',
|
||||
'/analytics': '数据分析',
|
||||
'/farms': '养殖场管理',
|
||||
'/cattle-management/archives': '牛只档案',
|
||||
'/devices': '设备管理',
|
||||
'/alerts': '预警管理',
|
||||
'/products': '产品管理',
|
||||
'/orders': '订单管理',
|
||||
'/reports': '报表管理',
|
||||
'/users': '用户管理'
|
||||
}
|
||||
return titleMap[route.path] || '智慧养殖监管平台'
|
||||
})
|
||||
|
||||
// 监听路由变化,更新选中的菜单项
|
||||
watch(() => route.path, (newPath) => {
|
||||
selectedKeys.value = [newPath]
|
||||
closeSidebar() // 移动端路由跳转后自动关闭侧边栏
|
||||
}, { immediate: true })
|
||||
|
||||
// 切换侧边栏显示状态
|
||||
const toggleSidebar = () => {
|
||||
sidebarVisible.value = !sidebarVisible.value
|
||||
}
|
||||
|
||||
// 关闭侧边栏
|
||||
const closeSidebar = () => {
|
||||
sidebarVisible.value = false
|
||||
}
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }) => {
|
||||
if (key !== route.path) {
|
||||
router.push(key)
|
||||
}
|
||||
closeSidebar()
|
||||
}
|
||||
|
||||
// 处理退出登录
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await userStore.logout()
|
||||
message.success('退出登录成功')
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
console.error('退出登录失败:', error)
|
||||
message.error('退出登录失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露给父组件的方法
|
||||
defineExpose({
|
||||
toggleSidebar,
|
||||
closeSidebar
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mobile-nav {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 移动端头部样式 */
|
||||
.mobile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.mobile-menu-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.mobile-menu-button:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.mobile-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.mobile-user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 侧边栏遮罩 */
|
||||
.mobile-sidebar-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 1001;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 移动端侧边栏 */
|
||||
.mobile-sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 280px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
z-index: 1002;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mobile-sidebar.sidebar-open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* 侧边栏头部 */
|
||||
.sidebar-header {
|
||||
padding: 20px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: linear-gradient(135deg, #1890ff 0%, #722ed1 100%);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 侧边栏内容 */
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
:deep(.ant-menu) {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item-group-title) {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item) {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
padding: 0 16px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item:hover) {
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item-selected) {
|
||||
background: #e6f7ff !important;
|
||||
border-right: 3px solid #1890ff;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item a) {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item-icon) {
|
||||
font-size: 16px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
/* 侧边栏底部 */
|
||||
.sidebar-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.version-info {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 只在移动端显示 */
|
||||
@media (min-width: 769px) {
|
||||
.mobile-nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式断点调整 */
|
||||
@media (max-width: 480px) {
|
||||
.mobile-sidebar {
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.mobile-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 横屏模式调整 */
|
||||
@media (max-width: 768px) and (orientation: landscape) {
|
||||
.mobile-header {
|
||||
padding: 8px 16px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.mobile-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item) {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
266
admin-system/src/components/MonitorChart.vue
Normal file
266
admin-system/src/components/MonitorChart.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="monitor-chart">
|
||||
<div class="chart-header">
|
||||
<h3>{{ title }}</h3>
|
||||
<div class="chart-controls">
|
||||
<a-select
|
||||
v-model="selectedPeriod"
|
||||
style="width: 120px"
|
||||
@change="handlePeriodChange"
|
||||
>
|
||||
<a-select-option value="day">今日</a-select-option>
|
||||
<a-select-option value="week">本周</a-select-option>
|
||||
<a-select-option value="month">本月</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="text" @click="refreshData">
|
||||
<template #icon><reload-outlined /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-content">
|
||||
<e-chart
|
||||
:options="chartOptions"
|
||||
:height="height"
|
||||
:cache-key="cacheKey"
|
||||
:enable-cache="enableCache"
|
||||
:cache-ttl="cacheTTL"
|
||||
@chart-ready="handleChartReady"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import EChart from './EChart.vue'
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
// 图表标题
|
||||
title: {
|
||||
type: String,
|
||||
default: '监控图表'
|
||||
},
|
||||
// 图表类型
|
||||
type: {
|
||||
type: String,
|
||||
default: 'line',
|
||||
validator: (value) => ['line', 'bar', 'pie'].includes(value)
|
||||
},
|
||||
// 图表数据
|
||||
data: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
xAxis: [],
|
||||
series: []
|
||||
})
|
||||
},
|
||||
// 图表高度
|
||||
height: {
|
||||
type: String,
|
||||
default: '300px'
|
||||
},
|
||||
// 自动刷新间隔(毫秒),0表示不自动刷新
|
||||
refreshInterval: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 缓存键
|
||||
cacheKey: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
// 启用缓存
|
||||
enableCache: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 缓存过期时间(毫秒)
|
||||
cacheTTL: {
|
||||
type: Number,
|
||||
default: 5 * 60 * 1000 // 5分钟
|
||||
}
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['refresh', 'period-change'])
|
||||
|
||||
// 选中的时间周期
|
||||
const selectedPeriod = ref('day')
|
||||
|
||||
// 图表实例
|
||||
let chartInstance = null
|
||||
|
||||
// 自动刷新定时器
|
||||
let refreshTimer = null
|
||||
|
||||
// 处理图表就绪事件
|
||||
function handleChartReady(chart) {
|
||||
chartInstance = chart
|
||||
}
|
||||
|
||||
// 处理时间周期变化
|
||||
function handlePeriodChange(period) {
|
||||
emit('period-change', period)
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
function refreshData() {
|
||||
emit('refresh', selectedPeriod.value)
|
||||
}
|
||||
|
||||
// 图表选项
|
||||
const chartOptions = computed(() => {
|
||||
if (props.type === 'line' || props.type === 'bar') {
|
||||
return {
|
||||
title: {
|
||||
text: props.title,
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
legend: {
|
||||
data: props.data.series?.map(item => item.name) || [],
|
||||
bottom: 0
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '10%',
|
||||
top: '15%',
|
||||
containLabel: true
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {}
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: props.type === 'bar',
|
||||
data: props.data.xAxis || []
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: (props.data.series || []).map(item => ({
|
||||
name: item.name,
|
||||
type: props.type,
|
||||
data: item.data,
|
||||
smooth: props.type === 'line',
|
||||
itemStyle: item.itemStyle,
|
||||
lineStyle: item.lineStyle,
|
||||
areaStyle: props.type === 'line' ? item.areaStyle : undefined
|
||||
}))
|
||||
}
|
||||
} else if (props.type === 'pie') {
|
||||
return {
|
||||
title: {
|
||||
text: props.title,
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: props.title,
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: props.data || []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
})
|
||||
|
||||
// 设置自动刷新
|
||||
function setupAutoRefresh() {
|
||||
clearInterval(refreshTimer)
|
||||
|
||||
if (props.refreshInterval > 0) {
|
||||
refreshTimer = setInterval(() => {
|
||||
refreshData()
|
||||
}, props.refreshInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听刷新间隔变化
|
||||
watch(() => props.refreshInterval, () => {
|
||||
setupAutoRefresh()
|
||||
})
|
||||
|
||||
// 组件挂载时设置自动刷新
|
||||
onMounted(() => {
|
||||
setupAutoRefresh()
|
||||
})
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onMounted(() => {
|
||||
clearInterval(refreshTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monitor-chart {
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.chart-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chart-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chart-content {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
75
admin-system/src/components/PermissionGuard.vue
Normal file
75
admin-system/src/components/PermissionGuard.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div v-if="hasAccess">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-else-if="showFallback" class="permission-denied">
|
||||
<a-result
|
||||
status="403"
|
||||
title="权限不足"
|
||||
sub-title="抱歉,您没有访问此功能的权限。"
|
||||
>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="$router.push('/dashboard')">
|
||||
返回首页
|
||||
</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useUserStore } from '../stores/user'
|
||||
|
||||
const props = defineProps({
|
||||
// 需要的权限
|
||||
permission: {
|
||||
type: [String, Array],
|
||||
default: null
|
||||
},
|
||||
// 需要的角色
|
||||
role: {
|
||||
type: [String, Array],
|
||||
default: null
|
||||
},
|
||||
// 菜单键
|
||||
menu: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
// 是否显示无权限时的fallback内容
|
||||
showFallback: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const hasAccess = computed(() => {
|
||||
// 如果指定了权限要求
|
||||
if (props.permission) {
|
||||
return userStore.hasPermission(props.permission)
|
||||
}
|
||||
|
||||
// 如果指定了角色要求
|
||||
if (props.role) {
|
||||
return userStore.hasRole(props.role)
|
||||
}
|
||||
|
||||
// 如果指定了菜单要求
|
||||
if (props.menu) {
|
||||
return userStore.canAccessMenu(props.menu)
|
||||
}
|
||||
|
||||
// 默认允许访问
|
||||
return true
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.permission-denied {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
63
admin-system/src/components/SimpleDeviceTest.vue
Normal file
63
admin-system/src/components/SimpleDeviceTest.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useDataStore } from '../stores'
|
||||
|
||||
const dataStore = useDataStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const devices = computed(() => dataStore.devices)
|
||||
const onlineCount = computed(() => devices.value.filter(d => d.status === 'online').length)
|
||||
const offlineCount = computed(() => devices.value.filter(d => d.status === 'offline').length)
|
||||
const maintenanceCount = computed(() => devices.value.filter(d => d.status === 'maintenance').length)
|
||||
|
||||
async function refreshData() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
console.log('开始获取设备数据...')
|
||||
await dataStore.fetchDevices()
|
||||
console.log('设备数据获取完成:', devices.value.length)
|
||||
} catch (err) {
|
||||
console.error('获取设备数据失败:', err)
|
||||
error.value = err.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('SimpleDeviceTest组件挂载')
|
||||
refreshData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.simple-device-test {
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #40a9ff;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f5f5f5;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
298
admin-system/src/components/VirtualScrollChart.vue
Normal file
298
admin-system/src/components/VirtualScrollChart.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div class="virtual-scroll-chart">
|
||||
<a-card :title="title" :bordered="false">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-input-number
|
||||
v-model:value="pageSize"
|
||||
:min="10"
|
||||
:max="1000"
|
||||
:step="10"
|
||||
size="small"
|
||||
addon-before="每页"
|
||||
addon-after="条"
|
||||
@change="handlePageSizeChange"
|
||||
/>
|
||||
<a-button size="small" @click="refreshData">
|
||||
<template #icon><reload-outlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<!-- 数据统计信息 -->
|
||||
<div class="data-info" v-if="totalCount > 0">
|
||||
<a-space>
|
||||
<a-tag color="blue">总数据: {{ totalCount.toLocaleString() }}</a-tag>
|
||||
<a-tag color="green">当前页: {{ currentPage }}/{{ totalPages }}</a-tag>
|
||||
<a-tag color="orange">显示: {{ visibleData.length }}</a-tag>
|
||||
<a-tag v-if="isVirtualMode" color="purple">虚拟滚动: 开启</a-tag>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 图表容器 -->
|
||||
<div class="chart-wrapper">
|
||||
<e-chart
|
||||
:options="chartOptions"
|
||||
:height="chartHeight"
|
||||
:cache-key="cacheKey"
|
||||
:enable-cache="enableCache"
|
||||
:cache-ttl="cacheTTL"
|
||||
@chart-ready="handleChartReady"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 分页控制 -->
|
||||
<div class="pagination-wrapper" v-if="totalPages > 1">
|
||||
<a-pagination
|
||||
v-model:current="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="totalCount"
|
||||
:show-size-changer="true"
|
||||
:show-quick-jumper="true"
|
||||
:show-total="(total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`"
|
||||
:page-size-options="['10', '20', '50', '100', '200', '500']"
|
||||
@change="handlePageChange"
|
||||
@show-size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div class="loading-wrapper" v-if="loading">
|
||||
<a-spin size="large" tip="加载中..." />
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import EChart from './EChart.vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '大数据量图表'
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
chartType: {
|
||||
type: String,
|
||||
default: 'line',
|
||||
validator: (value) => ['line', 'bar', 'scatter'].includes(value)
|
||||
},
|
||||
chartHeight: {
|
||||
type: String,
|
||||
default: '400px'
|
||||
},
|
||||
virtualThreshold: {
|
||||
type: Number,
|
||||
default: 1000 // 超过1000条数据启用虚拟滚动
|
||||
},
|
||||
enableCache: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
cacheTTL: {
|
||||
type: Number,
|
||||
default: 300000 // 5分钟
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['refresh', 'page-change'])
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(100)
|
||||
const chartInstance = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const totalCount = computed(() => props.data.length)
|
||||
const totalPages = computed(() => Math.ceil(totalCount.value / pageSize.value))
|
||||
const isVirtualMode = computed(() => totalCount.value > props.virtualThreshold)
|
||||
|
||||
// 缓存键
|
||||
const cacheKey = computed(() => {
|
||||
return `virtual_chart_${props.chartType}_${currentPage.value}_${pageSize.value}`
|
||||
})
|
||||
|
||||
// 可见数据(当前页数据)
|
||||
const visibleData = computed(() => {
|
||||
if (!isVirtualMode.value) {
|
||||
return props.data
|
||||
}
|
||||
|
||||
const startIndex = (currentPage.value - 1) * pageSize.value
|
||||
const endIndex = startIndex + pageSize.value
|
||||
return props.data.slice(startIndex, endIndex)
|
||||
})
|
||||
|
||||
// 图表配置
|
||||
const chartOptions = computed(() => {
|
||||
const baseOptions = {
|
||||
title: {
|
||||
text: isVirtualMode.value ? `${props.title} (第${currentPage.value}页)` : props.title,
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
top: 30
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: visibleData.value.map(item => item.name || item.x)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
// 性能优化配置
|
||||
animation: visibleData.value.length > 200 ? false : true,
|
||||
progressive: visibleData.value.length > 500 ? 500 : 0,
|
||||
progressiveThreshold: 1000
|
||||
}
|
||||
|
||||
// 根据图表类型配置series
|
||||
if (props.chartType === 'line') {
|
||||
baseOptions.series = [{
|
||||
name: '数据',
|
||||
type: 'line',
|
||||
data: visibleData.value.map(item => item.value || item.y),
|
||||
smooth: true,
|
||||
symbol: visibleData.value.length > 100 ? 'none' : 'circle',
|
||||
lineStyle: {
|
||||
width: 1
|
||||
}
|
||||
}]
|
||||
} else if (props.chartType === 'bar') {
|
||||
baseOptions.series = [{
|
||||
name: '数据',
|
||||
type: 'bar',
|
||||
data: visibleData.value.map(item => item.value || item.y),
|
||||
itemStyle: {
|
||||
color: '#1890ff'
|
||||
}
|
||||
}]
|
||||
} else if (props.chartType === 'scatter') {
|
||||
baseOptions.series = [{
|
||||
name: '数据',
|
||||
type: 'scatter',
|
||||
data: visibleData.value.map(item => [item.x, item.y]),
|
||||
symbolSize: 6
|
||||
}]
|
||||
}
|
||||
|
||||
return baseOptions
|
||||
})
|
||||
|
||||
// 处理页面变化
|
||||
function handlePageChange(page, size) {
|
||||
currentPage.value = page
|
||||
pageSize.value = size
|
||||
emit('page-change', { page, size })
|
||||
}
|
||||
|
||||
// 处理页面大小变化
|
||||
function handlePageSizeChange(current, size) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
emit('page-change', { page: 1, size })
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
function refreshData() {
|
||||
loading.value = true
|
||||
emit('refresh')
|
||||
|
||||
// 模拟加载延迟
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
message.success('数据刷新完成')
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 图表就绪回调
|
||||
function handleChartReady(chart) {
|
||||
chartInstance.value = chart
|
||||
}
|
||||
|
||||
// 监听数据变化,自动调整页面
|
||||
watch(() => props.data, (newData) => {
|
||||
if (newData.length === 0) {
|
||||
currentPage.value = 1
|
||||
return
|
||||
}
|
||||
|
||||
// 如果当前页超出范围,调整到最后一页
|
||||
const maxPage = Math.ceil(newData.length / pageSize.value)
|
||||
if (currentPage.value > maxPage) {
|
||||
currentPage.value = maxPage
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 组件挂载时的初始化
|
||||
onMounted(() => {
|
||||
if (totalCount.value > props.virtualThreshold) {
|
||||
message.info(`检测到大数据量(${totalCount.value.toLocaleString()}条),已启用虚拟滚动优化`)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.virtual-scroll-chart {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-info {
|
||||
margin-bottom: 16px;
|
||||
padding: 8px 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.loading-wrapper {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
:deep(.ant-card-head-title) {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.ant-pagination) {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
67
admin-system/src/config/env.js
Normal file
67
admin-system/src/config/env.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 环境配置文件
|
||||
* 包含各种API密钥和环境变量
|
||||
*/
|
||||
|
||||
// 百度地图API配置
|
||||
export const BAIDU_MAP_CONFIG = {
|
||||
// 百度地图API密钥
|
||||
// 从环境变量读取API密钥,如果没有则使用开发测试密钥
|
||||
// 生产环境请设置 VITE_BAIDU_MAP_API_KEY 环境变量
|
||||
// 请访问 http://lbsyun.baidu.com/apiconsole/key 申请有效的API密钥
|
||||
apiKey: import.meta.env.VITE_BAIDU_MAP_API_KEY || 'SOawZTeQbxdgrKYYx0o2hn34G0DyU2uo',
|
||||
|
||||
// 备用API密钥(用于不同环境)
|
||||
fallbackApiKey: import.meta.env.VITE_BAIDU_MAP_FALLBACK_KEY || 'SOawZTeQbxdgrKYYx0o2hn34G0DyU2uo',
|
||||
|
||||
// 是否启用Referer校验(开发环境建议关闭)
|
||||
enableRefererCheck: import.meta.env.VITE_BAIDU_MAP_ENABLE_REFERER !== 'false',
|
||||
|
||||
// 默认中心点(宁夏中心位置)
|
||||
defaultCenter: {
|
||||
lng: 106.27,
|
||||
lat: 38.47
|
||||
},
|
||||
|
||||
// 默认缩放级别
|
||||
defaultZoom: 8,
|
||||
|
||||
// 重试次数
|
||||
maxRetries: 2,
|
||||
|
||||
// 重试延迟(毫秒)
|
||||
retryDelay: 2000
|
||||
};
|
||||
|
||||
// API服务配置
|
||||
export const API_CONFIG = {
|
||||
// API基础URL - 支持环境变量配置
|
||||
baseUrl: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
|
||||
// 完整API URL - 用于直接调用
|
||||
fullBaseUrl: import.meta.env.VITE_API_FULL_URL || 'http://localhost:5350/api',
|
||||
|
||||
// 请求超时时间(毫秒)
|
||||
timeout: parseInt(import.meta.env.VITE_API_TIMEOUT) || 10000,
|
||||
|
||||
// 是否启用代理
|
||||
useProxy: import.meta.env.VITE_USE_PROXY !== 'false',
|
||||
|
||||
// 开发环境配置
|
||||
isDev: import.meta.env.DEV,
|
||||
|
||||
// 生产环境配置
|
||||
isProd: import.meta.env.PROD
|
||||
};
|
||||
|
||||
// 其他环境配置
|
||||
export const APP_CONFIG = {
|
||||
// 应用名称
|
||||
appName: '宁夏智慧养殖监管平台',
|
||||
|
||||
// 版本号
|
||||
version: '1.0.0',
|
||||
|
||||
// 是否为开发环境
|
||||
isDev: process.env.NODE_ENV === 'development'
|
||||
};
|
||||
33
admin-system/src/main.js
Normal file
33
admin-system/src/main.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { createPinia } from 'pinia'
|
||||
import Antd from 'ant-design-vue'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import { themeConfig } from './styles/theme.js'
|
||||
import './styles/global.css'
|
||||
import './styles/responsive.css'
|
||||
import { useUserStore } from './stores/user.js'
|
||||
|
||||
// 导入图标组件
|
||||
import * as Icons from '@ant-design/icons-vue'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
// 注册所有图标组件
|
||||
Object.keys(Icons).forEach(key => {
|
||||
app.component(key, Icons[key])
|
||||
})
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(Antd, {
|
||||
theme: themeConfig
|
||||
})
|
||||
|
||||
// 在应用挂载前初始化用户登录状态
|
||||
const userStore = useUserStore()
|
||||
userStore.checkLoginStatus()
|
||||
|
||||
app.mount('#app')
|
||||
91
admin-system/src/router/history.js
Normal file
91
admin-system/src/router/history.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 路由历史记录服务
|
||||
* 用于跟踪和管理用户的导航历史
|
||||
*/
|
||||
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
// 最大历史记录数量
|
||||
const MAX_HISTORY = 10
|
||||
|
||||
// 创建历史记录服务
|
||||
export function useRouteHistory() {
|
||||
// 路由实例
|
||||
const router = useRouter()
|
||||
|
||||
// 历史记录
|
||||
const history = ref([])
|
||||
|
||||
// 监听路由变化
|
||||
watch(
|
||||
() => router.currentRoute.value,
|
||||
(route) => {
|
||||
// 只记录需要认证的路由
|
||||
if (route.meta.requiresAuth) {
|
||||
// 添加到历史记录
|
||||
addToHistory({
|
||||
name: route.name,
|
||||
path: route.path,
|
||||
title: route.meta.title,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 添加到历史记录
|
||||
function addToHistory(item) {
|
||||
// 检查是否已存在相同路径
|
||||
const index = history.value.findIndex(h => h.path === item.path)
|
||||
|
||||
// 如果已存在,则移除
|
||||
if (index !== -1) {
|
||||
history.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 添加到历史记录开头
|
||||
history.value.unshift(item)
|
||||
|
||||
// 限制历史记录数量
|
||||
if (history.value.length > MAX_HISTORY) {
|
||||
history.value = history.value.slice(0, MAX_HISTORY)
|
||||
}
|
||||
|
||||
// 保存到本地存储
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
// 清除历史记录
|
||||
function clearHistory() {
|
||||
history.value = []
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
// 保存历史记录到本地存储
|
||||
function saveHistory() {
|
||||
localStorage.setItem('routeHistory', JSON.stringify(history.value))
|
||||
}
|
||||
|
||||
// 加载历史记录
|
||||
function loadHistory() {
|
||||
try {
|
||||
const saved = localStorage.getItem('routeHistory')
|
||||
if (saved) {
|
||||
history.value = JSON.parse(saved)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载历史记录失败:', error)
|
||||
clearHistory()
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
loadHistory()
|
||||
|
||||
return {
|
||||
history,
|
||||
clearHistory
|
||||
}
|
||||
}
|
||||
64
admin-system/src/router/index.js
Normal file
64
admin-system/src/router/index.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '../stores'
|
||||
import routes from './routes'
|
||||
|
||||
// 创建路由实例
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
// 如果有保存的位置,则恢复到保存的位置
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
}
|
||||
// 否则滚动到顶部
|
||||
return { top: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
// 全局前置守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 设置页面标题
|
||||
document.title = to.meta.title ? `${to.meta.title} - 宁夏智慧养殖监管平台` : '宁夏智慧养殖监管平台'
|
||||
|
||||
// 获取用户存储
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 处理旧版本路由重定向
|
||||
if (to.path === '/admin' || to.path === '/admin/') {
|
||||
next('/dashboard')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果访问登录页面且已有有效token,重定向到仪表盘
|
||||
if (to.path === '/login' && userStore.token && userStore.isLoggedIn) {
|
||||
const redirectPath = to.query.redirect || '/dashboard'
|
||||
next(redirectPath)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查该路由是否需要登录权限
|
||||
if (to.meta.requiresAuth) {
|
||||
// 如果需要登录但用户未登录,则重定向到登录页面
|
||||
if (!userStore.isLoggedIn) {
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath } // 保存原本要访问的路径,以便登录后重定向
|
||||
})
|
||||
} else {
|
||||
// 用户已登录,允许访问
|
||||
next()
|
||||
}
|
||||
} else {
|
||||
// 不需要登录权限的路由,直接访问
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
// 全局后置钩子
|
||||
router.afterEach((to, from) => {
|
||||
// 路由切换后的逻辑,如记录访问历史、分析等
|
||||
console.log(`路由从 ${from.path} 切换到 ${to.path}`)
|
||||
})
|
||||
|
||||
export default router
|
||||
383
admin-system/src/router/routes.js
Normal file
383
admin-system/src/router/routes.js
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* 路由配置模块
|
||||
* 用于集中管理应用的路由配置
|
||||
*/
|
||||
|
||||
// 主布局路由
|
||||
export const mainRoutes = [
|
||||
// 根路径重定向到仪表盘
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard',
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
// 确保 /admin/ 路径也重定向到仪表盘(兼容旧版本)
|
||||
{
|
||||
path: '/admin/',
|
||||
redirect: '/dashboard',
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
// 确保 /admin 路径也重定向到仪表盘(兼容旧版本)
|
||||
{
|
||||
path: '/admin',
|
||||
redirect: '/dashboard',
|
||||
meta: {
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/Dashboard.vue'),
|
||||
meta: {
|
||||
title: '系统概览',
|
||||
requiresAuth: true,
|
||||
icon: 'dashboard-outlined'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/smart-devices',
|
||||
name: 'SmartDevices',
|
||||
redirect: '/smart-devices/eartag',
|
||||
meta: {
|
||||
title: '智能设备',
|
||||
requiresAuth: true,
|
||||
icon: 'SettingOutlined',
|
||||
permission: 'smart_device:view',
|
||||
menu: 'smart_device.main',
|
||||
isSubmenu: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/smart-devices/eartag',
|
||||
name: 'SmartEartag',
|
||||
component: () => import('../views/SmartEartag.vue'),
|
||||
meta: {
|
||||
title: '智能耳标',
|
||||
requiresAuth: true,
|
||||
permission: 'smart_eartag:view',
|
||||
menu: 'smart_device.eartag',
|
||||
parent: 'SmartDevices'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
path: '/smart-devices/collar',
|
||||
name: 'SmartCollar',
|
||||
component: () => import('../views/SmartCollar.vue'),
|
||||
meta: {
|
||||
title: '智能项圈',
|
||||
requiresAuth: true,
|
||||
permission: 'smart_collar:view',
|
||||
menu: 'smart_device.collar',
|
||||
parent: 'SmartDevices'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/smart-devices/host',
|
||||
name: 'SmartHost',
|
||||
component: () => import('../views/SmartHost.vue'),
|
||||
meta: {
|
||||
title: '智能主机',
|
||||
requiresAuth: true,
|
||||
permission: 'smart_host:view',
|
||||
menu: 'smart_device.host',
|
||||
parent: 'SmartDevices'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/smart-devices/fence',
|
||||
name: 'ElectronicFence',
|
||||
component: () => import('../views/ElectronicFence.vue'),
|
||||
meta: {
|
||||
title: '电子围栏',
|
||||
requiresAuth: true,
|
||||
permission: 'smart_fence:view',
|
||||
menu: 'smart_device.fence',
|
||||
parent: 'SmartDevices'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/smart-alerts',
|
||||
name: 'SmartAlerts',
|
||||
redirect: '/smart-alerts/eartag',
|
||||
meta: {
|
||||
title: '智能预警总览',
|
||||
requiresAuth: true,
|
||||
icon: 'AlertOutlined',
|
||||
permission: 'smart_alert:view',
|
||||
menu: 'smart_alert.main',
|
||||
isSubmenu: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/smart-alerts/eartag',
|
||||
name: 'SmartEartagAlert',
|
||||
component: () => import('../views/SmartEartagAlert.vue'),
|
||||
meta: {
|
||||
title: '智能耳标预警',
|
||||
requiresAuth: true,
|
||||
permission: 'smart_eartag_alert:view',
|
||||
menu: 'smart_alert.eartag',
|
||||
parent: 'SmartAlerts'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/smart-alerts/collar',
|
||||
name: 'SmartCollarAlert',
|
||||
component: () => import('../views/SmartCollarAlert.vue'),
|
||||
meta: {
|
||||
title: '智能项圈预警',
|
||||
requiresAuth: true,
|
||||
permission: 'smart_collar_alert:view',
|
||||
menu: 'smart_alert.collar',
|
||||
parent: 'SmartAlerts'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/cattle-management',
|
||||
name: 'CattleManagement',
|
||||
redirect: '/cattle-management/archives',
|
||||
meta: {
|
||||
title: '牛只管理',
|
||||
requiresAuth: true,
|
||||
icon: 'BugOutlined',
|
||||
permission: 'animal:view',
|
||||
menu: 'animal.management',
|
||||
isSubmenu: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/cattle-management/archives',
|
||||
name: 'CattleArchives',
|
||||
component: () => import('../views/Animals.vue'),
|
||||
meta: {
|
||||
title: '牛只档案',
|
||||
requiresAuth: true,
|
||||
permission: 'cattle:archives:view',
|
||||
menu: 'cattle.archives',
|
||||
parent: 'CattleManagement'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/cattle-management/pens',
|
||||
name: 'CattlePens',
|
||||
component: () => import('../views/CattlePens.vue'),
|
||||
meta: {
|
||||
title: '栏舍设置',
|
||||
requiresAuth: true,
|
||||
permission: 'cattle:pens:view',
|
||||
menu: 'cattle.pens',
|
||||
parent: 'CattleManagement'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/cattle-management/batches',
|
||||
name: 'CattleBatches',
|
||||
component: () => import('../views/CattleBatches.vue'),
|
||||
meta: {
|
||||
title: '批次设置',
|
||||
requiresAuth: true,
|
||||
permission: 'cattle:batches:view',
|
||||
menu: 'cattle.batches',
|
||||
parent: 'CattleManagement'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/cattle-management/transfer-records',
|
||||
name: 'CattleTransferRecords',
|
||||
component: () => import('../views/CattleTransferRecords.vue'),
|
||||
meta: {
|
||||
title: '转栏记录',
|
||||
requiresAuth: true,
|
||||
permission: 'cattle:transfer:view',
|
||||
menu: 'cattle.transfer',
|
||||
parent: 'CattleManagement'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/cattle-management/exit-records',
|
||||
name: 'CattleExitRecords',
|
||||
component: () => import('../views/CattleExitRecords.vue'),
|
||||
meta: {
|
||||
title: '离栏记录',
|
||||
requiresAuth: true,
|
||||
permission: 'cattle:exit:view',
|
||||
menu: 'cattle.exit',
|
||||
parent: 'CattleManagement'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
path: '/farm-management',
|
||||
name: 'FarmManagement',
|
||||
redirect: '/farm-management/info',
|
||||
meta: {
|
||||
title: '养殖场管理',
|
||||
requiresAuth: true,
|
||||
icon: 'HomeOutlined',
|
||||
permission: 'farm:view',
|
||||
menu: 'farm.main',
|
||||
isSubmenu: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/farm-management/info',
|
||||
name: 'FarmInfoManagement',
|
||||
component: () => import('../views/FarmInfoManagement.vue'),
|
||||
meta: {
|
||||
title: '养殖场信息管理',
|
||||
requiresAuth: true,
|
||||
permission: 'farm_info:view',
|
||||
menu: 'farm.info',
|
||||
parent: 'FarmManagement'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/farm-management/pens',
|
||||
name: 'PenManagement',
|
||||
component: () => import('../views/PenManagement.vue'),
|
||||
meta: {
|
||||
title: '栏舍管理',
|
||||
requiresAuth: true,
|
||||
permission: 'pen:view',
|
||||
menu: 'farm.pens',
|
||||
parent: 'FarmManagement'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/farm-management/users',
|
||||
name: 'Users',
|
||||
component: () => import('../views/Users.vue'),
|
||||
meta: {
|
||||
title: '用户管理',
|
||||
requiresAuth: true,
|
||||
permission: 'user:view',
|
||||
menu: 'farm.users',
|
||||
parent: 'FarmManagement'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/farm-management/role-permissions',
|
||||
name: 'RolePermissions',
|
||||
component: () => import('../views/RolePermissions.vue'),
|
||||
meta: {
|
||||
title: '角色权限管理',
|
||||
requiresAuth: true,
|
||||
permission: 'role:view',
|
||||
menu: 'farm.role_permissions',
|
||||
parent: 'FarmManagement'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
path: '/system',
|
||||
name: 'System',
|
||||
redirect: '/system/config',
|
||||
meta: {
|
||||
title: '系统管理',
|
||||
requiresAuth: true,
|
||||
icon: 'setting-outlined',
|
||||
permission: 'system:view',
|
||||
menu: 'system.main',
|
||||
isSubmenu: true
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/system/config',
|
||||
name: 'SystemConfig',
|
||||
component: () => import('../views/System.vue'),
|
||||
meta: {
|
||||
title: '系统配置',
|
||||
requiresAuth: true,
|
||||
permission: 'system:config',
|
||||
menu: 'system.config',
|
||||
parent: 'System'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/system/logs',
|
||||
name: 'FormLogManagement',
|
||||
component: () => import('../views/FormLogManagement.vue'),
|
||||
meta: {
|
||||
title: '表单日志管理',
|
||||
requiresAuth: true,
|
||||
permission: 'log:view',
|
||||
menu: 'system.logs',
|
||||
parent: 'System'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/system/search-monitor',
|
||||
name: 'SearchMonitor',
|
||||
component: () => import('../views/SearchMonitor.vue'),
|
||||
meta: {
|
||||
title: '搜索监听数据查询',
|
||||
requiresAuth: true,
|
||||
permission: 'log:view',
|
||||
menu: 'system.search_monitor',
|
||||
parent: 'System'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/system/operation-logs',
|
||||
name: 'OperationLogs',
|
||||
component: () => import('../views/OperationLogs.vue'),
|
||||
meta: {
|
||||
title: '操作日志',
|
||||
requiresAuth: true,
|
||||
permission: 'operation_log:view',
|
||||
menu: 'system.operation_logs',
|
||||
parent: 'System'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
]
|
||||
|
||||
// 认证相关路由
|
||||
export const authRoutes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/Login.vue'),
|
||||
meta: {
|
||||
title: '登录',
|
||||
requiresAuth: false,
|
||||
layout: 'blank'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 错误页面路由
|
||||
export const errorRoutes = [
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('../views/NotFound.vue'),
|
||||
meta: {
|
||||
title: '页面未找到',
|
||||
requiresAuth: false
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 导出所有路由
|
||||
export default [
|
||||
...authRoutes,
|
||||
...mainRoutes,
|
||||
...errorRoutes
|
||||
]
|
||||
369
admin-system/src/stores/data.js
Normal file
369
admin-system/src/stores/data.js
Normal file
@@ -0,0 +1,369 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useDataStore = defineStore('data', () => {
|
||||
// 数据状态
|
||||
const farms = ref([])
|
||||
const animals = ref([])
|
||||
const devices = ref([])
|
||||
const alerts = ref([])
|
||||
const pens = ref([])
|
||||
const cattle = ref([])
|
||||
const smartDevices = ref([])
|
||||
const smartAlerts = ref({ totalAlerts: 0, eartagAlerts: 0, collarAlerts: 0 })
|
||||
const stats = ref({
|
||||
farmGrowth: 0,
|
||||
animalGrowth: 0,
|
||||
alertReduction: 0
|
||||
})
|
||||
|
||||
// 加载状态
|
||||
const loading = ref({
|
||||
farms: false,
|
||||
animals: false,
|
||||
devices: false,
|
||||
alerts: false,
|
||||
pens: false,
|
||||
cattle: false,
|
||||
smartDevices: false,
|
||||
smartAlerts: false,
|
||||
stats: false
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const farmCount = computed(() => farms.value.length)
|
||||
const animalCount = computed(() => Array.isArray(cattle.value) ? cattle.value.length : 0) // 使用牛只数据
|
||||
const deviceCount = computed(() => smartAlerts.value?.eartagDevices || 0) // 使用智能设备统计数据
|
||||
const alertCount = computed(() => smartAlerts.value?.eartagAlerts || 0) // 使用智能耳标预警数据
|
||||
const penCount = computed(() => Array.isArray(pens.value) ? pens.value.length : 0)
|
||||
|
||||
// 在线设备数量
|
||||
const onlineDeviceCount = computed(() => {
|
||||
if (!Array.isArray(smartDevices.value)) return 0
|
||||
return smartDevices.value.filter(device => device.state === 1).length
|
||||
})
|
||||
|
||||
// 设备在线率
|
||||
const deviceOnlineRate = computed(() => {
|
||||
if (!Array.isArray(smartDevices.value) || smartDevices.value.length === 0) return 0
|
||||
return (onlineDeviceCount.value / smartDevices.value.length * 100).toFixed(1)
|
||||
})
|
||||
|
||||
// 获取养殖场数据
|
||||
async function fetchFarms() {
|
||||
loading.value.farms = true
|
||||
console.log('开始获取养殖场数据...')
|
||||
|
||||
try {
|
||||
// 导入数据服务
|
||||
const { farmService } = await import('../utils/dataService')
|
||||
console.log('调用 farmService.getAllFarms()...')
|
||||
const data = await farmService.getAllFarms()
|
||||
|
||||
console.log('养殖场API返回数据:', data)
|
||||
|
||||
// farmService.getAllFarms()通过api.get返回的是result.data,直接使用
|
||||
farms.value = data || []
|
||||
console.log('设置farms.value:', farms.value.length, '条记录')
|
||||
} catch (error) {
|
||||
console.error('获取养殖场数据失败:', error)
|
||||
farms.value = []
|
||||
} finally {
|
||||
loading.value.farms = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取动物数据
|
||||
async function fetchAnimals() {
|
||||
loading.value.animals = true
|
||||
try {
|
||||
// 导入数据服务
|
||||
const { animalService } = await import('../utils/dataService')
|
||||
const data = await animalService.getAllAnimals()
|
||||
animals.value = data || []
|
||||
} catch (error) {
|
||||
console.error('获取动物数据失败:', error)
|
||||
animals.value = []
|
||||
} finally {
|
||||
loading.value.animals = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取设备数据
|
||||
async function fetchDevices() {
|
||||
loading.value.devices = true
|
||||
try {
|
||||
// 导入数据服务
|
||||
const { deviceService } = await import('../utils/dataService')
|
||||
const data = await deviceService.getAllDevices()
|
||||
devices.value = data || []
|
||||
} catch (error) {
|
||||
console.error('获取设备数据失败:', error)
|
||||
devices.value = []
|
||||
} finally {
|
||||
loading.value.devices = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取预警数据
|
||||
async function fetchAlerts() {
|
||||
loading.value.alerts = true
|
||||
try {
|
||||
// 导入数据服务
|
||||
const { alertService } = await import('../utils/dataService')
|
||||
const data = await alertService.getAllAlerts()
|
||||
|
||||
// alertService.getAllAlerts()通过api.get返回的是result.data,直接使用
|
||||
alerts.value = data || []
|
||||
} catch (error) {
|
||||
console.error('获取预警数据失败:', error)
|
||||
alerts.value = []
|
||||
} finally {
|
||||
loading.value.alerts = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取栏舍数据(基于cattle_pens表)
|
||||
async function fetchPens() {
|
||||
loading.value.pens = true
|
||||
try {
|
||||
console.log('开始获取栏舍数据(cattle_pens表)...')
|
||||
// 导入数据服务
|
||||
const { penService } = await import('../utils/dataService')
|
||||
const data = await penService.getAllPens()
|
||||
|
||||
console.log('栏舍API返回数据:', data)
|
||||
|
||||
// penService.getAllPens()通过api.get返回的是result.data.list,直接使用
|
||||
pens.value = data || []
|
||||
console.log('设置pens.value:', pens.value.length, '条记录')
|
||||
} catch (error) {
|
||||
console.error('获取栏舍数据失败:', error)
|
||||
pens.value = []
|
||||
} finally {
|
||||
loading.value.pens = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取牛只数据(基于iot_cattle表)
|
||||
async function fetchCattle() {
|
||||
loading.value.cattle = true
|
||||
try {
|
||||
console.log('开始获取牛只数据(iot_cattle表)...')
|
||||
// 导入数据服务
|
||||
const { cattleService } = await import('../utils/dataService')
|
||||
const data = await cattleService.getAllCattle()
|
||||
|
||||
console.log('牛只API返回数据:', data)
|
||||
|
||||
// cattleService.getAllCattle()通过api.get返回的是result.data.list,直接使用
|
||||
cattle.value = data || []
|
||||
console.log('设置cattle.value:', cattle.value.length, '条记录')
|
||||
} catch (error) {
|
||||
console.error('获取牛只数据失败:', error)
|
||||
cattle.value = []
|
||||
} finally {
|
||||
loading.value.cattle = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取智能设备数据(基于iot_jbq_client表)
|
||||
async function fetchSmartDevices() {
|
||||
loading.value.smartDevices = true
|
||||
try {
|
||||
console.log('开始获取智能设备数据(iot_jbq_client表)...')
|
||||
// 导入数据服务
|
||||
const { smartDeviceService } = await import('../utils/dataService')
|
||||
const data = await smartDeviceService.getAllSmartDevices()
|
||||
|
||||
console.log('智能设备API返回数据:', data)
|
||||
|
||||
// 确保smartDevices.value始终是数组
|
||||
if (Array.isArray(data)) {
|
||||
smartDevices.value = data
|
||||
} else if (data && Array.isArray(data.list)) {
|
||||
smartDevices.value = data.list
|
||||
} else {
|
||||
console.warn('智能设备数据格式不正确:', data)
|
||||
smartDevices.value = []
|
||||
}
|
||||
console.log('设置smartDevices.value:', smartDevices.value.length, '条记录')
|
||||
} catch (error) {
|
||||
console.error('获取智能设备数据失败:', error)
|
||||
smartDevices.value = []
|
||||
} finally {
|
||||
loading.value.smartDevices = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取智能预警数据
|
||||
async function fetchSmartAlerts() {
|
||||
loading.value.smartAlerts = true
|
||||
try {
|
||||
console.log('开始获取智能预警数据...')
|
||||
// 导入数据服务
|
||||
const { smartAlertService } = await import('../utils/dataService')
|
||||
console.log('智能预警服务导入成功')
|
||||
const data = await smartAlertService.getSmartAlertStats()
|
||||
console.log('智能预警API响应:', data)
|
||||
|
||||
// smartAlertService.getSmartAlertStats()通过api.get返回的是result.data,直接使用
|
||||
smartAlerts.value = data || { totalAlerts: 0, eartagAlerts: 0, collarAlerts: 0 }
|
||||
console.log('智能预警数据设置完成:', smartAlerts.value)
|
||||
} catch (error) {
|
||||
console.error('获取智能预警数据失败:', error)
|
||||
smartAlerts.value = { totalAlerts: 0, eartagAlerts: 0, collarAlerts: 0 }
|
||||
} finally {
|
||||
loading.value.smartAlerts = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取统计数据
|
||||
async function fetchStats() {
|
||||
loading.value.stats = true
|
||||
try {
|
||||
// 导入数据服务
|
||||
const { statsService } = await import('../utils/dataService')
|
||||
const data = await statsService.getDashboardStats()
|
||||
stats.value = data || {
|
||||
farmGrowth: 0,
|
||||
animalGrowth: 0,
|
||||
alertReduction: 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error)
|
||||
stats.value = {
|
||||
farmGrowth: 0,
|
||||
animalGrowth: 0,
|
||||
alertReduction: 0
|
||||
}
|
||||
} finally {
|
||||
loading.value.stats = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载所有数据
|
||||
async function fetchAllData() {
|
||||
console.log('开始并行加载所有数据...')
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchFarms(),
|
||||
fetchAnimals(),
|
||||
fetchDevices(),
|
||||
fetchAlerts(),
|
||||
fetchPens(),
|
||||
fetchCattle(),
|
||||
fetchSmartDevices(),
|
||||
fetchSmartAlerts(),
|
||||
fetchStats()
|
||||
])
|
||||
|
||||
console.log('所有数据加载完成:', {
|
||||
farms: farms.value.length,
|
||||
animals: animals.value.length,
|
||||
devices: devices.value.length,
|
||||
alerts: alerts.value.length,
|
||||
pens: pens.value.length,
|
||||
cattle: cattle.value.length,
|
||||
smartDevices: smartDevices.value.length,
|
||||
smartAlerts: smartAlerts.value.totalAlerts
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('数据加载过程中出现错误:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 实时数据更新方法(WebSocket调用)
|
||||
function updateDeviceRealtime(deviceData) {
|
||||
const index = devices.value.findIndex(device => device.id === deviceData.id)
|
||||
if (index !== -1) {
|
||||
// 更新现有设备数据
|
||||
devices.value[index] = { ...devices.value[index], ...deviceData }
|
||||
console.log(`设备 ${deviceData.id} 实时数据已更新`)
|
||||
} else {
|
||||
// 如果是新设备,添加到列表
|
||||
devices.value.push(deviceData)
|
||||
console.log(`新设备 ${deviceData.id} 已添加`)
|
||||
}
|
||||
}
|
||||
|
||||
function addNewAlert(alertData) {
|
||||
// 添加新预警到列表顶部
|
||||
alerts.value.unshift(alertData)
|
||||
console.log(`新预警 ${alertData.id} 已添加`)
|
||||
}
|
||||
|
||||
function updateAnimalRealtime(animalData) {
|
||||
const index = animals.value.findIndex(animal => animal.id === animalData.id)
|
||||
if (index !== -1) {
|
||||
// 更新现有动物数据
|
||||
animals.value[index] = { ...animals.value[index], ...animalData }
|
||||
console.log(`动物 ${animalData.id} 实时数据已更新`)
|
||||
} else {
|
||||
// 如果是新动物记录,添加到列表
|
||||
animals.value.push(animalData)
|
||||
console.log(`新动物记录 ${animalData.id} 已添加`)
|
||||
}
|
||||
}
|
||||
|
||||
function updateStatsRealtime(statsData) {
|
||||
// 更新统计数据
|
||||
stats.value = { ...stats.value, ...statsData }
|
||||
console.log('系统统计数据已实时更新')
|
||||
}
|
||||
|
||||
function updateAlertStatus(alertId, status) {
|
||||
const index = alerts.value.findIndex(alert => alert.id === alertId)
|
||||
if (index !== -1) {
|
||||
alerts.value[index].status = status
|
||||
if (status === 'resolved') {
|
||||
alerts.value[index].resolved_at = new Date()
|
||||
}
|
||||
console.log(`预警 ${alertId} 状态已更新为: ${status}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
farms,
|
||||
animals,
|
||||
devices,
|
||||
alerts,
|
||||
pens,
|
||||
cattle,
|
||||
smartDevices,
|
||||
smartAlerts,
|
||||
stats,
|
||||
loading,
|
||||
|
||||
// 计算属性
|
||||
farmCount,
|
||||
animalCount,
|
||||
deviceCount,
|
||||
alertCount,
|
||||
penCount,
|
||||
onlineDeviceCount,
|
||||
deviceOnlineRate,
|
||||
|
||||
// 数据获取方法
|
||||
fetchFarms,
|
||||
fetchAnimals,
|
||||
fetchDevices,
|
||||
fetchAlerts,
|
||||
fetchPens,
|
||||
fetchCattle,
|
||||
fetchSmartDevices,
|
||||
fetchSmartAlerts,
|
||||
fetchStats,
|
||||
fetchAllData,
|
||||
|
||||
// 实时数据更新方法
|
||||
updateDeviceRealtime,
|
||||
addNewAlert,
|
||||
updateAnimalRealtime,
|
||||
updateStatsRealtime,
|
||||
updateAlertStatus
|
||||
}
|
||||
})
|
||||
4
admin-system/src/stores/index.js
Normal file
4
admin-system/src/stores/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
// 导出所有状态管理存储
|
||||
export { useUserStore } from './user'
|
||||
export { useSettingsStore } from './settings'
|
||||
export { useDataStore } from './data'
|
||||
43
admin-system/src/stores/settings.js
Normal file
43
admin-system/src/stores/settings.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
// 应用设置状态
|
||||
const theme = ref(localStorage.getItem('theme') || 'light')
|
||||
const sidebarCollapsed = ref(localStorage.getItem('sidebarCollapsed') === 'true')
|
||||
const locale = ref(localStorage.getItem('locale') || 'zh-CN')
|
||||
|
||||
// 切换主题
|
||||
function toggleTheme() {
|
||||
theme.value = theme.value === 'light' ? 'dark' : 'light'
|
||||
localStorage.setItem('theme', theme.value)
|
||||
}
|
||||
|
||||
// 设置主题
|
||||
function setTheme(newTheme) {
|
||||
theme.value = newTheme
|
||||
localStorage.setItem('theme', newTheme)
|
||||
}
|
||||
|
||||
// 切换侧边栏折叠状态
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
localStorage.setItem('sidebarCollapsed', sidebarCollapsed.value)
|
||||
}
|
||||
|
||||
// 设置语言
|
||||
function setLocale(newLocale) {
|
||||
locale.value = newLocale
|
||||
localStorage.setItem('locale', newLocale)
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
sidebarCollapsed,
|
||||
locale,
|
||||
toggleTheme,
|
||||
setTheme,
|
||||
toggleSidebar,
|
||||
setLocale
|
||||
}
|
||||
})
|
||||
239
admin-system/src/stores/user.js
Normal file
239
admin-system/src/stores/user.js
Normal file
@@ -0,0 +1,239 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 状态
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const userData = ref(JSON.parse(localStorage.getItem('user') || 'null'))
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
// 检查登录状态
|
||||
function checkLoginStatus() {
|
||||
const savedToken = localStorage.getItem('token')
|
||||
const savedUser = localStorage.getItem('user')
|
||||
|
||||
if (savedToken && savedUser) {
|
||||
try {
|
||||
token.value = savedToken
|
||||
userData.value = JSON.parse(savedUser)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('解析用户数据失败', error)
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查token是否有效
|
||||
async function validateToken() {
|
||||
if (!token.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const { api } = await import('../utils/api')
|
||||
// 尝试调用一个需要认证的API来验证token
|
||||
await api.get('/auth/validate')
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.message && error.message.includes('认证已过期')) {
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
// 其他错误可能是网络问题,不清除token
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 登录操作
|
||||
async function login(username, password, retryCount = 0) {
|
||||
try {
|
||||
const { api } = await import('../utils/api');
|
||||
// 使用专门的login方法,它返回完整的响应对象
|
||||
const result = await api.login(username, password);
|
||||
|
||||
// 登录成功后设置token和用户数据
|
||||
if (result.success && result.token) {
|
||||
token.value = result.token;
|
||||
userData.value = {
|
||||
id: result.user?.id,
|
||||
username: result.user?.username || username,
|
||||
email: result.user?.email || `${username}@example.com`,
|
||||
role: result.role || result.user?.role,
|
||||
permissions: result.permissions || result.user?.permissions || [],
|
||||
accessibleMenus: result.accessibleMenus || result.user?.accessibleMenus || []
|
||||
};
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('token', result.token);
|
||||
localStorage.setItem('user', JSON.stringify(userData.value));
|
||||
|
||||
// 建立WebSocket连接
|
||||
await connectWebSocket();
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('登录错误:', error);
|
||||
// 重试逻辑(仅对500错误且重试次数<2)
|
||||
if (error.message.includes('500') && retryCount < 2) {
|
||||
return login(username, password, retryCount + 1);
|
||||
}
|
||||
// 直接抛出错误,由调用方处理
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket连接状态
|
||||
const isWebSocketConnected = ref(false)
|
||||
|
||||
// 建立WebSocket连接
|
||||
async function connectWebSocket() {
|
||||
if (!token.value) {
|
||||
console.log('无token,跳过WebSocket连接')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const webSocketService = await import('../utils/websocketService')
|
||||
webSocketService.default.connect(token.value)
|
||||
isWebSocketConnected.value = true
|
||||
console.log('WebSocket连接已建立')
|
||||
} catch (error) {
|
||||
console.error('WebSocket连接失败:', error)
|
||||
isWebSocketConnected.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 断开WebSocket连接
|
||||
async function disconnectWebSocket() {
|
||||
try {
|
||||
const webSocketService = await import('../utils/websocketService')
|
||||
webSocketService.default.disconnect()
|
||||
isWebSocketConnected.value = false
|
||||
console.log('WebSocket连接已断开')
|
||||
} catch (error) {
|
||||
console.error('断开WebSocket连接失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 登出操作
|
||||
async function logout() {
|
||||
// 断开WebSocket连接
|
||||
await disconnectWebSocket()
|
||||
|
||||
token.value = ''
|
||||
userData.value = null
|
||||
|
||||
// 清除本地存储
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
function updateUserInfo(newUserInfo) {
|
||||
userData.value = { ...userData.value, ...newUserInfo }
|
||||
localStorage.setItem('user', JSON.stringify(userData.value))
|
||||
}
|
||||
|
||||
// 权限检查方法
|
||||
function hasPermission(permission) {
|
||||
console.log('检查权限:', permission)
|
||||
console.log('用户数据:', userData.value)
|
||||
console.log('用户权限:', userData.value?.permissions)
|
||||
|
||||
if (!userData.value || !userData.value.permissions) {
|
||||
console.log('无用户数据或权限,返回false')
|
||||
return false
|
||||
}
|
||||
|
||||
if (Array.isArray(permission)) {
|
||||
const hasAny = permission.some(p => userData.value.permissions.includes(p))
|
||||
console.log('权限数组检查结果:', permission, hasAny)
|
||||
return hasAny
|
||||
}
|
||||
|
||||
const hasPerm = userData.value.permissions.includes(permission)
|
||||
console.log('单个权限检查结果:', permission, hasPerm)
|
||||
return hasPerm
|
||||
}
|
||||
|
||||
// 角色检查方法
|
||||
function hasRole(role) {
|
||||
console.log('检查角色:', role)
|
||||
console.log('用户角色:', userData.value?.role)
|
||||
|
||||
if (!userData.value || !userData.value.role) {
|
||||
console.log('无用户数据或角色,返回false')
|
||||
return false
|
||||
}
|
||||
|
||||
if (Array.isArray(role)) {
|
||||
const hasAny = role.includes(userData.value.role.name)
|
||||
console.log('角色数组检查结果:', role, hasAny)
|
||||
return hasAny
|
||||
}
|
||||
|
||||
const hasRole = userData.value.role.name === role
|
||||
console.log('单个角色检查结果:', role, hasRole)
|
||||
return hasRole
|
||||
}
|
||||
|
||||
// 检查是否可以访问菜单
|
||||
function canAccessMenu(menuKey) {
|
||||
console.log('检查菜单访问:', menuKey)
|
||||
console.log('可访问菜单:', userData.value?.accessibleMenus)
|
||||
|
||||
if (!userData.value) {
|
||||
console.log('无用户数据,返回false')
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果没有accessibleMenus数组,返回false
|
||||
if (!Array.isArray(userData.value.accessibleMenus)) {
|
||||
console.log('可访问菜单不是数组,返回false')
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果accessibleMenus为空数组,返回false(没有菜单权限)
|
||||
if (userData.value.accessibleMenus.length === 0) {
|
||||
console.log('可访问菜单为空数组,返回false')
|
||||
return false
|
||||
}
|
||||
|
||||
const canAccess = userData.value.accessibleMenus.includes(menuKey)
|
||||
console.log('菜单访问检查结果:', menuKey, canAccess)
|
||||
return canAccess
|
||||
}
|
||||
|
||||
// 获取用户角色名称
|
||||
function getUserRoleName() {
|
||||
return userData.value?.role?.name || 'user'
|
||||
}
|
||||
|
||||
// 获取用户权限列表
|
||||
function getUserPermissions() {
|
||||
return userData.value?.permissions || []
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
userData,
|
||||
isLoggedIn,
|
||||
isWebSocketConnected,
|
||||
checkLoginStatus,
|
||||
validateToken,
|
||||
login,
|
||||
logout,
|
||||
updateUserInfo,
|
||||
connectWebSocket,
|
||||
disconnectWebSocket,
|
||||
hasPermission,
|
||||
hasRole,
|
||||
canAccessMenu,
|
||||
getUserRoleName,
|
||||
getUserPermissions
|
||||
}
|
||||
})
|
||||
105
admin-system/src/styles/global.css
Normal file
105
admin-system/src/styles/global.css
Normal file
@@ -0,0 +1,105 @@
|
||||
/* 全局样式 */
|
||||
|
||||
/* 基础样式重置 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
/* 链接样式 */
|
||||
a {
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
/* 常用辅助类 */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.mt-3 {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
719
admin-system/src/styles/responsive.css
Normal file
719
admin-system/src/styles/responsive.css
Normal file
@@ -0,0 +1,719 @@
|
||||
/**
|
||||
* 响应式设计样式
|
||||
* @file responsive.css
|
||||
* @description 提供全面的响应式设计支持,确保在各种设备上的良好体验
|
||||
*/
|
||||
|
||||
/* ========== 断点定义 ========== */
|
||||
:root {
|
||||
/* 屏幕断点 */
|
||||
--screen-xs: 480px; /* 超小屏幕 */
|
||||
--screen-sm: 576px; /* 小屏幕 */
|
||||
--screen-md: 768px; /* 中等屏幕 */
|
||||
--screen-lg: 992px; /* 大屏幕 */
|
||||
--screen-xl: 1200px; /* 超大屏幕 */
|
||||
--screen-xxl: 1600px; /* 超超大屏幕 */
|
||||
|
||||
/* 移动端优化变量 */
|
||||
--mobile-padding: 12px;
|
||||
--mobile-margin: 8px;
|
||||
--mobile-font-size: 14px;
|
||||
--mobile-button-height: 40px;
|
||||
--mobile-input-height: 40px;
|
||||
|
||||
/* 触摸友好的最小点击区域 */
|
||||
--touch-target-min: 44px;
|
||||
}
|
||||
|
||||
/* ========== 全局响应式基础 ========== */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch; /* iOS 平滑滚动 */
|
||||
}
|
||||
|
||||
/* ========== 移动端基础布局 ========== */
|
||||
@media (max-width: 768px) {
|
||||
/* 页面标题区域 */
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 搜索区域移动端优化 */
|
||||
.search-area {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.search-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-buttons .ant-btn {
|
||||
flex: 1;
|
||||
height: var(--mobile-button-height);
|
||||
}
|
||||
|
||||
/* 表格移动端优化 */
|
||||
.ant-table-wrapper {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.ant-table {
|
||||
min-width: 600px; /* 确保表格在小屏幕上可滚动 */
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
padding: 8px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
padding: 8px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 操作按钮移动端优化 */
|
||||
.table-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.table-actions .ant-btn {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 模态框移动端优化 */
|
||||
.ant-modal {
|
||||
margin: 0 !important;
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
border-radius: 0;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ant-modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.ant-modal-footer {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.ant-modal-footer .ant-btn {
|
||||
height: var(--mobile-button-height);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* 表单移动端优化 */
|
||||
.ant-form-item-label {
|
||||
font-size: var(--mobile-font-size);
|
||||
}
|
||||
|
||||
.ant-input,
|
||||
.ant-input-number,
|
||||
.ant-select-selector,
|
||||
.ant-picker {
|
||||
height: var(--mobile-input-height) !important;
|
||||
font-size: var(--mobile-font-size);
|
||||
}
|
||||
|
||||
.ant-select-selection-item {
|
||||
line-height: calc(var(--mobile-input-height) - 2px);
|
||||
}
|
||||
|
||||
/* 卡片组件移动端优化 */
|
||||
.ant-card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ant-card-head {
|
||||
padding: 0 12px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.ant-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* 分页器移动端优化 */
|
||||
.ant-pagination {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ant-pagination-item,
|
||||
.ant-pagination-prev,
|
||||
.ant-pagination-next {
|
||||
min-width: var(--touch-target-min);
|
||||
height: var(--touch-target-min);
|
||||
line-height: var(--touch-target-min);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 平板端优化 (768px - 992px) ========== */
|
||||
@media (min-width: 768px) and (max-width: 992px) {
|
||||
.page-header {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th,
|
||||
.ant-table-tbody > tr > td {
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
.ant-modal {
|
||||
width: 90vw !important;
|
||||
max-width: 800px !important;
|
||||
}
|
||||
|
||||
.search-area {
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
min-width: 250px;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 大屏幕优化 (>1600px) ========== */
|
||||
@media (min-width: 1600px) {
|
||||
.page-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 移动端导航优化 ========== */
|
||||
@media (max-width: 768px) {
|
||||
/* 侧边栏移动端优化 */
|
||||
.ant-layout-sider {
|
||||
position: fixed !important;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 1001;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.ant-layout-sider.ant-layout-sider-collapsed {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.ant-layout-sider:not(.ant-layout-sider-collapsed) {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* 移动端顶部栏 */
|
||||
.mobile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.mobile-menu-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--touch-target-min);
|
||||
height: var(--touch-target-min);
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mobile-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
/* 内容区域移动端调整 */
|
||||
.ant-layout-content {
|
||||
padding: 12px !important;
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
/* 面包屑移动端优化 */
|
||||
.ant-breadcrumb {
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 移动端表格优化 ========== */
|
||||
@media (max-width: 576px) {
|
||||
.responsive-table {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.responsive-table .ant-table-wrapper {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.responsive-table .ant-table {
|
||||
display: block;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.responsive-table .ant-table-thead {
|
||||
display: none; /* 在超小屏幕上隐藏表头 */
|
||||
}
|
||||
|
||||
.responsive-table .ant-table-tbody {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.responsive-table .ant-table-row {
|
||||
display: block;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.responsive-table .ant-table-cell {
|
||||
display: block;
|
||||
border: none;
|
||||
padding: 4px 0;
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.responsive-table .ant-table-cell:before {
|
||||
content: attr(data-label) ": ";
|
||||
font-weight: 600;
|
||||
color: #595959;
|
||||
min-width: 80px;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 移动端图表优化 ========== */
|
||||
@media (max-width: 768px) {
|
||||
.chart-container {
|
||||
height: 300px !important;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dashboard-stats {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 12px !important;
|
||||
}
|
||||
|
||||
.stat-card .stat-value {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
|
||||
.stat-card .stat-label {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 移动端地图优化 ========== */
|
||||
@media (max-width: 768px) {
|
||||
.map-container {
|
||||
height: 60vh !important;
|
||||
margin: 0 -12px;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.map-controls .ant-btn {
|
||||
width: var(--touch-target-min);
|
||||
height: var(--touch-target-min);
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 移动端性能优化 ========== */
|
||||
@media (max-width: 768px) {
|
||||
/* 减少阴影效果以提升性能 */
|
||||
.ant-card,
|
||||
.ant-modal-content,
|
||||
.ant-drawer-content {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
|
||||
/* 优化动画性能 */
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: #f5f5f5 !important;
|
||||
}
|
||||
|
||||
/* 禁用某些在移动端不必要的动画 */
|
||||
.ant-table-tbody > tr {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 触摸设备优化 ========== */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
/* 为触摸设备优化点击区域 */
|
||||
.ant-btn,
|
||||
.ant-input,
|
||||
.ant-select-selector,
|
||||
.ant-picker,
|
||||
.ant-table-row {
|
||||
min-height: var(--touch-target-min);
|
||||
}
|
||||
|
||||
/* 移除悬停效果,因为触摸设备不需要 */
|
||||
.ant-btn:hover,
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: inherit !important;
|
||||
}
|
||||
|
||||
/* 触摸反馈 */
|
||||
.ant-btn:active {
|
||||
transform: scale(0.98);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 横屏模式优化 ========== */
|
||||
@media (max-width: 768px) and (orientation: landscape) {
|
||||
.mobile-header {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 40vh !important;
|
||||
}
|
||||
|
||||
.ant-modal-content {
|
||||
height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 可访问性改进 ========== */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* 为喜欢减少动画的用户提供静态体验 */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 高对比度模式支持 ========== */
|
||||
@media (prefers-contrast: high) {
|
||||
.ant-btn-primary {
|
||||
background: #000 !important;
|
||||
border-color: #000 !important;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
background: #f0f0f0 !important;
|
||||
border: 1px solid #d9d9d9 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 深色模式基础支持 ========== */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ant-layout {
|
||||
background: #141414 !important;
|
||||
}
|
||||
|
||||
.ant-layout-content {
|
||||
background: #000 !important;
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
background: #1f1f1f !important;
|
||||
border-color: #434343 !important;
|
||||
}
|
||||
|
||||
.ant-table-wrapper {
|
||||
background: #1f1f1f !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 特定组件移动端优化 ========== */
|
||||
|
||||
/* 分页器移动端优化 */
|
||||
@media (max-width: 576px) {
|
||||
.ant-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ant-pagination-options {
|
||||
display: none; /* 隐藏页面大小选择器 */
|
||||
}
|
||||
|
||||
.ant-pagination-simple {
|
||||
margin: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 标签移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.ant-tag {
|
||||
margin: 2px;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 统计卡片移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
gap: 8px !important;
|
||||
}
|
||||
|
||||
.stats-grid .ant-col {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 图表容器移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.charts-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chart-card .ant-card-body {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 自定义移动端布局类 ========== */
|
||||
.mobile-container {
|
||||
padding: var(--mobile-padding);
|
||||
}
|
||||
|
||||
.mobile-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-flex {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.mobile-full-width {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.mobile-text-center {
|
||||
text-align: center !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 加载状态移动端优化 ========== */
|
||||
@media (max-width: 768px) {
|
||||
.ant-spin-container {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.ant-empty {
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.ant-empty-description {
|
||||
font-size: var(--mobile-font-size);
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 消息提示移动端优化 ========== */
|
||||
@media (max-width: 768px) {
|
||||
.ant-message {
|
||||
top: 20px !important;
|
||||
}
|
||||
|
||||
.ant-message-notice {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ant-notification {
|
||||
width: calc(100vw - 32px) !important;
|
||||
margin-right: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 菜单移动端优化 ========== */
|
||||
@media (max-width: 768px) {
|
||||
.ant-menu-item {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
padding: 0 16px !important;
|
||||
}
|
||||
|
||||
.ant-menu-item-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ant-menu-submenu-title {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
padding: 0 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 工具提示移动端优化 ========== */
|
||||
@media (max-width: 768px) {
|
||||
.ant-tooltip {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 在移动端禁用工具提示,因为没有悬停 */
|
||||
.ant-tooltip-hidden-arrow {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 表单布局移动端优化 ========== */
|
||||
@media (max-width: 768px) {
|
||||
.ant-row {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.ant-col {
|
||||
padding: 0 4px !important;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ant-form-item-control {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 调试辅助类 ========== */
|
||||
.debug-breakpoint {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: rgba(255, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
z-index: 9999;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.debug-breakpoint:after {
|
||||
content: 'Desktop';
|
||||
}
|
||||
|
||||
@media (max-width: 1600px) {
|
||||
.debug-breakpoint:after {
|
||||
content: 'XL';
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.debug-breakpoint:after {
|
||||
content: 'LG';
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.debug-breakpoint:after {
|
||||
content: 'MD';
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.debug-breakpoint:after {
|
||||
content: 'SM';
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.debug-breakpoint:after {
|
||||
content: 'XS';
|
||||
}
|
||||
}
|
||||
29
admin-system/src/styles/theme.js
Normal file
29
admin-system/src/styles/theme.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// Ant Design Vue 主题配置
|
||||
export const themeConfig = {
|
||||
token: {
|
||||
colorPrimary: '#1890ff',
|
||||
colorSuccess: '#52c41a',
|
||||
colorWarning: '#faad14',
|
||||
colorError: '#f5222d',
|
||||
colorInfo: '#1890ff',
|
||||
borderRadius: 4,
|
||||
wireframe: false,
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
colorPrimary: '#1890ff',
|
||||
algorithm: true,
|
||||
},
|
||||
Input: {
|
||||
colorPrimary: '#1890ff',
|
||||
},
|
||||
Card: {
|
||||
colorBgContainer: '#ffffff',
|
||||
},
|
||||
Layout: {
|
||||
colorBgHeader: '#001529',
|
||||
colorBgBody: '#f0f2f5',
|
||||
colorBgSider: '#001529',
|
||||
},
|
||||
},
|
||||
};
|
||||
27
admin-system/src/test-api-direct.js
Normal file
27
admin-system/src/test-api-direct.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// 直接测试API调用
|
||||
import { api } from './utils/api.js'
|
||||
|
||||
console.log('=== 开始直接API测试 ===')
|
||||
|
||||
try {
|
||||
console.log('正在调用 /devices/public API...')
|
||||
const result = await api.get('/devices/public')
|
||||
console.log('API调用成功!')
|
||||
console.log('返回数据类型:', typeof result)
|
||||
console.log('是否为数组:', Array.isArray(result))
|
||||
console.log('数据长度:', result?.length || 0)
|
||||
console.log('前3个设备:', result?.slice(0, 3))
|
||||
|
||||
if (result && result.length > 0) {
|
||||
const statusCount = {}
|
||||
result.forEach(device => {
|
||||
statusCount[device.status] = (statusCount[device.status] || 0) + 1
|
||||
})
|
||||
console.log('设备状态分布:', statusCount)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API调用失败:', error.message)
|
||||
console.error('错误详情:', error)
|
||||
}
|
||||
|
||||
console.log('=== API测试完成 ===')
|
||||
41
admin-system/src/test-data-store.js
Normal file
41
admin-system/src/test-data-store.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// 测试数据存储
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { useDataStore } from './stores/data.js'
|
||||
|
||||
// 创建应用和Pinia实例
|
||||
const app = createApp({})
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
// 测试数据存储
|
||||
async function testDataStore() {
|
||||
console.log('=== 开始测试数据存储 ===')
|
||||
|
||||
const dataStore = useDataStore()
|
||||
|
||||
console.log('初始设备数量:', dataStore.devices.length)
|
||||
|
||||
try {
|
||||
console.log('开始获取设备数据...')
|
||||
await dataStore.fetchDevices()
|
||||
|
||||
console.log('获取完成,设备数量:', dataStore.devices.length)
|
||||
console.log('前3个设备:', dataStore.devices.slice(0, 3))
|
||||
|
||||
// 统计状态
|
||||
const statusCount = {}
|
||||
dataStore.devices.forEach(device => {
|
||||
statusCount[device.status] = (statusCount[device.status] || 0) + 1
|
||||
})
|
||||
console.log('状态分布:', statusCount)
|
||||
|
||||
} catch (error) {
|
||||
console.error('测试失败:', error)
|
||||
}
|
||||
|
||||
console.log('=== 测试完成 ===')
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
testDataStore()
|
||||
1148
admin-system/src/utils/api.js
Normal file
1148
admin-system/src/utils/api.js
Normal file
File diff suppressed because it is too large
Load Diff
273
admin-system/src/utils/baiduMapLoader.js
Normal file
273
admin-system/src/utils/baiduMapLoader.js
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 百度地图加载器
|
||||
* 提供更健壮的百度地图API加载和初始化功能
|
||||
*/
|
||||
|
||||
// 百度地图API加载状态
|
||||
let BMapLoaded = false;
|
||||
let loadingPromise = null;
|
||||
let retryCount = 0;
|
||||
const MAX_RETRY = 3;
|
||||
|
||||
/**
|
||||
* 加载百度地图API
|
||||
* @param {string} apiKey - 百度地图API密钥
|
||||
* @returns {Promise} 加载完成的Promise
|
||||
*/
|
||||
export const loadBaiduMapAPI = async (apiKey = 'SOawZTeQbxdgrKYYx0o2hn34G0DyU2uo') => {
|
||||
// 如果已经加载过,直接返回
|
||||
if (BMapLoaded && window.BMap) {
|
||||
console.log('百度地图API已加载');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 如果正在加载中,返回加载Promise
|
||||
if (loadingPromise) {
|
||||
console.log('百度地图API正在加载中...');
|
||||
return loadingPromise;
|
||||
}
|
||||
|
||||
console.log('开始加载百度地图API...');
|
||||
|
||||
// 创建加载Promise
|
||||
loadingPromise = new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
// 检查API密钥
|
||||
if (!apiKey || apiKey === 'YOUR_VALID_BAIDU_MAP_API_KEY') {
|
||||
const error = new Error('百度地图API密钥未配置或无效');
|
||||
console.error('API密钥错误:', error);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已经存在BMap
|
||||
if (typeof window.BMap !== 'undefined' && window.BMap.Map) {
|
||||
console.log('BMap已存在,直接使用');
|
||||
BMapLoaded = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建全局回调函数
|
||||
window.initBaiduMapCallback = () => {
|
||||
console.log('百度地图API脚本加载完成');
|
||||
|
||||
// 等待BMap对象完全初始化
|
||||
const checkBMap = () => {
|
||||
if (window.BMap && typeof window.BMap.Map === 'function') {
|
||||
console.log('BMap对象初始化完成');
|
||||
BMapLoaded = true;
|
||||
resolve();
|
||||
// 清理全局回调
|
||||
delete window.initBaiduMapCallback;
|
||||
} else {
|
||||
console.log('等待BMap对象初始化...');
|
||||
setTimeout(checkBMap, 100);
|
||||
}
|
||||
};
|
||||
|
||||
// 开始检查BMap对象
|
||||
setTimeout(checkBMap, 50);
|
||||
};
|
||||
|
||||
// 创建script标签
|
||||
const script = document.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
script.src = `https://api.map.baidu.com/api?v=3.0&ak=${apiKey}&callback=initBaiduMapCallback`;
|
||||
|
||||
console.log('百度地图API URL:', script.src);
|
||||
|
||||
script.onerror = (error) => {
|
||||
console.error('百度地图脚本加载失败:', error);
|
||||
reject(new Error('百度地图脚本加载失败'));
|
||||
};
|
||||
|
||||
script.onload = () => {
|
||||
console.log('百度地图脚本加载成功');
|
||||
};
|
||||
|
||||
// 设置超时
|
||||
const timeout = setTimeout(() => {
|
||||
if (!BMapLoaded) {
|
||||
console.error('百度地图API加载超时');
|
||||
reject(new Error('百度地图API加载超时'));
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
// 成功加载后清除超时
|
||||
const originalResolve = resolve;
|
||||
resolve = () => {
|
||||
clearTimeout(timeout);
|
||||
originalResolve();
|
||||
};
|
||||
|
||||
// 添加到文档中
|
||||
document.head.appendChild(script);
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载百度地图API时出错:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
return loadingPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* 重试加载百度地图API
|
||||
* @param {string} apiKey - 百度地图API密钥
|
||||
* @returns {Promise} 加载完成的Promise
|
||||
*/
|
||||
export const retryLoadBaiduMapAPI = async (apiKey = 'SOawZTeQbxdgrKYYx0o2hn34G0DyU2uo') => {
|
||||
if (retryCount >= MAX_RETRY) {
|
||||
throw new Error('百度地图API加载重试次数已达上限');
|
||||
}
|
||||
|
||||
retryCount++;
|
||||
console.log(`第 ${retryCount} 次尝试加载百度地图API...`);
|
||||
|
||||
// 重置状态
|
||||
BMapLoaded = false;
|
||||
loadingPromise = null;
|
||||
|
||||
// 清理可能存在的旧脚本
|
||||
const existingScript = document.querySelector('script[src*="api.map.baidu.com"]');
|
||||
if (existingScript) {
|
||||
existingScript.remove();
|
||||
}
|
||||
|
||||
// 清理全局回调
|
||||
if (window.initBaiduMapCallback) {
|
||||
delete window.initBaiduMapCallback;
|
||||
}
|
||||
|
||||
return loadBaiduMapAPI(apiKey);
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查百度地图API是否可用
|
||||
* @returns {boolean} 是否可用
|
||||
*/
|
||||
export const isBaiduMapAvailable = () => {
|
||||
return BMapLoaded && window.BMap && typeof window.BMap.Map === 'function';
|
||||
};
|
||||
|
||||
/**
|
||||
* 等待百度地图API加载完成
|
||||
* @param {number} timeout - 超时时间(毫秒)
|
||||
* @returns {Promise} 加载完成的Promise
|
||||
*/
|
||||
export const waitForBaiduMap = (timeout = 10000) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isBaiduMapAvailable()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const checkInterval = setInterval(() => {
|
||||
if (isBaiduMapAvailable()) {
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
} else if (Date.now() - startTime > timeout) {
|
||||
clearInterval(checkInterval);
|
||||
reject(new Error('等待百度地图API加载超时'));
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建百度地图实例
|
||||
* @param {string|HTMLElement} container - 地图容器ID或元素
|
||||
* @param {Object} options - 地图配置选项
|
||||
* @returns {Promise<BMap.Map>} 地图实例
|
||||
*/
|
||||
export const createBaiduMap = async (container, options = {}) => {
|
||||
try {
|
||||
// 确保百度地图API已加载
|
||||
await loadBaiduMapAPI();
|
||||
|
||||
// 等待BMap完全可用
|
||||
await waitForBaiduMap();
|
||||
|
||||
// 获取容器元素
|
||||
let mapContainer = typeof container === 'string'
|
||||
? document.getElementById(container)
|
||||
: container;
|
||||
|
||||
// 如果容器不存在,等待一段时间后重试
|
||||
if (!mapContainer) {
|
||||
console.log('地图容器不存在,等待DOM渲染...');
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
mapContainer = typeof container === 'string'
|
||||
? document.getElementById(container)
|
||||
: container;
|
||||
}
|
||||
|
||||
if (!mapContainer) {
|
||||
throw new Error('地图容器不存在,请确保DOM已正确渲染');
|
||||
}
|
||||
|
||||
// 检查容器是否有尺寸
|
||||
if (mapContainer.offsetWidth === 0 || mapContainer.offsetHeight === 0) {
|
||||
console.warn('地图容器尺寸为0,等待尺寸计算...');
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
const defaultOptions = {
|
||||
center: new window.BMap.Point(106.27, 38.47), // 宁夏中心点
|
||||
zoom: 8,
|
||||
enableMapClick: true,
|
||||
enableScrollWheelZoom: true,
|
||||
enableDragging: true,
|
||||
enableDoubleClickZoom: true,
|
||||
enableKeyboard: true
|
||||
};
|
||||
|
||||
// 合并配置
|
||||
const mergedOptions = { ...defaultOptions, ...options };
|
||||
|
||||
// 创建地图实例
|
||||
const map = new window.BMap.Map(mapContainer);
|
||||
|
||||
// 设置中心点和缩放级别
|
||||
map.centerAndZoom(mergedOptions.center, mergedOptions.zoom);
|
||||
|
||||
// 配置地图功能
|
||||
if (mergedOptions.enableScrollWheelZoom) {
|
||||
map.enableScrollWheelZoom();
|
||||
}
|
||||
|
||||
if (mergedOptions.enableDragging) {
|
||||
map.enableDragging();
|
||||
} else {
|
||||
map.disableDragging();
|
||||
}
|
||||
|
||||
if (mergedOptions.enableDoubleClickZoom) {
|
||||
map.enableDoubleClickZoom();
|
||||
} else {
|
||||
map.disableDoubleClickZoom();
|
||||
}
|
||||
|
||||
if (mergedOptions.enableKeyboard) {
|
||||
map.enableKeyboard();
|
||||
} else {
|
||||
map.disableKeyboard();
|
||||
}
|
||||
|
||||
// 添加地图控件
|
||||
map.addControl(new window.BMap.NavigationControl());
|
||||
map.addControl(new window.BMap.ScaleControl());
|
||||
map.addControl(new window.BMap.OverviewMapControl());
|
||||
|
||||
console.log('百度地图创建成功');
|
||||
return map;
|
||||
|
||||
} catch (error) {
|
||||
console.error('创建百度地图失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
337
admin-system/src/utils/baiduMapTest.js
Normal file
337
admin-system/src/utils/baiduMapTest.js
Normal file
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* 百度地图API测试工具
|
||||
* 用于诊断和修复百度地图API加载问题
|
||||
*/
|
||||
|
||||
export class BaiduMapTester {
|
||||
constructor() {
|
||||
this.testResults = [];
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行完整的API测试
|
||||
*/
|
||||
async runFullTest() {
|
||||
console.log('🔍 开始百度地图API完整测试...');
|
||||
this.testResults = [];
|
||||
|
||||
try {
|
||||
// 测试1: 检查当前BMap状态
|
||||
await this.testCurrentBMapStatus();
|
||||
|
||||
// 测试2: 测试API加载
|
||||
await this.testApiLoading();
|
||||
|
||||
// 测试3: 测试BMap对象功能
|
||||
await this.testBMapFunctionality();
|
||||
|
||||
// 测试4: 测试地图创建
|
||||
await this.testMapCreation();
|
||||
|
||||
// 输出测试结果
|
||||
this.outputTestResults();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 测试过程中出现错误:', error);
|
||||
this.testResults.push({
|
||||
name: '测试过程错误',
|
||||
status: 'error',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
return this.testResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试当前BMap状态
|
||||
*/
|
||||
async testCurrentBMapStatus() {
|
||||
console.log('📋 测试1: 检查当前BMap状态');
|
||||
|
||||
const result = {
|
||||
name: 'BMap状态检查',
|
||||
status: 'info',
|
||||
details: {}
|
||||
};
|
||||
|
||||
result.details.bMapExists = typeof window.BMap !== 'undefined';
|
||||
result.details.bMapType = typeof window.BMap;
|
||||
result.details.bMapValue = window.BMap;
|
||||
|
||||
if (window.BMap) {
|
||||
result.details.bMapVersion = window.BMap.version || '未知';
|
||||
result.details.mapConstructor = typeof window.BMap.Map;
|
||||
result.details.pointConstructor = typeof window.BMap.Point;
|
||||
result.details.markerConstructor = typeof window.BMap.Marker;
|
||||
result.details.infoWindowConstructor = typeof window.BMap.InfoWindow;
|
||||
|
||||
if (typeof window.BMap.Map === 'function') {
|
||||
result.status = 'success';
|
||||
result.message = 'BMap对象已存在且功能完整';
|
||||
} else {
|
||||
result.status = 'warning';
|
||||
result.message = 'BMap对象存在但功能不完整';
|
||||
}
|
||||
} else {
|
||||
result.status = 'error';
|
||||
result.message = 'BMap对象不存在,需要加载API';
|
||||
}
|
||||
|
||||
this.testResults.push(result);
|
||||
console.log(`✅ 测试1完成: ${result.message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试API加载
|
||||
*/
|
||||
async testApiLoading() {
|
||||
console.log('📋 测试2: 测试API加载');
|
||||
|
||||
const result = {
|
||||
name: 'API加载测试',
|
||||
status: 'info',
|
||||
details: {}
|
||||
};
|
||||
|
||||
try {
|
||||
// 检查是否已有BMap
|
||||
if (typeof window.BMap !== 'undefined') {
|
||||
result.status = 'success';
|
||||
result.message = 'BMap已存在,跳过API加载测试';
|
||||
this.testResults.push(result);
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试加载API
|
||||
result.details.loadingStarted = true;
|
||||
const loadPromise = this.loadBMapApi();
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('API加载超时')), 10000)
|
||||
);
|
||||
|
||||
await Promise.race([loadPromise, timeoutPromise]);
|
||||
|
||||
result.status = 'success';
|
||||
result.message = 'API加载成功';
|
||||
result.details.loadingCompleted = true;
|
||||
|
||||
} catch (error) {
|
||||
result.status = 'error';
|
||||
result.message = `API加载失败: ${error.message}`;
|
||||
result.details.error = error.message;
|
||||
}
|
||||
|
||||
this.testResults.push(result);
|
||||
console.log(`✅ 测试2完成: ${result.message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试BMap对象功能
|
||||
*/
|
||||
async testBMapFunctionality() {
|
||||
console.log('📋 测试3: 测试BMap对象功能');
|
||||
|
||||
const result = {
|
||||
name: 'BMap功能测试',
|
||||
status: 'info',
|
||||
details: {}
|
||||
};
|
||||
|
||||
try {
|
||||
if (typeof window.BMap === 'undefined') {
|
||||
throw new Error('BMap对象不存在');
|
||||
}
|
||||
|
||||
// 测试Point创建
|
||||
const point = new window.BMap.Point(106.27, 38.47);
|
||||
result.details.pointTest = {
|
||||
success: true,
|
||||
lng: point.lng,
|
||||
lat: point.lat
|
||||
};
|
||||
|
||||
// 测试Marker创建
|
||||
const marker = new window.BMap.Marker(point);
|
||||
result.details.markerTest = {
|
||||
success: true,
|
||||
position: marker.getPosition()
|
||||
};
|
||||
|
||||
// 测试InfoWindow创建
|
||||
const infoWindow = new window.BMap.InfoWindow('<div>测试</div>');
|
||||
result.details.infoWindowTest = {
|
||||
success: true,
|
||||
type: typeof infoWindow
|
||||
};
|
||||
|
||||
result.status = 'success';
|
||||
result.message = 'BMap对象功能测试通过';
|
||||
|
||||
} catch (error) {
|
||||
result.status = 'error';
|
||||
result.message = `BMap功能测试失败: ${error.message}`;
|
||||
result.details.error = error.message;
|
||||
}
|
||||
|
||||
this.testResults.push(result);
|
||||
console.log(`✅ 测试3完成: ${result.message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试地图创建
|
||||
*/
|
||||
async testMapCreation() {
|
||||
console.log('📋 测试4: 测试地图创建');
|
||||
|
||||
const result = {
|
||||
name: '地图创建测试',
|
||||
status: 'info',
|
||||
details: {}
|
||||
};
|
||||
|
||||
try {
|
||||
if (typeof window.BMap === 'undefined') {
|
||||
throw new Error('BMap对象不存在');
|
||||
}
|
||||
|
||||
// 创建测试容器
|
||||
const testContainer = document.createElement('div');
|
||||
testContainer.style.width = '100px';
|
||||
testContainer.style.height = '100px';
|
||||
testContainer.style.position = 'absolute';
|
||||
testContainer.style.top = '-1000px';
|
||||
testContainer.style.left = '-1000px';
|
||||
document.body.appendChild(testContainer);
|
||||
|
||||
// 创建地图
|
||||
const map = new window.BMap.Map(testContainer);
|
||||
result.details.mapCreated = true;
|
||||
result.details.mapType = typeof map;
|
||||
|
||||
// 测试地图基本功能
|
||||
const center = new window.BMap.Point(106.27, 38.47);
|
||||
map.centerAndZoom(center, 10);
|
||||
result.details.mapConfigured = true;
|
||||
|
||||
// 清理测试容器
|
||||
document.body.removeChild(testContainer);
|
||||
|
||||
result.status = 'success';
|
||||
result.message = '地图创建测试通过';
|
||||
|
||||
} catch (error) {
|
||||
result.status = 'error';
|
||||
result.message = `地图创建测试失败: ${error.message}`;
|
||||
result.details.error = error.message;
|
||||
}
|
||||
|
||||
this.testResults.push(result);
|
||||
console.log(`✅ 测试4完成: ${result.message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载百度地图API
|
||||
*/
|
||||
loadBMapApi() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.isLoading) {
|
||||
reject(new Error('API正在加载中'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://api.map.baidu.com/api?v=3.0&ak=SOawZTeQbxdgrKYYx0o2hn34G0DyU2uo&callback=initBMapTest';
|
||||
|
||||
window.initBMapTest = () => {
|
||||
this.isLoading = false;
|
||||
delete window.initBMapTest;
|
||||
resolve();
|
||||
};
|
||||
|
||||
script.onerror = () => {
|
||||
this.isLoading = false;
|
||||
delete window.initBMapTest;
|
||||
reject(new Error('API脚本加载失败'));
|
||||
};
|
||||
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出测试结果
|
||||
*/
|
||||
outputTestResults() {
|
||||
console.log('\n📊 百度地图API测试结果:');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
this.testResults.forEach((result, index) => {
|
||||
const statusIcon = {
|
||||
success: '✅',
|
||||
error: '❌',
|
||||
warning: '⚠️',
|
||||
info: 'ℹ️'
|
||||
}[result.status] || '❓';
|
||||
|
||||
console.log(`${index + 1}. ${statusIcon} ${result.name}: ${result.message}`);
|
||||
|
||||
if (result.details && Object.keys(result.details).length > 0) {
|
||||
console.log(' 详细信息:', result.details);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('='.repeat(50));
|
||||
|
||||
const successCount = this.testResults.filter(r => r.status === 'success').length;
|
||||
const totalCount = this.testResults.length;
|
||||
|
||||
console.log(`📈 测试总结: ${successCount}/${totalCount} 项测试通过`);
|
||||
|
||||
if (successCount === totalCount) {
|
||||
console.log('🎉 所有测试通过!百度地图API工作正常。');
|
||||
} else {
|
||||
console.log('⚠️ 部分测试失败,请检查上述错误信息。');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取修复建议
|
||||
*/
|
||||
getFixSuggestions() {
|
||||
const suggestions = [];
|
||||
|
||||
this.testResults.forEach(result => {
|
||||
if (result.status === 'error') {
|
||||
switch (result.name) {
|
||||
case 'BMap状态检查':
|
||||
suggestions.push('1. 检查网络连接是否正常');
|
||||
suggestions.push('2. 检查百度地图API密钥是否有效');
|
||||
suggestions.push('3. 检查域名白名单配置');
|
||||
break;
|
||||
case 'API加载测试':
|
||||
suggestions.push('1. 尝试刷新页面');
|
||||
suggestions.push('2. 检查浏览器控制台是否有CORS错误');
|
||||
suggestions.push('3. 尝试使用备用API密钥');
|
||||
break;
|
||||
case 'BMap功能测试':
|
||||
suggestions.push('1. 等待API完全加载后再使用');
|
||||
suggestions.push('2. 检查API版本兼容性');
|
||||
break;
|
||||
case '地图创建测试':
|
||||
suggestions.push('1. 确保容器元素存在且有尺寸');
|
||||
suggestions.push('2. 检查容器是否被隐藏');
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例实例
|
||||
export const baiduMapTester = new BaiduMapTester();
|
||||
501
admin-system/src/utils/chartService.js
Normal file
501
admin-system/src/utils/chartService.js
Normal file
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* 图表服务工具
|
||||
* 封装ECharts图表的初始化和配置功能
|
||||
* 优化版本:支持按需加载、懒加载和性能优化
|
||||
*/
|
||||
|
||||
// 导入ECharts核心模块
|
||||
import * as echarts from 'echarts/core';
|
||||
|
||||
// 按需导入图表组件
|
||||
import {
|
||||
LineChart,
|
||||
BarChart,
|
||||
PieChart
|
||||
} from 'echarts/charts';
|
||||
|
||||
// 按需导入组件
|
||||
import {
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
DatasetComponent,
|
||||
TransformComponent,
|
||||
LegendComponent,
|
||||
ToolboxComponent
|
||||
} from 'echarts/components';
|
||||
|
||||
// 导入Canvas渲染器(性能更好)
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
|
||||
// 图表实例缓存
|
||||
const chartInstanceCache = new Map();
|
||||
|
||||
// 数据缓存
|
||||
const dataCache = new Map();
|
||||
|
||||
// 注册必要的组件
|
||||
echarts.use([
|
||||
LineChart,
|
||||
BarChart,
|
||||
PieChart,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
DatasetComponent,
|
||||
TransformComponent,
|
||||
LegendComponent,
|
||||
ToolboxComponent,
|
||||
CanvasRenderer
|
||||
]);
|
||||
|
||||
// 性能优化配置
|
||||
const PERFORMANCE_CONFIG = {
|
||||
// 启用硬件加速
|
||||
devicePixelRatio: window.devicePixelRatio || 1,
|
||||
// 渲染器配置
|
||||
renderer: 'canvas',
|
||||
// 动画配置
|
||||
animation: {
|
||||
duration: 300,
|
||||
easing: 'cubicOut'
|
||||
},
|
||||
// 大数据优化
|
||||
largeThreshold: 2000,
|
||||
progressive: 400,
|
||||
progressiveThreshold: 3000
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建图表实例(优化版本)
|
||||
* @param {HTMLElement} container - 图表容器元素
|
||||
* @param {Object} options - 图表配置选项
|
||||
* @param {string} cacheKey - 缓存键(可选)
|
||||
* @returns {echarts.ECharts} 图表实例
|
||||
*/
|
||||
export function createChart(container, options = {}, cacheKey = null) {
|
||||
if (!container) {
|
||||
console.error('图表容器不存在');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
if (cacheKey && chartInstanceCache.has(cacheKey)) {
|
||||
const cachedChart = chartInstanceCache.get(cacheKey);
|
||||
if (cachedChart && !cachedChart.isDisposed()) {
|
||||
// 更新配置
|
||||
cachedChart.setOption(options, true);
|
||||
return cachedChart;
|
||||
} else {
|
||||
// 清理无效缓存
|
||||
chartInstanceCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 合并性能优化配置
|
||||
const initOptions = {
|
||||
devicePixelRatio: PERFORMANCE_CONFIG.devicePixelRatio,
|
||||
renderer: PERFORMANCE_CONFIG.renderer,
|
||||
width: 'auto',
|
||||
height: 'auto'
|
||||
};
|
||||
|
||||
// 创建图表实例
|
||||
const chart = echarts.init(container, null, initOptions);
|
||||
|
||||
// 应用性能优化配置到选项
|
||||
const optimizedOptions = {
|
||||
...options,
|
||||
animation: {
|
||||
...PERFORMANCE_CONFIG.animation,
|
||||
...options.animation
|
||||
},
|
||||
// 大数据优化
|
||||
progressive: PERFORMANCE_CONFIG.progressive,
|
||||
progressiveThreshold: PERFORMANCE_CONFIG.progressiveThreshold
|
||||
};
|
||||
|
||||
// 设置图表选项
|
||||
if (optimizedOptions) {
|
||||
chart.setOption(optimizedOptions, true);
|
||||
}
|
||||
|
||||
// 缓存图表实例
|
||||
if (cacheKey) {
|
||||
chartInstanceCache.set(cacheKey, chart);
|
||||
}
|
||||
|
||||
return chart;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建趋势图表
|
||||
* @param {HTMLElement} container - 图表容器元素
|
||||
* @param {Array} data - 图表数据
|
||||
* @param {Object} options - 额外配置选项
|
||||
* @returns {echarts.ECharts} 图表实例
|
||||
*/
|
||||
export function createTrendChart(container, data, options = {}) {
|
||||
const { xAxis = [], series = [] } = data;
|
||||
|
||||
const defaultOptions = {
|
||||
title: {
|
||||
text: options.title || '趋势图表',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
legend: {
|
||||
data: series.map(item => item.name),
|
||||
bottom: 0
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '10%',
|
||||
top: '15%',
|
||||
containLabel: true
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {}
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: xAxis
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: series.map(item => ({
|
||||
name: item.name,
|
||||
type: item.type || 'line',
|
||||
data: item.data,
|
||||
smooth: true,
|
||||
itemStyle: item.itemStyle,
|
||||
lineStyle: item.lineStyle,
|
||||
areaStyle: item.areaStyle
|
||||
}))
|
||||
};
|
||||
|
||||
// 合并选项
|
||||
const chartOptions = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
title: { ...defaultOptions.title, ...options.title },
|
||||
tooltip: { ...defaultOptions.tooltip, ...options.tooltip },
|
||||
legend: { ...defaultOptions.legend, ...options.legend },
|
||||
grid: { ...defaultOptions.grid, ...options.grid }
|
||||
};
|
||||
|
||||
return createChart(container, chartOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建饼图
|
||||
* @param {HTMLElement} container - 图表容器元素
|
||||
* @param {Array} data - 图表数据
|
||||
* @param {Object} options - 额外配置选项
|
||||
* @returns {echarts.ECharts} 图表实例
|
||||
*/
|
||||
export function createPieChart(container, data, options = {}) {
|
||||
const defaultOptions = {
|
||||
title: {
|
||||
text: options.title || '饼图',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: options.seriesName || '数据',
|
||||
type: 'pie',
|
||||
radius: options.radius || ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: data
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// 合并选项
|
||||
const chartOptions = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
title: { ...defaultOptions.title, ...options.title },
|
||||
tooltip: { ...defaultOptions.tooltip, ...options.tooltip },
|
||||
legend: { ...defaultOptions.legend, ...options.legend }
|
||||
};
|
||||
|
||||
return createChart(container, chartOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建柱状图
|
||||
* @param {HTMLElement} container - 图表容器元素
|
||||
* @param {Object} data - 图表数据
|
||||
* @param {Object} options - 额外配置选项
|
||||
* @returns {echarts.ECharts} 图表实例
|
||||
*/
|
||||
export function createBarChart(container, data, options = {}) {
|
||||
const { xAxis = [], series = [] } = data;
|
||||
|
||||
const defaultOptions = {
|
||||
title: {
|
||||
text: options.title || '柱状图',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: series.map(item => item.name),
|
||||
bottom: 0
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '10%',
|
||||
top: '15%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xAxis
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: series.map(item => ({
|
||||
name: item.name,
|
||||
type: 'bar',
|
||||
data: item.data,
|
||||
itemStyle: item.itemStyle
|
||||
}))
|
||||
};
|
||||
|
||||
// 合并选项
|
||||
const chartOptions = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
title: { ...defaultOptions.title, ...options.title },
|
||||
tooltip: { ...defaultOptions.tooltip, ...options.tooltip },
|
||||
legend: { ...defaultOptions.legend, ...options.legend },
|
||||
grid: { ...defaultOptions.grid, ...options.grid }
|
||||
};
|
||||
|
||||
return createChart(container, chartOptions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 处理窗口大小变化,调整图表大小
|
||||
* @param {echarts.ECharts} chart - 图表实例
|
||||
*/
|
||||
export function handleResize(chart) {
|
||||
if (chart) {
|
||||
chart.resize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁图表实例
|
||||
* @param {echarts.ECharts} chart - 图表实例
|
||||
* @param {string} cacheKey - 缓存键(可选)
|
||||
*/
|
||||
export function disposeChart(chart, cacheKey = null) {
|
||||
if (chart) {
|
||||
chart.dispose();
|
||||
|
||||
// 清理缓存
|
||||
if (cacheKey) {
|
||||
chartInstanceCache.delete(cacheKey);
|
||||
dataCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据缓存管理
|
||||
*/
|
||||
export const DataCache = {
|
||||
/**
|
||||
* 设置缓存数据
|
||||
* @param {string} key - 缓存键
|
||||
* @param {any} data - 数据
|
||||
* @param {number} ttl - 过期时间(毫秒),默认5分钟
|
||||
*/
|
||||
set(key, data, ttl = 5 * 60 * 1000) {
|
||||
const expireTime = Date.now() + ttl;
|
||||
dataCache.set(key, { data, expireTime });
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取缓存数据
|
||||
* @param {string} key - 缓存键
|
||||
* @returns {any|null} 缓存的数据或null
|
||||
*/
|
||||
get(key) {
|
||||
const cached = dataCache.get(key);
|
||||
if (!cached) return null;
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() > cached.expireTime) {
|
||||
dataCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除缓存数据
|
||||
* @param {string} key - 缓存键
|
||||
*/
|
||||
delete(key) {
|
||||
dataCache.delete(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* 清空所有缓存
|
||||
*/
|
||||
clear() {
|
||||
dataCache.clear();
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查缓存是否存在且未过期
|
||||
* @param {string} key - 缓存键
|
||||
* @returns {boolean}
|
||||
*/
|
||||
has(key) {
|
||||
return this.get(key) !== null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 懒加载图表组件
|
||||
* @param {HTMLElement} container - 图表容器
|
||||
* @param {Function} dataLoader - 数据加载函数
|
||||
* @param {Object} options - 图表配置
|
||||
* @param {string} cacheKey - 缓存键
|
||||
* @returns {Promise<echarts.ECharts>}
|
||||
*/
|
||||
export async function createLazyChart(container, dataLoader, options = {}, cacheKey = null) {
|
||||
// 检查数据缓存
|
||||
let data = cacheKey ? DataCache.get(cacheKey) : null;
|
||||
|
||||
if (!data) {
|
||||
// 显示加载状态
|
||||
const loadingChart = createChart(container, {
|
||||
title: {
|
||||
text: '加载中...',
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
textStyle: {
|
||||
fontSize: 14,
|
||||
color: '#999'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// 异步加载数据
|
||||
data = await dataLoader();
|
||||
|
||||
// 缓存数据
|
||||
if (cacheKey) {
|
||||
DataCache.set(cacheKey, data);
|
||||
}
|
||||
|
||||
// 销毁加载图表
|
||||
loadingChart.dispose();
|
||||
} catch (error) {
|
||||
console.error('图表数据加载失败:', error);
|
||||
|
||||
// 显示错误状态
|
||||
loadingChart.setOption({
|
||||
title: {
|
||||
text: '加载失败',
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
textStyle: {
|
||||
fontSize: 14,
|
||||
color: '#ff4d4f'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return loadingChart;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建实际图表
|
||||
const chartOptions = {
|
||||
...options,
|
||||
...data
|
||||
};
|
||||
|
||||
return createChart(container, chartOptions, cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有缓存
|
||||
*/
|
||||
export function clearAllCache() {
|
||||
// 销毁所有缓存的图表实例
|
||||
chartInstanceCache.forEach(chart => {
|
||||
if (chart && !chart.isDisposed()) {
|
||||
chart.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// 清空缓存
|
||||
chartInstanceCache.clear();
|
||||
dataCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
*/
|
||||
export function getCacheStats() {
|
||||
return {
|
||||
chartInstances: chartInstanceCache.size,
|
||||
dataCache: dataCache.size,
|
||||
memoryUsage: {
|
||||
charts: chartInstanceCache.size * 0.1, // 估算MB
|
||||
data: dataCache.size * 0.05 // 估算MB
|
||||
}
|
||||
};
|
||||
}
|
||||
581
admin-system/src/utils/dataService.js
Normal file
581
admin-system/src/utils/dataService.js
Normal file
@@ -0,0 +1,581 @@
|
||||
/**
|
||||
* 数据服务工具
|
||||
* 封装了与后端数据相关的API请求
|
||||
*/
|
||||
|
||||
import { api } from './api';
|
||||
|
||||
/**
|
||||
* 养殖场数据服务
|
||||
*/
|
||||
export const farmService = {
|
||||
/**
|
||||
* 获取所有养殖场
|
||||
* @returns {Promise<Array>} 养殖场列表
|
||||
*/
|
||||
async getAllFarms() {
|
||||
const response = await api.get('/farms/public');
|
||||
const farms = response && response.success ? response.data : response;
|
||||
|
||||
// 标准化location字段格式
|
||||
return farms.map(farm => {
|
||||
if (farm.location) {
|
||||
// 如果location是字符串,尝试解析为JSON
|
||||
if (typeof farm.location === 'string') {
|
||||
try {
|
||||
farm.location = JSON.parse(farm.location);
|
||||
} catch (error) {
|
||||
console.warn(`解析养殖场 ${farm.id} 的location字段失败:`, error);
|
||||
farm.location = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 标准化location字段格式,支持latitude/longitude和lat/lng
|
||||
if (farm.location) {
|
||||
if (farm.location.latitude && farm.location.longitude) {
|
||||
// 将latitude/longitude转换为lat/lng
|
||||
farm.location.lat = farm.location.latitude;
|
||||
farm.location.lng = farm.location.longitude;
|
||||
} else if (farm.location.lat && farm.location.lng) {
|
||||
// 已经是lat/lng格式,保持不变
|
||||
} else {
|
||||
console.warn(`养殖场 ${farm.id} 的location字段格式不正确:`, farm.location);
|
||||
farm.location = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return farm;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取养殖场详情
|
||||
* @param {string} id - 养殖场ID
|
||||
* @returns {Promise<Object>} 养殖场详情
|
||||
*/
|
||||
async getFarmById(id) {
|
||||
const response = await api.get(`/farms/${id}`);
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建养殖场
|
||||
* @param {Object} farmData - 养殖场数据
|
||||
* @returns {Promise<Object>} 创建的养殖场
|
||||
*/
|
||||
async createFarm(farmData) {
|
||||
return api.post('/farms', farmData);
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新养殖场
|
||||
* @param {string} id - 养殖场ID
|
||||
* @param {Object} farmData - 养殖场数据
|
||||
* @returns {Promise<Object>} 更新后的养殖场
|
||||
*/
|
||||
async updateFarm(id, farmData) {
|
||||
return api.put(`/farms/${id}`, farmData);
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除养殖场
|
||||
* @param {string} id - 养殖场ID
|
||||
* @returns {Promise<Object>} 删除结果
|
||||
*/
|
||||
async deleteFarm(id) {
|
||||
return api.delete(`/farms/${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 动物数据服务
|
||||
*/
|
||||
export const animalService = {
|
||||
/**
|
||||
* 获取所有动物
|
||||
* @returns {Promise<Array>} 动物列表
|
||||
*/
|
||||
async getAllAnimals() {
|
||||
const response = await api.get('/animals/public');
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取养殖场的所有动物
|
||||
* @param {string} farmId - 养殖场ID
|
||||
* @returns {Promise<Array>} 动物列表
|
||||
*/
|
||||
async getAnimalsByFarm(farmId) {
|
||||
const response = await api.get(`/farms/${farmId}/animals`);
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取动物详情
|
||||
* @param {string} id - 动物ID
|
||||
* @returns {Promise<Object>} 动物详情
|
||||
*/
|
||||
async getAnimalById(id) {
|
||||
const response = await api.get(`/animals/${id}`);
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建动物
|
||||
* @param {Object} animalData - 动物数据
|
||||
* @returns {Promise<Object>} 创建的动物
|
||||
*/
|
||||
async createAnimal(animalData) {
|
||||
return api.post('/animals', animalData);
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新动物
|
||||
* @param {string} id - 动物ID
|
||||
* @param {Object} animalData - 动物数据
|
||||
* @returns {Promise<Object>} 更新后的动物
|
||||
*/
|
||||
async updateAnimal(id, animalData) {
|
||||
return api.put(`/animals/${id}`, animalData);
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除动物
|
||||
* @param {string} id - 动物ID
|
||||
* @returns {Promise<Object>} 删除结果
|
||||
*/
|
||||
async deleteAnimal(id) {
|
||||
return api.delete(`/animals/${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 设备数据服务
|
||||
*/
|
||||
export const deviceService = {
|
||||
/**
|
||||
* 获取所有设备
|
||||
* @returns {Promise<Array>} 设备列表
|
||||
*/
|
||||
async getAllDevices() {
|
||||
const response = await api.get('/devices/public');
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取养殖场的所有设备
|
||||
* @param {string} farmId - 养殖场ID
|
||||
* @returns {Promise<Array>} 设备列表
|
||||
*/
|
||||
async getDevicesByFarm(farmId) {
|
||||
const response = await api.get(`/farms/${farmId}/devices`);
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取设备详情
|
||||
* @param {string} id - 设备ID
|
||||
* @returns {Promise<Object>} 设备详情
|
||||
*/
|
||||
async getDeviceById(id) {
|
||||
const response = await api.get(`/devices/${id}`);
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取设备状态
|
||||
* @param {string} id - 设备ID
|
||||
* @returns {Promise<Object>} 设备状态
|
||||
*/
|
||||
async getDeviceStatus(id) {
|
||||
const response = await api.get(`/devices/${id}/status`);
|
||||
return response && response.success ? response.data : response;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 预警数据服务
|
||||
*/
|
||||
export const alertService = {
|
||||
/**
|
||||
* 获取所有预警
|
||||
* @returns {Promise<Array>} 预警列表
|
||||
*/
|
||||
async getAllAlerts() {
|
||||
const response = await api.get('/alerts/public');
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取养殖场的所有预警
|
||||
* @param {string} farmId - 养殖场ID
|
||||
* @returns {Promise<Array>} 预警列表
|
||||
*/
|
||||
async getAlertsByFarm(farmId) {
|
||||
const response = await api.get(`/farms/${farmId}/alerts`);
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取预警详情
|
||||
* @param {string} id - 预警ID
|
||||
* @returns {Promise<Object>} 预警详情
|
||||
*/
|
||||
async getAlertById(id) {
|
||||
const response = await api.get(`/alerts/${id}`);
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理预警
|
||||
* @param {string} id - 预警ID
|
||||
* @param {Object} data - 处理数据
|
||||
* @returns {Promise<Object>} 处理结果
|
||||
*/
|
||||
async handleAlert(id, data) {
|
||||
return api.put(`/alerts/${id}/handle`, data);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 菜单数据服务
|
||||
*/
|
||||
export const menuService = {
|
||||
/**
|
||||
* 获取所有菜单
|
||||
* @returns {Promise<Array>} 菜单列表
|
||||
*/
|
||||
async getAllMenus() {
|
||||
const response = await api.get('/menus', { params: { pageSize: 1000 } });
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取菜单详情
|
||||
* @param {string} id - 菜单ID
|
||||
* @returns {Promise<Object>} 菜单详情
|
||||
*/
|
||||
async getMenuById(id) {
|
||||
const response = await api.get(`/menus/public/${id}`);
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取角色的菜单权限
|
||||
* @param {string} roleId - 角色ID
|
||||
* @returns {Promise<Object>} 角色菜单权限
|
||||
*/
|
||||
async getRoleMenus(roleId) {
|
||||
const response = await api.get(`/menus/public/role/${roleId}`);
|
||||
return response && response.success ? response.data : response;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 角色权限数据服务
|
||||
*/
|
||||
export const rolePermissionService = {
|
||||
/**
|
||||
* 获取角色列表
|
||||
* @param {Object} params - 查询参数
|
||||
* @returns {Promise<Object>} 角色列表
|
||||
*/
|
||||
async getRoles(params = {}) {
|
||||
const response = await api.get('/role-permissions/public/roles', { params });
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取角色详情
|
||||
* @param {string} id - 角色ID
|
||||
* @returns {Promise<Object>} 角色详情
|
||||
*/
|
||||
async getRoleById(id) {
|
||||
const response = await api.get(`/role-permissions/public/roles/${id}`);
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建角色
|
||||
* @param {Object} roleData - 角色数据
|
||||
* @returns {Promise<Object>} 创建结果
|
||||
*/
|
||||
async createRole(roleData) {
|
||||
const response = await api.post('/role-permissions/roles', roleData);
|
||||
return response && response.success ? response : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新角色
|
||||
* @param {string} id - 角色ID
|
||||
* @param {Object} roleData - 角色数据
|
||||
* @returns {Promise<Object>} 更新结果
|
||||
*/
|
||||
async updateRole(id, roleData) {
|
||||
const response = await api.put(`/role-permissions/roles/${id}`, roleData);
|
||||
return response && response.success ? response : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除角色
|
||||
* @param {string} id - 角色ID
|
||||
* @returns {Promise<Object>} 删除结果
|
||||
*/
|
||||
async deleteRole(id) {
|
||||
const response = await api.delete(`/role-permissions/roles/${id}`);
|
||||
return response && response.success ? response : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取权限菜单树
|
||||
* @returns {Promise<Object>} 权限菜单树
|
||||
*/
|
||||
async getPermissionTree() {
|
||||
const response = await api.get('/role-permissions/public/menus');
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取角色的菜单权限
|
||||
* @param {string} roleId - 角色ID
|
||||
* @returns {Promise<Object>} 角色菜单权限
|
||||
*/
|
||||
async getRoleMenuPermissions(roleId) {
|
||||
const response = await api.get(`/role-permissions/public/roles/${roleId}/menus`);
|
||||
return response && response.success ? response : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置角色菜单权限
|
||||
* @param {string} roleId - 角色ID
|
||||
* @param {Object} permissionData - 权限数据
|
||||
* @returns {Promise<Object>} 设置结果
|
||||
*/
|
||||
async setRoleMenuPermissions(roleId, permissionData) {
|
||||
const response = await api.post(`/role-permissions/roles/${roleId}/menus`, permissionData);
|
||||
return response && response.success ? response : response;
|
||||
},
|
||||
|
||||
// 获取所有功能权限
|
||||
async getAllPermissions(params = {}) {
|
||||
const response = await api.get('/role-permissions/public/permissions', { params });
|
||||
return response && response.success ? response : response;
|
||||
},
|
||||
|
||||
// 获取权限模块列表
|
||||
async getPermissionModules() {
|
||||
const response = await api.get('/role-permissions/public/permissions/modules');
|
||||
return response && response.success ? response : response;
|
||||
},
|
||||
|
||||
// 获取角色功能权限
|
||||
async getRoleFunctionPermissions(roleId) {
|
||||
const response = await api.get(`/role-permissions/public/roles/${roleId}/permissions`);
|
||||
return response && response.success ? response : response;
|
||||
},
|
||||
|
||||
// 设置角色功能权限
|
||||
async setRoleFunctionPermissions(roleId, data) {
|
||||
const response = await api.post(`/role-permissions/roles/${roleId}/permissions`, data);
|
||||
return response && response.success ? response : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 切换角色状态
|
||||
* @param {string} id - 角色ID
|
||||
* @param {Object} statusData - 状态数据
|
||||
* @returns {Promise<Object>} 切换结果
|
||||
*/
|
||||
async toggleRoleStatus(id, statusData) {
|
||||
const response = await api.put(`/role-permissions/roles/${id}/status`, statusData);
|
||||
return response && response.success ? response : response;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 智能预警数据服务
|
||||
*/
|
||||
export const smartAlertService = {
|
||||
/**
|
||||
* 获取智能预警统计
|
||||
* @returns {Promise<Object>} 智能预警统计数据
|
||||
*/
|
||||
async getSmartAlertStats() {
|
||||
const response = await api.get('/smart-alerts/public/stats');
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取智能耳标预警列表
|
||||
* @param {Object} params - 查询参数
|
||||
* @returns {Promise<Object>} 智能耳标预警列表响应
|
||||
*/
|
||||
async getEartagAlerts(params = {}) {
|
||||
const response = await api.get('/smart-alerts/public/eartag', { params });
|
||||
return response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取智能项圈预警列表
|
||||
* @param {Object} params - 查询参数
|
||||
* @returns {Promise<Object>} 智能项圈预警列表响应
|
||||
*/
|
||||
async getCollarAlerts(params = {}) {
|
||||
const response = await api.get('/smart-alerts/public/collar', { params });
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 智能设备数据服务(基于iot_jbq_client表)
|
||||
*/
|
||||
export const smartDeviceService = {
|
||||
/**
|
||||
* 获取所有智能设备
|
||||
* @returns {Promise<Array>} 智能设备列表
|
||||
*/
|
||||
async getAllSmartDevices() {
|
||||
const response = await api.get('/smart-devices/public/eartags?limit=1000');
|
||||
// 后端返回的数据结构是 { success: true, data: { list: [...] } }
|
||||
if (response && response.success && response.data && response.data.list) {
|
||||
return response.data.list;
|
||||
}
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取智能设备详情
|
||||
* @param {string} id - 设备ID
|
||||
* @returns {Promise<Object>} 设备详情
|
||||
*/
|
||||
async getSmartDeviceById(id) {
|
||||
const response = await api.get(`/smart-devices/eartags/search/${id}`);
|
||||
return response && response.success ? response.data : response;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 牛只数据服务(基于iot_cattle表)
|
||||
*/
|
||||
export const cattleService = {
|
||||
/**
|
||||
* 获取所有牛只
|
||||
* @returns {Promise<Array>} 牛只列表
|
||||
*/
|
||||
async getAllCattle() {
|
||||
const response = await api.get('/iot-cattle/public?pageSize=1000');
|
||||
return response && response.success ? response.data.list : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取牛只详情
|
||||
* @param {string} id - 牛只ID
|
||||
* @returns {Promise<Object>} 牛只详情
|
||||
*/
|
||||
async getCattleById(id) {
|
||||
const response = await api.get(`/iot-cattle/public/${id}`);
|
||||
return response && response.success ? response.data : response;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 栏舍数据服务(基于cattle_pens表)
|
||||
*/
|
||||
export const penService = {
|
||||
/**
|
||||
* 获取所有栏舍
|
||||
* @returns {Promise<Array>} 栏舍列表
|
||||
*/
|
||||
async getAllPens() {
|
||||
const response = await api.get('/cattle-pens/public?pageSize=1000');
|
||||
return response && response.success ? response.data.list : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取栏舍详情
|
||||
* @param {string} id - 栏舍ID
|
||||
* @returns {Promise<Object>} 栏舍详情
|
||||
*/
|
||||
async getPenById(id) {
|
||||
const response = await api.get(`/cattle-pens/public/${id}`);
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建栏舍
|
||||
* @param {Object} penData - 栏舍数据
|
||||
* @returns {Promise<Object>} 创建的栏舍
|
||||
*/
|
||||
async createPen(penData) {
|
||||
return api.post('/cattle-pens', penData);
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新栏舍
|
||||
* @param {string} id - 栏舍ID
|
||||
* @param {Object} penData - 栏舍数据
|
||||
* @returns {Promise<Object>} 更新后的栏舍
|
||||
*/
|
||||
async updatePen(id, penData) {
|
||||
return api.put(`/cattle-pens/${id}`, penData);
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除栏舍
|
||||
* @param {string} id - 栏舍ID
|
||||
* @returns {Promise<Object>} 删除结果
|
||||
*/
|
||||
async deletePen(id) {
|
||||
return api.delete(`/cattle-pens/${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 统计数据服务
|
||||
*/
|
||||
export const statsService = {
|
||||
/**
|
||||
* 获取系统概览统计数据
|
||||
* @returns {Promise<Object>} 统计数据
|
||||
*/
|
||||
async getDashboardStats() {
|
||||
const response = await api.get('/stats/public/dashboard');
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取养殖场统计数据
|
||||
* @returns {Promise<Object>} 统计数据
|
||||
*/
|
||||
async getFarmStats() {
|
||||
const response = await api.get('/stats/farms');
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取动物统计数据
|
||||
* @returns {Promise<Object>} 统计数据
|
||||
*/
|
||||
async getAnimalStats() {
|
||||
const response = await api.get('/stats/animals');
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取设备统计数据
|
||||
* @returns {Promise<Object>} 统计数据
|
||||
*/
|
||||
async getDeviceStats() {
|
||||
const response = await api.get('/stats/devices');
|
||||
return response && response.success ? response.data : response;
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取预警统计数据
|
||||
* @returns {Promise<Object>} 统计数据
|
||||
*/
|
||||
async getAlertStats() {
|
||||
const response = await api.get('/stats/alerts');
|
||||
return response && response.success ? response.data : response;
|
||||
}
|
||||
};
|
||||
447
admin-system/src/utils/exportUtils.js
Normal file
447
admin-system/src/utils/exportUtils.js
Normal file
@@ -0,0 +1,447 @@
|
||||
import * as XLSX from 'xlsx'
|
||||
import { saveAs } from 'file-saver'
|
||||
|
||||
/**
|
||||
* 通用Excel导出工具类
|
||||
*/
|
||||
export class ExportUtils {
|
||||
/**
|
||||
* 导出数据到Excel文件
|
||||
* @param {Array} data - 要导出的数据数组
|
||||
* @param {Array} columns - 列配置数组
|
||||
* @param {String} filename - 文件名(不包含扩展名)
|
||||
* @param {String} sheetName - 工作表名称,默认为'Sheet1'
|
||||
*/
|
||||
static exportToExcel(data, columns, filename, sheetName = 'Sheet1') {
|
||||
try {
|
||||
// 验证参数
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error('数据必须是数组格式')
|
||||
}
|
||||
if (!Array.isArray(columns)) {
|
||||
throw new Error('列配置必须是数组格式')
|
||||
}
|
||||
if (!filename) {
|
||||
throw new Error('文件名不能为空')
|
||||
}
|
||||
|
||||
// 准备Excel数据
|
||||
const excelData = this.prepareExcelData(data, columns)
|
||||
|
||||
// 创建工作簿
|
||||
const workbook = XLSX.utils.book_new()
|
||||
|
||||
// 创建工作表
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(excelData)
|
||||
|
||||
// 设置列宽
|
||||
const colWidths = this.calculateColumnWidths(excelData)
|
||||
worksheet['!cols'] = colWidths
|
||||
|
||||
// 添加工作表到工作簿
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
|
||||
|
||||
// 生成Excel文件并下载
|
||||
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' })
|
||||
const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
|
||||
// 添加时间戳到文件名
|
||||
const timestamp = new Date().toISOString().slice(0, 19).replace(/[-:]/g, '').replace('T', '_')
|
||||
const finalFilename = `${filename}_${timestamp}.xlsx`
|
||||
|
||||
saveAs(blob, finalFilename)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '导出成功',
|
||||
filename: finalFilename
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导出Excel失败:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: `导出失败: ${error.message}`,
|
||||
error: error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备Excel数据格式
|
||||
*/
|
||||
static prepareExcelData(data, columns) {
|
||||
// 第一行:列标题
|
||||
const headers = columns.map(col => col.title || col.dataIndex || col.key)
|
||||
const excelData = [headers]
|
||||
|
||||
// 数据行
|
||||
data.forEach(item => {
|
||||
const row = columns.map(col => {
|
||||
const fieldName = col.dataIndex || col.key
|
||||
let value = item[fieldName]
|
||||
|
||||
// 处理特殊数据类型
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 处理日期时间
|
||||
if (col.dataType === 'datetime' && value) {
|
||||
return new Date(value).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 处理布尔值
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? '是' : '否'
|
||||
}
|
||||
|
||||
// 处理数组
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(', ')
|
||||
}
|
||||
|
||||
// 处理对象
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
return String(value)
|
||||
})
|
||||
excelData.push(row)
|
||||
})
|
||||
|
||||
return excelData
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算列宽
|
||||
*/
|
||||
static calculateColumnWidths(excelData) {
|
||||
if (!excelData || excelData.length === 0) return []
|
||||
|
||||
const colCount = excelData[0].length
|
||||
const colWidths = []
|
||||
|
||||
for (let i = 0; i < colCount; i++) {
|
||||
let maxWidth = 10 // 最小宽度
|
||||
|
||||
excelData.forEach(row => {
|
||||
if (row[i]) {
|
||||
const cellWidth = String(row[i]).length
|
||||
maxWidth = Math.max(maxWidth, cellWidth)
|
||||
}
|
||||
})
|
||||
|
||||
// 限制最大宽度
|
||||
colWidths.push({ wch: Math.min(maxWidth + 2, 50) })
|
||||
}
|
||||
|
||||
return colWidths
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出智能设备数据
|
||||
*/
|
||||
static exportDeviceData(data, deviceType) {
|
||||
const deviceTypeMap = {
|
||||
collar: { name: '智能项圈', columns: this.getCollarColumns() },
|
||||
eartag: { name: '智能耳标', columns: this.getEartagColumns() },
|
||||
host: { name: '智能主机', columns: this.getHostColumns() }
|
||||
}
|
||||
|
||||
const config = deviceTypeMap[deviceType]
|
||||
if (!config) {
|
||||
throw new Error(`不支持的设备类型: ${deviceType}`)
|
||||
}
|
||||
|
||||
return this.exportToExcel(data, config.columns, config.name + '数据')
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出预警数据
|
||||
*/
|
||||
static exportAlertData(data, alertType) {
|
||||
const alertTypeMap = {
|
||||
collar: { name: '智能项圈预警', columns: this.getCollarAlertColumns() },
|
||||
eartag: { name: '智能耳标预警', columns: this.getEartagAlertColumns() }
|
||||
}
|
||||
|
||||
const config = alertTypeMap[alertType]
|
||||
if (!config) {
|
||||
throw new Error(`不支持的预警类型: ${alertType}`)
|
||||
}
|
||||
|
||||
return this.exportToExcel(data, config.columns, config.name + '数据')
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出牛只档案数据
|
||||
*/
|
||||
static exportCattleData(data) {
|
||||
return this.exportToExcel(data, this.getCattleColumns(), '牛只档案数据')
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出动物数据(别名方法,与exportCattleData相同)
|
||||
*/
|
||||
static exportAnimalsData(data) {
|
||||
return this.exportCattleData(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出栏舍数据
|
||||
*/
|
||||
static exportPenData(data) {
|
||||
return this.exportToExcel(data, this.getPenColumns(), '栏舍数据')
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出批次数据
|
||||
*/
|
||||
static exportBatchData(data) {
|
||||
return this.exportToExcel(data, this.getBatchColumns(), '批次数据')
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出转栏记录数据
|
||||
*/
|
||||
static exportTransferData(data) {
|
||||
return this.exportToExcel(data, this.getTransferColumns(), '转栏记录数据')
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出离栏记录数据
|
||||
*/
|
||||
static exportExitData(data) {
|
||||
return this.exportToExcel(data, this.getExitColumns(), '离栏记录数据')
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出养殖场数据
|
||||
*/
|
||||
static exportFarmData(data) {
|
||||
return this.exportToExcel(data, this.getFarmColumns(), '养殖场数据')
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用户数据
|
||||
*/
|
||||
static exportUserData(data) {
|
||||
return this.exportToExcel(data, this.getUserColumns(), '用户数据')
|
||||
}
|
||||
|
||||
// 列配置定义
|
||||
static getCollarColumns() {
|
||||
return [
|
||||
{ title: '设备ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '设备名称', dataIndex: 'device_name', key: 'device_name' },
|
||||
{ title: '设备编号', dataIndex: 'device_code', key: 'device_code' },
|
||||
{ title: '设备状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '电量', dataIndex: 'battery_level', key: 'battery_level' },
|
||||
{ title: '信号强度', dataIndex: 'signal_strength', key: 'signal_strength' },
|
||||
{ title: '最后在线时间', dataIndex: 'last_online', key: 'last_online', dataType: 'datetime' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', dataType: 'datetime' }
|
||||
]
|
||||
}
|
||||
|
||||
static getEartagColumns() {
|
||||
return [
|
||||
{ title: '设备ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '耳标编号', dataIndex: 'eartagNumber', key: 'eartagNumber' },
|
||||
{ title: '设备状态', dataIndex: 'deviceStatus', key: 'deviceStatus' },
|
||||
{ title: '电量/%', dataIndex: 'battery', key: 'battery' },
|
||||
{ title: '设备温度/°C', dataIndex: 'temperature', key: 'temperature' },
|
||||
{ title: '被采集主机', dataIndex: 'collectedHost', key: 'collectedHost' },
|
||||
{ title: '总运动量', dataIndex: 'totalMovement', key: 'totalMovement' },
|
||||
{ title: '当日运动量', dataIndex: 'dailyMovement', key: 'dailyMovement' },
|
||||
{ title: '定位信息', dataIndex: 'location', key: 'location' },
|
||||
{ title: '数据最后更新时间', dataIndex: 'lastUpdate', key: 'lastUpdate', dataType: 'datetime' },
|
||||
{ title: '绑定牲畜', dataIndex: 'bindingStatus', key: 'bindingStatus' },
|
||||
{ title: 'GPS信号强度', dataIndex: 'gpsSignal', key: 'gpsSignal' },
|
||||
{ title: '经度', dataIndex: 'longitude', key: 'longitude' },
|
||||
{ title: '纬度', dataIndex: 'latitude', key: 'latitude' }
|
||||
]
|
||||
}
|
||||
|
||||
static getHostColumns() {
|
||||
return [
|
||||
{ title: '设备ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '设备名称', dataIndex: 'device_name', key: 'device_name' },
|
||||
{ title: '设备编号', dataIndex: 'device_code', key: 'device_code' },
|
||||
{ title: '设备状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: 'IP地址', dataIndex: 'ip_address', key: 'ip_address' },
|
||||
{ title: '端口', dataIndex: 'port', key: 'port' },
|
||||
{ title: '最后在线时间', dataIndex: 'last_online', key: 'last_online', dataType: 'datetime' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', dataType: 'datetime' }
|
||||
]
|
||||
}
|
||||
|
||||
static getCollarAlertColumns() {
|
||||
return [
|
||||
{ title: '耳标编号', dataIndex: 'collarNumber', key: 'collarNumber' },
|
||||
{ title: '预警类型', dataIndex: 'alertType', key: 'alertType' },
|
||||
{ title: '预警级别', dataIndex: 'alertLevel', key: 'alertLevel' },
|
||||
{ title: '预警时间', dataIndex: 'alertTime', key: 'alertTime', dataType: 'datetime' },
|
||||
{ title: '设备电量', dataIndex: 'battery', key: 'battery' },
|
||||
{ title: '设备温度', dataIndex: 'temperature', key: 'temperature' },
|
||||
{ title: '当日步数', dataIndex: 'dailySteps', key: 'dailySteps' }
|
||||
]
|
||||
}
|
||||
|
||||
static getEartagAlertColumns() {
|
||||
return [
|
||||
{ title: '设备编号', dataIndex: 'device_name', key: 'device_name' },
|
||||
{ title: '预警类型', dataIndex: 'alert_type', key: 'alert_type' },
|
||||
{ title: '预警级别', dataIndex: 'alert_level', key: 'alert_level' },
|
||||
{ title: '预警内容', dataIndex: 'alert_content', key: 'alert_content' },
|
||||
{ title: '预警时间', dataIndex: 'alert_time', key: 'alert_time', dataType: 'datetime' },
|
||||
{ title: '处理状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '处理人', dataIndex: 'handler', key: 'handler' }
|
||||
]
|
||||
}
|
||||
|
||||
static getCattleColumns() {
|
||||
return [
|
||||
{ title: '牛只ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '耳标号', dataIndex: 'ear_tag', key: 'ear_tag' },
|
||||
{ title: '智能佩戴设备', dataIndex: 'device_id', key: 'device_id' },
|
||||
{ title: '出生日期', dataIndex: 'birth_date', key: 'birth_date', dataType: 'datetime' },
|
||||
{ title: '月龄', dataIndex: 'age_in_months', key: 'age_in_months' },
|
||||
{ title: '品类', dataIndex: 'category', key: 'category' },
|
||||
{ title: '品种', dataIndex: 'breed', key: 'breed' },
|
||||
{ title: '生理阶段', dataIndex: 'physiological_stage', key: 'physiological_stage' },
|
||||
{ title: '性别', dataIndex: 'gender', key: 'gender' },
|
||||
{ title: '出生体重(KG)', dataIndex: 'birth_weight', key: 'birth_weight' },
|
||||
{ title: '现估值(KG)', dataIndex: 'current_weight', key: 'current_weight' },
|
||||
{ title: '代数', dataIndex: 'generation', key: 'generation' },
|
||||
{ title: '血统纯度', dataIndex: 'bloodline_purity', key: 'bloodline_purity' },
|
||||
{ title: '入场日期', dataIndex: 'entry_date', key: 'entry_date', dataType: 'datetime' },
|
||||
{ title: '栏舍', dataIndex: 'pen_name', key: 'pen_name' },
|
||||
{ title: '已产胎次', dataIndex: 'calvings', key: 'calvings' },
|
||||
{ title: '来源', dataIndex: 'source', key: 'source' },
|
||||
{ title: '冻精编号', dataIndex: 'frozen_semen_number', key: 'frozen_semen_number' }
|
||||
]
|
||||
}
|
||||
|
||||
static getPenColumns() {
|
||||
return [
|
||||
{ title: '栏舍ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '栏舍名', dataIndex: 'name', key: 'name' },
|
||||
{ title: '动物类型', dataIndex: 'animal_type', key: 'animal_type' },
|
||||
{ title: '栏舍类型', dataIndex: 'pen_type', key: 'pen_type' },
|
||||
{ title: '负责人', dataIndex: 'responsible', key: 'responsible' },
|
||||
{ title: '容量', dataIndex: 'capacity', key: 'capacity' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '创建人', dataIndex: 'creator', key: 'creator' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', dataType: 'datetime' }
|
||||
]
|
||||
}
|
||||
|
||||
static getBatchColumns() {
|
||||
return [
|
||||
{ title: '批次ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '批次名称', dataIndex: 'batch_name', key: 'batch_name' },
|
||||
{ title: '批次类型', dataIndex: 'batch_type', key: 'batch_type' },
|
||||
{ title: '目标数量', dataIndex: 'target_count', key: 'target_count' },
|
||||
{ title: '当前数量', dataIndex: 'current_count', key: 'current_count' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', dataType: 'datetime' },
|
||||
{ title: '负责人', dataIndex: 'manager', key: 'manager' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' }
|
||||
]
|
||||
}
|
||||
|
||||
static getTransferColumns() {
|
||||
return [
|
||||
{ title: '记录ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '牛只耳标', dataIndex: 'ear_tag', key: 'ear_tag' },
|
||||
{ title: '原栏舍', dataIndex: 'from_pen', key: 'from_pen' },
|
||||
{ title: '目标栏舍', dataIndex: 'to_pen', key: 'to_pen' },
|
||||
{ title: '转栏时间', dataIndex: 'transfer_time', key: 'transfer_time', dataType: 'datetime' },
|
||||
{ title: '转栏原因', dataIndex: 'reason', key: 'reason' },
|
||||
{ title: '操作人', dataIndex: 'operator', key: 'operator' }
|
||||
]
|
||||
}
|
||||
|
||||
static getExitColumns() {
|
||||
return [
|
||||
{ title: '记录ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '牛只耳标', dataIndex: 'ear_tag', key: 'ear_tag' },
|
||||
{ title: '原栏舍', dataIndex: 'from_pen', key: 'from_pen' },
|
||||
{ title: '离栏时间', dataIndex: 'exit_time', key: 'exit_time', dataType: 'datetime' },
|
||||
{ title: '离栏原因', dataIndex: 'exit_reason', key: 'exit_reason' },
|
||||
{ title: '去向', dataIndex: 'destination', key: 'destination' },
|
||||
{ title: '操作人', dataIndex: 'operator', key: 'operator' }
|
||||
]
|
||||
}
|
||||
|
||||
static getFarmColumns() {
|
||||
return [
|
||||
{ title: '养殖场ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '养殖场名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '地址', dataIndex: 'address', key: 'address' },
|
||||
{ title: '联系电话', dataIndex: 'phone', key: 'phone' },
|
||||
{ title: '负责人', dataIndex: 'contact', key: 'contact' },
|
||||
{ title: '面积', dataIndex: 'area', key: 'area' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', dataType: 'datetime' }
|
||||
]
|
||||
}
|
||||
|
||||
static getUserColumns() {
|
||||
return [
|
||||
{ title: '用户ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username' },
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email' },
|
||||
{ title: '角色', dataIndex: 'roleName', key: 'roleName' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '最后登录', dataIndex: 'last_login', key: 'last_login', dataType: 'datetime' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', dataType: 'datetime' }
|
||||
]
|
||||
}
|
||||
|
||||
// 其他导出方法(为了兼容性)
|
||||
static exportFarmsData(data) {
|
||||
return this.exportFarmData(data)
|
||||
}
|
||||
|
||||
static exportAlertsData(data) {
|
||||
return this.exportAlertData(data, 'collar') // 默认使用collar类型
|
||||
}
|
||||
|
||||
static exportDevicesData(data) {
|
||||
return this.exportDeviceData(data, 'collar') // 默认使用collar类型
|
||||
}
|
||||
|
||||
static exportOrdersData(data) {
|
||||
return this.exportToExcel(data, this.getOrderColumns(), '订单数据')
|
||||
}
|
||||
|
||||
static exportProductsData(data) {
|
||||
return this.exportToExcel(data, this.getProductColumns(), '产品数据')
|
||||
}
|
||||
|
||||
static getOrderColumns() {
|
||||
return [
|
||||
{ title: '订单ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '订单号', dataIndex: 'order_number', key: 'order_number' },
|
||||
{ title: '客户名称', dataIndex: 'customer_name', key: 'customer_name' },
|
||||
{ title: '订单金额', dataIndex: 'total_amount', key: 'total_amount' },
|
||||
{ title: '订单状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', dataType: 'datetime' }
|
||||
]
|
||||
}
|
||||
|
||||
static getProductColumns() {
|
||||
return [
|
||||
{ title: '产品ID', dataIndex: 'id', key: 'id' },
|
||||
{ title: '产品名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '产品类型', dataIndex: 'type', key: 'type' },
|
||||
{ title: '价格', dataIndex: 'price', key: 'price' },
|
||||
{ title: '库存', dataIndex: 'stock', key: 'stock' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', dataType: 'datetime' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export default ExportUtils
|
||||
664
admin-system/src/utils/mapService.js
Normal file
664
admin-system/src/utils/mapService.js
Normal file
@@ -0,0 +1,664 @@
|
||||
/**
|
||||
* 百度地图服务工具
|
||||
* 封装了百度地图API的初始化和操作方法
|
||||
*/
|
||||
|
||||
// 百度地图API加载状态
|
||||
let BMapLoaded = false;
|
||||
let loadingPromise = null;
|
||||
|
||||
/**
|
||||
* 加载百度地图API
|
||||
* @param {number} retryCount 重试次数
|
||||
* @returns {Promise} 加载完成的Promise
|
||||
*/
|
||||
export const loadBMapScript = async (retryCount = 0) => {
|
||||
// 如果已经加载过,直接返回
|
||||
if (BMapLoaded) {
|
||||
console.log('百度地图API已加载');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 如果正在加载中,返回加载Promise
|
||||
if (loadingPromise) {
|
||||
console.log('百度地图API正在加载中...');
|
||||
return loadingPromise;
|
||||
}
|
||||
|
||||
console.log(`开始加载百度地图API... (重试次数: ${retryCount})`);
|
||||
|
||||
// 创建加载Promise
|
||||
loadingPromise = new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
// 导入环境配置
|
||||
const { BAIDU_MAP_CONFIG } = await import('../config/env');
|
||||
|
||||
console.log('使用API密钥:', BAIDU_MAP_CONFIG.apiKey);
|
||||
console.log('完整配置:', BAIDU_MAP_CONFIG);
|
||||
|
||||
// 检查API密钥是否有效
|
||||
if (!BAIDU_MAP_CONFIG.apiKey || BAIDU_MAP_CONFIG.apiKey === 'YOUR_VALID_BAIDU_MAP_API_KEY') {
|
||||
const error = new Error('百度地图API密钥未配置或无效');
|
||||
console.error('API密钥错误:', error);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建API URL,包含Referer参数
|
||||
let apiUrl = `https://api.map.baidu.com/api?v=3.0&ak=${BAIDU_MAP_CONFIG.apiKey}`;
|
||||
|
||||
// 如果启用Referer校验,添加当前域名
|
||||
if (BAIDU_MAP_CONFIG.enableRefererCheck) {
|
||||
const currentDomain = window.location.hostname;
|
||||
apiUrl += `&callback=initBMap&referer=${encodeURIComponent(currentDomain)}`;
|
||||
} else {
|
||||
apiUrl += '&callback=initBMap';
|
||||
}
|
||||
|
||||
console.log('百度地图API URL:', apiUrl);
|
||||
|
||||
// 检查是否已经存在BMap
|
||||
if (typeof window.BMap !== 'undefined') {
|
||||
console.log('BMap已存在,直接使用');
|
||||
BMapLoaded = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建全局回调函数
|
||||
window.initBMap = () => {
|
||||
console.log('百度地图API加载成功,BMap对象类型:', typeof window.BMap);
|
||||
console.log('BMap对象详情:', window.BMap);
|
||||
console.log('BMap.Map是否存在:', typeof window.BMap?.Map);
|
||||
|
||||
// 清理超时定时器
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// 等待BMap完全初始化
|
||||
setTimeout(() => {
|
||||
// 详细验证BMap对象
|
||||
if (!window.BMap) {
|
||||
console.error('BMap对象不存在');
|
||||
reject(new Error('BMap对象不存在'));
|
||||
delete window.initBMap;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.BMap.Map !== 'function') {
|
||||
console.error('BMap.Map构造函数不可用');
|
||||
reject(new Error('BMap.Map构造函数不可用'));
|
||||
delete window.initBMap;
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.BMap.Point !== 'function') {
|
||||
console.error('BMap.Point构造函数不可用');
|
||||
reject(new Error('BMap.Point构造函数不可用'));
|
||||
delete window.initBMap;
|
||||
return;
|
||||
}
|
||||
|
||||
// 测试创建Point对象
|
||||
try {
|
||||
const testPoint = new window.BMap.Point(0, 0);
|
||||
if (!testPoint || typeof testPoint.lng !== 'number') {
|
||||
throw new Error('Point对象创建失败');
|
||||
}
|
||||
console.log('BMap对象验证通过,版本:', window.BMap.version || '未知');
|
||||
} catch (error) {
|
||||
console.error('BMap对象功能测试失败:', error);
|
||||
reject(new Error(`BMap对象功能测试失败: ${error.message}`));
|
||||
delete window.initBMap;
|
||||
return;
|
||||
}
|
||||
|
||||
BMapLoaded = true;
|
||||
resolve();
|
||||
// 清理全局回调
|
||||
delete window.initBMap;
|
||||
}, 200); // 增加等待时间
|
||||
};
|
||||
|
||||
// 创建script标签
|
||||
const script = document.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
// 使用构建的API URL
|
||||
script.src = apiUrl;
|
||||
|
||||
console.log('百度地图API URL:', script.src);
|
||||
|
||||
script.onerror = async (error) => {
|
||||
console.error('百度地图脚本加载失败:', error);
|
||||
console.error('失败的URL:', script.src);
|
||||
// 清理超时定时器
|
||||
clearTimeout(timeoutId);
|
||||
// 清理script标签
|
||||
if (script.parentNode) {
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
// 清理全局回调
|
||||
if (window.initBMap) {
|
||||
delete window.initBMap;
|
||||
}
|
||||
|
||||
// 尝试使用备用API密钥
|
||||
if (BAIDU_MAP_CONFIG.fallbackApiKey && BAIDU_MAP_CONFIG.fallbackApiKey !== 'YOUR_FALLBACK_API_KEY') {
|
||||
console.log('尝试使用备用API密钥...');
|
||||
try {
|
||||
const fallbackUrl = `https://api.map.baidu.com/api?v=3.0&ak=${BAIDU_MAP_CONFIG.fallbackApiKey}&callback=initBMap`;
|
||||
const fallbackScript = document.createElement('script');
|
||||
fallbackScript.type = 'text/javascript';
|
||||
fallbackScript.src = fallbackUrl;
|
||||
|
||||
fallbackScript.onerror = () => {
|
||||
console.error('备用API密钥也加载失败');
|
||||
if (retryCount < BAIDU_MAP_CONFIG.maxRetries) {
|
||||
retryLoad();
|
||||
} else {
|
||||
reject(new Error('百度地图API加载失败,主密钥和备用密钥都无法使用'));
|
||||
}
|
||||
};
|
||||
|
||||
window.initBMap = () => {
|
||||
console.log('备用API密钥加载成功');
|
||||
BMapLoaded = true;
|
||||
loadingPromise = null;
|
||||
resolve();
|
||||
delete window.initBMap;
|
||||
};
|
||||
|
||||
document.head.appendChild(fallbackScript);
|
||||
return;
|
||||
} catch (fallbackError) {
|
||||
console.error('备用API密钥加载出错:', fallbackError);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否可以重试
|
||||
if (retryCount < BAIDU_MAP_CONFIG.maxRetries) {
|
||||
retryLoad();
|
||||
} else {
|
||||
reject(new Error('百度地图脚本加载失败,已达到最大重试次数'));
|
||||
}
|
||||
|
||||
function retryLoad() {
|
||||
console.log(`准备重试加载百度地图API (${retryCount + 1}/${BAIDU_MAP_CONFIG.maxRetries})`);
|
||||
loadingPromise = null; // 重置加载状态
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await loadBMapScript(retryCount + 1);
|
||||
resolve();
|
||||
} catch (retryError) {
|
||||
reject(retryError);
|
||||
}
|
||||
}, BAIDU_MAP_CONFIG.retryDelay);
|
||||
}
|
||||
};
|
||||
|
||||
// 设置超时
|
||||
const timeoutId = setTimeout(async () => {
|
||||
if (!BMapLoaded) {
|
||||
console.error('百度地图API加载超时');
|
||||
// 清理script标签
|
||||
if (script.parentNode) {
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
// 清理全局回调
|
||||
if (window.initBMap) {
|
||||
delete window.initBMap;
|
||||
}
|
||||
|
||||
// 检查是否可以重试
|
||||
if (retryCount < BAIDU_MAP_CONFIG.maxRetries) {
|
||||
console.log(`准备重试加载百度地图API (${retryCount + 1}/${BAIDU_MAP_CONFIG.maxRetries})`);
|
||||
loadingPromise = null; // 重置加载状态
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await loadBMapScript(retryCount + 1);
|
||||
resolve();
|
||||
} catch (retryError) {
|
||||
reject(retryError);
|
||||
}
|
||||
}, BAIDU_MAP_CONFIG.retryDelay);
|
||||
} else {
|
||||
reject(new Error('百度地图API加载超时,已达到最大重试次数'));
|
||||
}
|
||||
}
|
||||
}, 30000); // 增加超时时间到30秒
|
||||
|
||||
// 添加到文档中
|
||||
document.head.appendChild(script);
|
||||
} catch (error) {
|
||||
console.error('加载百度地图API时出错:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
return loadingPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建百度地图实例
|
||||
* @param {HTMLElement} container - 地图容器元素
|
||||
* @param {Object} options - 地图配置选项
|
||||
* @returns {Promise<BMap.Map>} 地图实例
|
||||
*/
|
||||
export const createMap = async (container, options = {}) => {
|
||||
try {
|
||||
console.log('开始创建地图,容器:', container);
|
||||
console.log('容器尺寸:', container.offsetWidth, 'x', container.offsetHeight);
|
||||
|
||||
// 确保百度地图API已加载
|
||||
await loadBMapScript();
|
||||
|
||||
// 导入环境配置
|
||||
const { BAIDU_MAP_CONFIG } = await import('../config/env');
|
||||
|
||||
console.log('百度地图配置:', BAIDU_MAP_CONFIG);
|
||||
|
||||
// 检查BMap是否可用
|
||||
if (typeof window.BMap === 'undefined' || !window.BMap) {
|
||||
const error = new Error('百度地图API未正确加载,BMap对象不存在');
|
||||
console.error('BMap未定义:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 验证BMap对象的关键属性
|
||||
if (typeof window.BMap.Map !== 'function') {
|
||||
const error = new Error('BMap.Map构造函数不可用');
|
||||
console.error('BMap.Map不可用:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (typeof window.BMap.Point !== 'function') {
|
||||
const error = new Error('BMap.Point构造函数不可用');
|
||||
console.error('BMap.Point不可用:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log('BMap对象可用,版本:', window.BMap.version || '未知');
|
||||
console.log('BMap对象详细信息:', {
|
||||
Map: typeof window.BMap.Map,
|
||||
Point: typeof window.BMap.Point,
|
||||
Marker: typeof window.BMap.Marker,
|
||||
InfoWindow: typeof window.BMap.InfoWindow
|
||||
});
|
||||
|
||||
// 默认配置
|
||||
const defaultOptions = {
|
||||
center: new BMap.Point(BAIDU_MAP_CONFIG.defaultCenter.lng, BAIDU_MAP_CONFIG.defaultCenter.lat), // 宁夏中心点
|
||||
zoom: BAIDU_MAP_CONFIG.defaultZoom, // 默认缩放级别
|
||||
enableMapClick: true, // 启用地图点击
|
||||
enableScrollWheelZoom: true, // 启用滚轮缩放
|
||||
enableDragging: true, // 启用拖拽
|
||||
enableDoubleClickZoom: true, // 启用双击缩放
|
||||
enableKeyboard: true // 启用键盘控制
|
||||
};
|
||||
|
||||
// 合并配置
|
||||
const mergedOptions = { ...defaultOptions, ...options };
|
||||
|
||||
console.log('地图配置选项:', mergedOptions);
|
||||
|
||||
// 验证容器
|
||||
if (!container || !container.offsetWidth || !container.offsetHeight) {
|
||||
throw new Error('地图容器无效或没有设置尺寸');
|
||||
}
|
||||
|
||||
// 验证BMap对象
|
||||
if (!window.BMap || typeof window.BMap.Map !== 'function') {
|
||||
throw new Error('BMap对象未正确加载');
|
||||
}
|
||||
|
||||
// 检查容器及其父级的样式问题
|
||||
let currentElement = container;
|
||||
while (currentElement && currentElement !== document.body) {
|
||||
const computedStyle = window.getComputedStyle(currentElement);
|
||||
console.log('检查元素样式:', {
|
||||
tagName: currentElement.tagName,
|
||||
id: currentElement.id,
|
||||
className: currentElement.className,
|
||||
position: computedStyle.position,
|
||||
display: computedStyle.display,
|
||||
visibility: computedStyle.visibility
|
||||
});
|
||||
|
||||
// 检查是否有问题样式
|
||||
if (computedStyle.position === 'fixed') {
|
||||
console.warn('发现position: fixed的父级元素,可能导致地图初始化失败');
|
||||
}
|
||||
if (computedStyle.display === 'none') {
|
||||
console.warn('发现display: none的父级元素,可能导致地图初始化失败');
|
||||
}
|
||||
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
|
||||
// 临时修复:确保容器有正确的样式
|
||||
const originalStyle = {
|
||||
position: container.style.position,
|
||||
display: container.style.display,
|
||||
visibility: container.style.visibility
|
||||
};
|
||||
|
||||
// 设置临时样式确保地图能正确初始化
|
||||
container.style.position = 'relative';
|
||||
container.style.display = 'block';
|
||||
container.style.visibility = 'visible';
|
||||
|
||||
console.log('创建BMap.Map实例...');
|
||||
console.log('容器信息:', {
|
||||
id: container.id,
|
||||
className: container.className,
|
||||
offsetWidth: container.offsetWidth,
|
||||
offsetHeight: container.offsetHeight,
|
||||
clientWidth: container.clientWidth,
|
||||
clientHeight: container.clientHeight,
|
||||
style: container.style.cssText
|
||||
});
|
||||
|
||||
// 最终验证容器状态
|
||||
if (!container || !container.parentNode) {
|
||||
throw new Error('地图容器无效或已从DOM中移除');
|
||||
}
|
||||
|
||||
if (container.offsetWidth === 0 || container.offsetHeight === 0) {
|
||||
throw new Error('地图容器尺寸为0,无法创建地图');
|
||||
}
|
||||
|
||||
let map;
|
||||
try {
|
||||
// 临时移除所有可能干扰的样式
|
||||
const tempStyles = {};
|
||||
const styleProps = ['position', 'display', 'visibility', 'transform', 'opacity', 'zIndex'];
|
||||
|
||||
styleProps.forEach(prop => {
|
||||
tempStyles[prop] = container.style[prop];
|
||||
container.style[prop] = '';
|
||||
});
|
||||
|
||||
// 确保容器有基本样式
|
||||
container.style.position = 'relative';
|
||||
container.style.display = 'block';
|
||||
container.style.visibility = 'visible';
|
||||
container.style.width = container.offsetWidth + 'px';
|
||||
container.style.height = container.offsetHeight + 'px';
|
||||
|
||||
console.log('创建地图前的容器状态:', {
|
||||
offsetWidth: container.offsetWidth,
|
||||
offsetHeight: container.offsetHeight,
|
||||
style: container.style.cssText
|
||||
});
|
||||
|
||||
// 再次验证BMap对象
|
||||
if (!window.BMap || typeof window.BMap.Map !== 'function') {
|
||||
throw new Error('BMap.Map构造函数不可用');
|
||||
}
|
||||
|
||||
// 创建地图实例,添加错误捕获
|
||||
try {
|
||||
map = new window.BMap.Map(container);
|
||||
console.log('地图实例创建成功:', map);
|
||||
} catch (mapError) {
|
||||
console.error('BMap.Map构造函数调用失败:', mapError);
|
||||
throw new Error(`地图实例创建失败: ${mapError.message}`);
|
||||
}
|
||||
|
||||
// 恢复临时移除的样式
|
||||
styleProps.forEach(prop => {
|
||||
if (tempStyles[prop]) {
|
||||
container.style[prop] = tempStyles[prop];
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('地图实例创建失败:', error);
|
||||
// 恢复所有原始样式
|
||||
container.style.position = originalStyle.position;
|
||||
container.style.display = originalStyle.display;
|
||||
container.style.visibility = originalStyle.visibility;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 恢复原始样式(如果地图创建成功)
|
||||
container.style.position = originalStyle.position;
|
||||
container.style.display = originalStyle.display;
|
||||
container.style.visibility = originalStyle.visibility;
|
||||
|
||||
// 验证地图实例
|
||||
if (!map || typeof map.centerAndZoom !== 'function') {
|
||||
throw new Error('地图实例创建失败');
|
||||
}
|
||||
|
||||
// 设置中心点和缩放级别
|
||||
console.log('设置地图中心点和缩放级别:', mergedOptions.center, mergedOptions.zoom);
|
||||
map.centerAndZoom(mergedOptions.center, mergedOptions.zoom);
|
||||
|
||||
// 监听地图加载完成事件
|
||||
map.addEventListener('tilesloaded', function() {
|
||||
console.log('百度地图瓦片加载完成');
|
||||
});
|
||||
|
||||
map.addEventListener('load', function() {
|
||||
console.log('百度地图完全加载完成');
|
||||
});
|
||||
|
||||
// 强制刷新地图
|
||||
setTimeout(() => {
|
||||
console.log('强制刷新地图');
|
||||
map.reset();
|
||||
}, 100);
|
||||
|
||||
// 延迟确保地图完全渲染
|
||||
setTimeout(() => {
|
||||
console.log('地图渲染完成');
|
||||
// 移除map.reset()调用,避免缩放时重置地图
|
||||
}, 100);
|
||||
|
||||
// 添加地图类型控件
|
||||
console.log('添加地图控件...');
|
||||
map.addControl(new window.BMap.MapTypeControl());
|
||||
|
||||
// 添加增强的缩放控件
|
||||
const navigationControl = new window.BMap.NavigationControl({
|
||||
anchor: window.BMAP_ANCHOR_TOP_LEFT,
|
||||
type: window.BMAP_NAVIGATION_CONTROL_LARGE,
|
||||
enableGeolocation: false
|
||||
});
|
||||
map.addControl(navigationControl);
|
||||
|
||||
// 添加缩放控件(滑块式)
|
||||
const scaleControl = new window.BMap.ScaleControl({
|
||||
anchor: window.BMAP_ANCHOR_BOTTOM_LEFT
|
||||
});
|
||||
map.addControl(scaleControl);
|
||||
|
||||
// 添加概览地图控件
|
||||
const overviewMapControl = new window.BMap.OverviewMapControl({
|
||||
anchor: window.BMAP_ANCHOR_TOP_RIGHT,
|
||||
isOpen: false
|
||||
});
|
||||
map.addControl(overviewMapControl);
|
||||
|
||||
// 设置缩放范围
|
||||
map.setMinZoom(3);
|
||||
map.setMaxZoom(19);
|
||||
|
||||
// 配置地图功能
|
||||
if (mergedOptions.enableScrollWheelZoom) {
|
||||
map.enableScrollWheelZoom();
|
||||
}
|
||||
|
||||
if (mergedOptions.enableDragging) {
|
||||
map.enableDragging();
|
||||
} else {
|
||||
map.disableDragging();
|
||||
}
|
||||
|
||||
if (mergedOptions.enableDoubleClickZoom) {
|
||||
map.enableDoubleClickZoom();
|
||||
} else {
|
||||
map.disableDoubleClickZoom();
|
||||
}
|
||||
|
||||
if (mergedOptions.enableKeyboard) {
|
||||
map.enableKeyboard();
|
||||
} else {
|
||||
map.disableKeyboard();
|
||||
}
|
||||
|
||||
console.log('百度地图创建成功:', map);
|
||||
|
||||
return map;
|
||||
} catch (error) {
|
||||
console.error('创建百度地图失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 在地图上添加标记
|
||||
* @param {BMap.Map} map - 百度地图实例
|
||||
* @param {Array} markers - 标记点数据数组
|
||||
* @param {Function} onClick - 点击标记的回调函数
|
||||
* @returns {Array<BMap.Marker>} 标记点实例数组
|
||||
*/
|
||||
export const addMarkers = (map, markers = [], onClick = null) => {
|
||||
// 验证地图实例是否有效
|
||||
if (!map || typeof map.addOverlay !== 'function') {
|
||||
console.error('addMarkers: 无效的地图实例', map);
|
||||
return [];
|
||||
}
|
||||
|
||||
// 验证BMap对象是否可用
|
||||
if (!window.BMap || typeof window.BMap.Point !== 'function' || typeof window.BMap.Marker !== 'function') {
|
||||
console.error('addMarkers: BMap对象不可用');
|
||||
return [];
|
||||
}
|
||||
|
||||
return markers.map(markerData => {
|
||||
try {
|
||||
// 验证标记数据
|
||||
if (!markerData || !markerData.location || typeof markerData.location.lng !== 'number' || typeof markerData.location.lat !== 'number') {
|
||||
console.error('addMarkers: 无效的标记数据', markerData);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 创建标记点
|
||||
const point = new window.BMap.Point(markerData.location.lng, markerData.location.lat);
|
||||
const marker = new window.BMap.Marker(point);
|
||||
|
||||
// 添加标记到地图
|
||||
map.addOverlay(marker);
|
||||
|
||||
// 创建信息窗口
|
||||
if (markerData.title || markerData.content) {
|
||||
const infoWindow = new window.BMap.InfoWindow(
|
||||
`<div class="map-info-window">
|
||||
${markerData.content || ''}
|
||||
</div>`,
|
||||
{
|
||||
title: markerData.title || '',
|
||||
width: 250,
|
||||
height: 120,
|
||||
enableMessage: false
|
||||
}
|
||||
);
|
||||
|
||||
// 添加点击事件
|
||||
marker.addEventListener('click', () => {
|
||||
marker.openInfoWindow(infoWindow);
|
||||
|
||||
// 如果有自定义点击回调,则调用
|
||||
if (onClick && typeof onClick === 'function') {
|
||||
onClick(markerData, marker);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return marker;
|
||||
} catch (error) {
|
||||
console.error('addMarkers: 创建标记失败:', error, markerData);
|
||||
return null;
|
||||
}
|
||||
}).filter(marker => marker !== null);
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除地图上的所有覆盖物
|
||||
* @param {BMap.Map} map - 百度地图实例
|
||||
*/
|
||||
export const clearOverlays = (map) => {
|
||||
if (!map || typeof map.clearOverlays !== 'function') {
|
||||
console.warn('clearOverlays: 无效的地图实例,已跳过清理');
|
||||
return;
|
||||
}
|
||||
map.clearOverlays();
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置地图视图以包含所有标记点
|
||||
* @param {BMap.Map} map - 百度地图实例
|
||||
* @param {Array<BMap.Point>} points - 点数组
|
||||
* @param {Number} padding - 边距,单位像素
|
||||
*/
|
||||
export const setViewToFitMarkers = (map, points, padding = 50) => {
|
||||
if (!map) {
|
||||
console.warn('setViewToFitMarkers: 无效的地图实例,已跳过设置视图');
|
||||
return;
|
||||
}
|
||||
if (!points || points.length === 0) return;
|
||||
|
||||
// 如果只有一个点,直接居中
|
||||
if (points.length === 1) {
|
||||
map.centerAndZoom(points[0], 15);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建视图范围
|
||||
const viewport = map.getViewport(points, { margins: [padding, padding, padding, padding] });
|
||||
map.setViewport(viewport);
|
||||
};
|
||||
|
||||
/**
|
||||
* 转换养殖场数据为地图标记数据
|
||||
* @param {Array} farms - 养殖场数据数组
|
||||
* @returns {Array} 地图标记数据数组
|
||||
*/
|
||||
export const convertFarmsToMarkers = (farms = []) => {
|
||||
return farms
|
||||
.filter(farm => {
|
||||
// 只保留有有效位置信息的农场
|
||||
return farm.location &&
|
||||
typeof farm.location.lat === 'number' &&
|
||||
typeof farm.location.lng === 'number';
|
||||
})
|
||||
.map(farm => {
|
||||
// 计算动物总数
|
||||
const animalCount = farm.animals ?
|
||||
farm.animals.reduce((total, animal) => total + (animal.count || 0), 0) : 0;
|
||||
|
||||
// 计算在线设备数
|
||||
const onlineDevices = farm.devices ?
|
||||
farm.devices.filter(device => device.status === 'online').length : 0;
|
||||
const totalDevices = farm.devices ? farm.devices.length : 0;
|
||||
|
||||
return {
|
||||
id: farm.id,
|
||||
title: farm.name,
|
||||
location: farm.location,
|
||||
content: `
|
||||
<div style="padding: 8px; font-size: 14px;">
|
||||
<h4 style="margin: 0 0 8px 0; color: #1890ff;">${farm.name}</h4>
|
||||
<p style="margin: 4px 0;"><strong>类型:</strong> ${farm.type || '未知'}</p>
|
||||
<p style="margin: 4px 0;"><strong>动物数量:</strong> ${animalCount} 只</p>
|
||||
<p style="margin: 4px 0;"><strong>设备状态:</strong> ${onlineDevices}/${totalDevices} 在线</p>
|
||||
<p style="margin: 4px 0;"><strong>联系人:</strong> ${farm.contact || '未知'}</p>
|
||||
<p style="margin: 4px 0;"><strong>地址:</strong> ${farm.address || '未知'}</p>
|
||||
<p style="margin: 4px 0; color: #666;"><strong>坐标:</strong> ${farm.location.lat.toFixed(4)}, ${farm.location.lng.toFixed(4)}</p>
|
||||
</div>
|
||||
`,
|
||||
originalData: farm
|
||||
};
|
||||
});
|
||||
};
|
||||
332
admin-system/src/utils/mapServiceFixed.js
Normal file
332
admin-system/src/utils/mapServiceFixed.js
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* 修复版百度地图服务工具
|
||||
* 专门解决 coordType 错误问题
|
||||
*/
|
||||
|
||||
// 百度地图API加载状态
|
||||
let BMapLoaded = false;
|
||||
let loadingPromise = null;
|
||||
|
||||
/**
|
||||
* 加载百度地图API
|
||||
*/
|
||||
export const loadBMapScript = async (retryCount = 0) => {
|
||||
if (BMapLoaded) {
|
||||
console.log('百度地图API已加载');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (loadingPromise) {
|
||||
console.log('百度地图API正在加载中...');
|
||||
return loadingPromise;
|
||||
}
|
||||
|
||||
console.log(`开始加载百度地图API... (重试次数: ${retryCount})`);
|
||||
|
||||
loadingPromise = new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const { BAIDU_MAP_CONFIG } = await import('../config/env');
|
||||
|
||||
console.log('使用API密钥:', BAIDU_MAP_CONFIG.apiKey);
|
||||
|
||||
if (!BAIDU_MAP_CONFIG.apiKey || BAIDU_MAP_CONFIG.apiKey === 'YOUR_VALID_BAIDU_MAP_API_KEY') {
|
||||
const error = new Error('百度地图API密钥未配置或无效');
|
||||
console.error('API密钥错误:', error);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.BMap !== 'undefined') {
|
||||
console.log('BMap已存在,直接使用');
|
||||
BMapLoaded = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建全局回调函数
|
||||
window.initBMap = () => {
|
||||
console.log('百度地图API加载成功');
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
setTimeout(() => {
|
||||
if (window.BMap && typeof window.BMap.Map === 'function') {
|
||||
BMapLoaded = true;
|
||||
resolve();
|
||||
} else {
|
||||
console.error('BMap对象未正确初始化');
|
||||
reject(new Error('BMap对象未正确初始化'));
|
||||
}
|
||||
delete window.initBMap;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 创建script标签
|
||||
const script = document.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
script.src = `https://api.map.baidu.com/api?v=3.0&ak=${BAIDU_MAP_CONFIG.apiKey}&callback=initBMap`;
|
||||
|
||||
console.log('百度地图API URL:', script.src);
|
||||
|
||||
script.onerror = async (error) => {
|
||||
console.error('百度地图脚本加载失败:', error);
|
||||
clearTimeout(timeoutId);
|
||||
if (script.parentNode) {
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
if (window.initBMap) {
|
||||
delete window.initBMap;
|
||||
}
|
||||
|
||||
if (retryCount < BAIDU_MAP_CONFIG.maxRetries) {
|
||||
console.log(`重试加载百度地图API (${retryCount + 1}/${BAIDU_MAP_CONFIG.maxRetries})`);
|
||||
setTimeout(() => {
|
||||
loadBMapScript(retryCount + 1).then(resolve).catch(reject);
|
||||
}, BAIDU_MAP_CONFIG.retryDelay);
|
||||
} else {
|
||||
reject(new Error('百度地图API加载失败,已达到最大重试次数'));
|
||||
}
|
||||
};
|
||||
|
||||
// 设置超时
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.error('百度地图API加载超时');
|
||||
if (script.parentNode) {
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
if (window.initBMap) {
|
||||
delete window.initBMap;
|
||||
}
|
||||
reject(new Error('百度地图API加载超时'));
|
||||
}, 10000);
|
||||
|
||||
document.head.appendChild(script);
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载百度地图API时发生错误:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
return loadingPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* 修复版创建地图函数
|
||||
* 专门解决 coordType 错误
|
||||
*/
|
||||
export const createMap = async (container, options = {}) => {
|
||||
console.log('修复版createMap函数开始执行');
|
||||
|
||||
// 确保API已加载
|
||||
await loadBMapScript();
|
||||
|
||||
// 验证容器
|
||||
if (!container) {
|
||||
throw new Error('地图容器不能为空');
|
||||
}
|
||||
|
||||
// 检查容器是否在DOM中
|
||||
if (!document.contains(container)) {
|
||||
throw new Error('地图容器不在DOM中');
|
||||
}
|
||||
|
||||
// 强制设置容器样式,确保地图能正确初始化
|
||||
const originalStyles = {
|
||||
position: container.style.position,
|
||||
display: container.style.display,
|
||||
visibility: container.style.visibility,
|
||||
width: container.style.width,
|
||||
height: container.style.height,
|
||||
minHeight: container.style.minHeight
|
||||
};
|
||||
|
||||
// 设置临时样式
|
||||
container.style.position = 'relative';
|
||||
container.style.display = 'block';
|
||||
container.style.visibility = 'visible';
|
||||
container.style.width = container.style.width || '100%';
|
||||
container.style.height = container.style.height || '400px';
|
||||
container.style.minHeight = container.style.minHeight || '400px';
|
||||
|
||||
// 等待样式生效
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// 检查容器尺寸
|
||||
if (container.offsetWidth === 0 || container.offsetHeight === 0) {
|
||||
console.warn('容器尺寸为0,强制设置尺寸');
|
||||
container.style.width = '100%';
|
||||
container.style.height = '400px';
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
console.log('容器最终状态:', {
|
||||
offsetWidth: container.offsetWidth,
|
||||
offsetHeight: container.offsetHeight,
|
||||
clientWidth: container.clientWidth,
|
||||
clientHeight: container.clientHeight,
|
||||
computedStyle: {
|
||||
position: window.getComputedStyle(container).position,
|
||||
display: window.getComputedStyle(container).display,
|
||||
visibility: window.getComputedStyle(container).visibility
|
||||
}
|
||||
});
|
||||
|
||||
// 检查父级元素样式
|
||||
let parent = container.parentElement;
|
||||
while (parent && parent !== document.body) {
|
||||
const parentStyle = window.getComputedStyle(parent);
|
||||
if (parentStyle.position === 'fixed' || parentStyle.display === 'none') {
|
||||
console.warn('发现可能有问题的父级样式:', {
|
||||
tagName: parent.tagName,
|
||||
position: parentStyle.position,
|
||||
display: parentStyle.display
|
||||
});
|
||||
}
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
const defaultOptions = {
|
||||
center: { lng: 106.27, lat: 38.47 },
|
||||
zoom: 8,
|
||||
enableMapClick: true,
|
||||
enableScrollWheelZoom: true,
|
||||
enableDragging: true,
|
||||
enableDoubleClickZoom: true,
|
||||
enableKeyboard: true
|
||||
};
|
||||
|
||||
const mergedOptions = { ...defaultOptions, ...options };
|
||||
|
||||
console.log('地图配置选项:', mergedOptions);
|
||||
|
||||
// 创建地图实例
|
||||
let map;
|
||||
try {
|
||||
console.log('开始创建BMap.Map实例...');
|
||||
|
||||
// 确保BMap对象存在
|
||||
if (!window.BMap || typeof window.BMap.Map !== 'function') {
|
||||
throw new Error('BMap对象未正确加载');
|
||||
}
|
||||
|
||||
// 创建地图实例
|
||||
map = new window.BMap.Map(container);
|
||||
console.log('地图实例创建成功:', map);
|
||||
|
||||
// 验证地图实例
|
||||
if (!map || typeof map.centerAndZoom !== 'function') {
|
||||
throw new Error('地图实例创建失败');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('创建地图实例失败:', error);
|
||||
// 恢复原始样式
|
||||
Object.keys(originalStyles).forEach(key => {
|
||||
container.style[key] = originalStyles[key];
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 恢复原始样式
|
||||
Object.keys(originalStyles).forEach(key => {
|
||||
container.style[key] = originalStyles[key];
|
||||
});
|
||||
|
||||
// 设置中心点和缩放级别
|
||||
console.log('设置地图中心点和缩放级别:', mergedOptions.center, mergedOptions.zoom);
|
||||
map.centerAndZoom(mergedOptions.center, mergedOptions.zoom);
|
||||
|
||||
// 添加地图控件
|
||||
map.addControl(new window.BMap.NavigationControl());
|
||||
map.addControl(new window.BMap.ScaleControl());
|
||||
map.addControl(new window.BMap.OverviewMapControl());
|
||||
map.addControl(new window.BMap.MapTypeControl());
|
||||
|
||||
// 监听地图事件
|
||||
map.addEventListener('tilesloaded', function() {
|
||||
console.log('百度地图瓦片加载完成');
|
||||
});
|
||||
|
||||
map.addEventListener('load', function() {
|
||||
console.log('百度地图完全加载完成');
|
||||
});
|
||||
|
||||
// 设置地图功能
|
||||
if (mergedOptions.enableMapClick) {
|
||||
map.enableMapClick();
|
||||
}
|
||||
if (mergedOptions.enableScrollWheelZoom) {
|
||||
map.enableScrollWheelZoom();
|
||||
}
|
||||
if (mergedOptions.enableDragging) {
|
||||
map.enableDragging();
|
||||
}
|
||||
if (mergedOptions.enableDoubleClickZoom) {
|
||||
map.enableDoubleClickZoom();
|
||||
}
|
||||
if (mergedOptions.enableKeyboard) {
|
||||
map.enableKeyboard();
|
||||
}
|
||||
|
||||
console.log('修复版地图创建完成');
|
||||
return map;
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加标记点
|
||||
*/
|
||||
export const addMarkers = (map, markers) => {
|
||||
if (!map || !markers || !Array.isArray(markers)) {
|
||||
console.warn('addMarkers: 参数无效');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('添加标记点:', markers.length);
|
||||
|
||||
markers.forEach((markerData, index) => {
|
||||
try {
|
||||
const point = new window.BMap.Point(markerData.lng, markerData.lat);
|
||||
const marker = new window.BMap.Marker(point);
|
||||
|
||||
if (markerData.title) {
|
||||
const infoWindow = new window.BMap.InfoWindow(markerData.title);
|
||||
marker.addEventListener('click', function() {
|
||||
map.openInfoWindow(infoWindow, point);
|
||||
});
|
||||
}
|
||||
|
||||
map.addOverlay(marker);
|
||||
console.log(`标记点 ${index + 1} 添加成功`);
|
||||
} catch (error) {
|
||||
console.error(`添加标记点 ${index + 1} 失败:`, error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除所有覆盖物
|
||||
*/
|
||||
export const clearOverlays = (map) => {
|
||||
if (!map) {
|
||||
console.warn('clearOverlays: 地图实例无效');
|
||||
return;
|
||||
}
|
||||
|
||||
map.clearOverlays();
|
||||
console.log('已清除所有覆盖物');
|
||||
};
|
||||
|
||||
/**
|
||||
* 调整视图以适应所有标记
|
||||
*/
|
||||
export const setViewToFitMarkers = (map, markers) => {
|
||||
if (!map || !markers || !Array.isArray(markers) || markers.length === 0) {
|
||||
console.warn('setViewToFitMarkers: 参数无效');
|
||||
return;
|
||||
}
|
||||
|
||||
const points = markers.map(marker => new window.BMap.Point(marker.lng, marker.lat));
|
||||
map.setViewport(points);
|
||||
console.log('已调整视图以适应标记点');
|
||||
};
|
||||
116
admin-system/src/utils/menuDebugger.js
Normal file
116
admin-system/src/utils/menuDebugger.js
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 菜单权限调试工具
|
||||
* @file menuDebugger.js
|
||||
* @description 用于调试菜单权限问题的工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 调试菜单权限问题
|
||||
* @param {Object} userData - 用户数据
|
||||
* @param {Array} routes - 路由配置
|
||||
*/
|
||||
export function debugMenuPermissions(userData, routes) {
|
||||
console.log('🔍 菜单权限调试开始...')
|
||||
console.log('📊 用户数据:', userData)
|
||||
console.log('📊 路由配置:', routes)
|
||||
|
||||
if (!userData) {
|
||||
console.error('❌ 用户数据为空')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('📋 用户权限:', userData.permissions || [])
|
||||
console.log('📋 用户角色:', userData.role)
|
||||
console.log('📋 可访问菜单:', userData.accessibleMenus || [])
|
||||
|
||||
// 检查每个路由的权限
|
||||
routes.forEach(route => {
|
||||
console.log(`\n🔍 检查路由: ${route.name}`)
|
||||
console.log(' - 路径:', route.path)
|
||||
console.log(' - 标题:', route.meta?.title)
|
||||
console.log(' - 图标:', route.meta?.icon)
|
||||
console.log(' - 权限要求:', route.meta?.permission)
|
||||
console.log(' - 角色要求:', route.meta?.roles)
|
||||
console.log(' - 菜单要求:', route.meta?.menu)
|
||||
|
||||
// 检查权限
|
||||
if (route.meta?.permission) {
|
||||
const hasPerm = userData.permissions?.includes(route.meta.permission)
|
||||
console.log(` - 权限检查: ${route.meta.permission} -> ${hasPerm ? '✅' : '❌'}`)
|
||||
}
|
||||
|
||||
// 检查角色
|
||||
if (route.meta?.roles) {
|
||||
const hasRole = route.meta.roles.includes(userData.role?.name)
|
||||
console.log(` - 角色检查: ${route.meta.roles} -> ${hasRole ? '✅' : '❌'}`)
|
||||
}
|
||||
|
||||
// 检查菜单
|
||||
if (route.meta?.menu) {
|
||||
const canAccess = userData.accessibleMenus?.includes(route.meta.menu)
|
||||
console.log(` - 菜单检查: ${route.meta.menu} -> ${canAccess ? '✅' : '❌'}`)
|
||||
}
|
||||
})
|
||||
|
||||
console.log('🔍 菜单权限调试完成')
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查特定权限
|
||||
* @param {Object} userData - 用户数据
|
||||
* @param {string} permission - 权限名称
|
||||
*/
|
||||
export function checkPermission(userData, permission) {
|
||||
console.log(`🔍 检查权限: ${permission}`)
|
||||
console.log('用户权限列表:', userData.permissions || [])
|
||||
|
||||
const hasPerm = userData.permissions?.includes(permission)
|
||||
console.log(`权限检查结果: ${hasPerm ? '✅' : '❌'}`)
|
||||
|
||||
return hasPerm
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查特定角色
|
||||
* @param {Object} userData - 用户数据
|
||||
* @param {string} role - 角色名称
|
||||
*/
|
||||
export function checkRole(userData, role) {
|
||||
console.log(`🔍 检查角色: ${role}`)
|
||||
console.log('用户角色:', userData.role)
|
||||
|
||||
const hasRole = userData.role?.name === role
|
||||
console.log(`角色检查结果: ${hasRole ? '✅' : '❌'}`)
|
||||
|
||||
return hasRole
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查菜单访问
|
||||
* @param {Object} userData - 用户数据
|
||||
* @param {string} menuKey - 菜单键
|
||||
*/
|
||||
export function checkMenuAccess(userData, menuKey) {
|
||||
console.log(`🔍 检查菜单访问: ${menuKey}`)
|
||||
console.log('可访问菜单:', userData.accessibleMenus || [])
|
||||
|
||||
const canAccess = userData.accessibleMenus?.includes(menuKey)
|
||||
console.log(`菜单访问检查结果: ${canAccess ? '✅' : '❌'}`)
|
||||
|
||||
return canAccess
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出调试工具到全局
|
||||
*/
|
||||
export function setupGlobalDebugger() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.menuDebugger = {
|
||||
debugMenuPermissions,
|
||||
checkPermission,
|
||||
checkRole,
|
||||
checkMenuAccess
|
||||
}
|
||||
console.log('🔧 菜单权限调试工具已添加到 window.menuDebugger')
|
||||
}
|
||||
}
|
||||
379
admin-system/src/utils/websocketService.js
Normal file
379
admin-system/src/utils/websocketService.js
Normal file
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* WebSocket实时通信服务
|
||||
* @file websocketService.js
|
||||
* @description 前端WebSocket客户端,处理实时数据接收
|
||||
*/
|
||||
import { io } from 'socket.io-client';
|
||||
import { useUserStore } from '../stores/user';
|
||||
import { useDataStore } from '../stores/data';
|
||||
import { message, notification } from 'ant-design-vue';
|
||||
|
||||
class WebSocketService {
|
||||
constructor() {
|
||||
this.socket = null;
|
||||
this.isConnected = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 5;
|
||||
this.reconnectInterval = 3000; // 3秒重连间隔
|
||||
this.userStore = null;
|
||||
this.dataStore = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接WebSocket服务器
|
||||
* @param {string} token JWT认证令牌
|
||||
*/
|
||||
connect(token) {
|
||||
if (this.socket && this.isConnected) {
|
||||
console.log('WebSocket已连接,无需重复连接');
|
||||
return;
|
||||
}
|
||||
|
||||
// 初始化store
|
||||
this.userStore = useUserStore();
|
||||
this.dataStore = useDataStore();
|
||||
|
||||
const serverUrl = import.meta.env.VITE_API_URL || 'http://localhost:5350';
|
||||
|
||||
console.log('正在连接WebSocket服务器:', serverUrl);
|
||||
|
||||
this.socket = io(serverUrl, {
|
||||
auth: {
|
||||
token: token
|
||||
},
|
||||
transports: ['websocket', 'polling'],
|
||||
timeout: 20000,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: this.maxReconnectAttempts,
|
||||
reconnectionDelay: this.reconnectInterval
|
||||
});
|
||||
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置事件监听器
|
||||
*/
|
||||
setupEventListeners() {
|
||||
if (!this.socket) return;
|
||||
|
||||
// 连接成功
|
||||
this.socket.on('connect', () => {
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
console.log('WebSocket连接成功,连接ID:', this.socket.id);
|
||||
|
||||
message.success('实时数据连接已建立');
|
||||
});
|
||||
|
||||
// 连接确认
|
||||
this.socket.on('connected', (data) => {
|
||||
console.log('收到服务器连接确认:', data);
|
||||
});
|
||||
|
||||
// 设备状态更新
|
||||
this.socket.on('device_update', (data) => {
|
||||
console.log('收到设备状态更新:', data);
|
||||
this.handleDeviceUpdate(data);
|
||||
});
|
||||
|
||||
// 预警更新
|
||||
this.socket.on('alert_update', (data) => {
|
||||
console.log('收到预警更新:', data);
|
||||
this.handleAlertUpdate(data);
|
||||
});
|
||||
|
||||
// 紧急预警
|
||||
this.socket.on('urgent_alert', (data) => {
|
||||
console.log('收到紧急预警:', data);
|
||||
this.handleUrgentAlert(data);
|
||||
});
|
||||
|
||||
// 动物健康状态更新
|
||||
this.socket.on('animal_update', (data) => {
|
||||
console.log('收到动物健康状态更新:', data);
|
||||
this.handleAnimalUpdate(data);
|
||||
});
|
||||
|
||||
// 系统统计数据更新
|
||||
this.socket.on('stats_update', (data) => {
|
||||
console.log('收到系统统计数据更新:', data);
|
||||
this.handleStatsUpdate(data);
|
||||
});
|
||||
|
||||
// 性能监控数据(仅管理员)
|
||||
this.socket.on('performance_update', (data) => {
|
||||
console.log('收到性能监控数据:', data);
|
||||
this.handlePerformanceUpdate(data);
|
||||
});
|
||||
|
||||
// 连接断开
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
this.isConnected = false;
|
||||
console.log('WebSocket连接断开:', reason);
|
||||
|
||||
if (reason === 'io server disconnect') {
|
||||
// 服务器主动断开,需要重新连接
|
||||
this.reconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// 连接错误
|
||||
this.socket.on('connect_error', (error) => {
|
||||
console.error('WebSocket连接错误:', error);
|
||||
|
||||
if (error.message.includes('认证失败') || error.message.includes('未提供认证令牌')) {
|
||||
message.error('实时连接认证失败,请重新登录');
|
||||
this.userStore.logout();
|
||||
} else {
|
||||
this.handleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// 心跳响应
|
||||
this.socket.on('pong', (data) => {
|
||||
console.log('收到心跳响应:', data);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理设备状态更新
|
||||
* @param {Object} data 设备数据
|
||||
*/
|
||||
handleDeviceUpdate(data) {
|
||||
// 更新数据存储中的设备状态
|
||||
if (this.dataStore) {
|
||||
this.dataStore.updateDeviceRealtime(data.data);
|
||||
}
|
||||
|
||||
// 如果设备状态异常,显示通知
|
||||
if (data.data.status === 'offline') {
|
||||
notification.warning({
|
||||
message: '设备状态变化',
|
||||
description: `设备 ${data.data.name} 已离线`,
|
||||
duration: 4.5,
|
||||
});
|
||||
} else if (data.data.status === 'maintenance') {
|
||||
notification.info({
|
||||
message: '设备状态变化',
|
||||
description: `设备 ${data.data.name} 进入维护模式`,
|
||||
duration: 4.5,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理预警更新
|
||||
* @param {Object} data 预警数据
|
||||
*/
|
||||
handleAlertUpdate(data) {
|
||||
// 更新数据存储中的预警数据
|
||||
if (this.dataStore) {
|
||||
this.dataStore.addNewAlert(data.data);
|
||||
}
|
||||
|
||||
// 显示预警通知
|
||||
const alertLevel = data.data.level;
|
||||
let notificationType = 'info';
|
||||
|
||||
if (alertLevel === 'critical') {
|
||||
notificationType = 'error';
|
||||
} else if (alertLevel === 'high') {
|
||||
notificationType = 'warning';
|
||||
}
|
||||
|
||||
notification[notificationType]({
|
||||
message: '新预警',
|
||||
description: `${data.data.farm_name}: ${data.data.message}`,
|
||||
duration: 6,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理紧急预警
|
||||
* @param {Object} data 紧急预警数据
|
||||
*/
|
||||
handleUrgentAlert(data) {
|
||||
// 紧急预警使用模态框显示
|
||||
notification.error({
|
||||
message: '🚨 紧急预警',
|
||||
description: `${data.alert.farm_name}: ${data.alert.message}`,
|
||||
duration: 0, // 不自动关闭
|
||||
style: {
|
||||
backgroundColor: '#fff2f0',
|
||||
border: '1px solid #ffccc7'
|
||||
}
|
||||
});
|
||||
|
||||
// 播放警报声音(如果浏览器支持)
|
||||
this.playAlertSound();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理动物健康状态更新
|
||||
* @param {Object} data 动物数据
|
||||
*/
|
||||
handleAnimalUpdate(data) {
|
||||
// 更新数据存储
|
||||
if (this.dataStore) {
|
||||
this.dataStore.updateAnimalRealtime(data.data);
|
||||
}
|
||||
|
||||
// 如果动物健康状态异常,显示通知
|
||||
if (data.data.health_status === 'sick') {
|
||||
notification.warning({
|
||||
message: '动物健康状态变化',
|
||||
description: `${data.data.farm_name}的${data.data.type}出现健康问题`,
|
||||
duration: 5,
|
||||
});
|
||||
} else if (data.data.health_status === 'quarantined') {
|
||||
notification.error({
|
||||
message: '动物健康状态变化',
|
||||
description: `${data.data.farm_name}的${data.data.type}已隔离`,
|
||||
duration: 6,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理系统统计数据更新
|
||||
* @param {Object} data 统计数据
|
||||
*/
|
||||
handleStatsUpdate(data) {
|
||||
// 更新数据存储中的统计信息
|
||||
if (this.dataStore) {
|
||||
this.dataStore.updateStatsRealtime(data.data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理性能监控数据更新
|
||||
* @param {Object} data 性能数据
|
||||
*/
|
||||
handlePerformanceUpdate(data) {
|
||||
// 只有管理员才能看到性能数据
|
||||
if (this.userStore?.user?.roles?.includes('admin')) {
|
||||
console.log('收到性能监控数据:', data);
|
||||
// 可以通过事件总线通知性能监控组件更新
|
||||
window.dispatchEvent(new CustomEvent('performance_update', { detail: data }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放警报声音
|
||||
*/
|
||||
playAlertSound() {
|
||||
try {
|
||||
// 创建音频上下文
|
||||
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
|
||||
// 生成警报音
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.setValueAtTime(800, audioContext.currentTime);
|
||||
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||
|
||||
oscillator.start();
|
||||
oscillator.stop(audioContext.currentTime + 0.5);
|
||||
} catch (error) {
|
||||
console.log('无法播放警报声音:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅农场数据
|
||||
* @param {number} farmId 农场ID
|
||||
*/
|
||||
subscribeFarm(farmId) {
|
||||
if (this.socket && this.isConnected) {
|
||||
this.socket.emit('subscribe_farm', farmId);
|
||||
console.log(`已订阅农场 ${farmId} 的实时数据`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅农场数据
|
||||
* @param {number} farmId 农场ID
|
||||
*/
|
||||
unsubscribeFarm(farmId) {
|
||||
if (this.socket && this.isConnected) {
|
||||
this.socket.emit('unsubscribe_farm', farmId);
|
||||
console.log(`已取消订阅农场 ${farmId} 的实时数据`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅设备数据
|
||||
* @param {number} deviceId 设备ID
|
||||
*/
|
||||
subscribeDevice(deviceId) {
|
||||
if (this.socket && this.isConnected) {
|
||||
this.socket.emit('subscribe_device', deviceId);
|
||||
console.log(`已订阅设备 ${deviceId} 的实时数据`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送心跳
|
||||
*/
|
||||
sendHeartbeat() {
|
||||
if (this.socket && this.isConnected) {
|
||||
this.socket.emit('ping');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理重连
|
||||
*/
|
||||
handleReconnect() {
|
||||
this.reconnectAttempts++;
|
||||
|
||||
if (this.reconnectAttempts <= this.maxReconnectAttempts) {
|
||||
console.log(`尝试重连WebSocket (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||
|
||||
setTimeout(() => {
|
||||
this.reconnect();
|
||||
}, this.reconnectInterval * this.reconnectAttempts);
|
||||
} else {
|
||||
message.error('实时连接已断开,请刷新页面重试');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新连接
|
||||
*/
|
||||
reconnect() {
|
||||
if (this.userStore?.token) {
|
||||
this.connect(this.userStore.token);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
this.isConnected = false;
|
||||
console.log('WebSocket连接已断开');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
* @returns {boolean} 连接状态
|
||||
*/
|
||||
getConnectionStatus() {
|
||||
return this.isConnected;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
const webSocketService = new WebSocketService();
|
||||
|
||||
export default webSocketService;
|
||||
710
admin-system/src/views/Alerts.vue
Normal file
710
admin-system/src/views/Alerts.vue
Normal file
@@ -0,0 +1,710 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>预警管理</h1>
|
||||
<a-space>
|
||||
<a-button @click="exportAlerts" :loading="exportLoading">
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出数据
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加预警
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 8px; align-items: center;">
|
||||
<a-auto-complete
|
||||
v-model:value="searchFarmName"
|
||||
:options="farmNameOptions"
|
||||
placeholder="请选择或输入养殖场名称进行搜索"
|
||||
style="width: 300px;"
|
||||
:filter-option="false"
|
||||
@search="handleSearchInput"
|
||||
@press-enter="searchAlertsByFarm"
|
||||
/>
|
||||
<a-button type="primary" @click="searchAlertsByFarm" :loading="searchLoading">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="resetSearch" :disabled="!isSearching">
|
||||
重置
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="alerts"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'type'">
|
||||
<a-tag color="blue">{{ getTypeText(record.type) }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'level'">
|
||||
<a-tag :color="getLevelColor(record.level)">
|
||||
{{ getLevelText(record.level) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'farm_name'">
|
||||
{{ getFarmName(record.farm_id) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'device_name'">
|
||||
{{ getDeviceName(record.device_id) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'resolved_at'">
|
||||
{{ formatDate(record.resolved_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'created_at'">
|
||||
{{ formatDateTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="editAlert(record)">编辑</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
@click="resolveAlert(record)"
|
||||
v-if="record.status !== 'resolved'"
|
||||
>
|
||||
解决
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个预警吗?"
|
||||
@confirm="deleteAlert(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑预警模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑预警' : '添加预警'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
width="600px"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="预警类型" name="type">
|
||||
<a-select v-model="formData.type" placeholder="请选择预警类型">
|
||||
<a-select-option value="temperature">温度异常</a-select-option>
|
||||
<a-select-option value="humidity">湿度异常</a-select-option>
|
||||
<a-select-option value="device_failure">设备故障</a-select-option>
|
||||
<a-select-option value="animal_health">动物健康</a-select-option>
|
||||
<a-select-option value="security">安全警报</a-select-option>
|
||||
<a-select-option value="maintenance">维护提醒</a-select-option>
|
||||
<a-select-option value="other">其他</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="预警级别" name="level">
|
||||
<a-select v-model="formData.level" placeholder="请选择预警级别">
|
||||
<a-select-option value="low">低</a-select-option>
|
||||
<a-select-option value="medium">中</a-select-option>
|
||||
<a-select-option value="high">高</a-select-option>
|
||||
<a-select-option value="critical">紧急</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="预警消息" name="message">
|
||||
<a-textarea
|
||||
v-model:value="formData.message"
|
||||
placeholder="请输入预警消息"
|
||||
:rows="3"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="预警状态" name="status">
|
||||
<a-select v-model="formData.status" placeholder="请选择预警状态">
|
||||
<a-select-option value="active">活跃</a-select-option>
|
||||
<a-select-option value="acknowledged">已确认</a-select-option>
|
||||
<a-select-option value="resolved">已解决</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="所属农场" name="farm_id">
|
||||
<a-select
|
||||
v-model="formData.farm_id"
|
||||
placeholder="请选择所属农场"
|
||||
:loading="farmsLoading"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="farm in farms"
|
||||
:key="farm.id"
|
||||
:value="farm.id"
|
||||
>
|
||||
{{ farm.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="关联设备" name="device_id">
|
||||
<a-select
|
||||
v-model="formData.device_id"
|
||||
placeholder="请选择关联设备(可选)"
|
||||
:loading="devicesLoading"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option
|
||||
v-for="device in devices"
|
||||
:key="device.id"
|
||||
:value="device.id"
|
||||
>
|
||||
{{ device.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="解决备注" name="resolution_notes" v-if="formData.status === 'resolved'">
|
||||
<a-textarea
|
||||
v-model:value="formData.resolution_notes"
|
||||
placeholder="请输入解决备注"
|
||||
:rows="2"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 解决预警模态框 -->
|
||||
<a-modal
|
||||
v-model:open="resolveModalVisible"
|
||||
title="解决预警"
|
||||
@ok="handleResolve"
|
||||
@cancel="resolveModalVisible = false"
|
||||
:confirm-loading="resolveLoading"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="解决备注">
|
||||
<a-textarea
|
||||
v-model:value="resolveNotes"
|
||||
placeholder="请输入解决备注"
|
||||
:rows="3"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
import { api } from '../utils/api'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// 响应式数据
|
||||
const alerts = ref([])
|
||||
const farms = ref([])
|
||||
const devices = ref([])
|
||||
const loading = ref(false)
|
||||
const farmsLoading = ref(false)
|
||||
const devicesLoading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const resolveModalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const resolveLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
const currentAlert = ref(null)
|
||||
const resolveNotes = ref('')
|
||||
|
||||
// 搜索相关的响应式数据
|
||||
const searchFarmName = ref('')
|
||||
const searchLoading = ref(false)
|
||||
const isSearching = ref(false)
|
||||
const farmNameOptions = ref([])
|
||||
|
||||
// 导出相关
|
||||
const exportLoading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
type: '',
|
||||
level: 'medium',
|
||||
message: '',
|
||||
status: 'active',
|
||||
farm_id: null,
|
||||
device_id: null,
|
||||
resolution_notes: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
type: [{ required: true, message: '请选择预警类型', trigger: 'change' }],
|
||||
level: [{ required: true, message: '请选择预警级别', trigger: 'change' }],
|
||||
message: [{ required: true, message: '请输入预警消息', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择预警状态', trigger: 'change' }],
|
||||
farm_id: [{ required: true, message: '请选择所属农场', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '预警类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'level',
|
||||
key: 'level',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '预警消息',
|
||||
dataIndex: 'message',
|
||||
key: 'message',
|
||||
ellipsis: true,
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '所属农场',
|
||||
dataIndex: 'farm_name',
|
||||
key: 'farm_name',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '关联设备',
|
||||
dataIndex: 'device_name',
|
||||
key: 'device_name',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '解决时间',
|
||||
dataIndex: 'resolved_at',
|
||||
key: 'resolved_at',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 180
|
||||
}
|
||||
]
|
||||
|
||||
// 获取预警列表
|
||||
const fetchAlerts = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.get('/alerts')
|
||||
if (response.success) {
|
||||
alerts.value = response.data
|
||||
} else {
|
||||
message.error('获取预警列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取预警列表失败:', error)
|
||||
if (error.response && error.response.status === 401) {
|
||||
message.error('登录已过期,请重新登录')
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login'
|
||||
}, 2000)
|
||||
} else {
|
||||
message.error('获取预警列表失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取农场列表
|
||||
const fetchFarms = async () => {
|
||||
try {
|
||||
farmsLoading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.get('/farms')
|
||||
if (response.success) {
|
||||
farms.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取农场列表失败:', error)
|
||||
} finally {
|
||||
farmsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取设备列表
|
||||
const fetchDevices = async () => {
|
||||
try {
|
||||
devicesLoading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.get('/devices')
|
||||
if (response.success) {
|
||||
devices.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取设备列表失败:', error)
|
||||
} finally {
|
||||
devicesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
fetchFarms()
|
||||
fetchDevices()
|
||||
}
|
||||
|
||||
// 编辑预警
|
||||
const editAlert = (record) => {
|
||||
isEdit.value = true
|
||||
// 逐个字段赋值,避免破坏响应式绑定
|
||||
formData.id = record.id
|
||||
formData.type = record.type
|
||||
formData.level = record.level
|
||||
formData.message = record.message
|
||||
formData.status = record.status
|
||||
formData.farm_id = record.farm_id
|
||||
formData.device_id = record.device_id
|
||||
formData.resolution_notes = record.resolution_notes || ''
|
||||
|
||||
modalVisible.value = true
|
||||
fetchFarms()
|
||||
fetchDevices()
|
||||
}
|
||||
|
||||
// 解决预警
|
||||
const resolveAlert = (record) => {
|
||||
currentAlert.value = record
|
||||
resolveNotes.value = ''
|
||||
resolveModalVisible.value = true
|
||||
}
|
||||
|
||||
// 处理解决预警
|
||||
const handleResolve = async () => {
|
||||
try {
|
||||
resolveLoading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.put(`/alerts/${currentAlert.value.id}`, {
|
||||
status: 'resolved',
|
||||
resolved_at: new Date().toISOString(),
|
||||
resolution_notes: resolveNotes.value
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
message.success('预警已解决')
|
||||
resolveModalVisible.value = false
|
||||
fetchAlerts()
|
||||
} else {
|
||||
message.error('解决预警失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解决预警失败:', error)
|
||||
message.error('解决预警失败')
|
||||
} finally {
|
||||
resolveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除预警
|
||||
const deleteAlert = async (id) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.delete(`/alerts/${id}`)
|
||||
if (response.success) {
|
||||
message.success('删除成功')
|
||||
fetchAlerts()
|
||||
} else {
|
||||
message.error('删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除预警失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
const config = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
// 准备提交数据
|
||||
const submitData = { ...formData }
|
||||
// 如果是新增操作,移除id字段
|
||||
if (!isEdit.value) {
|
||||
delete submitData.id
|
||||
}
|
||||
|
||||
let response
|
||||
if (isEdit.value) {
|
||||
response = await api.put(`/alerts/${formData.id}`, submitData)
|
||||
} else {
|
||||
response = await api.post('/alerts', submitData)
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
modalVisible.value = false
|
||||
fetchAlerts()
|
||||
} else {
|
||||
message.error(isEdit.value ? '更新失败' : '创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
type: '',
|
||||
level: 'medium',
|
||||
message: '',
|
||||
status: 'active',
|
||||
farm_id: null,
|
||||
device_id: null,
|
||||
resolution_notes: ''
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 获取级别颜色
|
||||
const getLevelColor = (level) => {
|
||||
const colors = {
|
||||
low: 'green',
|
||||
medium: 'orange',
|
||||
high: 'red',
|
||||
critical: 'purple'
|
||||
}
|
||||
return colors[level] || 'default'
|
||||
}
|
||||
|
||||
// 获取类型文本
|
||||
const getTypeText = (type) => {
|
||||
const texts = {
|
||||
temperature_alert: '温度异常',
|
||||
humidity_alert: '湿度异常',
|
||||
feed_alert: '饲料异常',
|
||||
health_alert: '健康异常',
|
||||
device_alert: '设备异常',
|
||||
temperature: '温度异常',
|
||||
humidity: '湿度异常',
|
||||
device_failure: '设备故障',
|
||||
animal_health: '动物健康',
|
||||
security: '安全警报',
|
||||
maintenance: '维护提醒',
|
||||
other: '其他'
|
||||
}
|
||||
return texts[type] || type
|
||||
}
|
||||
|
||||
// 获取级别文本
|
||||
const getLevelText = (level) => {
|
||||
const texts = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
critical: '紧急'
|
||||
}
|
||||
return texts[level] || level
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
active: 'red',
|
||||
acknowledged: 'orange',
|
||||
resolved: 'green'
|
||||
}
|
||||
return colors[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
active: '活跃',
|
||||
acknowledged: '已确认',
|
||||
resolved: '已解决'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
// 获取农场名称
|
||||
const getFarmName = (farmId) => {
|
||||
const farm = farms.value.find(f => f.id === farmId)
|
||||
return farm ? farm.name : `农场ID: ${farmId}`
|
||||
}
|
||||
|
||||
// 获取设备名称
|
||||
const getDeviceName = (deviceId) => {
|
||||
if (!deviceId) return '-'
|
||||
const device = devices.value.find(d => d.id === deviceId)
|
||||
return device ? device.name : `设备ID: ${deviceId}`
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-'
|
||||
return dayjs(date).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (date) => {
|
||||
if (!date) return '-'
|
||||
return dayjs(date).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
// 根据养殖场搜索预警
|
||||
const searchAlertsByFarm = async () => {
|
||||
if (!searchFarmName.value.trim()) {
|
||||
message.warning('请输入养殖场名称进行搜索')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
searchLoading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.get('/alerts/search', {
|
||||
params: { farmName: searchFarmName.value.trim() }
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
alerts.value = response.data || []
|
||||
isSearching.value = true
|
||||
message.success(response.message || `找到 ${alerts.value.length} 个匹配的预警`)
|
||||
} else {
|
||||
alerts.value = []
|
||||
message.info('未找到匹配的预警')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索预警失败:', error)
|
||||
message.error('搜索预警失败')
|
||||
alerts.value = []
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索输入(为自动完成提供选项)
|
||||
const handleSearchInput = (value) => {
|
||||
if (!value) {
|
||||
farmNameOptions.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 从现有农场列表中筛选匹配的农场名称
|
||||
const matchingFarms = farms.value.filter(farm =>
|
||||
farm.name.toLowerCase().includes(value.toLowerCase())
|
||||
)
|
||||
|
||||
farmNameOptions.value = matchingFarms.map(farm => ({
|
||||
value: farm.name,
|
||||
label: farm.name
|
||||
}))
|
||||
}
|
||||
|
||||
// 更新农场名称选项(在数据加载后)
|
||||
const updateFarmNameOptions = () => {
|
||||
farmNameOptions.value = farms.value.map(farm => ({
|
||||
value: farm.name,
|
||||
label: farm.name
|
||||
}))
|
||||
}
|
||||
|
||||
// 导出预警数据
|
||||
const exportAlerts = async () => {
|
||||
try {
|
||||
if (!alerts.value || alerts.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
exportLoading.value = true
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportAlertsData(alerts.value)
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchFarmName.value = ''
|
||||
isSearching.value = false
|
||||
farmNameOptions.value = []
|
||||
fetchAlerts() // 重新加载全部预警
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchAlerts()
|
||||
fetchFarms().then(() => {
|
||||
updateFarmNameOptions()
|
||||
})
|
||||
fetchDevices()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 可以添加自定义样式 */
|
||||
</style>
|
||||
512
admin-system/src/views/Analytics.vue
Normal file
512
admin-system/src/views/Analytics.vue
Normal file
@@ -0,0 +1,512 @@
|
||||
<template>
|
||||
<div class="analytics-page">
|
||||
<a-page-header
|
||||
title="数据分析"
|
||||
sub-title="宁夏智慧养殖监管平台数据分析"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button>导出报表</a-button>
|
||||
<a-button type="primary" @click="refreshData">
|
||||
<template #icon><reload-outlined /></template>
|
||||
刷新数据
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<div class="analytics-content">
|
||||
<!-- 趋势图表 -->
|
||||
<a-card title="月度数据趋势" :bordered="false" class="chart-card">
|
||||
<e-chart :options="trendChartOptions" height="350px" @chart-ready="handleChartReady" />
|
||||
</a-card>
|
||||
|
||||
<div class="analytics-row">
|
||||
<!-- 养殖场类型分布 -->
|
||||
<a-card title="养殖场类型分布" :bordered="false" class="chart-card">
|
||||
<e-chart :options="farmTypeChartOptions" height="300px" />
|
||||
</a-card>
|
||||
|
||||
<!-- 动物类型分布 -->
|
||||
<a-card title="动物类型分布" :bordered="false" class="chart-card">
|
||||
<animal-stats />
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<div class="analytics-row">
|
||||
<!-- 设备类型分布 -->
|
||||
<a-card title="设备类型分布" :bordered="false" class="chart-card">
|
||||
<device-stats />
|
||||
<simple-device-test />
|
||||
</a-card>
|
||||
|
||||
<!-- 预警类型分布 -->
|
||||
<a-card title="预警类型分布" :bordered="false" class="chart-card">
|
||||
<alert-stats />
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<a-card title="养殖场数据统计" :bordered="false" class="data-card">
|
||||
<a-table :dataSource="farmTableData" :columns="farmColumns" :pagination="{ pageSize: 5 }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'animalCount'">
|
||||
<a-progress :percent="getPercentage(record.animalCount, maxAnimalCount)" :stroke-color="getProgressColor(record.animalCount, maxAnimalCount)" />
|
||||
</template>
|
||||
<template v-if="column.key === 'deviceCount'">
|
||||
<a-progress :percent="getPercentage(record.deviceCount, maxDeviceCount)" :stroke-color="getProgressColor(record.deviceCount, maxDeviceCount)" />
|
||||
</template>
|
||||
<template v-if="column.key === 'alertCount'">
|
||||
<a-progress :percent="getPercentage(record.alertCount, maxAlertCount)" :stroke-color="{ from: '#108ee9', to: '#ff4d4f' }" />
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { useDataStore } from '../stores'
|
||||
import EChart from '../components/EChart.vue'
|
||||
import AnimalStats from '../components/AnimalStats.vue'
|
||||
import DeviceStats from '../components/DeviceStats.vue'
|
||||
import AlertStats from '../components/AlertStats.vue'
|
||||
import SimpleDeviceTest from '../components/SimpleDeviceTest.vue'
|
||||
// 移除模拟数据导入
|
||||
|
||||
// 使用数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 图表实例
|
||||
const charts = reactive({
|
||||
trendChart: null
|
||||
})
|
||||
|
||||
// 月度数据趋势
|
||||
const trendData = ref({
|
||||
xAxis: [],
|
||||
series: []
|
||||
})
|
||||
|
||||
// 处理图表就绪事件
|
||||
function handleChartReady(chart, type) {
|
||||
if (type === 'trend') {
|
||||
charts.trendChart = chart
|
||||
}
|
||||
}
|
||||
|
||||
// 获取月度数据趋势
|
||||
async function fetchMonthlyTrends() {
|
||||
try {
|
||||
const response = await fetch('/api/stats/public/monthly-trends')
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
trendData.value = result.data
|
||||
updateChartData()
|
||||
} else {
|
||||
console.error('获取月度数据趋势失败:', result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取月度数据趋势失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
async function refreshData() {
|
||||
await dataStore.fetchAllData()
|
||||
await fetchMonthlyTrends()
|
||||
updateChartData()
|
||||
}
|
||||
|
||||
// 更新图表数据
|
||||
function updateChartData() {
|
||||
// 更新趋势图表数据
|
||||
if (charts.trendChart) {
|
||||
charts.trendChart.setOption({
|
||||
xAxis: { data: trendData.value.xAxis },
|
||||
series: trendData.value.series
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 趋势图表选项
|
||||
const trendChartOptions = computed(() => {
|
||||
return {
|
||||
title: {
|
||||
text: '月度数据趋势',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
legend: {
|
||||
data: trendData.value.series.map(item => item.name),
|
||||
bottom: 0
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '10%',
|
||||
top: '15%',
|
||||
containLabel: true
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {}
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: trendData.value.xAxis
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: trendData.value.series.map(item => ({
|
||||
name: item.name,
|
||||
type: item.type || 'line',
|
||||
data: item.data,
|
||||
smooth: true,
|
||||
itemStyle: item.itemStyle,
|
||||
lineStyle: item.lineStyle,
|
||||
areaStyle: item.areaStyle
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// 养殖场类型分布图表选项
|
||||
const farmTypeChartOptions = computed(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '养殖场类型',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: [
|
||||
{ value: 5, name: '牛养殖场' },
|
||||
{ value: 7, name: '羊养殖场' },
|
||||
{ value: 3, name: '混合养殖场' },
|
||||
{ value: 2, name: '其他养殖场' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 动物类型分布图表选项
|
||||
const animalTypeChartOptions = computed(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '动物类型',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: []
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 设备类型分布图表选项
|
||||
const deviceTypeChartOptions = computed(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '设备类型',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: []
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 预警类型分布图表选项
|
||||
const alertTypeChartOptions = computed(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '预警类型',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: []
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 养殖场表格数据
|
||||
const farmTableData = computed(() => {
|
||||
console.log('计算farmTableData:', {
|
||||
farms: dataStore.farms.length,
|
||||
animals: dataStore.animals.length,
|
||||
devices: dataStore.devices.length,
|
||||
alerts: dataStore.alerts.length
|
||||
})
|
||||
|
||||
return dataStore.farms.map(farm => {
|
||||
// 获取该养殖场的动物数量
|
||||
const animals = dataStore.animals.filter(animal => animal.farm_id === farm.id)
|
||||
const animalCount = animals.reduce((sum, animal) => sum + (animal.count || 0), 0)
|
||||
|
||||
// 获取该养殖场的设备数量
|
||||
const devices = dataStore.devices.filter(device => device.farm_id === farm.id)
|
||||
const deviceCount = devices.length
|
||||
|
||||
// 获取该养殖场的预警数量
|
||||
const alerts = dataStore.alerts.filter(alert => alert.farm_id === farm.id)
|
||||
const alertCount = alerts.length
|
||||
|
||||
console.log(`养殖场 ${farm.name} (ID: ${farm.id}):`, {
|
||||
animalCount,
|
||||
deviceCount,
|
||||
alertCount
|
||||
})
|
||||
|
||||
return {
|
||||
key: farm.id,
|
||||
id: farm.id,
|
||||
name: farm.name,
|
||||
animalCount,
|
||||
deviceCount,
|
||||
alertCount
|
||||
}
|
||||
}).filter(farm => farm.animalCount > 0 || farm.deviceCount > 0 || farm.alertCount > 0)
|
||||
})
|
||||
|
||||
// 养殖场表格列定义
|
||||
const farmColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '养殖场名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: '动物数量',
|
||||
dataIndex: 'animalCount',
|
||||
key: 'animalCount',
|
||||
sorter: (a, b) => a.animalCount - b.animalCount
|
||||
},
|
||||
{
|
||||
title: '设备数量',
|
||||
dataIndex: 'deviceCount',
|
||||
key: 'deviceCount',
|
||||
sorter: (a, b) => a.deviceCount - b.deviceCount
|
||||
},
|
||||
{
|
||||
title: '预警数量',
|
||||
dataIndex: 'alertCount',
|
||||
key: 'alertCount',
|
||||
sorter: (a, b) => a.alertCount - b.alertCount
|
||||
}
|
||||
]
|
||||
|
||||
// 获取最大值
|
||||
const maxAnimalCount = computed(() => {
|
||||
const counts = farmTableData.value.map(farm => farm.animalCount)
|
||||
return Math.max(...counts, 1)
|
||||
})
|
||||
|
||||
const maxDeviceCount = computed(() => {
|
||||
const counts = farmTableData.value.map(farm => farm.deviceCount)
|
||||
return Math.max(...counts, 1)
|
||||
})
|
||||
|
||||
const maxAlertCount = computed(() => {
|
||||
const counts = farmTableData.value.map(farm => farm.alertCount)
|
||||
return Math.max(...counts, 1)
|
||||
})
|
||||
|
||||
// 计算百分比
|
||||
function getPercentage(value, max) {
|
||||
return Math.round((value / max) * 100)
|
||||
}
|
||||
|
||||
// 获取进度条颜色
|
||||
function getProgressColor(value, max) {
|
||||
const percentage = getPercentage(value, max)
|
||||
if (percentage < 30) return '#52c41a'
|
||||
if (percentage < 70) return '#1890ff'
|
||||
return '#ff4d4f'
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(async () => {
|
||||
console.log('Analytics页面开始加载数据...')
|
||||
|
||||
try {
|
||||
// 加载数据
|
||||
console.log('调用 dataStore.fetchAllData()...')
|
||||
await dataStore.fetchAllData()
|
||||
|
||||
console.log('数据加载完成:', {
|
||||
farms: dataStore.farms.length,
|
||||
animals: dataStore.animals.length,
|
||||
devices: dataStore.devices.length,
|
||||
alerts: dataStore.alerts.length
|
||||
})
|
||||
|
||||
// 获取月度数据趋势
|
||||
console.log('获取月度数据趋势...')
|
||||
await fetchMonthlyTrends()
|
||||
|
||||
console.log('Analytics页面数据加载完成')
|
||||
} catch (error) {
|
||||
console.error('Analytics页面数据加载失败:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.analytics-page {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.analytics-content {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.analytics-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.data-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.analytics-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1218
admin-system/src/views/Animals.vue
Normal file
1218
admin-system/src/views/Animals.vue
Normal file
File diff suppressed because it is too large
Load Diff
665
admin-system/src/views/ApiTester.vue
Normal file
665
admin-system/src/views/ApiTester.vue
Normal file
@@ -0,0 +1,665 @@
|
||||
<template>
|
||||
<div class="api-tester-page">
|
||||
<a-page-header
|
||||
title="API测试工具"
|
||||
sub-title="快速测试和调试API接口"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button @click="clearHistory">
|
||||
<template #icon><ClearOutlined /></template>
|
||||
清空历史
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openSwaggerDocs" target="_blank">
|
||||
<template #icon><ApiOutlined /></template>
|
||||
查看API文档
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<div class="api-tester-content">
|
||||
<a-row :gutter="24">
|
||||
<!-- 左侧:API请求面板 -->
|
||||
<a-col :span="12">
|
||||
<a-card title="API请求" :bordered="false">
|
||||
<!-- 快捷API选择 -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<a-select
|
||||
v-model:value="selectedApi"
|
||||
placeholder="选择常用API"
|
||||
style="width: 100%;"
|
||||
@change="loadApiTemplate"
|
||||
show-search
|
||||
option-filter-prop="children"
|
||||
>
|
||||
<a-select-opt-group label="认证相关">
|
||||
<a-select-option value="auth-login">登录</a-select-option>
|
||||
<a-select-option value="auth-logout">登出</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="农场管理">
|
||||
<a-select-option value="farms-list">获取农场列表</a-select-option>
|
||||
<a-select-option value="farms-create">创建农场</a-select-option>
|
||||
<a-select-option value="farms-search">搜索农场</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="设备管理">
|
||||
<a-select-option value="devices-list">获取设备列表</a-select-option>
|
||||
<a-select-option value="devices-search">搜索设备</a-select-option>
|
||||
<a-select-option value="devices-stats">设备统计</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="牛只管理">
|
||||
<a-select-option value="animals-list">获取动物列表</a-select-option>
|
||||
<a-select-option value="animals-search">搜索动物</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="预警管理">
|
||||
<a-select-option value="alerts-list">获取预警列表</a-select-option>
|
||||
<a-select-option value="alerts-search">搜索预警</a-select-option>
|
||||
<a-select-option value="alerts-resolve">解决预警</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="用户管理">
|
||||
<a-select-option value="users-list">获取用户列表</a-select-option>
|
||||
<a-select-option value="users-search">搜索用户</a-select-option>
|
||||
<a-select-option value="users-create">创建用户</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="产品管理">
|
||||
<a-select-option value="products-list">获取产品列表</a-select-option>
|
||||
<a-select-option value="products-search">搜索产品</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="订单管理">
|
||||
<a-select-option value="orders-list">获取订单列表</a-select-option>
|
||||
<a-select-option value="orders-search">搜索订单</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="报表管理">
|
||||
<a-select-option value="reports-farm">生成农场报表</a-select-option>
|
||||
<a-select-option value="reports-sales">生成销售报表</a-select-option>
|
||||
<a-select-option value="reports-list">获取报表列表</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="系统管理">
|
||||
<a-select-option value="system-configs">获取系统配置</a-select-option>
|
||||
<a-select-option value="system-menus">获取菜单权限</a-select-option>
|
||||
<a-select-option value="system-stats">获取系统统计</a-select-option>
|
||||
</a-select-opt-group>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<!-- HTTP方法和URL -->
|
||||
<a-row :gutter="8" style="margin-bottom: 16px;">
|
||||
<a-col :span="6">
|
||||
<a-select v-model:value="requestMethod" style="width: 100%;">
|
||||
<a-select-option value="GET">GET</a-select-option>
|
||||
<a-select-option value="POST">POST</a-select-option>
|
||||
<a-select-option value="PUT">PUT</a-select-option>
|
||||
<a-select-option value="DELETE">DELETE</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="18">
|
||||
<a-input
|
||||
v-model:value="requestUrl"
|
||||
placeholder="请输入API端点,例如: /api/farms"
|
||||
addonBefore="API"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 请求头 -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<a-typography-title :level="5">请求头</a-typography-title>
|
||||
<a-textarea
|
||||
v-model:value="requestHeaders"
|
||||
placeholder='{"Authorization": "Bearer your-token", "Content-Type": "application/json"}'
|
||||
:rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 请求体 -->
|
||||
<div style="margin-bottom: 16px;" v-if="['POST', 'PUT'].includes(requestMethod)">
|
||||
<a-typography-title :level="5">请求体 (JSON)</a-typography-title>
|
||||
<a-textarea
|
||||
v-model:value="requestBody"
|
||||
placeholder='{"key": "value"}'
|
||||
:rows="6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 查询参数 -->
|
||||
<div style="margin-bottom: 16px;" v-if="requestMethod === 'GET'">
|
||||
<a-typography-title :level="5">查询参数</a-typography-title>
|
||||
<a-textarea
|
||||
v-model:value="queryParams"
|
||||
placeholder='{"page": 1, "limit": 10}'
|
||||
:rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 发送请求按钮 -->
|
||||
<a-space style="width: 100%;">
|
||||
<a-button
|
||||
type="primary"
|
||||
@click="sendRequest"
|
||||
:loading="requesting"
|
||||
size="large"
|
||||
>
|
||||
<template #icon><SendOutlined /></template>
|
||||
发送请求
|
||||
</a-button>
|
||||
<a-button @click="saveToHistory" :disabled="!lastResponse">
|
||||
<template #icon><SaveOutlined /></template>
|
||||
保存到历史
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- 右侧:响应面板 -->
|
||||
<a-col :span="12">
|
||||
<a-card title="API响应" :bordered="false">
|
||||
<!-- 响应状态 -->
|
||||
<div v-if="lastResponse" style="margin-bottom: 16px;">
|
||||
<a-descriptions size="small" :column="2">
|
||||
<a-descriptions-item label="状态码">
|
||||
<a-tag
|
||||
:color="getStatusColor(lastResponse.status)"
|
||||
>
|
||||
{{ lastResponse.status }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="响应时间">
|
||||
{{ lastResponse.duration }}ms
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="响应大小">
|
||||
{{ formatResponseSize(lastResponse.size) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="内容类型">
|
||||
{{ lastResponse.contentType }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 响应内容 -->
|
||||
<div v-if="lastResponse">
|
||||
<a-typography-title :level="5">响应数据</a-typography-title>
|
||||
<a-textarea
|
||||
:value="formatResponse(lastResponse.data)"
|
||||
:rows="15"
|
||||
readonly
|
||||
style="font-family: 'Courier New', monospace; font-size: 12px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<a-empty
|
||||
v-else
|
||||
description="发送请求后将显示响应结果"
|
||||
style="margin: 60px 0;"
|
||||
>
|
||||
<template #image>
|
||||
<ApiOutlined style="font-size: 64px; color: #d9d9d9;" />
|
||||
</template>
|
||||
</a-empty>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 请求历史 -->
|
||||
<a-row style="margin-top: 24px;">
|
||||
<a-col :span="24">
|
||||
<a-card title="请求历史" :bordered="false">
|
||||
<a-table
|
||||
:columns="historyColumns"
|
||||
:data-source="requestHistory"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="timestamp"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'method'">
|
||||
<a-tag :color="getMethodColor(record.method)">
|
||||
{{ record.method }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'timestamp'">
|
||||
{{ formatDate(record.timestamp) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-space size="small">
|
||||
<a-button size="small" @click="loadFromHistory(record)">
|
||||
加载
|
||||
</a-button>
|
||||
<a-button size="small" @click="viewResponse(record)">
|
||||
查看响应
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 响应查看模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showResponseModal"
|
||||
title="查看响应详情"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
>
|
||||
<div v-if="selectedHistoryItem">
|
||||
<a-descriptions :column="2" size="small" style="margin-bottom: 16px;">
|
||||
<a-descriptions-item label="请求方法">
|
||||
<a-tag :color="getMethodColor(selectedHistoryItem.method)">
|
||||
{{ selectedHistoryItem.method }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态码">
|
||||
<a-tag :color="getStatusColor(selectedHistoryItem.status)">
|
||||
{{ selectedHistoryItem.status }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="请求URL">
|
||||
{{ selectedHistoryItem.url }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="响应时间">
|
||||
{{ selectedHistoryItem.duration }}ms
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-typography-title :level="5">响应数据</a-typography-title>
|
||||
<a-textarea
|
||||
:value="formatResponse(selectedHistoryItem.response)"
|
||||
:rows="12"
|
||||
readonly
|
||||
style="font-family: 'Courier New', monospace; font-size: 12px;"
|
||||
/>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
ClearOutlined,
|
||||
ApiOutlined,
|
||||
SendOutlined,
|
||||
SaveOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { api } from '../utils/api'
|
||||
import moment from 'moment'
|
||||
|
||||
// 响应式数据
|
||||
const requesting = ref(false)
|
||||
const selectedApi = ref('')
|
||||
const requestMethod = ref('GET')
|
||||
const requestUrl = ref('')
|
||||
const requestHeaders = ref('{"Authorization": "Bearer ' + localStorage.getItem('token') + '", "Content-Type": "application/json"}')
|
||||
const requestBody = ref('')
|
||||
const queryParams = ref('')
|
||||
const lastResponse = ref(null)
|
||||
const requestHistory = ref([])
|
||||
const showResponseModal = ref(false)
|
||||
const selectedHistoryItem = ref(null)
|
||||
|
||||
// API模板
|
||||
const apiTemplates = {
|
||||
'auth-login': {
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
body: '{"username": "admin", "password": "admin123"}'
|
||||
},
|
||||
'farms-list': {
|
||||
method: 'GET',
|
||||
url: '/api/farms',
|
||||
params: '{"page": 1, "limit": 10}'
|
||||
},
|
||||
'farms-create': {
|
||||
method: 'POST',
|
||||
url: '/api/farms',
|
||||
body: '{"name": "测试农场", "type": "养牛场", "location": {"latitude": 38.4872, "longitude": 106.2309}, "address": "银川市测试地址", "contact": "张三", "phone": "13800138000"}'
|
||||
},
|
||||
'farms-search': {
|
||||
method: 'GET',
|
||||
url: '/api/farms/search',
|
||||
params: '{"farmName": "测试"}'
|
||||
},
|
||||
'devices-list': {
|
||||
method: 'GET',
|
||||
url: '/api/devices'
|
||||
},
|
||||
'devices-search': {
|
||||
method: 'GET',
|
||||
url: '/api/devices/search',
|
||||
params: '{"deviceName": "传感器"}'
|
||||
},
|
||||
'animals-list': {
|
||||
method: 'GET',
|
||||
url: '/api/animals'
|
||||
},
|
||||
'alerts-list': {
|
||||
method: 'GET',
|
||||
url: '/api/alerts'
|
||||
},
|
||||
'users-list': {
|
||||
method: 'GET',
|
||||
url: '/api/users'
|
||||
},
|
||||
'products-list': {
|
||||
method: 'GET',
|
||||
url: '/api/products'
|
||||
},
|
||||
'orders-list': {
|
||||
method: 'GET',
|
||||
url: '/api/orders'
|
||||
},
|
||||
'reports-farm': {
|
||||
method: 'POST',
|
||||
url: '/api/reports/farm',
|
||||
body: '{"startDate": "2025-01-01", "endDate": "2025-01-18", "format": "pdf"}'
|
||||
},
|
||||
'system-configs': {
|
||||
method: 'GET',
|
||||
url: '/api/system/configs'
|
||||
},
|
||||
'system-stats': {
|
||||
method: 'GET',
|
||||
url: '/api/system/stats'
|
||||
}
|
||||
}
|
||||
|
||||
// 历史记录表格列
|
||||
const historyColumns = [
|
||||
{
|
||||
title: '方法',
|
||||
dataIndex: 'method',
|
||||
key: 'method',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: 'URL',
|
||||
dataIndex: 'url',
|
||||
key: 'url',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 120
|
||||
}
|
||||
]
|
||||
|
||||
// 组件挂载时加载历史记录
|
||||
onMounted(() => {
|
||||
loadHistory()
|
||||
})
|
||||
|
||||
// 加载API模板
|
||||
function loadApiTemplate(value) {
|
||||
const template = apiTemplates[value]
|
||||
if (template) {
|
||||
requestMethod.value = template.method
|
||||
requestUrl.value = template.url
|
||||
requestBody.value = template.body || ''
|
||||
queryParams.value = template.params || ''
|
||||
}
|
||||
}
|
||||
|
||||
// 发送API请求
|
||||
async function sendRequest() {
|
||||
if (!requestUrl.value.trim()) {
|
||||
message.error('请输入API端点')
|
||||
return
|
||||
}
|
||||
|
||||
requesting.value = true
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 解析请求头
|
||||
let headers = {}
|
||||
try {
|
||||
headers = JSON.parse(requestHeaders.value || '{}')
|
||||
} catch (error) {
|
||||
message.error('请求头格式错误,请使用有效的JSON格式')
|
||||
return
|
||||
}
|
||||
|
||||
// 构建请求配置
|
||||
const config = {
|
||||
method: requestMethod.value,
|
||||
url: requestUrl.value,
|
||||
headers
|
||||
}
|
||||
|
||||
// 添加请求体(POST/PUT)
|
||||
if (['POST', 'PUT'].includes(requestMethod.value) && requestBody.value.trim()) {
|
||||
try {
|
||||
config.data = JSON.parse(requestBody.value)
|
||||
} catch (error) {
|
||||
message.error('请求体格式错误,请使用有效的JSON格式')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 添加查询参数(GET)
|
||||
if (requestMethod.value === 'GET' && queryParams.value.trim()) {
|
||||
try {
|
||||
config.params = JSON.parse(queryParams.value)
|
||||
} catch (error) {
|
||||
message.error('查询参数格式错误,请使用有效的JSON格式')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const response = await api.request(config)
|
||||
const endTime = Date.now()
|
||||
|
||||
lastResponse.value = {
|
||||
status: 200,
|
||||
data: response,
|
||||
duration: endTime - startTime,
|
||||
size: JSON.stringify(response).length,
|
||||
contentType: 'application/json'
|
||||
}
|
||||
|
||||
message.success('请求发送成功')
|
||||
} catch (error) {
|
||||
const endTime = Date.now()
|
||||
|
||||
lastResponse.value = {
|
||||
status: error.response?.status || 500,
|
||||
data: error.response?.data || { error: error.message },
|
||||
duration: endTime - startTime,
|
||||
size: JSON.stringify(error.response?.data || {}).length,
|
||||
contentType: 'application/json'
|
||||
}
|
||||
|
||||
message.error('请求失败: ' + error.message)
|
||||
} finally {
|
||||
requesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到历史记录
|
||||
function saveToHistory() {
|
||||
if (!lastResponse.value) {
|
||||
message.warning('没有响应数据可保存')
|
||||
return
|
||||
}
|
||||
|
||||
const historyItem = {
|
||||
timestamp: Date.now(),
|
||||
method: requestMethod.value,
|
||||
url: requestUrl.value,
|
||||
headers: requestHeaders.value,
|
||||
body: requestBody.value,
|
||||
params: queryParams.value,
|
||||
status: lastResponse.value.status,
|
||||
response: lastResponse.value.data,
|
||||
duration: lastResponse.value.duration
|
||||
}
|
||||
|
||||
requestHistory.value.unshift(historyItem)
|
||||
|
||||
// 只保留最近50条记录
|
||||
if (requestHistory.value.length > 50) {
|
||||
requestHistory.value = requestHistory.value.slice(0, 50)
|
||||
}
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('api-test-history', JSON.stringify(requestHistory.value))
|
||||
|
||||
message.success('已保存到历史记录')
|
||||
}
|
||||
|
||||
// 从历史记录加载
|
||||
function loadFromHistory(record) {
|
||||
requestMethod.value = record.method
|
||||
requestUrl.value = record.url
|
||||
requestHeaders.value = record.headers
|
||||
requestBody.value = record.body
|
||||
queryParams.value = record.params
|
||||
|
||||
message.info('已从历史记录加载请求配置')
|
||||
}
|
||||
|
||||
// 查看历史响应
|
||||
function viewResponse(record) {
|
||||
selectedHistoryItem.value = record
|
||||
showResponseModal.value = true
|
||||
}
|
||||
|
||||
// 清空历史记录
|
||||
function clearHistory() {
|
||||
requestHistory.value = []
|
||||
localStorage.removeItem('api-test-history')
|
||||
message.success('历史记录已清空')
|
||||
}
|
||||
|
||||
// 加载历史记录
|
||||
function loadHistory() {
|
||||
try {
|
||||
const stored = localStorage.getItem('api-test-history')
|
||||
if (stored) {
|
||||
requestHistory.value = JSON.parse(stored)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载历史记录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开Swagger文档
|
||||
function openSwaggerDocs() {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_FULL_URL || 'http://localhost:5350/api'
|
||||
const docsUrl = apiBaseUrl.replace('/api', '/api-docs')
|
||||
window.open(docsUrl, '_blank')
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
function getMethodColor(method) {
|
||||
const colors = {
|
||||
GET: 'green',
|
||||
POST: 'blue',
|
||||
PUT: 'orange',
|
||||
DELETE: 'red'
|
||||
}
|
||||
return colors[method] || 'default'
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
if (status >= 200 && status < 300) return 'green'
|
||||
if (status >= 400 && status < 500) return 'orange'
|
||||
if (status >= 500) return 'red'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function formatResponse(data) {
|
||||
try {
|
||||
return JSON.stringify(data, null, 2)
|
||||
} catch (error) {
|
||||
return String(data)
|
||||
}
|
||||
}
|
||||
|
||||
function formatResponseSize(size) {
|
||||
if (size < 1024) return `${size} B`
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
return moment(timestamp).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.api-tester-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.api-tester-content {
|
||||
padding: 24px;
|
||||
background: #f0f2f5;
|
||||
min-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-page-header {
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
:deep(.ant-textarea) {
|
||||
font-family: 'Courier New', Monaco, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
height: calc(100% - 57px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.api-tester-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-col) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ant-row {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ant-col {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1398
admin-system/src/views/CattleArchives.vue
Normal file
1398
admin-system/src/views/CattleArchives.vue
Normal file
File diff suppressed because it is too large
Load Diff
1163
admin-system/src/views/CattleBatches.vue
Normal file
1163
admin-system/src/views/CattleBatches.vue
Normal file
File diff suppressed because it is too large
Load Diff
1147
admin-system/src/views/CattleExitRecords.vue
Normal file
1147
admin-system/src/views/CattleExitRecords.vue
Normal file
File diff suppressed because it is too large
Load Diff
777
admin-system/src/views/CattlePens.vue
Normal file
777
admin-system/src/views/CattlePens.vue
Normal file
@@ -0,0 +1,777 @@
|
||||
<template>
|
||||
<div class="cattle-pens">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">栏舍设置</h1>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮区域 -->
|
||||
<div class="action-bar">
|
||||
<div class="action-buttons">
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
新增栏舍
|
||||
</a-button>
|
||||
<a-button @click="handleBatchDelete" :disabled="selectedRowKeys.length === 0">
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
批量删除
|
||||
</a-button>
|
||||
<a-button @click="handleExport">
|
||||
<template #icon>
|
||||
<ExportOutlined />
|
||||
</template>
|
||||
导出数据
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-container">
|
||||
<a-input
|
||||
v-model="searchValue"
|
||||
placeholder="请输入栏舍名称(精确匹配)"
|
||||
class="search-input"
|
||||
@pressEnter="handleSearch"
|
||||
@input="handleSearchInput"
|
||||
>
|
||||
<template #suffix>
|
||||
<SearchOutlined @click="handleSearch" />
|
||||
</template>
|
||||
</a-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<div class="table-container">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:pagination="pagination"
|
||||
:loading="loading"
|
||||
:row-selection="rowSelection"
|
||||
class="pens-table"
|
||||
size="middle"
|
||||
>
|
||||
<!-- 状态列 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === '启用' ? 'green' : 'red'">
|
||||
{{ record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleViewAnimals(record)">
|
||||
查看牛只
|
||||
</a-button>
|
||||
<a-button type="link" size="small" danger @click="handleDelete(record)">
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<a-modal
|
||||
:open="modalVisible"
|
||||
@update:open="modalVisible = $event"
|
||||
:title="modalTitle"
|
||||
width="600px"
|
||||
@ok="handleModalOk"
|
||||
@cancel="handleModalCancel"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 18 }"
|
||||
>
|
||||
<a-form-item label="栏舍名称" name="name">
|
||||
<a-input
|
||||
v-model="formData.name"
|
||||
placeholder="请输入栏舍名称"
|
||||
@input="handleFieldChange('name', $event.target.value)"
|
||||
@change="handleFieldChange('name', $event.target.value)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="栏舍编号" name="code">
|
||||
<a-input
|
||||
v-model="formData.code"
|
||||
placeholder="请输入栏舍编号"
|
||||
@input="handleFieldChange('code', $event.target.value)"
|
||||
@change="handleFieldChange('code', $event.target.value)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="栏舍类型" name="type">
|
||||
<a-select
|
||||
v-model="formData.type"
|
||||
placeholder="请选择栏舍类型"
|
||||
@change="handleFieldChange('type', $event)"
|
||||
>
|
||||
<a-select-option value="育成栏">育成栏</a-select-option>
|
||||
<a-select-option value="产房">产房</a-select-option>
|
||||
<a-select-option value="配种栏">配种栏</a-select-option>
|
||||
<a-select-option value="隔离栏">隔离栏</a-select-option>
|
||||
<a-select-option value="治疗栏">治疗栏</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="容量" name="capacity">
|
||||
<a-input-number
|
||||
v-model="formData.capacity"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
style="width: 100%"
|
||||
placeholder="请输入栏舍容量"
|
||||
@change="handleFieldChange('capacity', $event)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="当前牛只数量" name="currentCount">
|
||||
<a-input-number
|
||||
v-model="formData.currentCount"
|
||||
:min="0"
|
||||
:max="formData.capacity || 1000"
|
||||
style="width: 100%"
|
||||
placeholder="当前牛只数量"
|
||||
@change="handleFieldChange('currentCount', $event)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="面积(平方米)" name="area">
|
||||
<a-input-number
|
||||
v-model="formData.area"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
placeholder="请输入栏舍面积"
|
||||
@change="handleFieldChange('area', $event)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="位置描述" name="location">
|
||||
<a-textarea
|
||||
v-model="formData.location"
|
||||
:rows="3"
|
||||
placeholder="请输入栏舍位置描述"
|
||||
@input="handleFieldChange('location', $event.target.value)"
|
||||
@change="handleFieldChange('location', $event.target.value)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group
|
||||
v-model="formData.status"
|
||||
@change="handleFieldChange('status', $event.target.value)"
|
||||
>
|
||||
<a-radio value="启用">启用</a-radio>
|
||||
<a-radio value="停用">停用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="备注" name="remark">
|
||||
<a-textarea
|
||||
v-model="formData.remark"
|
||||
:rows="3"
|
||||
placeholder="请输入备注信息"
|
||||
@input="handleFieldChange('remark', $event.target.value)"
|
||||
@change="handleFieldChange('remark', $event.target.value)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 查看牛只弹窗 -->
|
||||
<a-modal
|
||||
:open="animalsModalVisible"
|
||||
@update:open="animalsModalVisible = $event"
|
||||
title="栏舍牛只信息"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
>
|
||||
<a-table
|
||||
:columns="animalColumns"
|
||||
:data-source="currentPenAnimals"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
size="small"
|
||||
/>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
ExportOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
import { api } from '../utils/api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const searchValue = ref('')
|
||||
const modalVisible = ref(false)
|
||||
const animalsModalVisible = ref(false)
|
||||
const modalTitle = ref('新增栏舍')
|
||||
const formRef = ref()
|
||||
const selectedRowKeys = ref([])
|
||||
|
||||
// 表格数据
|
||||
const tableData = ref([])
|
||||
|
||||
// 当前栏舍的牛只数据
|
||||
const currentPenAnimals = ref([])
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '栏舍名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '栏舍编号',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '栏舍类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '容量',
|
||||
dataIndex: 'capacity',
|
||||
key: 'capacity',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '当前牛只数量',
|
||||
dataIndex: 'currentCount',
|
||||
key: 'currentCount',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '面积(平方米)',
|
||||
dataIndex: 'area',
|
||||
key: 'area',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '位置描述',
|
||||
dataIndex: 'location',
|
||||
key: 'location',
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 牛只表格列配置
|
||||
const animalColumns = [
|
||||
{
|
||||
title: '耳号',
|
||||
dataIndex: 'earTag',
|
||||
key: 'earTag'
|
||||
},
|
||||
{
|
||||
title: '品种',
|
||||
dataIndex: 'breed',
|
||||
key: 'breed'
|
||||
},
|
||||
{
|
||||
title: '性别',
|
||||
dataIndex: 'gender',
|
||||
key: 'gender'
|
||||
},
|
||||
{
|
||||
title: '月龄',
|
||||
dataIndex: 'ageInMonths',
|
||||
key: 'ageInMonths'
|
||||
},
|
||||
{
|
||||
title: '生理阶段',
|
||||
dataIndex: 'physiologicalStage',
|
||||
key: 'physiologicalStage'
|
||||
}
|
||||
]
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条/共 ${total} 条`
|
||||
})
|
||||
|
||||
// 行选择配置
|
||||
const rowSelection = {
|
||||
selectedRowKeys: selectedRowKeys,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys
|
||||
}
|
||||
}
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
code: '',
|
||||
type: '',
|
||||
capacity: null,
|
||||
currentCount: null,
|
||||
area: null,
|
||||
location: '',
|
||||
status: '启用',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入栏舍名称', trigger: 'blur' }
|
||||
],
|
||||
code: [
|
||||
{ required: true, message: '请输入栏舍编号', trigger: 'blur' }
|
||||
],
|
||||
type: [
|
||||
{ required: true, message: '请选择栏舍类型', trigger: 'change' }
|
||||
],
|
||||
capacity: [
|
||||
{ required: true, message: '请输入栏舍容量', trigger: 'blur' }
|
||||
],
|
||||
area: [
|
||||
{ required: true, message: '请输入栏舍面积', trigger: 'blur' }
|
||||
],
|
||||
status: [
|
||||
{ required: true, message: '请选择状态', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
// 方法
|
||||
const loadData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const params = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize
|
||||
}
|
||||
|
||||
// 精确匹配栏舍名称
|
||||
if (searchValue.value) {
|
||||
params.name = searchValue.value
|
||||
}
|
||||
|
||||
console.log('📤 [栏舍设置] 发送请求参数:', params)
|
||||
|
||||
const response = await api.cattlePens.getList(params)
|
||||
console.log('📥 [栏舍设置] 接收到的响应数据:', response)
|
||||
|
||||
if (response.success) {
|
||||
console.log('📊 [栏舍设置] 原始列表数据:', response.data.list)
|
||||
|
||||
tableData.value = response.data.list.map(item => ({
|
||||
...item,
|
||||
key: item.id,
|
||||
createTime: item.created_at ? dayjs(item.created_at).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
}))
|
||||
|
||||
console.log('📊 [栏舍设置] 格式化后的表格数据:', tableData.value)
|
||||
pagination.total = response.data.total
|
||||
console.log('📊 [栏舍设置] 总记录数:', response.data.total)
|
||||
} else {
|
||||
console.warn('⚠️ [栏舍设置] 请求成功但返回失败状态:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [栏舍设置] 加载数据失败:', error)
|
||||
message.error('加载数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
modalTitle.value = '新增栏舍'
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (record) => {
|
||||
console.log('🔄 [栏舍设置] 开始编辑操作')
|
||||
console.log('📋 [栏舍设置] 原始记录数据:', {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
status: record.status,
|
||||
capacity: record.capacity,
|
||||
area: record.area,
|
||||
location: record.location,
|
||||
description: record.description,
|
||||
remark: record.remark,
|
||||
farmId: record.farmId,
|
||||
farmName: record.farm?.name
|
||||
})
|
||||
|
||||
modalTitle.value = '编辑栏舍'
|
||||
Object.assign(formData, record)
|
||||
|
||||
console.log('📝 [栏舍设置] 表单数据已填充:', formData)
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除栏舍"${record.name}"吗?`,
|
||||
async onOk() {
|
||||
try {
|
||||
await api.cattlePens.delete(record.id)
|
||||
message.success('删除成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
Modal.confirm({
|
||||
title: '确认批量删除',
|
||||
content: `确定要删除选中的 ${selectedRowKeys.value.length} 个栏舍吗?`,
|
||||
async onOk() {
|
||||
try {
|
||||
await api.cattlePens.batchDelete(selectedRowKeys.value)
|
||||
message.success('批量删除成功')
|
||||
selectedRowKeys.value = []
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('批量删除失败:', error)
|
||||
message.error('批量删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleViewAnimals = async (record) => {
|
||||
try {
|
||||
const response = await api.cattlePens.getAnimals(record.id)
|
||||
if (response.success) {
|
||||
currentPenAnimals.value = response.data.list.map(item => ({
|
||||
...item,
|
||||
key: item.id
|
||||
}))
|
||||
animalsModalVisible.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取栏舍牛只失败:', error)
|
||||
message.error('获取栏舍牛只失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
console.log('=== 开始导出栏舍数据 ===')
|
||||
console.log('tableData.value长度:', tableData.value.length)
|
||||
console.log('tableData.value示例:', tableData.value[0])
|
||||
|
||||
if (!tableData.value || tableData.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
// 转换数据格式以匹配导出工具类的列配置
|
||||
const exportData = tableData.value.map(item => {
|
||||
console.log('转换前栏舍数据项:', item)
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name || '',
|
||||
animal_type: item.animalType || item.animal_type || '牛', // 默认动物类型为牛
|
||||
pen_type: item.type || item.penType || item.pen_type || '', // 使用type字段
|
||||
responsible: item.responsible || item.manager || item.managerName || '',
|
||||
capacity: item.capacity || 0,
|
||||
status: item.status || '启用',
|
||||
creator: item.creator || item.created_by || item.creatorName || '',
|
||||
created_at: item.created_at || item.createTime || ''
|
||||
}
|
||||
})
|
||||
|
||||
console.log('转换后栏舍数据示例:', exportData[0])
|
||||
console.log('转换后栏舍数据总数:', exportData.length)
|
||||
|
||||
const result = ExportUtils.exportPenData(exportData)
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听搜索输入变化
|
||||
const handleSearchInput = (e) => {
|
||||
console.log('🔍 [栏舍设置] 搜索输入变化:', e.target.value)
|
||||
// 确保searchValue被正确更新
|
||||
searchValue.value = e.target.value
|
||||
|
||||
// 实时过滤表格数据
|
||||
debounceSearch()
|
||||
}
|
||||
|
||||
// 使用防抖函数处理实时搜索,避免频繁请求
|
||||
const debounceSearch = (() => {
|
||||
let timer = null
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
console.log('🔍 [栏舍设置] 执行实时搜索,搜索值:', searchValue.value)
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}, 300) // 300ms防抖延迟
|
||||
}
|
||||
})()
|
||||
|
||||
const handleSearch = () => {
|
||||
console.log('🔍 [栏舍设置] 执行搜索操作,搜索值:', searchValue.value)
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 字段变化监听方法
|
||||
const handleFieldChange = (fieldName, value) => {
|
||||
console.log(`📝 [栏舍设置] 字段变化监听: ${fieldName} = ${value}`)
|
||||
console.log(`📝 [栏舍设置] 当前表单数据:`, formData)
|
||||
|
||||
// 确保数据同步到formData
|
||||
if (formData[fieldName] !== value) {
|
||||
formData[fieldName] = value
|
||||
console.log(`✅ [栏舍设置] 字段 ${fieldName} 已更新为: ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
console.log('💾 [栏舍设置] 开始保存操作')
|
||||
console.log('📝 [栏舍设置] 用户输入的表单数据:', {
|
||||
id: formData.id,
|
||||
name: formData.name,
|
||||
code: formData.code,
|
||||
type: formData.type,
|
||||
status: formData.status,
|
||||
capacity: formData.capacity,
|
||||
currentCount: formData.currentCount,
|
||||
area: formData.area,
|
||||
location: formData.location,
|
||||
remark: formData.remark,
|
||||
farmId: formData.farmId
|
||||
})
|
||||
|
||||
await formRef.value.validate()
|
||||
console.log('✅ [栏舍设置] 表单验证通过')
|
||||
|
||||
// 准备发送给后端的数据,转换数字格式
|
||||
const submitData = {
|
||||
name: formData.name,
|
||||
code: formData.code,
|
||||
type: formData.type,
|
||||
status: formData.status,
|
||||
capacity: formData.capacity ? Number(formData.capacity) : null,
|
||||
currentCount: formData.currentCount ? Number(formData.currentCount) : 0,
|
||||
area: formData.area ? Number(formData.area) : null,
|
||||
location: formData.location || '',
|
||||
remark: formData.remark || '',
|
||||
farmId: formData.farmId || 1
|
||||
}
|
||||
|
||||
console.log('📤 [栏舍设置] 准备发送的数据:', submitData)
|
||||
|
||||
if (modalTitle.value === '新增栏舍') {
|
||||
console.log('🆕 [栏舍设置] 执行创建操作')
|
||||
await api.cattlePens.create(submitData)
|
||||
console.log('✅ [栏舍设置] 创建成功')
|
||||
message.success('创建成功')
|
||||
} else {
|
||||
console.log('🔄 [栏舍设置] 执行更新操作,记录ID:', formData.id)
|
||||
const response = await api.cattlePens.update(formData.id, submitData)
|
||||
console.log('✅ [栏舍设置] 更新成功,服务器响应:', response)
|
||||
message.success('更新成功')
|
||||
}
|
||||
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('❌ [栏舍设置] 操作失败:', error)
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleModalCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.keys(formData).forEach(key => {
|
||||
if (key === 'capacity' || key === 'currentCount' || key === 'area') {
|
||||
formData[key] = null
|
||||
} else {
|
||||
formData[key] = ''
|
||||
}
|
||||
})
|
||||
formData.status = '启用'
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cattle-pens {
|
||||
padding: 24px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pens-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pens-table :deep(.ant-table-thead > tr > th) {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.pens-table :deep(.ant-table-tbody > tr > td) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.pens-table :deep(.ant-table-tbody > tr:hover > td) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.action-bar {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cattle-pens {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1098
admin-system/src/views/CattleTransferRecords.vue
Normal file
1098
admin-system/src/views/CattleTransferRecords.vue
Normal file
File diff suppressed because it is too large
Load Diff
20
admin-system/src/views/Dashboard.vue
Normal file
20
admin-system/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<a-page-header
|
||||
title="宁夏智慧养殖监管平台数据概览"
|
||||
>
|
||||
</a-page-header>
|
||||
|
||||
<Dashboard />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Dashboard from '../components/Dashboard.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-page {
|
||||
padding: 0 16px;
|
||||
}
|
||||
</style>
|
||||
586
admin-system/src/views/Devices.vue
Normal file
586
admin-system/src/views/Devices.vue
Normal file
@@ -0,0 +1,586 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>设备管理</h1>
|
||||
<a-space>
|
||||
<a-button @click="exportDevices" :loading="exportLoading">
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出数据
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加设备
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 8px; align-items: center;">
|
||||
<a-auto-complete
|
||||
v-model:value="searchDeviceName"
|
||||
:options="deviceNameOptions"
|
||||
placeholder="请选择或输入设备名称进行搜索"
|
||||
style="width: 300px;"
|
||||
:filter-option="false"
|
||||
@search="handleSearchInput"
|
||||
@press-enter="searchDevices"
|
||||
/>
|
||||
<a-button type="primary" @click="searchDevices" :loading="searchLoading">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="resetSearch" :disabled="!isSearching">
|
||||
重置
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="devices"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'id'">
|
||||
{{ record.id }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'name'">
|
||||
{{ record.name }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'type'">
|
||||
<a-tag color="blue">{{ getTypeText(record.type) }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'farm_name'">
|
||||
{{ getFarmName(record.farm_id) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'last_maintenance'">
|
||||
{{ formatDate(record.last_maintenance) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'installation_date'">
|
||||
{{ formatDate(record.installation_date) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="editDevice(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个设备吗?"
|
||||
@confirm="deleteDevice(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑设备模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑设备' : '添加设备'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="设备名称" name="name">
|
||||
<a-input v-model:value="formData.name" placeholder="请输入设备名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="设备类型" name="type">
|
||||
<a-select v-model:value="formData.type" placeholder="请选择设备类型" @change="handleTypeChange">
|
||||
<a-select-option value="sensor">传感器</a-select-option>
|
||||
<a-select-option value="camera">摄像头</a-select-option>
|
||||
<a-select-option value="feeder">喂食器</a-select-option>
|
||||
<a-select-option value="monitor">监控器</a-select-option>
|
||||
<a-select-option value="controller">控制器</a-select-option>
|
||||
<a-select-option value="other">其他</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="showCustomTypeInput" label="自定义设备类型" name="customType">
|
||||
<a-input
|
||||
v-model:value="formData.customType"
|
||||
placeholder="请输入自定义设备类型"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="设备状态" name="status">
|
||||
<a-select v-model:value="formData.status" placeholder="请选择设备状态">
|
||||
<a-select-option value="online">在线</a-select-option>
|
||||
<a-select-option value="offline">离线</a-select-option>
|
||||
<a-select-option value="maintenance">维护中</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="所属农场" name="farm_id">
|
||||
<a-select
|
||||
v-model:value="formData.farm_id"
|
||||
placeholder="请选择所属农场"
|
||||
:loading="farmsLoading"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="farm in farms"
|
||||
:key="farm.id"
|
||||
:value="farm.id"
|
||||
>
|
||||
{{ farm.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="安装日期" name="installation_date">
|
||||
<a-date-picker
|
||||
v-model:value="formData.installation_date"
|
||||
placeholder="请选择安装日期"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="最后维护时间" name="last_maintenance">
|
||||
<a-date-picker
|
||||
v-model:value="formData.last_maintenance"
|
||||
placeholder="请选择最后维护时间"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
import { api } from '../utils/api'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// 响应式数据
|
||||
const devices = ref([])
|
||||
const farms = ref([])
|
||||
const loading = ref(false)
|
||||
const farmsLoading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 搜索相关的响应式数据
|
||||
const searchDeviceName = ref('')
|
||||
const searchLoading = ref(false)
|
||||
const isSearching = ref(false)
|
||||
const deviceNameOptions = ref([])
|
||||
|
||||
// 导出相关
|
||||
const exportLoading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
type: '',
|
||||
status: 'offline',
|
||||
farm_id: null,
|
||||
installation_date: null,
|
||||
last_maintenance: null,
|
||||
customType: '' // 自定义设备类型
|
||||
})
|
||||
|
||||
// 控制是否显示自定义输入框
|
||||
const showCustomTypeInput = ref(false)
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
|
||||
type: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
|
||||
customType: [{
|
||||
required: () => formData.type === 'other',
|
||||
message: '请输入自定义设备类型',
|
||||
trigger: 'blur'
|
||||
}],
|
||||
status: [{ required: true, message: '请选择设备状态', trigger: 'change' }],
|
||||
farm_id: [{ required: true, message: '请选择所属农场', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '设备名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: '设备类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '设备状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '所属农场',
|
||||
dataIndex: 'farm_name',
|
||||
key: 'farm_name',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '安装日期',
|
||||
dataIndex: 'installation_date',
|
||||
key: 'installation_date',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '最后维护',
|
||||
dataIndex: 'last_maintenance',
|
||||
key: 'last_maintenance',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 150
|
||||
}
|
||||
]
|
||||
|
||||
// 获取设备列表
|
||||
const fetchDevices = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.get('/devices')
|
||||
if (response.success) {
|
||||
devices.value = response.data
|
||||
} else {
|
||||
message.error('获取设备列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取设备列表失败:', error)
|
||||
if (error.response && error.response.status === 401) {
|
||||
message.error('登录已过期,请重新登录')
|
||||
// 可以在这里添加重定向到登录页的逻辑
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login'
|
||||
}, 2000)
|
||||
} else {
|
||||
message.error('获取设备列表失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取农场列表
|
||||
const fetchFarms = async () => {
|
||||
try {
|
||||
farmsLoading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.get('/farms')
|
||||
if (response.success) {
|
||||
farms.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取农场列表失败:', error)
|
||||
} finally {
|
||||
farmsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理设备类型选择变化
|
||||
const handleTypeChange = (value) => {
|
||||
showCustomTypeInput.value = value === 'other'
|
||||
if (value !== 'other') {
|
||||
formData.customType = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
fetchFarms()
|
||||
}
|
||||
|
||||
// 编辑设备
|
||||
const editDevice = (record) => {
|
||||
isEdit.value = true
|
||||
const predefinedTypes = ['sensor', 'camera', 'feeder', 'monitor', 'controller']
|
||||
const isCustomType = !predefinedTypes.includes(record.type)
|
||||
|
||||
// 逐个字段赋值,避免破坏响应式绑定
|
||||
formData.id = record.id
|
||||
formData.name = record.name
|
||||
formData.type = isCustomType ? 'other' : record.type
|
||||
formData.customType = isCustomType ? record.type : ''
|
||||
formData.status = record.status
|
||||
formData.farm_id = record.farm_id
|
||||
formData.installation_date = record.installation_date ? dayjs(record.installation_date) : null
|
||||
formData.last_maintenance = record.last_maintenance ? dayjs(record.last_maintenance) : null
|
||||
|
||||
showCustomTypeInput.value = isCustomType
|
||||
modalVisible.value = true
|
||||
fetchFarms()
|
||||
}
|
||||
|
||||
// 删除设备
|
||||
const deleteDevice = async (id) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.delete(`/devices/${id}`)
|
||||
if (response.success) {
|
||||
message.success('删除成功')
|
||||
fetchDevices()
|
||||
} else {
|
||||
message.error('删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除设备失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
const config = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
// 处理日期格式和自定义类型
|
||||
const submitData = {
|
||||
...formData,
|
||||
type: formData.type === 'other' ? formData.customType : formData.type,
|
||||
installation_date: formData.installation_date ? formData.installation_date.format('YYYY-MM-DD') : null,
|
||||
last_maintenance: formData.last_maintenance ? formData.last_maintenance.format('YYYY-MM-DD') : null
|
||||
}
|
||||
// 移除customType字段,避免发送到后端
|
||||
delete submitData.customType
|
||||
|
||||
// 如果是新增操作,移除id字段
|
||||
if (!isEdit.value) {
|
||||
delete submitData.id
|
||||
}
|
||||
|
||||
let response
|
||||
if (isEdit.value) {
|
||||
response = await api.put(`/devices/${formData.id}`, submitData)
|
||||
} else {
|
||||
response = await api.post('/devices', submitData)
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
modalVisible.value = false
|
||||
fetchDevices()
|
||||
} else {
|
||||
message.error(isEdit.value ? '更新失败' : '创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
name: '',
|
||||
type: '',
|
||||
status: 'offline',
|
||||
farm_id: null,
|
||||
installation_date: null,
|
||||
last_maintenance: null,
|
||||
customType: ''
|
||||
})
|
||||
showCustomTypeInput.value = false
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
online: 'green',
|
||||
offline: 'red',
|
||||
maintenance: 'orange'
|
||||
}
|
||||
return colors[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
maintenance: '维护中'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
// 获取类型文本
|
||||
const getTypeText = (type) => {
|
||||
const texts = {
|
||||
temperature_sensor: '温度传感器',
|
||||
humidity_sensor: '湿度传感器',
|
||||
feed_dispenser: '饲料分配器',
|
||||
water_system: '水系统',
|
||||
ventilation_system: '通风系统',
|
||||
sensor: '传感器',
|
||||
camera: '摄像头',
|
||||
feeder: '喂食器',
|
||||
monitor: '监控器',
|
||||
controller: '控制器',
|
||||
other: '其他'
|
||||
}
|
||||
return texts[type] || type
|
||||
}
|
||||
|
||||
// 获取农场名称
|
||||
const getFarmName = (farmId) => {
|
||||
const farm = farms.value.find(f => f.id === farmId)
|
||||
return farm ? farm.name : `农场ID: ${farmId}`
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-'
|
||||
return dayjs(date).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
// 搜索设备
|
||||
const searchDevices = async () => {
|
||||
if (!searchDeviceName.value.trim()) {
|
||||
message.warning('请输入设备名称进行搜索')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
searchLoading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.get('/devices/search', {
|
||||
params: { name: searchDeviceName.value.trim() }
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
devices.value = response.data || []
|
||||
isSearching.value = true
|
||||
message.success(response.message || `找到 ${devices.value.length} 个匹配的设备`)
|
||||
} else {
|
||||
devices.value = []
|
||||
message.info('未找到匹配的设备')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索设备失败:', error)
|
||||
message.error('搜索设备失败')
|
||||
devices.value = []
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索输入(为自动完成提供选项)
|
||||
const handleSearchInput = (value) => {
|
||||
if (!value) {
|
||||
deviceNameOptions.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 从现有设备列表中筛选匹配的设备名称
|
||||
const matchingDevices = devices.value.filter(device =>
|
||||
device.name.toLowerCase().includes(value.toLowerCase())
|
||||
)
|
||||
|
||||
deviceNameOptions.value = matchingDevices.map(device => ({
|
||||
value: device.name,
|
||||
label: device.name
|
||||
}))
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchDeviceName.value = ''
|
||||
isSearching.value = false
|
||||
deviceNameOptions.value = []
|
||||
fetchDevices() // 重新加载全部设备
|
||||
}
|
||||
|
||||
// 更新设备名称选项(在数据加载后)
|
||||
const updateDeviceNameOptions = () => {
|
||||
deviceNameOptions.value = devices.value.map(device => ({
|
||||
value: device.name,
|
||||
label: device.name
|
||||
}))
|
||||
}
|
||||
|
||||
// 导出设备数据
|
||||
const exportDevices = async () => {
|
||||
try {
|
||||
if (!devices.value || devices.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
exportLoading.value = true
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportDevicesData(devices.value)
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchDevices().then(() => {
|
||||
updateDeviceNameOptions()
|
||||
})
|
||||
fetchFarms()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 可以添加自定义样式 */
|
||||
</style>
|
||||
1336
admin-system/src/views/ElectronicFence.vue
Normal file
1336
admin-system/src/views/ElectronicFence.vue
Normal file
File diff suppressed because it is too large
Load Diff
1411
admin-system/src/views/FarmInfoManagement.vue
Normal file
1411
admin-system/src/views/FarmInfoManagement.vue
Normal file
File diff suppressed because it is too large
Load Diff
588
admin-system/src/views/Farms.vue
Normal file
588
admin-system/src/views/Farms.vue
Normal file
@@ -0,0 +1,588 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>养殖场管理</h1>
|
||||
<a-space>
|
||||
<a-button @click="exportFarms" :loading="exportLoading">
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出数据
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加养殖场
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 8px; align-items: center;">
|
||||
<a-auto-complete
|
||||
v-model:value="searchFarmName"
|
||||
:options="farmNameOptions"
|
||||
placeholder="请选择或输入养殖场名称进行搜索"
|
||||
style="width: 300px;"
|
||||
:filter-option="false"
|
||||
@search="handleSearchInput"
|
||||
@press-enter="searchFarms"
|
||||
/>
|
||||
<a-button type="primary" @click="searchFarms" :loading="searchLoading">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="resetSearch" :disabled="!isSearching">
|
||||
重置
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="farms"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="record.status === 'active' ? 'green' : record.status === 'inactive' ? 'red' : 'orange'">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'created_at'">
|
||||
{{ formatDate(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="editFarm(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个养殖场吗?"
|
||||
@confirm="deleteFarm(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑养殖场模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑养殖场' : '添加养殖场'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
width="800px"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 18 }"
|
||||
>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="养殖场名称" name="name">
|
||||
<a-input v-model:value="formData.name" placeholder="请输入养殖场名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="负责人" name="owner">
|
||||
<a-input v-model:value="formData.owner" placeholder="请输入负责人姓名" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="联系电话" name="phone">
|
||||
<a-input v-model:value="formData.phone" placeholder="请输入联系电话" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select v-model:value="formData.status" placeholder="请选择状态">
|
||||
<a-select-option value="active">运营中</a-select-option>
|
||||
<a-select-option value="inactive">已停用</a-select-option>
|
||||
<a-select-option value="maintenance">维护中</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="地址" name="address">
|
||||
<a-input v-model:value="formData.address" placeholder="请输入详细地址" />
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="经度" name="longitude">
|
||||
<a-input-number
|
||||
v-model:value="formData.longitude"
|
||||
placeholder="请输入经度 (-180 ~ 180)"
|
||||
:precision="6"
|
||||
:step="0.000001"
|
||||
:min="-180"
|
||||
:max="180"
|
||||
:string-mode="false"
|
||||
:controls="false"
|
||||
:parser="value => {
|
||||
if (!value) return value;
|
||||
// 移除非数字字符,保留小数点和负号
|
||||
const cleaned = value.toString().replace(/[^\d.-]/g, '');
|
||||
// 确保只有一个小数点和负号在开头
|
||||
const parts = cleaned.split('.');
|
||||
if (parts.length > 2) {
|
||||
return parts[0] + '.' + parts.slice(1).join('');
|
||||
}
|
||||
return cleaned;
|
||||
}"
|
||||
:formatter="value => value"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="纬度" name="latitude">
|
||||
<a-input-number
|
||||
v-model:value="formData.latitude"
|
||||
placeholder="请输入纬度 (-90 ~ 90)"
|
||||
:precision="6"
|
||||
:step="0.000001"
|
||||
:min="-90"
|
||||
:max="90"
|
||||
:string-mode="false"
|
||||
:controls="false"
|
||||
:parser="value => {
|
||||
if (!value) return value;
|
||||
// 移除非数字字符,保留小数点和负号
|
||||
const cleaned = value.toString().replace(/[^\d.-]/g, '');
|
||||
// 确保只有一个小数点和负号在开头
|
||||
const parts = cleaned.split('.');
|
||||
if (parts.length > 2) {
|
||||
return parts[0] + '.' + parts.slice(1).join('');
|
||||
}
|
||||
return cleaned;
|
||||
}"
|
||||
:formatter="value => value"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="占地面积(亩)" name="area">
|
||||
<a-input-number
|
||||
v-model:value="formData.area"
|
||||
placeholder="请输入占地面积"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="养殖规模" name="capacity">
|
||||
<a-input-number
|
||||
v-model:value="formData.capacity"
|
||||
placeholder="请输入养殖规模"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="描述" name="description">
|
||||
<a-textarea
|
||||
v-model:value="formData.description"
|
||||
placeholder="请输入养殖场描述"
|
||||
:rows="3"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
|
||||
// 响应式数据
|
||||
const farms = ref([])
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 搜索相关的响应式数据
|
||||
const searchFarmName = ref('')
|
||||
const searchLoading = ref(false)
|
||||
const isSearching = ref(false)
|
||||
const farmNameOptions = ref([])
|
||||
|
||||
// 导出相关
|
||||
const exportLoading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
owner: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
longitude: undefined,
|
||||
latitude: undefined,
|
||||
area: undefined,
|
||||
capacity: undefined,
|
||||
status: 'active',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入养殖场名称', trigger: 'blur' }],
|
||||
owner: [{ required: true, message: '请输入负责人姓名', trigger: 'blur' }],
|
||||
phone: [
|
||||
{ required: true, message: '请输入联系电话', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
|
||||
],
|
||||
address: [{ required: true, message: '请输入详细地址', trigger: 'blur' }],
|
||||
longitude: [
|
||||
{ validator: (rule, value) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return Promise.resolve(); // 允许为空
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num)) {
|
||||
return Promise.reject('请输入有效的经度数值');
|
||||
}
|
||||
if (num < -180 || num > 180) {
|
||||
return Promise.reject('经度范围应在-180到180之间');
|
||||
}
|
||||
// 检查小数位数不超过6位
|
||||
const decimalPart = value.toString().split('.')[1];
|
||||
if (decimalPart && decimalPart.length > 6) {
|
||||
return Promise.reject('经度小数位数不能超过6位');
|
||||
}
|
||||
return Promise.resolve();
|
||||
}, trigger: 'blur' }
|
||||
],
|
||||
latitude: [
|
||||
{ validator: (rule, value) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return Promise.resolve(); // 允许为空
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num)) {
|
||||
return Promise.reject('请输入有效的纬度数值');
|
||||
}
|
||||
if (num < -90 || num > 90) {
|
||||
return Promise.reject('纬度范围应在-90到90之间');
|
||||
}
|
||||
// 检查小数位数不超过6位
|
||||
const decimalPart = value.toString().split('.')[1];
|
||||
if (decimalPart && decimalPart.length > 6) {
|
||||
return Promise.reject('纬度小数位数不能超过6位');
|
||||
}
|
||||
return Promise.resolve();
|
||||
}, trigger: 'blur' }
|
||||
],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '养殖场名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '负责人',
|
||||
dataIndex: 'owner',
|
||||
key: 'owner',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '联系电话',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '地址',
|
||||
dataIndex: 'address',
|
||||
key: 'address',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 150
|
||||
}
|
||||
]
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
active: '运营中',
|
||||
inactive: '已停用',
|
||||
maintenance: '维护中'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取养殖场列表
|
||||
const fetchFarms = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const { api } = await import('../utils/api')
|
||||
const response = await api.get('/farms')
|
||||
console.log('养殖场API响应:', response)
|
||||
|
||||
// 检查响应格式
|
||||
if (response && response.success && Array.isArray(response.data)) {
|
||||
farms.value = response.data
|
||||
} else if (Array.isArray(response)) {
|
||||
// 兼容旧格式
|
||||
farms.value = response
|
||||
} else {
|
||||
farms.value = []
|
||||
console.warn('API返回数据格式异常:', response)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取养殖场列表失败:', error)
|
||||
message.error('获取养殖场列表失败')
|
||||
farms.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑养殖场
|
||||
const editFarm = (record) => {
|
||||
isEdit.value = true
|
||||
// 解析location对象中的经纬度数据
|
||||
const longitude = record.location?.lng || undefined
|
||||
const latitude = record.location?.lat || undefined
|
||||
|
||||
Object.assign(formData, {
|
||||
...record,
|
||||
longitude,
|
||||
latitude,
|
||||
owner: record.contact || ''
|
||||
})
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 删除养殖场
|
||||
const deleteFarm = async (id) => {
|
||||
try {
|
||||
const { api } = await import('../utils/api')
|
||||
await api.delete(`/farms/${id}`)
|
||||
message.success('删除成功')
|
||||
fetchFarms()
|
||||
} catch (error) {
|
||||
console.error('删除养殖场失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
const submitData = { ...formData }
|
||||
|
||||
const { api } = await import('../utils/api')
|
||||
if (isEdit.value) {
|
||||
await api.put(`/farms/${formData.id}`, submitData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await api.post('/farms', submitData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
|
||||
modalVisible.value = false
|
||||
fetchFarms()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
name: '',
|
||||
owner: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
longitude: undefined,
|
||||
latitude: undefined,
|
||||
area: undefined,
|
||||
capacity: undefined,
|
||||
status: 'active',
|
||||
description: ''
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 搜索养殖场
|
||||
const searchFarms = async () => {
|
||||
if (!searchFarmName.value.trim()) {
|
||||
message.warning('请输入养殖场名称进行搜索')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
searchLoading.value = true
|
||||
const { api } = await import('../utils/api')
|
||||
const response = await api.get('/farms/search', {
|
||||
params: { name: searchFarmName.value.trim() }
|
||||
})
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
farms.value = response
|
||||
isSearching.value = true
|
||||
message.success(`找到 ${response.length} 个匹配的养殖场`)
|
||||
} else {
|
||||
farms.value = []
|
||||
message.info('未找到匹配的养殖场')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索养殖场失败:', error)
|
||||
message.error('搜索养殖场失败')
|
||||
farms.value = []
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索输入(为自动完成提供选项)
|
||||
const handleSearchInput = (value) => {
|
||||
if (!value) {
|
||||
farmNameOptions.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 从现有养殖场列表中筛选匹配的养殖场名称
|
||||
const matchingFarms = farms.value.filter(farm =>
|
||||
farm.name.toLowerCase().includes(value.toLowerCase())
|
||||
)
|
||||
|
||||
farmNameOptions.value = matchingFarms.map(farm => ({
|
||||
value: farm.name,
|
||||
label: farm.name
|
||||
}))
|
||||
}
|
||||
|
||||
// 更新养殖场名称选项(在数据加载后)
|
||||
const updateFarmNameOptions = () => {
|
||||
farmNameOptions.value = farms.value.map(farm => ({
|
||||
value: farm.name,
|
||||
label: farm.name
|
||||
}))
|
||||
}
|
||||
|
||||
// 导出农场数据
|
||||
const exportFarms = async () => {
|
||||
try {
|
||||
if (!farms.value || farms.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
exportLoading.value = true
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportFarmsData(farms.value)
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchFarmName.value = ''
|
||||
isSearching.value = false
|
||||
farmNameOptions.value = []
|
||||
fetchFarms() // 重新加载全部养殖场
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchFarms().then(() => {
|
||||
updateFarmNameOptions()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
599
admin-system/src/views/FormLogManagement.vue
Normal file
599
admin-system/src/views/FormLogManagement.vue
Normal file
@@ -0,0 +1,599 @@
|
||||
<template>
|
||||
<div class="form-log-management">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">表单日志管理</h1>
|
||||
<div class="header-actions">
|
||||
<a-button @click="handleRefresh" :loading="loading">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button @click="handleExport" :disabled="selectedRowKeys.length === 0">
|
||||
<template #icon>
|
||||
<ExportOutlined />
|
||||
</template>
|
||||
导出日志
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选区域 -->
|
||||
<div class="filter-bar">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-select
|
||||
v-model="filters.module"
|
||||
placeholder="选择模块"
|
||||
allowClear
|
||||
style="width: 100%"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<a-select-option value="farm-management">养殖场管理</a-select-option>
|
||||
<a-select-option value="animal-management">动物管理</a-select-option>
|
||||
<a-select-option value="device-management">设备管理</a-select-option>
|
||||
<a-select-option value="alert-management">预警管理</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-select
|
||||
v-model="filters.action"
|
||||
placeholder="选择操作"
|
||||
allowClear
|
||||
style="width: 100%"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<a-select-option value="form_submit_start">表单提交开始</a-select-option>
|
||||
<a-select-option value="form_validation_success">验证成功</a-select-option>
|
||||
<a-select-option value="form_validation_failed">验证失败</a-select-option>
|
||||
<a-select-option value="form_create_success">创建成功</a-select-option>
|
||||
<a-select-option value="form_edit_success">编辑成功</a-select-option>
|
||||
<a-select-option value="field_change">字段变化</a-select-option>
|
||||
<a-select-option value="search">搜索操作</a-select-option>
|
||||
<a-select-option value="modal_open">打开模态框</a-select-option>
|
||||
<a-select-option value="modal_cancel">取消操作</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-select
|
||||
v-model="filters.status"
|
||||
placeholder="选择状态"
|
||||
allowClear
|
||||
style="width: 100%"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<a-select-option value="success">成功</a-select-option>
|
||||
<a-select-option value="error">错误</a-select-option>
|
||||
<a-select-option value="warning">警告</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-range-picker
|
||||
v-model="filters.dateRange"
|
||||
style="width: 100%"
|
||||
@change="handleFilterChange"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16" style="margin-top: 16px;">
|
||||
<a-col :span="12">
|
||||
<a-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="搜索用户名、操作或模块"
|
||||
@pressEnter="handleSearch"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<template #suffix>
|
||||
<SearchOutlined @click="handleSearch" style="cursor: pointer;" />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-button @click="handleSearch" type="primary">
|
||||
搜索
|
||||
</a-button>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-button @click="handleResetFilters">
|
||||
重置筛选
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息卡片 -->
|
||||
<div class="stats-cards">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="总日志数"
|
||||
:value="stats.totalLogs"
|
||||
:loading="statsLoading"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="成功操作"
|
||||
:value="stats.successCount"
|
||||
:loading="statsLoading"
|
||||
value-style="color: #52c41a"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="错误操作"
|
||||
:value="stats.errorCount"
|
||||
:loading="statsLoading"
|
||||
value-style="color: #ff4d4f"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="活跃用户"
|
||||
:value="stats.activeUsers"
|
||||
:loading="statsLoading"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<div class="table-container">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:pagination="pagination"
|
||||
:loading="loading"
|
||||
:row-selection="rowSelection"
|
||||
class="log-table"
|
||||
size="middle"
|
||||
:scroll="{ x: 1200 }"
|
||||
>
|
||||
<!-- 状态列 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleViewDetail(record)">
|
||||
查看详情
|
||||
</a-button>
|
||||
<a-button type="link" size="small" danger @click="handleDelete(record)">
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<template v-if="column.key === 'formData'">
|
||||
<a-button type="link" size="small" @click="handleViewFormData(record)">
|
||||
查看数据
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<a-modal
|
||||
:open="detailModalVisible"
|
||||
@update:open="detailModalVisible = $event"
|
||||
title="日志详情"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
>
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="日志ID">{{ currentRecord.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="模块">{{ currentRecord.module }}</a-descriptions-item>
|
||||
<a-descriptions-item label="操作">{{ currentRecord.action }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="getStatusColor(currentRecord.status)">
|
||||
{{ getStatusText(currentRecord.status) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="用户">{{ currentRecord.username || '未知' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="用户ID">{{ currentRecord.userId || '未知' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="会话ID">{{ currentRecord.sessionId || '未知' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="IP地址">{{ currentRecord.ipAddress || '未知' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="屏幕分辨率">{{ currentRecord.screenResolution || '未知' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="时间戳">{{ currentRecord.timestamp }}</a-descriptions-item>
|
||||
<a-descriptions-item label="当前URL" :span="2">
|
||||
<a :href="currentRecord.currentUrl" target="_blank" v-if="currentRecord.currentUrl">
|
||||
{{ currentRecord.currentUrl }}
|
||||
</a>
|
||||
<span v-else>未知</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="用户代理" :span="2">
|
||||
<div style="word-break: break-all; max-height: 100px; overflow-y: auto;">
|
||||
{{ currentRecord.userAgent || '未知' }}
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="错误信息" :span="2" v-if="currentRecord.errorMessage">
|
||||
<div style="color: #ff4d4f;">
|
||||
{{ currentRecord.errorMessage }}
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-modal>
|
||||
|
||||
<!-- 表单数据弹窗 -->
|
||||
<a-modal
|
||||
:open="formDataModalVisible"
|
||||
@update:open="formDataModalVisible = $event"
|
||||
title="表单数据"
|
||||
width="600px"
|
||||
:footer="null"
|
||||
>
|
||||
<pre style="background: #f5f5f5; padding: 16px; border-radius: 4px; max-height: 400px; overflow-y: auto;">{{ JSON.stringify(currentRecord.formData, null, 2) }}</pre>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
ExportOutlined,
|
||||
SearchOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const statsLoading = ref(false)
|
||||
const detailModalVisible = ref(false)
|
||||
const formDataModalVisible = ref(false)
|
||||
const selectedRowKeys = ref([])
|
||||
const currentRecord = ref({})
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
module: null,
|
||||
action: null,
|
||||
status: null,
|
||||
dateRange: null,
|
||||
keyword: ''
|
||||
})
|
||||
|
||||
// 统计数据
|
||||
const stats = reactive({
|
||||
totalLogs: 0,
|
||||
successCount: 0,
|
||||
errorCount: 0,
|
||||
activeUsers: 0
|
||||
})
|
||||
|
||||
// 表格数据
|
||||
const tableData = ref([])
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '模块',
|
||||
dataIndex: 'module',
|
||||
key: 'module',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '表单数据',
|
||||
dataIndex: 'formData',
|
||||
key: 'formData',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条/共 ${total} 条`
|
||||
})
|
||||
|
||||
// 行选择配置
|
||||
const rowSelection = {
|
||||
selectedRowKeys: selectedRowKeys,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys
|
||||
}
|
||||
}
|
||||
|
||||
// 方法
|
||||
const getStatusColor = (status) => {
|
||||
const colorMap = {
|
||||
'success': 'green',
|
||||
'error': 'red',
|
||||
'warning': 'orange'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const textMap = {
|
||||
'success': '成功',
|
||||
'error': '错误',
|
||||
'warning': '警告'
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
// 数据加载方法
|
||||
const loadData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const params = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
...filters
|
||||
}
|
||||
|
||||
// 处理日期范围
|
||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||
params.startDate = filters.dateRange[0].format('YYYY-MM-DD')
|
||||
params.endDate = filters.dateRange[1].format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
console.group('📋 [日志管理] 加载数据')
|
||||
console.log('🕒 时间:', new Date().toLocaleString())
|
||||
console.log('📊 查询参数:', params)
|
||||
console.groupEnd()
|
||||
|
||||
const response = await api.formLogs.getList(params)
|
||||
|
||||
if (response.success) {
|
||||
tableData.value = response.data.list.map(item => ({
|
||||
...item,
|
||||
key: item.id
|
||||
}))
|
||||
pagination.total = response.data.total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [日志管理] 加载数据失败:', error)
|
||||
message.error('加载数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载统计数据
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
statsLoading.value = true
|
||||
const params = {}
|
||||
|
||||
// 处理日期范围
|
||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||
params.startDate = filters.dateRange[0].format('YYYY-MM-DD')
|
||||
params.endDate = filters.dateRange[1].format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
const response = await api.formLogs.getStats(params)
|
||||
|
||||
if (response.success) {
|
||||
const data = response.data
|
||||
stats.totalLogs = data.totalLogs || 0
|
||||
stats.successCount = data.statusStats?.find(s => s.status === 'success')?.count || 0
|
||||
stats.errorCount = data.statusStats?.find(s => s.status === 'error')?.count || 0
|
||||
stats.activeUsers = data.userStats?.length || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [日志管理] 加载统计失败:', error)
|
||||
} finally {
|
||||
statsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadData()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
const handleResetFilters = () => {
|
||||
Object.assign(filters, {
|
||||
module: null,
|
||||
action: null,
|
||||
status: null,
|
||||
dateRange: null,
|
||||
keyword: ''
|
||||
})
|
||||
handleFilterChange()
|
||||
}
|
||||
|
||||
const handleViewDetail = (record) => {
|
||||
currentRecord.value = record
|
||||
detailModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleViewFormData = (record) => {
|
||||
currentRecord.value = record
|
||||
formDataModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除日志"${record.id}"吗?`,
|
||||
async onOk() {
|
||||
try {
|
||||
await api.formLogs.delete(record.id)
|
||||
message.success('删除成功')
|
||||
loadData()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
message.success('导出功能开发中')
|
||||
// 实现导出逻辑
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-log-management {
|
||||
padding: 24px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.log-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.log-table :deep(.ant-table-thead > tr > th) {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.log-table :deep(.ant-table-tbody > tr > td) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.log-table :deep(.ant-table-tbody > tr:hover > td) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filter-bar .ant-row .ant-col {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-log-management {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
24
admin-system/src/views/Home.vue
Normal file
24
admin-system/src/views/Home.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-card title="用户总数" style="text-align: center">
|
||||
<h2>1,234</h2>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-card title="产品总数" style="text-align: center">
|
||||
<h2>567</h2>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-card title="订单总数" style="text-align: center">
|
||||
<h2>890</h2>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
518
admin-system/src/views/Login.vue
Normal file
518
admin-system/src/views/Login.vue
Normal file
@@ -0,0 +1,518 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<!-- 左侧标题 -->
|
||||
<div class="left-title">
|
||||
<h1 class="main-title">智慧宁夏牧场</h1>
|
||||
</div>
|
||||
|
||||
<!-- 玻璃态登录卡片 -->
|
||||
<div class="glass-card">
|
||||
<div class="card-header">
|
||||
<h1 class="login-title">用户登录</h1>
|
||||
</div>
|
||||
|
||||
<!-- 信息提示框 -->
|
||||
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="error" class="error-alert">
|
||||
<span class="error-icon">⚠️</span>
|
||||
<span class="error-message">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<!-- 用户名输入框 -->
|
||||
<div class="input-group">
|
||||
<label class="input-label">
|
||||
<span class="required">*</span> 用户名:
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="input-icon">👨💼</span>
|
||||
<input
|
||||
v-model="formState.username"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 密码输入框 -->
|
||||
<div class="input-group">
|
||||
<label class="input-label">
|
||||
<span class="required">*</span> 密码:
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="input-icon">🔒</span>
|
||||
<input
|
||||
v-model="formState.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="password-toggle"
|
||||
@click="togglePassword"
|
||||
>
|
||||
{{ showPassword ? '👁️' : '🚫' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<button
|
||||
type="submit"
|
||||
class="login-button"
|
||||
:disabled="loading"
|
||||
>
|
||||
<span v-if="loading" class="loading-spinner"></span>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useUserStore } from '../stores';
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const showPassword = ref(false);
|
||||
|
||||
const formState = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
// 切换密码显示/隐藏
|
||||
const togglePassword = () => {
|
||||
showPassword.value = !showPassword.value;
|
||||
};
|
||||
|
||||
// 页面加载时检查token状态
|
||||
onMounted(async () => {
|
||||
// 如果已有有效token,直接跳转到仪表盘
|
||||
if (userStore.token) {
|
||||
try {
|
||||
const isValid = await userStore.validateToken();
|
||||
if (isValid) {
|
||||
const redirectPath = router.currentRoute.value.query.redirect || '/dashboard';
|
||||
router.push(redirectPath);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Token验证失败,需要重新登录');
|
||||
userStore.logout(); // 清除无效token
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleLogin = async (values) => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
// 使用表单数据进行登录
|
||||
const username = values?.username || formState.username;
|
||||
const password = values?.password || formState.password;
|
||||
|
||||
// 验证表单数据
|
||||
if (!username || !password) {
|
||||
error.value = '请输入用户名和密码';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('开始登录,用户名:', username);
|
||||
|
||||
// 使用Pinia用户存储进行登录
|
||||
const result = await userStore.login(username, password);
|
||||
|
||||
console.log('登录结果:', result);
|
||||
|
||||
if (result.success) {
|
||||
// 登录成功提示
|
||||
message.success(`登录成功,欢迎 ${userStore.userData.username}`);
|
||||
|
||||
// 获取重定向路径(如果有)
|
||||
const redirectPath = router.currentRoute.value.query.redirect || '/dashboard';
|
||||
|
||||
// 跳转到重定向路径或仪表盘页面
|
||||
router.push(redirectPath);
|
||||
} else {
|
||||
error.value = result.message || '登录失败';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('登录错误:', e);
|
||||
error.value = e.message || '登录失败,请检查网络连接和后端服务状态';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 主容器 - 图片背景 */
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
background: url('/cows.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: fixed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 20px 160px 20px 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 左侧标题 */
|
||||
.left-title {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
color: #ffffff;
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 16px 0;
|
||||
text-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
|
||||
letter-spacing: 2px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
|
||||
letter-spacing: 1px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 玻璃态卡片 */
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(25px);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
box-shadow:
|
||||
0 8px 32px 0 rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
min-width: 350px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
transform: translateY(-3px);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
box-shadow:
|
||||
0 12px 40px 0 rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* 信息提示框 */
|
||||
.info-box {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-text:first-child {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 错误提示 */
|
||||
.error-alert {
|
||||
background: rgba(255, 77, 79, 0.2);
|
||||
border: 1px solid rgba(255, 77, 79, 0.4);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 登录表单 */
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 输入组 */
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #ff4d4f;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 输入框包装器 */
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
font-size: 16px;
|
||||
z-index: 1;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 8px;
|
||||
padding: 0 16px 0 40px;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
backdrop-filter: blur(20px);
|
||||
transition: all 0.3s ease;
|
||||
outline: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(255, 255, 255, 0.4),
|
||||
0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 密码切换按钮 */
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
transition: color 0.3s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
/* 登录按钮 */
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: linear-gradient(90deg, #4CAF50 0%, #45a049 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.login-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.login-button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(76, 175, 80, 0.6);
|
||||
}
|
||||
|
||||
.login-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.login-container {
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.left-title {
|
||||
position: relative;
|
||||
top: auto;
|
||||
left: auto;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
max-width: 100%;
|
||||
min-width: auto;
|
||||
padding: 30px 20px;
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.glass-card {
|
||||
padding: 30px 20px;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色模式支持 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.glass-card {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
165
admin-system/src/views/MapView.vue
Normal file
165
admin-system/src/views/MapView.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="map-view">
|
||||
<div class="map-header">
|
||||
<h1>养殖场分布地图</h1>
|
||||
<div class="map-controls">
|
||||
<a-select
|
||||
v-model="selectedFarmType"
|
||||
style="width: 200px"
|
||||
placeholder="选择养殖场类型"
|
||||
@change="handleFarmTypeChange"
|
||||
>
|
||||
<a-select-option value="all">全部养殖场</a-select-option>
|
||||
<a-select-option value="cattle">牛养殖场</a-select-option>
|
||||
<a-select-option value="sheep">羊养殖场</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<a-button type="primary" @click="refreshMap">
|
||||
<template #icon><reload-outlined /></template>
|
||||
刷新数据
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-container">
|
||||
<baidu-map
|
||||
:markers="filteredMarkers"
|
||||
height="calc(100vh - 180px)"
|
||||
@marker-click="handleFarmClick"
|
||||
@map-ready="handleMapReady"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 养殖场详情抽屉 -->
|
||||
<a-drawer
|
||||
:title="`养殖场详情 - ${selectedFarmId ? dataStore.farms.find(f => f.id == selectedFarmId)?.name : ''}`"
|
||||
:width="600"
|
||||
:visible="drawerVisible"
|
||||
@close="closeDrawer"
|
||||
:bodyStyle="{ paddingBottom: '80px' }"
|
||||
>
|
||||
<farm-detail v-if="selectedFarmId" :farm-id="selectedFarmId" />
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useDataStore } from '../stores'
|
||||
import { convertFarmsToMarkers } from '../utils/mapService'
|
||||
import BaiduMap from '../components/BaiduMap.vue'
|
||||
import FarmDetail from '../components/FarmDetail.vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
// 数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 地图实例
|
||||
let mapInstance = null
|
||||
|
||||
// 养殖场类型筛选
|
||||
const selectedFarmType = ref('all')
|
||||
|
||||
// 抽屉控制
|
||||
const drawerVisible = ref(false)
|
||||
const selectedFarmId = ref(null)
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 养殖场标记
|
||||
const farmMarkers = computed(() => {
|
||||
return convertFarmsToMarkers(dataStore.farms)
|
||||
})
|
||||
|
||||
// 根据类型筛选标记
|
||||
const filteredMarkers = computed(() => {
|
||||
if (selectedFarmType.value === 'all') {
|
||||
return farmMarkers.value
|
||||
}
|
||||
|
||||
// 根据养殖场类型筛选
|
||||
// 这里假设养殖场数据中有type字段,实际项目中需要根据真实数据结构调整
|
||||
return farmMarkers.value.filter(marker => {
|
||||
const farm = marker.originalData
|
||||
if (selectedFarmType.value === 'cattle') {
|
||||
// 筛选牛养殖场
|
||||
return farm.type === 'cattle' || farm.name.includes('牛')
|
||||
} else if (selectedFarmType.value === 'sheep') {
|
||||
// 筛选羊养殖场
|
||||
return farm.type === 'sheep' || farm.name.includes('羊')
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// 处理地图就绪事件
|
||||
function handleMapReady(map) {
|
||||
mapInstance = map
|
||||
message.success('地图加载完成')
|
||||
}
|
||||
|
||||
// 处理养殖场类型变化
|
||||
function handleFarmTypeChange(value) {
|
||||
message.info(`已筛选${value === 'all' ? '全部' : value === 'cattle' ? '牛' : '羊'}养殖场`)
|
||||
}
|
||||
|
||||
// 处理养殖场标记点击事件
|
||||
function handleFarmClick(markerData) {
|
||||
// 设置选中的养殖场ID
|
||||
selectedFarmId.value = markerData.originalData.id
|
||||
// 打开抽屉
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
// 关闭抽屉
|
||||
function closeDrawer() {
|
||||
drawerVisible.value = false
|
||||
}
|
||||
|
||||
// 刷新地图数据
|
||||
async function refreshMap() {
|
||||
loading.value = true
|
||||
try {
|
||||
await dataStore.fetchFarms()
|
||||
message.success('地图数据已更新')
|
||||
} catch (error) {
|
||||
message.error('更新地图数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载后初始化数据
|
||||
onMounted(async () => {
|
||||
if (dataStore.farms.length === 0) {
|
||||
await dataStore.fetchAllData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-view {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
173
admin-system/src/views/MapZoomDemo.vue
Normal file
173
admin-system/src/views/MapZoomDemo.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="map-zoom-demo">
|
||||
<div class="demo-header">
|
||||
<h2>地图缩放功能演示</h2>
|
||||
<p>这个页面展示了地图组件的各种缩放功能</p>
|
||||
</div>
|
||||
|
||||
<div class="demo-controls">
|
||||
<div class="control-group">
|
||||
<label>显示缩放级别:</label>
|
||||
<input type="checkbox" v-model="showZoomLevel" />
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>当前缩放级别:</label>
|
||||
<span class="zoom-info">{{ currentZoom }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-container">
|
||||
<BaiduMap
|
||||
:markers="demoMarkers"
|
||||
:show-zoom-level="showZoomLevel"
|
||||
:height="'500px'"
|
||||
@map-ready="onMapReady"
|
||||
@marker-click="onMarkerClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="demo-instructions">
|
||||
<h3>缩放功能说明:</h3>
|
||||
<ul>
|
||||
<li><strong>右上角缩放按钮:</strong>
|
||||
<ul>
|
||||
<li>「+」按钮:放大地图</li>
|
||||
<li>「−」按钮:缩小地图</li>
|
||||
<li>「⌂」按钮:重置到默认缩放级别和中心点</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>鼠标滚轮:</strong>向上滚动放大,向下滚动缩小</li>
|
||||
<li><strong>双击地图:</strong>放大一个级别</li>
|
||||
<li><strong>键盘控制:</strong>使用方向键移动地图,+/- 键缩放</li>
|
||||
<li><strong>缩放级别显示:</strong>右下角显示当前缩放级别(可通过复选框控制显示/隐藏)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import BaiduMap from '../components/BaiduMap.vue'
|
||||
|
||||
// 响应式数据
|
||||
const showZoomLevel = ref(true)
|
||||
const mapInstance = ref(null)
|
||||
|
||||
// 演示标记数据
|
||||
const demoMarkers = ref([
|
||||
{
|
||||
location: { lng: 106.27, lat: 38.47 },
|
||||
title: '银川市',
|
||||
content: '宁夏回族自治区首府'
|
||||
},
|
||||
{
|
||||
location: { lng: 106.15, lat: 38.35 },
|
||||
title: '永宁县',
|
||||
content: '银川市下辖县'
|
||||
},
|
||||
{
|
||||
location: { lng: 106.45, lat: 38.55 },
|
||||
title: '贺兰县',
|
||||
content: '银川市下辖县'
|
||||
}
|
||||
])
|
||||
|
||||
// 计算当前缩放级别
|
||||
const currentZoom = computed(() => {
|
||||
return mapInstance.value ? mapInstance.value.currentZoom : '未知'
|
||||
})
|
||||
|
||||
// 地图就绪事件
|
||||
const onMapReady = (map) => {
|
||||
console.log('地图就绪:', map)
|
||||
mapInstance.value = map
|
||||
}
|
||||
|
||||
// 标记点击事件
|
||||
const onMarkerClick = (markerData, marker) => {
|
||||
console.log('标记被点击:', markerData)
|
||||
alert(`点击了标记: ${markerData.title}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-zoom-demo {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.demo-header h2 {
|
||||
color: #1890ff;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.zoom-info {
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
border: 2px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.demo-instructions {
|
||||
background: #fafafa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #1890ff;
|
||||
}
|
||||
|
||||
.demo-instructions h3 {
|
||||
color: #1890ff;
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.demo-instructions ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.demo-instructions li {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.demo-instructions ul ul {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
</style>
|
||||
534
admin-system/src/views/Monitor.vue
Normal file
534
admin-system/src/views/Monitor.vue
Normal file
@@ -0,0 +1,534 @@
|
||||
<template>
|
||||
<div class="monitor-page">
|
||||
<a-page-header
|
||||
title="实时监控"
|
||||
sub-title="宁夏智慧养殖监管平台实时监控"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-select
|
||||
v-model="selectedFarm"
|
||||
style="width: 200px"
|
||||
placeholder="选择养殖场"
|
||||
@change="handleFarmChange"
|
||||
>
|
||||
<a-select-option value="all">全部养殖场</a-select-option>
|
||||
<a-select-option v-for="farm in dataStore.farms" :key="farm.id" :value="farm.id">
|
||||
{{ farm.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" @click="refreshAllData">
|
||||
<template #icon><reload-outlined /></template>
|
||||
刷新数据
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<!-- 性能监控组件 -->
|
||||
<chart-performance-monitor style="display: none;" />
|
||||
|
||||
<div class="monitor-content">
|
||||
<!-- 实时数据概览 -->
|
||||
<div class="monitor-stats">
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<span>温度监控</span>
|
||||
<a-tag color="orange" style="margin-left: 8px">{{ getLatestValue(temperatureData) }}°C</a-tag>
|
||||
</template>
|
||||
<monitor-chart
|
||||
title="温度变化趋势"
|
||||
type="line"
|
||||
:data="temperatureData"
|
||||
height="250px"
|
||||
:refresh-interval="30000"
|
||||
cache-key="temperature-chart"
|
||||
:enable-cache="true"
|
||||
:cache-ttl="60000"
|
||||
@refresh="refreshTemperatureData"
|
||||
/>
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<span>湿度监控</span>
|
||||
<a-tag color="blue" style="margin-left: 8px">{{ getLatestValue(humidityData) }}%</a-tag>
|
||||
</template>
|
||||
<monitor-chart
|
||||
title="湿度变化趋势"
|
||||
type="line"
|
||||
:data="humidityData"
|
||||
height="250px"
|
||||
:refresh-interval="30000"
|
||||
cache-key="humidity-chart"
|
||||
:enable-cache="true"
|
||||
:cache-ttl="60000"
|
||||
@refresh="refreshHumidityData"
|
||||
/>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<div class="monitor-stats">
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<span>设备状态</span>
|
||||
<a-tag color="green" style="margin-left: 8px">在线率: {{ deviceOnlineRate }}%</a-tag>
|
||||
</template>
|
||||
<monitor-chart
|
||||
title="设备状态分布"
|
||||
type="pie"
|
||||
:data="deviceStatusData"
|
||||
height="250px"
|
||||
:refresh-interval="60000"
|
||||
cache-key="device-status-chart"
|
||||
:enable-cache="true"
|
||||
:cache-ttl="120000"
|
||||
@refresh="refreshDeviceData"
|
||||
/>
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<span>预警监控</span>
|
||||
<a-tag color="red" style="margin-left: 8px">{{ alertCount }}个预警</a-tag>
|
||||
</template>
|
||||
<monitor-chart
|
||||
title="预警类型分布"
|
||||
type="pie"
|
||||
:data="alertTypeData"
|
||||
height="250px"
|
||||
:refresh-interval="60000"
|
||||
cache-key="alert-type-chart"
|
||||
:enable-cache="true"
|
||||
:cache-ttl="120000"
|
||||
@refresh="refreshAlertData"
|
||||
/>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 实时预警列表 -->
|
||||
<a-card title="实时预警信息" :bordered="false" class="alert-card">
|
||||
<a-table
|
||||
:dataSource="alertTableData"
|
||||
:columns="alertColumns"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
:loading="dataStore.loading.alerts"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'type'">
|
||||
{{ getAlertTypeText(record.type) }}
|
||||
</template>
|
||||
<template v-if="column.key === 'level'">
|
||||
<a-tag :color="getAlertLevelColor(record.level)">
|
||||
{{ getAlertLevelText(record.level) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'created_at'">
|
||||
{{ formatTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="handleAlert(record.id)">
|
||||
处理
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { useDataStore } from '../stores'
|
||||
import MonitorChart from '../components/MonitorChart.vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { api } from '../utils/api'
|
||||
import ChartPerformanceMonitor from '../components/ChartPerformanceMonitor.vue'
|
||||
import { DataCache } from '../utils/chartService'
|
||||
|
||||
// 使用数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 选中的养殖场
|
||||
const selectedFarm = ref('all')
|
||||
|
||||
// 处理养殖场变化
|
||||
function handleFarmChange(farmId) {
|
||||
refreshAllData()
|
||||
}
|
||||
|
||||
// 刷新所有数据(优化版本)
|
||||
async function refreshAllData() {
|
||||
// 清理相关缓存以强制刷新
|
||||
DataCache.delete(`temperature_data_${selectedFarm.value}`)
|
||||
DataCache.delete(`humidity_data_${selectedFarm.value}`)
|
||||
|
||||
await dataStore.fetchAllData()
|
||||
refreshTemperatureData()
|
||||
refreshHumidityData()
|
||||
refreshDeviceData()
|
||||
refreshAlertData()
|
||||
|
||||
message.success('数据刷新完成')
|
||||
}
|
||||
|
||||
// 温度数据
|
||||
const temperatureData = reactive({
|
||||
xAxis: [],
|
||||
series: [{
|
||||
name: '温度',
|
||||
data: [],
|
||||
itemStyle: { color: '#ff7a45' },
|
||||
areaStyle: { opacity: 0.2 }
|
||||
}]
|
||||
})
|
||||
|
||||
// 湿度数据
|
||||
const humidityData = reactive({
|
||||
xAxis: [],
|
||||
series: [{
|
||||
name: '湿度',
|
||||
data: [],
|
||||
itemStyle: { color: '#1890ff' },
|
||||
areaStyle: { opacity: 0.2 }
|
||||
}]
|
||||
})
|
||||
|
||||
// 设备状态数据
|
||||
const deviceStatusData = computed(() => {
|
||||
const devices = selectedFarm.value === 'all'
|
||||
? dataStore.devices
|
||||
: dataStore.devices.filter(d => (d.farm_id || d.farmId) == selectedFarm.value)
|
||||
|
||||
const online = devices.filter(d => d.status === 'online').length
|
||||
const offline = devices.length - online
|
||||
|
||||
return [
|
||||
{ value: online, name: '在线设备', itemStyle: { color: '#52c41a' } },
|
||||
{ value: offline, name: '离线设备', itemStyle: { color: '#d9d9d9' } }
|
||||
]
|
||||
})
|
||||
|
||||
// 设备在线率
|
||||
const deviceOnlineRate = computed(() => {
|
||||
const devices = selectedFarm.value === 'all'
|
||||
? dataStore.devices
|
||||
: dataStore.devices.filter(d => (d.farm_id || d.farmId) == selectedFarm.value)
|
||||
|
||||
if (devices.length === 0) return 0
|
||||
|
||||
const online = devices.filter(d => d.status === 'online').length
|
||||
return ((online / devices.length) * 100).toFixed(1)
|
||||
})
|
||||
|
||||
// 预警类型数据
|
||||
const alertTypeData = computed(() => {
|
||||
const alerts = selectedFarm.value === 'all'
|
||||
? dataStore.alerts
|
||||
: dataStore.alerts.filter(a => a.farm_id == selectedFarm.value)
|
||||
|
||||
// 按类型分组统计(使用中文类型名)
|
||||
const typeCount = {}
|
||||
alerts.forEach(alert => {
|
||||
const chineseType = getAlertTypeText(alert.type)
|
||||
typeCount[chineseType] = (typeCount[chineseType] || 0) + 1
|
||||
})
|
||||
|
||||
// 转换为图表数据格式
|
||||
return Object.keys(typeCount).map(type => ({
|
||||
value: typeCount[type],
|
||||
name: type,
|
||||
itemStyle: {
|
||||
color: type.includes('温度') ? '#ff4d4f' :
|
||||
type.includes('湿度') ? '#1890ff' :
|
||||
type.includes('设备') ? '#faad14' : '#52c41a'
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
// 预警数量
|
||||
const alertCount = computed(() => {
|
||||
return selectedFarm.value === 'all'
|
||||
? dataStore.alertCount
|
||||
: dataStore.alerts.filter(a => a.farm_id == selectedFarm.value).length
|
||||
})
|
||||
|
||||
// 预警表格数据
|
||||
const alertTableData = computed(() => {
|
||||
const alerts = selectedFarm.value === 'all'
|
||||
? dataStore.alerts
|
||||
: dataStore.alerts.filter(a => a.farm_id == selectedFarm.value)
|
||||
|
||||
return alerts.map(alert => ({
|
||||
key: alert.id,
|
||||
id: alert.id,
|
||||
type: getAlertTypeText(alert.type),
|
||||
level: alert.level,
|
||||
farmId: alert.farm_id,
|
||||
farmName: dataStore.farms.find(f => f.id == alert.farm_id)?.name || '',
|
||||
created_at: alert.created_at
|
||||
}))
|
||||
})
|
||||
|
||||
// 预警表格列定义
|
||||
const alertColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '预警类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type'
|
||||
},
|
||||
{
|
||||
title: '预警级别',
|
||||
dataIndex: 'level',
|
||||
key: 'level'
|
||||
},
|
||||
{
|
||||
title: '养殖场',
|
||||
dataIndex: 'farmName',
|
||||
key: 'farmName'
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
sorter: (a, b) => new Date(a.created_at) - new Date(b.created_at),
|
||||
defaultSortOrder: 'descend'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取预警级别颜色
|
||||
function getAlertLevelColor(level) {
|
||||
switch (level) {
|
||||
case 'high': return 'red'
|
||||
case 'medium': return 'orange'
|
||||
case 'low': return 'blue'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取预警级别文本
|
||||
function getAlertLevelText(level) {
|
||||
switch (level) {
|
||||
case 'high': return '高'
|
||||
case 'medium': return '中'
|
||||
case 'low': return '低'
|
||||
default: return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取预警类型文本(英文转中文)
|
||||
function getAlertTypeText(type) {
|
||||
const texts = {
|
||||
temperature_alert: '温度异常',
|
||||
humidity_alert: '湿度异常',
|
||||
feed_alert: '饲料异常',
|
||||
health_alert: '健康异常',
|
||||
device_alert: '设备异常',
|
||||
temperature: '温度异常',
|
||||
humidity: '湿度异常',
|
||||
device_failure: '设备故障',
|
||||
animal_health: '动物健康',
|
||||
security: '安全警报',
|
||||
maintenance: '维护提醒',
|
||||
other: '其他'
|
||||
}
|
||||
return texts[type] || type
|
||||
}
|
||||
|
||||
// 格式化时间显示
|
||||
function formatTime(timestamp) {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
// 如果是今天
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 如果是昨天
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return '昨天 ' + date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 如果是一周内
|
||||
if (diff < 7 * 24 * 60 * 60 * 1000) {
|
||||
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
return weekdays[date.getDay()] + ' ' + date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 其他情况显示完整日期
|
||||
return date.toLocaleDateString('zh-CN') + ' ' + date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 处理预警
|
||||
function handleAlert(alertId) {
|
||||
message.success(`已处理预警 #${alertId}`)
|
||||
}
|
||||
|
||||
// 刷新温度数据(优化版本)
|
||||
async function refreshTemperatureData() {
|
||||
const cacheKey = `temperature_data_${selectedFarm.value}`
|
||||
|
||||
try {
|
||||
// 检查缓存
|
||||
let data = DataCache.get(cacheKey)
|
||||
|
||||
if (!data) {
|
||||
// 调用后端监控数据API
|
||||
data = await api.get('/stats/public/monitoring')
|
||||
|
||||
// 缓存数据(2分钟)
|
||||
if (data) {
|
||||
DataCache.set(cacheKey, data, 2 * 60 * 1000)
|
||||
}
|
||||
} else {
|
||||
console.log('使用缓存的温度数据')
|
||||
}
|
||||
|
||||
// api.get返回的是result.data,直接检查数据结构
|
||||
if (data && data.environmentData && data.environmentData.temperature && data.environmentData.temperature.history) {
|
||||
const temperatureInfo = data.environmentData.temperature
|
||||
|
||||
// 使用后端返回的温度历史数据,格式化时间标签
|
||||
const timeLabels = temperatureInfo.history.map(item => formatTime(item.time))
|
||||
const tempValues = temperatureInfo.history.map(item => item.value)
|
||||
|
||||
console.log('温度数据处理成功:', { timeLabels, tempValues })
|
||||
|
||||
temperatureData.xAxis = timeLabels
|
||||
temperatureData.series[0].data = tempValues
|
||||
return
|
||||
}
|
||||
|
||||
// 如果API调用失败,显示错误信息
|
||||
console.error('无法获取温度数据,请检查后端服务')
|
||||
} catch (error) {
|
||||
console.error('获取温度数据出错:', error)
|
||||
// 显示错误状态
|
||||
temperatureData.xAxis = ['无数据']
|
||||
temperatureData.series[0].data = [0]
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新湿度数据(优化版本)
|
||||
async function refreshHumidityData() {
|
||||
const cacheKey = `humidity_data_${selectedFarm.value}`
|
||||
|
||||
try {
|
||||
// 检查缓存
|
||||
let data = DataCache.get(cacheKey)
|
||||
|
||||
if (!data) {
|
||||
// 调用后端监控数据API
|
||||
data = await api.get('/stats/public/monitoring')
|
||||
|
||||
// 缓存数据(2分钟)
|
||||
if (data) {
|
||||
DataCache.set(cacheKey, data, 2 * 60 * 1000)
|
||||
}
|
||||
} else {
|
||||
console.log('使用缓存的湿度数据')
|
||||
}
|
||||
|
||||
if (data && data.environmentData && data.environmentData.humidity && data.environmentData.humidity.history) {
|
||||
const timeLabels = data.environmentData.humidity.history.map(item => {
|
||||
const date = new Date(item.time);
|
||||
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
||||
});
|
||||
const humidityValues = data.environmentData.humidity.history.map(item => item.value);
|
||||
|
||||
humidityData.xAxis = timeLabels;
|
||||
humidityData.series[0].data = humidityValues;
|
||||
} else {
|
||||
console.error('无法获取湿度数据,请检查后端服务');
|
||||
humidityData.series[0].data = [];
|
||||
humidityData.xAxis = ['无数据'];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取湿度数据出错:', error)
|
||||
// 显示错误状态
|
||||
humidityData.xAxis = ['无数据']
|
||||
humidityData.series[0].data = [0]
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新设备数据
|
||||
function refreshDeviceData() {
|
||||
// 设备状态数据是计算属性,会自动更新
|
||||
}
|
||||
|
||||
// 刷新预警数据
|
||||
function refreshAlertData() {
|
||||
// 预警数据是计算属性,会自动更新
|
||||
}
|
||||
|
||||
// 获取最新值
|
||||
function getLatestValue(data) {
|
||||
const seriesData = data.series?.[0]?.data
|
||||
return seriesData?.length > 0 ? seriesData[seriesData.length - 1] : '0'
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(async () => {
|
||||
// 加载数据
|
||||
await dataStore.fetchAllData()
|
||||
|
||||
// 初始化图表数据
|
||||
refreshTemperatureData()
|
||||
refreshHumidityData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monitor-page {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.monitor-content {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.monitor-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.monitor-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
31
admin-system/src/views/NotFound.vue
Normal file
31
admin-system/src/views/NotFound.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<a-result
|
||||
status="404"
|
||||
title="404"
|
||||
sub-title="抱歉,您访问的页面不存在。"
|
||||
>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="$router.push('/')">
|
||||
返回首页
|
||||
</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
}
|
||||
</style>
|
||||
516
admin-system/src/views/OperationLogs.vue
Normal file
516
admin-system/src/views/OperationLogs.vue
Normal file
@@ -0,0 +1,516 @@
|
||||
<template>
|
||||
<div class="operation-logs-container">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h1>操作日志管理</h1>
|
||||
<p>查看和管理系统用户的操作记录</p>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选区域 -->
|
||||
<div class="search-section">
|
||||
<a-card title="搜索条件" class="search-card">
|
||||
<a-form
|
||||
:model="searchForm"
|
||||
layout="inline"
|
||||
@finish="handleSearch"
|
||||
>
|
||||
<a-form-item label="用户名">
|
||||
<a-input
|
||||
v-model:value="searchForm.username"
|
||||
placeholder="请输入用户名"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit" :loading="loading">
|
||||
<template #icon>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片区域 -->
|
||||
<div class="stats-section">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-card class="stats-card">
|
||||
<a-statistic
|
||||
title="总操作数"
|
||||
:value="stats.total"
|
||||
:value-style="{ color: '#1890ff' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card class="stats-card">
|
||||
<a-statistic
|
||||
title="新增操作"
|
||||
:value="stats.CREATE || 0"
|
||||
:value-style="{ color: '#52c41a' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card class="stats-card">
|
||||
<a-statistic
|
||||
title="编辑操作"
|
||||
:value="stats.UPDATE || 0"
|
||||
:value-style="{ color: '#faad14' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card class="stats-card">
|
||||
<a-statistic
|
||||
title="删除操作"
|
||||
:value="stats.DELETE || 0"
|
||||
:value-style="{ color: '#f5222d' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 操作日志表格 -->
|
||||
<div class="table-section">
|
||||
<a-card title="操作日志列表" class="table-card">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button @click="handleExport" :loading="exportLoading">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
导出
|
||||
</a-button>
|
||||
<a-button @click="handleRefresh" :loading="loading">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="operationLogList"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<!-- 操作描述列 -->
|
||||
<template #operationDesc="{ record }">
|
||||
<a-tooltip :title="record.operation_desc" placement="topLeft">
|
||||
<span class="operation-desc">{{ record.operation_desc }}</span>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
DownloadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { api } from '@/utils/api'
|
||||
|
||||
export default {
|
||||
name: 'OperationLogs',
|
||||
components: {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
DownloadOutlined
|
||||
},
|
||||
setup() {
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
const operationLogList = ref([])
|
||||
const stats = ref({})
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
username: ''
|
||||
})
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`
|
||||
})
|
||||
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 60,
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '操作用户',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
width: 120,
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '操作类型',
|
||||
dataIndex: 'operation_type',
|
||||
key: 'operation_type',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
customCell: (record) => {
|
||||
const typeMap = {
|
||||
'CREATE': '新增',
|
||||
'UPDATE': '编辑',
|
||||
'DELETE': '删除',
|
||||
'READ': '查看',
|
||||
'LOGIN': '登录',
|
||||
'LOGOUT': '登出'
|
||||
}
|
||||
const colorMap = {
|
||||
'CREATE': 'green',
|
||||
'UPDATE': 'blue',
|
||||
'DELETE': 'red',
|
||||
'READ': 'default',
|
||||
'LOGIN': 'cyan',
|
||||
'LOGOUT': 'orange'
|
||||
}
|
||||
return {
|
||||
style: { color: colorMap[record.operation_type] || 'default' }
|
||||
}
|
||||
},
|
||||
customRender: ({ record }) => {
|
||||
const typeMap = {
|
||||
'CREATE': '新增',
|
||||
'UPDATE': '编辑',
|
||||
'DELETE': '删除',
|
||||
'READ': '查看',
|
||||
'LOGIN': '登录',
|
||||
'LOGOUT': '登出'
|
||||
}
|
||||
return typeMap[record.operation_type] || record.operation_type
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '模块名称',
|
||||
dataIndex: 'module_name',
|
||||
key: 'module_name',
|
||||
width: 140,
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '操作描述',
|
||||
dataIndex: 'operation_desc',
|
||||
key: 'operation_desc',
|
||||
width: 200,
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
align: 'center'
|
||||
}
|
||||
]
|
||||
|
||||
// 计算属性
|
||||
const searchParams = computed(() => {
|
||||
return { ...searchForm }
|
||||
})
|
||||
|
||||
// 方法
|
||||
|
||||
|
||||
const loadOperationLogs = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const params = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
...searchParams.value
|
||||
}
|
||||
|
||||
const response = await api.operationLogs.getOperationLogs(params)
|
||||
if (response.success) {
|
||||
operationLogList.value = response.data
|
||||
pagination.total = response.pagination.total
|
||||
} else {
|
||||
message.error(response.message || '获取操作日志失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取操作日志失败:', error)
|
||||
message.error('获取操作日志失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const params = {
|
||||
type: 'overall',
|
||||
...searchParams.value
|
||||
}
|
||||
|
||||
const response = await api.operationLogs.getOperationStats(params)
|
||||
if (response.success) {
|
||||
stats.value = response.data
|
||||
stats.value.total = (stats.value.CREATE || 0) +
|
||||
(stats.value.UPDATE || 0) +
|
||||
(stats.value.DELETE || 0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
loadOperationLogs()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
searchForm.username = ''
|
||||
pagination.current = 1
|
||||
loadOperationLogs()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadOperationLogs()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadOperationLogs()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
exportLoading.value = true
|
||||
const params = { ...searchParams.value }
|
||||
|
||||
const response = await api.operationLogs.exportOperationLogs(params)
|
||||
|
||||
// 创建下载链接
|
||||
const blob = new Blob([response], { type: 'text/csv;charset=utf-8' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `operation_logs_${new Date().getTime()}.csv`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
message.success('导出成功')
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadOperationLogs()
|
||||
loadStats()
|
||||
})
|
||||
|
||||
return {
|
||||
loading,
|
||||
exportLoading,
|
||||
operationLogList,
|
||||
stats,
|
||||
searchForm,
|
||||
pagination,
|
||||
columns,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handleTableChange,
|
||||
handleRefresh,
|
||||
handleExport
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.operation-logs-container {
|
||||
padding: 24px;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 0;
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 表格布局优化 */
|
||||
:deep(.ant-table) {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
padding: 16px 12px;
|
||||
font-size: 14px;
|
||||
border-bottom: 2px solid #e8e8e8;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
padding: 16px 12px;
|
||||
word-break: break-word;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:hover > td) {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 操作描述列样式 */
|
||||
.operation-desc {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
/* 表格行样式优化 */
|
||||
:deep(.ant-table-tbody > tr) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 响应式布局 */
|
||||
@media (max-width: 1400px) {
|
||||
:deep(.ant-table-thead > tr > th),
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
padding: 12px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
:deep(.ant-table-thead > tr > th),
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
padding: 10px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:deep(.ant-table-thead > tr > th),
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
padding: 8px 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.data-content {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background-color: #f5f5f5;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.data-content pre {
|
||||
margin: 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #262626;
|
||||
}
|
||||
</style>
|
||||
684
admin-system/src/views/Orders.vue
Normal file
684
admin-system/src/views/Orders.vue
Normal file
@@ -0,0 +1,684 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>订单管理</h1>
|
||||
<a-space>
|
||||
<a-button @click="exportOrders" :loading="exportLoading">
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出数据
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加订单
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 8px; align-items: center;">
|
||||
<a-auto-complete
|
||||
v-model:value="searchUsername"
|
||||
:options="usernameOptions"
|
||||
placeholder="请选择或输入用户名进行搜索"
|
||||
style="width: 300px;"
|
||||
:filter-option="false"
|
||||
@search="handleSearchInput"
|
||||
@press-enter="searchOrdersByUsername"
|
||||
/>
|
||||
<a-button type="primary" @click="searchOrdersByUsername" :loading="searchLoading">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="resetSearch" :disabled="!isSearching">
|
||||
重置
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="orders"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'total_amount'">
|
||||
¥{{ record.total_amount }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'created_at'">
|
||||
{{ formatDate(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="viewOrder(record)">查看</a-button>
|
||||
<a-button type="link" @click="editOrder(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个订单吗?"
|
||||
@confirm="deleteOrder(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑订单模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑订单' : '添加订单'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
width="800px"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="用户" name="user_id">
|
||||
<a-select v-model:value="formData.user_id" placeholder="请选择用户" :loading="usersLoading">
|
||||
<a-select-option v-for="user in users" :key="user.id" :value="user.id">
|
||||
{{ user.username }} ({{ user.email }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="订单状态" name="status">
|
||||
<a-select v-model:value="formData.status" placeholder="请选择状态">
|
||||
<a-select-option value="pending">待处理</a-select-option>
|
||||
<a-select-option value="paid">已支付</a-select-option>
|
||||
<a-select-option value="shipped">已发货</a-select-option>
|
||||
<a-select-option value="delivered">已送达</a-select-option>
|
||||
<a-select-option value="cancelled">已取消</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="订单项目">
|
||||
<div v-for="(item, index) in formData.items" :key="index" style="border: 1px solid #d9d9d9; padding: 16px; margin-bottom: 8px; border-radius: 6px;">
|
||||
<a-row :gutter="16" align="middle">
|
||||
<a-col :span="8">
|
||||
<a-form-item :name="['items', index, 'product_id']" label="产品" :rules="[{ required: true, message: '请选择产品' }]">
|
||||
<a-select v-model:value="item.product_id" placeholder="请选择产品" :loading="productsLoading">
|
||||
<a-select-option v-for="product in products" :key="product.id" :value="product.id">
|
||||
{{ product.name }} (¥{{ product.price }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-form-item :name="['items', index, 'quantity']" label="数量" :rules="[{ required: true, message: '请输入数量' }]">
|
||||
<a-input-number v-model:value="item.quantity" :min="1" style="width: 100%" @change="calculateItemTotal(index)" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-form-item :name="['items', index, 'price']" label="单价" :rules="[{ required: true, message: '请输入单价' }]">
|
||||
<a-input-number v-model:value="item.price" :min="0" :precision="2" style="width: 100%" @change="calculateItemTotal(index)" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="3">
|
||||
<div style="margin-top: 30px;">小计: ¥{{ (item.quantity * item.price || 0).toFixed(2) }}</div>
|
||||
</a-col>
|
||||
<a-col :span="1">
|
||||
<a-button type="text" danger @click="removeItem(index)" style="margin-top: 30px;">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
<a-button type="dashed" @click="addItem" style="width: 100%; margin-top: 8px;">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加订单项
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<div style="text-align: right; font-size: 16px; font-weight: bold;">
|
||||
总金额: ¥{{ calculateTotalAmount() }}
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 查看订单详情模态框 -->
|
||||
<a-modal
|
||||
v-model:open="viewModalVisible"
|
||||
title="订单详情"
|
||||
:footer="null"
|
||||
width="800px"
|
||||
>
|
||||
<div v-if="viewOrderData">
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="订单ID">{{ viewOrderData.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="用户">{{ viewOrderData.user?.username || `用户ID: ${viewOrderData.user_id}` }}</a-descriptions-item>
|
||||
<a-descriptions-item label="总金额">¥{{ viewOrderData.total_amount }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="getStatusColor(viewOrderData.status)">
|
||||
{{ getStatusText(viewOrderData.status) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ formatDate(viewOrderData.created_at) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="更新时间">{{ formatDate(viewOrderData.updated_at) }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider>订单项目</a-divider>
|
||||
<a-table
|
||||
:columns="orderItemColumns"
|
||||
:data-source="viewOrderData.items || []"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'product_name'">
|
||||
{{ getProductName(record.product_id) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'price'">
|
||||
¥{{ record.price }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'total'">
|
||||
¥{{ (record.quantity * record.price).toFixed(2) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, DeleteOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
// 移除axios导入,使用统一的api工具
|
||||
import { api } from '../utils/api'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
ExportOutlined
|
||||
},
|
||||
setup() {
|
||||
const orders = ref([])
|
||||
const users = ref([])
|
||||
const products = ref([])
|
||||
const loading = ref(false)
|
||||
const usersLoading = ref(false)
|
||||
const productsLoading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const viewModalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
const viewOrderData = ref(null)
|
||||
|
||||
// 搜索相关的响应式数据
|
||||
const searchUsername = ref('')
|
||||
const searchLoading = ref(false)
|
||||
const isSearching = ref(false)
|
||||
const usernameOptions = ref([])
|
||||
|
||||
// 导出相关
|
||||
const exportLoading = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
user_id: undefined,
|
||||
status: 'pending',
|
||||
items: [{
|
||||
product_id: undefined,
|
||||
quantity: 1,
|
||||
price: 0
|
||||
}]
|
||||
})
|
||||
|
||||
const rules = {
|
||||
user_id: [{ required: true, message: '请选择用户' }],
|
||||
status: [{ required: true, message: '请选择状态' }]
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
customRender: ({ record }) => record.user?.username || `用户ID: ${record.user_id}`
|
||||
},
|
||||
{
|
||||
title: '总金额',
|
||||
dataIndex: 'total_amount',
|
||||
key: 'total_amount'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status'
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 200
|
||||
}
|
||||
]
|
||||
|
||||
const orderItemColumns = [
|
||||
{
|
||||
title: '产品',
|
||||
dataIndex: 'product_name',
|
||||
key: 'product_name'
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'quantity',
|
||||
key: 'quantity'
|
||||
},
|
||||
{
|
||||
title: '单价',
|
||||
dataIndex: 'price',
|
||||
key: 'price'
|
||||
},
|
||||
{
|
||||
title: '小计',
|
||||
dataIndex: 'total',
|
||||
key: 'total'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取所有订单
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await api.get('/orders')
|
||||
if (response.success) {
|
||||
orders.value = response.data || []
|
||||
} else {
|
||||
message.error('获取订单失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取订单失败:', error)
|
||||
message.error('获取订单失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有用户
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
usersLoading.value = true
|
||||
const response = await api.get('/users')
|
||||
if (response.success) {
|
||||
users.value = response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户失败:', error)
|
||||
} finally {
|
||||
usersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有产品
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
productsLoading.value = true
|
||||
const response = await api.get('/products')
|
||||
if (response.success) {
|
||||
products.value = response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取产品失败:', error)
|
||||
} finally {
|
||||
productsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
fetchUsers()
|
||||
fetchProducts()
|
||||
}
|
||||
|
||||
// 查看订单
|
||||
const viewOrder = (record) => {
|
||||
viewOrderData.value = record
|
||||
viewModalVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑订单
|
||||
const editOrder = (record) => {
|
||||
isEdit.value = true
|
||||
formData.user_id = record.user_id
|
||||
formData.status = record.status
|
||||
formData.items = record.items || [{
|
||||
product_id: undefined,
|
||||
quantity: 1,
|
||||
price: 0
|
||||
}]
|
||||
formData.id = record.id
|
||||
modalVisible.value = true
|
||||
fetchUsers()
|
||||
fetchProducts()
|
||||
}
|
||||
|
||||
// 删除订单
|
||||
const deleteOrder = async (id) => {
|
||||
try {
|
||||
await api.delete(`/orders/${id}`)
|
||||
message.success('删除成功')
|
||||
fetchOrders()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
const orderData = {
|
||||
user_id: formData.user_id,
|
||||
status: formData.status,
|
||||
items: formData.items.filter(item => item.product_id && item.quantity && item.price),
|
||||
total_amount: calculateTotalAmount()
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await api.put(`/orders/${formData.id}`, orderData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await api.post('/orders', orderData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
|
||||
modalVisible.value = false
|
||||
fetchOrders()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('提交失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
formData.user_id = undefined
|
||||
formData.status = 'pending'
|
||||
formData.items = [{
|
||||
product_id: undefined,
|
||||
quantity: 1,
|
||||
price: 0
|
||||
}]
|
||||
delete formData.id
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 添加订单项
|
||||
const addItem = () => {
|
||||
formData.items.push({
|
||||
product_id: undefined,
|
||||
quantity: 1,
|
||||
price: 0
|
||||
})
|
||||
}
|
||||
|
||||
// 删除订单项
|
||||
const removeItem = (index) => {
|
||||
if (formData.items.length > 1) {
|
||||
formData.items.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算订单项小计
|
||||
const calculateItemTotal = (index) => {
|
||||
const item = formData.items[index]
|
||||
if (item.product_id) {
|
||||
const product = products.value.find(p => p.id === item.product_id)
|
||||
if (product && !item.price) {
|
||||
item.price = product.price
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算总金额
|
||||
const calculateTotalAmount = () => {
|
||||
return formData.items.reduce((total, item) => {
|
||||
return total + (item.quantity * item.price || 0)
|
||||
}, 0).toFixed(2)
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
pending: 'orange',
|
||||
paid: 'blue',
|
||||
shipped: 'cyan',
|
||||
delivered: 'green',
|
||||
cancelled: 'red'
|
||||
}
|
||||
return colors[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
pending: '待处理',
|
||||
paid: '已支付',
|
||||
shipped: '已发货',
|
||||
delivered: '已送达',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取用户名
|
||||
const getUserName = (userId) => {
|
||||
const user = users.value.find(u => u.id === userId)
|
||||
return user ? `${user.username} (${user.email})` : `用户ID: ${userId}`
|
||||
}
|
||||
|
||||
// 获取产品名
|
||||
const getProductName = (productId) => {
|
||||
const product = products.value.find(p => p.id === productId)
|
||||
return product ? product.name : `产品ID: ${productId}`
|
||||
}
|
||||
|
||||
// 搜索订单
|
||||
const searchOrdersByUsername = async () => {
|
||||
if (!searchUsername.value.trim()) {
|
||||
message.warning('请输入用户名进行搜索')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
searchLoading.value = true
|
||||
const response = await api.get('/orders/search', {
|
||||
params: { username: searchUsername.value.trim() }
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
orders.value = response.data || []
|
||||
isSearching.value = true
|
||||
message.success(response.message || `找到 ${orders.value.length} 个匹配的订单`)
|
||||
} else {
|
||||
orders.value = []
|
||||
message.info('未找到匹配的订单')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索订单失败:', error)
|
||||
message.error('搜索订单失败')
|
||||
orders.value = []
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索输入(为自动完成提供选项)
|
||||
const handleSearchInput = (value) => {
|
||||
if (!value) {
|
||||
usernameOptions.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 从现有用户列表中筛选匹配的用户名
|
||||
const matchingUsers = users.value.filter(user =>
|
||||
user.username && user.username.toLowerCase().includes(value.toLowerCase())
|
||||
)
|
||||
|
||||
usernameOptions.value = matchingUsers.map(user => ({
|
||||
value: user.username,
|
||||
label: user.username
|
||||
}))
|
||||
}
|
||||
|
||||
// 更新用户名选项(在数据加载后)
|
||||
const updateUsernameOptions = () => {
|
||||
const uniqueUsernames = [...new Set(users.value.map(user => user.username).filter(Boolean))]
|
||||
usernameOptions.value = uniqueUsernames.map(username => ({
|
||||
value: username,
|
||||
label: username
|
||||
}))
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchUsername.value = ''
|
||||
isSearching.value = false
|
||||
usernameOptions.value = []
|
||||
fetchOrders() // 重新加载全部订单
|
||||
}
|
||||
|
||||
// 导出订单数据
|
||||
const exportOrders = async () => {
|
||||
try {
|
||||
if (!orders.value || orders.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
exportLoading.value = true
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportOrdersData(orders.value)
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchOrders()
|
||||
fetchUsers().then(() => {
|
||||
updateUsernameOptions()
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
orders,
|
||||
users,
|
||||
products,
|
||||
loading,
|
||||
usersLoading,
|
||||
productsLoading,
|
||||
modalVisible,
|
||||
viewModalVisible,
|
||||
submitLoading,
|
||||
isEdit,
|
||||
formRef,
|
||||
formData,
|
||||
rules,
|
||||
columns,
|
||||
orderItemColumns,
|
||||
viewOrderData,
|
||||
|
||||
// 搜索相关
|
||||
searchUsername,
|
||||
searchLoading,
|
||||
isSearching,
|
||||
usernameOptions,
|
||||
handleSearchInput,
|
||||
updateUsernameOptions,
|
||||
searchOrdersByUsername,
|
||||
resetSearch,
|
||||
|
||||
// 导出相关
|
||||
exportLoading,
|
||||
exportOrders,
|
||||
|
||||
fetchOrders,
|
||||
fetchUsers,
|
||||
fetchProducts,
|
||||
showAddModal,
|
||||
viewOrder,
|
||||
editOrder,
|
||||
deleteOrder,
|
||||
handleSubmit,
|
||||
handleCancel,
|
||||
resetForm,
|
||||
addItem,
|
||||
removeItem,
|
||||
calculateItemTotal,
|
||||
calculateTotalAmount,
|
||||
getStatusColor,
|
||||
getStatusText,
|
||||
formatDate,
|
||||
getUserName,
|
||||
getProductName
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
717
admin-system/src/views/PenManagement.vue
Normal file
717
admin-system/src/views/PenManagement.vue
Normal file
@@ -0,0 +1,717 @@
|
||||
<template>
|
||||
<div class="pen-management-container">
|
||||
<!-- 页面标题和操作栏 -->
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">栏舍设置</h2>
|
||||
<div class="header-actions">
|
||||
<a-button @click="exportData" class="add-button">
|
||||
<template #icon>
|
||||
<ExportOutlined />
|
||||
</template>
|
||||
导出数据
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showAddModal" class="add-button">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
新增栏舍
|
||||
</a-button>
|
||||
<div class="search-container">
|
||||
<a-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="请输入栏舍名、类型、负责人等关键词"
|
||||
class="search-input"
|
||||
@pressEnter="handleSearch"
|
||||
@input="(e) => { console.log('搜索输入框输入:', e.target.value); searchKeyword = e.target.value; }"
|
||||
@change="(e) => { console.log('搜索输入框变化:', e.target.value); searchKeyword = e.target.value; }"
|
||||
allowClear
|
||||
/>
|
||||
<a-button type="primary" @click="handleSearch" class="search-button">
|
||||
<template #icon>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="handleClearSearch" class="clear-button">
|
||||
清空
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<div class="table-container">
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="filteredData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
class="pen-table"
|
||||
>
|
||||
<!-- 状态列 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-switch
|
||||
:checked="record.status"
|
||||
@change="(checked) => { record.status = checked; handleStatusChange(record) }"
|
||||
:checked-children="'开启'"
|
||||
:un-checked-children="'关闭'"
|
||||
/>
|
||||
</template>
|
||||
<!-- 操作列 -->
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button type="link" @click="handleEdit(record)" class="action-btn">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button type="link" danger @click="handleDelete(record)" class="action-btn">
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑栏舍模态框 -->
|
||||
<a-modal
|
||||
:open="modalVisible"
|
||||
@update:open="(val) => modalVisible = val"
|
||||
:title="isEdit ? '编辑栏舍' : '新增栏舍'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitting"
|
||||
width="600px"
|
||||
>
|
||||
<a-form
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
ref="formRef"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="栏舍名" name="name" required>
|
||||
<a-input
|
||||
v-model="formData.name"
|
||||
placeholder="请输入栏舍名"
|
||||
@input="(e) => { console.log('栏舍名输入:', e.target.value); formData.name = e.target.value; }"
|
||||
@change="(e) => { console.log('栏舍名变化:', e.target.value); }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="动物类型" name="animal_type" required>
|
||||
<a-select
|
||||
v-model="formData.animal_type"
|
||||
placeholder="请选择动物类型"
|
||||
@change="(value) => { console.log('动物类型变化:', value); }"
|
||||
>
|
||||
<a-select-option value="马">马</a-select-option>
|
||||
<a-select-option value="牛">牛</a-select-option>
|
||||
<a-select-option value="羊">羊</a-select-option>
|
||||
<a-select-option value="家禽">家禽</a-select-option>
|
||||
<a-select-option value="猪">猪</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="栏舍类型" name="pen_type">
|
||||
<a-input
|
||||
v-model="formData.pen_type"
|
||||
placeholder="请输入栏舍类型"
|
||||
@input="(e) => { console.log('栏舍类型输入:', e.target.value); formData.pen_type = e.target.value; }"
|
||||
@change="(e) => { console.log('栏舍类型变化:', e.target.value); }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="负责人" name="responsible" required>
|
||||
<a-input
|
||||
v-model="formData.responsible"
|
||||
placeholder="请输入负责人"
|
||||
@input="(e) => { console.log('负责人输入:', e.target.value); formData.responsible = e.target.value; }"
|
||||
@change="(e) => { console.log('负责人变化:', e.target.value); }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="容量" name="capacity" required>
|
||||
<a-input-number
|
||||
v-model="formData.capacity"
|
||||
:min="1"
|
||||
:max="10000"
|
||||
placeholder="请输入容量"
|
||||
style="width: 100%"
|
||||
@change="(value) => { console.log('容量变化:', value); formData.capacity = value; }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-switch
|
||||
:checked="formData.status"
|
||||
@change="(checked) => { console.log('状态变化:', checked); formData.status = checked; }"
|
||||
:checked-children="'开启'"
|
||||
:un-checked-children="'关闭'"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="备注" name="description">
|
||||
<a-textarea
|
||||
v-model="formData.description"
|
||||
placeholder="请输入备注信息"
|
||||
:rows="3"
|
||||
@input="(e) => { console.log('备注输入:', e.target.value); formData.description = e.target.value; }"
|
||||
@change="(e) => { console.log('备注变化:', e.target.value); }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { PlusOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
import { api } from '../utils/api'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const formRef = ref(null)
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: '栏舍名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '动物类型',
|
||||
dataIndex: 'animal_type',
|
||||
key: 'animal_type',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '栏舍类型',
|
||||
dataIndex: 'pen_type',
|
||||
key: 'pen_type',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '负责人',
|
||||
dataIndex: 'responsible',
|
||||
key: 'responsible',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '容量',
|
||||
dataIndex: 'capacity',
|
||||
key: 'capacity',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '创建人',
|
||||
dataIndex: 'creator',
|
||||
key: 'creator',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
},
|
||||
]
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条/共 ${total} 条`,
|
||||
onChange: async (page, pageSize) => {
|
||||
pagination.current = page
|
||||
pagination.pageSize = pageSize
|
||||
await loadPenData()
|
||||
},
|
||||
onShowSizeChange: async (current, size) => {
|
||||
pagination.current = 1
|
||||
pagination.pageSize = size
|
||||
await loadPenData()
|
||||
}
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
animal_type: '',
|
||||
pen_type: '',
|
||||
responsible: '',
|
||||
capacity: 1,
|
||||
status: true,
|
||||
description: '',
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入栏舍名', trigger: 'blur' },
|
||||
{ min: 1, max: 50, message: '栏舍名长度应在1-50个字符之间', trigger: 'blur' }
|
||||
],
|
||||
animal_type: [
|
||||
{ required: true, message: '请选择动物类型', trigger: 'change' }
|
||||
],
|
||||
responsible: [
|
||||
{ required: true, message: '请输入负责人', trigger: 'blur' },
|
||||
{ min: 1, max: 20, message: '负责人姓名长度应在1-20个字符之间', trigger: 'blur' }
|
||||
],
|
||||
capacity: [
|
||||
{ required: true, message: '请输入容量', trigger: 'blur' },
|
||||
{ type: 'number', min: 1, max: 10000, message: '容量应在1-10000之间', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 真实数据
|
||||
const penData = ref([])
|
||||
|
||||
// 计算属性 - 过滤后的数据(暂时不使用前端过滤,使用API搜索)
|
||||
const filteredData = computed(() => {
|
||||
return penData.value
|
||||
})
|
||||
|
||||
// 数据加载方法
|
||||
const loadPenData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
console.log('开始加载栏舍数据...', {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
search: searchKeyword.value
|
||||
})
|
||||
|
||||
const requestParams = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
search: searchKeyword.value
|
||||
}
|
||||
|
||||
console.log('请求参数:', requestParams)
|
||||
console.log('搜索参数详情:', {
|
||||
search: searchKeyword.value,
|
||||
searchType: typeof searchKeyword.value,
|
||||
searchEmpty: !searchKeyword.value,
|
||||
searchTrimmed: searchKeyword.value?.trim()
|
||||
})
|
||||
|
||||
const response = await api.pens.getList(requestParams)
|
||||
|
||||
console.log('API响应:', response)
|
||||
|
||||
if (response && response.success) {
|
||||
penData.value = response.data.list
|
||||
pagination.total = response.data.pagination.total
|
||||
console.log('栏舍数据加载成功:', penData.value)
|
||||
} else {
|
||||
console.error('API返回失败:', response)
|
||||
message.error('获取栏舍数据失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载栏舍数据失败:', error)
|
||||
message.error('加载栏舍数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 方法
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (record) => {
|
||||
console.log('=== 开始编辑栏舍 ===')
|
||||
console.log('点击编辑按钮,原始记录数据:', record)
|
||||
|
||||
isEdit.value = true
|
||||
Object.assign(formData, {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
animal_type: record.animal_type,
|
||||
pen_type: record.pen_type,
|
||||
responsible: record.responsible,
|
||||
capacity: record.capacity,
|
||||
status: record.status,
|
||||
description: record.description
|
||||
})
|
||||
|
||||
console.log('编辑模式:表单数据已填充')
|
||||
console.log('formData对象:', formData)
|
||||
console.log('formData.name:', formData.name)
|
||||
console.log('formData.animal_type:', formData.animal_type)
|
||||
console.log('formData.pen_type:', formData.pen_type)
|
||||
console.log('formData.responsible:', formData.responsible)
|
||||
console.log('formData.capacity:', formData.capacity)
|
||||
console.log('formData.status:', formData.status)
|
||||
console.log('formData.description:', formData.description)
|
||||
|
||||
modalVisible.value = true
|
||||
console.log('编辑模态框已打开')
|
||||
}
|
||||
|
||||
const handleDelete = (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除栏舍"${record.name}"吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await api.pens.delete(record.id)
|
||||
if (response.success) {
|
||||
message.success('删除成功')
|
||||
await loadPenData() // 重新加载数据
|
||||
} else {
|
||||
message.error('删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除栏舍失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleStatusChange = async (record) => {
|
||||
try {
|
||||
const response = await api.pens.update(record.id, { status: record.status })
|
||||
if (response.success) {
|
||||
message.success(`栏舍"${record.name}"状态已${record.status ? '开启' : '关闭'}`)
|
||||
} else {
|
||||
message.error('状态更新失败')
|
||||
// 恢复原状态
|
||||
record.status = !record.status
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新状态失败:', error)
|
||||
message.error('状态更新失败')
|
||||
// 恢复原状态
|
||||
record.status = !record.status
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
console.log('=== 开始搜索栏舍 ===')
|
||||
console.log('搜索关键词:', searchKeyword.value)
|
||||
console.log('搜索关键词类型:', typeof searchKeyword.value)
|
||||
console.log('搜索关键词长度:', searchKeyword.value?.length)
|
||||
console.log('搜索关键词是否为空:', !searchKeyword.value)
|
||||
console.log('搜索关键词去除空格后:', searchKeyword.value?.trim())
|
||||
|
||||
// 确保搜索关键词正确传递
|
||||
const searchValue = searchKeyword.value?.trim() || ''
|
||||
console.log('实际使用的搜索值:', searchValue)
|
||||
|
||||
pagination.current = 1 // 重置到第一页
|
||||
await loadPenData()
|
||||
|
||||
console.log('=== 搜索完成 ===')
|
||||
}
|
||||
|
||||
const handleClearSearch = async () => {
|
||||
console.log('=== 清空搜索 ===')
|
||||
searchKeyword.value = ''
|
||||
pagination.current = 1 // 重置到第一页
|
||||
await loadPenData()
|
||||
console.log('=== 搜索已清空 ===')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
console.log('=== 开始提交栏舍数据 ===')
|
||||
console.log('当前表单数据:', formData)
|
||||
console.log('是否为编辑模式:', isEdit.value)
|
||||
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
|
||||
const submitData = {
|
||||
name: formData.name,
|
||||
animal_type: formData.animal_type,
|
||||
pen_type: formData.pen_type,
|
||||
responsible: formData.responsible,
|
||||
capacity: formData.capacity,
|
||||
status: formData.status,
|
||||
description: formData.description
|
||||
}
|
||||
|
||||
console.log('准备提交的数据:', submitData)
|
||||
console.log('提交的字段详情:')
|
||||
console.log('- 栏舍名 (name):', submitData.name)
|
||||
console.log('- 动物类型 (animal_type):', submitData.animal_type)
|
||||
console.log('- 栏舍类型 (pen_type):', submitData.pen_type)
|
||||
console.log('- 负责人 (responsible):', submitData.responsible)
|
||||
console.log('- 容量 (capacity):', submitData.capacity, typeof submitData.capacity)
|
||||
console.log('- 状态 (status):', submitData.status, typeof submitData.status)
|
||||
console.log('- 描述 (description):', submitData.description)
|
||||
|
||||
let response
|
||||
if (isEdit.value) {
|
||||
// 编辑
|
||||
console.log('执行编辑操作,栏舍ID:', formData.id)
|
||||
response = await api.pens.update(formData.id, submitData)
|
||||
console.log('编辑API响应:', response)
|
||||
} else {
|
||||
// 新增
|
||||
console.log('执行新增操作')
|
||||
response = await api.pens.create(submitData)
|
||||
console.log('新增API响应:', response)
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
console.log('✅ 操作成功:', response.message)
|
||||
console.log('返回的数据:', response.data)
|
||||
message.success(isEdit.value ? '编辑成功' : '新增成功')
|
||||
modalVisible.value = false
|
||||
await loadPenData() // 重新加载数据
|
||||
console.log('数据已重新加载')
|
||||
} else {
|
||||
console.log('❌ 操作失败:', response.message)
|
||||
message.error(isEdit.value ? '编辑失败' : '新增失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 提交失败:', error)
|
||||
console.error('错误详情:', error.response?.data || error.message)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
console.log('=== 提交操作完成 ===')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
name: '',
|
||||
animal_type: '',
|
||||
pen_type: '',
|
||||
responsible: '',
|
||||
capacity: 1,
|
||||
status: true,
|
||||
description: '',
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
console.log('PenManagement组件已挂载')
|
||||
// 初始化数据
|
||||
await loadPenData()
|
||||
})
|
||||
|
||||
// 导出数据
|
||||
const exportData = async () => {
|
||||
try {
|
||||
if (!penData.value || penData.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportPenData(penData.value)
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pen-management-container {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 300px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
height: 40px;
|
||||
color: #666;
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pen-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 8px;
|
||||
height: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:hover > td) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
/* 状态开关样式 */
|
||||
:deep(.ant-switch-checked) {
|
||||
background-color: #1890ff;
|
||||
}
|
||||
|
||||
/* 表单样式 */
|
||||
:deep(.ant-form-item-label > label) {
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
:deep(.ant-input),
|
||||
:deep(.ant-select),
|
||||
:deep(.ant-input-number) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:deep(.ant-btn) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
398
admin-system/src/views/Products.vue
Normal file
398
admin-system/src/views/Products.vue
Normal file
@@ -0,0 +1,398 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>产品管理</h1>
|
||||
<a-space>
|
||||
<a-button @click="exportProducts" :loading="exportLoading">
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出数据
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加产品
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 8px; align-items: center;">
|
||||
<a-auto-complete
|
||||
v-model:value="searchProductName"
|
||||
:options="productNameOptions"
|
||||
placeholder="请选择或输入产品名称进行搜索"
|
||||
style="width: 300px;"
|
||||
:filter-option="false"
|
||||
@search="handleSearchInput"
|
||||
@press-enter="searchProducts"
|
||||
/>
|
||||
<a-button type="primary" @click="searchProducts" :loading="searchLoading">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="resetSearch" :disabled="!isSearching">
|
||||
重置
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="products"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'price'">
|
||||
¥{{ record.price }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
|
||||
{{ record.status === 'active' ? '活跃' : '停用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="editProduct(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个产品吗?"
|
||||
@confirm="deleteProduct(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑产品模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑产品' : '添加产品'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="产品名称" name="name">
|
||||
<a-input v-model:value="formData.name" placeholder="请输入产品名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="产品描述" name="description">
|
||||
<a-textarea v-model:value="formData.description" placeholder="请输入产品描述" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-form-item label="价格" name="price">
|
||||
<a-input-number
|
||||
v-model:value="formData.price"
|
||||
placeholder="请输入价格"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="库存" name="stock">
|
||||
<a-input-number
|
||||
v-model:value="formData.stock"
|
||||
placeholder="请输入库存数量"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select v-model="formData.status" placeholder="请选择状态">
|
||||
<a-select-option value="active">活跃</a-select-option>
|
||||
<a-select-option value="inactive">停用</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
import { api } from '../utils/api'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
|
||||
// 响应式数据
|
||||
const products = ref([])
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 搜索相关的响应式数据
|
||||
const searchProductName = ref('')
|
||||
const searchLoading = ref(false)
|
||||
const isSearching = ref(false)
|
||||
const productNameOptions = ref([])
|
||||
|
||||
// 导出相关
|
||||
const exportLoading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
price: null,
|
||||
stock: null,
|
||||
status: 'active'
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入产品名称', trigger: 'blur' }],
|
||||
price: [{ required: true, message: '请输入价格', trigger: 'blur' }],
|
||||
stock: [{ required: true, message: '请输入库存数量', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '产品名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '价格',
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '库存',
|
||||
dataIndex: 'stock',
|
||||
key: 'stock',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 150
|
||||
}
|
||||
]
|
||||
|
||||
// 获取产品列表
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await api.get('/products')
|
||||
if (response.success) {
|
||||
products.value = response.data
|
||||
} else {
|
||||
message.error('获取产品列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取产品列表失败:', error)
|
||||
message.error('获取产品列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑产品
|
||||
const editProduct = (record) => {
|
||||
isEdit.value = true
|
||||
Object.assign(formData, record)
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 删除产品
|
||||
const deleteProduct = async (id) => {
|
||||
try {
|
||||
const response = await api.delete(`/products/${id}`)
|
||||
if (response.success) {
|
||||
message.success('删除成功')
|
||||
fetchProducts()
|
||||
} else {
|
||||
message.error('删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除产品失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
let response
|
||||
if (isEdit.value) {
|
||||
response = await api.put(`/products/${formData.id}`, formData)
|
||||
} else {
|
||||
response = await api.post('/products', formData)
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
modalVisible.value = false
|
||||
fetchProducts()
|
||||
} else {
|
||||
message.error(isEdit.value ? '更新失败' : '创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
price: null,
|
||||
stock: null,
|
||||
status: 'active'
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 搜索产品
|
||||
const searchProducts = async () => {
|
||||
if (!searchProductName.value.trim()) {
|
||||
message.warning('请输入产品名称进行搜索')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
searchLoading.value = true
|
||||
const response = await api.get('/products/search', {
|
||||
params: { name: searchProductName.value.trim() }
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
products.value = response.data || []
|
||||
isSearching.value = true
|
||||
message.success(response.message || `找到 ${products.value.length} 个匹配的产品`)
|
||||
} else {
|
||||
products.value = []
|
||||
message.info('未找到匹配的产品')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索产品失败:', error)
|
||||
message.error('搜索产品失败')
|
||||
products.value = []
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索输入(为自动完成提供选项)
|
||||
const handleSearchInput = (value) => {
|
||||
if (!value) {
|
||||
productNameOptions.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 从现有产品列表中筛选匹配的产品名称
|
||||
const matchingProducts = products.value.filter(product =>
|
||||
product.name.toLowerCase().includes(value.toLowerCase())
|
||||
)
|
||||
|
||||
productNameOptions.value = matchingProducts.map(product => ({
|
||||
value: product.name,
|
||||
label: product.name
|
||||
}))
|
||||
}
|
||||
|
||||
// 更新产品名称选项(在数据加载后)
|
||||
const updateProductNameOptions = () => {
|
||||
productNameOptions.value = products.value.map(product => ({
|
||||
value: product.name,
|
||||
label: product.name
|
||||
}))
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchProductName.value = ''
|
||||
isSearching.value = false
|
||||
productNameOptions.value = []
|
||||
fetchProducts() // 重新加载全部产品
|
||||
}
|
||||
|
||||
// 导出产品数据
|
||||
const exportProducts = async () => {
|
||||
try {
|
||||
if (!products.value || products.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
exportLoading.value = true
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportProductsData(products.value)
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchProducts().then(() => {
|
||||
updateProductNameOptions()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
485
admin-system/src/views/Reports.vue
Normal file
485
admin-system/src/views/Reports.vue
Normal file
@@ -0,0 +1,485 @@
|
||||
<template>
|
||||
<div class="reports-page">
|
||||
<a-page-header
|
||||
title="报表管理"
|
||||
sub-title="生成和管理系统报表"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button @click="fetchReportList">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新列表
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showGenerateModal = true">
|
||||
<template #icon><FilePdfOutlined /></template>
|
||||
生成报表
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<div class="reports-content">
|
||||
<!-- 快捷导出区域 -->
|
||||
<a-card title="快捷数据导出" :bordered="false" style="margin-bottom: 24px;">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-button
|
||||
type="primary"
|
||||
block
|
||||
:loading="exportLoading.farms"
|
||||
@click="quickExport('farms', 'excel')"
|
||||
>
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出农场数据
|
||||
</a-button>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-button
|
||||
type="primary"
|
||||
block
|
||||
:loading="exportLoading.devices"
|
||||
@click="quickExport('devices', 'excel')"
|
||||
>
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出设备数据
|
||||
</a-button>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-button
|
||||
type="primary"
|
||||
block
|
||||
:loading="exportLoading.animals"
|
||||
@click="quickExport('animals', 'excel')"
|
||||
>
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出动物数据
|
||||
</a-button>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-button
|
||||
type="primary"
|
||||
block
|
||||
:loading="exportLoading.alerts"
|
||||
@click="quickExport('alerts', 'excel')"
|
||||
>
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出预警数据
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<!-- 报表文件列表 -->
|
||||
<a-card title="历史报表文件" :bordered="false">
|
||||
<a-table
|
||||
:columns="reportColumns"
|
||||
:data-source="reportFiles"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="fileName"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'size'">
|
||||
{{ formatFileSize(record.size) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'createdAt'">
|
||||
{{ formatDate(record.createdAt) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-space>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="downloadReport(record)"
|
||||
>
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
下载
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
danger
|
||||
size="small"
|
||||
@click="deleteReport(record)"
|
||||
v-if="userStore.userData?.roles?.includes('admin')"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 生成报表模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showGenerateModal"
|
||||
title="生成报表"
|
||||
:confirm-loading="generateLoading"
|
||||
@ok="generateReport"
|
||||
@cancel="resetGenerateForm"
|
||||
width="600px"
|
||||
>
|
||||
<a-form
|
||||
ref="generateFormRef"
|
||||
:model="generateForm"
|
||||
:rules="generateRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="报表类型" name="reportType">
|
||||
<a-select v-model:value="generateForm.reportType" @change="onReportTypeChange">
|
||||
<a-select-option value="farm">养殖统计报表</a-select-option>
|
||||
<a-select-option value="sales">销售分析报表</a-select-option>
|
||||
<a-select-option value="compliance" v-if="userStore.userData?.roles?.includes('admin')">
|
||||
监管合规报表
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="开始日期" name="startDate">
|
||||
<a-date-picker
|
||||
v-model:value="generateForm.startDate"
|
||||
style="width: 100%;"
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="结束日期" name="endDate">
|
||||
<a-date-picker
|
||||
v-model:value="generateForm.endDate"
|
||||
style="width: 100%;"
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="报表格式" name="format">
|
||||
<a-radio-group v-model:value="generateForm.format">
|
||||
<a-radio-button value="pdf">PDF</a-radio-button>
|
||||
<a-radio-button value="excel">Excel</a-radio-button>
|
||||
<a-radio-button value="csv" v-if="generateForm.reportType === 'farm'">CSV</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="选择农场"
|
||||
name="farmIds"
|
||||
v-if="generateForm.reportType === 'farm'"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="generateForm.farmIds"
|
||||
mode="multiple"
|
||||
placeholder="不选择则包含所有农场"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="farm in dataStore.farms"
|
||||
:key="farm.id"
|
||||
:value="farm.id"
|
||||
>
|
||||
{{ farm.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
FilePdfOutlined,
|
||||
ExportOutlined,
|
||||
DownloadOutlined,
|
||||
DeleteOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { api } from '../utils/api'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { useDataStore } from '../stores/data'
|
||||
import moment from 'moment'
|
||||
|
||||
// Store
|
||||
const userStore = useUserStore()
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const generateLoading = ref(false)
|
||||
const showGenerateModal = ref(false)
|
||||
const reportFiles = ref([])
|
||||
|
||||
// 导出加载状态
|
||||
const exportLoading = reactive({
|
||||
farms: false,
|
||||
devices: false,
|
||||
animals: false,
|
||||
alerts: false
|
||||
})
|
||||
|
||||
// 生成报表表单
|
||||
const generateFormRef = ref()
|
||||
const generateForm = reactive({
|
||||
reportType: 'farm',
|
||||
startDate: moment().subtract(30, 'days'),
|
||||
endDate: moment(),
|
||||
format: 'pdf',
|
||||
farmIds: []
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const generateRules = {
|
||||
reportType: [{ required: true, message: '请选择报表类型' }],
|
||||
startDate: [{ required: true, message: '请选择开始日期' }],
|
||||
endDate: [{ required: true, message: '请选择结束日期' }],
|
||||
format: [{ required: true, message: '请选择报表格式' }]
|
||||
}
|
||||
|
||||
// 报表文件列表表格列定义
|
||||
const reportColumns = [
|
||||
{
|
||||
title: '文件名',
|
||||
dataIndex: 'fileName',
|
||||
key: 'fileName',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '文件大小',
|
||||
dataIndex: 'size',
|
||||
key: 'size',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 150
|
||||
}
|
||||
]
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(async () => {
|
||||
await dataStore.fetchAllData()
|
||||
await fetchReportList()
|
||||
})
|
||||
|
||||
// 获取报表文件列表
|
||||
async function fetchReportList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get('/reports/list')
|
||||
if (response.success) {
|
||||
reportFiles.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取报表列表失败:', error)
|
||||
message.error('获取报表列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生成报表
|
||||
async function generateReport() {
|
||||
try {
|
||||
await generateFormRef.value.validate()
|
||||
generateLoading.value = true
|
||||
|
||||
const params = {
|
||||
startDate: generateForm.startDate.format('YYYY-MM-DD'),
|
||||
endDate: generateForm.endDate.format('YYYY-MM-DD'),
|
||||
format: generateForm.format
|
||||
}
|
||||
|
||||
if (generateForm.reportType === 'farm' && generateForm.farmIds.length > 0) {
|
||||
params.farmIds = generateForm.farmIds
|
||||
}
|
||||
|
||||
let endpoint = ''
|
||||
if (generateForm.reportType === 'farm') {
|
||||
endpoint = '/reports/farm'
|
||||
} else if (generateForm.reportType === 'sales') {
|
||||
endpoint = '/reports/sales'
|
||||
} else if (generateForm.reportType === 'compliance') {
|
||||
endpoint = '/reports/compliance'
|
||||
}
|
||||
|
||||
const response = await api.post(endpoint, params)
|
||||
|
||||
if (response.success) {
|
||||
message.success('报表生成成功')
|
||||
showGenerateModal.value = false
|
||||
resetGenerateForm()
|
||||
await fetchReportList()
|
||||
|
||||
// 自动下载生成的报表
|
||||
if (response.data.downloadUrl) {
|
||||
window.open(`${api.baseURL}${response.data.downloadUrl}`, '_blank')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成报表失败:', error)
|
||||
message.error('生成报表失败')
|
||||
} finally {
|
||||
generateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置生成表单
|
||||
function resetGenerateForm() {
|
||||
generateForm.reportType = 'farm'
|
||||
generateForm.startDate = moment().subtract(30, 'days')
|
||||
generateForm.endDate = moment()
|
||||
generateForm.format = 'pdf'
|
||||
generateForm.farmIds = []
|
||||
}
|
||||
|
||||
// 报表类型改变时的处理
|
||||
function onReportTypeChange(value) {
|
||||
if (value !== 'farm') {
|
||||
generateForm.farmIds = []
|
||||
}
|
||||
}
|
||||
|
||||
// 快捷导出
|
||||
async function quickExport(dataType, format) {
|
||||
exportLoading[dataType] = true
|
||||
try {
|
||||
let endpoint = ''
|
||||
let fileName = ''
|
||||
|
||||
switch (dataType) {
|
||||
case 'farms':
|
||||
endpoint = '/reports/export/farms'
|
||||
fileName = `农场数据_${moment().format('YYYYMMDD_HHmmss')}.xlsx`
|
||||
break
|
||||
case 'devices':
|
||||
endpoint = '/reports/export/devices'
|
||||
fileName = `设备数据_${moment().format('YYYYMMDD_HHmmss')}.xlsx`
|
||||
break
|
||||
case 'animals':
|
||||
// 使用农场报表API导出动物数据
|
||||
endpoint = '/reports/farm'
|
||||
fileName = `动物数据_${moment().format('YYYYMMDD_HHmmss')}.xlsx`
|
||||
break
|
||||
case 'alerts':
|
||||
// 使用农场报表API导出预警数据
|
||||
endpoint = '/reports/farm'
|
||||
fileName = `预警数据_${moment().format('YYYYMMDD_HHmmss')}.xlsx`
|
||||
break
|
||||
}
|
||||
|
||||
if (dataType === 'animals' || dataType === 'alerts') {
|
||||
// 生成包含动物或预警数据的报表
|
||||
const response = await api.post(endpoint, { format: 'excel' })
|
||||
if (response.success && response.data.downloadUrl) {
|
||||
downloadFile(`${api.baseURL}${response.data.downloadUrl}`, response.data.fileName)
|
||||
message.success('数据导出成功')
|
||||
}
|
||||
} else {
|
||||
// 直接导出
|
||||
const url = `${api.baseURL}${endpoint}?format=${format}`
|
||||
downloadFile(url, fileName)
|
||||
message.success('数据导出成功')
|
||||
}
|
||||
|
||||
await fetchReportList()
|
||||
} catch (error) {
|
||||
console.error(`导出${dataType}数据失败:`, error)
|
||||
message.error('数据导出失败')
|
||||
} finally {
|
||||
exportLoading[dataType] = false
|
||||
}
|
||||
}
|
||||
|
||||
// 下载报表文件
|
||||
function downloadReport(record) {
|
||||
const url = `${api.baseURL}${record.downloadUrl}`
|
||||
downloadFile(url, record.fileName)
|
||||
}
|
||||
|
||||
// 下载文件辅助函数
|
||||
function downloadFile(url, fileName) {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// 删除报表文件
|
||||
async function deleteReport(record) {
|
||||
try {
|
||||
// 注意:这里需要在后端添加删除API
|
||||
message.success('删除功能将在后续版本实现')
|
||||
} catch (error) {
|
||||
console.error('删除报表失败:', error)
|
||||
message.error('删除报表失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(date) {
|
||||
return moment(date).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reports-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.reports-content {
|
||||
padding: 24px;
|
||||
background: #f0f2f5;
|
||||
min-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-page-header {
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background: #fafafa;
|
||||
color: #262626;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-group) .ant-btn {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ant-col {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
660
admin-system/src/views/RolePermissions.vue
Normal file
660
admin-system/src/views/RolePermissions.vue
Normal file
@@ -0,0 +1,660 @@
|
||||
<template>
|
||||
<div class="role-permissions">
|
||||
<div class="page-header">
|
||||
<h1>角色权限管理</h1>
|
||||
<a-button type="primary" @click="showCreateModal">
|
||||
<PlusOutlined />
|
||||
新增角色
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 角色列表 -->
|
||||
<a-card title="角色列表" :bordered="false">
|
||||
<template #extra>
|
||||
<a-input-search
|
||||
v-model="searchText"
|
||||
placeholder="搜索角色名称"
|
||||
style="width: 200px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="roles"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-switch
|
||||
:checked="record.status"
|
||||
@change="(checked) => handleStatusChange(record, checked)"
|
||||
:checked-children="'启用'"
|
||||
:un-checked-children="'禁用'"
|
||||
:loading="record.statusChanging"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="editRole(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-dropdown>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="menu" @click="manageMenuPermissions(record)">
|
||||
菜单权限
|
||||
</a-menu-item>
|
||||
<a-menu-item key="function" @click="manageFunctionPermissions(record)">
|
||||
功能权限
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
<a-button type="link" size="small">
|
||||
权限管理 <DownOutlined />
|
||||
</a-button>
|
||||
</a-dropdown>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个角色吗?"
|
||||
@confirm="deleteRole(record.id)"
|
||||
>
|
||||
<a-button type="link" size="small" danger>
|
||||
删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 创建/编辑角色模态框 -->
|
||||
<a-modal
|
||||
:open="modalVisible"
|
||||
:title="isEdit ? '编辑角色' : '新增角色'"
|
||||
@ok="handleModalOk"
|
||||
@cancel="handleModalCancel"
|
||||
@update:open="modalVisible = $event"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 18 }"
|
||||
>
|
||||
<a-form-item label="角色名称" name="name">
|
||||
<a-input :value="formData.name" placeholder="请输入角色名称" @update:value="formData.name = $event" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="角色描述" name="description">
|
||||
<a-textarea :value="formData.description" placeholder="请输入角色描述" @update:value="formData.description = $event" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-switch :checked="formData.status" @update:checked="formData.status = $event" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 菜单权限管理模态框 -->
|
||||
<a-modal
|
||||
:open="menuPermissionModalVisible"
|
||||
title="菜单权限管理"
|
||||
width="800px"
|
||||
@ok="handleMenuPermissionOk"
|
||||
@cancel="handleMenuPermissionCancel"
|
||||
@update:open="menuPermissionModalVisible = $event"
|
||||
>
|
||||
<div class="permission-container">
|
||||
<div class="permission-header">
|
||||
<h3>角色:{{ currentRole?.name }}</h3>
|
||||
<a-space>
|
||||
<a-button @click="checkAllMenus">全选</a-button>
|
||||
<a-button @click="uncheckAllMenus">全不选</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-tree
|
||||
:checkedKeys="menuCheckedKeys"
|
||||
:tree-data="menuTree"
|
||||
:field-names="{ children: 'children', title: 'name', key: 'id' }"
|
||||
checkable
|
||||
:check-strictly="false"
|
||||
@check="handleMenuTreeCheck"
|
||||
@update:checkedKeys="menuCheckedKeys = $event"
|
||||
/>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 功能权限管理模态框 -->
|
||||
<a-modal
|
||||
:open="functionPermissionModalVisible"
|
||||
title="功能权限管理"
|
||||
width="1000px"
|
||||
@ok="handleFunctionPermissionOk"
|
||||
@cancel="handleFunctionPermissionCancel"
|
||||
@update:open="functionPermissionModalVisible = $event"
|
||||
>
|
||||
<div class="permission-container">
|
||||
<div class="permission-header">
|
||||
<h3>角色:{{ currentRole?.name }}</h3>
|
||||
<a-space>
|
||||
<a-select
|
||||
:value="selectedModule"
|
||||
placeholder="选择模块"
|
||||
style="width: 150px"
|
||||
@update:value="selectedModule = $event"
|
||||
@change="filterPermissions"
|
||||
>
|
||||
<a-select-option value="">全部模块</a-select-option>
|
||||
<a-select-option v-for="module in permissionModules" :key="module" :value="module">
|
||||
{{ module }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-button @click="checkAllFunctions">全选</a-button>
|
||||
<a-button @click="uncheckAllFunctions">全不选</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div class="function-permissions">
|
||||
<a-collapse v-model="activeModules">
|
||||
<a-collapse-panel v-for="(permissions, module) in filteredPermissions" :key="module" :header="module">
|
||||
<a-checkbox-group
|
||||
:value="functionCheckedKeys"
|
||||
@update:value="functionCheckedKeys = $event"
|
||||
@change="handleFunctionCheck"
|
||||
>
|
||||
<a-row :gutter="[16, 8]">
|
||||
<a-col v-for="permission in permissions" :key="permission.id" :span="8">
|
||||
<a-checkbox :value="permission.id">
|
||||
{{ permission.permission_name }}
|
||||
</a-checkbox>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-checkbox-group>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { PlusOutlined, DownOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { rolePermissionService } from '../utils/dataService'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const roles = ref([])
|
||||
const menuTree = ref([])
|
||||
const menuCheckedKeys = ref([])
|
||||
const functionCheckedKeys = ref([])
|
||||
const allPermissions = ref([])
|
||||
const permissionModules = ref([])
|
||||
const selectedModule = ref('')
|
||||
const activeModules = ref([])
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条记录`
|
||||
})
|
||||
|
||||
// 模态框状态
|
||||
const modalVisible = ref(false)
|
||||
const menuPermissionModalVisible = ref(false)
|
||||
const functionPermissionModalVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const currentRole = ref(null)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
status: true
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入角色名称', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '角色名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 计算属性
|
||||
const filteredPermissions = computed(() => {
|
||||
if (!selectedModule.value) {
|
||||
return allPermissions.value.reduce((acc, permission) => {
|
||||
const module = permission.module;
|
||||
if (!acc[module]) {
|
||||
acc[module] = [];
|
||||
}
|
||||
acc[module].push(permission);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
return allPermissions.value
|
||||
.filter(permission => permission.module === selectedModule.value)
|
||||
.reduce((acc, permission) => {
|
||||
const module = permission.module;
|
||||
if (!acc[module]) {
|
||||
acc[module] = [];
|
||||
}
|
||||
acc[module].push(permission);
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
// 加载角色列表
|
||||
const loadRoles = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
search: searchText.value
|
||||
}
|
||||
|
||||
const response = await rolePermissionService.getRoles(params)
|
||||
roles.value = response.list || []
|
||||
pagination.total = response.pagination?.total || 0
|
||||
} catch (error) {
|
||||
console.error('加载角色列表失败:', error)
|
||||
message.error('加载角色列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载菜单树
|
||||
const loadMenuTree = async () => {
|
||||
try {
|
||||
const response = await rolePermissionService.getPermissionTree()
|
||||
|
||||
// 转换字段名以匹配前端组件期望的格式
|
||||
const convertMenuFields = (menu) => {
|
||||
return {
|
||||
id: menu.id,
|
||||
name: menu.menu_name || menu.name, // 映射 menu_name 到 name
|
||||
key: menu.id,
|
||||
children: menu.children ? menu.children.map(convertMenuFields) : []
|
||||
}
|
||||
}
|
||||
|
||||
menuTree.value = response ? response.map(convertMenuFields) : []
|
||||
} catch (error) {
|
||||
console.error('加载菜单树失败:', error)
|
||||
message.error('加载菜单树失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载所有权限
|
||||
const loadAllPermissions = async () => {
|
||||
try {
|
||||
const response = await rolePermissionService.getAllPermissions()
|
||||
allPermissions.value = response.data?.permissions || []
|
||||
} catch (error) {
|
||||
console.error('加载权限列表失败:', error)
|
||||
message.error('加载权限列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载权限模块列表
|
||||
const loadPermissionModules = async () => {
|
||||
try {
|
||||
const response = await rolePermissionService.getPermissionModules()
|
||||
permissionModules.value = response.data || []
|
||||
// 默认展开所有模块
|
||||
activeModules.value = permissionModules.value
|
||||
} catch (error) {
|
||||
console.error('加载权限模块失败:', error)
|
||||
message.error('加载权限模块失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
loadRoles()
|
||||
}
|
||||
|
||||
// 表格变化
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadRoles()
|
||||
}
|
||||
|
||||
// 显示创建模态框
|
||||
const showCreateModal = () => {
|
||||
isEdit.value = false
|
||||
modalVisible.value = true
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 编辑角色
|
||||
const editRole = (record) => {
|
||||
isEdit.value = true
|
||||
modalVisible.value = true
|
||||
Object.assign(formData, record)
|
||||
}
|
||||
|
||||
// 管理菜单权限
|
||||
const manageMenuPermissions = async (record) => {
|
||||
currentRole.value = record
|
||||
menuPermissionModalVisible.value = true
|
||||
|
||||
try {
|
||||
// 加载角色的菜单权限
|
||||
console.log('🔍 [菜单权限加载] 角色ID:', record.id)
|
||||
console.log('🔍 [菜单权限加载] 调用API: /role-permissions/public/roles/' + record.id + '/menus')
|
||||
|
||||
const response = await rolePermissionService.getRoleMenuPermissions(record.id)
|
||||
console.log('🔍 [菜单权限加载] API响应:', response)
|
||||
console.log('🔍 [菜单权限加载] 权限数组:', response.data?.permissions)
|
||||
console.log('🔍 [菜单权限加载] 权限数组长度:', response.data?.permissions?.length)
|
||||
|
||||
const permissionIds = response.data?.permissions?.map(p => p.id) || []
|
||||
console.log('🔍 [菜单权限加载] 权限ID数组:', permissionIds)
|
||||
console.log('🔍 [菜单权限加载] 权限ID数组长度:', permissionIds.length)
|
||||
|
||||
menuCheckedKeys.value = permissionIds
|
||||
console.log('🔍 [菜单权限加载] 设置后的menuCheckedKeys:', menuCheckedKeys.value)
|
||||
} catch (error) {
|
||||
console.error('加载角色菜单权限失败:', error)
|
||||
message.error('加载角色菜单权限失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 管理功能权限
|
||||
const manageFunctionPermissions = async (record) => {
|
||||
currentRole.value = record
|
||||
functionPermissionModalVisible.value = true
|
||||
|
||||
try {
|
||||
// 加载角色的功能权限
|
||||
const response = await rolePermissionService.getRoleFunctionPermissions(record.id)
|
||||
functionCheckedKeys.value = response.data?.permissions?.map(p => p.id) || []
|
||||
} catch (error) {
|
||||
console.error('加载角色功能权限失败:', error)
|
||||
message.error('加载角色功能权限失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除角色
|
||||
const deleteRole = async (id) => {
|
||||
try {
|
||||
await rolePermissionService.deleteRole(id)
|
||||
message.success('删除成功')
|
||||
loadRoles()
|
||||
} catch (error) {
|
||||
console.error('删除角色失败:', error)
|
||||
message.error('删除角色失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 模态框确定
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await rolePermissionService.updateRole(formData.id, formData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await rolePermissionService.createRole(formData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
|
||||
modalVisible.value = false
|
||||
loadRoles()
|
||||
} catch (error) {
|
||||
console.error('保存角色失败:', error)
|
||||
message.error('保存角色失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 模态框取消
|
||||
const handleModalCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 菜单权限管理确定
|
||||
const handleMenuPermissionOk = async () => {
|
||||
try {
|
||||
console.log('🔍 [菜单权限保存] 角色ID:', currentRole.value.id)
|
||||
console.log('🔍 [菜单权限保存] 要保存的权限ID:', menuCheckedKeys.value)
|
||||
|
||||
await rolePermissionService.setRoleMenuPermissions(currentRole.value.id, {
|
||||
menuIds: menuCheckedKeys.value
|
||||
})
|
||||
|
||||
console.log('🔍 [菜单权限保存] 保存成功')
|
||||
message.success('菜单权限设置成功')
|
||||
menuPermissionModalVisible.value = false
|
||||
loadRoles()
|
||||
} catch (error) {
|
||||
console.error('设置菜单权限失败:', error)
|
||||
message.error('设置菜单权限失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 菜单权限管理取消
|
||||
const handleMenuPermissionCancel = () => {
|
||||
menuPermissionModalVisible.value = false
|
||||
menuCheckedKeys.value = []
|
||||
}
|
||||
|
||||
// 功能权限管理确定
|
||||
const handleFunctionPermissionOk = async () => {
|
||||
try {
|
||||
await rolePermissionService.setRoleFunctionPermissions(currentRole.value.id, {
|
||||
permissionIds: functionCheckedKeys.value
|
||||
})
|
||||
message.success('功能权限设置成功')
|
||||
functionPermissionModalVisible.value = false
|
||||
loadRoles()
|
||||
} catch (error) {
|
||||
console.error('设置功能权限失败:', error)
|
||||
message.error('设置功能权限失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 功能权限管理取消
|
||||
const handleFunctionPermissionCancel = () => {
|
||||
functionPermissionModalVisible.value = false
|
||||
functionCheckedKeys.value = []
|
||||
}
|
||||
|
||||
// 菜单树形选择变化
|
||||
const handleMenuTreeCheck = (checkedKeysValue) => {
|
||||
menuCheckedKeys.value = checkedKeysValue
|
||||
}
|
||||
|
||||
// 功能权限选择变化
|
||||
const handleFunctionCheck = (checkedValues) => {
|
||||
functionCheckedKeys.value = checkedValues
|
||||
}
|
||||
|
||||
// 过滤权限
|
||||
const filterPermissions = () => {
|
||||
// 重新计算过滤后的权限
|
||||
}
|
||||
|
||||
// 菜单权限全选
|
||||
const checkAllMenus = () => {
|
||||
const getAllKeys = (tree) => {
|
||||
let keys = []
|
||||
tree.forEach(node => {
|
||||
keys.push(node.id)
|
||||
if (node.children) {
|
||||
keys = keys.concat(getAllKeys(node.children))
|
||||
}
|
||||
})
|
||||
return keys
|
||||
}
|
||||
menuCheckedKeys.value = getAllKeys(menuTree.value)
|
||||
}
|
||||
|
||||
// 菜单权限全不选
|
||||
const uncheckAllMenus = () => {
|
||||
menuCheckedKeys.value = []
|
||||
}
|
||||
|
||||
// 功能权限全选
|
||||
const checkAllFunctions = () => {
|
||||
functionCheckedKeys.value = allPermissions.value.map(p => p.id)
|
||||
}
|
||||
|
||||
// 功能权限全不选
|
||||
const uncheckAllFunctions = () => {
|
||||
functionCheckedKeys.value = []
|
||||
}
|
||||
|
||||
// 处理角色状态切换
|
||||
const handleStatusChange = async (record, checked) => {
|
||||
try {
|
||||
// 设置加载状态
|
||||
record.statusChanging = true
|
||||
|
||||
await rolePermissionService.toggleRoleStatus(record.id, { status: checked })
|
||||
|
||||
// 更新本地状态
|
||||
record.status = checked
|
||||
|
||||
message.success(`角色${checked ? '启用' : '禁用'}成功`)
|
||||
} catch (error) {
|
||||
console.error('切换角色状态失败:', error)
|
||||
message.error('切换角色状态失败')
|
||||
|
||||
// 恢复原状态
|
||||
record.status = !checked
|
||||
} finally {
|
||||
record.statusChanging = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
status: true
|
||||
})
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadRoles()
|
||||
loadMenuTree()
|
||||
loadAllPermissions()
|
||||
loadPermissionModules()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.role-permissions {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.permission-container {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.permission-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.permission-header h3 {
|
||||
margin: 0;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.function-permissions {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.function-permissions .ant-collapse {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.function-permissions .ant-collapse-item {
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.function-permissions .ant-collapse-header {
|
||||
background: #fafafa;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
358
admin-system/src/views/SearchMonitor.vue
Normal file
358
admin-system/src/views/SearchMonitor.vue
Normal file
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
<div class="search-monitor">
|
||||
<div class="page-header">
|
||||
<h2>搜索监听数据查询</h2>
|
||||
<p>查看前端和后端接收到的搜索相关信息数据</p>
|
||||
</div>
|
||||
|
||||
<div class="monitor-content">
|
||||
<!-- 搜索测试区域 -->
|
||||
<div class="test-section">
|
||||
<h3>搜索测试</h3>
|
||||
<div class="test-controls">
|
||||
<a-input
|
||||
v-model="testKeyword"
|
||||
placeholder="输入测试关键词"
|
||||
@input="handleTestInput"
|
||||
@focus="handleTestFocus"
|
||||
@blur="handleTestBlur"
|
||||
@change="handleTestChange"
|
||||
style="width: 300px; margin-right: 10px"
|
||||
/>
|
||||
<a-button type="primary" @click="handleTestSearch">测试搜索</a-button>
|
||||
<a-button @click="clearLogs">清空日志</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 实时日志显示 -->
|
||||
<div class="logs-section">
|
||||
<h3>实时监听日志</h3>
|
||||
<div class="logs-container">
|
||||
<div
|
||||
v-for="(log, index) in logs"
|
||||
:key="index"
|
||||
:class="['log-item', log.type]"
|
||||
>
|
||||
<div class="log-header">
|
||||
<span class="log-time">{{ log.timestamp }}</span>
|
||||
<span class="log-type">{{ log.typeLabel }}</span>
|
||||
<span class="log-module">{{ log.module }}</span>
|
||||
</div>
|
||||
<div class="log-content">
|
||||
<pre>{{ JSON.stringify(log.data, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-section">
|
||||
<h3>统计信息</h3>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic title="总请求数" :value="stats.totalRequests" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic title="成功请求" :value="stats.successRequests" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic title="失败请求" :value="stats.failedRequests" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic title="平均响应时间" :value="stats.avgResponseTime" suffix="ms" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { api, directApi } from '../utils/api'
|
||||
|
||||
// 测试关键词
|
||||
const testKeyword = ref('')
|
||||
|
||||
// 日志数据
|
||||
const logs = ref([])
|
||||
|
||||
// 统计信息
|
||||
const stats = reactive({
|
||||
totalRequests: 0,
|
||||
successRequests: 0,
|
||||
failedRequests: 0,
|
||||
avgResponseTime: 0
|
||||
})
|
||||
|
||||
// 测试输入处理
|
||||
const handleTestInput = (e) => {
|
||||
addLog('input', '前端输入监听', {
|
||||
event: 'input',
|
||||
value: e.target.value,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
const handleTestFocus = (e) => {
|
||||
addLog('focus', '前端焦点监听', {
|
||||
event: 'focus',
|
||||
value: e.target.value,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
const handleTestBlur = (e) => {
|
||||
addLog('blur', '前端失焦监听', {
|
||||
event: 'blur',
|
||||
value: e.target.value,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
const handleTestChange = (e) => {
|
||||
addLog('change', '前端值改变监听', {
|
||||
event: 'change',
|
||||
value: e.target.value,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
// 测试搜索
|
||||
const handleTestSearch = async () => {
|
||||
if (!testKeyword.value.trim()) {
|
||||
message.warning('请输入测试关键词')
|
||||
return
|
||||
}
|
||||
|
||||
addLog('search_start', '前端搜索开始', {
|
||||
keyword: testKeyword.value,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
const response = await api.get(`/farms/search?name=${encodeURIComponent(testKeyword.value)}`)
|
||||
const responseTime = Date.now() - startTime
|
||||
|
||||
const result = response
|
||||
|
||||
addLog('search_success', '前端搜索成功', {
|
||||
keyword: testKeyword.value,
|
||||
resultCount: result.data ? result.data.length : 0,
|
||||
responseTime: responseTime,
|
||||
backendData: result.data,
|
||||
meta: result.meta
|
||||
})
|
||||
|
||||
// 更新统计
|
||||
stats.totalRequests++
|
||||
stats.successRequests++
|
||||
stats.avgResponseTime = Math.round((stats.avgResponseTime * (stats.totalRequests - 1) + responseTime) / stats.totalRequests)
|
||||
|
||||
message.success(`搜索成功,找到 ${result.data ? result.data.length : 0} 条记录`)
|
||||
} catch (error) {
|
||||
addLog('search_error', '前端搜索失败', {
|
||||
keyword: testKeyword.value,
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
stats.totalRequests++
|
||||
stats.failedRequests++
|
||||
|
||||
message.error('搜索失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加日志
|
||||
const addLog = (type, typeLabel, data) => {
|
||||
const log = {
|
||||
type,
|
||||
typeLabel,
|
||||
module: 'search_monitor',
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
data
|
||||
}
|
||||
|
||||
logs.value.unshift(log)
|
||||
|
||||
// 限制日志数量
|
||||
if (logs.value.length > 100) {
|
||||
logs.value = logs.value.slice(0, 100)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空日志
|
||||
const clearLogs = () => {
|
||||
logs.value = []
|
||||
stats.totalRequests = 0
|
||||
stats.successRequests = 0
|
||||
stats.failedRequests = 0
|
||||
stats.avgResponseTime = 0
|
||||
message.info('日志已清空')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
addLog('system', '系统启动', {
|
||||
message: '搜索监听系统已启动',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-monitor {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.monitor-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.test-section h3 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.test-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logs-section {
|
||||
background: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logs-section h3 {
|
||||
margin: 0;
|
||||
padding: 15px 20px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #d9d9d9;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.log-item.input {
|
||||
border-left: 4px solid #1890ff;
|
||||
}
|
||||
|
||||
.log-item.focus {
|
||||
border-left: 4px solid #52c41a;
|
||||
}
|
||||
|
||||
.log-item.blur {
|
||||
border-left: 4px solid #faad14;
|
||||
}
|
||||
|
||||
.log-item.change {
|
||||
border-left: 4px solid #722ed1;
|
||||
}
|
||||
|
||||
.log-item.search_start {
|
||||
border-left: 4px solid #13c2c2;
|
||||
}
|
||||
|
||||
.log-item.search_success {
|
||||
border-left: 4px solid #52c41a;
|
||||
}
|
||||
|
||||
.log-item.search_error {
|
||||
border-left: 4px solid #ff4d4f;
|
||||
}
|
||||
|
||||
.log-item.system {
|
||||
border-left: 4px solid #8c8c8c;
|
||||
}
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #fafafa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #666;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.log-type {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.log-module {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
padding: 10px 12px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.log-content pre {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.stats-section h3 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
509
admin-system/src/views/SmartAnklet.vue
Normal file
509
admin-system/src/views/SmartAnklet.vue
Normal file
@@ -0,0 +1,509 @@
|
||||
<template>
|
||||
<div class="smart-anklet-container">
|
||||
<div class="page-header">
|
||||
<h2>
|
||||
<a-icon type="radar-chart" style="margin-right: 8px;" />
|
||||
智能脚环管理
|
||||
</h2>
|
||||
<p>管理和监控智能脚环设备,实时监测动物运动和健康数据</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="action-bar">
|
||||
<a-space>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
添加脚环
|
||||
</a-button>
|
||||
<a-button @click="refreshData">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button @click="exportData">
|
||||
<template #icon>
|
||||
<ExportOutlined />
|
||||
</template>
|
||||
导出数据
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<!-- <a-row :gutter="16" class="stats-row">
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="总数量"
|
||||
:value="stats.total"
|
||||
:value-style="{ color: '#1890ff' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="活跃设备"
|
||||
:value="stats.active"
|
||||
:value-style="{ color: '#52c41a' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="待机设备"
|
||||
:value="stats.standby"
|
||||
:value-style="{ color: '#faad14' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="故障设备"
|
||||
:value="stats.fault"
|
||||
:value-style="{ color: '#f5222d' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row> -->
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<a-card title="脚环设备列表" class="table-card">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="anklets"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'stepCount'">
|
||||
<a-statistic
|
||||
:value="record.stepCount"
|
||||
suffix="步"
|
||||
:value-style="{ fontSize: '14px' }"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'heartRate'">
|
||||
<span :style="{ color: getHeartRateColor(record.heartRate) }">
|
||||
{{ record.heartRate }} BPM
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'temperature'">
|
||||
<span :style="{ color: getTemperatureColor(record.temperature) }">
|
||||
{{ record.temperature }}°C
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="viewDetail(record)">
|
||||
详情
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="viewChart(record)">
|
||||
图表
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="editAnklet(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个脚环吗?"
|
||||
@confirm="deleteAnklet(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 添加/编辑模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑脚环' : '添加脚环'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="设备编号" name="deviceId">
|
||||
<a-input v-model:value="formData.deviceId" placeholder="请输入设备编号" />
|
||||
</a-form-item>
|
||||
<a-form-item label="动物ID" name="animalId">
|
||||
<a-select v-model:value="formData.animalId" placeholder="请选择关联动物">
|
||||
<a-select-option value="1">动物001</a-select-option>
|
||||
<a-select-option value="2">动物002</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="设备型号" name="model">
|
||||
<a-input v-model:value="formData.model" placeholder="请输入设备型号" />
|
||||
</a-form-item>
|
||||
<a-form-item label="监测频率" name="frequency">
|
||||
<a-select v-model:value="formData.frequency" placeholder="请选择监测频率">
|
||||
<a-select-option value="1">每分钟</a-select-option>
|
||||
<a-select-option value="5">每5分钟</a-select-option>
|
||||
<a-select-option value="15">每15分钟</a-select-option>
|
||||
<a-select-option value="60">每小时</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="安装日期" name="installDate">
|
||||
<a-date-picker v-model:value="formData.installDate" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" name="notes">
|
||||
<a-textarea v-model:value="formData.notes" placeholder="请输入备注信息" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, ReloadOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
// 响应式数据
|
||||
const anklets = ref([])
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 统计数据
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
active: 0,
|
||||
standby: 0,
|
||||
fault: 0
|
||||
})
|
||||
|
||||
// 表格配置
|
||||
const columns = [
|
||||
{
|
||||
title: '设备编号',
|
||||
dataIndex: 'deviceId',
|
||||
key: 'deviceId',
|
||||
},
|
||||
{
|
||||
title: '关联动物',
|
||||
dataIndex: 'animalName',
|
||||
key: 'animalName',
|
||||
},
|
||||
{
|
||||
title: '设备型号',
|
||||
dataIndex: 'model',
|
||||
key: 'model',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
},
|
||||
{
|
||||
title: '步数',
|
||||
dataIndex: 'stepCount',
|
||||
key: 'stepCount',
|
||||
},
|
||||
{
|
||||
title: '心率',
|
||||
dataIndex: 'heartRate',
|
||||
key: 'heartRate',
|
||||
},
|
||||
{
|
||||
title: '体温',
|
||||
dataIndex: 'temperature',
|
||||
key: 'temperature',
|
||||
},
|
||||
{
|
||||
title: '最后更新',
|
||||
dataIndex: 'lastUpdate',
|
||||
key: 'lastUpdate',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
}
|
||||
]
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
deviceId: '',
|
||||
animalId: '',
|
||||
model: '',
|
||||
frequency: '',
|
||||
installDate: null,
|
||||
notes: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
deviceId: [
|
||||
{ required: true, message: '请输入设备编号', trigger: 'blur' }
|
||||
],
|
||||
animalId: [
|
||||
{ required: true, message: '请选择关联动物', trigger: 'change' }
|
||||
],
|
||||
model: [
|
||||
{ required: true, message: '请输入设备型号', trigger: 'blur' }
|
||||
],
|
||||
frequency: [
|
||||
{ required: true, message: '请选择监测频率', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
'active': 'green',
|
||||
'standby': 'orange',
|
||||
'fault': 'red'
|
||||
}
|
||||
return colors[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
'active': '活跃',
|
||||
'standby': '待机',
|
||||
'fault': '故障'
|
||||
}
|
||||
return texts[status] || '未知'
|
||||
}
|
||||
|
||||
// 获取心率颜色
|
||||
const getHeartRateColor = (heartRate) => {
|
||||
if (heartRate >= 60 && heartRate <= 100) return '#52c41a'
|
||||
if (heartRate > 100 || heartRate < 60) return '#faad14'
|
||||
return '#f5222d'
|
||||
}
|
||||
|
||||
// 获取体温颜色
|
||||
const getTemperatureColor = (temperature) => {
|
||||
if (temperature >= 38.0 && temperature <= 39.5) return '#52c41a'
|
||||
if (temperature > 39.5 || temperature < 38.0) return '#faad14'
|
||||
return '#f5222d'
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 模拟数据
|
||||
const mockData = [
|
||||
{
|
||||
id: 1,
|
||||
deviceId: 'AN001',
|
||||
animalName: '牛001',
|
||||
model: 'SmartAnklet-V1',
|
||||
status: 'active',
|
||||
stepCount: 2456,
|
||||
heartRate: 75,
|
||||
temperature: 38.5,
|
||||
lastUpdate: '2025-01-18 10:30:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
deviceId: 'AN002',
|
||||
animalName: '牛002',
|
||||
model: 'SmartAnklet-V1',
|
||||
status: 'standby',
|
||||
stepCount: 1823,
|
||||
heartRate: 68,
|
||||
temperature: 38.2,
|
||||
lastUpdate: '2025-01-18 09:15:00'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
deviceId: 'AN003',
|
||||
animalName: '羊001',
|
||||
model: 'SmartAnklet-V2',
|
||||
status: 'active',
|
||||
stepCount: 3124,
|
||||
heartRate: 82,
|
||||
temperature: 39.1,
|
||||
lastUpdate: '2025-01-18 10:25:00'
|
||||
}
|
||||
]
|
||||
|
||||
anklets.value = mockData
|
||||
pagination.total = mockData.length
|
||||
|
||||
// 更新统计数据
|
||||
stats.total = mockData.length
|
||||
stats.active = mockData.filter(item => item.status === 'active').length
|
||||
stats.standby = mockData.filter(item => item.status === 'standby').length
|
||||
stats.fault = mockData.filter(item => item.status === 'fault').length
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取数据失败:', error)
|
||||
message.error('获取数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = () => {
|
||||
fetchData()
|
||||
message.success('数据已刷新')
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
const exportData = () => {
|
||||
message.info('导出功能开发中...')
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑脚环
|
||||
const editAnklet = (record) => {
|
||||
isEdit.value = true
|
||||
Object.assign(formData, {
|
||||
...record,
|
||||
installDate: null
|
||||
})
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const viewDetail = (record) => {
|
||||
message.info(`查看 ${record.deviceId} 的详细信息`)
|
||||
}
|
||||
|
||||
// 查看图表
|
||||
const viewChart = (record) => {
|
||||
message.info(`查看 ${record.deviceId} 的运动图表`)
|
||||
}
|
||||
|
||||
// 删除脚环
|
||||
const deleteAnklet = async (id) => {
|
||||
try {
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
modalVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
deviceId: '',
|
||||
animalId: '',
|
||||
model: '',
|
||||
frequency: '',
|
||||
installDate: null,
|
||||
notes: ''
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.smart-anklet-container {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1890ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
1776
admin-system/src/views/SmartCollar.vue
Normal file
1776
admin-system/src/views/SmartCollar.vue
Normal file
File diff suppressed because it is too large
Load Diff
1141
admin-system/src/views/SmartCollarAlert.vue
Normal file
1141
admin-system/src/views/SmartCollarAlert.vue
Normal file
File diff suppressed because it is too large
Load Diff
1252
admin-system/src/views/SmartEartag.vue
Normal file
1252
admin-system/src/views/SmartEartag.vue
Normal file
File diff suppressed because it is too large
Load Diff
935
admin-system/src/views/SmartEartagAlert.vue
Normal file
935
admin-system/src/views/SmartEartagAlert.vue
Normal file
@@ -0,0 +1,935 @@
|
||||
<template>
|
||||
<div class="smart-eartag-alert-container">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">智能耳标预警</h2>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon battery">
|
||||
<PoweroffOutlined />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.lowBattery }}</div>
|
||||
<div class="stat-label">低电量预警</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon offline">
|
||||
<DisconnectOutlined />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.offline }}</div>
|
||||
<div class="stat-label">离线预警</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon temperature">
|
||||
<FireOutlined />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.highTemperature }}</div>
|
||||
<div class="stat-label">温度预警</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon movement">
|
||||
<ThunderboltOutlined />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.abnormalMovement }}</div>
|
||||
<div class="stat-label">异常运动预警</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选栏 -->
|
||||
<div class="search-bar">
|
||||
<div class="search-group">
|
||||
<a-input
|
||||
:value="searchValue"
|
||||
@input="updateSearchValue"
|
||||
placeholder="请输入耳标编号"
|
||||
class="search-input"
|
||||
@pressEnter="handleSearch"
|
||||
/>
|
||||
<a-select
|
||||
v-model="alertTypeFilter"
|
||||
placeholder="预警类型"
|
||||
class="filter-select"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<a-select-option value="">全部预警</a-select-option>
|
||||
<a-select-option value="battery">低电量预警</a-select-option>
|
||||
<a-select-option value="offline">离线预警</a-select-option>
|
||||
<a-select-option value="temperature">温度预警</a-select-option>
|
||||
<a-select-option value="movement">异常运动预警</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" class="search-button" @click="handleSearch">
|
||||
<template #icon>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button class="search-button" @click="handleClearSearch" v-if="searchValue.trim() || alertTypeFilter">
|
||||
清除
|
||||
</a-button>
|
||||
</div>
|
||||
<a-button @click="exportData" class="export-button">
|
||||
<template #icon>
|
||||
<ExportOutlined />
|
||||
</template>
|
||||
导出数据
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 预警列表表格 -->
|
||||
<div class="table-container">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="alerts"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
class="alert-table"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 耳标编号 -->
|
||||
<template v-if="column.dataIndex === 'eartagNumber'">
|
||||
<span class="eartag-number">{{ record.eartagNumber }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 预警类型 -->
|
||||
<template v-else-if="column.dataIndex === 'alertType'">
|
||||
<a-tag
|
||||
:color="getAlertTypeColor(record.alertType)"
|
||||
class="alert-type-tag"
|
||||
>
|
||||
{{ getAlertTypeText(record.alertType) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 预警级别 -->
|
||||
<template v-else-if="column.dataIndex === 'alertLevel'">
|
||||
<a-tag
|
||||
:color="getAlertLevelColor(record.alertLevel)"
|
||||
class="alert-level-tag"
|
||||
>
|
||||
{{ getAlertLevelText(record.alertLevel) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 预警时间 -->
|
||||
<template v-else-if="column.dataIndex === 'alertTime'">
|
||||
<div class="alert-time-cell">
|
||||
<span class="alert-time-value">{{ record.alertTime }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<div class="action-cell">
|
||||
<a-button type="link" class="action-link" @click="viewDetails(record)">
|
||||
查看详情
|
||||
</a-button>
|
||||
<a-button type="link" class="action-link" @click="handleAlert(record)">
|
||||
处理预警
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 预警详情模态框 -->
|
||||
<a-modal
|
||||
:open="detailVisible"
|
||||
title="预警详情"
|
||||
:footer="null"
|
||||
width="800px"
|
||||
@cancel="handleDetailCancel"
|
||||
>
|
||||
<div class="alert-detail-modal">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="label">耳标编号:</span>
|
||||
<span class="value">{{ currentAlert?.eartagNumber }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">预警类型:</span>
|
||||
<span class="value">
|
||||
<a-tag :color="getAlertTypeColor(currentAlert?.alertType)">
|
||||
{{ getAlertTypeText(currentAlert?.alertType) }}
|
||||
</a-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">预警级别:</span>
|
||||
<span class="value">
|
||||
<a-tag :color="getAlertLevelColor(currentAlert?.alertLevel)">
|
||||
{{ getAlertLevelText(currentAlert?.alertLevel) }}
|
||||
</a-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">预警时间:</span>
|
||||
<span class="value">{{ currentAlert?.alertTime }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">设备电量:</span>
|
||||
<span class="value">{{ currentAlert?.battery }}%</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">设备温度:</span>
|
||||
<span class="value">{{ currentAlert?.temperature }}°C</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">运动量:</span>
|
||||
<span class="value">{{ currentAlert?.movement }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">预警描述:</span>
|
||||
<span class="value">{{ currentAlert?.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="modal-footer">
|
||||
<a-button @click="handleDetailCancel">关闭</a-button>
|
||||
<a-button type="primary" @click="handleAlert(currentAlert)">处理预警</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
PoweroffOutlined,
|
||||
DisconnectOutlined,
|
||||
FireOutlined,
|
||||
ThunderboltOutlined,
|
||||
ExportOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
|
||||
// 响应式数据
|
||||
const alerts = ref([])
|
||||
const loading = ref(false)
|
||||
const searchValue = ref('')
|
||||
const alertTypeFilter = ref('')
|
||||
const detailVisible = ref(false)
|
||||
const currentAlert = ref(null)
|
||||
|
||||
// 统计数据
|
||||
const stats = reactive({
|
||||
lowBattery: 0,
|
||||
offline: 0,
|
||||
highTemperature: 0,
|
||||
abnormalMovement: 0
|
||||
})
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: '耳标编号',
|
||||
dataIndex: 'eartagNumber',
|
||||
key: 'eartagNumber',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '预警类型',
|
||||
dataIndex: 'alertType',
|
||||
key: 'alertType',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '预警级别',
|
||||
dataIndex: 'alertLevel',
|
||||
key: 'alertLevel',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '预警时间',
|
||||
dataIndex: 'alertTime',
|
||||
key: 'alertTime',
|
||||
width: 180,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '设备电量',
|
||||
dataIndex: 'battery',
|
||||
key: 'battery',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '设备温度',
|
||||
dataIndex: 'temperature',
|
||||
key: 'temperature',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '当日步数',
|
||||
dataIndex: 'dailySteps',
|
||||
key: 'dailySteps',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
align: 'center',
|
||||
}
|
||||
]
|
||||
|
||||
// 获取预警类型文本
|
||||
const getAlertTypeText = (type) => {
|
||||
const typeMap = {
|
||||
'battery': '低电量预警',
|
||||
'offline': '离线预警',
|
||||
'temperature': '温度预警',
|
||||
'movement': '异常运动预警'
|
||||
}
|
||||
return typeMap[type] || '未知预警'
|
||||
}
|
||||
|
||||
// 获取预警类型颜色
|
||||
const getAlertTypeColor = (type) => {
|
||||
const colorMap = {
|
||||
'battery': 'orange',
|
||||
'offline': 'red',
|
||||
'temperature': 'red',
|
||||
'movement': 'purple'
|
||||
}
|
||||
return colorMap[type] || 'default'
|
||||
}
|
||||
|
||||
// 获取预警级别文本
|
||||
const getAlertLevelText = (level) => {
|
||||
const levelMap = {
|
||||
'high': '高级',
|
||||
'medium': '中级',
|
||||
'low': '低级'
|
||||
}
|
||||
return levelMap[level] || '未知'
|
||||
}
|
||||
|
||||
// 获取预警级别颜色
|
||||
const getAlertLevelColor = (level) => {
|
||||
const colorMap = {
|
||||
'high': 'red',
|
||||
'medium': 'orange',
|
||||
'low': 'green'
|
||||
}
|
||||
return colorMap[level] || 'default'
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async (showMessage = false, customAlertType = null) => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 构建查询参数
|
||||
const params = new URLSearchParams({
|
||||
page: pagination.current.toString(),
|
||||
limit: pagination.pageSize.toString(),
|
||||
_t: Date.now().toString(),
|
||||
refresh: 'true'
|
||||
})
|
||||
|
||||
// 添加搜索条件
|
||||
if (searchValue.value.trim()) {
|
||||
params.append('search', searchValue.value.trim())
|
||||
}
|
||||
|
||||
// 添加预警类型筛选 - 使用传入的参数或默认值
|
||||
const alertType = customAlertType !== null ? customAlertType : alertTypeFilter.value
|
||||
if (alertType && alertType.trim() !== '') {
|
||||
params.append('alertType', alertType)
|
||||
}
|
||||
|
||||
// 调用API获取预警数据
|
||||
const { smartAlertService } = await import('../utils/dataService')
|
||||
console.log('使用smartAlertService获取耳标预警数据')
|
||||
console.log('搜索参数:', {
|
||||
search: searchValue.value.trim(),
|
||||
alertType: alertTypeFilter.value,
|
||||
page: pagination.current,
|
||||
limit: pagination.pageSize
|
||||
})
|
||||
|
||||
const requestParams = {
|
||||
search: searchValue.value.trim(),
|
||||
alertType: alertType,
|
||||
page: pagination.current,
|
||||
limit: pagination.pageSize
|
||||
}
|
||||
console.log('发送API请求,参数:', requestParams)
|
||||
|
||||
const result = await smartAlertService.getEartagAlerts(requestParams)
|
||||
console.log('API响应结果:', result)
|
||||
console.log('API返回的数据类型:', typeof result.data)
|
||||
console.log('API返回的数据长度:', result.data ? result.data.length : 'undefined')
|
||||
console.log('API返回的数据内容:', result.data)
|
||||
console.log('响应类型:', typeof result)
|
||||
console.log('是否有success字段:', 'success' in result)
|
||||
console.log('success值:', result.success)
|
||||
|
||||
if (result.success) {
|
||||
// 更新预警列表数据
|
||||
alerts.value = result.data || []
|
||||
pagination.total = result.total || 0
|
||||
|
||||
// 更新统计数据
|
||||
if (result.stats) {
|
||||
stats.lowBattery = result.stats.lowBattery || 0
|
||||
stats.offline = result.stats.offline || 0
|
||||
stats.highTemperature = result.stats.highTemperature || 0
|
||||
stats.abnormalMovement = result.stats.abnormalMovement || 0
|
||||
}
|
||||
|
||||
console.log('更新后的预警列表:', alerts.value)
|
||||
console.log('总数:', pagination.total)
|
||||
|
||||
if (showMessage) {
|
||||
const searchText = searchValue.value.trim() ? `搜索"${searchValue.value.trim()}"` : '加载'
|
||||
message.success(`${searchText}完成,共找到 ${pagination.total} 条预警数据`)
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new Error(result.message || '获取数据失败')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取数据失败:', error)
|
||||
console.error('错误详情:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name
|
||||
})
|
||||
if (showMessage) {
|
||||
message.error('获取数据失败: ' + error.message)
|
||||
}
|
||||
|
||||
// 清空数据而不是显示模拟数据
|
||||
alerts.value = []
|
||||
pagination.total = 0
|
||||
stats.lowBattery = 0
|
||||
stats.offline = 0
|
||||
stats.highTemperature = 0
|
||||
stats.abnormalMovement = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生成模拟数据
|
||||
const generateMockData = () => {
|
||||
const mockAlerts = [
|
||||
{
|
||||
id: 1,
|
||||
eartagNumber: 'EARTAG001',
|
||||
alertType: 'battery',
|
||||
alertLevel: 'high',
|
||||
alertTime: '2025-01-18 10:30:00',
|
||||
battery: 15,
|
||||
temperature: 38.5,
|
||||
gpsSignal: '强',
|
||||
movementStatus: '正常',
|
||||
description: '设备电量低于20%,需要及时充电',
|
||||
longitude: 116.3974,
|
||||
latitude: 39.9093
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
eartagNumber: 'EARTAG002',
|
||||
alertType: 'offline',
|
||||
alertLevel: 'high',
|
||||
alertTime: '2025-01-18 09:15:00',
|
||||
battery: 0,
|
||||
temperature: 0,
|
||||
gpsSignal: '无',
|
||||
movementStatus: '静止',
|
||||
description: '设备已离线超过30分钟',
|
||||
longitude: 0,
|
||||
latitude: 0
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
eartagNumber: 'EARTAG003',
|
||||
alertType: 'temperature',
|
||||
alertLevel: 'medium',
|
||||
alertTime: '2025-01-18 08:45:00',
|
||||
battery: 85,
|
||||
temperature: 42.3,
|
||||
gpsSignal: '强',
|
||||
movementStatus: '正常',
|
||||
description: '设备温度异常,超过正常范围',
|
||||
longitude: 116.4074,
|
||||
latitude: 39.9193
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
eartagNumber: 'EARTAG004',
|
||||
alertType: 'movement',
|
||||
alertLevel: 'low',
|
||||
alertTime: '2025-01-18 07:20:00',
|
||||
battery: 92,
|
||||
temperature: 39.1,
|
||||
gpsSignal: '强',
|
||||
movementStatus: '异常',
|
||||
description: '运动量异常,可能发生异常行为',
|
||||
longitude: 116.4174,
|
||||
latitude: 39.9293
|
||||
}
|
||||
]
|
||||
|
||||
alerts.value = mockAlerts
|
||||
pagination.total = mockAlerts.length
|
||||
|
||||
// 更新统计数据
|
||||
stats.lowBattery = mockAlerts.filter(alert => alert.alertType === 'battery').length
|
||||
stats.offline = mockAlerts.filter(alert => alert.alertType === 'offline').length
|
||||
stats.highTemperature = mockAlerts.filter(alert => alert.alertType === 'temperature').length
|
||||
stats.abnormalMovement = mockAlerts.filter(alert => alert.alertType === 'movement').length
|
||||
}
|
||||
|
||||
// 更新搜索值
|
||||
const updateSearchValue = (e) => {
|
||||
searchValue.value = e.target.value
|
||||
console.log('搜索输入框值变化:', searchValue.value)
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
fetchData(true)
|
||||
}
|
||||
|
||||
// 筛选变化处理
|
||||
const handleFilterChange = (value) => {
|
||||
console.log('=== 智能耳标预警类型筛选变化 ===')
|
||||
console.log('传入的 value 参数:', value)
|
||||
console.log('传入的 value 参数类型:', typeof value)
|
||||
console.log('传入的 value 参数长度:', value !== undefined ? value.length : 'undefined')
|
||||
console.log('传入的 value 参数是否为空:', value === '')
|
||||
console.log('传入的 value 参数是否为undefined:', value === undefined)
|
||||
console.log('传入的 value 参数是否为null:', value === null)
|
||||
console.log('传入的 value 参数是否有效:', value && value.trim() !== '')
|
||||
|
||||
// 使用传入的 value 参数而不是 alertTypeFilter.value
|
||||
const alertType = value || ''
|
||||
console.log('使用的 alertType:', alertType)
|
||||
|
||||
pagination.current = 1
|
||||
console.log('准备调用 fetchData,参数:', {
|
||||
search: searchValue.value,
|
||||
alertType: alertType,
|
||||
page: pagination.current,
|
||||
limit: pagination.pageSize
|
||||
})
|
||||
|
||||
// 直接调用 fetchData 并传递 alertType 参数
|
||||
fetchData(true, alertType)
|
||||
}
|
||||
|
||||
// 清除搜索
|
||||
const handleClearSearch = () => {
|
||||
searchValue.value = ''
|
||||
alertTypeFilter.value = ''
|
||||
pagination.current = 1
|
||||
fetchData(true)
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const viewDetails = (record) => {
|
||||
currentAlert.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
// 处理预警
|
||||
const handleAlert = (record) => {
|
||||
message.success(`正在处理预警: ${record.eartagNumber}`)
|
||||
// 这里可以添加处理预警的逻辑
|
||||
}
|
||||
|
||||
// 取消详情
|
||||
const handleDetailCancel = () => {
|
||||
detailVisible.value = false
|
||||
currentAlert.value = null
|
||||
}
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
const exportData = async () => {
|
||||
try {
|
||||
console.log('=== 开始导出智能耳标预警数据 ===')
|
||||
|
||||
message.loading('正在获取所有预警数据...', 0)
|
||||
|
||||
// 使用smartAlertService获取所有预警数据,不受分页限制
|
||||
const { smartAlertService } = await import('../utils/dataService')
|
||||
|
||||
const requestParams = {
|
||||
search: searchValue.value.trim(),
|
||||
alertType: alertTypeFilter.value,
|
||||
page: 1,
|
||||
limit: 1000 // 获取大量数据
|
||||
}
|
||||
|
||||
console.log('导出请求参数:', requestParams)
|
||||
|
||||
const apiResult = await smartAlertService.getEartagAlerts(requestParams)
|
||||
console.log('预警API响应:', apiResult)
|
||||
|
||||
if (!apiResult.success || !apiResult.data) {
|
||||
message.destroy()
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
const allAlerts = apiResult.data || []
|
||||
console.log('获取到所有预警数据:', allAlerts.length, '条记录')
|
||||
console.log('原始数据示例:', allAlerts[0])
|
||||
|
||||
// 预警类型中文映射
|
||||
const alertTypeMap = {
|
||||
'battery': '低电量预警',
|
||||
'offline': '离线预警',
|
||||
'temperature': '温度异常预警',
|
||||
'movement': '运动异常预警',
|
||||
'location': '位置异常预警'
|
||||
}
|
||||
|
||||
// 预警级别中文映射
|
||||
const alertLevelMap = {
|
||||
'high': '高',
|
||||
'medium': '中',
|
||||
'low': '低',
|
||||
'critical': '紧急'
|
||||
}
|
||||
|
||||
// 转换数据格式以匹配导出工具类的列配置
|
||||
const exportData = allAlerts.map(item => {
|
||||
console.log('转换前预警数据项:', item)
|
||||
|
||||
// 格式化时间
|
||||
let alertTime = ''
|
||||
if (item.alertTime || item.alert_time || item.created_at) {
|
||||
const timeValue = item.alertTime || item.alert_time || item.created_at
|
||||
if (typeof timeValue === 'number') {
|
||||
// Unix时间戳转换
|
||||
alertTime = new Date(timeValue * 1000).toLocaleString('zh-CN')
|
||||
} else if (typeof timeValue === 'string') {
|
||||
// 字符串时间转换
|
||||
const date = new Date(timeValue)
|
||||
if (!isNaN(date.getTime())) {
|
||||
alertTime = date.toLocaleString('zh-CN')
|
||||
} else {
|
||||
alertTime = timeValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
device_name: item.deviceName || item.eartagNumber || item.device_name || item.deviceId || '',
|
||||
alert_type: alertTypeMap[item.alertType || item.alert_type] || item.alertType || item.alert_type || '',
|
||||
alert_level: alertLevelMap[item.alertLevel || item.alert_level] || item.alertLevel || item.alert_level || '',
|
||||
alert_content: item.alertContent || item.alert_content || item.message || item.description || '系统预警',
|
||||
alert_time: alertTime,
|
||||
status: item.status || (item.processed ? '已处理' : '未处理'),
|
||||
handler: item.handler || item.processor || item.handler_name || item.operator || ''
|
||||
}
|
||||
})
|
||||
|
||||
console.log('转换后预警数据示例:', exportData[0])
|
||||
console.log('转换后预警数据总数:', exportData.length)
|
||||
|
||||
message.destroy()
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportAlertData(exportData, 'eartag')
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchData(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.smart-eartag-alert-container {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 页面标题样式 */
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #52c41a;
|
||||
margin: 0;
|
||||
padding: 12px 20px;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 统计卡片样式 */
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.stat-icon.battery {
|
||||
background: linear-gradient(135deg, #ff9a56, #ff6b6b);
|
||||
}
|
||||
|
||||
.stat-icon.offline {
|
||||
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
|
||||
}
|
||||
|
||||
.stat-icon.temperature {
|
||||
background: linear-gradient(135deg, #ff6b6b, #ff4757);
|
||||
}
|
||||
|
||||
.stat-icon.movement {
|
||||
background: linear-gradient(135deg, #a55eea, #8b5cf6);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #262626;
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
/* 搜索栏样式 */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.search-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 200px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 150px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.export-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 表格容器样式 */
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.alert-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 表格单元格样式 */
|
||||
.eartag-number {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.alert-type-tag,
|
||||
.alert-level-tag {
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.alert-time-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.alert-time-value {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-link {
|
||||
color: #1890ff;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
line-height: 1.2;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.action-link:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
/* 预警详情模态框样式 */
|
||||
.alert-detail-modal {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-item .label {
|
||||
min-width: 100px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.detail-item .value {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
.alert-table :deep(.ant-table) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.alert-table :deep(.ant-table-thead > tr > th) {
|
||||
padding: 8px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.alert-table :deep(.ant-table-tbody > tr > td) {
|
||||
padding: 6px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.alert-table :deep(.ant-table-tbody > tr:hover > td) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
890
admin-system/src/views/SmartHost.vue
Normal file
890
admin-system/src/views/SmartHost.vue
Normal file
@@ -0,0 +1,890 @@
|
||||
<template>
|
||||
<div class="smart-host-container">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">主机定位总览</h2>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<a-button @click="exportData" class="export-button">
|
||||
<template #icon>
|
||||
<ExportOutlined />
|
||||
</template>
|
||||
导出数据
|
||||
</a-button>
|
||||
<div class="search-group">
|
||||
<a-input
|
||||
:value="searchValue"
|
||||
@input="updateSearchValue"
|
||||
placeholder="请输入主机编号"
|
||||
class="search-input"
|
||||
@pressEnter="handleSearch"
|
||||
/>
|
||||
<a-button type="primary" class="search-button" @click="handleSearch">
|
||||
<template #icon>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button class="search-button" @click="handleClearSearch" v-if="searchValue.trim()">
|
||||
清除
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<div class="table-container">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="hosts"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
class="host-table"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 设备编号 -->
|
||||
<template v-if="column.dataIndex === 'deviceNumber'">
|
||||
<span class="device-number">{{ record.deviceNumber }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 设备电量% -->
|
||||
<template v-else-if="column.dataIndex === 'battery'">
|
||||
<div class="battery-cell">
|
||||
<span class="battery-value">{{ record.battery }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 设备信号值 -->
|
||||
<template v-else-if="column.dataIndex === 'signalValue'">
|
||||
<div class="signal-cell">
|
||||
<span class="signal-value">{{ record.signalValue }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 设备温度/°C -->
|
||||
<template v-else-if="column.dataIndex === 'temperature'">
|
||||
<div class="temperature-cell">
|
||||
<span class="temperature-value">{{ record.temperature }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 更新时间 -->
|
||||
<template v-else-if="column.dataIndex === 'updateTime'">
|
||||
<div class="update-time-cell">
|
||||
<span class="update-time-value">{{ record.updateTime }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 联网状态 -->
|
||||
<template v-else-if="column.dataIndex === 'networkStatus'">
|
||||
<div class="network-status-cell">
|
||||
<a-tag
|
||||
:color="getNetworkStatusColor(record.networkStatus)"
|
||||
class="network-status-tag"
|
||||
>
|
||||
{{ record.networkStatus }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<div class="action-cell">
|
||||
<a-button type="link" class="action-link" @click="viewLocation(record)">
|
||||
查看定位
|
||||
</a-button>
|
||||
<a-button type="link" class="action-link" @click="viewCollectionInfo(record)">
|
||||
查看采集信息
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 定位信息模态框 -->
|
||||
<a-modal
|
||||
:open="locationVisible"
|
||||
title="查看定位"
|
||||
:footer="null"
|
||||
width="90%"
|
||||
:style="{ top: '20px' }"
|
||||
@cancel="handleLocationCancel"
|
||||
>
|
||||
<div class="location-modal">
|
||||
<!-- 地图容器 -->
|
||||
<div class="map-container">
|
||||
<div id="locationMap" class="baidu-map"></div>
|
||||
|
||||
<!-- 地图样式切换按钮 - 严格按照图片样式 -->
|
||||
<div class="map-style-controls">
|
||||
<div
|
||||
:class="['style-btn', { active: mapStyle === 'normal' }]"
|
||||
@click="switchMapStyle('normal')"
|
||||
>
|
||||
地图
|
||||
</div>
|
||||
<div
|
||||
:class="['style-btn', { active: mapStyle === 'hybrid' }]"
|
||||
@click="switchMapStyle('hybrid')"
|
||||
>
|
||||
混合
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 底部取消按钮 -->
|
||||
<div class="location-footer">
|
||||
<a-button class="cancel-btn" @click="handleLocationCancel">取消</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 采集信息模态框 -->
|
||||
<a-modal
|
||||
:open="collectionInfoVisible"
|
||||
title="查看采集信息"
|
||||
:footer="null"
|
||||
width="800px"
|
||||
@cancel="handleCollectionInfoCancel"
|
||||
>
|
||||
<div class="collection-info-modal">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">设备编号:</span>
|
||||
<span class="value">{{ currentHost?.deviceNumber }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">设备电量:</span>
|
||||
<span class="value">{{ currentHost?.battery }}%</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">信号强度:</span>
|
||||
<span class="value">{{ currentHost?.signalValue }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">设备温度:</span>
|
||||
<span class="value">{{ currentHost?.temperature }}°C</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">GPS状态:</span>
|
||||
<span class="value">{{ currentHost?.gpsStatus }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">联网状态:</span>
|
||||
<span class="value">{{ currentHost?.networkStatus }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">最后更新:</span>
|
||||
<span class="value">{{ currentHost?.updateTime }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">设备标题:</span>
|
||||
<span class="value">{{ currentHost?.title || '未设置' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="modal-footer">
|
||||
<a-button @click="handleCollectionInfoCancel">关闭</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { SearchOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
import { loadBMapScript, createMap } from '@/utils/mapService'
|
||||
|
||||
// 响应式数据
|
||||
const hosts = ref([])
|
||||
const loading = ref(false)
|
||||
const searchValue = ref('')
|
||||
const autoRefresh = ref(true)
|
||||
const refreshInterval = ref(null)
|
||||
|
||||
// 模态框相关数据
|
||||
const locationVisible = ref(false)
|
||||
const collectionInfoVisible = ref(false)
|
||||
const mapStyle = ref('normal')
|
||||
const currentLocation = ref(null)
|
||||
const currentHost = ref(null)
|
||||
const baiduMap = ref(null)
|
||||
const locationMarker = ref(null)
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
// 表格列配置 - 严格按照图片样式
|
||||
const columns = [
|
||||
{
|
||||
title: '设备编号',
|
||||
dataIndex: 'deviceNumber',
|
||||
key: 'deviceNumber',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '设备电量/%',
|
||||
dataIndex: 'battery',
|
||||
key: 'battery',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '设备信号值',
|
||||
dataIndex: 'signalValue',
|
||||
key: 'signalValue',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '设备温度/°C',
|
||||
dataIndex: 'temperature',
|
||||
key: 'temperature',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updateTime',
|
||||
key: 'updateTime',
|
||||
width: 180,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '联网状态',
|
||||
dataIndex: 'networkStatus',
|
||||
key: 'networkStatus',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
align: 'center',
|
||||
}
|
||||
]
|
||||
|
||||
// 获取联网状态颜色
|
||||
const getNetworkStatusColor = (status) => {
|
||||
const colorMap = {
|
||||
'已联网': 'green',
|
||||
'未联网': 'red'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async (showMessage = false) => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 构建查询参数
|
||||
const params = new URLSearchParams({
|
||||
page: pagination.current.toString(),
|
||||
limit: pagination.pageSize.toString(),
|
||||
_t: Date.now().toString(), // 防止缓存
|
||||
refresh: 'true'
|
||||
})
|
||||
|
||||
// 如果有搜索条件,添加到参数中
|
||||
if (searchValue.value.trim()) {
|
||||
params.append('search', searchValue.value.trim())
|
||||
console.log('搜索条件:', searchValue.value.trim())
|
||||
}
|
||||
|
||||
// 调用API获取智能主机数据
|
||||
const apiUrl = `/api/smart-devices/hosts?${params}`
|
||||
console.log('API请求URL:', apiUrl)
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log('API响应结果:', result)
|
||||
|
||||
if (result.success) {
|
||||
// 更新设备列表数据(后端已计算联网状态)
|
||||
hosts.value = result.data || []
|
||||
pagination.total = result.total || 0
|
||||
|
||||
console.log('更新后的设备列表:', hosts.value)
|
||||
console.log('总数:', pagination.total)
|
||||
|
||||
if (showMessage) {
|
||||
const searchText = searchValue.value.trim() ? `搜索"${searchValue.value.trim()}"` : '加载'
|
||||
message.success(`${searchText}完成,共找到 ${pagination.total} 条主机数据`)
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new Error(result.message || '获取数据失败')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取数据失败:', error)
|
||||
if (showMessage) {
|
||||
message.error('获取数据失败: ' + error.message)
|
||||
}
|
||||
|
||||
// 如果API失败,显示空数据
|
||||
hosts.value = []
|
||||
pagination.total = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新搜索值
|
||||
const updateSearchValue = (e) => {
|
||||
const newValue = e.target.value
|
||||
console.log('输入框值变化:', newValue)
|
||||
console.log('更新前searchValue:', searchValue.value)
|
||||
searchValue.value = newValue
|
||||
console.log('更新后searchValue:', searchValue.value)
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
console.log('执行搜索,当前搜索值:', searchValue.value)
|
||||
pagination.current = 1
|
||||
fetchData(true)
|
||||
}
|
||||
|
||||
// 清除搜索
|
||||
const handleClearSearch = () => {
|
||||
searchValue.value = ''
|
||||
pagination.current = 1
|
||||
fetchData(true)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 自动刷新功能
|
||||
const startAutoRefresh = () => {
|
||||
if (refreshInterval.value) {
|
||||
clearInterval(refreshInterval.value)
|
||||
}
|
||||
|
||||
if (autoRefresh.value) {
|
||||
refreshInterval.value = setInterval(() => {
|
||||
fetchData(false) // 自动刷新时不显示成功消息
|
||||
}, 30000) // 每30秒自动刷新一次
|
||||
console.log('自动刷新已启动,每30秒更新一次数据')
|
||||
}
|
||||
}
|
||||
|
||||
// 停止自动刷新
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshInterval.value) {
|
||||
clearInterval(refreshInterval.value)
|
||||
refreshInterval.value = null
|
||||
console.log('自动刷新已停止')
|
||||
}
|
||||
}
|
||||
|
||||
// 查看定位
|
||||
const viewLocation = (record) => {
|
||||
if (record.longitude && record.latitude && record.longitude !== '0' && record.latitude !== '90') {
|
||||
currentLocation.value = {
|
||||
longitude: parseFloat(record.longitude),
|
||||
latitude: parseFloat(record.latitude),
|
||||
deviceNumber: record.deviceNumber,
|
||||
updateTime: record.updateTime
|
||||
}
|
||||
locationVisible.value = true
|
||||
|
||||
// 延迟初始化地图,确保DOM已渲染
|
||||
setTimeout(() => {
|
||||
initBaiduMap()
|
||||
}, 100)
|
||||
} else {
|
||||
message.warning('该设备暂无有效定位信息')
|
||||
}
|
||||
}
|
||||
|
||||
// 查看采集信息
|
||||
const viewCollectionInfo = (record) => {
|
||||
currentHost.value = record
|
||||
collectionInfoVisible.value = true
|
||||
}
|
||||
|
||||
// 取消定位信息
|
||||
const handleLocationCancel = () => {
|
||||
locationVisible.value = false
|
||||
currentLocation.value = null
|
||||
if (baiduMap.value) {
|
||||
baiduMap.value = null
|
||||
}
|
||||
if (locationMarker.value) {
|
||||
locationMarker.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 取消采集信息
|
||||
const handleCollectionInfoCancel = () => {
|
||||
collectionInfoVisible.value = false
|
||||
currentHost.value = null
|
||||
}
|
||||
|
||||
// 初始化百度地图
|
||||
const initBaiduMap = async () => {
|
||||
if (!currentLocation.value) return
|
||||
|
||||
try {
|
||||
// 确保百度地图API已加载
|
||||
await loadBMapScript()
|
||||
|
||||
// 获取地图容器
|
||||
const mapContainer = document.getElementById('locationMap')
|
||||
if (!mapContainer) {
|
||||
message.error('地图容器不存在')
|
||||
return
|
||||
}
|
||||
|
||||
// 创建地图实例
|
||||
const map = await createMap(mapContainer, {
|
||||
center: new window.BMap.Point(currentLocation.value.longitude, currentLocation.value.latitude),
|
||||
zoom: 15
|
||||
})
|
||||
|
||||
baiduMap.value = map
|
||||
|
||||
// 添加设备位置标记 - 使用红色大头针样式
|
||||
const point = new window.BMap.Point(currentLocation.value.longitude, currentLocation.value.latitude)
|
||||
|
||||
// 创建自定义标记图标
|
||||
const icon = new window.BMap.Icon(
|
||||
'data:image/svg+xml;base64,' + btoa(`
|
||||
<svg width="32" height="48" viewBox="0 0 32 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 0C7.163 0 0 7.163 0 16c0 16 16 32 16 32s16-16 16-32C32 7.163 24.837 0 16 0z" fill="#ff0000"/>
|
||||
<circle cx="16" cy="16" r="8" fill="#ffffff"/>
|
||||
</svg>
|
||||
`),
|
||||
new window.BMap.Size(32, 48),
|
||||
{
|
||||
anchor: new window.BMap.Size(16, 48)
|
||||
}
|
||||
)
|
||||
|
||||
const marker = new window.BMap.Marker(point, { icon: icon })
|
||||
map.addOverlay(marker)
|
||||
locationMarker.value = marker
|
||||
|
||||
// 创建时间戳标签,固定在红色大头针下方中间
|
||||
const label = new window.BMap.Label(currentLocation.value.updateTime, {
|
||||
position: point,
|
||||
offset: new window.BMap.Size(-50, 30) // 水平居中偏移-50像素,向下偏移30像素
|
||||
})
|
||||
|
||||
// 设置标签样式
|
||||
label.setStyle({
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'normal',
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.3)',
|
||||
zIndex: 999,
|
||||
textAlign: 'center',
|
||||
width: 'auto',
|
||||
minWidth: '120px'
|
||||
})
|
||||
|
||||
map.addOverlay(label)
|
||||
|
||||
// 创建信息窗口
|
||||
const infoWindow = new window.BMap.InfoWindow(`
|
||||
<div style="padding: 10px; font-size: 14px;">
|
||||
<div style="font-weight: bold; margin-bottom: 5px;">主机位置</div>
|
||||
<div>设备编号: ${currentLocation.value.deviceNumber}</div>
|
||||
<div>经度: ${currentLocation.value.longitude}</div>
|
||||
<div>纬度: ${currentLocation.value.latitude}</div>
|
||||
<div style="margin-top: 5px; color: #666;">
|
||||
最后定位时间: ${currentLocation.value.updateTime}
|
||||
</div>
|
||||
</div>
|
||||
`, {
|
||||
width: 200,
|
||||
height: 120
|
||||
})
|
||||
|
||||
// 点击标记显示信息窗口
|
||||
marker.addEventListener('click', () => {
|
||||
map.openInfoWindow(infoWindow, point)
|
||||
})
|
||||
|
||||
// 启用地图控件
|
||||
map.addControl(new window.BMap.NavigationControl())
|
||||
map.addControl(new window.BMap.ScaleControl())
|
||||
map.addControl(new window.BMap.OverviewMapControl())
|
||||
map.addControl(new window.BMap.MapTypeControl())
|
||||
|
||||
// 启用滚轮缩放
|
||||
map.enableScrollWheelZoom(true)
|
||||
|
||||
// 设置地图样式
|
||||
switchMapStyle(mapStyle.value)
|
||||
|
||||
console.log('定位地图初始化成功')
|
||||
|
||||
} catch (error) {
|
||||
console.error('初始化百度地图失败:', error)
|
||||
message.error('地图初始化失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换地图样式
|
||||
const switchMapStyle = (style) => {
|
||||
mapStyle.value = style
|
||||
|
||||
if (!baiduMap.value) return
|
||||
|
||||
try {
|
||||
if (style === 'normal') {
|
||||
// 普通地图
|
||||
baiduMap.value.setMapType(window.BMAP_NORMAL_MAP)
|
||||
} else if (style === 'hybrid') {
|
||||
// 混合地图(卫星图+路网)
|
||||
baiduMap.value.setMapType(window.BMAP_HYBRID_MAP)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换地图样式失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
const exportData = async () => {
|
||||
try {
|
||||
if (!hosts.value || hosts.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportDeviceData(hosts.value, 'host')
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据并启动自动刷新
|
||||
onMounted(() => {
|
||||
fetchData(true)
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.smart-host-container {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 页面标题样式 */
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #52c41a;
|
||||
margin: 0;
|
||||
padding: 12px 20px;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 搜索栏样式 */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.search-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 200px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.export-button {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 表格容器样式 */
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.host-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 表格单元格样式 */
|
||||
.device-number {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.battery-cell,
|
||||
.signal-cell,
|
||||
.temperature-cell,
|
||||
.update-time-cell,
|
||||
.network-status-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.battery-value,
|
||||
.signal-value,
|
||||
.temperature-value,
|
||||
.update-time-value {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.network-status-tag {
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-link {
|
||||
color: #1890ff;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
line-height: 1.2;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.action-link:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
/* 定位信息模态框样式 */
|
||||
.location-modal {
|
||||
height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.baidu-map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.map-style-controls {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.style-btn {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
border: 1px solid #d9d9d9;
|
||||
background: white;
|
||||
transition: all 0.3s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.style-btn:first-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.style-btn:hover {
|
||||
color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.style-btn.active {
|
||||
color: #fff;
|
||||
background-color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.location-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background-color: #f5f5f5;
|
||||
border-color: #d9d9d9;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background-color: #e6e6e6;
|
||||
border-color: #bfbfbf;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 采集信息模态框样式 */
|
||||
.collection-info-modal {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
min-width: 100px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
.host-table :deep(.ant-table) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.host-table :deep(.ant-table-thead > tr > th) {
|
||||
padding: 8px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.host-table :deep(.ant-table-tbody > tr > td) {
|
||||
padding: 6px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.host-table :deep(.ant-table-tbody > tr:hover > td) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 确保表格不会超出容器 */
|
||||
.host-table :deep(.ant-table-wrapper) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.host-table :deep(.ant-table-container) {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
</style>
|
||||
924
admin-system/src/views/System.vue
Normal file
924
admin-system/src/views/System.vue
Normal file
@@ -0,0 +1,924 @@
|
||||
<template>
|
||||
<div class="system-page">
|
||||
<a-page-header
|
||||
title="系统管理"
|
||||
sub-title="系统配置和权限管理"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button @click="fetchData">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新数据
|
||||
</a-button>
|
||||
<a-button type="primary" @click="initSystem" :loading="initLoading">
|
||||
<template #icon><SettingOutlined /></template>
|
||||
初始化系统
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<div class="system-content">
|
||||
<!-- 系统统计卡片 -->
|
||||
<a-row :gutter="16" style="margin-bottom: 24px;">
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="系统配置"
|
||||
:value="systemStats.configs?.total || 0"
|
||||
:value-style="{ color: '#1890ff' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<SettingOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="菜单权限"
|
||||
:value="systemStats.menus?.total || 0"
|
||||
:value-style="{ color: '#52c41a' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<MenuOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="系统用户"
|
||||
:value="systemStats.users?.total || 0"
|
||||
:value-style="{ color: '#fa8c16' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="用户角色"
|
||||
:value="systemStats.roles?.total || 0"
|
||||
:value-style="{ color: '#722ed1' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<TeamOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 功能标签页 -->
|
||||
<a-tabs v-model:activeKey="activeTab" type="card">
|
||||
<!-- 系统配置管理 -->
|
||||
<a-tab-pane key="configs" tab="系统配置">
|
||||
<div class="config-section">
|
||||
<div class="section-header">
|
||||
<a-space>
|
||||
<a-select
|
||||
v-model:value="selectedCategory"
|
||||
placeholder="选择配置分类"
|
||||
style="width: 200px;"
|
||||
@change="filterConfigs"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="">全部分类</a-select-option>
|
||||
<a-select-option
|
||||
v-for="category in categories"
|
||||
:key="category"
|
||||
:value="category"
|
||||
>
|
||||
{{ getCategoryName(category) }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" @click="showAddConfigModal = true">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加配置
|
||||
</a-button>
|
||||
<a-button @click="batchSaveConfigs" :loading="batchSaveLoading">
|
||||
<template #icon><SaveOutlined /></template>
|
||||
批量保存
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="configColumns"
|
||||
:data-source="filteredConfigs"
|
||||
:loading="configLoading"
|
||||
:pagination="{ pageSize: 15 }"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'config_value'">
|
||||
<div v-if="record.config_type === 'boolean'">
|
||||
<a-switch
|
||||
:checked="record.parsed_value"
|
||||
@change="updateConfigValue(record, $event)"
|
||||
:disabled="!record.is_editable"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="record.config_type === 'number'">
|
||||
<a-input-number
|
||||
:value="record.parsed_value"
|
||||
@change="updateConfigValue(record, $event)"
|
||||
:disabled="!record.is_editable"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<a-input
|
||||
:value="record.config_value"
|
||||
@change="updateConfigValue(record, $event.target.value)"
|
||||
:disabled="!record.is_editable"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'category'">
|
||||
<a-tag :color="getCategoryColor(record.category)">
|
||||
{{ getCategoryName(record.category) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'is_public'">
|
||||
<a-tag :color="record.is_public ? 'green' : 'orange'">
|
||||
{{ record.is_public ? '公开' : '私有' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'is_editable'">
|
||||
<a-tag :color="record.is_editable ? 'blue' : 'red'">
|
||||
{{ record.is_editable ? '可编辑' : '只读' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-space size="small">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="editConfig(record)"
|
||||
:disabled="!record.is_editable"
|
||||
>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
@click="resetConfig(record)"
|
||||
:disabled="!record.is_editable"
|
||||
>
|
||||
重置
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
danger
|
||||
size="small"
|
||||
@click="deleteConfig(record)"
|
||||
:disabled="!record.is_editable"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 菜单权限管理 -->
|
||||
<a-tab-pane key="menus" tab="菜单权限">
|
||||
<div class="menu-section">
|
||||
<div class="section-header">
|
||||
<a-space>
|
||||
<a-button @click="fetchMenus">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新菜单
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showAddMenuModal = true">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加菜单
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="menuColumns"
|
||||
:data-source="menus"
|
||||
:loading="menuLoading"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
:default-expand-all-rows="true"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'menu_name'">
|
||||
<a-space>
|
||||
<component
|
||||
:is="getIconComponent(record.icon)"
|
||||
v-if="record.icon"
|
||||
style="color: #1890ff;"
|
||||
/>
|
||||
{{ record.menu_name }}
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'required_roles'">
|
||||
<a-space wrap>
|
||||
<a-tag
|
||||
v-for="role in parseRoles(record.required_roles)"
|
||||
:key="role"
|
||||
:color="getRoleColor(role)"
|
||||
>
|
||||
{{ getRoleName(role) }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'is_visible'">
|
||||
<a-switch
|
||||
:checked="record.is_visible"
|
||||
@change="updateMenuVisible(record, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'is_enabled'">
|
||||
<a-switch
|
||||
:checked="record.is_enabled"
|
||||
@change="updateMenuEnabled(record, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-space size="small">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="editMenu(record)"
|
||||
>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
danger
|
||||
size="small"
|
||||
@click="deleteMenu(record)"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 添加配置模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showAddConfigModal"
|
||||
title="添加系统配置"
|
||||
:confirm-loading="configSubmitLoading"
|
||||
@ok="saveConfig"
|
||||
@cancel="resetConfigForm"
|
||||
>
|
||||
<a-form
|
||||
ref="configFormRef"
|
||||
:model="configForm"
|
||||
:rules="configRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="配置键名" name="config_key">
|
||||
<a-input v-model:value="configForm.config_key" placeholder="例如: system.name" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="配置值" name="config_value">
|
||||
<a-textarea v-model:value="configForm.config_value" :rows="3" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="配置分类" name="category">
|
||||
<a-select v-model:value="configForm.category">
|
||||
<a-select-option value="general">通用配置</a-select-option>
|
||||
<a-select-option value="ui">界面配置</a-select-option>
|
||||
<a-select-option value="security">安全配置</a-select-option>
|
||||
<a-select-option value="notification">通知配置</a-select-option>
|
||||
<a-select-option value="monitoring">监控配置</a-select-option>
|
||||
<a-select-option value="report">报表配置</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="配置描述" name="description">
|
||||
<a-input v-model:value="configForm.description" />
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item name="is_public">
|
||||
<a-checkbox v-model:checked="configForm.is_public">
|
||||
公开配置(前端可访问)
|
||||
</a-checkbox>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item name="is_editable">
|
||||
<a-checkbox v-model:checked="configForm.is_editable">
|
||||
允许编辑
|
||||
</a-checkbox>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="排序顺序" name="sort_order">
|
||||
<a-input-number
|
||||
v-model:value="configForm.sort_order"
|
||||
:min="0"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 编辑配置模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showEditConfigModal"
|
||||
title="编辑系统配置"
|
||||
:confirm-loading="configSubmitLoading"
|
||||
@ok="saveConfig"
|
||||
@cancel="resetConfigForm"
|
||||
>
|
||||
<a-form
|
||||
ref="configFormRef"
|
||||
:model="configForm"
|
||||
:rules="configRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="配置键名">
|
||||
<a-input :value="configForm.config_key" disabled />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="配置值" name="config_value">
|
||||
<a-textarea v-model:value="configForm.config_value" :rows="3" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="配置描述" name="description">
|
||||
<a-input v-model:value="configForm.description" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
SettingOutlined,
|
||||
PlusOutlined,
|
||||
SaveOutlined,
|
||||
MenuOutlined,
|
||||
UserOutlined,
|
||||
TeamOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { api } from '../utils/api'
|
||||
import * as Icons from '@ant-design/icons-vue'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const configLoading = ref(false)
|
||||
const menuLoading = ref(false)
|
||||
const initLoading = ref(false)
|
||||
const configSubmitLoading = ref(false)
|
||||
const batchSaveLoading = ref(false)
|
||||
|
||||
const activeTab = ref('configs')
|
||||
const systemStats = ref({})
|
||||
const configs = ref([])
|
||||
const menus = ref([])
|
||||
const categories = ref([])
|
||||
const selectedCategory = ref('')
|
||||
|
||||
// 模态框状态
|
||||
const showAddConfigModal = ref(false)
|
||||
const showEditConfigModal = ref(false)
|
||||
const showAddMenuModal = ref(false)
|
||||
|
||||
// 表单引用
|
||||
const configFormRef = ref()
|
||||
|
||||
// 配置表单数据
|
||||
const configForm = reactive({
|
||||
id: null,
|
||||
config_key: '',
|
||||
config_value: '',
|
||||
category: 'general',
|
||||
description: '',
|
||||
is_public: false,
|
||||
is_editable: true,
|
||||
sort_order: 0
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const configRules = {
|
||||
config_key: [{ required: true, message: '请输入配置键名' }],
|
||||
config_value: [{ required: true, message: '请输入配置值' }],
|
||||
category: [{ required: true, message: '请选择配置分类' }]
|
||||
}
|
||||
|
||||
// 过滤后的配置列表
|
||||
const filteredConfigs = computed(() => {
|
||||
if (!selectedCategory.value) {
|
||||
return configs.value
|
||||
}
|
||||
return configs.value.filter(config => config.category === selectedCategory.value)
|
||||
})
|
||||
|
||||
// 配置表格列定义
|
||||
const configColumns = [
|
||||
{
|
||||
title: '配置键名',
|
||||
dataIndex: 'config_key',
|
||||
key: 'config_key',
|
||||
width: 200,
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '配置值',
|
||||
dataIndex: 'config_value',
|
||||
key: 'config_value',
|
||||
width: 250
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '公开',
|
||||
dataIndex: 'is_public',
|
||||
key: 'is_public',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '可编辑',
|
||||
dataIndex: 'is_editable',
|
||||
key: 'is_editable',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 菜单表格列定义
|
||||
const menuColumns = [
|
||||
{
|
||||
title: '菜单名称',
|
||||
dataIndex: 'menu_name',
|
||||
key: 'menu_name',
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
title: '菜单路径',
|
||||
dataIndex: 'menu_path',
|
||||
key: 'menu_path',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '所需角色',
|
||||
dataIndex: 'required_roles',
|
||||
key: 'required_roles',
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort_order',
|
||||
key: 'sort_order',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '可见',
|
||||
dataIndex: 'is_visible',
|
||||
key: 'is_visible',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'is_enabled',
|
||||
key: 'is_enabled',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(async () => {
|
||||
await fetchData()
|
||||
})
|
||||
|
||||
// 获取所有数据
|
||||
async function fetchData() {
|
||||
await Promise.all([
|
||||
fetchSystemStats(),
|
||||
fetchConfigs(),
|
||||
fetchMenus(),
|
||||
fetchCategories()
|
||||
])
|
||||
}
|
||||
|
||||
// 获取系统统计信息
|
||||
async function fetchSystemStats() {
|
||||
try {
|
||||
const response = await api.get('/system/stats')
|
||||
if (response.success) {
|
||||
systemStats.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取系统统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取系统配置
|
||||
async function fetchConfigs() {
|
||||
configLoading.value = true
|
||||
try {
|
||||
const response = await api.get('/system/configs')
|
||||
if (response.success) {
|
||||
configs.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取系统配置失败:', error)
|
||||
message.error('获取系统配置失败')
|
||||
} finally {
|
||||
configLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取菜单权限
|
||||
async function fetchMenus() {
|
||||
menuLoading.value = true
|
||||
try {
|
||||
const response = await api.get('/system/menus')
|
||||
if (response.success) {
|
||||
menus.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取菜单权限失败:', error)
|
||||
message.error('获取菜单权限失败')
|
||||
} finally {
|
||||
menuLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取配置分类
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const response = await api.get('/system/configs/categories')
|
||||
if (response.success) {
|
||||
categories.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取配置分类失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化系统
|
||||
async function initSystem() {
|
||||
initLoading.value = true
|
||||
try {
|
||||
const response = await api.post('/system/init')
|
||||
if (response.success) {
|
||||
message.success('系统初始化成功')
|
||||
await fetchData()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('系统初始化失败:', error)
|
||||
message.error('系统初始化失败')
|
||||
} finally {
|
||||
initLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤配置
|
||||
function filterConfigs() {
|
||||
// 响应式计算属性会自动处理过滤
|
||||
}
|
||||
|
||||
// 更新配置值
|
||||
function updateConfigValue(config, value) {
|
||||
const index = configs.value.findIndex(c => c.id === config.id)
|
||||
if (index !== -1) {
|
||||
configs.value[index].config_value = String(value)
|
||||
configs.value[index].parsed_value = value
|
||||
configs.value[index]._changed = true // 标记为已修改
|
||||
}
|
||||
}
|
||||
|
||||
// 批量保存配置
|
||||
async function batchSaveConfigs() {
|
||||
const changedConfigs = configs.value.filter(config => config._changed)
|
||||
|
||||
if (changedConfigs.length === 0) {
|
||||
message.info('没有配置需要保存')
|
||||
return
|
||||
}
|
||||
|
||||
batchSaveLoading.value = true
|
||||
try {
|
||||
const configsToUpdate = changedConfigs.map(config => ({
|
||||
config_key: config.config_key,
|
||||
config_value: config.parsed_value
|
||||
}))
|
||||
|
||||
const response = await api.put('/system/configs/batch', {
|
||||
configs: configsToUpdate
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
message.success(`成功保存 ${changedConfigs.length} 个配置`)
|
||||
await fetchConfigs() // 重新获取数据
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量保存配置失败:', error)
|
||||
message.error('批量保存配置失败')
|
||||
} finally {
|
||||
batchSaveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑配置
|
||||
function editConfig(record) {
|
||||
Object.assign(configForm, {
|
||||
id: record.id,
|
||||
config_key: record.config_key,
|
||||
config_value: record.config_value,
|
||||
category: record.category,
|
||||
description: record.description,
|
||||
is_public: record.is_public,
|
||||
is_editable: record.is_editable,
|
||||
sort_order: record.sort_order
|
||||
})
|
||||
showEditConfigModal.value = true
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
async function saveConfig() {
|
||||
try {
|
||||
await configFormRef.value.validate()
|
||||
configSubmitLoading.value = true
|
||||
|
||||
if (configForm.id) {
|
||||
// 更新配置
|
||||
const response = await api.put(`/system/configs/${configForm.id}`, {
|
||||
config_value: configForm.config_value,
|
||||
description: configForm.description
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
message.success('配置更新成功')
|
||||
showEditConfigModal.value = false
|
||||
await fetchConfigs()
|
||||
}
|
||||
} else {
|
||||
// 创建配置
|
||||
const response = await api.post('/system/configs', configForm)
|
||||
|
||||
if (response.success) {
|
||||
message.success('配置创建成功')
|
||||
showAddConfigModal.value = false
|
||||
await fetchConfigs()
|
||||
}
|
||||
}
|
||||
|
||||
resetConfigForm()
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
message.error('保存配置失败')
|
||||
} finally {
|
||||
configSubmitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置配置表单
|
||||
function resetConfigForm() {
|
||||
Object.assign(configForm, {
|
||||
id: null,
|
||||
config_key: '',
|
||||
config_value: '',
|
||||
category: 'general',
|
||||
description: '',
|
||||
is_public: false,
|
||||
is_editable: true,
|
||||
sort_order: 0
|
||||
})
|
||||
configFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 删除配置
|
||||
async function deleteConfig(record) {
|
||||
try {
|
||||
const response = await api.delete(`/system/configs/${record.id}`)
|
||||
if (response.success) {
|
||||
message.success('配置删除成功')
|
||||
await fetchConfigs()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除配置失败:', error)
|
||||
message.error('删除配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 重置配置
|
||||
async function resetConfig(record) {
|
||||
try {
|
||||
const response = await api.post(`/system/configs/${record.id}/reset`)
|
||||
if (response.success) {
|
||||
message.success('配置重置成功')
|
||||
await fetchConfigs()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重置配置失败:', error)
|
||||
message.error('重置配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
function getCategoryName(category) {
|
||||
const categoryMap = {
|
||||
general: '通用配置',
|
||||
ui: '界面配置',
|
||||
security: '安全配置',
|
||||
notification: '通知配置',
|
||||
monitoring: '监控配置',
|
||||
report: '报表配置'
|
||||
}
|
||||
return categoryMap[category] || category
|
||||
}
|
||||
|
||||
function getCategoryColor(category) {
|
||||
const colorMap = {
|
||||
general: 'blue',
|
||||
ui: 'green',
|
||||
security: 'red',
|
||||
notification: 'orange',
|
||||
monitoring: 'purple',
|
||||
report: 'cyan'
|
||||
}
|
||||
return colorMap[category] || 'default'
|
||||
}
|
||||
|
||||
function getIconComponent(iconName) {
|
||||
if (!iconName) return null
|
||||
const iconKey = iconName.split('-').map(part =>
|
||||
part.charAt(0).toUpperCase() + part.slice(1)
|
||||
).join('')
|
||||
return Icons[iconKey] || Icons.SettingOutlined
|
||||
}
|
||||
|
||||
function parseRoles(rolesStr) {
|
||||
try {
|
||||
return rolesStr ? JSON.parse(rolesStr) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleColor(role) {
|
||||
const colorMap = {
|
||||
admin: 'red',
|
||||
manager: 'orange',
|
||||
user: 'blue'
|
||||
}
|
||||
return colorMap[role] || 'default'
|
||||
}
|
||||
|
||||
function getRoleName(role) {
|
||||
const nameMap = {
|
||||
admin: '管理员',
|
||||
manager: '管理者',
|
||||
user: '普通用户'
|
||||
}
|
||||
return nameMap[role] || role
|
||||
}
|
||||
|
||||
// 菜单相关方法(占位符)
|
||||
function editMenu(record) {
|
||||
message.info('菜单编辑功能开发中')
|
||||
}
|
||||
|
||||
function deleteMenu(record) {
|
||||
message.info('菜单删除功能开发中')
|
||||
}
|
||||
|
||||
function updateMenuVisible(record, visible) {
|
||||
message.info('菜单可见性更新功能开发中')
|
||||
}
|
||||
|
||||
function updateMenuEnabled(record, enabled) {
|
||||
message.info('菜单启用状态更新功能开发中')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.system-content {
|
||||
padding: 24px;
|
||||
background: #f0f2f5;
|
||||
min-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-page-header {
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background: #fafafa;
|
||||
color: #262626;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-tab) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-card .ant-tabs-content) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.config-section,
|
||||
.menu-section {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.system-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
:deep(.ant-col) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-statistic-title) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-statistic-content-value) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
211
admin-system/src/views/TableStats.vue
Normal file
211
admin-system/src/views/TableStats.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="table-stats-container">
|
||||
<div class="header">
|
||||
<h2>统计数据表格</h2>
|
||||
<p>基于MySQL数据库的真实统计数据</p>
|
||||
</div>
|
||||
|
||||
<div class="canvas-container">
|
||||
<canvas
|
||||
ref="tableCanvas"
|
||||
:width="canvasWidth"
|
||||
:height="canvasHeight"
|
||||
class="stats-canvas"
|
||||
></canvas>
|
||||
</div>
|
||||
|
||||
<div class="loading" v-if="loading">
|
||||
<a-spin size="large" />
|
||||
<p>正在加载数据...</p>
|
||||
</div>
|
||||
|
||||
<div class="error" v-if="error">
|
||||
<a-alert
|
||||
:message="error"
|
||||
type="error"
|
||||
show-icon
|
||||
@close="error = null"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { api } from '@/utils/api'
|
||||
|
||||
// 响应式数据
|
||||
const tableCanvas = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const tableData = ref([])
|
||||
const canvasWidth = ref(600)
|
||||
const canvasHeight = ref(300)
|
||||
|
||||
// 获取统计数据
|
||||
const fetchTableStats = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const response = await api.get('/stats/public/table')
|
||||
|
||||
if (response.data.success) {
|
||||
tableData.value = response.data.data
|
||||
await nextTick()
|
||||
drawTable()
|
||||
} else {
|
||||
throw new Error(response.data.message || '获取数据失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取统计数据失败:', err)
|
||||
error.value = err.message || '获取统计数据失败'
|
||||
message.error('获取统计数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制表格
|
||||
const drawTable = () => {
|
||||
if (!tableCanvas.value || !tableData.value.length) return
|
||||
|
||||
const canvas = tableCanvas.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 设置字体和样式
|
||||
ctx.font = '16px Arial, sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// 表格参数
|
||||
const startX = 50
|
||||
const startY = 50
|
||||
const rowHeight = 50
|
||||
const col1Width = 200
|
||||
const col2Width = 150
|
||||
const tableWidth = col1Width + col2Width
|
||||
const tableHeight = (tableData.value.length + 1) * rowHeight
|
||||
|
||||
// 绘制表格边框
|
||||
ctx.strokeStyle = '#d9d9d9'
|
||||
ctx.lineWidth = 1
|
||||
|
||||
// 绘制外边框
|
||||
ctx.strokeRect(startX, startY, tableWidth, tableHeight)
|
||||
|
||||
// 绘制列分隔线
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(startX + col1Width, startY)
|
||||
ctx.lineTo(startX + col1Width, startY + tableHeight)
|
||||
ctx.stroke()
|
||||
|
||||
// 绘制表头
|
||||
ctx.fillStyle = '#f5f5f5'
|
||||
ctx.fillRect(startX, startY, tableWidth, rowHeight)
|
||||
|
||||
// 绘制表头边框
|
||||
ctx.strokeRect(startX, startY, tableWidth, rowHeight)
|
||||
|
||||
// 绘制表头文字
|
||||
ctx.fillStyle = '#262626'
|
||||
ctx.font = 'bold 16px Arial, sans-serif'
|
||||
ctx.fillText('数据描述', startX + 10, startY + rowHeight / 2)
|
||||
ctx.fillText('统计数值', startX + col1Width + 10, startY + rowHeight / 2)
|
||||
|
||||
// 绘制数据行
|
||||
ctx.font = '16px Arial, sans-serif'
|
||||
tableData.value.forEach((item, index) => {
|
||||
const y = startY + (index + 1) * rowHeight
|
||||
|
||||
// 绘制行分隔线
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(startX, y)
|
||||
ctx.lineTo(startX + tableWidth, y)
|
||||
ctx.stroke()
|
||||
|
||||
// 绘制数据
|
||||
ctx.fillStyle = '#262626'
|
||||
ctx.fillText(item.description, startX + 10, y + rowHeight / 2)
|
||||
|
||||
// 数值用不同颜色显示
|
||||
ctx.fillStyle = '#1890ff'
|
||||
ctx.font = 'bold 16px Arial, sans-serif'
|
||||
ctx.fillText(item.value.toLocaleString(), startX + col1Width + 10, y + rowHeight / 2)
|
||||
ctx.font = '16px Arial, sans-serif'
|
||||
})
|
||||
|
||||
// 绘制标题
|
||||
ctx.fillStyle = '#262626'
|
||||
ctx.font = 'bold 20px Arial, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('农场统计数据表', startX + tableWidth / 2, 25)
|
||||
|
||||
// 绘制数据来源说明
|
||||
ctx.font = '12px Arial, sans-serif'
|
||||
ctx.fillStyle = '#8c8c8c'
|
||||
ctx.fillText('数据来源:MySQL数据库实时查询', startX + tableWidth / 2, startY + tableHeight + 30)
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchTableStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-stats-container {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #262626;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.stats-canvas {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.loading p {
|
||||
margin-top: 16px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
173
admin-system/src/views/TestAnalytics.vue
Normal file
173
admin-system/src/views/TestAnalytics.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="test-analytics">
|
||||
<h2>数据加载测试页面</h2>
|
||||
|
||||
<div class="debug-info">
|
||||
<h3>数据状态</h3>
|
||||
<p>养殖场数量: {{ dataStore.farms.length }}</p>
|
||||
<p>动物数量: {{ dataStore.animals.length }}</p>
|
||||
<p>设备数量: {{ dataStore.devices.length }}</p>
|
||||
<p>预警数量: {{ dataStore.alerts.length }}</p>
|
||||
</div>
|
||||
|
||||
<div class="raw-data">
|
||||
<h3>原始数据</h3>
|
||||
<div class="data-section">
|
||||
<h4>养殖场数据</h4>
|
||||
<pre>{{ JSON.stringify(dataStore.farms, null, 2) }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="data-section">
|
||||
<h4>动物数据</h4>
|
||||
<pre>{{ JSON.stringify(dataStore.animals, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-data">
|
||||
<h3>计算后的表格数据</h3>
|
||||
<pre>{{ JSON.stringify(farmTableData, null, 2) }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="actual-table">
|
||||
<h3>实际表格</h3>
|
||||
<a-table
|
||||
:columns="farmColumns"
|
||||
:data-source="farmTableData"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a-button @click="refreshData" type="primary">刷新数据</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useDataStore } from '../stores/data'
|
||||
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 养殖场表格数据
|
||||
const farmTableData = computed(() => {
|
||||
console.log('计算farmTableData:', {
|
||||
farms: dataStore.farms.length,
|
||||
animals: dataStore.animals.length,
|
||||
devices: dataStore.devices.length,
|
||||
alerts: dataStore.alerts.length
|
||||
})
|
||||
|
||||
return dataStore.farms.map(farm => {
|
||||
// 获取该养殖场的动物数量
|
||||
const animals = dataStore.animals.filter(animal => animal.farm_id === farm.id)
|
||||
const animalCount = animals.reduce((sum, animal) => sum + (animal.count || 0), 0)
|
||||
|
||||
// 获取该养殖场的设备数量
|
||||
const devices = dataStore.devices.filter(device => device.farm_id === farm.id)
|
||||
const deviceCount = devices.length
|
||||
|
||||
// 获取该养殖场的预警数量
|
||||
const alerts = dataStore.alerts.filter(alert => alert.farm_id === farm.id)
|
||||
const alertCount = alerts.length
|
||||
|
||||
console.log(`养殖场 ${farm.name} (ID: ${farm.id}):`, {
|
||||
animalCount,
|
||||
deviceCount,
|
||||
alertCount,
|
||||
animals: animals.map(a => ({ id: a.id, farm_id: a.farm_id, count: a.count })),
|
||||
devices: devices.map(d => ({ id: d.id, farm_id: d.farm_id })),
|
||||
alerts: alerts.map(a => ({ id: a.id, farm_id: a.farm_id }))
|
||||
})
|
||||
|
||||
return {
|
||||
key: farm.id,
|
||||
id: farm.id,
|
||||
name: farm.name,
|
||||
animalCount,
|
||||
deviceCount,
|
||||
alertCount
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 养殖场表格列定义
|
||||
const farmColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '养殖场名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: '动物数量',
|
||||
dataIndex: 'animalCount',
|
||||
key: 'animalCount',
|
||||
sorter: (a, b) => a.animalCount - b.animalCount
|
||||
},
|
||||
{
|
||||
title: '设备数量',
|
||||
dataIndex: 'deviceCount',
|
||||
key: 'deviceCount',
|
||||
sorter: (a, b) => a.deviceCount - b.deviceCount
|
||||
},
|
||||
{
|
||||
title: '预警数量',
|
||||
dataIndex: 'alertCount',
|
||||
key: 'alertCount',
|
||||
sorter: (a, b) => a.alertCount - b.alertCount
|
||||
}
|
||||
]
|
||||
|
||||
const refreshData = async () => {
|
||||
console.log('手动刷新数据...')
|
||||
await dataStore.fetchAllData()
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(async () => {
|
||||
console.log('TestAnalytics页面开始加载数据...')
|
||||
await dataStore.fetchAllData()
|
||||
console.log('TestAnalytics页面数据加载完成')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.test-analytics {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.debug-info {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.data-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.data-section h4 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f8f8f8;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
13
admin-system/src/views/TestImport.vue
Normal file
13
admin-system/src/views/TestImport.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>测试导入</h1>
|
||||
<p>API 对象: {{ api ? '已加载' : '未加载' }}</p>
|
||||
<p>电子围栏方法: {{ api?.electronicFence ? '已加载' : '未加载' }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { api } from '@/utils/api'
|
||||
|
||||
console.log('API 对象:', api)
|
||||
</script>
|
||||
755
admin-system/src/views/Users.vue
Normal file
755
admin-system/src/views/Users.vue
Normal file
@@ -0,0 +1,755 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>用户管理</h1>
|
||||
<a-space class="page-actions">
|
||||
<a-button @click="exportUsers" :loading="exportLoading">
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出数据
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加用户
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div class="search-area" style="margin-bottom: 16px; display: flex; gap: 8px; align-items: center;">
|
||||
<a-input
|
||||
v-model="searchUsername"
|
||||
placeholder="请输入用户名进行搜索"
|
||||
class="search-input"
|
||||
style="width: 300px;"
|
||||
@input="handleSearchInput"
|
||||
@focus="handleSearchFocus"
|
||||
@blur="handleSearchBlur"
|
||||
@change="handleSearchChange"
|
||||
@press-enter="searchUsers"
|
||||
/>
|
||||
<div class="search-buttons">
|
||||
<a-button type="primary" @click="searchUsers" :loading="searchLoading">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="resetSearch" :disabled="!isSearching">
|
||||
重置
|
||||
</a-button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="users"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'roles'">
|
||||
<a-tag :color="getRoleColor(record.roleName || record.role?.name)">
|
||||
{{ getRoleDisplayName(record.roleName || record.role?.name) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'created_at'">
|
||||
{{ formatDate(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="editUser(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个用户吗?"
|
||||
@confirm="deleteUser(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑用户模态框 -->
|
||||
<a-modal
|
||||
:open="modalVisible"
|
||||
@update:open="(val) => modalVisible = val"
|
||||
:title="isEdit ? '编辑用户' : '添加用户'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="用户名" name="username">
|
||||
<a-input v-model="formData.username" placeholder="请输入用户名" :disabled="isEdit" />
|
||||
</a-form-item>
|
||||
<a-form-item label="邮箱" name="email">
|
||||
<a-input v-model="formData.email" placeholder="请输入邮箱" />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码" name="password" v-if="!isEdit">
|
||||
<a-input-password v-model="formData.password" placeholder="请输入密码" />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色" name="roles">
|
||||
<a-select v-model="formData.roles" placeholder="请选择角色" :loading="rolesLoading">
|
||||
<a-select-option
|
||||
v-for="role in availableRoles"
|
||||
:key="role.id"
|
||||
:value="role.id"
|
||||
>
|
||||
{{ role.description || role.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
import { api, directApi } from '../utils/api'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
|
||||
// 响应式数据
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 搜索相关的响应式数据
|
||||
const searchUsername = ref('')
|
||||
const searchLoading = ref(false)
|
||||
const isSearching = ref(false)
|
||||
const usernameOptions = ref([])
|
||||
|
||||
// 搜索监听相关
|
||||
let searchTimeout = null
|
||||
let searchFocusTime = null
|
||||
|
||||
// 导出相关
|
||||
const exportLoading = ref(false)
|
||||
|
||||
// 角色相关
|
||||
const availableRoles = ref([])
|
||||
const rolesLoading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
roles: 2 // 默认为普通用户ID
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
|
||||
],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
roles: [{ required: true, message: '请选择角色', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username'
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email'
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'roles',
|
||||
key: 'roles',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 150
|
||||
}
|
||||
]
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取角色颜色
|
||||
const getRoleColor = (roleName) => {
|
||||
const colorMap = {
|
||||
'admin': 'red',
|
||||
'farm_manager': 'green',
|
||||
'inspector': 'orange',
|
||||
'user': 'blue'
|
||||
}
|
||||
return colorMap[roleName] || 'default'
|
||||
}
|
||||
|
||||
// 获取角色显示名称
|
||||
const getRoleDisplayName = (roleName) => {
|
||||
const role = availableRoles.value.find(r => r.name === roleName)
|
||||
if (role) {
|
||||
return role.description || role.name
|
||||
}
|
||||
|
||||
// 如果没有找到角色,使用默认映射
|
||||
const nameMap = {
|
||||
'admin': '系统管理员',
|
||||
'farm_manager': '养殖场管理员',
|
||||
'inspector': '监管人员',
|
||||
'user': '普通用户'
|
||||
}
|
||||
return nameMap[roleName] || roleName || '未知角色'
|
||||
}
|
||||
|
||||
// 获取角色列表
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
rolesLoading.value = true
|
||||
console.log('开始获取角色列表...')
|
||||
|
||||
const { api } = await import('../utils/api')
|
||||
const response = await api.get('/auth/roles')
|
||||
|
||||
console.log('角色API响应:', response)
|
||||
|
||||
// 处理API响应格式
|
||||
// api.get() 已经处理了响应格式,直接返回 result.data
|
||||
let roles = []
|
||||
if (Array.isArray(response)) {
|
||||
// 直接是角色数组
|
||||
roles = response
|
||||
} else if (response && Array.isArray(response.data)) {
|
||||
roles = response.data
|
||||
} else if (response && Array.isArray(response.roles)) {
|
||||
roles = response.roles
|
||||
} else {
|
||||
console.warn('API返回角色数据格式异常:', response)
|
||||
// 如果是对象,尝试提取角色数据
|
||||
if (response && typeof response === 'object') {
|
||||
// 可能的字段名
|
||||
const possibleFields = ['data', 'roles', 'items', 'list']
|
||||
for (const field of possibleFields) {
|
||||
if (response[field] && Array.isArray(response[field])) {
|
||||
roles = response[field]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('处理后的角色列表:', roles)
|
||||
|
||||
if (roles.length > 0) {
|
||||
availableRoles.value = roles
|
||||
console.log('✅ 角色列表加载成功,数量:', roles.length)
|
||||
} else {
|
||||
throw new Error('角色列表为空')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取角色列表失败:', error)
|
||||
message.error('获取角色列表失败,使用默认角色')
|
||||
|
||||
// 如果API失败,使用默认角色
|
||||
availableRoles.value = [
|
||||
{ id: 1, name: 'admin', description: '系统管理员' },
|
||||
{ id: 2, name: 'user', description: '普通用户' },
|
||||
{ id: 32, name: 'farm_manager', description: '养殖场管理员' },
|
||||
{ id: 33, name: 'inspector', description: '监管人员' }
|
||||
]
|
||||
console.log('使用默认角色列表')
|
||||
} finally {
|
||||
rolesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const { api } = await import('../utils/api')
|
||||
const response = await api.get('/users')
|
||||
console.log('用户API响应:', response)
|
||||
|
||||
// 检查响应格式
|
||||
if (response && response.success && Array.isArray(response.data)) {
|
||||
users.value = response.data
|
||||
} else if (Array.isArray(response)) {
|
||||
// 兼容旧格式
|
||||
users.value = response
|
||||
} else {
|
||||
users.value = []
|
||||
console.warn('API返回数据格式异常:', response)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error)
|
||||
message.error('获取用户列表失败')
|
||||
users.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导出用户数据
|
||||
const exportUsers = async () => {
|
||||
try {
|
||||
if (!users.value || users.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
exportLoading.value = true
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportUserData(users.value)
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = async () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
// 确保角色数据已加载
|
||||
if (availableRoles.value.length === 0) {
|
||||
await fetchRoles()
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑用户
|
||||
const editUser = async (record) => {
|
||||
isEdit.value = true
|
||||
Object.assign(formData, {
|
||||
...record,
|
||||
password: '' // 编辑时不显示密码
|
||||
})
|
||||
modalVisible.value = true
|
||||
// 确保角色数据已加载
|
||||
if (availableRoles.value.length === 0) {
|
||||
await fetchRoles()
|
||||
}
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
const deleteUser = async (id) => {
|
||||
try {
|
||||
const { api } = await import('../utils/api')
|
||||
await api.delete(`/users/${id}`)
|
||||
message.success('删除成功')
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('删除用户失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
const submitData = { ...formData }
|
||||
if (isEdit.value && !submitData.password) {
|
||||
delete submitData.password // 编辑时如果密码为空则不更新密码
|
||||
}
|
||||
|
||||
const { api } = await import('../utils/api')
|
||||
if (isEdit.value) {
|
||||
await api.put(`/users/${formData.id}`, submitData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await api.post('/users', submitData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
|
||||
modalVisible.value = false
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
roles: 2 // 默认为普通用户ID
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 搜索用户
|
||||
const searchUsers = async () => {
|
||||
const searchKeywordValue = searchUsername.value
|
||||
const searchStartTime = Date.now()
|
||||
|
||||
console.log('🔍 [用户搜索监听] 开始搜索:', {
|
||||
keyword: searchKeywordValue,
|
||||
keywordType: typeof searchKeywordValue,
|
||||
keywordLength: searchKeywordValue ? searchKeywordValue.length : 0,
|
||||
keywordTrimmed: searchKeywordValue ? searchKeywordValue.trim() : '',
|
||||
timestamp: new Date().toISOString(),
|
||||
searchStartTime: searchStartTime
|
||||
})
|
||||
|
||||
// 记录搜索开始日志
|
||||
await logUserAction('search_start', {
|
||||
keyword: searchKeywordValue,
|
||||
searchMethod: 'fetch_direct',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
if (!searchKeywordValue || searchKeywordValue.trim() === '') {
|
||||
console.log('🔄 [用户搜索监听] 搜索关键词为空,重新加载所有数据', {
|
||||
searchKeywordValue: searchKeywordValue,
|
||||
isFalsy: !searchKeywordValue,
|
||||
isEmpty: searchKeywordValue === '',
|
||||
isWhitespace: searchKeywordValue && searchKeywordValue.trim() === ''
|
||||
})
|
||||
await fetchUsers()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🔄 [用户搜索监听] 发送搜索请求到后端:', searchKeywordValue)
|
||||
searchLoading.value = true
|
||||
|
||||
const searchUrl = `/users/search?username=${encodeURIComponent(searchKeywordValue.trim())}`
|
||||
console.log('🌐 [用户搜索监听] 请求URL:', searchUrl)
|
||||
|
||||
// 获取认证token
|
||||
const userInfo = JSON.parse(localStorage.getItem('user') || '{}')
|
||||
const token = userInfo.token || localStorage.getItem('token')
|
||||
|
||||
console.log('🔐 [用户搜索监听] 认证信息:', {
|
||||
hasUserInfo: !!userInfo,
|
||||
hasToken: !!token,
|
||||
tokenLength: token ? token.length : 0,
|
||||
tokenPreview: token ? token.substring(0, 20) + '...' : 'none'
|
||||
})
|
||||
|
||||
if (!token) {
|
||||
console.error('❌ [用户搜索监听] 未找到认证token')
|
||||
message.error('未找到认证token,请重新登录')
|
||||
throw new Error('未找到认证token,请重新登录')
|
||||
}
|
||||
|
||||
// 检查token是否过期(简单检查)
|
||||
try {
|
||||
const tokenPayload = JSON.parse(atob(token.split('.')[1]))
|
||||
const currentTime = Math.floor(Date.now() / 1000)
|
||||
if (tokenPayload.exp && tokenPayload.exp < currentTime) {
|
||||
console.error('❌ [用户搜索监听] Token已过期')
|
||||
message.error('登录已过期,请重新登录')
|
||||
throw new Error('Token已过期,请重新登录')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ [用户搜索监听] Token解析失败:', e.message)
|
||||
}
|
||||
|
||||
// 使用API工具查询后端API
|
||||
const result = await api.get(searchUrl)
|
||||
|
||||
const responseTime = Date.now() - searchStartTime
|
||||
console.log('⏱️ [用户搜索监听] 后端响应时间:', responseTime + 'ms')
|
||||
console.log('📊 [用户搜索监听] 后端返回数据:', {
|
||||
success: result.success,
|
||||
dataCount: result.data ? result.data.length : 0,
|
||||
message: result.message
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
users.value = result.data
|
||||
isSearching.value = true
|
||||
|
||||
console.log(`✅ [用户搜索监听] 搜索成功,找到 ${users.value.length} 条记录`)
|
||||
message.success(`搜索完成,找到 ${users.value.length} 个用户`)
|
||||
|
||||
// 记录搜索成功日志
|
||||
await logUserAction('search_success', {
|
||||
keyword: searchKeywordValue,
|
||||
resultCount: users.value.length,
|
||||
searchMethod: 'fetch_direct',
|
||||
responseTime: responseTime,
|
||||
backendData: result.data
|
||||
})
|
||||
} else {
|
||||
throw new Error(result.message || '搜索失败')
|
||||
}
|
||||
} catch (error) {
|
||||
const errorTime = Date.now() - searchStartTime
|
||||
console.error('❌ [用户搜索监听] 搜索失败:', {
|
||||
error: error.message,
|
||||
keyword: searchKeywordValue,
|
||||
errorTime: errorTime
|
||||
})
|
||||
message.error('搜索失败: ' + (error.message || '网络错误'))
|
||||
|
||||
// 记录搜索失败日志
|
||||
await logUserAction('search_failed', {
|
||||
keyword: searchKeywordValue,
|
||||
error: error.message,
|
||||
errorTime: errorTime,
|
||||
searchMethod: 'fetch_direct'
|
||||
})
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
const totalTime = Date.now() - searchStartTime
|
||||
console.log('⏱️ [用户搜索监听] 搜索总耗时:', totalTime + 'ms')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听searchUsername变化
|
||||
watch(searchUsername, (newValue, oldValue) => {
|
||||
console.log('👀 [用户搜索监听] searchUsername变化:', {
|
||||
oldValue: oldValue,
|
||||
newValue: newValue,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: typeof newValue,
|
||||
length: newValue ? newValue.length : 0
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
// 输入框获得焦点
|
||||
const handleSearchFocus = async (e) => {
|
||||
searchFocusTime = Date.now()
|
||||
console.log('🎯 [用户搜索监听] 搜索框获得焦点:', {
|
||||
timestamp: new Date().toISOString(),
|
||||
currentValue: searchUsername.value,
|
||||
eventValue: e.target.value,
|
||||
focusTime: searchFocusTime,
|
||||
valuesMatch: searchUsername.value === e.target.value
|
||||
})
|
||||
|
||||
await logUserAction('search_focus', {
|
||||
field: 'searchUsername',
|
||||
currentValue: searchUsername.value,
|
||||
eventValue: e.target.value,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
// 输入框失去焦点
|
||||
const handleSearchBlur = async (e) => {
|
||||
const blurTime = Date.now()
|
||||
const focusDuration = searchFocusTime ? blurTime - searchFocusTime : 0
|
||||
|
||||
console.log('👋 [用户搜索监听] 搜索框失去焦点:', {
|
||||
timestamp: new Date().toISOString(),
|
||||
finalValue: searchUsername.value,
|
||||
focusDuration: focusDuration + 'ms',
|
||||
blurTime: blurTime
|
||||
})
|
||||
|
||||
await logUserAction('search_blur', {
|
||||
field: 'searchUsername',
|
||||
finalValue: searchUsername.value,
|
||||
focusDuration: focusDuration,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
searchFocusTime = null
|
||||
}
|
||||
|
||||
// 输入框值改变(与input不同,change在失焦时触发)
|
||||
const handleSearchChange = async (e) => {
|
||||
const value = e.target.value
|
||||
console.log('🔄 [用户搜索监听] 搜索框值改变:', {
|
||||
newValue: value,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'change'
|
||||
})
|
||||
|
||||
await logUserAction('search_change', {
|
||||
field: 'searchUsername',
|
||||
newValue: value,
|
||||
eventType: 'change',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
// 实时搜索输入处理
|
||||
const handleSearchInput = async (e) => {
|
||||
const value = e.target.value
|
||||
const oldValue = searchUsername.value
|
||||
|
||||
// 更新searchUsername的值
|
||||
searchUsername.value = value
|
||||
|
||||
console.log('🔍 [用户搜索监听] 搜索输入变化:', {
|
||||
oldValue: oldValue,
|
||||
newValue: value,
|
||||
timestamp: new Date().toISOString(),
|
||||
inputLength: value ? value.length : 0,
|
||||
searchUsernameValue: searchUsername.value
|
||||
})
|
||||
|
||||
// 记录输入变化日志
|
||||
await logUserAction('search_input_change', {
|
||||
field: 'searchUsername',
|
||||
oldValue: oldValue,
|
||||
newValue: value,
|
||||
inputLength: value ? value.length : 0,
|
||||
isEmpty: !value || value.trim() === ''
|
||||
})
|
||||
|
||||
// 清除之前的定时器
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
console.log('⏰ [用户搜索监听] 清除之前的搜索定时器')
|
||||
}
|
||||
|
||||
// 如果输入为空,立即重新加载所有数据
|
||||
if (!value || value.trim() === '') {
|
||||
console.log('🔄 [用户搜索监听] 输入为空,重新加载所有数据')
|
||||
await fetchUsers()
|
||||
return
|
||||
}
|
||||
|
||||
// 延迟500ms执行搜索,避免频繁请求
|
||||
searchTimeout = setTimeout(async () => {
|
||||
console.log('⏰ [用户搜索监听] 延迟搜索触发,关键词:', value, 'searchUsername.value:', searchUsername.value)
|
||||
await searchUsers()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 更新用户名选项(在数据加载后)
|
||||
const updateUsernameOptions = () => {
|
||||
usernameOptions.value = users.value.map(user => ({
|
||||
value: user.username,
|
||||
label: user.username
|
||||
}))
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchUsername.value = ''
|
||||
isSearching.value = false
|
||||
usernameOptions.value = []
|
||||
fetchUsers() // 重新加载全部用户
|
||||
}
|
||||
|
||||
// 调试搜索关键词
|
||||
const debugSearchKeyword = () => {
|
||||
console.log('🐛 [用户搜索调试] 搜索关键词调试信息:', {
|
||||
searchUsername: searchUsername.value,
|
||||
searchUsernameType: typeof searchUsername.value,
|
||||
searchUsernameLength: searchUsername.value ? searchUsername.value.length : 0,
|
||||
searchUsernameTrimmed: searchUsername.value ? searchUsername.value.trim() : '',
|
||||
searchUsernameIsEmpty: !searchUsername.value,
|
||||
searchUsernameIsWhitespace: searchUsername.value && searchUsername.value.trim() === '',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
message.info(`搜索关键词: "${searchUsername.value}" (长度: ${searchUsername.value ? searchUsername.value.length : 0})`)
|
||||
}
|
||||
|
||||
// 用户操作日志记录
|
||||
const logUserAction = async (action, data = {}) => {
|
||||
try {
|
||||
const userInfo = JSON.parse(localStorage.getItem('user') || '{}')
|
||||
|
||||
const logData = {
|
||||
module: 'user-management',
|
||||
action: action,
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: userInfo.id || null,
|
||||
username: userInfo.username || 'anonymous',
|
||||
...data
|
||||
}
|
||||
|
||||
console.log('📝 [用户操作日志]', action, {
|
||||
time: new Date().toLocaleString(),
|
||||
user: userInfo.username || 'anonymous',
|
||||
action: action,
|
||||
data: logData
|
||||
})
|
||||
|
||||
// 发送到后端记录
|
||||
const { api } = await import('../utils/api')
|
||||
await api.formLogs.add({
|
||||
action: action,
|
||||
module: 'user-management',
|
||||
userId: userInfo.id || null,
|
||||
formData: JSON.stringify(logData),
|
||||
oldValues: null,
|
||||
newValues: JSON.stringify(data),
|
||||
success: true,
|
||||
errorMessage: null
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('❌ 记录用户操作日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(async () => {
|
||||
// 并行获取用户列表和角色列表
|
||||
await Promise.all([
|
||||
fetchUsers().then(() => {
|
||||
updateUsernameOptions()
|
||||
}),
|
||||
fetchRoles()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user