删除前端废弃组件和示例文件

This commit is contained in:
ylweng
2025-09-12 21:53:14 +08:00
parent 7e093946a8
commit bc3b3d7b52
98 changed files with 14136 additions and 14931 deletions

267
admin-system/src/App.vue Normal file
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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')

View 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
}
}

View 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

View 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
]

View 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
}
})

View File

@@ -0,0 +1,4 @@
// 导出所有状态管理存储
export { useUserStore } from './user'
export { useSettingsStore } from './settings'
export { useDataStore } from './data'

View 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
}
})

View 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
}
})

View 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;
}

View 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';
}
}

View 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',
},
},
};

View 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测试完成 ===')

View 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()

File diff suppressed because it is too large Load Diff

View 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;
}
};

View 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();

View 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
}
};
}

View 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;
}
};

View 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

View 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
};
});
};

View 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('已调整视图以适应标记点');
};

View 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')
}
}

View 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;

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>