删除前端废弃组件和示例文件
This commit is contained in:
710
admin-system/src/views/Alerts.vue
Normal file
710
admin-system/src/views/Alerts.vue
Normal file
@@ -0,0 +1,710 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>预警管理</h1>
|
||||
<a-space>
|
||||
<a-button @click="exportAlerts" :loading="exportLoading">
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出数据
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加预警
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 8px; align-items: center;">
|
||||
<a-auto-complete
|
||||
v-model:value="searchFarmName"
|
||||
:options="farmNameOptions"
|
||||
placeholder="请选择或输入养殖场名称进行搜索"
|
||||
style="width: 300px;"
|
||||
:filter-option="false"
|
||||
@search="handleSearchInput"
|
||||
@press-enter="searchAlertsByFarm"
|
||||
/>
|
||||
<a-button type="primary" @click="searchAlertsByFarm" :loading="searchLoading">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="resetSearch" :disabled="!isSearching">
|
||||
重置
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="alerts"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'type'">
|
||||
<a-tag color="blue">{{ getTypeText(record.type) }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'level'">
|
||||
<a-tag :color="getLevelColor(record.level)">
|
||||
{{ getLevelText(record.level) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'farm_name'">
|
||||
{{ getFarmName(record.farm_id) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'device_name'">
|
||||
{{ getDeviceName(record.device_id) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'resolved_at'">
|
||||
{{ formatDate(record.resolved_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'created_at'">
|
||||
{{ formatDateTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="editAlert(record)">编辑</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
@click="resolveAlert(record)"
|
||||
v-if="record.status !== 'resolved'"
|
||||
>
|
||||
解决
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个预警吗?"
|
||||
@confirm="deleteAlert(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑预警模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑预警' : '添加预警'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
width="600px"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="预警类型" name="type">
|
||||
<a-select v-model="formData.type" placeholder="请选择预警类型">
|
||||
<a-select-option value="temperature">温度异常</a-select-option>
|
||||
<a-select-option value="humidity">湿度异常</a-select-option>
|
||||
<a-select-option value="device_failure">设备故障</a-select-option>
|
||||
<a-select-option value="animal_health">动物健康</a-select-option>
|
||||
<a-select-option value="security">安全警报</a-select-option>
|
||||
<a-select-option value="maintenance">维护提醒</a-select-option>
|
||||
<a-select-option value="other">其他</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="预警级别" name="level">
|
||||
<a-select v-model="formData.level" placeholder="请选择预警级别">
|
||||
<a-select-option value="low">低</a-select-option>
|
||||
<a-select-option value="medium">中</a-select-option>
|
||||
<a-select-option value="high">高</a-select-option>
|
||||
<a-select-option value="critical">紧急</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="预警消息" name="message">
|
||||
<a-textarea
|
||||
v-model:value="formData.message"
|
||||
placeholder="请输入预警消息"
|
||||
:rows="3"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="预警状态" name="status">
|
||||
<a-select v-model="formData.status" placeholder="请选择预警状态">
|
||||
<a-select-option value="active">活跃</a-select-option>
|
||||
<a-select-option value="acknowledged">已确认</a-select-option>
|
||||
<a-select-option value="resolved">已解决</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="所属农场" name="farm_id">
|
||||
<a-select
|
||||
v-model="formData.farm_id"
|
||||
placeholder="请选择所属农场"
|
||||
:loading="farmsLoading"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="farm in farms"
|
||||
:key="farm.id"
|
||||
:value="farm.id"
|
||||
>
|
||||
{{ farm.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="关联设备" name="device_id">
|
||||
<a-select
|
||||
v-model="formData.device_id"
|
||||
placeholder="请选择关联设备(可选)"
|
||||
:loading="devicesLoading"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option
|
||||
v-for="device in devices"
|
||||
:key="device.id"
|
||||
:value="device.id"
|
||||
>
|
||||
{{ device.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="解决备注" name="resolution_notes" v-if="formData.status === 'resolved'">
|
||||
<a-textarea
|
||||
v-model:value="formData.resolution_notes"
|
||||
placeholder="请输入解决备注"
|
||||
:rows="2"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 解决预警模态框 -->
|
||||
<a-modal
|
||||
v-model:open="resolveModalVisible"
|
||||
title="解决预警"
|
||||
@ok="handleResolve"
|
||||
@cancel="resolveModalVisible = false"
|
||||
:confirm-loading="resolveLoading"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="解决备注">
|
||||
<a-textarea
|
||||
v-model:value="resolveNotes"
|
||||
placeholder="请输入解决备注"
|
||||
:rows="3"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
import { api } from '../utils/api'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// 响应式数据
|
||||
const alerts = ref([])
|
||||
const farms = ref([])
|
||||
const devices = ref([])
|
||||
const loading = ref(false)
|
||||
const farmsLoading = ref(false)
|
||||
const devicesLoading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const resolveModalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const resolveLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
const currentAlert = ref(null)
|
||||
const resolveNotes = ref('')
|
||||
|
||||
// 搜索相关的响应式数据
|
||||
const searchFarmName = ref('')
|
||||
const searchLoading = ref(false)
|
||||
const isSearching = ref(false)
|
||||
const farmNameOptions = ref([])
|
||||
|
||||
// 导出相关
|
||||
const exportLoading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
type: '',
|
||||
level: 'medium',
|
||||
message: '',
|
||||
status: 'active',
|
||||
farm_id: null,
|
||||
device_id: null,
|
||||
resolution_notes: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
type: [{ required: true, message: '请选择预警类型', trigger: 'change' }],
|
||||
level: [{ required: true, message: '请选择预警级别', trigger: 'change' }],
|
||||
message: [{ required: true, message: '请输入预警消息', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择预警状态', trigger: 'change' }],
|
||||
farm_id: [{ required: true, message: '请选择所属农场', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '预警类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'level',
|
||||
key: 'level',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '预警消息',
|
||||
dataIndex: 'message',
|
||||
key: 'message',
|
||||
ellipsis: true,
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '所属农场',
|
||||
dataIndex: 'farm_name',
|
||||
key: 'farm_name',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '关联设备',
|
||||
dataIndex: 'device_name',
|
||||
key: 'device_name',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '解决时间',
|
||||
dataIndex: 'resolved_at',
|
||||
key: 'resolved_at',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 180
|
||||
}
|
||||
]
|
||||
|
||||
// 获取预警列表
|
||||
const fetchAlerts = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.get('/alerts')
|
||||
if (response.success) {
|
||||
alerts.value = response.data
|
||||
} else {
|
||||
message.error('获取预警列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取预警列表失败:', error)
|
||||
if (error.response && error.response.status === 401) {
|
||||
message.error('登录已过期,请重新登录')
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login'
|
||||
}, 2000)
|
||||
} else {
|
||||
message.error('获取预警列表失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取农场列表
|
||||
const fetchFarms = async () => {
|
||||
try {
|
||||
farmsLoading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.get('/farms')
|
||||
if (response.success) {
|
||||
farms.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取农场列表失败:', error)
|
||||
} finally {
|
||||
farmsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取设备列表
|
||||
const fetchDevices = async () => {
|
||||
try {
|
||||
devicesLoading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.get('/devices')
|
||||
if (response.success) {
|
||||
devices.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取设备列表失败:', error)
|
||||
} finally {
|
||||
devicesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
fetchFarms()
|
||||
fetchDevices()
|
||||
}
|
||||
|
||||
// 编辑预警
|
||||
const editAlert = (record) => {
|
||||
isEdit.value = true
|
||||
// 逐个字段赋值,避免破坏响应式绑定
|
||||
formData.id = record.id
|
||||
formData.type = record.type
|
||||
formData.level = record.level
|
||||
formData.message = record.message
|
||||
formData.status = record.status
|
||||
formData.farm_id = record.farm_id
|
||||
formData.device_id = record.device_id
|
||||
formData.resolution_notes = record.resolution_notes || ''
|
||||
|
||||
modalVisible.value = true
|
||||
fetchFarms()
|
||||
fetchDevices()
|
||||
}
|
||||
|
||||
// 解决预警
|
||||
const resolveAlert = (record) => {
|
||||
currentAlert.value = record
|
||||
resolveNotes.value = ''
|
||||
resolveModalVisible.value = true
|
||||
}
|
||||
|
||||
// 处理解决预警
|
||||
const handleResolve = async () => {
|
||||
try {
|
||||
resolveLoading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.put(`/alerts/${currentAlert.value.id}`, {
|
||||
status: 'resolved',
|
||||
resolved_at: new Date().toISOString(),
|
||||
resolution_notes: resolveNotes.value
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
message.success('预警已解决')
|
||||
resolveModalVisible.value = false
|
||||
fetchAlerts()
|
||||
} else {
|
||||
message.error('解决预警失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解决预警失败:', error)
|
||||
message.error('解决预警失败')
|
||||
} finally {
|
||||
resolveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除预警
|
||||
const deleteAlert = async (id) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.delete(`/alerts/${id}`)
|
||||
if (response.success) {
|
||||
message.success('删除成功')
|
||||
fetchAlerts()
|
||||
} else {
|
||||
message.error('删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除预警失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
const config = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
// 准备提交数据
|
||||
const submitData = { ...formData }
|
||||
// 如果是新增操作,移除id字段
|
||||
if (!isEdit.value) {
|
||||
delete submitData.id
|
||||
}
|
||||
|
||||
let response
|
||||
if (isEdit.value) {
|
||||
response = await api.put(`/alerts/${formData.id}`, submitData)
|
||||
} else {
|
||||
response = await api.post('/alerts', submitData)
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
modalVisible.value = false
|
||||
fetchAlerts()
|
||||
} else {
|
||||
message.error(isEdit.value ? '更新失败' : '创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
type: '',
|
||||
level: 'medium',
|
||||
message: '',
|
||||
status: 'active',
|
||||
farm_id: null,
|
||||
device_id: null,
|
||||
resolution_notes: ''
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 获取级别颜色
|
||||
const getLevelColor = (level) => {
|
||||
const colors = {
|
||||
low: 'green',
|
||||
medium: 'orange',
|
||||
high: 'red',
|
||||
critical: 'purple'
|
||||
}
|
||||
return colors[level] || 'default'
|
||||
}
|
||||
|
||||
// 获取类型文本
|
||||
const getTypeText = (type) => {
|
||||
const texts = {
|
||||
temperature_alert: '温度异常',
|
||||
humidity_alert: '湿度异常',
|
||||
feed_alert: '饲料异常',
|
||||
health_alert: '健康异常',
|
||||
device_alert: '设备异常',
|
||||
temperature: '温度异常',
|
||||
humidity: '湿度异常',
|
||||
device_failure: '设备故障',
|
||||
animal_health: '动物健康',
|
||||
security: '安全警报',
|
||||
maintenance: '维护提醒',
|
||||
other: '其他'
|
||||
}
|
||||
return texts[type] || type
|
||||
}
|
||||
|
||||
// 获取级别文本
|
||||
const getLevelText = (level) => {
|
||||
const texts = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
critical: '紧急'
|
||||
}
|
||||
return texts[level] || level
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
active: 'red',
|
||||
acknowledged: 'orange',
|
||||
resolved: 'green'
|
||||
}
|
||||
return colors[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
active: '活跃',
|
||||
acknowledged: '已确认',
|
||||
resolved: '已解决'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
// 获取农场名称
|
||||
const getFarmName = (farmId) => {
|
||||
const farm = farms.value.find(f => f.id === farmId)
|
||||
return farm ? farm.name : `农场ID: ${farmId}`
|
||||
}
|
||||
|
||||
// 获取设备名称
|
||||
const getDeviceName = (deviceId) => {
|
||||
if (!deviceId) return '-'
|
||||
const device = devices.value.find(d => d.id === deviceId)
|
||||
return device ? device.name : `设备ID: ${deviceId}`
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-'
|
||||
return dayjs(date).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (date) => {
|
||||
if (!date) return '-'
|
||||
return dayjs(date).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
// 根据养殖场搜索预警
|
||||
const searchAlertsByFarm = async () => {
|
||||
if (!searchFarmName.value.trim()) {
|
||||
message.warning('请输入养殖场名称进行搜索')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
searchLoading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.get('/alerts/search', {
|
||||
params: { farmName: searchFarmName.value.trim() }
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
alerts.value = response.data || []
|
||||
isSearching.value = true
|
||||
message.success(response.message || `找到 ${alerts.value.length} 个匹配的预警`)
|
||||
} else {
|
||||
alerts.value = []
|
||||
message.info('未找到匹配的预警')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索预警失败:', error)
|
||||
message.error('搜索预警失败')
|
||||
alerts.value = []
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索输入(为自动完成提供选项)
|
||||
const handleSearchInput = (value) => {
|
||||
if (!value) {
|
||||
farmNameOptions.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 从现有农场列表中筛选匹配的农场名称
|
||||
const matchingFarms = farms.value.filter(farm =>
|
||||
farm.name.toLowerCase().includes(value.toLowerCase())
|
||||
)
|
||||
|
||||
farmNameOptions.value = matchingFarms.map(farm => ({
|
||||
value: farm.name,
|
||||
label: farm.name
|
||||
}))
|
||||
}
|
||||
|
||||
// 更新农场名称选项(在数据加载后)
|
||||
const updateFarmNameOptions = () => {
|
||||
farmNameOptions.value = farms.value.map(farm => ({
|
||||
value: farm.name,
|
||||
label: farm.name
|
||||
}))
|
||||
}
|
||||
|
||||
// 导出预警数据
|
||||
const exportAlerts = async () => {
|
||||
try {
|
||||
if (!alerts.value || alerts.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
exportLoading.value = true
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportAlertsData(alerts.value)
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchFarmName.value = ''
|
||||
isSearching.value = false
|
||||
farmNameOptions.value = []
|
||||
fetchAlerts() // 重新加载全部预警
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchAlerts()
|
||||
fetchFarms().then(() => {
|
||||
updateFarmNameOptions()
|
||||
})
|
||||
fetchDevices()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 可以添加自定义样式 */
|
||||
</style>
|
||||
512
admin-system/src/views/Analytics.vue
Normal file
512
admin-system/src/views/Analytics.vue
Normal file
@@ -0,0 +1,512 @@
|
||||
<template>
|
||||
<div class="analytics-page">
|
||||
<a-page-header
|
||||
title="数据分析"
|
||||
sub-title="宁夏智慧养殖监管平台数据分析"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button>导出报表</a-button>
|
||||
<a-button type="primary" @click="refreshData">
|
||||
<template #icon><reload-outlined /></template>
|
||||
刷新数据
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<div class="analytics-content">
|
||||
<!-- 趋势图表 -->
|
||||
<a-card title="月度数据趋势" :bordered="false" class="chart-card">
|
||||
<e-chart :options="trendChartOptions" height="350px" @chart-ready="handleChartReady" />
|
||||
</a-card>
|
||||
|
||||
<div class="analytics-row">
|
||||
<!-- 养殖场类型分布 -->
|
||||
<a-card title="养殖场类型分布" :bordered="false" class="chart-card">
|
||||
<e-chart :options="farmTypeChartOptions" height="300px" />
|
||||
</a-card>
|
||||
|
||||
<!-- 动物类型分布 -->
|
||||
<a-card title="动物类型分布" :bordered="false" class="chart-card">
|
||||
<animal-stats />
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<div class="analytics-row">
|
||||
<!-- 设备类型分布 -->
|
||||
<a-card title="设备类型分布" :bordered="false" class="chart-card">
|
||||
<device-stats />
|
||||
<simple-device-test />
|
||||
</a-card>
|
||||
|
||||
<!-- 预警类型分布 -->
|
||||
<a-card title="预警类型分布" :bordered="false" class="chart-card">
|
||||
<alert-stats />
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<a-card title="养殖场数据统计" :bordered="false" class="data-card">
|
||||
<a-table :dataSource="farmTableData" :columns="farmColumns" :pagination="{ pageSize: 5 }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'animalCount'">
|
||||
<a-progress :percent="getPercentage(record.animalCount, maxAnimalCount)" :stroke-color="getProgressColor(record.animalCount, maxAnimalCount)" />
|
||||
</template>
|
||||
<template v-if="column.key === 'deviceCount'">
|
||||
<a-progress :percent="getPercentage(record.deviceCount, maxDeviceCount)" :stroke-color="getProgressColor(record.deviceCount, maxDeviceCount)" />
|
||||
</template>
|
||||
<template v-if="column.key === 'alertCount'">
|
||||
<a-progress :percent="getPercentage(record.alertCount, maxAlertCount)" :stroke-color="{ from: '#108ee9', to: '#ff4d4f' }" />
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { useDataStore } from '../stores'
|
||||
import EChart from '../components/EChart.vue'
|
||||
import AnimalStats from '../components/AnimalStats.vue'
|
||||
import DeviceStats from '../components/DeviceStats.vue'
|
||||
import AlertStats from '../components/AlertStats.vue'
|
||||
import SimpleDeviceTest from '../components/SimpleDeviceTest.vue'
|
||||
// 移除模拟数据导入
|
||||
|
||||
// 使用数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 图表实例
|
||||
const charts = reactive({
|
||||
trendChart: null
|
||||
})
|
||||
|
||||
// 月度数据趋势
|
||||
const trendData = ref({
|
||||
xAxis: [],
|
||||
series: []
|
||||
})
|
||||
|
||||
// 处理图表就绪事件
|
||||
function handleChartReady(chart, type) {
|
||||
if (type === 'trend') {
|
||||
charts.trendChart = chart
|
||||
}
|
||||
}
|
||||
|
||||
// 获取月度数据趋势
|
||||
async function fetchMonthlyTrends() {
|
||||
try {
|
||||
const response = await fetch('/api/stats/public/monthly-trends')
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
trendData.value = result.data
|
||||
updateChartData()
|
||||
} else {
|
||||
console.error('获取月度数据趋势失败:', result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取月度数据趋势失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
async function refreshData() {
|
||||
await dataStore.fetchAllData()
|
||||
await fetchMonthlyTrends()
|
||||
updateChartData()
|
||||
}
|
||||
|
||||
// 更新图表数据
|
||||
function updateChartData() {
|
||||
// 更新趋势图表数据
|
||||
if (charts.trendChart) {
|
||||
charts.trendChart.setOption({
|
||||
xAxis: { data: trendData.value.xAxis },
|
||||
series: trendData.value.series
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 趋势图表选项
|
||||
const trendChartOptions = computed(() => {
|
||||
return {
|
||||
title: {
|
||||
text: '月度数据趋势',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
legend: {
|
||||
data: trendData.value.series.map(item => item.name),
|
||||
bottom: 0
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '10%',
|
||||
top: '15%',
|
||||
containLabel: true
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {}
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: trendData.value.xAxis
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: trendData.value.series.map(item => ({
|
||||
name: item.name,
|
||||
type: item.type || 'line',
|
||||
data: item.data,
|
||||
smooth: true,
|
||||
itemStyle: item.itemStyle,
|
||||
lineStyle: item.lineStyle,
|
||||
areaStyle: item.areaStyle
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// 养殖场类型分布图表选项
|
||||
const farmTypeChartOptions = computed(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '养殖场类型',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: [
|
||||
{ value: 5, name: '牛养殖场' },
|
||||
{ value: 7, name: '羊养殖场' },
|
||||
{ value: 3, name: '混合养殖场' },
|
||||
{ value: 2, name: '其他养殖场' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 动物类型分布图表选项
|
||||
const animalTypeChartOptions = computed(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '动物类型',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: []
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 设备类型分布图表选项
|
||||
const deviceTypeChartOptions = computed(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '设备类型',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: []
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 预警类型分布图表选项
|
||||
const alertTypeChartOptions = computed(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '预警类型',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: []
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 养殖场表格数据
|
||||
const farmTableData = computed(() => {
|
||||
console.log('计算farmTableData:', {
|
||||
farms: dataStore.farms.length,
|
||||
animals: dataStore.animals.length,
|
||||
devices: dataStore.devices.length,
|
||||
alerts: dataStore.alerts.length
|
||||
})
|
||||
|
||||
return dataStore.farms.map(farm => {
|
||||
// 获取该养殖场的动物数量
|
||||
const animals = dataStore.animals.filter(animal => animal.farm_id === farm.id)
|
||||
const animalCount = animals.reduce((sum, animal) => sum + (animal.count || 0), 0)
|
||||
|
||||
// 获取该养殖场的设备数量
|
||||
const devices = dataStore.devices.filter(device => device.farm_id === farm.id)
|
||||
const deviceCount = devices.length
|
||||
|
||||
// 获取该养殖场的预警数量
|
||||
const alerts = dataStore.alerts.filter(alert => alert.farm_id === farm.id)
|
||||
const alertCount = alerts.length
|
||||
|
||||
console.log(`养殖场 ${farm.name} (ID: ${farm.id}):`, {
|
||||
animalCount,
|
||||
deviceCount,
|
||||
alertCount
|
||||
})
|
||||
|
||||
return {
|
||||
key: farm.id,
|
||||
id: farm.id,
|
||||
name: farm.name,
|
||||
animalCount,
|
||||
deviceCount,
|
||||
alertCount
|
||||
}
|
||||
}).filter(farm => farm.animalCount > 0 || farm.deviceCount > 0 || farm.alertCount > 0)
|
||||
})
|
||||
|
||||
// 养殖场表格列定义
|
||||
const farmColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '养殖场名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: '动物数量',
|
||||
dataIndex: 'animalCount',
|
||||
key: 'animalCount',
|
||||
sorter: (a, b) => a.animalCount - b.animalCount
|
||||
},
|
||||
{
|
||||
title: '设备数量',
|
||||
dataIndex: 'deviceCount',
|
||||
key: 'deviceCount',
|
||||
sorter: (a, b) => a.deviceCount - b.deviceCount
|
||||
},
|
||||
{
|
||||
title: '预警数量',
|
||||
dataIndex: 'alertCount',
|
||||
key: 'alertCount',
|
||||
sorter: (a, b) => a.alertCount - b.alertCount
|
||||
}
|
||||
]
|
||||
|
||||
// 获取最大值
|
||||
const maxAnimalCount = computed(() => {
|
||||
const counts = farmTableData.value.map(farm => farm.animalCount)
|
||||
return Math.max(...counts, 1)
|
||||
})
|
||||
|
||||
const maxDeviceCount = computed(() => {
|
||||
const counts = farmTableData.value.map(farm => farm.deviceCount)
|
||||
return Math.max(...counts, 1)
|
||||
})
|
||||
|
||||
const maxAlertCount = computed(() => {
|
||||
const counts = farmTableData.value.map(farm => farm.alertCount)
|
||||
return Math.max(...counts, 1)
|
||||
})
|
||||
|
||||
// 计算百分比
|
||||
function getPercentage(value, max) {
|
||||
return Math.round((value / max) * 100)
|
||||
}
|
||||
|
||||
// 获取进度条颜色
|
||||
function getProgressColor(value, max) {
|
||||
const percentage = getPercentage(value, max)
|
||||
if (percentage < 30) return '#52c41a'
|
||||
if (percentage < 70) return '#1890ff'
|
||||
return '#ff4d4f'
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(async () => {
|
||||
console.log('Analytics页面开始加载数据...')
|
||||
|
||||
try {
|
||||
// 加载数据
|
||||
console.log('调用 dataStore.fetchAllData()...')
|
||||
await dataStore.fetchAllData()
|
||||
|
||||
console.log('数据加载完成:', {
|
||||
farms: dataStore.farms.length,
|
||||
animals: dataStore.animals.length,
|
||||
devices: dataStore.devices.length,
|
||||
alerts: dataStore.alerts.length
|
||||
})
|
||||
|
||||
// 获取月度数据趋势
|
||||
console.log('获取月度数据趋势...')
|
||||
await fetchMonthlyTrends()
|
||||
|
||||
console.log('Analytics页面数据加载完成')
|
||||
} catch (error) {
|
||||
console.error('Analytics页面数据加载失败:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.analytics-page {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.analytics-content {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.analytics-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.data-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.analytics-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1218
admin-system/src/views/Animals.vue
Normal file
1218
admin-system/src/views/Animals.vue
Normal file
File diff suppressed because it is too large
Load Diff
665
admin-system/src/views/ApiTester.vue
Normal file
665
admin-system/src/views/ApiTester.vue
Normal file
@@ -0,0 +1,665 @@
|
||||
<template>
|
||||
<div class="api-tester-page">
|
||||
<a-page-header
|
||||
title="API测试工具"
|
||||
sub-title="快速测试和调试API接口"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button @click="clearHistory">
|
||||
<template #icon><ClearOutlined /></template>
|
||||
清空历史
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openSwaggerDocs" target="_blank">
|
||||
<template #icon><ApiOutlined /></template>
|
||||
查看API文档
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<div class="api-tester-content">
|
||||
<a-row :gutter="24">
|
||||
<!-- 左侧:API请求面板 -->
|
||||
<a-col :span="12">
|
||||
<a-card title="API请求" :bordered="false">
|
||||
<!-- 快捷API选择 -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<a-select
|
||||
v-model:value="selectedApi"
|
||||
placeholder="选择常用API"
|
||||
style="width: 100%;"
|
||||
@change="loadApiTemplate"
|
||||
show-search
|
||||
option-filter-prop="children"
|
||||
>
|
||||
<a-select-opt-group label="认证相关">
|
||||
<a-select-option value="auth-login">登录</a-select-option>
|
||||
<a-select-option value="auth-logout">登出</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="农场管理">
|
||||
<a-select-option value="farms-list">获取农场列表</a-select-option>
|
||||
<a-select-option value="farms-create">创建农场</a-select-option>
|
||||
<a-select-option value="farms-search">搜索农场</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="设备管理">
|
||||
<a-select-option value="devices-list">获取设备列表</a-select-option>
|
||||
<a-select-option value="devices-search">搜索设备</a-select-option>
|
||||
<a-select-option value="devices-stats">设备统计</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="牛只管理">
|
||||
<a-select-option value="animals-list">获取动物列表</a-select-option>
|
||||
<a-select-option value="animals-search">搜索动物</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="预警管理">
|
||||
<a-select-option value="alerts-list">获取预警列表</a-select-option>
|
||||
<a-select-option value="alerts-search">搜索预警</a-select-option>
|
||||
<a-select-option value="alerts-resolve">解决预警</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="用户管理">
|
||||
<a-select-option value="users-list">获取用户列表</a-select-option>
|
||||
<a-select-option value="users-search">搜索用户</a-select-option>
|
||||
<a-select-option value="users-create">创建用户</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="产品管理">
|
||||
<a-select-option value="products-list">获取产品列表</a-select-option>
|
||||
<a-select-option value="products-search">搜索产品</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="订单管理">
|
||||
<a-select-option value="orders-list">获取订单列表</a-select-option>
|
||||
<a-select-option value="orders-search">搜索订单</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="报表管理">
|
||||
<a-select-option value="reports-farm">生成农场报表</a-select-option>
|
||||
<a-select-option value="reports-sales">生成销售报表</a-select-option>
|
||||
<a-select-option value="reports-list">获取报表列表</a-select-option>
|
||||
</a-select-opt-group>
|
||||
<a-select-opt-group label="系统管理">
|
||||
<a-select-option value="system-configs">获取系统配置</a-select-option>
|
||||
<a-select-option value="system-menus">获取菜单权限</a-select-option>
|
||||
<a-select-option value="system-stats">获取系统统计</a-select-option>
|
||||
</a-select-opt-group>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
<!-- HTTP方法和URL -->
|
||||
<a-row :gutter="8" style="margin-bottom: 16px;">
|
||||
<a-col :span="6">
|
||||
<a-select v-model:value="requestMethod" style="width: 100%;">
|
||||
<a-select-option value="GET">GET</a-select-option>
|
||||
<a-select-option value="POST">POST</a-select-option>
|
||||
<a-select-option value="PUT">PUT</a-select-option>
|
||||
<a-select-option value="DELETE">DELETE</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="18">
|
||||
<a-input
|
||||
v-model:value="requestUrl"
|
||||
placeholder="请输入API端点,例如: /api/farms"
|
||||
addonBefore="API"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 请求头 -->
|
||||
<div style="margin-bottom: 16px;">
|
||||
<a-typography-title :level="5">请求头</a-typography-title>
|
||||
<a-textarea
|
||||
v-model:value="requestHeaders"
|
||||
placeholder='{"Authorization": "Bearer your-token", "Content-Type": "application/json"}'
|
||||
:rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 请求体 -->
|
||||
<div style="margin-bottom: 16px;" v-if="['POST', 'PUT'].includes(requestMethod)">
|
||||
<a-typography-title :level="5">请求体 (JSON)</a-typography-title>
|
||||
<a-textarea
|
||||
v-model:value="requestBody"
|
||||
placeholder='{"key": "value"}'
|
||||
:rows="6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 查询参数 -->
|
||||
<div style="margin-bottom: 16px;" v-if="requestMethod === 'GET'">
|
||||
<a-typography-title :level="5">查询参数</a-typography-title>
|
||||
<a-textarea
|
||||
v-model:value="queryParams"
|
||||
placeholder='{"page": 1, "limit": 10}'
|
||||
:rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 发送请求按钮 -->
|
||||
<a-space style="width: 100%;">
|
||||
<a-button
|
||||
type="primary"
|
||||
@click="sendRequest"
|
||||
:loading="requesting"
|
||||
size="large"
|
||||
>
|
||||
<template #icon><SendOutlined /></template>
|
||||
发送请求
|
||||
</a-button>
|
||||
<a-button @click="saveToHistory" :disabled="!lastResponse">
|
||||
<template #icon><SaveOutlined /></template>
|
||||
保存到历史
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- 右侧:响应面板 -->
|
||||
<a-col :span="12">
|
||||
<a-card title="API响应" :bordered="false">
|
||||
<!-- 响应状态 -->
|
||||
<div v-if="lastResponse" style="margin-bottom: 16px;">
|
||||
<a-descriptions size="small" :column="2">
|
||||
<a-descriptions-item label="状态码">
|
||||
<a-tag
|
||||
:color="getStatusColor(lastResponse.status)"
|
||||
>
|
||||
{{ lastResponse.status }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="响应时间">
|
||||
{{ lastResponse.duration }}ms
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="响应大小">
|
||||
{{ formatResponseSize(lastResponse.size) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="内容类型">
|
||||
{{ lastResponse.contentType }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 响应内容 -->
|
||||
<div v-if="lastResponse">
|
||||
<a-typography-title :level="5">响应数据</a-typography-title>
|
||||
<a-textarea
|
||||
:value="formatResponse(lastResponse.data)"
|
||||
:rows="15"
|
||||
readonly
|
||||
style="font-family: 'Courier New', monospace; font-size: 12px;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<a-empty
|
||||
v-else
|
||||
description="发送请求后将显示响应结果"
|
||||
style="margin: 60px 0;"
|
||||
>
|
||||
<template #image>
|
||||
<ApiOutlined style="font-size: 64px; color: #d9d9d9;" />
|
||||
</template>
|
||||
</a-empty>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 请求历史 -->
|
||||
<a-row style="margin-top: 24px;">
|
||||
<a-col :span="24">
|
||||
<a-card title="请求历史" :bordered="false">
|
||||
<a-table
|
||||
:columns="historyColumns"
|
||||
:data-source="requestHistory"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="timestamp"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'method'">
|
||||
<a-tag :color="getMethodColor(record.method)">
|
||||
{{ record.method }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'timestamp'">
|
||||
{{ formatDate(record.timestamp) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-space size="small">
|
||||
<a-button size="small" @click="loadFromHistory(record)">
|
||||
加载
|
||||
</a-button>
|
||||
<a-button size="small" @click="viewResponse(record)">
|
||||
查看响应
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 响应查看模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showResponseModal"
|
||||
title="查看响应详情"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
>
|
||||
<div v-if="selectedHistoryItem">
|
||||
<a-descriptions :column="2" size="small" style="margin-bottom: 16px;">
|
||||
<a-descriptions-item label="请求方法">
|
||||
<a-tag :color="getMethodColor(selectedHistoryItem.method)">
|
||||
{{ selectedHistoryItem.method }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态码">
|
||||
<a-tag :color="getStatusColor(selectedHistoryItem.status)">
|
||||
{{ selectedHistoryItem.status }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="请求URL">
|
||||
{{ selectedHistoryItem.url }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="响应时间">
|
||||
{{ selectedHistoryItem.duration }}ms
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-typography-title :level="5">响应数据</a-typography-title>
|
||||
<a-textarea
|
||||
:value="formatResponse(selectedHistoryItem.response)"
|
||||
:rows="12"
|
||||
readonly
|
||||
style="font-family: 'Courier New', monospace; font-size: 12px;"
|
||||
/>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
ClearOutlined,
|
||||
ApiOutlined,
|
||||
SendOutlined,
|
||||
SaveOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { api } from '../utils/api'
|
||||
import moment from 'moment'
|
||||
|
||||
// 响应式数据
|
||||
const requesting = ref(false)
|
||||
const selectedApi = ref('')
|
||||
const requestMethod = ref('GET')
|
||||
const requestUrl = ref('')
|
||||
const requestHeaders = ref('{"Authorization": "Bearer ' + localStorage.getItem('token') + '", "Content-Type": "application/json"}')
|
||||
const requestBody = ref('')
|
||||
const queryParams = ref('')
|
||||
const lastResponse = ref(null)
|
||||
const requestHistory = ref([])
|
||||
const showResponseModal = ref(false)
|
||||
const selectedHistoryItem = ref(null)
|
||||
|
||||
// API模板
|
||||
const apiTemplates = {
|
||||
'auth-login': {
|
||||
method: 'POST',
|
||||
url: '/api/auth/login',
|
||||
body: '{"username": "admin", "password": "admin123"}'
|
||||
},
|
||||
'farms-list': {
|
||||
method: 'GET',
|
||||
url: '/api/farms',
|
||||
params: '{"page": 1, "limit": 10}'
|
||||
},
|
||||
'farms-create': {
|
||||
method: 'POST',
|
||||
url: '/api/farms',
|
||||
body: '{"name": "测试农场", "type": "养牛场", "location": {"latitude": 38.4872, "longitude": 106.2309}, "address": "银川市测试地址", "contact": "张三", "phone": "13800138000"}'
|
||||
},
|
||||
'farms-search': {
|
||||
method: 'GET',
|
||||
url: '/api/farms/search',
|
||||
params: '{"farmName": "测试"}'
|
||||
},
|
||||
'devices-list': {
|
||||
method: 'GET',
|
||||
url: '/api/devices'
|
||||
},
|
||||
'devices-search': {
|
||||
method: 'GET',
|
||||
url: '/api/devices/search',
|
||||
params: '{"deviceName": "传感器"}'
|
||||
},
|
||||
'animals-list': {
|
||||
method: 'GET',
|
||||
url: '/api/animals'
|
||||
},
|
||||
'alerts-list': {
|
||||
method: 'GET',
|
||||
url: '/api/alerts'
|
||||
},
|
||||
'users-list': {
|
||||
method: 'GET',
|
||||
url: '/api/users'
|
||||
},
|
||||
'products-list': {
|
||||
method: 'GET',
|
||||
url: '/api/products'
|
||||
},
|
||||
'orders-list': {
|
||||
method: 'GET',
|
||||
url: '/api/orders'
|
||||
},
|
||||
'reports-farm': {
|
||||
method: 'POST',
|
||||
url: '/api/reports/farm',
|
||||
body: '{"startDate": "2025-01-01", "endDate": "2025-01-18", "format": "pdf"}'
|
||||
},
|
||||
'system-configs': {
|
||||
method: 'GET',
|
||||
url: '/api/system/configs'
|
||||
},
|
||||
'system-stats': {
|
||||
method: 'GET',
|
||||
url: '/api/system/stats'
|
||||
}
|
||||
}
|
||||
|
||||
// 历史记录表格列
|
||||
const historyColumns = [
|
||||
{
|
||||
title: '方法',
|
||||
dataIndex: 'method',
|
||||
key: 'method',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: 'URL',
|
||||
dataIndex: 'url',
|
||||
key: 'url',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 120
|
||||
}
|
||||
]
|
||||
|
||||
// 组件挂载时加载历史记录
|
||||
onMounted(() => {
|
||||
loadHistory()
|
||||
})
|
||||
|
||||
// 加载API模板
|
||||
function loadApiTemplate(value) {
|
||||
const template = apiTemplates[value]
|
||||
if (template) {
|
||||
requestMethod.value = template.method
|
||||
requestUrl.value = template.url
|
||||
requestBody.value = template.body || ''
|
||||
queryParams.value = template.params || ''
|
||||
}
|
||||
}
|
||||
|
||||
// 发送API请求
|
||||
async function sendRequest() {
|
||||
if (!requestUrl.value.trim()) {
|
||||
message.error('请输入API端点')
|
||||
return
|
||||
}
|
||||
|
||||
requesting.value = true
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// 解析请求头
|
||||
let headers = {}
|
||||
try {
|
||||
headers = JSON.parse(requestHeaders.value || '{}')
|
||||
} catch (error) {
|
||||
message.error('请求头格式错误,请使用有效的JSON格式')
|
||||
return
|
||||
}
|
||||
|
||||
// 构建请求配置
|
||||
const config = {
|
||||
method: requestMethod.value,
|
||||
url: requestUrl.value,
|
||||
headers
|
||||
}
|
||||
|
||||
// 添加请求体(POST/PUT)
|
||||
if (['POST', 'PUT'].includes(requestMethod.value) && requestBody.value.trim()) {
|
||||
try {
|
||||
config.data = JSON.parse(requestBody.value)
|
||||
} catch (error) {
|
||||
message.error('请求体格式错误,请使用有效的JSON格式')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 添加查询参数(GET)
|
||||
if (requestMethod.value === 'GET' && queryParams.value.trim()) {
|
||||
try {
|
||||
config.params = JSON.parse(queryParams.value)
|
||||
} catch (error) {
|
||||
message.error('查询参数格式错误,请使用有效的JSON格式')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const response = await api.request(config)
|
||||
const endTime = Date.now()
|
||||
|
||||
lastResponse.value = {
|
||||
status: 200,
|
||||
data: response,
|
||||
duration: endTime - startTime,
|
||||
size: JSON.stringify(response).length,
|
||||
contentType: 'application/json'
|
||||
}
|
||||
|
||||
message.success('请求发送成功')
|
||||
} catch (error) {
|
||||
const endTime = Date.now()
|
||||
|
||||
lastResponse.value = {
|
||||
status: error.response?.status || 500,
|
||||
data: error.response?.data || { error: error.message },
|
||||
duration: endTime - startTime,
|
||||
size: JSON.stringify(error.response?.data || {}).length,
|
||||
contentType: 'application/json'
|
||||
}
|
||||
|
||||
message.error('请求失败: ' + error.message)
|
||||
} finally {
|
||||
requesting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到历史记录
|
||||
function saveToHistory() {
|
||||
if (!lastResponse.value) {
|
||||
message.warning('没有响应数据可保存')
|
||||
return
|
||||
}
|
||||
|
||||
const historyItem = {
|
||||
timestamp: Date.now(),
|
||||
method: requestMethod.value,
|
||||
url: requestUrl.value,
|
||||
headers: requestHeaders.value,
|
||||
body: requestBody.value,
|
||||
params: queryParams.value,
|
||||
status: lastResponse.value.status,
|
||||
response: lastResponse.value.data,
|
||||
duration: lastResponse.value.duration
|
||||
}
|
||||
|
||||
requestHistory.value.unshift(historyItem)
|
||||
|
||||
// 只保留最近50条记录
|
||||
if (requestHistory.value.length > 50) {
|
||||
requestHistory.value = requestHistory.value.slice(0, 50)
|
||||
}
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('api-test-history', JSON.stringify(requestHistory.value))
|
||||
|
||||
message.success('已保存到历史记录')
|
||||
}
|
||||
|
||||
// 从历史记录加载
|
||||
function loadFromHistory(record) {
|
||||
requestMethod.value = record.method
|
||||
requestUrl.value = record.url
|
||||
requestHeaders.value = record.headers
|
||||
requestBody.value = record.body
|
||||
queryParams.value = record.params
|
||||
|
||||
message.info('已从历史记录加载请求配置')
|
||||
}
|
||||
|
||||
// 查看历史响应
|
||||
function viewResponse(record) {
|
||||
selectedHistoryItem.value = record
|
||||
showResponseModal.value = true
|
||||
}
|
||||
|
||||
// 清空历史记录
|
||||
function clearHistory() {
|
||||
requestHistory.value = []
|
||||
localStorage.removeItem('api-test-history')
|
||||
message.success('历史记录已清空')
|
||||
}
|
||||
|
||||
// 加载历史记录
|
||||
function loadHistory() {
|
||||
try {
|
||||
const stored = localStorage.getItem('api-test-history')
|
||||
if (stored) {
|
||||
requestHistory.value = JSON.parse(stored)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载历史记录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开Swagger文档
|
||||
function openSwaggerDocs() {
|
||||
const apiBaseUrl = import.meta.env.VITE_API_FULL_URL || 'http://localhost:5350/api'
|
||||
const docsUrl = apiBaseUrl.replace('/api', '/api-docs')
|
||||
window.open(docsUrl, '_blank')
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
function getMethodColor(method) {
|
||||
const colors = {
|
||||
GET: 'green',
|
||||
POST: 'blue',
|
||||
PUT: 'orange',
|
||||
DELETE: 'red'
|
||||
}
|
||||
return colors[method] || 'default'
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
if (status >= 200 && status < 300) return 'green'
|
||||
if (status >= 400 && status < 500) return 'orange'
|
||||
if (status >= 500) return 'red'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function formatResponse(data) {
|
||||
try {
|
||||
return JSON.stringify(data, null, 2)
|
||||
} catch (error) {
|
||||
return String(data)
|
||||
}
|
||||
}
|
||||
|
||||
function formatResponseSize(size) {
|
||||
if (size < 1024) return `${size} B`
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
return moment(timestamp).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.api-tester-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.api-tester-content {
|
||||
padding: 24px;
|
||||
background: #f0f2f5;
|
||||
min-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ant-page-header {
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
:deep(.ant-textarea) {
|
||||
font-family: 'Courier New', Monaco, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
height: calc(100% - 57px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.api-tester-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-col) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.ant-row {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ant-col {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1398
admin-system/src/views/CattleArchives.vue
Normal file
1398
admin-system/src/views/CattleArchives.vue
Normal file
File diff suppressed because it is too large
Load Diff
1163
admin-system/src/views/CattleBatches.vue
Normal file
1163
admin-system/src/views/CattleBatches.vue
Normal file
File diff suppressed because it is too large
Load Diff
1147
admin-system/src/views/CattleExitRecords.vue
Normal file
1147
admin-system/src/views/CattleExitRecords.vue
Normal file
File diff suppressed because it is too large
Load Diff
777
admin-system/src/views/CattlePens.vue
Normal file
777
admin-system/src/views/CattlePens.vue
Normal file
@@ -0,0 +1,777 @@
|
||||
<template>
|
||||
<div class="cattle-pens">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">栏舍设置</h1>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮区域 -->
|
||||
<div class="action-bar">
|
||||
<div class="action-buttons">
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
新增栏舍
|
||||
</a-button>
|
||||
<a-button @click="handleBatchDelete" :disabled="selectedRowKeys.length === 0">
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
批量删除
|
||||
</a-button>
|
||||
<a-button @click="handleExport">
|
||||
<template #icon>
|
||||
<ExportOutlined />
|
||||
</template>
|
||||
导出数据
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-container">
|
||||
<a-input
|
||||
v-model="searchValue"
|
||||
placeholder="请输入栏舍名称(精确匹配)"
|
||||
class="search-input"
|
||||
@pressEnter="handleSearch"
|
||||
@input="handleSearchInput"
|
||||
>
|
||||
<template #suffix>
|
||||
<SearchOutlined @click="handleSearch" />
|
||||
</template>
|
||||
</a-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<div class="table-container">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:pagination="pagination"
|
||||
:loading="loading"
|
||||
:row-selection="rowSelection"
|
||||
class="pens-table"
|
||||
size="middle"
|
||||
>
|
||||
<!-- 状态列 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === '启用' ? 'green' : 'red'">
|
||||
{{ record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleViewAnimals(record)">
|
||||
查看牛只
|
||||
</a-button>
|
||||
<a-button type="link" size="small" danger @click="handleDelete(record)">
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<a-modal
|
||||
:open="modalVisible"
|
||||
@update:open="modalVisible = $event"
|
||||
:title="modalTitle"
|
||||
width="600px"
|
||||
@ok="handleModalOk"
|
||||
@cancel="handleModalCancel"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 18 }"
|
||||
>
|
||||
<a-form-item label="栏舍名称" name="name">
|
||||
<a-input
|
||||
v-model="formData.name"
|
||||
placeholder="请输入栏舍名称"
|
||||
@input="handleFieldChange('name', $event.target.value)"
|
||||
@change="handleFieldChange('name', $event.target.value)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="栏舍编号" name="code">
|
||||
<a-input
|
||||
v-model="formData.code"
|
||||
placeholder="请输入栏舍编号"
|
||||
@input="handleFieldChange('code', $event.target.value)"
|
||||
@change="handleFieldChange('code', $event.target.value)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="栏舍类型" name="type">
|
||||
<a-select
|
||||
v-model="formData.type"
|
||||
placeholder="请选择栏舍类型"
|
||||
@change="handleFieldChange('type', $event)"
|
||||
>
|
||||
<a-select-option value="育成栏">育成栏</a-select-option>
|
||||
<a-select-option value="产房">产房</a-select-option>
|
||||
<a-select-option value="配种栏">配种栏</a-select-option>
|
||||
<a-select-option value="隔离栏">隔离栏</a-select-option>
|
||||
<a-select-option value="治疗栏">治疗栏</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="容量" name="capacity">
|
||||
<a-input-number
|
||||
v-model="formData.capacity"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
style="width: 100%"
|
||||
placeholder="请输入栏舍容量"
|
||||
@change="handleFieldChange('capacity', $event)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="当前牛只数量" name="currentCount">
|
||||
<a-input-number
|
||||
v-model="formData.currentCount"
|
||||
:min="0"
|
||||
:max="formData.capacity || 1000"
|
||||
style="width: 100%"
|
||||
placeholder="当前牛只数量"
|
||||
@change="handleFieldChange('currentCount', $event)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="面积(平方米)" name="area">
|
||||
<a-input-number
|
||||
v-model="formData.area"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
placeholder="请输入栏舍面积"
|
||||
@change="handleFieldChange('area', $event)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="位置描述" name="location">
|
||||
<a-textarea
|
||||
v-model="formData.location"
|
||||
:rows="3"
|
||||
placeholder="请输入栏舍位置描述"
|
||||
@input="handleFieldChange('location', $event.target.value)"
|
||||
@change="handleFieldChange('location', $event.target.value)"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group
|
||||
v-model="formData.status"
|
||||
@change="handleFieldChange('status', $event.target.value)"
|
||||
>
|
||||
<a-radio value="启用">启用</a-radio>
|
||||
<a-radio value="停用">停用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="备注" name="remark">
|
||||
<a-textarea
|
||||
v-model="formData.remark"
|
||||
:rows="3"
|
||||
placeholder="请输入备注信息"
|
||||
@input="handleFieldChange('remark', $event.target.value)"
|
||||
@change="handleFieldChange('remark', $event.target.value)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 查看牛只弹窗 -->
|
||||
<a-modal
|
||||
:open="animalsModalVisible"
|
||||
@update:open="animalsModalVisible = $event"
|
||||
title="栏舍牛只信息"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
>
|
||||
<a-table
|
||||
:columns="animalColumns"
|
||||
:data-source="currentPenAnimals"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
size="small"
|
||||
/>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
ExportOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
import { api } from '../utils/api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const searchValue = ref('')
|
||||
const modalVisible = ref(false)
|
||||
const animalsModalVisible = ref(false)
|
||||
const modalTitle = ref('新增栏舍')
|
||||
const formRef = ref()
|
||||
const selectedRowKeys = ref([])
|
||||
|
||||
// 表格数据
|
||||
const tableData = ref([])
|
||||
|
||||
// 当前栏舍的牛只数据
|
||||
const currentPenAnimals = ref([])
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '栏舍名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '栏舍编号',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '栏舍类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '容量',
|
||||
dataIndex: 'capacity',
|
||||
key: 'capacity',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '当前牛只数量',
|
||||
dataIndex: 'currentCount',
|
||||
key: 'currentCount',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '面积(平方米)',
|
||||
dataIndex: 'area',
|
||||
key: 'area',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '位置描述',
|
||||
dataIndex: 'location',
|
||||
key: 'location',
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 牛只表格列配置
|
||||
const animalColumns = [
|
||||
{
|
||||
title: '耳号',
|
||||
dataIndex: 'earTag',
|
||||
key: 'earTag'
|
||||
},
|
||||
{
|
||||
title: '品种',
|
||||
dataIndex: 'breed',
|
||||
key: 'breed'
|
||||
},
|
||||
{
|
||||
title: '性别',
|
||||
dataIndex: 'gender',
|
||||
key: 'gender'
|
||||
},
|
||||
{
|
||||
title: '月龄',
|
||||
dataIndex: 'ageInMonths',
|
||||
key: 'ageInMonths'
|
||||
},
|
||||
{
|
||||
title: '生理阶段',
|
||||
dataIndex: 'physiologicalStage',
|
||||
key: 'physiologicalStage'
|
||||
}
|
||||
]
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条/共 ${total} 条`
|
||||
})
|
||||
|
||||
// 行选择配置
|
||||
const rowSelection = {
|
||||
selectedRowKeys: selectedRowKeys,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys
|
||||
}
|
||||
}
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
code: '',
|
||||
type: '',
|
||||
capacity: null,
|
||||
currentCount: null,
|
||||
area: null,
|
||||
location: '',
|
||||
status: '启用',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入栏舍名称', trigger: 'blur' }
|
||||
],
|
||||
code: [
|
||||
{ required: true, message: '请输入栏舍编号', trigger: 'blur' }
|
||||
],
|
||||
type: [
|
||||
{ required: true, message: '请选择栏舍类型', trigger: 'change' }
|
||||
],
|
||||
capacity: [
|
||||
{ required: true, message: '请输入栏舍容量', trigger: 'blur' }
|
||||
],
|
||||
area: [
|
||||
{ required: true, message: '请输入栏舍面积', trigger: 'blur' }
|
||||
],
|
||||
status: [
|
||||
{ required: true, message: '请选择状态', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
// 方法
|
||||
const loadData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const params = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize
|
||||
}
|
||||
|
||||
// 精确匹配栏舍名称
|
||||
if (searchValue.value) {
|
||||
params.name = searchValue.value
|
||||
}
|
||||
|
||||
console.log('📤 [栏舍设置] 发送请求参数:', params)
|
||||
|
||||
const response = await api.cattlePens.getList(params)
|
||||
console.log('📥 [栏舍设置] 接收到的响应数据:', response)
|
||||
|
||||
if (response.success) {
|
||||
console.log('📊 [栏舍设置] 原始列表数据:', response.data.list)
|
||||
|
||||
tableData.value = response.data.list.map(item => ({
|
||||
...item,
|
||||
key: item.id,
|
||||
createTime: item.created_at ? dayjs(item.created_at).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
}))
|
||||
|
||||
console.log('📊 [栏舍设置] 格式化后的表格数据:', tableData.value)
|
||||
pagination.total = response.data.total
|
||||
console.log('📊 [栏舍设置] 总记录数:', response.data.total)
|
||||
} else {
|
||||
console.warn('⚠️ [栏舍设置] 请求成功但返回失败状态:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [栏舍设置] 加载数据失败:', error)
|
||||
message.error('加载数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
modalTitle.value = '新增栏舍'
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (record) => {
|
||||
console.log('🔄 [栏舍设置] 开始编辑操作')
|
||||
console.log('📋 [栏舍设置] 原始记录数据:', {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
type: record.type,
|
||||
status: record.status,
|
||||
capacity: record.capacity,
|
||||
area: record.area,
|
||||
location: record.location,
|
||||
description: record.description,
|
||||
remark: record.remark,
|
||||
farmId: record.farmId,
|
||||
farmName: record.farm?.name
|
||||
})
|
||||
|
||||
modalTitle.value = '编辑栏舍'
|
||||
Object.assign(formData, record)
|
||||
|
||||
console.log('📝 [栏舍设置] 表单数据已填充:', formData)
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除栏舍"${record.name}"吗?`,
|
||||
async onOk() {
|
||||
try {
|
||||
await api.cattlePens.delete(record.id)
|
||||
message.success('删除成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
Modal.confirm({
|
||||
title: '确认批量删除',
|
||||
content: `确定要删除选中的 ${selectedRowKeys.value.length} 个栏舍吗?`,
|
||||
async onOk() {
|
||||
try {
|
||||
await api.cattlePens.batchDelete(selectedRowKeys.value)
|
||||
message.success('批量删除成功')
|
||||
selectedRowKeys.value = []
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('批量删除失败:', error)
|
||||
message.error('批量删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleViewAnimals = async (record) => {
|
||||
try {
|
||||
const response = await api.cattlePens.getAnimals(record.id)
|
||||
if (response.success) {
|
||||
currentPenAnimals.value = response.data.list.map(item => ({
|
||||
...item,
|
||||
key: item.id
|
||||
}))
|
||||
animalsModalVisible.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取栏舍牛只失败:', error)
|
||||
message.error('获取栏舍牛只失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
console.log('=== 开始导出栏舍数据 ===')
|
||||
console.log('tableData.value长度:', tableData.value.length)
|
||||
console.log('tableData.value示例:', tableData.value[0])
|
||||
|
||||
if (!tableData.value || tableData.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
// 转换数据格式以匹配导出工具类的列配置
|
||||
const exportData = tableData.value.map(item => {
|
||||
console.log('转换前栏舍数据项:', item)
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name || '',
|
||||
animal_type: item.animalType || item.animal_type || '牛', // 默认动物类型为牛
|
||||
pen_type: item.type || item.penType || item.pen_type || '', // 使用type字段
|
||||
responsible: item.responsible || item.manager || item.managerName || '',
|
||||
capacity: item.capacity || 0,
|
||||
status: item.status || '启用',
|
||||
creator: item.creator || item.created_by || item.creatorName || '',
|
||||
created_at: item.created_at || item.createTime || ''
|
||||
}
|
||||
})
|
||||
|
||||
console.log('转换后栏舍数据示例:', exportData[0])
|
||||
console.log('转换后栏舍数据总数:', exportData.length)
|
||||
|
||||
const result = ExportUtils.exportPenData(exportData)
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听搜索输入变化
|
||||
const handleSearchInput = (e) => {
|
||||
console.log('🔍 [栏舍设置] 搜索输入变化:', e.target.value)
|
||||
// 确保searchValue被正确更新
|
||||
searchValue.value = e.target.value
|
||||
|
||||
// 实时过滤表格数据
|
||||
debounceSearch()
|
||||
}
|
||||
|
||||
// 使用防抖函数处理实时搜索,避免频繁请求
|
||||
const debounceSearch = (() => {
|
||||
let timer = null
|
||||
return () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
console.log('🔍 [栏舍设置] 执行实时搜索,搜索值:', searchValue.value)
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}, 300) // 300ms防抖延迟
|
||||
}
|
||||
})()
|
||||
|
||||
const handleSearch = () => {
|
||||
console.log('🔍 [栏舍设置] 执行搜索操作,搜索值:', searchValue.value)
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 字段变化监听方法
|
||||
const handleFieldChange = (fieldName, value) => {
|
||||
console.log(`📝 [栏舍设置] 字段变化监听: ${fieldName} = ${value}`)
|
||||
console.log(`📝 [栏舍设置] 当前表单数据:`, formData)
|
||||
|
||||
// 确保数据同步到formData
|
||||
if (formData[fieldName] !== value) {
|
||||
formData[fieldName] = value
|
||||
console.log(`✅ [栏舍设置] 字段 ${fieldName} 已更新为: ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
console.log('💾 [栏舍设置] 开始保存操作')
|
||||
console.log('📝 [栏舍设置] 用户输入的表单数据:', {
|
||||
id: formData.id,
|
||||
name: formData.name,
|
||||
code: formData.code,
|
||||
type: formData.type,
|
||||
status: formData.status,
|
||||
capacity: formData.capacity,
|
||||
currentCount: formData.currentCount,
|
||||
area: formData.area,
|
||||
location: formData.location,
|
||||
remark: formData.remark,
|
||||
farmId: formData.farmId
|
||||
})
|
||||
|
||||
await formRef.value.validate()
|
||||
console.log('✅ [栏舍设置] 表单验证通过')
|
||||
|
||||
// 准备发送给后端的数据,转换数字格式
|
||||
const submitData = {
|
||||
name: formData.name,
|
||||
code: formData.code,
|
||||
type: formData.type,
|
||||
status: formData.status,
|
||||
capacity: formData.capacity ? Number(formData.capacity) : null,
|
||||
currentCount: formData.currentCount ? Number(formData.currentCount) : 0,
|
||||
area: formData.area ? Number(formData.area) : null,
|
||||
location: formData.location || '',
|
||||
remark: formData.remark || '',
|
||||
farmId: formData.farmId || 1
|
||||
}
|
||||
|
||||
console.log('📤 [栏舍设置] 准备发送的数据:', submitData)
|
||||
|
||||
if (modalTitle.value === '新增栏舍') {
|
||||
console.log('🆕 [栏舍设置] 执行创建操作')
|
||||
await api.cattlePens.create(submitData)
|
||||
console.log('✅ [栏舍设置] 创建成功')
|
||||
message.success('创建成功')
|
||||
} else {
|
||||
console.log('🔄 [栏舍设置] 执行更新操作,记录ID:', formData.id)
|
||||
const response = await api.cattlePens.update(formData.id, submitData)
|
||||
console.log('✅ [栏舍设置] 更新成功,服务器响应:', response)
|
||||
message.success('更新成功')
|
||||
}
|
||||
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('❌ [栏舍设置] 操作失败:', error)
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleModalCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.keys(formData).forEach(key => {
|
||||
if (key === 'capacity' || key === 'currentCount' || key === 'area') {
|
||||
formData[key] = null
|
||||
} else {
|
||||
formData[key] = ''
|
||||
}
|
||||
})
|
||||
formData.status = '启用'
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cattle-pens {
|
||||
padding: 24px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pens-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pens-table :deep(.ant-table-thead > tr > th) {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.pens-table :deep(.ant-table-tbody > tr > td) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.pens-table :deep(.ant-table-tbody > tr:hover > td) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.action-bar {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.cattle-pens {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1098
admin-system/src/views/CattleTransferRecords.vue
Normal file
1098
admin-system/src/views/CattleTransferRecords.vue
Normal file
File diff suppressed because it is too large
Load Diff
20
admin-system/src/views/Dashboard.vue
Normal file
20
admin-system/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<a-page-header
|
||||
title="宁夏智慧养殖监管平台数据概览"
|
||||
>
|
||||
</a-page-header>
|
||||
|
||||
<Dashboard />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Dashboard from '../components/Dashboard.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-page {
|
||||
padding: 0 16px;
|
||||
}
|
||||
</style>
|
||||
586
admin-system/src/views/Devices.vue
Normal file
586
admin-system/src/views/Devices.vue
Normal file
@@ -0,0 +1,586 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>设备管理</h1>
|
||||
<a-space>
|
||||
<a-button @click="exportDevices" :loading="exportLoading">
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出数据
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加设备
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 8px; align-items: center;">
|
||||
<a-auto-complete
|
||||
v-model:value="searchDeviceName"
|
||||
:options="deviceNameOptions"
|
||||
placeholder="请选择或输入设备名称进行搜索"
|
||||
style="width: 300px;"
|
||||
:filter-option="false"
|
||||
@search="handleSearchInput"
|
||||
@press-enter="searchDevices"
|
||||
/>
|
||||
<a-button type="primary" @click="searchDevices" :loading="searchLoading">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="resetSearch" :disabled="!isSearching">
|
||||
重置
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="devices"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'id'">
|
||||
{{ record.id }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'name'">
|
||||
{{ record.name }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'type'">
|
||||
<a-tag color="blue">{{ getTypeText(record.type) }}</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'farm_name'">
|
||||
{{ getFarmName(record.farm_id) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'last_maintenance'">
|
||||
{{ formatDate(record.last_maintenance) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'installation_date'">
|
||||
{{ formatDate(record.installation_date) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="editDevice(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个设备吗?"
|
||||
@confirm="deleteDevice(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑设备模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑设备' : '添加设备'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="设备名称" name="name">
|
||||
<a-input v-model:value="formData.name" placeholder="请输入设备名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="设备类型" name="type">
|
||||
<a-select v-model:value="formData.type" placeholder="请选择设备类型" @change="handleTypeChange">
|
||||
<a-select-option value="sensor">传感器</a-select-option>
|
||||
<a-select-option value="camera">摄像头</a-select-option>
|
||||
<a-select-option value="feeder">喂食器</a-select-option>
|
||||
<a-select-option value="monitor">监控器</a-select-option>
|
||||
<a-select-option value="controller">控制器</a-select-option>
|
||||
<a-select-option value="other">其他</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="showCustomTypeInput" label="自定义设备类型" name="customType">
|
||||
<a-input
|
||||
v-model:value="formData.customType"
|
||||
placeholder="请输入自定义设备类型"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="设备状态" name="status">
|
||||
<a-select v-model:value="formData.status" placeholder="请选择设备状态">
|
||||
<a-select-option value="online">在线</a-select-option>
|
||||
<a-select-option value="offline">离线</a-select-option>
|
||||
<a-select-option value="maintenance">维护中</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="所属农场" name="farm_id">
|
||||
<a-select
|
||||
v-model:value="formData.farm_id"
|
||||
placeholder="请选择所属农场"
|
||||
:loading="farmsLoading"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="farm in farms"
|
||||
:key="farm.id"
|
||||
:value="farm.id"
|
||||
>
|
||||
{{ farm.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="安装日期" name="installation_date">
|
||||
<a-date-picker
|
||||
v-model:value="formData.installation_date"
|
||||
placeholder="请选择安装日期"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="最后维护时间" name="last_maintenance">
|
||||
<a-date-picker
|
||||
v-model:value="formData.last_maintenance"
|
||||
placeholder="请选择最后维护时间"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
import { api } from '../utils/api'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// 响应式数据
|
||||
const devices = ref([])
|
||||
const farms = ref([])
|
||||
const loading = ref(false)
|
||||
const farmsLoading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 搜索相关的响应式数据
|
||||
const searchDeviceName = ref('')
|
||||
const searchLoading = ref(false)
|
||||
const isSearching = ref(false)
|
||||
const deviceNameOptions = ref([])
|
||||
|
||||
// 导出相关
|
||||
const exportLoading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
type: '',
|
||||
status: 'offline',
|
||||
farm_id: null,
|
||||
installation_date: null,
|
||||
last_maintenance: null,
|
||||
customType: '' // 自定义设备类型
|
||||
})
|
||||
|
||||
// 控制是否显示自定义输入框
|
||||
const showCustomTypeInput = ref(false)
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
|
||||
type: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
|
||||
customType: [{
|
||||
required: () => formData.type === 'other',
|
||||
message: '请输入自定义设备类型',
|
||||
trigger: 'blur'
|
||||
}],
|
||||
status: [{ required: true, message: '请选择设备状态', trigger: 'change' }],
|
||||
farm_id: [{ required: true, message: '请选择所属农场', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '设备名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: '设备类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '设备状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '所属农场',
|
||||
dataIndex: 'farm_name',
|
||||
key: 'farm_name',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '安装日期',
|
||||
dataIndex: 'installation_date',
|
||||
key: 'installation_date',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '最后维护',
|
||||
dataIndex: 'last_maintenance',
|
||||
key: 'last_maintenance',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 150
|
||||
}
|
||||
]
|
||||
|
||||
// 获取设备列表
|
||||
const fetchDevices = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.get('/devices')
|
||||
if (response.success) {
|
||||
devices.value = response.data
|
||||
} else {
|
||||
message.error('获取设备列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取设备列表失败:', error)
|
||||
if (error.response && error.response.status === 401) {
|
||||
message.error('登录已过期,请重新登录')
|
||||
// 可以在这里添加重定向到登录页的逻辑
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login'
|
||||
}, 2000)
|
||||
} else {
|
||||
message.error('获取设备列表失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取农场列表
|
||||
const fetchFarms = async () => {
|
||||
try {
|
||||
farmsLoading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.get('/farms')
|
||||
if (response.success) {
|
||||
farms.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取农场列表失败:', error)
|
||||
} finally {
|
||||
farmsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理设备类型选择变化
|
||||
const handleTypeChange = (value) => {
|
||||
showCustomTypeInput.value = value === 'other'
|
||||
if (value !== 'other') {
|
||||
formData.customType = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
fetchFarms()
|
||||
}
|
||||
|
||||
// 编辑设备
|
||||
const editDevice = (record) => {
|
||||
isEdit.value = true
|
||||
const predefinedTypes = ['sensor', 'camera', 'feeder', 'monitor', 'controller']
|
||||
const isCustomType = !predefinedTypes.includes(record.type)
|
||||
|
||||
// 逐个字段赋值,避免破坏响应式绑定
|
||||
formData.id = record.id
|
||||
formData.name = record.name
|
||||
formData.type = isCustomType ? 'other' : record.type
|
||||
formData.customType = isCustomType ? record.type : ''
|
||||
formData.status = record.status
|
||||
formData.farm_id = record.farm_id
|
||||
formData.installation_date = record.installation_date ? dayjs(record.installation_date) : null
|
||||
formData.last_maintenance = record.last_maintenance ? dayjs(record.last_maintenance) : null
|
||||
|
||||
showCustomTypeInput.value = isCustomType
|
||||
modalVisible.value = true
|
||||
fetchFarms()
|
||||
}
|
||||
|
||||
// 删除设备
|
||||
const deleteDevice = async (id) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.delete(`/devices/${id}`)
|
||||
if (response.success) {
|
||||
message.success('删除成功')
|
||||
fetchDevices()
|
||||
} else {
|
||||
message.error('删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除设备失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
const config = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
// 处理日期格式和自定义类型
|
||||
const submitData = {
|
||||
...formData,
|
||||
type: formData.type === 'other' ? formData.customType : formData.type,
|
||||
installation_date: formData.installation_date ? formData.installation_date.format('YYYY-MM-DD') : null,
|
||||
last_maintenance: formData.last_maintenance ? formData.last_maintenance.format('YYYY-MM-DD') : null
|
||||
}
|
||||
// 移除customType字段,避免发送到后端
|
||||
delete submitData.customType
|
||||
|
||||
// 如果是新增操作,移除id字段
|
||||
if (!isEdit.value) {
|
||||
delete submitData.id
|
||||
}
|
||||
|
||||
let response
|
||||
if (isEdit.value) {
|
||||
response = await api.put(`/devices/${formData.id}`, submitData)
|
||||
} else {
|
||||
response = await api.post('/devices', submitData)
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
modalVisible.value = false
|
||||
fetchDevices()
|
||||
} else {
|
||||
message.error(isEdit.value ? '更新失败' : '创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
name: '',
|
||||
type: '',
|
||||
status: 'offline',
|
||||
farm_id: null,
|
||||
installation_date: null,
|
||||
last_maintenance: null,
|
||||
customType: ''
|
||||
})
|
||||
showCustomTypeInput.value = false
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
online: 'green',
|
||||
offline: 'red',
|
||||
maintenance: 'orange'
|
||||
}
|
||||
return colors[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
maintenance: '维护中'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
// 获取类型文本
|
||||
const getTypeText = (type) => {
|
||||
const texts = {
|
||||
temperature_sensor: '温度传感器',
|
||||
humidity_sensor: '湿度传感器',
|
||||
feed_dispenser: '饲料分配器',
|
||||
water_system: '水系统',
|
||||
ventilation_system: '通风系统',
|
||||
sensor: '传感器',
|
||||
camera: '摄像头',
|
||||
feeder: '喂食器',
|
||||
monitor: '监控器',
|
||||
controller: '控制器',
|
||||
other: '其他'
|
||||
}
|
||||
return texts[type] || type
|
||||
}
|
||||
|
||||
// 获取农场名称
|
||||
const getFarmName = (farmId) => {
|
||||
const farm = farms.value.find(f => f.id === farmId)
|
||||
return farm ? farm.name : `农场ID: ${farmId}`
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-'
|
||||
return dayjs(date).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
// 搜索设备
|
||||
const searchDevices = async () => {
|
||||
if (!searchDeviceName.value.trim()) {
|
||||
message.warning('请输入设备名称进行搜索')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
searchLoading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await api.get('/devices/search', {
|
||||
params: { name: searchDeviceName.value.trim() }
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
devices.value = response.data || []
|
||||
isSearching.value = true
|
||||
message.success(response.message || `找到 ${devices.value.length} 个匹配的设备`)
|
||||
} else {
|
||||
devices.value = []
|
||||
message.info('未找到匹配的设备')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索设备失败:', error)
|
||||
message.error('搜索设备失败')
|
||||
devices.value = []
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索输入(为自动完成提供选项)
|
||||
const handleSearchInput = (value) => {
|
||||
if (!value) {
|
||||
deviceNameOptions.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 从现有设备列表中筛选匹配的设备名称
|
||||
const matchingDevices = devices.value.filter(device =>
|
||||
device.name.toLowerCase().includes(value.toLowerCase())
|
||||
)
|
||||
|
||||
deviceNameOptions.value = matchingDevices.map(device => ({
|
||||
value: device.name,
|
||||
label: device.name
|
||||
}))
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchDeviceName.value = ''
|
||||
isSearching.value = false
|
||||
deviceNameOptions.value = []
|
||||
fetchDevices() // 重新加载全部设备
|
||||
}
|
||||
|
||||
// 更新设备名称选项(在数据加载后)
|
||||
const updateDeviceNameOptions = () => {
|
||||
deviceNameOptions.value = devices.value.map(device => ({
|
||||
value: device.name,
|
||||
label: device.name
|
||||
}))
|
||||
}
|
||||
|
||||
// 导出设备数据
|
||||
const exportDevices = async () => {
|
||||
try {
|
||||
if (!devices.value || devices.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
exportLoading.value = true
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportDevicesData(devices.value)
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchDevices().then(() => {
|
||||
updateDeviceNameOptions()
|
||||
})
|
||||
fetchFarms()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 可以添加自定义样式 */
|
||||
</style>
|
||||
1336
admin-system/src/views/ElectronicFence.vue
Normal file
1336
admin-system/src/views/ElectronicFence.vue
Normal file
File diff suppressed because it is too large
Load Diff
1411
admin-system/src/views/FarmInfoManagement.vue
Normal file
1411
admin-system/src/views/FarmInfoManagement.vue
Normal file
File diff suppressed because it is too large
Load Diff
588
admin-system/src/views/Farms.vue
Normal file
588
admin-system/src/views/Farms.vue
Normal file
@@ -0,0 +1,588 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>养殖场管理</h1>
|
||||
<a-space>
|
||||
<a-button @click="exportFarms" :loading="exportLoading">
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出数据
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加养殖场
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 8px; align-items: center;">
|
||||
<a-auto-complete
|
||||
v-model:value="searchFarmName"
|
||||
:options="farmNameOptions"
|
||||
placeholder="请选择或输入养殖场名称进行搜索"
|
||||
style="width: 300px;"
|
||||
:filter-option="false"
|
||||
@search="handleSearchInput"
|
||||
@press-enter="searchFarms"
|
||||
/>
|
||||
<a-button type="primary" @click="searchFarms" :loading="searchLoading">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="resetSearch" :disabled="!isSearching">
|
||||
重置
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="farms"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="record.status === 'active' ? 'green' : record.status === 'inactive' ? 'red' : 'orange'">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'created_at'">
|
||||
{{ formatDate(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="editFarm(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个养殖场吗?"
|
||||
@confirm="deleteFarm(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑养殖场模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑养殖场' : '添加养殖场'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
width="800px"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 18 }"
|
||||
>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="养殖场名称" name="name">
|
||||
<a-input v-model:value="formData.name" placeholder="请输入养殖场名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="负责人" name="owner">
|
||||
<a-input v-model:value="formData.owner" placeholder="请输入负责人姓名" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="联系电话" name="phone">
|
||||
<a-input v-model:value="formData.phone" placeholder="请输入联系电话" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select v-model:value="formData.status" placeholder="请选择状态">
|
||||
<a-select-option value="active">运营中</a-select-option>
|
||||
<a-select-option value="inactive">已停用</a-select-option>
|
||||
<a-select-option value="maintenance">维护中</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="地址" name="address">
|
||||
<a-input v-model:value="formData.address" placeholder="请输入详细地址" />
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="经度" name="longitude">
|
||||
<a-input-number
|
||||
v-model:value="formData.longitude"
|
||||
placeholder="请输入经度 (-180 ~ 180)"
|
||||
:precision="6"
|
||||
:step="0.000001"
|
||||
:min="-180"
|
||||
:max="180"
|
||||
:string-mode="false"
|
||||
:controls="false"
|
||||
:parser="value => {
|
||||
if (!value) return value;
|
||||
// 移除非数字字符,保留小数点和负号
|
||||
const cleaned = value.toString().replace(/[^\d.-]/g, '');
|
||||
// 确保只有一个小数点和负号在开头
|
||||
const parts = cleaned.split('.');
|
||||
if (parts.length > 2) {
|
||||
return parts[0] + '.' + parts.slice(1).join('');
|
||||
}
|
||||
return cleaned;
|
||||
}"
|
||||
:formatter="value => value"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="纬度" name="latitude">
|
||||
<a-input-number
|
||||
v-model:value="formData.latitude"
|
||||
placeholder="请输入纬度 (-90 ~ 90)"
|
||||
:precision="6"
|
||||
:step="0.000001"
|
||||
:min="-90"
|
||||
:max="90"
|
||||
:string-mode="false"
|
||||
:controls="false"
|
||||
:parser="value => {
|
||||
if (!value) return value;
|
||||
// 移除非数字字符,保留小数点和负号
|
||||
const cleaned = value.toString().replace(/[^\d.-]/g, '');
|
||||
// 确保只有一个小数点和负号在开头
|
||||
const parts = cleaned.split('.');
|
||||
if (parts.length > 2) {
|
||||
return parts[0] + '.' + parts.slice(1).join('');
|
||||
}
|
||||
return cleaned;
|
||||
}"
|
||||
:formatter="value => value"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="占地面积(亩)" name="area">
|
||||
<a-input-number
|
||||
v-model:value="formData.area"
|
||||
placeholder="请输入占地面积"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="养殖规模" name="capacity">
|
||||
<a-input-number
|
||||
v-model:value="formData.capacity"
|
||||
placeholder="请输入养殖规模"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="描述" name="description">
|
||||
<a-textarea
|
||||
v-model:value="formData.description"
|
||||
placeholder="请输入养殖场描述"
|
||||
:rows="3"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
|
||||
// 响应式数据
|
||||
const farms = ref([])
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 搜索相关的响应式数据
|
||||
const searchFarmName = ref('')
|
||||
const searchLoading = ref(false)
|
||||
const isSearching = ref(false)
|
||||
const farmNameOptions = ref([])
|
||||
|
||||
// 导出相关
|
||||
const exportLoading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
owner: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
longitude: undefined,
|
||||
latitude: undefined,
|
||||
area: undefined,
|
||||
capacity: undefined,
|
||||
status: 'active',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入养殖场名称', trigger: 'blur' }],
|
||||
owner: [{ required: true, message: '请输入负责人姓名', trigger: 'blur' }],
|
||||
phone: [
|
||||
{ required: true, message: '请输入联系电话', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
|
||||
],
|
||||
address: [{ required: true, message: '请输入详细地址', trigger: 'blur' }],
|
||||
longitude: [
|
||||
{ validator: (rule, value) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return Promise.resolve(); // 允许为空
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num)) {
|
||||
return Promise.reject('请输入有效的经度数值');
|
||||
}
|
||||
if (num < -180 || num > 180) {
|
||||
return Promise.reject('经度范围应在-180到180之间');
|
||||
}
|
||||
// 检查小数位数不超过6位
|
||||
const decimalPart = value.toString().split('.')[1];
|
||||
if (decimalPart && decimalPart.length > 6) {
|
||||
return Promise.reject('经度小数位数不能超过6位');
|
||||
}
|
||||
return Promise.resolve();
|
||||
}, trigger: 'blur' }
|
||||
],
|
||||
latitude: [
|
||||
{ validator: (rule, value) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return Promise.resolve(); // 允许为空
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num)) {
|
||||
return Promise.reject('请输入有效的纬度数值');
|
||||
}
|
||||
if (num < -90 || num > 90) {
|
||||
return Promise.reject('纬度范围应在-90到90之间');
|
||||
}
|
||||
// 检查小数位数不超过6位
|
||||
const decimalPart = value.toString().split('.')[1];
|
||||
if (decimalPart && decimalPart.length > 6) {
|
||||
return Promise.reject('纬度小数位数不能超过6位');
|
||||
}
|
||||
return Promise.resolve();
|
||||
}, trigger: 'blur' }
|
||||
],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '养殖场名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '负责人',
|
||||
dataIndex: 'owner',
|
||||
key: 'owner',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '联系电话',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '地址',
|
||||
dataIndex: 'address',
|
||||
key: 'address',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 150
|
||||
}
|
||||
]
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
active: '运营中',
|
||||
inactive: '已停用',
|
||||
maintenance: '维护中'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取养殖场列表
|
||||
const fetchFarms = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const { api } = await import('../utils/api')
|
||||
const response = await api.get('/farms')
|
||||
console.log('养殖场API响应:', response)
|
||||
|
||||
// 检查响应格式
|
||||
if (response && response.success && Array.isArray(response.data)) {
|
||||
farms.value = response.data
|
||||
} else if (Array.isArray(response)) {
|
||||
// 兼容旧格式
|
||||
farms.value = response
|
||||
} else {
|
||||
farms.value = []
|
||||
console.warn('API返回数据格式异常:', response)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取养殖场列表失败:', error)
|
||||
message.error('获取养殖场列表失败')
|
||||
farms.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑养殖场
|
||||
const editFarm = (record) => {
|
||||
isEdit.value = true
|
||||
// 解析location对象中的经纬度数据
|
||||
const longitude = record.location?.lng || undefined
|
||||
const latitude = record.location?.lat || undefined
|
||||
|
||||
Object.assign(formData, {
|
||||
...record,
|
||||
longitude,
|
||||
latitude,
|
||||
owner: record.contact || ''
|
||||
})
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 删除养殖场
|
||||
const deleteFarm = async (id) => {
|
||||
try {
|
||||
const { api } = await import('../utils/api')
|
||||
await api.delete(`/farms/${id}`)
|
||||
message.success('删除成功')
|
||||
fetchFarms()
|
||||
} catch (error) {
|
||||
console.error('删除养殖场失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
const submitData = { ...formData }
|
||||
|
||||
const { api } = await import('../utils/api')
|
||||
if (isEdit.value) {
|
||||
await api.put(`/farms/${formData.id}`, submitData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await api.post('/farms', submitData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
|
||||
modalVisible.value = false
|
||||
fetchFarms()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
name: '',
|
||||
owner: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
longitude: undefined,
|
||||
latitude: undefined,
|
||||
area: undefined,
|
||||
capacity: undefined,
|
||||
status: 'active',
|
||||
description: ''
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 搜索养殖场
|
||||
const searchFarms = async () => {
|
||||
if (!searchFarmName.value.trim()) {
|
||||
message.warning('请输入养殖场名称进行搜索')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
searchLoading.value = true
|
||||
const { api } = await import('../utils/api')
|
||||
const response = await api.get('/farms/search', {
|
||||
params: { name: searchFarmName.value.trim() }
|
||||
})
|
||||
|
||||
if (Array.isArray(response)) {
|
||||
farms.value = response
|
||||
isSearching.value = true
|
||||
message.success(`找到 ${response.length} 个匹配的养殖场`)
|
||||
} else {
|
||||
farms.value = []
|
||||
message.info('未找到匹配的养殖场')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索养殖场失败:', error)
|
||||
message.error('搜索养殖场失败')
|
||||
farms.value = []
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索输入(为自动完成提供选项)
|
||||
const handleSearchInput = (value) => {
|
||||
if (!value) {
|
||||
farmNameOptions.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 从现有养殖场列表中筛选匹配的养殖场名称
|
||||
const matchingFarms = farms.value.filter(farm =>
|
||||
farm.name.toLowerCase().includes(value.toLowerCase())
|
||||
)
|
||||
|
||||
farmNameOptions.value = matchingFarms.map(farm => ({
|
||||
value: farm.name,
|
||||
label: farm.name
|
||||
}))
|
||||
}
|
||||
|
||||
// 更新养殖场名称选项(在数据加载后)
|
||||
const updateFarmNameOptions = () => {
|
||||
farmNameOptions.value = farms.value.map(farm => ({
|
||||
value: farm.name,
|
||||
label: farm.name
|
||||
}))
|
||||
}
|
||||
|
||||
// 导出农场数据
|
||||
const exportFarms = async () => {
|
||||
try {
|
||||
if (!farms.value || farms.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
exportLoading.value = true
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportFarmsData(farms.value)
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchFarmName.value = ''
|
||||
isSearching.value = false
|
||||
farmNameOptions.value = []
|
||||
fetchFarms() // 重新加载全部养殖场
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchFarms().then(() => {
|
||||
updateFarmNameOptions()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
599
admin-system/src/views/FormLogManagement.vue
Normal file
599
admin-system/src/views/FormLogManagement.vue
Normal file
@@ -0,0 +1,599 @@
|
||||
<template>
|
||||
<div class="form-log-management">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">表单日志管理</h1>
|
||||
<div class="header-actions">
|
||||
<a-button @click="handleRefresh" :loading="loading">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button @click="handleExport" :disabled="selectedRowKeys.length === 0">
|
||||
<template #icon>
|
||||
<ExportOutlined />
|
||||
</template>
|
||||
导出日志
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选区域 -->
|
||||
<div class="filter-bar">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-select
|
||||
v-model="filters.module"
|
||||
placeholder="选择模块"
|
||||
allowClear
|
||||
style="width: 100%"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<a-select-option value="farm-management">养殖场管理</a-select-option>
|
||||
<a-select-option value="animal-management">动物管理</a-select-option>
|
||||
<a-select-option value="device-management">设备管理</a-select-option>
|
||||
<a-select-option value="alert-management">预警管理</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-select
|
||||
v-model="filters.action"
|
||||
placeholder="选择操作"
|
||||
allowClear
|
||||
style="width: 100%"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<a-select-option value="form_submit_start">表单提交开始</a-select-option>
|
||||
<a-select-option value="form_validation_success">验证成功</a-select-option>
|
||||
<a-select-option value="form_validation_failed">验证失败</a-select-option>
|
||||
<a-select-option value="form_create_success">创建成功</a-select-option>
|
||||
<a-select-option value="form_edit_success">编辑成功</a-select-option>
|
||||
<a-select-option value="field_change">字段变化</a-select-option>
|
||||
<a-select-option value="search">搜索操作</a-select-option>
|
||||
<a-select-option value="modal_open">打开模态框</a-select-option>
|
||||
<a-select-option value="modal_cancel">取消操作</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-select
|
||||
v-model="filters.status"
|
||||
placeholder="选择状态"
|
||||
allowClear
|
||||
style="width: 100%"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<a-select-option value="success">成功</a-select-option>
|
||||
<a-select-option value="error">错误</a-select-option>
|
||||
<a-select-option value="warning">警告</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-range-picker
|
||||
v-model="filters.dateRange"
|
||||
style="width: 100%"
|
||||
@change="handleFilterChange"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16" style="margin-top: 16px;">
|
||||
<a-col :span="12">
|
||||
<a-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="搜索用户名、操作或模块"
|
||||
@pressEnter="handleSearch"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<template #suffix>
|
||||
<SearchOutlined @click="handleSearch" style="cursor: pointer;" />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-button @click="handleSearch" type="primary">
|
||||
搜索
|
||||
</a-button>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-button @click="handleResetFilters">
|
||||
重置筛选
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息卡片 -->
|
||||
<div class="stats-cards">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="总日志数"
|
||||
:value="stats.totalLogs"
|
||||
:loading="statsLoading"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="成功操作"
|
||||
:value="stats.successCount"
|
||||
:loading="statsLoading"
|
||||
value-style="color: #52c41a"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="错误操作"
|
||||
:value="stats.errorCount"
|
||||
:loading="statsLoading"
|
||||
value-style="color: #ff4d4f"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="活跃用户"
|
||||
:value="stats.activeUsers"
|
||||
:loading="statsLoading"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<div class="table-container">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:pagination="pagination"
|
||||
:loading="loading"
|
||||
:row-selection="rowSelection"
|
||||
class="log-table"
|
||||
size="middle"
|
||||
:scroll="{ x: 1200 }"
|
||||
>
|
||||
<!-- 状态列 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleViewDetail(record)">
|
||||
查看详情
|
||||
</a-button>
|
||||
<a-button type="link" size="small" danger @click="handleDelete(record)">
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<template v-if="column.key === 'formData'">
|
||||
<a-button type="link" size="small" @click="handleViewFormData(record)">
|
||||
查看数据
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<a-modal
|
||||
:open="detailModalVisible"
|
||||
@update:open="detailModalVisible = $event"
|
||||
title="日志详情"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
>
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="日志ID">{{ currentRecord.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="模块">{{ currentRecord.module }}</a-descriptions-item>
|
||||
<a-descriptions-item label="操作">{{ currentRecord.action }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="getStatusColor(currentRecord.status)">
|
||||
{{ getStatusText(currentRecord.status) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="用户">{{ currentRecord.username || '未知' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="用户ID">{{ currentRecord.userId || '未知' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="会话ID">{{ currentRecord.sessionId || '未知' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="IP地址">{{ currentRecord.ipAddress || '未知' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="屏幕分辨率">{{ currentRecord.screenResolution || '未知' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="时间戳">{{ currentRecord.timestamp }}</a-descriptions-item>
|
||||
<a-descriptions-item label="当前URL" :span="2">
|
||||
<a :href="currentRecord.currentUrl" target="_blank" v-if="currentRecord.currentUrl">
|
||||
{{ currentRecord.currentUrl }}
|
||||
</a>
|
||||
<span v-else>未知</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="用户代理" :span="2">
|
||||
<div style="word-break: break-all; max-height: 100px; overflow-y: auto;">
|
||||
{{ currentRecord.userAgent || '未知' }}
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="错误信息" :span="2" v-if="currentRecord.errorMessage">
|
||||
<div style="color: #ff4d4f;">
|
||||
{{ currentRecord.errorMessage }}
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-modal>
|
||||
|
||||
<!-- 表单数据弹窗 -->
|
||||
<a-modal
|
||||
:open="formDataModalVisible"
|
||||
@update:open="formDataModalVisible = $event"
|
||||
title="表单数据"
|
||||
width="600px"
|
||||
:footer="null"
|
||||
>
|
||||
<pre style="background: #f5f5f5; padding: 16px; border-radius: 4px; max-height: 400px; overflow-y: auto;">{{ JSON.stringify(currentRecord.formData, null, 2) }}</pre>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
ExportOutlined,
|
||||
SearchOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const statsLoading = ref(false)
|
||||
const detailModalVisible = ref(false)
|
||||
const formDataModalVisible = ref(false)
|
||||
const selectedRowKeys = ref([])
|
||||
const currentRecord = ref({})
|
||||
|
||||
// 筛选条件
|
||||
const filters = reactive({
|
||||
module: null,
|
||||
action: null,
|
||||
status: null,
|
||||
dateRange: null,
|
||||
keyword: ''
|
||||
})
|
||||
|
||||
// 统计数据
|
||||
const stats = reactive({
|
||||
totalLogs: 0,
|
||||
successCount: 0,
|
||||
errorCount: 0,
|
||||
activeUsers: 0
|
||||
})
|
||||
|
||||
// 表格数据
|
||||
const tableData = ref([])
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '模块',
|
||||
dataIndex: 'module',
|
||||
key: 'module',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '表单数据',
|
||||
dataIndex: 'formData',
|
||||
key: 'formData',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条/共 ${total} 条`
|
||||
})
|
||||
|
||||
// 行选择配置
|
||||
const rowSelection = {
|
||||
selectedRowKeys: selectedRowKeys,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys
|
||||
}
|
||||
}
|
||||
|
||||
// 方法
|
||||
const getStatusColor = (status) => {
|
||||
const colorMap = {
|
||||
'success': 'green',
|
||||
'error': 'red',
|
||||
'warning': 'orange'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const textMap = {
|
||||
'success': '成功',
|
||||
'error': '错误',
|
||||
'warning': '警告'
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
// 数据加载方法
|
||||
const loadData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const params = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
...filters
|
||||
}
|
||||
|
||||
// 处理日期范围
|
||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||
params.startDate = filters.dateRange[0].format('YYYY-MM-DD')
|
||||
params.endDate = filters.dateRange[1].format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
console.group('📋 [日志管理] 加载数据')
|
||||
console.log('🕒 时间:', new Date().toLocaleString())
|
||||
console.log('📊 查询参数:', params)
|
||||
console.groupEnd()
|
||||
|
||||
const response = await api.formLogs.getList(params)
|
||||
|
||||
if (response.success) {
|
||||
tableData.value = response.data.list.map(item => ({
|
||||
...item,
|
||||
key: item.id
|
||||
}))
|
||||
pagination.total = response.data.total
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [日志管理] 加载数据失败:', error)
|
||||
message.error('加载数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载统计数据
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
statsLoading.value = true
|
||||
const params = {}
|
||||
|
||||
// 处理日期范围
|
||||
if (filters.dateRange && filters.dateRange.length === 2) {
|
||||
params.startDate = filters.dateRange[0].format('YYYY-MM-DD')
|
||||
params.endDate = filters.dateRange[1].format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
const response = await api.formLogs.getStats(params)
|
||||
|
||||
if (response.success) {
|
||||
const data = response.data
|
||||
stats.totalLogs = data.totalLogs || 0
|
||||
stats.successCount = data.statusStats?.find(s => s.status === 'success')?.count || 0
|
||||
stats.errorCount = data.statusStats?.find(s => s.status === 'error')?.count || 0
|
||||
stats.activeUsers = data.userStats?.length || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [日志管理] 加载统计失败:', error)
|
||||
} finally {
|
||||
statsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadData()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
pagination.current = 1
|
||||
loadData()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
const handleResetFilters = () => {
|
||||
Object.assign(filters, {
|
||||
module: null,
|
||||
action: null,
|
||||
status: null,
|
||||
dateRange: null,
|
||||
keyword: ''
|
||||
})
|
||||
handleFilterChange()
|
||||
}
|
||||
|
||||
const handleViewDetail = (record) => {
|
||||
currentRecord.value = record
|
||||
detailModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleViewFormData = (record) => {
|
||||
currentRecord.value = record
|
||||
formDataModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除日志"${record.id}"吗?`,
|
||||
async onOk() {
|
||||
try {
|
||||
await api.formLogs.delete(record.id)
|
||||
message.success('删除成功')
|
||||
loadData()
|
||||
loadStats()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleExport = () => {
|
||||
message.success('导出功能开发中')
|
||||
// 实现导出逻辑
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-log-management {
|
||||
padding: 24px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.log-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.log-table :deep(.ant-table-thead > tr > th) {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.log-table :deep(.ant-table-tbody > tr > td) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.log-table :deep(.ant-table-tbody > tr:hover > td) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filter-bar .ant-row .ant-col {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-log-management {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
24
admin-system/src/views/Home.vue
Normal file
24
admin-system/src/views/Home.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<div>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-card title="用户总数" style="text-align: center">
|
||||
<h2>1,234</h2>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-card title="产品总数" style="text-align: center">
|
||||
<h2>567</h2>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-card title="订单总数" style="text-align: center">
|
||||
<h2>890</h2>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
518
admin-system/src/views/Login.vue
Normal file
518
admin-system/src/views/Login.vue
Normal file
@@ -0,0 +1,518 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<!-- 左侧标题 -->
|
||||
<div class="left-title">
|
||||
<h1 class="main-title">智慧宁夏牧场</h1>
|
||||
</div>
|
||||
|
||||
<!-- 玻璃态登录卡片 -->
|
||||
<div class="glass-card">
|
||||
<div class="card-header">
|
||||
<h1 class="login-title">用户登录</h1>
|
||||
</div>
|
||||
|
||||
<!-- 信息提示框 -->
|
||||
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="error" class="error-alert">
|
||||
<span class="error-icon">⚠️</span>
|
||||
<span class="error-message">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<!-- 用户名输入框 -->
|
||||
<div class="input-group">
|
||||
<label class="input-label">
|
||||
<span class="required">*</span> 用户名:
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="input-icon">👨💼</span>
|
||||
<input
|
||||
v-model="formState.username"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 密码输入框 -->
|
||||
<div class="input-group">
|
||||
<label class="input-label">
|
||||
<span class="required">*</span> 密码:
|
||||
</label>
|
||||
<div class="input-wrapper">
|
||||
<span class="input-icon">🔒</span>
|
||||
<input
|
||||
v-model="formState.password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
class="form-input"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="password-toggle"
|
||||
@click="togglePassword"
|
||||
>
|
||||
{{ showPassword ? '👁️' : '🚫' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<button
|
||||
type="submit"
|
||||
class="login-button"
|
||||
:disabled="loading"
|
||||
>
|
||||
<span v-if="loading" class="loading-spinner"></span>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useUserStore } from '../stores';
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const showPassword = ref(false);
|
||||
|
||||
const formState = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
// 切换密码显示/隐藏
|
||||
const togglePassword = () => {
|
||||
showPassword.value = !showPassword.value;
|
||||
};
|
||||
|
||||
// 页面加载时检查token状态
|
||||
onMounted(async () => {
|
||||
// 如果已有有效token,直接跳转到仪表盘
|
||||
if (userStore.token) {
|
||||
try {
|
||||
const isValid = await userStore.validateToken();
|
||||
if (isValid) {
|
||||
const redirectPath = router.currentRoute.value.query.redirect || '/dashboard';
|
||||
router.push(redirectPath);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Token验证失败,需要重新登录');
|
||||
userStore.logout(); // 清除无效token
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleLogin = async (values) => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
// 使用表单数据进行登录
|
||||
const username = values?.username || formState.username;
|
||||
const password = values?.password || formState.password;
|
||||
|
||||
// 验证表单数据
|
||||
if (!username || !password) {
|
||||
error.value = '请输入用户名和密码';
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('开始登录,用户名:', username);
|
||||
|
||||
// 使用Pinia用户存储进行登录
|
||||
const result = await userStore.login(username, password);
|
||||
|
||||
console.log('登录结果:', result);
|
||||
|
||||
if (result.success) {
|
||||
// 登录成功提示
|
||||
message.success(`登录成功,欢迎 ${userStore.userData.username}`);
|
||||
|
||||
// 获取重定向路径(如果有)
|
||||
const redirectPath = router.currentRoute.value.query.redirect || '/dashboard';
|
||||
|
||||
// 跳转到重定向路径或仪表盘页面
|
||||
router.push(redirectPath);
|
||||
} else {
|
||||
error.value = result.message || '登录失败';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('登录错误:', e);
|
||||
error.value = e.message || '登录失败,请检查网络连接和后端服务状态';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 主容器 - 图片背景 */
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
background: url('/cows.jpg');
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: fixed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 20px 160px 20px 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 左侧标题 */
|
||||
.left-title {
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 3;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
color: #ffffff;
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 16px 0;
|
||||
text-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
|
||||
letter-spacing: 2px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
|
||||
letter-spacing: 1px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* 玻璃态卡片 */
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(25px);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
box-shadow:
|
||||
0 8px 32px 0 rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
min-width: 350px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
transform: translateY(-3px);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
box-shadow:
|
||||
0 12px 40px 0 rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* 信息提示框 */
|
||||
.info-box {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
backdrop-filter: blur(20px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.info-text:first-child {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 错误提示 */
|
||||
.error-alert {
|
||||
background: rgba(255, 77, 79, 0.2);
|
||||
border: 1px solid rgba(255, 77, 79, 0.4);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 登录表单 */
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* 输入组 */
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #ff4d4f;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 输入框包装器 */
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
font-size: 16px;
|
||||
z-index: 1;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 8px;
|
||||
padding: 0 16px 0 40px;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
backdrop-filter: blur(20px);
|
||||
transition: all 0.3s ease;
|
||||
outline: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(255, 255, 255, 0.4),
|
||||
0 4px 16px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 密码切换按钮 */
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
transition: color 0.3s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: rgba(255, 255, 255, 1);
|
||||
}
|
||||
|
||||
/* 登录按钮 */
|
||||
.login-button {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: linear-gradient(90deg, #4CAF50 0%, #45a049 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 16px rgba(76, 175, 80, 0.4);
|
||||
}
|
||||
|
||||
.login-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.login-button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(76, 175, 80, 0.6);
|
||||
}
|
||||
|
||||
.login-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.login-container {
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.left-title {
|
||||
position: relative;
|
||||
top: auto;
|
||||
left: auto;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.main-title {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
max-width: 100%;
|
||||
min-width: auto;
|
||||
padding: 30px 20px;
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.glass-card {
|
||||
padding: 30px 20px;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 深色模式支持 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.glass-card {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
165
admin-system/src/views/MapView.vue
Normal file
165
admin-system/src/views/MapView.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="map-view">
|
||||
<div class="map-header">
|
||||
<h1>养殖场分布地图</h1>
|
||||
<div class="map-controls">
|
||||
<a-select
|
||||
v-model="selectedFarmType"
|
||||
style="width: 200px"
|
||||
placeholder="选择养殖场类型"
|
||||
@change="handleFarmTypeChange"
|
||||
>
|
||||
<a-select-option value="all">全部养殖场</a-select-option>
|
||||
<a-select-option value="cattle">牛养殖场</a-select-option>
|
||||
<a-select-option value="sheep">羊养殖场</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<a-button type="primary" @click="refreshMap">
|
||||
<template #icon><reload-outlined /></template>
|
||||
刷新数据
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-container">
|
||||
<baidu-map
|
||||
:markers="filteredMarkers"
|
||||
height="calc(100vh - 180px)"
|
||||
@marker-click="handleFarmClick"
|
||||
@map-ready="handleMapReady"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 养殖场详情抽屉 -->
|
||||
<a-drawer
|
||||
:title="`养殖场详情 - ${selectedFarmId ? dataStore.farms.find(f => f.id == selectedFarmId)?.name : ''}`"
|
||||
:width="600"
|
||||
:visible="drawerVisible"
|
||||
@close="closeDrawer"
|
||||
:bodyStyle="{ paddingBottom: '80px' }"
|
||||
>
|
||||
<farm-detail v-if="selectedFarmId" :farm-id="selectedFarmId" />
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useDataStore } from '../stores'
|
||||
import { convertFarmsToMarkers } from '../utils/mapService'
|
||||
import BaiduMap from '../components/BaiduMap.vue'
|
||||
import FarmDetail from '../components/FarmDetail.vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
// 数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 地图实例
|
||||
let mapInstance = null
|
||||
|
||||
// 养殖场类型筛选
|
||||
const selectedFarmType = ref('all')
|
||||
|
||||
// 抽屉控制
|
||||
const drawerVisible = ref(false)
|
||||
const selectedFarmId = ref(null)
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 养殖场标记
|
||||
const farmMarkers = computed(() => {
|
||||
return convertFarmsToMarkers(dataStore.farms)
|
||||
})
|
||||
|
||||
// 根据类型筛选标记
|
||||
const filteredMarkers = computed(() => {
|
||||
if (selectedFarmType.value === 'all') {
|
||||
return farmMarkers.value
|
||||
}
|
||||
|
||||
// 根据养殖场类型筛选
|
||||
// 这里假设养殖场数据中有type字段,实际项目中需要根据真实数据结构调整
|
||||
return farmMarkers.value.filter(marker => {
|
||||
const farm = marker.originalData
|
||||
if (selectedFarmType.value === 'cattle') {
|
||||
// 筛选牛养殖场
|
||||
return farm.type === 'cattle' || farm.name.includes('牛')
|
||||
} else if (selectedFarmType.value === 'sheep') {
|
||||
// 筛选羊养殖场
|
||||
return farm.type === 'sheep' || farm.name.includes('羊')
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// 处理地图就绪事件
|
||||
function handleMapReady(map) {
|
||||
mapInstance = map
|
||||
message.success('地图加载完成')
|
||||
}
|
||||
|
||||
// 处理养殖场类型变化
|
||||
function handleFarmTypeChange(value) {
|
||||
message.info(`已筛选${value === 'all' ? '全部' : value === 'cattle' ? '牛' : '羊'}养殖场`)
|
||||
}
|
||||
|
||||
// 处理养殖场标记点击事件
|
||||
function handleFarmClick(markerData) {
|
||||
// 设置选中的养殖场ID
|
||||
selectedFarmId.value = markerData.originalData.id
|
||||
// 打开抽屉
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
// 关闭抽屉
|
||||
function closeDrawer() {
|
||||
drawerVisible.value = false
|
||||
}
|
||||
|
||||
// 刷新地图数据
|
||||
async function refreshMap() {
|
||||
loading.value = true
|
||||
try {
|
||||
await dataStore.fetchFarms()
|
||||
message.success('地图数据已更新')
|
||||
} catch (error) {
|
||||
message.error('更新地图数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载后初始化数据
|
||||
onMounted(async () => {
|
||||
if (dataStore.farms.length === 0) {
|
||||
await dataStore.fetchAllData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-view {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
173
admin-system/src/views/MapZoomDemo.vue
Normal file
173
admin-system/src/views/MapZoomDemo.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="map-zoom-demo">
|
||||
<div class="demo-header">
|
||||
<h2>地图缩放功能演示</h2>
|
||||
<p>这个页面展示了地图组件的各种缩放功能</p>
|
||||
</div>
|
||||
|
||||
<div class="demo-controls">
|
||||
<div class="control-group">
|
||||
<label>显示缩放级别:</label>
|
||||
<input type="checkbox" v-model="showZoomLevel" />
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>当前缩放级别:</label>
|
||||
<span class="zoom-info">{{ currentZoom }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-container">
|
||||
<BaiduMap
|
||||
:markers="demoMarkers"
|
||||
:show-zoom-level="showZoomLevel"
|
||||
:height="'500px'"
|
||||
@map-ready="onMapReady"
|
||||
@marker-click="onMarkerClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="demo-instructions">
|
||||
<h3>缩放功能说明:</h3>
|
||||
<ul>
|
||||
<li><strong>右上角缩放按钮:</strong>
|
||||
<ul>
|
||||
<li>「+」按钮:放大地图</li>
|
||||
<li>「−」按钮:缩小地图</li>
|
||||
<li>「⌂」按钮:重置到默认缩放级别和中心点</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>鼠标滚轮:</strong>向上滚动放大,向下滚动缩小</li>
|
||||
<li><strong>双击地图:</strong>放大一个级别</li>
|
||||
<li><strong>键盘控制:</strong>使用方向键移动地图,+/- 键缩放</li>
|
||||
<li><strong>缩放级别显示:</strong>右下角显示当前缩放级别(可通过复选框控制显示/隐藏)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import BaiduMap from '../components/BaiduMap.vue'
|
||||
|
||||
// 响应式数据
|
||||
const showZoomLevel = ref(true)
|
||||
const mapInstance = ref(null)
|
||||
|
||||
// 演示标记数据
|
||||
const demoMarkers = ref([
|
||||
{
|
||||
location: { lng: 106.27, lat: 38.47 },
|
||||
title: '银川市',
|
||||
content: '宁夏回族自治区首府'
|
||||
},
|
||||
{
|
||||
location: { lng: 106.15, lat: 38.35 },
|
||||
title: '永宁县',
|
||||
content: '银川市下辖县'
|
||||
},
|
||||
{
|
||||
location: { lng: 106.45, lat: 38.55 },
|
||||
title: '贺兰县',
|
||||
content: '银川市下辖县'
|
||||
}
|
||||
])
|
||||
|
||||
// 计算当前缩放级别
|
||||
const currentZoom = computed(() => {
|
||||
return mapInstance.value ? mapInstance.value.currentZoom : '未知'
|
||||
})
|
||||
|
||||
// 地图就绪事件
|
||||
const onMapReady = (map) => {
|
||||
console.log('地图就绪:', map)
|
||||
mapInstance.value = map
|
||||
}
|
||||
|
||||
// 标记点击事件
|
||||
const onMarkerClick = (markerData, marker) => {
|
||||
console.log('标记被点击:', markerData)
|
||||
alert(`点击了标记: ${markerData.title}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-zoom-demo {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.demo-header h2 {
|
||||
color: #1890ff;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.zoom-info {
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
border: 2px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.demo-instructions {
|
||||
background: #fafafa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #1890ff;
|
||||
}
|
||||
|
||||
.demo-instructions h3 {
|
||||
color: #1890ff;
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.demo-instructions ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.demo-instructions li {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.demo-instructions ul ul {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
</style>
|
||||
534
admin-system/src/views/Monitor.vue
Normal file
534
admin-system/src/views/Monitor.vue
Normal file
@@ -0,0 +1,534 @@
|
||||
<template>
|
||||
<div class="monitor-page">
|
||||
<a-page-header
|
||||
title="实时监控"
|
||||
sub-title="宁夏智慧养殖监管平台实时监控"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-select
|
||||
v-model="selectedFarm"
|
||||
style="width: 200px"
|
||||
placeholder="选择养殖场"
|
||||
@change="handleFarmChange"
|
||||
>
|
||||
<a-select-option value="all">全部养殖场</a-select-option>
|
||||
<a-select-option v-for="farm in dataStore.farms" :key="farm.id" :value="farm.id">
|
||||
{{ farm.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" @click="refreshAllData">
|
||||
<template #icon><reload-outlined /></template>
|
||||
刷新数据
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<!-- 性能监控组件 -->
|
||||
<chart-performance-monitor style="display: none;" />
|
||||
|
||||
<div class="monitor-content">
|
||||
<!-- 实时数据概览 -->
|
||||
<div class="monitor-stats">
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<span>温度监控</span>
|
||||
<a-tag color="orange" style="margin-left: 8px">{{ getLatestValue(temperatureData) }}°C</a-tag>
|
||||
</template>
|
||||
<monitor-chart
|
||||
title="温度变化趋势"
|
||||
type="line"
|
||||
:data="temperatureData"
|
||||
height="250px"
|
||||
:refresh-interval="30000"
|
||||
cache-key="temperature-chart"
|
||||
:enable-cache="true"
|
||||
:cache-ttl="60000"
|
||||
@refresh="refreshTemperatureData"
|
||||
/>
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<span>湿度监控</span>
|
||||
<a-tag color="blue" style="margin-left: 8px">{{ getLatestValue(humidityData) }}%</a-tag>
|
||||
</template>
|
||||
<monitor-chart
|
||||
title="湿度变化趋势"
|
||||
type="line"
|
||||
:data="humidityData"
|
||||
height="250px"
|
||||
:refresh-interval="30000"
|
||||
cache-key="humidity-chart"
|
||||
:enable-cache="true"
|
||||
:cache-ttl="60000"
|
||||
@refresh="refreshHumidityData"
|
||||
/>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<div class="monitor-stats">
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<span>设备状态</span>
|
||||
<a-tag color="green" style="margin-left: 8px">在线率: {{ deviceOnlineRate }}%</a-tag>
|
||||
</template>
|
||||
<monitor-chart
|
||||
title="设备状态分布"
|
||||
type="pie"
|
||||
:data="deviceStatusData"
|
||||
height="250px"
|
||||
:refresh-interval="60000"
|
||||
cache-key="device-status-chart"
|
||||
:enable-cache="true"
|
||||
:cache-ttl="120000"
|
||||
@refresh="refreshDeviceData"
|
||||
/>
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<span>预警监控</span>
|
||||
<a-tag color="red" style="margin-left: 8px">{{ alertCount }}个预警</a-tag>
|
||||
</template>
|
||||
<monitor-chart
|
||||
title="预警类型分布"
|
||||
type="pie"
|
||||
:data="alertTypeData"
|
||||
height="250px"
|
||||
:refresh-interval="60000"
|
||||
cache-key="alert-type-chart"
|
||||
:enable-cache="true"
|
||||
:cache-ttl="120000"
|
||||
@refresh="refreshAlertData"
|
||||
/>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 实时预警列表 -->
|
||||
<a-card title="实时预警信息" :bordered="false" class="alert-card">
|
||||
<a-table
|
||||
:dataSource="alertTableData"
|
||||
:columns="alertColumns"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
:loading="dataStore.loading.alerts"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'type'">
|
||||
{{ getAlertTypeText(record.type) }}
|
||||
</template>
|
||||
<template v-if="column.key === 'level'">
|
||||
<a-tag :color="getAlertLevelColor(record.level)">
|
||||
{{ getAlertLevelText(record.level) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'created_at'">
|
||||
{{ formatTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="handleAlert(record.id)">
|
||||
处理
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { useDataStore } from '../stores'
|
||||
import MonitorChart from '../components/MonitorChart.vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { api } from '../utils/api'
|
||||
import ChartPerformanceMonitor from '../components/ChartPerformanceMonitor.vue'
|
||||
import { DataCache } from '../utils/chartService'
|
||||
|
||||
// 使用数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 选中的养殖场
|
||||
const selectedFarm = ref('all')
|
||||
|
||||
// 处理养殖场变化
|
||||
function handleFarmChange(farmId) {
|
||||
refreshAllData()
|
||||
}
|
||||
|
||||
// 刷新所有数据(优化版本)
|
||||
async function refreshAllData() {
|
||||
// 清理相关缓存以强制刷新
|
||||
DataCache.delete(`temperature_data_${selectedFarm.value}`)
|
||||
DataCache.delete(`humidity_data_${selectedFarm.value}`)
|
||||
|
||||
await dataStore.fetchAllData()
|
||||
refreshTemperatureData()
|
||||
refreshHumidityData()
|
||||
refreshDeviceData()
|
||||
refreshAlertData()
|
||||
|
||||
message.success('数据刷新完成')
|
||||
}
|
||||
|
||||
// 温度数据
|
||||
const temperatureData = reactive({
|
||||
xAxis: [],
|
||||
series: [{
|
||||
name: '温度',
|
||||
data: [],
|
||||
itemStyle: { color: '#ff7a45' },
|
||||
areaStyle: { opacity: 0.2 }
|
||||
}]
|
||||
})
|
||||
|
||||
// 湿度数据
|
||||
const humidityData = reactive({
|
||||
xAxis: [],
|
||||
series: [{
|
||||
name: '湿度',
|
||||
data: [],
|
||||
itemStyle: { color: '#1890ff' },
|
||||
areaStyle: { opacity: 0.2 }
|
||||
}]
|
||||
})
|
||||
|
||||
// 设备状态数据
|
||||
const deviceStatusData = computed(() => {
|
||||
const devices = selectedFarm.value === 'all'
|
||||
? dataStore.devices
|
||||
: dataStore.devices.filter(d => (d.farm_id || d.farmId) == selectedFarm.value)
|
||||
|
||||
const online = devices.filter(d => d.status === 'online').length
|
||||
const offline = devices.length - online
|
||||
|
||||
return [
|
||||
{ value: online, name: '在线设备', itemStyle: { color: '#52c41a' } },
|
||||
{ value: offline, name: '离线设备', itemStyle: { color: '#d9d9d9' } }
|
||||
]
|
||||
})
|
||||
|
||||
// 设备在线率
|
||||
const deviceOnlineRate = computed(() => {
|
||||
const devices = selectedFarm.value === 'all'
|
||||
? dataStore.devices
|
||||
: dataStore.devices.filter(d => (d.farm_id || d.farmId) == selectedFarm.value)
|
||||
|
||||
if (devices.length === 0) return 0
|
||||
|
||||
const online = devices.filter(d => d.status === 'online').length
|
||||
return ((online / devices.length) * 100).toFixed(1)
|
||||
})
|
||||
|
||||
// 预警类型数据
|
||||
const alertTypeData = computed(() => {
|
||||
const alerts = selectedFarm.value === 'all'
|
||||
? dataStore.alerts
|
||||
: dataStore.alerts.filter(a => a.farm_id == selectedFarm.value)
|
||||
|
||||
// 按类型分组统计(使用中文类型名)
|
||||
const typeCount = {}
|
||||
alerts.forEach(alert => {
|
||||
const chineseType = getAlertTypeText(alert.type)
|
||||
typeCount[chineseType] = (typeCount[chineseType] || 0) + 1
|
||||
})
|
||||
|
||||
// 转换为图表数据格式
|
||||
return Object.keys(typeCount).map(type => ({
|
||||
value: typeCount[type],
|
||||
name: type,
|
||||
itemStyle: {
|
||||
color: type.includes('温度') ? '#ff4d4f' :
|
||||
type.includes('湿度') ? '#1890ff' :
|
||||
type.includes('设备') ? '#faad14' : '#52c41a'
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
// 预警数量
|
||||
const alertCount = computed(() => {
|
||||
return selectedFarm.value === 'all'
|
||||
? dataStore.alertCount
|
||||
: dataStore.alerts.filter(a => a.farm_id == selectedFarm.value).length
|
||||
})
|
||||
|
||||
// 预警表格数据
|
||||
const alertTableData = computed(() => {
|
||||
const alerts = selectedFarm.value === 'all'
|
||||
? dataStore.alerts
|
||||
: dataStore.alerts.filter(a => a.farm_id == selectedFarm.value)
|
||||
|
||||
return alerts.map(alert => ({
|
||||
key: alert.id,
|
||||
id: alert.id,
|
||||
type: getAlertTypeText(alert.type),
|
||||
level: alert.level,
|
||||
farmId: alert.farm_id,
|
||||
farmName: dataStore.farms.find(f => f.id == alert.farm_id)?.name || '',
|
||||
created_at: alert.created_at
|
||||
}))
|
||||
})
|
||||
|
||||
// 预警表格列定义
|
||||
const alertColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '预警类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type'
|
||||
},
|
||||
{
|
||||
title: '预警级别',
|
||||
dataIndex: 'level',
|
||||
key: 'level'
|
||||
},
|
||||
{
|
||||
title: '养殖场',
|
||||
dataIndex: 'farmName',
|
||||
key: 'farmName'
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
sorter: (a, b) => new Date(a.created_at) - new Date(b.created_at),
|
||||
defaultSortOrder: 'descend'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取预警级别颜色
|
||||
function getAlertLevelColor(level) {
|
||||
switch (level) {
|
||||
case 'high': return 'red'
|
||||
case 'medium': return 'orange'
|
||||
case 'low': return 'blue'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取预警级别文本
|
||||
function getAlertLevelText(level) {
|
||||
switch (level) {
|
||||
case 'high': return '高'
|
||||
case 'medium': return '中'
|
||||
case 'low': return '低'
|
||||
default: return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取预警类型文本(英文转中文)
|
||||
function getAlertTypeText(type) {
|
||||
const texts = {
|
||||
temperature_alert: '温度异常',
|
||||
humidity_alert: '湿度异常',
|
||||
feed_alert: '饲料异常',
|
||||
health_alert: '健康异常',
|
||||
device_alert: '设备异常',
|
||||
temperature: '温度异常',
|
||||
humidity: '湿度异常',
|
||||
device_failure: '设备故障',
|
||||
animal_health: '动物健康',
|
||||
security: '安全警报',
|
||||
maintenance: '维护提醒',
|
||||
other: '其他'
|
||||
}
|
||||
return texts[type] || type
|
||||
}
|
||||
|
||||
// 格式化时间显示
|
||||
function formatTime(timestamp) {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
// 如果是今天
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 如果是昨天
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return '昨天 ' + date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 如果是一周内
|
||||
if (diff < 7 * 24 * 60 * 60 * 1000) {
|
||||
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
return weekdays[date.getDay()] + ' ' + date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 其他情况显示完整日期
|
||||
return date.toLocaleDateString('zh-CN') + ' ' + date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 处理预警
|
||||
function handleAlert(alertId) {
|
||||
message.success(`已处理预警 #${alertId}`)
|
||||
}
|
||||
|
||||
// 刷新温度数据(优化版本)
|
||||
async function refreshTemperatureData() {
|
||||
const cacheKey = `temperature_data_${selectedFarm.value}`
|
||||
|
||||
try {
|
||||
// 检查缓存
|
||||
let data = DataCache.get(cacheKey)
|
||||
|
||||
if (!data) {
|
||||
// 调用后端监控数据API
|
||||
data = await api.get('/stats/public/monitoring')
|
||||
|
||||
// 缓存数据(2分钟)
|
||||
if (data) {
|
||||
DataCache.set(cacheKey, data, 2 * 60 * 1000)
|
||||
}
|
||||
} else {
|
||||
console.log('使用缓存的温度数据')
|
||||
}
|
||||
|
||||
// api.get返回的是result.data,直接检查数据结构
|
||||
if (data && data.environmentData && data.environmentData.temperature && data.environmentData.temperature.history) {
|
||||
const temperatureInfo = data.environmentData.temperature
|
||||
|
||||
// 使用后端返回的温度历史数据,格式化时间标签
|
||||
const timeLabels = temperatureInfo.history.map(item => formatTime(item.time))
|
||||
const tempValues = temperatureInfo.history.map(item => item.value)
|
||||
|
||||
console.log('温度数据处理成功:', { timeLabels, tempValues })
|
||||
|
||||
temperatureData.xAxis = timeLabels
|
||||
temperatureData.series[0].data = tempValues
|
||||
return
|
||||
}
|
||||
|
||||
// 如果API调用失败,显示错误信息
|
||||
console.error('无法获取温度数据,请检查后端服务')
|
||||
} catch (error) {
|
||||
console.error('获取温度数据出错:', error)
|
||||
// 显示错误状态
|
||||
temperatureData.xAxis = ['无数据']
|
||||
temperatureData.series[0].data = [0]
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新湿度数据(优化版本)
|
||||
async function refreshHumidityData() {
|
||||
const cacheKey = `humidity_data_${selectedFarm.value}`
|
||||
|
||||
try {
|
||||
// 检查缓存
|
||||
let data = DataCache.get(cacheKey)
|
||||
|
||||
if (!data) {
|
||||
// 调用后端监控数据API
|
||||
data = await api.get('/stats/public/monitoring')
|
||||
|
||||
// 缓存数据(2分钟)
|
||||
if (data) {
|
||||
DataCache.set(cacheKey, data, 2 * 60 * 1000)
|
||||
}
|
||||
} else {
|
||||
console.log('使用缓存的湿度数据')
|
||||
}
|
||||
|
||||
if (data && data.environmentData && data.environmentData.humidity && data.environmentData.humidity.history) {
|
||||
const timeLabels = data.environmentData.humidity.history.map(item => {
|
||||
const date = new Date(item.time);
|
||||
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
||||
});
|
||||
const humidityValues = data.environmentData.humidity.history.map(item => item.value);
|
||||
|
||||
humidityData.xAxis = timeLabels;
|
||||
humidityData.series[0].data = humidityValues;
|
||||
} else {
|
||||
console.error('无法获取湿度数据,请检查后端服务');
|
||||
humidityData.series[0].data = [];
|
||||
humidityData.xAxis = ['无数据'];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取湿度数据出错:', error)
|
||||
// 显示错误状态
|
||||
humidityData.xAxis = ['无数据']
|
||||
humidityData.series[0].data = [0]
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新设备数据
|
||||
function refreshDeviceData() {
|
||||
// 设备状态数据是计算属性,会自动更新
|
||||
}
|
||||
|
||||
// 刷新预警数据
|
||||
function refreshAlertData() {
|
||||
// 预警数据是计算属性,会自动更新
|
||||
}
|
||||
|
||||
// 获取最新值
|
||||
function getLatestValue(data) {
|
||||
const seriesData = data.series?.[0]?.data
|
||||
return seriesData?.length > 0 ? seriesData[seriesData.length - 1] : '0'
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(async () => {
|
||||
// 加载数据
|
||||
await dataStore.fetchAllData()
|
||||
|
||||
// 初始化图表数据
|
||||
refreshTemperatureData()
|
||||
refreshHumidityData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monitor-page {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.monitor-content {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.monitor-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.monitor-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
31
admin-system/src/views/NotFound.vue
Normal file
31
admin-system/src/views/NotFound.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<a-result
|
||||
status="404"
|
||||
title="404"
|
||||
sub-title="抱歉,您访问的页面不存在。"
|
||||
>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="$router.push('/')">
|
||||
返回首页
|
||||
</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
}
|
||||
</style>
|
||||
516
admin-system/src/views/OperationLogs.vue
Normal file
516
admin-system/src/views/OperationLogs.vue
Normal file
@@ -0,0 +1,516 @@
|
||||
<template>
|
||||
<div class="operation-logs-container">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h1>操作日志管理</h1>
|
||||
<p>查看和管理系统用户的操作记录</p>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选区域 -->
|
||||
<div class="search-section">
|
||||
<a-card title="搜索条件" class="search-card">
|
||||
<a-form
|
||||
:model="searchForm"
|
||||
layout="inline"
|
||||
@finish="handleSearch"
|
||||
>
|
||||
<a-form-item label="用户名">
|
||||
<a-input
|
||||
v-model:value="searchForm.username"
|
||||
placeholder="请输入用户名"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" html-type="submit" :loading="loading">
|
||||
<template #icon>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片区域 -->
|
||||
<div class="stats-section">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-card class="stats-card">
|
||||
<a-statistic
|
||||
title="总操作数"
|
||||
:value="stats.total"
|
||||
:value-style="{ color: '#1890ff' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card class="stats-card">
|
||||
<a-statistic
|
||||
title="新增操作"
|
||||
:value="stats.CREATE || 0"
|
||||
:value-style="{ color: '#52c41a' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card class="stats-card">
|
||||
<a-statistic
|
||||
title="编辑操作"
|
||||
:value="stats.UPDATE || 0"
|
||||
:value-style="{ color: '#faad14' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card class="stats-card">
|
||||
<a-statistic
|
||||
title="删除操作"
|
||||
:value="stats.DELETE || 0"
|
||||
:value-style="{ color: '#f5222d' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 操作日志表格 -->
|
||||
<div class="table-section">
|
||||
<a-card title="操作日志列表" class="table-card">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button @click="handleExport" :loading="exportLoading">
|
||||
<template #icon>
|
||||
<DownloadOutlined />
|
||||
</template>
|
||||
导出
|
||||
</a-button>
|
||||
<a-button @click="handleRefresh" :loading="loading">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="operationLogList"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<!-- 操作描述列 -->
|
||||
<template #operationDesc="{ record }">
|
||||
<a-tooltip :title="record.operation_desc" placement="topLeft">
|
||||
<span class="operation-desc">{{ record.operation_desc }}</span>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
DownloadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { api } from '@/utils/api'
|
||||
|
||||
export default {
|
||||
name: 'OperationLogs',
|
||||
components: {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
DownloadOutlined
|
||||
},
|
||||
setup() {
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
const operationLogList = ref([])
|
||||
const stats = ref({})
|
||||
|
||||
// 搜索表单
|
||||
const searchForm = reactive({
|
||||
username: ''
|
||||
})
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`
|
||||
})
|
||||
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 60,
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '操作用户',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
width: 120,
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '操作类型',
|
||||
dataIndex: 'operation_type',
|
||||
key: 'operation_type',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
customCell: (record) => {
|
||||
const typeMap = {
|
||||
'CREATE': '新增',
|
||||
'UPDATE': '编辑',
|
||||
'DELETE': '删除',
|
||||
'READ': '查看',
|
||||
'LOGIN': '登录',
|
||||
'LOGOUT': '登出'
|
||||
}
|
||||
const colorMap = {
|
||||
'CREATE': 'green',
|
||||
'UPDATE': 'blue',
|
||||
'DELETE': 'red',
|
||||
'READ': 'default',
|
||||
'LOGIN': 'cyan',
|
||||
'LOGOUT': 'orange'
|
||||
}
|
||||
return {
|
||||
style: { color: colorMap[record.operation_type] || 'default' }
|
||||
}
|
||||
},
|
||||
customRender: ({ record }) => {
|
||||
const typeMap = {
|
||||
'CREATE': '新增',
|
||||
'UPDATE': '编辑',
|
||||
'DELETE': '删除',
|
||||
'READ': '查看',
|
||||
'LOGIN': '登录',
|
||||
'LOGOUT': '登出'
|
||||
}
|
||||
return typeMap[record.operation_type] || record.operation_type
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '模块名称',
|
||||
dataIndex: 'module_name',
|
||||
key: 'module_name',
|
||||
width: 140,
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '操作描述',
|
||||
dataIndex: 'operation_desc',
|
||||
key: 'operation_desc',
|
||||
width: 200,
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
align: 'center'
|
||||
}
|
||||
]
|
||||
|
||||
// 计算属性
|
||||
const searchParams = computed(() => {
|
||||
return { ...searchForm }
|
||||
})
|
||||
|
||||
// 方法
|
||||
|
||||
|
||||
const loadOperationLogs = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const params = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
...searchParams.value
|
||||
}
|
||||
|
||||
const response = await api.operationLogs.getOperationLogs(params)
|
||||
if (response.success) {
|
||||
operationLogList.value = response.data
|
||||
pagination.total = response.pagination.total
|
||||
} else {
|
||||
message.error(response.message || '获取操作日志失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取操作日志失败:', error)
|
||||
message.error('获取操作日志失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const params = {
|
||||
type: 'overall',
|
||||
...searchParams.value
|
||||
}
|
||||
|
||||
const response = await api.operationLogs.getOperationStats(params)
|
||||
if (response.success) {
|
||||
stats.value = response.data
|
||||
stats.value.total = (stats.value.CREATE || 0) +
|
||||
(stats.value.UPDATE || 0) +
|
||||
(stats.value.DELETE || 0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
loadOperationLogs()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
searchForm.username = ''
|
||||
pagination.current = 1
|
||||
loadOperationLogs()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadOperationLogs()
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadOperationLogs()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
exportLoading.value = true
|
||||
const params = { ...searchParams.value }
|
||||
|
||||
const response = await api.operationLogs.exportOperationLogs(params)
|
||||
|
||||
// 创建下载链接
|
||||
const blob = new Blob([response], { type: 'text/csv;charset=utf-8' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `operation_logs_${new Date().getTime()}.csv`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(url)
|
||||
|
||||
message.success('导出成功')
|
||||
} catch (error) {
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadOperationLogs()
|
||||
loadStats()
|
||||
})
|
||||
|
||||
return {
|
||||
loading,
|
||||
exportLoading,
|
||||
operationLogList,
|
||||
stats,
|
||||
searchForm,
|
||||
pagination,
|
||||
columns,
|
||||
handleSearch,
|
||||
handleReset,
|
||||
handleTableChange,
|
||||
handleRefresh,
|
||||
handleExport
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.operation-logs-container {
|
||||
padding: 24px;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 0;
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 表格布局优化 */
|
||||
:deep(.ant-table) {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
padding: 16px 12px;
|
||||
font-size: 14px;
|
||||
border-bottom: 2px solid #e8e8e8;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
padding: 16px 12px;
|
||||
word-break: break-word;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:hover > td) {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 操作描述列样式 */
|
||||
.operation-desc {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
/* 表格行样式优化 */
|
||||
:deep(.ant-table-tbody > tr) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* 响应式布局 */
|
||||
@media (max-width: 1400px) {
|
||||
:deep(.ant-table-thead > tr > th),
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
padding: 12px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
:deep(.ant-table-thead > tr > th),
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
padding: 10px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:deep(.ant-table-thead > tr > th),
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
padding: 8px 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.data-content {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
background-color: #f5f5f5;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.data-content pre {
|
||||
margin: 0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #262626;
|
||||
}
|
||||
</style>
|
||||
684
admin-system/src/views/Orders.vue
Normal file
684
admin-system/src/views/Orders.vue
Normal file
@@ -0,0 +1,684 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>订单管理</h1>
|
||||
<a-space>
|
||||
<a-button @click="exportOrders" :loading="exportLoading">
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出数据
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加订单
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 8px; align-items: center;">
|
||||
<a-auto-complete
|
||||
v-model:value="searchUsername"
|
||||
:options="usernameOptions"
|
||||
placeholder="请选择或输入用户名进行搜索"
|
||||
style="width: 300px;"
|
||||
:filter-option="false"
|
||||
@search="handleSearchInput"
|
||||
@press-enter="searchOrdersByUsername"
|
||||
/>
|
||||
<a-button type="primary" @click="searchOrdersByUsername" :loading="searchLoading">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="resetSearch" :disabled="!isSearching">
|
||||
重置
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="orders"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'total_amount'">
|
||||
¥{{ record.total_amount }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'created_at'">
|
||||
{{ formatDate(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="viewOrder(record)">查看</a-button>
|
||||
<a-button type="link" @click="editOrder(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个订单吗?"
|
||||
@confirm="deleteOrder(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑订单模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑订单' : '添加订单'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
width="800px"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="用户" name="user_id">
|
||||
<a-select v-model:value="formData.user_id" placeholder="请选择用户" :loading="usersLoading">
|
||||
<a-select-option v-for="user in users" :key="user.id" :value="user.id">
|
||||
{{ user.username }} ({{ user.email }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="订单状态" name="status">
|
||||
<a-select v-model:value="formData.status" placeholder="请选择状态">
|
||||
<a-select-option value="pending">待处理</a-select-option>
|
||||
<a-select-option value="paid">已支付</a-select-option>
|
||||
<a-select-option value="shipped">已发货</a-select-option>
|
||||
<a-select-option value="delivered">已送达</a-select-option>
|
||||
<a-select-option value="cancelled">已取消</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="订单项目">
|
||||
<div v-for="(item, index) in formData.items" :key="index" style="border: 1px solid #d9d9d9; padding: 16px; margin-bottom: 8px; border-radius: 6px;">
|
||||
<a-row :gutter="16" align="middle">
|
||||
<a-col :span="8">
|
||||
<a-form-item :name="['items', index, 'product_id']" label="产品" :rules="[{ required: true, message: '请选择产品' }]">
|
||||
<a-select v-model:value="item.product_id" placeholder="请选择产品" :loading="productsLoading">
|
||||
<a-select-option v-for="product in products" :key="product.id" :value="product.id">
|
||||
{{ product.name }} (¥{{ product.price }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-form-item :name="['items', index, 'quantity']" label="数量" :rules="[{ required: true, message: '请输入数量' }]">
|
||||
<a-input-number v-model:value="item.quantity" :min="1" style="width: 100%" @change="calculateItemTotal(index)" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-form-item :name="['items', index, 'price']" label="单价" :rules="[{ required: true, message: '请输入单价' }]">
|
||||
<a-input-number v-model:value="item.price" :min="0" :precision="2" style="width: 100%" @change="calculateItemTotal(index)" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="3">
|
||||
<div style="margin-top: 30px;">小计: ¥{{ (item.quantity * item.price || 0).toFixed(2) }}</div>
|
||||
</a-col>
|
||||
<a-col :span="1">
|
||||
<a-button type="text" danger @click="removeItem(index)" style="margin-top: 30px;">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
<a-button type="dashed" @click="addItem" style="width: 100%; margin-top: 8px;">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加订单项
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<div style="text-align: right; font-size: 16px; font-weight: bold;">
|
||||
总金额: ¥{{ calculateTotalAmount() }}
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 查看订单详情模态框 -->
|
||||
<a-modal
|
||||
v-model:open="viewModalVisible"
|
||||
title="订单详情"
|
||||
:footer="null"
|
||||
width="800px"
|
||||
>
|
||||
<div v-if="viewOrderData">
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="订单ID">{{ viewOrderData.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="用户">{{ viewOrderData.user?.username || `用户ID: ${viewOrderData.user_id}` }}</a-descriptions-item>
|
||||
<a-descriptions-item label="总金额">¥{{ viewOrderData.total_amount }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="getStatusColor(viewOrderData.status)">
|
||||
{{ getStatusText(viewOrderData.status) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ formatDate(viewOrderData.created_at) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="更新时间">{{ formatDate(viewOrderData.updated_at) }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider>订单项目</a-divider>
|
||||
<a-table
|
||||
:columns="orderItemColumns"
|
||||
:data-source="viewOrderData.items || []"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'product_name'">
|
||||
{{ getProductName(record.product_id) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'price'">
|
||||
¥{{ record.price }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'total'">
|
||||
¥{{ (record.quantity * record.price).toFixed(2) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, DeleteOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
// 移除axios导入,使用统一的api工具
|
||||
import { api } from '../utils/api'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
SearchOutlined,
|
||||
ExportOutlined
|
||||
},
|
||||
setup() {
|
||||
const orders = ref([])
|
||||
const users = ref([])
|
||||
const products = ref([])
|
||||
const loading = ref(false)
|
||||
const usersLoading = ref(false)
|
||||
const productsLoading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const viewModalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
const viewOrderData = ref(null)
|
||||
|
||||
// 搜索相关的响应式数据
|
||||
const searchUsername = ref('')
|
||||
const searchLoading = ref(false)
|
||||
const isSearching = ref(false)
|
||||
const usernameOptions = ref([])
|
||||
|
||||
// 导出相关
|
||||
const exportLoading = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
user_id: undefined,
|
||||
status: 'pending',
|
||||
items: [{
|
||||
product_id: undefined,
|
||||
quantity: 1,
|
||||
price: 0
|
||||
}]
|
||||
})
|
||||
|
||||
const rules = {
|
||||
user_id: [{ required: true, message: '请选择用户' }],
|
||||
status: [{ required: true, message: '请选择状态' }]
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
customRender: ({ record }) => record.user?.username || `用户ID: ${record.user_id}`
|
||||
},
|
||||
{
|
||||
title: '总金额',
|
||||
dataIndex: 'total_amount',
|
||||
key: 'total_amount'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status'
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 200
|
||||
}
|
||||
]
|
||||
|
||||
const orderItemColumns = [
|
||||
{
|
||||
title: '产品',
|
||||
dataIndex: 'product_name',
|
||||
key: 'product_name'
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'quantity',
|
||||
key: 'quantity'
|
||||
},
|
||||
{
|
||||
title: '单价',
|
||||
dataIndex: 'price',
|
||||
key: 'price'
|
||||
},
|
||||
{
|
||||
title: '小计',
|
||||
dataIndex: 'total',
|
||||
key: 'total'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取所有订单
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await api.get('/orders')
|
||||
if (response.success) {
|
||||
orders.value = response.data || []
|
||||
} else {
|
||||
message.error('获取订单失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取订单失败:', error)
|
||||
message.error('获取订单失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有用户
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
usersLoading.value = true
|
||||
const response = await api.get('/users')
|
||||
if (response.success) {
|
||||
users.value = response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户失败:', error)
|
||||
} finally {
|
||||
usersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有产品
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
productsLoading.value = true
|
||||
const response = await api.get('/products')
|
||||
if (response.success) {
|
||||
products.value = response.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取产品失败:', error)
|
||||
} finally {
|
||||
productsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
fetchUsers()
|
||||
fetchProducts()
|
||||
}
|
||||
|
||||
// 查看订单
|
||||
const viewOrder = (record) => {
|
||||
viewOrderData.value = record
|
||||
viewModalVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑订单
|
||||
const editOrder = (record) => {
|
||||
isEdit.value = true
|
||||
formData.user_id = record.user_id
|
||||
formData.status = record.status
|
||||
formData.items = record.items || [{
|
||||
product_id: undefined,
|
||||
quantity: 1,
|
||||
price: 0
|
||||
}]
|
||||
formData.id = record.id
|
||||
modalVisible.value = true
|
||||
fetchUsers()
|
||||
fetchProducts()
|
||||
}
|
||||
|
||||
// 删除订单
|
||||
const deleteOrder = async (id) => {
|
||||
try {
|
||||
await api.delete(`/orders/${id}`)
|
||||
message.success('删除成功')
|
||||
fetchOrders()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
const orderData = {
|
||||
user_id: formData.user_id,
|
||||
status: formData.status,
|
||||
items: formData.items.filter(item => item.product_id && item.quantity && item.price),
|
||||
total_amount: calculateTotalAmount()
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await api.put(`/orders/${formData.id}`, orderData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await api.post('/orders', orderData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
|
||||
modalVisible.value = false
|
||||
fetchOrders()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('提交失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
formData.user_id = undefined
|
||||
formData.status = 'pending'
|
||||
formData.items = [{
|
||||
product_id: undefined,
|
||||
quantity: 1,
|
||||
price: 0
|
||||
}]
|
||||
delete formData.id
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 添加订单项
|
||||
const addItem = () => {
|
||||
formData.items.push({
|
||||
product_id: undefined,
|
||||
quantity: 1,
|
||||
price: 0
|
||||
})
|
||||
}
|
||||
|
||||
// 删除订单项
|
||||
const removeItem = (index) => {
|
||||
if (formData.items.length > 1) {
|
||||
formData.items.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算订单项小计
|
||||
const calculateItemTotal = (index) => {
|
||||
const item = formData.items[index]
|
||||
if (item.product_id) {
|
||||
const product = products.value.find(p => p.id === item.product_id)
|
||||
if (product && !item.price) {
|
||||
item.price = product.price
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算总金额
|
||||
const calculateTotalAmount = () => {
|
||||
return formData.items.reduce((total, item) => {
|
||||
return total + (item.quantity * item.price || 0)
|
||||
}, 0).toFixed(2)
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
pending: 'orange',
|
||||
paid: 'blue',
|
||||
shipped: 'cyan',
|
||||
delivered: 'green',
|
||||
cancelled: 'red'
|
||||
}
|
||||
return colors[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
pending: '待处理',
|
||||
paid: '已支付',
|
||||
shipped: '已发货',
|
||||
delivered: '已送达',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取用户名
|
||||
const getUserName = (userId) => {
|
||||
const user = users.value.find(u => u.id === userId)
|
||||
return user ? `${user.username} (${user.email})` : `用户ID: ${userId}`
|
||||
}
|
||||
|
||||
// 获取产品名
|
||||
const getProductName = (productId) => {
|
||||
const product = products.value.find(p => p.id === productId)
|
||||
return product ? product.name : `产品ID: ${productId}`
|
||||
}
|
||||
|
||||
// 搜索订单
|
||||
const searchOrdersByUsername = async () => {
|
||||
if (!searchUsername.value.trim()) {
|
||||
message.warning('请输入用户名进行搜索')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
searchLoading.value = true
|
||||
const response = await api.get('/orders/search', {
|
||||
params: { username: searchUsername.value.trim() }
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
orders.value = response.data || []
|
||||
isSearching.value = true
|
||||
message.success(response.message || `找到 ${orders.value.length} 个匹配的订单`)
|
||||
} else {
|
||||
orders.value = []
|
||||
message.info('未找到匹配的订单')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索订单失败:', error)
|
||||
message.error('搜索订单失败')
|
||||
orders.value = []
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索输入(为自动完成提供选项)
|
||||
const handleSearchInput = (value) => {
|
||||
if (!value) {
|
||||
usernameOptions.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 从现有用户列表中筛选匹配的用户名
|
||||
const matchingUsers = users.value.filter(user =>
|
||||
user.username && user.username.toLowerCase().includes(value.toLowerCase())
|
||||
)
|
||||
|
||||
usernameOptions.value = matchingUsers.map(user => ({
|
||||
value: user.username,
|
||||
label: user.username
|
||||
}))
|
||||
}
|
||||
|
||||
// 更新用户名选项(在数据加载后)
|
||||
const updateUsernameOptions = () => {
|
||||
const uniqueUsernames = [...new Set(users.value.map(user => user.username).filter(Boolean))]
|
||||
usernameOptions.value = uniqueUsernames.map(username => ({
|
||||
value: username,
|
||||
label: username
|
||||
}))
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchUsername.value = ''
|
||||
isSearching.value = false
|
||||
usernameOptions.value = []
|
||||
fetchOrders() // 重新加载全部订单
|
||||
}
|
||||
|
||||
// 导出订单数据
|
||||
const exportOrders = async () => {
|
||||
try {
|
||||
if (!orders.value || orders.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
exportLoading.value = true
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportOrdersData(orders.value)
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchOrders()
|
||||
fetchUsers().then(() => {
|
||||
updateUsernameOptions()
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
orders,
|
||||
users,
|
||||
products,
|
||||
loading,
|
||||
usersLoading,
|
||||
productsLoading,
|
||||
modalVisible,
|
||||
viewModalVisible,
|
||||
submitLoading,
|
||||
isEdit,
|
||||
formRef,
|
||||
formData,
|
||||
rules,
|
||||
columns,
|
||||
orderItemColumns,
|
||||
viewOrderData,
|
||||
|
||||
// 搜索相关
|
||||
searchUsername,
|
||||
searchLoading,
|
||||
isSearching,
|
||||
usernameOptions,
|
||||
handleSearchInput,
|
||||
updateUsernameOptions,
|
||||
searchOrdersByUsername,
|
||||
resetSearch,
|
||||
|
||||
// 导出相关
|
||||
exportLoading,
|
||||
exportOrders,
|
||||
|
||||
fetchOrders,
|
||||
fetchUsers,
|
||||
fetchProducts,
|
||||
showAddModal,
|
||||
viewOrder,
|
||||
editOrder,
|
||||
deleteOrder,
|
||||
handleSubmit,
|
||||
handleCancel,
|
||||
resetForm,
|
||||
addItem,
|
||||
removeItem,
|
||||
calculateItemTotal,
|
||||
calculateTotalAmount,
|
||||
getStatusColor,
|
||||
getStatusText,
|
||||
formatDate,
|
||||
getUserName,
|
||||
getProductName
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
717
admin-system/src/views/PenManagement.vue
Normal file
717
admin-system/src/views/PenManagement.vue
Normal file
@@ -0,0 +1,717 @@
|
||||
<template>
|
||||
<div class="pen-management-container">
|
||||
<!-- 页面标题和操作栏 -->
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">栏舍设置</h2>
|
||||
<div class="header-actions">
|
||||
<a-button @click="exportData" class="add-button">
|
||||
<template #icon>
|
||||
<ExportOutlined />
|
||||
</template>
|
||||
导出数据
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showAddModal" class="add-button">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
新增栏舍
|
||||
</a-button>
|
||||
<div class="search-container">
|
||||
<a-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="请输入栏舍名、类型、负责人等关键词"
|
||||
class="search-input"
|
||||
@pressEnter="handleSearch"
|
||||
@input="(e) => { console.log('搜索输入框输入:', e.target.value); searchKeyword = e.target.value; }"
|
||||
@change="(e) => { console.log('搜索输入框变化:', e.target.value); searchKeyword = e.target.value; }"
|
||||
allowClear
|
||||
/>
|
||||
<a-button type="primary" @click="handleSearch" class="search-button">
|
||||
<template #icon>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="handleClearSearch" class="clear-button">
|
||||
清空
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<div class="table-container">
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="filteredData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
class="pen-table"
|
||||
>
|
||||
<!-- 状态列 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-switch
|
||||
:checked="record.status"
|
||||
@change="(checked) => { record.status = checked; handleStatusChange(record) }"
|
||||
:checked-children="'开启'"
|
||||
:un-checked-children="'关闭'"
|
||||
/>
|
||||
</template>
|
||||
<!-- 操作列 -->
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<a-space>
|
||||
<a-button type="link" @click="handleEdit(record)" class="action-btn">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button type="link" danger @click="handleDelete(record)" class="action-btn">
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑栏舍模态框 -->
|
||||
<a-modal
|
||||
:open="modalVisible"
|
||||
@update:open="(val) => modalVisible = val"
|
||||
:title="isEdit ? '编辑栏舍' : '新增栏舍'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitting"
|
||||
width="600px"
|
||||
>
|
||||
<a-form
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
ref="formRef"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="栏舍名" name="name" required>
|
||||
<a-input
|
||||
v-model="formData.name"
|
||||
placeholder="请输入栏舍名"
|
||||
@input="(e) => { console.log('栏舍名输入:', e.target.value); formData.name = e.target.value; }"
|
||||
@change="(e) => { console.log('栏舍名变化:', e.target.value); }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="动物类型" name="animal_type" required>
|
||||
<a-select
|
||||
v-model="formData.animal_type"
|
||||
placeholder="请选择动物类型"
|
||||
@change="(value) => { console.log('动物类型变化:', value); }"
|
||||
>
|
||||
<a-select-option value="马">马</a-select-option>
|
||||
<a-select-option value="牛">牛</a-select-option>
|
||||
<a-select-option value="羊">羊</a-select-option>
|
||||
<a-select-option value="家禽">家禽</a-select-option>
|
||||
<a-select-option value="猪">猪</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="栏舍类型" name="pen_type">
|
||||
<a-input
|
||||
v-model="formData.pen_type"
|
||||
placeholder="请输入栏舍类型"
|
||||
@input="(e) => { console.log('栏舍类型输入:', e.target.value); formData.pen_type = e.target.value; }"
|
||||
@change="(e) => { console.log('栏舍类型变化:', e.target.value); }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="负责人" name="responsible" required>
|
||||
<a-input
|
||||
v-model="formData.responsible"
|
||||
placeholder="请输入负责人"
|
||||
@input="(e) => { console.log('负责人输入:', e.target.value); formData.responsible = e.target.value; }"
|
||||
@change="(e) => { console.log('负责人变化:', e.target.value); }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="容量" name="capacity" required>
|
||||
<a-input-number
|
||||
v-model="formData.capacity"
|
||||
:min="1"
|
||||
:max="10000"
|
||||
placeholder="请输入容量"
|
||||
style="width: 100%"
|
||||
@change="(value) => { console.log('容量变化:', value); formData.capacity = value; }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-switch
|
||||
:checked="formData.status"
|
||||
@change="(checked) => { console.log('状态变化:', checked); formData.status = checked; }"
|
||||
:checked-children="'开启'"
|
||||
:un-checked-children="'关闭'"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="备注" name="description">
|
||||
<a-textarea
|
||||
v-model="formData.description"
|
||||
placeholder="请输入备注信息"
|
||||
:rows="3"
|
||||
@input="(e) => { console.log('备注输入:', e.target.value); formData.description = e.target.value; }"
|
||||
@change="(e) => { console.log('备注变化:', e.target.value); }"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { PlusOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
import { api } from '../utils/api'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const formRef = ref(null)
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: '栏舍名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '动物类型',
|
||||
dataIndex: 'animal_type',
|
||||
key: 'animal_type',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '栏舍类型',
|
||||
dataIndex: 'pen_type',
|
||||
key: 'pen_type',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '负责人',
|
||||
dataIndex: 'responsible',
|
||||
key: 'responsible',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '容量',
|
||||
dataIndex: 'capacity',
|
||||
key: 'capacity',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '创建人',
|
||||
dataIndex: 'creator',
|
||||
key: 'creator',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
},
|
||||
]
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条/共 ${total} 条`,
|
||||
onChange: async (page, pageSize) => {
|
||||
pagination.current = page
|
||||
pagination.pageSize = pageSize
|
||||
await loadPenData()
|
||||
},
|
||||
onShowSizeChange: async (current, size) => {
|
||||
pagination.current = 1
|
||||
pagination.pageSize = size
|
||||
await loadPenData()
|
||||
}
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
animal_type: '',
|
||||
pen_type: '',
|
||||
responsible: '',
|
||||
capacity: 1,
|
||||
status: true,
|
||||
description: '',
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入栏舍名', trigger: 'blur' },
|
||||
{ min: 1, max: 50, message: '栏舍名长度应在1-50个字符之间', trigger: 'blur' }
|
||||
],
|
||||
animal_type: [
|
||||
{ required: true, message: '请选择动物类型', trigger: 'change' }
|
||||
],
|
||||
responsible: [
|
||||
{ required: true, message: '请输入负责人', trigger: 'blur' },
|
||||
{ min: 1, max: 20, message: '负责人姓名长度应在1-20个字符之间', trigger: 'blur' }
|
||||
],
|
||||
capacity: [
|
||||
{ required: true, message: '请输入容量', trigger: 'blur' },
|
||||
{ type: 'number', min: 1, max: 10000, message: '容量应在1-10000之间', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 真实数据
|
||||
const penData = ref([])
|
||||
|
||||
// 计算属性 - 过滤后的数据(暂时不使用前端过滤,使用API搜索)
|
||||
const filteredData = computed(() => {
|
||||
return penData.value
|
||||
})
|
||||
|
||||
// 数据加载方法
|
||||
const loadPenData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
console.log('开始加载栏舍数据...', {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
search: searchKeyword.value
|
||||
})
|
||||
|
||||
const requestParams = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
search: searchKeyword.value
|
||||
}
|
||||
|
||||
console.log('请求参数:', requestParams)
|
||||
console.log('搜索参数详情:', {
|
||||
search: searchKeyword.value,
|
||||
searchType: typeof searchKeyword.value,
|
||||
searchEmpty: !searchKeyword.value,
|
||||
searchTrimmed: searchKeyword.value?.trim()
|
||||
})
|
||||
|
||||
const response = await api.pens.getList(requestParams)
|
||||
|
||||
console.log('API响应:', response)
|
||||
|
||||
if (response && response.success) {
|
||||
penData.value = response.data.list
|
||||
pagination.total = response.data.pagination.total
|
||||
console.log('栏舍数据加载成功:', penData.value)
|
||||
} else {
|
||||
console.error('API返回失败:', response)
|
||||
message.error('获取栏舍数据失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载栏舍数据失败:', error)
|
||||
message.error('加载栏舍数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 方法
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (record) => {
|
||||
console.log('=== 开始编辑栏舍 ===')
|
||||
console.log('点击编辑按钮,原始记录数据:', record)
|
||||
|
||||
isEdit.value = true
|
||||
Object.assign(formData, {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
animal_type: record.animal_type,
|
||||
pen_type: record.pen_type,
|
||||
responsible: record.responsible,
|
||||
capacity: record.capacity,
|
||||
status: record.status,
|
||||
description: record.description
|
||||
})
|
||||
|
||||
console.log('编辑模式:表单数据已填充')
|
||||
console.log('formData对象:', formData)
|
||||
console.log('formData.name:', formData.name)
|
||||
console.log('formData.animal_type:', formData.animal_type)
|
||||
console.log('formData.pen_type:', formData.pen_type)
|
||||
console.log('formData.responsible:', formData.responsible)
|
||||
console.log('formData.capacity:', formData.capacity)
|
||||
console.log('formData.status:', formData.status)
|
||||
console.log('formData.description:', formData.description)
|
||||
|
||||
modalVisible.value = true
|
||||
console.log('编辑模态框已打开')
|
||||
}
|
||||
|
||||
const handleDelete = (record) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除栏舍"${record.name}"吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const response = await api.pens.delete(record.id)
|
||||
if (response.success) {
|
||||
message.success('删除成功')
|
||||
await loadPenData() // 重新加载数据
|
||||
} else {
|
||||
message.error('删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除栏舍失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleStatusChange = async (record) => {
|
||||
try {
|
||||
const response = await api.pens.update(record.id, { status: record.status })
|
||||
if (response.success) {
|
||||
message.success(`栏舍"${record.name}"状态已${record.status ? '开启' : '关闭'}`)
|
||||
} else {
|
||||
message.error('状态更新失败')
|
||||
// 恢复原状态
|
||||
record.status = !record.status
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新状态失败:', error)
|
||||
message.error('状态更新失败')
|
||||
// 恢复原状态
|
||||
record.status = !record.status
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = async () => {
|
||||
console.log('=== 开始搜索栏舍 ===')
|
||||
console.log('搜索关键词:', searchKeyword.value)
|
||||
console.log('搜索关键词类型:', typeof searchKeyword.value)
|
||||
console.log('搜索关键词长度:', searchKeyword.value?.length)
|
||||
console.log('搜索关键词是否为空:', !searchKeyword.value)
|
||||
console.log('搜索关键词去除空格后:', searchKeyword.value?.trim())
|
||||
|
||||
// 确保搜索关键词正确传递
|
||||
const searchValue = searchKeyword.value?.trim() || ''
|
||||
console.log('实际使用的搜索值:', searchValue)
|
||||
|
||||
pagination.current = 1 // 重置到第一页
|
||||
await loadPenData()
|
||||
|
||||
console.log('=== 搜索完成 ===')
|
||||
}
|
||||
|
||||
const handleClearSearch = async () => {
|
||||
console.log('=== 清空搜索 ===')
|
||||
searchKeyword.value = ''
|
||||
pagination.current = 1 // 重置到第一页
|
||||
await loadPenData()
|
||||
console.log('=== 搜索已清空 ===')
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
console.log('=== 开始提交栏舍数据 ===')
|
||||
console.log('当前表单数据:', formData)
|
||||
console.log('是否为编辑模式:', isEdit.value)
|
||||
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
|
||||
const submitData = {
|
||||
name: formData.name,
|
||||
animal_type: formData.animal_type,
|
||||
pen_type: formData.pen_type,
|
||||
responsible: formData.responsible,
|
||||
capacity: formData.capacity,
|
||||
status: formData.status,
|
||||
description: formData.description
|
||||
}
|
||||
|
||||
console.log('准备提交的数据:', submitData)
|
||||
console.log('提交的字段详情:')
|
||||
console.log('- 栏舍名 (name):', submitData.name)
|
||||
console.log('- 动物类型 (animal_type):', submitData.animal_type)
|
||||
console.log('- 栏舍类型 (pen_type):', submitData.pen_type)
|
||||
console.log('- 负责人 (responsible):', submitData.responsible)
|
||||
console.log('- 容量 (capacity):', submitData.capacity, typeof submitData.capacity)
|
||||
console.log('- 状态 (status):', submitData.status, typeof submitData.status)
|
||||
console.log('- 描述 (description):', submitData.description)
|
||||
|
||||
let response
|
||||
if (isEdit.value) {
|
||||
// 编辑
|
||||
console.log('执行编辑操作,栏舍ID:', formData.id)
|
||||
response = await api.pens.update(formData.id, submitData)
|
||||
console.log('编辑API响应:', response)
|
||||
} else {
|
||||
// 新增
|
||||
console.log('执行新增操作')
|
||||
response = await api.pens.create(submitData)
|
||||
console.log('新增API响应:', response)
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
console.log('✅ 操作成功:', response.message)
|
||||
console.log('返回的数据:', response.data)
|
||||
message.success(isEdit.value ? '编辑成功' : '新增成功')
|
||||
modalVisible.value = false
|
||||
await loadPenData() // 重新加载数据
|
||||
console.log('数据已重新加载')
|
||||
} else {
|
||||
console.log('❌ 操作失败:', response.message)
|
||||
message.error(isEdit.value ? '编辑失败' : '新增失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 提交失败:', error)
|
||||
console.error('错误详情:', error.response?.data || error.message)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
console.log('=== 提交操作完成 ===')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
name: '',
|
||||
animal_type: '',
|
||||
pen_type: '',
|
||||
responsible: '',
|
||||
capacity: 1,
|
||||
status: true,
|
||||
description: '',
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
console.log('PenManagement组件已挂载')
|
||||
// 初始化数据
|
||||
await loadPenData()
|
||||
})
|
||||
|
||||
// 导出数据
|
||||
const exportData = async () => {
|
||||
try {
|
||||
if (!penData.value || penData.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportPenData(penData.value)
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pen-management-container {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 300px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
height: 40px;
|
||||
color: #666;
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pen-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 8px;
|
||||
height: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr:hover > td) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
:deep(.ant-table-tbody > tr > td) {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
/* 状态开关样式 */
|
||||
:deep(.ant-switch-checked) {
|
||||
background-color: #1890ff;
|
||||
}
|
||||
|
||||
/* 表单样式 */
|
||||
:deep(.ant-form-item-label > label) {
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
:deep(.ant-input),
|
||||
:deep(.ant-select),
|
||||
:deep(.ant-input-number) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:deep(.ant-btn) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
398
admin-system/src/views/Products.vue
Normal file
398
admin-system/src/views/Products.vue
Normal file
@@ -0,0 +1,398 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>产品管理</h1>
|
||||
<a-space>
|
||||
<a-button @click="exportProducts" :loading="exportLoading">
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出数据
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加产品
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div style="margin-bottom: 16px; display: flex; gap: 8px; align-items: center;">
|
||||
<a-auto-complete
|
||||
v-model:value="searchProductName"
|
||||
:options="productNameOptions"
|
||||
placeholder="请选择或输入产品名称进行搜索"
|
||||
style="width: 300px;"
|
||||
:filter-option="false"
|
||||
@search="handleSearchInput"
|
||||
@press-enter="searchProducts"
|
||||
/>
|
||||
<a-button type="primary" @click="searchProducts" :loading="searchLoading">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="resetSearch" :disabled="!isSearching">
|
||||
重置
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="products"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'price'">
|
||||
¥{{ record.price }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
|
||||
{{ record.status === 'active' ? '活跃' : '停用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="editProduct(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个产品吗?"
|
||||
@confirm="deleteProduct(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑产品模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑产品' : '添加产品'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="产品名称" name="name">
|
||||
<a-input v-model:value="formData.name" placeholder="请输入产品名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="产品描述" name="description">
|
||||
<a-textarea v-model:value="formData.description" placeholder="请输入产品描述" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-form-item label="价格" name="price">
|
||||
<a-input-number
|
||||
v-model:value="formData.price"
|
||||
placeholder="请输入价格"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="库存" name="stock">
|
||||
<a-input-number
|
||||
v-model:value="formData.stock"
|
||||
placeholder="请输入库存数量"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select v-model="formData.status" placeholder="请选择状态">
|
||||
<a-select-option value="active">活跃</a-select-option>
|
||||
<a-select-option value="inactive">停用</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
import { api } from '../utils/api'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
|
||||
// 响应式数据
|
||||
const products = ref([])
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 搜索相关的响应式数据
|
||||
const searchProductName = ref('')
|
||||
const searchLoading = ref(false)
|
||||
const isSearching = ref(false)
|
||||
const productNameOptions = ref([])
|
||||
|
||||
// 导出相关
|
||||
const exportLoading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
price: null,
|
||||
stock: null,
|
||||
status: 'active'
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入产品名称', trigger: 'blur' }],
|
||||
price: [{ required: true, message: '请输入价格', trigger: 'blur' }],
|
||||
stock: [{ required: true, message: '请输入库存数量', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '产品名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '价格',
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '库存',
|
||||
dataIndex: 'stock',
|
||||
key: 'stock',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 150
|
||||
}
|
||||
]
|
||||
|
||||
// 获取产品列表
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await api.get('/products')
|
||||
if (response.success) {
|
||||
products.value = response.data
|
||||
} else {
|
||||
message.error('获取产品列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取产品列表失败:', error)
|
||||
message.error('获取产品列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑产品
|
||||
const editProduct = (record) => {
|
||||
isEdit.value = true
|
||||
Object.assign(formData, record)
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 删除产品
|
||||
const deleteProduct = async (id) => {
|
||||
try {
|
||||
const response = await api.delete(`/products/${id}`)
|
||||
if (response.success) {
|
||||
message.success('删除成功')
|
||||
fetchProducts()
|
||||
} else {
|
||||
message.error('删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除产品失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
let response
|
||||
if (isEdit.value) {
|
||||
response = await api.put(`/products/${formData.id}`, formData)
|
||||
} else {
|
||||
response = await api.post('/products', formData)
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
modalVisible.value = false
|
||||
fetchProducts()
|
||||
} else {
|
||||
message.error(isEdit.value ? '更新失败' : '创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
price: null,
|
||||
stock: null,
|
||||
status: 'active'
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 搜索产品
|
||||
const searchProducts = async () => {
|
||||
if (!searchProductName.value.trim()) {
|
||||
message.warning('请输入产品名称进行搜索')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
searchLoading.value = true
|
||||
const response = await api.get('/products/search', {
|
||||
params: { name: searchProductName.value.trim() }
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
products.value = response.data || []
|
||||
isSearching.value = true
|
||||
message.success(response.message || `找到 ${products.value.length} 个匹配的产品`)
|
||||
} else {
|
||||
products.value = []
|
||||
message.info('未找到匹配的产品')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索产品失败:', error)
|
||||
message.error('搜索产品失败')
|
||||
products.value = []
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索输入(为自动完成提供选项)
|
||||
const handleSearchInput = (value) => {
|
||||
if (!value) {
|
||||
productNameOptions.value = []
|
||||
return
|
||||
}
|
||||
|
||||
// 从现有产品列表中筛选匹配的产品名称
|
||||
const matchingProducts = products.value.filter(product =>
|
||||
product.name.toLowerCase().includes(value.toLowerCase())
|
||||
)
|
||||
|
||||
productNameOptions.value = matchingProducts.map(product => ({
|
||||
value: product.name,
|
||||
label: product.name
|
||||
}))
|
||||
}
|
||||
|
||||
// 更新产品名称选项(在数据加载后)
|
||||
const updateProductNameOptions = () => {
|
||||
productNameOptions.value = products.value.map(product => ({
|
||||
value: product.name,
|
||||
label: product.name
|
||||
}))
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchProductName.value = ''
|
||||
isSearching.value = false
|
||||
productNameOptions.value = []
|
||||
fetchProducts() // 重新加载全部产品
|
||||
}
|
||||
|
||||
// 导出产品数据
|
||||
const exportProducts = async () => {
|
||||
try {
|
||||
if (!products.value || products.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
exportLoading.value = true
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportProductsData(products.value)
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchProducts().then(() => {
|
||||
updateProductNameOptions()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
485
admin-system/src/views/Reports.vue
Normal file
485
admin-system/src/views/Reports.vue
Normal file
@@ -0,0 +1,485 @@
|
||||
<template>
|
||||
<div class="reports-page">
|
||||
<a-page-header
|
||||
title="报表管理"
|
||||
sub-title="生成和管理系统报表"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button @click="fetchReportList">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新列表
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showGenerateModal = true">
|
||||
<template #icon><FilePdfOutlined /></template>
|
||||
生成报表
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<div class="reports-content">
|
||||
<!-- 快捷导出区域 -->
|
||||
<a-card title="快捷数据导出" :bordered="false" style="margin-bottom: 24px;">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-button
|
||||
type="primary"
|
||||
block
|
||||
:loading="exportLoading.farms"
|
||||
@click="quickExport('farms', 'excel')"
|
||||
>
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出农场数据
|
||||
</a-button>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-button
|
||||
type="primary"
|
||||
block
|
||||
:loading="exportLoading.devices"
|
||||
@click="quickExport('devices', 'excel')"
|
||||
>
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出设备数据
|
||||
</a-button>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-button
|
||||
type="primary"
|
||||
block
|
||||
:loading="exportLoading.animals"
|
||||
@click="quickExport('animals', 'excel')"
|
||||
>
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出动物数据
|
||||
</a-button>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-button
|
||||
type="primary"
|
||||
block
|
||||
:loading="exportLoading.alerts"
|
||||
@click="quickExport('alerts', 'excel')"
|
||||
>
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出预警数据
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<!-- 报表文件列表 -->
|
||||
<a-card title="历史报表文件" :bordered="false">
|
||||
<a-table
|
||||
:columns="reportColumns"
|
||||
:data-source="reportFiles"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="fileName"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'size'">
|
||||
{{ formatFileSize(record.size) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'createdAt'">
|
||||
{{ formatDate(record.createdAt) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-space>
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="downloadReport(record)"
|
||||
>
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
下载
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
danger
|
||||
size="small"
|
||||
@click="deleteReport(record)"
|
||||
v-if="userStore.userData?.roles?.includes('admin')"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 生成报表模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showGenerateModal"
|
||||
title="生成报表"
|
||||
:confirm-loading="generateLoading"
|
||||
@ok="generateReport"
|
||||
@cancel="resetGenerateForm"
|
||||
width="600px"
|
||||
>
|
||||
<a-form
|
||||
ref="generateFormRef"
|
||||
:model="generateForm"
|
||||
:rules="generateRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="报表类型" name="reportType">
|
||||
<a-select v-model:value="generateForm.reportType" @change="onReportTypeChange">
|
||||
<a-select-option value="farm">养殖统计报表</a-select-option>
|
||||
<a-select-option value="sales">销售分析报表</a-select-option>
|
||||
<a-select-option value="compliance" v-if="userStore.userData?.roles?.includes('admin')">
|
||||
监管合规报表
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="开始日期" name="startDate">
|
||||
<a-date-picker
|
||||
v-model:value="generateForm.startDate"
|
||||
style="width: 100%;"
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="结束日期" name="endDate">
|
||||
<a-date-picker
|
||||
v-model:value="generateForm.endDate"
|
||||
style="width: 100%;"
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="报表格式" name="format">
|
||||
<a-radio-group v-model:value="generateForm.format">
|
||||
<a-radio-button value="pdf">PDF</a-radio-button>
|
||||
<a-radio-button value="excel">Excel</a-radio-button>
|
||||
<a-radio-button value="csv" v-if="generateForm.reportType === 'farm'">CSV</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="选择农场"
|
||||
name="farmIds"
|
||||
v-if="generateForm.reportType === 'farm'"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="generateForm.farmIds"
|
||||
mode="multiple"
|
||||
placeholder="不选择则包含所有农场"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="farm in dataStore.farms"
|
||||
:key="farm.id"
|
||||
:value="farm.id"
|
||||
>
|
||||
{{ farm.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
FilePdfOutlined,
|
||||
ExportOutlined,
|
||||
DownloadOutlined,
|
||||
DeleteOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { api } from '../utils/api'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { useDataStore } from '../stores/data'
|
||||
import moment from 'moment'
|
||||
|
||||
// Store
|
||||
const userStore = useUserStore()
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const generateLoading = ref(false)
|
||||
const showGenerateModal = ref(false)
|
||||
const reportFiles = ref([])
|
||||
|
||||
// 导出加载状态
|
||||
const exportLoading = reactive({
|
||||
farms: false,
|
||||
devices: false,
|
||||
animals: false,
|
||||
alerts: false
|
||||
})
|
||||
|
||||
// 生成报表表单
|
||||
const generateFormRef = ref()
|
||||
const generateForm = reactive({
|
||||
reportType: 'farm',
|
||||
startDate: moment().subtract(30, 'days'),
|
||||
endDate: moment(),
|
||||
format: 'pdf',
|
||||
farmIds: []
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const generateRules = {
|
||||
reportType: [{ required: true, message: '请选择报表类型' }],
|
||||
startDate: [{ required: true, message: '请选择开始日期' }],
|
||||
endDate: [{ required: true, message: '请选择结束日期' }],
|
||||
format: [{ required: true, message: '请选择报表格式' }]
|
||||
}
|
||||
|
||||
// 报表文件列表表格列定义
|
||||
const reportColumns = [
|
||||
{
|
||||
title: '文件名',
|
||||
dataIndex: 'fileName',
|
||||
key: 'fileName',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '文件大小',
|
||||
dataIndex: 'size',
|
||||
key: 'size',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 150
|
||||
}
|
||||
]
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(async () => {
|
||||
await dataStore.fetchAllData()
|
||||
await fetchReportList()
|
||||
})
|
||||
|
||||
// 获取报表文件列表
|
||||
async function fetchReportList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await api.get('/reports/list')
|
||||
if (response.success) {
|
||||
reportFiles.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取报表列表失败:', error)
|
||||
message.error('获取报表列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生成报表
|
||||
async function generateReport() {
|
||||
try {
|
||||
await generateFormRef.value.validate()
|
||||
generateLoading.value = true
|
||||
|
||||
const params = {
|
||||
startDate: generateForm.startDate.format('YYYY-MM-DD'),
|
||||
endDate: generateForm.endDate.format('YYYY-MM-DD'),
|
||||
format: generateForm.format
|
||||
}
|
||||
|
||||
if (generateForm.reportType === 'farm' && generateForm.farmIds.length > 0) {
|
||||
params.farmIds = generateForm.farmIds
|
||||
}
|
||||
|
||||
let endpoint = ''
|
||||
if (generateForm.reportType === 'farm') {
|
||||
endpoint = '/reports/farm'
|
||||
} else if (generateForm.reportType === 'sales') {
|
||||
endpoint = '/reports/sales'
|
||||
} else if (generateForm.reportType === 'compliance') {
|
||||
endpoint = '/reports/compliance'
|
||||
}
|
||||
|
||||
const response = await api.post(endpoint, params)
|
||||
|
||||
if (response.success) {
|
||||
message.success('报表生成成功')
|
||||
showGenerateModal.value = false
|
||||
resetGenerateForm()
|
||||
await fetchReportList()
|
||||
|
||||
// 自动下载生成的报表
|
||||
if (response.data.downloadUrl) {
|
||||
window.open(`${api.baseURL}${response.data.downloadUrl}`, '_blank')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成报表失败:', error)
|
||||
message.error('生成报表失败')
|
||||
} finally {
|
||||
generateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置生成表单
|
||||
function resetGenerateForm() {
|
||||
generateForm.reportType = 'farm'
|
||||
generateForm.startDate = moment().subtract(30, 'days')
|
||||
generateForm.endDate = moment()
|
||||
generateForm.format = 'pdf'
|
||||
generateForm.farmIds = []
|
||||
}
|
||||
|
||||
// 报表类型改变时的处理
|
||||
function onReportTypeChange(value) {
|
||||
if (value !== 'farm') {
|
||||
generateForm.farmIds = []
|
||||
}
|
||||
}
|
||||
|
||||
// 快捷导出
|
||||
async function quickExport(dataType, format) {
|
||||
exportLoading[dataType] = true
|
||||
try {
|
||||
let endpoint = ''
|
||||
let fileName = ''
|
||||
|
||||
switch (dataType) {
|
||||
case 'farms':
|
||||
endpoint = '/reports/export/farms'
|
||||
fileName = `农场数据_${moment().format('YYYYMMDD_HHmmss')}.xlsx`
|
||||
break
|
||||
case 'devices':
|
||||
endpoint = '/reports/export/devices'
|
||||
fileName = `设备数据_${moment().format('YYYYMMDD_HHmmss')}.xlsx`
|
||||
break
|
||||
case 'animals':
|
||||
// 使用农场报表API导出动物数据
|
||||
endpoint = '/reports/farm'
|
||||
fileName = `动物数据_${moment().format('YYYYMMDD_HHmmss')}.xlsx`
|
||||
break
|
||||
case 'alerts':
|
||||
// 使用农场报表API导出预警数据
|
||||
endpoint = '/reports/farm'
|
||||
fileName = `预警数据_${moment().format('YYYYMMDD_HHmmss')}.xlsx`
|
||||
break
|
||||
}
|
||||
|
||||
if (dataType === 'animals' || dataType === 'alerts') {
|
||||
// 生成包含动物或预警数据的报表
|
||||
const response = await api.post(endpoint, { format: 'excel' })
|
||||
if (response.success && response.data.downloadUrl) {
|
||||
downloadFile(`${api.baseURL}${response.data.downloadUrl}`, response.data.fileName)
|
||||
message.success('数据导出成功')
|
||||
}
|
||||
} else {
|
||||
// 直接导出
|
||||
const url = `${api.baseURL}${endpoint}?format=${format}`
|
||||
downloadFile(url, fileName)
|
||||
message.success('数据导出成功')
|
||||
}
|
||||
|
||||
await fetchReportList()
|
||||
} catch (error) {
|
||||
console.error(`导出${dataType}数据失败:`, error)
|
||||
message.error('数据导出失败')
|
||||
} finally {
|
||||
exportLoading[dataType] = false
|
||||
}
|
||||
}
|
||||
|
||||
// 下载报表文件
|
||||
function downloadReport(record) {
|
||||
const url = `${api.baseURL}${record.downloadUrl}`
|
||||
downloadFile(url, record.fileName)
|
||||
}
|
||||
|
||||
// 下载文件辅助函数
|
||||
function downloadFile(url, fileName) {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
link.target = '_blank'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// 删除报表文件
|
||||
async function deleteReport(record) {
|
||||
try {
|
||||
// 注意:这里需要在后端添加删除API
|
||||
message.success('删除功能将在后续版本实现')
|
||||
} catch (error) {
|
||||
console.error('删除报表失败:', error)
|
||||
message.error('删除报表失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(date) {
|
||||
return moment(date).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reports-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.reports-content {
|
||||
padding: 24px;
|
||||
background: #f0f2f5;
|
||||
min-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-page-header {
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background: #fafafa;
|
||||
color: #262626;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-group) .ant-btn {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ant-col {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
660
admin-system/src/views/RolePermissions.vue
Normal file
660
admin-system/src/views/RolePermissions.vue
Normal file
@@ -0,0 +1,660 @@
|
||||
<template>
|
||||
<div class="role-permissions">
|
||||
<div class="page-header">
|
||||
<h1>角色权限管理</h1>
|
||||
<a-button type="primary" @click="showCreateModal">
|
||||
<PlusOutlined />
|
||||
新增角色
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 角色列表 -->
|
||||
<a-card title="角色列表" :bordered="false">
|
||||
<template #extra>
|
||||
<a-input-search
|
||||
v-model="searchText"
|
||||
placeholder="搜索角色名称"
|
||||
style="width: 200px"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="roles"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-switch
|
||||
:checked="record.status"
|
||||
@change="(checked) => handleStatusChange(record, checked)"
|
||||
:checked-children="'启用'"
|
||||
:un-checked-children="'禁用'"
|
||||
:loading="record.statusChanging"
|
||||
/>
|
||||
</template>
|
||||
|
||||
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="editRole(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-dropdown>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="menu" @click="manageMenuPermissions(record)">
|
||||
菜单权限
|
||||
</a-menu-item>
|
||||
<a-menu-item key="function" @click="manageFunctionPermissions(record)">
|
||||
功能权限
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
<a-button type="link" size="small">
|
||||
权限管理 <DownOutlined />
|
||||
</a-button>
|
||||
</a-dropdown>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个角色吗?"
|
||||
@confirm="deleteRole(record.id)"
|
||||
>
|
||||
<a-button type="link" size="small" danger>
|
||||
删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 创建/编辑角色模态框 -->
|
||||
<a-modal
|
||||
:open="modalVisible"
|
||||
:title="isEdit ? '编辑角色' : '新增角色'"
|
||||
@ok="handleModalOk"
|
||||
@cancel="handleModalCancel"
|
||||
@update:open="modalVisible = $event"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 18 }"
|
||||
>
|
||||
<a-form-item label="角色名称" name="name">
|
||||
<a-input :value="formData.name" placeholder="请输入角色名称" @update:value="formData.name = $event" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="角色描述" name="description">
|
||||
<a-textarea :value="formData.description" placeholder="请输入角色描述" @update:value="formData.description = $event" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-switch :checked="formData.status" @update:checked="formData.status = $event" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 菜单权限管理模态框 -->
|
||||
<a-modal
|
||||
:open="menuPermissionModalVisible"
|
||||
title="菜单权限管理"
|
||||
width="800px"
|
||||
@ok="handleMenuPermissionOk"
|
||||
@cancel="handleMenuPermissionCancel"
|
||||
@update:open="menuPermissionModalVisible = $event"
|
||||
>
|
||||
<div class="permission-container">
|
||||
<div class="permission-header">
|
||||
<h3>角色:{{ currentRole?.name }}</h3>
|
||||
<a-space>
|
||||
<a-button @click="checkAllMenus">全选</a-button>
|
||||
<a-button @click="uncheckAllMenus">全不选</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-tree
|
||||
:checkedKeys="menuCheckedKeys"
|
||||
:tree-data="menuTree"
|
||||
:field-names="{ children: 'children', title: 'name', key: 'id' }"
|
||||
checkable
|
||||
:check-strictly="false"
|
||||
@check="handleMenuTreeCheck"
|
||||
@update:checkedKeys="menuCheckedKeys = $event"
|
||||
/>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 功能权限管理模态框 -->
|
||||
<a-modal
|
||||
:open="functionPermissionModalVisible"
|
||||
title="功能权限管理"
|
||||
width="1000px"
|
||||
@ok="handleFunctionPermissionOk"
|
||||
@cancel="handleFunctionPermissionCancel"
|
||||
@update:open="functionPermissionModalVisible = $event"
|
||||
>
|
||||
<div class="permission-container">
|
||||
<div class="permission-header">
|
||||
<h3>角色:{{ currentRole?.name }}</h3>
|
||||
<a-space>
|
||||
<a-select
|
||||
:value="selectedModule"
|
||||
placeholder="选择模块"
|
||||
style="width: 150px"
|
||||
@update:value="selectedModule = $event"
|
||||
@change="filterPermissions"
|
||||
>
|
||||
<a-select-option value="">全部模块</a-select-option>
|
||||
<a-select-option v-for="module in permissionModules" :key="module" :value="module">
|
||||
{{ module }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-button @click="checkAllFunctions">全选</a-button>
|
||||
<a-button @click="uncheckAllFunctions">全不选</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div class="function-permissions">
|
||||
<a-collapse v-model="activeModules">
|
||||
<a-collapse-panel v-for="(permissions, module) in filteredPermissions" :key="module" :header="module">
|
||||
<a-checkbox-group
|
||||
:value="functionCheckedKeys"
|
||||
@update:value="functionCheckedKeys = $event"
|
||||
@change="handleFunctionCheck"
|
||||
>
|
||||
<a-row :gutter="[16, 8]">
|
||||
<a-col v-for="permission in permissions" :key="permission.id" :span="8">
|
||||
<a-checkbox :value="permission.id">
|
||||
{{ permission.permission_name }}
|
||||
</a-checkbox>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-checkbox-group>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { PlusOutlined, DownOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { rolePermissionService } from '../utils/dataService'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const roles = ref([])
|
||||
const menuTree = ref([])
|
||||
const menuCheckedKeys = ref([])
|
||||
const functionCheckedKeys = ref([])
|
||||
const allPermissions = ref([])
|
||||
const permissionModules = ref([])
|
||||
const selectedModule = ref('')
|
||||
const activeModules = ref([])
|
||||
|
||||
// 分页
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条记录`
|
||||
})
|
||||
|
||||
// 模态框状态
|
||||
const modalVisible = ref(false)
|
||||
const menuPermissionModalVisible = ref(false)
|
||||
const functionPermissionModalVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const currentRole = ref(null)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
status: true
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入角色名称', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '角色名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 计算属性
|
||||
const filteredPermissions = computed(() => {
|
||||
if (!selectedModule.value) {
|
||||
return allPermissions.value.reduce((acc, permission) => {
|
||||
const module = permission.module;
|
||||
if (!acc[module]) {
|
||||
acc[module] = [];
|
||||
}
|
||||
acc[module].push(permission);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
return allPermissions.value
|
||||
.filter(permission => permission.module === selectedModule.value)
|
||||
.reduce((acc, permission) => {
|
||||
const module = permission.module;
|
||||
if (!acc[module]) {
|
||||
acc[module] = [];
|
||||
}
|
||||
acc[module].push(permission);
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
// 加载角色列表
|
||||
const loadRoles = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
search: searchText.value
|
||||
}
|
||||
|
||||
const response = await rolePermissionService.getRoles(params)
|
||||
roles.value = response.list || []
|
||||
pagination.total = response.pagination?.total || 0
|
||||
} catch (error) {
|
||||
console.error('加载角色列表失败:', error)
|
||||
message.error('加载角色列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载菜单树
|
||||
const loadMenuTree = async () => {
|
||||
try {
|
||||
const response = await rolePermissionService.getPermissionTree()
|
||||
|
||||
// 转换字段名以匹配前端组件期望的格式
|
||||
const convertMenuFields = (menu) => {
|
||||
return {
|
||||
id: menu.id,
|
||||
name: menu.menu_name || menu.name, // 映射 menu_name 到 name
|
||||
key: menu.id,
|
||||
children: menu.children ? menu.children.map(convertMenuFields) : []
|
||||
}
|
||||
}
|
||||
|
||||
menuTree.value = response ? response.map(convertMenuFields) : []
|
||||
} catch (error) {
|
||||
console.error('加载菜单树失败:', error)
|
||||
message.error('加载菜单树失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载所有权限
|
||||
const loadAllPermissions = async () => {
|
||||
try {
|
||||
const response = await rolePermissionService.getAllPermissions()
|
||||
allPermissions.value = response.data?.permissions || []
|
||||
} catch (error) {
|
||||
console.error('加载权限列表失败:', error)
|
||||
message.error('加载权限列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载权限模块列表
|
||||
const loadPermissionModules = async () => {
|
||||
try {
|
||||
const response = await rolePermissionService.getPermissionModules()
|
||||
permissionModules.value = response.data || []
|
||||
// 默认展开所有模块
|
||||
activeModules.value = permissionModules.value
|
||||
} catch (error) {
|
||||
console.error('加载权限模块失败:', error)
|
||||
message.error('加载权限模块失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
loadRoles()
|
||||
}
|
||||
|
||||
// 表格变化
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadRoles()
|
||||
}
|
||||
|
||||
// 显示创建模态框
|
||||
const showCreateModal = () => {
|
||||
isEdit.value = false
|
||||
modalVisible.value = true
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 编辑角色
|
||||
const editRole = (record) => {
|
||||
isEdit.value = true
|
||||
modalVisible.value = true
|
||||
Object.assign(formData, record)
|
||||
}
|
||||
|
||||
// 管理菜单权限
|
||||
const manageMenuPermissions = async (record) => {
|
||||
currentRole.value = record
|
||||
menuPermissionModalVisible.value = true
|
||||
|
||||
try {
|
||||
// 加载角色的菜单权限
|
||||
console.log('🔍 [菜单权限加载] 角色ID:', record.id)
|
||||
console.log('🔍 [菜单权限加载] 调用API: /role-permissions/public/roles/' + record.id + '/menus')
|
||||
|
||||
const response = await rolePermissionService.getRoleMenuPermissions(record.id)
|
||||
console.log('🔍 [菜单权限加载] API响应:', response)
|
||||
console.log('🔍 [菜单权限加载] 权限数组:', response.data?.permissions)
|
||||
console.log('🔍 [菜单权限加载] 权限数组长度:', response.data?.permissions?.length)
|
||||
|
||||
const permissionIds = response.data?.permissions?.map(p => p.id) || []
|
||||
console.log('🔍 [菜单权限加载] 权限ID数组:', permissionIds)
|
||||
console.log('🔍 [菜单权限加载] 权限ID数组长度:', permissionIds.length)
|
||||
|
||||
menuCheckedKeys.value = permissionIds
|
||||
console.log('🔍 [菜单权限加载] 设置后的menuCheckedKeys:', menuCheckedKeys.value)
|
||||
} catch (error) {
|
||||
console.error('加载角色菜单权限失败:', error)
|
||||
message.error('加载角色菜单权限失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 管理功能权限
|
||||
const manageFunctionPermissions = async (record) => {
|
||||
currentRole.value = record
|
||||
functionPermissionModalVisible.value = true
|
||||
|
||||
try {
|
||||
// 加载角色的功能权限
|
||||
const response = await rolePermissionService.getRoleFunctionPermissions(record.id)
|
||||
functionCheckedKeys.value = response.data?.permissions?.map(p => p.id) || []
|
||||
} catch (error) {
|
||||
console.error('加载角色功能权限失败:', error)
|
||||
message.error('加载角色功能权限失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除角色
|
||||
const deleteRole = async (id) => {
|
||||
try {
|
||||
await rolePermissionService.deleteRole(id)
|
||||
message.success('删除成功')
|
||||
loadRoles()
|
||||
} catch (error) {
|
||||
console.error('删除角色失败:', error)
|
||||
message.error('删除角色失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 模态框确定
|
||||
const handleModalOk = async () => {
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await rolePermissionService.updateRole(formData.id, formData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await rolePermissionService.createRole(formData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
|
||||
modalVisible.value = false
|
||||
loadRoles()
|
||||
} catch (error) {
|
||||
console.error('保存角色失败:', error)
|
||||
message.error('保存角色失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 模态框取消
|
||||
const handleModalCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 菜单权限管理确定
|
||||
const handleMenuPermissionOk = async () => {
|
||||
try {
|
||||
console.log('🔍 [菜单权限保存] 角色ID:', currentRole.value.id)
|
||||
console.log('🔍 [菜单权限保存] 要保存的权限ID:', menuCheckedKeys.value)
|
||||
|
||||
await rolePermissionService.setRoleMenuPermissions(currentRole.value.id, {
|
||||
menuIds: menuCheckedKeys.value
|
||||
})
|
||||
|
||||
console.log('🔍 [菜单权限保存] 保存成功')
|
||||
message.success('菜单权限设置成功')
|
||||
menuPermissionModalVisible.value = false
|
||||
loadRoles()
|
||||
} catch (error) {
|
||||
console.error('设置菜单权限失败:', error)
|
||||
message.error('设置菜单权限失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 菜单权限管理取消
|
||||
const handleMenuPermissionCancel = () => {
|
||||
menuPermissionModalVisible.value = false
|
||||
menuCheckedKeys.value = []
|
||||
}
|
||||
|
||||
// 功能权限管理确定
|
||||
const handleFunctionPermissionOk = async () => {
|
||||
try {
|
||||
await rolePermissionService.setRoleFunctionPermissions(currentRole.value.id, {
|
||||
permissionIds: functionCheckedKeys.value
|
||||
})
|
||||
message.success('功能权限设置成功')
|
||||
functionPermissionModalVisible.value = false
|
||||
loadRoles()
|
||||
} catch (error) {
|
||||
console.error('设置功能权限失败:', error)
|
||||
message.error('设置功能权限失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 功能权限管理取消
|
||||
const handleFunctionPermissionCancel = () => {
|
||||
functionPermissionModalVisible.value = false
|
||||
functionCheckedKeys.value = []
|
||||
}
|
||||
|
||||
// 菜单树形选择变化
|
||||
const handleMenuTreeCheck = (checkedKeysValue) => {
|
||||
menuCheckedKeys.value = checkedKeysValue
|
||||
}
|
||||
|
||||
// 功能权限选择变化
|
||||
const handleFunctionCheck = (checkedValues) => {
|
||||
functionCheckedKeys.value = checkedValues
|
||||
}
|
||||
|
||||
// 过滤权限
|
||||
const filterPermissions = () => {
|
||||
// 重新计算过滤后的权限
|
||||
}
|
||||
|
||||
// 菜单权限全选
|
||||
const checkAllMenus = () => {
|
||||
const getAllKeys = (tree) => {
|
||||
let keys = []
|
||||
tree.forEach(node => {
|
||||
keys.push(node.id)
|
||||
if (node.children) {
|
||||
keys = keys.concat(getAllKeys(node.children))
|
||||
}
|
||||
})
|
||||
return keys
|
||||
}
|
||||
menuCheckedKeys.value = getAllKeys(menuTree.value)
|
||||
}
|
||||
|
||||
// 菜单权限全不选
|
||||
const uncheckAllMenus = () => {
|
||||
menuCheckedKeys.value = []
|
||||
}
|
||||
|
||||
// 功能权限全选
|
||||
const checkAllFunctions = () => {
|
||||
functionCheckedKeys.value = allPermissions.value.map(p => p.id)
|
||||
}
|
||||
|
||||
// 功能权限全不选
|
||||
const uncheckAllFunctions = () => {
|
||||
functionCheckedKeys.value = []
|
||||
}
|
||||
|
||||
// 处理角色状态切换
|
||||
const handleStatusChange = async (record, checked) => {
|
||||
try {
|
||||
// 设置加载状态
|
||||
record.statusChanging = true
|
||||
|
||||
await rolePermissionService.toggleRoleStatus(record.id, { status: checked })
|
||||
|
||||
// 更新本地状态
|
||||
record.status = checked
|
||||
|
||||
message.success(`角色${checked ? '启用' : '禁用'}成功`)
|
||||
} catch (error) {
|
||||
console.error('切换角色状态失败:', error)
|
||||
message.error('切换角色状态失败')
|
||||
|
||||
// 恢复原状态
|
||||
record.status = !checked
|
||||
} finally {
|
||||
record.statusChanging = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
status: true
|
||||
})
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(() => {
|
||||
loadRoles()
|
||||
loadMenuTree()
|
||||
loadAllPermissions()
|
||||
loadPermissionModules()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.role-permissions {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.permission-container {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.permission-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.permission-header h3 {
|
||||
margin: 0;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.function-permissions {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.function-permissions .ant-collapse {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.function-permissions .ant-collapse-item {
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.function-permissions .ant-collapse-header {
|
||||
background: #fafafa;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
358
admin-system/src/views/SearchMonitor.vue
Normal file
358
admin-system/src/views/SearchMonitor.vue
Normal file
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
<div class="search-monitor">
|
||||
<div class="page-header">
|
||||
<h2>搜索监听数据查询</h2>
|
||||
<p>查看前端和后端接收到的搜索相关信息数据</p>
|
||||
</div>
|
||||
|
||||
<div class="monitor-content">
|
||||
<!-- 搜索测试区域 -->
|
||||
<div class="test-section">
|
||||
<h3>搜索测试</h3>
|
||||
<div class="test-controls">
|
||||
<a-input
|
||||
v-model="testKeyword"
|
||||
placeholder="输入测试关键词"
|
||||
@input="handleTestInput"
|
||||
@focus="handleTestFocus"
|
||||
@blur="handleTestBlur"
|
||||
@change="handleTestChange"
|
||||
style="width: 300px; margin-right: 10px"
|
||||
/>
|
||||
<a-button type="primary" @click="handleTestSearch">测试搜索</a-button>
|
||||
<a-button @click="clearLogs">清空日志</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 实时日志显示 -->
|
||||
<div class="logs-section">
|
||||
<h3>实时监听日志</h3>
|
||||
<div class="logs-container">
|
||||
<div
|
||||
v-for="(log, index) in logs"
|
||||
:key="index"
|
||||
:class="['log-item', log.type]"
|
||||
>
|
||||
<div class="log-header">
|
||||
<span class="log-time">{{ log.timestamp }}</span>
|
||||
<span class="log-type">{{ log.typeLabel }}</span>
|
||||
<span class="log-module">{{ log.module }}</span>
|
||||
</div>
|
||||
<div class="log-content">
|
||||
<pre>{{ JSON.stringify(log.data, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-section">
|
||||
<h3>统计信息</h3>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic title="总请求数" :value="stats.totalRequests" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic title="成功请求" :value="stats.successRequests" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic title="失败请求" :value="stats.failedRequests" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic title="平均响应时间" :value="stats.avgResponseTime" suffix="ms" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { api, directApi } from '../utils/api'
|
||||
|
||||
// 测试关键词
|
||||
const testKeyword = ref('')
|
||||
|
||||
// 日志数据
|
||||
const logs = ref([])
|
||||
|
||||
// 统计信息
|
||||
const stats = reactive({
|
||||
totalRequests: 0,
|
||||
successRequests: 0,
|
||||
failedRequests: 0,
|
||||
avgResponseTime: 0
|
||||
})
|
||||
|
||||
// 测试输入处理
|
||||
const handleTestInput = (e) => {
|
||||
addLog('input', '前端输入监听', {
|
||||
event: 'input',
|
||||
value: e.target.value,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
const handleTestFocus = (e) => {
|
||||
addLog('focus', '前端焦点监听', {
|
||||
event: 'focus',
|
||||
value: e.target.value,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
const handleTestBlur = (e) => {
|
||||
addLog('blur', '前端失焦监听', {
|
||||
event: 'blur',
|
||||
value: e.target.value,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
const handleTestChange = (e) => {
|
||||
addLog('change', '前端值改变监听', {
|
||||
event: 'change',
|
||||
value: e.target.value,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
// 测试搜索
|
||||
const handleTestSearch = async () => {
|
||||
if (!testKeyword.value.trim()) {
|
||||
message.warning('请输入测试关键词')
|
||||
return
|
||||
}
|
||||
|
||||
addLog('search_start', '前端搜索开始', {
|
||||
keyword: testKeyword.value,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
try {
|
||||
const startTime = Date.now()
|
||||
const response = await api.get(`/farms/search?name=${encodeURIComponent(testKeyword.value)}`)
|
||||
const responseTime = Date.now() - startTime
|
||||
|
||||
const result = response
|
||||
|
||||
addLog('search_success', '前端搜索成功', {
|
||||
keyword: testKeyword.value,
|
||||
resultCount: result.data ? result.data.length : 0,
|
||||
responseTime: responseTime,
|
||||
backendData: result.data,
|
||||
meta: result.meta
|
||||
})
|
||||
|
||||
// 更新统计
|
||||
stats.totalRequests++
|
||||
stats.successRequests++
|
||||
stats.avgResponseTime = Math.round((stats.avgResponseTime * (stats.totalRequests - 1) + responseTime) / stats.totalRequests)
|
||||
|
||||
message.success(`搜索成功,找到 ${result.data ? result.data.length : 0} 条记录`)
|
||||
} catch (error) {
|
||||
addLog('search_error', '前端搜索失败', {
|
||||
keyword: testKeyword.value,
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
stats.totalRequests++
|
||||
stats.failedRequests++
|
||||
|
||||
message.error('搜索失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加日志
|
||||
const addLog = (type, typeLabel, data) => {
|
||||
const log = {
|
||||
type,
|
||||
typeLabel,
|
||||
module: 'search_monitor',
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
data
|
||||
}
|
||||
|
||||
logs.value.unshift(log)
|
||||
|
||||
// 限制日志数量
|
||||
if (logs.value.length > 100) {
|
||||
logs.value = logs.value.slice(0, 100)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空日志
|
||||
const clearLogs = () => {
|
||||
logs.value = []
|
||||
stats.totalRequests = 0
|
||||
stats.successRequests = 0
|
||||
stats.failedRequests = 0
|
||||
stats.avgResponseTime = 0
|
||||
message.info('日志已清空')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
addLog('system', '系统启动', {
|
||||
message: '搜索监听系统已启动',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-monitor {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.monitor-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
background: #f5f5f5;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.test-section h3 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.test-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logs-section {
|
||||
background: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logs-section h3 {
|
||||
margin: 0;
|
||||
padding: 15px 20px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #d9d9d9;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.log-item {
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.log-item.input {
|
||||
border-left: 4px solid #1890ff;
|
||||
}
|
||||
|
||||
.log-item.focus {
|
||||
border-left: 4px solid #52c41a;
|
||||
}
|
||||
|
||||
.log-item.blur {
|
||||
border-left: 4px solid #faad14;
|
||||
}
|
||||
|
||||
.log-item.change {
|
||||
border-left: 4px solid #722ed1;
|
||||
}
|
||||
|
||||
.log-item.search_start {
|
||||
border-left: 4px solid #13c2c2;
|
||||
}
|
||||
|
||||
.log-item.search_success {
|
||||
border-left: 4px solid #52c41a;
|
||||
}
|
||||
|
||||
.log-item.search_error {
|
||||
border-left: 4px solid #ff4d4f;
|
||||
}
|
||||
|
||||
.log-item.system {
|
||||
border-left: 4px solid #8c8c8c;
|
||||
}
|
||||
|
||||
.log-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #fafafa;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
color: #666;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.log-type {
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.log-module {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
padding: 10px 12px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.log-content pre {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.stats-section h3 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
509
admin-system/src/views/SmartAnklet.vue
Normal file
509
admin-system/src/views/SmartAnklet.vue
Normal file
@@ -0,0 +1,509 @@
|
||||
<template>
|
||||
<div class="smart-anklet-container">
|
||||
<div class="page-header">
|
||||
<h2>
|
||||
<a-icon type="radar-chart" style="margin-right: 8px;" />
|
||||
智能脚环管理
|
||||
</h2>
|
||||
<p>管理和监控智能脚环设备,实时监测动物运动和健康数据</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="action-bar">
|
||||
<a-space>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
添加脚环
|
||||
</a-button>
|
||||
<a-button @click="refreshData">
|
||||
<template #icon>
|
||||
<ReloadOutlined />
|
||||
</template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button @click="exportData">
|
||||
<template #icon>
|
||||
<ExportOutlined />
|
||||
</template>
|
||||
导出数据
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<!-- <a-row :gutter="16" class="stats-row">
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="总数量"
|
||||
:value="stats.total"
|
||||
:value-style="{ color: '#1890ff' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="活跃设备"
|
||||
:value="stats.active"
|
||||
:value-style="{ color: '#52c41a' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="待机设备"
|
||||
:value="stats.standby"
|
||||
:value-style="{ color: '#faad14' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="故障设备"
|
||||
:value="stats.fault"
|
||||
:value-style="{ color: '#f5222d' }"
|
||||
/>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row> -->
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<a-card title="脚环设备列表" class="table-card">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="anklets"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'stepCount'">
|
||||
<a-statistic
|
||||
:value="record.stepCount"
|
||||
suffix="步"
|
||||
:value-style="{ fontSize: '14px' }"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'heartRate'">
|
||||
<span :style="{ color: getHeartRateColor(record.heartRate) }">
|
||||
{{ record.heartRate }} BPM
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'temperature'">
|
||||
<span :style="{ color: getTemperatureColor(record.temperature) }">
|
||||
{{ record.temperature }}°C
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="viewDetail(record)">
|
||||
详情
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="viewChart(record)">
|
||||
图表
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="editAnklet(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个脚环吗?"
|
||||
@confirm="deleteAnklet(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 添加/编辑模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑脚环' : '添加脚环'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="设备编号" name="deviceId">
|
||||
<a-input v-model:value="formData.deviceId" placeholder="请输入设备编号" />
|
||||
</a-form-item>
|
||||
<a-form-item label="动物ID" name="animalId">
|
||||
<a-select v-model:value="formData.animalId" placeholder="请选择关联动物">
|
||||
<a-select-option value="1">动物001</a-select-option>
|
||||
<a-select-option value="2">动物002</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="设备型号" name="model">
|
||||
<a-input v-model:value="formData.model" placeholder="请输入设备型号" />
|
||||
</a-form-item>
|
||||
<a-form-item label="监测频率" name="frequency">
|
||||
<a-select v-model:value="formData.frequency" placeholder="请选择监测频率">
|
||||
<a-select-option value="1">每分钟</a-select-option>
|
||||
<a-select-option value="5">每5分钟</a-select-option>
|
||||
<a-select-option value="15">每15分钟</a-select-option>
|
||||
<a-select-option value="60">每小时</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="安装日期" name="installDate">
|
||||
<a-date-picker v-model:value="formData.installDate" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" name="notes">
|
||||
<a-textarea v-model:value="formData.notes" placeholder="请输入备注信息" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, ReloadOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
// 响应式数据
|
||||
const anklets = ref([])
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 统计数据
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
active: 0,
|
||||
standby: 0,
|
||||
fault: 0
|
||||
})
|
||||
|
||||
// 表格配置
|
||||
const columns = [
|
||||
{
|
||||
title: '设备编号',
|
||||
dataIndex: 'deviceId',
|
||||
key: 'deviceId',
|
||||
},
|
||||
{
|
||||
title: '关联动物',
|
||||
dataIndex: 'animalName',
|
||||
key: 'animalName',
|
||||
},
|
||||
{
|
||||
title: '设备型号',
|
||||
dataIndex: 'model',
|
||||
key: 'model',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
},
|
||||
{
|
||||
title: '步数',
|
||||
dataIndex: 'stepCount',
|
||||
key: 'stepCount',
|
||||
},
|
||||
{
|
||||
title: '心率',
|
||||
dataIndex: 'heartRate',
|
||||
key: 'heartRate',
|
||||
},
|
||||
{
|
||||
title: '体温',
|
||||
dataIndex: 'temperature',
|
||||
key: 'temperature',
|
||||
},
|
||||
{
|
||||
title: '最后更新',
|
||||
dataIndex: 'lastUpdate',
|
||||
key: 'lastUpdate',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
}
|
||||
]
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
deviceId: '',
|
||||
animalId: '',
|
||||
model: '',
|
||||
frequency: '',
|
||||
installDate: null,
|
||||
notes: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
deviceId: [
|
||||
{ required: true, message: '请输入设备编号', trigger: 'blur' }
|
||||
],
|
||||
animalId: [
|
||||
{ required: true, message: '请选择关联动物', trigger: 'change' }
|
||||
],
|
||||
model: [
|
||||
{ required: true, message: '请输入设备型号', trigger: 'blur' }
|
||||
],
|
||||
frequency: [
|
||||
{ required: true, message: '请选择监测频率', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
'active': 'green',
|
||||
'standby': 'orange',
|
||||
'fault': 'red'
|
||||
}
|
||||
return colors[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
'active': '活跃',
|
||||
'standby': '待机',
|
||||
'fault': '故障'
|
||||
}
|
||||
return texts[status] || '未知'
|
||||
}
|
||||
|
||||
// 获取心率颜色
|
||||
const getHeartRateColor = (heartRate) => {
|
||||
if (heartRate >= 60 && heartRate <= 100) return '#52c41a'
|
||||
if (heartRate > 100 || heartRate < 60) return '#faad14'
|
||||
return '#f5222d'
|
||||
}
|
||||
|
||||
// 获取体温颜色
|
||||
const getTemperatureColor = (temperature) => {
|
||||
if (temperature >= 38.0 && temperature <= 39.5) return '#52c41a'
|
||||
if (temperature > 39.5 || temperature < 38.0) return '#faad14'
|
||||
return '#f5222d'
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 模拟数据
|
||||
const mockData = [
|
||||
{
|
||||
id: 1,
|
||||
deviceId: 'AN001',
|
||||
animalName: '牛001',
|
||||
model: 'SmartAnklet-V1',
|
||||
status: 'active',
|
||||
stepCount: 2456,
|
||||
heartRate: 75,
|
||||
temperature: 38.5,
|
||||
lastUpdate: '2025-01-18 10:30:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
deviceId: 'AN002',
|
||||
animalName: '牛002',
|
||||
model: 'SmartAnklet-V1',
|
||||
status: 'standby',
|
||||
stepCount: 1823,
|
||||
heartRate: 68,
|
||||
temperature: 38.2,
|
||||
lastUpdate: '2025-01-18 09:15:00'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
deviceId: 'AN003',
|
||||
animalName: '羊001',
|
||||
model: 'SmartAnklet-V2',
|
||||
status: 'active',
|
||||
stepCount: 3124,
|
||||
heartRate: 82,
|
||||
temperature: 39.1,
|
||||
lastUpdate: '2025-01-18 10:25:00'
|
||||
}
|
||||
]
|
||||
|
||||
anklets.value = mockData
|
||||
pagination.total = mockData.length
|
||||
|
||||
// 更新统计数据
|
||||
stats.total = mockData.length
|
||||
stats.active = mockData.filter(item => item.status === 'active').length
|
||||
stats.standby = mockData.filter(item => item.status === 'standby').length
|
||||
stats.fault = mockData.filter(item => item.status === 'fault').length
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取数据失败:', error)
|
||||
message.error('获取数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = () => {
|
||||
fetchData()
|
||||
message.success('数据已刷新')
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
const exportData = () => {
|
||||
message.info('导出功能开发中...')
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑脚环
|
||||
const editAnklet = (record) => {
|
||||
isEdit.value = true
|
||||
Object.assign(formData, {
|
||||
...record,
|
||||
installDate: null
|
||||
})
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const viewDetail = (record) => {
|
||||
message.info(`查看 ${record.deviceId} 的详细信息`)
|
||||
}
|
||||
|
||||
// 查看图表
|
||||
const viewChart = (record) => {
|
||||
message.info(`查看 ${record.deviceId} 的运动图表`)
|
||||
}
|
||||
|
||||
// 删除脚环
|
||||
const deleteAnklet = async (id) => {
|
||||
try {
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
modalVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
deviceId: '',
|
||||
animalId: '',
|
||||
model: '',
|
||||
frequency: '',
|
||||
installDate: null,
|
||||
notes: ''
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.smart-anklet-container {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #1890ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
1776
admin-system/src/views/SmartCollar.vue
Normal file
1776
admin-system/src/views/SmartCollar.vue
Normal file
File diff suppressed because it is too large
Load Diff
1141
admin-system/src/views/SmartCollarAlert.vue
Normal file
1141
admin-system/src/views/SmartCollarAlert.vue
Normal file
File diff suppressed because it is too large
Load Diff
1252
admin-system/src/views/SmartEartag.vue
Normal file
1252
admin-system/src/views/SmartEartag.vue
Normal file
File diff suppressed because it is too large
Load Diff
935
admin-system/src/views/SmartEartagAlert.vue
Normal file
935
admin-system/src/views/SmartEartagAlert.vue
Normal file
@@ -0,0 +1,935 @@
|
||||
<template>
|
||||
<div class="smart-eartag-alert-container">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">智能耳标预警</h2>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon battery">
|
||||
<PoweroffOutlined />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.lowBattery }}</div>
|
||||
<div class="stat-label">低电量预警</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon offline">
|
||||
<DisconnectOutlined />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.offline }}</div>
|
||||
<div class="stat-label">离线预警</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon temperature">
|
||||
<FireOutlined />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.highTemperature }}</div>
|
||||
<div class="stat-label">温度预警</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon movement">
|
||||
<ThunderboltOutlined />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.abnormalMovement }}</div>
|
||||
<div class="stat-label">异常运动预警</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索和筛选栏 -->
|
||||
<div class="search-bar">
|
||||
<div class="search-group">
|
||||
<a-input
|
||||
:value="searchValue"
|
||||
@input="updateSearchValue"
|
||||
placeholder="请输入耳标编号"
|
||||
class="search-input"
|
||||
@pressEnter="handleSearch"
|
||||
/>
|
||||
<a-select
|
||||
v-model="alertTypeFilter"
|
||||
placeholder="预警类型"
|
||||
class="filter-select"
|
||||
@change="handleFilterChange"
|
||||
>
|
||||
<a-select-option value="">全部预警</a-select-option>
|
||||
<a-select-option value="battery">低电量预警</a-select-option>
|
||||
<a-select-option value="offline">离线预警</a-select-option>
|
||||
<a-select-option value="temperature">温度预警</a-select-option>
|
||||
<a-select-option value="movement">异常运动预警</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" class="search-button" @click="handleSearch">
|
||||
<template #icon>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button class="search-button" @click="handleClearSearch" v-if="searchValue.trim() || alertTypeFilter">
|
||||
清除
|
||||
</a-button>
|
||||
</div>
|
||||
<a-button @click="exportData" class="export-button">
|
||||
<template #icon>
|
||||
<ExportOutlined />
|
||||
</template>
|
||||
导出数据
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 预警列表表格 -->
|
||||
<div class="table-container">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="alerts"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
class="alert-table"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 耳标编号 -->
|
||||
<template v-if="column.dataIndex === 'eartagNumber'">
|
||||
<span class="eartag-number">{{ record.eartagNumber }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 预警类型 -->
|
||||
<template v-else-if="column.dataIndex === 'alertType'">
|
||||
<a-tag
|
||||
:color="getAlertTypeColor(record.alertType)"
|
||||
class="alert-type-tag"
|
||||
>
|
||||
{{ getAlertTypeText(record.alertType) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 预警级别 -->
|
||||
<template v-else-if="column.dataIndex === 'alertLevel'">
|
||||
<a-tag
|
||||
:color="getAlertLevelColor(record.alertLevel)"
|
||||
class="alert-level-tag"
|
||||
>
|
||||
{{ getAlertLevelText(record.alertLevel) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<!-- 预警时间 -->
|
||||
<template v-else-if="column.dataIndex === 'alertTime'">
|
||||
<div class="alert-time-cell">
|
||||
<span class="alert-time-value">{{ record.alertTime }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<div class="action-cell">
|
||||
<a-button type="link" class="action-link" @click="viewDetails(record)">
|
||||
查看详情
|
||||
</a-button>
|
||||
<a-button type="link" class="action-link" @click="handleAlert(record)">
|
||||
处理预警
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 预警详情模态框 -->
|
||||
<a-modal
|
||||
:open="detailVisible"
|
||||
title="预警详情"
|
||||
:footer="null"
|
||||
width="800px"
|
||||
@cancel="handleDetailCancel"
|
||||
>
|
||||
<div class="alert-detail-modal">
|
||||
<div class="detail-grid">
|
||||
<div class="detail-item">
|
||||
<span class="label">耳标编号:</span>
|
||||
<span class="value">{{ currentAlert?.eartagNumber }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">预警类型:</span>
|
||||
<span class="value">
|
||||
<a-tag :color="getAlertTypeColor(currentAlert?.alertType)">
|
||||
{{ getAlertTypeText(currentAlert?.alertType) }}
|
||||
</a-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">预警级别:</span>
|
||||
<span class="value">
|
||||
<a-tag :color="getAlertLevelColor(currentAlert?.alertLevel)">
|
||||
{{ getAlertLevelText(currentAlert?.alertLevel) }}
|
||||
</a-tag>
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">预警时间:</span>
|
||||
<span class="value">{{ currentAlert?.alertTime }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">设备电量:</span>
|
||||
<span class="value">{{ currentAlert?.battery }}%</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">设备温度:</span>
|
||||
<span class="value">{{ currentAlert?.temperature }}°C</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">运动量:</span>
|
||||
<span class="value">{{ currentAlert?.movement }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">预警描述:</span>
|
||||
<span class="value">{{ currentAlert?.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="modal-footer">
|
||||
<a-button @click="handleDetailCancel">关闭</a-button>
|
||||
<a-button type="primary" @click="handleAlert(currentAlert)">处理预警</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
SearchOutlined,
|
||||
PoweroffOutlined,
|
||||
DisconnectOutlined,
|
||||
FireOutlined,
|
||||
ThunderboltOutlined,
|
||||
ExportOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
|
||||
// 响应式数据
|
||||
const alerts = ref([])
|
||||
const loading = ref(false)
|
||||
const searchValue = ref('')
|
||||
const alertTypeFilter = ref('')
|
||||
const detailVisible = ref(false)
|
||||
const currentAlert = ref(null)
|
||||
|
||||
// 统计数据
|
||||
const stats = reactive({
|
||||
lowBattery: 0,
|
||||
offline: 0,
|
||||
highTemperature: 0,
|
||||
abnormalMovement: 0
|
||||
})
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: '耳标编号',
|
||||
dataIndex: 'eartagNumber',
|
||||
key: 'eartagNumber',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '预警类型',
|
||||
dataIndex: 'alertType',
|
||||
key: 'alertType',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '预警级别',
|
||||
dataIndex: 'alertLevel',
|
||||
key: 'alertLevel',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '预警时间',
|
||||
dataIndex: 'alertTime',
|
||||
key: 'alertTime',
|
||||
width: 180,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '设备电量',
|
||||
dataIndex: 'battery',
|
||||
key: 'battery',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '设备温度',
|
||||
dataIndex: 'temperature',
|
||||
key: 'temperature',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '当日步数',
|
||||
dataIndex: 'dailySteps',
|
||||
key: 'dailySteps',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
align: 'center',
|
||||
}
|
||||
]
|
||||
|
||||
// 获取预警类型文本
|
||||
const getAlertTypeText = (type) => {
|
||||
const typeMap = {
|
||||
'battery': '低电量预警',
|
||||
'offline': '离线预警',
|
||||
'temperature': '温度预警',
|
||||
'movement': '异常运动预警'
|
||||
}
|
||||
return typeMap[type] || '未知预警'
|
||||
}
|
||||
|
||||
// 获取预警类型颜色
|
||||
const getAlertTypeColor = (type) => {
|
||||
const colorMap = {
|
||||
'battery': 'orange',
|
||||
'offline': 'red',
|
||||
'temperature': 'red',
|
||||
'movement': 'purple'
|
||||
}
|
||||
return colorMap[type] || 'default'
|
||||
}
|
||||
|
||||
// 获取预警级别文本
|
||||
const getAlertLevelText = (level) => {
|
||||
const levelMap = {
|
||||
'high': '高级',
|
||||
'medium': '中级',
|
||||
'low': '低级'
|
||||
}
|
||||
return levelMap[level] || '未知'
|
||||
}
|
||||
|
||||
// 获取预警级别颜色
|
||||
const getAlertLevelColor = (level) => {
|
||||
const colorMap = {
|
||||
'high': 'red',
|
||||
'medium': 'orange',
|
||||
'low': 'green'
|
||||
}
|
||||
return colorMap[level] || 'default'
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async (showMessage = false, customAlertType = null) => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 构建查询参数
|
||||
const params = new URLSearchParams({
|
||||
page: pagination.current.toString(),
|
||||
limit: pagination.pageSize.toString(),
|
||||
_t: Date.now().toString(),
|
||||
refresh: 'true'
|
||||
})
|
||||
|
||||
// 添加搜索条件
|
||||
if (searchValue.value.trim()) {
|
||||
params.append('search', searchValue.value.trim())
|
||||
}
|
||||
|
||||
// 添加预警类型筛选 - 使用传入的参数或默认值
|
||||
const alertType = customAlertType !== null ? customAlertType : alertTypeFilter.value
|
||||
if (alertType && alertType.trim() !== '') {
|
||||
params.append('alertType', alertType)
|
||||
}
|
||||
|
||||
// 调用API获取预警数据
|
||||
const { smartAlertService } = await import('../utils/dataService')
|
||||
console.log('使用smartAlertService获取耳标预警数据')
|
||||
console.log('搜索参数:', {
|
||||
search: searchValue.value.trim(),
|
||||
alertType: alertTypeFilter.value,
|
||||
page: pagination.current,
|
||||
limit: pagination.pageSize
|
||||
})
|
||||
|
||||
const requestParams = {
|
||||
search: searchValue.value.trim(),
|
||||
alertType: alertType,
|
||||
page: pagination.current,
|
||||
limit: pagination.pageSize
|
||||
}
|
||||
console.log('发送API请求,参数:', requestParams)
|
||||
|
||||
const result = await smartAlertService.getEartagAlerts(requestParams)
|
||||
console.log('API响应结果:', result)
|
||||
console.log('API返回的数据类型:', typeof result.data)
|
||||
console.log('API返回的数据长度:', result.data ? result.data.length : 'undefined')
|
||||
console.log('API返回的数据内容:', result.data)
|
||||
console.log('响应类型:', typeof result)
|
||||
console.log('是否有success字段:', 'success' in result)
|
||||
console.log('success值:', result.success)
|
||||
|
||||
if (result.success) {
|
||||
// 更新预警列表数据
|
||||
alerts.value = result.data || []
|
||||
pagination.total = result.total || 0
|
||||
|
||||
// 更新统计数据
|
||||
if (result.stats) {
|
||||
stats.lowBattery = result.stats.lowBattery || 0
|
||||
stats.offline = result.stats.offline || 0
|
||||
stats.highTemperature = result.stats.highTemperature || 0
|
||||
stats.abnormalMovement = result.stats.abnormalMovement || 0
|
||||
}
|
||||
|
||||
console.log('更新后的预警列表:', alerts.value)
|
||||
console.log('总数:', pagination.total)
|
||||
|
||||
if (showMessage) {
|
||||
const searchText = searchValue.value.trim() ? `搜索"${searchValue.value.trim()}"` : '加载'
|
||||
message.success(`${searchText}完成,共找到 ${pagination.total} 条预警数据`)
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new Error(result.message || '获取数据失败')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取数据失败:', error)
|
||||
console.error('错误详情:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name
|
||||
})
|
||||
if (showMessage) {
|
||||
message.error('获取数据失败: ' + error.message)
|
||||
}
|
||||
|
||||
// 清空数据而不是显示模拟数据
|
||||
alerts.value = []
|
||||
pagination.total = 0
|
||||
stats.lowBattery = 0
|
||||
stats.offline = 0
|
||||
stats.highTemperature = 0
|
||||
stats.abnormalMovement = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生成模拟数据
|
||||
const generateMockData = () => {
|
||||
const mockAlerts = [
|
||||
{
|
||||
id: 1,
|
||||
eartagNumber: 'EARTAG001',
|
||||
alertType: 'battery',
|
||||
alertLevel: 'high',
|
||||
alertTime: '2025-01-18 10:30:00',
|
||||
battery: 15,
|
||||
temperature: 38.5,
|
||||
gpsSignal: '强',
|
||||
movementStatus: '正常',
|
||||
description: '设备电量低于20%,需要及时充电',
|
||||
longitude: 116.3974,
|
||||
latitude: 39.9093
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
eartagNumber: 'EARTAG002',
|
||||
alertType: 'offline',
|
||||
alertLevel: 'high',
|
||||
alertTime: '2025-01-18 09:15:00',
|
||||
battery: 0,
|
||||
temperature: 0,
|
||||
gpsSignal: '无',
|
||||
movementStatus: '静止',
|
||||
description: '设备已离线超过30分钟',
|
||||
longitude: 0,
|
||||
latitude: 0
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
eartagNumber: 'EARTAG003',
|
||||
alertType: 'temperature',
|
||||
alertLevel: 'medium',
|
||||
alertTime: '2025-01-18 08:45:00',
|
||||
battery: 85,
|
||||
temperature: 42.3,
|
||||
gpsSignal: '强',
|
||||
movementStatus: '正常',
|
||||
description: '设备温度异常,超过正常范围',
|
||||
longitude: 116.4074,
|
||||
latitude: 39.9193
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
eartagNumber: 'EARTAG004',
|
||||
alertType: 'movement',
|
||||
alertLevel: 'low',
|
||||
alertTime: '2025-01-18 07:20:00',
|
||||
battery: 92,
|
||||
temperature: 39.1,
|
||||
gpsSignal: '强',
|
||||
movementStatus: '异常',
|
||||
description: '运动量异常,可能发生异常行为',
|
||||
longitude: 116.4174,
|
||||
latitude: 39.9293
|
||||
}
|
||||
]
|
||||
|
||||
alerts.value = mockAlerts
|
||||
pagination.total = mockAlerts.length
|
||||
|
||||
// 更新统计数据
|
||||
stats.lowBattery = mockAlerts.filter(alert => alert.alertType === 'battery').length
|
||||
stats.offline = mockAlerts.filter(alert => alert.alertType === 'offline').length
|
||||
stats.highTemperature = mockAlerts.filter(alert => alert.alertType === 'temperature').length
|
||||
stats.abnormalMovement = mockAlerts.filter(alert => alert.alertType === 'movement').length
|
||||
}
|
||||
|
||||
// 更新搜索值
|
||||
const updateSearchValue = (e) => {
|
||||
searchValue.value = e.target.value
|
||||
console.log('搜索输入框值变化:', searchValue.value)
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
fetchData(true)
|
||||
}
|
||||
|
||||
// 筛选变化处理
|
||||
const handleFilterChange = (value) => {
|
||||
console.log('=== 智能耳标预警类型筛选变化 ===')
|
||||
console.log('传入的 value 参数:', value)
|
||||
console.log('传入的 value 参数类型:', typeof value)
|
||||
console.log('传入的 value 参数长度:', value !== undefined ? value.length : 'undefined')
|
||||
console.log('传入的 value 参数是否为空:', value === '')
|
||||
console.log('传入的 value 参数是否为undefined:', value === undefined)
|
||||
console.log('传入的 value 参数是否为null:', value === null)
|
||||
console.log('传入的 value 参数是否有效:', value && value.trim() !== '')
|
||||
|
||||
// 使用传入的 value 参数而不是 alertTypeFilter.value
|
||||
const alertType = value || ''
|
||||
console.log('使用的 alertType:', alertType)
|
||||
|
||||
pagination.current = 1
|
||||
console.log('准备调用 fetchData,参数:', {
|
||||
search: searchValue.value,
|
||||
alertType: alertType,
|
||||
page: pagination.current,
|
||||
limit: pagination.pageSize
|
||||
})
|
||||
|
||||
// 直接调用 fetchData 并传递 alertType 参数
|
||||
fetchData(true, alertType)
|
||||
}
|
||||
|
||||
// 清除搜索
|
||||
const handleClearSearch = () => {
|
||||
searchValue.value = ''
|
||||
alertTypeFilter.value = ''
|
||||
pagination.current = 1
|
||||
fetchData(true)
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const viewDetails = (record) => {
|
||||
currentAlert.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
// 处理预警
|
||||
const handleAlert = (record) => {
|
||||
message.success(`正在处理预警: ${record.eartagNumber}`)
|
||||
// 这里可以添加处理预警的逻辑
|
||||
}
|
||||
|
||||
// 取消详情
|
||||
const handleDetailCancel = () => {
|
||||
detailVisible.value = false
|
||||
currentAlert.value = null
|
||||
}
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
const exportData = async () => {
|
||||
try {
|
||||
console.log('=== 开始导出智能耳标预警数据 ===')
|
||||
|
||||
message.loading('正在获取所有预警数据...', 0)
|
||||
|
||||
// 使用smartAlertService获取所有预警数据,不受分页限制
|
||||
const { smartAlertService } = await import('../utils/dataService')
|
||||
|
||||
const requestParams = {
|
||||
search: searchValue.value.trim(),
|
||||
alertType: alertTypeFilter.value,
|
||||
page: 1,
|
||||
limit: 1000 // 获取大量数据
|
||||
}
|
||||
|
||||
console.log('导出请求参数:', requestParams)
|
||||
|
||||
const apiResult = await smartAlertService.getEartagAlerts(requestParams)
|
||||
console.log('预警API响应:', apiResult)
|
||||
|
||||
if (!apiResult.success || !apiResult.data) {
|
||||
message.destroy()
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
const allAlerts = apiResult.data || []
|
||||
console.log('获取到所有预警数据:', allAlerts.length, '条记录')
|
||||
console.log('原始数据示例:', allAlerts[0])
|
||||
|
||||
// 预警类型中文映射
|
||||
const alertTypeMap = {
|
||||
'battery': '低电量预警',
|
||||
'offline': '离线预警',
|
||||
'temperature': '温度异常预警',
|
||||
'movement': '运动异常预警',
|
||||
'location': '位置异常预警'
|
||||
}
|
||||
|
||||
// 预警级别中文映射
|
||||
const alertLevelMap = {
|
||||
'high': '高',
|
||||
'medium': '中',
|
||||
'low': '低',
|
||||
'critical': '紧急'
|
||||
}
|
||||
|
||||
// 转换数据格式以匹配导出工具类的列配置
|
||||
const exportData = allAlerts.map(item => {
|
||||
console.log('转换前预警数据项:', item)
|
||||
|
||||
// 格式化时间
|
||||
let alertTime = ''
|
||||
if (item.alertTime || item.alert_time || item.created_at) {
|
||||
const timeValue = item.alertTime || item.alert_time || item.created_at
|
||||
if (typeof timeValue === 'number') {
|
||||
// Unix时间戳转换
|
||||
alertTime = new Date(timeValue * 1000).toLocaleString('zh-CN')
|
||||
} else if (typeof timeValue === 'string') {
|
||||
// 字符串时间转换
|
||||
const date = new Date(timeValue)
|
||||
if (!isNaN(date.getTime())) {
|
||||
alertTime = date.toLocaleString('zh-CN')
|
||||
} else {
|
||||
alertTime = timeValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
device_name: item.deviceName || item.eartagNumber || item.device_name || item.deviceId || '',
|
||||
alert_type: alertTypeMap[item.alertType || item.alert_type] || item.alertType || item.alert_type || '',
|
||||
alert_level: alertLevelMap[item.alertLevel || item.alert_level] || item.alertLevel || item.alert_level || '',
|
||||
alert_content: item.alertContent || item.alert_content || item.message || item.description || '系统预警',
|
||||
alert_time: alertTime,
|
||||
status: item.status || (item.processed ? '已处理' : '未处理'),
|
||||
handler: item.handler || item.processor || item.handler_name || item.operator || ''
|
||||
}
|
||||
})
|
||||
|
||||
console.log('转换后预警数据示例:', exportData[0])
|
||||
console.log('转换后预警数据总数:', exportData.length)
|
||||
|
||||
message.destroy()
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportAlertData(exportData, 'eartag')
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchData(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.smart-eartag-alert-container {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 页面标题样式 */
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #52c41a;
|
||||
margin: 0;
|
||||
padding: 12px 20px;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 统计卡片样式 */
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.stat-icon.battery {
|
||||
background: linear-gradient(135deg, #ff9a56, #ff6b6b);
|
||||
}
|
||||
|
||||
.stat-icon.offline {
|
||||
background: linear-gradient(135deg, #ff6b6b, #ee5a52);
|
||||
}
|
||||
|
||||
.stat-icon.temperature {
|
||||
background: linear-gradient(135deg, #ff6b6b, #ff4757);
|
||||
}
|
||||
|
||||
.stat-icon.movement {
|
||||
background: linear-gradient(135deg, #a55eea, #8b5cf6);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #262626;
|
||||
line-height: 1;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
/* 搜索栏样式 */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.search-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 200px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 150px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.export-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 表格容器样式 */
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.alert-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 表格单元格样式 */
|
||||
.eartag-number {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.alert-type-tag,
|
||||
.alert-level-tag {
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.alert-time-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.alert-time-value {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-link {
|
||||
color: #1890ff;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
line-height: 1.2;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.action-link:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
/* 预警详情模态框样式 */
|
||||
.alert-detail-modal {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-item .label {
|
||||
min-width: 100px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.detail-item .value {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
.alert-table :deep(.ant-table) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.alert-table :deep(.ant-table-thead > tr > th) {
|
||||
padding: 8px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.alert-table :deep(.ant-table-tbody > tr > td) {
|
||||
padding: 6px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.alert-table :deep(.ant-table-tbody > tr:hover > td) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
890
admin-system/src/views/SmartHost.vue
Normal file
890
admin-system/src/views/SmartHost.vue
Normal file
@@ -0,0 +1,890 @@
|
||||
<template>
|
||||
<div class="smart-host-container">
|
||||
<!-- 页面标题 -->
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">主机定位总览</h2>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<a-button @click="exportData" class="export-button">
|
||||
<template #icon>
|
||||
<ExportOutlined />
|
||||
</template>
|
||||
导出数据
|
||||
</a-button>
|
||||
<div class="search-group">
|
||||
<a-input
|
||||
:value="searchValue"
|
||||
@input="updateSearchValue"
|
||||
placeholder="请输入主机编号"
|
||||
class="search-input"
|
||||
@pressEnter="handleSearch"
|
||||
/>
|
||||
<a-button type="primary" class="search-button" @click="handleSearch">
|
||||
<template #icon>
|
||||
<SearchOutlined />
|
||||
</template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button class="search-button" @click="handleClearSearch" v-if="searchValue.trim()">
|
||||
清除
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<div class="table-container">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="hosts"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
class="host-table"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<!-- 设备编号 -->
|
||||
<template v-if="column.dataIndex === 'deviceNumber'">
|
||||
<span class="device-number">{{ record.deviceNumber }}</span>
|
||||
</template>
|
||||
|
||||
<!-- 设备电量% -->
|
||||
<template v-else-if="column.dataIndex === 'battery'">
|
||||
<div class="battery-cell">
|
||||
<span class="battery-value">{{ record.battery }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 设备信号值 -->
|
||||
<template v-else-if="column.dataIndex === 'signalValue'">
|
||||
<div class="signal-cell">
|
||||
<span class="signal-value">{{ record.signalValue }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 设备温度/°C -->
|
||||
<template v-else-if="column.dataIndex === 'temperature'">
|
||||
<div class="temperature-cell">
|
||||
<span class="temperature-value">{{ record.temperature }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 更新时间 -->
|
||||
<template v-else-if="column.dataIndex === 'updateTime'">
|
||||
<div class="update-time-cell">
|
||||
<span class="update-time-value">{{ record.updateTime }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 联网状态 -->
|
||||
<template v-else-if="column.dataIndex === 'networkStatus'">
|
||||
<div class="network-status-cell">
|
||||
<a-tag
|
||||
:color="getNetworkStatusColor(record.networkStatus)"
|
||||
class="network-status-tag"
|
||||
>
|
||||
{{ record.networkStatus }}
|
||||
</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 操作 -->
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<div class="action-cell">
|
||||
<a-button type="link" class="action-link" @click="viewLocation(record)">
|
||||
查看定位
|
||||
</a-button>
|
||||
<a-button type="link" class="action-link" @click="viewCollectionInfo(record)">
|
||||
查看采集信息
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 定位信息模态框 -->
|
||||
<a-modal
|
||||
:open="locationVisible"
|
||||
title="查看定位"
|
||||
:footer="null"
|
||||
width="90%"
|
||||
:style="{ top: '20px' }"
|
||||
@cancel="handleLocationCancel"
|
||||
>
|
||||
<div class="location-modal">
|
||||
<!-- 地图容器 -->
|
||||
<div class="map-container">
|
||||
<div id="locationMap" class="baidu-map"></div>
|
||||
|
||||
<!-- 地图样式切换按钮 - 严格按照图片样式 -->
|
||||
<div class="map-style-controls">
|
||||
<div
|
||||
:class="['style-btn', { active: mapStyle === 'normal' }]"
|
||||
@click="switchMapStyle('normal')"
|
||||
>
|
||||
地图
|
||||
</div>
|
||||
<div
|
||||
:class="['style-btn', { active: mapStyle === 'hybrid' }]"
|
||||
@click="switchMapStyle('hybrid')"
|
||||
>
|
||||
混合
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 底部取消按钮 -->
|
||||
<div class="location-footer">
|
||||
<a-button class="cancel-btn" @click="handleLocationCancel">取消</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 采集信息模态框 -->
|
||||
<a-modal
|
||||
:open="collectionInfoVisible"
|
||||
title="查看采集信息"
|
||||
:footer="null"
|
||||
width="800px"
|
||||
@cancel="handleCollectionInfoCancel"
|
||||
>
|
||||
<div class="collection-info-modal">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">设备编号:</span>
|
||||
<span class="value">{{ currentHost?.deviceNumber }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">设备电量:</span>
|
||||
<span class="value">{{ currentHost?.battery }}%</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">信号强度:</span>
|
||||
<span class="value">{{ currentHost?.signalValue }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">设备温度:</span>
|
||||
<span class="value">{{ currentHost?.temperature }}°C</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">GPS状态:</span>
|
||||
<span class="value">{{ currentHost?.gpsStatus }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">联网状态:</span>
|
||||
<span class="value">{{ currentHost?.networkStatus }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">最后更新:</span>
|
||||
<span class="value">{{ currentHost?.updateTime }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">设备标题:</span>
|
||||
<span class="value">{{ currentHost?.title || '未设置' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="modal-footer">
|
||||
<a-button @click="handleCollectionInfoCancel">关闭</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { SearchOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
import { loadBMapScript, createMap } from '@/utils/mapService'
|
||||
|
||||
// 响应式数据
|
||||
const hosts = ref([])
|
||||
const loading = ref(false)
|
||||
const searchValue = ref('')
|
||||
const autoRefresh = ref(true)
|
||||
const refreshInterval = ref(null)
|
||||
|
||||
// 模态框相关数据
|
||||
const locationVisible = ref(false)
|
||||
const collectionInfoVisible = ref(false)
|
||||
const mapStyle = ref('normal')
|
||||
const currentLocation = ref(null)
|
||||
const currentHost = ref(null)
|
||||
const baiduMap = ref(null)
|
||||
const locationMarker = ref(null)
|
||||
|
||||
// 分页配置
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
})
|
||||
|
||||
// 表格列配置 - 严格按照图片样式
|
||||
const columns = [
|
||||
{
|
||||
title: '设备编号',
|
||||
dataIndex: 'deviceNumber',
|
||||
key: 'deviceNumber',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '设备电量/%',
|
||||
dataIndex: 'battery',
|
||||
key: 'battery',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '设备信号值',
|
||||
dataIndex: 'signalValue',
|
||||
key: 'signalValue',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '设备温度/°C',
|
||||
dataIndex: 'temperature',
|
||||
key: 'temperature',
|
||||
width: 120,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updateTime',
|
||||
key: 'updateTime',
|
||||
width: 180,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '联网状态',
|
||||
dataIndex: 'networkStatus',
|
||||
key: 'networkStatus',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
align: 'center',
|
||||
}
|
||||
]
|
||||
|
||||
// 获取联网状态颜色
|
||||
const getNetworkStatusColor = (status) => {
|
||||
const colorMap = {
|
||||
'已联网': 'green',
|
||||
'未联网': 'red'
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async (showMessage = false) => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// 构建查询参数
|
||||
const params = new URLSearchParams({
|
||||
page: pagination.current.toString(),
|
||||
limit: pagination.pageSize.toString(),
|
||||
_t: Date.now().toString(), // 防止缓存
|
||||
refresh: 'true'
|
||||
})
|
||||
|
||||
// 如果有搜索条件,添加到参数中
|
||||
if (searchValue.value.trim()) {
|
||||
params.append('search', searchValue.value.trim())
|
||||
console.log('搜索条件:', searchValue.value.trim())
|
||||
}
|
||||
|
||||
// 调用API获取智能主机数据
|
||||
const apiUrl = `/api/smart-devices/hosts?${params}`
|
||||
console.log('API请求URL:', apiUrl)
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log('API响应结果:', result)
|
||||
|
||||
if (result.success) {
|
||||
// 更新设备列表数据(后端已计算联网状态)
|
||||
hosts.value = result.data || []
|
||||
pagination.total = result.total || 0
|
||||
|
||||
console.log('更新后的设备列表:', hosts.value)
|
||||
console.log('总数:', pagination.total)
|
||||
|
||||
if (showMessage) {
|
||||
const searchText = searchValue.value.trim() ? `搜索"${searchValue.value.trim()}"` : '加载'
|
||||
message.success(`${searchText}完成,共找到 ${pagination.total} 条主机数据`)
|
||||
}
|
||||
|
||||
} else {
|
||||
throw new Error(result.message || '获取数据失败')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取数据失败:', error)
|
||||
if (showMessage) {
|
||||
message.error('获取数据失败: ' + error.message)
|
||||
}
|
||||
|
||||
// 如果API失败,显示空数据
|
||||
hosts.value = []
|
||||
pagination.total = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 更新搜索值
|
||||
const updateSearchValue = (e) => {
|
||||
const newValue = e.target.value
|
||||
console.log('输入框值变化:', newValue)
|
||||
console.log('更新前searchValue:', searchValue.value)
|
||||
searchValue.value = newValue
|
||||
console.log('更新后searchValue:', searchValue.value)
|
||||
}
|
||||
|
||||
// 搜索处理
|
||||
const handleSearch = () => {
|
||||
console.log('执行搜索,当前搜索值:', searchValue.value)
|
||||
pagination.current = 1
|
||||
fetchData(true)
|
||||
}
|
||||
|
||||
// 清除搜索
|
||||
const handleClearSearch = () => {
|
||||
searchValue.value = ''
|
||||
pagination.current = 1
|
||||
fetchData(true)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 自动刷新功能
|
||||
const startAutoRefresh = () => {
|
||||
if (refreshInterval.value) {
|
||||
clearInterval(refreshInterval.value)
|
||||
}
|
||||
|
||||
if (autoRefresh.value) {
|
||||
refreshInterval.value = setInterval(() => {
|
||||
fetchData(false) // 自动刷新时不显示成功消息
|
||||
}, 30000) // 每30秒自动刷新一次
|
||||
console.log('自动刷新已启动,每30秒更新一次数据')
|
||||
}
|
||||
}
|
||||
|
||||
// 停止自动刷新
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshInterval.value) {
|
||||
clearInterval(refreshInterval.value)
|
||||
refreshInterval.value = null
|
||||
console.log('自动刷新已停止')
|
||||
}
|
||||
}
|
||||
|
||||
// 查看定位
|
||||
const viewLocation = (record) => {
|
||||
if (record.longitude && record.latitude && record.longitude !== '0' && record.latitude !== '90') {
|
||||
currentLocation.value = {
|
||||
longitude: parseFloat(record.longitude),
|
||||
latitude: parseFloat(record.latitude),
|
||||
deviceNumber: record.deviceNumber,
|
||||
updateTime: record.updateTime
|
||||
}
|
||||
locationVisible.value = true
|
||||
|
||||
// 延迟初始化地图,确保DOM已渲染
|
||||
setTimeout(() => {
|
||||
initBaiduMap()
|
||||
}, 100)
|
||||
} else {
|
||||
message.warning('该设备暂无有效定位信息')
|
||||
}
|
||||
}
|
||||
|
||||
// 查看采集信息
|
||||
const viewCollectionInfo = (record) => {
|
||||
currentHost.value = record
|
||||
collectionInfoVisible.value = true
|
||||
}
|
||||
|
||||
// 取消定位信息
|
||||
const handleLocationCancel = () => {
|
||||
locationVisible.value = false
|
||||
currentLocation.value = null
|
||||
if (baiduMap.value) {
|
||||
baiduMap.value = null
|
||||
}
|
||||
if (locationMarker.value) {
|
||||
locationMarker.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 取消采集信息
|
||||
const handleCollectionInfoCancel = () => {
|
||||
collectionInfoVisible.value = false
|
||||
currentHost.value = null
|
||||
}
|
||||
|
||||
// 初始化百度地图
|
||||
const initBaiduMap = async () => {
|
||||
if (!currentLocation.value) return
|
||||
|
||||
try {
|
||||
// 确保百度地图API已加载
|
||||
await loadBMapScript()
|
||||
|
||||
// 获取地图容器
|
||||
const mapContainer = document.getElementById('locationMap')
|
||||
if (!mapContainer) {
|
||||
message.error('地图容器不存在')
|
||||
return
|
||||
}
|
||||
|
||||
// 创建地图实例
|
||||
const map = await createMap(mapContainer, {
|
||||
center: new window.BMap.Point(currentLocation.value.longitude, currentLocation.value.latitude),
|
||||
zoom: 15
|
||||
})
|
||||
|
||||
baiduMap.value = map
|
||||
|
||||
// 添加设备位置标记 - 使用红色大头针样式
|
||||
const point = new window.BMap.Point(currentLocation.value.longitude, currentLocation.value.latitude)
|
||||
|
||||
// 创建自定义标记图标
|
||||
const icon = new window.BMap.Icon(
|
||||
'data:image/svg+xml;base64,' + btoa(`
|
||||
<svg width="32" height="48" viewBox="0 0 32 48" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 0C7.163 0 0 7.163 0 16c0 16 16 32 16 32s16-16 16-32C32 7.163 24.837 0 16 0z" fill="#ff0000"/>
|
||||
<circle cx="16" cy="16" r="8" fill="#ffffff"/>
|
||||
</svg>
|
||||
`),
|
||||
new window.BMap.Size(32, 48),
|
||||
{
|
||||
anchor: new window.BMap.Size(16, 48)
|
||||
}
|
||||
)
|
||||
|
||||
const marker = new window.BMap.Marker(point, { icon: icon })
|
||||
map.addOverlay(marker)
|
||||
locationMarker.value = marker
|
||||
|
||||
// 创建时间戳标签,固定在红色大头针下方中间
|
||||
const label = new window.BMap.Label(currentLocation.value.updateTime, {
|
||||
position: point,
|
||||
offset: new window.BMap.Size(-50, 30) // 水平居中偏移-50像素,向下偏移30像素
|
||||
})
|
||||
|
||||
// 设置标签样式
|
||||
label.setStyle({
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'normal',
|
||||
whiteSpace: 'nowrap',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.3)',
|
||||
zIndex: 999,
|
||||
textAlign: 'center',
|
||||
width: 'auto',
|
||||
minWidth: '120px'
|
||||
})
|
||||
|
||||
map.addOverlay(label)
|
||||
|
||||
// 创建信息窗口
|
||||
const infoWindow = new window.BMap.InfoWindow(`
|
||||
<div style="padding: 10px; font-size: 14px;">
|
||||
<div style="font-weight: bold; margin-bottom: 5px;">主机位置</div>
|
||||
<div>设备编号: ${currentLocation.value.deviceNumber}</div>
|
||||
<div>经度: ${currentLocation.value.longitude}</div>
|
||||
<div>纬度: ${currentLocation.value.latitude}</div>
|
||||
<div style="margin-top: 5px; color: #666;">
|
||||
最后定位时间: ${currentLocation.value.updateTime}
|
||||
</div>
|
||||
</div>
|
||||
`, {
|
||||
width: 200,
|
||||
height: 120
|
||||
})
|
||||
|
||||
// 点击标记显示信息窗口
|
||||
marker.addEventListener('click', () => {
|
||||
map.openInfoWindow(infoWindow, point)
|
||||
})
|
||||
|
||||
// 启用地图控件
|
||||
map.addControl(new window.BMap.NavigationControl())
|
||||
map.addControl(new window.BMap.ScaleControl())
|
||||
map.addControl(new window.BMap.OverviewMapControl())
|
||||
map.addControl(new window.BMap.MapTypeControl())
|
||||
|
||||
// 启用滚轮缩放
|
||||
map.enableScrollWheelZoom(true)
|
||||
|
||||
// 设置地图样式
|
||||
switchMapStyle(mapStyle.value)
|
||||
|
||||
console.log('定位地图初始化成功')
|
||||
|
||||
} catch (error) {
|
||||
console.error('初始化百度地图失败:', error)
|
||||
message.error('地图初始化失败: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换地图样式
|
||||
const switchMapStyle = (style) => {
|
||||
mapStyle.value = style
|
||||
|
||||
if (!baiduMap.value) return
|
||||
|
||||
try {
|
||||
if (style === 'normal') {
|
||||
// 普通地图
|
||||
baiduMap.value.setMapType(window.BMAP_NORMAL_MAP)
|
||||
} else if (style === 'hybrid') {
|
||||
// 混合地图(卫星图+路网)
|
||||
baiduMap.value.setMapType(window.BMAP_HYBRID_MAP)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换地图样式失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 表格变化处理
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
const exportData = async () => {
|
||||
try {
|
||||
if (!hosts.value || hosts.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportDeviceData(hosts.value, 'host')
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据并启动自动刷新
|
||||
onMounted(() => {
|
||||
fetchData(true)
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.smart-host-container {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 页面标题样式 */
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #52c41a;
|
||||
margin: 0;
|
||||
padding: 12px 20px;
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* 搜索栏样式 */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.search-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 200px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.search-button {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.export-button {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 表格容器样式 */
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.host-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 表格单元格样式 */
|
||||
.device-number {
|
||||
color: #1890ff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.battery-cell,
|
||||
.signal-cell,
|
||||
.temperature-cell,
|
||||
.update-time-cell,
|
||||
.network-status-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.battery-value,
|
||||
.signal-value,
|
||||
.temperature-value,
|
||||
.update-time-value {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.network-status-tag {
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.action-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-link {
|
||||
color: #1890ff;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
line-height: 1.2;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.action-link:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
/* 定位信息模态框样式 */
|
||||
.location-modal {
|
||||
height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.baidu-map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.map-style-controls {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
display: flex;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.style-btn {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
border: 1px solid #d9d9d9;
|
||||
background: white;
|
||||
transition: all 0.3s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.style-btn:first-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.style-btn:hover {
|
||||
color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.style-btn.active {
|
||||
color: #fff;
|
||||
background-color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.location-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background-color: #f5f5f5;
|
||||
border-color: #d9d9d9;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.cancel-btn:hover {
|
||||
background-color: #e6e6e6;
|
||||
border-color: #bfbfbf;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 采集信息模态框样式 */
|
||||
.collection-info-modal {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
min-width: 100px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
.host-table :deep(.ant-table) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.host-table :deep(.ant-table-thead > tr > th) {
|
||||
padding: 8px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.host-table :deep(.ant-table-tbody > tr > td) {
|
||||
padding: 6px 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.host-table :deep(.ant-table-tbody > tr:hover > td) {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
/* 确保表格不会超出容器 */
|
||||
.host-table :deep(.ant-table-wrapper) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.host-table :deep(.ant-table-container) {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
</style>
|
||||
924
admin-system/src/views/System.vue
Normal file
924
admin-system/src/views/System.vue
Normal file
@@ -0,0 +1,924 @@
|
||||
<template>
|
||||
<div class="system-page">
|
||||
<a-page-header
|
||||
title="系统管理"
|
||||
sub-title="系统配置和权限管理"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button @click="fetchData">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新数据
|
||||
</a-button>
|
||||
<a-button type="primary" @click="initSystem" :loading="initLoading">
|
||||
<template #icon><SettingOutlined /></template>
|
||||
初始化系统
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<div class="system-content">
|
||||
<!-- 系统统计卡片 -->
|
||||
<a-row :gutter="16" style="margin-bottom: 24px;">
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="系统配置"
|
||||
:value="systemStats.configs?.total || 0"
|
||||
:value-style="{ color: '#1890ff' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<SettingOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="菜单权限"
|
||||
:value="systemStats.menus?.total || 0"
|
||||
:value-style="{ color: '#52c41a' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<MenuOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="系统用户"
|
||||
:value="systemStats.users?.total || 0"
|
||||
:value-style="{ color: '#fa8c16' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card>
|
||||
<a-statistic
|
||||
title="用户角色"
|
||||
:value="systemStats.roles?.total || 0"
|
||||
:value-style="{ color: '#722ed1' }"
|
||||
>
|
||||
<template #prefix>
|
||||
<TeamOutlined />
|
||||
</template>
|
||||
</a-statistic>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 功能标签页 -->
|
||||
<a-tabs v-model:activeKey="activeTab" type="card">
|
||||
<!-- 系统配置管理 -->
|
||||
<a-tab-pane key="configs" tab="系统配置">
|
||||
<div class="config-section">
|
||||
<div class="section-header">
|
||||
<a-space>
|
||||
<a-select
|
||||
v-model:value="selectedCategory"
|
||||
placeholder="选择配置分类"
|
||||
style="width: 200px;"
|
||||
@change="filterConfigs"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option value="">全部分类</a-select-option>
|
||||
<a-select-option
|
||||
v-for="category in categories"
|
||||
:key="category"
|
||||
:value="category"
|
||||
>
|
||||
{{ getCategoryName(category) }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" @click="showAddConfigModal = true">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加配置
|
||||
</a-button>
|
||||
<a-button @click="batchSaveConfigs" :loading="batchSaveLoading">
|
||||
<template #icon><SaveOutlined /></template>
|
||||
批量保存
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="configColumns"
|
||||
:data-source="filteredConfigs"
|
||||
:loading="configLoading"
|
||||
:pagination="{ pageSize: 15 }"
|
||||
row-key="id"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'config_value'">
|
||||
<div v-if="record.config_type === 'boolean'">
|
||||
<a-switch
|
||||
:checked="record.parsed_value"
|
||||
@change="updateConfigValue(record, $event)"
|
||||
:disabled="!record.is_editable"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="record.config_type === 'number'">
|
||||
<a-input-number
|
||||
:value="record.parsed_value"
|
||||
@change="updateConfigValue(record, $event)"
|
||||
:disabled="!record.is_editable"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<a-input
|
||||
:value="record.config_value"
|
||||
@change="updateConfigValue(record, $event.target.value)"
|
||||
:disabled="!record.is_editable"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'category'">
|
||||
<a-tag :color="getCategoryColor(record.category)">
|
||||
{{ getCategoryName(record.category) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'is_public'">
|
||||
<a-tag :color="record.is_public ? 'green' : 'orange'">
|
||||
{{ record.is_public ? '公开' : '私有' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'is_editable'">
|
||||
<a-tag :color="record.is_editable ? 'blue' : 'red'">
|
||||
{{ record.is_editable ? '可编辑' : '只读' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-space size="small">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="editConfig(record)"
|
||||
:disabled="!record.is_editable"
|
||||
>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button
|
||||
size="small"
|
||||
@click="resetConfig(record)"
|
||||
:disabled="!record.is_editable"
|
||||
>
|
||||
重置
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
danger
|
||||
size="small"
|
||||
@click="deleteConfig(record)"
|
||||
:disabled="!record.is_editable"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 菜单权限管理 -->
|
||||
<a-tab-pane key="menus" tab="菜单权限">
|
||||
<div class="menu-section">
|
||||
<div class="section-header">
|
||||
<a-space>
|
||||
<a-button @click="fetchMenus">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新菜单
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showAddMenuModal = true">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加菜单
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="menuColumns"
|
||||
:data-source="menus"
|
||||
:loading="menuLoading"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
size="small"
|
||||
:default-expand-all-rows="true"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'menu_name'">
|
||||
<a-space>
|
||||
<component
|
||||
:is="getIconComponent(record.icon)"
|
||||
v-if="record.icon"
|
||||
style="color: #1890ff;"
|
||||
/>
|
||||
{{ record.menu_name }}
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'required_roles'">
|
||||
<a-space wrap>
|
||||
<a-tag
|
||||
v-for="role in parseRoles(record.required_roles)"
|
||||
:key="role"
|
||||
:color="getRoleColor(role)"
|
||||
>
|
||||
{{ getRoleName(role) }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'is_visible'">
|
||||
<a-switch
|
||||
:checked="record.is_visible"
|
||||
@change="updateMenuVisible(record, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'is_enabled'">
|
||||
<a-switch
|
||||
:checked="record.is_enabled"
|
||||
@change="updateMenuEnabled(record, $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-space size="small">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="editMenu(record)"
|
||||
>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
danger
|
||||
size="small"
|
||||
@click="deleteMenu(record)"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</div>
|
||||
|
||||
<!-- 添加配置模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showAddConfigModal"
|
||||
title="添加系统配置"
|
||||
:confirm-loading="configSubmitLoading"
|
||||
@ok="saveConfig"
|
||||
@cancel="resetConfigForm"
|
||||
>
|
||||
<a-form
|
||||
ref="configFormRef"
|
||||
:model="configForm"
|
||||
:rules="configRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="配置键名" name="config_key">
|
||||
<a-input v-model:value="configForm.config_key" placeholder="例如: system.name" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="配置值" name="config_value">
|
||||
<a-textarea v-model:value="configForm.config_value" :rows="3" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="配置分类" name="category">
|
||||
<a-select v-model:value="configForm.category">
|
||||
<a-select-option value="general">通用配置</a-select-option>
|
||||
<a-select-option value="ui">界面配置</a-select-option>
|
||||
<a-select-option value="security">安全配置</a-select-option>
|
||||
<a-select-option value="notification">通知配置</a-select-option>
|
||||
<a-select-option value="monitoring">监控配置</a-select-option>
|
||||
<a-select-option value="report">报表配置</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="配置描述" name="description">
|
||||
<a-input v-model:value="configForm.description" />
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item name="is_public">
|
||||
<a-checkbox v-model:checked="configForm.is_public">
|
||||
公开配置(前端可访问)
|
||||
</a-checkbox>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item name="is_editable">
|
||||
<a-checkbox v-model:checked="configForm.is_editable">
|
||||
允许编辑
|
||||
</a-checkbox>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="排序顺序" name="sort_order">
|
||||
<a-input-number
|
||||
v-model:value="configForm.sort_order"
|
||||
:min="0"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 编辑配置模态框 -->
|
||||
<a-modal
|
||||
v-model:open="showEditConfigModal"
|
||||
title="编辑系统配置"
|
||||
:confirm-loading="configSubmitLoading"
|
||||
@ok="saveConfig"
|
||||
@cancel="resetConfigForm"
|
||||
>
|
||||
<a-form
|
||||
ref="configFormRef"
|
||||
:model="configForm"
|
||||
:rules="configRules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="配置键名">
|
||||
<a-input :value="configForm.config_key" disabled />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="配置值" name="config_value">
|
||||
<a-textarea v-model:value="configForm.config_value" :rows="3" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="配置描述" name="description">
|
||||
<a-input v-model:value="configForm.description" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
ReloadOutlined,
|
||||
SettingOutlined,
|
||||
PlusOutlined,
|
||||
SaveOutlined,
|
||||
MenuOutlined,
|
||||
UserOutlined,
|
||||
TeamOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { api } from '../utils/api'
|
||||
import * as Icons from '@ant-design/icons-vue'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const configLoading = ref(false)
|
||||
const menuLoading = ref(false)
|
||||
const initLoading = ref(false)
|
||||
const configSubmitLoading = ref(false)
|
||||
const batchSaveLoading = ref(false)
|
||||
|
||||
const activeTab = ref('configs')
|
||||
const systemStats = ref({})
|
||||
const configs = ref([])
|
||||
const menus = ref([])
|
||||
const categories = ref([])
|
||||
const selectedCategory = ref('')
|
||||
|
||||
// 模态框状态
|
||||
const showAddConfigModal = ref(false)
|
||||
const showEditConfigModal = ref(false)
|
||||
const showAddMenuModal = ref(false)
|
||||
|
||||
// 表单引用
|
||||
const configFormRef = ref()
|
||||
|
||||
// 配置表单数据
|
||||
const configForm = reactive({
|
||||
id: null,
|
||||
config_key: '',
|
||||
config_value: '',
|
||||
category: 'general',
|
||||
description: '',
|
||||
is_public: false,
|
||||
is_editable: true,
|
||||
sort_order: 0
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const configRules = {
|
||||
config_key: [{ required: true, message: '请输入配置键名' }],
|
||||
config_value: [{ required: true, message: '请输入配置值' }],
|
||||
category: [{ required: true, message: '请选择配置分类' }]
|
||||
}
|
||||
|
||||
// 过滤后的配置列表
|
||||
const filteredConfigs = computed(() => {
|
||||
if (!selectedCategory.value) {
|
||||
return configs.value
|
||||
}
|
||||
return configs.value.filter(config => config.category === selectedCategory.value)
|
||||
})
|
||||
|
||||
// 配置表格列定义
|
||||
const configColumns = [
|
||||
{
|
||||
title: '配置键名',
|
||||
dataIndex: 'config_key',
|
||||
key: 'config_key',
|
||||
width: 200,
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '配置值',
|
||||
dataIndex: 'config_value',
|
||||
key: 'config_value',
|
||||
width: 250
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '公开',
|
||||
dataIndex: 'is_public',
|
||||
key: 'is_public',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '可编辑',
|
||||
dataIndex: 'is_editable',
|
||||
key: 'is_editable',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 180,
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 菜单表格列定义
|
||||
const menuColumns = [
|
||||
{
|
||||
title: '菜单名称',
|
||||
dataIndex: 'menu_name',
|
||||
key: 'menu_name',
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
title: '菜单路径',
|
||||
dataIndex: 'menu_path',
|
||||
key: 'menu_path',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '所需角色',
|
||||
dataIndex: 'required_roles',
|
||||
key: 'required_roles',
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort_order',
|
||||
key: 'sort_order',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '可见',
|
||||
dataIndex: 'is_visible',
|
||||
key: 'is_visible',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'is_enabled',
|
||||
key: 'is_enabled',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
fixed: 'right'
|
||||
}
|
||||
]
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(async () => {
|
||||
await fetchData()
|
||||
})
|
||||
|
||||
// 获取所有数据
|
||||
async function fetchData() {
|
||||
await Promise.all([
|
||||
fetchSystemStats(),
|
||||
fetchConfigs(),
|
||||
fetchMenus(),
|
||||
fetchCategories()
|
||||
])
|
||||
}
|
||||
|
||||
// 获取系统统计信息
|
||||
async function fetchSystemStats() {
|
||||
try {
|
||||
const response = await api.get('/system/stats')
|
||||
if (response.success) {
|
||||
systemStats.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取系统统计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取系统配置
|
||||
async function fetchConfigs() {
|
||||
configLoading.value = true
|
||||
try {
|
||||
const response = await api.get('/system/configs')
|
||||
if (response.success) {
|
||||
configs.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取系统配置失败:', error)
|
||||
message.error('获取系统配置失败')
|
||||
} finally {
|
||||
configLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取菜单权限
|
||||
async function fetchMenus() {
|
||||
menuLoading.value = true
|
||||
try {
|
||||
const response = await api.get('/system/menus')
|
||||
if (response.success) {
|
||||
menus.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取菜单权限失败:', error)
|
||||
message.error('获取菜单权限失败')
|
||||
} finally {
|
||||
menuLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取配置分类
|
||||
async function fetchCategories() {
|
||||
try {
|
||||
const response = await api.get('/system/configs/categories')
|
||||
if (response.success) {
|
||||
categories.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取配置分类失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化系统
|
||||
async function initSystem() {
|
||||
initLoading.value = true
|
||||
try {
|
||||
const response = await api.post('/system/init')
|
||||
if (response.success) {
|
||||
message.success('系统初始化成功')
|
||||
await fetchData()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('系统初始化失败:', error)
|
||||
message.error('系统初始化失败')
|
||||
} finally {
|
||||
initLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤配置
|
||||
function filterConfigs() {
|
||||
// 响应式计算属性会自动处理过滤
|
||||
}
|
||||
|
||||
// 更新配置值
|
||||
function updateConfigValue(config, value) {
|
||||
const index = configs.value.findIndex(c => c.id === config.id)
|
||||
if (index !== -1) {
|
||||
configs.value[index].config_value = String(value)
|
||||
configs.value[index].parsed_value = value
|
||||
configs.value[index]._changed = true // 标记为已修改
|
||||
}
|
||||
}
|
||||
|
||||
// 批量保存配置
|
||||
async function batchSaveConfigs() {
|
||||
const changedConfigs = configs.value.filter(config => config._changed)
|
||||
|
||||
if (changedConfigs.length === 0) {
|
||||
message.info('没有配置需要保存')
|
||||
return
|
||||
}
|
||||
|
||||
batchSaveLoading.value = true
|
||||
try {
|
||||
const configsToUpdate = changedConfigs.map(config => ({
|
||||
config_key: config.config_key,
|
||||
config_value: config.parsed_value
|
||||
}))
|
||||
|
||||
const response = await api.put('/system/configs/batch', {
|
||||
configs: configsToUpdate
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
message.success(`成功保存 ${changedConfigs.length} 个配置`)
|
||||
await fetchConfigs() // 重新获取数据
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量保存配置失败:', error)
|
||||
message.error('批量保存配置失败')
|
||||
} finally {
|
||||
batchSaveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑配置
|
||||
function editConfig(record) {
|
||||
Object.assign(configForm, {
|
||||
id: record.id,
|
||||
config_key: record.config_key,
|
||||
config_value: record.config_value,
|
||||
category: record.category,
|
||||
description: record.description,
|
||||
is_public: record.is_public,
|
||||
is_editable: record.is_editable,
|
||||
sort_order: record.sort_order
|
||||
})
|
||||
showEditConfigModal.value = true
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
async function saveConfig() {
|
||||
try {
|
||||
await configFormRef.value.validate()
|
||||
configSubmitLoading.value = true
|
||||
|
||||
if (configForm.id) {
|
||||
// 更新配置
|
||||
const response = await api.put(`/system/configs/${configForm.id}`, {
|
||||
config_value: configForm.config_value,
|
||||
description: configForm.description
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
message.success('配置更新成功')
|
||||
showEditConfigModal.value = false
|
||||
await fetchConfigs()
|
||||
}
|
||||
} else {
|
||||
// 创建配置
|
||||
const response = await api.post('/system/configs', configForm)
|
||||
|
||||
if (response.success) {
|
||||
message.success('配置创建成功')
|
||||
showAddConfigModal.value = false
|
||||
await fetchConfigs()
|
||||
}
|
||||
}
|
||||
|
||||
resetConfigForm()
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
message.error('保存配置失败')
|
||||
} finally {
|
||||
configSubmitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置配置表单
|
||||
function resetConfigForm() {
|
||||
Object.assign(configForm, {
|
||||
id: null,
|
||||
config_key: '',
|
||||
config_value: '',
|
||||
category: 'general',
|
||||
description: '',
|
||||
is_public: false,
|
||||
is_editable: true,
|
||||
sort_order: 0
|
||||
})
|
||||
configFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 删除配置
|
||||
async function deleteConfig(record) {
|
||||
try {
|
||||
const response = await api.delete(`/system/configs/${record.id}`)
|
||||
if (response.success) {
|
||||
message.success('配置删除成功')
|
||||
await fetchConfigs()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除配置失败:', error)
|
||||
message.error('删除配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 重置配置
|
||||
async function resetConfig(record) {
|
||||
try {
|
||||
const response = await api.post(`/system/configs/${record.id}/reset`)
|
||||
if (response.success) {
|
||||
message.success('配置重置成功')
|
||||
await fetchConfigs()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重置配置失败:', error)
|
||||
message.error('重置配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
function getCategoryName(category) {
|
||||
const categoryMap = {
|
||||
general: '通用配置',
|
||||
ui: '界面配置',
|
||||
security: '安全配置',
|
||||
notification: '通知配置',
|
||||
monitoring: '监控配置',
|
||||
report: '报表配置'
|
||||
}
|
||||
return categoryMap[category] || category
|
||||
}
|
||||
|
||||
function getCategoryColor(category) {
|
||||
const colorMap = {
|
||||
general: 'blue',
|
||||
ui: 'green',
|
||||
security: 'red',
|
||||
notification: 'orange',
|
||||
monitoring: 'purple',
|
||||
report: 'cyan'
|
||||
}
|
||||
return colorMap[category] || 'default'
|
||||
}
|
||||
|
||||
function getIconComponent(iconName) {
|
||||
if (!iconName) return null
|
||||
const iconKey = iconName.split('-').map(part =>
|
||||
part.charAt(0).toUpperCase() + part.slice(1)
|
||||
).join('')
|
||||
return Icons[iconKey] || Icons.SettingOutlined
|
||||
}
|
||||
|
||||
function parseRoles(rolesStr) {
|
||||
try {
|
||||
return rolesStr ? JSON.parse(rolesStr) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleColor(role) {
|
||||
const colorMap = {
|
||||
admin: 'red',
|
||||
manager: 'orange',
|
||||
user: 'blue'
|
||||
}
|
||||
return colorMap[role] || 'default'
|
||||
}
|
||||
|
||||
function getRoleName(role) {
|
||||
const nameMap = {
|
||||
admin: '管理员',
|
||||
manager: '管理者',
|
||||
user: '普通用户'
|
||||
}
|
||||
return nameMap[role] || role
|
||||
}
|
||||
|
||||
// 菜单相关方法(占位符)
|
||||
function editMenu(record) {
|
||||
message.info('菜单编辑功能开发中')
|
||||
}
|
||||
|
||||
function deleteMenu(record) {
|
||||
message.info('菜单删除功能开发中')
|
||||
}
|
||||
|
||||
function updateMenuVisible(record, visible) {
|
||||
message.info('菜单可见性更新功能开发中')
|
||||
}
|
||||
|
||||
function updateMenuEnabled(record, enabled) {
|
||||
message.info('菜单启用状态更新功能开发中')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.system-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.system-content {
|
||||
padding: 24px;
|
||||
background: #f0f2f5;
|
||||
min-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-page-header {
|
||||
background: #fff;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
:deep(.ant-table-thead > tr > th) {
|
||||
background: #fafafa;
|
||||
color: #262626;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-tab) {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-card .ant-tabs-content) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.config-section,
|
||||
.menu-section {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.system-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
:deep(.ant-col) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-statistic-title) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-statistic-content-value) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
211
admin-system/src/views/TableStats.vue
Normal file
211
admin-system/src/views/TableStats.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="table-stats-container">
|
||||
<div class="header">
|
||||
<h2>统计数据表格</h2>
|
||||
<p>基于MySQL数据库的真实统计数据</p>
|
||||
</div>
|
||||
|
||||
<div class="canvas-container">
|
||||
<canvas
|
||||
ref="tableCanvas"
|
||||
:width="canvasWidth"
|
||||
:height="canvasHeight"
|
||||
class="stats-canvas"
|
||||
></canvas>
|
||||
</div>
|
||||
|
||||
<div class="loading" v-if="loading">
|
||||
<a-spin size="large" />
|
||||
<p>正在加载数据...</p>
|
||||
</div>
|
||||
|
||||
<div class="error" v-if="error">
|
||||
<a-alert
|
||||
:message="error"
|
||||
type="error"
|
||||
show-icon
|
||||
@close="error = null"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { api } from '@/utils/api'
|
||||
|
||||
// 响应式数据
|
||||
const tableCanvas = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const tableData = ref([])
|
||||
const canvasWidth = ref(600)
|
||||
const canvasHeight = ref(300)
|
||||
|
||||
// 获取统计数据
|
||||
const fetchTableStats = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const response = await api.get('/stats/public/table')
|
||||
|
||||
if (response.data.success) {
|
||||
tableData.value = response.data.data
|
||||
await nextTick()
|
||||
drawTable()
|
||||
} else {
|
||||
throw new Error(response.data.message || '获取数据失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取统计数据失败:', err)
|
||||
error.value = err.message || '获取统计数据失败'
|
||||
message.error('获取统计数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制表格
|
||||
const drawTable = () => {
|
||||
if (!tableCanvas.value || !tableData.value.length) return
|
||||
|
||||
const canvas = tableCanvas.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 设置字体和样式
|
||||
ctx.font = '16px Arial, sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// 表格参数
|
||||
const startX = 50
|
||||
const startY = 50
|
||||
const rowHeight = 50
|
||||
const col1Width = 200
|
||||
const col2Width = 150
|
||||
const tableWidth = col1Width + col2Width
|
||||
const tableHeight = (tableData.value.length + 1) * rowHeight
|
||||
|
||||
// 绘制表格边框
|
||||
ctx.strokeStyle = '#d9d9d9'
|
||||
ctx.lineWidth = 1
|
||||
|
||||
// 绘制外边框
|
||||
ctx.strokeRect(startX, startY, tableWidth, tableHeight)
|
||||
|
||||
// 绘制列分隔线
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(startX + col1Width, startY)
|
||||
ctx.lineTo(startX + col1Width, startY + tableHeight)
|
||||
ctx.stroke()
|
||||
|
||||
// 绘制表头
|
||||
ctx.fillStyle = '#f5f5f5'
|
||||
ctx.fillRect(startX, startY, tableWidth, rowHeight)
|
||||
|
||||
// 绘制表头边框
|
||||
ctx.strokeRect(startX, startY, tableWidth, rowHeight)
|
||||
|
||||
// 绘制表头文字
|
||||
ctx.fillStyle = '#262626'
|
||||
ctx.font = 'bold 16px Arial, sans-serif'
|
||||
ctx.fillText('数据描述', startX + 10, startY + rowHeight / 2)
|
||||
ctx.fillText('统计数值', startX + col1Width + 10, startY + rowHeight / 2)
|
||||
|
||||
// 绘制数据行
|
||||
ctx.font = '16px Arial, sans-serif'
|
||||
tableData.value.forEach((item, index) => {
|
||||
const y = startY + (index + 1) * rowHeight
|
||||
|
||||
// 绘制行分隔线
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(startX, y)
|
||||
ctx.lineTo(startX + tableWidth, y)
|
||||
ctx.stroke()
|
||||
|
||||
// 绘制数据
|
||||
ctx.fillStyle = '#262626'
|
||||
ctx.fillText(item.description, startX + 10, y + rowHeight / 2)
|
||||
|
||||
// 数值用不同颜色显示
|
||||
ctx.fillStyle = '#1890ff'
|
||||
ctx.font = 'bold 16px Arial, sans-serif'
|
||||
ctx.fillText(item.value.toLocaleString(), startX + col1Width + 10, y + rowHeight / 2)
|
||||
ctx.font = '16px Arial, sans-serif'
|
||||
})
|
||||
|
||||
// 绘制标题
|
||||
ctx.fillStyle = '#262626'
|
||||
ctx.font = 'bold 20px Arial, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('农场统计数据表', startX + tableWidth / 2, 25)
|
||||
|
||||
// 绘制数据来源说明
|
||||
ctx.font = '12px Arial, sans-serif'
|
||||
ctx.fillStyle = '#8c8c8c'
|
||||
ctx.fillText('数据来源:MySQL数据库实时查询', startX + tableWidth / 2, startY + tableHeight + 30)
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchTableStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-stats-container {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #262626;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.stats-canvas {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.loading p {
|
||||
margin-top: 16px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
173
admin-system/src/views/TestAnalytics.vue
Normal file
173
admin-system/src/views/TestAnalytics.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="test-analytics">
|
||||
<h2>数据加载测试页面</h2>
|
||||
|
||||
<div class="debug-info">
|
||||
<h3>数据状态</h3>
|
||||
<p>养殖场数量: {{ dataStore.farms.length }}</p>
|
||||
<p>动物数量: {{ dataStore.animals.length }}</p>
|
||||
<p>设备数量: {{ dataStore.devices.length }}</p>
|
||||
<p>预警数量: {{ dataStore.alerts.length }}</p>
|
||||
</div>
|
||||
|
||||
<div class="raw-data">
|
||||
<h3>原始数据</h3>
|
||||
<div class="data-section">
|
||||
<h4>养殖场数据</h4>
|
||||
<pre>{{ JSON.stringify(dataStore.farms, null, 2) }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="data-section">
|
||||
<h4>动物数据</h4>
|
||||
<pre>{{ JSON.stringify(dataStore.animals, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-data">
|
||||
<h3>计算后的表格数据</h3>
|
||||
<pre>{{ JSON.stringify(farmTableData, null, 2) }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="actual-table">
|
||||
<h3>实际表格</h3>
|
||||
<a-table
|
||||
:columns="farmColumns"
|
||||
:data-source="farmTableData"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a-button @click="refreshData" type="primary">刷新数据</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useDataStore } from '../stores/data'
|
||||
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 养殖场表格数据
|
||||
const farmTableData = computed(() => {
|
||||
console.log('计算farmTableData:', {
|
||||
farms: dataStore.farms.length,
|
||||
animals: dataStore.animals.length,
|
||||
devices: dataStore.devices.length,
|
||||
alerts: dataStore.alerts.length
|
||||
})
|
||||
|
||||
return dataStore.farms.map(farm => {
|
||||
// 获取该养殖场的动物数量
|
||||
const animals = dataStore.animals.filter(animal => animal.farm_id === farm.id)
|
||||
const animalCount = animals.reduce((sum, animal) => sum + (animal.count || 0), 0)
|
||||
|
||||
// 获取该养殖场的设备数量
|
||||
const devices = dataStore.devices.filter(device => device.farm_id === farm.id)
|
||||
const deviceCount = devices.length
|
||||
|
||||
// 获取该养殖场的预警数量
|
||||
const alerts = dataStore.alerts.filter(alert => alert.farm_id === farm.id)
|
||||
const alertCount = alerts.length
|
||||
|
||||
console.log(`养殖场 ${farm.name} (ID: ${farm.id}):`, {
|
||||
animalCount,
|
||||
deviceCount,
|
||||
alertCount,
|
||||
animals: animals.map(a => ({ id: a.id, farm_id: a.farm_id, count: a.count })),
|
||||
devices: devices.map(d => ({ id: d.id, farm_id: d.farm_id })),
|
||||
alerts: alerts.map(a => ({ id: a.id, farm_id: a.farm_id }))
|
||||
})
|
||||
|
||||
return {
|
||||
key: farm.id,
|
||||
id: farm.id,
|
||||
name: farm.name,
|
||||
animalCount,
|
||||
deviceCount,
|
||||
alertCount
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 养殖场表格列定义
|
||||
const farmColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '养殖场名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: '动物数量',
|
||||
dataIndex: 'animalCount',
|
||||
key: 'animalCount',
|
||||
sorter: (a, b) => a.animalCount - b.animalCount
|
||||
},
|
||||
{
|
||||
title: '设备数量',
|
||||
dataIndex: 'deviceCount',
|
||||
key: 'deviceCount',
|
||||
sorter: (a, b) => a.deviceCount - b.deviceCount
|
||||
},
|
||||
{
|
||||
title: '预警数量',
|
||||
dataIndex: 'alertCount',
|
||||
key: 'alertCount',
|
||||
sorter: (a, b) => a.alertCount - b.alertCount
|
||||
}
|
||||
]
|
||||
|
||||
const refreshData = async () => {
|
||||
console.log('手动刷新数据...')
|
||||
await dataStore.fetchAllData()
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(async () => {
|
||||
console.log('TestAnalytics页面开始加载数据...')
|
||||
await dataStore.fetchAllData()
|
||||
console.log('TestAnalytics页面数据加载完成')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.test-analytics {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.debug-info {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.data-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.data-section h4 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f8f8f8;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
13
admin-system/src/views/TestImport.vue
Normal file
13
admin-system/src/views/TestImport.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>测试导入</h1>
|
||||
<p>API 对象: {{ api ? '已加载' : '未加载' }}</p>
|
||||
<p>电子围栏方法: {{ api?.electronicFence ? '已加载' : '未加载' }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { api } from '@/utils/api'
|
||||
|
||||
console.log('API 对象:', api)
|
||||
</script>
|
||||
755
admin-system/src/views/Users.vue
Normal file
755
admin-system/src/views/Users.vue
Normal file
@@ -0,0 +1,755 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>用户管理</h1>
|
||||
<a-space class="page-actions">
|
||||
<a-button @click="exportUsers" :loading="exportLoading">
|
||||
<template #icon><ExportOutlined /></template>
|
||||
导出数据
|
||||
</a-button>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加用户
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 搜索区域 -->
|
||||
<div class="search-area" style="margin-bottom: 16px; display: flex; gap: 8px; align-items: center;">
|
||||
<a-input
|
||||
v-model="searchUsername"
|
||||
placeholder="请输入用户名进行搜索"
|
||||
class="search-input"
|
||||
style="width: 300px;"
|
||||
@input="handleSearchInput"
|
||||
@focus="handleSearchFocus"
|
||||
@blur="handleSearchBlur"
|
||||
@change="handleSearchChange"
|
||||
@press-enter="searchUsers"
|
||||
/>
|
||||
<div class="search-buttons">
|
||||
<a-button type="primary" @click="searchUsers" :loading="searchLoading">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button @click="resetSearch" :disabled="!isSearching">
|
||||
重置
|
||||
</a-button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="users"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'roles'">
|
||||
<a-tag :color="getRoleColor(record.roleName || record.role?.name)">
|
||||
{{ getRoleDisplayName(record.roleName || record.role?.name) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'created_at'">
|
||||
{{ formatDate(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="editUser(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个用户吗?"
|
||||
@confirm="deleteUser(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑用户模态框 -->
|
||||
<a-modal
|
||||
:open="modalVisible"
|
||||
@update:open="(val) => modalVisible = val"
|
||||
:title="isEdit ? '编辑用户' : '添加用户'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="用户名" name="username">
|
||||
<a-input v-model="formData.username" placeholder="请输入用户名" :disabled="isEdit" />
|
||||
</a-form-item>
|
||||
<a-form-item label="邮箱" name="email">
|
||||
<a-input v-model="formData.email" placeholder="请输入邮箱" />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码" name="password" v-if="!isEdit">
|
||||
<a-input-password v-model="formData.password" placeholder="请输入密码" />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色" name="roles">
|
||||
<a-select v-model="formData.roles" placeholder="请选择角色" :loading="rolesLoading">
|
||||
<a-select-option
|
||||
v-for="role in availableRoles"
|
||||
:key="role.id"
|
||||
:value="role.id"
|
||||
>
|
||||
{{ role.description || role.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons-vue'
|
||||
import { api, directApi } from '../utils/api'
|
||||
import { ExportUtils } from '../utils/exportUtils'
|
||||
|
||||
// 响应式数据
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 搜索相关的响应式数据
|
||||
const searchUsername = ref('')
|
||||
const searchLoading = ref(false)
|
||||
const isSearching = ref(false)
|
||||
const usernameOptions = ref([])
|
||||
|
||||
// 搜索监听相关
|
||||
let searchTimeout = null
|
||||
let searchFocusTime = null
|
||||
|
||||
// 导出相关
|
||||
const exportLoading = ref(false)
|
||||
|
||||
// 角色相关
|
||||
const availableRoles = ref([])
|
||||
const rolesLoading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
roles: 2 // 默认为普通用户ID
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
|
||||
],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
roles: [{ required: true, message: '请选择角色', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username'
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email'
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'roles',
|
||||
key: 'roles',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 150
|
||||
}
|
||||
]
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取角色颜色
|
||||
const getRoleColor = (roleName) => {
|
||||
const colorMap = {
|
||||
'admin': 'red',
|
||||
'farm_manager': 'green',
|
||||
'inspector': 'orange',
|
||||
'user': 'blue'
|
||||
}
|
||||
return colorMap[roleName] || 'default'
|
||||
}
|
||||
|
||||
// 获取角色显示名称
|
||||
const getRoleDisplayName = (roleName) => {
|
||||
const role = availableRoles.value.find(r => r.name === roleName)
|
||||
if (role) {
|
||||
return role.description || role.name
|
||||
}
|
||||
|
||||
// 如果没有找到角色,使用默认映射
|
||||
const nameMap = {
|
||||
'admin': '系统管理员',
|
||||
'farm_manager': '养殖场管理员',
|
||||
'inspector': '监管人员',
|
||||
'user': '普通用户'
|
||||
}
|
||||
return nameMap[roleName] || roleName || '未知角色'
|
||||
}
|
||||
|
||||
// 获取角色列表
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
rolesLoading.value = true
|
||||
console.log('开始获取角色列表...')
|
||||
|
||||
const { api } = await import('../utils/api')
|
||||
const response = await api.get('/auth/roles')
|
||||
|
||||
console.log('角色API响应:', response)
|
||||
|
||||
// 处理API响应格式
|
||||
// api.get() 已经处理了响应格式,直接返回 result.data
|
||||
let roles = []
|
||||
if (Array.isArray(response)) {
|
||||
// 直接是角色数组
|
||||
roles = response
|
||||
} else if (response && Array.isArray(response.data)) {
|
||||
roles = response.data
|
||||
} else if (response && Array.isArray(response.roles)) {
|
||||
roles = response.roles
|
||||
} else {
|
||||
console.warn('API返回角色数据格式异常:', response)
|
||||
// 如果是对象,尝试提取角色数据
|
||||
if (response && typeof response === 'object') {
|
||||
// 可能的字段名
|
||||
const possibleFields = ['data', 'roles', 'items', 'list']
|
||||
for (const field of possibleFields) {
|
||||
if (response[field] && Array.isArray(response[field])) {
|
||||
roles = response[field]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('处理后的角色列表:', roles)
|
||||
|
||||
if (roles.length > 0) {
|
||||
availableRoles.value = roles
|
||||
console.log('✅ 角色列表加载成功,数量:', roles.length)
|
||||
} else {
|
||||
throw new Error('角色列表为空')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取角色列表失败:', error)
|
||||
message.error('获取角色列表失败,使用默认角色')
|
||||
|
||||
// 如果API失败,使用默认角色
|
||||
availableRoles.value = [
|
||||
{ id: 1, name: 'admin', description: '系统管理员' },
|
||||
{ id: 2, name: 'user', description: '普通用户' },
|
||||
{ id: 32, name: 'farm_manager', description: '养殖场管理员' },
|
||||
{ id: 33, name: 'inspector', description: '监管人员' }
|
||||
]
|
||||
console.log('使用默认角色列表')
|
||||
} finally {
|
||||
rolesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const { api } = await import('../utils/api')
|
||||
const response = await api.get('/users')
|
||||
console.log('用户API响应:', response)
|
||||
|
||||
// 检查响应格式
|
||||
if (response && response.success && Array.isArray(response.data)) {
|
||||
users.value = response.data
|
||||
} else if (Array.isArray(response)) {
|
||||
// 兼容旧格式
|
||||
users.value = response
|
||||
} else {
|
||||
users.value = []
|
||||
console.warn('API返回数据格式异常:', response)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error)
|
||||
message.error('获取用户列表失败')
|
||||
users.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导出用户数据
|
||||
const exportUsers = async () => {
|
||||
try {
|
||||
if (!users.value || users.value.length === 0) {
|
||||
message.warning('没有数据可导出')
|
||||
return
|
||||
}
|
||||
|
||||
exportLoading.value = true
|
||||
message.loading('正在导出数据...', 0)
|
||||
|
||||
const result = ExportUtils.exportUserData(users.value)
|
||||
|
||||
if (result.success) {
|
||||
message.destroy()
|
||||
message.success(`导出成功!文件:${result.filename}`)
|
||||
} else {
|
||||
message.destroy()
|
||||
message.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
message.destroy()
|
||||
console.error('导出失败:', error)
|
||||
message.error('导出失败,请重试')
|
||||
} finally {
|
||||
exportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = async () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
// 确保角色数据已加载
|
||||
if (availableRoles.value.length === 0) {
|
||||
await fetchRoles()
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑用户
|
||||
const editUser = async (record) => {
|
||||
isEdit.value = true
|
||||
Object.assign(formData, {
|
||||
...record,
|
||||
password: '' // 编辑时不显示密码
|
||||
})
|
||||
modalVisible.value = true
|
||||
// 确保角色数据已加载
|
||||
if (availableRoles.value.length === 0) {
|
||||
await fetchRoles()
|
||||
}
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
const deleteUser = async (id) => {
|
||||
try {
|
||||
const { api } = await import('../utils/api')
|
||||
await api.delete(`/users/${id}`)
|
||||
message.success('删除成功')
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('删除用户失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
const submitData = { ...formData }
|
||||
if (isEdit.value && !submitData.password) {
|
||||
delete submitData.password // 编辑时如果密码为空则不更新密码
|
||||
}
|
||||
|
||||
const { api } = await import('../utils/api')
|
||||
if (isEdit.value) {
|
||||
await api.put(`/users/${formData.id}`, submitData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await api.post('/users', submitData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
|
||||
modalVisible.value = false
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
roles: 2 // 默认为普通用户ID
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 搜索用户
|
||||
const searchUsers = async () => {
|
||||
const searchKeywordValue = searchUsername.value
|
||||
const searchStartTime = Date.now()
|
||||
|
||||
console.log('🔍 [用户搜索监听] 开始搜索:', {
|
||||
keyword: searchKeywordValue,
|
||||
keywordType: typeof searchKeywordValue,
|
||||
keywordLength: searchKeywordValue ? searchKeywordValue.length : 0,
|
||||
keywordTrimmed: searchKeywordValue ? searchKeywordValue.trim() : '',
|
||||
timestamp: new Date().toISOString(),
|
||||
searchStartTime: searchStartTime
|
||||
})
|
||||
|
||||
// 记录搜索开始日志
|
||||
await logUserAction('search_start', {
|
||||
keyword: searchKeywordValue,
|
||||
searchMethod: 'fetch_direct',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
if (!searchKeywordValue || searchKeywordValue.trim() === '') {
|
||||
console.log('🔄 [用户搜索监听] 搜索关键词为空,重新加载所有数据', {
|
||||
searchKeywordValue: searchKeywordValue,
|
||||
isFalsy: !searchKeywordValue,
|
||||
isEmpty: searchKeywordValue === '',
|
||||
isWhitespace: searchKeywordValue && searchKeywordValue.trim() === ''
|
||||
})
|
||||
await fetchUsers()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('🔄 [用户搜索监听] 发送搜索请求到后端:', searchKeywordValue)
|
||||
searchLoading.value = true
|
||||
|
||||
const searchUrl = `/users/search?username=${encodeURIComponent(searchKeywordValue.trim())}`
|
||||
console.log('🌐 [用户搜索监听] 请求URL:', searchUrl)
|
||||
|
||||
// 获取认证token
|
||||
const userInfo = JSON.parse(localStorage.getItem('user') || '{}')
|
||||
const token = userInfo.token || localStorage.getItem('token')
|
||||
|
||||
console.log('🔐 [用户搜索监听] 认证信息:', {
|
||||
hasUserInfo: !!userInfo,
|
||||
hasToken: !!token,
|
||||
tokenLength: token ? token.length : 0,
|
||||
tokenPreview: token ? token.substring(0, 20) + '...' : 'none'
|
||||
})
|
||||
|
||||
if (!token) {
|
||||
console.error('❌ [用户搜索监听] 未找到认证token')
|
||||
message.error('未找到认证token,请重新登录')
|
||||
throw new Error('未找到认证token,请重新登录')
|
||||
}
|
||||
|
||||
// 检查token是否过期(简单检查)
|
||||
try {
|
||||
const tokenPayload = JSON.parse(atob(token.split('.')[1]))
|
||||
const currentTime = Math.floor(Date.now() / 1000)
|
||||
if (tokenPayload.exp && tokenPayload.exp < currentTime) {
|
||||
console.error('❌ [用户搜索监听] Token已过期')
|
||||
message.error('登录已过期,请重新登录')
|
||||
throw new Error('Token已过期,请重新登录')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('⚠️ [用户搜索监听] Token解析失败:', e.message)
|
||||
}
|
||||
|
||||
// 使用API工具查询后端API
|
||||
const result = await api.get(searchUrl)
|
||||
|
||||
const responseTime = Date.now() - searchStartTime
|
||||
console.log('⏱️ [用户搜索监听] 后端响应时间:', responseTime + 'ms')
|
||||
console.log('📊 [用户搜索监听] 后端返回数据:', {
|
||||
success: result.success,
|
||||
dataCount: result.data ? result.data.length : 0,
|
||||
message: result.message
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
users.value = result.data
|
||||
isSearching.value = true
|
||||
|
||||
console.log(`✅ [用户搜索监听] 搜索成功,找到 ${users.value.length} 条记录`)
|
||||
message.success(`搜索完成,找到 ${users.value.length} 个用户`)
|
||||
|
||||
// 记录搜索成功日志
|
||||
await logUserAction('search_success', {
|
||||
keyword: searchKeywordValue,
|
||||
resultCount: users.value.length,
|
||||
searchMethod: 'fetch_direct',
|
||||
responseTime: responseTime,
|
||||
backendData: result.data
|
||||
})
|
||||
} else {
|
||||
throw new Error(result.message || '搜索失败')
|
||||
}
|
||||
} catch (error) {
|
||||
const errorTime = Date.now() - searchStartTime
|
||||
console.error('❌ [用户搜索监听] 搜索失败:', {
|
||||
error: error.message,
|
||||
keyword: searchKeywordValue,
|
||||
errorTime: errorTime
|
||||
})
|
||||
message.error('搜索失败: ' + (error.message || '网络错误'))
|
||||
|
||||
// 记录搜索失败日志
|
||||
await logUserAction('search_failed', {
|
||||
keyword: searchKeywordValue,
|
||||
error: error.message,
|
||||
errorTime: errorTime,
|
||||
searchMethod: 'fetch_direct'
|
||||
})
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
const totalTime = Date.now() - searchStartTime
|
||||
console.log('⏱️ [用户搜索监听] 搜索总耗时:', totalTime + 'ms')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听searchUsername变化
|
||||
watch(searchUsername, (newValue, oldValue) => {
|
||||
console.log('👀 [用户搜索监听] searchUsername变化:', {
|
||||
oldValue: oldValue,
|
||||
newValue: newValue,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: typeof newValue,
|
||||
length: newValue ? newValue.length : 0
|
||||
})
|
||||
}, { immediate: true })
|
||||
|
||||
// 输入框获得焦点
|
||||
const handleSearchFocus = async (e) => {
|
||||
searchFocusTime = Date.now()
|
||||
console.log('🎯 [用户搜索监听] 搜索框获得焦点:', {
|
||||
timestamp: new Date().toISOString(),
|
||||
currentValue: searchUsername.value,
|
||||
eventValue: e.target.value,
|
||||
focusTime: searchFocusTime,
|
||||
valuesMatch: searchUsername.value === e.target.value
|
||||
})
|
||||
|
||||
await logUserAction('search_focus', {
|
||||
field: 'searchUsername',
|
||||
currentValue: searchUsername.value,
|
||||
eventValue: e.target.value,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
// 输入框失去焦点
|
||||
const handleSearchBlur = async (e) => {
|
||||
const blurTime = Date.now()
|
||||
const focusDuration = searchFocusTime ? blurTime - searchFocusTime : 0
|
||||
|
||||
console.log('👋 [用户搜索监听] 搜索框失去焦点:', {
|
||||
timestamp: new Date().toISOString(),
|
||||
finalValue: searchUsername.value,
|
||||
focusDuration: focusDuration + 'ms',
|
||||
blurTime: blurTime
|
||||
})
|
||||
|
||||
await logUserAction('search_blur', {
|
||||
field: 'searchUsername',
|
||||
finalValue: searchUsername.value,
|
||||
focusDuration: focusDuration,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
searchFocusTime = null
|
||||
}
|
||||
|
||||
// 输入框值改变(与input不同,change在失焦时触发)
|
||||
const handleSearchChange = async (e) => {
|
||||
const value = e.target.value
|
||||
console.log('🔄 [用户搜索监听] 搜索框值改变:', {
|
||||
newValue: value,
|
||||
timestamp: new Date().toISOString(),
|
||||
eventType: 'change'
|
||||
})
|
||||
|
||||
await logUserAction('search_change', {
|
||||
field: 'searchUsername',
|
||||
newValue: value,
|
||||
eventType: 'change',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
// 实时搜索输入处理
|
||||
const handleSearchInput = async (e) => {
|
||||
const value = e.target.value
|
||||
const oldValue = searchUsername.value
|
||||
|
||||
// 更新searchUsername的值
|
||||
searchUsername.value = value
|
||||
|
||||
console.log('🔍 [用户搜索监听] 搜索输入变化:', {
|
||||
oldValue: oldValue,
|
||||
newValue: value,
|
||||
timestamp: new Date().toISOString(),
|
||||
inputLength: value ? value.length : 0,
|
||||
searchUsernameValue: searchUsername.value
|
||||
})
|
||||
|
||||
// 记录输入变化日志
|
||||
await logUserAction('search_input_change', {
|
||||
field: 'searchUsername',
|
||||
oldValue: oldValue,
|
||||
newValue: value,
|
||||
inputLength: value ? value.length : 0,
|
||||
isEmpty: !value || value.trim() === ''
|
||||
})
|
||||
|
||||
// 清除之前的定时器
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
console.log('⏰ [用户搜索监听] 清除之前的搜索定时器')
|
||||
}
|
||||
|
||||
// 如果输入为空,立即重新加载所有数据
|
||||
if (!value || value.trim() === '') {
|
||||
console.log('🔄 [用户搜索监听] 输入为空,重新加载所有数据')
|
||||
await fetchUsers()
|
||||
return
|
||||
}
|
||||
|
||||
// 延迟500ms执行搜索,避免频繁请求
|
||||
searchTimeout = setTimeout(async () => {
|
||||
console.log('⏰ [用户搜索监听] 延迟搜索触发,关键词:', value, 'searchUsername.value:', searchUsername.value)
|
||||
await searchUsers()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 更新用户名选项(在数据加载后)
|
||||
const updateUsernameOptions = () => {
|
||||
usernameOptions.value = users.value.map(user => ({
|
||||
value: user.username,
|
||||
label: user.username
|
||||
}))
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const resetSearch = () => {
|
||||
searchUsername.value = ''
|
||||
isSearching.value = false
|
||||
usernameOptions.value = []
|
||||
fetchUsers() // 重新加载全部用户
|
||||
}
|
||||
|
||||
// 调试搜索关键词
|
||||
const debugSearchKeyword = () => {
|
||||
console.log('🐛 [用户搜索调试] 搜索关键词调试信息:', {
|
||||
searchUsername: searchUsername.value,
|
||||
searchUsernameType: typeof searchUsername.value,
|
||||
searchUsernameLength: searchUsername.value ? searchUsername.value.length : 0,
|
||||
searchUsernameTrimmed: searchUsername.value ? searchUsername.value.trim() : '',
|
||||
searchUsernameIsEmpty: !searchUsername.value,
|
||||
searchUsernameIsWhitespace: searchUsername.value && searchUsername.value.trim() === '',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
message.info(`搜索关键词: "${searchUsername.value}" (长度: ${searchUsername.value ? searchUsername.value.length : 0})`)
|
||||
}
|
||||
|
||||
// 用户操作日志记录
|
||||
const logUserAction = async (action, data = {}) => {
|
||||
try {
|
||||
const userInfo = JSON.parse(localStorage.getItem('user') || '{}')
|
||||
|
||||
const logData = {
|
||||
module: 'user-management',
|
||||
action: action,
|
||||
timestamp: new Date().toISOString(),
|
||||
userId: userInfo.id || null,
|
||||
username: userInfo.username || 'anonymous',
|
||||
...data
|
||||
}
|
||||
|
||||
console.log('📝 [用户操作日志]', action, {
|
||||
time: new Date().toLocaleString(),
|
||||
user: userInfo.username || 'anonymous',
|
||||
action: action,
|
||||
data: logData
|
||||
})
|
||||
|
||||
// 发送到后端记录
|
||||
const { api } = await import('../utils/api')
|
||||
await api.formLogs.add({
|
||||
action: action,
|
||||
module: 'user-management',
|
||||
userId: userInfo.id || null,
|
||||
formData: JSON.stringify(logData),
|
||||
oldValues: null,
|
||||
newValues: JSON.stringify(data),
|
||||
success: true,
|
||||
errorMessage: null
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('❌ 记录用户操作日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(async () => {
|
||||
// 并行获取用户列表和角色列表
|
||||
await Promise.all([
|
||||
fetchUsers().then(() => {
|
||||
updateUsernameOptions()
|
||||
}),
|
||||
fetchRoles()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user