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

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

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>