更新政府端和银行端

This commit is contained in:
2025-09-17 18:04:28 +08:00
parent f35ceef31f
commit e4287b83fe
185 changed files with 78320 additions and 189 deletions

View File

@@ -0,0 +1,25 @@
# 环境变量配置示例文件
# 复制此文件为 .env 并根据实际情况修改配置
# 应用配置
VITE_APP_TITLE=宁夏智慧养殖监管平台 - 政府端管理后台
VITE_APP_VERSION=1.0.0
VITE_APP_ENV=development
# API配置
VITE_API_BASE_URL=http://localhost:5350/api
VITE_API_FULL_URL=http://localhost:5350/api
# 百度地图API密钥
VITE_BAIDU_MAP_AK=your_baidu_map_api_key
# WebSocket配置
VITE_WS_URL=ws://localhost:5350
# 文件上传配置
VITE_UPLOAD_URL=http://localhost:5350/api/upload
VITE_MAX_FILE_SIZE=10485760
# 其他配置
VITE_ENABLE_MOCK=false
VITE_ENABLE_DEVTOOLS=true

1
government-admin/.nvmrc Normal file
View File

@@ -0,0 +1 @@
16

191
government-admin/README.md Normal file
View File

@@ -0,0 +1,191 @@
# 宁夏智慧养殖监管平台 - 政府端管理后台
## 项目简介
本项目是宁夏智慧养殖监管平台的政府端管理后台,基于 Vue 3 + Ant Design Vue 构建,为政府监管部门提供养殖场管理、设备监控、数据分析等功能。
## 技术栈
- **前端框架**: Vue 3.4+
- **构建工具**: Vite 5.0+
- **UI组件库**: Ant Design Vue 4.0+
- **状态管理**: Pinia 2.1+
- **路由管理**: Vue Router 4.2+
- **HTTP客户端**: Axios 1.6+
- **图表库**: ECharts 5.4+
- **样式预处理**: Sass
- **Node.js版本**: 16.x
## 功能特性
### 核心功能
- 🔐 用户认证与权限管理
- 🏠 养殖场信息管理
- 🗺️ 地图可视化展示
- 📊 设备监控与状态管理
- 🐄 动物健康管理
- ⚠️ 预警管理系统
- 📈 数据可视化与报表
- 👥 用户管理
- ⚙️ 系统设置
### 技术特性
- 📱 响应式设计,支持多端适配
- 🎨 现代化UI设计用户体验优良
- 🚀 基于Vite的快速开发体验
- 🔄 实时数据更新WebSocket
- 📦 组件化开发,代码复用性高
- 🛡️ 完善的权限控制系统
- 🌐 国际化支持(预留)
## 环境要求
- Node.js 16.x
- npm 8.0+ 或 yarn 1.22+
- 现代浏览器Chrome 88+, Firefox 78+, Safari 14+
## 快速开始
### 1. 克隆项目
```bash
git clone <repository-url>
cd government-admin
```
### 2. 安装依赖
```bash
# 使用npm
npm install
# 或使用yarn
yarn install
```
### 3. 配置环境变量
```bash
# 复制环境变量示例文件
cp .env.example .env
# 编辑 .env 文件配置API地址等信息
```
### 4. 启动开发服务器
```bash
# 使用npm
npm run dev
# 或使用yarn
yarn dev
```
### 5. 构建生产版本
```bash
# 使用npm
npm run build
# 或使用yarn
yarn build
```
## 项目结构
```
government-admin/
├── public/ # 静态资源
├── src/
│ ├── assets/ # 资源文件
│ ├── components/ # 通用组件
│ ├── layouts/ # 布局组件
│ ├── router/ # 路由配置
│ ├── stores/ # 状态管理
│ ├── styles/ # 样式文件
│ ├── utils/ # 工具函数
│ ├── views/ # 页面组件
│ ├── App.vue # 根组件
│ └── main.js # 入口文件
├── .env.example # 环境变量示例
├── .nvmrc # Node.js版本配置
├── index.html # HTML模板
├── package.json # 项目配置
├── vite.config.js # Vite配置
└── README.md # 项目说明
```
## 开发规范
### 代码规范
- 使用 ESLint + Prettier 进行代码格式化
- 组件命名使用 PascalCase
- 文件命名使用 kebab-case
- 变量命名使用 camelCase
### Git提交规范
```
feat: 新功能
fix: 修复bug
docs: 文档更新
style: 代码格式调整
refactor: 代码重构
test: 测试相关
chore: 构建过程或辅助工具的变动
```
## 部署说明
### 开发环境
```bash
npm run dev
```
访问: http://localhost:5400
### 生产环境
```bash
npm run build
npm run preview
```
### Docker部署
```bash
# 构建镜像
docker build -t government-admin .
# 运行容器
docker run -p 5400:80 government-admin
```
## API接口
后端API服务地址: http://localhost:5350/api
主要接口:
- `/auth/*` - 认证相关
- `/farms/*` - 养殖场管理
- `/devices/*` - 设备监控
- `/animals/*` - 动物管理
- `/alerts/*` - 预警管理
- `/reports/*` - 报表数据
- `/users/*` - 用户管理
## 浏览器支持
| Chrome | Firefox | Safari | Edge |
|--------|---------|--------|------|
| 88+ | 78+ | 14+ | 88+ |
## 许可证
MIT License
## 联系方式
- 项目维护: NXXM Development Team
- 技术支持: [技术支持邮箱]
- 问题反馈: [GitHub Issues]
## 更新日志
### v1.0.0 (2025-01-18)
- 🎉 初始版本发布
- ✨ 完成基础框架搭建
- ✨ 实现用户认证系统
- ✨ 完成基础布局和路由配置

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="宁夏智慧养殖监管平台 - 政府端管理后台" />
<meta name="keywords" content="智慧养殖,监管平台,政府管理,数据监控" />
<title>宁夏智慧养殖监管平台 - 政府端管理后台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

6175
government-admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
{
"name": "government-admin-system",
"version": "1.0.0",
"description": "宁夏智慧养殖监管平台 - 政府端管理后台",
"author": "NXXM Development Team",
"license": "MIT",
"keywords": [
"vue3",
"vite",
"ant-design-vue",
"echarts",
"pinia",
"government-admin",
"smart-farming",
"monitoring-system"
],
"engines": {
"node": "16.x",
"npm": ">=8.0.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"preview": "vite preview --port 5400",
"lint": "eslint . --ext .vue,.js,.ts --fix",
"lint:check": "eslint . --ext .vue,.js,.ts",
"type-check": "vue-tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"clean": "rimraf dist node_modules/.vite",
"analyze": "vite-bundle-analyzer dist/stats.html",
"deploy": "npm run build && npm run preview"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"ant-design-vue": "^4.0.6",
"axios": "^1.6.2",
"dayjs": "^1.11.10",
"echarts": "^5.4.3",
"element-plus": "^2.11.2",
"file-saver": "^2.0.5",
"lodash-es": "^4.17.21",
"nprogress": "^0.2.0",
"pinia": "^2.1.7",
"qrcode": "^1.5.4",
"socket.io-client": "^4.7.4",
"vue": "^3.4.15",
"vue-router": "^4.2.5",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",
"@vue/eslint-config-prettier": "^9.0.0",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.20.1",
"prettier": "^3.2.4",
"rimraf": "^5.0.5",
"vite": "^5.0.12",
"vite-bundle-analyzer": "^0.7.0",
"vitest": "^1.2.2",
"vue-tsc": "^1.8.27"
}
}

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" fill="#1890ff"/>
<circle cx="50" cy="50" r="30" fill="white"/>
<circle cx="50" cy="50" r="20" fill="#1890ff"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" fill="#1890ff"/>
<circle cx="50" cy="50" r="30" fill="white"/>
<circle cx="50" cy="50" r="20" fill="#1890ff"/>
</svg>

After

Width:  |  Height:  |  Size: 435 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" fill="#1890ff"/>
<circle cx="50" cy="50" r="30" fill="white"/>
<circle cx="50" cy="50" r="20" fill="#1890ff"/>
</svg>

After

Width:  |  Height:  |  Size: 217 B

View File

@@ -0,0 +1,60 @@
<template>
<div id="app">
<router-view />
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
onMounted(() => {
// 应用初始化时检查用户登录状态
authStore.checkAuthStatus()
})
</script>
<style lang="scss">
#app {
height: 100vh;
width: 100vw;
}
// 全局样式重置
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
font-size: 14px;
line-height: 1.5715;
color: rgba(0, 0, 0, 0.85);
background-color: #f0f2f5;
}
// 滚动条样式
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>

View File

@@ -0,0 +1,137 @@
import request from '@/utils/request'
// 获取养殖场列表
export function getFarmList(params) {
return request({
url: '/api/farms',
method: 'get',
params
})
}
// 获取养殖场详情
export function getFarmDetail(id) {
return request({
url: `/api/farms/${id}`,
method: 'get'
})
}
// 创建养殖场
export function createFarm(data) {
return request({
url: '/api/farms',
method: 'post',
data
})
}
// 更新养殖场
export function updateFarm(id, data) {
return request({
url: `/api/farms/${id}`,
method: 'put',
data
})
}
// 删除养殖场
export function deleteFarm(id) {
return request({
url: `/api/farms/${id}`,
method: 'delete'
})
}
// 批量删除养殖场
export function batchDeleteFarms(ids) {
return request({
url: '/api/farms/batch',
method: 'delete',
data: { ids }
})
}
// 更新养殖场状态
export function updateFarmStatus(id, status) {
return request({
url: `/api/farms/${id}/status`,
method: 'patch',
data: { status }
})
}
// 获取养殖场统计数据
export function getFarmStats() {
return request({
url: '/api/farms/stats',
method: 'get'
})
}
// 获取养殖场地图数据
export function getFarmMapData(params) {
return request({
url: '/api/farms/map',
method: 'get',
params
})
}
// 导出养殖场数据
export function exportFarmData(params) {
return request({
url: '/api/farms/export',
method: 'get',
params,
responseType: 'blob'
})
}
// 导入养殖场数据
export function importFarmData(file) {
const formData = new FormData()
formData.append('file', file)
return request({
url: '/api/farms/import',
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
// 获取养殖场类型选项
export function getFarmTypes() {
return request({
url: '/api/farms/types',
method: 'get'
})
}
// 获取养殖场规模选项
export function getFarmScales() {
return request({
url: '/api/farms/scales',
method: 'get'
})
}
// 验证养殖场编号唯一性
export function validateFarmCode(code, excludeId) {
return request({
url: '/api/farms/validate-code',
method: 'post',
data: { code, excludeId }
})
}
// 获取附近养殖场
export function getNearbyFarms(lat, lng, radius = 5000) {
return request({
url: '/api/farms/nearby',
method: 'get',
params: { lat, lng, radius }
})
}

View File

@@ -0,0 +1,391 @@
/**
* 政府业务API接口
*/
import request from '@/utils/request'
// 政府监管API
export const supervisionApi = {
// 获取监管数据
getSupervisionData: () => request.get('/api/supervision/data'),
// 获取监管实体列表
getEntities: (params) => request.get('/api/supervision/entities', { params }),
// 添加监管实体
addEntity: (data) => request.post('/api/supervision/entities', data),
// 更新监管实体
updateEntity: (id, data) => request.put(`/api/supervision/entities/${id}`, data),
// 删除监管实体
deleteEntity: (id) => request.delete(`/api/supervision/entities/${id}`),
// 获取检查记录
getInspections: (params) => request.get('/api/supervision/inspections', { params }),
// 创建检查记录
createInspection: (data) => request.post('/api/supervision/inspections', data),
// 更新检查记录
updateInspection: (id, data) => request.put(`/api/supervision/inspections/${id}`, data),
// 获取违规记录
getViolations: (params) => request.get('/api/supervision/violations', { params }),
// 创建违规记录
createViolation: (data) => request.post('/api/supervision/violations', data),
// 处理违规记录
processViolation: (id, data) => request.put(`/api/supervision/violations/${id}/process`, data)
}
// 审批管理API
export const approvalApi = {
// 获取审批数据
getApprovalData: () => request.get('/api/approval/data'),
// 获取审批流程
getWorkflows: (params) => request.get('/api/approval/workflows', { params }),
// 创建审批流程
createWorkflow: (data) => request.post('/api/approval/workflows', data),
// 更新审批流程
updateWorkflow: (id, data) => request.put(`/api/approval/workflows/${id}`, data),
// 获取审批记录
getRecords: (params) => request.get('/api/approval/records', { params }),
// 提交审批申请
submitApproval: (data) => request.post('/api/approval/records', data),
// 处理审批
processApproval: (id, data) => request.put(`/api/approval/records/${id}/process`, data),
// 获取待办任务
getTasks: (params) => request.get('/api/approval/tasks', { params }),
// 完成任务
completeTask: (id, data) => request.put(`/api/approval/tasks/${id}/complete`, data),
// 转派任务
transferTask: (id, data) => request.put(`/api/approval/tasks/${id}/transfer`, data)
}
// 人员管理API
export const personnelApi = {
// 获取人员数据
getPersonnelData: () => request.get('/api/personnel/data'),
// 获取员工列表
getStaff: (params) => request.get('/api/personnel/staff', { params }),
// 添加员工
addStaff: (data) => request.post('/api/personnel/staff', data),
// 更新员工信息
updateStaff: (id, data) => request.put(`/api/personnel/staff/${id}`, data),
// 删除员工
deleteStaff: (id) => request.delete(`/api/personnel/staff/${id}`),
// 获取部门列表
getDepartments: (params) => request.get('/api/personnel/departments', { params }),
// 添加部门
addDepartment: (data) => request.post('/api/personnel/departments', data),
// 更新部门信息
updateDepartment: (id, data) => request.put(`/api/personnel/departments/${id}`, data),
// 删除部门
deleteDepartment: (id) => request.delete(`/api/personnel/departments/${id}`),
// 获取职位列表
getPositions: (params) => request.get('/api/personnel/positions', { params }),
// 添加职位
addPosition: (data) => request.post('/api/personnel/positions', data),
// 更新职位信息
updatePosition: (id, data) => request.put(`/api/personnel/positions/${id}`, data),
// 获取考勤记录
getAttendance: (params) => request.get('/api/personnel/attendance', { params }),
// 记录考勤
recordAttendance: (data) => request.post('/api/personnel/attendance', data),
// 获取员工详情
getStaffDetail: (id) => request.get(`/api/personnel/staff/${id}`),
// 员工调岗
transferStaff: (id, data) => request.put(`/api/personnel/staff/${id}/transfer`, data)
}
// 设备仓库API
export const warehouseApi = {
// 获取仓库数据
getWarehouseData: () => request.get('/api/warehouse/data'),
// 获取设备列表
getEquipment: (params) => request.get('/api/warehouse/equipment', { params }),
// 添加设备
addEquipment: (data) => request.post('/api/warehouse/equipment', data),
// 更新设备信息
updateEquipment: (id, data) => request.put(`/api/warehouse/equipment/${id}`, data),
// 删除设备
deleteEquipment: (id) => request.delete(`/api/warehouse/equipment/${id}`),
// 设备入库
equipmentInbound: (data) => request.post('/api/warehouse/inbound', data),
// 设备出库
equipmentOutbound: (data) => request.post('/api/warehouse/outbound', data),
// 获取入库记录
getInboundRecords: (params) => request.get('/api/warehouse/inbound', { params }),
// 获取出库记录
getOutboundRecords: (params) => request.get('/api/warehouse/outbound', { params }),
// 获取维护记录
getMaintenanceRecords: (params) => request.get('/api/warehouse/maintenance', { params }),
// 创建维护记录
createMaintenanceRecord: (data) => request.post('/api/warehouse/maintenance', data),
// 更新维护记录
updateMaintenanceRecord: (id, data) => request.put(`/api/warehouse/maintenance/${id}`, data),
// 设备盘点
inventoryCheck: (data) => request.post('/api/warehouse/inventory', data),
// 获取库存报告
getInventoryReport: (params) => request.get('/api/warehouse/inventory/report', { params })
}
// 防疫管理API
export const epidemicApi = {
// 获取防疫数据
getEpidemicData: () => request.get('/api/epidemic/data'),
// 获取疫情案例
getCases: (params) => request.get('/api/epidemic/cases', { params }),
// 添加疫情案例
addCase: (data) => request.post('/api/epidemic/cases', data),
// 更新疫情案例
updateCase: (id, data) => request.put(`/api/epidemic/cases/${id}`, data),
// 获取疫苗接种记录
getVaccinations: (params) => request.get('/api/epidemic/vaccinations', { params }),
// 记录疫苗接种
recordVaccination: (data) => request.post('/api/epidemic/vaccinations', data),
// 获取防疫措施
getMeasures: (params) => request.get('/api/epidemic/measures', { params }),
// 创建防疫措施
createMeasure: (data) => request.post('/api/epidemic/measures', data),
// 更新防疫措施
updateMeasure: (id, data) => request.put(`/api/epidemic/measures/${id}`, data),
// 获取健康码数据
getHealthCodes: (params) => request.get('/api/epidemic/health-codes', { params }),
// 生成健康码
generateHealthCode: (data) => request.post('/api/epidemic/health-codes', data),
// 验证健康码
verifyHealthCode: (code) => request.get(`/api/epidemic/health-codes/${code}/verify`),
// 获取疫情统计
getEpidemicStats: (params) => request.get('/api/epidemic/stats', { params }),
// 获取疫情地图数据
getEpidemicMapData: (params) => request.get('/api/epidemic/map', { params })
}
// 服务管理API
export const serviceApi = {
// 获取服务数据
getServiceData: () => request.get('/api/service/data'),
// 获取服务项目
getServices: (params) => request.get('/api/service/services', { params }),
// 创建服务项目
createService: (data) => request.post('/api/service/services', data),
// 更新服务项目
updateService: (id, data) => request.put(`/api/service/services/${id}`, data),
// 删除服务项目
deleteService: (id) => request.delete(`/api/service/services/${id}`),
// 获取服务申请
getApplications: (params) => request.get('/api/service/applications', { params }),
// 提交服务申请
submitApplication: (data) => request.post('/api/service/applications', data),
// 处理服务申请
processApplication: (id, data) => request.put(`/api/service/applications/${id}/process`, data),
// 获取服务评价
getEvaluations: (params) => request.get('/api/service/evaluations', { params }),
// 提交服务评价
submitEvaluation: (data) => request.post('/api/service/evaluations', data),
// 获取服务指南
getGuides: (params) => request.get('/api/service/guides', { params }),
// 创建服务指南
createGuide: (data) => request.post('/api/service/guides', data),
// 更新服务指南
updateGuide: (id, data) => request.put(`/api/service/guides/${id}`, data),
// 获取服务统计
getServiceStats: (params) => request.get('/api/service/stats', { params })
}
// 数据可视化API
export const visualizationApi = {
// 获取仪表盘数据
getDashboardData: () => request.get('/api/visualization/dashboard'),
// 获取图表数据
getChartData: (chartType, params) => request.get(`/api/visualization/charts/${chartType}`, { params }),
// 获取实时数据
getRealTimeData: (dataType) => request.get(`/api/visualization/realtime/${dataType}`),
// 获取统计报告
getStatisticsReport: (params) => request.get('/api/visualization/statistics', { params }),
// 导出数据
exportData: (params) => request.get('/api/visualization/export', {
params,
responseType: 'blob'
}),
// 获取地图数据
getMapData: (params) => request.get('/api/visualization/map', { params }),
// 获取热力图数据
getHeatmapData: (params) => request.get('/api/visualization/heatmap', { params })
}
// 系统管理API
export const systemApi = {
// 获取系统信息
getSystemInfo: () => request.get('/api/system/info'),
// 获取系统日志
getSystemLogs: (params) => request.get('/api/system/logs', { params }),
// 获取操作日志
getOperationLogs: (params) => request.get('/api/system/operation-logs', { params }),
// 系统备份
systemBackup: () => request.post('/api/system/backup'),
// 系统恢复
systemRestore: (data) => request.post('/api/system/restore', data),
// 清理缓存
clearCache: () => request.post('/api/system/clear-cache'),
// 获取系统配置
getSystemConfig: () => request.get('/api/system/config'),
// 更新系统配置
updateSystemConfig: (data) => request.put('/api/system/config', data)
}
// 文件管理API
export const fileApi = {
// 上传文件
uploadFile: (file, onProgress) => {
const formData = new FormData()
formData.append('file', file)
return request.post('/api/files/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: onProgress
})
},
// 批量上传文件
uploadFiles: (files, onProgress) => {
const formData = new FormData()
files.forEach(file => {
formData.append('files', file)
})
return request.post('/api/files/upload/batch', formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: onProgress
})
},
// 删除文件
deleteFile: (fileId) => request.delete(`/api/files/${fileId}`),
// 获取文件列表
getFiles: (params) => request.get('/api/files', { params }),
// 下载文件
downloadFile: (fileId) => request.get(`/api/files/${fileId}/download`, {
responseType: 'blob'
}),
// 获取文件信息
getFileInfo: (fileId) => request.get(`/api/files/${fileId}`)
}
// 统一导出政府业务API
export const governmentApi = {
// 获取所有模块数据
getSupervisionData: supervisionApi.getSupervisionData,
getApprovalData: approvalApi.getApprovalData,
getPersonnelData: personnelApi.getPersonnelData,
getWarehouseData: warehouseApi.getWarehouseData,
getEpidemicData: epidemicApi.getEpidemicData,
getServiceData: serviceApi.getServiceData,
// 常用操作
submitApproval: approvalApi.submitApproval,
processApproval: approvalApi.processApproval,
addEquipment: warehouseApi.addEquipment,
equipmentInbound: warehouseApi.equipmentInbound,
equipmentOutbound: warehouseApi.equipmentOutbound,
addStaff: personnelApi.addStaff,
updateStaff: personnelApi.updateStaff,
// 子模块API
supervision: supervisionApi,
approval: approvalApi,
personnel: personnelApi,
warehouse: warehouseApi,
epidemic: epidemicApi,
service: serviceApi,
visualization: visualizationApi,
system: systemApi,
file: fileApi
}
export default governmentApi

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" fill="#1890ff"/>
<circle cx="50" cy="50" r="30" fill="white"/>
<circle cx="50" cy="50" r="20" fill="#1890ff"/>
</svg>

After

Width:  |  Height:  |  Size: 217 B

View File

@@ -0,0 +1,76 @@
<template>
<div class="page-header">
<div class="page-header-content">
<div class="page-header-main">
<div class="page-header-title">
<h1>{{ title }}</h1>
<p v-if="description" class="page-header-description">{{ description }}</p>
</div>
<div v-if="$slots.extra" class="page-header-extra">
<slot name="extra"></slot>
</div>
</div>
<div v-if="$slots.default" class="page-header-body">
<slot></slot>
</div>
</div>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true
},
description: {
type: String,
default: ''
}
})
</script>
<style lang="scss" scoped>
.page-header {
background: #fff;
padding: 16px 24px;
margin-bottom: 24px;
border-bottom: 1px solid #f0f0f0;
.page-header-content {
.page-header-main {
display: flex;
justify-content: space-between;
align-items: flex-start;
.page-header-title {
flex: 1;
h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #262626;
line-height: 32px;
}
.page-header-description {
margin: 4px 0 0;
color: #8c8c8c;
font-size: 14px;
line-height: 22px;
}
}
.page-header-extra {
flex-shrink: 0;
margin-left: 24px;
}
}
.page-header-body {
margin-top: 16px;
}
}
}
</style>

View File

@@ -0,0 +1,89 @@
<template>
<a-button
v-if="hasPermission"
v-bind="$attrs"
@click="handleClick"
>
<slot />
</a-button>
</template>
<script setup>
import { computed } from 'vue'
import { usePermissionStore } from '@/stores/permission'
const props = defineProps({
// 权限码
permission: {
type: String,
default: ''
},
// 角色
role: {
type: String,
default: ''
},
// 权限列表(任一权限)
permissions: {
type: Array,
default: () => []
},
// 是否需要全部权限
requireAll: {
type: Boolean,
default: false
},
// 角色列表
roles: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['click'])
const permissionStore = usePermissionStore()
// 检查是否有权限
const hasPermission = computed(() => {
// 如果没有设置任何权限要求,默认显示
if (!props.permission && !props.role && props.permissions.length === 0 && props.roles.length === 0) {
return true
}
// 检查单个权限
if (props.permission) {
return permissionStore.hasPermission(props.permission)
}
// 检查单个角色
if (props.role) {
return permissionStore.hasRole(props.role)
}
// 检查权限列表
if (props.permissions.length > 0) {
return props.requireAll
? permissionStore.hasAllPermissions(props.permissions)
: permissionStore.hasAnyPermission(props.permissions)
}
// 检查角色列表
if (props.roles.length > 0) {
return props.roles.some(role => permissionStore.hasRole(role))
}
return false
})
const handleClick = (event) => {
emit('click', event)
}
</script>
<script>
export default {
name: 'PermissionButton',
inheritAttrs: false
}
</script>

View File

@@ -0,0 +1,196 @@
<template>
<div class="bar-chart" :style="{ width: width, height: height }">
<div ref="chartRef" :style="{ width: '100%', height: '100%' }"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
data: {
type: Array,
default: () => []
},
xAxisData: {
type: Array,
default: () => []
},
title: {
type: String,
default: ''
},
width: {
type: String,
default: '100%'
},
height: {
type: String,
default: '400px'
},
color: {
type: Array,
default: () => ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1']
},
horizontal: {
type: Boolean,
default: false
},
stack: {
type: String,
default: ''
},
grid: {
type: Object,
default: () => ({
top: '10%',
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
})
}
})
const chartRef = ref(null)
let chartInstance = null
const initChart = () => {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
updateChart()
}
const updateChart = () => {
if (!chartInstance) return
const series = props.data.map((item, index) => ({
name: item.name,
type: 'bar',
data: item.data,
stack: props.stack,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: props.color[index % props.color.length] },
{ offset: 1, color: echarts.color.lift(props.color[index % props.color.length], -0.3) }
]),
borderRadius: props.horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]
},
emphasis: {
itemStyle: {
color: props.color[index % props.color.length]
}
},
barWidth: '60%'
}))
const option = {
title: {
text: props.title,
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 'normal',
color: '#333'
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#ccc',
borderWidth: 1,
textStyle: {
color: '#333'
}
},
legend: {
data: props.data.map(item => item.name),
bottom: 10,
textStyle: {
color: '#666'
}
},
grid: props.grid,
xAxis: {
type: props.horizontal ? 'value' : 'category',
data: props.horizontal ? null : props.xAxisData,
axisLine: {
lineStyle: {
color: '#e8e8e8'
}
},
axisLabel: {
color: '#666',
rotate: props.horizontal ? 0 : (props.xAxisData.length > 6 ? 45 : 0)
},
splitLine: props.horizontal ? {
lineStyle: {
color: '#f0f0f0'
}
} : null
},
yAxis: {
type: props.horizontal ? 'category' : 'value',
data: props.horizontal ? props.xAxisData : null,
axisLine: {
lineStyle: {
color: '#e8e8e8'
}
},
axisLabel: {
color: '#666'
},
splitLine: props.horizontal ? null : {
lineStyle: {
color: '#f0f0f0'
}
}
},
series
}
chartInstance.setOption(option, true)
}
const resizeChart = () => {
if (chartInstance) {
chartInstance.resize()
}
}
onMounted(() => {
nextTick(() => {
initChart()
})
window.addEventListener('resize', resizeChart)
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
window.removeEventListener('resize', resizeChart)
})
watch(() => [props.data, props.xAxisData], () => {
updateChart()
}, { deep: true })
defineExpose({
getChartInstance: () => chartInstance,
resize: resizeChart
})
</script>
<style lang="scss" scoped>
.bar-chart {
position: relative;
}
</style>

View File

@@ -0,0 +1,205 @@
<template>
<div class="gauge-chart" :style="{ width: width, height: height }">
<div ref="chartRef" :style="{ width: '100%', height: '100%' }"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
value: {
type: Number,
default: 0
},
max: {
type: Number,
default: 100
},
min: {
type: Number,
default: 0
},
title: {
type: String,
default: ''
},
unit: {
type: String,
default: '%'
},
width: {
type: String,
default: '100%'
},
height: {
type: String,
default: '400px'
},
color: {
type: Array,
default: () => [
[0.2, '#67e0e3'],
[0.8, '#37a2da'],
[1, '#fd666d']
]
},
radius: {
type: String,
default: '75%'
},
center: {
type: Array,
default: () => ['50%', '60%']
}
})
const chartRef = ref(null)
let chartInstance = null
const initChart = () => {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
updateChart()
}
const updateChart = () => {
if (!chartInstance) return
const option = {
title: {
text: props.title,
left: 'center',
top: 20,
textStyle: {
fontSize: 16,
fontWeight: 'normal',
color: '#333'
}
},
tooltip: {
formatter: '{a} <br/>{b} : {c}' + props.unit
},
series: [
{
name: props.title || '指标',
type: 'gauge',
min: props.min,
max: props.max,
radius: props.radius,
center: props.center,
splitNumber: 10,
axisLine: {
lineStyle: {
color: props.color,
width: 20,
shadowColor: '#fff',
shadowBlur: 10
}
},
axisLabel: {
textStyle: {
fontWeight: 'bolder',
color: '#fff',
shadowColor: '#fff',
shadowBlur: 10
}
},
axisTick: {
length: 15,
lineStyle: {
color: 'auto',
shadowColor: '#fff',
shadowBlur: 10
}
},
splitLine: {
length: 25,
lineStyle: {
width: 3,
color: '#fff',
shadowColor: '#fff',
shadowBlur: 10
}
},
pointer: {
shadowColor: '#fff',
shadowBlur: 5
},
title: {
textStyle: {
fontWeight: 'bolder',
fontSize: 20,
fontStyle: 'italic',
color: '#fff',
shadowColor: '#fff',
shadowBlur: 10
}
},
detail: {
backgroundColor: 'rgba(30,144,255,0.8)',
borderWidth: 1,
borderColor: '#fff',
shadowColor: '#fff',
shadowBlur: 5,
offsetCenter: [0, '50%'],
textStyle: {
fontWeight: 'bolder',
color: '#fff'
},
formatter: function(value) {
return value + props.unit
}
},
data: [
{
value: props.value,
name: props.title || '完成度'
}
]
}
]
}
chartInstance.setOption(option, true)
}
const resizeChart = () => {
if (chartInstance) {
chartInstance.resize()
}
}
onMounted(() => {
nextTick(() => {
initChart()
})
window.addEventListener('resize', resizeChart)
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
window.removeEventListener('resize', resizeChart)
})
watch(() => [props.value, props.max, props.min], () => {
updateChart()
})
defineExpose({
getChartInstance: () => chartInstance,
resize: resizeChart
})
</script>
<style lang="scss" scoped>
.gauge-chart {
position: relative;
}
</style>

View File

@@ -0,0 +1,200 @@
<template>
<div class="line-chart" :style="{ width: width, height: height }">
<div ref="chartRef" :style="{ width: '100%', height: '100%' }"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
data: {
type: Array,
default: () => []
},
xAxisData: {
type: Array,
default: () => []
},
title: {
type: String,
default: ''
},
width: {
type: String,
default: '100%'
},
height: {
type: String,
default: '400px'
},
color: {
type: Array,
default: () => ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1']
},
smooth: {
type: Boolean,
default: true
},
showArea: {
type: Boolean,
default: false
},
showSymbol: {
type: Boolean,
default: true
},
grid: {
type: Object,
default: () => ({
top: '10%',
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
})
}
})
const chartRef = ref(null)
let chartInstance = null
const initChart = () => {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
updateChart()
}
const updateChart = () => {
if (!chartInstance) return
const series = props.data.map((item, index) => ({
name: item.name,
type: 'line',
data: item.data,
smooth: props.smooth,
symbol: props.showSymbol ? 'circle' : 'none',
symbolSize: 6,
lineStyle: {
width: 2,
color: props.color[index % props.color.length]
},
itemStyle: {
color: props.color[index % props.color.length]
},
areaStyle: props.showArea ? {
opacity: 0.3,
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: props.color[index % props.color.length] },
{ offset: 1, color: 'rgba(255, 255, 255, 0)' }
])
} : null
}))
const option = {
title: {
text: props.title,
left: 'center',
textStyle: {
fontSize: 16,
fontWeight: 'normal',
color: '#333'
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985'
}
},
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#ccc',
borderWidth: 1,
textStyle: {
color: '#333'
}
},
legend: {
data: props.data.map(item => item.name),
bottom: 10,
textStyle: {
color: '#666'
}
},
grid: props.grid,
xAxis: {
type: 'category',
boundaryGap: false,
data: props.xAxisData,
axisLine: {
lineStyle: {
color: '#e8e8e8'
}
},
axisLabel: {
color: '#666'
}
},
yAxis: {
type: 'value',
axisLine: {
lineStyle: {
color: '#e8e8e8'
}
},
axisLabel: {
color: '#666'
},
splitLine: {
lineStyle: {
color: '#f0f0f0'
}
}
},
series
}
chartInstance.setOption(option, true)
}
const resizeChart = () => {
if (chartInstance) {
chartInstance.resize()
}
}
onMounted(() => {
nextTick(() => {
initChart()
})
window.addEventListener('resize', resizeChart)
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
window.removeEventListener('resize', resizeChart)
})
watch(() => [props.data, props.xAxisData], () => {
updateChart()
}, { deep: true })
defineExpose({
getChartInstance: () => chartInstance,
resize: resizeChart
})
</script>
<style lang="scss" scoped>
.line-chart {
position: relative;
}
</style>

View File

@@ -0,0 +1,185 @@
<template>
<div class="map-chart" :style="{ width: width, height: height }">
<div ref="chartRef" :style="{ width: '100%', height: '100%' }"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
data: {
type: Array,
default: () => []
},
mapName: {
type: String,
default: 'china'
},
title: {
type: String,
default: ''
},
width: {
type: String,
default: '100%'
},
height: {
type: String,
default: '400px'
},
color: {
type: Array,
default: () => ['#313695', '#4575b4', '#74add1', '#abd9e9', '#e0f3f8', '#ffffcc', '#fee090', '#fdae61', '#f46d43', '#d73027', '#a50026']
},
visualMapMin: {
type: Number,
default: 0
},
visualMapMax: {
type: Number,
default: 1000
},
roam: {
type: Boolean,
default: true
}
})
const chartRef = ref(null)
let chartInstance = null
const initChart = async () => {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
// 注册地图(这里需要根据实际情况加载地图数据)
// 示例:加载中国地图数据
try {
// 这里应该加载实际的地图JSON数据
// const mapData = await import('@/assets/maps/china.json')
// echarts.registerMap(props.mapName, mapData.default)
// 临时使用内置地图
updateChart()
} catch (error) {
console.warn('地图数据加载失败,使用默认配置')
updateChart()
}
}
const updateChart = () => {
if (!chartInstance) return
const option = {
title: {
text: props.title,
left: 'center',
top: 20,
textStyle: {
fontSize: 16,
fontWeight: 'normal',
color: '#333'
}
},
tooltip: {
trigger: 'item',
formatter: function(params) {
if (params.data) {
return `${params.name}<br/>${params.seriesName}: ${params.data.value}`
}
return `${params.name}<br/>暂无数据`
},
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#ccc',
borderWidth: 1,
textStyle: {
color: '#333'
}
},
visualMap: {
min: props.visualMapMin,
max: props.visualMapMax,
left: 'left',
top: 'bottom',
text: ['高', '低'],
inRange: {
color: props.color
},
textStyle: {
color: '#666'
}
},
series: [
{
name: props.title || '数据分布',
type: 'map',
map: props.mapName,
roam: props.roam,
data: props.data,
emphasis: {
label: {
show: true,
color: '#fff'
},
itemStyle: {
areaColor: '#389BB7',
borderWidth: 0
}
},
itemStyle: {
borderColor: '#fff',
borderWidth: 1,
areaColor: '#eee'
},
label: {
show: false,
fontSize: 12,
color: '#333'
}
}
]
}
chartInstance.setOption(option, true)
}
const resizeChart = () => {
if (chartInstance) {
chartInstance.resize()
}
}
onMounted(() => {
nextTick(() => {
initChart()
})
window.addEventListener('resize', resizeChart)
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
window.removeEventListener('resize', resizeChart)
})
watch(() => props.data, () => {
updateChart()
}, { deep: true })
defineExpose({
getChartInstance: () => chartInstance,
resize: resizeChart
})
</script>
<style lang="scss" scoped>
.map-chart {
position: relative;
}
</style>

View File

@@ -0,0 +1,179 @@
<template>
<div class="pie-chart" :style="{ width: width, height: height }">
<div ref="chartRef" :style="{ width: '100%', height: '100%' }"></div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
data: {
type: Array,
default: () => []
},
title: {
type: String,
default: ''
},
width: {
type: String,
default: '100%'
},
height: {
type: String,
default: '400px'
},
color: {
type: Array,
default: () => ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#eb2f96', '#13c2c2', '#fa8c16']
},
radius: {
type: Array,
default: () => ['40%', '70%']
},
center: {
type: Array,
default: () => ['50%', '50%']
},
roseType: {
type: [String, Boolean],
default: false
},
showLabel: {
type: Boolean,
default: true
},
showLabelLine: {
type: Boolean,
default: true
}
})
const chartRef = ref(null)
let chartInstance = null
const initChart = () => {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value)
updateChart()
}
const updateChart = () => {
if (!chartInstance) return
const option = {
title: {
text: props.title,
left: 'center',
top: 20,
textStyle: {
fontSize: 16,
fontWeight: 'normal',
color: '#333'
}
},
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: '#ccc',
borderWidth: 1,
textStyle: {
color: '#333'
}
},
legend: {
orient: 'vertical',
left: 'left',
top: 'middle',
textStyle: {
color: '#666'
},
formatter: function(name) {
const item = props.data.find(d => d.name === name)
return item ? `${name}: ${item.value}` : name
}
},
color: props.color,
series: [
{
name: props.title || '数据统计',
type: 'pie',
radius: props.radius,
center: props.center,
roseType: props.roseType,
data: props.data,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
},
label: {
show: props.showLabel,
position: 'outside',
formatter: '{b}: {d}%',
fontSize: 12,
color: '#666'
},
labelLine: {
show: props.showLabelLine,
length: 15,
length2: 10,
lineStyle: {
color: '#ccc'
}
},
itemStyle: {
borderRadius: 8,
borderColor: '#fff',
borderWidth: 2
}
}
]
}
chartInstance.setOption(option, true)
}
const resizeChart = () => {
if (chartInstance) {
chartInstance.resize()
}
}
onMounted(() => {
nextTick(() => {
initChart()
})
window.addEventListener('resize', resizeChart)
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
window.removeEventListener('resize', resizeChart)
})
watch(() => props.data, () => {
updateChart()
}, { deep: true })
defineExpose({
getChartInstance: () => chartInstance,
resize: resizeChart
})
</script>
<style lang="scss" scoped>
.pie-chart {
position: relative;
}
</style>

View File

@@ -0,0 +1,22 @@
// 图表组件统一导出
import LineChart from './LineChart.vue'
import BarChart from './BarChart.vue'
import PieChart from './PieChart.vue'
import GaugeChart from './GaugeChart.vue'
import MapChart from './MapChart.vue'
export {
LineChart,
BarChart,
PieChart,
GaugeChart,
MapChart
}
export default {
LineChart,
BarChart,
PieChart,
GaugeChart,
MapChart
}

View File

@@ -0,0 +1,272 @@
<template>
<div class="data-table">
<div v-if="showToolbar" class="table-toolbar">
<div class="toolbar-left">
<slot name="toolbar-left">
<a-space>
<a-button
v-if="showAdd"
type="primary"
@click="$emit('add')"
>
<PlusOutlined />
{{ addText || '新增' }}
</a-button>
<a-button
v-if="showBatchDelete && selectedRowKeys.length > 0"
danger
@click="handleBatchDelete"
>
<DeleteOutlined />
批量删除
</a-button>
</a-space>
</slot>
</div>
<div class="toolbar-right">
<slot name="toolbar-right">
<a-space>
<a-tooltip title="刷新">
<a-button @click="$emit('refresh')">
<ReloadOutlined />
</a-button>
</a-tooltip>
<a-tooltip title="列设置">
<a-button @click="showColumnSetting = true">
<SettingOutlined />
</a-button>
</a-tooltip>
</a-space>
</slot>
</div>
</div>
<a-table
:columns="visibleColumns"
:data-source="dataSource"
:loading="loading"
:pagination="paginationConfig"
:row-selection="rowSelection"
:scroll="scroll"
:size="size"
@change="handleTableChange"
>
<template v-for="(_, name) in $slots" :key="name" #[name]="slotData">
<slot :name="name" v-bind="slotData"></slot>
</template>
</a-table>
<!-- 列设置弹窗 -->
<a-modal
v-model:open="showColumnSetting"
title="列设置"
@ok="handleColumnSettingOk"
>
<a-checkbox-group v-model:value="selectedColumns" class="column-setting">
<div v-for="column in columns" :key="column.key || column.dataIndex" class="column-item">
<a-checkbox :value="column.key || column.dataIndex">
{{ column.title }}
</a-checkbox>
</div>
</a-checkbox-group>
</a-modal>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { Modal, message } from 'ant-design-vue'
import {
PlusOutlined,
DeleteOutlined,
ReloadOutlined,
SettingOutlined
} from '@ant-design/icons-vue'
const props = defineProps({
columns: {
type: Array,
required: true
},
dataSource: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
pagination: {
type: [Object, Boolean],
default: () => ({})
},
showToolbar: {
type: Boolean,
default: true
},
showAdd: {
type: Boolean,
default: true
},
addText: {
type: String,
default: ''
},
showBatchDelete: {
type: Boolean,
default: true
},
rowKey: {
type: String,
default: 'id'
},
scroll: {
type: Object,
default: () => ({ x: 'max-content' })
},
size: {
type: String,
default: 'middle'
}
})
const emit = defineEmits([
'add',
'refresh',
'change',
'batchDelete',
'selectionChange'
])
const selectedRowKeys = ref([])
const showColumnSetting = ref(false)
const selectedColumns = ref([])
// 初始化选中的列
const initSelectedColumns = () => {
selectedColumns.value = props.columns
.filter(col => col.key || col.dataIndex)
.map(col => col.key || col.dataIndex)
}
// 可见的列
const visibleColumns = computed(() => {
return props.columns.filter(col => {
const key = col.key || col.dataIndex
return !key || selectedColumns.value.includes(key)
})
})
// 行选择配置
const rowSelection = computed(() => {
if (!props.showBatchDelete) return null
return {
selectedRowKeys: selectedRowKeys.value,
onChange: (keys, rows) => {
selectedRowKeys.value = keys
emit('selectionChange', keys, rows)
}
}
})
// 分页配置
const paginationConfig = computed(() => {
if (props.pagination === false) return false
return {
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => `${range[0]}-${range[1]} 条/共 ${total}`,
...props.pagination
}
})
// 表格变化处理
const handleTableChange = (pagination, filters, sorter) => {
emit('change', { pagination, filters, sorter })
}
// 批量删除
const handleBatchDelete = () => {
if (selectedRowKeys.value.length === 0) {
message.warning('请选择要删除的数据')
return
}
Modal.confirm({
title: '确认删除',
content: `确定要删除选中的 ${selectedRowKeys.value.length} 条数据吗?`,
onOk: () => {
emit('batchDelete', selectedRowKeys.value)
selectedRowKeys.value = []
}
})
}
// 列设置确认
const handleColumnSettingOk = () => {
showColumnSetting.value = false
message.success('列设置已保存')
}
// 监听列变化,重新初始化选中的列
watch(
() => props.columns,
() => {
initSelectedColumns()
},
{ immediate: true }
)
</script>
<style lang="scss" scoped>
.data-table {
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 16px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
}
}
:deep(.ant-table) {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.column-setting {
.column-item {
display: block;
margin-bottom: 8px;
padding: 4px 0;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.data-table {
.table-toolbar {
flex-direction: column;
gap: 12px;
align-items: stretch;
.toolbar-left,
.toolbar-right {
justify-content: center;
}
}
}
}
</style>

View File

@@ -0,0 +1,80 @@
<template>
<div class="empty-state">
<div class="empty-icon">
<component v-if="icon" :is="icon" />
<InboxOutlined v-else />
</div>
<div class="empty-title">{{ title || '暂无数据' }}</div>
<div v-if="description" class="empty-description">{{ description }}</div>
<div v-if="showAction" class="empty-action">
<a-button type="primary" @click="$emit('action')">
{{ actionText || '重新加载' }}
</a-button>
</div>
</div>
</template>
<script setup>
import { InboxOutlined } from '@ant-design/icons-vue'
defineProps({
icon: {
type: [String, Object],
default: null
},
title: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
showAction: {
type: Boolean,
default: false
},
actionText: {
type: String,
default: ''
}
})
defineEmits(['action'])
</script>
<style lang="scss" scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
.empty-icon {
font-size: 64px;
color: #d9d9d9;
margin-bottom: 16px;
}
.empty-title {
font-size: 16px;
color: #262626;
margin-bottom: 8px;
font-weight: 500;
}
.empty-description {
font-size: 14px;
color: #8c8c8c;
margin-bottom: 24px;
max-width: 300px;
line-height: 1.5;
}
.empty-action {
margin-top: 8px;
}
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<div class="loading-spinner" :class="{ 'full-screen': fullScreen }">
<div class="spinner-container">
<div class="spinner" :style="{ width: size + 'px', height: size + 'px' }">
<div class="spinner-inner"></div>
</div>
<div v-if="text" class="loading-text">{{ text }}</div>
</div>
</div>
</template>
<script setup>
defineProps({
size: {
type: Number,
default: 40
},
text: {
type: String,
default: ''
},
fullScreen: {
type: Boolean,
default: false
}
})
</script>
<style lang="scss" scoped>
.loading-spinner {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
&.full-screen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
z-index: 9999;
backdrop-filter: blur(2px);
}
.spinner-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
.spinner {
position: relative;
border-radius: 50%;
background: conic-gradient(from 0deg, #1890ff, #40a9ff, #69c0ff, #91d5ff, transparent);
animation: spin 1s linear infinite;
.spinner-inner {
position: absolute;
top: 2px;
left: 2px;
right: 2px;
bottom: 2px;
background: white;
border-radius: 50%;
}
}
.loading-text {
font-size: 14px;
color: #666;
text-align: center;
}
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="header-title">
<component v-if="icon" :is="icon" class="title-icon" />
<h1>{{ title }}</h1>
</div>
<div v-if="description" class="header-description">{{ description }}</div>
</div>
<div v-if="$slots.extra" class="header-extra">
<slot name="extra"></slot>
</div>
</div>
<div v-if="$slots.tabs" class="header-tabs">
<slot name="tabs"></slot>
</div>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
required: true
},
description: {
type: String,
default: ''
},
icon: {
type: [String, Object],
default: null
}
})
</script>
<style lang="scss" scoped>
.page-header {
background: white;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 24px;
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px;
.header-left {
flex: 1;
.header-title {
display: flex;
align-items: center;
margin-bottom: 8px;
.title-icon {
font-size: 24px;
color: #1890ff;
margin-right: 12px;
}
h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #262626;
}
}
.header-description {
font-size: 14px;
color: #8c8c8c;
line-height: 1.5;
}
}
.header-extra {
flex-shrink: 0;
margin-left: 24px;
}
}
.header-tabs {
padding: 0 24px;
border-top: 1px solid #f0f0f0;
}
}
// 响应式设计
@media (max-width: 768px) {
.page-header {
.header-content {
flex-direction: column;
align-items: flex-start;
gap: 16px;
.header-extra {
margin-left: 0;
width: 100%;
}
}
}
}
</style>

View File

@@ -0,0 +1,210 @@
<template>
<div class="search-form">
<a-form
:model="formData"
layout="inline"
@finish="handleSearch"
@reset="handleReset"
>
<template v-for="field in fields" :key="field.key">
<!-- 输入框 -->
<a-form-item
v-if="field.type === 'input'"
:label="field.label"
:name="field.key"
>
<a-input
v-model:value="formData[field.key]"
:placeholder="field.placeholder || `请输入${field.label}`"
:style="{ width: field.width || '200px' }"
allow-clear
/>
</a-form-item>
<!-- 选择框 -->
<a-form-item
v-else-if="field.type === 'select'"
:label="field.label"
:name="field.key"
>
<a-select
v-model:value="formData[field.key]"
:placeholder="field.placeholder || `请选择${field.label}`"
:style="{ width: field.width || '200px' }"
allow-clear
>
<a-select-option
v-for="option in field.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-select-option>
</a-select>
</a-form-item>
<!-- 日期选择 -->
<a-form-item
v-else-if="field.type === 'date'"
:label="field.label"
:name="field.key"
>
<a-date-picker
v-model:value="formData[field.key]"
:placeholder="field.placeholder || `请选择${field.label}`"
:style="{ width: field.width || '200px' }"
format="YYYY-MM-DD"
/>
</a-form-item>
<!-- 日期范围选择 -->
<a-form-item
v-else-if="field.type === 'dateRange'"
:label="field.label"
:name="field.key"
>
<a-range-picker
v-model:value="formData[field.key]"
:placeholder="field.placeholder || ['开始日期', '结束日期']"
:style="{ width: field.width || '300px' }"
format="YYYY-MM-DD"
/>
</a-form-item>
</template>
<a-form-item>
<a-space>
<a-button type="primary" html-type="submit" :loading="loading">
<SearchOutlined />
搜索
</a-button>
<a-button html-type="reset">
<ReloadOutlined />
重置
</a-button>
<a-button
v-if="showToggle && fields.length > 3"
type="link"
@click="toggleExpanded"
>
{{ expanded ? '收起' : '展开' }}
<UpOutlined v-if="expanded" />
<DownOutlined v-else />
</a-button>
</a-space>
</a-form-item>
</a-form>
</div>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { SearchOutlined, ReloadOutlined, UpOutlined, DownOutlined } from '@ant-design/icons-vue'
const props = defineProps({
fields: {
type: Array,
required: true
},
loading: {
type: Boolean,
default: false
},
showToggle: {
type: Boolean,
default: true
},
initialValues: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['search', 'reset'])
const expanded = ref(false)
const formData = reactive({})
// 初始化表单数据
const initFormData = () => {
props.fields.forEach(field => {
formData[field.key] = props.initialValues[field.key] || field.defaultValue || undefined
})
}
const handleSearch = () => {
const searchData = { ...formData }
// 过滤空值
Object.keys(searchData).forEach(key => {
if (searchData[key] === undefined || searchData[key] === null || searchData[key] === '') {
delete searchData[key]
}
})
emit('search', searchData)
}
const handleReset = () => {
props.fields.forEach(field => {
formData[field.key] = field.defaultValue || undefined
})
emit('reset')
}
const toggleExpanded = () => {
expanded.value = !expanded.value
}
// 监听初始值变化
watch(
() => props.initialValues,
() => {
initFormData()
},
{ immediate: true, deep: true }
)
// 监听字段变化
watch(
() => props.fields,
() => {
initFormData()
},
{ immediate: true }
)
</script>
<style lang="scss" scoped>
.search-form {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 16px;
:deep(.ant-form-item) {
margin-bottom: 16px;
}
:deep(.ant-form-item-label) {
font-weight: 500;
}
}
// 响应式设计
@media (max-width: 768px) {
.search-form {
padding: 16px;
:deep(.ant-form) {
.ant-form-item {
display: block;
margin-bottom: 16px;
.ant-form-item-control-input {
width: 100% !important;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,551 @@
<template>
<div class="tabs-view">
<!-- 标签页导航 -->
<div class="tabs-nav" ref="tabsNavRef">
<div class="tabs-nav-scroll" :style="{ transform: `translateX(${scrollOffset}px)` }">
<div
v-for="tab in tabsStore.openTabs"
:key="tab.path"
:class="[
'tab-item',
{ 'active': tab.active },
{ 'closable': tab.closable }
]"
@click="handleTabClick(tab)"
@contextmenu.prevent="handleTabContextMenu(tab, $event)"
>
<span class="tab-title">{{ tab.title }}</span>
<CloseOutlined
v-if="tab.closable"
class="tab-close"
@click.stop="handleTabClose(tab)"
/>
</div>
</div>
<!-- 滚动控制按钮 -->
<div class="tabs-nav-controls">
<LeftOutlined
:class="['nav-btn', { 'disabled': scrollOffset >= 0 }]"
@click="scrollTabs('left')"
/>
<RightOutlined
:class="['nav-btn', { 'disabled': scrollOffset <= maxScrollOffset }]"
@click="scrollTabs('right')"
/>
<MoreOutlined
class="nav-btn"
@click="showTabsMenu"
/>
</div>
</div>
<!-- 标签页内容 -->
<div class="tabs-content">
<router-view v-slot="{ Component, route }">
<keep-alive :include="tabsStore.cachedViews">
<component
:is="Component"
:key="route.path"
v-if="Component"
/>
</keep-alive>
</router-view>
</div>
<!-- 右键菜单 -->
<div
v-if="contextMenu.visible"
:class="['context-menu']"
:style="{
left: contextMenu.x + 'px',
top: contextMenu.y + 'px'
}"
@click.stop
>
<div class="menu-item" @click="refreshTab(contextMenu.tab)">
<ReloadOutlined />
刷新页面
</div>
<div
v-if="contextMenu.tab.closable"
class="menu-item"
@click="closeTab(contextMenu.tab)"
>
<CloseOutlined />
关闭标签
</div>
<div class="menu-divider"></div>
<div class="menu-item" @click="closeOtherTabs(contextMenu.tab)">
<CloseCircleOutlined />
关闭其他
</div>
<div class="menu-item" @click="closeLeftTabs(contextMenu.tab)">
<VerticalLeftOutlined />
关闭左侧
</div>
<div class="menu-item" @click="closeRightTabs(contextMenu.tab)">
<VerticalRightOutlined />
关闭右侧
</div>
<div class="menu-item" @click="closeAllTabs">
<CloseSquareOutlined />
关闭全部
</div>
</div>
<!-- 标签页列表菜单 -->
<a-dropdown
v-model:open="tabsMenuVisible"
:trigger="['click']"
placement="bottomRight"
>
<template #overlay>
<a-menu>
<a-menu-item
v-for="tab in tabsStore.openTabs"
:key="tab.path"
@click="handleTabClick(tab)"
>
<span :class="{ 'active-tab': tab.active }">
{{ tab.title }}
</span>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<!-- 遮罩层用于关闭右键菜单 -->
<div
v-if="contextMenu.visible"
class="context-menu-overlay"
@click="hideContextMenu"
></div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useTabsStore } from '@/stores/tabs'
import {
CloseOutlined,
LeftOutlined,
RightOutlined,
MoreOutlined,
ReloadOutlined,
CloseCircleOutlined,
VerticalLeftOutlined,
VerticalRightOutlined,
CloseSquareOutlined
} from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
const tabsStore = useTabsStore()
// 标签页导航引用
const tabsNavRef = ref(null)
// 滚动偏移量
const scrollOffset = ref(0)
// 最大滚动偏移量
const maxScrollOffset = computed(() => {
if (!tabsNavRef.value) return 0
const navWidth = tabsNavRef.value.clientWidth - 120 // 减去控制按钮宽度
const scrollWidth = tabsNavRef.value.querySelector('.tabs-nav-scroll')?.scrollWidth || 0
return Math.min(0, navWidth - scrollWidth)
})
// 右键菜单状态
const contextMenu = reactive({
visible: false,
x: 0,
y: 0,
tab: null
})
// 标签页菜单显示状态
const tabsMenuVisible = ref(false)
/**
* 处理标签页点击
*/
const handleTabClick = (tab) => {
if (tab.path !== route.path) {
router.push(tab.path)
}
tabsStore.setActiveTab(tab.path)
}
/**
* 处理标签页关闭
*/
const handleTabClose = (tab) => {
if (!tab.closable) return
tabsStore.removeTab(tab.path)
// 如果关闭的是当前标签页,跳转到其他标签页
if (tab.active && tabsStore.openTabs.length > 0) {
const activeTab = tabsStore.openTabs.find(t => t.active)
if (activeTab) {
router.push(activeTab.path)
}
}
}
/**
* 处理标签页右键菜单
*/
const handleTabContextMenu = (tab, event) => {
contextMenu.visible = true
contextMenu.x = event.clientX
contextMenu.y = event.clientY
contextMenu.tab = tab
}
/**
* 隐藏右键菜单
*/
const hideContextMenu = () => {
contextMenu.visible = false
contextMenu.tab = null
}
/**
* 刷新标签页
*/
const refreshTab = (tab) => {
tabsStore.refreshTab(tab.path)
hideContextMenu()
}
/**
* 关闭标签页
*/
const closeTab = (tab) => {
handleTabClose(tab)
hideContextMenu()
}
/**
* 关闭其他标签页
*/
const closeOtherTabs = (tab) => {
tabsStore.closeOtherTabs(tab.path)
if (tab.path !== route.path) {
router.push(tab.path)
}
hideContextMenu()
}
/**
* 关闭左侧标签页
*/
const closeLeftTabs = (tab) => {
tabsStore.closeLeftTabs(tab.path)
hideContextMenu()
}
/**
* 关闭右侧标签页
*/
const closeRightTabs = (tab) => {
tabsStore.closeRightTabs(tab.path)
hideContextMenu()
}
/**
* 关闭所有标签页
*/
const closeAllTabs = () => {
tabsStore.closeAllTabs()
const activeTab = tabsStore.openTabs[0]
if (activeTab && activeTab.path !== route.path) {
router.push(activeTab.path)
}
hideContextMenu()
}
/**
* 滚动标签页
*/
const scrollTabs = (direction) => {
const step = 200
if (direction === 'left') {
scrollOffset.value = Math.min(0, scrollOffset.value + step)
} else {
scrollOffset.value = Math.max(maxScrollOffset.value, scrollOffset.value - step)
}
}
/**
* 显示标签页菜单
*/
const showTabsMenu = () => {
tabsMenuVisible.value = true
}
/**
* 监听路由变化,添加标签页
*/
const addCurrentRouteTab = () => {
const { path, meta, name } = route
if (meta.hidden) return
const tab = {
path,
title: meta.title || name || '未命名页面',
name: name,
closable: meta.closable !== false
}
tabsStore.addTab(tab)
}
/**
* 监听点击事件,关闭右键菜单
*/
const handleDocumentClick = () => {
if (contextMenu.visible) {
hideContextMenu()
}
}
/**
* 监听窗口大小变化,调整滚动偏移量
*/
const handleWindowResize = () => {
nextTick(() => {
if (scrollOffset.value < maxScrollOffset.value) {
scrollOffset.value = maxScrollOffset.value
}
})
}
onMounted(() => {
// 添加当前路由标签页
addCurrentRouteTab()
// 监听文档点击事件
document.addEventListener('click', handleDocumentClick)
// 监听窗口大小变化
window.addEventListener('resize', handleWindowResize)
})
onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick)
window.removeEventListener('resize', handleWindowResize)
})
// 监听路由变化
router.afterEach((to) => {
if (!to.meta.hidden) {
const tab = {
path: to.path,
title: to.meta.title || to.name || '未命名页面',
name: to.name,
closable: to.meta.closable !== false
}
tabsStore.addTab(tab)
}
})
</script>
<style lang="scss" scoped>
.tabs-view {
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
}
.tabs-nav {
display: flex;
align-items: center;
height: 40px;
border-bottom: 1px solid #e8e8e8;
background: #fafafa;
position: relative;
overflow: hidden;
.tabs-nav-scroll {
display: flex;
align-items: center;
height: 100%;
transition: transform 0.3s ease;
white-space: nowrap;
}
.tab-item {
display: inline-flex;
align-items: center;
height: 32px;
padding: 0 16px;
margin: 4px 2px;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 4px;
cursor: pointer;
user-select: none;
transition: all 0.2s ease;
min-width: 80px;
max-width: 200px;
&:hover {
background: #f0f0f0;
border-color: #40a9ff;
}
&.active {
background: #1890ff;
border-color: #1890ff;
color: #fff;
.tab-close {
color: #fff;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
}
.tab-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.tab-close {
margin-left: 8px;
padding: 2px;
border-radius: 2px;
font-size: 10px;
transition: all 0.2s ease;
&:hover {
background: rgba(0, 0, 0, 0.1);
}
}
}
}
.tabs-nav-controls {
display: flex;
align-items: center;
height: 100%;
padding: 0 8px;
border-left: 1px solid #e8e8e8;
background: #fafafa;
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin: 0 2px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
color: #666;
transition: all 0.2s ease;
&:hover:not(.disabled) {
background: #e6f7ff;
color: #1890ff;
}
&.disabled {
color: #ccc;
cursor: not-allowed;
}
}
}
.tabs-content {
flex: 1;
overflow: hidden;
background: #fff;
}
.context-menu {
position: fixed;
z-index: 1000;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
min-width: 120px;
.menu-item {
display: flex;
align-items: center;
padding: 8px 12px;
font-size: 12px;
cursor: pointer;
transition: background 0.2s ease;
&:hover {
background: #f0f0f0;
}
.anticon {
margin-right: 8px;
font-size: 12px;
}
}
.menu-divider {
height: 1px;
background: #e8e8e8;
margin: 4px 0;
}
}
.context-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999;
}
.active-tab {
color: #1890ff;
font-weight: 500;
}
// 响应式设计
@media (max-width: 768px) {
.tabs-nav {
.tab-item {
min-width: 60px;
max-width: 120px;
padding: 0 8px;
.tab-title {
font-size: 11px;
}
}
}
.context-menu {
min-width: 100px;
.menu-item {
padding: 6px 8px;
font-size: 11px;
}
}
}
</style>

View File

@@ -0,0 +1,312 @@
<template>
<div class="sidebar-menu">
<a-menu
v-model:selectedKeys="selectedKeys"
v-model:openKeys="openKeys"
mode="inline"
theme="dark"
:inline-collapsed="collapsed"
@click="handleMenuClick"
>
<template v-for="item in menuItems" :key="item.key">
<a-menu-item
v-if="!item.children"
:key="item.key"
:disabled="item.disabled"
>
<template #icon>
<component :is="item.icon" />
</template>
<span>{{ item.title }}</span>
</a-menu-item>
<a-sub-menu
v-else
:disabled="item.disabled"
>
<template #icon>
<component :is="item.icon" />
</template>
<template #title>{{ item.title }}</template>
<!-- :key="item.key" -->
<a-menu-item
v-for="child in item.children"
:key="child.key"
:disabled="child.disabled"
>
<template #icon>
<component :is="child.icon" />
</template>
<span>{{ child.title }}</span>
</a-menu-item>
</a-sub-menu>
</template>
</a-menu>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
DashboardOutlined,
HomeOutlined,
MonitorOutlined,
AuditOutlined,
LinkOutlined,
AlertOutlined,
FileTextOutlined,
BarChartOutlined,
SettingOutlined,
SafetyOutlined,
TeamOutlined,
DatabaseOutlined,
KeyOutlined,
SolutionOutlined,
MedicineBoxOutlined,
CustomerServiceOutlined,
EyeOutlined
} from '@ant-design/icons-vue'
const props = defineProps({
collapsed: {
type: Boolean,
default: false
}
})
const router = useRouter()
const route = useRoute()
const selectedKeys = ref([])
const openKeys = ref([])
// 菜单配置
const menuItems = computed(() => [
{
key: '/dashboard',
title: '仪表盘',
icon: DashboardOutlined,
path: '/dashboard'
},
{
key: '/breeding',
title: '养殖管理',
icon: HomeOutlined,
children: [
{
key: '/breeding/farms',
title: '养殖场管理',
icon: HomeOutlined,
path: '/breeding/farms'
}
]
},
{
key: '/monitoring',
title: '健康监控',
icon: MonitorOutlined,
children: [
{
key: '/monitoring/health',
title: '动物健康监控',
icon: SafetyOutlined,
path: '/monitoring/health'
}
]
},
{
key: '/inspection',
title: '检查管理',
icon: AuditOutlined,
children: [
{
key: '/inspection/management',
title: '检查管理',
icon: AuditOutlined,
path: '/inspection/management'
}
]
},
{
key: '/traceability',
title: '溯源系统',
icon: LinkOutlined,
children: [
{
key: '/traceability/system',
title: '产品溯源',
icon: LinkOutlined,
path: '/traceability/system'
}
]
},
{
key: '/emergency',
title: '应急响应',
icon: AlertOutlined,
children: [
{
key: '/emergency/response',
title: '应急响应',
icon: AlertOutlined,
path: '/emergency/response'
}
]
},
{
key: '/policy',
title: '政策管理',
icon: FileTextOutlined,
children: [
{
key: '/policy/management',
title: '政策管理',
icon: FileTextOutlined,
path: '/policy/management'
}
]
},
{
key: '/statistics',
title: '数据统计',
icon: BarChartOutlined,
children: [
{
key: '/statistics/data',
title: '数据统计',
icon: BarChartOutlined,
path: '/statistics/data'
}
]
},
{
key: '/reports',
title: '报表中心',
icon: FileTextOutlined,
children: [
{
key: '/reports/center',
title: '报表中心',
icon: FileTextOutlined,
path: '/reports/center'
}
]
},
{
key: '/settings',
title: '系统设置',
icon: SettingOutlined,
children: [
{
key: '/settings/system',
title: '系统设置',
icon: SettingOutlined,
path: '/settings/system'
}
]
}
])
// 处理菜单点击
const handleMenuClick = ({ key }) => {
const findMenuItem = (items, targetKey) => {
for (const item of items) {
if (item.key === targetKey) {
return item
}
if (item.children) {
const found = findMenuItem(item.children, targetKey)
if (found) return found
}
}
return null
}
const menuItem = findMenuItem(menuItems.value, key)
if (menuItem && menuItem.path) {
router.push(menuItem.path)
}
}
// 根据当前路由设置选中状态
const updateSelectedKeys = () => {
const currentPath = route.path
selectedKeys.value = [currentPath]
// 自动展开父级菜单
const findParentKey = (items, targetPath, parentKey = null) => {
for (const item of items) {
if (item.children) {
for (const child of item.children) {
if (child.path === targetPath) {
return item.key
}
}
const found = findParentKey(item.children, targetPath, item.key)
if (found) return found
}
}
return parentKey
}
const parentKey = findParentKey(menuItems.value, currentPath)
if (parentKey && !openKeys.value.includes(parentKey)) {
openKeys.value = [...openKeys.value, parentKey]
}
}
// 监听路由变化
watch(route, updateSelectedKeys, { immediate: true })
// 监听折叠状态变化
watch(() => props.collapsed, (collapsed) => {
if (collapsed) {
openKeys.value = []
} else {
updateSelectedKeys()
}
})
</script>
<style lang="scss" scoped>
.sidebar-menu {
height: 100%;
:deep(.ant-menu) {
border-right: none;
.ant-menu-item,
.ant-menu-submenu-title {
height: 48px;
line-height: 48px;
margin: 0;
border-radius: 0;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
.ant-menu-item-selected {
background-color: #1890ff !important;
&::after {
display: none;
}
}
.ant-menu-submenu-selected {
.ant-menu-submenu-title {
background-color: rgba(255, 255, 255, 0.1);
}
}
.ant-menu-item-icon,
.ant-menu-submenu-title .ant-menu-item-icon {
font-size: 16px;
}
}
}
</style>

View File

@@ -0,0 +1,271 @@
<template>
<div class="tabs-view">
<a-tabs
v-model:activeKey="activeKey"
type="editable-card"
hide-add
@edit="onEdit"
@change="onChange"
>
<a-tab-pane
v-for="tab in tabs"
:key="tab.key"
:tab="tab.title"
:closable="tab.closable"
>
<template #tab>
<span class="tab-title">
<component v-if="tab.icon" :is="tab.icon" class="tab-icon" />
{{ tab.title }}
</span>
</template>
</a-tab-pane>
</a-tabs>
<!-- 右键菜单 -->
<a-dropdown
v-model:open="contextMenuVisible"
:trigger="['contextmenu']"
placement="bottomLeft"
>
<div ref="contextMenuTarget" class="context-menu-target"></div>
<template #overlay>
<a-menu @click="handleContextMenu">
<a-menu-item key="refresh">
<ReloadOutlined />
刷新页面
</a-menu-item>
<a-menu-divider />
<a-menu-item key="close">
<CloseOutlined />
关闭标签
</a-menu-item>
<a-menu-item key="closeOthers">
<CloseCircleOutlined />
关闭其他
</a-menu-item>
<a-menu-item key="closeAll">
<CloseSquareOutlined />
关闭全部
</a-menu-item>
<a-menu-divider />
<a-menu-item key="closeLeft">
<VerticalLeftOutlined />
关闭左侧
</a-menu-item>
<a-menu-item key="closeRight">
<VerticalRightOutlined />
关闭右侧
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useTabsStore } from '@/stores/tabs'
import {
ReloadOutlined,
CloseOutlined,
CloseCircleOutlined,
CloseSquareOutlined,
VerticalLeftOutlined,
VerticalRightOutlined
} from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
const tabsStore = useTabsStore()
const activeKey = ref('')
const contextMenuVisible = ref(false)
const contextMenuTarget = ref(null)
const currentContextTab = ref(null)
// 标签页列表
const tabs = computed(() => tabsStore.tabs)
// 处理标签页变化
const onChange = (key) => {
const tab = tabs.value.find(t => t.key === key)
if (tab) {
router.push(tab.path)
}
}
// 处理标签页编辑(关闭)
const onEdit = (targetKey, action) => {
if (action === 'remove') {
closeTab(targetKey)
}
}
// 关闭标签页
const closeTab = (key) => {
const tab = tabs.value.find(t => t.key === key)
if (tab && tab.closable) {
tabsStore.removeTab(key)
// 如果关闭的是当前标签,跳转到最后一个标签
if (key === activeKey.value && tabs.value.length > 0) {
const lastTab = tabs.value[tabs.value.length - 1]
router.push(lastTab.path)
}
}
}
// 右键菜单处理
const handleContextMenu = ({ key }) => {
const currentTab = currentContextTab.value
if (!currentTab) return
switch (key) {
case 'refresh':
// 刷新当前页面
router.go(0)
break
case 'close':
closeTab(currentTab.key)
break
case 'closeOthers':
tabsStore.closeOtherTabs(currentTab.key)
break
case 'closeAll':
tabsStore.closeAllTabs()
router.push('/dashboard')
break
case 'closeLeft':
tabsStore.closeLeftTabs(currentTab.key)
break
case 'closeRight':
tabsStore.closeRightTabs(currentTab.key)
break
}
contextMenuVisible.value = false
}
// 监听路由变化,添加标签页
watch(route, (newRoute) => {
if (newRoute.meta && newRoute.meta.title) {
const tab = {
key: newRoute.path,
path: newRoute.path,
title: newRoute.meta.title,
icon: newRoute.meta.icon,
closable: !newRoute.meta.affix
}
tabsStore.addTab(tab)
activeKey.value = newRoute.path
}
}, { immediate: true })
// 监听标签页变化
watch(tabs, (newTabs) => {
if (newTabs.length === 0) {
router.push('/dashboard')
}
}, { deep: true })
// 添加右键菜单事件监听
const addContextMenuListener = () => {
nextTick(() => {
const tabsContainer = document.querySelector('.ant-tabs-nav')
if (tabsContainer) {
tabsContainer.addEventListener('contextmenu', (e) => {
e.preventDefault()
// 查找被右键点击的标签
const tabElement = e.target.closest('.ant-tabs-tab')
if (tabElement) {
const tabKey = tabElement.getAttribute('data-node-key')
const tab = tabs.value.find(t => t.key === tabKey)
if (tab) {
currentContextTab.value = tab
contextMenuVisible.value = true
}
}
})
}
})
}
// 组件挂载后添加事件监听
watch(tabs, addContextMenuListener, { immediate: true })
</script>
<style lang="scss" scoped>
.tabs-view {
background: white;
border-bottom: 1px solid #f0f0f0;
:deep(.ant-tabs) {
.ant-tabs-nav {
margin: 0;
padding: 0 16px;
.ant-tabs-nav-wrap {
.ant-tabs-nav-list {
.ant-tabs-tab {
border: none;
background: transparent;
margin-right: 4px;
padding: 8px 16px;
border-radius: 4px 4px 0 0;
transition: all 0.3s;
&:hover {
background: #f5f5f5;
}
&.ant-tabs-tab-active {
background: #e6f7ff;
color: #1890ff;
.tab-title {
color: #1890ff;
}
}
.tab-title {
display: flex;
align-items: center;
gap: 6px;
.tab-icon {
font-size: 14px;
}
}
.ant-tabs-tab-remove {
margin-left: 8px;
color: #999;
&:hover {
color: #ff4d4f;
}
}
}
}
}
}
.ant-tabs-content-holder {
display: none;
}
}
}
.context-menu-target {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,880 @@
<template>
<div class="government-layout">
<!-- 顶部导航栏 -->
<header class="layout-header">
<div class="header-left">
<div class="logo">
<img src="/logo.svg" alt="政府管理后台" />
<span class="logo-text">政府管理后台</span>
</div>
<div class="header-menu">
<a-menu
v-model:selectedKeys="selectedMenuKeys"
mode="horizontal"
:items="headerMenuItems"
@click="handleHeaderMenuClick"
/>
</div>
</div>
<div class="header-right">
<!-- 通知中心 -->
<a-dropdown :trigger="['click']" placement="bottomRight">
<div class="header-action">
<a-badge :count="notificationStore.unreadCount" :offset="[10, 0]">
<BellOutlined />
</a-badge>
</div>
<template #overlay>
<div class="notification-dropdown">
<div class="notification-header">
<span>通知中心</span>
<a @click="notificationStore.markAllAsRead()">全部已读</a>
</div>
<div class="notification-list">
<div
v-for="notification in notificationStore.recentNotifications.slice(0, 5)"
:key="notification.id"
:class="['notification-item', { 'unread': !notification.read }]"
@click="handleNotificationClick(notification)"
>
<div class="notification-content">
<div class="notification-title">{{ notification.title }}</div>
<div class="notification-desc">{{ notification.content }}</div>
<div class="notification-time">{{ formatTime(notification.timestamp) }}</div>
</div>
</div>
</div>
<div class="notification-footer">
<a @click="$router.push('/notifications')">查看全部</a>
</div>
</div>
</template>
</a-dropdown>
<!-- 用户信息 -->
<a-dropdown :trigger="['click']" placement="bottomRight">
<div class="header-action user-info">
<a-avatar :src="userStore.userInfo.avatar" :size="32">
{{ userStore.userInfo.name?.charAt(0) }}
</a-avatar>
<span class="user-name">{{ userStore.userInfo.name }}</span>
<DownOutlined />
</div>
<template #overlay>
<a-menu @click="handleUserMenuClick">
<a-menu-item key="profile">
<UserOutlined />
个人中心
</a-menu-item>
<a-menu-item key="settings">
<SettingOutlined />
系统设置
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout">
<LogoutOutlined />
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</header>
<!-- 主体内容区域 -->
<div class="layout-content">
<!-- 侧边栏 -->
<aside :class="['layout-sider', { 'collapsed': siderCollapsed }]">
<div class="sider-trigger" @click="toggleSider">
<MenuUnfoldOutlined v-if="siderCollapsed" />
<MenuFoldOutlined v-else />
</div>
<a-menu
v-model:selectedKeys="selectedSiderKeys"
v-model:openKeys="openKeys"
mode="inline"
:inline-collapsed="siderCollapsed"
:items="siderMenuItems"
@click="handleSiderMenuClick"
/>
</aside>
<!-- 右侧内容区域 -->
<main class="layout-main">
<!-- 面包屑导航 -->
<div class="breadcrumb-container">
<a-breadcrumb>
<a-breadcrumb-item v-for="item in breadcrumbItems" :key="item.path">
<router-link v-if="item.path && item.path !== $route.path" :to="item.path">
{{ item.title }}
</router-link>
<span v-else>{{ item.title }}</span>
</a-breadcrumb-item>
</a-breadcrumb>
</div>
<!-- 标签页视图 -->
<TabsView />
</main>
</div>
<!-- 通知抽屉 -->
<a-drawer
v-model:open="notificationDrawerVisible"
title="系统通知"
placement="right"
:width="400"
>
<div class="notification-drawer">
<div class="notification-filters">
<a-radio-group v-model:value="notificationFilter" size="small">
<a-radio-button value="all">全部</a-radio-button>
<a-radio-button value="unread">未读</a-radio-button>
<a-radio-button value="system">系统</a-radio-button>
<a-radio-button value="task">任务</a-radio-button>
</a-radio-group>
</div>
<div class="notification-list">
<div
v-for="notification in filteredNotifications"
:key="notification.id"
:class="['notification-item', { 'unread': !notification.read }]"
>
<div class="notification-header">
<span class="notification-title">{{ notification.title }}</span>
<span class="notification-time">{{ formatTime(notification.timestamp) }}</span>
</div>
<div class="notification-content">{{ notification.content }}</div>
<div class="notification-actions">
<a-button
v-if="!notification.read"
type="link"
size="small"
@click="notificationStore.markAsRead(notification.id)"
>
标记已读
</a-button>
<a-button
type="link"
size="small"
@click="notificationStore.removeNotification(notification.id)"
>
删除
</a-button>
</div>
</div>
</div>
</div>
</a-drawer>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
BellOutlined,
UserOutlined,
SettingOutlined,
LogoutOutlined,
DownOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { useUserStore } from '@/stores/user'
import { useAppStore } from '@/stores/app'
import { useTabsStore } from '@/stores/tabs'
import { useNotificationStore } from '@/stores/notification'
import TabsView from '@/components/layout/TabsView.vue'
import { hasPermission } from '@/utils/permission'
const router = useRouter()
const route = useRoute()
// Store
const userStore = useUserStore()
const appStore = useAppStore()
const tabsStore = useTabsStore()
const notificationStore = useNotificationStore()
// 响应式数据
const siderCollapsed = ref(false)
const selectedMenuKeys = ref([])
const selectedSiderKeys = ref([])
const openKeys = ref([])
const notificationDrawerVisible = ref(false)
const notificationFilter = ref('all')
// 顶部菜单项
const headerMenuItems = computed(() => [
{
key: 'dashboard',
label: '工作台',
onClick: () => router.push('/dashboard')
},
{
key: 'supervision',
label: '政府监管',
onClick: () => router.push('/supervision')
},
{
key: 'approval',
label: '审批管理',
onClick: () => router.push('/approval')
},
{
key: 'visualization',
label: '可视化大屏',
onClick: () => router.push('/visualization')
}
])
// 侧边栏菜单项
const siderMenuItems = computed(() => {
const menuItems = [
{
key: '/dashboard',
icon: 'DashboardOutlined',
label: '工作台',
permission: 'dashboard:view'
},
{
key: '/supervision',
icon: 'EyeOutlined',
label: '政府监管',
permission: 'supervision:view',
children: [
{
key: '/supervision/enterprise',
label: '企业监管',
permission: 'supervision:enterprise:view'
},
{
key: '/supervision/environment',
label: '环境监管',
permission: 'supervision:environment:view'
},
{
key: '/supervision/safety',
label: '安全监管',
permission: 'supervision:safety:view'
}
]
},
{
key: '/approval',
icon: 'AuditOutlined',
label: '审批管理',
permission: 'approval:view',
children: [
{
key: '/approval/business',
label: '营业执照',
permission: 'approval:business:view'
},
{
key: '/approval/construction',
label: '建设工程',
permission: 'approval:construction:view'
},
{
key: '/approval/environmental',
label: '环保审批',
permission: 'approval:environmental:view'
}
]
},
{
key: '/personnel',
icon: 'TeamOutlined',
label: '人员管理',
permission: 'personnel:view',
children: [
{
key: '/personnel/staff',
label: '员工管理',
permission: 'personnel:staff:view'
},
{
key: '/personnel/department',
label: '部门管理',
permission: 'personnel:department:view'
},
{
key: '/personnel/role',
label: '角色管理',
permission: 'personnel:role:view'
}
]
},
{
key: '/warehouse',
icon: 'InboxOutlined',
label: '设备仓库',
permission: 'warehouse:view',
children: [
{
key: '/warehouse/equipment',
label: '设备管理',
permission: 'warehouse:equipment:view'
},
{
key: '/warehouse/inventory',
label: '库存管理',
permission: 'warehouse:inventory:view'
},
{
key: '/warehouse/maintenance',
label: '维护记录',
permission: 'warehouse:maintenance:view'
}
]
},
{
key: '/epidemic',
icon: 'SafetyOutlined',
label: '防疫管理',
permission: 'epidemic:view',
children: [
{
key: '/epidemic/monitoring',
label: '疫情监控',
permission: 'epidemic:monitoring:view'
},
{
key: '/epidemic/prevention',
label: '防控措施',
permission: 'epidemic:prevention:view'
},
{
key: '/epidemic/statistics',
label: '统计报告',
permission: 'epidemic:statistics:view'
}
]
},
{
key: '/service',
icon: 'CustomerServiceOutlined',
label: '服务管理',
permission: 'service:view',
children: [
{
key: '/service/public',
label: '公共服务',
permission: 'service:public:view'
},
{
key: '/service/online',
label: '在线办事',
permission: 'service:online:view'
},
{
key: '/service/feedback',
label: '意见反馈',
permission: 'service:feedback:view'
}
]
},
{
key: '/visualization',
icon: 'BarChartOutlined',
label: '可视化大屏',
permission: 'visualization:view'
},
{
key: '/system',
icon: 'SettingOutlined',
label: '系统管理',
permission: 'system:view',
children: [
{
key: '/system/user',
label: '用户管理',
permission: 'system:user:view'
},
{
key: '/system/role',
label: '角色管理',
permission: 'system:role:view'
},
{
key: '/system/permission',
label: '权限管理',
permission: 'system:permission:view'
},
{
key: '/system/config',
label: '系统配置',
permission: 'system:config:view'
},
{
key: '/system/log',
label: '操作日志',
permission: 'system:log:view'
}
]
}
]
// 根据权限过滤菜单项
return filterMenuByPermission(menuItems)
})
// 面包屑导航
const breadcrumbItems = computed(() => {
const matched = route.matched.filter(item => item.meta && item.meta.title)
return matched.map(item => ({
path: item.path,
title: item.meta.title
}))
})
// 过滤后的通知列表
const filteredNotifications = computed(() => {
let notifications = notificationStore.notifications
switch (notificationFilter.value) {
case 'unread':
notifications = notifications.filter(n => !n.read)
break
case 'system':
notifications = notifications.filter(n => n.type === 'system')
break
case 'task':
notifications = notifications.filter(n => n.type === 'task')
break
}
return notifications
})
// 方法
const toggleSider = () => {
siderCollapsed.value = !siderCollapsed.value
appStore.setSiderCollapsed(siderCollapsed.value)
}
const handleHeaderMenuClick = ({ key }) => {
selectedMenuKeys.value = [key]
}
const handleSiderMenuClick = ({ key }) => {
selectedSiderKeys.value = [key]
router.push(key)
// 添加到标签页
const route = router.resolve(key)
if (route.meta?.title) {
tabsStore.addTab({
path: key,
name: route.name,
title: route.meta.title,
closable: key !== '/dashboard'
})
}
}
const handleUserMenuClick = ({ key }) => {
switch (key) {
case 'profile':
router.push('/profile')
break
case 'settings':
router.push('/settings')
break
case 'logout':
handleLogout()
break
}
}
const handleNotificationClick = (notification) => {
if (!notification.read) {
notificationStore.markAsRead(notification.id)
}
// 如果通知有关联的路由,跳转到对应页面
if (notification.route) {
router.push(notification.route)
}
}
const handleLogout = async () => {
try {
await userStore.logout()
message.success('退出登录成功')
router.push('/login')
} catch (error) {
message.error('退出登录失败')
}
}
const formatTime = (timestamp) => {
const date = new Date(timestamp)
const now = new Date()
const diff = now - date
if (diff < 60000) { // 1分钟内
return '刚刚'
} else if (diff < 3600000) { // 1小时内
return `${Math.floor(diff / 60000)}分钟前`
} else if (diff < 86400000) { // 1天内
return `${Math.floor(diff / 3600000)}小时前`
} else {
return date.toLocaleDateString()
}
}
const filterMenuByPermission = (menuItems) => {
return menuItems.filter(item => {
// 检查当前菜单项权限
if (item.permission && !hasPermission(item.permission)) {
return false
}
// 递归过滤子菜单
if (item.children) {
item.children = filterMenuByPermission(item.children)
// 如果子菜单全部被过滤掉,则隐藏父菜单
return item.children.length > 0
}
return true
})
}
// 监听路由变化
watch(route, (newRoute) => {
selectedSiderKeys.value = [newRoute.path]
// 更新面包屑
const matched = newRoute.matched.filter(item => item.meta && item.meta.title)
if (matched.length > 0) {
// 自动展开对应的菜单
const parentPath = matched[matched.length - 2]?.path
if (parentPath && !openKeys.value.includes(parentPath)) {
openKeys.value.push(parentPath)
}
}
}, { immediate: true })
// 初始化
onMounted(() => {
// 从store恢复状态
siderCollapsed.value = appStore.siderCollapsed
// 初始化通知
notificationStore.fetchNotifications()
// 设置当前选中的菜单
selectedSiderKeys.value = [route.path]
})
</script>
<style lang="scss" scoped>
.government-layout {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f0f2f5;
.layout-header {
height: 60px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 1000;
.header-left {
display: flex;
align-items: center;
gap: 20px;
.logo-container {
display: flex;
align-items: center;
gap: 12px;
.logo {
width: 32px;
height: 32px;
}
.system-title {
font-size: 18px;
font-weight: 600;
margin: 0;
}
}
.menu-toggle {
cursor: pointer;
padding: 8px;
border-radius: 4px;
transition: background-color 0.3s;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
}
.header-center {
flex: 1;
display: flex;
justify-content: center;
.breadcrumb-container {
:deep(.el-breadcrumb__item) {
.el-breadcrumb__inner {
color: rgba(255, 255, 255, 0.8);
&:hover {
color: white;
}
}
}
}
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
.notification-center,
.fullscreen-toggle {
.el-button {
color: white;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}
}
.user-info {
.user-avatar {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.3s;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.username {
font-size: 14px;
font-weight: 500;
}
}
}
}
}
.layout-container {
flex: 1;
display: flex;
overflow: hidden;
.layout-sidebar {
width: 240px;
background: white;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
transition: width 0.3s;
display: flex;
flex-direction: column;
&.collapsed {
width: 64px;
}
.sidebar-content {
flex: 1;
overflow-y: auto;
.sidebar-menu {
border: none;
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
height: 48px;
line-height: 48px;
&:hover {
background-color: #f5f7fa;
}
}
:deep(.el-menu-item.is-active) {
background-color: #e6f7ff;
color: #1890ff;
border-right: 3px solid #1890ff;
}
}
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid #f0f0f0;
.system-info {
text-align: center;
color: #999;
font-size: 12px;
.version {
margin-bottom: 4px;
}
}
}
}
.layout-main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
.tabs-container {
background: white;
border-bottom: 1px solid #f0f0f0;
padding: 0 16px;
:deep(.el-tabs__header) {
margin: 0;
}
:deep(.el-tabs__nav-wrap::after) {
display: none;
}
}
.main-content {
flex: 1;
padding: 16px;
overflow-y: auto;
background-color: #f0f2f5;
}
}
}
}
// 页面切换动画
.fade-transform-enter-active,
.fade-transform-leave-active {
transition: all 0.3s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(-30px);
}
// 通知列表样式
.notification-list {
.notification-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.3s;
&:hover {
background-color: #f9f9f9;
}
&.unread {
background-color: #f6ffed;
}
.notification-icon {
margin-top: 2px;
}
.notification-content {
flex: 1;
.notification-title {
font-weight: 500;
margin-bottom: 4px;
}
.notification-message {
color: #666;
font-size: 14px;
margin-bottom: 8px;
}
.notification-time {
color: #999;
font-size: 12px;
}
}
.notification-actions {
margin-top: 2px;
}
}
}
// 响应式设计
@media (max-width: 768px) {
.government-layout {
.layout-header {
padding: 0 12px;
.header-center {
display: none;
}
.header-right {
gap: 8px;
.user-info .username {
display: none;
}
}
}
.layout-container {
.layout-sidebar {
position: fixed;
left: 0;
top: 60px;
height: calc(100vh - 60px);
z-index: 999;
transform: translateX(-100%);
transition: transform 0.3s;
&:not(.collapsed) {
transform: translateX(0);
}
}
.layout-main {
margin-left: 0;
}
}
}
}
</style>

View File

@@ -0,0 +1,388 @@
<template>
<a-layout class="basic-layout">
<!-- 侧边栏 -->
<a-layout-sider
v-model:collapsed="collapsed"
:trigger="null"
collapsible
theme="dark"
width="256"
class="layout-sider"
>
<!-- Logo -->
<div class="logo">
<img src="/favicon.svg" alt="Logo" />
<span v-show="!collapsed" class="logo-text">政府监管平台</span>
</div>
<!-- 菜单 -->
<a-menu
v-model:selectedKeys="selectedKeys"
v-model:openKeys="openKeys"
mode="inline"
theme="dark"
:inline-collapsed="collapsed"
@click="handleMenuClick"
>
<template v-for="route in menuRoutes" :key="route.name">
<a-menu-item
v-if="!route.children || route.children.length === 0"
:key="route.name"
>
<component :is="getIcon(route.meta?.icon)" />
<span>{{ route.meta?.title }}</span>
</a-menu-item>
<a-sub-menu
v-else
>
<template #title>
<component :is="getIcon(route.meta?.icon)" />
<span>{{ route.meta?.title }}</span>
</template>
<a-menu-item
v-for="child in route.children"
:key="child.name"
>
<component :is="getIcon(child.meta?.icon)" />
<span>{{ child.meta?.title }}</span>
</a-menu-item>
</a-sub-menu>
</template>
</a-menu>
</a-layout-sider>
<!-- :key="route.name" -->
<!-- 主体内容 -->
<a-layout class="layout-content">
<!-- 顶部导航 -->
<a-layout-header class="layout-header">
<div class="header-left">
<a-button
type="text"
@click="collapsed = !collapsed"
class="trigger"
>
<menu-unfold-outlined v-if="collapsed" />
<menu-fold-outlined v-else />
</a-button>
<!-- 面包屑 -->
<a-breadcrumb class="breadcrumb">
<a-breadcrumb-item
v-for="item in breadcrumbItems"
:key="item.path"
>
<router-link v-if="item.path && item.path !== $route.path" :to="item.path">
{{ item.title }}
</router-link>
<span v-else>{{ item.title }}</span>
</a-breadcrumb-item>
</a-breadcrumb>
</div>
<div class="header-right">
<!-- 通知 -->
<a-badge :count="notificationCount" class="notification-badge">
<a-button type="text" @click="showNotifications">
<bell-outlined />
</a-button>
</a-badge>
<!-- 用户信息 -->
<a-dropdown>
<a-button type="text" class="user-info">
<a-avatar :src="authStore.avatar" :size="32">
{{ authStore.userName.charAt(0) }}
</a-avatar>
<span class="user-name">{{ authStore.userName }}</span>
<down-outlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="showProfile">
<user-outlined />
个人资料
</a-menu-item>
<a-menu-item @click="showSettings">
<setting-outlined />
系统设置
</a-menu-item>
<a-menu-divider />
<a-menu-item @click="handleLogout">
<logout-outlined />
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</a-layout-header>
<!-- 页面内容 -->
<a-layout-content class="main-content">
<router-view />
</a-layout-content>
<!-- 页脚 -->
<a-layout-footer class="layout-footer">
<div class="footer-content">
<span>© 2025 宁夏智慧养殖监管平台 - 政府端管理后台</span>
<span>版本 v1.0.0</span>
</div>
</a-layout-footer>
</a-layout>
</a-layout>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { Modal } from 'ant-design-vue'
import {
MenuUnfoldOutlined,
MenuFoldOutlined,
DashboardOutlined,
HomeOutlined,
MonitorOutlined,
BugOutlined,
AlertOutlined,
BarChartOutlined,
UserOutlined,
SettingOutlined,
BellOutlined,
DownOutlined,
LogoutOutlined
} from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// 响应式数据
const collapsed = ref(false)
const selectedKeys = ref([])
const openKeys = ref([])
const notificationCount = ref(5)
// 图标映射
const iconMap = {
dashboard: DashboardOutlined,
home: HomeOutlined,
monitor: MonitorOutlined,
bug: BugOutlined,
alert: AlertOutlined,
'bar-chart': BarChartOutlined,
user: UserOutlined,
setting: SettingOutlined
}
// 获取图标组件
const getIcon = (iconName) => {
return iconMap[iconName] || DashboardOutlined
}
// 菜单路由
const menuRoutes = computed(() => {
return router.getRoutes()
.find(route => route.name === 'Layout')
?.children?.filter(child =>
!child.meta?.hidden &&
(!child.meta?.roles || authStore.hasRole(child.meta.roles))
) || []
})
// 面包屑
const breadcrumbItems = computed(() => {
const matched = route.matched.filter(item => item.meta?.title)
return matched.map(item => ({
title: item.meta.title,
path: item.path === route.path ? null : item.path
}))
})
// 监听路由变化
watch(
() => route.name,
(newName) => {
if (newName) {
selectedKeys.value = [newName]
}
},
{ immediate: true }
)
// 菜单点击处理
const handleMenuClick = ({ key }) => {
router.push({ name: key })
}
// 显示通知
const showNotifications = () => {
// TODO: 实现通知功能
console.log('显示通知')
}
// 显示个人资料
const showProfile = () => {
// TODO: 实现个人资料功能
console.log('显示个人资料')
}
// 显示系统设置
const showSettings = () => {
router.push('/settings')
}
// 退出登录
const handleLogout = () => {
Modal.confirm({
title: '确认退出',
content: '确定要退出登录吗?',
onOk: async () => {
await authStore.logout()
router.push('/login')
}
})
}
</script>
<style lang="scss" scoped>
.basic-layout {
height: 100vh;
.layout-sider {
position: fixed;
height: 100vh;
left: 0;
top: 0;
z-index: 100;
.logo {
height: 64px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 16px;
background: rgba(255, 255, 255, 0.1);
img {
width: 32px;
height: 32px;
}
.logo-text {
color: white;
font-size: 16px;
font-weight: 500;
margin-left: 12px;
}
}
}
.layout-content {
margin-left: 256px;
transition: margin-left 0.2s;
&.collapsed {
margin-left: 80px;
}
}
.layout-header {
background: white;
padding: 0 24px;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
.header-left {
display: flex;
align-items: center;
.trigger {
font-size: 18px;
margin-right: 24px;
}
.breadcrumb {
font-size: 14px;
}
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
.notification-badge {
.ant-btn {
font-size: 16px;
}
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
.user-name {
font-size: 14px;
color: rgba(0, 0, 0, 0.85);
}
}
}
}
.main-content {
background: #f0f2f5;
min-height: calc(100vh - 64px - 48px);
overflow: auto;
}
.layout-footer {
background: white;
border-top: 1px solid #f0f0f0;
padding: 12px 24px;
.footer-content {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
}
}
// 响应式适配
@media (max-width: 768px) {
.basic-layout {
.layout-sider {
transform: translateX(-100%);
transition: transform 0.2s;
&.ant-layout-sider-collapsed {
transform: translateX(0);
}
}
.layout-content {
margin-left: 0;
}
.layout-header {
padding: 0 16px;
.header-left .breadcrumb {
display: none;
}
}
}
}
</style>

View File

@@ -0,0 +1,20 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
import router from './router'
import App from './App.vue'
import './styles/index.css'
import { permissionDirective } from './stores/permission'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(Antd)
app.use(router)
// 注册权限指令
app.directive('permission', permissionDirective)
app.mount('#app')

View File

@@ -0,0 +1,287 @@
/**
* 路由守卫配置
*/
import { useUserStore } from '@/stores/user'
import { usePermissionStore } from '@/stores/permission'
import { checkRoutePermission } from '@/utils/permission'
import { message } from 'ant-design-vue'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
// 配置 NProgress
NProgress.configure({
showSpinner: false,
minimum: 0.2,
speed: 500
})
// 白名单路由 - 不需要登录验证的路由
const whiteList = [
'/login',
'/register',
'/forgot-password',
'/404',
'/403',
'/500'
]
// 公共路由 - 登录后都可以访问的路由
const publicRoutes = [
'/dashboard',
'/profile',
'/settings'
]
/**
* 前置守卫 - 路由跳转前的权限验证
*/
export async function beforeEach(to, from, next) {
// 开始进度条
NProgress.start()
const userStore = useUserStore()
const permissionStore = usePermissionStore()
// 获取用户token
const token = userStore.token || localStorage.getItem('token')
// 检查是否在白名单中
if (whiteList.includes(to.path)) {
// 如果已登录且访问登录页,重定向到首页
if (token && to.path === '/login') {
next({ path: '/dashboard' })
} else {
next()
}
return
}
// 检查是否已登录
if (!token) {
message.warning('请先登录')
next({
path: '/login',
query: { redirect: to.fullPath }
})
return
}
// 检查用户信息是否存在
if (!userStore.userInfo || !userStore.userInfo.id) {
try {
// 获取用户信息
await userStore.getUserInfo()
// 初始化权限
await permissionStore.initPermissions(userStore.userInfo)
} catch (error) {
console.error('获取用户信息失败:', error)
message.error('获取用户信息失败,请重新登录')
userStore.logout()
next({ path: '/login' })
return
}
}
// 检查是否为公共路由
if (publicRoutes.includes(to.path)) {
next()
return
}
// 检查路由权限
if (!checkRoutePermission(to, userStore.userInfo)) {
message.error('您没有访问该页面的权限')
next({ path: '/403' })
return
}
// 检查动态路由是否已生成
if (!permissionStore.routesGenerated) {
try {
// 生成动态路由
const accessRoutes = await permissionStore.generateRoutes(userStore.userInfo)
// 动态添加路由
accessRoutes.forEach(route => {
router.addRoute(route)
})
// 重新导航到目标路由
next({ ...to, replace: true })
} catch (error) {
console.error('生成路由失败:', error)
message.error('系统初始化失败')
next({ path: '/500' })
}
return
}
next()
}
/**
* 后置守卫 - 路由跳转后的处理
*/
export function afterEach(to, from) {
// 结束进度条
NProgress.done()
// 设置页面标题
const title = to.meta?.title
if (title) {
document.title = `${title} - 政府管理后台`
} else {
document.title = '政府管理后台'
}
// 记录路由访问日志
if (process.env.NODE_ENV === 'development') {
console.log(`路由跳转: ${from.path} -> ${to.path}`)
}
}
/**
* 路由错误处理
*/
export function onError(error) {
console.error('路由错误:', error)
NProgress.done()
// 根据错误类型进行处理
if (error.name === 'ChunkLoadError') {
message.error('页面加载失败,请刷新重试')
} else if (error.name === 'NavigationDuplicated') {
// 重复导航错误,忽略
return
} else {
message.error('页面访问异常')
}
}
/**
* 权限验证中间件
*/
export function requireAuth(permission) {
return (to, from, next) => {
const userStore = useUserStore()
const permissionStore = usePermissionStore()
if (!userStore.token) {
next({ path: '/login' })
return
}
if (permission && !permissionStore.hasPermission(permission)) {
message.error('权限不足')
next({ path: '/403' })
return
}
next()
}
}
/**
* 角色验证中间件
*/
export function requireRole(role) {
return (to, from, next) => {
const userStore = useUserStore()
const permissionStore = usePermissionStore()
if (!userStore.token) {
next({ path: '/login' })
return
}
if (role && !permissionStore.hasRole(role)) {
message.error('角色权限不足')
next({ path: '/403' })
return
}
next()
}
}
/**
* 管理员权限验证
*/
export function requireAdmin(to, from, next) {
const userStore = useUserStore()
if (!userStore.token) {
next({ path: '/login' })
return
}
const userRole = userStore.userInfo?.role
if (!['super_admin', 'admin'].includes(userRole)) {
message.error('需要管理员权限')
next({ path: '/403' })
return
}
next()
}
/**
* 超级管理员权限验证
*/
export function requireSuperAdmin(to, from, next) {
const userStore = useUserStore()
if (!userStore.token) {
next({ path: '/login' })
return
}
if (userStore.userInfo?.role !== 'super_admin') {
message.error('需要超级管理员权限')
next({ path: '/403' })
return
}
next()
}
/**
* 检查页面访问权限
*/
export function checkPageAccess(requiredPermissions = []) {
return (to, from, next) => {
const permissionStore = usePermissionStore()
// 检查是否有任一权限
const hasAccess = requiredPermissions.length === 0 ||
requiredPermissions.some(permission =>
permissionStore.hasPermission(permission)
)
if (!hasAccess) {
message.error('您没有访问该页面的权限')
next({ path: '/403' })
return
}
next()
}
}
/**
* 动态路由加载守卫
*/
export function loadDynamicRoutes(to, from, next) {
const permissionStore = usePermissionStore()
if (!permissionStore.routesGenerated) {
// 如果路由未生成,等待生成完成
permissionStore.generateRoutes().then(() => {
next({ ...to, replace: true })
}).catch(() => {
next({ path: '/500' })
})
} else {
next()
}
}

View File

@@ -0,0 +1,498 @@
import { createRouter, createWebHistory } from 'vue-router'
import { beforeEach, afterEach, onError } from './guards'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
// 配置 NProgress
NProgress.configure({ showSpinner: false })
// 导入布局组件
const Layout = () => import('@/layout/GovernmentLayout.vue')
// 基础路由配置
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: {
title: '登录',
hidden: true
}
},
{
path: '/404',
name: 'NotFound',
component: () => import('@/views/error/404.vue'),
meta: {
title: '页面不存在',
hidden: true
}
},
{
path: '/403',
name: 'Forbidden',
component: () => import('@/views/error/403.vue'),
meta: {
title: '权限不足',
hidden: true
}
},
{
path: '/500',
name: 'ServerError',
component: () => import('@/views/error/500.vue'),
meta: {
title: '服务器错误',
hidden: true
}
},
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/Dashboard.vue'),
meta: {
title: '仪表盘',
icon: 'dashboard',
affix: true,
permission: 'dashboard:view'
}
},
{
path: '/breeding',
name: 'Breeding',
meta: {
title: '养殖管理',
icon: 'home',
permission: 'breeding:view'
},
children: [
{
path: 'farms',
name: 'BreedingFarmList',
component: () => import('@/views/breeding/BreedingFarmList.vue'),
meta: {
title: '养殖场管理',
permission: 'breeding:farm'
}
}
]
},
{
path: '/monitoring',
name: 'Monitoring',
meta: {
title: '健康监控',
icon: 'monitor',
permission: 'monitoring:view'
},
children: [
{
path: 'health',
name: 'AnimalHealthMonitor',
component: () => import('@/views/monitoring/AnimalHealthMonitor.vue'),
meta: {
title: '动物健康监控',
permission: 'monitoring:health'
}
}
]
},
{
path: '/inspection',
name: 'Inspection',
meta: {
title: '检查管理',
icon: 'audit',
permission: 'inspection:view'
},
children: [
{
path: 'management',
name: 'InspectionManagement',
component: () => import('@/views/inspection/InspectionManagement.vue'),
meta: {
title: '检查管理',
permission: 'inspection:manage'
}
}
]
},
{
path: '/traceability',
name: 'Traceability',
meta: {
title: '溯源系统',
icon: 'link',
permission: 'traceability:view'
},
children: [
{
path: 'system',
name: 'TraceabilitySystem',
component: () => import('@/views/traceability/TraceabilitySystem.vue'),
meta: {
title: '产品溯源',
permission: 'traceability:system'
}
}
]
},
{
path: '/emergency',
name: 'Emergency',
meta: {
title: '应急响应',
icon: 'alert',
permission: 'emergency:view'
},
children: [
{
path: 'response',
name: 'EmergencyResponse',
component: () => import('@/views/emergency/EmergencyResponse.vue'),
meta: {
title: '应急响应',
permission: 'emergency:response'
}
}
]
},
{
path: '/policy',
name: 'Policy',
meta: {
title: '政策管理',
icon: 'file-text',
permission: 'policy:view'
},
children: [
{
path: 'management',
name: 'PolicyManagement',
component: () => import('@/views/policy/PolicyManagement.vue'),
meta: {
title: '政策管理',
permission: 'policy:manage'
}
}
]
},
{
path: '/statistics',
name: 'Statistics',
meta: {
title: '数据统计',
icon: 'bar-chart',
permission: 'statistics:view'
},
children: [
{
path: 'data',
name: 'DataStatistics',
component: () => import('@/views/statistics/DataStatistics.vue'),
meta: {
title: '数据统计',
permission: 'statistics:data'
}
}
]
},
{
path: '/reports',
name: 'Reports',
meta: {
title: '报表中心',
icon: 'file-text',
permission: 'reports:view'
},
children: [
{
path: 'center',
name: 'ReportCenter',
component: () => import('@/views/reports/ReportCenter.vue'),
meta: {
title: '报表中心',
permission: 'reports:center'
}
}
]
},
{
path: '/settings',
name: 'Settings',
meta: {
title: '系统设置',
icon: 'setting',
permission: 'settings:view'
},
children: [
{
path: 'system',
name: 'SystemSettings',
component: () => import('@/views/settings/SystemSettings.vue'),
meta: {
title: '系统设置',
permission: 'settings:system'
}
}
]
},
{
path: '/monitor',
name: 'Monitor',
component: () => import('@/views/monitor/MonitorDashboard.vue'),
meta: {
title: '实时监控',
icon: 'eye',
requiresAuth: true,
permission: 'monitor:view'
}
},
{
path: '/reports',
name: 'Reports',
component: () => import('@/views/reports/ReportList.vue'),
meta: {
title: '报表管理',
icon: 'file-text',
requiresAuth: true,
permission: 'monitor:report'
}
},
{
path: '/data',
name: 'Data',
component: () => import('@/views/data/DataAnalysis.vue'),
meta: {
title: '数据分析',
icon: 'bar-chart',
requiresAuth: true,
permission: 'data:view'
}
},
// 业务管理
{
path: '/business',
name: 'Business',
meta: {
title: '业务管理',
icon: 'solution',
permission: 'business:view'
},
children: [
{
path: 'insurance',
name: 'InsuranceManagement',
component: () => import('@/views/business/InsuranceManagement.vue'),
meta: {
title: '保险管理',
permission: 'business:insurance'
}
},
{
path: 'trading',
name: 'TradingManagement',
component: () => import('@/views/business/TradingManagement.vue'),
meta: {
title: '生资交易',
permission: 'business:trading'
}
},
{
path: 'waste-collection',
name: 'WasteCollection',
component: () => import('@/views/business/WasteCollection.vue'),
meta: {
title: '粪污报收',
permission: 'business:waste'
}
},
{
path: 'subsidies',
name: 'SubsidyManagement',
component: () => import('@/views/business/SubsidyManagement.vue'),
meta: {
title: '奖补管理',
permission: 'business:subsidy'
}
},
{
path: 'forage-enterprises',
name: 'ForageEnterprises',
component: () => import('@/views/business/ForageEnterprises.vue'),
meta: {
title: '饲草料企业管理',
permission: 'business:forage'
}
},
{
path: 'market-info',
name: 'MarketInfo',
component: () => import('@/views/business/MarketInfo.vue'),
meta: {
title: '市场行情',
permission: 'business:market'
}
}
]
},
// 防疫管理
{
path: '/epidemic-prevention',
name: 'EpidemicPrevention',
meta: {
title: '防疫管理',
icon: 'medicine-box',
permission: 'epidemic:view'
},
children: [
{
path: 'institutions',
name: 'EpidemicInstitutions',
component: () => import('@/views/epidemic/EpidemicInstitutions.vue'),
meta: {
title: '防疫机构管理',
permission: 'epidemic:institution'
}
},
{
path: 'records',
name: 'EpidemicRecords',
component: () => import('@/views/epidemic/EpidemicRecords.vue'),
meta: {
title: '防疫记录',
permission: 'epidemic:record'
}
},
{
path: 'vaccines',
name: 'VaccineManagement',
component: () => import('@/views/epidemic/VaccineManagement.vue'),
meta: {
title: '疫苗管理',
permission: 'epidemic:vaccine'
}
},
{
path: 'activities',
name: 'EpidemicActivities',
component: () => import('@/views/epidemic/EpidemicActivities.vue'),
meta: {
title: '防疫活动管理',
permission: 'epidemic:activity'
}
}
]
},
// 服务管理
{
path: '/services',
name: 'Services',
meta: {
title: '服务管理',
icon: 'customer-service',
permission: 'service:view'
},
children: [
{
path: 'education',
name: 'CattleEducation',
component: () => import('@/views/services/CattleEducation.vue'),
meta: {
title: '养牛学院',
permission: 'service:education'
}
},
{
path: 'consultation',
name: 'OnlineConsultation',
component: () => import('@/views/services/OnlineConsultation.vue'),
meta: {
title: '线上问诊',
permission: 'service:consultation'
}
},
{
path: 'community',
name: 'CommunityManagement',
component: () => import('@/views/services/CommunityManagement.vue'),
meta: {
title: '交流社区',
permission: 'service:community'
}
}
]
},
{
path: '/users',
name: 'Users',
component: () => import('@/views/users/UserList.vue'),
meta: {
title: '用户管理',
icon: 'user',
requiresAuth: true,
permission: 'user:view'
}
},
{
path: '/settings',
name: 'Settings',
component: () => import('@/views/settings/SystemSettings.vue'),
meta: {
title: '系统设置',
icon: 'setting',
requiresAuth: true,
permission: 'system:config'
}
}
]
},
{
path: '/403',
name: 'Forbidden',
component: () => import('@/views/error/403.vue'),
meta: {
title: '权限不足',
hideInMenu: true
}
},
{
path: '/404',
name: 'NotFound',
component: () => import('@/views/error/404.vue'),
meta: {
title: '页面不存在',
hideInMenu: true
}
},
{
path: '/:pathMatch(.*)*',
redirect: '/404'
}
]
// 创建路由实例
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
return { top: 0 }
}
}
})
// 注册路由守卫
router.beforeEach(beforeEach)
router.afterEach(afterEach)
router.onError(onError)
export default router

View File

@@ -0,0 +1,116 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import api from '@/utils/api'
export const useAuthStore = defineStore('auth', () => {
// 状态
const token = ref(localStorage.getItem('token') || '')
const userInfo = ref(JSON.parse(localStorage.getItem('userInfo') || 'null'))
const permissions = ref([])
// 计算属性
const isAuthenticated = computed(() => !!token.value && !!userInfo.value)
const userName = computed(() => userInfo.value?.name || '')
const userRole = computed(() => userInfo.value?.role || '')
const avatar = computed(() => userInfo.value?.avatar || '')
// 方法
const login = async (credentials) => {
try {
const response = await api.post('/auth/login', credentials)
const { token: newToken, user, permissions: userPermissions } = response.data
// 保存认证信息
token.value = newToken
userInfo.value = user
permissions.value = userPermissions || []
// 持久化存储
localStorage.setItem('token', newToken)
localStorage.setItem('userInfo', JSON.stringify(user))
localStorage.setItem('permissions', JSON.stringify(userPermissions || []))
message.success('登录成功')
return true
} catch (error) {
message.error(error.response?.data?.message || '登录失败')
return false
}
}
const logout = async () => {
try {
await api.post('/auth/logout')
} catch (error) {
console.error('退出登录请求失败:', error)
} finally {
// 清除认证信息
token.value = ''
userInfo.value = null
permissions.value = []
// 清除本地存储
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
localStorage.removeItem('permissions')
message.success('已退出登录')
}
}
const checkAuthStatus = async () => {
if (!token.value) return false
try {
const response = await api.get('/auth/me')
userInfo.value = response.data.user
permissions.value = response.data.permissions || []
// 更新本地存储
localStorage.setItem('userInfo', JSON.stringify(response.data.user))
localStorage.setItem('permissions', JSON.stringify(response.data.permissions || []))
return true
} catch (error) {
// 认证失败,清除本地数据
logout()
return false
}
}
const hasPermission = (permission) => {
return permissions.value.includes(permission)
}
const hasRole = (roles) => {
if (!Array.isArray(roles)) roles = [roles]
return roles.includes(userRole.value)
}
const updateUserInfo = (newUserInfo) => {
userInfo.value = { ...userInfo.value, ...newUserInfo }
localStorage.setItem('userInfo', JSON.stringify(userInfo.value))
}
return {
// 状态
token,
userInfo,
permissions,
// 计算属性
isAuthenticated,
userName,
userRole,
avatar,
// 方法
login,
logout,
checkAuthStatus,
hasPermission,
hasRole,
updateUserInfo
}
})

View File

@@ -0,0 +1,292 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import {
getFarmList,
getFarmDetail,
createFarm,
updateFarm,
deleteFarm,
batchDeleteFarms,
updateFarmStatus,
getFarmStats,
getFarmMapData,
getFarmTypes,
getFarmScales
} from '@/api/farm'
import { message } from 'ant-design-vue'
export const useFarmStore = defineStore('farm', () => {
// 状态
const farms = ref([])
const currentFarm = ref(null)
const loading = ref(false)
const total = ref(0)
const stats = ref({
total: 0,
active: 0,
inactive: 0,
pending: 0
})
const farmTypes = ref([])
const farmScales = ref([])
const mapData = ref([])
// 计算属性
const activeFarms = computed(() =>
farms.value.filter(farm => farm.status === 'active')
)
const inactiveFarms = computed(() =>
farms.value.filter(farm => farm.status === 'inactive')
)
const pendingFarms = computed(() =>
farms.value.filter(farm => farm.status === 'pending')
)
// 获取养殖场列表
const fetchFarms = async (params = {}) => {
try {
loading.value = true
const response = await getFarmList(params)
farms.value = response.data.list || []
total.value = response.data.total || 0
return response
} catch (error) {
message.error('获取养殖场列表失败')
throw error
} finally {
loading.value = false
}
}
// 获取养殖场详情
const fetchFarmDetail = async (id) => {
try {
loading.value = true
const response = await getFarmDetail(id)
currentFarm.value = response.data
return response.data
} catch (error) {
message.error('获取养殖场详情失败')
throw error
} finally {
loading.value = false
}
}
// 创建养殖场
const addFarm = async (farmData) => {
try {
loading.value = true
const response = await createFarm(farmData)
message.success('创建养殖场成功')
// 重新获取列表
await fetchFarms()
return response.data
} catch (error) {
message.error('创建养殖场失败')
throw error
} finally {
loading.value = false
}
}
// 更新养殖场
const editFarm = async (id, farmData) => {
try {
loading.value = true
const response = await updateFarm(id, farmData)
message.success('更新养殖场成功')
// 更新本地数据
const index = farms.value.findIndex(farm => farm.id === id)
if (index !== -1) {
farms.value[index] = { ...farms.value[index], ...response.data }
}
// 如果是当前查看的养殖场,也更新
if (currentFarm.value && currentFarm.value.id === id) {
currentFarm.value = { ...currentFarm.value, ...response.data }
}
return response.data
} catch (error) {
message.error('更新养殖场失败')
throw error
} finally {
loading.value = false
}
}
// 删除养殖场
const removeFarm = async (id) => {
try {
loading.value = true
await deleteFarm(id)
message.success('删除养殖场成功')
// 从本地数据中移除
const index = farms.value.findIndex(farm => farm.id === id)
if (index !== -1) {
farms.value.splice(index, 1)
total.value -= 1
}
return true
} catch (error) {
message.error('删除养殖场失败')
throw error
} finally {
loading.value = false
}
}
// 批量删除养殖场
const batchRemoveFarms = async (ids) => {
try {
loading.value = true
await batchDeleteFarms(ids)
message.success(`成功删除 ${ids.length} 个养殖场`)
// 从本地数据中移除
farms.value = farms.value.filter(farm => !ids.includes(farm.id))
total.value -= ids.length
return true
} catch (error) {
message.error('批量删除养殖场失败')
throw error
} finally {
loading.value = false
}
}
// 更新养殖场状态
const changeFarmStatus = async (id, status) => {
try {
loading.value = true
const response = await updateFarmStatus(id, status)
message.success('更新状态成功')
// 更新本地数据
const index = farms.value.findIndex(farm => farm.id === id)
if (index !== -1) {
farms.value[index].status = status
}
return response.data
} catch (error) {
message.error('更新状态失败')
throw error
} finally {
loading.value = false
}
}
// 获取统计数据
const fetchStats = async () => {
try {
const response = await getFarmStats()
stats.value = response.data
return response.data
} catch (error) {
message.error('获取统计数据失败')
throw error
}
}
// 获取地图数据
const fetchMapData = async (params = {}) => {
try {
const response = await getFarmMapData(params)
mapData.value = response.data
return response.data
} catch (error) {
message.error('获取地图数据失败')
throw error
}
}
// 获取养殖场类型选项
const fetchFarmTypes = async () => {
try {
const response = await getFarmTypes()
farmTypes.value = response.data
return response.data
} catch (error) {
console.error('获取养殖场类型失败:', error)
return []
}
}
// 获取养殖场规模选项
const fetchFarmScales = async () => {
try {
const response = await getFarmScales()
farmScales.value = response.data
return response.data
} catch (error) {
console.error('获取养殖场规模失败:', error)
return []
}
}
// 重置状态
const resetState = () => {
farms.value = []
currentFarm.value = null
loading.value = false
total.value = 0
stats.value = {
total: 0,
active: 0,
inactive: 0,
pending: 0
}
mapData.value = []
}
// 设置当前养殖场
const setCurrentFarm = (farm) => {
currentFarm.value = farm
}
// 清除当前养殖场
const clearCurrentFarm = () => {
currentFarm.value = null
}
return {
// 状态
farms,
currentFarm,
loading,
total,
stats,
farmTypes,
farmScales,
mapData,
// 计算属性
activeFarms,
inactiveFarms,
pendingFarms,
// 方法
fetchFarms,
fetchFarmDetail,
addFarm,
editFarm,
removeFarm,
batchRemoveFarms,
changeFarmStatus,
fetchStats,
fetchMapData,
fetchFarmTypes,
fetchFarmScales,
resetState,
setCurrentFarm,
clearCurrentFarm
}
})

View File

@@ -0,0 +1,522 @@
/**
* 政府业务状态管理
*/
import { defineStore } from 'pinia'
import { governmentApi } from '@/api/government'
export const useGovernmentStore = defineStore('government', {
state: () => ({
// 政府监管数据
supervision: {
// 监管统计
stats: {
totalEntities: 0,
activeInspections: 0,
pendingApprovals: 0,
completedTasks: 0
},
// 监管实体列表
entities: [],
// 检查记录
inspections: [],
// 违规记录
violations: []
},
// 审批管理数据
approval: {
// 审批统计
stats: {
pending: 0,
approved: 0,
rejected: 0,
total: 0
},
// 审批流程
workflows: [],
// 审批记录
records: [],
// 待办任务
tasks: []
},
// 人员管理数据
personnel: {
// 人员统计
stats: {
totalStaff: 0,
activeStaff: 0,
departments: 0,
positions: 0
},
// 员工列表
staff: [],
// 部门列表
departments: [],
// 职位列表
positions: [],
// 考勤记录
attendance: []
},
// 设备仓库数据
warehouse: {
// 库存统计
stats: {
totalEquipment: 0,
availableEquipment: 0,
inUseEquipment: 0,
maintenanceEquipment: 0
},
// 设备列表
equipment: [],
// 入库记录
inboundRecords: [],
// 出库记录
outboundRecords: [],
// 维护记录
maintenanceRecords: []
},
// 防疫管理数据
epidemic: {
// 防疫统计
stats: {
totalCases: 0,
activeCases: 0,
recoveredCases: 0,
vaccinationRate: 0
},
// 疫情数据
cases: [],
// 疫苗接种记录
vaccinations: [],
// 防疫措施
measures: [],
// 健康码数据
healthCodes: []
},
// 服务管理数据
service: {
// 服务统计
stats: {
totalServices: 0,
activeServices: 0,
completedServices: 0,
satisfactionRate: 0
},
// 服务项目
services: [],
// 服务申请
applications: [],
// 服务评价
evaluations: [],
// 服务指南
guides: []
},
// 数据可视化配置
visualization: {
// 图表配置
charts: {},
// 数据源配置
dataSources: {},
// 刷新间隔
refreshInterval: 30000,
// 实时数据开关
realTimeEnabled: true
},
// 加载状态
loading: {
supervision: false,
approval: false,
personnel: false,
warehouse: false,
epidemic: false,
service: false
},
// 错误信息
errors: {}
}),
getters: {
// 总体统计数据
overallStats: (state) => ({
supervision: state.supervision.stats,
approval: state.approval.stats,
personnel: state.personnel.stats,
warehouse: state.warehouse.stats,
epidemic: state.epidemic.stats,
service: state.service.stats
}),
// 待处理任务总数
totalPendingTasks: (state) => {
return state.approval.stats.pending +
state.supervision.stats.pendingApprovals +
state.service.stats.activeServices
},
// 系统健康状态
systemHealth: (state) => {
const totalTasks = state.approval.stats.total
const completedTasks = state.approval.stats.approved + state.approval.stats.rejected
const completionRate = totalTasks > 0 ? (completedTasks / totalTasks) * 100 : 100
if (completionRate >= 90) return 'excellent'
if (completionRate >= 75) return 'good'
if (completionRate >= 60) return 'fair'
return 'poor'
},
// 最近活动
recentActivities: (state) => {
const activities = []
// 添加审批活动
state.approval.records.slice(0, 5).forEach(record => {
activities.push({
type: 'approval',
title: `审批:${record.title}`,
time: record.updatedAt,
status: record.status
})
})
// 添加监管活动
state.supervision.inspections.slice(0, 5).forEach(inspection => {
activities.push({
type: 'inspection',
title: `检查:${inspection.title}`,
time: inspection.createdAt,
status: inspection.status
})
})
return activities
.sort((a, b) => new Date(b.time) - new Date(a.time))
.slice(0, 10)
}
},
actions: {
/**
* 初始化政府数据
*/
async initializeGovernmentData() {
try {
await Promise.all([
this.loadSupervisionData(),
this.loadApprovalData(),
this.loadPersonnelData(),
this.loadWarehouseData(),
this.loadEpidemicData(),
this.loadServiceData()
])
} catch (error) {
console.error('初始化政府数据失败:', error)
throw error
}
},
/**
* 加载监管数据
*/
async loadSupervisionData() {
this.loading.supervision = true
try {
const response = await governmentApi.getSupervisionData()
this.supervision = { ...this.supervision, ...response.data }
delete this.errors.supervision
} catch (error) {
this.errors.supervision = error.message
throw error
} finally {
this.loading.supervision = false
}
},
/**
* 加载审批数据
*/
async loadApprovalData() {
this.loading.approval = true
try {
const response = await governmentApi.getApprovalData()
this.approval = { ...this.approval, ...response.data }
delete this.errors.approval
} catch (error) {
this.errors.approval = error.message
throw error
} finally {
this.loading.approval = false
}
},
/**
* 加载人员数据
*/
async loadPersonnelData() {
this.loading.personnel = true
try {
const response = await governmentApi.getPersonnelData()
this.personnel = { ...this.personnel, ...response.data }
delete this.errors.personnel
} catch (error) {
this.errors.personnel = error.message
throw error
} finally {
this.loading.personnel = false
}
},
/**
* 加载仓库数据
*/
async loadWarehouseData() {
this.loading.warehouse = true
try {
const response = await governmentApi.getWarehouseData()
this.warehouse = { ...this.warehouse, ...response.data }
delete this.errors.warehouse
} catch (error) {
this.errors.warehouse = error.message
throw error
} finally {
this.loading.warehouse = false
}
},
/**
* 加载防疫数据
*/
async loadEpidemicData() {
this.loading.epidemic = true
try {
const response = await governmentApi.getEpidemicData()
this.epidemic = { ...this.epidemic, ...response.data }
delete this.errors.epidemic
} catch (error) {
this.errors.epidemic = error.message
throw error
} finally {
this.loading.epidemic = false
}
},
/**
* 加载服务数据
*/
async loadServiceData() {
this.loading.service = true
try {
const response = await governmentApi.getServiceData()
this.service = { ...this.service, ...response.data }
delete this.errors.service
} catch (error) {
this.errors.service = error.message
throw error
} finally {
this.loading.service = false
}
},
/**
* 提交审批
* @param {Object} approvalData - 审批数据
*/
async submitApproval(approvalData) {
try {
const response = await governmentApi.submitApproval(approvalData)
// 更新本地数据
this.approval.records.unshift(response.data)
this.approval.stats.pending += 1
this.approval.stats.total += 1
return response.data
} catch (error) {
throw error
}
},
/**
* 处理审批
* @param {string} id - 审批ID
* @param {Object} decision - 审批决定
*/
async processApproval(id, decision) {
try {
const response = await governmentApi.processApproval(id, decision)
// 更新本地数据
const index = this.approval.records.findIndex(r => r.id === id)
if (index > -1) {
this.approval.records[index] = response.data
// 更新统计
this.approval.stats.pending -= 1
if (decision.status === 'approved') {
this.approval.stats.approved += 1
} else if (decision.status === 'rejected') {
this.approval.stats.rejected += 1
}
}
return response.data
} catch (error) {
throw error
}
},
/**
* 添加设备
* @param {Object} equipment - 设备数据
*/
async addEquipment(equipment) {
try {
const response = await governmentApi.addEquipment(equipment)
// 更新本地数据
this.warehouse.equipment.unshift(response.data)
this.warehouse.stats.totalEquipment += 1
this.warehouse.stats.availableEquipment += 1
return response.data
} catch (error) {
throw error
}
},
/**
* 设备入库
* @param {Object} inboundData - 入库数据
*/
async equipmentInbound(inboundData) {
try {
const response = await governmentApi.equipmentInbound(inboundData)
// 更新本地数据
this.warehouse.inboundRecords.unshift(response.data)
// 更新设备状态
const equipment = this.warehouse.equipment.find(e => e.id === inboundData.equipmentId)
if (equipment) {
equipment.quantity += inboundData.quantity
equipment.status = 'available'
}
return response.data
} catch (error) {
throw error
}
},
/**
* 设备出库
* @param {Object} outboundData - 出库数据
*/
async equipmentOutbound(outboundData) {
try {
const response = await governmentApi.equipmentOutbound(outboundData)
// 更新本地数据
this.warehouse.outboundRecords.unshift(response.data)
// 更新设备状态
const equipment = this.warehouse.equipment.find(e => e.id === outboundData.equipmentId)
if (equipment) {
equipment.quantity -= outboundData.quantity
if (equipment.quantity <= 0) {
equipment.status = 'out_of_stock'
}
}
return response.data
} catch (error) {
throw error
}
},
/**
* 添加员工
* @param {Object} staff - 员工数据
*/
async addStaff(staff) {
try {
const response = await governmentApi.addStaff(staff)
// 更新本地数据
this.personnel.staff.unshift(response.data)
this.personnel.stats.totalStaff += 1
this.personnel.stats.activeStaff += 1
return response.data
} catch (error) {
throw error
}
},
/**
* 更新员工信息
* @param {string} id - 员工ID
* @param {Object} updates - 更新数据
*/
async updateStaff(id, updates) {
try {
const response = await governmentApi.updateStaff(id, updates)
// 更新本地数据
const index = this.personnel.staff.findIndex(s => s.id === id)
if (index > -1) {
this.personnel.staff[index] = response.data
}
return response.data
} catch (error) {
throw error
}
},
/**
* 刷新所有数据
*/
async refreshAllData() {
await this.initializeGovernmentData()
},
/**
* 清除错误信息
* @param {string} module - 模块名称
*/
clearError(module) {
if (module) {
delete this.errors[module]
} else {
this.errors = {}
}
},
/**
* 重置模块数据
* @param {string} module - 模块名称
*/
resetModuleData(module) {
if (this[module]) {
// 重置为初始状态
const initialState = this.$state[module]
this[module] = { ...initialState }
}
}
},
// 持久化配置
persist: {
key: 'government-admin-data',
storage: localStorage,
paths: ['visualization.charts', 'visualization.dataSources']
}
})

View File

@@ -0,0 +1,14 @@
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export default pinia
// 导出所有store
export { useUserStore } from './user'
export { useAppStore } from './app'
export { useTabsStore } from './tabs'
export { useNotificationStore } from './notification'
export { useGovernmentStore } from './government'

View File

@@ -0,0 +1,407 @@
/**
* 通知状态管理
*/
import { defineStore } from 'pinia'
export const useNotificationStore = defineStore('notification', {
state: () => ({
// 通知列表
notifications: [
{
id: '1',
type: 'info',
title: '系统通知',
content: '政府管理后台系统已成功启动',
timestamp: new Date().toISOString(),
read: false,
category: 'system'
},
{
id: '2',
type: 'warning',
title: '待办提醒',
content: '您有3个审批任务待处理',
timestamp: new Date(Date.now() - 3600000).toISOString(),
read: false,
category: 'task'
},
{
id: '3',
type: 'success',
title: '操作成功',
content: '设备入库操作已完成',
timestamp: new Date(Date.now() - 7200000).toISOString(),
read: true,
category: 'operation'
}
],
// 通知设置
settings: {
// 是否启用桌面通知
desktop: true,
// 是否启用声音提醒
sound: true,
// 通知显示时长(毫秒)
duration: 4500,
// 最大显示数量
maxVisible: 5,
// 自动清理已读通知(天数)
autoCleanDays: 7
},
// 当前显示的toast通知
toasts: []
}),
getters: {
// 未读通知数量
unreadCount: (state) => {
return state.notifications.filter(n => !n.read).length
},
// 按类型分组的通知
notificationsByType: (state) => {
return state.notifications.reduce((acc, notification) => {
const type = notification.type
if (!acc[type]) {
acc[type] = []
}
acc[type].push(notification)
return acc
}, {})
},
// 按分类分组的通知
notificationsByCategory: (state) => {
return state.notifications.reduce((acc, notification) => {
const category = notification.category
if (!acc[category]) {
acc[category] = []
}
acc[category].push(notification)
return acc
}, {})
},
// 最近的通知(按时间排序)
recentNotifications: (state) => {
return [...state.notifications]
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
.slice(0, 10)
},
// 未读通知
unreadNotifications: (state) => {
return state.notifications.filter(n => !n.read)
},
// 今日通知
todayNotifications: (state) => {
const today = new Date().toDateString()
return state.notifications.filter(n =>
new Date(n.timestamp).toDateString() === today
)
}
},
actions: {
/**
* 添加通知
* @param {Object} notification - 通知对象
*/
addNotification(notification) {
const newNotification = {
id: this.generateId(),
type: notification.type || 'info',
title: notification.title,
content: notification.content,
timestamp: new Date().toISOString(),
read: false,
category: notification.category || 'general',
...notification
}
this.notifications.unshift(newNotification)
// 显示toast通知
if (notification.showToast !== false) {
this.showToast(newNotification)
}
// 桌面通知
if (this.settings.desktop && notification.desktop !== false) {
this.showDesktopNotification(newNotification)
}
// 声音提醒
if (this.settings.sound && notification.sound !== false) {
this.playNotificationSound()
}
return newNotification.id
},
/**
* 标记通知为已读
* @param {string} id - 通知ID
*/
markAsRead(id) {
const notification = this.notifications.find(n => n.id === id)
if (notification) {
notification.read = true
}
},
/**
* 标记所有通知为已读
*/
markAllAsRead() {
this.notifications.forEach(notification => {
notification.read = true
})
},
/**
* 删除通知
* @param {string} id - 通知ID
*/
removeNotification(id) {
const index = this.notifications.findIndex(n => n.id === id)
if (index > -1) {
this.notifications.splice(index, 1)
}
},
/**
* 清空所有通知
*/
clearAllNotifications() {
this.notifications = []
},
/**
* 清空已读通知
*/
clearReadNotifications() {
this.notifications = this.notifications.filter(n => !n.read)
},
/**
* 显示Toast通知
* @param {Object} notification - 通知对象
*/
showToast(notification) {
const toast = {
id: notification.id,
type: notification.type,
title: notification.title,
content: notification.content,
duration: notification.duration || this.settings.duration
}
this.toasts.push(toast)
// 限制显示数量
if (this.toasts.length > this.settings.maxVisible) {
this.toasts.shift()
}
// 自动移除
if (toast.duration > 0) {
setTimeout(() => {
this.removeToast(toast.id)
}, toast.duration)
}
},
/**
* 移除Toast通知
* @param {string} id - 通知ID
*/
removeToast(id) {
const index = this.toasts.findIndex(t => t.id === id)
if (index > -1) {
this.toasts.splice(index, 1)
}
},
/**
* 显示桌面通知
* @param {Object} notification - 通知对象
*/
showDesktopNotification(notification) {
if ('Notification' in window && Notification.permission === 'granted') {
const desktopNotification = new Notification(notification.title, {
body: notification.content,
icon: '/favicon.ico',
tag: notification.id
})
desktopNotification.onclick = () => {
window.focus()
this.markAsRead(notification.id)
desktopNotification.close()
}
// 自动关闭
setTimeout(() => {
desktopNotification.close()
}, this.settings.duration)
}
},
/**
* 请求桌面通知权限
*/
async requestDesktopPermission() {
if ('Notification' in window) {
const permission = await Notification.requestPermission()
this.settings.desktop = permission === 'granted'
return permission
}
return 'denied'
},
/**
* 播放通知声音
*/
playNotificationSound() {
try {
const audio = new Audio('/sounds/notification.mp3')
audio.volume = 0.5
audio.play().catch(() => {
// 忽略播放失败的错误
})
} catch (error) {
// 忽略音频播放错误
}
},
/**
* 更新通知设置
* @param {Object} newSettings - 新设置
*/
updateSettings(newSettings) {
this.settings = { ...this.settings, ...newSettings }
},
/**
* 生成唯一ID
* @returns {string}
*/
generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2)
},
/**
* 按类型筛选通知
* @param {string} type - 通知类型
* @returns {Array}
*/
getNotificationsByType(type) {
return this.notifications.filter(n => n.type === type)
},
/**
* 按分类筛选通知
* @param {string} category - 通知分类
* @returns {Array}
*/
getNotificationsByCategory(category) {
return this.notifications.filter(n => n.category === category)
},
/**
* 搜索通知
* @param {string} keyword - 搜索关键词
* @returns {Array}
*/
searchNotifications(keyword) {
if (!keyword) return this.notifications
const lowerKeyword = keyword.toLowerCase()
return this.notifications.filter(n =>
n.title.toLowerCase().includes(lowerKeyword) ||
n.content.toLowerCase().includes(lowerKeyword)
)
},
/**
* 自动清理过期通知
*/
autoCleanNotifications() {
if (this.settings.autoCleanDays <= 0) return
const cutoffDate = new Date()
cutoffDate.setDate(cutoffDate.getDate() - this.settings.autoCleanDays)
this.notifications = this.notifications.filter(n => {
const notificationDate = new Date(n.timestamp)
return !n.read || notificationDate > cutoffDate
})
},
/**
* 批量操作通知
* @param {Array} ids - 通知ID列表
* @param {string} action - 操作类型 ('read', 'delete')
*/
batchOperation(ids, action) {
ids.forEach(id => {
if (action === 'read') {
this.markAsRead(id)
} else if (action === 'delete') {
this.removeNotification(id)
}
})
},
/**
* 导出通知数据
* @param {Object} options - 导出选项
* @returns {Array}
*/
exportNotifications(options = {}) {
let notifications = [...this.notifications]
// 按时间范围筛选
if (options.startDate) {
const startDate = new Date(options.startDate)
notifications = notifications.filter(n =>
new Date(n.timestamp) >= startDate
)
}
if (options.endDate) {
const endDate = new Date(options.endDate)
notifications = notifications.filter(n =>
new Date(n.timestamp) <= endDate
)
}
// 按类型筛选
if (options.types && options.types.length > 0) {
notifications = notifications.filter(n =>
options.types.includes(n.type)
)
}
// 按分类筛选
if (options.categories && options.categories.length > 0) {
notifications = notifications.filter(n =>
options.categories.includes(n.category)
)
}
return notifications
}
},
// 持久化配置
persist: {
key: 'government-admin-notifications',
storage: localStorage,
paths: ['notifications', 'settings']
}
})

View File

@@ -0,0 +1,288 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/utils/api'
export const usePermissionStore = defineStore('permission', () => {
// 状态
const permissions = ref([])
const roles = ref([])
const userRole = ref('')
const menuList = ref([])
const loading = ref(false)
// 计算属性
const hasPermission = computed(() => {
return (permission) => {
if (!permission) return true
return permissions.value.includes(permission)
}
})
const hasRole = computed(() => {
return (role) => {
if (!role) return true
return roles.value.includes(role) || userRole.value === role
}
})
const hasAnyPermission = computed(() => {
return (permissionList) => {
if (!permissionList || !Array.isArray(permissionList)) return true
return permissionList.some(permission => permissions.value.includes(permission))
}
})
const hasAllPermissions = computed(() => {
return (permissionList) => {
if (!permissionList || !Array.isArray(permissionList)) return true
return permissionList.every(permission => permissions.value.includes(permission))
}
})
// 过滤后的菜单(根据权限)
const filteredMenus = computed(() => {
return filterMenusByPermission(menuList.value)
})
// 方法
const setPermissions = (newPermissions) => {
permissions.value = newPermissions || []
}
const setRoles = (newRoles) => {
roles.value = newRoles || []
}
const setUserRole = (role) => {
userRole.value = role || ''
}
const setMenuList = (menus) => {
menuList.value = menus || []
}
// 获取用户权限
const fetchUserPermissions = async () => {
try {
loading.value = true
const response = await api.get('/auth/permissions')
if (response.success) {
setPermissions(response.data.permissions)
setRoles(response.data.roles)
setUserRole(response.data.role)
return response.data
} else {
throw new Error(response.message || '获取权限失败')
}
} catch (error) {
console.error('获取用户权限失败:', error)
throw error
} finally {
loading.value = false
}
}
// 获取菜单列表
const fetchMenuList = async () => {
try {
const response = await api.get('/auth/menus')
if (response.success) {
setMenuList(response.data)
return response.data
} else {
throw new Error(response.message || '获取菜单失败')
}
} catch (error) {
console.error('获取菜单列表失败:', error)
throw error
}
}
// 根据权限过滤菜单
const filterMenusByPermission = (menus) => {
return menus.filter(menu => {
// 检查菜单权限
if (menu.permission && !hasPermission.value(menu.permission)) {
return false
}
// 检查角色权限
if (menu.roles && menu.roles.length > 0) {
const hasRequiredRole = menu.roles.some(role => hasRole.value(role))
if (!hasRequiredRole) {
return false
}
}
// 递归过滤子菜单
if (menu.children && menu.children.length > 0) {
menu.children = filterMenusByPermission(menu.children)
}
return true
})
}
// 检查路由权限
const checkRoutePermission = (route) => {
// 检查路由元信息中的权限
if (route.meta?.permission) {
return hasPermission.value(route.meta.permission)
}
// 检查路由元信息中的角色
if (route.meta?.roles && route.meta.roles.length > 0) {
return route.meta.roles.some(role => hasRole.value(role))
}
// 默认允许访问
return true
}
// 重置权限数据
const resetPermissions = () => {
permissions.value = []
roles.value = []
userRole.value = ''
menuList.value = []
}
// 权限常量定义
const PERMISSIONS = {
// 养殖场管理
FARM_VIEW: 'farm:view',
FARM_CREATE: 'farm:create',
FARM_UPDATE: 'farm:update',
FARM_DELETE: 'farm:delete',
FARM_EXPORT: 'farm:export',
// 设备管理
DEVICE_VIEW: 'device:view',
DEVICE_CREATE: 'device:create',
DEVICE_UPDATE: 'device:update',
DEVICE_DELETE: 'device:delete',
DEVICE_CONTROL: 'device:control',
// 监控管理
MONITOR_VIEW: 'monitor:view',
MONITOR_ALERT: 'monitor:alert',
MONITOR_REPORT: 'monitor:report',
// 数据管理
DATA_VIEW: 'data:view',
DATA_EXPORT: 'data:export',
DATA_ANALYSIS: 'data:analysis',
// 用户管理
USER_VIEW: 'user:view',
USER_CREATE: 'user:create',
USER_UPDATE: 'user:update',
USER_DELETE: 'user:delete',
// 系统管理
SYSTEM_CONFIG: 'system:config',
SYSTEM_LOG: 'system:log',
SYSTEM_BACKUP: 'system:backup'
}
// 角色常量定义
const ROLES = {
SUPER_ADMIN: 'super_admin',
ADMIN: 'admin',
MANAGER: 'manager',
OPERATOR: 'operator',
VIEWER: 'viewer'
}
return {
// 状态
permissions,
roles,
userRole,
menuList,
loading,
// 计算属性
hasPermission,
hasRole,
hasAnyPermission,
hasAllPermissions,
filteredMenus,
// 方法
setPermissions,
setRoles,
setUserRole,
setMenuList,
fetchUserPermissions,
fetchMenuList,
filterMenusByPermission,
checkRoutePermission,
resetPermissions,
// 常量
PERMISSIONS,
ROLES
}
})
// 权限指令
export const permissionDirective = {
mounted(el, binding) {
const permissionStore = usePermissionStore()
const { value } = binding
if (value) {
let hasPermission = false
if (typeof value === 'string') {
hasPermission = permissionStore.hasPermission(value)
} else if (Array.isArray(value)) {
hasPermission = permissionStore.hasAnyPermission(value)
} else if (typeof value === 'object') {
if (value.permission) {
hasPermission = permissionStore.hasPermission(value.permission)
} else if (value.role) {
hasPermission = permissionStore.hasRole(value.role)
} else if (value.permissions) {
hasPermission = value.all
? permissionStore.hasAllPermissions(value.permissions)
: permissionStore.hasAnyPermission(value.permissions)
}
}
if (!hasPermission) {
el.style.display = 'none'
}
}
},
updated(el, binding) {
const permissionStore = usePermissionStore()
const { value } = binding
if (value) {
let hasPermission = false
if (typeof value === 'string') {
hasPermission = permissionStore.hasPermission(value)
} else if (Array.isArray(value)) {
hasPermission = permissionStore.hasAnyPermission(value)
} else if (typeof value === 'object') {
if (value.permission) {
hasPermission = permissionStore.hasPermission(value.permission)
} else if (value.role) {
hasPermission = permissionStore.hasRole(value.role)
} else if (value.permissions) {
hasPermission = value.all
? permissionStore.hasAllPermissions(value.permissions)
: permissionStore.hasAnyPermission(value.permissions)
}
}
el.style.display = hasPermission ? '' : 'none'
}
}
}

View File

@@ -0,0 +1,294 @@
/**
* 标签页状态管理
*/
import { defineStore } from 'pinia'
export const useTabsStore = defineStore('tabs', {
state: () => ({
// 打开的标签页列表
openTabs: [
{
path: '/dashboard',
title: '仪表盘',
closable: false
}
],
// 缓存的视图组件名称列表
cachedViews: ['Dashboard']
}),
getters: {
// 获取当前活跃的标签页
activeTab: (state) => {
return state.openTabs.find(tab => tab.active) || state.openTabs[0]
},
// 获取可关闭的标签页
closableTabs: (state) => {
return state.openTabs.filter(tab => tab.closable)
},
// 获取标签页数量
tabCount: (state) => {
return state.openTabs.length
}
},
actions: {
/**
* 添加标签页
* @param {Object} tab - 标签页信息
*/
addTab(tab) {
const existingTab = this.openTabs.find(t => t.path === tab.path)
if (!existingTab) {
// 新标签页,添加到列表
this.openTabs.push({
path: tab.path,
title: tab.title,
closable: tab.closable !== false,
active: true
})
// 添加到缓存列表
if (tab.name && !this.cachedViews.includes(tab.name)) {
this.cachedViews.push(tab.name)
}
} else {
// 已存在的标签页,激活它
this.setActiveTab(tab.path)
}
// 取消其他标签页的激活状态
this.openTabs.forEach(t => {
t.active = t.path === tab.path
})
},
/**
* 移除标签页
* @param {string} path - 标签页路径
*/
removeTab(path) {
const index = this.openTabs.findIndex(tab => tab.path === path)
if (index > -1) {
const removedTab = this.openTabs[index]
this.openTabs.splice(index, 1)
// 从缓存中移除
if (removedTab.name) {
const cacheIndex = this.cachedViews.indexOf(removedTab.name)
if (cacheIndex > -1) {
this.cachedViews.splice(cacheIndex, 1)
}
}
// 如果移除的是当前激活的标签页,需要激活其他标签页
if (removedTab.active && this.openTabs.length > 0) {
const nextTab = this.openTabs[Math.min(index, this.openTabs.length - 1)]
this.setActiveTab(nextTab.path)
}
}
},
/**
* 设置活跃标签页
* @param {string} path - 标签页路径
*/
setActiveTab(path) {
this.openTabs.forEach(tab => {
tab.active = tab.path === path
})
},
/**
* 关闭其他标签页
* @param {string} currentPath - 当前标签页路径
*/
closeOtherTabs(currentPath) {
const currentTab = this.openTabs.find(tab => tab.path === currentPath)
const fixedTabs = this.openTabs.filter(tab => !tab.closable)
if (currentTab) {
this.openTabs = [...fixedTabs, currentTab]
// 更新缓存列表
const keepNames = this.openTabs
.map(tab => tab.name)
.filter(name => name)
this.cachedViews = this.cachedViews.filter(name =>
keepNames.includes(name)
)
}
},
/**
* 关闭所有可关闭的标签页
*/
closeAllTabs() {
this.openTabs = this.openTabs.filter(tab => !tab.closable)
// 更新缓存列表
const keepNames = this.openTabs
.map(tab => tab.name)
.filter(name => name)
this.cachedViews = this.cachedViews.filter(name =>
keepNames.includes(name)
)
// 激活第一个标签页
if (this.openTabs.length > 0) {
this.setActiveTab(this.openTabs[0].path)
}
},
/**
* 关闭左侧标签页
* @param {string} currentPath - 当前标签页路径
*/
closeLeftTabs(currentPath) {
const currentIndex = this.openTabs.findIndex(tab => tab.path === currentPath)
if (currentIndex > 0) {
const leftTabs = this.openTabs.slice(0, currentIndex)
const closableLeftTabs = leftTabs.filter(tab => tab.closable)
// 移除可关闭的左侧标签页
closableLeftTabs.forEach(tab => {
this.removeTab(tab.path)
})
}
},
/**
* 关闭右侧标签页
* @param {string} currentPath - 当前标签页路径
*/
closeRightTabs(currentPath) {
const currentIndex = this.openTabs.findIndex(tab => tab.path === currentPath)
if (currentIndex < this.openTabs.length - 1) {
const rightTabs = this.openTabs.slice(currentIndex + 1)
const closableRightTabs = rightTabs.filter(tab => tab.closable)
// 移除可关闭的右侧标签页
closableRightTabs.forEach(tab => {
this.removeTab(tab.path)
})
}
},
/**
* 刷新标签页
* @param {string} path - 标签页路径
*/
refreshTab(path) {
const tab = this.openTabs.find(t => t.path === path)
if (tab && tab.name) {
// 从缓存中移除,强制重新渲染
const cacheIndex = this.cachedViews.indexOf(tab.name)
if (cacheIndex > -1) {
this.cachedViews.splice(cacheIndex, 1)
}
// 延迟重新添加到缓存
setTimeout(() => {
this.cachedViews.push(tab.name)
}, 100)
}
},
/**
* 更新标签页标题
* @param {string} path - 标签页路径
* @param {string} title - 新标题
*/
updateTabTitle(path, title) {
const tab = this.openTabs.find(t => t.path === path)
if (tab) {
tab.title = title
}
},
/**
* 检查标签页是否存在
* @param {string} path - 标签页路径
* @returns {boolean}
*/
hasTab(path) {
return this.openTabs.some(tab => tab.path === path)
},
/**
* 获取标签页信息
* @param {string} path - 标签页路径
* @returns {Object|null}
*/
getTab(path) {
return this.openTabs.find(tab => tab.path === path) || null
},
/**
* 重置标签页状态
*/
resetTabs() {
this.openTabs = [
{
path: '/dashboard',
title: '仪表盘',
closable: false,
active: true
}
]
this.cachedViews = ['Dashboard']
},
/**
* 批量添加标签页
* @param {Array} tabs - 标签页列表
*/
addTabs(tabs) {
tabs.forEach(tab => {
this.addTab(tab)
})
},
/**
* 移动标签页位置
* @param {number} oldIndex - 原位置
* @param {number} newIndex - 新位置
*/
moveTab(oldIndex, newIndex) {
if (oldIndex >= 0 && oldIndex < this.openTabs.length &&
newIndex >= 0 && newIndex < this.openTabs.length) {
const tab = this.openTabs.splice(oldIndex, 1)[0]
this.openTabs.splice(newIndex, 0, tab)
}
},
/**
* 固定/取消固定标签页
* @param {string} path - 标签页路径
* @param {boolean} pinned - 是否固定
*/
pinTab(path, pinned = true) {
const tab = this.openTabs.find(t => t.path === path)
if (tab) {
tab.closable = !pinned
tab.pinned = pinned
}
}
},
// 持久化配置
persist: {
key: 'government-admin-tabs',
storage: localStorage,
paths: ['openTabs', 'cachedViews']
}
})

View File

@@ -0,0 +1,116 @@
/**
* 用户状态管理
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// 状态
const token = ref(localStorage.getItem('token') || '')
const userInfo = ref(JSON.parse(localStorage.getItem('userInfo') || 'null'))
const permissions = ref([])
const roles = ref([])
// 计算属性
const isLoggedIn = computed(() => !!token.value && !!userInfo.value)
const userName = computed(() => userInfo.value?.name || '管理员')
const userRole = computed(() => userInfo.value?.role || '系统管理员')
// 检查登录状态
function checkLoginStatus() {
const savedToken = localStorage.getItem('token')
const savedUserInfo = localStorage.getItem('userInfo')
if (savedToken && savedUserInfo) {
try {
token.value = savedToken
userInfo.value = JSON.parse(savedUserInfo)
return true
} catch (error) {
console.error('解析用户数据失败', error)
logout()
return false
}
}
return false
}
// 登录
async function login(credentials) {
try {
// 这里应该调用实际的登录API
// const response = await authApi.login(credentials)
// 模拟登录成功
const mockUser = {
id: 1,
name: credentials.username || '管理员',
role: '系统管理员',
avatar: '',
email: 'admin@example.com'
}
token.value = 'mock-token-' + Date.now()
userInfo.value = mockUser
localStorage.setItem('token', token.value)
localStorage.setItem('userInfo', JSON.stringify(mockUser))
return { success: true, data: mockUser }
} catch (error) {
console.error('登录失败:', error)
return { success: false, message: '登录失败' }
}
}
// 登出
function logout() {
token.value = ''
userInfo.value = null
permissions.value = []
roles.value = []
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
}
// 更新用户信息
function updateUserInfo(newUserInfo) {
userInfo.value = { ...userInfo.value, ...newUserInfo }
localStorage.setItem('userInfo', JSON.stringify(userInfo.value))
}
// 权限检查
function hasPermission(permission) {
if (!userInfo.value) return false
// 管理员拥有所有权限
if (userInfo.value.role === '系统管理员' || userInfo.value.role === 'admin') {
return true
}
return permissions.value.includes(permission)
}
// 角色检查
function hasRole(role) {
if (!userInfo.value) return false
return roles.value.includes(role) || userInfo.value.role === role
}
return {
token,
userInfo,
permissions,
roles,
isLoggedIn,
userName,
userRole,
checkLoginStatus,
login,
logout,
updateUserInfo,
hasPermission,
hasRole
}
})

View File

@@ -0,0 +1,11 @@
/* 全局基础样式 */
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
min-height: 100vh;
}

View File

@@ -0,0 +1,211 @@
// 全局样式文件
// 变量定义
:root {
--primary-color: #1890ff;
--success-color: #52c41a;
--warning-color: #faad14;
--error-color: #f5222d;
--info-color: #1890ff;
--text-color: rgba(0, 0, 0, 0.85);
--text-color-secondary: rgba(0, 0, 0, 0.65);
--text-color-disabled: rgba(0, 0, 0, 0.25);
--background-color: #f0f2f5;
--component-background: #ffffff;
--border-color: #d9d9d9;
--border-radius: 6px;
--shadow-1: 0 2px 8px rgba(0, 0, 0, 0.15);
--shadow-2: 0 4px 12px rgba(0, 0, 0, 0.15);
}
// 通用工具类
.text-center { text-align: center; }
.text-left { text-align: left; }
.text-right { text-align: right; }
.flex { display: flex; }
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-column { flex-direction: column; }
.flex-wrap { flex-wrap: wrap; }
.flex-1 { flex: 1; }
.mb-0 { margin-bottom: 0 !important; }
.mb-8 { margin-bottom: 8px; }
.mb-16 { margin-bottom: 16px; }
.mb-24 { margin-bottom: 24px; }
.mb-32 { margin-bottom: 32px; }
.mt-0 { margin-top: 0 !important; }
.mt-8 { margin-top: 8px; }
.mt-16 { margin-top: 16px; }
.mt-24 { margin-top: 24px; }
.mt-32 { margin-top: 32px; }
.ml-8 { margin-left: 8px; }
.ml-16 { margin-left: 16px; }
.mr-8 { margin-right: 8px; }
.mr-16 { margin-right: 16px; }
.p-16 { padding: 16px; }
.p-24 { padding: 24px; }
.pt-16 { padding-top: 16px; }
.pb-16 { padding-bottom: 16px; }
// 卡片样式
.card {
background: var(--component-background);
border-radius: var(--border-radius);
box-shadow: var(--shadow-1);
padding: 24px;
margin-bottom: 16px;
&.no-padding {
padding: 0;
}
&.small-padding {
padding: 16px;
}
}
// 页面容器
.page-container {
padding: 24px;
min-height: calc(100vh - 64px);
.page-header {
margin-bottom: 24px;
.page-title {
font-size: 20px;
font-weight: 500;
color: var(--text-color);
margin-bottom: 8px;
}
.page-description {
color: var(--text-color-secondary);
font-size: 14px;
}
}
}
// 表格样式增强
.ant-table {
.ant-table-thead > tr > th {
background: #fafafa;
font-weight: 500;
}
.ant-table-tbody > tr:hover > td {
background: #f5f5f5;
}
}
// 表单样式增强
.ant-form {
.ant-form-item-label > label {
font-weight: 500;
}
}
// 状态标签
.status-tag {
&.online { color: var(--success-color); }
&.offline { color: var(--error-color); }
&.warning { color: var(--warning-color); }
&.maintenance { color: var(--text-color-secondary); }
}
// 数据卡片
.data-card {
background: var(--component-background);
border-radius: var(--border-radius);
padding: 20px;
box-shadow: var(--shadow-1);
transition: all 0.3s;
&:hover {
box-shadow: var(--shadow-2);
transform: translateY(-2px);
}
.data-card-title {
font-size: 14px;
color: var(--text-color-secondary);
margin-bottom: 8px;
}
.data-card-value {
font-size: 24px;
font-weight: 500;
color: var(--text-color);
margin-bottom: 4px;
}
.data-card-trend {
font-size: 12px;
&.up { color: var(--success-color); }
&.down { color: var(--error-color); }
}
}
// 响应式设计
@media (max-width: 768px) {
.page-container {
padding: 16px;
}
.ant-table {
font-size: 12px;
}
.data-card {
padding: 16px;
.data-card-value {
font-size: 20px;
}
}
}
// 加载状态
.loading-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
}
// 空状态
.empty-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--text-color-secondary);
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-text {
font-size: 14px;
}
}

View File

@@ -0,0 +1,447 @@
@import './variables.scss';
// 清除浮动
@mixin clearfix {
&::after {
content: '';
display: table;
clear: both;
}
}
// 文本省略
@mixin text-ellipsis($lines: 1) {
@if $lines == 1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} @else {
display: -webkit-box;
-webkit-line-clamp: $lines;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
}
// 居中对齐
@mixin center($type: 'both') {
position: absolute;
@if $type == 'both' {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
} @else if $type == 'horizontal' {
left: 50%;
transform: translateX(-50%);
} @else if $type == 'vertical' {
top: 50%;
transform: translateY(-50%);
}
}
// Flex 布局
@mixin flex($direction: row, $justify: flex-start, $align: stretch, $wrap: nowrap) {
display: flex;
flex-direction: $direction;
justify-content: $justify;
align-items: $align;
flex-wrap: $wrap;
}
// 响应式断点
@mixin respond-to($breakpoint) {
@if $breakpoint == xs {
@media (max-width: #{$screen-xs - 1px}) {
@content;
}
}
@if $breakpoint == sm {
@media (min-width: #{$screen-sm}) and (max-width: #{$screen-md - 1px}) {
@content;
}
}
@if $breakpoint == md {
@media (min-width: #{$screen-md}) and (max-width: #{$screen-lg - 1px}) {
@content;
}
}
@if $breakpoint == lg {
@media (min-width: #{$screen-lg}) and (max-width: #{$screen-xl - 1px}) {
@content;
}
}
@if $breakpoint == xl {
@media (min-width: #{$screen-xl}) and (max-width: #{$screen-xxl - 1px}) {
@content;
}
}
@if $breakpoint == xxl {
@media (min-width: #{$screen-xxl}) {
@content;
}
}
}
// 阴影效果
@mixin box-shadow($level: 1) {
@if $level == 1 {
box-shadow: $box-shadow-sm;
} @else if $level == 2 {
box-shadow: $box-shadow-base;
} @else if $level == 3 {
box-shadow: $box-shadow-lg;
} @else if $level == 4 {
box-shadow: $box-shadow-xl;
}
}
// 渐变背景
@mixin gradient-bg($direction: 135deg, $start-color: $primary-color, $end-color: lighten($primary-color, 10%)) {
background: linear-gradient($direction, $start-color 0%, $end-color 100%);
}
// 按钮样式
@mixin button-variant($color, $background, $border: $background) {
color: $color;
background-color: $background;
border-color: $border;
&:hover,
&:focus {
color: $color;
background-color: lighten($background, 5%);
border-color: lighten($border, 5%);
}
&:active {
color: $color;
background-color: darken($background, 5%);
border-color: darken($border, 5%);
}
&:disabled,
&.disabled {
color: $text-color-disabled;
background-color: $background-color;
border-color: $border-color;
cursor: not-allowed;
}
}
// 输入框样式
@mixin input-variant($border-color: $border-color, $focus-color: $primary-color) {
border-color: $border-color;
&:hover {
border-color: lighten($focus-color, 20%);
}
&:focus,
&.focused {
border-color: $focus-color;
box-shadow: 0 0 0 2px fade($focus-color, 20%);
outline: none;
}
}
// 卡片样式
@mixin card-variant($padding: $card-padding-base, $radius: $border-radius-base, $shadow: $box-shadow-base) {
background: $background-color-light;
border-radius: $radius;
box-shadow: $shadow;
padding: $padding;
transition: box-shadow $animation-duration-base $ease-out;
&:hover {
box-shadow: $box-shadow-lg;
}
}
// 标签样式
@mixin tag-variant($color, $background, $border: $background) {
color: $color;
background-color: $background;
border-color: $border;
border-radius: $tag-border-radius;
padding: 0 7px;
font-size: $tag-font-size;
line-height: $tag-line-height;
display: inline-block;
height: auto;
margin: 0;
white-space: nowrap;
cursor: default;
opacity: 1;
transition: all $animation-duration-base;
}
// 动画效果
@mixin fade-in($duration: $animation-duration-base) {
animation: fadeIn $duration $ease-out;
}
@mixin slide-up($duration: $animation-duration-base) {
animation: slideUp $duration $ease-out;
}
@mixin slide-down($duration: $animation-duration-base) {
animation: slideDown $duration $ease-out;
}
@mixin zoom-in($duration: $animation-duration-base) {
animation: zoomIn $duration $ease-out;
}
// 加载动画
@mixin loading-spin($size: 14px, $color: $primary-color) {
display: inline-block;
width: $size;
height: $size;
border: 2px solid fade($color, 20%);
border-top-color: $color;
border-radius: 50%;
animation: spin 1s linear infinite;
}
// 脉冲动画
@mixin pulse($color: $primary-color) {
animation: pulse 2s infinite;
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 fade($color, 70%);
}
70% {
box-shadow: 0 0 0 10px fade($color, 0%);
}
100% {
box-shadow: 0 0 0 0 fade($color, 0%);
}
}
}
// 滚动条样式
@mixin scrollbar($width: 6px, $track-color: $background-color, $thumb-color: $border-color) {
&::-webkit-scrollbar {
width: $width;
height: $width;
}
&::-webkit-scrollbar-track {
background: $track-color;
border-radius: $width / 2;
}
&::-webkit-scrollbar-thumb {
background: $thumb-color;
border-radius: $width / 2;
&:hover {
background: darken($thumb-color, 10%);
}
}
}
// 表格斑马纹
@mixin table-striped($odd-color: $background-color-light, $even-color: transparent) {
tbody tr:nth-child(odd) {
background-color: $odd-color;
}
tbody tr:nth-child(even) {
background-color: $even-color;
}
}
// 工具提示箭头
@mixin tooltip-arrow($direction: top, $size: 4px, $color: $tooltip-bg) {
&::before {
content: '';
position: absolute;
width: 0;
height: 0;
border: $size solid transparent;
@if $direction == top {
bottom: 100%;
left: 50%;
margin-left: -$size;
border-bottom-color: $color;
} @else if $direction == bottom {
top: 100%;
left: 50%;
margin-left: -$size;
border-top-color: $color;
} @else if $direction == left {
right: 100%;
top: 50%;
margin-top: -$size;
border-right-color: $color;
} @else if $direction == right {
left: 100%;
top: 50%;
margin-top: -$size;
border-left-color: $color;
}
}
}
// 文字渐变
@mixin text-gradient($start-color: $primary-color, $end-color: lighten($primary-color, 20%)) {
background: linear-gradient(45deg, $start-color, $end-color);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
// 毛玻璃效果
@mixin glass-morphism($blur: 10px, $opacity: 0.1) {
backdrop-filter: blur($blur);
background: rgba(255, 255, 255, $opacity);
border: 1px solid rgba(255, 255, 255, 0.2);
}
// 网格布局
@mixin grid($columns: 12, $gap: $spacing-md) {
display: grid;
grid-template-columns: repeat($columns, 1fr);
gap: $gap;
}
// 粘性定位
@mixin sticky($top: 0, $z-index: $zindex-sticky) {
position: sticky;
top: $top;
z-index: $z-index;
}
// 隐藏文本(用于图标替换)
@mixin hide-text {
font: 0/0 a;
color: transparent;
text-shadow: none;
background-color: transparent;
border: 0;
}
// 三角形
@mixin triangle($direction: up, $size: 6px, $color: $text-color) {
width: 0;
height: 0;
@if $direction == up {
border-left: $size solid transparent;
border-right: $size solid transparent;
border-bottom: $size solid $color;
} @else if $direction == down {
border-left: $size solid transparent;
border-right: $size solid transparent;
border-top: $size solid $color;
} @else if $direction == left {
border-top: $size solid transparent;
border-bottom: $size solid transparent;
border-right: $size solid $color;
} @else if $direction == right {
border-top: $size solid transparent;
border-bottom: $size solid transparent;
border-left: $size solid $color;
}
}
// 关键帧动画
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slideDown {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes zoomIn {
from {
transform: scale(0);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes bounce {
0%, 20%, 53%, 80%, 100% {
transform: translate3d(0, 0, 0);
}
40%, 43% {
transform: translate3d(0, -30px, 0);
}
70% {
transform: translate3d(0, -15px, 0);
}
90% {
transform: translate3d(0, -4px, 0);
}
}
@keyframes shake {
0%, 100% {
transform: translateX(0);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-10px);
}
20%, 40%, 60%, 80% {
transform: translateX(10px);
}
}
@keyframes heartbeat {
0% {
transform: scale(1);
}
14% {
transform: scale(1.3);
}
28% {
transform: scale(1);
}
42% {
transform: scale(1.3);
}
70% {
transform: scale(1);
}
}

View File

@@ -0,0 +1,134 @@
import axios from 'axios'
import { message } from 'ant-design-vue'
import router from '@/router'
// 创建axios实例
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
api.interceptors.request.use(
(config) => {
// 添加认证token
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// 添加时间戳防止缓存
if (config.method === 'get') {
config.params = {
...config.params,
_t: Date.now()
}
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
(response) => {
const { code, message: msg } = response.data
// 处理业务错误码
if (code && code !== 200) {
message.error(msg || '请求失败')
return Promise.reject(new Error(msg || '请求失败'))
}
return response
},
(error) => {
const { response } = error
if (response) {
const { status, data } = response
switch (status) {
case 401:
// 未授权清除token并跳转到登录页
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
localStorage.removeItem('permissions')
router.push('/login')
message.error('登录已过期,请重新登录')
break
case 403:
message.error('没有权限访问该资源')
break
case 404:
message.error('请求的资源不存在')
break
case 500:
message.error('服务器内部错误')
break
default:
message.error(data?.message || `请求失败 (${status})`)
}
} else if (error.code === 'ECONNABORTED') {
message.error('请求超时,请稍后重试')
} else {
message.error('网络错误,请检查网络连接')
}
return Promise.reject(error)
}
)
// 封装常用请求方法
export const request = {
get: (url, params = {}) => api.get(url, { params }),
post: (url, data = {}) => api.post(url, data),
put: (url, data = {}) => api.put(url, data),
delete: (url, params = {}) => api.delete(url, { params }),
patch: (url, data = {}) => api.patch(url, data)
}
// 文件上传
export const upload = (url, formData, onProgress) => {
return api.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: onProgress
})
}
// 文件下载
export const download = async (url, filename, params = {}) => {
try {
const response = await api.get(url, {
params,
responseType: 'blob'
})
const blob = new Blob([response.data])
const downloadUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = downloadUrl
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(downloadUrl)
} catch (error) {
message.error('文件下载失败')
throw error
}
}
export default api

View File

@@ -0,0 +1,508 @@
import { usePermissionStore } from '@/stores/permission'
/**
* 权限检查工具函数
*/
// 检查单个权限
export function hasPermission(permission) {
const permissionStore = usePermissionStore()
return permissionStore.hasPermission(permission)
}
// 检查角色
export function hasRole(role) {
const permissionStore = usePermissionStore()
return permissionStore.hasRole(role)
}
// 检查任一权限
export function hasAnyPermission(permissions) {
const permissionStore = usePermissionStore()
return permissionStore.hasAnyPermission(permissions)
}
// 检查全部权限
export function hasAllPermissions(permissions) {
const permissionStore = usePermissionStore()
return permissionStore.hasAllPermissions(permissions)
}
// 检查路由权限
export function checkRoutePermission(route) {
const permissionStore = usePermissionStore()
return permissionStore.checkRoutePermission(route)
}
/**
* 权限装饰器
* 用于方法级别的权限控制
*/
export function requirePermission(permission) {
return function(target, propertyKey, descriptor) {
const originalMethod = descriptor.value
descriptor.value = function(...args) {
if (hasPermission(permission)) {
return originalMethod.apply(this, args)
} else {
console.warn(`权限不足: ${permission}`)
return Promise.reject(new Error('权限不足'))
}
}
return descriptor
}
}
/**
* 角色装饰器
* 用于方法级别的角色控制
*/
export function requireRole(role) {
return function(target, propertyKey, descriptor) {
const originalMethod = descriptor.value
descriptor.value = function(...args) {
if (hasRole(role)) {
return originalMethod.apply(this, args)
} else {
console.warn(`角色权限不足: ${role}`)
return Promise.reject(new Error('角色权限不足'))
}
}
return descriptor
}
}
/**
* 权限混入
* 为组件提供权限检查方法
*/
export const permissionMixin = {
methods: {
$hasPermission: hasPermission,
$hasRole: hasRole,
$hasAnyPermission: hasAnyPermission,
$hasAllPermissions: hasAllPermissions,
// 权限检查快捷方法
$canView(resource) {
return hasPermission(`${resource}:view`)
},
$canCreate(resource) {
return hasPermission(`${resource}:create`)
},
$canUpdate(resource) {
return hasPermission(`${resource}:update`)
},
$canDelete(resource) {
return hasPermission(`${resource}:delete`)
},
$canExport(resource) {
return hasPermission(`${resource}:export`)
}
}
}
/**
* 权限常量
*/
export const PERMISSIONS = {
// 养殖场管理
FARM_VIEW: 'farm:view',
FARM_CREATE: 'farm:create',
FARM_UPDATE: 'farm:update',
FARM_DELETE: 'farm:delete',
FARM_EXPORT: 'farm:export',
// 设备管理
DEVICE_VIEW: 'device:view',
DEVICE_CREATE: 'device:create',
DEVICE_UPDATE: 'device:update',
DEVICE_DELETE: 'device:delete',
DEVICE_CONTROL: 'device:control',
// 监控管理
MONITOR_VIEW: 'monitor:view',
MONITOR_ALERT: 'monitor:alert',
MONITOR_REPORT: 'monitor:report',
// 数据管理
DATA_VIEW: 'data:view',
DATA_EXPORT: 'data:export',
DATA_ANALYSIS: 'data:analysis',
// 用户管理
USER_VIEW: 'user:view',
USER_CREATE: 'user:create',
USER_UPDATE: 'user:update',
USER_DELETE: 'user:delete',
// 系统管理
SYSTEM_CONFIG: 'system:config',
SYSTEM_LOG: 'system:log',
SYSTEM_BACKUP: 'system:backup',
// 政府监管
SUPERVISION_VIEW: 'supervision:view',
SUPERVISION_CREATE: 'supervision:create',
SUPERVISION_UPDATE: 'supervision:update',
SUPERVISION_DELETE: 'supervision:delete',
SUPERVISION_APPROVE: 'supervision:approve',
SUPERVISION_EXPORT: 'supervision:export',
// 审批管理
APPROVAL_VIEW: 'approval:view',
APPROVAL_CREATE: 'approval:create',
APPROVAL_UPDATE: 'approval:update',
APPROVAL_DELETE: 'approval:delete',
APPROVAL_APPROVE: 'approval:approve',
APPROVAL_REJECT: 'approval:reject',
APPROVAL_EXPORT: 'approval:export',
// 人员管理
PERSONNEL_VIEW: 'personnel:view',
PERSONNEL_CREATE: 'personnel:create',
PERSONNEL_UPDATE: 'personnel:update',
PERSONNEL_DELETE: 'personnel:delete',
PERSONNEL_ASSIGN: 'personnel:assign',
PERSONNEL_EXPORT: 'personnel:export',
// 设备仓库
WAREHOUSE_VIEW: 'warehouse:view',
WAREHOUSE_CREATE: 'warehouse:create',
WAREHOUSE_UPDATE: 'warehouse:update',
WAREHOUSE_DELETE: 'warehouse:delete',
WAREHOUSE_IN: 'warehouse:in',
WAREHOUSE_OUT: 'warehouse:out',
WAREHOUSE_EXPORT: 'warehouse:export',
// 防疫管理
EPIDEMIC_VIEW: 'epidemic:view',
EPIDEMIC_CREATE: 'epidemic:create',
EPIDEMIC_UPDATE: 'epidemic:update',
EPIDEMIC_DELETE: 'epidemic:delete',
EPIDEMIC_PLAN: 'epidemic:plan',
EPIDEMIC_REPORT: 'epidemic:report',
// 服务管理
SERVICE_VIEW: 'service:view',
SERVICE_CREATE: 'service:create',
SERVICE_UPDATE: 'service:update',
SERVICE_DELETE: 'service:delete',
SERVICE_ASSIGN: 'service:assign',
// 可视化大屏
VISUALIZATION_VIEW: 'visualization:view',
VISUALIZATION_CONFIG: 'visualization:config',
VISUALIZATION_EXPORT: 'visualization:export'
}
/**
* 角色常量
*/
export const ROLES = {
SUPER_ADMIN: 'super_admin',
ADMIN: 'admin',
MANAGER: 'manager',
OPERATOR: 'operator',
VIEWER: 'viewer'
}
/**
* 权限组合
*/
export const PERMISSION_GROUPS = {
// 养殖场管理权限组
FARM_MANAGEMENT: [
PERMISSIONS.FARM_VIEW,
PERMISSIONS.FARM_CREATE,
PERMISSIONS.FARM_UPDATE,
PERMISSIONS.FARM_DELETE,
PERMISSIONS.FARM_EXPORT
],
// 设备管理权限组
DEVICE_MANAGEMENT: [
PERMISSIONS.DEVICE_VIEW,
PERMISSIONS.DEVICE_CREATE,
PERMISSIONS.DEVICE_UPDATE,
PERMISSIONS.DEVICE_DELETE,
PERMISSIONS.DEVICE_CONTROL
],
// 监控管理权限组
MONITOR_MANAGEMENT: [
PERMISSIONS.MONITOR_VIEW,
PERMISSIONS.MONITOR_ALERT,
PERMISSIONS.MONITOR_REPORT
],
// 数据管理权限组
DATA_MANAGEMENT: [
PERMISSIONS.DATA_VIEW,
PERMISSIONS.DATA_EXPORT,
PERMISSIONS.DATA_ANALYSIS
],
// 用户管理权限组
USER_MANAGEMENT: [
PERMISSIONS.USER_VIEW,
PERMISSIONS.USER_CREATE,
PERMISSIONS.USER_UPDATE,
PERMISSIONS.USER_DELETE
],
// 系统管理权限组
SYSTEM_MANAGEMENT: [
PERMISSIONS.SYSTEM_CONFIG,
PERMISSIONS.SYSTEM_LOG,
PERMISSIONS.SYSTEM_BACKUP
],
// 政府监管
SUPERVISION_MANAGEMENT: [
PERMISSIONS.SUPERVISION_VIEW,
PERMISSIONS.SUPERVISION_CREATE,
PERMISSIONS.SUPERVISION_UPDATE,
PERMISSIONS.SUPERVISION_DELETE,
PERMISSIONS.SUPERVISION_APPROVE,
PERMISSIONS.SUPERVISION_EXPORT
],
// 审批管理
APPROVAL_MANAGEMENT: [
PERMISSIONS.APPROVAL_VIEW,
PERMISSIONS.APPROVAL_CREATE,
PERMISSIONS.APPROVAL_UPDATE,
PERMISSIONS.APPROVAL_DELETE,
PERMISSIONS.APPROVAL_APPROVE,
PERMISSIONS.APPROVAL_REJECT,
PERMISSIONS.APPROVAL_EXPORT
],
// 人员管理
PERSONNEL_MANAGEMENT: [
PERMISSIONS.PERSONNEL_VIEW,
PERMISSIONS.PERSONNEL_CREATE,
PERMISSIONS.PERSONNEL_UPDATE,
PERMISSIONS.PERSONNEL_DELETE,
PERMISSIONS.PERSONNEL_ASSIGN,
PERMISSIONS.PERSONNEL_EXPORT
],
// 设备仓库
WAREHOUSE_MANAGEMENT: [
PERMISSIONS.WAREHOUSE_VIEW,
PERMISSIONS.WAREHOUSE_CREATE,
PERMISSIONS.WAREHOUSE_UPDATE,
PERMISSIONS.WAREHOUSE_DELETE,
PERMISSIONS.WAREHOUSE_IN,
PERMISSIONS.WAREHOUSE_OUT,
PERMISSIONS.WAREHOUSE_EXPORT
],
// 防疫管理
EPIDEMIC_MANAGEMENT: [
PERMISSIONS.EPIDEMIC_VIEW,
PERMISSIONS.EPIDEMIC_CREATE,
PERMISSIONS.EPIDEMIC_UPDATE,
PERMISSIONS.EPIDEMIC_DELETE,
PERMISSIONS.EPIDEMIC_PLAN,
PERMISSIONS.EPIDEMIC_REPORT
],
// 服务管理
SERVICE_MANAGEMENT: [
PERMISSIONS.SERVICE_VIEW,
PERMISSIONS.SERVICE_CREATE,
PERMISSIONS.SERVICE_UPDATE,
PERMISSIONS.SERVICE_DELETE,
PERMISSIONS.SERVICE_ASSIGN
],
// 可视化大屏
VISUALIZATION_MANAGEMENT: [
PERMISSIONS.VISUALIZATION_VIEW,
PERMISSIONS.VISUALIZATION_CONFIG,
PERMISSIONS.VISUALIZATION_EXPORT
]
}
/**
* 角色权限映射
*/
export const ROLE_PERMISSIONS = {
[ROLES.SUPER_ADMIN]: [
...PERMISSION_GROUPS.FARM_MANAGEMENT,
...PERMISSION_GROUPS.DEVICE_MANAGEMENT,
...PERMISSION_GROUPS.MONITOR_MANAGEMENT,
...PERMISSION_GROUPS.DATA_MANAGEMENT,
...PERMISSION_GROUPS.USER_MANAGEMENT,
...PERMISSION_GROUPS.SYSTEM_MANAGEMENT,
...PERMISSION_GROUPS.SUPERVISION_MANAGEMENT,
...PERMISSION_GROUPS.APPROVAL_MANAGEMENT,
...PERMISSION_GROUPS.PERSONNEL_MANAGEMENT,
...PERMISSION_GROUPS.WAREHOUSE_MANAGEMENT,
...PERMISSION_GROUPS.EPIDEMIC_MANAGEMENT,
...PERMISSION_GROUPS.SERVICE_MANAGEMENT,
...PERMISSION_GROUPS.VISUALIZATION_MANAGEMENT
],
[ROLES.ADMIN]: [
...PERMISSION_GROUPS.FARM_MANAGEMENT,
...PERMISSION_GROUPS.DEVICE_MANAGEMENT,
...PERMISSION_GROUPS.MONITOR_MANAGEMENT,
...PERMISSION_GROUPS.DATA_MANAGEMENT,
PERMISSIONS.USER_VIEW,
PERMISSIONS.USER_CREATE,
PERMISSIONS.USER_UPDATE,
...PERMISSION_GROUPS.SUPERVISION_MANAGEMENT,
...PERMISSION_GROUPS.APPROVAL_MANAGEMENT,
...PERMISSION_GROUPS.PERSONNEL_MANAGEMENT,
...PERMISSION_GROUPS.WAREHOUSE_MANAGEMENT,
...PERMISSION_GROUPS.EPIDEMIC_MANAGEMENT,
...PERMISSION_GROUPS.SERVICE_MANAGEMENT,
PERMISSIONS.VISUALIZATION_VIEW,
PERMISSIONS.VISUALIZATION_CONFIG
],
[ROLES.MANAGER]: [
...PERMISSION_GROUPS.FARM_MANAGEMENT,
...PERMISSION_GROUPS.DEVICE_MANAGEMENT,
...PERMISSION_GROUPS.MONITOR_MANAGEMENT,
PERMISSIONS.DATA_VIEW,
PERMISSIONS.DATA_EXPORT,
PERMISSIONS.SUPERVISION_VIEW,
PERMISSIONS.SUPERVISION_CREATE,
PERMISSIONS.SUPERVISION_UPDATE,
PERMISSIONS.SUPERVISION_EXPORT,
PERMISSIONS.APPROVAL_VIEW,
PERMISSIONS.APPROVAL_APPROVE,
PERMISSIONS.APPROVAL_REJECT,
PERMISSIONS.PERSONNEL_VIEW,
PERMISSIONS.PERSONNEL_ASSIGN,
PERMISSIONS.WAREHOUSE_VIEW,
PERMISSIONS.WAREHOUSE_IN,
PERMISSIONS.WAREHOUSE_OUT,
PERMISSIONS.EPIDEMIC_VIEW,
PERMISSIONS.EPIDEMIC_PLAN,
PERMISSIONS.SERVICE_VIEW,
PERMISSIONS.SERVICE_ASSIGN,
PERMISSIONS.VISUALIZATION_VIEW
],
[ROLES.OPERATOR]: [
PERMISSIONS.FARM_VIEW,
PERMISSIONS.FARM_UPDATE,
PERMISSIONS.DEVICE_VIEW,
PERMISSIONS.DEVICE_CONTROL,
PERMISSIONS.MONITOR_VIEW,
PERMISSIONS.MONITOR_ALERT,
PERMISSIONS.DATA_VIEW,
PERMISSIONS.SUPERVISION_VIEW,
PERMISSIONS.SUPERVISION_CREATE,
PERMISSIONS.APPROVAL_VIEW,
PERMISSIONS.PERSONNEL_VIEW,
PERMISSIONS.WAREHOUSE_VIEW,
PERMISSIONS.WAREHOUSE_IN,
PERMISSIONS.WAREHOUSE_OUT,
PERMISSIONS.EPIDEMIC_VIEW,
PERMISSIONS.EPIDEMIC_CREATE,
PERMISSIONS.SERVICE_VIEW,
PERMISSIONS.VISUALIZATION_VIEW
],
[ROLES.VIEWER]: [
PERMISSIONS.FARM_VIEW,
PERMISSIONS.DEVICE_VIEW,
PERMISSIONS.MONITOR_VIEW,
PERMISSIONS.DATA_VIEW,
PERMISSIONS.SUPERVISION_VIEW,
PERMISSIONS.APPROVAL_VIEW,
PERMISSIONS.PERSONNEL_VIEW,
PERMISSIONS.WAREHOUSE_VIEW,
PERMISSIONS.EPIDEMIC_VIEW,
PERMISSIONS.SERVICE_VIEW,
PERMISSIONS.VISUALIZATION_VIEW
]
}
/**
* 获取角色对应的权限列表
*/
export function getRolePermissions(role) {
return ROLE_PERMISSIONS[role] || []
}
/**
* 检查权限是否属于某个权限组
*/
export function isPermissionInGroup(permission, group) {
return PERMISSION_GROUPS[group]?.includes(permission) || false
}
/**
* 格式化权限显示名称
*/
export function formatPermissionName(permission) {
const permissionNames = {
// 养殖场管理
'farm:view': '查看养殖场',
'farm:create': '新增养殖场',
'farm:update': '编辑养殖场',
'farm:delete': '删除养殖场',
'farm:export': '导出养殖场数据',
// 设备管理
'device:view': '查看设备',
'device:create': '新增设备',
'device:update': '编辑设备',
'device:delete': '删除设备',
'device:control': '控制设备',
// 监控管理
'monitor:view': '查看监控',
'monitor:alert': '处理预警',
'monitor:report': '生成报表',
// 数据管理
'data:view': '查看数据',
'data:export': '导出数据',
'data:analysis': '数据分析',
// 用户管理
'user:view': '查看用户',
'user:create': '新增用户',
'user:update': '编辑用户',
'user:delete': '删除用户',
// 系统管理
'system:config': '系统配置',
'system:log': '系统日志',
'system:backup': '系统备份'
}
return permissionNames[permission] || permission
}
/**
* 格式化角色显示名称
*/
export function formatRoleName(role) {
const roleNames = {
'super_admin': '超级管理员',
'admin': '管理员',
'manager': '经理',
'operator': '操作员',
'viewer': '查看者'
}
return roleNames[role] || role
}

View File

@@ -0,0 +1,307 @@
/**
* HTTP请求工具
*/
import axios from 'axios'
import { message, Modal } from 'ant-design-vue'
import { useUserStore } from '@/stores/user'
import { useNotificationStore } from '@/stores/notification'
import router from '@/router'
// 创建axios实例
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
request.interceptors.request.use(
(config) => {
const userStore = useUserStore()
// 添加认证token
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
// 添加请求ID用于追踪
config.headers['X-Request-ID'] = generateRequestId()
// 添加时间戳防止缓存
if (config.method === 'get') {
config.params = {
...config.params,
_t: Date.now()
}
}
// 开发环境下打印请求信息
if (import.meta.env.DEV) {
console.log('🚀 Request:', {
url: config.url,
method: config.method,
params: config.params,
data: config.data
})
}
return config
},
(error) => {
console.error('❌ Request Error:', error)
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
(response) => {
const { data, config } = response
// 开发环境下打印响应信息
if (import.meta.env.DEV) {
console.log('✅ Response:', {
url: config.url,
status: response.status,
data: data
})
}
// 统一处理响应格式
if (data && typeof data === 'object') {
// 标准响应格式: { code, data, message }
if (data.hasOwnProperty('code')) {
if (data.code === 200 || data.code === 0) {
return {
data: data.data,
message: data.message,
success: true
}
} else {
// 业务错误
const errorMessage = data.message || '请求失败'
message.error(errorMessage)
return Promise.reject(new Error(errorMessage))
}
}
// 直接返回数据
return {
data: data,
success: true
}
}
return {
data: data,
success: true
}
},
(error) => {
const { response, config } = error
const notificationStore = useNotificationStore()
console.error('❌ Response Error:', error)
// 网络错误
if (!response) {
const errorMessage = '网络连接失败,请检查网络设置'
message.error(errorMessage)
// 添加系统通知
notificationStore.addNotification({
type: 'error',
title: '网络错误',
content: errorMessage,
category: 'system'
})
return Promise.reject(new Error(errorMessage))
}
const { status, data } = response
let errorMessage = '请求失败'
// 根据状态码处理不同错误
switch (status) {
case 400:
errorMessage = data?.message || '请求参数错误'
break
case 401:
errorMessage = '登录已过期,请重新登录'
handleUnauthorized()
break
case 403:
errorMessage = '没有权限访问该资源'
break
case 404:
errorMessage = '请求的资源不存在'
break
case 422:
errorMessage = data?.message || '数据验证失败'
break
case 429:
errorMessage = '请求过于频繁,请稍后再试'
break
case 500:
errorMessage = '服务器内部错误'
break
case 502:
errorMessage = '网关错误'
break
case 503:
errorMessage = '服务暂时不可用'
break
case 504:
errorMessage = '请求超时'
break
default:
errorMessage = data?.message || `请求失败 (${status})`
}
// 显示错误消息
if (status !== 401) { // 401错误由handleUnauthorized处理
message.error(errorMessage)
}
// 添加错误通知
notificationStore.addNotification({
type: 'error',
title: '请求错误',
content: `${config.url}: ${errorMessage}`,
category: 'system'
})
return Promise.reject(new Error(errorMessage))
}
)
/**
* 处理未授权错误
*/
function handleUnauthorized() {
const userStore = useUserStore()
Modal.confirm({
title: '登录已过期',
content: '您的登录状态已过期,请重新登录',
okText: '重新登录',
cancelText: '取消',
onOk() {
userStore.logout()
router.push('/login')
}
})
}
/**
* 生成请求ID
*/
function generateRequestId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2)
}
/**
* 请求方法封装
*/
export const http = {
get: (url, config = {}) => request.get(url, config),
post: (url, data = {}, config = {}) => request.post(url, data, config),
put: (url, data = {}, config = {}) => request.put(url, data, config),
patch: (url, data = {}, config = {}) => request.patch(url, data, config),
delete: (url, config = {}) => request.delete(url, config),
upload: (url, formData, config = {}) => {
return request.post(url, formData, {
...config,
headers: {
'Content-Type': 'multipart/form-data',
...config.headers
}
})
},
download: (url, config = {}) => {
return request.get(url, {
...config,
responseType: 'blob'
})
}
}
/**
* 批量请求
*/
export const batchRequest = (requests) => {
return Promise.allSettled(requests.map(req => {
const { method = 'get', url, data, config } = req
return http[method](url, data, config)
}))
}
/**
* 重试请求
*/
export const retryRequest = async (requestFn, maxRetries = 3, delay = 1000) => {
let lastError
for (let i = 0; i <= maxRetries; i++) {
try {
return await requestFn()
} catch (error) {
lastError = error
if (i < maxRetries) {
await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, i)))
}
}
}
throw lastError
}
/**
* 取消请求的控制器
*/
export const createCancelToken = () => {
return axios.CancelToken.source()
}
/**
* 检查请求是否被取消
*/
export const isCancel = axios.isCancel
/**
* 请求缓存
*/
const requestCache = new Map()
export const cachedRequest = (key, requestFn, ttl = 5 * 60 * 1000) => {
const cached = requestCache.get(key)
if (cached && Date.now() - cached.timestamp < ttl) {
return Promise.resolve(cached.data)
}
return requestFn().then(data => {
requestCache.set(key, {
data,
timestamp: Date.now()
})
return data
})
}
/**
* 清除请求缓存
*/
export const clearRequestCache = (key) => {
if (key) {
requestCache.delete(key)
} else {
requestCache.clear()
}
}
export default request

View File

@@ -0,0 +1,681 @@
<template>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">仪表盘</h1>
<p class="page-description">宁夏智慧养殖监管平台数据概览</p>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="stats-row">
<a-col :xs="24" :sm="12" :lg="6">
<div class="data-card">
<div class="data-card-content">
<div class="data-card-icon farms">
<home-outlined />
</div>
<div class="data-card-info">
<div class="data-card-title">养殖场总数</div>
<div class="data-card-value">{{ stats.totalFarms }}</div>
<div class="data-card-trend up">
<arrow-up-outlined />
较上月 +12%
</div>
</div>
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<div class="data-card">
<div class="data-card-content">
<div class="data-card-icon devices">
<monitor-outlined />
</div>
<div class="data-card-info">
<div class="data-card-title">在线设备</div>
<div class="data-card-value">{{ stats.onlineDevices }}</div>
<div class="data-card-trend up">
<arrow-up-outlined />
在线率 95.2%
</div>
</div>
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<div class="data-card">
<div class="data-card-content">
<div class="data-card-icon animals">
<bug-outlined />
</div>
<div class="data-card-info">
<div class="data-card-title">动物总数</div>
<div class="data-card-value">{{ stats.totalAnimals }}</div>
<div class="data-card-trend up">
<arrow-up-outlined />
较上月 +8%
</div>
</div>
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<div class="data-card">
<div class="data-card-content">
<div class="data-card-icon alerts">
<alert-outlined />
</div>
<div class="data-card-info">
<div class="data-card-title">待处理预警</div>
<div class="data-card-value">{{ stats.pendingAlerts }}</div>
<div class="data-card-trend down">
<arrow-down-outlined />
较昨日 -5
</div>
</div>
</div>
</div>
</a-col>
</a-row>
<!-- 图表区域 -->
<a-row :gutter="[16, 16]" class="charts-row">
<!-- 养殖场分布地图 -->
<a-col :xs="24" :lg="12">
<div class="card">
<div class="card-header">
<h3>养殖场分布地图</h3>
<a-button type="link" @click="viewFullMap">查看详情</a-button>
</div>
<div class="map-container" ref="mapRef">
<!-- 地图将在这里渲染 -->
</div>
</div>
</a-col>
<!-- 设备状态统计 -->
<a-col :xs="24" :lg="12">
<div class="card">
<div class="card-header">
<h3>设备状态统计</h3>
<a-select v-model:value="deviceTimeRange" style="width: 120px">
<a-select-option value="today">今日</a-select-option>
<a-select-option value="week">本周</a-select-option>
<a-select-option value="month">本月</a-select-option>
</a-select>
</div>
<div class="chart-container" ref="deviceChartRef"></div>
</div>
</a-col>
</a-row>
<a-row :gutter="[16, 16]" class="charts-row">
<!-- 动物健康趋势 -->
<a-col :xs="24" :lg="16">
<div class="card">
<div class="card-header">
<h3>动物健康趋势</h3>
<a-radio-group v-model:value="healthTimeRange" button-style="solid" size="small">
<a-radio-button value="7d">7</a-radio-button>
<a-radio-button value="30d">30</a-radio-button>
<a-radio-button value="90d">90</a-radio-button>
</a-radio-group>
</div>
<div class="chart-container" ref="healthChartRef"></div>
</div>
</a-col>
<!-- 预警统计 -->
<a-col :xs="24" :lg="8">
<div class="card">
<div class="card-header">
<h3>预警统计</h3>
</div>
<div class="alert-stats">
<div class="alert-item">
<div class="alert-level high">
<exclamation-circle-outlined />
</div>
<div class="alert-info">
<div class="alert-type">高级预警</div>
<div class="alert-count">{{ alertStats.high }}</div>
</div>
</div>
<div class="alert-item">
<div class="alert-level medium">
<warning-outlined />
</div>
<div class="alert-info">
<div class="alert-type">中级预警</div>
<div class="alert-count">{{ alertStats.medium }}</div>
</div>
</div>
<div class="alert-item">
<div class="alert-level low">
<info-circle-outlined />
</div>
<div class="alert-info">
<div class="alert-type">低级预警</div>
<div class="alert-count">{{ alertStats.low }}</div>
</div>
</div>
</div>
</div>
</a-col>
</a-row>
<!-- 最新动态 -->
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="12">
<div class="card">
<div class="card-header">
<h3>最新动态</h3>
<a-button type="link" @click="viewAllNews">查看全部</a-button>
</div>
<a-list
:data-source="recentNews"
:loading="newsLoading"
size="small"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>
<a href="#">{{ item.title }}</a>
</template>
<template #description>
{{ item.description }}
</template>
</a-list-item-meta>
<div class="news-time">{{ item.time }}</div>
</a-list-item>
</template>
</a-list>
</div>
</a-col>
<a-col :xs="24" :lg="12">
<div class="card">
<div class="card-header">
<h3>快捷操作</h3>
</div>
<div class="quick-actions">
<a-button type="primary" @click="addFarm" class="action-btn">
<plus-outlined />
新增养殖场
</a-button>
<a-button @click="deviceMonitor" class="action-btn">
<monitor-outlined />
设备监控
</a-button>
<a-button @click="generateReport" class="action-btn">
<file-text-outlined />
生成报表
</a-button>
<a-button @click="systemSettings" class="action-btn">
<setting-outlined />
系统设置
</a-button>
</div>
</div>
</a-col>
</a-row>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import * as echarts from 'echarts'
import {
HomeOutlined,
MonitorOutlined,
BugOutlined,
AlertOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
ExclamationCircleOutlined,
WarningOutlined,
InfoCircleOutlined,
PlusOutlined,
FileTextOutlined,
SettingOutlined
} from '@ant-design/icons-vue'
const router = useRouter()
// 响应式数据
const mapRef = ref()
const deviceChartRef = ref()
const healthChartRef = ref()
const deviceTimeRange = ref('today')
const healthTimeRange = ref('7d')
const newsLoading = ref(false)
// 统计数据
const stats = reactive({
totalFarms: 156,
onlineDevices: 1248,
totalAnimals: 25680,
pendingAlerts: 12
})
// 预警统计
const alertStats = reactive({
high: 3,
medium: 7,
low: 15
})
// 最新动态
const recentNews = ref([
{
title: '新增5家养殖场接入监管平台',
description: '本月新增5家大型养殖场接入智慧监管平台覆盖范围进一步扩大',
time: '2小时前'
},
{
title: '设备维护通知',
description: '计划于本周末对部分监控设备进行例行维护,请提前做好准备',
time: '5小时前'
},
{
title: '月度数据报表已生成',
description: '12月份养殖监管数据报表已生成完成可在报表管理中查看',
time: '1天前'
},
{
title: '系统升级公告',
description: '系统将于下周进行功能升级,新增多项智能分析功能',
time: '2天前'
}
])
// 图表实例
let deviceChart = null
let healthChart = null
// 初始化设备状态图表
const initDeviceChart = () => {
if (!deviceChartRef.value) return
deviceChart = echarts.init(deviceChartRef.value)
const option = {
tooltip: {
trigger: 'item'
},
legend: {
orient: 'vertical',
left: 'left'
},
series: [
{
name: '设备状态',
type: 'pie',
radius: '50%',
data: [
{ value: 1186, name: '在线' },
{ value: 45, name: '离线' },
{ value: 17, name: '维护中' }
],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
}
deviceChart.setOption(option)
}
// 初始化健康趋势图表
const initHealthChart = () => {
if (!healthChartRef.value) return
healthChart = echarts.init(healthChartRef.value)
const option = {
tooltip: {
trigger: 'axis'
},
legend: {
data: ['健康', '亚健康', '异常']
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
},
yAxis: {
type: 'value'
},
series: [
{
name: '健康',
type: 'line',
stack: 'Total',
data: [120, 132, 101, 134, 90, 230, 210]
},
{
name: '亚健康',
type: 'line',
stack: 'Total',
data: [220, 182, 191, 234, 290, 330, 310]
},
{
name: '异常',
type: 'line',
stack: 'Total',
data: [150, 232, 201, 154, 190, 330, 410]
}
]
}
healthChart.setOption(option)
}
// 快捷操作
const addFarm = () => {
router.push('/farms?action=add')
}
const deviceMonitor = () => {
router.push('/devices')
}
const generateReport = () => {
router.push('/reports')
}
const systemSettings = () => {
router.push('/settings')
}
const viewFullMap = () => {
router.push('/farms?view=map')
}
const viewAllNews = () => {
// TODO: 实现查看全部动态功能
console.log('查看全部动态')
}
// 窗口大小变化处理
const handleResize = () => {
if (deviceChart) {
deviceChart.resize()
}
if (healthChart) {
healthChart.resize()
}
}
// 组件挂载
onMounted(async () => {
await nextTick()
// 初始化图表
initDeviceChart()
initHealthChart()
// 监听窗口大小变化
window.addEventListener('resize', handleResize)
})
// 组件卸载
onUnmounted(() => {
// 销毁图表实例
if (deviceChart) {
deviceChart.dispose()
}
if (healthChart) {
healthChart.dispose()
}
// 移除事件监听
window.removeEventListener('resize', handleResize)
})
</script>
<style lang="scss" scoped>
.stats-row {
margin-bottom: 24px;
}
.charts-row {
margin-bottom: 24px;
}
.data-card {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.data-card-content {
display: flex;
align-items: center;
}
.data-card-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: white;
margin-right: 16px;
&.farms {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
&.devices {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
&.animals {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
&.alerts {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
}
}
.data-card-info {
flex: 1;
}
.data-card-title {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.data-card-value {
font-size: 28px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 4px;
}
.data-card-trend {
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
&.up {
color: #52c41a;
}
&.down {
color: #f5222d;
}
}
}
.card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
.card-header {
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
}
}
}
.map-container {
height: 300px;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 14px;
}
.chart-container {
height: 300px;
padding: 16px;
}
.alert-stats {
padding: 24px;
.alert-item {
display: flex;
align-items: center;
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
.alert-level {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: white;
margin-right: 16px;
&.high {
background: #f5222d;
}
&.medium {
background: #faad14;
}
&.low {
background: #1890ff;
}
}
.alert-info {
flex: 1;
.alert-type {
font-size: 14px;
color: #666;
margin-bottom: 4px;
}
.alert-count {
font-size: 20px;
font-weight: 600;
color: #1a1a1a;
}
}
}
}
.news-time {
color: #999;
font-size: 12px;
}
.quick-actions {
padding: 24px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
.action-btn {
height: 48px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 8px;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
}
// 响应式设计
@media (max-width: 768px) {
.data-card {
padding: 16px;
.data-card-content {
flex-direction: column;
text-align: center;
}
.data-card-icon {
margin-right: 0;
margin-bottom: 12px;
}
}
.chart-container {
height: 250px;
padding: 12px;
}
.quick-actions {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,445 @@
<template>
<div class="login-container">
<div class="login-background">
<div class="background-overlay"></div>
</div>
<div class="login-content">
<div class="login-form-container">
<!-- Logo和标题 -->
<div class="login-header">
<img src="@/assets/images/favicon.svg" alt="Logo" class="logo" />
<h1 class="title">宁夏养殖政府管理平台</h1>
<p class="subtitle">政府端管理后台</p>
</div>
<!-- 登录表单 -->
<a-form
ref="formRef"
:model="formData"
:rules="rules"
class="login-form"
@finish="handleLogin"
>
<a-form-item name="username">
<a-input
v-model:value="formData.username"
size="large"
placeholder="请输入用户名"
:prefix="h(UserOutlined)"
/>
</a-form-item>
<a-form-item name="password">
<a-input-password
v-model:value="formData.password"
size="large"
placeholder="请输入密码"
:prefix="h(LockOutlined)"
/>
</a-form-item>
<a-form-item name="captcha" v-if="showCaptcha">
<div class="captcha-container">
<a-input
v-model:value="formData.captcha"
size="large"
placeholder="请输入验证码"
:prefix="h(SafetyOutlined)"
style="flex: 1; margin-right: 12px;"
/>
<div class="captcha-image" @click="refreshCaptcha">
<img :src="captchaUrl" alt="验证码" />
</div>
</div>
</a-form-item>
<a-form-item>
<div class="login-options">
<a-checkbox v-model:checked="formData.remember">
记住我
</a-checkbox>
<a href="#" class="forgot-password">忘记密码</a>
</div>
</a-form-item>
<a-form-item>
<a-button
type="primary"
html-type="submit"
size="large"
:loading="loading"
class="login-button"
block
>
登录
</a-button>
</a-form-item>
</a-form>
<!-- 其他登录方式 -->
<div class="other-login" v-if="false">
<a-divider>其他登录方式</a-divider>
<div class="other-login-buttons">
<a-button type="text" class="other-login-btn">
<wechat-outlined />
</a-button>
<a-button type="text" class="other-login-btn">
<alipay-outlined />
</a-button>
</div>
</div>
<!-- 版权信息 -->
<div class="login-footer">
<p>© 2025 宁夏智慧养殖监管平台 版权所有</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, h } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { message } from 'ant-design-vue'
import {
UserOutlined,
LockOutlined,
SafetyOutlined,
WechatOutlined,
AlipayOutlined
} from '@ant-design/icons-vue'
const router = useRouter()
const authStore = useAuthStore()
// 响应式数据
const formRef = ref()
const loading = ref(false)
const showCaptcha = ref(false)
const captchaUrl = ref('')
// 表单数据
const formData = reactive({
username: 'admin',
password: '123456',
captcha: '',
remember: true
})
// 表单验证规则
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度为3-20个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度为6-20个字符', trigger: 'blur' }
],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur', validator: () => showCaptcha.value }
]
}
// 登录处理
const handleLogin = async (values) => {
loading.value = true
try {
const success = await authStore.login({
username: values.username,
password: values.password,
captcha: values.captcha,
remember: values.remember
})
if (success) {
message.success('登录成功')
// 跳转到首页或之前访问的页面
const redirect = router.currentRoute.value.query.redirect || '/'
router.push(redirect)
}
} catch (error) {
console.error('登录失败:', error)
// 登录失败后显示验证码
if (!showCaptcha.value) {
showCaptcha.value = true
refreshCaptcha()
}
} finally {
loading.value = false
}
}
// 刷新验证码
const refreshCaptcha = () => {
captchaUrl.value = `/api/auth/captcha?t=${Date.now()}`
}
// 组件挂载时的处理
onMounted(() => {
// 如果已经登录,直接跳转到首页
if (authStore.isAuthenticated) {
router.push('/')
}
// 初始化验证码(如果需要)
if (showCaptcha.value) {
refreshCaptcha()
}
})
</script>
<style lang="scss" scoped>
.login-container {
position: relative;
width: 100vw;
height: 100vh;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.login-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: #f0f2f5; /* 使用纯色背景替代渐变色 */
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.05) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(120, 119, 198, 0.05) 0%, transparent 50%);
}
.background-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.05);
}
}
.login-content {
position: relative;
z-index: 10;
width: 100%;
max-width: 400px;
padding: 0 24px;
}
.login-form-container {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 16px;
padding: 40px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.login-header {
text-align: center;
margin-bottom: 32px;
.logo {
width: 64px;
height: 64px;
margin-bottom: 16px;
}
.title {
font-size: 24px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 8px;
line-height: 1.2;
}
.subtitle {
font-size: 14px;
color: #666;
margin: 0;
}
}
.login-form {
.ant-form-item {
margin-bottom: 20px;
}
.ant-input-affix-wrapper,
.ant-input {
border-radius: 8px;
border: 1px solid #e0e0e0;
transition: all 0.3s;
&:hover,
&:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
}
}
}
.captcha-container {
display: flex;
align-items: center;
.captcha-image {
width: 100px;
height: 40px;
border: 1px solid #e0e0e0;
border-radius: 8px;
cursor: pointer;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&:hover {
border-color: #1890ff;
}
}
}
.login-options {
display: flex;
justify-content: space-between;
align-items: center;
.forgot-password {
color: #1890ff;
text-decoration: none;
font-size: 14px;
&:hover {
text-decoration: underline;
}
}
}
.login-button {
height: 48px;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
background: #1890ff; /* 使用纯色背景替代渐变色 */
border: none;
&:hover {
background: #40a9ff; /* 使用纯色背景替代渐变色 */
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
}
.other-login {
margin-top: 24px;
.ant-divider {
font-size: 12px;
color: #999;
}
.other-login-buttons {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 16px;
.other-login-btn {
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #666;
border: 1px solid #e0e0e0;
&:hover {
color: #1890ff;
border-color: #1890ff;
transform: translateY(-2px);
}
}
}
}
.login-footer {
text-align: center;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
p {
font-size: 12px;
color: #999;
margin: 0;
}
}
// 响应式设计
@media (max-width: 480px) {
.login-content {
padding: 0 16px;
}
.login-form-container {
padding: 24px;
border-radius: 12px;
}
.login-header {
.title {
font-size: 20px;
}
.logo {
width: 48px;
height: 48px;
}
}
}
// 动画效果
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-form-container {
animation: fadeInUp 0.6s ease-out;
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<div class="approved-list">
<a-card title="已审批" :bordered="false">
<a-table
:columns="columns"
:data-source="approvedList"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 'approved' ? 'green' : 'red'">
{{ record.status === 'approved' ? '已通过' : '已拒绝' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small">详情</a-button>
<a-button type="link" size="small">导出</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const loading = ref(false)
const approvedList = ref([])
const columns = [
{ title: '申请标题', dataIndex: 'title', key: 'title' },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant' },
{ title: '申请类型', dataIndex: 'type', key: 'type' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '审批人', dataIndex: 'approver', key: 'approver' },
{ title: '审批时间', dataIndex: 'approveTime', key: 'approveTime' },
{ title: '操作', key: 'action', width: 150 }
]
onMounted(() => {
approvedList.value = [
{
id: 1,
title: '养殖场许可证申请',
applicant: '李四',
type: '许可证',
status: 'approved',
approver: '管理员',
approveTime: '2024-01-10'
}
]
})
</script>
<style scoped>
.approved-list {
padding: 24px;
}
</style>

View File

@@ -0,0 +1,98 @@
<template>
<div class="identity-auth">
<a-card title="电子身份认证" :bordered="false">
<a-tabs>
<a-tab-pane key="pending" tab="待认证">
<a-table
:columns="authColumns"
:data-source="pendingList"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-space>
<a-button type="primary" size="small">认证</a-button>
<a-button type="link" size="small">详情</a-button>
</a-space>
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="completed" tab="已认证">
<a-table
:columns="completedColumns"
:data-source="completedList"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag color="green">已认证</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small">详情</a-button>
<a-button type="link" size="small">证书</a-button>
</a-space>
</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const loading = ref(false)
const pendingList = ref([])
const completedList = ref([])
const authColumns = [
{ title: '申请人', dataIndex: 'name', key: 'name' },
{ title: '身份证号', dataIndex: 'idCard', key: 'idCard' },
{ title: '联系电话', dataIndex: 'phone', key: 'phone' },
{ title: '申请时间', dataIndex: 'createTime', key: 'createTime' },
{ title: '操作', key: 'action', width: 150 }
]
const completedColumns = [
{ title: '姓名', dataIndex: 'name', key: 'name' },
{ title: '身份证号', dataIndex: 'idCard', key: 'idCard' },
{ title: '认证状态', dataIndex: 'status', key: 'status' },
{ title: '认证时间', dataIndex: 'authTime', key: 'authTime' },
{ title: '操作', key: 'action', width: 150 }
]
onMounted(() => {
pendingList.value = [
{
id: 1,
name: '王五',
idCard: '123456789012345678',
phone: '13800138000',
createTime: '2024-01-15'
}
]
completedList.value = [
{
id: 2,
name: '赵六',
idCard: '987654321098765432',
status: 'verified',
authTime: '2024-01-10'
}
]
})
</script>
<style scoped>
.identity-auth {
padding: 24px;
}
</style>

View File

@@ -0,0 +1,887 @@
<template>
<div class="license-approval">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-content">
<h1 class="page-title">许可证审批</h1>
<p class="page-description">管理养殖许可证申请审批和证书发放</p>
</div>
<div class="header-actions">
<a-button type="primary" @click="showAddModal">
<template #icon><PlusOutlined /></template>
新增申请
</a-button>
<a-button @click="exportData" style="margin-left: 8px">
<template #icon><ExportOutlined /></template>
导出数据
</a-button>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-cards">
<a-row :gutter="16">
<a-col :span="6">
<a-card class="stat-card pending">
<a-statistic
title="待审批"
:value="stats.pending"
:value-style="{ color: '#faad14' }"
>
<template #prefix><ClockCircleOutlined /></template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card approved">
<a-statistic
title="已通过"
:value="stats.approved"
:value-style="{ color: '#52c41a' }"
>
<template #prefix><CheckCircleOutlined /></template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card rejected">
<a-statistic
title="已拒绝"
:value="stats.rejected"
:value-style="{ color: '#ff4d4f' }"
>
<template #prefix><CloseCircleOutlined /></template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card expired">
<a-statistic
title="即将到期"
:value="stats.expiring"
:value-style="{ color: '#fa8c16' }"
>
<template #prefix><ExclamationCircleOutlined /></template>
</a-statistic>
</a-card>
</a-col>
</a-row>
</div>
<!-- 搜索和筛选 -->
<a-card class="search-card">
<a-form layout="inline" :model="searchForm" @finish="handleSearch">
<a-form-item label="申请编号" name="applicationNo">
<a-input v-model:value="searchForm.applicationNo" placeholder="请输入申请编号" />
</a-form-item>
<a-form-item label="申请人" name="applicant">
<a-input v-model:value="searchForm.applicant" placeholder="请输入申请人" />
</a-form-item>
<a-form-item label="许可证类型" name="licenseType">
<a-select v-model:value="searchForm.licenseType" placeholder="请选择类型" style="width: 150px">
<a-select-option value="">全部</a-select-option>
<a-select-option value="breeding">养殖许可证</a-select-option>
<a-select-option value="transport">运输许可证</a-select-option>
<a-select-option value="slaughter">屠宰许可证</a-select-option>
<a-select-option value="feed">饲料生产许可证</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="审批状态" name="status">
<a-select v-model:value="searchForm.status" placeholder="请选择状态" style="width: 120px">
<a-select-option value="">全部</a-select-option>
<a-select-option value="pending">待审批</a-select-option>
<a-select-option value="approved">已通过</a-select-option>
<a-select-option value="rejected">已拒绝</a-select-option>
<a-select-option value="expired">已过期</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="申请时间" name="dateRange">
<a-range-picker v-model:value="searchForm.dateRange" />
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="resetSearch" style="margin-left: 8px">
重置
</a-button>
</a-form-item>
</a-form>
</a-card>
<!-- 数据表格 -->
<a-card class="table-card">
<a-table
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
row-key="id"
: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-else-if="column.key === 'licenseType'">
<a-tag color="blue">{{ getLicenseTypeText(record.licenseType) }}</a-tag>
</template>
<template v-else-if="column.key === 'validPeriod'">
<span :class="{ 'text-warning': isExpiringSoon(record.validPeriod) }">
{{ record.validPeriod }}
</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="viewDetail(record)">
查看
</a-button>
<a-button
type="link"
size="small"
@click="handleApproval(record)"
v-if="record.status === 'pending'"
>
审批
</a-button>
<a-button
type="link"
size="small"
@click="downloadCertificate(record)"
v-if="record.status === 'approved'"
>
下载证书
</a-button>
<a-button type="link" size="small" @click="editRecord(record)">
编辑
</a-button>
<a-popconfirm
title="确定要删除这条记录吗?"
@confirm="deleteRecord(record.id)"
>
<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="modalTitle"
width="900px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
layout="vertical"
>
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="basic" tab="基本信息">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="申请编号" name="applicationNo">
<a-input v-model:value="formData.applicationNo" placeholder="系统自动生成" disabled />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="许可证类型" name="licenseType" required>
<a-select v-model:value="formData.licenseType" placeholder="请选择许可证类型">
<a-select-option value="breeding">养殖许可证</a-select-option>
<a-select-option value="transport">运输许可证</a-select-option>
<a-select-option value="slaughter">屠宰许可证</a-select-option>
<a-select-option value="feed">饲料生产许可证</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="applicant" required>
<a-input v-model:value="formData.applicant" placeholder="请输入申请人姓名" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="联系电话" name="phone" required>
<a-input v-model:value="formData.phone" placeholder="请输入联系电话" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="身份证号" name="idCard" required>
<a-input v-model:value="formData.idCard" placeholder="请输入身份证号" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="企业名称" name="companyName">
<a-input v-model:value="formData.companyName" placeholder="请输入企业名称" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="经营地址" name="address" required>
<a-input v-model:value="formData.address" placeholder="请输入经营地址" />
</a-form-item>
</a-tab-pane>
<a-tab-pane key="business" tab="经营信息">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="经营范围" name="businessScope" required>
<a-select
v-model:value="formData.businessScope"
mode="multiple"
placeholder="请选择经营范围"
>
<a-select-option value="cattle">牛类养殖</a-select-option>
<a-select-option value="sheep">羊类养殖</a-select-option>
<a-select-option value="pig">猪类养殖</a-select-option>
<a-select-option value="poultry">家禽养殖</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="养殖规模" name="scale" required>
<a-input-number
v-model:value="formData.scale"
placeholder="请输入养殖规模"
:min="1"
style="width: 100%"
addon-after="/"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="占地面积" name="area" required>
<a-input-number
v-model:value="formData.area"
placeholder="请输入占地面积"
:min="0"
style="width: 100%"
addon-after="平方米"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="投资金额" name="investment">
<a-input-number
v-model:value="formData.investment"
placeholder="请输入投资金额"
:min="0"
style="width: 100%"
addon-after="万元"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="经营计划" name="businessPlan">
<a-textarea
v-model:value="formData.businessPlan"
placeholder="请输入经营计划"
:rows="4"
/>
</a-form-item>
</a-tab-pane>
<a-tab-pane key="documents" tab="申请材料">
<a-form-item label="申请材料" name="documents">
<a-upload
v-model:file-list="formData.documents"
name="file"
multiple
:before-upload="beforeUpload"
@remove="handleRemove"
>
<a-button>
<template #icon><UploadOutlined /></template>
上传文件
</a-button>
</a-upload>
<div class="upload-tips">
<p>请上传以下材料</p>
<ul>
<li>身份证复印件</li>
<li>营业执照企业申请</li>
<li>土地使用证明</li>
<li>环评报告</li>
<li>其他相关证明材料</li>
</ul>
</div>
</a-form-item>
</a-tab-pane>
</a-tabs>
</a-form>
</a-modal>
<!-- 审批弹窗 -->
<a-modal
v-model:open="approvalModalVisible"
title="许可证审批"
width="700px"
@ok="handleApprovalSubmit"
@cancel="approvalModalVisible = false"
>
<a-form
ref="approvalFormRef"
:model="approvalForm"
:rules="approvalRules"
layout="vertical"
>
<a-form-item label="审批结果" name="result" required>
<a-radio-group v-model:value="approvalForm.result">
<a-radio value="approved">通过</a-radio>
<a-radio value="rejected">拒绝</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="审批意见" name="opinion" required>
<a-textarea
v-model:value="approvalForm.opinion"
placeholder="请输入审批意见"
:rows="4"
/>
</a-form-item>
<div v-if="approvalForm.result === 'approved'">
<a-form-item label="许可证编号" name="licenseNo">
<a-input v-model:value="approvalForm.licenseNo" placeholder="请输入许可证编号" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="有效期开始" name="validFrom">
<a-date-picker
v-model:value="approvalForm.validFrom"
style="width: 100%"
placeholder="请选择开始日期"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="有效期结束" name="validTo">
<a-date-picker
v-model:value="approvalForm.validTo"
style="width: 100%"
placeholder="请选择结束日期"
/>
</a-form-item>
</a-col>
</a-row>
</div>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
PlusOutlined,
SearchOutlined,
ExportOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ExclamationCircleOutlined,
UploadOutlined
} from '@ant-design/icons-vue'
// 响应式数据
const loading = ref(false)
const modalVisible = ref(false)
const approvalModalVisible = ref(false)
const modalTitle = ref('新增申请')
const currentRecord = ref(null)
const activeTab = ref('basic')
// 统计数据
const stats = reactive({
pending: 25,
approved: 186,
rejected: 12,
expiring: 8
})
// 搜索表单
const searchForm = reactive({
applicationNo: '',
applicant: '',
licenseType: '',
status: '',
dateRange: null
})
// 表单数据
const formData = reactive({
applicationNo: '',
licenseType: '',
applicant: '',
phone: '',
idCard: '',
companyName: '',
address: '',
businessScope: [],
scale: null,
area: null,
investment: null,
businessPlan: '',
documents: []
})
// 审批表单
const approvalForm = reactive({
result: '',
opinion: '',
licenseNo: '',
validFrom: null,
validTo: null
})
// 表格数据
const tableData = ref([])
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`
})
// 表格列配置
const columns = [
{
title: '申请编号',
dataIndex: 'applicationNo',
key: 'applicationNo',
width: 150,
fixed: 'left'
},
{
title: '许可证类型',
dataIndex: 'licenseType',
key: 'licenseType',
width: 120
},
{
title: '申请人',
dataIndex: 'applicant',
key: 'applicant',
width: 100
},
{
title: '联系电话',
dataIndex: 'phone',
key: 'phone',
width: 120
},
{
title: '经营地址',
dataIndex: 'address',
key: 'address',
width: 200,
ellipsis: true
},
{
title: '申请时间',
dataIndex: 'createTime',
key: 'createTime',
width: 150
},
{
title: '有效期',
dataIndex: 'validPeriod',
key: 'validPeriod',
width: 180
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100
},
{
title: '操作',
key: 'action',
width: 250,
fixed: 'right'
}
]
// 表单验证规则
const formRules = {
licenseType: [{ required: true, message: '请选择许可证类型' }],
applicant: [{ required: true, message: '请输入申请人姓名' }],
phone: [{ required: true, message: '请输入联系电话' }],
idCard: [{ required: true, message: '请输入身份证号' }],
address: [{ required: true, message: '请输入经营地址' }],
businessScope: [{ required: true, message: '请选择经营范围' }],
scale: [{ required: true, message: '请输入养殖规模' }],
area: [{ required: true, message: '请输入占地面积' }]
}
const approvalRules = {
result: [{ required: true, message: '请选择审批结果' }],
opinion: [{ required: true, message: '请输入审批意见' }]
}
// 方法
const fetchData = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
tableData.value = [
{
id: 1,
applicationNo: 'XK202401001',
licenseType: 'breeding',
applicant: '张三',
phone: '13800138001',
address: '内蒙古呼和浩特市某养殖场',
status: 'pending',
createTime: '2024-01-15 09:30:00',
validPeriod: '2024-02-01 至 2027-02-01'
},
{
id: 2,
applicationNo: 'XK202401002',
licenseType: 'transport',
applicant: '李四',
phone: '13800138002',
address: '内蒙古包头市某运输公司',
status: 'approved',
createTime: '2024-01-14 14:20:00',
validPeriod: '2024-01-20 至 2027-01-20'
}
]
pagination.total = 50
} catch (error) {
message.error('获取数据失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const resetSearch = () => {
Object.keys(searchForm).forEach(key => {
searchForm[key] = key === 'dateRange' ? null : ''
})
handleSearch()
}
const handleTableChange = (pag) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
fetchData()
}
const showAddModal = () => {
modalTitle.value = '新增申请'
resetForm()
modalVisible.value = true
}
const editRecord = (record) => {
modalTitle.value = '编辑申请'
currentRecord.value = record
Object.keys(formData).forEach(key => {
if (key === 'businessScope') {
formData[key] = record[key] || []
} else if (key === 'documents') {
formData[key] = record[key] || []
} else {
formData[key] = record[key] || (typeof formData[key] === 'number' ? null : '')
}
})
modalVisible.value = true
}
const resetForm = () => {
Object.keys(formData).forEach(key => {
if (key === 'businessScope' || key === 'documents') {
formData[key] = []
} else if (typeof formData[key] === 'number') {
formData[key] = null
} else {
formData[key] = ''
}
})
formData.applicationNo = 'XK' + Date.now()
activeTab.value = 'basic'
}
const handleSubmit = async () => {
try {
// 表单验证
// await formRef.value.validate()
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
message.success(currentRecord.value ? '更新成功' : '创建成功')
modalVisible.value = false
fetchData()
} catch (error) {
message.error('操作失败')
}
}
const handleCancel = () => {
modalVisible.value = false
currentRecord.value = null
}
const handleApproval = (record) => {
currentRecord.value = record
Object.keys(approvalForm).forEach(key => {
approvalForm[key] = key.includes('valid') ? null : ''
})
approvalModalVisible.value = true
}
const handleApprovalSubmit = async () => {
try {
// 表单验证
// await approvalFormRef.value.validate()
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
message.success('审批完成')
approvalModalVisible.value = false
fetchData()
} catch (error) {
message.error('审批失败')
}
}
const viewDetail = (record) => {
message.info('查看详情功能待实现')
}
const downloadCertificate = (record) => {
message.info('下载证书功能待实现')
}
const exportData = () => {
message.info('导出数据功能待实现')
}
const deleteRecord = async (id) => {
try {
await new Promise(resolve => setTimeout(resolve, 500))
message.success('删除成功')
fetchData()
} catch (error) {
message.error('删除失败')
}
}
const beforeUpload = (file) => {
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
message.error('文件大小不能超过10MB!')
}
return false // 阻止自动上传
}
const handleRemove = (file) => {
const index = formData.documents.indexOf(file)
const newFileList = formData.documents.slice()
newFileList.splice(index, 1)
formData.documents = newFileList
}
const getStatusColor = (status) => {
const colors = {
pending: 'orange',
approved: 'green',
rejected: 'red',
expired: 'default'
}
return colors[status] || 'default'
}
const getStatusText = (status) => {
const texts = {
pending: '待审批',
approved: '已通过',
rejected: '已拒绝',
expired: '已过期'
}
return texts[status] || '未知'
}
const getLicenseTypeText = (type) => {
const texts = {
breeding: '养殖许可证',
transport: '运输许可证',
slaughter: '屠宰许可证',
feed: '饲料生产许可证'
}
return texts[type] || '未知'
}
const isExpiringSoon = (validPeriod) => {
// 简单判断是否即将到期的逻辑
return validPeriod && validPeriod.includes('2024-02')
}
// 生命周期
onMounted(() => {
fetchData()
})
</script>
<style scoped lang="scss">
.license-approval {
padding: 24px;
background: #f5f5f5;
min-height: 100vh;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding: 24px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.header-content {
.page-title {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
color: #262626;
}
.page-description {
margin: 0;
color: #8c8c8c;
font-size: 14px;
}
}
}
.stats-cards {
margin-bottom: 24px;
.stat-card {
text-align: center;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
&.pending {
border-left: 4px solid #faad14;
}
&.approved {
border-left: 4px solid #52c41a;
}
&.rejected {
border-left: 4px solid #ff4d4f;
}
&.expired {
border-left: 4px solid #fa8c16;
}
}
}
.search-card,
.table-card {
margin-bottom: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
:deep(.ant-card-body) {
padding: 24px;
}
}
.search-card {
:deep(.ant-form-item) {
margin-bottom: 0;
}
}
.text-warning {
color: #fa8c16;
font-weight: 500;
}
.upload-tips {
margin-top: 8px;
padding: 12px;
background: #f6f8fa;
border-radius: 4px;
font-size: 12px;
color: #666;
p {
margin: 0 0 8px 0;
font-weight: 500;
}
ul {
margin: 0;
padding-left: 16px;
li {
margin-bottom: 4px;
}
}
}
}
:deep(.ant-table) {
.ant-table-thead > tr > th {
background: #fafafa;
font-weight: 600;
}
}
:deep(.ant-modal) {
.ant-modal-header {
border-radius: 8px 8px 0 0;
}
.ant-modal-content {
border-radius: 8px;
}
}
:deep(.ant-tabs) {
.ant-tabs-tab {
font-weight: 500;
}
}
</style>

View File

@@ -0,0 +1,111 @@
<template>
<div class="loan-supervision">
<a-card title="惠农贷款监管" :bordered="false">
<a-row :gutter="16" style="margin-bottom: 16px;">
<a-col :span="6">
<a-statistic title="贷款总额" :value="statistics.totalAmount" suffix="万元" />
</a-col>
<a-col :span="6">
<a-statistic title="已放贷" :value="statistics.loanedAmount" suffix="万元" />
</a-col>
<a-col :span="6">
<a-statistic title="待审核" :value="statistics.pendingCount" suffix="笔" />
</a-col>
<a-col :span="6">
<a-statistic title="逾期率" :value="statistics.overdueRate" suffix="%" />
</a-col>
</a-row>
<a-table
:columns="columns"
:data-source="loanList"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small">详情</a-button>
<a-button type="link" size="small">监管</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
const loading = ref(false)
const loanList = ref([])
const statistics = reactive({
totalAmount: 5000,
loanedAmount: 3500,
pendingCount: 25,
overdueRate: 2.5
})
const columns = [
{ title: '借款人', dataIndex: 'borrower', key: 'borrower' },
{ title: '贷款金额', dataIndex: 'amount', key: 'amount' },
{ title: '贷款用途', dataIndex: 'purpose', key: 'purpose' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '申请时间', dataIndex: 'applyTime', key: 'applyTime' },
{ title: '操作', key: 'action', width: 150 }
]
const getStatusColor = (status) => {
const colors = {
pending: 'orange',
approved: 'green',
rejected: 'red',
overdue: 'red'
}
return colors[status] || 'default'
}
const getStatusText = (status) => {
const texts = {
pending: '待审核',
approved: '已批准',
rejected: '已拒绝',
overdue: '逾期'
}
return texts[status] || '未知'
}
onMounted(() => {
loanList.value = [
{
id: 1,
borrower: '农户张三',
amount: '50万元',
purpose: '养殖场扩建',
status: 'pending',
applyTime: '2024-01-15'
},
{
id: 2,
borrower: '农户李四',
amount: '30万元',
purpose: '购买饲料',
status: 'approved',
applyTime: '2024-01-10'
}
]
})
</script>
<style scoped>
.loan-supervision {
padding: 24px;
}
</style>

View File

@@ -0,0 +1,59 @@
<template>
<div class="pending-approval">
<a-card title="待审批" :bordered="false">
<a-table
:columns="columns"
:data-source="approvalList"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag color="orange">待审批</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="primary" size="small">审批</a-button>
<a-button type="link" size="small">详情</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const loading = ref(false)
const approvalList = ref([])
const columns = [
{ title: '申请标题', dataIndex: 'title', key: 'title' },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant' },
{ title: '申请类型', dataIndex: 'type', key: 'type' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '申请时间', dataIndex: 'createTime', key: 'createTime' },
{ title: '操作', key: 'action', width: 150 }
]
onMounted(() => {
approvalList.value = [
{
id: 1,
title: '养殖场许可证申请',
applicant: '张三',
type: '许可证',
status: 'pending',
createTime: '2024-01-15'
}
]
})
</script>
<style scoped>
.pending-approval {
padding: 24px;
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<div class="animal-tracking">
<a-card title="动物溯源" :bordered="false">
<a-tabs>
<a-tab-pane key="search" tab="溯源查询">
<div class="tracking-search">
<a-form layout="inline" :model="searchForm">
<a-form-item label="耳标号">
<a-input v-model:value="searchForm.earTag" placeholder="请输入耳标号" />
</a-form-item>
<a-form-item label="养殖场">
<a-select v-model:value="searchForm.farm" placeholder="请选择养殖场" style="width: 200px">
<a-select-option value="farm1">阳光养殖场</a-select-option>
<a-select-option value="farm2">绿野养牛场</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary">查询</a-button>
</a-form-item>
</a-form>
</div>
<a-table
:columns="trackingColumns"
:data-source="trackingList"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small">查看轨迹</a-button>
<a-button type="link" size="small">详情</a-button>
</a-space>
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="register" tab="动物登记">
<a-form :model="registerForm" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="耳标号">
<a-input v-model:value="registerForm.earTag" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="动物种类">
<a-select v-model:value="registerForm.species">
<a-select-option value="pig"></a-select-option>
<a-select-option value="cattle"></a-select-option>
<a-select-option value="sheep"></a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="出生日期">
<a-date-picker v-model:value="registerForm.birthDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="养殖场">
<a-select v-model:value="registerForm.farm">
<a-select-option value="farm1">阳光养殖场</a-select-option>
<a-select-option value="farm2">绿野养牛场</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item>
<a-button type="primary">登记</a-button>
<a-button style="margin-left: 8px">重置</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
const loading = ref(false)
const trackingList = ref([])
const searchForm = reactive({
earTag: '',
farm: undefined
})
const registerForm = reactive({
earTag: '',
species: undefined,
birthDate: null,
farm: undefined
})
const trackingColumns = [
{ title: '耳标号', dataIndex: 'earTag', key: 'earTag' },
{ title: '动物种类', dataIndex: 'species', key: 'species' },
{ title: '出生日期', dataIndex: 'birthDate', key: 'birthDate' },
{ title: '当前位置', dataIndex: 'location', key: 'location' },
{ title: '健康状态', dataIndex: 'health', key: 'health' },
{ title: '操作', key: 'action', width: 150 }
]
onMounted(() => {
trackingList.value = [
{
id: 1,
earTag: 'PIG001',
species: '猪',
birthDate: '2023-06-15',
location: '阳光养殖场',
health: '健康'
},
{
id: 2,
earTag: 'COW001',
species: '牛',
birthDate: '2023-03-20',
location: '绿野养牛场',
health: '健康'
}
]
})
</script>
<style scoped>
.animal-tracking {
padding: 24px;
}
.tracking-search {
margin-bottom: 16px;
padding: 16px;
background: #fafafa;
border-radius: 6px;
}
</style>

View File

@@ -0,0 +1,578 @@
<template>
<div class="breeding-farm-list">
<!-- 页面头部 -->
<PageHeader
title="养殖场管理"
description="管理和监控所有注册的养殖场信息"
icon="bank"
>
<template #extra>
<a-space>
<a-button type="primary" @click="handleAdd">
<template #icon>
<PlusOutlined />
</template>
新增养殖场
</a-button>
<a-button @click="handleExport">
<template #icon>
<ExportOutlined />
</template>
导出数据
</a-button>
</a-space>
</template>
</PageHeader>
<!-- 搜索表单 -->
<SearchForm
:fields="searchFields"
@search="handleSearch"
@reset="handleReset"
/>
<!-- 数据表格 -->
<DataTable
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'scale'">
<a-tag color="blue">{{ getScaleText(record.scale) }}</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleView(record)">
查看
</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-popconfirm
title="确定要删除这个养殖场吗?"
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</DataTable>
<!-- 新增/编辑弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
width="800px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
layout="vertical"
>
<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="manager">
<a-input v-model:value="formData.manager" 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="scale">
<a-select v-model:value="formData.scale" placeholder="请选择养殖规模">
<a-select-option value="small">小型<1000头</a-select-option>
<a-select-option value="medium">中型1000-5000</a-select-option>
<a-select-option value="large">大型>5000</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="breed">
<a-select v-model:value="formData.breed" placeholder="请选择养殖品种">
<a-select-option value="pig">生猪</a-select-option>
<a-select-option value="cattle"></a-select-option>
<a-select-option value="sheep"></a-select-option>
<a-select-option value="chicken"></a-select-option>
<a-select-option value="duck"></a-select-option>
<a-select-option value="fish"></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="active">正常运营</a-select-option>
<a-select-option value="suspended">暂停运营</a-select-option>
<a-select-option value="closed">已关闭</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="详细地址" name="address">
<a-textarea
v-model:value="formData.address"
placeholder="请输入详细地址"
:rows="3"
/>
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea
v-model:value="formData.remark"
placeholder="请输入备注信息"
:rows="3"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 查看详情弹窗 -->
<a-modal
v-model:open="detailVisible"
title="养殖场详情"
width="800px"
:footer="null"
>
<a-descriptions :column="2" bordered>
<a-descriptions-item label="养殖场名称">
{{ currentRecord?.name }}
</a-descriptions-item>
<a-descriptions-item label="负责人">
{{ currentRecord?.manager }}
</a-descriptions-item>
<a-descriptions-item label="联系电话">
{{ currentRecord?.phone }}
</a-descriptions-item>
<a-descriptions-item label="养殖规模">
<a-tag color="blue">{{ getScaleText(currentRecord?.scale) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="养殖品种">
{{ getBreedText(currentRecord?.breed) }}
</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="注册时间" :span="2">
{{ formatDate(currentRecord?.createdAt) }}
</a-descriptions-item>
<a-descriptions-item label="详细地址" :span="2">
{{ currentRecord?.address }}
</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">
{{ currentRecord?.remark || '无' }}
</a-descriptions-item>
</a-descriptions>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
PlusOutlined,
ExportOutlined,
BankOutlined
} from '@ant-design/icons-vue'
import PageHeader from '@/components/common/PageHeader.vue'
import SearchForm from '@/components/common/SearchForm.vue'
import DataTable from '@/components/common/DataTable.vue'
import { useBreedingStore } from '@/stores/breeding'
import { formatDate } from '@/utils/date'
const breedingStore = useBreedingStore()
// 响应式数据
const loading = ref(false)
const modalVisible = ref(false)
const detailVisible = ref(false)
const currentRecord = ref(null)
const formRef = ref()
// 搜索条件
const searchParams = reactive({
name: '',
manager: '',
status: '',
scale: '',
breed: ''
})
// 表单数据
const formData = reactive({
id: null,
name: '',
manager: '',
phone: '',
scale: '',
breed: '',
status: 'active',
address: '',
remark: ''
})
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => `${range[0]}-${range[1]} 条,共 ${total}`
})
// 搜索字段配置
const searchFields = [
{
type: 'input',
key: 'name',
label: '养殖场名称',
placeholder: '请输入养殖场名称'
},
{
type: 'input',
key: 'manager',
label: '负责人',
placeholder: '请输入负责人姓名'
},
{
type: 'select',
key: 'status',
label: '状态',
placeholder: '请选择状态',
options: [
{ label: '正常运营', value: 'active' },
{ label: '暂停运营', value: 'suspended' },
{ label: '已关闭', value: 'closed' }
]
},
{
type: 'select',
key: 'scale',
label: '养殖规模',
placeholder: '请选择养殖规模',
options: [
{ label: '小型(<1000头', value: 'small' },
{ label: '中型1000-5000头', value: 'medium' },
{ label: '大型(>5000头', value: 'large' }
]
},
{
type: 'select',
key: 'breed',
label: '养殖品种',
placeholder: '请选择养殖品种',
options: [
{ label: '生猪', value: 'pig' },
{ label: '牛', value: 'cattle' },
{ label: '羊', value: 'sheep' },
{ label: '鸡', value: 'chicken' },
{ label: '鸭', value: 'duck' },
{ label: '鱼', value: 'fish' }
]
}
]
// 表格列配置
const columns = [
{
title: '养殖场名称',
dataIndex: 'name',
key: 'name',
width: 200,
ellipsis: true
},
{
title: '负责人',
dataIndex: 'manager',
key: 'manager',
width: 120
},
{
title: '联系电话',
dataIndex: 'phone',
key: 'phone',
width: 140
},
{
title: '养殖规模',
dataIndex: 'scale',
key: 'scale',
width: 120,
align: 'center'
},
{
title: '养殖品种',
dataIndex: 'breed',
key: 'breed',
width: 100,
customRender: ({ text }) => getBreedText(text)
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
align: 'center'
},
{
title: '注册时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
customRender: ({ text }) => formatDate(text)
},
{
title: '操作',
key: 'action',
width: 200,
align: 'center',
fixed: 'right'
}
]
// 表单验证规则
const formRules = {
name: [
{ required: true, message: '请输入养殖场名称', trigger: 'blur' }
],
manager: [
{ required: true, message: '请输入负责人姓名', trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入联系电话', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
],
scale: [
{ required: true, message: '请选择养殖规模', trigger: 'change' }
],
breed: [
{ required: true, message: '请选择养殖品种', trigger: 'change' }
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' }
],
address: [
{ required: true, message: '请输入详细地址', trigger: 'blur' }
]
}
// 计算属性
const dataSource = computed(() => breedingStore.farmList || [])
const modalTitle = computed(() => formData.id ? '编辑养殖场' : '新增养殖场')
// 获取状态颜色
const getStatusColor = (status) => {
const colorMap = {
active: 'success',
suspended: 'warning',
closed: 'error'
}
return colorMap[status] || 'default'
}
// 获取状态文本
const getStatusText = (status) => {
const textMap = {
active: '正常运营',
suspended: '暂停运营',
closed: '已关闭'
}
return textMap[status] || '未知'
}
// 获取规模文本
const getScaleText = (scale) => {
const textMap = {
small: '小型(<1000头',
medium: '中型1000-5000头',
large: '大型(>5000头'
}
return textMap[scale] || '未知'
}
// 获取品种文本
const getBreedText = (breed) => {
const textMap = {
pig: '生猪',
cattle: '牛',
sheep: '羊',
chicken: '鸡',
duck: '鸭',
fish: '鱼'
}
return textMap[breed] || '未知'
}
// 加载数据
const loadData = async () => {
try {
loading.value = true
const params = {
...searchParams,
page: pagination.current,
pageSize: pagination.pageSize
}
const result = await breedingStore.fetchFarmList(params)
pagination.total = result.total
} catch (error) {
message.error('加载数据失败')
} finally {
loading.value = false
}
}
// 搜索处理
const handleSearch = (values) => {
Object.assign(searchParams, values)
pagination.current = 1
loadData()
}
// 重置搜索
const handleReset = () => {
Object.keys(searchParams).forEach(key => {
searchParams[key] = ''
})
pagination.current = 1
loadData()
}
// 表格变化处理
const handleTableChange = (pag, filters, sorter) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadData()
}
// 新增处理
const handleAdd = () => {
resetForm()
modalVisible.value = true
}
// 编辑处理
const handleEdit = (record) => {
Object.assign(formData, record)
modalVisible.value = true
}
// 查看处理
const handleView = (record) => {
currentRecord.value = record
detailVisible.value = true
}
// 删除处理
const handleDelete = async (record) => {
try {
await breedingStore.deleteFarm(record.id)
message.success('删除成功')
loadData()
} catch (error) {
message.error('删除失败')
}
}
// 导出处理
const handleExport = async () => {
try {
await breedingStore.exportFarmList(searchParams)
message.success('导出成功')
} catch (error) {
message.error('导出失败')
}
}
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value.validate()
if (formData.id) {
await breedingStore.updateFarm(formData.id, formData)
message.success('更新成功')
} else {
await breedingStore.createFarm(formData)
message.success('创建成功')
}
modalVisible.value = false
loadData()
} catch (error) {
if (error.errorFields) {
message.error('请检查表单输入')
} else {
message.error('操作失败')
}
}
}
// 取消处理
const handleCancel = () => {
modalVisible.value = false
resetForm()
}
// 重置表单
const resetForm = () => {
Object.assign(formData, {
id: null,
name: '',
manager: '',
phone: '',
scale: '',
breed: '',
status: 'active',
address: '',
remark: ''
})
formRef.value?.resetFields()
}
// 组件挂载
onMounted(() => {
loadData()
})
</script>
<style lang="scss" scoped>
.breeding-farm-list {
.ant-descriptions {
margin-top: 16px;
}
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div class="farm-management">
<a-card title="养殖场管理" :bordered="false">
<div class="search-form">
<a-form layout="inline" :model="searchForm">
<a-form-item label="养殖场名称">
<a-input v-model:value="searchForm.name" placeholder="请输入养殖场名称" />
</a-form-item>
<a-form-item label="养殖类型">
<a-select v-model:value="searchForm.type" placeholder="请选择养殖类型" style="width: 120px">
<a-select-option value="pig">生猪</a-select-option>
<a-select-option value="cattle"></a-select-option>
<a-select-option value="poultry">家禽</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary">查询</a-button>
<a-button style="margin-left: 8px">重置</a-button>
</a-form-item>
</a-form>
</div>
<a-table
:columns="columns"
:data-source="farmList"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
{{ record.status === 'active' ? '正常' : '停用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small">详情</a-button>
<a-button type="link" size="small">编辑</a-button>
<a-button type="link" size="small" danger>删除</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
const loading = ref(false)
const farmList = ref([])
const searchForm = reactive({
name: '',
type: undefined
})
const columns = [
{ title: '养殖场名称', dataIndex: 'name', key: 'name' },
{ title: '负责人', dataIndex: 'owner', key: 'owner' },
{ title: '养殖类型', dataIndex: 'type', key: 'type' },
{ title: '规模', dataIndex: 'scale', key: 'scale' },
{ title: '地址', dataIndex: 'address', key: 'address' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '操作', key: 'action', width: 200 }
]
onMounted(() => {
farmList.value = [
{
id: 1,
name: '阳光养殖场',
owner: '张三',
type: '生猪',
scale: '500头',
address: '某县某镇某村',
status: 'active'
},
{
id: 2,
name: '绿野养牛场',
owner: '李四',
type: '牛',
scale: '200头',
address: '某县某镇某村',
status: 'active'
}
]
})
</script>
<style scoped>
.farm-management {
padding: 24px;
}
.search-form {
margin-bottom: 16px;
padding: 16px;
background: #fafafa;
border-radius: 6px;
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<div class="forage-enterprises">
<a-card title="饲料企业管理" :bordered="false">
<a-table
:columns="columns"
:data-source="enterprisesList"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
{{ record.status === 'active' ? '正常' : '停业' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small">详情</a-button>
<a-button type="link" size="small">监管</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const loading = ref(false)
const enterprisesList = ref([])
const columns = [
{ title: '企业名称', dataIndex: 'name', key: 'name' },
{ title: '法人代表', dataIndex: 'representative', key: 'representative' },
{ title: '许可证号', dataIndex: 'licenseNo', key: 'licenseNo' },
{ title: '联系电话', dataIndex: 'phone', key: 'phone' },
{ title: '地址', dataIndex: 'address', key: 'address' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '操作', key: 'action', width: 150 }
]
onMounted(() => {
enterprisesList.value = [
{
id: 1,
name: '绿源饲料有限公司',
representative: '王总',
licenseNo: 'FL2024001',
phone: '0123-4567890',
address: '工业园区A区',
status: 'active'
}
]
})
</script>
<style scoped>
.forage-enterprises {
padding: 24px;
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="insurance-management">
<a-card title="保险管理" :bordered="false">
<a-table
:columns="columns"
:data-source="insuranceList"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small">详情</a-button>
<a-button type="link" size="small">理赔</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const loading = ref(false)
const insuranceList = ref([])
const columns = [
{ title: '保单号', dataIndex: 'policyNo', key: 'policyNo' },
{ title: '投保人', dataIndex: 'insured', key: 'insured' },
{ title: '保险类型', dataIndex: 'type', key: 'type' },
{ title: '保险金额', dataIndex: 'amount', key: 'amount' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '操作', key: 'action', width: 150 }
]
const getStatusColor = (status) => {
const colors = {
active: 'green',
expired: 'red',
pending: 'orange'
}
return colors[status] || 'default'
}
const getStatusText = (status) => {
const texts = {
active: '有效',
expired: '已过期',
pending: '待审核'
}
return texts[status] || '未知'
}
onMounted(() => {
insuranceList.value = [
{
id: 1,
policyNo: 'INS2024001',
insured: '张三',
type: '养殖保险',
amount: '50万元',
status: 'active'
}
]
})
</script>
<style scoped>
.insurance-management {
padding: 24px;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div class="market-info">
<a-card title="市场信息" :bordered="false">
<a-row :gutter="16" style="margin-bottom: 24px;">
<a-col :span="6">
<a-card size="small">
<a-statistic
title="生猪价格"
:value="marketData.pigPrice"
suffix="元/公斤"
:value-style="{ color: '#3f8600' }"
/>
</a-card>
</a-col>
<a-col :span="6">
<a-card size="small">
<a-statistic
title="牛肉价格"
:value="marketData.beefPrice"
suffix="元/公斤"
:value-style="{ color: '#1890ff' }"
/>
</a-card>
</a-col>
<a-col :span="6">
<a-card size="small">
<a-statistic
title="鸡蛋价格"
:value="marketData.eggPrice"
suffix="元/公斤"
:value-style="{ color: '#722ed1' }"
/>
</a-card>
</a-col>
<a-col :span="6">
<a-card size="small">
<a-statistic
title="饲料价格"
:value="marketData.feedPrice"
suffix="元/吨"
:value-style="{ color: '#fa8c16' }"
/>
</a-card>
</a-col>
</a-row>
<a-table
:columns="columns"
:data-source="priceList"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'trend'">
<a-tag :color="getTrendColor(record.trend)">
{{ getTrendText(record.trend) }}
</a-tag>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
const loading = ref(false)
const priceList = ref([])
const marketData = reactive({
pigPrice: 16.8,
beefPrice: 68.5,
eggPrice: 12.3,
feedPrice: 3200
})
const columns = [
{ title: '商品名称', dataIndex: 'product', key: 'product' },
{ title: '当前价格', dataIndex: 'currentPrice', key: 'currentPrice' },
{ title: '昨日价格', dataIndex: 'yesterdayPrice', key: 'yesterdayPrice' },
{ title: '涨跌幅', dataIndex: 'change', key: 'change' },
{ title: '趋势', dataIndex: 'trend', key: 'trend' },
{ title: '更新时间', dataIndex: 'updateTime', key: 'updateTime' }
]
const getTrendColor = (trend) => {
const colors = {
up: 'red',
down: 'green',
stable: 'blue'
}
return colors[trend] || 'default'
}
const getTrendText = (trend) => {
const texts = {
up: '上涨',
down: '下跌',
stable: '持平'
}
return texts[trend] || '未知'
}
onMounted(() => {
priceList.value = [
{
id: 1,
product: '生猪',
currentPrice: '16.8元/公斤',
yesterdayPrice: '16.5元/公斤',
change: '+1.8%',
trend: 'up',
updateTime: '2024-01-15 14:00'
},
{
id: 2,
product: '牛肉',
currentPrice: '68.5元/公斤',
yesterdayPrice: '69.0元/公斤',
change: '-0.7%',
trend: 'down',
updateTime: '2024-01-15 14:00'
}
]
})
</script>
<style scoped>
.market-info {
padding: 24px;
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<div class="subsidy-management">
<a-card title="补贴管理" :bordered="false">
<a-table
:columns="columns"
:data-source="subsidyList"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small">详情</a-button>
<a-button type="link" size="small">发放</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const loading = ref(false)
const subsidyList = ref([])
const columns = [
{ title: '补贴编号', dataIndex: 'subsidyNo', key: 'subsidyNo' },
{ title: '申请人', dataIndex: 'applicant', key: 'applicant' },
{ title: '补贴类型', dataIndex: 'type', key: 'type' },
{ title: '补贴金额', dataIndex: 'amount', key: 'amount' },
{ title: '申请时间', dataIndex: 'applyTime', key: 'applyTime' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '操作', key: 'action', width: 150 }
]
const getStatusColor = (status) => {
const colors = {
pending: 'orange',
approved: 'green',
rejected: 'red',
paid: 'blue'
}
return colors[status] || 'default'
}
const getStatusText = (status) => {
const texts = {
pending: '待审核',
approved: '已批准',
rejected: '已拒绝',
paid: '已发放'
}
return texts[status] || '未知'
}
onMounted(() => {
subsidyList.value = [
{
id: 1,
subsidyNo: 'SUB2024001',
applicant: '张三',
type: '养殖补贴',
amount: '5000元',
applyTime: '2024-01-10',
status: 'pending'
}
]
})
</script>
<style scoped>
.subsidy-management {
padding: 24px;
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<div class="trading-management">
<a-card title="交易管理" :bordered="false">
<a-table
:columns="columns"
:data-source="tradingList"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small">详情</a-button>
<a-button type="link" size="small">审核</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const loading = ref(false)
const tradingList = ref([])
const columns = [
{ title: '交易编号', dataIndex: 'tradeNo', key: 'tradeNo' },
{ title: '卖方', dataIndex: 'seller', key: 'seller' },
{ title: '买方', dataIndex: 'buyer', key: 'buyer' },
{ title: '商品', dataIndex: 'product', key: 'product' },
{ title: '数量', dataIndex: 'quantity', key: 'quantity' },
{ title: '金额', dataIndex: 'amount', key: 'amount' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '操作', key: 'action', width: 150 }
]
const getStatusColor = (status) => {
const colors = {
pending: 'orange',
approved: 'green',
rejected: 'red',
completed: 'blue'
}
return colors[status] || 'default'
}
const getStatusText = (status) => {
const texts = {
pending: '待审核',
approved: '已批准',
rejected: '已拒绝',
completed: '已完成'
}
return texts[status] || '未知'
}
onMounted(() => {
tradingList.value = [
{
id: 1,
tradeNo: 'TR2024001',
seller: '阳光养殖场',
buyer: '某肉类加工厂',
product: '生猪',
quantity: '100头',
amount: '50万元',
status: 'pending'
}
]
})
</script>
<style scoped>
.trading-management {
padding: 24px;
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<div class="waste-collection">
<a-card title="废料收集管理" :bordered="false">
<a-table
:columns="columns"
:data-source="wasteList"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small">详情</a-button>
<a-button type="link" size="small">处理</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const loading = ref(false)
const wasteList = ref([])
const columns = [
{ title: '收集编号', dataIndex: 'collectionNo', key: 'collectionNo' },
{ title: '养殖场', dataIndex: 'farm', key: 'farm' },
{ title: '废料类型', dataIndex: 'wasteType', key: 'wasteType' },
{ title: '数量', dataIndex: 'quantity', key: 'quantity' },
{ title: '收集时间', dataIndex: 'collectionTime', key: 'collectionTime' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '操作', key: 'action', width: 150 }
]
const getStatusColor = (status) => {
const colors = {
collected: 'blue',
processing: 'orange',
completed: 'green'
}
return colors[status] || 'default'
}
const getStatusText = (status) => {
const texts = {
collected: '已收集',
processing: '处理中',
completed: '已完成'
}
return texts[status] || '未知'
}
onMounted(() => {
wasteList.value = [
{
id: 1,
collectionNo: 'WC2024001',
farm: '阳光养殖场',
wasteType: '粪便',
quantity: '5吨',
collectionTime: '2024-01-15',
status: 'collected'
}
]
})
</script>
<style scoped>
.waste-collection {
padding: 24px;
}
</style>

View File

@@ -0,0 +1,975 @@
<template>
<div class="dashboard">
<!-- 页面头部 -->
<PageHeader
title="数据概览"
description="畜牧业监管系统数据统计与分析"
icon="dashboard"
>
<template #extra>
<a-space>
<a-select v-model:value="timeRange" style="width: 120px" @change="handleTimeRangeChange">
<a-select-option value="today">今日</a-select-option>
<a-select-option value="week">本周</a-select-option>
<a-select-option value="month">本月</a-select-option>
<a-select-option value="year">本年</a-select-option>
</a-select>
<a-button @click="handleRefresh" :loading="refreshLoading">
<template #icon>
<ReloadOutlined />
</template>
刷新
</a-button>
<a-button type="primary" @click="handleExportReport">
<template #icon>
<DownloadOutlined />
</template>
导出报表
</a-button>
</a-space>
</template>
</PageHeader>
<!-- 核心指标卡片 -->
<a-row :gutter="[16, 16]" class="stats-cards">
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stat-card">
<a-statistic
title="养殖场总数"
:value="dashboardData.totalFarms"
:precision="0"
:value-style="{ color: '#1890ff' }"
>
<template #prefix>
<HomeOutlined />
</template>
<template #suffix>
<span class="stat-suffix"></span>
</template>
</a-statistic>
<div class="stat-trend">
<span :class="['trend-text', getTrendClass(dashboardData.farmTrend)]">
<CaretUpOutlined v-if="dashboardData.farmTrend > 0" />
<CaretDownOutlined v-else-if="dashboardData.farmTrend < 0" />
{{ Math.abs(dashboardData.farmTrend) }}%
</span>
<span class="trend-desc">较上期</span>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stat-card">
<a-statistic
title="动物总数"
:value="dashboardData.totalAnimals"
:precision="0"
:value-style="{ color: '#52c41a' }"
>
<template #prefix>
<BugOutlined />
</template>
<template #suffix>
<span class="stat-suffix"></span>
</template>
</a-statistic>
<div class="stat-trend">
<span :class="['trend-text', getTrendClass(dashboardData.animalTrend)]">
<CaretUpOutlined v-if="dashboardData.animalTrend > 0" />
<CaretDownOutlined v-else-if="dashboardData.animalTrend < 0" />
{{ Math.abs(dashboardData.animalTrend) }}%
</span>
<span class="trend-desc">较上期</span>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stat-card">
<a-statistic
title="检查次数"
:value="dashboardData.totalInspections"
:precision="0"
:value-style="{ color: '#faad14' }"
>
<template #prefix>
<SearchOutlined />
</template>
<template #suffix>
<span class="stat-suffix"></span>
</template>
</a-statistic>
<div class="stat-trend">
<span :class="['trend-text', getTrendClass(dashboardData.inspectionTrend)]">
<CaretUpOutlined v-if="dashboardData.inspectionTrend > 0" />
<CaretDownOutlined v-else-if="dashboardData.inspectionTrend < 0" />
{{ Math.abs(dashboardData.inspectionTrend) }}%
</span>
<span class="trend-desc">较上期</span>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<a-card class="stat-card">
<a-statistic
title="预警事件"
:value="dashboardData.totalAlerts"
:precision="0"
:value-style="{ color: '#ff4d4f' }"
>
<template #prefix>
<AlertOutlined />
</template>
<template #suffix>
<span class="stat-suffix"></span>
</template>
</a-statistic>
<div class="stat-trend">
<span :class="['trend-text', getTrendClass(dashboardData.alertTrend)]">
<CaretUpOutlined v-if="dashboardData.alertTrend > 0" />
<CaretDownOutlined v-else-if="dashboardData.alertTrend < 0" />
{{ Math.abs(dashboardData.alertTrend) }}%
</span>
<span class="trend-desc">较上期</span>
</div>
</a-card>
</a-col>
</a-row>
<!-- 图表区域 -->
<div class="charts-section">
<a-row :gutter="[16, 16]">
<a-col :span="12">
<a-card title="养殖场数量趋势" class="chart-card">
<LineChart
:data="farmTrendData"
:x-axis-data="trendXAxisData"
title=""
height="300px"
:show-area="true"
/>
</a-card>
</a-col>
<a-col :span="12">
<a-card title="动物健康状况分布" class="chart-card">
<PieChart
:data="healthStatusData"
title=""
height="300px"
:radius="['30%', '60%']"
/>
</a-card>
</a-col>
<a-col :span="8">
<a-card title="检查完成率" class="chart-card">
<GaugeChart
:value="inspectionCompletionRate"
title=""
height="250px"
unit="%"
/>
</a-card>
</a-col>
<a-col :span="8">
<a-card title="月度检查数量" class="chart-card">
<BarChart
:data="monthlyInspectionData"
:x-axis-data="monthXAxisData"
title=""
height="250px"
/>
</a-card>
</a-col>
<a-col :span="8">
<a-card title="应急响应时效" class="chart-card">
<GaugeChart
:value="emergencyResponseRate"
title=""
height="250px"
unit="%"
:color="[
[0.3, '#fd666d'],
[0.7, '#faad14'],
[1, '#52c41a']
]"
/>
</a-card>
</a-col>
<a-col :span="24">
<a-card title="各地区养殖场分布" class="chart-card">
<MapChart
:data="regionDistributionData"
title=""
height="400px"
map-name="china"
:visual-map-max="500"
/>
</a-card>
</a-col>
</a-row>
</div>
<!-- 快捷操作和最新动态 -->
<a-row :gutter="[16, 16]" class="action-section">
<!-- 快捷操作 -->
<a-col :xs="24" :lg="8">
<a-card title="快捷操作" class="action-card">
<div class="quick-actions">
<a-row :gutter="[8, 8]">
<a-col :span="12">
<a-button
type="primary"
block
@click="handleQuickAction('addFarm')"
class="quick-btn"
>
<template #icon>
<PlusOutlined />
</template>
新增养殖场
</a-button>
</a-col>
<a-col :span="12">
<a-button
block
@click="handleQuickAction('inspection')"
class="quick-btn"
>
<template #icon>
<SearchOutlined />
</template>
开始检查
</a-button>
</a-col>
<a-col :span="12">
<a-button
block
@click="handleQuickAction('emergency')"
class="quick-btn emergency"
>
<template #icon>
<AlertOutlined />
</template>
应急响应
</a-button>
</a-col>
<a-col :span="12">
<a-button
block
@click="handleQuickAction('report')"
class="quick-btn"
>
<template #icon>
<FileTextOutlined />
</template>
生成报表
</a-button>
</a-col>
</a-row>
</div>
</a-card>
</a-col>
<!-- 最新动态 -->
<a-col :xs="24" :lg="8">
<a-card title="最新动态" class="activity-card">
<template #extra>
<a @click="handleViewAllActivities">查看全部</a>
</template>
<a-list
:data-source="recentActivities"
size="small"
:loading="activitiesLoading"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #avatar>
<a-avatar :style="{ backgroundColor: getActivityColor(item.type) }">
<component :is="getActivityIcon(item.type)" />
</a-avatar>
</template>
<template #title>
<span class="activity-title">{{ item.title }}</span>
</template>
<template #description>
<div class="activity-desc">
<div>{{ item.description }}</div>
<div class="activity-time">{{ formatDateTime(item.createTime) }}</div>
</div>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
<!-- 待办事项 -->
<a-col :xs="24" :lg="8">
<a-card title="待办事项" class="todo-card">
<template #extra>
<a-badge :count="todoList.length" :offset="[10, 0]">
<a @click="handleViewAllTodos">查看全部</a>
</a-badge>
</template>
<a-list
:data-source="todoList"
size="small"
:loading="todoLoading"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>
<div class="todo-item">
<span class="todo-title">{{ item.title }}</span>
<a-tag
:color="getPriorityColor(item.priority)"
size="small"
>
{{ getPriorityText(item.priority) }}
</a-tag>
</div>
</template>
<template #description>
<div class="todo-desc">
<div>{{ item.description }}</div>
<div class="todo-time">
截止{{ formatDateTime(item.deadline) }}
</div>
</div>
</template>
</a-list-item-meta>
<template #actions>
<a @click="handleCompleteTodo(item)">完成</a>
</template>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
</a-row>
<!-- 系统状态 -->
<a-row :gutter="[16, 16]" class="system-section">
<a-col :span="24">
<a-card title="系统状态" class="system-card">
<a-row :gutter="16">
<a-col :xs="24" :sm="12" :md="6">
<div class="system-item">
<div class="system-label">数据库状态</div>
<div class="system-value">
<a-badge status="success" text="正常" />
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<div class="system-item">
<div class="system-label">API服务</div>
<div class="system-value">
<a-badge status="success" text="运行中" />
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<div class="system-item">
<div class="system-label">定时任务</div>
<div class="system-value">
<a-badge status="processing" text="执行中" />
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<div class="system-item">
<div class="system-label">存储空间</div>
<div class="system-value">
<span>75.2% 已使用</span>
</div>
</div>
</a-col>
</a-row>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
ReloadOutlined,
DownloadOutlined,
HomeOutlined,
BugOutlined,
SearchOutlined,
AlertOutlined,
CaretUpOutlined,
CaretDownOutlined,
MoreOutlined,
PlusOutlined,
FileTextOutlined
} from '@ant-design/icons-vue'
import PageHeader from '@/components/common/PageHeader.vue'
import { LineChart, BarChart, PieChart, GaugeChart, MapChart } from '@/components/charts'
import { useDashboardStore } from '@/stores/dashboard'
import { formatDateTime } from '@/utils/format'
const router = useRouter()
const dashboardStore = useDashboardStore()
// 响应式数据
const timeRange = ref('month')
const refreshLoading = ref(false)
const activitiesLoading = ref(false)
const todoLoading = ref(false)
// 图表引用
const farmDistributionChart = ref()
const healthStatusChart = ref()
const inspectionTrendChart = ref()
const alertStatChart = ref()
// 图表数据
const farmTrendData = ref([
{
name: '养殖场数量',
data: [120, 132, 101, 134, 90, 230, 210, 182, 191, 234, 290, 330]
}
])
const trendXAxisData = ref([
'1月', '2月', '3月', '4月', '5月', '6月',
'7月', '8月', '9月', '10月', '11月', '12月'
])
const healthStatusData = ref([
{ name: '健康', value: 1548 },
{ name: '亚健康', value: 735 },
{ name: '疑似患病', value: 580 },
{ name: '确诊患病', value: 484 },
{ name: '康复中', value: 300 }
])
const inspectionCompletionRate = ref(87.5)
const monthlyInspectionData = ref([
{
name: '检查数量',
data: [820, 932, 901, 934, 1290, 1330, 1320, 1200, 1100, 1400, 1500, 1600]
}
])
const monthXAxisData = ref([
'1月', '2月', '3月', '4月', '5月', '6月',
'7月', '8月', '9月', '10月', '11月', '12月'
])
const emergencyResponseRate = ref(92.3)
const regionDistributionData = ref([
{ name: '北京', value: 177 },
{ name: '天津', value: 42 },
{ name: '河北', value: 102 },
{ name: '山西', value: 81 },
{ name: '内蒙古', value: 47 },
{ name: '辽宁', value: 67 },
{ name: '吉林', value: 82 },
{ name: '黑龙江', value: 123 },
{ name: '上海', value: 24 },
{ name: '江苏', value: 215 },
{ name: '浙江', value: 189 },
{ name: '安徽', value: 134 },
{ name: '福建', value: 156 },
{ name: '江西', value: 98 },
{ name: '山东', value: 345 },
{ name: '河南', value: 267 },
{ name: '湖北', value: 187 },
{ name: '湖南', value: 234 },
{ name: '广东', value: 456 },
{ name: '广西', value: 123 },
{ name: '海南', value: 45 },
{ name: '重庆', value: 89 },
{ name: '四川', value: 278 },
{ name: '贵州', value: 67 },
{ name: '云南', value: 134 },
{ name: '西藏', value: 12 },
{ name: '陕西', value: 156 },
{ name: '甘肃', value: 78 },
{ name: '青海', value: 23 },
{ name: '宁夏', value: 34 },
{ name: '新疆', value: 89 }
])
// 仪表盘数据
const dashboardData = reactive({
totalFarms: 1245,
farmTrend: 8.5,
totalAnimals: 156789,
animalTrend: 12.3,
totalInspections: 2456,
inspectionTrend: -3.2,
totalAlerts: 23,
alertTrend: -15.6
})
// 最新动态
const recentActivities = ref([
{
id: 1,
type: 'inspection',
title: '完成例行检查',
description: '对阳光养殖场进行了例行检查发现2项轻微问题',
createTime: new Date(Date.now() - 2 * 60 * 60 * 1000)
},
{
id: 2,
type: 'alert',
title: '健康预警',
description: '绿野养殖场出现动物健康异常,已通知相关人员',
createTime: new Date(Date.now() - 4 * 60 * 60 * 1000)
},
{
id: 3,
type: 'farm',
title: '新增养殖场',
description: '春天养殖场完成注册审核,正式纳入监管范围',
createTime: new Date(Date.now() - 6 * 60 * 60 * 1000)
},
{
id: 4,
type: 'report',
title: '月度报表生成',
description: '2024年3月畜牧业监管月报已生成完成',
createTime: new Date(Date.now() - 8 * 60 * 60 * 1000)
}
])
// 待办事项
const todoList = ref([
{
id: 1,
title: '处理健康预警',
description: '绿野养殖场动物健康异常需要跟进处理',
priority: 'high',
deadline: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000)
},
{
id: 2,
title: '审核养殖场申请',
description: '3家新申请养殖场的资质审核',
priority: 'medium',
deadline: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000)
},
{
id: 3,
title: '制定检查计划',
description: '下月度检查计划制定和人员安排',
priority: 'low',
deadline: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
}
])
// 获取趋势样式类
const getTrendClass = (trend) => {
if (trend > 0) return 'trend-up'
if (trend < 0) return 'trend-down'
return 'trend-flat'
}
// 获取活动颜色
const getActivityColor = (type) => {
const colorMap = {
inspection: '#1890ff',
alert: '#ff4d4f',
farm: '#52c41a',
report: '#faad14'
}
return colorMap[type] || '#d9d9d9'
}
// 获取活动图标
const getActivityIcon = (type) => {
const iconMap = {
inspection: 'SearchOutlined',
alert: 'AlertOutlined',
farm: 'HomeOutlined',
report: 'FileTextOutlined'
}
return iconMap[type] || 'InfoCircleOutlined'
}
// 获取优先级颜色
const getPriorityColor = (priority) => {
const colorMap = {
high: 'red',
medium: 'orange',
low: 'blue'
}
return colorMap[priority] || 'default'
}
// 获取优先级文本
const getPriorityText = (priority) => {
const textMap = {
high: '高',
medium: '中',
low: '低'
}
return textMap[priority] || '普通'
}
// 时间范围变化处理
const handleTimeRangeChange = (value) => {
loadDashboardData()
}
// 刷新数据
const handleRefresh = async () => {
try {
refreshLoading.value = true
await loadDashboardData()
message.success('数据刷新成功')
} catch (error) {
message.error('数据刷新失败')
} finally {
refreshLoading.value = false
}
}
// 导出报表
const handleExportReport = async () => {
try {
await dashboardStore.exportReport(timeRange.value)
message.success('报表导出成功')
} catch (error) {
message.error('报表导出失败')
}
}
// 图表操作
const handleChartAction = ({ key }) => {
switch (key) {
case 'refresh':
loadChartData()
break
case 'export':
message.info('图表导出功能开发中')
break
case 'fullscreen':
message.info('全屏查看功能开发中')
break
}
}
// 快捷操作
const handleQuickAction = (action) => {
switch (action) {
case 'addFarm':
router.push('/breeding/farms')
break
case 'inspection':
router.push('/inspection/management')
break
case 'emergency':
router.push('/emergency/response')
break
case 'report':
router.push('/reports/center')
break
}
}
// 查看所有活动
const handleViewAllActivities = () => {
message.info('查看全部活动功能开发中')
}
// 查看所有待办
const handleViewAllTodos = () => {
message.info('查看全部待办功能开发中')
}
// 完成待办
const handleCompleteTodo = async (todo) => {
try {
await dashboardStore.completeTodo(todo.id)
const index = todoList.value.findIndex(item => item.id === todo.id)
if (index > -1) {
todoList.value.splice(index, 1)
}
message.success('待办事项已完成')
} catch (error) {
message.error('操作失败')
}
}
// 加载仪表盘数据
const loadDashboardData = async () => {
try {
const data = await dashboardStore.fetchDashboardData(timeRange.value)
Object.assign(dashboardData, data)
// 加载图表数据
loadChartData()
} catch (error) {
message.error('加载数据失败')
}
}
// 加载图表数据
const loadChartData = () => {
// 这里应该初始化ECharts图表
// 暂时用占位符代替
if (farmDistributionChart.value) {
farmDistributionChart.value.innerHTML = '<div style="height: 300px; background: #f0f0f0; display: flex; align-items: center; justify-content: center; color: #999;">养殖场分布图表</div>'
}
if (healthStatusChart.value) {
healthStatusChart.value.innerHTML = '<div style="height: 300px; background: #f0f0f0; display: flex; align-items: center; justify-content: center; color: #999;">健康状况图表</div>'
}
if (inspectionTrendChart.value) {
inspectionTrendChart.value.innerHTML = '<div style="height: 300px; background: #f0f0f0; display: flex; align-items: center; justify-content: center; color: #999;">检查趋势图表</div>'
}
if (alertStatChart.value) {
alertStatChart.value.innerHTML = '<div style="height: 300px; background: #f0f0f0; display: flex; align-items: center; justify-content: center; color: #999;">预警统计图表</div>'
}
}
// 加载活动数据
const loadActivities = async () => {
try {
activitiesLoading.value = true
const data = await dashboardStore.fetchRecentActivities()
recentActivities.value = data
} catch (error) {
message.error('加载活动数据失败')
} finally {
activitiesLoading.value = false
}
}
// 加载待办数据
const loadTodos = async () => {
try {
todoLoading.value = true
const data = await dashboardStore.fetchTodoList()
todoList.value = data
} catch (error) {
message.error('加载待办数据失败')
} finally {
todoLoading.value = false
}
}
// 组件挂载
onMounted(() => {
loadDashboardData()
loadActivities()
loadTodos()
// 设置定时刷新
const refreshInterval = setInterval(() => {
loadDashboardData()
}, 5 * 60 * 1000) // 5分钟刷新一次
// 保存定时器引用
onUnmounted(() => {
clearInterval(refreshInterval)
})
})
</script>
<script>
export default {
name: 'Dashboard',
components: {
PageHeader,
LineChart,
BarChart,
PieChart,
GaugeChart,
MapChart
}
}
</script>
<style lang="scss" scoped>
.dashboard {
.stats-cards {
margin-bottom: 24px;
.stat-card {
.ant-statistic {
.ant-statistic-title {
color: #666;
font-size: 14px;
margin-bottom: 8px;
}
.ant-statistic-content {
.ant-statistic-content-value {
font-size: 28px;
font-weight: 600;
}
.stat-suffix {
font-size: 16px;
margin-left: 4px;
}
}
}
.stat-trend {
margin-top: 12px;
display: flex;
align-items: center;
gap: 8px;
.trend-text {
font-size: 12px;
font-weight: 500;
&.trend-up {
color: #52c41a;
}
&.trend-down {
color: #ff4d4f;
}
&.trend-flat {
color: #666;
}
}
.trend-desc {
font-size: 12px;
color: #999;
}
}
}
}
.chart-section {
margin-bottom: 24px;
.chart-card {
.chart-container {
height: 300px;
}
}
}
.action-section {
margin-bottom: 24px;
.action-card {
.quick-actions {
.quick-btn {
height: 48px;
margin-bottom: 8px;
&.emergency {
background-color: #ff4d4f;
border-color: #ff4d4f;
color: white;
&:hover {
background-color: #ff7875;
border-color: #ff7875;
}
}
}
}
}
.activity-card {
.activity-title {
font-weight: 500;
}
.activity-desc {
.activity-time {
font-size: 12px;
color: #999;
margin-top: 4px;
}
}
}
.todo-card {
.todo-item {
display: flex;
justify-content: space-between;
align-items: center;
.todo-title {
font-weight: 500;
}
}
.todo-desc {
.todo-time {
font-size: 12px;
color: #999;
margin-top: 4px;
}
}
}
}
.system-section {
.system-card {
.system-item {
text-align: center;
padding: 16px 0;
.system-label {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.system-value {
font-size: 16px;
font-weight: 500;
}
}
}
}
}
@media (max-width: 768px) {
.dashboard {
.stats-cards {
.stat-card {
margin-bottom: 16px;
}
}
.chart-section {
.chart-card {
margin-bottom: 16px;
.chart-container {
height: 250px;
}
}
}
.action-section {
.action-card,
.activity-card,
.todo-card {
margin-bottom: 16px;
}
}
}
}
</style>

View File

@@ -0,0 +1,948 @@
<template>
<div class="government-dashboard">
<!-- 欢迎区域 -->
<div class="welcome-section">
<a-card class="welcome-card">
<div class="welcome-content">
<div class="welcome-text">
<h2>欢迎回来{{ userInfo.name }}</h2>
<p>今天是 {{ currentDate }}{{ currentWeather }}</p>
<p class="welcome-desc">内蒙古畜牧业管理系统为您提供全面的数据监控和管理服务</p>
</div>
<div class="welcome-stats">
<div class="stat-item">
<div class="stat-number">{{ todayStats.newApplications }}</div>
<div class="stat-label">今日新增申请</div>
</div>
<div class="stat-item">
<div class="stat-number">{{ todayStats.pendingApprovals }}</div>
<div class="stat-label">待处理审批</div>
</div>
<div class="stat-item">
<div class="stat-number">{{ todayStats.completedTasks }}</div>
<div class="stat-label">已完成任务</div>
</div>
</div>
</div>
</a-card>
</div>
<!-- 核心数据统计 -->
<div class="core-stats">
<a-row :gutter="16">
<a-col :span="6">
<a-card class="stat-card farms">
<a-statistic
title="注册养殖场"
:value="coreStats.totalFarms"
:value-style="{ color: '#1890ff' }"
>
<template #prefix><HomeOutlined /></template>
<template #suffix>
<span class="stat-trend up">
<ArrowUpOutlined /> +12%
</span>
</template>
</a-statistic>
<div class="stat-detail">
<span>本月新增: {{ coreStats.newFarmsThisMonth }}</span>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card animals">
<a-statistic
title="监管动物总数"
:value="coreStats.totalAnimals"
:value-style="{ color: '#52c41a' }"
>
<template #prefix><BugOutlined /></template>
<template #suffix>
<span class="stat-trend up">
<ArrowUpOutlined /> +8%
</span>
</template>
</a-statistic>
<div class="stat-detail">
<span>健康率: {{ coreStats.healthRate }}%</span>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card veterinarians">
<a-statistic
title="注册兽医"
:value="coreStats.totalVeterinarians"
:value-style="{ color: '#722ed1' }"
>
<template #prefix><UserOutlined /></template>
<template #suffix>
<span class="stat-trend up">
<ArrowUpOutlined /> +5%
</span>
</template>
</a-statistic>
<div class="stat-detail">
<span>在线: {{ coreStats.onlineVeterinarians }}</span>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card alerts">
<a-statistic
title="预警信息"
:value="coreStats.totalAlerts"
:value-style="{ color: '#fa8c16' }"
>
<template #prefix><AlertOutlined /></template>
<template #suffix>
<span class="stat-trend down">
<ArrowDownOutlined /> -15%
</span>
</template>
</a-statistic>
<div class="stat-detail">
<span>紧急: {{ coreStats.urgentAlerts }}</span>
</div>
</a-card>
</a-col>
</a-row>
</div>
<!-- 图表区域 -->
<div class="charts-section">
<a-row :gutter="16">
<!-- 养殖场分布图 -->
<a-col :span="12">
<a-card title="养殖场地区分布" class="chart-card">
<template #extra>
<a-select v-model:value="farmDistributionPeriod" style="width: 120px">
<a-select-option value="month">本月</a-select-option>
<a-select-option value="quarter">本季度</a-select-option>
<a-select-option value="year">本年</a-select-option>
</a-select>
</template>
<div ref="farmDistributionChart" class="chart-container"></div>
</a-card>
</a-col>
<!-- 动物健康趋势 -->
<a-col :span="12">
<a-card title="动物健康趋势" class="chart-card">
<template #extra>
<a-radio-group v-model:value="healthTrendType" size="small">
<a-radio-button value="week"></a-radio-button>
<a-radio-button value="month"></a-radio-button>
<a-radio-button value="year"></a-radio-button>
</a-radio-group>
</template>
<div ref="healthTrendChart" class="chart-container"></div>
</a-card>
</a-col>
</a-row>
<a-row :gutter="16" style="margin-top: 16px">
<!-- 审批流程统计 -->
<a-col :span="8">
<a-card title="审批流程统计" class="chart-card">
<div ref="approvalChart" class="chart-container small"></div>
</a-card>
</a-col>
<!-- 设备监控状态 -->
<a-col :span="8">
<a-card title="设备监控状态" class="chart-card">
<div ref="deviceStatusChart" class="chart-container small"></div>
</a-card>
</a-col>
<!-- 预警类型分布 -->
<a-col :span="8">
<a-card title="预警类型分布" class="chart-card">
<div ref="alertTypeChart" class="chart-container small"></div>
</a-card>
</a-col>
</a-row>
</div>
<!-- 快捷操作和最新动态 -->
<div class="bottom-section">
<a-row :gutter="16">
<!-- 快捷操作 -->
<a-col :span="8">
<a-card title="快捷操作" class="quick-actions-card">
<div class="quick-actions">
<div class="action-item" @click="navigateTo('/farms/list')">
<div class="action-icon farms">
<HomeOutlined />
</div>
<div class="action-content">
<div class="action-title">养殖场管理</div>
<div class="action-desc">查看和管理养殖场信息</div>
</div>
</div>
<div class="action-item" @click="navigateTo('/approval/license')">
<div class="action-icon approval">
<FileTextOutlined />
</div>
<div class="action-content">
<div class="action-title">许可证审批</div>
<div class="action-desc">处理许可证申请和审批</div>
</div>
</div>
<div class="action-item" @click="navigateTo('/monitoring/devices')">
<div class="action-icon monitoring">
<MonitorOutlined />
</div>
<div class="action-content">
<div class="action-title">设备监控</div>
<div class="action-desc">实时监控设备运行状态</div>
</div>
</div>
<div class="action-item" @click="navigateTo('/personnel/veterinarians')">
<div class="action-icon personnel">
<TeamOutlined />
</div>
<div class="action-content">
<div class="action-title">人员管理</div>
<div class="action-desc">管理兽医和工作人员</div>
</div>
</div>
</div>
</a-card>
</a-col>
<!-- 最新动态 -->
<a-col :span="8">
<a-card title="最新动态" class="recent-activities-card">
<template #extra>
<a @click="viewAllActivities">查看全部</a>
</template>
<a-list
:data-source="recentActivities"
size="small"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #avatar>
<a-avatar :style="{ backgroundColor: item.color }">
<component :is="item.icon" />
</a-avatar>
</template>
<template #title>
<span>{{ item.title }}</span>
</template>
<template #description>
<div class="activity-desc">
<span>{{ item.description }}</span>
<span class="activity-time">{{ item.time }}</span>
</div>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
<!-- 待办事项 -->
<a-col :span="8">
<a-card title="待办事项" class="todo-card">
<template #extra>
<a-badge :count="todoList.filter(item => !item.completed).length">
<BellOutlined />
</a-badge>
</template>
<a-list
:data-source="todoList"
size="small"
>
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-button
type="link"
size="small"
@click="completeTodo(item.id)"
v-if="!item.completed"
>
完成
</a-button>
</template>
<a-list-item-meta>
<template #title>
<span :class="{ 'completed': item.completed }">
{{ item.title }}
</span>
</template>
<template #description>
<div class="todo-desc">
<a-tag :color="getPriorityColor(item.priority)" size="small">
{{ item.priority }}
</a-tag>
<span class="todo-time">{{ item.dueDate }}</span>
</div>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
</a-row>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, nextTick, computed } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import * as echarts from 'echarts'
import {
HomeOutlined,
BugOutlined,
UserOutlined,
AlertOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
FileTextOutlined,
MonitorOutlined,
TeamOutlined,
BellOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined
} from '@ant-design/icons-vue'
import { useGovernmentStore } from '@/stores/government'
import { useUserStore } from '@/stores/user'
import { useNotificationStore } from '@/stores/notification'
const router = useRouter()
const governmentStore = useGovernmentStore()
const userStore = useUserStore()
const notificationStore = useNotificationStore()
// 响应式数据
const farmDistributionPeriod = ref('month')
const healthTrendType = ref('month')
// 用户信息 - 从store获取
const userInfo = computed(() => ({
name: userStore.userInfo?.name || '管理员',
role: userStore.userInfo?.role || '系统管理员'
}))
// 当前日期和天气
const currentDate = ref('')
const currentWeather = ref('晴朗,适宜户外作业')
// 今日统计 - 从store获取
const todayStats = computed(() => ({
newApplications: governmentStore.approval.todayApplications || 15,
pendingApprovals: governmentStore.approval.pendingCount || 8,
completedTasks: governmentStore.approval.completedToday || 23
}))
// 核心统计数据 - 从store获取
const coreStats = computed(() => ({
totalFarms: governmentStore.supervision.totalEnterprises || 1248,
newFarmsThisMonth: governmentStore.supervision.newThisMonth || 45,
totalAnimals: governmentStore.supervision.totalAnimals || 156780,
healthRate: governmentStore.supervision.healthRate || 98.5,
totalVeterinarians: governmentStore.personnel.totalStaff || 156,
onlineVeterinarians: governmentStore.personnel.onlineStaff || 89,
totalAlerts: governmentStore.supervision.totalAlerts || 23,
urgentAlerts: governmentStore.supervision.urgentAlerts || 3
}))
// 最新动态 - 从store获取
const recentActivities = computed(() => {
return notificationStore.recentActivities.length > 0
? notificationStore.recentActivities.slice(0, 4)
: [
{
id: 1,
title: '新养殖场注册',
description: '呼和浩特市某养殖场完成注册',
time: '2小时前',
icon: 'HomeOutlined',
color: '#1890ff'
},
{
id: 2,
title: '许可证审批通过',
description: '包头市运输许可证审批完成',
time: '4小时前',
icon: 'CheckCircleOutlined',
color: '#52c41a'
},
{
id: 3,
title: '设备预警',
description: '某养殖场温度监控设备异常',
time: '6小时前',
icon: 'ExclamationCircleOutlined',
color: '#fa8c16'
},
{
id: 4,
title: '兽医认证',
description: '新增3名兽医完成资质认证',
time: '1天前',
icon: 'UserOutlined',
color: '#722ed1'
}
]
})
// 待办事项 - 从store获取
const todoList = computed(() => {
return governmentStore.todoList.length > 0
? governmentStore.todoList
: [
{
id: 1,
title: '审批养殖许可证申请',
priority: '高',
dueDate: '今天',
completed: false
},
{
id: 2,
title: '检查设备维护报告',
priority: '中',
dueDate: '明天',
completed: false
},
{
id: 3,
title: '更新兽医资质信息',
priority: '低',
dueDate: '本周',
completed: true
},
{
id: 4,
title: '处理养殖场投诉',
priority: '高',
dueDate: '今天',
completed: false
}
]
})
// 图表引用
const farmDistributionChart = ref(null)
const healthTrendChart = ref(null)
const approvalChart = ref(null)
const deviceStatusChart = ref(null)
const alertTypeChart = ref(null)
// 方法
const initCharts = () => {
nextTick(() => {
initFarmDistributionChart()
initHealthTrendChart()
initApprovalChart()
initDeviceStatusChart()
initAlertTypeChart()
})
}
const initFarmDistributionChart = () => {
if (!farmDistributionChart.value) return
const chart = echarts.init(farmDistributionChart.value)
const option = {
tooltip: {
trigger: 'item'
},
legend: {
orient: 'vertical',
left: 'left'
},
series: [
{
name: '养殖场分布',
type: 'pie',
radius: '50%',
data: [
{ value: 456, name: '呼和浩特市' },
{ value: 312, name: '包头市' },
{ value: 234, name: '乌海市' },
{ value: 156, name: '赤峰市' },
{ value: 90, name: '其他地区' }
],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
}
chart.setOption(option)
}
const initHealthTrendChart = () => {
if (!healthTrendChart.value) return
const chart = echarts.init(healthTrendChart.value)
const option = {
tooltip: {
trigger: 'axis'
},
legend: {
data: ['健康率', '疫苗接种率', '治疗成功率']
},
xAxis: {
type: 'category',
data: ['1月', '2月', '3月', '4月', '5月', '6月']
},
yAxis: {
type: 'value',
min: 90,
max: 100
},
series: [
{
name: '健康率',
type: 'line',
data: [98.2, 98.5, 98.1, 98.8, 98.6, 98.5],
smooth: true
},
{
name: '疫苗接种率',
type: 'line',
data: [96.5, 97.2, 97.8, 98.1, 98.3, 98.0],
smooth: true
},
{
name: '治疗成功率',
type: 'line',
data: [94.8, 95.2, 95.6, 96.1, 96.5, 96.8],
smooth: true
}
]
}
chart.setOption(option)
}
const initApprovalChart = () => {
if (!approvalChart.value) return
const chart = echarts.init(approvalChart.value)
const option = {
tooltip: {
trigger: 'item'
},
series: [
{
name: '审批状态',
type: 'pie',
radius: ['40%', '70%'],
data: [
{ value: 45, name: '已通过' },
{ value: 15, name: '待审批' },
{ value: 8, name: '已拒绝' }
]
}
]
}
chart.setOption(option)
}
const initDeviceStatusChart = () => {
if (!deviceStatusChart.value) return
const chart = echarts.init(deviceStatusChart.value)
const option = {
tooltip: {
trigger: 'item'
},
series: [
{
name: '设备状态',
type: 'pie',
radius: ['40%', '70%'],
data: [
{ value: 156, name: '正常' },
{ value: 23, name: '预警' },
{ value: 8, name: '故障' }
]
}
]
}
chart.setOption(option)
}
const initAlertTypeChart = () => {
if (!alertTypeChart.value) return
const chart = echarts.init(alertTypeChart.value)
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
xAxis: {
type: 'category',
data: ['温度', '湿度', '疫情', '设备', '其他']
},
yAxis: {
type: 'value'
},
series: [
{
name: '预警数量',
type: 'bar',
data: [8, 5, 3, 4, 3]
}
]
}
chart.setOption(option)
}
const navigateTo = (path) => {
router.push(path)
}
const viewAllActivities = () => {
message.info('查看全部动态功能待实现')
}
const completeTodo = async (id) => {
try {
await governmentStore.completeTodo(id)
message.success('任务已完成')
} catch (error) {
message.error('完成任务失败')
}
}
const getPriorityColor = (priority) => {
const colors = {
'高': 'red',
'中': 'orange',
'低': 'green'
}
return colors[priority] || 'default'
}
const updateCurrentDate = () => {
const now = new Date()
const options = {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long'
}
currentDate.value = now.toLocaleDateString('zh-CN', options)
}
// 生命周期
onMounted(async () => {
updateCurrentDate()
// 加载数据
try {
await Promise.all([
governmentStore.fetchDashboardData(),
notificationStore.fetchNotifications(),
userStore.fetchUserInfo()
])
} catch (error) {
console.error('加载仪表盘数据失败:', error)
message.error('数据加载失败,请刷新页面重试')
}
initCharts()
// 监听窗口大小变化,重新调整图表
window.addEventListener('resize', () => {
// 重新调整所有图表大小
setTimeout(() => {
const charts = [
farmDistributionChart,
healthTrendChart,
approvalChart,
deviceStatusChart,
alertTypeChart
]
charts.forEach(chartRef => {
if (chartRef.value) {
const chart = echarts.getInstanceByDom(chartRef.value)
if (chart) {
chart.resize()
}
}
})
}, 100)
})
})
</script>
<style scoped lang="scss">
.government-dashboard {
padding: 24px;
background: #f5f5f5;
min-height: 100vh;
.welcome-section {
margin-bottom: 24px;
.welcome-card {
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
:deep(.ant-card-body) {
padding: 32px;
}
.welcome-content {
display: flex;
justify-content: space-between;
align-items: center;
color: white;
.welcome-text {
h2 {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 600;
color: white;
}
p {
margin: 0 0 8px 0;
font-size: 16px;
opacity: 0.9;
}
.welcome-desc {
font-size: 14px;
opacity: 0.8;
}
}
.welcome-stats {
display: flex;
gap: 32px;
.stat-item {
text-align: center;
.stat-number {
font-size: 32px;
font-weight: 700;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
opacity: 0.8;
}
}
}
}
}
}
.core-stats {
margin-bottom: 24px;
.stat-card {
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
&.farms {
border-left: 4px solid #1890ff;
}
&.animals {
border-left: 4px solid #52c41a;
}
&.veterinarians {
border-left: 4px solid #722ed1;
}
&.alerts {
border-left: 4px solid #fa8c16;
}
.stat-trend {
font-size: 12px;
margin-left: 8px;
&.up {
color: #52c41a;
}
&.down {
color: #ff4d4f;
}
}
.stat-detail {
margin-top: 8px;
font-size: 12px;
color: #8c8c8c;
}
}
}
.charts-section {
margin-bottom: 24px;
.chart-card {
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
.chart-container {
height: 300px;
&.small {
height: 200px;
}
}
}
}
.bottom-section {
.quick-actions-card,
.recent-activities-card,
.todo-card {
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
height: 400px;
:deep(.ant-card-body) {
height: calc(100% - 57px);
overflow-y: auto;
}
}
.quick-actions {
.action-item {
display: flex;
align-items: center;
padding: 16px;
margin-bottom: 12px;
border-radius: 8px;
background: #fafafa;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: #f0f0f0;
transform: translateX(4px);
}
.action-icon {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
margin-right: 16px;
&.farms {
background: #1890ff;
}
&.approval {
background: #52c41a;
}
&.monitoring {
background: #fa8c16;
}
&.personnel {
background: #722ed1;
}
}
.action-content {
.action-title {
font-size: 16px;
font-weight: 500;
margin-bottom: 4px;
}
.action-desc {
font-size: 12px;
color: #8c8c8c;
}
}
}
}
.activity-desc {
display: flex;
justify-content: space-between;
align-items: center;
.activity-time {
font-size: 12px;
color: #8c8c8c;
}
}
.todo-desc {
display: flex;
justify-content: space-between;
align-items: center;
.todo-time {
font-size: 12px;
color: #8c8c8c;
}
}
.completed {
text-decoration: line-through;
opacity: 0.6;
}
}
}
:deep(.ant-statistic-title) {
font-size: 14px;
color: #8c8c8c;
}
:deep(.ant-statistic-content) {
font-size: 24px;
font-weight: 600;
}
:deep(.ant-card-head-title) {
font-weight: 600;
}
:deep(.ant-list-item-meta-title) {
font-size: 14px;
}
:deep(.ant-list-item-meta-description) {
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<div class="data-analysis">
<a-card title="数据分析" :bordered="false">
<a-row :gutter="16">
<a-col :span="12">
<a-card title="养殖数据趋势" size="small">
<div ref="trendChart" style="height: 300px;"></div>
</a-card>
</a-col>
<a-col :span="12">
<a-card title="区域分布" size="small">
<div ref="regionChart" style="height: 300px;"></div>
</a-card>
</a-col>
</a-row>
<a-row :gutter="16" style="margin-top: 16px;">
<a-col :span="24">
<a-card title="数据统计表" size="small">
<a-table
:columns="columns"
:data-source="dataList"
:loading="loading"
row-key="id"
size="small"
/>
</a-card>
</a-col>
</a-row>
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const loading = ref(false)
const dataList = ref([])
const trendChart = ref()
const regionChart = ref()
const columns = [
{ title: '指标名称', dataIndex: 'name', key: 'name' },
{ title: '数值', dataIndex: 'value', key: 'value' },
{ title: '单位', dataIndex: 'unit', key: 'unit' },
{ title: '更新时间', dataIndex: 'updateTime', key: 'updateTime' }
]
onMounted(() => {
// 模拟数据
dataList.value = [
{ id: 1, name: '养殖场总数', value: 150, unit: '个', updateTime: '2024-01-15' },
{ id: 2, name: '牲畜总数', value: 5000, unit: '头', updateTime: '2024-01-15' },
{ id: 3, name: '疫苗接种率', value: 95, unit: '%', updateTime: '2024-01-15' }
]
})
</script>
<style scoped>
.data-analysis {
padding: 24px;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
<template>
<div class="epidemic-activities">
<a-card title="疫情防控活动" :bordered="false">
<a-tabs>
<a-tab-pane key="ongoing" tab="进行中活动">
<a-table
:columns="activityColumns"
:data-source="ongoingActivities"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag color="blue">进行中</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small">详情</a-button>
<a-button type="link" size="small">参与</a-button>
</a-space>
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="completed" tab="已完成活动">
<a-table
:columns="completedColumns"
:data-source="completedActivities"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag color="green">已完成</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small">详情</a-button>
<a-button type="link" size="small">报告</a-button>
</a-space>
</template>
</template>
</a-table>
</a-tab-pane>
<a-tab-pane key="planned" tab="计划活动">
<a-table
:columns="plannedColumns"
:data-source="plannedActivities"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag color="orange">计划中</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small">详情</a-button>
<a-button type="link" size="small">编辑</a-button>
</a-space>
</template>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const loading = ref(false)
const ongoingActivities = ref([])
const completedActivities = ref([])
const plannedActivities = ref([])
const activityColumns = [
{ title: '活动名称', dataIndex: 'name', key: 'name' },
{ title: '活动类型', dataIndex: 'type', key: 'type' },
{ title: '负责机构', dataIndex: 'organization', key: 'organization' },
{ title: '开始时间', dataIndex: 'startTime', key: 'startTime' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '操作', key: 'action', width: 150 }
]
const completedColumns = [
{ title: '活动名称', dataIndex: 'name', key: 'name' },
{ title: '活动类型', dataIndex: 'type', key: 'type' },
{ title: '负责机构', dataIndex: 'organization', key: 'organization' },
{ title: '完成时间', dataIndex: 'endTime', key: 'endTime' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '操作', key: 'action', width: 150 }
]
const plannedColumns = [
{ title: '活动名称', dataIndex: 'name', key: 'name' },
{ title: '活动类型', dataIndex: 'type', key: 'type' },
{ title: '负责机构', dataIndex: 'organization', key: 'organization' },
{ title: '计划时间', dataIndex: 'plannedTime', key: 'plannedTime' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '操作', key: 'action', width: 150 }
]
onMounted(() => {
ongoingActivities.value = [
{
id: 1,
name: '春季疫苗接种活动',
type: '疫苗接种',
organization: '县疫控中心',
startTime: '2024-01-10',
status: 'ongoing'
}
]
completedActivities.value = [
{
id: 2,
name: '冬季消毒活动',
type: '环境消毒',
organization: '县疫控中心',
endTime: '2024-01-05',
status: 'completed'
}
]
plannedActivities.value = [
{
id: 3,
name: '夏季防疫培训',
type: '培训教育',
organization: '市畜牧局',
plannedTime: '2024-06-01',
status: 'planned'
}
]
})
</script>
<style scoped>
.epidemic-activities {
padding: 24px;
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<div class="epidemic-institutions">
<a-card title="疫情防控机构" :bordered="false">
<a-table
:columns="columns"
:data-source="institutionsList"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'level'">
<a-tag :color="getLevelColor(record.level)">
{{ getLevelText(record.level) }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small">详情</a-button>
<a-button type="link" size="small">联系</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const loading = ref(false)
const institutionsList = ref([])
const columns = [
{ title: '机构名称', dataIndex: 'name', key: 'name' },
{ title: '机构类型', dataIndex: 'type', key: 'type' },
{ title: '级别', dataIndex: 'level', key: 'level' },
{ title: '负责人', dataIndex: 'director', key: 'director' },
{ title: '联系电话', dataIndex: 'phone', key: 'phone' },
{ title: '地址', dataIndex: 'address', key: 'address' },
{ title: '操作', key: 'action', width: 150 }
]
const getLevelColor = (level) => {
const colors = {
national: 'red',
provincial: 'orange',
city: 'blue',
county: 'green'
}
return colors[level] || 'default'
}
const getLevelText = (level) => {
const texts = {
national: '国家级',
provincial: '省级',
city: '市级',
county: '县级'
}
return texts[level] || '未知'
}
onMounted(() => {
institutionsList.value = [
{
id: 1,
name: '县动物疫病预防控制中心',
type: '疫病防控',
level: 'county',
director: '李主任',
phone: '0123-4567890',
address: '县城中心路123号'
},
{
id: 2,
name: '市畜牧兽医局',
type: '行政管理',
level: 'city',
director: '王局长',
phone: '0123-7890123',
address: '市政府大楼5楼'
}
]
})
</script>
<style scoped>
.epidemic-institutions {
padding: 24px;
}
</style>

View File

@@ -0,0 +1,134 @@
<template>
<div class="epidemic-records">
<a-card title="疫情记录" :bordered="false">
<div class="search-form">
<a-form layout="inline" :model="searchForm">
<a-form-item label="疫情类型">
<a-select v-model:value="searchForm.type" placeholder="请选择疫情类型" style="width: 150px">
<a-select-option value="bird_flu">禽流感</a-select-option>
<a-select-option value="swine_fever">猪瘟</a-select-option>
<a-select-option value="foot_mouth">口蹄疫</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="发生地区">
<a-input v-model:value="searchForm.location" placeholder="请输入地区" />
</a-form-item>
<a-form-item label="时间范围">
<a-range-picker v-model:value="searchForm.dateRange" />
</a-form-item>
<a-form-item>
<a-button type="primary">查询</a-button>
<a-button style="margin-left: 8px">重置</a-button>
</a-form-item>
</a-form>
</div>
<a-table
:columns="columns"
:data-source="recordsList"
:loading="loading"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'severity'">
<a-tag :color="getSeverityColor(record.severity)">
{{ getSeverityText(record.severity) }}
</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'controlled' ? 'green' : 'orange'">
{{ record.status === 'controlled' ? '已控制' : '处理中' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small">详情</a-button>
<a-button type="link" size="small">编辑</a-button>
<a-button type="link" size="small">报告</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
const loading = ref(false)
const recordsList = ref([])
const searchForm = reactive({
type: undefined,
location: '',
dateRange: null
})
const columns = [
{ title: '疫情编号', dataIndex: 'code', key: 'code' },
{ title: '疫情类型', dataIndex: 'type', key: 'type' },
{ title: '发生地区', dataIndex: 'location', key: 'location' },
{ title: '严重程度', dataIndex: 'severity', key: 'severity' },
{ title: '影响范围', dataIndex: 'scope', key: 'scope' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '发生时间', dataIndex: 'occurTime', key: 'occurTime' },
{ title: '操作', key: 'action', width: 200 }
]
const getSeverityColor = (severity) => {
const colors = {
low: 'green',
medium: 'orange',
high: 'red'
}
return colors[severity] || 'default'
}
const getSeverityText = (severity) => {
const texts = {
low: '轻微',
medium: '中等',
high: '严重'
}
return texts[severity] || '未知'
}
onMounted(() => {
recordsList.value = [
{
id: 1,
code: 'EP2024001',
type: '禽流感',
location: '某县某镇',
severity: 'medium',
scope: '3个养殖场',
status: 'controlled',
occurTime: '2024-01-10'
},
{
id: 2,
code: 'EP2024002',
type: '猪瘟',
location: '某县某村',
severity: 'high',
scope: '1个养殖场',
status: 'processing',
occurTime: '2024-01-15'
}
]
})
</script>
<style scoped>
.epidemic-records {
padding: 24px;
}
.search-form {
margin-bottom: 16px;
padding: 16px;
background: #fafafa;
border-radius: 6px;
}
</style>

View File

@@ -0,0 +1,441 @@
<template>
<div class="vaccine-management">
<a-card title="疫苗管理" :bordered="false">
<template #extra>
<a-button type="primary" @click="showAddModal">
<template #icon>
<PlusOutlined />
</template>
添加疫苗
</a-button>
</template>
<!-- 搜索筛选 -->
<div class="search-form">
<a-row :gutter="16">
<a-col :span="6">
<a-input
v-model:value="searchForm.name"
placeholder="疫苗名称"
allow-clear
/>
</a-col>
<a-col :span="6">
<a-select
v-model:value="searchForm.type"
placeholder="疫苗类型"
allow-clear
>
<a-select-option value="preventive">预防性疫苗</a-select-option>
<a-select-option value="therapeutic">治疗性疫苗</a-select-option>
</a-select>
</a-col>
<a-col :span="6">
<a-select
v-model:value="searchForm.status"
placeholder="状态"
allow-clear
>
<a-select-option value="available">可用</a-select-option>
<a-select-option value="expired">过期</a-select-option>
<a-select-option value="shortage">库存不足</a-select-option>
</a-select>
</a-col>
<a-col :span="6">
<a-button type="primary" @click="handleSearch">
搜索
</a-button>
<a-button style="margin-left: 8px" @click="resetSearch">
重置
</a-button>
</a-col>
</a-row>
</div>
<!-- 疫苗列表 -->
<a-table
:columns="columns"
:data-source="vaccineList"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="showEditModal(record)">
编辑
</a-button>
<a-button type="link" size="small" @click="showDetail(record)">
详情
</a-button>
<a-popconfirm
title="确定要删除这个疫苗吗?"
@confirm="handleDelete(record.id)"
>
<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="modalTitle"
@ok="handleSubmit"
@cancel="handleCancel"
width="600px"
>
<a-form
ref="formRef"
:model="formData"
:rules="rules"
layout="vertical"
>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="疫苗名称" name="name">
<a-input v-model:value="formData.name" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="疫苗类型" name="type">
<a-select v-model:value="formData.type">
<a-select-option value="preventive">预防性疫苗</a-select-option>
<a-select-option value="therapeutic">治疗性疫苗</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="manufacturer">
<a-input v-model:value="formData.manufacturer" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="批次号" name="batchNumber">
<a-input v-model:value="formData.batchNumber" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="生产日期" name="productionDate">
<a-date-picker
v-model:value="formData.productionDate"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="有效期至" name="expiryDate">
<a-date-picker
v-model:value="formData.expiryDate"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="库存数量" name="stock">
<a-input-number
v-model:value="formData.stock"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="单位" name="unit">
<a-select v-model:value="formData.unit">
<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-form-item label="备注" name="remark">
<a-textarea v-model:value="formData.remark" :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 } from '@ant-design/icons-vue'
// 响应式数据
const loading = ref(false)
const modalVisible = ref(false)
const modalTitle = ref('添加疫苗')
const formRef = ref()
const vaccineList = ref([])
// 搜索表单
const searchForm = reactive({
name: '',
type: '',
status: ''
})
// 表单数据
const formData = reactive({
id: null,
name: '',
type: '',
manufacturer: '',
batchNumber: '',
productionDate: null,
expiryDate: null,
stock: 0,
unit: '支',
remark: ''
})
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true
})
// 表格列配置
const columns = [
{
title: '疫苗名称',
dataIndex: 'name',
key: 'name'
},
{
title: '疫苗类型',
dataIndex: 'type',
key: 'type'
},
{
title: '生产厂家',
dataIndex: 'manufacturer',
key: 'manufacturer'
},
{
title: '批次号',
dataIndex: 'batchNumber',
key: 'batchNumber'
},
{
title: '库存数量',
dataIndex: 'stock',
key: 'stock'
},
{
title: '状态',
dataIndex: 'status',
key: 'status'
},
{
title: '操作',
key: 'action',
width: 200
}
]
// 表单验证规则
const rules = {
name: [{ required: true, message: '请输入疫苗名称' }],
type: [{ required: true, message: '请选择疫苗类型' }],
manufacturer: [{ required: true, message: '请输入生产厂家' }],
batchNumber: [{ required: true, message: '请输入批次号' }],
productionDate: [{ required: true, message: '请选择生产日期' }],
expiryDate: [{ required: true, message: '请选择有效期' }],
stock: [{ required: true, message: '请输入库存数量' }],
unit: [{ required: true, message: '请选择单位' }]
}
// 获取状态颜色
const getStatusColor = (status) => {
const colors = {
available: 'green',
expired: 'red',
shortage: 'orange'
}
return colors[status] || 'default'
}
// 获取状态文本
const getStatusText = (status) => {
const texts = {
available: '可用',
expired: '过期',
shortage: '库存不足'
}
return texts[status] || '未知'
}
// 加载疫苗列表
const loadVaccineList = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
// 模拟数据
vaccineList.value = [
{
id: 1,
name: '口蹄疫疫苗',
type: 'preventive',
manufacturer: '中牧股份',
batchNumber: 'FMD20240101',
productionDate: '2024-01-01',
expiryDate: '2025-01-01',
stock: 500,
unit: '支',
status: 'available',
remark: '预防口蹄疫'
},
{
id: 2,
name: '布鲁氏菌病疫苗',
type: 'preventive',
manufacturer: '兰州生物制品研究所',
batchNumber: 'BRU20240201',
productionDate: '2024-02-01',
expiryDate: '2025-02-01',
stock: 200,
unit: '支',
status: 'shortage',
remark: '预防布鲁氏菌病'
}
]
pagination.total = vaccineList.value.length
} catch (error) {
message.error('加载疫苗列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
loadVaccineList()
}
// 重置搜索
const resetSearch = () => {
Object.keys(searchForm).forEach(key => {
searchForm[key] = ''
})
loadVaccineList()
}
// 表格变化处理
const handleTableChange = (pag) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadVaccineList()
}
// 显示添加模态框
const showAddModal = () => {
modalTitle.value = '添加疫苗'
resetForm()
modalVisible.value = true
}
// 显示编辑模态框
const showEditModal = (record) => {
modalTitle.value = '编辑疫苗'
Object.assign(formData, record)
modalVisible.value = true
}
// 显示详情
const showDetail = (record) => {
message.info('查看疫苗详情功能待实现')
}
// 删除疫苗
const handleDelete = async (id) => {
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 500))
message.success('删除成功')
loadVaccineList()
} catch (error) {
message.error('删除失败')
}
}
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value.validate()
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
message.success(formData.id ? '更新成功' : '添加成功')
modalVisible.value = false
loadVaccineList()
} catch (error) {
console.error('表单验证失败:', error)
}
}
// 取消操作
const handleCancel = () => {
modalVisible.value = false
resetForm()
}
// 重置表单
const resetForm = () => {
Object.keys(formData).forEach(key => {
if (key === 'stock') {
formData[key] = 0
} else if (key === 'unit') {
formData[key] = '支'
} else {
formData[key] = key === 'id' ? null : ''
}
})
formRef.value?.resetFields()
}
// 组件挂载时加载数据
onMounted(() => {
loadVaccineList()
})
</script>
<style scoped>
.vaccine-management {
padding: 24px;
}
.search-form {
margin-bottom: 16px;
padding: 16px;
background: #fafafa;
border-radius: 6px;
}
</style>

View File

@@ -0,0 +1,262 @@
<template>
<div class="error-page">
<div class="error-container">
<div class="error-content">
<div class="error-icon">
<stop-outlined />
</div>
<h1 class="error-title">403</h1>
<h2 class="error-subtitle">权限不足</h2>
<p class="error-description">
抱歉您没有权限访问此页面请联系管理员获取相应权限
</p>
<div class="error-actions">
<a-button type="primary" @click="goBack">
<arrow-left-outlined />
返回上页
</a-button>
<a-button @click="goHome">
<home-outlined />
回到首页
</a-button>
<a-button @click="contactAdmin">
<phone-outlined />
联系管理员
</a-button>
</div>
</div>
<div class="error-illustration">
<svg viewBox="0 0 400 300" class="illustration-svg">
<!-- 锁图标 -->
<g transform="translate(150, 80)">
<rect x="0" y="40" width="100" height="80" rx="10" fill="#f5f5f5" stroke="#d9d9d9" stroke-width="2"/>
<circle cx="50" cy="80" r="8" fill="#ff4d4f"/>
<path d="M20 40 L20 20 Q20 0 50 0 Q80 0 80 20 L80 40" fill="none" stroke="#d9d9d9" stroke-width="4"/>
</g>
<!-- 装饰元素 -->
<circle cx="80" cy="50" r="3" fill="#faad14" opacity="0.6"/>
<circle cx="320" cy="80" r="4" fill="#52c41a" opacity="0.6"/>
<circle cx="60" cy="200" r="2" fill="#1890ff" opacity="0.6"/>
<circle cx="340" cy="180" r="3" fill="#722ed1" opacity="0.6"/>
</svg>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
StopOutlined,
ArrowLeftOutlined,
HomeOutlined,
PhoneOutlined
} from '@ant-design/icons-vue'
const router = useRouter()
const goBack = () => {
if (window.history.length > 1) {
router.go(-1)
} else {
router.push('/dashboard')
}
}
const goHome = () => {
router.push('/dashboard')
}
const contactAdmin = () => {
message.info('请联系系统管理员admin@example.com')
}
</script>
<style lang="scss" scoped>
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.error-container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
max-width: 800px;
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 500px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
max-width: 400px;
}
}
.error-content {
padding: 60px 40px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
@media (max-width: 768px) {
padding: 40px 20px;
}
}
.error-icon {
font-size: 48px;
color: #ff4d4f;
margin-bottom: 20px;
.anticon {
display: block;
}
}
.error-title {
font-size: 72px;
font-weight: 700;
color: #ff4d4f;
margin: 0 0 10px 0;
line-height: 1;
@media (max-width: 768px) {
font-size: 56px;
}
}
.error-subtitle {
font-size: 24px;
font-weight: 600;
color: #262626;
margin: 0 0 16px 0;
@media (max-width: 768px) {
font-size: 20px;
}
}
.error-description {
font-size: 16px;
color: #8c8c8c;
line-height: 1.6;
margin: 0 0 32px 0;
max-width: 300px;
}
.error-actions {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 280px;
.ant-btn {
height: 44px;
border-radius: 8px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
&.ant-btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
&:hover {
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.3);
}
}
&:not(.ant-btn-primary) {
border-color: #d9d9d9;
color: #595959;
&:hover {
border-color: #667eea;
color: #667eea;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
transition: all 0.3s ease;
}
}
.error-illustration {
background: linear-gradient(135deg, #f6f9fc 0%, #e9f2ff 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
@media (max-width: 768px) {
display: none;
}
}
.illustration-svg {
width: 100%;
height: auto;
max-width: 300px;
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.1));
}
// 动画效果
.error-icon {
animation: bounce 2s infinite;
}
@keyframes bounce {
0%, 20%, 50%, 80%, 100% {
transform: translateY(0);
}
40% {
transform: translateY(-10px);
}
60% {
transform: translateY(-5px);
}
}
.error-title {
animation: fadeInUp 0.8s ease-out;
}
.error-subtitle {
animation: fadeInUp 0.8s ease-out 0.2s both;
}
.error-description {
animation: fadeInUp 0.8s ease-out 0.4s both;
}
.error-actions {
animation: fadeInUp 0.8s ease-out 0.6s both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,299 @@
<template>
<div class="error-page">
<div class="error-container">
<div class="error-content">
<div class="error-icon">
<question-circle-outlined />
</div>
<h1 class="error-title">404</h1>
<h2 class="error-subtitle">页面不存在</h2>
<p class="error-description">
抱歉您访问的页面不存在或已被移除请检查网址是否正确
</p>
<div class="error-actions">
<a-button type="primary" @click="goBack">
<arrow-left-outlined />
返回上页
</a-button>
<a-button @click="goHome">
<home-outlined />
回到首页
</a-button>
<a-button @click="reportProblem">
<bug-outlined />
报告问题
</a-button>
</div>
</div>
<div class="error-illustration">
<svg viewBox="0 0 400 300" class="illustration-svg">
<!-- 404 数字 -->
<g transform="translate(50, 100)">
<!-- 4 -->
<path d="M0 0 L0 40 L20 40 L20 0 L20 20 L40 20 M20 0 L20 60" stroke="#1890ff" stroke-width="4" fill="none"/>
<!-- 0 -->
<ellipse cx="80" cy="30" rx="20" ry="30" stroke="#52c41a" stroke-width="4" fill="none"/>
<!-- 4 -->
<path d="M120 0 L120 40 L140 40 L140 0 L140 20 L160 20 M140 0 L140 60" stroke="#faad14" stroke-width="4" fill="none"/>
</g>
<!-- 装饰元素 -->
<circle cx="300" cy="60" r="8" fill="#ff4d4f" opacity="0.3">
<animate attributeName="r" values="8;12;8" dur="2s" repeatCount="indefinite"/>
</circle>
<circle cx="80" cy="250" r="6" fill="#722ed1" opacity="0.4">
<animate attributeName="r" values="6;10;6" dur="1.5s" repeatCount="indefinite"/>
</circle>
<circle cx="320" cy="220" r="4" fill="#13c2c2" opacity="0.5">
<animate attributeName="r" values="4;8;4" dur="1.8s" repeatCount="indefinite"/>
</circle>
<!-- 搜索图标 -->
<g transform="translate(280, 150)">
<circle cx="0" cy="0" r="15" stroke="#bfbfbf" stroke-width="3" fill="none"/>
<path d="M12 12 L25 25" stroke="#bfbfbf" stroke-width="3"/>
</g>
</svg>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
QuestionCircleOutlined,
ArrowLeftOutlined,
HomeOutlined,
BugOutlined
} from '@ant-design/icons-vue'
const router = useRouter()
const goBack = () => {
if (window.history.length > 1) {
router.go(-1)
} else {
router.push('/dashboard')
}
}
const goHome = () => {
router.push('/dashboard')
}
const reportProblem = () => {
message.info('问题已记录,我们会尽快处理')
}
</script>
<style lang="scss" scoped>
.error-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1890ff 0%, #722ed1 100%);
padding: 20px;
}
.error-container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
overflow: hidden;
max-width: 800px;
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 500px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
max-width: 400px;
}
}
.error-content {
padding: 60px 40px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
@media (max-width: 768px) {
padding: 40px 20px;
}
}
.error-icon {
font-size: 48px;
color: #1890ff;
margin-bottom: 20px;
.anticon {
display: block;
}
}
.error-title {
font-size: 72px;
font-weight: 700;
color: #1890ff;
margin: 0 0 10px 0;
line-height: 1;
@media (max-width: 768px) {
font-size: 56px;
}
}
.error-subtitle {
font-size: 24px;
font-weight: 600;
color: #262626;
margin: 0 0 16px 0;
@media (max-width: 768px) {
font-size: 20px;
}
}
.error-description {
font-size: 16px;
color: #8c8c8c;
line-height: 1.6;
margin: 0 0 32px 0;
max-width: 300px;
}
.error-actions {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 280px;
.ant-btn {
height: 44px;
border-radius: 8px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
&.ant-btn-primary {
background: linear-gradient(135deg, #1890ff 0%, #722ed1 100%);
border: none;
&:hover {
background: linear-gradient(135deg, #0f7ae5 0%, #6526c7 100%);
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(24, 144, 255, 0.3);
}
}
&:not(.ant-btn-primary) {
border-color: #d9d9d9;
color: #595959;
&:hover {
border-color: #1890ff;
color: #1890ff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
transition: all 0.3s ease;
}
}
.error-illustration {
background: linear-gradient(135deg, #f0f9ff 0%, #e6f7ff 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
@media (max-width: 768px) {
display: none;
}
}
.illustration-svg {
width: 100%;
height: auto;
max-width: 300px;
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.1));
}
// 动画效果
.error-icon {
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.error-title {
animation: fadeInUp 0.8s ease-out;
}
.error-subtitle {
animation: fadeInUp 0.8s ease-out 0.2s both;
}
.error-description {
animation: fadeInUp 0.8s ease-out 0.4s both;
}
.error-actions {
animation: fadeInUp 0.8s ease-out 0.6s both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
// 404数字动画
.illustration-svg g path,
.illustration-svg g ellipse {
stroke-dasharray: 200;
stroke-dashoffset: 200;
animation: drawPath 2s ease-in-out forwards;
}
.illustration-svg g path:nth-child(2),
.illustration-svg g ellipse:nth-child(2) {
animation-delay: 0.5s;
}
.illustration-svg g path:nth-child(3),
.illustration-svg g ellipse:nth-child(3) {
animation-delay: 1s;
}
@keyframes drawPath {
to {
stroke-dashoffset: 0;
}
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<div class="error-page">
<div class="error-container">
<div class="error-icon">
<a-result
status="500"
title="500"
sub-title="抱歉服务器出现错误"
>
<template #extra>
<a-button type="primary" @click="goHome">
返回首页
</a-button>
<a-button @click="goBack">
返回上页
</a-button>
</template>
</a-result>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const goHome = () => {
router.push('/')
}
const goBack = () => {
router.go(-1)
}
</script>
<style scoped>
.error-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f0f2f5;
}
.error-container {
text-align: center;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
</style>

View File

@@ -0,0 +1,869 @@
<template>
<div class="page-container">
<div class="page-header">
<div class="header-left">
<a-button @click="goBack" class="back-btn">
<arrow-left-outlined />
返回
</a-button>
<div class="title-info">
<h1 class="page-title">{{ farmDetail.name }}</h1>
<div class="farm-meta">
<a-tag :color="getStatusColor(farmDetail.status)">
{{ getStatusText(farmDetail.status) }}
</a-tag>
<span class="farm-code">编号{{ farmDetail.code }}</span>
</div>
</div>
</div>
<div class="header-right">
<a-space>
<permission-button
permission="farm:update"
@click="editFarm"
>
<edit-outlined />
编辑
</permission-button>
<permission-button
type="primary"
permission="monitor:view"
@click="realTimeMonitor"
>
<monitor-outlined />
实时监控
</permission-button>
</a-space>
</div>
</div>
<a-spin :spinning="loading">
<a-row :gutter="16">
<!-- 左侧主要信息 -->
<a-col :xs="24" :lg="16">
<!-- 基本信息卡片 -->
<a-card title="基本信息" class="info-card" :bordered="false">
<template #extra>
<a-button type="link" @click="showEditModal">
<edit-outlined />
编辑
</a-button>
</template>
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12">
<div class="info-item">
<div class="info-label">养殖场名称</div>
<div class="info-value">{{ farmDetail.name }}</div>
</div>
</a-col>
<a-col :xs="24" :sm="12">
<div class="info-item">
<div class="info-label">养殖场编号</div>
<div class="info-value">{{ farmDetail.code }}</div>
</div>
</a-col>
<a-col :xs="24" :sm="12">
<div class="info-item">
<div class="info-label">所属区域</div>
<div class="info-value">{{ getRegionText(farmDetail.region) }}</div>
</div>
</a-col>
<a-col :xs="24" :sm="12">
<div class="info-item">
<div class="info-label">养殖类型</div>
<div class="info-value">{{ getFarmTypeText(farmDetail.farmType) }}</div>
</div>
</a-col>
<a-col :xs="24" :sm="12">
<div class="info-item">
<div class="info-label">养殖规模</div>
<div class="info-value">{{ getScaleText(farmDetail.scale) }}</div>
</div>
</a-col>
<a-col :xs="24" :sm="12">
<div class="info-item">
<div class="info-label">养殖面积</div>
<div class="info-value">{{ farmDetail.area }} 平方米</div>
</div>
</a-col>
<a-col :span="24">
<div class="info-item">
<div class="info-label">详细地址</div>
<div class="info-value">
<environment-outlined />
{{ farmDetail.address }}
<a-button type="link" size="small" @click="showOnMap">
在地图中查看
</a-button>
</div>
</div>
</a-col>
<a-col :span="24">
<div class="info-item">
<div class="info-label">养殖场描述</div>
<div class="info-value">{{ farmDetail.description || '暂无描述' }}</div>
</div>
</a-col>
</a-row>
</a-card>
<!-- 负责人信息卡片 -->
<a-card title="负责人信息" class="info-card" :bordered="false">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12">
<div class="info-item">
<div class="info-label">负责人姓名</div>
<div class="info-value">{{ farmDetail.manager }}</div>
</div>
</a-col>
<a-col :xs="24" :sm="12">
<div class="info-item">
<div class="info-label">联系电话</div>
<div class="info-value">
<phone-outlined />
{{ farmDetail.phone }}
<a-button type="link" size="small" @click="callPhone(farmDetail.phone)">
拨打
</a-button>
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12">
<div class="info-item">
<div class="info-label">邮箱地址</div>
<div class="info-value">
<mail-outlined />
{{ farmDetail.email || '暂无' }}
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12">
<div class="info-item">
<div class="info-label">身份证号</div>
<div class="info-value">{{ maskIdCard(farmDetail.idCard) }}</div>
</div>
</a-col>
</a-row>
</a-card>
<!-- 养殖信息卡片 -->
<a-card title="养殖信息" class="info-card" :bordered="false">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12">
<div class="info-item">
<div class="info-label">动物种类</div>
<div class="info-value">
<a-space wrap>
<a-tag
v-for="type in farmDetail.animalTypes"
:key="type"
color="blue"
>
{{ getAnimalTypeText(type) }}
</a-tag>
</a-space>
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12">
<div class="info-item">
<div class="info-label">动物总数</div>
<div class="info-value">
<span class="number-highlight">{{ farmDetail.animalCount }}</span>
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12">
<div class="info-item">
<div class="info-label">建设时间</div>
<div class="info-value">{{ formatDate(farmDetail.buildDate) }}</div>
</div>
</a-col>
<a-col :xs="24" :sm="12">
<div class="info-item">
<div class="info-label">投产时间</div>
<div class="info-value">{{ formatDate(farmDetail.operationDate) }}</div>
</div>
</a-col>
</a-row>
</a-card>
<!-- 证照信息卡片 -->
<a-card title="证照信息" class="info-card" :bordered="false">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12">
<div class="info-item">
<div class="info-label">营业执照号</div>
<div class="info-value">{{ farmDetail.licenseNumber }}</div>
</div>
</a-col>
<a-col :xs="24" :sm="12">
<div class="info-item">
<div class="info-label">动物防疫证号</div>
<div class="info-value">{{ farmDetail.vaccineNumber }}</div>
</div>
</a-col>
<a-col :xs="24" :sm="12">
<div class="info-item">
<div class="info-label">环保证号</div>
<div class="info-value">{{ farmDetail.environmentNumber }}</div>
</div>
</a-col>
</a-row>
</a-card>
</a-col>
<!-- 右侧统计和操作 -->
<a-col :xs="24" :lg="8">
<!-- 实时统计卡片 -->
<a-card title="实时统计" class="stats-card" :bordered="false">
<div class="stat-item">
<div class="stat-icon devices">
<monitor-outlined />
</div>
<div class="stat-content">
<div class="stat-value">{{ farmDetail.onlineDevices }}/{{ farmDetail.totalDevices }}</div>
<div class="stat-label">设备在线</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon alerts">
<bell-outlined />
</div>
<div class="stat-content">
<div class="stat-value">{{ farmDetail.alertCount || 0 }}</div>
<div class="stat-label">当前预警</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon temperature">
<fire-outlined />
</div>
<div class="stat-content">
<div class="stat-value">{{ farmDetail.avgTemperature || '--' }}°C</div>
<div class="stat-label">平均温度</div>
</div>
</div>
<div class="stat-item">
<div class="stat-icon humidity">
<cloud-outlined />
</div>
<div class="stat-content">
<div class="stat-value">{{ farmDetail.avgHumidity || '--' }}%</div>
<div class="stat-label">平均湿度</div>
</div>
</div>
</a-card>
<!-- 快捷操作卡片 -->
<a-card title="快捷操作" class="action-card" :bordered="false">
<div class="action-grid">
<permission-button
class="action-btn"
permission="device:view"
@click="viewDevices"
>
<monitor-outlined />
<span>设备管理</span>
</permission-button>
<permission-button
class="action-btn"
permission="alert:view"
@click="viewAlerts"
>
<bell-outlined />
<span>预警管理</span>
</permission-button>
<permission-button
class="action-btn"
permission="report:view"
@click="viewReports"
>
<file-text-outlined />
<span>报表查看</span>
</permission-button>
<permission-button
class="action-btn"
permission="data:export"
@click="exportData"
>
<download-outlined />
<span>数据导出</span>
</permission-button>
</div>
</a-card>
<!-- 位置地图卡片 -->
<a-card title="位置信息" class="map-card" :bordered="false">
<div class="mini-map" id="farmDetailMap">
<!-- 地图容器 -->
</div>
<div class="location-info">
<div class="coordinate">
<span>经度{{ farmDetail.longitude }}</span>
<span>纬度{{ farmDetail.latitude }}</span>
</div>
<a-button type="link" @click="openFullMap">
查看大图
</a-button>
</div>
</a-card>
<!-- 最近活动卡片 -->
<a-card title="最近活动" class="activity-card" :bordered="false">
<a-timeline size="small">
<a-timeline-item
v-for="activity in recentActivities"
:key="activity.id"
:color="getActivityColor(activity.type)"
>
<div class="activity-item">
<div class="activity-title">{{ activity.title }}</div>
<div class="activity-time">{{ formatTime(activity.time) }}</div>
</div>
</a-timeline-item>
</a-timeline>
<div class="activity-more">
<a-button type="link" @click="viewAllActivities">
查看全部活动
</a-button>
</div>
</a-card>
</a-col>
</a-row>
</a-spin>
<!-- 编辑弹窗 -->
<a-modal
v-model:open="editModalVisible"
title="编辑养殖场"
width="800px"
@ok="handleUpdate"
@cancel="handleEditCancel"
:confirm-loading="updateLoading"
>
<farm-form
ref="editFormRef"
:form-data="editFormData"
:is-edit="true"
/>
</a-modal>
<!-- 地图弹窗 -->
<a-modal
v-model:open="mapModalVisible"
title="位置地图"
width="800px"
:footer="null"
>
<div id="fullMap" style="height: 400px;"></div>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import {
ArrowLeftOutlined,
EditOutlined,
MonitorOutlined,
EnvironmentOutlined,
PhoneOutlined,
MailOutlined,
BellOutlined,
FireOutlined,
CloudOutlined,
FileTextOutlined,
DownloadOutlined
} from '@ant-design/icons-vue'
import PermissionButton from '@/components/PermissionButton.vue'
import FarmForm from './components/FarmForm.vue'
import api from '@/utils/api'
const router = useRouter()
const route = useRoute()
// 响应式数据
const loading = ref(false)
const updateLoading = ref(false)
const editModalVisible = ref(false)
const mapModalVisible = ref(false)
const farmDetail = ref({})
const editFormData = ref({})
const editFormRef = ref()
const recentActivities = ref([])
// 获取养殖场ID
const farmId = route.params.id
// 方法
const fetchFarmDetail = async () => {
try {
loading.value = true
const response = await api.get(`/farms/${farmId}`)
if (response.success) {
farmDetail.value = response.data
// 获取最近活动
fetchRecentActivities()
// 初始化地图
initMiniMap()
}
} catch (error) {
message.error('获取养殖场详情失败')
} finally {
loading.value = false
}
}
const fetchRecentActivities = async () => {
try {
const response = await api.get(`/farms/${farmId}/activities`, {
params: { limit: 5 }
})
if (response.success) {
recentActivities.value = response.data
}
} catch (error) {
console.error('获取活动记录失败:', error)
}
}
const goBack = () => {
router.go(-1)
}
const editFarm = () => {
editFormData.value = { ...farmDetail.value }
editModalVisible.value = true
}
const showEditModal = () => {
editFarm()
}
const handleUpdate = async () => {
try {
const formData = await editFormRef.value.validate()
updateLoading.value = true
const response = await api.put(`/farms/${farmId}`, formData)
if (response.success) {
message.success('更新成功')
editModalVisible.value = false
fetchFarmDetail()
}
} catch (error) {
if (error.errorFields) {
message.error('请检查表单信息')
} else {
message.error('更新失败')
}
} finally {
updateLoading.value = false
}
}
const handleEditCancel = () => {
editModalVisible.value = false
editFormData.value = {}
}
const realTimeMonitor = () => {
router.push(`/monitor?farmId=${farmId}`)
}
const viewDevices = () => {
router.push(`/devices?farmId=${farmId}`)
}
const viewAlerts = () => {
router.push(`/alerts?farmId=${farmId}`)
}
const viewReports = () => {
router.push(`/reports?farmId=${farmId}`)
}
const viewAllActivities = () => {
router.push(`/activities?farmId=${farmId}`)
}
const exportData = async () => {
try {
const response = await api.get(`/farms/${farmId}/export`, {
responseType: 'blob'
})
// 创建下载链接
const url = window.URL.createObjectURL(new Blob([response]))
const link = document.createElement('a')
link.href = url
link.download = `${farmDetail.value.name}_数据导出_${new Date().toISOString().slice(0, 10)}.xlsx`
link.click()
window.URL.revokeObjectURL(url)
message.success('导出成功')
} catch (error) {
message.error('导出失败')
}
}
const showOnMap = () => {
mapModalVisible.value = true
// 延迟初始化地图确保DOM已渲染
setTimeout(() => {
initFullMap()
}, 100)
}
const openFullMap = () => {
showOnMap()
}
const callPhone = (phone) => {
window.open(`tel:${phone}`)
}
// 工具方法
const getStatusColor = (status) => {
const colors = {
active: 'green',
inactive: 'red',
maintenance: 'orange'
}
return colors[status] || 'default'
}
const getStatusText = (status) => {
const texts = {
active: '正常',
inactive: '停用',
maintenance: '维护中'
}
return texts[status] || '未知'
}
const getRegionText = (region) => {
const regions = {
yinchuan: '银川市',
shizuishan: '石嘴山市',
wuzhong: '吴忠市',
guyuan: '固原市',
zhongwei: '中卫市'
}
return regions[region] || region
}
const getFarmTypeText = (type) => {
const types = {
cattle: '肉牛',
dairy: '奶牛',
sheep: '羊',
pig: '猪',
chicken: '鸡',
mixed: '混合养殖'
}
return types[type] || type
}
const getScaleText = (scale) => {
const scales = {
small: '小型(<100头',
medium: '中型100-500头',
large: '大型500-1000头',
xlarge: '超大型(>1000头'
}
return scales[scale] || scale
}
const getAnimalTypeText = (type) => {
const types = {
cattle: '肉牛',
dairy_cow: '奶牛',
sheep: '羊',
goat: '山羊',
pig: '猪',
chicken: '鸡',
duck: '鸭',
goose: '鹅'
}
return types[type] || type
}
const maskIdCard = (idCard) => {
if (!idCard) return '暂无'
return idCard.replace(/(\d{6})\d{8}(\d{4})/, '$1********$2')
}
const formatDate = (date) => {
if (!date) return '暂无'
return new Date(date).toLocaleDateString()
}
const formatTime = (time) => {
if (!time) return ''
return new Date(time).toLocaleString()
}
const getActivityColor = (type) => {
const colors = {
info: 'blue',
warning: 'orange',
error: 'red',
success: 'green'
}
return colors[type] || 'blue'
}
// 地图相关方法
const initMiniMap = () => {
// TODO: 初始化小地图
console.log('初始化小地图')
}
const initFullMap = () => {
// TODO: 初始化完整地图
console.log('初始化完整地图')
}
// 组件挂载和卸载
onMounted(() => {
fetchFarmDetail()
})
onUnmounted(() => {
// 清理地图资源
})
</script>
<style lang="scss" scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
.header-left {
display: flex;
align-items: flex-start;
gap: 16px;
.back-btn {
margin-top: 4px;
}
.title-info {
.page-title {
margin-bottom: 8px;
}
.farm-meta {
display: flex;
align-items: center;
gap: 12px;
.farm-code {
color: #666;
font-size: 14px;
}
}
}
}
}
.info-card {
margin-bottom: 16px;
.info-item {
.info-label {
font-size: 14px;
color: #666;
margin-bottom: 4px;
}
.info-value {
font-size: 14px;
color: #1a1a1a;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
.number-highlight {
color: #1890ff;
font-size: 18px;
font-weight: 600;
}
}
}
}
.stats-card {
margin-bottom: 16px;
.stat-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
.stat-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: white;
margin-right: 12px;
&.devices {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
&.alerts {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
}
&.temperature {
background: linear-gradient(135deg, #ff9500 0%, #ff6b35 100%);
}
&.humidity {
background: linear-gradient(135deg, #74b9ff 0%, #0984e3 100%);
}
}
.stat-content {
.stat-value {
font-size: 18px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 2px;
}
.stat-label {
font-size: 12px;
color: #666;
}
}
}
}
.action-card {
margin-bottom: 16px;
.action-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
.action-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 12px;
border: 1px solid #f0f0f0;
border-radius: 8px;
background: #fafafa;
transition: all 0.3s;
&:hover {
background: #f0f0f0;
border-color: #d9d9d9;
}
span {
font-size: 12px;
}
}
}
}
.map-card {
margin-bottom: 16px;
.mini-map {
height: 200px;
background: #f5f5f5;
border-radius: 8px;
margin-bottom: 12px;
}
.location-info {
display: flex;
justify-content: space-between;
align-items: center;
.coordinate {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #666;
}
}
}
.activity-card {
.activity-item {
.activity-title {
font-size: 14px;
color: #1a1a1a;
margin-bottom: 4px;
}
.activity-time {
font-size: 12px;
color: #999;
}
}
.activity-more {
text-align: center;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
}
// 响应式设计
@media (max-width: 768px) {
.page-header {
flex-direction: column;
gap: 16px;
.header-left {
width: 100%;
}
.header-right {
width: 100%;
justify-content: flex-start;
}
}
.action-grid {
grid-template-columns: 1fr !important;
}
}
</style>

View File

@@ -0,0 +1,931 @@
<template>
<div class="page-container">
<div class="page-header">
<h1 class="page-title">养殖场管理</h1>
<p class="page-description">管理和监控所有养殖场信息</p>
</div>
<!-- 搜索和操作栏 -->
<div class="search-bar">
<div class="search-left">
<a-input-search
v-model:value="searchForm.keyword"
placeholder="搜索养殖场名称、编号或负责人"
style="width: 300px"
@search="handleSearch"
allow-clear
/>
<a-select
v-model:value="searchForm.status"
placeholder="状态筛选"
style="width: 120px"
allow-clear
>
<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-select
v-model:value="searchForm.region"
placeholder="区域筛选"
style="width: 150px"
allow-clear
>
<a-select-option value="yinchuan">银川市</a-select-option>
<a-select-option value="shizuishan">石嘴山市</a-select-option>
<a-select-option value="wuzhong">吴忠市</a-select-option>
<a-select-option value="guyuan">固原市</a-select-option>
<a-select-option value="zhongwei">中卫市</a-select-option>
</a-select>
<a-button @click="resetSearch">重置</a-button>
</div>
<div class="search-right">
<permission-button
permission="farm:export"
@click="exportData"
:loading="exportLoading"
>
<download-outlined />
导出数据
</permission-button>
<permission-button
type="primary"
permission="farm:create"
@click="showAddModal"
>
<plus-outlined />
新增养殖场
</permission-button>
</div>
</div>
<!-- 统计卡片 -->
<a-row :gutter="16" class="stats-cards">
<a-col :xs="24" :sm="12" :lg="6">
<div class="stat-card">
<div class="stat-icon farms">
<home-outlined />
</div>
<div class="stat-content">
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">总数量</div>
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<div class="stat-card">
<div class="stat-icon active">
<check-circle-outlined />
</div>
<div class="stat-content">
<div class="stat-value">{{ stats.active }}</div>
<div class="stat-label">正常运营</div>
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<div class="stat-card">
<div class="stat-icon warning">
<exclamation-circle-outlined />
</div>
<div class="stat-content">
<div class="stat-value">{{ stats.warning }}</div>
<div class="stat-label">预警中</div>
</div>
</div>
</a-col>
<a-col :xs="24" :sm="12" :lg="6">
<div class="stat-card">
<div class="stat-icon offline">
<stop-outlined />
</div>
<div class="stat-content">
<div class="stat-value">{{ stats.offline }}</div>
<div class="stat-label">离线</div>
</div>
</div>
</a-col>
</a-row>
<!-- 视图切换 -->
<div class="view-controls">
<a-radio-group v-model:value="viewMode" button-style="solid">
<a-radio-button value="table">
<table-outlined />
列表视图
</a-radio-button>
<a-radio-button value="map">
<environment-outlined />
地图视图
</a-radio-button>
<a-radio-button value="card">
<appstore-outlined />
卡片视图
</a-radio-button>
</a-radio-group>
</div>
<!-- 列表视图 -->
<div v-if="viewMode === 'table'" class="table-container">
<a-table
:columns="columns"
:data-source="farmList"
:loading="loading"
:pagination="pagination"
@change="handleTableChange"
row-key="id"
:scroll="{ x: 1200 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="farm-name">
<a @click="viewDetail(record.id)">{{ record.name }}</a>
<div class="farm-code">{{ record.code }}</div>
</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<template v-if="column.key === 'location'">
<div class="location-info">
<environment-outlined />
{{ record.region }} {{ record.address }}
</div>
</template>
<template v-if="column.key === 'animals'">
<div class="animal-stats">
<div class="animal-count">{{ record.animalCount }}</div>
<div class="animal-types">{{ record.animalTypes?.join('、') }}</div>
</div>
</template>
<template v-if="column.key === 'devices'">
<div class="device-stats">
<span class="online">{{ record.onlineDevices }}</span>
/
<span class="total">{{ record.totalDevices }}</span>
</div>
</template>
<template v-if="column.key === 'alerts'">
<a-badge
:count="record.alertCount"
:number-style="{ backgroundColor: record.alertCount > 0 ? '#ff4d4f' : '#52c41a' }"
>
<a-button size="small" @click="viewAlerts(record.id)">
查看预警
</a-button>
</a-badge>
</template>
<template v-if="column.key === 'action'">
<a-space>
<permission-button
type="link"
size="small"
permission="farm:view"
@click="viewDetail(record.id)"
>
详情
</permission-button>
<permission-button
type="link"
size="small"
permission="farm:update"
@click="editFarm(record)"
>
编辑
</permission-button>
<permission-button
type="link"
size="small"
permission="device:view"
@click="viewDevices(record.id)"
>
设备
</permission-button>
<a-dropdown>
<template #overlay>
<a-menu>
<a-menu-item key="monitor" @click="monitorFarm(record.id)">
<monitor-outlined />
实时监控
</a-menu-item>
<a-menu-item key="report" @click="generateReport(record.id)">
<file-text-outlined />
生成报表
</a-menu-item>
<a-menu-divider />
<a-menu-item
key="delete"
danger
@click="deleteFarm(record)"
v-if="$hasPermission('farm:delete')"
>
<delete-outlined />
删除
</a-menu-item>
</a-menu>
</template>
<a-button type="link" size="small">
更多
<down-outlined />
</a-button>
</a-dropdown>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 地图视图 -->
<div v-if="viewMode === 'map'" class="map-container">
<div id="farmMap" class="farm-map"></div>
<div class="map-controls">
<a-card size="small" title="图例">
<div class="legend-item">
<span class="legend-dot active"></span>
正常运营
</div>
<div class="legend-item">
<span class="legend-dot warning"></span>
预警中
</div>
<div class="legend-item">
<span class="legend-dot offline"></span>
离线
</div>
</a-card>
</div>
</div>
<!-- 卡片视图 -->
<div v-if="viewMode === 'card'" class="card-container">
<a-row :gutter="[16, 16]">
<a-col
v-for="farm in farmList"
:key="farm.id"
:xs="24"
:sm="12"
:lg="8"
:xl="6"
>
<a-card
:hoverable="true"
class="farm-card"
@click="viewDetail(farm.id)"
>
<template #cover>
<div class="farm-cover">
<img
:src="farm.image || '/images/default-farm.jpg'"
:alt="farm.name"
@error="handleImageError"
/>
<div class="farm-status">
<a-tag :color="getStatusColor(farm.status)">
{{ getStatusText(farm.status) }}
</a-tag>
</div>
</div>
</template>
<template #actions>
<permission-button
type="text"
permission="farm:view"
@click.stop="viewDetail(farm.id)"
>
<eye-outlined />
</permission-button>
<permission-button
type="text"
permission="farm:update"
@click.stop="editFarm(farm)"
>
<edit-outlined />
</permission-button>
<permission-button
type="text"
permission="monitor:view"
@click.stop="monitorFarm(farm.id)"
>
<monitor-outlined />
</permission-button>
</template>
<a-card-meta
:title="farm.name"
:description="farm.description"
/>
<div class="farm-info">
<div class="info-item">
<environment-outlined />
{{ farm.region }}
</div>
<div class="info-item">
<user-outlined />
{{ farm.manager }}
</div>
<div class="info-item">
<bug-outlined />
{{ farm.animalCount }} 头
</div>
<div class="info-item">
<monitor-outlined />
{{ farm.onlineDevices }}/{{ farm.totalDevices }}
</div>
</div>
</a-card>
</a-col>
</a-row>
</div>
<!-- 新增/编辑养殖场弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
width="800px"
@ok="handleSubmit"
@cancel="handleCancel"
:confirm-loading="submitLoading"
>
<farm-form
ref="farmFormRef"
:form-data="currentFarm"
:is-edit="isEdit"
/>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import {
PlusOutlined,
DownloadOutlined,
HomeOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
StopOutlined,
TableOutlined,
EnvironmentOutlined,
AppstoreOutlined,
MonitorOutlined,
FileTextOutlined,
DeleteOutlined,
DownOutlined,
EyeOutlined,
EditOutlined,
UserOutlined,
BugOutlined
} from '@ant-design/icons-vue'
import PermissionButton from '@/components/PermissionButton.vue'
import FarmForm from './components/FarmForm.vue'
import { permissionMixin } from '@/utils/permission'
import api from '@/utils/api'
const router = useRouter()
// 响应式数据
const loading = ref(false)
const exportLoading = ref(false)
const submitLoading = ref(false)
const modalVisible = ref(false)
const viewMode = ref('table')
const farmList = ref([])
const currentFarm = ref({})
const isEdit = ref(false)
const farmFormRef = ref()
// 搜索表单
const searchForm = reactive({
keyword: '',
status: undefined,
region: undefined
})
// 统计数据
const stats = reactive({
total: 156,
active: 142,
warning: 8,
offline: 6
})
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`
})
// 表格列配置
const columns = [
{
title: '养殖场信息',
key: 'name',
width: 200,
fixed: 'left'
},
{
title: '状态',
key: 'status',
width: 100
},
{
title: '位置',
key: 'location',
width: 200
},
{
title: '负责人',
dataIndex: 'manager',
width: 120
},
{
title: '联系电话',
dataIndex: 'phone',
width: 130
},
{
title: '动物数量',
key: 'animals',
width: 120
},
{
title: '设备状态',
key: 'devices',
width: 100
},
{
title: '预警',
key: 'alerts',
width: 100
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 120
},
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right'
}
]
// 计算属性
const modalTitle = computed(() => {
return isEdit.value ? '编辑养殖场' : '新增养殖场'
})
// 混入权限方法
const { $hasPermission } = permissionMixin.methods
// 方法
const fetchFarmList = async () => {
try {
loading.value = true
const params = {
page: pagination.current,
pageSize: pagination.pageSize,
...searchForm
}
const response = await api.get('/farms', { params })
if (response.success) {
farmList.value = response.data.list
pagination.total = response.data.total
}
} catch (error) {
message.error('获取养殖场列表失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.current = 1
fetchFarmList()
}
const resetSearch = () => {
Object.assign(searchForm, {
keyword: '',
status: undefined,
region: undefined
})
handleSearch()
}
const handleTableChange = (pag) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
fetchFarmList()
}
const getStatusColor = (status) => {
const colors = {
active: 'green',
inactive: 'red',
maintenance: 'orange',
warning: 'orange'
}
return colors[status] || 'default'
}
const getStatusText = (status) => {
const texts = {
active: '正常',
inactive: '停用',
maintenance: '维护中',
warning: '预警中'
}
return texts[status] || '未知'
}
const showAddModal = () => {
currentFarm.value = {}
isEdit.value = false
modalVisible.value = true
}
const editFarm = (farm) => {
currentFarm.value = { ...farm }
isEdit.value = true
modalVisible.value = true
}
const handleSubmit = async () => {
try {
const formData = await farmFormRef.value.validate()
submitLoading.value = true
const url = isEdit.value ? `/farms/${currentFarm.value.id}` : '/farms'
const method = isEdit.value ? 'put' : 'post'
const response = await api[method](url, formData)
if (response.success) {
message.success(isEdit.value ? '编辑成功' : '新增成功')
modalVisible.value = false
fetchFarmList()
}
} catch (error) {
if (error.errorFields) {
message.error('请检查表单信息')
} else {
message.error(isEdit.value ? '编辑失败' : '新增失败')
}
} finally {
submitLoading.value = false
}
}
const handleCancel = () => {
modalVisible.value = false
currentFarm.value = {}
}
const viewDetail = (id) => {
router.push(`/farms/detail/${id}`)
}
const viewDevices = (id) => {
router.push(`/devices?farmId=${id}`)
}
const viewAlerts = (id) => {
router.push(`/alerts?farmId=${id}`)
}
const monitorFarm = (id) => {
router.push(`/monitor?farmId=${id}`)
}
const generateReport = (id) => {
router.push(`/reports?farmId=${id}`)
}
const deleteFarm = (farm) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除养殖场"${farm.name}"吗?此操作不可恢复。`,
okText: '确定',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
const response = await api.delete(`/farms/${farm.id}`)
if (response.success) {
message.success('删除成功')
fetchFarmList()
}
} catch (error) {
message.error('删除失败')
}
}
})
}
const exportData = async () => {
try {
exportLoading.value = true
const response = await api.get('/farms/export', {
params: searchForm,
responseType: 'blob'
})
// 创建下载链接
const url = window.URL.createObjectURL(new Blob([response]))
const link = document.createElement('a')
link.href = url
link.download = `养殖场数据_${new Date().toISOString().slice(0, 10)}.xlsx`
link.click()
window.URL.revokeObjectURL(url)
message.success('导出成功')
} catch (error) {
message.error('导出失败')
} finally {
exportLoading.value = false
}
}
const handleImageError = (e) => {
e.target.src = '/images/default-farm.jpg'
}
// 初始化地图
const initMap = () => {
// TODO: 集成百度地图
console.log('初始化地图')
}
// 组件挂载
onMounted(() => {
fetchFarmList()
})
</script>
<style lang="scss" scoped>
.search-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 16px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.search-left {
display: flex;
gap: 12px;
align-items: center;
}
.search-right {
display: flex;
gap: 12px;
}
}
.stats-cards {
margin-bottom: 16px;
.stat-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: white;
margin-right: 16px;
&.farms {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
&.active {
background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%);
}
&.warning {
background: linear-gradient(135deg, #faad14 0%, #ffc53d 100%);
}
&.offline {
background: linear-gradient(135deg, #ff4d4f 0%, #ff7875 100%);
}
}
.stat-content {
.stat-value {
font-size: 24px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #666;
}
}
}
}
.view-controls {
margin-bottom: 16px;
text-align: right;
}
.table-container {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.farm-name {
a {
font-weight: 500;
color: #1890ff;
&:hover {
text-decoration: underline;
}
}
.farm-code {
font-size: 12px;
color: #999;
margin-top: 2px;
}
}
.location-info {
display: flex;
align-items: center;
gap: 4px;
color: #666;
}
.animal-stats {
.animal-count {
font-weight: 500;
color: #1a1a1a;
}
.animal-types {
font-size: 12px;
color: #999;
margin-top: 2px;
}
}
.device-stats {
.online {
color: #52c41a;
font-weight: 500;
}
.total {
color: #666;
}
}
}
.map-container {
position: relative;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
.farm-map {
height: 600px;
background: #f5f5f5;
}
.map-controls {
position: absolute;
top: 16px;
right: 16px;
.legend-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 12px;
&:last-child {
margin-bottom: 0;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
&.active {
background: #52c41a;
}
&.warning {
background: #faad14;
}
&.offline {
background: #ff4d4f;
}
}
}
}
}
.card-container {
.farm-card {
.farm-cover {
position: relative;
height: 160px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.farm-status {
position: absolute;
top: 8px;
right: 8px;
}
}
.farm-info {
margin-top: 12px;
.info-item {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
font-size: 12px;
color: #666;
&:last-child {
margin-bottom: 0;
}
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.search-bar {
flex-direction: column;
gap: 12px;
.search-left,
.search-right {
width: 100%;
justify-content: center;
}
.search-left {
flex-wrap: wrap;
}
}
.view-controls {
text-align: center;
}
}
</style>

View File

@@ -0,0 +1,566 @@
<template>
<a-form
ref="formRef"
:model="formData"
:rules="rules"
layout="vertical"
class="farm-form"
>
<a-row :gutter="16">
<!-- 基本信息 -->
<a-col :span="24">
<div class="form-section">
<h3 class="section-title">基本信息</h3>
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item label="养殖场名称" name="name">
<a-input
v-model:value="formData.name"
placeholder="请输入养殖场名称"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="养殖场编号" name="code">
<a-input
v-model:value="formData.code"
placeholder="请输入养殖场编号"
allow-clear
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :xs="24" :md="8">
<a-form-item label="所属区域" name="region">
<a-select
v-model:value="formData.region"
placeholder="请选择所属区域"
allow-clear
>
<a-select-option value="yinchuan">银川市</a-select-option>
<a-select-option value="shizuishan">石嘴山市</a-select-option>
<a-select-option value="wuzhong">吴忠市</a-select-option>
<a-select-option value="guyuan">固原市</a-select-option>
<a-select-option value="zhongwei">中卫市</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="养殖类型" name="farmType">
<a-select
v-model:value="formData.farmType"
placeholder="请选择养殖类型"
allow-clear
>
<a-select-option value="cattle">肉牛</a-select-option>
<a-select-option value="dairy">奶牛</a-select-option>
<a-select-option value="sheep"></a-select-option>
<a-select-option value="pig"></a-select-option>
<a-select-option value="chicken"></a-select-option>
<a-select-option value="mixed">混合养殖</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="养殖规模" name="scale">
<a-select
v-model:value="formData.scale"
placeholder="请选择养殖规模"
allow-clear
>
<a-select-option value="small">小型<100头</a-select-option>
<a-select-option value="medium">中型100-500</a-select-option>
<a-select-option value="large">大型500-1000</a-select-option>
<a-select-option value="xlarge">超大型>1000</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="请输入详细地址"
allow-clear
/>
</a-form-item>
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item label="经度" name="longitude">
<a-input-number
v-model:value="formData.longitude"
placeholder="请输入经度"
:precision="6"
:min="-180"
:max="180"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="纬度" name="latitude">
<a-input-number
v-model:value="formData.latitude"
placeholder="请输入纬度"
:precision="6"
:min="-90"
:max="90"
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"
allow-clear
/>
</a-form-item>
</div>
</a-col>
<!-- 负责人信息 -->
<a-col :span="24">
<div class="form-section">
<h3 class="section-title">负责人信息</h3>
<a-row :gutter="16">
<a-col :xs="24" :md="8">
<a-form-item label="负责人姓名" name="manager">
<a-input
v-model:value="formData.manager"
placeholder="请输入负责人姓名"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="联系电话" name="phone">
<a-input
v-model:value="formData.phone"
placeholder="请输入联系电话"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="邮箱地址" name="email">
<a-input
v-model:value="formData.email"
placeholder="请输入邮箱地址"
allow-clear
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="身份证号" name="idCard">
<a-input
v-model:value="formData.idCard"
placeholder="请输入身份证号"
allow-clear
/>
</a-form-item>
</div>
</a-col>
<!-- 养殖信息 -->
<a-col :span="24">
<div class="form-section">
<h3 class="section-title">养殖信息</h3>
<a-row :gutter="16">
<a-col :xs="24" :md="8">
<a-form-item label="动物种类" name="animalTypes">
<a-select
v-model:value="formData.animalTypes"
mode="multiple"
placeholder="请选择动物种类"
allow-clear
>
<a-select-option value="cattle">肉牛</a-select-option>
<a-select-option value="dairy_cow">奶牛</a-select-option>
<a-select-option value="sheep"></a-select-option>
<a-select-option value="goat">山羊</a-select-option>
<a-select-option value="pig"></a-select-option>
<a-select-option value="chicken"></a-select-option>
<a-select-option value="duck"></a-select-option>
<a-select-option value="goose"></a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="动物总数" name="animalCount">
<a-input-number
v-model:value="formData.animalCount"
placeholder="请输入动物总数"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<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-row>
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item label="建设时间" name="buildDate">
<a-date-picker
v-model:value="formData.buildDate"
placeholder="请选择建设时间"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="投产时间" name="operationDate">
<a-date-picker
v-model:value="formData.operationDate"
placeholder="请选择投产时间"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</div>
</a-col>
<!-- 设备信息 -->
<a-col :span="24">
<div class="form-section">
<h3 class="section-title">设备信息</h3>
<a-row :gutter="16">
<a-col :xs="24" :md="8">
<a-form-item label="监控设备数量" name="monitorDevices">
<a-input-number
v-model:value="formData.monitorDevices"
placeholder="请输入监控设备数量"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="传感器数量" name="sensorDevices">
<a-input-number
v-model:value="formData.sensorDevices"
placeholder="请输入传感器数量"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="自动化设备数量" name="autoDevices">
<a-input-number
v-model:value="formData.autoDevices"
placeholder="请输入自动化设备数量"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</div>
</a-col>
<!-- 其他信息 -->
<a-col :span="24">
<div class="form-section">
<h3 class="section-title">其他信息</h3>
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item label="营业执照号" name="licenseNumber">
<a-input
v-model:value="formData.licenseNumber"
placeholder="请输入营业执照号"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="动物防疫证号" name="vaccineNumber">
<a-input
v-model:value="formData.vaccineNumber"
placeholder="请输入动物防疫证号"
allow-clear
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item label="环保证号" name="environmentNumber">
<a-input
v-model:value="formData.environmentNumber"
placeholder="请输入环保证号"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="状态" name="status">
<a-select
v-model:value="formData.status"
placeholder="请选择状态"
allow-clear
>
<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="remarks">
<a-textarea
v-model:value="formData.remarks"
placeholder="请输入备注信息"
:rows="3"
allow-clear
/>
</a-form-item>
</div>
</a-col>
</a-row>
</a-form>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
const props = defineProps({
formData: {
type: Object,
default: () => ({})
},
isEdit: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:formData'])
const formRef = ref()
// 表单数据
const formData = reactive({
name: '',
code: '',
region: '',
farmType: '',
scale: '',
address: '',
longitude: null,
latitude: null,
description: '',
manager: '',
phone: '',
email: '',
idCard: '',
animalTypes: [],
animalCount: null,
area: null,
buildDate: null,
operationDate: null,
monitorDevices: 0,
sensorDevices: 0,
autoDevices: 0,
licenseNumber: '',
vaccineNumber: '',
environmentNumber: '',
status: 'active',
remarks: '',
...props.formData
})
// 表单验证规则
const rules = {
name: [
{ required: true, message: '请输入养殖场名称', trigger: 'blur' },
{ min: 2, max: 50, message: '名称长度在 2 到 50 个字符', trigger: 'blur' }
],
code: [
{ required: true, message: '请输入养殖场编号', trigger: 'blur' },
{ pattern: /^[A-Z0-9]{6,20}$/, message: '编号格式不正确6-20位大写字母和数字', trigger: 'blur' }
],
region: [
{ required: true, message: '请选择所属区域', trigger: 'change' }
],
farmType: [
{ required: true, message: '请选择养殖类型', trigger: 'change' }
],
scale: [
{ required: true, message: '请选择养殖规模', trigger: 'change' }
],
address: [
{ required: true, message: '请输入详细地址', trigger: 'blur' },
{ min: 5, max: 200, message: '地址长度在 5 到 200 个字符', trigger: 'blur' }
],
longitude: [
{ required: true, message: '请输入经度', trigger: 'blur' },
{ type: 'number', min: -180, max: 180, message: '经度范围在 -180 到 180 之间', trigger: 'blur' }
],
latitude: [
{ required: true, message: '请输入纬度', trigger: 'blur' },
{ type: 'number', min: -90, max: 90, message: '纬度范围在 -90 到 90 之间', trigger: 'blur' }
],
manager: [
{ required: true, message: '请输入负责人姓名', trigger: 'blur' },
{ min: 2, max: 20, message: '姓名长度在 2 到 20 个字符', trigger: 'blur' }
],
phone: [
{ required: true, message: '请输入联系电话', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
idCard: [
{ required: true, message: '请输入身份证号', trigger: 'blur' },
{ pattern: /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/, message: '请输入正确的身份证号', trigger: 'blur' }
],
animalTypes: [
{ required: true, message: '请选择动物种类', trigger: 'change' }
],
animalCount: [
{ required: true, message: '请输入动物总数', trigger: 'blur' },
{ type: 'number', min: 1, message: '动物总数必须大于0', trigger: 'blur' }
],
area: [
{ required: true, message: '请输入养殖面积', trigger: 'blur' },
{ type: 'number', min: 1, message: '养殖面积必须大于0', trigger: 'blur' }
],
buildDate: [
{ required: true, message: '请选择建设时间', trigger: 'change' }
],
operationDate: [
{ required: true, message: '请选择投产时间', trigger: 'change' }
],
licenseNumber: [
{ required: true, message: '请输入营业执照号', trigger: 'blur' }
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' }
]
}
// 监听表单数据变化
watch(
() => props.formData,
(newData) => {
Object.assign(formData, newData)
},
{ deep: true, immediate: true }
)
// 表单验证方法
const validate = async () => {
try {
await formRef.value.validate()
return formData
} catch (error) {
throw error
}
}
// 重置表单
const resetFields = () => {
formRef.value.resetFields()
}
// 暴露方法给父组件
defineExpose({
validate,
resetFields
})
</script>
<style lang="scss" scoped>
.farm-form {
.form-section {
margin-bottom: 24px;
.section-title {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #f0f0f0;
position: relative;
&::before {
content: '';
position: absolute;
bottom: -2px;
left: 0;
width: 40px;
height: 2px;
background: #1890ff;
}
}
}
:deep(.ant-form-item-label) {
font-weight: 500;
}
:deep(.ant-input),
:deep(.ant-select-selector),
:deep(.ant-picker),
:deep(.ant-input-number) {
border-radius: 6px;
}
:deep(.ant-select-multiple .ant-select-selection-item) {
border-radius: 4px;
}
}
// 响应式设计
@media (max-width: 768px) {
.farm-form {
.form-section {
.section-title {
font-size: 14px;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,618 @@
<template>
<div class="monitor-dashboard">
<!-- 页面头部 -->
<PageHeader
title="监控仪表盘"
description="实时监控系统运行状态、性能指标和关键业务数据"
>
<template #extra>
<a-space>
<a-button @click="refreshData">
<ReloadOutlined />
刷新数据
</a-button>
<a-button type="primary" @click="exportReport">
<ExportOutlined />
导出报告
</a-button>
</a-space>
</template>
</PageHeader>
<!-- 系统状态概览 -->
<div class="system-overview">
<a-row :gutter="16">
<a-col :span="6">
<a-card>
<a-statistic
title="系统状态"
value="正常"
:value-style="{ color: '#52c41a', fontSize: '24px' }"
>
<template #prefix>
<CheckCircleOutlined style="color: #52c41a" />
</template>
</a-statistic>
<div class="status-detail">
<a-progress :percent="98" size="small" status="active" />
<span style="font-size: 12px; color: #666;">系统健康度: 98%</span>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic
title="在线用户"
:value="stats.onlineUsers"
:value-style="{ color: '#1890ff' }"
>
<template #prefix>
<UserOutlined />
</template>
</a-statistic>
<div class="trend-info">
<ArrowUpOutlined style="color: #52c41a" />
<span style="color: #52c41a; font-size: 12px;">较昨日 +12%</span>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic
title="今日访问量"
:value="stats.todayVisits"
:value-style="{ color: '#faad14' }"
>
<template #prefix>
<EyeOutlined />
</template>
</a-statistic>
<div class="trend-info">
<ArrowUpOutlined style="color: #52c41a" />
<span style="color: #52c41a; font-size: 12px;">较昨日 +8%</span>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic
title="系统负载"
:value="stats.systemLoad"
suffix="%"
:value-style="{ color: '#f5222d' }"
>
<template #prefix>
<DashboardOutlined />
</template>
</a-statistic>
<div class="trend-info">
<ArrowDownOutlined style="color: #f5222d" />
<span style="color: #f5222d; font-size: 12px;">较昨日 +5%</span>
</div>
</a-card>
</a-col>
</a-row>
</div>
<!-- 实时监控图表 -->
<div class="monitor-charts">
<a-row :gutter="16">
<a-col :span="12">
<a-card title="系统性能监控" :bordered="false">
<template #extra>
<a-radio-group v-model:value="performanceTimeRange" size="small">
<a-radio-button value="1h">1小时</a-radio-button>
<a-radio-button value="6h">6小时</a-radio-button>
<a-radio-button value="24h">24小时</a-radio-button>
</a-radio-group>
</template>
<div ref="performanceChartRef" style="height: 300px;"></div>
</a-card>
</a-col>
<a-col :span="12">
<a-card title="用户访问统计" :bordered="false">
<template #extra>
<a-radio-group v-model:value="visitTimeRange" size="small">
<a-radio-button value="today">今日</a-radio-button>
<a-radio-button value="week">本周</a-radio-button>
<a-radio-button value="month">本月</a-radio-button>
</a-radio-group>
</template>
<div ref="visitChartRef" style="height: 300px;"></div>
</a-card>
</a-col>
</a-row>
</div>
<!-- 服务状态监控 -->
<div class="service-monitor">
<a-row :gutter="16">
<a-col :span="16">
<a-card title="服务状态监控" :bordered="false">
<template #extra>
<a-space>
<a-tag color="green">{{ serviceStats.running }}个服务正常</a-tag>
<a-tag color="red">{{ serviceStats.error }}个服务异常</a-tag>
</a-space>
</template>
<a-table
:columns="serviceColumns"
:data-source="serviceList"
:pagination="false"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-badge
:status="record.status === 'running' ? 'success' : 'error'"
:text="record.status === 'running' ? '正常' : '异常'"
/>
</template>
<template v-else-if="column.key === 'responseTime'">
<span :style="{ color: record.responseTime > 1000 ? '#f5222d' : '#52c41a' }">
{{ record.responseTime }}ms
</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="viewServiceDetail(record)">
详情
</a-button>
<a-button type="link" size="small" @click="restartService(record)">
重启
</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
</a-col>
<a-col :span="8">
<a-card title="系统资源使用" :bordered="false">
<div class="resource-monitor">
<div class="resource-item">
<div class="resource-label">CPU使用率</div>
<a-progress
:percent="resourceUsage.cpu"
:stroke-color="getProgressColor(resourceUsage.cpu)"
/>
</div>
<div class="resource-item">
<div class="resource-label">内存使用率</div>
<a-progress
:percent="resourceUsage.memory"
:stroke-color="getProgressColor(resourceUsage.memory)"
/>
</div>
<div class="resource-item">
<div class="resource-label">磁盘使用率</div>
<a-progress
:percent="resourceUsage.disk"
:stroke-color="getProgressColor(resourceUsage.disk)"
/>
</div>
<div class="resource-item">
<div class="resource-label">网络带宽</div>
<a-progress
:percent="resourceUsage.network"
:stroke-color="getProgressColor(resourceUsage.network)"
/>
</div>
</div>
</a-card>
</a-col>
</a-row>
</div>
<!-- 告警信息 -->
<div class="alert-panel">
<a-card title="系统告警" :bordered="false">
<template #extra>
<a-space>
<a-badge :count="alertList.filter(item => item.level === 'error').length" />
<span>严重告警</span>
<a-badge :count="alertList.filter(item => item.level === 'warning').length" />
<span>警告</span>
</a-space>
</template>
<a-list
:data-source="alertList"
size="small"
:pagination="{ pageSize: 5, size: 'small' }"
>
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a @click="handleAlert(item)">处理</a>
<a @click="ignoreAlert(item)">忽略</a>
</template>
<a-list-item-meta>
<template #avatar>
<a-badge
:status="item.level === 'error' ? 'error' : 'warning'"
/>
</template>
<template #title>
<span>{{ item.title }}</span>
<a-tag
:color="item.level === 'error' ? 'red' : 'orange'"
size="small"
style="margin-left: 8px"
>
{{ item.level === 'error' ? '严重' : '警告' }}
</a-tag>
</template>
<template #description>
<div>
<div>{{ item.description }}</div>
<div style="color: #999; font-size: 12px; margin-top: 4px;">
{{ item.time }}
</div>
</div>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, nextTick } from 'vue'
import { message } from 'ant-design-vue'
import * as echarts from 'echarts'
import {
ReloadOutlined,
ExportOutlined,
CheckCircleOutlined,
UserOutlined,
EyeOutlined,
DashboardOutlined,
ArrowUpOutlined,
ArrowDownOutlined
} from '@ant-design/icons-vue'
import PageHeader from '@/components/PageHeader.vue'
// 响应式数据
const performanceChartRef = ref()
const visitChartRef = ref()
const performanceTimeRange = ref('6h')
const visitTimeRange = ref('today')
let performanceChart = null
let visitChart = null
let refreshTimer = null
// 统计数据
const stats = reactive({
onlineUsers: 1248,
todayVisits: 8652,
systemLoad: 75
})
// 服务状态统计
const serviceStats = reactive({
running: 12,
error: 2
})
// 资源使用情况
const resourceUsage = reactive({
cpu: 68,
memory: 72,
disk: 45,
network: 35
})
// 服务列表表格列
const serviceColumns = [
{
title: '服务名称',
dataIndex: 'name',
key: 'name'
},
{
title: '状态',
dataIndex: 'status',
key: 'status'
},
{
title: '响应时间',
dataIndex: 'responseTime',
key: 'responseTime'
},
{
title: '最后检查',
dataIndex: 'lastCheck',
key: 'lastCheck'
},
{
title: '操作',
key: 'action'
}
]
// 服务列表数据
const serviceList = ref([
{
id: 1,
name: 'Web服务',
status: 'running',
responseTime: 245,
lastCheck: '2024-01-20 14:30:00'
},
{
id: 2,
name: '数据库服务',
status: 'running',
responseTime: 156,
lastCheck: '2024-01-20 14:30:00'
},
{
id: 3,
name: 'Redis缓存',
status: 'error',
responseTime: 0,
lastCheck: '2024-01-20 14:25:00'
},
{
id: 4,
name: 'API网关',
status: 'running',
responseTime: 89,
lastCheck: '2024-01-20 14:30:00'
},
{
id: 5,
name: '消息队列',
status: 'running',
responseTime: 123,
lastCheck: '2024-01-20 14:30:00'
}
])
// 告警列表
const alertList = ref([
{
id: 1,
title: 'Redis服务连接失败',
description: 'Redis服务无法连接可能影响缓存功能',
level: 'error',
time: '2024-01-20 14:25:00'
},
{
id: 2,
title: 'CPU使用率过高',
description: 'CPU使用率达到75%,建议检查系统负载',
level: 'warning',
time: '2024-01-20 14:20:00'
},
{
id: 3,
title: '磁盘空间不足',
description: '系统磁盘使用率达到85%,建议清理日志文件',
level: 'warning',
time: '2024-01-20 14:15:00'
}
])
// 方法
const getProgressColor = (percent) => {
if (percent >= 80) return '#f5222d'
if (percent >= 60) return '#faad14'
return '#52c41a'
}
const initPerformanceChart = () => {
if (!performanceChartRef.value) return
performanceChart = echarts.init(performanceChartRef.value)
const option = {
tooltip: {
trigger: 'axis'
},
legend: {
data: ['CPU使用率', '内存使用率', '网络IO']
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: ['14:00', '14:05', '14:10', '14:15', '14:20', '14:25', '14:30']
},
yAxis: {
type: 'value',
max: 100,
axisLabel: {
formatter: '{value}%'
}
},
series: [
{
name: 'CPU使用率',
type: 'line',
data: [65, 68, 72, 70, 75, 73, 68],
smooth: true,
itemStyle: { color: '#1890ff' }
},
{
name: '内存使用率',
type: 'line',
data: [70, 72, 75, 74, 76, 75, 72],
smooth: true,
itemStyle: { color: '#52c41a' }
},
{
name: '网络IO',
type: 'line',
data: [30, 35, 40, 38, 42, 40, 35],
smooth: true,
itemStyle: { color: '#faad14' }
}
]
}
performanceChart.setOption(option)
}
const initVisitChart = () => {
if (!visitChartRef.value) return
visitChart = echarts.init(visitChartRef.value)
const option = {
tooltip: {
trigger: 'axis'
},
legend: {
data: ['页面访问量', '用户访问量']
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00', '24:00']
},
yAxis: {
type: 'value'
},
series: [
{
name: '页面访问量',
type: 'bar',
data: [320, 280, 450, 680, 890, 750, 420],
itemStyle: { color: '#1890ff' }
},
{
name: '用户访问量',
type: 'line',
data: [180, 160, 280, 420, 520, 450, 280],
smooth: true,
itemStyle: { color: '#52c41a' }
}
]
}
visitChart.setOption(option)
}
const refreshData = () => {
// 模拟数据刷新
stats.onlineUsers = Math.floor(Math.random() * 2000) + 1000
stats.todayVisits = Math.floor(Math.random() * 10000) + 5000
stats.systemLoad = Math.floor(Math.random() * 30) + 60
resourceUsage.cpu = Math.floor(Math.random() * 40) + 50
resourceUsage.memory = Math.floor(Math.random() * 40) + 50
resourceUsage.disk = Math.floor(Math.random() * 30) + 30
resourceUsage.network = Math.floor(Math.random() * 50) + 20
message.success('数据已刷新')
}
const exportReport = () => {
message.success('监控报告导出成功')
}
const viewServiceDetail = (service) => {
message.info(`查看服务"${service.name}"的详细信息`)
}
const restartService = (service) => {
message.success(`服务"${service.name}"重启成功`)
}
const handleAlert = (alert) => {
message.success(`告警"${alert.title}"已处理`)
}
const ignoreAlert = (alert) => {
message.info(`告警"${alert.title}"已忽略`)
}
// 自动刷新数据
const startAutoRefresh = () => {
refreshTimer = setInterval(() => {
refreshData()
}, 30000) // 30秒刷新一次
}
const stopAutoRefresh = () => {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
}
// 生命周期
onMounted(async () => {
await nextTick()
initPerformanceChart()
initVisitChart()
startAutoRefresh()
// 监听窗口大小变化
window.addEventListener('resize', () => {
performanceChart?.resize()
visitChart?.resize()
})
})
onUnmounted(() => {
stopAutoRefresh()
performanceChart?.dispose()
visitChart?.dispose()
window.removeEventListener('resize', () => {})
})
</script>
<style lang="scss" scoped>
.monitor-dashboard {
.system-overview {
margin: 24px 0;
.status-detail {
margin-top: 8px;
}
.trend-info {
margin-top: 8px;
font-size: 12px;
}
}
.monitor-charts {
margin: 24px 0;
}
.service-monitor {
margin: 24px 0;
.resource-monitor {
.resource-item {
margin-bottom: 16px;
.resource-label {
margin-bottom: 8px;
font-weight: 500;
}
}
}
}
.alert-panel {
margin: 24px 0;
}
}
</style>

View File

@@ -0,0 +1,902 @@
<template>
<div class="animal-health-monitor">
<!-- 页面头部 -->
<PageHeader
title="动物健康监控"
description="实时监控动物健康状态,及时发现和处理疫病风险"
icon="heart"
>
<template #extra>
<a-space>
<a-button type="primary" @click="handleAddRecord">
<template #icon>
<PlusOutlined />
</template>
新增健康记录
</a-button>
<a-button @click="handleExport">
<template #icon>
<ExportOutlined />
</template>
导出报告
</a-button>
</a-space>
</template>
</PageHeader>
<!-- 统计卡片 -->
<a-row :gutter="16" class="stats-cards">
<a-col :xs="24" :sm="12" :md="6">
<a-card class="stat-card healthy">
<a-statistic
title="健康动物"
:value="healthStats.healthy"
suffix="头"
:value-style="{ color: '#52c41a' }"
>
<template #prefix>
<CheckCircleOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card class="stat-card sick">
<a-statistic
title="患病动物"
:value="healthStats.sick"
suffix="头"
:value-style="{ color: '#ff4d4f' }"
>
<template #prefix>
<ExclamationCircleOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card class="stat-card quarantine">
<a-statistic
title="隔离观察"
:value="healthStats.quarantine"
suffix="头"
:value-style="{ color: '#faad14' }"
>
<template #prefix>
<EyeOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card class="stat-card vaccination">
<a-statistic
title="待疫苗接种"
:value="healthStats.vaccination"
suffix="头"
:value-style="{ color: '#1890ff' }"
>
<template #prefix>
<MedicineBoxOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
</a-row>
<!-- 健康趋势图表 -->
<a-card title="健康状态趋势" class="chart-card">
<div ref="healthTrendChart" class="chart-container"></div>
</a-card>
<!-- 疫病预警 -->
<a-card title="疫病预警" class="alert-card">
<a-list
:data-source="diseaseAlerts"
:loading="alertLoading"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #avatar>
<a-avatar :style="{ backgroundColor: getAlertColor(item.level) }">
<WarningOutlined />
</a-avatar>
</template>
<template #title>
<span>{{ item.title }}</span>
<a-tag :color="getAlertColor(item.level)" style="margin-left: 8px">
{{ getAlertLevelText(item.level) }}
</a-tag>
</template>
<template #description>
<div>
<p>{{ item.description }}</p>
<p class="alert-meta">
<span>影响范围{{ item.scope }}</span>
<span style="margin-left: 16px">发生时间{{ formatDate(item.createdAt) }}</span>
</p>
</div>
</template>
</a-list-item-meta>
<template #actions>
<a @click="handleAlertDetail(item)">查看详情</a>
<a @click="handleAlertProcess(item)">处理</a>
</template>
</a-list-item>
</template>
</a-list>
</a-card>
<!-- 健康记录表格 -->
<a-card title="健康记录" class="table-card">
<!-- 搜索表单 -->
<SearchForm
:fields="searchFields"
@search="handleSearch"
@reset="handleReset"
/>
<!-- 数据表格 -->
<DataTable
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'healthStatus'">
<a-tag :color="getHealthStatusColor(record.healthStatus)">
{{ getHealthStatusText(record.healthStatus) }}
</a-tag>
</template>
<template v-else-if="column.key === 'temperature'">
<span :class="{ 'high-temperature': record.temperature > 39 }">
{{ record.temperature }}°C
</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleView(record)">
查看
</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-popconfirm
title="确定要删除这条记录吗?"
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</DataTable>
</a-card>
<!-- 新增/编辑健康记录弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
width="800px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
layout="vertical"
>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="动物编号" name="animalId">
<a-input v-model:value="formData.animalId" placeholder="请输入动物编号" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="检查日期" name="checkDate">
<a-date-picker
v-model:value="formData.checkDate"
style="width: 100%"
placeholder="请选择检查日期"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="体温(°C)" name="temperature">
<a-input-number
v-model:value="formData.temperature"
:min="35"
:max="45"
:precision="1"
style="width: 100%"
placeholder="请输入体温"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="体重(kg)" name="weight">
<a-input-number
v-model:value="formData.weight"
:min="0"
:precision="1"
style="width: 100%"
placeholder="请输入体重"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="健康状态" name="healthStatus">
<a-select v-model:value="formData.healthStatus" placeholder="请选择健康状态">
<a-select-option value="healthy">健康</a-select-option>
<a-select-option value="sick">患病</a-select-option>
<a-select-option value="quarantine">隔离观察</a-select-option>
<a-select-option value="recovery">康复中</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="vaccination">
<a-select v-model:value="formData.vaccination" placeholder="请选择疫苗类型">
<a-select-option value=""></a-select-option>
<a-select-option value="foot_mouth">口蹄疫疫苗</a-select-option>
<a-select-option value="swine_fever">猪瘟疫苗</a-select-option>
<a-select-option value="blue_ear">蓝耳病疫苗</a-select-option>
<a-select-option value="porcine_parvovirus">细小病毒疫苗</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="检查人员" name="inspector">
<a-input v-model:value="formData.inspector" placeholder="请输入检查人员" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="症状描述" name="symptoms">
<a-textarea
v-model:value="formData.symptoms"
placeholder="请描述观察到的症状"
:rows="3"
/>
</a-form-item>
<a-form-item label="治疗措施" name="treatment">
<a-textarea
v-model:value="formData.treatment"
placeholder="请描述采取的治疗措施"
:rows="3"
/>
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea
v-model:value="formData.remark"
placeholder="请输入备注信息"
:rows="2"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 查看详情弹窗 -->
<a-modal
v-model:open="detailVisible"
title="健康记录详情"
width="800px"
:footer="null"
>
<a-descriptions :column="2" bordered>
<a-descriptions-item label="动物编号">
{{ currentRecord?.animalId }}
</a-descriptions-item>
<a-descriptions-item label="检查日期">
{{ formatDate(currentRecord?.checkDate) }}
</a-descriptions-item>
<a-descriptions-item label="体温">
<span :class="{ 'high-temperature': currentRecord?.temperature > 39 }">
{{ currentRecord?.temperature }}°C
</span>
</a-descriptions-item>
<a-descriptions-item label="体重">
{{ currentRecord?.weight }}kg
</a-descriptions-item>
<a-descriptions-item label="健康状态">
<a-tag :color="getHealthStatusColor(currentRecord?.healthStatus)">
{{ getHealthStatusText(currentRecord?.healthStatus) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="疫苗接种">
{{ getVaccinationText(currentRecord?.vaccination) }}
</a-descriptions-item>
<a-descriptions-item label="检查人员">
{{ currentRecord?.inspector }}
</a-descriptions-item>
<a-descriptions-item label="记录时间">
{{ formatDate(currentRecord?.createdAt) }}
</a-descriptions-item>
<a-descriptions-item label="症状描述" :span="2">
{{ currentRecord?.symptoms || '无' }}
</a-descriptions-item>
<a-descriptions-item label="治疗措施" :span="2">
{{ currentRecord?.treatment || '无' }}
</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">
{{ currentRecord?.remark || '无' }}
</a-descriptions-item>
</a-descriptions>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { message } from 'ant-design-vue'
import * as echarts from 'echarts'
import {
PlusOutlined,
ExportOutlined,
HeartOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
EyeOutlined,
MedicineBoxOutlined,
WarningOutlined
} from '@ant-design/icons-vue'
import PageHeader from '@/components/common/PageHeader.vue'
import SearchForm from '@/components/common/SearchForm.vue'
import DataTable from '@/components/common/DataTable.vue'
import { useMonitoringStore } from '@/stores/monitoring'
import { formatDate } from '@/utils/date'
const monitoringStore = useMonitoringStore()
// 响应式数据
const loading = ref(false)
const alertLoading = ref(false)
const modalVisible = ref(false)
const detailVisible = ref(false)
const currentRecord = ref(null)
const formRef = ref()
const healthTrendChart = ref()
// 健康统计数据
const healthStats = reactive({
healthy: 0,
sick: 0,
quarantine: 0,
vaccination: 0
})
// 疫病预警数据
const diseaseAlerts = ref([])
// 搜索条件
const searchParams = reactive({
animalId: '',
healthStatus: '',
startDate: null,
endDate: null,
inspector: ''
})
// 表单数据
const formData = reactive({
id: null,
animalId: '',
checkDate: null,
temperature: null,
weight: null,
healthStatus: '',
vaccination: '',
inspector: '',
symptoms: '',
treatment: '',
remark: ''
})
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => `${range[0]}-${range[1]} 条,共 ${total}`
})
// 搜索字段配置
const searchFields = [
{
type: 'input',
key: 'animalId',
label: '动物编号',
placeholder: '请输入动物编号'
},
{
type: 'select',
key: 'healthStatus',
label: '健康状态',
placeholder: '请选择健康状态',
options: [
{ label: '健康', value: 'healthy' },
{ label: '患病', value: 'sick' },
{ label: '隔离观察', value: 'quarantine' },
{ label: '康复中', value: 'recovery' }
]
},
{
type: 'date-range',
key: 'dateRange',
label: '检查日期',
placeholder: ['开始日期', '结束日期']
},
{
type: 'input',
key: 'inspector',
label: '检查人员',
placeholder: '请输入检查人员'
}
]
// 表格列配置
const columns = [
{
title: '动物编号',
dataIndex: 'animalId',
key: 'animalId',
width: 120
},
{
title: '检查日期',
dataIndex: 'checkDate',
key: 'checkDate',
width: 120,
customRender: ({ text }) => formatDate(text, 'YYYY-MM-DD')
},
{
title: '体温',
dataIndex: 'temperature',
key: 'temperature',
width: 80,
align: 'center'
},
{
title: '体重(kg)',
dataIndex: 'weight',
key: 'weight',
width: 100,
align: 'center'
},
{
title: '健康状态',
dataIndex: 'healthStatus',
key: 'healthStatus',
width: 120,
align: 'center'
},
{
title: '疫苗接种',
dataIndex: 'vaccination',
key: 'vaccination',
width: 120,
customRender: ({ text }) => getVaccinationText(text)
},
{
title: '检查人员',
dataIndex: 'inspector',
key: 'inspector',
width: 100
},
{
title: '记录时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 160,
customRender: ({ text }) => formatDate(text)
},
{
title: '操作',
key: 'action',
width: 180,
align: 'center',
fixed: 'right'
}
]
// 表单验证规则
const formRules = {
animalId: [
{ required: true, message: '请输入动物编号', trigger: 'blur' }
],
checkDate: [
{ required: true, message: '请选择检查日期', trigger: 'change' }
],
temperature: [
{ required: true, message: '请输入体温', trigger: 'blur' }
],
healthStatus: [
{ required: true, message: '请选择健康状态', trigger: 'change' }
],
inspector: [
{ required: true, message: '请输入检查人员', trigger: 'blur' }
]
}
// 计算属性
const dataSource = computed(() => monitoringStore.healthRecords || [])
const modalTitle = computed(() => formData.id ? '编辑健康记录' : '新增健康记录')
// 获取健康状态颜色
const getHealthStatusColor = (status) => {
const colorMap = {
healthy: 'success',
sick: 'error',
quarantine: 'warning',
recovery: 'processing'
}
return colorMap[status] || 'default'
}
// 获取健康状态文本
const getHealthStatusText = (status) => {
const textMap = {
healthy: '健康',
sick: '患病',
quarantine: '隔离观察',
recovery: '康复中'
}
return textMap[status] || '未知'
}
// 获取疫苗接种文本
const getVaccinationText = (vaccination) => {
const textMap = {
foot_mouth: '口蹄疫疫苗',
swine_fever: '猪瘟疫苗',
blue_ear: '蓝耳病疫苗',
porcine_parvovirus: '细小病毒疫苗'
}
return textMap[vaccination] || '无'
}
// 获取预警级别颜色
const getAlertColor = (level) => {
const colorMap = {
high: '#ff4d4f',
medium: '#faad14',
low: '#1890ff'
}
return colorMap[level] || '#d9d9d9'
}
// 获取预警级别文本
const getAlertLevelText = (level) => {
const textMap = {
high: '高风险',
medium: '中风险',
low: '低风险'
}
return textMap[level] || '未知'
}
// 初始化健康趋势图表
const initHealthTrendChart = () => {
nextTick(() => {
if (healthTrendChart.value) {
const chart = echarts.init(healthTrendChart.value)
const option = {
title: {
text: '近30天健康状态趋势',
left: 'center'
},
tooltip: {
trigger: 'axis'
},
legend: {
data: ['健康', '患病', '隔离观察', '康复中'],
bottom: 0
},
grid: {
left: '3%',
right: '4%',
bottom: '10%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: [] // 从store获取日期数据
},
yAxis: {
type: 'value'
},
series: [
{
name: '健康',
type: 'line',
stack: 'Total',
data: [], // 从store获取数据
itemStyle: { color: '#52c41a' }
},
{
name: '患病',
type: 'line',
stack: 'Total',
data: [], // 从store获取数据
itemStyle: { color: '#ff4d4f' }
},
{
name: '隔离观察',
type: 'line',
stack: 'Total',
data: [], // 从store获取数据
itemStyle: { color: '#faad14' }
},
{
name: '康复中',
type: 'line',
stack: 'Total',
data: [], // 从store获取数据
itemStyle: { color: '#1890ff' }
}
]
}
chart.setOption(option)
// 响应式调整
window.addEventListener('resize', () => {
chart.resize()
})
}
})
}
// 加载统计数据
const loadHealthStats = async () => {
try {
const stats = await monitoringStore.fetchHealthStats()
Object.assign(healthStats, stats)
} catch (error) {
message.error('加载统计数据失败')
}
}
// 加载疫病预警
const loadDiseaseAlerts = async () => {
try {
alertLoading.value = true
diseaseAlerts.value = await monitoringStore.fetchDiseaseAlerts()
} catch (error) {
message.error('加载预警信息失败')
} finally {
alertLoading.value = false
}
}
// 加载健康记录数据
const loadData = async () => {
try {
loading.value = true
const params = {
...searchParams,
page: pagination.current,
pageSize: pagination.pageSize
}
const result = await monitoringStore.fetchHealthRecords(params)
pagination.total = result.total
} catch (error) {
message.error('加载数据失败')
} finally {
loading.value = false
}
}
// 搜索处理
const handleSearch = (values) => {
Object.assign(searchParams, values)
if (values.dateRange) {
searchParams.startDate = values.dateRange[0]
searchParams.endDate = values.dateRange[1]
}
pagination.current = 1
loadData()
}
// 重置搜索
const handleReset = () => {
Object.keys(searchParams).forEach(key => {
searchParams[key] = key.includes('Date') ? null : ''
})
pagination.current = 1
loadData()
}
// 表格变化处理
const handleTableChange = (pag, filters, sorter) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadData()
}
// 新增记录处理
const handleAddRecord = () => {
resetForm()
modalVisible.value = true
}
// 编辑处理
const handleEdit = (record) => {
Object.assign(formData, record)
modalVisible.value = true
}
// 查看处理
const handleView = (record) => {
currentRecord.value = record
detailVisible.value = true
}
// 删除处理
const handleDelete = async (record) => {
try {
await monitoringStore.deleteHealthRecord(record.id)
message.success('删除成功')
loadData()
loadHealthStats()
} catch (error) {
message.error('删除失败')
}
}
// 导出处理
const handleExport = async () => {
try {
await monitoringStore.exportHealthReport(searchParams)
message.success('导出成功')
} catch (error) {
message.error('导出失败')
}
}
// 预警详情处理
const handleAlertDetail = (alert) => {
// 跳转到预警详情页面或显示详情弹窗
console.log('查看预警详情:', alert)
}
// 预警处理
const handleAlertProcess = (alert) => {
// 处理预警
console.log('处理预警:', alert)
}
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value.validate()
if (formData.id) {
await monitoringStore.updateHealthRecord(formData.id, formData)
message.success('更新成功')
} else {
await monitoringStore.createHealthRecord(formData)
message.success('创建成功')
}
modalVisible.value = false
loadData()
loadHealthStats()
} catch (error) {
if (error.errorFields) {
message.error('请检查表单输入')
} else {
message.error('操作失败')
}
}
}
// 取消处理
const handleCancel = () => {
modalVisible.value = false
resetForm()
}
// 重置表单
const resetForm = () => {
Object.assign(formData, {
id: null,
animalId: '',
checkDate: null,
temperature: null,
weight: null,
healthStatus: '',
vaccination: '',
inspector: '',
symptoms: '',
treatment: '',
remark: ''
})
formRef.value?.resetFields()
}
// 组件挂载
onMounted(() => {
loadHealthStats()
loadDiseaseAlerts()
loadData()
initHealthTrendChart()
})
</script>
<style lang="scss" scoped>
.animal-health-monitor {
.stats-cards {
margin-bottom: 24px;
.stat-card {
text-align: center;
&.healthy {
border-left: 4px solid #52c41a;
}
&.sick {
border-left: 4px solid #ff4d4f;
}
&.quarantine {
border-left: 4px solid #faad14;
}
&.vaccination {
border-left: 4px solid #1890ff;
}
}
}
.chart-card {
margin-bottom: 24px;
.chart-container {
height: 400px;
}
}
.alert-card {
margin-bottom: 24px;
.alert-meta {
color: #999;
font-size: 12px;
margin-top: 8px;
}
}
.table-card {
.high-temperature {
color: #ff4d4f;
font-weight: bold;
}
}
.ant-descriptions {
margin-top: 16px;
}
}
</style>

View File

@@ -0,0 +1,752 @@
<template>
<div class="device-list">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-content">
<h2>设备监控</h2>
<p>实时监控养殖场设备运行状态</p>
</div>
<div class="header-actions">
<a-button type="primary" @click="showAddModal">
<template #icon><PlusOutlined /></template>
添加设备
</a-button>
<a-button @click="refreshData">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-cards">
<a-row :gutter="16">
<a-col :span="6">
<a-card class="stat-card online">
<div class="stat-content">
<div class="stat-icon">
<CheckCircleOutlined />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.online }}</div>
<div class="stat-label">在线设备</div>
</div>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card offline">
<div class="stat-content">
<div class="stat-icon">
<ExclamationCircleOutlined />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.offline }}</div>
<div class="stat-label">离线设备</div>
</div>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card warning">
<div class="stat-content">
<div class="stat-icon">
<WarningOutlined />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.warning }}</div>
<div class="stat-label">预警设备</div>
</div>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card total">
<div class="stat-content">
<div class="stat-icon">
<DatabaseOutlined />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">设备总数</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
</div>
<!-- 搜索和筛选 -->
<a-card class="search-card">
<a-form layout="inline" :model="searchForm" @finish="handleSearch">
<a-form-item label="设备名称">
<a-input
v-model:value="searchForm.name"
placeholder="请输入设备名称"
allow-clear
/>
</a-form-item>
<a-form-item label="设备类型">
<a-select
v-model:value="searchForm.type"
placeholder="请选择设备类型"
allow-clear
style="width: 150px"
>
<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="aerator">增氧机</a-select-option>
<a-select-option value="pump">水泵</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="设备状态">
<a-select
v-model:value="searchForm.status"
placeholder="请选择设备状态"
allow-clear
style="width: 120px"
>
<a-select-option value="online">在线</a-select-option>
<a-select-option value="offline">离线</a-select-option>
<a-select-option value="warning">预警</a-select-option>
<a-select-option value="error">故障</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="所属养殖场">
<a-select
v-model:value="searchForm.farmId"
placeholder="请选择养殖场"
allow-clear
style="width: 200px"
show-search
:filter-option="filterFarmOption"
>
<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>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="resetSearch" style="margin-left: 8px">
重置
</a-button>
</a-form-item>
</a-form>
</a-card>
<!-- 设备列表 -->
<a-card class="table-card">
<template #title>
<div class="table-header">
<span>设备列表</span>
<div class="table-actions">
<a-button
type="primary"
danger
:disabled="!selectedRowKeys.length"
@click="handleBatchDelete"
>
批量删除
</a-button>
<a-button @click="handleExport">
<template #icon><ExportOutlined /></template>
导出
</a-button>
</div>
</div>
</template>
<a-table
:columns="columns"
:data-source="devices"
:loading="loading"
:pagination="pagination"
:row-selection="rowSelection"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
<template #icon>
<component :is="getStatusIcon(record.status)" />
</template>
{{ getStatusText(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'type'">
<a-tag color="blue">
{{ getDeviceTypeText(record.type) }}
</a-tag>
</template>
<template v-else-if="column.key === 'lastHeartbeat'">
<span :class="getHeartbeatClass(record.lastHeartbeat)">
{{ formatTime(record.lastHeartbeat) }}
</span>
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button
type="link"
size="small"
@click="viewDevice(record)"
>
查看
</a-button>
<a-button
type="link"
size="small"
@click="editDevice(record)"
v-permission="'device:edit'"
>
编辑
</a-button>
<a-button
type="link"
size="small"
@click="controlDevice(record)"
v-permission="'device:control'"
>
控制
</a-button>
<a-popconfirm
title="确定要删除这个设备吗?"
@confirm="deleteDevice(record.id)"
>
<a-button
type="link"
size="small"
danger
v-permission="'device:delete'"
>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 添加/编辑设备弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
width="800px"
@ok="handleModalOk"
@cancel="handleModalCancel"
>
<DeviceForm
ref="deviceFormRef"
:device="currentDevice"
:farms="farms"
/>
</a-modal>
<!-- 设备控制弹窗 -->
<a-modal
v-model:open="controlModalVisible"
title="设备控制"
width="600px"
@ok="handleControlOk"
@cancel="controlModalVisible = false"
>
<DeviceControl
ref="deviceControlRef"
:device="currentDevice"
/>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
PlusOutlined,
ReloadOutlined,
SearchOutlined,
ExportOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
WarningOutlined,
DatabaseOutlined,
WifiOutlined,
DisconnectOutlined,
CloseCircleOutlined
} from '@ant-design/icons-vue'
import DeviceForm from './components/DeviceForm.vue'
import DeviceControl from './components/DeviceControl.vue'
import { useDeviceStore } from '@/stores/device'
import { useFarmStore } from '@/stores/farm'
import { formatTime } from '@/utils/date'
// Store
const deviceStore = useDeviceStore()
const farmStore = useFarmStore()
// 响应式数据
const loading = ref(false)
const devices = ref([])
const farms = ref([])
const stats = ref({
total: 0,
online: 0,
offline: 0,
warning: 0
})
const selectedRowKeys = ref([])
const modalVisible = ref(false)
const controlModalVisible = ref(false)
const currentDevice = ref(null)
const deviceFormRef = ref()
const deviceControlRef = ref()
// 搜索表单
const searchForm = reactive({
name: '',
type: '',
status: '',
farmId: ''
})
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`
})
// 表格列配置
const columns = [
{
title: '设备名称',
dataIndex: 'name',
key: 'name',
width: 150
},
{
title: '设备编号',
dataIndex: 'code',
key: 'code',
width: 120
},
{
title: '设备类型',
dataIndex: 'type',
key: 'type',
width: 100
},
{
title: '所属养殖场',
dataIndex: 'farmName',
key: 'farmName',
width: 150
},
{
title: '设备状态',
dataIndex: 'status',
key: 'status',
width: 100
},
{
title: '最后心跳',
dataIndex: 'lastHeartbeat',
key: 'lastHeartbeat',
width: 150
},
{
title: '位置信息',
dataIndex: 'location',
key: 'location',
width: 120
},
{
title: '操作',
key: 'actions',
width: 200,
fixed: 'right'
}
]
// 行选择配置
const rowSelection = {
selectedRowKeys,
onChange: (keys) => {
selectedRowKeys.value = keys
}
}
// 计算属性
const modalTitle = computed(() => {
return currentDevice.value?.id ? '编辑设备' : '添加设备'
})
// 方法
const fetchData = async () => {
try {
loading.value = true
const params = {
page: pagination.current,
pageSize: pagination.pageSize,
...searchForm
}
const [devicesRes, statsRes] = await Promise.all([
deviceStore.fetchDevices(params),
deviceStore.fetchStats()
])
devices.value = devicesRes.data.list
pagination.total = devicesRes.data.total
stats.value = statsRes
} catch (error) {
console.error('获取数据失败:', error)
} finally {
loading.value = false
}
}
const fetchFarms = async () => {
try {
const response = await farmStore.fetchFarms({ pageSize: 1000 })
farms.value = response.data.list
} catch (error) {
console.error('获取养殖场列表失败:', error)
}
}
const refreshData = () => {
fetchData()
}
const handleSearch = () => {
pagination.current = 1
fetchData()
}
const resetSearch = () => {
Object.keys(searchForm).forEach(key => {
searchForm[key] = ''
})
pagination.current = 1
fetchData()
}
const handleTableChange = (pag) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
fetchData()
}
const showAddModal = () => {
currentDevice.value = null
modalVisible.value = true
}
const viewDevice = (device) => {
// 跳转到设备详情页
// router.push(`/monitoring/device/${device.id}`)
}
const editDevice = (device) => {
currentDevice.value = { ...device }
modalVisible.value = true
}
const controlDevice = (device) => {
currentDevice.value = device
controlModalVisible.value = true
}
const deleteDevice = async (id) => {
try {
await deviceStore.removeDevice(id)
await fetchData()
} catch (error) {
console.error('删除设备失败:', error)
}
}
const handleBatchDelete = () => {
Modal.confirm({
title: '确认删除',
content: `确定要删除选中的 ${selectedRowKeys.value.length} 个设备吗?`,
onOk: async () => {
try {
await deviceStore.batchRemoveDevices(selectedRowKeys.value)
selectedRowKeys.value = []
await fetchData()
} catch (error) {
console.error('批量删除失败:', error)
}
}
})
}
const handleExport = () => {
// 导出功能
message.info('导出功能开发中...')
}
const handleModalOk = async () => {
try {
const formData = await deviceFormRef.value.validate()
if (currentDevice.value?.id) {
await deviceStore.editDevice(currentDevice.value.id, formData)
} else {
await deviceStore.addDevice(formData)
}
modalVisible.value = false
await fetchData()
} catch (error) {
console.error('保存设备失败:', error)
}
}
const handleModalCancel = () => {
modalVisible.value = false
currentDevice.value = null
}
const handleControlOk = async () => {
try {
await deviceControlRef.value.executeControl()
controlModalVisible.value = false
} catch (error) {
console.error('设备控制失败:', error)
}
}
// 工具方法
const getStatusColor = (status) => {
const colors = {
online: 'green',
offline: 'red',
warning: 'orange',
error: 'red'
}
return colors[status] || 'default'
}
const getStatusIcon = (status) => {
const icons = {
online: WifiOutlined,
offline: DisconnectOutlined,
warning: WarningOutlined,
error: CloseCircleOutlined
}
return icons[status] || WifiOutlined
}
const getStatusText = (status) => {
const texts = {
online: '在线',
offline: '离线',
warning: '预警',
error: '故障'
}
return texts[status] || '未知'
}
const getDeviceTypeText = (type) => {
const texts = {
sensor: '传感器',
camera: '摄像头',
feeder: '投料机',
aerator: '增氧机',
pump: '水泵'
}
return texts[type] || type
}
const getHeartbeatClass = (lastHeartbeat) => {
const now = new Date()
const heartbeat = new Date(lastHeartbeat)
const diff = now - heartbeat
if (diff < 5 * 60 * 1000) return 'heartbeat-normal' // 5分钟内
if (diff < 30 * 60 * 1000) return 'heartbeat-warning' // 30分钟内
return 'heartbeat-danger' // 超过30分钟
}
const filterFarmOption = (input, option) => {
return option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
// 生命周期
onMounted(() => {
fetchData()
fetchFarms()
})
</script>
<style lang="scss" scoped>
.device-list {
padding: 24px;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
.header-content {
h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #1f2937;
}
p {
margin: 4px 0 0;
color: #6b7280;
}
}
.header-actions {
display: flex;
gap: 12px;
}
}
.stats-cards {
margin-bottom: 24px;
.stat-card {
border-radius: 12px;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.stat-content {
display: flex;
align-items: center;
gap: 16px;
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.stat-info {
.stat-value {
font-size: 28px;
font-weight: 700;
line-height: 1;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #6b7280;
}
}
}
&.online {
.stat-icon {
background: #dcfce7;
color: #16a34a;
}
.stat-value {
color: #16a34a;
}
}
&.offline {
.stat-icon {
background: #fef2f2;
color: #dc2626;
}
.stat-value {
color: #dc2626;
}
}
&.warning {
.stat-icon {
background: #fef3c7;
color: #d97706;
}
.stat-value {
color: #d97706;
}
}
&.total {
.stat-icon {
background: #dbeafe;
color: #2563eb;
}
.stat-value {
color: #2563eb;
}
}
}
}
.search-card {
margin-bottom: 24px;
border-radius: 12px;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.table-card {
border-radius: 12px;
border: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
.table-actions {
display: flex;
gap: 12px;
}
}
}
.heartbeat-normal {
color: #16a34a;
}
.heartbeat-warning {
color: #d97706;
}
.heartbeat-danger {
color: #dc2626;
}
}
</style>

View File

@@ -0,0 +1,175 @@
<template>
<div class="environment-monitor">
<a-card title="环境监测" :bordered="false">
<a-row :gutter="16" style="margin-bottom: 24px;">
<a-col :span="6">
<a-card size="small">
<a-statistic
title="温度"
:value="environmentData.temperature"
suffix="°C"
:value-style="{ color: '#3f8600' }"
/>
</a-card>
</a-col>
<a-col :span="6">
<a-card size="small">
<a-statistic
title="湿度"
:value="environmentData.humidity"
suffix="%"
:value-style="{ color: '#1890ff' }"
/>
</a-card>
</a-col>
<a-col :span="6">
<a-card size="small">
<a-statistic
title="空气质量"
:value="environmentData.airQuality"
:value-style="{ color: '#cf1322' }"
/>
</a-card>
</a-col>
<a-col :span="6">
<a-card size="small">
<a-statistic
title="噪音"
:value="environmentData.noise"
suffix="dB"
:value-style="{ color: '#722ed1' }"
/>
</a-card>
</a-col>
</a-row>
<a-tabs>
<a-tab-pane key="realtime" tab="实时监测">
<div id="realtime-chart" style="height: 400px;"></div>
</a-tab-pane>
<a-tab-pane key="history" tab="历史数据">
<div class="history-search">
<a-form layout="inline" :model="historyForm">
<a-form-item label="监测点">
<a-select v-model:value="historyForm.station" style="width: 200px">
<a-select-option value="station1">监测点1</a-select-option>
<a-select-option value="station2">监测点2</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="时间范围">
<a-range-picker v-model:value="historyForm.dateRange" />
</a-form-item>
<a-form-item>
<a-button type="primary">查询</a-button>
</a-form-item>
</a-form>
</div>
<a-table
:columns="historyColumns"
:data-source="historyData"
:loading="loading"
row-key="id"
/>
</a-tab-pane>
<a-tab-pane key="alert" tab="预警设置">
<a-form :model="alertForm" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="温度预警阈值">
<a-input-number
v-model:value="alertForm.temperatureThreshold"
:min="0"
:max="50"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="湿度预警阈值">
<a-input-number
v-model:value="alertForm.humidityThreshold"
:min="0"
:max="100"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item>
<a-button type="primary">保存设置</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
const loading = ref(false)
const historyData = ref([])
const environmentData = reactive({
temperature: 25.6,
humidity: 68,
airQuality: 'AQI 45',
noise: 42
})
const historyForm = reactive({
station: undefined,
dateRange: null
})
const alertForm = reactive({
temperatureThreshold: 30,
humidityThreshold: 80
})
const historyColumns = [
{ title: '监测时间', dataIndex: 'time', key: 'time' },
{ title: '温度(°C)', dataIndex: 'temperature', key: 'temperature' },
{ title: '湿度(%)', dataIndex: 'humidity', key: 'humidity' },
{ title: '空气质量', dataIndex: 'airQuality', key: 'airQuality' },
{ title: '噪音(dB)', dataIndex: 'noise', key: 'noise' }
]
onMounted(() => {
historyData.value = [
{
id: 1,
time: '2024-01-15 14:00',
temperature: 25.6,
humidity: 68,
airQuality: 'AQI 45',
noise: 42
},
{
id: 2,
time: '2024-01-15 13:00',
temperature: 24.8,
humidity: 70,
airQuality: 'AQI 48',
noise: 40
}
]
})
</script>
<style scoped>
.environment-monitor {
padding: 24px;
}
.history-search {
margin-bottom: 16px;
padding: 16px;
background: #fafafa;
border-radius: 6px;
}
</style>

View File

@@ -0,0 +1,215 @@
<template>
<div class="video-surveillance">
<a-card title="视频监控" :bordered="false">
<a-row :gutter="16">
<a-col :span="18">
<div class="video-grid">
<a-row :gutter="8">
<a-col :span="12" v-for="camera in cameras" :key="camera.id">
<div class="video-item">
<div class="video-header">
<span>{{ camera.name }}</span>
<a-tag :color="camera.status === 'online' ? 'green' : 'red'">
{{ camera.status === 'online' ? '在线' : '离线' }}
</a-tag>
</div>
<div class="video-player">
<div class="video-placeholder">
<video-camera-outlined style="font-size: 48px; color: #ccc;" />
<p>{{ camera.name }}</p>
</div>
</div>
<div class="video-controls">
<a-space>
<a-button size="small" type="text">
<play-circle-outlined />
</a-button>
<a-button size="small" type="text">
<pause-circle-outlined />
</a-button>
<a-button size="small" type="text">
<fullscreen-outlined />
</a-button>
<a-button size="small" type="text">
<camera-outlined />
</a-button>
</a-space>
</div>
</div>
</a-col>
</a-row>
</div>
</a-col>
<a-col :span="6">
<a-card title="摄像头列表" size="small">
<div class="camera-list">
<div
v-for="camera in allCameras"
:key="camera.id"
class="camera-item"
:class="{ active: selectedCamera === camera.id }"
@click="selectCamera(camera.id)"
>
<div class="camera-info">
<div class="camera-name">{{ camera.name }}</div>
<div class="camera-location">{{ camera.location }}</div>
</div>
<a-tag :color="camera.status === 'online' ? 'green' : 'red'" size="small">
{{ camera.status === 'online' ? '在线' : '离线' }}
</a-tag>
</div>
</div>
</a-card>
<a-card title="录像回放" size="small" style="margin-top: 16px;">
<a-form layout="vertical" size="small">
<a-form-item label="选择摄像头">
<a-select v-model:value="playbackForm.camera" size="small">
<a-select-option v-for="camera in allCameras" :key="camera.id" :value="camera.id">
{{ camera.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="回放时间">
<a-date-picker v-model:value="playbackForm.date" size="small" style="width: 100%" />
</a-form-item>
<a-form-item label="时间段">
<a-time-range-picker v-model:value="playbackForm.timeRange" size="small" style="width: 100%" />
</a-form-item>
<a-form-item>
<a-button type="primary" size="small" block>开始回放</a-button>
</a-form-item>
</a-form>
</a-card>
</a-col>
</a-row>
</a-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import {
VideoCameraOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
FullscreenOutlined,
CameraOutlined
} from '@ant-design/icons-vue'
const selectedCamera = ref(null)
const cameras = ref([])
const allCameras = ref([])
const playbackForm = reactive({
camera: undefined,
date: null,
timeRange: null
})
const selectCamera = (cameraId) => {
selectedCamera.value = cameraId
}
onMounted(() => {
allCameras.value = [
{ id: 1, name: '养殖场入口', location: '大门口', status: 'online' },
{ id: 2, name: '饲料仓库', location: '仓库区', status: 'online' },
{ id: 3, name: '养殖区域1', location: '1号棚', status: 'online' },
{ id: 4, name: '养殖区域2', location: '2号棚', status: 'offline' },
{ id: 5, name: '办公区域', location: '办公楼', status: 'online' },
{ id: 6, name: '污水处理', location: '处理站', status: 'online' }
]
// 显示前4个摄像头
cameras.value = allCameras.value.slice(0, 4)
})
</script>
<style scoped>
.video-surveillance {
padding: 24px;
}
.video-grid {
background: #f5f5f5;
padding: 16px;
border-radius: 6px;
}
.video-item {
background: white;
border-radius: 6px;
overflow: hidden;
margin-bottom: 16px;
}
.video-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
}
.video-player {
height: 200px;
background: #000;
position: relative;
}
.video-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #ccc;
}
.video-controls {
padding: 8px 12px;
background: #fafafa;
text-align: center;
}
.camera-list {
max-height: 300px;
overflow-y: auto;
}
.camera-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-radius: 4px;
cursor: pointer;
margin-bottom: 4px;
}
.camera-item:hover {
background: #f5f5f5;
}
.camera-item.active {
background: #e6f7ff;
border: 1px solid #91d5ff;
}
.camera-info {
flex: 1;
}
.camera-name {
font-weight: 500;
margin-bottom: 2px;
}
.camera-location {
font-size: 12px;
color: #999;
}
</style>

View File

@@ -0,0 +1,630 @@
<template>
<div class="device-control">
<div class="device-info">
<h4>{{ device?.name }}</h4>
<p>设备编号: {{ device?.code }}</p>
<a-tag :color="getStatusColor(device?.status)">
{{ getStatusText(device?.status) }}
</a-tag>
</div>
<a-divider />
<!-- 基础控制 -->
<div class="control-section">
<h5>基础控制</h5>
<a-space wrap>
<a-button
type="primary"
:loading="loading.start"
@click="handleControl('start')"
:disabled="device?.status === 'online'"
>
启动设备
</a-button>
<a-button
danger
:loading="loading.stop"
@click="handleControl('stop')"
:disabled="device?.status === 'offline'"
>
停止设备
</a-button>
<a-button
:loading="loading.restart"
@click="handleControl('restart')"
>
重启设备
</a-button>
<a-button
:loading="loading.reset"
@click="handleControl('reset')"
>
重置设备
</a-button>
</a-space>
</div>
<!-- 参数设置 -->
<div class="control-section" v-if="showParameterControl">
<h5>参数设置</h5>
<a-form layout="vertical" :model="parameterForm">
<a-row :gutter="16">
<a-col :span="12" v-if="deviceTypeConfig.temperature">
<a-form-item label="目标温度(°C)">
<a-input-number
v-model:value="parameterForm.targetTemperature"
:min="0"
:max="50"
:precision="1"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12" v-if="deviceTypeConfig.humidity">
<a-form-item label="目标湿度(%)">
<a-input-number
v-model:value="parameterForm.targetHumidity"
:min="0"
:max="100"
:precision="1"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12" v-if="deviceTypeConfig.speed">
<a-form-item label="运行速度(%)">
<a-input-number
v-model:value="parameterForm.speed"
:min="0"
:max="100"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12" v-if="deviceTypeConfig.power">
<a-form-item label="功率(%)">
<a-input-number
v-model:value="parameterForm.power"
:min="0"
:max="100"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12" v-if="deviceTypeConfig.interval">
<a-form-item label="工作间隔(秒)">
<a-input-number
v-model:value="parameterForm.workInterval"
:min="1"
:max="3600"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12" v-if="deviceTypeConfig.duration">
<a-form-item label="工作时长(秒)">
<a-input-number
v-model:value="parameterForm.workDuration"
:min="1"
:max="3600"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-button
type="primary"
:loading="loading.setParams"
@click="handleSetParameters"
>
设置参数
</a-button>
</a-form>
</div>
<!-- 定时任务 -->
<div class="control-section">
<h5>定时任务</h5>
<a-form layout="vertical" :model="scheduleForm">
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="任务类型">
<a-select v-model:value="scheduleForm.type" placeholder="选择任务类型">
<a-select-option value="start">启动</a-select-option>
<a-select-option value="stop">停止</a-select-option>
<a-select-option value="feed" v-if="device?.type === 'feeder'">投料</a-select-option>
<a-select-option value="aerate" v-if="device?.type === 'aerator'">增氧</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="执行时间">
<a-time-picker
v-model:value="scheduleForm.time"
format="HH:mm"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="重复">
<a-select v-model:value="scheduleForm.repeat" placeholder="选择重复方式">
<a-select-option value="once">仅一次</a-select-option>
<a-select-option value="daily">每天</a-select-option>
<a-select-option value="weekly">每周</a-select-option>
<a-select-option value="custom">自定义</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-button
type="primary"
:loading="loading.schedule"
@click="handleScheduleTask"
>
添加定时任务
</a-button>
</a-form>
</div>
<!-- 当前定时任务列表 -->
<div class="control-section">
<h5>当前定时任务</h5>
<a-list
:data-source="scheduleTasks"
size="small"
:locale="{ emptyText: '暂无定时任务' }"
>
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a-button
type="link"
size="small"
@click="toggleTask(item)"
>
{{ item.enabled ? '禁用' : '启用' }}
</a-button>
<a-button
type="link"
size="small"
danger
@click="deleteTask(item.id)"
>
删除
</a-button>
</template>
<a-list-item-meta>
<template #title>
<span :class="{ 'task-disabled': !item.enabled }">
{{ getTaskTypeText(item.type) }} - {{ item.time }}
</span>
</template>
<template #description>
重复: {{ getRepeatText(item.repeat) }}
<a-tag
:color="item.enabled ? 'green' : 'default'"
size="small"
style="margin-left: 8px"
>
{{ item.enabled ? '启用' : '禁用' }}
</a-tag>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</div>
<!-- 实时数据 -->
<div class="control-section" v-if="device?.monitorParams?.length">
<h5>实时数据</h5>
<a-row :gutter="16">
<a-col
:span="8"
v-for="param in device.monitorParams"
:key="param"
>
<a-card size="small" class="data-card">
<div class="data-item">
<div class="data-label">{{ getParamText(param) }}</div>
<div class="data-value">
{{ realtimeData[param] || '--' }}
<span class="data-unit">{{ getParamUnit(param) }}</span>
</div>
</div>
</a-card>
</a-col>
</a-row>
<a-button
type="link"
:loading="loading.refresh"
@click="refreshRealtimeData"
style="margin-top: 12px"
>
刷新数据
</a-button>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, defineExpose } from 'vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
// Props
const props = defineProps({
device: {
type: Object,
required: true
}
})
// 响应式数据
const loading = reactive({
start: false,
stop: false,
restart: false,
reset: false,
setParams: false,
schedule: false,
refresh: false
})
const parameterForm = reactive({
targetTemperature: null,
targetHumidity: null,
speed: null,
power: null,
workInterval: null,
workDuration: null
})
const scheduleForm = reactive({
type: '',
time: null,
repeat: 'once'
})
const scheduleTasks = ref([])
const realtimeData = ref({})
// 计算属性
const deviceTypeConfig = computed(() => {
const configs = {
sensor: { temperature: true, humidity: true },
camera: {},
feeder: { speed: true, interval: true, duration: true },
aerator: { power: true, interval: true, duration: true },
pump: { power: true, speed: true },
light: { power: true, interval: true, duration: true },
heater: { temperature: true, power: true },
cooler: { temperature: true, power: true }
}
return configs[props.device?.type] || {}
})
const showParameterControl = computed(() => {
return Object.keys(deviceTypeConfig.value).length > 0
})
// 方法
const handleControl = async (action) => {
try {
loading[action] = true
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
message.success(`${getActionText(action)}成功`)
// 这里应该调用实际的设备控制API
// await deviceControlAPI(props.device.id, action)
} catch (error) {
message.error(`${getActionText(action)}失败`)
} finally {
loading[action] = false
}
}
const handleSetParameters = async () => {
try {
loading.setParams = true
// 过滤空值
const params = Object.keys(parameterForm)
.filter(key => parameterForm[key] !== null && parameterForm[key] !== '')
.reduce((obj, key) => {
obj[key] = parameterForm[key]
return obj
}, {})
if (Object.keys(params).length === 0) {
message.warning('请至少设置一个参数')
return
}
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
message.success('参数设置成功')
// 这里应该调用实际的参数设置API
// await setDeviceParameters(props.device.id, params)
} catch (error) {
message.error('参数设置失败')
} finally {
loading.setParams = false
}
}
const handleScheduleTask = async () => {
try {
if (!scheduleForm.type || !scheduleForm.time) {
message.warning('请完整填写任务信息')
return
}
loading.schedule = true
const task = {
id: Date.now(),
type: scheduleForm.type,
time: scheduleForm.time.format('HH:mm'),
repeat: scheduleForm.repeat,
enabled: true,
deviceId: props.device.id
}
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
scheduleTasks.value.push(task)
message.success('定时任务添加成功')
// 重置表单
scheduleForm.type = ''
scheduleForm.time = null
scheduleForm.repeat = 'once'
} catch (error) {
message.error('添加定时任务失败')
} finally {
loading.schedule = false
}
}
const toggleTask = async (task) => {
try {
task.enabled = !task.enabled
message.success(`任务已${task.enabled ? '启用' : '禁用'}`)
// 这里应该调用实际的API
// await toggleScheduleTask(task.id, task.enabled)
} catch (error) {
task.enabled = !task.enabled // 回滚状态
message.error('操作失败')
}
}
const deleteTask = async (taskId) => {
try {
const index = scheduleTasks.value.findIndex(task => task.id === taskId)
if (index !== -1) {
scheduleTasks.value.splice(index, 1)
message.success('任务删除成功')
}
// 这里应该调用实际的API
// await deleteScheduleTask(taskId)
} catch (error) {
message.error('删除任务失败')
}
}
const refreshRealtimeData = async () => {
try {
loading.refresh = true
// 模拟获取实时数据
await new Promise(resolve => setTimeout(resolve, 1000))
// 模拟数据
const mockData = {
temperature: (Math.random() * 10 + 20).toFixed(1),
humidity: (Math.random() * 20 + 60).toFixed(1),
ph: (Math.random() * 2 + 6).toFixed(2),
oxygen: (Math.random() * 3 + 5).toFixed(2),
ammonia: (Math.random() * 0.5).toFixed(3),
turbidity: (Math.random() * 10 + 5).toFixed(1),
salinity: (Math.random() * 5 + 30).toFixed(1),
pressure: (Math.random() * 0.5 + 1).toFixed(2),
flow: (Math.random() * 50 + 100).toFixed(1),
level: (Math.random() * 2 + 1).toFixed(2)
}
realtimeData.value = mockData
} catch (error) {
message.error('获取实时数据失败')
} finally {
loading.refresh = false
}
}
const executeControl = async () => {
// 这个方法由父组件调用,用于执行控制操作
return true
}
// 工具方法
const getStatusColor = (status) => {
const colors = {
online: 'green',
offline: 'red',
warning: 'orange',
error: 'red'
}
return colors[status] || 'default'
}
const getStatusText = (status) => {
const texts = {
online: '在线',
offline: '离线',
warning: '预警',
error: '故障'
}
return texts[status] || '未知'
}
const getActionText = (action) => {
const texts = {
start: '启动设备',
stop: '停止设备',
restart: '重启设备',
reset: '重置设备'
}
return texts[action] || action
}
const getTaskTypeText = (type) => {
const texts = {
start: '启动',
stop: '停止',
feed: '投料',
aerate: '增氧'
}
return texts[type] || type
}
const getRepeatText = (repeat) => {
const texts = {
once: '仅一次',
daily: '每天',
weekly: '每周',
custom: '自定义'
}
return texts[repeat] || repeat
}
const getParamText = (param) => {
const texts = {
temperature: '温度',
humidity: '湿度',
ph: 'pH值',
oxygen: '溶氧量',
ammonia: '氨氮',
turbidity: '浊度',
salinity: '盐度',
pressure: '压力',
flow: '流量',
level: '液位'
}
return texts[param] || param
}
const getParamUnit = (param) => {
const units = {
temperature: '°C',
humidity: '%',
ph: '',
oxygen: 'mg/L',
ammonia: 'mg/L',
turbidity: 'NTU',
salinity: '‰',
pressure: 'MPa',
flow: 'L/min',
level: 'm'
}
return units[param] || ''
}
// 生命周期
onMounted(() => {
// 初始化参数表单
if (props.device?.parameters) {
Object.assign(parameterForm, props.device.parameters)
}
// 获取定时任务列表
// fetchScheduleTasks()
// 获取实时数据
if (props.device?.monitorParams?.length) {
refreshRealtimeData()
}
})
// 暴露方法
defineExpose({
executeControl
})
</script>
<style lang="scss" scoped>
.device-control {
.device-info {
text-align: center;
padding: 16px 0;
h4 {
margin: 0 0 8px 0;
font-size: 18px;
font-weight: 600;
}
p {
margin: 0 0 12px 0;
color: #666;
}
}
.control-section {
margin-bottom: 24px;
h5 {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
}
.data-card {
.data-item {
text-align: center;
.data-label {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.data-value {
font-size: 18px;
font-weight: 600;
color: #1f2937;
.data-unit {
font-size: 12px;
font-weight: normal;
color: #666;
margin-left: 4px;
}
}
}
}
.task-disabled {
color: #999;
text-decoration: line-through;
}
}
</style>

View File

@@ -0,0 +1,485 @@
<template>
<div class="device-form">
<a-form
ref="formRef"
:model="formData"
:rules="rules"
layout="vertical"
>
<a-row :gutter="16">
<!-- 基本信息 -->
<a-col :span="24">
<div class="form-section">
<h3>基本信息</h3>
<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="code">
<a-input
v-model:value="formData.code"
placeholder="请输入设备编号"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="设备类型" name="type">
<a-select
v-model:value="formData.type"
placeholder="请选择设备类型"
>
<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="aerator">增氧机</a-select-option>
<a-select-option value="pump">水泵</a-select-option>
<a-select-option value="light">照明设备</a-select-option>
<a-select-option value="heater">加热设备</a-select-option>
<a-select-option value="cooler">制冷设备</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="所属养殖场" name="farmId">
<a-select
v-model:value="formData.farmId"
placeholder="请选择养殖场"
show-search
:filter-option="filterFarmOption"
>
<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-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="设备品牌" name="brand">
<a-input
v-model:value="formData.brand"
placeholder="请输入设备品牌"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="设备型号" name="model">
<a-input
v-model:value="formData.model"
placeholder="请输入设备型号"
/>
</a-form-item>
</a-col>
</a-row>
</div>
</a-col>
<!-- 网络配置 -->
<a-col :span="24">
<div class="form-section">
<h3>网络配置</h3>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="IP地址" name="ipAddress">
<a-input
v-model:value="formData.ipAddress"
placeholder="请输入IP地址"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="端口号" name="port">
<a-input-number
v-model:value="formData.port"
placeholder="请输入端口号"
:min="1"
:max="65535"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="MAC地址" name="macAddress">
<a-input
v-model:value="formData.macAddress"
placeholder="请输入MAC地址"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="通信协议" name="protocol">
<a-select
v-model:value="formData.protocol"
placeholder="请选择通信协议"
>
<a-select-option value="tcp">TCP</a-select-option>
<a-select-option value="udp">UDP</a-select-option>
<a-select-option value="mqtt">MQTT</a-select-option>
<a-select-option value="http">HTTP</a-select-option>
<a-select-option value="modbus">Modbus</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</div>
</a-col>
<!-- 位置信息 -->
<a-col :span="24">
<div class="form-section">
<h3>位置信息</h3>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="经度" name="longitude">
<a-input-number
v-model:value="formData.longitude"
placeholder="请输入经度"
:precision="6"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="纬度" name="latitude">
<a-input-number
v-model:value="formData.latitude"
placeholder="请输入纬度"
:precision="6"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="安装高度(米)" name="altitude">
<a-input-number
v-model:value="formData.altitude"
placeholder="请输入安装高度"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="24">
<a-form-item label="详细位置" name="location">
<a-input
v-model:value="formData.location"
placeholder="请输入详细位置描述"
/>
</a-form-item>
</a-col>
</a-row>
</div>
</a-col>
<!-- 监控参数 -->
<a-col :span="24">
<div class="form-section">
<h3>监控参数</h3>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="数据采集间隔(秒)" name="dataInterval">
<a-input-number
v-model:value="formData.dataInterval"
placeholder="请输入数据采集间隔"
:min="1"
:max="3600"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="心跳间隔(秒)" name="heartbeatInterval">
<a-input-number
v-model:value="formData.heartbeatInterval"
placeholder="请输入心跳间隔"
:min="10"
:max="300"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="24">
<a-form-item label="监控参数" name="monitorParams">
<a-select
v-model:value="formData.monitorParams"
mode="multiple"
placeholder="请选择监控参数"
>
<a-select-option value="temperature">温度</a-select-option>
<a-select-option value="humidity">湿度</a-select-option>
<a-select-option value="ph">pH值</a-select-option>
<a-select-option value="oxygen">溶氧量</a-select-option>
<a-select-option value="ammonia">氨氮</a-select-option>
<a-select-option value="turbidity">浊度</a-select-option>
<a-select-option value="salinity">盐度</a-select-option>
<a-select-option value="pressure">压力</a-select-option>
<a-select-option value="flow">流量</a-select-option>
<a-select-option value="level">液位</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
</div>
</a-col>
<!-- 预警配置 -->
<a-col :span="24">
<div class="form-section">
<h3>预警配置</h3>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="启用预警" name="alertEnabled">
<a-switch v-model:checked="formData.alertEnabled" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="离线预警时间(分钟)" name="offlineAlertTime">
<a-input-number
v-model:value="formData.offlineAlertTime"
placeholder="请输入离线预警时间"
:min="1"
:max="1440"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</div>
</a-col>
<!-- 其他信息 -->
<a-col :span="24">
<div class="form-section">
<h3>其他信息</h3>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="安装日期" name="installDate">
<a-date-picker
v-model:value="formData.installDate"
placeholder="请选择安装日期"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="保修期至" name="warrantyDate">
<a-date-picker
v-model:value="formData.warrantyDate"
placeholder="请选择保修期"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="24">
<a-form-item label="备注" name="remark">
<a-textarea
v-model:value="formData.remark"
placeholder="请输入备注信息"
:rows="3"
/>
</a-form-item>
</a-col>
</a-row>
</div>
</a-col>
</a-row>
</a-form>
</div>
</template>
<script setup>
import { ref, reactive, watch, defineExpose } from 'vue'
import dayjs from 'dayjs'
// Props
const props = defineProps({
device: {
type: Object,
default: null
},
farms: {
type: Array,
default: () => []
}
})
// 表单引用
const formRef = ref()
// 表单数据
const formData = reactive({
name: '',
code: '',
type: '',
farmId: '',
brand: '',
model: '',
ipAddress: '',
port: null,
macAddress: '',
protocol: '',
longitude: null,
latitude: null,
altitude: null,
location: '',
dataInterval: 60,
heartbeatInterval: 30,
monitorParams: [],
alertEnabled: true,
offlineAlertTime: 5,
installDate: null,
warrantyDate: null,
remark: ''
})
// 表单验证规则
const rules = {
name: [
{ required: true, message: '请输入设备名称', trigger: 'blur' },
{ min: 2, max: 50, message: '设备名称长度在 2 到 50 个字符', trigger: 'blur' }
],
code: [
{ required: true, message: '请输入设备编号', trigger: 'blur' },
{ pattern: /^[A-Za-z0-9_-]+$/, message: '设备编号只能包含字母、数字、下划线和横线', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择设备类型', trigger: 'change' }
],
farmId: [
{ required: true, message: '请选择所属养殖场', trigger: 'change' }
],
ipAddress: [
{ pattern: /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/, message: '请输入正确的IP地址格式', trigger: 'blur' }
],
port: [
{ type: 'number', min: 1, max: 65535, message: '端口号范围为 1-65535', trigger: 'blur' }
],
macAddress: [
{ pattern: /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/, message: '请输入正确的MAC地址格式', trigger: 'blur' }
],
dataInterval: [
{ required: true, message: '请输入数据采集间隔', trigger: 'blur' },
{ type: 'number', min: 1, max: 3600, message: '数据采集间隔范围为 1-3600 秒', trigger: 'blur' }
],
heartbeatInterval: [
{ required: true, message: '请输入心跳间隔', trigger: 'blur' },
{ type: 'number', min: 10, max: 300, message: '心跳间隔范围为 10-300 秒', trigger: 'blur' }
],
offlineAlertTime: [
{ type: 'number', min: 1, max: 1440, message: '离线预警时间范围为 1-1440 分钟', trigger: 'blur' }
]
}
// 监听设备数据变化
watch(() => props.device, (newDevice) => {
if (newDevice) {
Object.keys(formData).forEach(key => {
if (newDevice[key] !== undefined) {
if (key === 'installDate' || key === 'warrantyDate') {
formData[key] = newDevice[key] ? dayjs(newDevice[key]) : null
} else {
formData[key] = newDevice[key]
}
}
})
} else {
resetForm()
}
}, { immediate: true })
// 重置表单
const resetForm = () => {
Object.keys(formData).forEach(key => {
if (key === 'dataInterval') {
formData[key] = 60
} else if (key === 'heartbeatInterval') {
formData[key] = 30
} else if (key === 'alertEnabled') {
formData[key] = true
} else if (key === 'offlineAlertTime') {
formData[key] = 5
} else if (key === 'monitorParams') {
formData[key] = []
} else if (key === 'installDate' || key === 'warrantyDate') {
formData[key] = null
} else {
formData[key] = ''
}
})
}
// 表单验证
const validate = async () => {
try {
await formRef.value.validate()
const data = { ...formData }
// 处理日期格式
if (data.installDate) {
data.installDate = data.installDate.format('YYYY-MM-DD')
}
if (data.warrantyDate) {
data.warrantyDate = data.warrantyDate.format('YYYY-MM-DD')
}
return data
} catch (error) {
throw new Error('表单验证失败')
}
}
// 养殖场筛选
const filterFarmOption = (input, option) => {
return option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
}
// 暴露方法
defineExpose({
validate,
resetForm
})
</script>
<style lang="scss" scoped>
.device-form {
.form-section {
margin-bottom: 32px;
h3 {
margin: 0 0 16px 0;
padding-bottom: 8px;
border-bottom: 2px solid #f0f0f0;
font-size: 16px;
font-weight: 600;
color: #1f2937;
}
}
}
</style>

View File

@@ -0,0 +1,736 @@
<template>
<div class="department-management">
<!-- 页面头部 -->
<PageHeader
title="部门管理"
description="管理系统部门信息,包括部门层级、人员配置、权限分配等"
>
<template #extra>
<a-button type="primary" @click="addDepartment">
<PlusOutlined />
新增部门
</a-button>
</template>
</PageHeader>
<!-- 统计面板 -->
<div class="stats-panel">
<a-row :gutter="16">
<a-col :span="6">
<a-card>
<a-statistic
title="总部门数"
:value="stats.totalDepartments"
:value-style="{ color: '#1890ff' }"
>
<template #prefix>
<ApartmentOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic
title="一级部门"
:value="stats.topLevelDepartments"
:value-style="{ color: '#52c41a' }"
>
<template #prefix>
<BankOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic
title="总员工数"
:value="stats.totalEmployees"
:value-style="{ color: '#faad14' }"
>
<template #prefix>
<TeamOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic
title="本月新增"
:value="stats.newDepartments"
:value-style="{ color: '#722ed1' }"
>
<template #prefix>
<PlusCircleOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
</a-row>
</div>
<!-- 搜索区域 -->
<div class="search-area">
<a-card :bordered="false">
<a-form
ref="searchFormRef"
:model="searchForm"
layout="inline"
@finish="handleSearch"
>
<a-form-item label="部门名称" name="name">
<a-input
v-model:value="searchForm.name"
placeholder="请输入部门名称"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="部门类型" name="type">
<a-select
v-model:value="searchForm.type"
placeholder="请选择部门类型"
style="width: 150px"
allow-clear
>
<a-select-option value="management">管理部门</a-select-option>
<a-select-option value="business">业务部门</a-select-option>
<a-select-option value="support">支持部门</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="状态" name="status">
<a-select
v-model:value="searchForm.status"
placeholder="请选择状态"
style="width: 120px"
allow-clear
>
<a-select-option value="active">正常</a-select-option>
<a-select-option value="inactive">停用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<SearchOutlined />
搜索
</a-button>
<a-button style="margin-left: 8px" @click="resetSearch">
重置
</a-button>
</a-form-item>
</a-form>
</a-card>
</div>
<!-- 部门树形结构 -->
<div class="department-tree">
<a-row :gutter="16">
<a-col :span="8">
<a-card title="部门结构" :bordered="false">
<template #extra>
<a-space>
<a-button size="small" @click="expandAll">
<NodeExpandOutlined />
展开全部
</a-button>
<a-button size="small" @click="collapseAll">
<NodeCollapseOutlined />
收起全部
</a-button>
</a-space>
</template>
<a-tree
v-model:expandedKeys="expandedKeys"
v-model:selectedKeys="selectedKeys"
:tree-data="departmentTree"
:field-names="{ children: 'children', title: 'name', key: 'id' }"
show-line
@select="onSelectDepartment"
>
<template #title="{ name, type, employeeCount }">
<span>{{ name }}</span>
<a-tag size="small" :color="getTypeColor(type)" style="margin-left: 8px">
{{ getTypeName(type) }}
</a-tag>
<span style="color: #999; font-size: 12px; margin-left: 4px">
({{ employeeCount }})
</span>
</template>
</a-tree>
</a-card>
</a-col>
<a-col :span="16">
<a-card title="部门详情" :bordered="false">
<template #extra>
<a-space v-if="selectedDepartment">
<a-button @click="editDepartment(selectedDepartment)">
<EditOutlined />
编辑
</a-button>
<a-button @click="addSubDepartment(selectedDepartment)">
<PlusOutlined />
添加子部门
</a-button>
<a-dropdown>
<template #overlay>
<a-menu>
<a-menu-item @click="viewEmployees(selectedDepartment)">
<TeamOutlined />
查看员工
</a-menu-item>
<a-menu-item @click="managePerm(selectedDepartment)">
<SafetyCertificateOutlined />
权限管理
</a-menu-item>
<a-menu-divider />
<a-menu-item @click="deleteDepartment(selectedDepartment)" danger>
<DeleteOutlined />
删除部门
</a-menu-item>
</a-menu>
</template>
<a-button>
更多操作
<DownOutlined />
</a-button>
</a-dropdown>
</a-space>
</template>
<div v-if="selectedDepartment" class="department-detail">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="部门名称">
{{ selectedDepartment.name }}
</a-descriptions-item>
<a-descriptions-item label="部门编码">
{{ selectedDepartment.code }}
</a-descriptions-item>
<a-descriptions-item label="部门类型">
<a-tag :color="getTypeColor(selectedDepartment.type)">
{{ getTypeName(selectedDepartment.type) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="selectedDepartment.status === 'active' ? 'green' : 'red'">
{{ selectedDepartment.status === 'active' ? '正常' : '停用' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="负责人">
{{ selectedDepartment.manager || '暂无' }}
</a-descriptions-item>
<a-descriptions-item label="联系电话">
{{ selectedDepartment.phone || '暂无' }}
</a-descriptions-item>
<a-descriptions-item label="员工数量">
{{ selectedDepartment.employeeCount }}
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ selectedDepartment.createdAt }}
</a-descriptions-item>
<a-descriptions-item label="部门描述" :span="2">
{{ selectedDepartment.description || '暂无描述' }}
</a-descriptions-item>
</a-descriptions>
<!-- 子部门列表 -->
<div v-if="selectedDepartment.children && selectedDepartment.children.length > 0" class="sub-departments">
<h4>子部门</h4>
<a-list
:data-source="selectedDepartment.children"
size="small"
>
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a @click="selectDepartment(item)">查看</a>
<a @click="editDepartment(item)">编辑</a>
</template>
<a-list-item-meta>
<template #title>
<span>{{ item.name }}</span>
<a-tag size="small" :color="getTypeColor(item.type)" style="margin-left: 8px">
{{ getTypeName(item.type) }}
</a-tag>
</template>
<template #description>
负责人{{ item.manager || '暂无' }} | 员工{{ item.employeeCount }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</div>
</div>
<div v-else class="empty-state">
<a-empty description="请选择左侧部门查看详情" />
</div>
</a-card>
</a-col>
</a-row>
</div>
<!-- 新增/编辑部门弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
width="600px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
layout="vertical"
>
<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="code">
<a-input v-model:value="formData.code" placeholder="请输入部门编码" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="上级部门" name="parentId">
<a-tree-select
v-model:value="formData.parentId"
:tree-data="departmentTree"
:field-names="{ children: 'children', label: 'name', value: 'id' }"
placeholder="请选择上级部门"
allow-clear
tree-default-expand-all
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="部门类型" name="type">
<a-select v-model:value="formData.type" placeholder="请选择部门类型">
<a-select-option value="management">管理部门</a-select-option>
<a-select-option value="business">业务部门</a-select-option>
<a-select-option value="support">支持部门</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="manager">
<a-input v-model:value="formData.manager" placeholder="请输入负责人姓名" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="联系电话" name="phone">
<a-input v-model:value="formData.phone" placeholder="请输入联系电话" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio value="active">正常</a-radio>
<a-radio value="inactive">停用</a-radio>
</a-radio-group>
</a-form-item>
<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, Modal } from 'ant-design-vue'
import {
PlusOutlined,
ApartmentOutlined,
BankOutlined,
TeamOutlined,
PlusCircleOutlined,
SearchOutlined,
NodeExpandOutlined,
NodeCollapseOutlined,
EditOutlined,
DeleteOutlined,
DownOutlined,
SafetyCertificateOutlined
} from '@ant-design/icons-vue'
import PageHeader from '@/components/PageHeader.vue'
// 响应式数据
const modalVisible = ref(false)
const modalTitle = ref('')
const expandedKeys = ref([])
const selectedKeys = ref([])
const selectedDepartment = ref(null)
const searchFormRef = ref()
const formRef = ref()
// 统计数据
const stats = reactive({
totalDepartments: 28,
topLevelDepartments: 6,
totalEmployees: 156,
newDepartments: 3
})
// 搜索表单
const searchForm = reactive({
name: '',
type: '',
status: ''
})
// 表单数据
const formData = reactive({
name: '',
code: '',
parentId: null,
type: '',
manager: '',
phone: '',
status: 'active',
description: ''
})
// 表单验证规则
const formRules = {
name: [{ required: true, message: '请输入部门名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入部门编码', trigger: 'blur' }],
type: [{ required: true, message: '请选择部门类型', trigger: 'change' }]
}
// 部门树形数据
const departmentTree = ref([
{
id: 1,
name: '总经理办公室',
code: 'CEO',
type: 'management',
status: 'active',
manager: '王总',
phone: '13800138001',
employeeCount: 3,
createdAt: '2024-01-01 09:00:00',
description: '公司最高管理层',
children: []
},
{
id: 2,
name: '技术部',
code: 'TECH',
type: 'business',
status: 'active',
manager: '张三',
phone: '13800138002',
employeeCount: 25,
createdAt: '2024-01-01 09:00:00',
description: '负责技术研发和系统维护',
children: [
{
id: 21,
name: '前端开发组',
code: 'TECH-FE',
type: 'business',
status: 'active',
manager: '李四',
phone: '13800138021',
employeeCount: 8,
createdAt: '2024-01-01 09:00:00',
description: '负责前端开发',
children: []
},
{
id: 22,
name: '后端开发组',
code: 'TECH-BE',
type: 'business',
status: 'active',
manager: '王五',
phone: '13800138022',
employeeCount: 12,
createdAt: '2024-01-01 09:00:00',
description: '负责后端开发',
children: []
},
{
id: 23,
name: '测试组',
code: 'TECH-QA',
type: 'support',
status: 'active',
manager: '赵六',
phone: '13800138023',
employeeCount: 5,
createdAt: '2024-01-01 09:00:00',
description: '负责软件测试',
children: []
}
]
},
{
id: 3,
name: '市场部',
code: 'MKT',
type: 'business',
status: 'active',
manager: '孙七',
phone: '13800138003',
employeeCount: 15,
createdAt: '2024-01-01 09:00:00',
description: '负责市场推广和销售',
children: [
{
id: 31,
name: '销售组',
code: 'MKT-SALES',
type: 'business',
status: 'active',
manager: '周八',
phone: '13800138031',
employeeCount: 10,
createdAt: '2024-01-01 09:00:00',
description: '负责产品销售',
children: []
},
{
id: 32,
name: '推广组',
code: 'MKT-PROMO',
type: 'business',
status: 'active',
manager: '吴九',
phone: '13800138032',
employeeCount: 5,
createdAt: '2024-01-01 09:00:00',
description: '负责市场推广',
children: []
}
]
},
{
id: 4,
name: '人事部',
code: 'HR',
type: 'support',
status: 'active',
manager: '郑十',
phone: '13800138004',
employeeCount: 8,
createdAt: '2024-01-01 09:00:00',
description: '负责人力资源管理',
children: []
},
{
id: 5,
name: '财务部',
code: 'FIN',
type: 'support',
status: 'active',
manager: '钱一',
phone: '13800138005',
employeeCount: 6,
createdAt: '2024-01-01 09:00:00',
description: '负责财务管理',
children: []
}
])
// 方法
const getTypeColor = (type) => {
const colors = {
management: '#722ed1',
business: '#1890ff',
support: '#52c41a'
}
return colors[type] || '#1890ff'
}
const getTypeName = (type) => {
const names = {
management: '管理',
business: '业务',
support: '支持'
}
return names[type] || '未知'
}
const handleSearch = () => {
message.info('搜索功能开发中')
}
const resetSearch = () => {
Object.assign(searchForm, {
name: '',
type: '',
status: ''
})
}
const expandAll = () => {
const getAllKeys = (data) => {
let keys = []
data.forEach(item => {
keys.push(item.id)
if (item.children && item.children.length > 0) {
keys = keys.concat(getAllKeys(item.children))
}
})
return keys
}
expandedKeys.value = getAllKeys(departmentTree.value)
}
const collapseAll = () => {
expandedKeys.value = []
}
const onSelectDepartment = (selectedKeysValue, info) => {
if (selectedKeysValue.length > 0) {
const findDepartment = (data, id) => {
for (const item of data) {
if (item.id === id) {
return item
}
if (item.children && item.children.length > 0) {
const found = findDepartment(item.children, id)
if (found) return found
}
}
return null
}
selectedDepartment.value = findDepartment(departmentTree.value, selectedKeysValue[0])
} else {
selectedDepartment.value = null
}
}
const selectDepartment = (department) => {
selectedKeys.value = [department.id]
selectedDepartment.value = department
}
const addDepartment = () => {
modalTitle.value = '新增部门'
modalVisible.value = true
resetForm()
}
const addSubDepartment = (parent) => {
modalTitle.value = '新增子部门'
modalVisible.value = true
resetForm()
formData.parentId = parent.id
}
const editDepartment = (department) => {
modalTitle.value = '编辑部门'
modalVisible.value = true
Object.assign(formData, {
...department,
parentId: department.parentId || null
})
}
const deleteDepartment = (department) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除部门"${department.name}"吗?删除后该部门下的所有子部门也将被删除。`,
onOk() {
message.success('删除成功')
}
})
}
const viewEmployees = (department) => {
message.info(`查看部门"${department.name}"的员工信息`)
}
const managePerm = (department) => {
message.info(`管理部门"${department.name}"的权限`)
}
const handleSubmit = async () => {
try {
await formRef.value.validate()
message.success(modalTitle.value.includes('新增') ? '新增成功' : '编辑成功')
modalVisible.value = false
} catch (error) {
console.error('表单验证失败:', error)
}
}
const handleCancel = () => {
modalVisible.value = false
resetForm()
}
const resetForm = () => {
Object.assign(formData, {
name: '',
code: '',
parentId: null,
type: '',
manager: '',
phone: '',
status: 'active',
description: ''
})
}
// 生命周期
onMounted(() => {
// 默认展开第一级
expandedKeys.value = departmentTree.value.map(item => item.id)
})
</script>
<style lang="scss" scoped>
.department-management {
.stats-panel {
margin: 24px 0;
}
.search-area {
margin: 24px 0;
}
.department-tree {
margin: 24px 0;
.department-detail {
.sub-departments {
margin-top: 24px;
h4 {
margin-bottom: 16px;
font-weight: 600;
}
}
}
.empty-state {
display: flex;
justify-content: center;
align-items: center;
height: 300px;
}
}
:deep(.ant-tree-title) {
display: flex;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,664 @@
<template>
<div class="farmer-management">
<!-- 页面头部 -->
<PageHeader
title="养殖户管理"
description="管理养殖户信息,包括基本信息、养殖场信息、认证状态等"
>
<template #extra>
<a-button type="primary" @click="addFarmer">
<UserAddOutlined />
新增养殖户
</a-button>
</template>
</PageHeader>
<!-- 统计面板 -->
<div class="stats-panel">
<a-row :gutter="16">
<a-col :span="6">
<a-card>
<a-statistic
title="总养殖户数"
:value="stats.totalFarmers"
:value-style="{ color: '#1890ff' }"
>
<template #prefix>
<UserOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic
title="已认证"
:value="stats.certifiedFarmers"
:value-style="{ color: '#52c41a' }"
>
<template #prefix>
<SafetyCertificateOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic
title="待审核"
:value="stats.pendingFarmers"
:value-style="{ color: '#faad14' }"
>
<template #prefix>
<ClockCircleOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic
title="本月新增"
:value="stats.newFarmers"
:value-style="{ color: '#722ed1' }"
>
<template #prefix>
<PlusOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
</a-row>
</div>
<!-- 搜索区域 -->
<div class="search-area">
<a-card :bordered="false">
<a-form
ref="searchFormRef"
:model="searchForm"
layout="inline"
@finish="handleSearch"
>
<a-form-item label="养殖户姓名" name="name">
<a-input
v-model:value="searchForm.name"
placeholder="请输入养殖户姓名"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="联系电话" name="phone">
<a-input
v-model:value="searchForm.phone"
placeholder="请输入联系电话"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="认证状态" name="status">
<a-select
v-model:value="searchForm.status"
placeholder="请选择认证状态"
style="width: 150px"
allow-clear
>
<a-select-option value="certified">已认证</a-select-option>
<a-select-option value="pending">待审核</a-select-option>
<a-select-option value="rejected">已拒绝</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="所在地区" name="region">
<a-input
v-model:value="searchForm.region"
placeholder="请输入所在地区"
style="width: 200px"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<SearchOutlined />
搜索
</a-button>
<a-button style="margin-left: 8px" @click="resetSearch">
重置
</a-button>
</a-form-item>
</a-form>
</a-card>
</div>
<!-- 养殖户列表 -->
<div class="farmer-table">
<a-card :bordered="false">
<template #title>
<span>养殖户列表</span>
<a-tag color="blue" style="margin-left: 8px">
{{ pagination.total }} 条记录
</a-tag>
</template>
<template #extra>
<a-space>
<a-button @click="exportFarmers">
<ExportOutlined />
导出
</a-button>
<a-button @click="refreshData">
<ReloadOutlined />
刷新
</a-button>
</a-space>
</template>
<a-table
:columns="columns"
:data-source="farmerList"
:pagination="pagination"
:loading="loading"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'avatar'">
<a-avatar :src="record.avatar" :alt="record.name">
{{ record.name?.charAt(0) }}
</a-avatar>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusName(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="viewFarmer(record)">
查看
</a-button>
<a-button type="link" size="small" @click="editFarmer(record)">
编辑
</a-button>
<a-dropdown>
<template #overlay>
<a-menu>
<a-menu-item @click="certifyFarmer(record)">
<SafetyCertificateOutlined />
认证管理
</a-menu-item>
<a-menu-item @click="viewFarms(record)">
<HomeOutlined />
查看养殖场
</a-menu-item>
<a-menu-divider />
<a-menu-item @click="deleteFarmer(record)" danger>
<DeleteOutlined />
删除
</a-menu-item>
</a-menu>
</template>
<a-button type="link" size="small">
更多
<DownOutlined />
</a-button>
</a-dropdown>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
<!-- 新增/编辑养殖户弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
width="800px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
layout="vertical"
>
<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="phone">
<a-input v-model:value="formData.phone" placeholder="请输入联系电话" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="身份证号" name="idCard">
<a-input v-model:value="formData.idCard" placeholder="请输入身份证号" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="性别" name="gender">
<a-select v-model:value="formData.gender" placeholder="请选择性别">
<a-select-option value="male"></a-select-option>
<a-select-option value="female"></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="birthDate">
<a-date-picker
v-model:value="formData.birthDate"
placeholder="请选择出生日期"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="学历" name="education">
<a-select v-model:value="formData.education" placeholder="请选择学历">
<a-select-option value="primary">小学</a-select-option>
<a-select-option value="middle">初中</a-select-option>
<a-select-option value="high">高中</a-select-option>
<a-select-option value="college">大专</a-select-option>
<a-select-option value="bachelor">本科</a-select-option>
<a-select-option value="master">硕士</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="详细地址" name="address">
<a-textarea
v-model:value="formData.address"
placeholder="请输入详细地址"
:rows="2"
/>
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea
v-model:value="formData.remark"
placeholder="请输入备注信息"
:rows="3"
/>
</a-form-item>
</a-form>
</a-modal>
<!-- 养殖户详情弹窗 -->
<a-modal
v-model:open="detailModalVisible"
title="养殖户详情"
width="800px"
:footer="null"
>
<div v-if="currentFarmer" class="farmer-detail">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="姓名">
{{ currentFarmer.name }}
</a-descriptions-item>
<a-descriptions-item label="联系电话">
{{ currentFarmer.phone }}
</a-descriptions-item>
<a-descriptions-item label="身份证号">
{{ currentFarmer.idCard }}
</a-descriptions-item>
<a-descriptions-item label="性别">
{{ currentFarmer.gender === 'male' ? '男' : '女' }}
</a-descriptions-item>
<a-descriptions-item label="出生日期">
{{ currentFarmer.birthDate }}
</a-descriptions-item>
<a-descriptions-item label="学历">
{{ getEducationName(currentFarmer.education) }}
</a-descriptions-item>
<a-descriptions-item label="认证状态">
<a-tag :color="getStatusColor(currentFarmer.status)">
{{ getStatusName(currentFarmer.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="注册时间">
{{ currentFarmer.createdAt }}
</a-descriptions-item>
<a-descriptions-item label="详细地址" :span="2">
{{ currentFarmer.address }}
</a-descriptions-item>
<a-descriptions-item label="备注" :span="2">
{{ currentFarmer.remark || '暂无备注' }}
</a-descriptions-item>
</a-descriptions>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
UserAddOutlined,
UserOutlined,
SafetyCertificateOutlined,
ClockCircleOutlined,
PlusOutlined,
SearchOutlined,
ExportOutlined,
ReloadOutlined,
EditOutlined,
DeleteOutlined,
DownOutlined,
HomeOutlined
} from '@ant-design/icons-vue'
import PageHeader from '@/components/PageHeader.vue'
// 响应式数据
const loading = ref(false)
const modalVisible = ref(false)
const detailModalVisible = ref(false)
const modalTitle = ref('')
const currentFarmer = ref(null)
const searchFormRef = ref()
const formRef = ref()
// 统计数据
const stats = reactive({
totalFarmers: 1256,
certifiedFarmers: 1089,
pendingFarmers: 125,
newFarmers: 42
})
// 搜索表单
const searchForm = reactive({
name: '',
phone: '',
status: '',
region: ''
})
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => `${range[0]}-${range[1]} 条,共 ${total}`
})
// 表格列配置
const columns = [
{
title: '头像',
dataIndex: 'avatar',
key: 'avatar',
width: 80
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
width: 120
},
{
title: '联系电话',
dataIndex: 'phone',
key: 'phone',
width: 140
},
{
title: '身份证号',
dataIndex: 'idCard',
key: 'idCard',
width: 180
},
{
title: '所在地区',
dataIndex: 'region',
key: 'region',
width: 150
},
{
title: '认证状态',
dataIndex: 'status',
key: 'status',
width: 100
},
{
title: '注册时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180
},
{
title: '操作',
key: 'action',
width: 150,
fixed: 'right'
}
]
// 表单数据
const formData = reactive({
name: '',
phone: '',
idCard: '',
gender: '',
birthDate: null,
education: '',
address: '',
remark: ''
})
// 表单验证规则
const formRules = {
name: [{ required: true, message: '请输入养殖户姓名', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入联系电话', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
],
idCard: [
{ required: true, message: '请输入身份证号', trigger: 'blur' },
{ pattern: /^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/, message: '请输入正确的身份证号', trigger: 'blur' }
],
gender: [{ required: true, message: '请选择性别', trigger: 'change' }]
}
// 养殖户列表数据
const farmerList = ref([
{
id: 1,
name: '张三',
phone: '13800138001',
idCard: '110101199001011234',
gender: 'male',
birthDate: '1990-01-01',
education: 'high',
region: '银川市兴庆区',
address: '宁夏银川市兴庆区某某村123号',
status: 'certified',
createdAt: '2024-01-15 10:30:00',
remark: '优秀养殖户'
},
{
id: 2,
name: '李四',
phone: '13800138002',
idCard: '110101199002021234',
gender: 'male',
birthDate: '1990-02-02',
education: 'college',
region: '银川市西夏区',
address: '宁夏银川市西夏区某某镇456号',
status: 'pending',
createdAt: '2024-01-14 14:20:00',
remark: ''
}
])
// 方法
const getStatusColor = (status) => {
const colors = {
certified: '#52c41a',
pending: '#faad14',
rejected: '#f5222d'
}
return colors[status] || '#1890ff'
}
const getStatusName = (status) => {
const names = {
certified: '已认证',
pending: '待审核',
rejected: '已拒绝'
}
return names[status] || '未知状态'
}
const getEducationName = (education) => {
const names = {
primary: '小学',
middle: '初中',
high: '高中',
college: '大专',
bachelor: '本科',
master: '硕士'
}
return names[education] || '未知'
}
const handleSearch = () => {
pagination.current = 1
loadData()
}
const resetSearch = () => {
Object.assign(searchForm, {
name: '',
phone: '',
status: '',
region: ''
})
handleSearch()
}
const handleTableChange = (pag) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadData()
}
const loadData = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 500))
pagination.total = farmerList.value.length
} catch (error) {
message.error('加载数据失败')
} finally {
loading.value = false
}
}
const addFarmer = () => {
modalTitle.value = '新增养殖户'
modalVisible.value = true
resetForm()
}
const editFarmer = (farmer) => {
modalTitle.value = '编辑养殖户'
modalVisible.value = true
Object.assign(formData, farmer)
}
const viewFarmer = (farmer) => {
currentFarmer.value = farmer
detailModalVisible.value = true
}
const deleteFarmer = (farmer) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除养殖户"${farmer.name}"吗?`,
onOk() {
message.success('删除成功')
loadData()
}
})
}
const certifyFarmer = (farmer) => {
message.info(`管理养殖户"${farmer.name}"的认证状态`)
}
const viewFarms = (farmer) => {
message.info(`查看养殖户"${farmer.name}"的养殖场信息`)
}
const handleSubmit = async () => {
try {
await formRef.value.validate()
message.success(modalTitle.value === '新增养殖户' ? '新增成功' : '编辑成功')
modalVisible.value = false
loadData()
} catch (error) {
console.error('表单验证失败:', error)
}
}
const handleCancel = () => {
modalVisible.value = false
resetForm()
}
const resetForm = () => {
Object.assign(formData, {
name: '',
phone: '',
idCard: '',
gender: '',
birthDate: null,
education: '',
address: '',
remark: ''
})
}
const exportFarmers = () => {
message.success('导出成功')
}
const refreshData = () => {
loadData()
}
// 生命周期
onMounted(() => {
loadData()
})
</script>
<style lang="scss" scoped>
.farmer-management {
.stats-panel {
margin: 24px 0;
}
.search-area {
margin: 24px 0;
}
.farmer-table {
margin: 24px 0;
}
.farmer-detail {
.ant-descriptions {
margin-bottom: 16px;
}
}
}
</style>

View File

@@ -0,0 +1,845 @@
<template>
<div class="staff-management">
<!-- 页面头部 -->
<PageHeader
title="员工管理"
description="管理系统员工信息,包括基本信息、岗位配置、权限分配等"
>
<template #extra>
<a-space>
<a-button @click="importStaff">
<ImportOutlined />
批量导入
</a-button>
<a-button type="primary" @click="addStaff">
<UserAddOutlined />
新增员工
</a-button>
</a-space>
</template>
</PageHeader>
<!-- 统计面板 -->
<div class="stats-panel">
<a-row :gutter="16">
<a-col :span="6">
<a-card>
<a-statistic
title="总员工数"
:value="stats.totalStaff"
:value-style="{ color: '#1890ff' }"
>
<template #prefix>
<TeamOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic
title="在职员工"
:value="stats.activeStaff"
:value-style="{ color: '#52c41a' }"
>
<template #prefix>
<UserOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic
title="离职员工"
:value="stats.inactiveStaff"
:value-style="{ color: '#f5222d' }"
>
<template #prefix>
<UserDeleteOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card>
<a-statistic
title="本月入职"
:value="stats.newStaff"
:value-style="{ color: '#722ed1' }"
>
<template #prefix>
<PlusOutlined />
</template>
</a-statistic>
</a-card>
</a-col>
</a-row>
</div>
<!-- 搜索区域 -->
<div class="search-area">
<a-card :bordered="false">
<a-form
ref="searchFormRef"
:model="searchForm"
layout="inline"
@finish="handleSearch"
>
<a-form-item label="员工姓名" name="name">
<a-input
v-model:value="searchForm.name"
placeholder="请输入员工姓名"
style="width: 150px"
/>
</a-form-item>
<a-form-item label="工号" name="employeeId">
<a-input
v-model:value="searchForm.employeeId"
placeholder="请输入工号"
style="width: 150px"
/>
</a-form-item>
<a-form-item label="部门" name="department">
<a-select
v-model:value="searchForm.department"
placeholder="请选择部门"
style="width: 150px"
allow-clear
>
<a-select-option value="tech">技术部</a-select-option>
<a-select-option value="market">市场部</a-select-option>
<a-select-option value="hr">人事部</a-select-option>
<a-select-option value="finance">财务部</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="职位" name="position">
<a-input
v-model:value="searchForm.position"
placeholder="请输入职位"
style="width: 150px"
/>
</a-form-item>
<a-form-item label="状态" name="status">
<a-select
v-model:value="searchForm.status"
placeholder="请选择状态"
style="width: 120px"
allow-clear
>
<a-select-option value="active">在职</a-select-option>
<a-select-option value="inactive">离职</a-select-option>
<a-select-option value="probation">试用期</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<SearchOutlined />
搜索
</a-button>
<a-button style="margin-left: 8px" @click="resetSearch">
重置
</a-button>
</a-form-item>
</a-form>
</a-card>
</div>
<!-- 员工列表 -->
<div class="staff-table">
<a-card :bordered="false">
<template #title>
<span>员工列表</span>
<a-tag color="blue" style="margin-left: 8px">
{{ pagination.total }} 条记录
</a-tag>
</template>
<template #extra>
<a-space>
<a-button @click="exportStaff">
<ExportOutlined />
导出
</a-button>
<a-button @click="refreshData">
<ReloadOutlined />
刷新
</a-button>
</a-space>
</template>
<a-table
:columns="columns"
:data-source="staffList"
:pagination="pagination"
:loading="loading"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'avatar'">
<a-avatar :src="record.avatar" :alt="record.name">
{{ record.name?.charAt(0) }}
</a-avatar>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusName(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="viewStaff(record)">
查看
</a-button>
<a-button type="link" size="small" @click="editStaff(record)">
编辑
</a-button>
<a-dropdown>
<template #overlay>
<a-menu>
<a-menu-item @click="resetPassword(record)">
<KeyOutlined />
重置密码
</a-menu-item>
<a-menu-item @click="manageRole(record)">
<SafetyCertificateOutlined />
角色管理
</a-menu-item>
<a-menu-item @click="viewAttendance(record)">
<CalendarOutlined />
考勤记录
</a-menu-item>
<a-menu-divider />
<a-menu-item @click="resignStaff(record)" danger>
<UserDeleteOutlined />
离职处理
</a-menu-item>
</a-menu>
</template>
<a-button type="link" size="small">
更多
<DownOutlined />
</a-button>
</a-dropdown>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
<!-- 新增/编辑员工弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
width="800px"
@ok="handleSubmit"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
layout="vertical"
>
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="basic" tab="基本信息">
<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="employeeId">
<a-input v-model:value="formData.employeeId" 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="email">
<a-input v-model:value="formData.email" placeholder="请输入邮箱" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="性别" name="gender">
<a-select v-model:value="formData.gender" placeholder="请选择性别">
<a-select-option value="male"></a-select-option>
<a-select-option value="female"></a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="出生日期" name="birthDate">
<a-date-picker
v-model:value="formData.birthDate"
placeholder="请选择出生日期"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="身份证号" name="idCard">
<a-input v-model:value="formData.idCard" placeholder="请输入身份证号" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="学历" name="education">
<a-select v-model:value="formData.education" placeholder="请选择学历">
<a-select-option value="high">高中</a-select-option>
<a-select-option value="college">大专</a-select-option>
<a-select-option value="bachelor">本科</a-select-option>
<a-select-option value="master">硕士</a-select-option>
<a-select-option value="doctor">博士</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="家庭住址" name="address">
<a-textarea
v-model:value="formData.address"
placeholder="请输入家庭住址"
:rows="2"
/>
</a-form-item>
</a-tab-pane>
<a-tab-pane key="work" tab="工作信息">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="所属部门" name="department">
<a-select v-model:value="formData.department" placeholder="请选择部门">
<a-select-option value="tech">技术部</a-select-option>
<a-select-option value="market">市场部</a-select-option>
<a-select-option value="hr">人事部</a-select-option>
<a-select-option value="finance">财务部</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="职位" name="position">
<a-input v-model:value="formData.position" placeholder="请输入职位" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="入职日期" name="hireDate">
<a-date-picker
v-model:value="formData.hireDate"
placeholder="请选择入职日期"
style="width: 100%"
/>
</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="probation">试用期</a-select-option>
<a-select-option value="active">正式员工</a-select-option>
<a-select-option value="inactive">离职</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="supervisor">
<a-input v-model:value="formData.supervisor" placeholder="请输入直属上级" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="工作地点" name="workLocation">
<a-input v-model:value="formData.workLocation" placeholder="请输入工作地点" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="工作职责" name="jobDescription">
<a-textarea
v-model:value="formData.jobDescription"
placeholder="请输入工作职责"
:rows="3"
/>
</a-form-item>
</a-tab-pane>
</a-tabs>
</a-form>
</a-modal>
<!-- 员工详情弹窗 -->
<a-modal
v-model:open="detailModalVisible"
title="员工详情"
width="800px"
:footer="null"
>
<div v-if="currentStaff" class="staff-detail">
<a-tabs>
<a-tab-pane key="info" tab="基本信息">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="姓名">
{{ currentStaff.name }}
</a-descriptions-item>
<a-descriptions-item label="工号">
{{ currentStaff.employeeId }}
</a-descriptions-item>
<a-descriptions-item label="联系电话">
{{ currentStaff.phone }}
</a-descriptions-item>
<a-descriptions-item label="邮箱">
{{ currentStaff.email }}
</a-descriptions-item>
<a-descriptions-item label="性别">
{{ currentStaff.gender === 'male' ? '男' : '女' }}
</a-descriptions-item>
<a-descriptions-item label="出生日期">
{{ currentStaff.birthDate }}
</a-descriptions-item>
<a-descriptions-item label="身份证号">
{{ currentStaff.idCard }}
</a-descriptions-item>
<a-descriptions-item label="学历">
{{ getEducationName(currentStaff.education) }}
</a-descriptions-item>
<a-descriptions-item label="家庭住址" :span="2">
{{ currentStaff.address }}
</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
<a-tab-pane key="work" tab="工作信息">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="所属部门">
{{ getDepartmentName(currentStaff.department) }}
</a-descriptions-item>
<a-descriptions-item label="职位">
{{ currentStaff.position }}
</a-descriptions-item>
<a-descriptions-item label="入职日期">
{{ currentStaff.hireDate }}
</a-descriptions-item>
<a-descriptions-item label="员工状态">
<a-tag :color="getStatusColor(currentStaff.status)">
{{ getStatusName(currentStaff.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="直属上级">
{{ currentStaff.supervisor || '暂无' }}
</a-descriptions-item>
<a-descriptions-item label="工作地点">
{{ currentStaff.workLocation }}
</a-descriptions-item>
<a-descriptions-item label="工作职责" :span="2">
{{ currentStaff.jobDescription || '暂无' }}
</a-descriptions-item>
</a-descriptions>
</a-tab-pane>
</a-tabs>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
ImportOutlined,
UserAddOutlined,
TeamOutlined,
UserOutlined,
UserDeleteOutlined,
PlusOutlined,
SearchOutlined,
ExportOutlined,
ReloadOutlined,
EditOutlined,
DeleteOutlined,
DownOutlined,
KeyOutlined,
SafetyCertificateOutlined,
CalendarOutlined
} from '@ant-design/icons-vue'
import PageHeader from '@/components/PageHeader.vue'
// 响应式数据
const loading = ref(false)
const modalVisible = ref(false)
const detailModalVisible = ref(false)
const modalTitle = ref('')
const activeTab = ref('basic')
const currentStaff = ref(null)
const searchFormRef = ref()
const formRef = ref()
// 统计数据
const stats = reactive({
totalStaff: 156,
activeStaff: 142,
inactiveStaff: 14,
newStaff: 8
})
// 搜索表单
const searchForm = reactive({
name: '',
employeeId: '',
department: '',
position: '',
status: ''
})
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => `${range[0]}-${range[1]} 条,共 ${total}`
})
// 表格列配置
const columns = [
{
title: '头像',
dataIndex: 'avatar',
key: 'avatar',
width: 80
},
{
title: '姓名',
dataIndex: 'name',
key: 'name',
width: 100
},
{
title: '工号',
dataIndex: 'employeeId',
key: 'employeeId',
width: 120
},
{
title: '部门',
dataIndex: 'department',
key: 'department',
width: 120,
customRender: ({ text }) => getDepartmentName(text)
},
{
title: '职位',
dataIndex: 'position',
key: 'position',
width: 120
},
{
title: '联系电话',
dataIndex: 'phone',
key: 'phone',
width: 140
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100
},
{
title: '入职日期',
dataIndex: 'hireDate',
key: 'hireDate',
width: 120
},
{
title: '操作',
key: 'action',
width: 150,
fixed: 'right'
}
]
// 表单数据
const formData = reactive({
name: '',
employeeId: '',
phone: '',
email: '',
gender: '',
birthDate: null,
idCard: '',
education: '',
address: '',
department: '',
position: '',
hireDate: null,
status: 'probation',
supervisor: '',
workLocation: '',
jobDescription: ''
})
// 表单验证规则
const formRules = {
name: [{ required: true, message: '请输入员工姓名', trigger: 'blur' }],
employeeId: [{ required: true, message: '请输入工号', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入联系电话', trigger: 'blur' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
],
department: [{ required: true, message: '请选择部门', trigger: 'change' }],
position: [{ required: true, message: '请输入职位', trigger: 'blur' }]
}
// 员工列表数据
const staffList = ref([
{
id: 1,
name: '张三',
employeeId: 'EMP001',
phone: '13800138001',
email: 'zhangsan@example.com',
gender: 'male',
birthDate: '1990-01-01',
idCard: '110101199001011234',
education: 'bachelor',
address: '北京市朝阳区某某街道123号',
department: 'tech',
position: '前端开发工程师',
hireDate: '2023-01-15',
status: 'active',
supervisor: '李经理',
workLocation: '北京总部',
jobDescription: '负责前端页面开发和维护'
},
{
id: 2,
name: '李四',
employeeId: 'EMP002',
phone: '13800138002',
email: 'lisi@example.com',
gender: 'male',
birthDate: '1988-05-20',
idCard: '110101198805201234',
education: 'master',
address: '北京市海淀区某某路456号',
department: 'tech',
position: '后端开发工程师',
hireDate: '2022-08-10',
status: 'active',
supervisor: '王总监',
workLocation: '北京总部',
jobDescription: '负责后端接口开发和数据库设计'
}
])
// 方法
const getStatusColor = (status) => {
const colors = {
active: '#52c41a',
probation: '#faad14',
inactive: '#f5222d'
}
return colors[status] || '#1890ff'
}
const getStatusName = (status) => {
const names = {
active: '在职',
probation: '试用期',
inactive: '离职'
}
return names[status] || '未知状态'
}
const getDepartmentName = (department) => {
const names = {
tech: '技术部',
market: '市场部',
hr: '人事部',
finance: '财务部'
}
return names[department] || '未知部门'
}
const getEducationName = (education) => {
const names = {
high: '高中',
college: '大专',
bachelor: '本科',
master: '硕士',
doctor: '博士'
}
return names[education] || '未知'
}
const handleSearch = () => {
pagination.current = 1
loadData()
}
const resetSearch = () => {
Object.assign(searchForm, {
name: '',
employeeId: '',
department: '',
position: '',
status: ''
})
handleSearch()
}
const handleTableChange = (pag) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadData()
}
const loadData = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 500))
pagination.total = staffList.value.length
} catch (error) {
message.error('加载数据失败')
} finally {
loading.value = false
}
}
const addStaff = () => {
modalTitle.value = '新增员工'
modalVisible.value = true
activeTab.value = 'basic'
resetForm()
}
const editStaff = (staff) => {
modalTitle.value = '编辑员工'
modalVisible.value = true
activeTab.value = 'basic'
Object.assign(formData, staff)
}
const viewStaff = (staff) => {
currentStaff.value = staff
detailModalVisible.value = true
}
const resignStaff = (staff) => {
Modal.confirm({
title: '确认离职',
content: `确定要处理员工"${staff.name}"的离职手续吗?`,
onOk() {
message.success('离职处理成功')
loadData()
}
})
}
const resetPassword = (staff) => {
Modal.confirm({
title: '确认重置密码',
content: `确定要重置员工"${staff.name}"的登录密码吗?`,
onOk() {
message.success('密码重置成功,新密码已发送至员工邮箱')
}
})
}
const manageRole = (staff) => {
message.info(`管理员工"${staff.name}"的角色权限`)
}
const viewAttendance = (staff) => {
message.info(`查看员工"${staff.name}"的考勤记录`)
}
const handleSubmit = async () => {
try {
await formRef.value.validate()
message.success(modalTitle.value === '新增员工' ? '新增成功' : '编辑成功')
modalVisible.value = false
loadData()
} catch (error) {
console.error('表单验证失败:', error)
}
}
const handleCancel = () => {
modalVisible.value = false
resetForm()
}
const resetForm = () => {
Object.assign(formData, {
name: '',
employeeId: '',
phone: '',
email: '',
gender: '',
birthDate: null,
idCard: '',
education: '',
address: '',
department: '',
position: '',
hireDate: null,
status: 'probation',
supervisor: '',
workLocation: '',
jobDescription: ''
})
}
const importStaff = () => {
message.info('批量导入功能开发中')
}
const exportStaff = () => {
message.success('导出成功')
}
const refreshData = () => {
loadData()
}
// 生命周期
onMounted(() => {
loadData()
})
</script>
<style lang="scss" scoped>
.staff-management {
.stats-panel {
margin: 24px 0;
}
.search-area {
margin: 24px 0;
}
.staff-table {
margin: 24px 0;
}
.staff-detail {
.ant-descriptions {
margin-bottom: 16px;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,456 @@
<template>
<div class="report-list">
<!-- 页面头部 -->
<PageHeader
title="报告列表"
description="查看和管理所有监管报告"
>
<template #extra>
<a-button type="primary" @click="generateReport">
<FileAddOutlined />
生成报告
</a-button>
</template>
</PageHeader>
<!-- 搜索区域 -->
<div class="search-area">
<a-card :bordered="false">
<a-form
ref="searchFormRef"
:model="searchForm"
layout="inline"
@finish="handleSearch"
>
<a-form-item label="报告类型" name="type">
<a-select
v-model:value="searchForm.type"
placeholder="请选择报告类型"
style="width: 150px"
allow-clear
>
<a-select-option value="quarantine">检疫报告</a-select-option>
<a-select-option value="slaughter">屠宰报告</a-select-option>
<a-select-option value="harmless">无害化报告</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="报告标题" name="title">
<a-input
v-model:value="searchForm.title"
placeholder="请输入报告标题"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="生成时间" name="dateRange">
<a-range-picker
v-model:value="searchForm.dateRange"
style="width: 240px"
/>
</a-form-item>
<a-form-item label="生成人" name="creator">
<a-input
v-model:value="searchForm.creator"
placeholder="请输入生成人"
style="width: 150px"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<SearchOutlined />
搜索
</a-button>
<a-button style="margin-left: 8px" @click="resetSearch">
重置
</a-button>
</a-form-item>
</a-form>
</a-card>
</div>
<!-- 报告列表 -->
<div class="report-table">
<a-card :bordered="false">
<template #title>
<span>报告列表</span>
<a-tag color="blue" style="margin-left: 8px">
{{ pagination.total }} 条记录
</a-tag>
</template>
<template #extra>
<a-space>
<a-button @click="exportReports">
<ExportOutlined />
导出
</a-button>
<a-button @click="refreshData">
<ReloadOutlined />
刷新
</a-button>
</a-space>
</template>
<a-table
:columns="columns"
:data-source="reportList"
:pagination="pagination"
:loading="loading"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag :color="getReportTypeColor(record.type)">
{{ getReportTypeName(record.type) }}
</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusName(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="viewReport(record)">
查看
</a-button>
<a-button type="link" size="small" @click="downloadReport(record)">
下载
</a-button>
<a-dropdown>
<template #overlay>
<a-menu>
<a-menu-item @click="editReport(record)">
<EditOutlined />
编辑
</a-menu-item>
<a-menu-item @click="shareReport(record)">
<ShareAltOutlined />
分享
</a-menu-item>
<a-menu-divider />
<a-menu-item @click="deleteReport(record)" danger>
<DeleteOutlined />
删除
</a-menu-item>
</a-menu>
</template>
<a-button type="link" size="small">
更多
<DownOutlined />
</a-button>
</a-dropdown>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
<!-- 报告详情弹窗 -->
<a-modal
v-model:open="detailModalVisible"
title="报告详情"
width="800px"
:footer="null"
>
<div v-if="currentReport" class="report-detail">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="报告标题">
{{ currentReport.title }}
</a-descriptions-item>
<a-descriptions-item label="报告类型">
<a-tag :color="getReportTypeColor(currentReport.type)">
{{ getReportTypeName(currentReport.type) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="生成时间">
{{ currentReport.createdAt }}
</a-descriptions-item>
<a-descriptions-item label="生成人">
{{ currentReport.creator }}
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="getStatusColor(currentReport.status)">
{{ getStatusName(currentReport.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="文件大小">
{{ currentReport.fileSize }}
</a-descriptions-item>
<a-descriptions-item label="报告描述" :span="2">
{{ currentReport.description || '暂无描述' }}
</a-descriptions-item>
</a-descriptions>
<div class="report-actions" style="margin-top: 24px; text-align: center;">
<a-space>
<a-button type="primary" @click="downloadReport(currentReport)">
<DownloadOutlined />
下载报告
</a-button>
<a-button @click="shareReport(currentReport)">
<ShareAltOutlined />
分享报告
</a-button>
</a-space>
</div>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
FileAddOutlined,
SearchOutlined,
ExportOutlined,
ReloadOutlined,
EditOutlined,
ShareAltOutlined,
DeleteOutlined,
DownOutlined,
DownloadOutlined
} from '@ant-design/icons-vue'
import PageHeader from '@/components/PageHeader.vue'
// 响应式数据
const loading = ref(false)
const detailModalVisible = ref(false)
const currentReport = ref(null)
const searchFormRef = ref()
// 搜索表单
const searchForm = reactive({
type: '',
title: '',
dateRange: [],
creator: ''
})
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => `${range[0]}-${range[1]} 条,共 ${total}`
})
// 表格列配置
const columns = [
{
title: '报告标题',
dataIndex: 'title',
key: 'title',
ellipsis: true
},
{
title: '报告类型',
dataIndex: 'type',
key: 'type',
width: 120
},
{
title: '生成时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180
},
{
title: '生成人',
dataIndex: 'creator',
key: 'creator',
width: 100
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100
},
{
title: '文件大小',
dataIndex: 'fileSize',
key: 'fileSize',
width: 100
},
{
title: '操作',
key: 'action',
width: 150,
fixed: 'right'
}
]
// 报告列表数据
const reportList = ref([
{
id: 1,
title: '2024年1月检疫报告',
type: 'quarantine',
createdAt: '2024-01-15 10:30:00',
creator: '张三',
status: 'completed',
fileSize: '2.5MB',
description: '2024年1月份动物检疫工作总结报告'
},
{
id: 2,
title: '屠宰场监管月报',
type: 'slaughter',
createdAt: '2024-01-14 14:20:00',
creator: '李四',
status: 'completed',
fileSize: '1.8MB',
description: '屠宰场日常监管工作月度报告'
},
{
id: 3,
title: '无害化处理统计报告',
type: 'harmless',
createdAt: '2024-01-13 09:15:00',
creator: '王五',
status: 'generating',
fileSize: '3.2MB',
description: '病死动物无害化处理统计分析报告'
}
])
// 方法
const getReportTypeColor = (type) => {
const colors = {
quarantine: '#52c41a',
slaughter: '#faad14',
harmless: '#f5222d'
}
return colors[type] || '#1890ff'
}
const getReportTypeName = (type) => {
const names = {
quarantine: '检疫报告',
slaughter: '屠宰报告',
harmless: '无害化报告'
}
return names[type] || '未知类型'
}
const getStatusColor = (status) => {
const colors = {
completed: '#52c41a',
generating: '#faad14',
failed: '#f5222d'
}
return colors[status] || '#1890ff'
}
const getStatusName = (status) => {
const names = {
completed: '已完成',
generating: '生成中',
failed: '生成失败'
}
return names[status] || '未知状态'
}
const handleSearch = () => {
pagination.current = 1
loadData()
}
const resetSearch = () => {
Object.assign(searchForm, {
type: '',
title: '',
dateRange: [],
creator: ''
})
handleSearch()
}
const handleTableChange = (pag) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadData()
}
const loadData = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 500))
pagination.total = reportList.value.length
} catch (error) {
message.error('加载数据失败')
} finally {
loading.value = false
}
}
const generateReport = () => {
message.info('跳转到报告生成页面')
}
const viewReport = (report) => {
currentReport.value = report
detailModalVisible.value = true
}
const downloadReport = (report) => {
message.success(`下载报告:${report.title}`)
}
const editReport = (report) => {
message.info(`编辑报告:${report.title}`)
}
const shareReport = (report) => {
message.success(`分享报告:${report.title}`)
}
const deleteReport = (report) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除报告"${report.title}"吗?`,
onOk() {
message.success('删除成功')
loadData()
}
})
}
const exportReports = () => {
message.success('导出成功')
}
const refreshData = () => {
loadData()
}
// 生命周期
onMounted(() => {
loadData()
})
</script>
<style lang="scss" scoped>
.report-list {
.search-area {
margin: 24px 0;
}
.report-table {
margin: 24px 0;
}
.report-detail {
.report-actions {
border-top: 1px solid #f0f0f0;
padding-top: 16px;
}
}
}
</style>

View File

@@ -0,0 +1,659 @@
<template>
<div class="cattle-education">
<PageHeader title="牛只教育服务" />
<div class="content-wrapper">
<!-- 课程分类 -->
<a-row :gutter="16" class="category-section">
<a-col :span="6" v-for="category in categories" :key="category.id">
<a-card
hoverable
class="category-card"
@click="selectCategory(category)"
:class="{ active: selectedCategory?.id === category.id }"
>
<template #cover>
<div class="category-icon">
<component :is="category.icon" style="font-size: 32px; color: #1890ff;" />
</div>
</template>
<a-card-meta :title="category.name" :description="category.description" />
<div class="category-stats">
<a-statistic :value="category.courseCount" suffix="门课程" />
</div>
</a-card>
</a-col>
</a-row>
<!-- 课程列表 -->
<a-card class="course-section">
<template #title>
<div class="section-title">
<span>{{ selectedCategory?.name || '全部课程' }}</span>
<a-tag color="blue">{{ filteredCourses.length }} 门课程</a-tag>
</div>
</template>
<template #extra>
<a-space>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索课程"
style="width: 200px"
@search="handleSearch"
/>
<a-select v-model:value="sortBy" style="width: 120px" @change="handleSort">
<a-select-option value="latest">最新</a-select-option>
<a-select-option value="popular">最热门</a-select-option>
<a-select-option value="rating">评分最高</a-select-option>
</a-select>
</a-space>
</template>
<a-row :gutter="[16, 16]">
<a-col :span="8" v-for="course in paginatedCourses" :key="course.id">
<a-card hoverable class="course-card">
<template #cover>
<div class="course-cover">
<img :src="course.cover" :alt="course.title" />
<div class="course-duration">{{ course.duration }}</div>
</div>
</template>
<a-card-meta :title="course.title" :description="course.description" />
<div class="course-info">
<div class="course-stats">
<a-space>
<span><UserOutlined /> {{ course.students }}</span>
<span><StarOutlined /> {{ course.rating }}</span>
<span><ClockCircleOutlined /> {{ course.duration }}</span>
</a-space>
</div>
<div class="course-level">
<a-tag :color="getLevelColor(course.level)">{{ course.level }}</a-tag>
</div>
</div>
<div class="course-actions">
<a-button type="primary" block @click="startCourse(course)">
开始学习
</a-button>
</div>
</a-card>
</a-col>
</a-row>
<!-- 分页 -->
<div class="pagination-wrapper">
<a-pagination
v-model:current="pagination.current"
v-model:page-size="pagination.pageSize"
:total="filteredCourses.length"
:show-size-changer="true"
:show-quick-jumper="true"
:show-total="(total) => `${total} 门课程`"
@change="handlePageChange"
/>
</div>
</a-card>
<!-- 学习进度 -->
<a-card title="我的学习进度" class="progress-section">
<a-row :gutter="16">
<a-col :span="8" v-for="progress in learningProgress" :key="progress.courseId">
<div class="progress-item">
<div class="progress-header">
<h4>{{ progress.courseTitle }}</h4>
<span class="progress-percent">{{ progress.progress }}%</span>
</div>
<a-progress :percent="progress.progress" :status="getProgressStatus(progress.progress)" />
<div class="progress-info">
<span>已学习 {{ progress.completedLessons }}/{{ progress.totalLessons }} 课时</span>
<a-button type="link" size="small" @click="continueLearning(progress)">
继续学习
</a-button>
</div>
</div>
</a-col>
</a-row>
</a-card>
</div>
<!-- 课程详情弹窗 -->
<a-modal
v-model:open="courseModalVisible"
:title="selectedCourse?.title"
width="800px"
:footer="null"
>
<div v-if="selectedCourse" class="course-detail">
<div class="course-header">
<img :src="selectedCourse.cover" :alt="selectedCourse.title" class="course-image" />
<div class="course-meta">
<h3>{{ selectedCourse.title }}</h3>
<p>{{ selectedCourse.description }}</p>
<div class="course-tags">
<a-tag v-for="tag in selectedCourse.tags" :key="tag" color="blue">{{ tag }}</a-tag>
</div>
<div class="course-stats-detail">
<a-space size="large">
<span><UserOutlined /> {{ selectedCourse.students }} 学员</span>
<span><StarOutlined /> {{ selectedCourse.rating }} 评分</span>
<span><ClockCircleOutlined /> {{ selectedCourse.duration }}</span>
<span><BookOutlined /> {{ selectedCourse.lessons }} 课时</span>
</a-space>
</div>
</div>
</div>
<a-tabs>
<a-tab-pane key="outline" tab="课程大纲">
<div class="course-outline">
<div v-for="(chapter, index) in selectedCourse.outline" :key="index" class="chapter">
<h4>第{{ index + 1 }}章 {{ chapter.title }}</h4>
<ul>
<li v-for="lesson in chapter.lessons" :key="lesson.id">
<PlayCircleOutlined />
{{ lesson.title }}
<span class="lesson-duration">{{ lesson.duration }}</span>
</li>
</ul>
</div>
</div>
</a-tab-pane>
<a-tab-pane key="reviews" tab="学员评价">
<div class="course-reviews">
<div v-for="review in selectedCourse.reviews" :key="review.id" class="review-item">
<div class="review-header">
<a-avatar :src="review.avatar">{{ review.username.charAt(0) }}</a-avatar>
<div class="review-meta">
<span class="username">{{ review.username }}</span>
<a-rate :value="review.rating" disabled />
</div>
<span class="review-date">{{ review.date }}</span>
</div>
<p class="review-content">{{ review.content }}</p>
</div>
</div>
</a-tab-pane>
</a-tabs>
<div class="course-actions-detail">
<a-button type="primary" size="large" @click="startCourse(selectedCourse)">
开始学习
</a-button>
<a-button size="large" @click="addToFavorites(selectedCourse)">
<HeartOutlined /> 收藏
</a-button>
</div>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
BookOutlined,
ExperimentOutlined,
MedicineBoxOutlined,
EnvironmentOutlined,
UserOutlined,
StarOutlined,
ClockCircleOutlined,
PlayCircleOutlined,
HeartOutlined
} from '@ant-design/icons-vue'
import PageHeader from '@/components/common/PageHeader.vue'
// 响应式数据
const selectedCategory = ref(null)
const searchKeyword = ref('')
const sortBy = ref('latest')
const courseModalVisible = ref(false)
const selectedCourse = ref(null)
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 9
})
// 课程分类
const categories = ref([
{
id: 1,
name: '基础知识',
description: '牛只基本生理知识',
icon: BookOutlined,
courseCount: 15
},
{
id: 2,
name: '疾病防治',
description: '常见疾病预防与治疗',
icon: MedicineBoxOutlined,
courseCount: 12
},
{
id: 3,
name: '营养管理',
description: '饲料配比与营养搭配',
icon: ExperimentOutlined,
courseCount: 8
},
{
id: 4,
name: '环境管理',
description: '养殖环境优化管理',
icon: EnvironmentOutlined,
courseCount: 10
}
])
// 课程数据
const courses = ref([
{
id: 1,
title: '牛只基础生理学',
description: '了解牛只的基本生理结构和功能',
cover: '/images/course1.jpg',
categoryId: 1,
level: '初级',
duration: '2小时',
students: 1234,
rating: 4.8,
lessons: 12,
tags: ['基础', '生理学', '入门'],
outline: [
{
title: '牛只解剖结构',
lessons: [
{ id: 1, title: '消化系统', duration: '15分钟' },
{ id: 2, title: '呼吸系统', duration: '12分钟' }
]
}
],
reviews: [
{
id: 1,
username: '张三',
avatar: '',
rating: 5,
date: '2024-01-15',
content: '课程内容很详细,讲解清晰易懂。'
}
]
},
{
id: 2,
title: '常见疾病预防',
description: '学习牛只常见疾病的预防措施',
cover: '/images/course2.jpg',
categoryId: 2,
level: '中级',
duration: '3小时',
students: 856,
rating: 4.6,
lessons: 18,
tags: ['疾病', '预防', '健康'],
outline: [
{
title: '传染病预防',
lessons: [
{ id: 1, title: '口蹄疫预防', duration: '20分钟' },
{ id: 2, title: '结核病防控', duration: '18分钟' }
]
}
],
reviews: [
{
id: 1,
username: '李四',
avatar: '',
rating: 4,
date: '2024-01-10',
content: '实用性很强,对实际工作帮助很大。'
}
]
}
])
// 学习进度
const learningProgress = ref([
{
courseId: 1,
courseTitle: '牛只基础生理学',
progress: 75,
completedLessons: 9,
totalLessons: 12
},
{
courseId: 2,
courseTitle: '常见疾病预防',
progress: 30,
completedLessons: 5,
totalLessons: 18
}
])
// 计算属性
const filteredCourses = computed(() => {
let result = courses.value
// 按分类筛选
if (selectedCategory.value) {
result = result.filter(course => course.categoryId === selectedCategory.value.id)
}
// 按关键词搜索
if (searchKeyword.value) {
result = result.filter(course =>
course.title.includes(searchKeyword.value) ||
course.description.includes(searchKeyword.value)
)
}
// 排序
switch (sortBy.value) {
case 'popular':
result.sort((a, b) => b.students - a.students)
break
case 'rating':
result.sort((a, b) => b.rating - a.rating)
break
case 'latest':
default:
// 默认按最新排序
break
}
return result
})
const paginatedCourses = computed(() => {
const start = (pagination.current - 1) * pagination.pageSize
const end = start + pagination.pageSize
return filteredCourses.value.slice(start, end)
})
// 方法
const selectCategory = (category) => {
selectedCategory.value = selectedCategory.value?.id === category.id ? null : category
pagination.current = 1
}
const handleSearch = () => {
pagination.current = 1
}
const handleSort = () => {
pagination.current = 1
}
const handlePageChange = () => {
// 分页变化时的处理
}
const getLevelColor = (level) => {
const colorMap = {
'初级': 'green',
'中级': 'orange',
'高级': 'red'
}
return colorMap[level] || 'default'
}
const getProgressStatus = (progress) => {
if (progress === 100) return 'success'
if (progress >= 50) return 'active'
return 'normal'
}
const startCourse = (course) => {
message.success(`开始学习:${course.title}`)
courseModalVisible.value = false
// 这里可以跳转到学习页面
}
const continueLearning = (progress) => {
message.success(`继续学习:${progress.courseTitle}`)
// 这里可以跳转到学习页面的具体位置
}
const addToFavorites = (course) => {
message.success(`已收藏:${course.title}`)
}
// 组件挂载
onMounted(() => {
// 初始化数据
})
</script>
<style lang="scss" scoped>
.cattle-education {
.content-wrapper {
margin-top: 16px;
}
.category-section {
margin-bottom: 24px;
.category-card {
text-align: center;
transition: all 0.3s;
&:hover, &.active {
border-color: #1890ff;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.15);
}
.category-icon {
padding: 20px;
background: #f5f5f5;
}
.category-stats {
margin-top: 12px;
}
}
}
.course-section {
margin-bottom: 24px;
.section-title {
display: flex;
align-items: center;
gap: 8px;
}
.course-card {
height: 100%;
.course-cover {
position: relative;
height: 160px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.course-duration {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
}
.course-info {
margin: 12px 0;
display: flex;
justify-content: space-between;
align-items: center;
.course-stats {
font-size: 12px;
color: #666;
}
}
.course-actions {
margin-top: 12px;
}
}
.pagination-wrapper {
margin-top: 24px;
text-align: center;
}
}
.progress-section {
.progress-item {
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
h4 {
margin: 0;
font-size: 14px;
}
.progress-percent {
font-weight: bold;
color: #1890ff;
}
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
font-size: 12px;
color: #666;
}
}
}
.course-detail {
.course-header {
display: flex;
gap: 16px;
margin-bottom: 24px;
.course-image {
width: 200px;
height: 120px;
object-fit: cover;
border-radius: 8px;
}
.course-meta {
flex: 1;
h3 {
margin: 0 0 8px 0;
}
.course-tags {
margin: 12px 0;
}
.course-stats-detail {
color: #666;
font-size: 14px;
}
}
}
.course-outline {
.chapter {
margin-bottom: 24px;
h4 {
color: #1890ff;
margin-bottom: 12px;
}
ul {
list-style: none;
padding: 0;
li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
.lesson-duration {
color: #666;
font-size: 12px;
}
}
}
}
}
.course-reviews {
.review-item {
margin-bottom: 16px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
.review-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
.review-meta {
flex: 1;
.username {
font-weight: 500;
margin-right: 8px;
}
}
.review-date {
color: #666;
font-size: 12px;
}
}
.review-content {
margin: 0;
color: #666;
}
}
}
.course-actions-detail {
margin-top: 24px;
text-align: center;
.ant-btn {
margin: 0 8px;
}
}
}
}
</style>

View File

@@ -0,0 +1,869 @@
<template>
<div class="community-management">
<PageHeader title="社区管理服务" />
<div class="content-wrapper">
<a-row :gutter="16">
<!-- 左侧社区列表 -->
<a-col :span="6">
<a-card title="社区列表" class="community-list-card">
<template #extra>
<a-button type="primary" size="small" @click="showCreateCommunity">
<PlusOutlined /> 创建社区
</a-button>
</template>
<div class="community-search">
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索社区"
@search="handleSearch"
/>
</div>
<div class="community-list">
<div
v-for="community in filteredCommunities"
:key="community.id"
class="community-item"
:class="{ active: selectedCommunity?.id === community.id }"
@click="selectCommunity(community)"
>
<div class="community-avatar">
<a-avatar :src="community.avatar" :size="40">
{{ community.name.charAt(0) }}
</a-avatar>
</div>
<div class="community-info">
<div class="community-name">{{ community.name }}</div>
<div class="community-desc">{{ community.description }}</div>
<div class="community-stats">
<span>{{ community.memberCount }} 成员</span>
<span>{{ community.postCount }} 帖子</span>
</div>
</div>
<div class="community-status">
<a-badge
:status="community.status === 'active' ? 'success' : 'default'"
:text="community.status === 'active' ? '活跃' : '普通'"
/>
</div>
</div>
</div>
</a-card>
</a-col>
<!-- 中间内容区域 -->
<a-col :span="12">
<a-card v-if="!selectedCommunity" class="empty-content">
<a-empty description="请选择社区查看详情" />
</a-card>
<div v-else class="community-content">
<!-- 社区信息 -->
<a-card class="community-info-card">
<template #title>
<div class="community-header">
<a-avatar :src="selectedCommunity.avatar" :size="50">
{{ selectedCommunity.name.charAt(0) }}
</a-avatar>
<div class="community-meta">
<h3>{{ selectedCommunity.name }}</h3>
<p>{{ selectedCommunity.description }}</p>
<div class="community-tags">
<a-tag v-for="tag in selectedCommunity.tags" :key="tag" color="blue">
{{ tag }}
</a-tag>
</div>
</div>
</div>
</template>
<template #extra>
<a-space>
<a-button @click="editCommunity(selectedCommunity)">
<EditOutlined /> 编辑
</a-button>
<a-dropdown>
<a-button>
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="manageMember">成员管理</a-menu-item>
<a-menu-item @click="communitySettings">社区设置</a-menu-item>
<a-menu-divider />
<a-menu-item danger @click="deleteCommunity">删除社区</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</template>
<a-row :gutter="16">
<a-col :span="6">
<a-statistic title="成员数量" :value="selectedCommunity.memberCount" />
</a-col>
<a-col :span="6">
<a-statistic title="帖子数量" :value="selectedCommunity.postCount" />
</a-col>
<a-col :span="6">
<a-statistic title="今日活跃" :value="selectedCommunity.todayActive" />
</a-col>
<a-col :span="6">
<a-statistic title="创建时间" :value="formatDate(selectedCommunity.createTime)" />
</a-col>
</a-row>
</a-card>
<!-- 帖子管理 -->
<a-card title="帖子管理" class="posts-card">
<template #extra>
<a-space>
<a-select v-model:value="postFilter" style="width: 120px">
<a-select-option value="all">全部帖子</a-select-option>
<a-select-option value="pending">待审核</a-select-option>
<a-select-option value="reported">被举报</a-select-option>
</a-select>
<a-button type="primary" @click="createPost">
<PlusOutlined /> 发布公告
</a-button>
</a-space>
</template>
<a-list
:data-source="filteredPosts"
:pagination="postPagination"
>
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<a @click="viewPost(item)">查看</a>
<a @click="editPost(item)">编辑</a>
<a-dropdown>
<a>更多</a>
<template #overlay>
<a-menu>
<a-menu-item @click="pinPost(item)">
{{ item.pinned ? '取消置顶' : '置顶' }}
</a-menu-item>
<a-menu-item @click="lockPost(item)">
{{ item.locked ? '解锁' : '锁定' }}
</a-menu-item>
<a-menu-divider />
<a-menu-item danger @click="deletePost(item)">删除</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<a-list-item-meta>
<template #avatar>
<a-avatar :src="item.author.avatar">
{{ item.author.name.charAt(0) }}
</a-avatar>
</template>
<template #title>
<div class="post-title">
<span>{{ item.title }}</span>
<a-space class="post-badges">
<a-tag v-if="item.pinned" color="red">置顶</a-tag>
<a-tag v-if="item.locked" color="orange">锁定</a-tag>
<a-tag v-if="item.status === 'pending'" color="yellow">待审核</a-tag>
</a-space>
</div>
</template>
<template #description>
<div class="post-meta">
<span>{{ item.author.name }}</span>
<span>{{ formatTime(item.createTime) }}</span>
<span>{{ item.viewCount }} 浏览</span>
<span>{{ item.replyCount }} 回复</span>
</div>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</div>
</a-col>
<!-- 右侧管理面板 -->
<a-col :span="6">
<a-card title="管理工具" class="management-tools">
<a-space direction="vertical" style="width: 100%;">
<a-button block @click="showMemberManagement">
<UserOutlined /> 成员管理
</a-button>
<a-button block @click="showContentModeration">
<SafetyOutlined /> 内容审核
</a-button>
<a-button block @click="showReportManagement">
<ExclamationCircleOutlined /> 举报处理
</a-button>
<a-button block @click="showAnalytics">
<BarChartOutlined /> 数据分析
</a-button>
</a-space>
</a-card>
<a-card title="最新动态" class="recent-activities">
<a-timeline>
<a-timeline-item
v-for="activity in recentActivities"
:key="activity.id"
:color="getActivityColor(activity.type)"
>
<div class="activity-content">
<div class="activity-text">{{ activity.content }}</div>
<div class="activity-time">{{ formatTime(activity.time) }}</div>
</div>
</a-timeline-item>
</a-timeline>
</a-card>
<a-card title="社区统计" class="community-stats">
<div class="stats-chart">
<div ref="memberChart" style="height: 200px;"></div>
</div>
</a-card>
</a-col>
</a-row>
</div>
<!-- 创建/编辑社区弹窗 -->
<a-modal
v-model:open="communityModalVisible"
:title="editingCommunity ? '编辑社区' : '创建社区'"
@ok="handleCommunitySubmit"
@cancel="handleCommunityCancel"
>
<a-form
ref="communityFormRef"
:model="communityForm"
:rules="communityRules"
layout="vertical"
>
<a-form-item label="社区名称" name="name">
<a-input v-model:value="communityForm.name" placeholder="请输入社区名称" />
</a-form-item>
<a-form-item label="社区描述" name="description">
<a-textarea v-model:value="communityForm.description" :rows="3" placeholder="请输入社区描述" />
</a-form-item>
<a-form-item label="社区标签" name="tags">
<a-select
v-model:value="communityForm.tags"
mode="tags"
placeholder="请选择或输入标签"
:options="tagOptions"
/>
</a-form-item>
<a-form-item label="社区类型" name="type">
<a-radio-group v-model:value="communityForm.type">
<a-radio value="public">公开社区</a-radio>
<a-radio value="private">私密社区</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
<!-- 成员管理弹窗 -->
<a-modal
v-model:open="memberModalVisible"
title="成员管理"
width="800px"
:footer="null"
>
<div class="member-management">
<div class="member-search">
<a-input-search
v-model:value="memberSearchKeyword"
placeholder="搜索成员"
style="width: 300px;"
/>
</div>
<a-table
:columns="memberColumns"
:data-source="communityMembers"
:pagination="memberPagination"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'avatar'">
<a-avatar :src="record.avatar">{{ record.name.charAt(0) }}</a-avatar>
</template>
<template v-else-if="column.key === 'role'">
<a-tag :color="getRoleColor(record.role)">{{ getRoleText(record.role) }}</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-badge
:status="record.status === 'active' ? 'success' : 'default'"
:text="record.status === 'active' ? '正常' : '禁用'"
/>
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a @click="changeMemberRole(record)">角色</a>
<a @click="toggleMemberStatus(record)">
{{ record.status === 'active' ? '禁用' : '启用' }}
</a>
<a @click="removeMember(record)" style="color: red;">移除</a>
</a-space>
</template>
</template>
</a-table>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import * as echarts from 'echarts'
import {
PlusOutlined,
EditOutlined,
MoreOutlined,
UserOutlined,
SafetyOutlined,
ExclamationCircleOutlined,
BarChartOutlined
} from '@ant-design/icons-vue'
import PageHeader from '@/components/common/PageHeader.vue'
// 响应式数据
const selectedCommunity = ref(null)
const searchKeyword = ref('')
const postFilter = ref('all')
const communityModalVisible = ref(false)
const memberModalVisible = ref(false)
const editingCommunity = ref(null)
const memberSearchKeyword = ref('')
const memberChart = ref(null)
let chartInstance = null
// 表单数据
const communityForm = reactive({
name: '',
description: '',
tags: [],
type: 'public'
})
const communityRules = {
name: [{ required: true, message: '请输入社区名称' }],
description: [{ required: true, message: '请输入社区描述' }]
}
// 分页配置
const postPagination = reactive({
current: 1,
pageSize: 10,
total: 0
})
const memberPagination = reactive({
current: 1,
pageSize: 10,
total: 0
})
// 社区数据
const communities = ref([
{
id: 1,
name: '养牛技术交流',
description: '专业的养牛技术讨论社区',
avatar: '/images/community1.jpg',
memberCount: 1234,
postCount: 567,
todayActive: 89,
status: 'active',
tags: ['技术', '交流', '养牛'],
createTime: new Date('2023-01-15')
},
{
id: 2,
name: '牛只健康管理',
description: '关注牛只健康,分享管理经验',
avatar: '/images/community2.jpg',
memberCount: 856,
postCount: 342,
todayActive: 45,
status: 'active',
tags: ['健康', '管理', '经验'],
createTime: new Date('2023-03-20')
}
])
// 帖子数据
const posts = ref([
{
id: 1,
title: '春季牛只疫苗接种注意事项',
author: { name: '张三', avatar: '/images/user1.jpg' },
createTime: new Date(),
viewCount: 234,
replyCount: 12,
pinned: true,
locked: false,
status: 'approved',
communityId: 1
},
{
id: 2,
title: '如何提高牛奶产量的几个技巧',
author: { name: '李四', avatar: '/images/user2.jpg' },
createTime: new Date(Date.now() - 3600000),
viewCount: 156,
replyCount: 8,
pinned: false,
locked: false,
status: 'pending',
communityId: 1
}
])
// 成员数据
const communityMembers = ref([
{
id: 1,
name: '张三',
avatar: '/images/user1.jpg',
role: 'admin',
status: 'active',
joinTime: new Date('2023-01-20'),
postCount: 45,
lastActive: new Date()
},
{
id: 2,
name: '李四',
avatar: '/images/user2.jpg',
role: 'moderator',
status: 'active',
joinTime: new Date('2023-02-15'),
postCount: 23,
lastActive: new Date(Date.now() - 3600000)
}
])
// 最新动态
const recentActivities = ref([
{
id: 1,
type: 'post',
content: '张三发布了新帖子《春季牛只疫苗接种注意事项》',
time: new Date()
},
{
id: 2,
type: 'member',
content: '新成员王五加入了社区',
time: new Date(Date.now() - 1800000)
},
{
id: 3,
type: 'report',
content: '收到一条举报信息,需要处理',
time: new Date(Date.now() - 3600000)
}
])
// 标签选项
const tagOptions = ref([
{ label: '技术', value: '技术' },
{ label: '交流', value: '交流' },
{ label: '养牛', value: '养牛' },
{ label: '健康', value: '健康' },
{ label: '管理', value: '管理' }
])
// 成员表格列
const memberColumns = [
{ title: '头像', key: 'avatar', width: 80 },
{ title: '姓名', dataIndex: 'name', key: 'name' },
{ title: '角色', key: 'role', width: 100 },
{ title: '状态', key: 'status', width: 100 },
{ title: '帖子数', dataIndex: 'postCount', key: 'postCount', width: 100 },
{ title: '加入时间', dataIndex: 'joinTime', key: 'joinTime', width: 120 },
{ title: '操作', key: 'actions', width: 150 }
]
// 计算属性
const filteredCommunities = computed(() => {
if (!searchKeyword.value) return communities.value
return communities.value.filter(community =>
community.name.includes(searchKeyword.value) ||
community.description.includes(searchKeyword.value)
)
})
const filteredPosts = computed(() => {
if (!selectedCommunity.value) return []
let result = posts.value.filter(post => post.communityId === selectedCommunity.value.id)
if (postFilter.value !== 'all') {
result = result.filter(post => post.status === postFilter.value)
}
return result
})
// 方法
const selectCommunity = (community) => {
selectedCommunity.value = community
}
const handleSearch = () => {
// 搜索处理
}
const showCreateCommunity = () => {
editingCommunity.value = null
Object.assign(communityForm, {
name: '',
description: '',
tags: [],
type: 'public'
})
communityModalVisible.value = true
}
const editCommunity = (community) => {
editingCommunity.value = community
Object.assign(communityForm, {
name: community.name,
description: community.description,
tags: community.tags,
type: community.type || 'public'
})
communityModalVisible.value = true
}
const handleCommunitySubmit = () => {
if (editingCommunity.value) {
message.success('社区信息更新成功')
} else {
message.success('社区创建成功')
}
communityModalVisible.value = false
}
const handleCommunityCancel = () => {
communityModalVisible.value = false
}
const manageMember = () => {
memberModalVisible.value = true
}
const communitySettings = () => {
message.info('社区设置功能开发中')
}
const deleteCommunity = () => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个社区吗?此操作不可恢复。',
onOk() {
message.success('社区删除成功')
}
})
}
const createPost = () => {
message.info('发布公告功能开发中')
}
const viewPost = (post) => {
message.info(`查看帖子:${post.title}`)
}
const editPost = (post) => {
message.info(`编辑帖子:${post.title}`)
}
const pinPost = (post) => {
post.pinned = !post.pinned
message.success(post.pinned ? '帖子已置顶' : '帖子已取消置顶')
}
const lockPost = (post) => {
post.locked = !post.locked
message.success(post.locked ? '帖子已锁定' : '帖子已解锁')
}
const deletePost = (post) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个帖子吗?',
onOk() {
message.success('帖子删除成功')
}
})
}
const showMemberManagement = () => {
memberModalVisible.value = true
}
const showContentModeration = () => {
message.info('内容审核功能开发中')
}
const showReportManagement = () => {
message.info('举报处理功能开发中')
}
const showAnalytics = () => {
message.info('数据分析功能开发中')
}
const changeMemberRole = (member) => {
message.info(`修改 ${member.name} 的角色`)
}
const toggleMemberStatus = (member) => {
member.status = member.status === 'active' ? 'inactive' : 'active'
message.success(`${member.name} 状态已更新`)
}
const removeMember = (member) => {
Modal.confirm({
title: '确认移除',
content: `确定要移除成员 ${member.name} 吗?`,
onOk() {
message.success('成员移除成功')
}
})
}
const getActivityColor = (type) => {
const colorMap = {
post: 'blue',
member: 'green',
report: 'red'
}
return colorMap[type] || 'gray'
}
const getRoleColor = (role) => {
const colorMap = {
admin: 'red',
moderator: 'orange',
member: 'blue'
}
return colorMap[role] || 'default'
}
const getRoleText = (role) => {
const textMap = {
admin: '管理员',
moderator: '版主',
member: '成员'
}
return textMap[role] || '成员'
}
const formatTime = (time) => {
return new Date(time).toLocaleString('zh-CN')
}
const formatDate = (date) => {
return new Date(date).toLocaleDateString('zh-CN')
}
const initChart = () => {
if (memberChart.value && !chartInstance) {
chartInstance = echarts.init(memberChart.value)
const option = {
title: {
text: '成员增长趋势',
textStyle: { fontSize: 14 }
},
tooltip: {
trigger: 'axis'
},
xAxis: {
type: 'category',
data: ['1月', '2月', '3月', '4月', '5月', '6月']
},
yAxis: {
type: 'value'
},
series: [{
data: [120, 200, 150, 80, 70, 110],
type: 'line',
smooth: true,
itemStyle: {
color: '#1890ff'
}
}]
}
chartInstance.setOption(option)
}
}
// 组件挂载和卸载
onMounted(() => {
setTimeout(() => {
initChart()
}, 100)
})
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
})
</script>
<style lang="scss" scoped>
.community-management {
.content-wrapper {
margin-top: 16px;
}
.community-list-card {
.community-search {
margin-bottom: 16px;
}
.community-list {
.community-item {
display: flex;
align-items: center;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 8px;
border: 1px solid transparent;
&:hover, &.active {
background: #f0f2ff;
border-color: #1890ff;
}
.community-avatar {
margin-right: 12px;
}
.community-info {
flex: 1;
.community-name {
font-weight: 500;
margin-bottom: 4px;
}
.community-desc {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.community-stats {
font-size: 12px;
color: #999;
span {
margin-right: 12px;
}
}
}
.community-status {
font-size: 12px;
}
}
}
}
.empty-content {
height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
.community-content {
.community-info-card {
margin-bottom: 16px;
.community-header {
display: flex;
align-items: center;
gap: 16px;
.community-meta {
flex: 1;
h3 {
margin: 0 0 8px 0;
}
p {
margin: 0 0 8px 0;
color: #666;
}
.community-tags {
margin: 0;
}
}
}
}
.posts-card {
.post-title {
display: flex;
align-items: center;
justify-content: space-between;
.post-badges {
margin-left: 8px;
}
}
.post-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: #666;
}
}
}
.management-tools, .recent-activities, .community-stats {
margin-bottom: 16px;
}
.recent-activities {
.activity-content {
.activity-text {
margin-bottom: 4px;
}
.activity-time {
font-size: 12px;
color: #999;
}
}
}
.member-management {
.member-search {
margin-bottom: 16px;
}
}
}
</style>

View File

@@ -0,0 +1,744 @@
<template>
<div class="online-consultation">
<PageHeader title="在线咨询服务" />
<div class="content-wrapper">
<a-row :gutter="16">
<!-- 左侧咨询列表 -->
<a-col :span="8">
<a-card title="咨询分类" class="category-card">
<a-menu
v-model:selectedKeys="selectedCategory"
mode="inline"
@click="handleCategoryChange"
>
<a-menu-item v-for="category in categories" :key="category.key">
<template #icon>
<component :is="category.icon" />
</template>
{{ category.label }}
<a-badge :count="category.count" style="margin-left: 8px;" />
</a-menu-item>
</a-menu>
</a-card>
<a-card title="专家列表" class="expert-card">
<div class="expert-list">
<div
v-for="expert in filteredExperts"
:key="expert.id"
class="expert-item"
:class="{ active: selectedExpert?.id === expert.id }"
@click="selectExpert(expert)"
>
<a-avatar :src="expert.avatar" :size="40">
{{ expert.name.charAt(0) }}
</a-avatar>
<div class="expert-info">
<div class="expert-name">{{ expert.name }}</div>
<div class="expert-title">{{ expert.title }}</div>
<div class="expert-status">
<a-badge
:status="expert.online ? 'success' : 'default'"
:text="expert.online ? '在线' : '离线'"
/>
</div>
</div>
<div class="expert-rating">
<a-rate :value="expert.rating" disabled />
<div class="rating-text">{{ expert.rating }}</div>
</div>
</div>
</div>
</a-card>
</a-col>
<!-- 中间聊天区域 -->
<a-col :span="10">
<a-card class="chat-card">
<template #title>
<div v-if="selectedExpert" class="chat-header">
<a-avatar :src="selectedExpert.avatar">
{{ selectedExpert.name.charAt(0) }}
</a-avatar>
<div class="expert-info">
<span class="expert-name">{{ selectedExpert.name }}</span>
<span class="expert-title">{{ selectedExpert.title }}</span>
</div>
<a-badge
:status="selectedExpert.online ? 'success' : 'default'"
:text="selectedExpert.online ? '在线' : '离线'"
/>
</div>
<div v-else class="no-expert">
请选择专家开始咨询
</div>
</template>
<div class="chat-content" ref="chatContent">
<div v-if="!selectedExpert" class="empty-chat">
<a-empty description="选择专家开始咨询" />
</div>
<div v-else class="message-list">
<div
v-for="message in currentMessages"
:key="message.id"
class="message-item"
:class="{ 'own-message': message.sender === 'user' }"
>
<div class="message-avatar">
<a-avatar
:src="message.sender === 'user' ? userAvatar : selectedExpert.avatar"
:size="32"
>
{{ message.sender === 'user' ? 'U' : selectedExpert.name.charAt(0) }}
</a-avatar>
</div>
<div class="message-content">
<div class="message-info">
<span class="sender-name">
{{ message.sender === 'user' ? '我' : selectedExpert.name }}
</span>
<span class="message-time">{{ formatTime(message.timestamp) }}</span>
</div>
<div class="message-text">{{ message.content }}</div>
<div v-if="message.images" class="message-images">
<img
v-for="(image, index) in message.images"
:key="index"
:src="image"
class="message-image"
@click="previewImage(image)"
/>
</div>
</div>
</div>
</div>
</div>
<div v-if="selectedExpert" class="chat-input">
<div class="input-toolbar">
<a-space>
<a-upload
:show-upload-list="false"
:before-upload="handleImageUpload"
accept="image/*"
>
<a-button type="text" size="small">
<PictureOutlined />
</a-button>
</a-upload>
<a-button type="text" size="small">
<FileOutlined />
</a-button>
</a-space>
</div>
<div class="input-area">
<a-textarea
v-model:value="inputMessage"
:rows="3"
placeholder="输入您的问题..."
@keydown.enter.prevent="handleSendMessage"
/>
<a-button
type="primary"
@click="handleSendMessage"
:disabled="!inputMessage.trim()"
class="send-button"
>
发送
</a-button>
</div>
</div>
</a-card>
</a-col>
<!-- 右侧信息面板 -->
<a-col :span="6">
<a-card title="咨询统计" class="stats-card">
<a-row :gutter="16">
<a-col :span="12">
<a-statistic title="今日咨询" :value="todayConsultations" />
</a-col>
<a-col :span="12">
<a-statistic title="总咨询数" :value="totalConsultations" />
</a-col>
</a-row>
<a-row :gutter="16" style="margin-top: 16px;">
<a-col :span="12">
<a-statistic title="在线专家" :value="onlineExperts" />
</a-col>
<a-col :span="12">
<a-statistic title="平均响应" :value="avgResponseTime" suffix="分钟" />
</a-col>
</a-row>
</a-card>
<a-card title="常见问题" class="faq-card">
<a-collapse ghost>
<a-collapse-panel
v-for="faq in faqs"
:key="faq.id"
:header="faq.question"
>
<p>{{ faq.answer }}</p>
</a-collapse-panel>
</a-collapse>
</a-card>
<a-card title="咨询记录" class="history-card">
<a-list
:data-source="consultationHistory"
size="small"
>
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #title>
<span>{{ item.expertName }}</span>
</template>
<template #description>
<div>{{ item.topic }}</div>
<div class="history-time">{{ formatDate(item.date) }}</div>
</template>
</a-list-item-meta>
<template #actions>
<a @click="viewHistory(item)">查看</a>
</template>
</a-list-item>
</template>
</a-list>
</a-card>
</a-col>
</a-row>
</div>
<!-- 图片预览 -->
<a-modal
v-model:open="imagePreviewVisible"
:footer="null"
:width="600"
>
<img :src="previewImageUrl" style="width: 100%;" />
</a-modal>
<!-- 咨询历史详情 -->
<a-modal
v-model:open="historyModalVisible"
title="咨询记录详情"
:width="800"
:footer="null"
>
<div v-if="selectedHistory" class="history-detail">
<div class="history-header">
<h3>{{ selectedHistory.topic }}</h3>
<div class="history-meta">
<span>专家{{ selectedHistory.expertName }}</span>
<span>时间{{ formatDate(selectedHistory.date) }}</span>
<span>状态{{ selectedHistory.status }}</span>
</div>
</div>
<div class="history-messages">
<div
v-for="message in selectedHistory.messages"
:key="message.id"
class="history-message"
:class="{ 'own-message': message.sender === 'user' }"
>
<div class="message-sender">{{ message.sender === 'user' ? '我' : selectedHistory.expertName }}</div>
<div class="message-content">{{ message.content }}</div>
<div class="message-time">{{ formatTime(message.timestamp) }}</div>
</div>
</div>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { message } from 'ant-design-vue'
import {
MedicineBoxOutlined,
ExperimentOutlined,
EnvironmentOutlined,
HeartOutlined,
PictureOutlined,
FileOutlined
} from '@ant-design/icons-vue'
import PageHeader from '@/components/common/PageHeader.vue'
// 响应式数据
const selectedCategory = ref(['all'])
const selectedExpert = ref(null)
const inputMessage = ref('')
const chatContent = ref(null)
const imagePreviewVisible = ref(false)
const previewImageUrl = ref('')
const historyModalVisible = ref(false)
const selectedHistory = ref(null)
const userAvatar = ref('/images/user-avatar.jpg')
// 咨询分类
const categories = ref([
{ key: 'all', label: '全部咨询', icon: HeartOutlined, count: 25 },
{ key: 'disease', label: '疾病诊断', icon: MedicineBoxOutlined, count: 12 },
{ key: 'nutrition', label: '营养管理', icon: ExperimentOutlined, count: 8 },
{ key: 'environment', label: '环境管理', icon: EnvironmentOutlined, count: 5 }
])
// 专家列表
const experts = ref([
{
id: 1,
name: '张教授',
title: '兽医学专家',
avatar: '/images/expert1.jpg',
online: true,
rating: 4.9,
specialty: ['disease', 'nutrition'],
responseTime: 5
},
{
id: 2,
name: '李博士',
title: '动物营养学专家',
avatar: '/images/expert2.jpg',
online: true,
rating: 4.8,
specialty: ['nutrition', 'environment'],
responseTime: 8
},
{
id: 3,
name: '王医生',
title: '畜牧兽医师',
avatar: '/images/expert3.jpg',
online: false,
rating: 4.7,
specialty: ['disease'],
responseTime: 12
}
])
// 消息数据
const messages = ref({
1: [
{
id: 1,
sender: 'expert',
content: '您好!我是张教授,很高兴为您服务。请问有什么问题需要咨询?',
timestamp: new Date(Date.now() - 3600000)
},
{
id: 2,
sender: 'user',
content: '我家的牛最近食欲不振,请问可能是什么原因?',
timestamp: new Date(Date.now() - 3500000)
},
{
id: 3,
sender: 'expert',
content: '食欲不振可能有多种原因,比如消化系统问题、环境应激、疾病等。您能详细描述一下牛的具体症状吗?比如精神状态、体温、排便情况等。',
timestamp: new Date(Date.now() - 3400000)
}
],
2: [],
3: []
})
// 统计数据
const todayConsultations = ref(15)
const totalConsultations = ref(1248)
const onlineExperts = ref(2)
const avgResponseTime = ref(6)
// 常见问题
const faqs = ref([
{
id: 1,
question: '如何预防牛只感冒?',
answer: '保持牛舍通风良好,避免温差过大,定期消毒,加强营养管理。'
},
{
id: 2,
question: '牛只不吃草怎么办?',
answer: '检查草料质量,观察牛只健康状况,必要时咨询兽医。'
},
{
id: 3,
question: '如何提高牛奶产量?',
answer: '优化饲料配比,保证充足饮水,创造舒适环境,定期健康检查。'
}
])
// 咨询历史
const consultationHistory = ref([
{
id: 1,
expertName: '张教授',
topic: '牛只消化不良问题',
date: new Date(Date.now() - 86400000),
status: '已解决',
messages: [
{
id: 1,
sender: 'user',
content: '我的牛出现消化不良症状',
timestamp: new Date(Date.now() - 86400000)
},
{
id: 2,
sender: 'expert',
content: '建议调整饲料配比,增加益生菌',
timestamp: new Date(Date.now() - 86300000)
}
]
},
{
id: 2,
expertName: '李博士',
topic: '饲料营养搭配咨询',
date: new Date(Date.now() - 172800000),
status: '已解决',
messages: []
}
])
// 计算属性
const filteredExperts = computed(() => {
if (selectedCategory.value.includes('all')) {
return experts.value
}
return experts.value.filter(expert =>
expert.specialty.some(spec => selectedCategory.value.includes(spec))
)
})
const currentMessages = computed(() => {
return selectedExpert.value ? messages.value[selectedExpert.value.id] || [] : []
})
// 方法
const handleCategoryChange = ({ key }) => {
selectedCategory.value = [key]
selectedExpert.value = null
}
const selectExpert = (expert) => {
selectedExpert.value = expert
nextTick(() => {
scrollToBottom()
})
}
const handleSendMessage = () => {
if (!inputMessage.value.trim() || !selectedExpert.value) return
const newMessage = {
id: Date.now(),
sender: 'user',
content: inputMessage.value.trim(),
timestamp: new Date()
}
if (!messages.value[selectedExpert.value.id]) {
messages.value[selectedExpert.value.id] = []
}
messages.value[selectedExpert.value.id].push(newMessage)
inputMessage.value = ''
// 模拟专家回复
setTimeout(() => {
const expertReply = {
id: Date.now() + 1,
sender: 'expert',
content: '感谢您的咨询,我正在分析您的问题,请稍等...',
timestamp: new Date()
}
messages.value[selectedExpert.value.id].push(expertReply)
nextTick(() => {
scrollToBottom()
})
}, 1000)
nextTick(() => {
scrollToBottom()
})
}
const handleImageUpload = (file) => {
// 处理图片上传
message.success('图片上传成功')
return false // 阻止默认上传行为
}
const scrollToBottom = () => {
if (chatContent.value) {
chatContent.value.scrollTop = chatContent.value.scrollHeight
}
}
const previewImage = (imageUrl) => {
previewImageUrl.value = imageUrl
imagePreviewVisible.value = true
}
const viewHistory = (history) => {
selectedHistory.value = history
historyModalVisible.value = true
}
const formatTime = (timestamp) => {
return new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
const formatDate = (date) => {
return new Date(date).toLocaleDateString('zh-CN')
}
// 组件挂载
onMounted(() => {
// 初始化数据
})
</script>
<style lang="scss" scoped>
.online-consultation {
.content-wrapper {
margin-top: 16px;
}
.category-card, .expert-card {
margin-bottom: 16px;
}
.expert-list {
.expert-item {
display: flex;
align-items: center;
padding: 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 8px;
&:hover, &.active {
background: #f0f2ff;
border-color: #1890ff;
}
.expert-info {
flex: 1;
margin-left: 12px;
.expert-name {
font-weight: 500;
margin-bottom: 4px;
}
.expert-title {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.expert-status {
font-size: 12px;
}
}
.expert-rating {
text-align: center;
.rating-text {
font-size: 12px;
color: #666;
margin-top: 4px;
}
}
}
}
.chat-card {
height: 600px;
display: flex;
flex-direction: column;
.chat-header {
display: flex;
align-items: center;
gap: 12px;
.expert-info {
flex: 1;
.expert-name {
font-weight: 500;
margin-right: 8px;
}
.expert-title {
font-size: 12px;
color: #666;
}
}
}
.no-expert {
text-align: center;
color: #666;
}
.chat-content {
flex: 1;
height: 400px;
overflow-y: auto;
padding: 16px 0;
.empty-chat {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.message-list {
.message-item {
display: flex;
margin-bottom: 16px;
&.own-message {
flex-direction: row-reverse;
.message-content {
background: #1890ff;
color: white;
}
}
.message-avatar {
margin: 0 8px;
}
.message-content {
max-width: 70%;
background: #f5f5f5;
padding: 8px 12px;
border-radius: 8px;
.message-info {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
font-size: 12px;
opacity: 0.7;
}
.message-text {
word-break: break-word;
}
.message-images {
margin-top: 8px;
.message-image {
max-width: 100px;
max-height: 100px;
border-radius: 4px;
cursor: pointer;
margin-right: 8px;
}
}
}
}
}
}
.chat-input {
border-top: 1px solid #f0f0f0;
padding-top: 16px;
.input-toolbar {
margin-bottom: 8px;
}
.input-area {
display: flex;
gap: 8px;
.ant-textarea {
flex: 1;
}
.send-button {
align-self: flex-end;
}
}
}
}
.stats-card, .faq-card, .history-card {
margin-bottom: 16px;
}
.history-card {
.history-time {
font-size: 12px;
color: #999;
}
}
.history-detail {
.history-header {
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
h3 {
margin: 0 0 8px 0;
}
.history-meta {
display: flex;
gap: 16px;
font-size: 14px;
color: #666;
}
}
.history-messages {
.history-message {
margin-bottom: 16px;
padding: 12px;
background: #f9f9f9;
border-radius: 8px;
&.own-message {
background: #e6f7ff;
margin-left: 20%;
}
.message-sender {
font-weight: 500;
margin-bottom: 4px;
font-size: 12px;
}
.message-content {
margin-bottom: 4px;
}
.message-time {
font-size: 12px;
color: #999;
}
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,945 @@
<template>
<div class="data-statistics">
<!-- 页面头部 -->
<PageHeader
title="数据统计"
description="数据可视化分析,生成统计报表,监控业务趋势"
icon="bar-chart"
>
<template #extra>
<a-space>
<a-range-picker
v-model:value="dateRange"
:placeholder="['开始日期', '结束日期']"
@change="handleDateRangeChange"
/>
<a-button type="primary" @click="handleGenerateReport">
<template #icon>
<FileTextOutlined />
</template>
生成报表
</a-button>
<a-button @click="handleExportData">
<template #icon>
<ExportOutlined />
</template>
导出数据
</a-button>
<a-button @click="handleRefreshData">
<template #icon>
<ReloadOutlined />
</template>
刷新数据
</a-button>
</a-space>
</template>
</PageHeader>
<!-- 核心指标卡片 -->
<a-row :gutter="16" class="stats-cards">
<a-col :xs="24" :sm="12" :md="6">
<a-card class="stat-card farms">
<a-statistic
title="养殖场总数"
:value="coreStats.totalFarms"
suffix="家"
:value-style="{ color: '#1890ff' }"
>
<template #prefix>
<HomeOutlined />
</template>
</a-statistic>
<div class="stat-trend">
<span class="trend-text" :class="{ positive: coreStats.farmsTrend > 0, negative: coreStats.farmsTrend < 0 }">
<ArrowUpOutlined v-if="coreStats.farmsTrend > 0" />
<ArrowDownOutlined v-if="coreStats.farmsTrend < 0" />
{{ Math.abs(coreStats.farmsTrend) }}%
</span>
<span class="trend-label">较上月</span>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card class="stat-card animals">
<a-statistic
title="动物总数"
:value="coreStats.totalAnimals"
suffix="头"
:value-style="{ color: '#52c41a' }"
>
<template #prefix>
<BugOutlined />
</template>
</a-statistic>
<div class="stat-trend">
<span class="trend-text" :class="{ positive: coreStats.animalsTrend > 0, negative: coreStats.animalsTrend < 0 }">
<ArrowUpOutlined v-if="coreStats.animalsTrend > 0" />
<ArrowDownOutlined v-if="coreStats.animalsTrend < 0" />
{{ Math.abs(coreStats.animalsTrend) }}%
</span>
<span class="trend-label">较上月</span>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card class="stat-card inspections">
<a-statistic
title="检查次数"
:value="coreStats.totalInspections"
suffix="次"
:value-style="{ color: '#faad14' }"
>
<template #prefix>
<SearchOutlined />
</template>
</a-statistic>
<div class="stat-trend">
<span class="trend-text" :class="{ positive: coreStats.inspectionsTrend > 0, negative: coreStats.inspectionsTrend < 0 }">
<ArrowUpOutlined v-if="coreStats.inspectionsTrend > 0" />
<ArrowDownOutlined v-if="coreStats.inspectionsTrend < 0" />
{{ Math.abs(coreStats.inspectionsTrend) }}%
</span>
<span class="trend-label">较上月</span>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-card class="stat-card compliance">
<a-statistic
title="合规率"
:value="coreStats.complianceRate"
suffix="%"
:precision="1"
:value-style="{ color: '#722ed1' }"
>
<template #prefix>
<CheckCircleOutlined />
</template>
</a-statistic>
<div class="stat-trend">
<span class="trend-text" :class="{ positive: coreStats.complianceTrend > 0, negative: coreStats.complianceTrend < 0 }">
<ArrowUpOutlined v-if="coreStats.complianceTrend > 0" />
<ArrowDownOutlined v-if="coreStats.complianceTrend < 0" />
{{ Math.abs(coreStats.complianceTrend) }}%
</span>
<span class="trend-label">较上月</span>
</div>
</a-card>
</a-col>
</a-row>
<!-- 图表区域 -->
<a-row :gutter="16" class="chart-section">
<a-col :xs="24" :lg="12">
<a-card title="养殖场数量趋势" class="chart-card">
<template #extra>
<a-radio-group v-model:value="farmTrendPeriod" size="small" @change="handleFarmTrendChange">
<a-radio-button value="week"></a-radio-button>
<a-radio-button value="month"></a-radio-button>
<a-radio-button value="year"></a-radio-button>
</a-radio-group>
</template>
<div ref="farmTrendChart" class="chart-container"></div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card title="动物健康状况" class="chart-card">
<div ref="healthStatusChart" class="chart-container"></div>
</a-card>
</a-col>
</a-row>
<a-row :gutter="16" class="chart-section">
<a-col :xs="24" :lg="12">
<a-card title="检查结果分布" class="chart-card">
<div ref="inspectionResultChart" class="chart-container"></div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card title="区域分布统计" class="chart-card">
<div ref="regionDistributionChart" class="chart-container"></div>
</a-card>
</a-col>
</a-row>
<!-- 详细统计表格 -->
<a-card title="详细统计数据" class="table-card">
<template #extra>
<a-space>
<a-select
v-model:value="statisticsType"
style="width: 120px"
@change="handleStatisticsTypeChange"
>
<a-select-option value="farm">养殖场</a-select-option>
<a-select-option value="animal">动物</a-select-option>
<a-select-option value="inspection">检查</a-select-option>
<a-select-option value="region">区域</a-select-option>
</a-select>
<a-button size="small" @click="handleTableExport">
<template #icon>
<ExportOutlined />
</template>
导出表格
</a-button>
</a-space>
</template>
<DataTable
:columns="currentTableColumns"
:data-source="currentTableData"
:loading="tableLoading"
:pagination="tablePagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'healthStatus'">
<a-tag :color="getHealthStatusColor(record.healthStatus)">
{{ getHealthStatusText(record.healthStatus) }}
</a-tag>
</template>
<template v-else-if="column.key === 'result'">
<a-tag :color="getResultColor(record.result)">
{{ getResultText(record.result) }}
</a-tag>
</template>
<template v-else-if="column.key === 'compliance'">
<a-progress
:percent="record.compliance"
size="small"
:status="record.compliance >= 80 ? 'success' : record.compliance >= 60 ? 'active' : 'exception'"
/>
</template>
</template>
</DataTable>
</a-card>
<!-- 报表生成弹窗 -->
<a-modal
v-model:open="reportModalVisible"
title="生成统计报表"
width="600px"
@ok="handleReportSubmit"
@cancel="handleReportCancel"
>
<a-form
ref="reportFormRef"
:model="reportFormData"
:rules="reportFormRules"
layout="vertical"
>
<a-form-item label="报表名称" name="name">
<a-input v-model:value="reportFormData.name" placeholder="请输入报表名称" />
</a-form-item>
<a-form-item label="报表类型" name="type">
<a-select v-model:value="reportFormData.type" placeholder="请选择报表类型">
<a-select-option value="summary">综合统计</a-select-option>
<a-select-option value="farm">养殖场统计</a-select-option>
<a-select-option value="animal">动物统计</a-select-option>
<a-select-option value="inspection">检查统计</a-select-option>
<a-select-option value="health">健康统计</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="统计周期" name="period">
<a-select v-model:value="reportFormData.period" placeholder="请选择统计周期">
<a-select-option value="daily">日报</a-select-option>
<a-select-option value="weekly">周报</a-select-option>
<a-select-option value="monthly">月报</a-select-option>
<a-select-option value="quarterly">季报</a-select-option>
<a-select-option value="yearly">年报</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="时间范围" name="dateRange">
<a-range-picker
v-model:value="reportFormData.dateRange"
style="width: 100%"
:placeholder="['开始日期', '结束日期']"
/>
</a-form-item>
<a-form-item label="包含内容" name="content">
<a-checkbox-group v-model:value="reportFormData.content">
<a-row>
<a-col :span="12">
<a-checkbox value="overview">概览统计</a-checkbox>
</a-col>
<a-col :span="12">
<a-checkbox value="trend">趋势分析</a-checkbox>
</a-col>
<a-col :span="12">
<a-checkbox value="distribution">分布统计</a-checkbox>
</a-col>
<a-col :span="12">
<a-checkbox value="comparison">对比分析</a-checkbox>
</a-col>
<a-col :span="12">
<a-checkbox value="charts">图表展示</a-checkbox>
</a-col>
<a-col :span="12">
<a-checkbox value="details">详细数据</a-checkbox>
</a-col>
</a-row>
</a-checkbox-group>
</a-form-item>
<a-form-item label="输出格式" name="format">
<a-radio-group v-model:value="reportFormData.format">
<a-radio value="pdf">PDF</a-radio>
<a-radio value="excel">Excel</a-radio>
<a-radio value="word">Word</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea
v-model:value="reportFormData.remark"
placeholder="请输入备注信息"
:rows="3"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import {
FileTextOutlined,
ExportOutlined,
ReloadOutlined,
HomeOutlined,
BugOutlined,
SearchOutlined,
CheckCircleOutlined,
ArrowUpOutlined,
ArrowDownOutlined
} from '@ant-design/icons-vue'
import PageHeader from '@/components/common/PageHeader.vue'
import DataTable from '@/components/common/DataTable.vue'
import { useStatisticsStore } from '@/stores/statistics'
import { formatDate } from '@/utils/date'
const statisticsStore = useStatisticsStore()
// 响应式数据
const dateRange = ref([dayjs().subtract(30, 'day'), dayjs()])
const farmTrendPeriod = ref('month')
const statisticsType = ref('farm')
const reportModalVisible = ref(false)
const reportFormRef = ref()
const tableLoading = ref(false)
// 图表引用
const farmTrendChart = ref()
const healthStatusChart = ref()
const inspectionResultChart = ref()
const regionDistributionChart = ref()
// 核心统计数据
const coreStats = reactive({
totalFarms: 0,
farmsTrend: 0,
totalAnimals: 0,
animalsTrend: 0,
totalInspections: 0,
inspectionsTrend: 0,
complianceRate: 0,
complianceTrend: 0
})
// 报表表单数据
const reportFormData = reactive({
name: '',
type: '',
period: '',
dateRange: null,
content: [],
format: 'pdf',
remark: ''
})
// 表格分页
const tablePagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) => `${range[0]}-${range[1]} 条,共 ${total}`
})
// 表格列配置
const farmColumns = [
{
title: '养殖场名称',
dataIndex: 'name',
key: 'name',
width: 200,
ellipsis: true
},
{
title: '所在区域',
dataIndex: 'region',
key: 'region',
width: 120
},
{
title: '养殖类型',
dataIndex: 'type',
key: 'type',
width: 120
},
{
title: '动物数量',
dataIndex: 'animalCount',
key: 'animalCount',
width: 100,
align: 'center'
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
align: 'center'
},
{
title: '合规率',
dataIndex: 'compliance',
key: 'compliance',
width: 120,
align: 'center'
},
{
title: '最后检查',
dataIndex: 'lastInspection',
key: 'lastInspection',
width: 120,
customRender: ({ text }) => formatDate(text)
}
]
const animalColumns = [
{
title: '动物类型',
dataIndex: 'type',
key: 'type',
width: 120
},
{
title: '数量',
dataIndex: 'count',
key: 'count',
width: 100,
align: 'center'
},
{
title: '健康状况',
dataIndex: 'healthStatus',
key: 'healthStatus',
width: 120,
align: 'center'
},
{
title: '疫苗接种率',
dataIndex: 'vaccinationRate',
key: 'vaccinationRate',
width: 120,
align: 'center',
customRender: ({ text }) => `${text}%`
},
{
title: '死亡率',
dataIndex: 'mortalityRate',
key: 'mortalityRate',
width: 100,
align: 'center',
customRender: ({ text }) => `${text}%`
},
{
title: '所属养殖场',
dataIndex: 'farmName',
key: 'farmName',
width: 200,
ellipsis: true
}
]
const inspectionColumns = [
{
title: '检查日期',
dataIndex: 'date',
key: 'date',
width: 120,
customRender: ({ text }) => formatDate(text)
},
{
title: '检查类型',
dataIndex: 'type',
key: 'type',
width: 120
},
{
title: '检查对象',
dataIndex: 'target',
key: 'target',
width: 200,
ellipsis: true
},
{
title: '检查结果',
dataIndex: 'result',
key: 'result',
width: 120,
align: 'center'
},
{
title: '检查人员',
dataIndex: 'inspector',
key: 'inspector',
width: 120
},
{
title: '问题数量',
dataIndex: 'issueCount',
key: 'issueCount',
width: 100,
align: 'center'
}
]
const regionColumns = [
{
title: '区域名称',
dataIndex: 'name',
key: 'name',
width: 150
},
{
title: '养殖场数量',
dataIndex: 'farmCount',
key: 'farmCount',
width: 120,
align: 'center'
},
{
title: '动物总数',
dataIndex: 'animalCount',
key: 'animalCount',
width: 120,
align: 'center'
},
{
title: '检查次数',
dataIndex: 'inspectionCount',
key: 'inspectionCount',
width: 120,
align: 'center'
},
{
title: '合规率',
dataIndex: 'compliance',
key: 'compliance',
width: 120,
align: 'center'
},
{
title: '风险等级',
dataIndex: 'riskLevel',
key: 'riskLevel',
width: 120,
align: 'center'
}
]
// 报表表单验证规则
const reportFormRules = {
name: [
{ required: true, message: '请输入报表名称', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择报表类型', trigger: 'change' }
],
period: [
{ required: true, message: '请选择统计周期', trigger: 'change' }
],
dateRange: [
{ required: true, message: '请选择时间范围', trigger: 'change' }
],
content: [
{ required: true, message: '请选择包含内容', trigger: 'change' }
]
}
// 计算属性
const currentTableColumns = computed(() => {
const columnMap = {
farm: farmColumns,
animal: animalColumns,
inspection: inspectionColumns,
region: regionColumns
}
return columnMap[statisticsType.value] || farmColumns
})
const currentTableData = computed(() => {
const dataMap = {
farm: statisticsStore.farmStatistics,
animal: statisticsStore.animalStatistics,
inspection: statisticsStore.inspectionStatistics,
region: statisticsStore.regionStatistics
}
return dataMap[statisticsType.value] || []
})
// 获取状态颜色
const getStatusColor = (status) => {
const colorMap = {
active: 'success',
inactive: 'default',
suspended: 'warning',
closed: 'error'
}
return colorMap[status] || 'default'
}
// 获取状态文本
const getStatusText = (status) => {
const textMap = {
active: '正常',
inactive: '停用',
suspended: '暂停',
closed: '关闭'
}
return textMap[status] || '未知'
}
// 获取健康状况颜色
const getHealthStatusColor = (status) => {
const colorMap = {
healthy: 'success',
sick: 'error',
quarantine: 'warning',
dead: 'default'
}
return colorMap[status] || 'default'
}
// 获取健康状况文本
const getHealthStatusText = (status) => {
const textMap = {
healthy: '健康',
sick: '患病',
quarantine: '隔离',
dead: '死亡'
}
return textMap[status] || '未知'
}
// 获取检查结果颜色
const getResultColor = (result) => {
const colorMap = {
pass: 'success',
fail: 'error',
warning: 'warning'
}
return colorMap[result] || 'default'
}
// 获取检查结果文本
const getResultText = (result) => {
const textMap = {
pass: '合格',
fail: '不合格',
warning: '警告'
}
return textMap[result] || '未知'
}
// 加载核心统计数据
const loadCoreStats = async () => {
try {
const params = {
startDate: dateRange.value[0]?.format('YYYY-MM-DD'),
endDate: dateRange.value[1]?.format('YYYY-MM-DD')
}
const stats = await statisticsStore.fetchCoreStats(params)
Object.assign(coreStats, stats)
} catch (error) {
message.error('加载核心统计数据失败')
}
}
// 加载表格数据
const loadTableData = async () => {
try {
tableLoading.value = true
const params = {
type: statisticsType.value,
page: tablePagination.current,
pageSize: tablePagination.pageSize,
startDate: dateRange.value[0]?.format('YYYY-MM-DD'),
endDate: dateRange.value[1]?.format('YYYY-MM-DD')
}
const result = await statisticsStore.fetchTableData(params)
tablePagination.total = result.total
} catch (error) {
message.error('加载表格数据失败')
} finally {
tableLoading.value = false
}
}
// 初始化图表
const initCharts = () => {
// 这里应该初始化ECharts图表
// 暂时用占位符代替
if (farmTrendChart.value) {
farmTrendChart.value.innerHTML = '<div style="height: 300px; background: #f0f0f0; display: flex; align-items: center; justify-content: center; color: #999;">养殖场数量趋势图</div>'
}
if (healthStatusChart.value) {
healthStatusChart.value.innerHTML = '<div style="height: 300px; background: #f0f0f0; display: flex; align-items: center; justify-content: center; color: #999;">动物健康状况图</div>'
}
if (inspectionResultChart.value) {
inspectionResultChart.value.innerHTML = '<div style="height: 300px; background: #f0f0f0; display: flex; align-items: center; justify-content: center; color: #999;">检查结果分布图</div>'
}
if (regionDistributionChart.value) {
regionDistributionChart.value.innerHTML = '<div style="height: 300px; background: #f0f0f0; display: flex; align-items: center; justify-content: center; color: #999;">区域分布统计图</div>'
}
}
// 更新图表数据
const updateCharts = async () => {
try {
const params = {
startDate: dateRange.value[0]?.format('YYYY-MM-DD'),
endDate: dateRange.value[1]?.format('YYYY-MM-DD'),
period: farmTrendPeriod.value
}
await statisticsStore.fetchChartData(params)
// 这里应该更新图表数据
// 暂时跳过实际的图表更新
} catch (error) {
message.error('更新图表数据失败')
}
}
// 日期范围变化处理
const handleDateRangeChange = (dates) => {
if (dates && dates.length === 2) {
loadCoreStats()
loadTableData()
updateCharts()
}
}
// 养殖场趋势周期变化处理
const handleFarmTrendChange = () => {
updateCharts()
}
// 统计类型变化处理
const handleStatisticsTypeChange = () => {
tablePagination.current = 1
loadTableData()
}
// 表格变化处理
const handleTableChange = (pag, filters, sorter) => {
tablePagination.current = pag.current
tablePagination.pageSize = pag.pageSize
loadTableData()
}
// 生成报表处理
const handleGenerateReport = () => {
reportFormData.name = `统计报表_${dayjs().format('YYYY-MM-DD')}`
reportFormData.dateRange = dateRange.value
reportModalVisible.value = true
}
// 导出数据处理
const handleExportData = async () => {
try {
const params = {
type: statisticsType.value,
startDate: dateRange.value[0]?.format('YYYY-MM-DD'),
endDate: dateRange.value[1]?.format('YYYY-MM-DD')
}
await statisticsStore.exportData(params)
message.success('导出成功')
} catch (error) {
message.error('导出失败')
}
}
// 刷新数据处理
const handleRefreshData = () => {
loadCoreStats()
loadTableData()
updateCharts()
message.success('数据已刷新')
}
// 表格导出处理
const handleTableExport = async () => {
try {
const params = {
type: statisticsType.value,
startDate: dateRange.value[0]?.format('YYYY-MM-DD'),
endDate: dateRange.value[1]?.format('YYYY-MM-DD')
}
await statisticsStore.exportTableData(params)
message.success('导出成功')
} catch (error) {
message.error('导出失败')
}
}
// 提交报表表单
const handleReportSubmit = async () => {
try {
await reportFormRef.value.validate()
await statisticsStore.generateReport(reportFormData)
message.success('报表生成成功')
reportModalVisible.value = false
resetReportForm()
} catch (error) {
if (error.errorFields) {
message.error('请检查表单输入')
} else {
message.error('生成报表失败')
}
}
}
// 取消报表表单
const handleReportCancel = () => {
reportModalVisible.value = false
resetReportForm()
}
// 重置报表表单
const resetReportForm = () => {
Object.assign(reportFormData, {
name: '',
type: '',
period: '',
dateRange: null,
content: [],
format: 'pdf',
remark: ''
})
reportFormRef.value?.resetFields()
}
// 监听日期范围变化
watch(dateRange, (newRange) => {
if (newRange && newRange.length === 2) {
handleDateRangeChange(newRange)
}
}, { deep: true })
// 组件挂载
onMounted(() => {
loadCoreStats()
loadTableData()
initCharts()
updateCharts()
})
// 组件卸载
onUnmounted(() => {
// 清理图表资源
})
</script>
<style lang="scss" scoped>
.data-statistics {
.stats-cards {
margin-bottom: 24px;
.stat-card {
text-align: center;
position: relative;
&.farms {
border-left: 4px solid #1890ff;
}
&.animals {
border-left: 4px solid #52c41a;
}
&.inspections {
border-left: 4px solid #faad14;
}
&.compliance {
border-left: 4px solid #722ed1;
}
.stat-trend {
position: absolute;
bottom: 16px;
right: 16px;
font-size: 12px;
.trend-text {
margin-right: 4px;
&.positive {
color: #52c41a;
}
&.negative {
color: #ff4d4f;
}
}
.trend-label {
color: #999;
}
}
}
}
.chart-section {
margin-bottom: 24px;
.chart-card {
.chart-container {
height: 300px;
background: #f0f0f0;
}
}
}
.table-card {
.ant-table-wrapper {
margin-top: 16px;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,604 @@
<template>
<div class="user-list">
<PageHeader title="用户管理" />
<div class="content-wrapper">
<a-card>
<template #title>
<div class="card-title">
<span>用户列表</span>
</div>
</template>
<template #extra>
<a-space>
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
添加用户
</a-button>
<a-button @click="handleRefresh">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</template>
<!-- 搜索区域 -->
<div class="search-section">
<a-form layout="inline" :model="searchForm">
<a-form-item label="用户名">
<a-input
v-model:value="searchForm.username"
placeholder="请输入用户名"
allow-clear
/>
</a-form-item>
<a-form-item label="邮箱">
<a-input
v-model:value="searchForm.email"
placeholder="请输入邮箱"
allow-clear
/>
</a-form-item>
<a-form-item label="角色">
<a-select
v-model:value="searchForm.role"
placeholder="请选择角色"
allow-clear
style="width: 120px"
>
<a-select-option value="admin">管理员</a-select-option>
<a-select-option value="user">普通用户</a-select-option>
<a-select-option value="viewer">查看者</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="searchForm.status"
placeholder="请选择状态"
allow-clear
style="width: 120px"
>
<a-select-option value="active">启用</a-select-option>
<a-select-option value="inactive">禁用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleReset" style="margin-left: 8px">
重置
</a-button>
</a-form-item>
</a-form>
</div>
<!-- 数据表格 -->
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'avatar'">
<a-avatar :src="record.avatar" :alt="record.username">
{{ record.username.charAt(0).toUpperCase() }}
</a-avatar>
</template>
<template v-else-if="column.key === 'role'">
<a-tag :color="getRoleColor(record.role)">
{{ getRoleText(record.role) }}
</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-switch
:checked="record.status === 'active'"
@change="(checked) => handleStatusChange(record, checked)"
:loading="record.statusLoading"
/>
</template>
<template v-else-if="column.key === 'lastLogin'">
<span v-if="record.lastLogin">{{ formatDate(record.lastLogin) }}</span>
<span v-else class="text-muted">从未登录</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleView(record)">
查看
</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-button type="link" size="small" @click="handleResetPassword(record)">
重置密码
</a-button>
<a-popconfirm
title="确定要删除这个用户吗?"
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger>
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
</div>
<!-- 添加/编辑弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="modalTitle"
width="600px"
@ok="handleModalOk"
@cancel="handleModalCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
layout="vertical"
>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="用户名" name="username">
<a-input v-model:value="formData.username" placeholder="请输入用户名" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="邮箱" name="email">
<a-input v-model:value="formData.email" placeholder="请输入邮箱" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="真实姓名" name="realName">
<a-input v-model:value="formData.realName" placeholder="请输入真实姓名" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="手机号" name="phone">
<a-input v-model:value="formData.phone" placeholder="请输入手机号" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="角色" name="role">
<a-select v-model:value="formData.role" placeholder="请选择角色">
<a-select-option value="admin">管理员</a-select-option>
<a-select-option value="user">普通用户</a-select-option>
<a-select-option value="viewer">查看者</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="active">启用</a-select-option>
<a-select-option value="inactive">禁用</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item v-if="!formData.id" label="密码" name="password">
<a-input-password v-model:value="formData.password" placeholder="请输入密码" />
</a-form-item>
<a-form-item label="部门" name="department">
<a-input v-model:value="formData.department" placeholder="请输入部门" />
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea
v-model:value="formData.remark"
placeholder="请输入备注信息"
:rows="3"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons-vue'
import PageHeader from '@/components/common/PageHeader.vue'
// 响应式数据
const loading = ref(false)
const dataSource = ref([])
const modalVisible = ref(false)
const modalTitle = ref('')
const formRef = ref()
// 搜索表单
const searchForm = reactive({
username: '',
email: '',
role: undefined,
status: undefined
})
// 表单数据
const formData = reactive({
id: null,
username: '',
email: '',
realName: '',
phone: '',
role: 'user',
status: 'active',
password: '',
department: '',
remark: ''
})
// 表单验证规则
const formRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 3, max: 20, message: '用户名长度在 3 到 20 个字符', trigger: 'blur' }
],
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
],
realName: [
{ required: true, message: '请输入真实姓名', trigger: 'blur' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
role: [
{ required: true, message: '请选择角色', trigger: 'change' }
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
]
}
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `${total} 条记录`
})
// 表格列配置
const columns = [
{
title: '头像',
dataIndex: 'avatar',
key: 'avatar',
width: 80
},
{
title: '用户名',
dataIndex: 'username',
key: 'username',
width: 120
},
{
title: '真实姓名',
dataIndex: 'realName',
key: 'realName',
width: 120
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
width: 180
},
{
title: '手机号',
dataIndex: 'phone',
key: 'phone',
width: 120
},
{
title: '角色',
dataIndex: 'role',
key: 'role',
width: 100
},
{
title: '部门',
dataIndex: 'department',
key: 'department',
width: 120
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80
},
{
title: '最后登录',
dataIndex: 'lastLogin',
key: 'lastLogin',
width: 150
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 150
},
{
title: '操作',
key: 'action',
width: 200,
fixed: 'right'
}
]
// 获取角色颜色
const getRoleColor = (role) => {
const colorMap = {
admin: 'red',
user: 'blue',
viewer: 'green'
}
return colorMap[role] || 'default'
}
// 获取角色文本
const getRoleText = (role) => {
const textMap = {
admin: '管理员',
user: '普通用户',
viewer: '查看者'
}
return textMap[role] || '未知'
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleString('zh-CN')
}
// 加载数据
const loadData = async () => {
loading.value = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000))
// 模拟数据
const mockData = [
{
id: 1,
username: 'admin',
realName: '系统管理员',
email: 'admin@example.com',
phone: '13800138000',
role: 'admin',
department: '技术部',
status: 'active',
lastLogin: '2024-01-15 10:30:00',
createTime: '2024-01-01 09:00:00',
avatar: '',
remark: '系统管理员账户',
statusLoading: false
},
{
id: 2,
username: 'user001',
realName: '张三',
email: 'zhangsan@example.com',
phone: '13800138001',
role: 'user',
department: '业务部',
status: 'active',
lastLogin: '2024-01-14 16:20:00',
createTime: '2024-01-02 14:30:00',
avatar: '',
remark: '普通用户',
statusLoading: false
},
{
id: 3,
username: 'viewer001',
realName: '李四',
email: 'lisi@example.com',
phone: '13800138002',
role: 'viewer',
department: '运营部',
status: 'inactive',
lastLogin: null,
createTime: '2024-01-03 11:15:00',
avatar: '',
remark: '查看者账户',
statusLoading: false
}
]
dataSource.value = mockData
pagination.total = mockData.length
} catch (error) {
message.error('加载数据失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.current = 1
loadData()
}
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, {
username: '',
email: '',
role: undefined,
status: undefined
})
handleSearch()
}
// 刷新
const handleRefresh = () => {
loadData()
}
// 添加
const handleAdd = () => {
modalTitle.value = '添加用户'
Object.assign(formData, {
id: null,
username: '',
email: '',
realName: '',
phone: '',
role: 'user',
status: 'active',
password: '',
department: '',
remark: ''
})
modalVisible.value = true
}
// 编辑
const handleEdit = (record) => {
modalTitle.value = '编辑用户'
Object.assign(formData, { ...record, password: '' })
modalVisible.value = true
}
// 查看
const handleView = (record) => {
message.info(`查看用户: ${record.username}`)
}
// 状态变更
const handleStatusChange = async (record, checked) => {
record.statusLoading = true
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 500))
record.status = checked ? 'active' : 'inactive'
message.success(`用户状态已${checked ? '启用' : '禁用'}`)
} catch (error) {
message.error('状态更新失败')
} finally {
record.statusLoading = false
}
}
// 重置密码
const handleResetPassword = async (record) => {
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 500))
message.success(`用户 ${record.username} 的密码已重置`)
} catch (error) {
message.error('密码重置失败')
}
}
// 删除
const handleDelete = async (record) => {
try {
// 模拟删除API调用
await new Promise(resolve => setTimeout(resolve, 500))
message.success('删除成功')
loadData()
} catch (error) {
message.error('删除失败')
}
}
// 表格变化处理
const handleTableChange = (pag) => {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadData()
}
// 弹窗确认
const handleModalOk = async () => {
try {
await formRef.value.validate()
// 模拟保存API调用
await new Promise(resolve => setTimeout(resolve, 500))
message.success(formData.id ? '更新成功' : '添加成功')
modalVisible.value = false
loadData()
} catch (error) {
console.error('表单验证失败:', error)
}
}
// 弹窗取消
const handleModalCancel = () => {
modalVisible.value = false
}
// 组件挂载时加载数据
onMounted(() => {
loadData()
})
</script>
<style lang="scss" scoped>
.user-list {
.content-wrapper {
margin-top: 16px;
}
.card-title {
display: flex;
align-items: center;
font-weight: 500;
}
.search-section {
margin-bottom: 16px;
padding: 16px;
background: #fafafa;
border-radius: 6px;
}
.text-muted {
color: #999;
}
:deep(.ant-table) {
.ant-table-thead > tr > th {
background: #fafafa;
font-weight: 500;
}
}
}
</style>

View File

@@ -0,0 +1,60 @@
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import { webcrypto } from 'crypto'
globalThis.crypto = webcrypto
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
// 加载环境变量
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [vue({
style: {
postcss: {}, // 仅使用PostCSS处理CSS
css: {
postcss: {},
modules: false
}
}
})],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 5400,
host: '0.0.0.0',
proxy: {
'/api/auth': {
target: env.VITE_API_FULL_URL || 'http://localhost:5352',
changeOrigin: true,
secure: false
},
'/api': {
target: env.VITE_API_FULL_URL || 'http://localhost:5352',
changeOrigin: true,
secure: false
}
}
},
build: {
outDir: 'dist',
assetsDir: 'assets',
rollupOptions: {
output: {
assetFileNames: 'assets/[name]-[hash][extname]',
chunkFileNames: 'assets/[name]-[hash].js',
entryFileNames: 'assets/[name]-[hash].js'
}
}
},
define: {
// 将环境变量注入到应用中
__APP_ENV__: JSON.stringify(env)
}
}
})