修改文件结构,统一文档格式
This commit is contained in:
18
admin-system/frontend/.env.example
Normal file
18
admin-system/frontend/.env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# 环境变量配置示例
|
||||
# 复制此文件为 .env.local 并填入真实的配置值
|
||||
|
||||
# 百度地图API密钥
|
||||
# 请访问 http://lbsyun.baidu.com/apiconsole/key 申请有效的API密钥
|
||||
# 注意:应用类型必须选择「浏览器端」
|
||||
# 如果遇到「APP不存在,AK有误」错误,请检查:
|
||||
# 1. API密钥是否正确
|
||||
# 2. 应用类型是否为「浏览器端」
|
||||
# 3. Referer白名单配置(开发环境可设置为 *)
|
||||
# 4. API密钥状态是否为「启用」
|
||||
VITE_BAIDU_MAP_API_KEY=your_valid_baidu_map_api_key_here
|
||||
|
||||
# API服务地址
|
||||
VITE_API_BASE_URL=http://localhost:5350/api
|
||||
|
||||
# 应用环境
|
||||
VITE_APP_ENV=development
|
||||
1068
admin-system/frontend/components/PerformanceMonitor.vue
Normal file
1068
admin-system/frontend/components/PerformanceMonitor.vue
Normal file
File diff suppressed because it is too large
Load Diff
13
admin-system/frontend/index.html
Normal file
13
admin-system/frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>宁夏智慧养殖监管平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1662
admin-system/frontend/package-lock.json
generated
Normal file
1662
admin-system/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
admin-system/frontend/package.json
Normal file
22
admin-system/frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "nxxmdata-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "宁夏智慧养殖监管平台前端",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"ant-design-vue": "^4.0.0",
|
||||
"axios": "^1.11.0",
|
||||
"echarts": "^5.4.0",
|
||||
"pinia": "^2.0.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
373
admin-system/frontend/public/debug-coordinate-input.html
Normal file
373
admin-system/frontend/public/debug-coordinate-input.html
Normal file
@@ -0,0 +1,373 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>经纬度输入调试</title>
|
||||
<style>
|
||||
body {
|
||||
padding: 20px;
|
||||
font-family: Arial, sans-serif;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.debug-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.debug-section {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.debug-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15px;
|
||||
color: #1890ff;
|
||||
}
|
||||
.debug-info {
|
||||
background: #f0f0f0;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
font-family: 'Courier New', monospace;
|
||||
white-space: pre-wrap;
|
||||
border-left: 4px solid #52c41a;
|
||||
font-size: 13px;
|
||||
}
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.input-item {
|
||||
flex: 1;
|
||||
}
|
||||
.input-item label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.input-item input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.input-item input:focus {
|
||||
border-color: #1890ff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
.test-case {
|
||||
margin-bottom: 10px;
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.test-case-info {
|
||||
flex: 1;
|
||||
}
|
||||
.test-case-name {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.test-case-values {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
button {
|
||||
padding: 6px 12px;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
button:hover {
|
||||
background: #40a9ff;
|
||||
}
|
||||
button:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.log-area {
|
||||
height: 200px;
|
||||
overflow-y: auto;
|
||||
background: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.clear-btn {
|
||||
background: #ff4d4f;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.clear-btn:hover {
|
||||
background: #ff7875;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="debug-container">
|
||||
<h1>经纬度输入调试工具</h1>
|
||||
|
||||
<!-- 实时输入测试 -->
|
||||
<div class="debug-section">
|
||||
<div class="debug-title">实时输入测试</div>
|
||||
<div class="input-group">
|
||||
<div class="input-item">
|
||||
<label>经度 (Longitude):</label>
|
||||
<input
|
||||
type="text"
|
||||
id="longitude"
|
||||
placeholder="请输入经度 (-180 到 180)"
|
||||
oninput="handleCoordinateInput('longitude', this.value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-item">
|
||||
<label>纬度 (Latitude):</label>
|
||||
<input
|
||||
type="text"
|
||||
id="latitude"
|
||||
placeholder="请输入纬度 (-90 到 90)"
|
||||
oninput="handleCoordinateInput('latitude', this.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="debug-info" id="currentValues">
|
||||
当前值:
|
||||
经度: undefined (类型: undefined)
|
||||
纬度: undefined (类型: undefined)
|
||||
|
||||
提交数据:
|
||||
{}
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px;">
|
||||
<button onclick="simulateSubmit()">模拟提交到后端</button>
|
||||
<button onclick="clearCoordinates()" style="margin-left: 10px;">清空坐标</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预设测试用例 -->
|
||||
<div class="debug-section">
|
||||
<div class="debug-title">预设测试用例</div>
|
||||
<div id="testCases"></div>
|
||||
</div>
|
||||
|
||||
<!-- 操作日志 -->
|
||||
<div class="debug-section">
|
||||
<div class="debug-title">操作日志</div>
|
||||
<div class="log-area" id="logArea"></div>
|
||||
<button class="clear-btn" onclick="clearLogs()">清空日志</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 全局状态
|
||||
let formData = {
|
||||
longitude: undefined,
|
||||
latitude: undefined
|
||||
};
|
||||
|
||||
let logs = [];
|
||||
|
||||
const testCases = [
|
||||
{ name: '正常小数', longitude: '106.2400', latitude: '38.4900' },
|
||||
{ name: '整数', longitude: '106', latitude: '38' },
|
||||
{ name: '高精度小数', longitude: '106.234567', latitude: '38.487654' },
|
||||
{ name: '负数', longitude: '-106.2400', latitude: '-38.4900' },
|
||||
{ name: '包含字母', longitude: '106.24abc', latitude: '38.49xyz' },
|
||||
{ name: '多个小数点', longitude: '106.24.56', latitude: '38.49.78' },
|
||||
{ name: '空字符串', longitude: '', latitude: '' },
|
||||
{ name: '只有小数点', longitude: '.', latitude: '.' },
|
||||
{ name: '边界值', longitude: '180.0', latitude: '90.0' },
|
||||
{ name: '超出范围', longitude: '200.0', latitude: '100.0' }
|
||||
];
|
||||
|
||||
// 坐标解析器
|
||||
function parseCoordinate(value) {
|
||||
if (!value) return value;
|
||||
|
||||
// 移除非数字字符,保留小数点和负号
|
||||
const cleaned = value.toString().replace(/[^\d.-]/g, '');
|
||||
|
||||
// 确保只有一个小数点
|
||||
const parts = cleaned.split('.');
|
||||
let result;
|
||||
if (parts.length > 2) {
|
||||
result = parts[0] + '.' + parts.slice(1).join('');
|
||||
} else {
|
||||
result = cleaned;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 处理坐标输入
|
||||
function handleCoordinateInput(type, value) {
|
||||
log(`${type === 'longitude' ? '经度' : '纬度'}输入: "${value}"`);
|
||||
|
||||
const parsed = parseCoordinate(value);
|
||||
log(`${type === 'longitude' ? '经度' : '纬度'}解析后: "${parsed}"`);
|
||||
|
||||
// 转换为数字
|
||||
const numValue = parsed === '' ? undefined : parseFloat(parsed);
|
||||
formData[type] = isNaN(numValue) ? undefined : numValue;
|
||||
|
||||
log(`${type === 'longitude' ? '经度' : '纬度'}最终值: ${formData[type]} (类型: ${typeof formData[type]})`);
|
||||
|
||||
// 更新输入框显示
|
||||
document.getElementById(type).value = parsed;
|
||||
|
||||
// 更新显示
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
// 更新显示
|
||||
function updateDisplay() {
|
||||
const currentValuesDiv = document.getElementById('currentValues');
|
||||
const submitData = getSubmitData();
|
||||
|
||||
currentValuesDiv.textContent = `当前值:
|
||||
经度: ${formData.longitude} (类型: ${typeof formData.longitude})
|
||||
纬度: ${formData.latitude} (类型: ${typeof formData.latitude})
|
||||
|
||||
提交数据:
|
||||
${JSON.stringify(submitData, null, 2)}`;
|
||||
}
|
||||
|
||||
// 获取提交数据
|
||||
function getSubmitData() {
|
||||
const submitData = {
|
||||
name: '测试农场',
|
||||
type: 'farm',
|
||||
owner: '测试负责人',
|
||||
phone: '13800138000',
|
||||
address: '测试地址',
|
||||
status: 'active'
|
||||
};
|
||||
|
||||
// 只有当经纬度有效时才添加
|
||||
if (formData.longitude !== undefined && formData.longitude !== null) {
|
||||
submitData.longitude = formData.longitude;
|
||||
}
|
||||
if (formData.latitude !== undefined && formData.latitude !== null) {
|
||||
submitData.latitude = formData.latitude;
|
||||
}
|
||||
|
||||
return submitData;
|
||||
}
|
||||
|
||||
// 应用测试用例
|
||||
function applyTestCase(testCase) {
|
||||
log(`\n=== 应用测试用例: ${testCase.name} ===`);
|
||||
log(`设置经度: "${testCase.longitude}"`);
|
||||
log(`设置纬度: "${testCase.latitude}"`);
|
||||
|
||||
// 设置输入框值并触发处理
|
||||
document.getElementById('longitude').value = testCase.longitude;
|
||||
document.getElementById('latitude').value = testCase.latitude;
|
||||
|
||||
handleCoordinateInput('longitude', testCase.longitude);
|
||||
handleCoordinateInput('latitude', testCase.latitude);
|
||||
}
|
||||
|
||||
// 模拟提交
|
||||
async function simulateSubmit() {
|
||||
log(`\n=== 模拟API提交 ===`);
|
||||
|
||||
const submitData = getSubmitData();
|
||||
log(`提交数据: ${JSON.stringify(submitData, null, 2)}`);
|
||||
|
||||
try {
|
||||
// 模拟API调用
|
||||
const response = await fetch('http://localhost:5350/api/farms', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(submitData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
log(`API响应成功: ${JSON.stringify(result)}`);
|
||||
alert('提交成功');
|
||||
} catch (error) {
|
||||
log(`API调用失败: ${error.message}`);
|
||||
alert('API调用失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 清空坐标
|
||||
function clearCoordinates() {
|
||||
formData.longitude = undefined;
|
||||
formData.latitude = undefined;
|
||||
document.getElementById('longitude').value = '';
|
||||
document.getElementById('latitude').value = '';
|
||||
updateDisplay();
|
||||
log('已清空坐标');
|
||||
}
|
||||
|
||||
// 日志功能
|
||||
function log(message) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
logs.push(`[${timestamp}] ${message}`);
|
||||
updateLogDisplay();
|
||||
}
|
||||
|
||||
function updateLogDisplay() {
|
||||
const logArea = document.getElementById('logArea');
|
||||
logArea.textContent = logs.join('\n');
|
||||
logArea.scrollTop = logArea.scrollHeight;
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs = [];
|
||||
updateLogDisplay();
|
||||
}
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 渲染测试用例
|
||||
const testCasesDiv = document.getElementById('testCases');
|
||||
testCases.forEach(testCase => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'test-case';
|
||||
div.innerHTML = `
|
||||
<div class="test-case-info">
|
||||
<div class="test-case-name">${testCase.name}</div>
|
||||
<div class="test-case-values">经度: ${testCase.longitude}, 纬度: ${testCase.latitude}</div>
|
||||
</div>
|
||||
<button onclick="applyTestCase({name: '${testCase.name}', longitude: '${testCase.longitude}', latitude: '${testCase.latitude}'})">应用</button>
|
||||
`;
|
||||
testCasesDiv.appendChild(div);
|
||||
});
|
||||
|
||||
log('调试工具已加载');
|
||||
log('请使用测试用例或手动输入来测试经纬度输入框');
|
||||
updateDisplay();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
52
admin-system/frontend/public/debug-devices.html
Normal file
52
admin-system/frontend/public/debug-devices.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>设备数据调试</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>设备数据调试</h1>
|
||||
<div id="result"></div>
|
||||
|
||||
<script>
|
||||
async function testDeviceAPI() {
|
||||
const resultDiv = document.getElementById('result');
|
||||
|
||||
try {
|
||||
console.log('开始测试设备API...');
|
||||
|
||||
// 测试API调用
|
||||
const response = await fetch('http://localhost:5350/api/devices/public');
|
||||
const data = await response.json();
|
||||
|
||||
console.log('API响应:', data);
|
||||
|
||||
// 显示结果
|
||||
const devices = data.data || [];
|
||||
const statusCount = {};
|
||||
devices.forEach(device => {
|
||||
statusCount[device.status] = (statusCount[device.status] || 0) + 1;
|
||||
});
|
||||
|
||||
resultDiv.innerHTML = `
|
||||
<h2>API测试结果</h2>
|
||||
<p><strong>设备总数:</strong> ${devices.length}</p>
|
||||
<p><strong>在线设备:</strong> ${statusCount.online || 0}</p>
|
||||
<p><strong>离线设备:</strong> ${statusCount.offline || 0}</p>
|
||||
<p><strong>维护设备:</strong> ${statusCount.maintenance || 0}</p>
|
||||
<h3>前3个设备:</h3>
|
||||
<pre>${JSON.stringify(devices.slice(0, 3), null, 2)}</pre>
|
||||
`;
|
||||
|
||||
} catch (error) {
|
||||
console.error('API测试失败:', error);
|
||||
resultDiv.innerHTML = `<p style="color: red;">API测试失败: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载后执行测试
|
||||
window.addEventListener('load', testDeviceAPI);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
187
admin-system/frontend/public/debug-users.html
Normal file
187
admin-system/frontend/public/debug-users.html
Normal file
@@ -0,0 +1,187 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>用户管理调试页面</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.status {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
button {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
pre {
|
||||
background-color: #f8f9fa;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>用户管理调试页面</h1>
|
||||
|
||||
<div id="tokenStatus" class="status info">
|
||||
检查中...
|
||||
</div>
|
||||
|
||||
<div id="apiStatus" class="status info">
|
||||
等待测试...
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button onclick="checkToken()">检查Token</button>
|
||||
<button onclick="testLogin()">测试登录</button>
|
||||
<button onclick="testUsersAPI()">测试用户API</button>
|
||||
<button onclick="clearToken()">清除Token</button>
|
||||
</div>
|
||||
|
||||
<div id="results">
|
||||
<h3>测试结果:</h3>
|
||||
<pre id="output"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE_URL = 'http://localhost:5350/api';
|
||||
|
||||
function log(message) {
|
||||
const output = document.getElementById('output');
|
||||
output.textContent += new Date().toLocaleTimeString() + ': ' + message + '\n';
|
||||
}
|
||||
|
||||
function checkToken() {
|
||||
const token = localStorage.getItem('token');
|
||||
const tokenStatus = document.getElementById('tokenStatus');
|
||||
|
||||
if (token) {
|
||||
tokenStatus.className = 'status success';
|
||||
tokenStatus.textContent = `Token存在 (长度: ${token.length})`;
|
||||
log('Token存在: ' + token.substring(0, 50) + '...');
|
||||
} else {
|
||||
tokenStatus.className = 'status error';
|
||||
tokenStatus.textContent = 'Token不存在';
|
||||
log('Token不存在');
|
||||
}
|
||||
}
|
||||
|
||||
async function testLogin() {
|
||||
try {
|
||||
log('开始测试登录...');
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: 'admin',
|
||||
password: '123456'
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
log('登录响应: ' + JSON.stringify(data, null, 2));
|
||||
|
||||
if (data.success && data.token) {
|
||||
localStorage.setItem('token', data.token);
|
||||
log('Token已保存到localStorage');
|
||||
checkToken();
|
||||
} else {
|
||||
log('登录失败: ' + data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
log('登录错误: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testUsersAPI() {
|
||||
try {
|
||||
log('开始测试用户API...');
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (!token) {
|
||||
log('错误: 没有找到token,请先登录');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/users`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
log('用户API响应: ' + JSON.stringify(data, null, 2));
|
||||
|
||||
const apiStatus = document.getElementById('apiStatus');
|
||||
if (data.success && data.data) {
|
||||
apiStatus.className = 'status success';
|
||||
apiStatus.textContent = `API正常 (获取到${data.data.length}个用户)`;
|
||||
} else {
|
||||
apiStatus.className = 'status error';
|
||||
apiStatus.textContent = 'API异常';
|
||||
}
|
||||
} catch (error) {
|
||||
log('用户API错误: ' + error.message);
|
||||
const apiStatus = document.getElementById('apiStatus');
|
||||
apiStatus.className = 'status error';
|
||||
apiStatus.textContent = 'API调用失败';
|
||||
}
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
localStorage.removeItem('token');
|
||||
log('Token已清除');
|
||||
checkToken();
|
||||
}
|
||||
|
||||
// 页面加载时自动检查token
|
||||
window.onload = function() {
|
||||
checkToken();
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
90
admin-system/frontend/public/map-test.html
Normal file
90
admin-system/frontend/public/map-test.html
Normal file
@@ -0,0 +1,90 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>百度地图测试</title>
|
||||
<style>
|
||||
#mapContainer {
|
||||
width: 100%;
|
||||
height: 500px;
|
||||
border: 2px solid #ccc;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.log {
|
||||
background: #f5f5f5;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>百度地图测试页面</h1>
|
||||
<div id="mapContainer"></div>
|
||||
<div class="log" id="logContainer">正在加载百度地图API...</div>
|
||||
|
||||
<script>
|
||||
const logContainer = document.getElementById('logContainer');
|
||||
|
||||
function log(message) {
|
||||
console.log(message);
|
||||
logContainer.textContent += '\n' + new Date().toLocaleTimeString() + ': ' + message;
|
||||
}
|
||||
|
||||
// 百度地图API回调函数
|
||||
window.initBMap = function() {
|
||||
log('百度地图API加载成功');
|
||||
|
||||
try {
|
||||
// 创建地图实例
|
||||
const map = new BMap.Map('mapContainer');
|
||||
log('地图实例创建成功');
|
||||
|
||||
// 设置中心点和缩放级别
|
||||
const point = new BMap.Point(106.27, 38.47); // 银川市中心
|
||||
map.centerAndZoom(point, 12);
|
||||
log('地图中心点设置成功: ' + point.lng + ', ' + point.lat);
|
||||
|
||||
// 启用滚轮缩放
|
||||
map.enableScrollWheelZoom(true);
|
||||
log('滚轮缩放已启用');
|
||||
|
||||
// 添加控件
|
||||
map.addControl(new BMap.NavigationControl());
|
||||
map.addControl(new BMap.ScaleControl());
|
||||
log('地图控件添加成功');
|
||||
|
||||
// 添加测试标记
|
||||
const marker = new BMap.Marker(point);
|
||||
map.addOverlay(marker);
|
||||
log('测试标记添加成功');
|
||||
|
||||
// 添加信息窗口
|
||||
const infoWindow = new BMap.InfoWindow('这是一个测试标记点');
|
||||
marker.addEventListener('click', function() {
|
||||
map.openInfoWindow(infoWindow, point);
|
||||
});
|
||||
log('信息窗口事件绑定成功');
|
||||
|
||||
log('地图初始化完成!');
|
||||
|
||||
} catch (error) {
|
||||
log('地图初始化失败: ' + error.message);
|
||||
console.error('地图初始化失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 动态加载百度地图API
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://api.map.baidu.com/api?v=3.0&ak=3AN3VahoqaXUs32U8luXD2Dwn86KK5B7&callback=initBMap';
|
||||
script.onerror = function() {
|
||||
log('百度地图API加载失败');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
log('开始加载百度地图API...');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
135
admin-system/frontend/public/test-auto-login.html
Normal file
135
admin-system/frontend/public/test-auto-login.html
Normal file
@@ -0,0 +1,135 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>自动登录测试</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.test-item {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.success {
|
||||
background-color: #f6ffed;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
.error {
|
||||
background-color: #fff2f0;
|
||||
border-color: #ffccc7;
|
||||
}
|
||||
button {
|
||||
background-color: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #40a9ff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>自动登录功能测试</h1>
|
||||
|
||||
<div class="test-item">
|
||||
<h3>测试步骤:</h3>
|
||||
<ol>
|
||||
<li>点击"清除登录状态"按钮</li>
|
||||
<li>点击"访问登录页面"按钮</li>
|
||||
<li>观察是否自动登录并跳转到仪表盘</li>
|
||||
<li>点击"访问用户管理页面"测试路由守卫</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="test-item">
|
||||
<h3>当前状态:</h3>
|
||||
<p>Token: <span id="token-status">检查中...</span></p>
|
||||
<p>用户信息: <span id="user-status">检查中...</span></p>
|
||||
</div>
|
||||
|
||||
<div class="test-item">
|
||||
<h3>测试操作:</h3>
|
||||
<button onclick="clearLoginState()">清除登录状态</button>
|
||||
<button onclick="checkLoginState()">检查登录状态</button>
|
||||
<button onclick="visitLoginPage()">访问登录页面</button>
|
||||
<button onclick="visitUsersPage()">访问用户管理页面</button>
|
||||
<button onclick="visitDashboard()">访问仪表盘</button>
|
||||
</div>
|
||||
|
||||
<div class="test-item">
|
||||
<h3>测试结果:</h3>
|
||||
<div id="test-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function addResult(message, isSuccess = true) {
|
||||
const results = document.getElementById('test-results');
|
||||
const div = document.createElement('div');
|
||||
div.className = isSuccess ? 'success' : 'error';
|
||||
div.style.margin = '10px 0';
|
||||
div.style.padding = '10px';
|
||||
div.style.borderRadius = '4px';
|
||||
div.innerHTML = `<strong>${new Date().toLocaleTimeString()}</strong>: ${message}`;
|
||||
results.appendChild(div);
|
||||
}
|
||||
|
||||
function clearLoginState() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
addResult('已清除登录状态');
|
||||
checkLoginState();
|
||||
}
|
||||
|
||||
function checkLoginState() {
|
||||
const token = localStorage.getItem('token');
|
||||
const user = localStorage.getItem('user');
|
||||
|
||||
document.getElementById('token-status').textContent = token ? '已存在' : '不存在';
|
||||
document.getElementById('user-status').textContent = user ? JSON.parse(user).username : '未登录';
|
||||
|
||||
addResult(`登录状态检查 - Token: ${token ? '存在' : '不存在'}, 用户: ${user ? JSON.parse(user).username : '未登录'}`);
|
||||
}
|
||||
|
||||
function visitLoginPage() {
|
||||
addResult('正在访问登录页面...');
|
||||
window.open('http://localhost:5300/#/login', '_blank');
|
||||
}
|
||||
|
||||
function visitUsersPage() {
|
||||
addResult('正在访问用户管理页面...');
|
||||
window.open('http://localhost:5300/#/users', '_blank');
|
||||
}
|
||||
|
||||
function visitDashboard() {
|
||||
addResult('正在访问仪表盘...');
|
||||
window.open('http://localhost:5300/#/dashboard', '_blank');
|
||||
}
|
||||
|
||||
// 页面加载时检查状态
|
||||
window.onload = function() {
|
||||
checkLoginState();
|
||||
addResult('测试页面已加载,请按照测试步骤进行操作');
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
227
admin-system/frontend/public/test-users-display.html
Normal file
227
admin-system/frontend/public/test-users-display.html
Normal file
@@ -0,0 +1,227 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>用户数据显示测试</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 1000px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.test-item {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.success {
|
||||
background-color: #f6ffed;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
.error {
|
||||
background-color: #fff2f0;
|
||||
border-color: #ffccc7;
|
||||
}
|
||||
button {
|
||||
background-color: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #40a9ff;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 15px;
|
||||
}
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.json-display {
|
||||
background: #f8f8f8;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>用户数据显示测试</h1>
|
||||
|
||||
<div class="test-item">
|
||||
<h3>测试步骤:</h3>
|
||||
<ol>
|
||||
<li>点击"测试API调用"按钮</li>
|
||||
<li>查看API响应数据</li>
|
||||
<li>点击"访问用户管理页面"查看前端显示</li>
|
||||
<li>对比数据是否一致</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="test-item">
|
||||
<h3>API测试:</h3>
|
||||
<button onclick="testAPI()">测试API调用</button>
|
||||
<button onclick="visitUsersPage()">访问用户管理页面</button>
|
||||
<button onclick="checkLocalStorage()">检查本地存储</button>
|
||||
</div>
|
||||
|
||||
<div class="test-item">
|
||||
<h3>API响应数据:</h3>
|
||||
<div id="api-response" class="json-display">点击"测试API调用"查看数据...</div>
|
||||
</div>
|
||||
|
||||
<div class="test-item">
|
||||
<h3>用户数据表格:</h3>
|
||||
<div id="users-table">暂无数据</div>
|
||||
</div>
|
||||
|
||||
<div class="test-item">
|
||||
<h3>测试结果:</h3>
|
||||
<div id="test-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function addResult(message, isSuccess = true) {
|
||||
const results = document.getElementById('test-results');
|
||||
const div = document.createElement('div');
|
||||
div.className = isSuccess ? 'success' : 'error';
|
||||
div.style.margin = '10px 0';
|
||||
div.style.padding = '10px';
|
||||
div.style.borderRadius = '4px';
|
||||
div.innerHTML = `<strong>${new Date().toLocaleTimeString()}</strong>: ${message}`;
|
||||
results.appendChild(div);
|
||||
}
|
||||
|
||||
async function testAPI() {
|
||||
try {
|
||||
addResult('正在调用用户API...');
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
addResult('未找到token,请先登录', false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('http://localhost:5350/api/users', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 显示API响应
|
||||
document.getElementById('api-response').textContent = JSON.stringify(data, null, 2);
|
||||
|
||||
// 创建用户表格
|
||||
if (data.success && data.data) {
|
||||
createUsersTable(data.data);
|
||||
addResult(`API调用成功,获取到 ${data.data.length} 个用户`);
|
||||
} else {
|
||||
addResult('API响应格式异常', false);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
addResult(`API调用失败: ${error.message}`, false);
|
||||
document.getElementById('api-response').textContent = `错误: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function createUsersTable(users) {
|
||||
const tableContainer = document.getElementById('users-table');
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
tableContainer.innerHTML = '<p>没有用户数据</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let tableHTML = `
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>邮箱</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
users.forEach(user => {
|
||||
tableHTML += `
|
||||
<tr>
|
||||
<td>${user.id}</td>
|
||||
<td>${user.username}</td>
|
||||
<td>${user.email}</td>
|
||||
<td>${user.role || '未知'}</td>
|
||||
<td>${user.status || '未知'}</td>
|
||||
<td>${user.created_at ? new Date(user.created_at).toLocaleString('zh-CN') : '未知'}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
tableHTML += `
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
tableContainer.innerHTML = tableHTML;
|
||||
}
|
||||
|
||||
function visitUsersPage() {
|
||||
addResult('正在打开用户管理页面...');
|
||||
window.open('http://localhost:5300/#/users', '_blank');
|
||||
}
|
||||
|
||||
function checkLocalStorage() {
|
||||
const token = localStorage.getItem('token');
|
||||
const user = localStorage.getItem('user');
|
||||
|
||||
addResult(`Token: ${token ? '存在' : '不存在'}, 用户信息: ${user ? '存在' : '不存在'}`);
|
||||
|
||||
if (token) {
|
||||
addResult('Token内容: ' + token.substring(0, 50) + '...');
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时检查登录状态
|
||||
window.onload = function() {
|
||||
checkLocalStorage();
|
||||
addResult('测试页面已加载,请按照测试步骤进行操作');
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
113
admin-system/frontend/src/App.vue
Normal file
113
admin-system/frontend/src/App.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div v-if="isLoggedIn">
|
||||
<a-layout style="min-height: 100vh">
|
||||
<a-layout-header class="header">
|
||||
<div class="logo">
|
||||
<a-button
|
||||
type="text"
|
||||
@click="settingsStore.toggleSidebar"
|
||||
style="color: white; margin-right: 8px;"
|
||||
>
|
||||
<menu-unfold-outlined v-if="sidebarCollapsed" />
|
||||
<menu-fold-outlined v-else />
|
||||
</a-button>
|
||||
宁夏智慧养殖监管平台
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span>欢迎, {{ userData?.username }}</span>
|
||||
<a-button type="link" @click="handleLogout" style="color: white;">退出</a-button>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
|
||||
<a-layout>
|
||||
<a-layout-sider
|
||||
width="200"
|
||||
style="background: #fff"
|
||||
:collapsed="sidebarCollapsed"
|
||||
collapsible
|
||||
>
|
||||
<Menu />
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout style="padding: 0 24px 24px">
|
||||
<a-layout-content
|
||||
:style="{ background: '#fff', padding: '24px', margin: '16px 0' }"
|
||||
>
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
|
||||
<a-layout-footer style="text-align: center">
|
||||
宁夏智慧养殖监管平台 ©2025
|
||||
</a-layout-footer>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</div>
|
||||
<div v-else>
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Menu from './components/Menu.vue'
|
||||
import { useUserStore, useSettingsStore } from './stores'
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
// 使用Pinia状态管理
|
||||
const userStore = useUserStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const router = useRouter()
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => userStore.isLoggedIn)
|
||||
const userData = computed(() => userStore.userData)
|
||||
const sidebarCollapsed = computed(() => settingsStore.sidebarCollapsed)
|
||||
|
||||
// 注意:路由守卫已移至router/index.js
|
||||
|
||||
// 监听多标签页登录状态同步
|
||||
const handleStorageChange = (event) => {
|
||||
if (event.key === 'token' || event.key === 'user') {
|
||||
userStore.checkLoginStatus()
|
||||
}
|
||||
}
|
||||
|
||||
// 登出处理
|
||||
const handleLogout = () => {
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
userStore.checkLoginStatus()
|
||||
window.addEventListener('storage', handleStorageChange)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('storage', handleStorageChange)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #001529;
|
||||
color: white;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
324
admin-system/frontend/src/components/AlertStats.vue
Normal file
324
admin-system/frontend/src/components/AlertStats.vue
Normal file
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<div class="alert-stats">
|
||||
<a-card :bordered="false" title="预警数据统计">
|
||||
<div class="stats-summary">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ totalAlerts }}</div>
|
||||
<div class="stat-label">预警总数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ criticalAlerts }}</div>
|
||||
<div class="stat-label">严重预警</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ resolvedRate }}%</div>
|
||||
<div class="stat-label">解决率</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="chart-container">
|
||||
<e-chart :options="alertTypeOptions" height="300px" />
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="alert-table">
|
||||
<a-table
|
||||
:dataSource="alertTableData"
|
||||
:columns="alertColumns"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'level'">
|
||||
<a-tag :color="getLevelColor(record.level)">
|
||||
{{ record.level }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag
|
||||
:color="getStatusColor(record.status)"
|
||||
style="cursor: pointer"
|
||||
@click="handleStatusUpdate(record)"
|
||||
>
|
||||
{{ getStatusLabel(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useDataStore } from '../stores'
|
||||
import EChart from './EChart.vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { api } from '../utils/api'
|
||||
|
||||
// 使用数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 预警总数
|
||||
const totalAlerts = computed(() => dataStore.alerts.length)
|
||||
|
||||
// 严重预警数量
|
||||
const criticalAlerts = computed(() => {
|
||||
return dataStore.alerts.filter(alert => alert.level === '严重').length
|
||||
})
|
||||
|
||||
// 已解决预警数量
|
||||
const resolvedAlerts = computed(() => {
|
||||
return dataStore.alerts.filter(alert => alert.status === '已解决').length
|
||||
})
|
||||
|
||||
// 解决率
|
||||
const resolvedRate = computed(() => {
|
||||
if (totalAlerts.value === 0) return 0
|
||||
return Math.round((resolvedAlerts.value / totalAlerts.value) * 100)
|
||||
})
|
||||
|
||||
// 预警类型分布图表选项
|
||||
const alertTypeOptions = computed(() => {
|
||||
// 按类型分组统计
|
||||
const typeCount = {}
|
||||
dataStore.alerts.forEach(alert => {
|
||||
typeCount[alert.type] = (typeCount[alert.type] || 0) + 1
|
||||
})
|
||||
|
||||
// 转换为图表数据格式
|
||||
const data = Object.keys(typeCount).map(type => ({
|
||||
value: typeCount[type],
|
||||
name: type
|
||||
}))
|
||||
|
||||
return {
|
||||
title: {
|
||||
text: '预警类型分布',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '预警类型',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: data
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 预警表格数据
|
||||
const alertTableData = computed(() => {
|
||||
return dataStore.alerts.map(alert => {
|
||||
const farm = dataStore.farms.find(f => f.id === alert.farm_id)
|
||||
const device = dataStore.devices.find(d => d.id === alert.device_id)
|
||||
return {
|
||||
key: alert.id,
|
||||
id: alert.id,
|
||||
type: alert.type,
|
||||
level: alert.level,
|
||||
message: alert.message,
|
||||
farmId: alert.farm_id,
|
||||
farmName: farm ? farm.name : `养殖场 #${alert.farm_id}`,
|
||||
deviceId: alert.device_id,
|
||||
deviceName: device ? device.name : `设备 #${alert.device_id}`,
|
||||
status: alert.status,
|
||||
timestamp: alert.created_at
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 预警表格列定义
|
||||
const alertColumns = [
|
||||
{
|
||||
title: '预警类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
filters: [
|
||||
{ text: '温度异常', value: '温度异常' },
|
||||
{ text: '湿度异常', value: '湿度异常' },
|
||||
{ text: '设备故障', value: '设备故障' },
|
||||
{ text: '电量不足', value: '电量不足' },
|
||||
{ text: '网络异常', value: '网络异常' }
|
||||
],
|
||||
onFilter: (value, record) => record.type === value
|
||||
},
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'level',
|
||||
key: 'level',
|
||||
filters: [
|
||||
{ text: '轻微', value: '轻微' },
|
||||
{ text: '一般', value: '一般' },
|
||||
{ text: '严重', value: '严重' }
|
||||
],
|
||||
onFilter: (value, record) => record.level === value
|
||||
},
|
||||
{
|
||||
title: '养殖场',
|
||||
dataIndex: 'farmName',
|
||||
key: 'farmName'
|
||||
},
|
||||
{
|
||||
title: '设备',
|
||||
dataIndex: 'deviceName',
|
||||
key: 'deviceName'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
filters: [
|
||||
{ text: '未处理', value: '未处理' },
|
||||
{ text: '处理中', value: '处理中' },
|
||||
{ text: '已解决', value: '已解决' }
|
||||
],
|
||||
onFilter: (value, record) => record.status === value
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'timestamp',
|
||||
key: 'timestamp',
|
||||
sorter: (a, b) => new Date(a.timestamp) - new Date(b.timestamp),
|
||||
defaultSortOrder: 'descend'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取级别颜色
|
||||
function getLevelColor(level) {
|
||||
switch (level) {
|
||||
case '轻微': return 'blue'
|
||||
case '一般': return 'orange'
|
||||
case '严重': return 'red'
|
||||
default: return 'blue'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
function getStatusColor(status) {
|
||||
switch (status) {
|
||||
case 'active': return 'error'
|
||||
case 'acknowledged': return 'processing'
|
||||
case 'resolved': return 'success'
|
||||
case '未处理': return 'error'
|
||||
case '处理中': return 'processing'
|
||||
case '已解决': return 'success'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
// 处理状态更新
|
||||
function handleStatusUpdate(record) {
|
||||
const statusOptions = [
|
||||
{ label: '活跃', value: 'active' },
|
||||
{ label: '已确认', value: 'acknowledged' },
|
||||
{ label: '已解决', value: 'resolved' }
|
||||
]
|
||||
|
||||
const currentIndex = statusOptions.findIndex(option => option.value === record.status)
|
||||
const nextIndex = (currentIndex + 1) % statusOptions.length
|
||||
const nextStatus = statusOptions[nextIndex]
|
||||
|
||||
Modal.confirm({
|
||||
title: '更新预警状态',
|
||||
content: `确定要将预警状态从"${getStatusLabel(record.status)}"更新为"${nextStatus.label}"吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
// 调用API更新预警状态
|
||||
await api.put(`/alerts/public/${record.id}/status`, {
|
||||
status: nextStatus.value
|
||||
})
|
||||
|
||||
// 更新本地数据
|
||||
const alertIndex = dataStore.alerts.findIndex(alert => alert.id === record.id)
|
||||
if (alertIndex !== -1) {
|
||||
dataStore.alerts[alertIndex].status = nextStatus.value
|
||||
}
|
||||
|
||||
message.success(`预警状态已更新为"${nextStatus.label}"`)
|
||||
} catch (error) {
|
||||
console.error('更新预警状态失败:', error)
|
||||
message.error('更新预警状态失败,请重试')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取状态标签
|
||||
function getStatusLabel(status) {
|
||||
switch (status) {
|
||||
case 'active': return '活跃'
|
||||
case 'acknowledged': return '已确认'
|
||||
case 'resolved': return '已解决'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alert-stats {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats-summary {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.alert-table {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
238
admin-system/frontend/src/components/AnimalStats.vue
Normal file
238
admin-system/frontend/src/components/AnimalStats.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div class="animal-stats">
|
||||
<a-card :bordered="false" title="动物数据统计">
|
||||
<div class="stats-summary">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ totalAnimals }}</div>
|
||||
<div class="stat-label">动物总数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ animalTypes.length }}</div>
|
||||
<div class="stat-label">动物种类</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ averagePerFarm }}</div>
|
||||
<div class="stat-label">平均数量/场</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="chart-container">
|
||||
<e-chart :options="typeDistributionOptions" height="300px" />
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="animal-table">
|
||||
<a-table
|
||||
:dataSource="animalTableData"
|
||||
:columns="animalColumns"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'count'">
|
||||
<a-progress
|
||||
:percent="getPercentage(record.count, maxCount)"
|
||||
:stroke-color="getProgressColor(record.count, maxCount)"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useDataStore } from '../stores'
|
||||
import EChart from './EChart.vue'
|
||||
|
||||
// 使用数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(async () => {
|
||||
console.log('AnimalStats: 组件挂载,开始加载数据')
|
||||
await dataStore.fetchAllData()
|
||||
console.log('AnimalStats: 数据加载完成')
|
||||
console.log('farms数据:', dataStore.farms)
|
||||
console.log('animals数据:', dataStore.animals)
|
||||
})
|
||||
|
||||
// 动物总数
|
||||
const totalAnimals = computed(() => {
|
||||
return dataStore.animals.reduce((total, animal) => total + (animal.count || 0), 0)
|
||||
})
|
||||
|
||||
// 动物种类
|
||||
const animalTypes = computed(() => {
|
||||
const types = new Set(dataStore.animals.map(animal => animal.type))
|
||||
return Array.from(types)
|
||||
})
|
||||
|
||||
// 平均每个养殖场的动物数量
|
||||
const averagePerFarm = computed(() => {
|
||||
if (dataStore.farms.length === 0) return 0
|
||||
return Math.round(totalAnimals.value / dataStore.farms.length)
|
||||
})
|
||||
|
||||
// 动物类型分布图表选项
|
||||
const typeDistributionOptions = computed(() => {
|
||||
// 按类型分组统计
|
||||
const typeCount = {}
|
||||
dataStore.animals.forEach(animal => {
|
||||
typeCount[animal.type] = (typeCount[animal.type] || 0) + (animal.count || 0)
|
||||
})
|
||||
|
||||
// 转换为图表数据格式
|
||||
const data = Object.keys(typeCount).map(type => ({
|
||||
value: typeCount[type],
|
||||
name: type
|
||||
}))
|
||||
|
||||
return {
|
||||
title: {
|
||||
text: '动物类型分布',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '动物类型',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: data
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 动物表格数据
|
||||
const animalTableData = computed(() => {
|
||||
// 按类型和养殖场分组统计
|
||||
const groupMap = new Map()
|
||||
|
||||
dataStore.animals.forEach(animal => {
|
||||
const key = `${animal.farm_id}-${animal.type}`
|
||||
if (!groupMap.has(key)) {
|
||||
const farm = dataStore.farms.find(f => f.id === animal.farm_id)
|
||||
groupMap.set(key, {
|
||||
key: key,
|
||||
farmId: animal.farm_id,
|
||||
farmName: farm ? farm.name : `养殖场 #${animal.farm_id}`,
|
||||
type: animal.type,
|
||||
count: 0
|
||||
})
|
||||
}
|
||||
groupMap.get(key).count += (animal.count || 0)
|
||||
})
|
||||
|
||||
// 转换为数组并按数量降序排序
|
||||
return Array.from(groupMap.values()).sort((a, b) => b.count - a.count)
|
||||
})
|
||||
|
||||
// 最大数量
|
||||
const maxCount = computed(() => {
|
||||
if (animalTableData.value.length === 0) return 1
|
||||
return Math.max(...animalTableData.value.map(item => item.count))
|
||||
})
|
||||
|
||||
// 动物表格列定义
|
||||
const animalColumns = [
|
||||
{
|
||||
title: '养殖场',
|
||||
dataIndex: 'farmName',
|
||||
key: 'farmName'
|
||||
},
|
||||
{
|
||||
title: '动物类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type'
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
sorter: (a, b) => a.count - b.count,
|
||||
defaultSortOrder: 'descend'
|
||||
}
|
||||
]
|
||||
|
||||
// 计算百分比
|
||||
function getPercentage(value, max) {
|
||||
return Math.round((value / max) * 100)
|
||||
}
|
||||
|
||||
// 获取进度条颜色
|
||||
function getProgressColor(value, max) {
|
||||
const percentage = getPercentage(value, max)
|
||||
if (percentage < 30) return '#52c41a'
|
||||
if (percentage < 70) return '#1890ff'
|
||||
return '#ff4d4f'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.animal-stats {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats-summary {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.animal-table {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
386
admin-system/frontend/src/components/BaiduMap.vue
Normal file
386
admin-system/frontend/src/components/BaiduMap.vue
Normal file
@@ -0,0 +1,386 @@
|
||||
<template>
|
||||
<div class="baidu-map-wrapper">
|
||||
<div class="baidu-map-container" ref="mapContainer"></div>
|
||||
|
||||
<!-- 自定义缩放控制按钮 -->
|
||||
<div class="map-zoom-controls" v-if="isReady">
|
||||
<button class="zoom-btn zoom-in" @click="zoomIn" title="放大">
|
||||
<span class="zoom-icon">+</span>
|
||||
</button>
|
||||
<button class="zoom-btn zoom-out" @click="zoomOut" title="缩小">
|
||||
<span class="zoom-icon">−</span>
|
||||
</button>
|
||||
<button class="zoom-btn zoom-reset" @click="resetZoom" title="重置缩放">
|
||||
<span class="zoom-icon">⌂</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 缩放级别显示 -->
|
||||
<div class="zoom-level-display" v-if="isReady && showZoomLevel">
|
||||
缩放级别: {{ currentZoom }}
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="map-loading-overlay">
|
||||
<div class="loading-content">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>地图加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 新增:加载状态信息div -->
|
||||
<div v-if="statusText" class="map-status">{{ statusText }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { createMap, addMarkers, clearOverlays, setViewToFitMarkers } from '../utils/mapService'
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
// 地图标记数据
|
||||
markers: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 地图配置选项
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
// 地图高度
|
||||
height: {
|
||||
type: String,
|
||||
default: '100%'
|
||||
},
|
||||
// 是否显示缩放级别
|
||||
showZoomLevel: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['map-ready', 'marker-click'])
|
||||
|
||||
// 地图容器引用
|
||||
const mapContainer = ref(null)
|
||||
|
||||
// 组件状态
|
||||
const isLoading = ref(true)
|
||||
// 新增:加载状态文本
|
||||
const statusText = ref('')
|
||||
let clearStatusTimer = null
|
||||
// 新增:地图就绪标志
|
||||
const isReady = ref(false)
|
||||
|
||||
// 缩放相关状态
|
||||
const currentZoom = ref(10)
|
||||
const defaultZoom = ref(10)
|
||||
|
||||
// 地图实例和标记
|
||||
let baiduMap = null
|
||||
let mapMarkers = []
|
||||
let markersInitialized = false
|
||||
|
||||
// 初始化地图
|
||||
async function initMap() {
|
||||
statusText.value = '正在初始化地图...'
|
||||
console.log('BaiduMap组件: 开始初始化地图')
|
||||
console.log('地图容器:', mapContainer.value)
|
||||
console.log('容器尺寸:', mapContainer.value?.offsetWidth, 'x', mapContainer.value?.offsetHeight)
|
||||
console.log('window.BMap是否存在:', typeof window.BMap)
|
||||
|
||||
if (!mapContainer.value) {
|
||||
console.error('地图容器不存在')
|
||||
statusText.value = '地图容器不存在'
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('BaiduMap组件: 调用createMap函数')
|
||||
statusText.value = '正在创建地图实例...'
|
||||
// 创建地图实例
|
||||
baiduMap = await createMap(mapContainer.value, props.options)
|
||||
|
||||
// 验证地图实例是否有效
|
||||
if (!baiduMap || typeof baiduMap.addEventListener !== 'function') {
|
||||
throw new Error('地图实例创建失败或无效')
|
||||
}
|
||||
|
||||
console.log('BaiduMap组件: 地图创建成功', baiduMap)
|
||||
statusText.value = '地图实例创建成功,正在加载瓦片...'
|
||||
|
||||
// 监听地图完全加载完成事件
|
||||
baiduMap.addEventListener('tilesloaded', () => {
|
||||
console.log('BaiduMap组件: 地图瓦片加载完成')
|
||||
|
||||
// 只在第一次加载时初始化
|
||||
if (!markersInitialized) {
|
||||
console.log('BaiduMap组件: 首次加载,开始初始化')
|
||||
|
||||
// 标记地图就绪,防止兜底重复添加
|
||||
if (baiduMap) {
|
||||
baiduMap._markersAdded = true
|
||||
}
|
||||
|
||||
// 初始化缩放相关状态
|
||||
if (baiduMap && typeof baiduMap.getZoom === 'function') {
|
||||
currentZoom.value = baiduMap.getZoom()
|
||||
defaultZoom.value = currentZoom.value
|
||||
|
||||
// 添加缩放事件监听器
|
||||
baiduMap.addEventListener('zoomend', () => {
|
||||
if (baiduMap && typeof baiduMap.getZoom === 'function') {
|
||||
currentZoom.value = baiduMap.getZoom()
|
||||
console.log('缩放级别变化:', currentZoom.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
isReady.value = true
|
||||
isLoading.value = false
|
||||
statusText.value = '地图加载完成'
|
||||
// 自动隐藏状态条
|
||||
if (clearStatusTimer) clearTimeout(clearStatusTimer)
|
||||
clearStatusTimer = setTimeout(() => { statusText.value = '' }, 1200)
|
||||
|
||||
// 地图就绪后,检查是否有待添加的标记
|
||||
if (props.markers && props.markers.length > 0) {
|
||||
console.log('BaiduMap组件: 地图就绪,添加标记', props.markers.length)
|
||||
addMarkersToMap()
|
||||
} else {
|
||||
console.log('BaiduMap组件: 没有标记数据,添加测试标记')
|
||||
const testMarkers = [{
|
||||
location: { lng: 106.27, lat: 38.47 },
|
||||
title: '测试标记',
|
||||
content: '这是一个测试标记点'
|
||||
}]
|
||||
addMarkers(baiduMap, testMarkers)
|
||||
}
|
||||
|
||||
markersInitialized = true
|
||||
}
|
||||
})
|
||||
|
||||
// 备用方案:如果事件监听失败,使用延时
|
||||
setTimeout(() => {
|
||||
if (baiduMap && !baiduMap._markersAdded) {
|
||||
console.log('BaiduMap组件: 备用方案 - 延时添加标记')
|
||||
if (baiduMap) {
|
||||
baiduMap._markersAdded = true
|
||||
}
|
||||
isReady.value = true
|
||||
isLoading.value = false
|
||||
statusText.value = '地图加载完成(兼容模式)'
|
||||
if (clearStatusTimer) clearTimeout(clearStatusTimer)
|
||||
clearStatusTimer = setTimeout(() => { statusText.value = '' }, 1500)
|
||||
|
||||
if (props.markers && props.markers.length > 0) {
|
||||
console.log('BaiduMap组件: 备用方案 - 地图就绪,添加标记', props.markers.length)
|
||||
addMarkersToMap()
|
||||
} else {
|
||||
const testMarkers = [{
|
||||
location: { lng: 106.27, lat: 38.47 },
|
||||
title: '测试标记',
|
||||
content: '这是一个测试标记点'
|
||||
}]
|
||||
addMarkers(baiduMap, testMarkers)
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
// 触发地图就绪事件
|
||||
emit('map-ready', baiduMap)
|
||||
console.log('BaiduMap组件: 地图初始化完成')
|
||||
} catch (error) {
|
||||
console.error('初始化百度地图失败:', error)
|
||||
console.error('错误详情:', error.stack)
|
||||
statusText.value = '地图加载失败,请检查网络或密钥配置'
|
||||
isLoading.value = false
|
||||
isReady.value = false
|
||||
baiduMap = null
|
||||
// 清理状态文本
|
||||
if (clearStatusTimer) clearTimeout(clearStatusTimer)
|
||||
clearStatusTimer = setTimeout(() => { statusText.value = '' }, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加标记到地图
|
||||
function addMarkersToMap() {
|
||||
// 检查地图实例是否有效
|
||||
if (!baiduMap || !isReady.value) {
|
||||
console.warn('BaiduMap组件: 地图未就绪,跳过标记添加')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证地图实例的有效性
|
||||
if (typeof baiduMap.addEventListener !== 'function') {
|
||||
console.error('BaiduMap组件: 地图实例无效')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 清除现有标记
|
||||
clearOverlays(baiduMap)
|
||||
|
||||
// 添加新标记
|
||||
mapMarkers = addMarkers(baiduMap, props.markers, (markerData, marker) => {
|
||||
// 触发标记点击事件
|
||||
emit('marker-click', markerData, marker)
|
||||
})
|
||||
|
||||
// 调整视图以显示所有标记
|
||||
if (mapMarkers.length > 0) {
|
||||
const points = mapMarkers.map(marker => marker.getPosition())
|
||||
setViewToFitMarkers(baiduMap, points)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('BaiduMap组件: 添加标记失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 缩放方法
|
||||
function zoomIn() {
|
||||
if (baiduMap && isReady.value) {
|
||||
const currentZoomLevel = baiduMap.getZoom()
|
||||
const newZoom = Math.min(currentZoomLevel + 1, 19)
|
||||
baiduMap.setZoom(newZoom)
|
||||
currentZoom.value = newZoom
|
||||
console.log('地图放大到级别:', newZoom)
|
||||
}
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
if (baiduMap && isReady.value) {
|
||||
const currentZoomLevel = baiduMap.getZoom()
|
||||
const newZoom = Math.max(currentZoomLevel - 1, 3)
|
||||
baiduMap.setZoom(newZoom)
|
||||
currentZoom.value = newZoom
|
||||
console.log('地图缩小到级别:', newZoom)
|
||||
}
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
if (baiduMap && isReady.value) {
|
||||
// 重置到默认缩放级别和中心点
|
||||
const defaultCenter = new window.BMap.Point(106.27, 38.47)
|
||||
baiduMap.centerAndZoom(defaultCenter, defaultZoom.value)
|
||||
currentZoom.value = defaultZoom.value
|
||||
console.log('地图重置到默认状态:', defaultZoom.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听标记数据变化
|
||||
watch(() => props.markers, (newMarkers) => {
|
||||
if (baiduMap && isReady.value && newMarkers) {
|
||||
addMarkersToMap()
|
||||
} else if (newMarkers && newMarkers.length > 0) {
|
||||
// 如果地图还未就绪但有标记数据,等待地图就绪后再添加
|
||||
console.log('BaiduMap组件: 地图未就绪,等待地图加载完成后添加标记')
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 组件挂载时初始化地图
|
||||
onMounted(() => {
|
||||
console.log('BaiduMap组件: 组件已挂载')
|
||||
console.log('地图容器DOM:', mapContainer.value)
|
||||
console.log('地图容器ID:', mapContainer.value?.id)
|
||||
console.log('地图容器类名:', mapContainer.value?.className)
|
||||
|
||||
// 延迟初始化,确保DOM完全渲染
|
||||
setTimeout(() => {
|
||||
console.log('BaiduMap组件: 延迟初始化地图')
|
||||
initMap()
|
||||
}, 100)
|
||||
})
|
||||
|
||||
// 组件卸载时清理资源
|
||||
onUnmounted(() => {
|
||||
if (baiduMap) {
|
||||
clearOverlays(baiduMap)
|
||||
baiduMap = null
|
||||
}
|
||||
isReady.value = false
|
||||
markersInitialized = false
|
||||
if (clearStatusTimer) {
|
||||
clearTimeout(clearStatusTimer)
|
||||
clearStatusTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露地图实例
|
||||
defineExpose({
|
||||
getMapInstance: () => baiduMap
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.baidu-map-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.baidu-map-container {
|
||||
width: 100%;
|
||||
height: v-bind(height);
|
||||
min-height: 400px;
|
||||
position: relative;
|
||||
border: 1px solid #ddd;
|
||||
background-color: #f5f5f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* 新增:地图状态信息样式 */
|
||||
.map-status {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: rgba(0,0,0,0.55);
|
||||
color: #fff;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
z-index: 1001;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #1890ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 10px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-content p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
217
admin-system/frontend/src/components/ChartPerformanceMonitor.vue
Normal file
217
admin-system/frontend/src/components/ChartPerformanceMonitor.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<div class="chart-performance-monitor">
|
||||
<a-card title="图表性能监控" size="small" :bordered="false">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="text" size="small" @click="refreshStats">
|
||||
<template #icon><reload-outlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button type="text" size="small" @click="clearCache" danger>
|
||||
<template #icon><delete-outlined /></template>
|
||||
清理缓存
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<div class="performance-stats">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<a-statistic
|
||||
title="图表实例"
|
||||
:value="stats.chartInstances"
|
||||
suffix="个"
|
||||
:value-style="{ color: '#3f8600' }"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-statistic
|
||||
title="数据缓存"
|
||||
:value="stats.dataCache"
|
||||
suffix="项"
|
||||
:value-style="{ color: '#1890ff' }"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-statistic
|
||||
title="内存使用"
|
||||
:value="totalMemoryUsage"
|
||||
suffix="MB"
|
||||
:precision="2"
|
||||
:value-style="{ color: '#722ed1' }"
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-statistic
|
||||
title="缓存命中率"
|
||||
:value="cacheHitRate"
|
||||
suffix="%"
|
||||
:precision="1"
|
||||
:value-style="{ color: cacheHitRate > 70 ? '#3f8600' : '#faad14' }"
|
||||
/>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="performance-details">
|
||||
<a-descriptions size="small" :column="2">
|
||||
<a-descriptions-item label="渲染器">Canvas (硬件加速)</a-descriptions-item>
|
||||
<a-descriptions-item label="动画优化">启用 (300ms)</a-descriptions-item>
|
||||
<a-descriptions-item label="大数据优化">启用 (2000+)</a-descriptions-item>
|
||||
<a-descriptions-item label="防抖更新">启用 (100ms)</a-descriptions-item>
|
||||
<a-descriptions-item label="懒加载">支持</a-descriptions-item>
|
||||
<a-descriptions-item label="数据缓存TTL">2-5分钟</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</div>
|
||||
|
||||
<div class="performance-tips" v-if="showTips">
|
||||
<a-alert
|
||||
:message="performanceTip.title"
|
||||
:description="performanceTip.description"
|
||||
:type="performanceTip.type"
|
||||
show-icon
|
||||
closable
|
||||
@close="showTips = false"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ReloadOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import { getCacheStats, clearAllCache } from '../utils/chartService'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
// 性能统计数据
|
||||
const stats = ref({
|
||||
chartInstances: 0,
|
||||
dataCache: 0,
|
||||
memoryUsage: {
|
||||
charts: 0,
|
||||
data: 0
|
||||
}
|
||||
})
|
||||
|
||||
// 缓存命中统计
|
||||
const cacheHits = ref(0)
|
||||
const cacheRequests = ref(0)
|
||||
const showTips = ref(true)
|
||||
|
||||
// 计算总内存使用量
|
||||
const totalMemoryUsage = computed(() => {
|
||||
return stats.value.memoryUsage.charts + stats.value.memoryUsage.data
|
||||
})
|
||||
|
||||
// 计算缓存命中率
|
||||
const cacheHitRate = computed(() => {
|
||||
if (cacheRequests.value === 0) return 0
|
||||
return (cacheHits.value / cacheRequests.value) * 100
|
||||
})
|
||||
|
||||
// 性能提示
|
||||
const performanceTip = computed(() => {
|
||||
const hitRate = cacheHitRate.value
|
||||
const memoryUsage = totalMemoryUsage.value
|
||||
|
||||
if (memoryUsage > 50) {
|
||||
return {
|
||||
title: '内存使用较高',
|
||||
description: '建议清理缓存或减少图表实例数量以优化性能',
|
||||
type: 'warning'
|
||||
}
|
||||
} else if (hitRate < 50) {
|
||||
return {
|
||||
title: '缓存命中率较低',
|
||||
description: '考虑增加缓存时间或优化数据请求策略',
|
||||
type: 'info'
|
||||
}
|
||||
} else if (hitRate > 80) {
|
||||
return {
|
||||
title: '性能表现优秀',
|
||||
description: '图表缓存工作良好,加载速度已优化',
|
||||
type: 'success'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
title: '性能表现良好',
|
||||
description: '图表加载速度正常,缓存机制运行稳定',
|
||||
type: 'info'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 刷新统计数据
|
||||
function refreshStats() {
|
||||
stats.value = getCacheStats()
|
||||
// message.success('统计数据已刷新')
|
||||
}
|
||||
|
||||
// 清理缓存
|
||||
function clearCache() {
|
||||
clearAllCache()
|
||||
refreshStats()
|
||||
cacheHits.value = 0
|
||||
cacheRequests.value = 0
|
||||
message.success('缓存已清理')
|
||||
}
|
||||
|
||||
// 模拟缓存命中统计(实际项目中应该从chartService获取)
|
||||
function simulateCacheStats() {
|
||||
// 模拟缓存请求
|
||||
cacheRequests.value += Math.floor(Math.random() * 3) + 1
|
||||
// 模拟缓存命中
|
||||
cacheHits.value += Math.floor(Math.random() * 2) + 1
|
||||
}
|
||||
|
||||
// 定时器
|
||||
let statsTimer = null
|
||||
|
||||
// 组件挂载时开始监控
|
||||
onMounted(() => {
|
||||
refreshStats()
|
||||
|
||||
// 每5秒更新一次统计数据
|
||||
statsTimer = setInterval(() => {
|
||||
refreshStats()
|
||||
simulateCacheStats()
|
||||
}, 5000)
|
||||
})
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onUnmounted(() => {
|
||||
if (statsTimer) {
|
||||
clearInterval(statsTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-performance-monitor {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.performance-stats {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.performance-details {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.performance-tips {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-statistic-content) {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
:deep(.ant-statistic-title) {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
226
admin-system/frontend/src/components/Dashboard.vue
Normal file
226
admin-system/frontend/src/components/Dashboard.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<h1>系统概览</h1>
|
||||
|
||||
<div class="dashboard-stats">
|
||||
<a-card title="养殖场数量" :bordered="false">
|
||||
<template #extra><a-tag color="blue">总计</a-tag></template>
|
||||
<p class="stat-number">{{ farmCount }}</p>
|
||||
<p class="stat-change"><rise-outlined /> 较上月增长 {{ statsData.farmGrowth }}%</p>
|
||||
</a-card>
|
||||
|
||||
<a-card title="动物种类" :bordered="false">
|
||||
<template #extra><a-tag color="green">总计</a-tag></template>
|
||||
<p class="stat-number">{{ animalCount }}</p>
|
||||
<p class="stat-change"><rise-outlined /> 较上月增长 {{ statsData.animalGrowth }}%</p>
|
||||
</a-card>
|
||||
|
||||
<a-card title="监测设备" :bordered="false">
|
||||
<template #extra><a-tag color="orange">在线率</a-tag></template>
|
||||
<p class="stat-number">{{ deviceCount }}</p>
|
||||
<p class="stat-change">在线率 {{ deviceOnlineRate }}%</p>
|
||||
</a-card>
|
||||
|
||||
<a-card title="预警信息" :bordered="false">
|
||||
<template #extra><a-tag color="red">本月</a-tag></template>
|
||||
<p class="stat-number">{{ alertCount }}</p>
|
||||
<p class="stat-change"><fall-outlined /> 较上月下降 {{ statsData.alertReduction }}%</p>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-charts">
|
||||
<a-card title="养殖场分布" :bordered="false" class="chart-card">
|
||||
<baidu-map
|
||||
:markers="farmMarkers"
|
||||
height="350px"
|
||||
@marker-click="handleFarmClick"
|
||||
/>
|
||||
</a-card>
|
||||
|
||||
<!-- <a-card title="月度数据趋势" :bordered="false" class="chart-card">
|
||||
<div ref="trendChart" class="chart-container"></div>
|
||||
</a-card> -->
|
||||
</div>
|
||||
|
||||
<!-- 养殖场详情抽屉 -->
|
||||
<a-drawer
|
||||
:title="`养殖场详情 - ${selectedFarmId ? dataStore.farms.find(f => f.id == selectedFarmId)?.name : ''}`"
|
||||
:width="600"
|
||||
:visible="drawerVisible"
|
||||
@close="closeDrawer"
|
||||
:bodyStyle="{ paddingBottom: '80px' }"
|
||||
>
|
||||
<farm-detail v-if="selectedFarmId" :farm-id="selectedFarmId" />
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { RiseOutlined, FallOutlined } from '@ant-design/icons-vue'
|
||||
import { useDataStore } from '../stores'
|
||||
import { convertFarmsToMarkers } from '../utils/mapService'
|
||||
import BaiduMap from './BaiduMap.vue'
|
||||
import FarmDetail from './FarmDetail.vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
// 使用数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 图表容器引用
|
||||
const trendChart = ref(null)
|
||||
|
||||
// 从store获取统计数据
|
||||
const statsData = computed(() => dataStore.stats)
|
||||
|
||||
// 从store获取计算属性
|
||||
const farmCount = computed(() => dataStore.farmCount)
|
||||
const animalCount = computed(() => dataStore.animalCount)
|
||||
const deviceCount = computed(() => dataStore.deviceCount)
|
||||
const alertCount = computed(() => dataStore.alertCount)
|
||||
const deviceOnlineRate = computed(() => dataStore.deviceOnlineRate)
|
||||
|
||||
// 养殖场地图标记
|
||||
const farmMarkers = computed(() => {
|
||||
console.log('Dashboard: 计算farmMarkers')
|
||||
console.log('dataStore.farms:', dataStore.farms)
|
||||
const markers = convertFarmsToMarkers(dataStore.farms)
|
||||
console.log('转换后的markers:', markers)
|
||||
return markers
|
||||
})
|
||||
|
||||
// 抽屉控制
|
||||
const drawerVisible = ref(false)
|
||||
const selectedFarmId = ref(null)
|
||||
|
||||
// 处理养殖场标记点击事件
|
||||
function handleFarmClick(markerData) {
|
||||
// 设置选中的养殖场ID
|
||||
selectedFarmId.value = markerData.originalData.id
|
||||
// 打开抽屉
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
// 关闭抽屉
|
||||
function closeDrawer() {
|
||||
drawerVisible.value = false
|
||||
}
|
||||
|
||||
// 图表实例
|
||||
let trendChartInstance = null
|
||||
|
||||
// 组件挂载后初始化数据和图表
|
||||
onMounted(async () => {
|
||||
console.log('Dashboard: 组件挂载,开始加载数据')
|
||||
|
||||
// 加载数据
|
||||
await dataStore.fetchAllData()
|
||||
|
||||
console.log('Dashboard: 数据加载完成')
|
||||
console.log('farms数据:', dataStore.farms)
|
||||
|
||||
// 初始化趋势图表
|
||||
initTrendChart()
|
||||
|
||||
// 添加窗口大小变化监听
|
||||
window.addEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
// 组件卸载时清理资源
|
||||
onUnmounted(() => {
|
||||
// 移除窗口大小变化监听
|
||||
window.removeEventListener('resize', handleResize)
|
||||
|
||||
// 销毁图表实例
|
||||
if (trendChartInstance) {
|
||||
trendChartInstance.dispose()
|
||||
trendChartInstance = null
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化趋势图表
|
||||
function initTrendChart() {
|
||||
if (!trendChart.value) return
|
||||
|
||||
// 导入图表服务
|
||||
import('../utils/chartService').then(({ createTrendChart }) => {
|
||||
// 使用空数据初始化图表,等待后端数据
|
||||
const trendData = {
|
||||
xAxis: [],
|
||||
series: []
|
||||
}
|
||||
|
||||
// 创建趋势图表
|
||||
trendChartInstance = createTrendChart(trendChart.value, trendData, {
|
||||
title: {
|
||||
text: '月度数据趋势',
|
||||
left: 'center'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 处理窗口大小变化
|
||||
function handleResize() {
|
||||
if (trendChartInstance) {
|
||||
trendChartInstance.resize()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dashboard-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
color: #8c8c8c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dashboard-charts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.map-container,
|
||||
.chart-container {
|
||||
height: 350px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dashboard-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.dashboard-charts {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
370
admin-system/frontend/src/components/DeviceStats.vue
Normal file
370
admin-system/frontend/src/components/DeviceStats.vue
Normal file
@@ -0,0 +1,370 @@
|
||||
<template>
|
||||
<div class="device-stats">
|
||||
<a-card :bordered="false" title="设备数据统计">
|
||||
<div class="stats-summary">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ totalDevices }}</div>
|
||||
<div class="stat-label">设备总数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" style="color: #52c41a;">{{ onlineDevices }}</div>
|
||||
<div class="stat-label">在线设备</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" style="color: #faad14;">{{ maintenanceDevices }}</div>
|
||||
<div class="stat-label">维护设备</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value" style="color: #ff4d4f;">{{ offlineDevices }}</div>
|
||||
<div class="stat-label">离线设备</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ onlineRate }}%</div>
|
||||
<div class="stat-label">在线率</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="chart-container">
|
||||
<e-chart :options="deviceStatusOptions" height="300px" />
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<div class="device-table">
|
||||
<a-table
|
||||
:dataSource="deviceTableData"
|
||||
:columns="deviceColumns"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.originalStatus)">
|
||||
{{ record.status }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 设备统计组件 - 显示设备状态分布
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useDataStore } from '../stores'
|
||||
import EChart from './EChart.vue'
|
||||
|
||||
// 使用数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 组件挂载时确保数据已加载
|
||||
onMounted(async () => {
|
||||
console.log('=== DeviceStats组件挂载 ===')
|
||||
console.log('当前设备数量:', dataStore.devices.length)
|
||||
|
||||
try {
|
||||
console.log('开始获取设备数据...')
|
||||
|
||||
// 直接测试API调用
|
||||
console.log('直接测试API调用...')
|
||||
const { deviceService } = await import('../utils/dataService')
|
||||
const apiResult = await deviceService.getAllDevices()
|
||||
console.log('直接API调用结果:', apiResult)
|
||||
console.log('API返回数据类型:', typeof apiResult)
|
||||
console.log('API返回是否为数组:', Array.isArray(apiResult))
|
||||
console.log('API返回数据长度:', apiResult?.length || 0)
|
||||
|
||||
await dataStore.fetchDevices()
|
||||
console.log('设备数据获取完成:', dataStore.devices.length, '台设备')
|
||||
|
||||
// 详细输出设备数据
|
||||
console.log('设备数据详情:', dataStore.devices.slice(0, 3))
|
||||
|
||||
const statusCount = {}
|
||||
dataStore.devices.forEach(device => {
|
||||
statusCount[device.status] = (statusCount[device.status] || 0) + 1
|
||||
})
|
||||
console.log('设备状态分布:', statusCount)
|
||||
|
||||
// 输出计算属性的值
|
||||
console.log('计算属性值:')
|
||||
console.log('- totalDevices:', totalDevices.value)
|
||||
console.log('- onlineDevices:', onlineDevices.value)
|
||||
console.log('- maintenanceDevices:', maintenanceDevices.value)
|
||||
console.log('- offlineDevices:', offlineDevices.value)
|
||||
console.log('- onlineRate:', onlineRate.value)
|
||||
|
||||
console.log('=== DeviceStats组件挂载完成 ===')
|
||||
} catch (error) {
|
||||
console.error('获取设备数据失败:', error)
|
||||
console.error('错误堆栈:', error.stack)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听设备数据变化
|
||||
watch(() => dataStore.devices, (newDevices) => {
|
||||
console.log('设备数据更新:', newDevices.length, '台设备')
|
||||
const statusCount = {}
|
||||
newDevices.forEach(device => {
|
||||
statusCount[device.status] = (statusCount[device.status] || 0) + 1
|
||||
})
|
||||
console.log('当前状态分布:', statusCount)
|
||||
|
||||
// 输出前几个设备的详细信息
|
||||
if (newDevices.length > 0) {
|
||||
console.log('前3个设备详情:', newDevices.slice(0, 3).map(d => ({ id: d.id, name: d.name, status: d.status })))
|
||||
}
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
// 设备总数
|
||||
const totalDevices = computed(() => dataStore.devices.length)
|
||||
|
||||
// 在线设备数量
|
||||
const onlineDevices = computed(() => {
|
||||
return dataStore.devices.filter(device => device.status === 'online').length
|
||||
})
|
||||
|
||||
// 维护设备数量
|
||||
const maintenanceDevices = computed(() => {
|
||||
return dataStore.devices.filter(device => device.status === 'maintenance').length
|
||||
})
|
||||
|
||||
// 离线设备数量
|
||||
const offlineDevices = computed(() => {
|
||||
return dataStore.devices.filter(device => device.status === 'offline').length
|
||||
})
|
||||
|
||||
// 在线率
|
||||
const onlineRate = computed(() => {
|
||||
if (totalDevices.value === 0) return 0
|
||||
return Math.round((onlineDevices.value / totalDevices.value) * 100)
|
||||
})
|
||||
|
||||
// 设备状态分布图表选项
|
||||
const deviceStatusOptions = computed(() => {
|
||||
console.log('计算图表选项,当前设备数量:', dataStore.devices.length)
|
||||
|
||||
// 如果没有设备数据,显示空状态
|
||||
if (dataStore.devices.length === 0) {
|
||||
console.log('没有设备数据,显示空状态图表')
|
||||
return {
|
||||
title: {
|
||||
text: '设备状态分布',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '设备状态',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
data: [
|
||||
{
|
||||
value: 1,
|
||||
name: '暂无数据',
|
||||
itemStyle: {
|
||||
color: '#d9d9d9'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 按状态分组统计
|
||||
const statusCount = {}
|
||||
dataStore.devices.forEach(device => {
|
||||
const chineseStatus = getStatusText(device.status)
|
||||
statusCount[chineseStatus] = (statusCount[chineseStatus] || 0) + 1
|
||||
})
|
||||
|
||||
console.log('状态统计:', statusCount)
|
||||
|
||||
// 定义状态颜色映射
|
||||
const statusColors = {
|
||||
'在线': '#52c41a',
|
||||
'维护中': '#faad14',
|
||||
'离线': '#ff4d4f'
|
||||
}
|
||||
|
||||
// 转换为图表数据格式,并添加颜色
|
||||
const data = Object.keys(statusCount).map(status => ({
|
||||
value: statusCount[status],
|
||||
name: status,
|
||||
itemStyle: {
|
||||
color: statusColors[status] || '#d9d9d9'
|
||||
}
|
||||
}))
|
||||
|
||||
console.log('图表数据:', data)
|
||||
|
||||
return {
|
||||
title: {
|
||||
text: '设备状态分布',
|
||||
left: 'center',
|
||||
textStyle: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c}台 ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0,
|
||||
itemGap: 20
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '设备状态',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: data
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 设备表格数据
|
||||
const deviceTableData = computed(() => {
|
||||
return dataStore.devices.map(device => {
|
||||
const farm = dataStore.farms.find(f => f.id === device.farm_id || f.id === device.farmId)
|
||||
return {
|
||||
key: device.id,
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
type: device.type,
|
||||
farmId: device.farm_id || device.farmId,
|
||||
farmName: farm ? farm.name : `养殖场 #${device.farm_id || device.farmId}`,
|
||||
status: getStatusText(device.status),
|
||||
originalStatus: device.status,
|
||||
lastUpdate: device.last_maintenance || device.lastUpdate || '未知'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 设备表格列定义
|
||||
const deviceColumns = [
|
||||
{
|
||||
title: '设备名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: '设备类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type'
|
||||
},
|
||||
{
|
||||
title: '养殖场',
|
||||
dataIndex: 'farmName',
|
||||
key: 'farmName'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
filters: [
|
||||
{ text: '在线', value: '在线' },
|
||||
{ text: '维护中', value: '维护中' },
|
||||
{ text: '离线', value: '离线' }
|
||||
],
|
||||
onFilter: (value, record) => record.status === value
|
||||
},
|
||||
{
|
||||
title: '最后更新',
|
||||
dataIndex: 'lastUpdate',
|
||||
key: 'lastUpdate',
|
||||
sorter: (a, b) => new Date(a.lastUpdate) - new Date(b.lastUpdate),
|
||||
defaultSortOrder: 'descend'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取状态文本(英文转中文)
|
||||
function getStatusText(status) {
|
||||
switch (status) {
|
||||
case 'online': return '在线'
|
||||
case 'offline': return '离线'
|
||||
case 'maintenance': return '维护中'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态颜色(支持英文状态)
|
||||
function getStatusColor(status) {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
case '在线': return 'success'
|
||||
case 'offline':
|
||||
case '离线': return 'error'
|
||||
case 'maintenance':
|
||||
case '维护中': return 'processing'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.device-stats {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.stats-summary {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.device-table {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
172
admin-system/frontend/src/components/EChart.vue
Normal file
172
admin-system/frontend/src/components/EChart.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="echart-container" ref="chartContainer" :style="{ height: height }"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
||||
import { createChart, handleResize, disposeChart, DataCache } from '../utils/chartService'
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
// 图表选项
|
||||
options: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
// 图表高度
|
||||
height: {
|
||||
type: String,
|
||||
default: '300px'
|
||||
},
|
||||
// 自动调整大小
|
||||
autoResize: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 缓存键
|
||||
cacheKey: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
// 启用缓存
|
||||
enableCache: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 缓存过期时间(毫秒)
|
||||
cacheTTL: {
|
||||
type: Number,
|
||||
default: 5 * 60 * 1000 // 5分钟
|
||||
}
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['chart-ready', 'chart-click'])
|
||||
|
||||
// 图表容器引用
|
||||
const chartContainer = ref(null)
|
||||
|
||||
// 图表实例
|
||||
let chartInstance = null
|
||||
|
||||
// 计算缓存键
|
||||
const computedCacheKey = computed(() => {
|
||||
if (!props.enableCache || !props.cacheKey) return null
|
||||
return `chart_${props.cacheKey}_${JSON.stringify(props.options).slice(0, 100)}`
|
||||
})
|
||||
|
||||
// 初始化图表
|
||||
function initChart() {
|
||||
if (!chartContainer.value) return
|
||||
|
||||
// 检查缓存
|
||||
if (props.enableCache && computedCacheKey.value) {
|
||||
const cachedData = DataCache.get(computedCacheKey.value)
|
||||
if (cachedData) {
|
||||
console.log('使用缓存的图表数据:', computedCacheKey.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 创建图表实例(使用缓存键)
|
||||
chartInstance = createChart(
|
||||
chartContainer.value,
|
||||
props.options,
|
||||
computedCacheKey.value
|
||||
)
|
||||
|
||||
// 添加点击事件监听
|
||||
chartInstance.on('click', (params) => {
|
||||
emit('chart-click', params)
|
||||
})
|
||||
|
||||
// 缓存图表配置
|
||||
if (props.enableCache && computedCacheKey.value) {
|
||||
DataCache.set(computedCacheKey.value, props.options, props.cacheTTL)
|
||||
}
|
||||
|
||||
// 触发图表就绪事件
|
||||
emit('chart-ready', chartInstance)
|
||||
}
|
||||
|
||||
// 更新图表选项(优化版本)
|
||||
function updateChart() {
|
||||
if (chartInstance) {
|
||||
// 使用notMerge=false和lazyUpdate=true来优化性能
|
||||
chartInstance.setOption(props.options, false, true)
|
||||
|
||||
// 更新缓存
|
||||
if (props.enableCache && computedCacheKey.value) {
|
||||
DataCache.set(computedCacheKey.value, props.options, props.cacheTTL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 防抖更新函数
|
||||
let updateTimer = null
|
||||
function debouncedUpdate() {
|
||||
if (updateTimer) {
|
||||
clearTimeout(updateTimer)
|
||||
}
|
||||
updateTimer = setTimeout(() => {
|
||||
updateChart()
|
||||
}, 100) // 100ms防抖
|
||||
}
|
||||
|
||||
// 监听选项变化(优化版本)
|
||||
watch(() => props.options, (newOptions, oldOptions) => {
|
||||
if (chartInstance) {
|
||||
// 深度比较,避免不必要的更新
|
||||
const optionsChanged = JSON.stringify(newOptions) !== JSON.stringify(oldOptions)
|
||||
if (optionsChanged) {
|
||||
debouncedUpdate()
|
||||
}
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 处理窗口大小变化
|
||||
function onResize() {
|
||||
handleResize(chartInstance)
|
||||
}
|
||||
|
||||
// 组件挂载时初始化图表
|
||||
onMounted(() => {
|
||||
initChart()
|
||||
|
||||
// 添加窗口大小变化监听
|
||||
if (props.autoResize) {
|
||||
window.addEventListener('resize', onResize)
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时清理资源
|
||||
onUnmounted(() => {
|
||||
// 清理防抖定时器
|
||||
if (updateTimer) {
|
||||
clearTimeout(updateTimer)
|
||||
updateTimer = null
|
||||
}
|
||||
|
||||
// 移除窗口大小变化监听
|
||||
if (props.autoResize) {
|
||||
window.removeEventListener('resize', onResize)
|
||||
}
|
||||
|
||||
// 销毁图表实例(传入缓存键以清理相关缓存)
|
||||
disposeChart(chartInstance, computedCacheKey.value)
|
||||
chartInstance = null
|
||||
})
|
||||
|
||||
// 暴露方法
|
||||
defineExpose({
|
||||
getChartInstance: () => chartInstance,
|
||||
resize: () => handleResize(chartInstance)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.echart-container {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
background-color: #fff;
|
||||
}
|
||||
</style>
|
||||
174
admin-system/frontend/src/components/FarmDetail.vue
Normal file
174
admin-system/frontend/src/components/FarmDetail.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="farm-detail">
|
||||
<a-descriptions :title="farm.name" bordered :column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }">
|
||||
<a-descriptions-item label="养殖场ID">{{ farm.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="动物数量">{{ farm.animalCount || '未知' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="位置坐标">
|
||||
{{ farm.location ? `${farm.location.lat.toFixed(4)}, ${farm.location.lng.toFixed(4)}` : '未知' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="设备数量">
|
||||
{{ farmDevices.length }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="预警数量">
|
||||
<a-badge :count="farmAlerts.length" :number-style="{ backgroundColor: farmAlerts.length > 0 ? '#f5222d' : '#52c41a' }" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">
|
||||
{{ farm.createdAt ? new Date(farm.createdAt).toLocaleString() : '未知' }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider orientation="left">设备状态</a-divider>
|
||||
<a-table
|
||||
:dataSource="farmDevices"
|
||||
:columns="deviceColumns"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
size="small"
|
||||
:loading="loading.devices"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 'online' ? 'green' : 'red'">
|
||||
{{ record.status === 'online' ? '在线' : '离线' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-divider orientation="left">预警信息</a-divider>
|
||||
<a-table
|
||||
:dataSource="farmAlerts"
|
||||
:columns="alertColumns"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
size="small"
|
||||
:loading="loading.alerts"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'level'">
|
||||
<a-tag :color="getAlertLevelColor(record.level)">
|
||||
{{ getAlertLevelText(record.level) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'created_at'">
|
||||
{{ new Date(record.created_at).toLocaleString() }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useDataStore } from '../stores'
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
// 养殖场ID
|
||||
farmId: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// 数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 加载状态
|
||||
const loading = ref({
|
||||
devices: false,
|
||||
alerts: false
|
||||
})
|
||||
|
||||
// 获取养殖场信息
|
||||
const farm = computed(() => {
|
||||
return dataStore.farms.find(f => f.id == props.farmId) || {}
|
||||
})
|
||||
|
||||
// 获取养殖场设备
|
||||
const farmDevices = computed(() => {
|
||||
return dataStore.devices.filter(d => d.farm_id == props.farmId)
|
||||
})
|
||||
|
||||
// 获取养殖场预警
|
||||
const farmAlerts = computed(() => {
|
||||
return dataStore.alerts.filter(a => a.farm_id == props.farmId)
|
||||
})
|
||||
|
||||
// 设备表格列定义
|
||||
const deviceColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
}
|
||||
]
|
||||
|
||||
// 预警表格列定义
|
||||
const alertColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
},
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'level',
|
||||
key: 'level',
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
}
|
||||
]
|
||||
|
||||
// 获取预警级别颜色
|
||||
function getAlertLevelColor(level) {
|
||||
switch(level) {
|
||||
case 'high': return 'red'
|
||||
case 'medium': return 'orange'
|
||||
case 'low': return 'blue'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取预警级别文本
|
||||
function getAlertLevelText(level) {
|
||||
switch(level) {
|
||||
case 'high': return '高'
|
||||
case 'medium': return '中'
|
||||
case 'low': return '低'
|
||||
default: return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
// 监听养殖场ID变化,确保数据已加载
|
||||
watch(() => props.farmId, async (newId) => {
|
||||
if (newId) {
|
||||
// 确保数据已加载
|
||||
if (dataStore.farms.length === 0) {
|
||||
await dataStore.fetchAllData()
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.farm-detail {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
34
admin-system/frontend/src/components/Menu.vue
Normal file
34
admin-system/frontend/src/components/Menu.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<a-menu
|
||||
mode="inline"
|
||||
:selected-keys="[activeRoute]"
|
||||
:style="{ height: '100%', borderRight: 0 }"
|
||||
>
|
||||
<a-menu-item v-for="route in menuRoutes" :key="route.name">
|
||||
<router-link :to="route.path">
|
||||
<component :is="route.meta.icon" />
|
||||
<span>{{ route.meta.title }}</span>
|
||||
</router-link>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import * as Icons from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// 获取所有需要在菜单中显示的路由
|
||||
const menuRoutes = computed(() => {
|
||||
return router.options.routes.filter(route => {
|
||||
// 只显示需要认证且有图标的路由
|
||||
return route.meta && route.meta.requiresAuth && route.meta.icon
|
||||
})
|
||||
})
|
||||
|
||||
// 当前活动路由
|
||||
const activeRoute = computed(() => route.name)
|
||||
</script>
|
||||
266
admin-system/frontend/src/components/MonitorChart.vue
Normal file
266
admin-system/frontend/src/components/MonitorChart.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="monitor-chart">
|
||||
<div class="chart-header">
|
||||
<h3>{{ title }}</h3>
|
||||
<div class="chart-controls">
|
||||
<a-select
|
||||
v-model="selectedPeriod"
|
||||
style="width: 120px"
|
||||
@change="handlePeriodChange"
|
||||
>
|
||||
<a-select-option value="day">今日</a-select-option>
|
||||
<a-select-option value="week">本周</a-select-option>
|
||||
<a-select-option value="month">本月</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="text" @click="refreshData">
|
||||
<template #icon><reload-outlined /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-content">
|
||||
<e-chart
|
||||
:options="chartOptions"
|
||||
:height="height"
|
||||
:cache-key="cacheKey"
|
||||
:enable-cache="enableCache"
|
||||
:cache-ttl="cacheTTL"
|
||||
@chart-ready="handleChartReady"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import EChart from './EChart.vue'
|
||||
|
||||
// 定义组件属性
|
||||
const props = defineProps({
|
||||
// 图表标题
|
||||
title: {
|
||||
type: String,
|
||||
default: '监控图表'
|
||||
},
|
||||
// 图表类型
|
||||
type: {
|
||||
type: String,
|
||||
default: 'line',
|
||||
validator: (value) => ['line', 'bar', 'pie'].includes(value)
|
||||
},
|
||||
// 图表数据
|
||||
data: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
xAxis: [],
|
||||
series: []
|
||||
})
|
||||
},
|
||||
// 图表高度
|
||||
height: {
|
||||
type: String,
|
||||
default: '300px'
|
||||
},
|
||||
// 自动刷新间隔(毫秒),0表示不自动刷新
|
||||
refreshInterval: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// 缓存键
|
||||
cacheKey: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
// 启用缓存
|
||||
enableCache: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
// 缓存过期时间(毫秒)
|
||||
cacheTTL: {
|
||||
type: Number,
|
||||
default: 5 * 60 * 1000 // 5分钟
|
||||
}
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['refresh', 'period-change'])
|
||||
|
||||
// 选中的时间周期
|
||||
const selectedPeriod = ref('day')
|
||||
|
||||
// 图表实例
|
||||
let chartInstance = null
|
||||
|
||||
// 自动刷新定时器
|
||||
let refreshTimer = null
|
||||
|
||||
// 处理图表就绪事件
|
||||
function handleChartReady(chart) {
|
||||
chartInstance = chart
|
||||
}
|
||||
|
||||
// 处理时间周期变化
|
||||
function handlePeriodChange(period) {
|
||||
emit('period-change', period)
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
function refreshData() {
|
||||
emit('refresh', selectedPeriod.value)
|
||||
}
|
||||
|
||||
// 图表选项
|
||||
const chartOptions = computed(() => {
|
||||
if (props.type === 'line' || props.type === 'bar') {
|
||||
return {
|
||||
title: {
|
||||
text: props.title,
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
legend: {
|
||||
data: props.data.series?.map(item => item.name) || [],
|
||||
bottom: 0
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '10%',
|
||||
top: '15%',
|
||||
containLabel: true
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {}
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: props.type === 'bar',
|
||||
data: props.data.xAxis || []
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: (props.data.series || []).map(item => ({
|
||||
name: item.name,
|
||||
type: props.type,
|
||||
data: item.data,
|
||||
smooth: props.type === 'line',
|
||||
itemStyle: item.itemStyle,
|
||||
lineStyle: item.lineStyle,
|
||||
areaStyle: props.type === 'line' ? item.areaStyle : undefined
|
||||
}))
|
||||
}
|
||||
} else if (props.type === 'pie') {
|
||||
return {
|
||||
title: {
|
||||
text: props.title,
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: props.title,
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: props.data || []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
})
|
||||
|
||||
// 设置自动刷新
|
||||
function setupAutoRefresh() {
|
||||
clearInterval(refreshTimer)
|
||||
|
||||
if (props.refreshInterval > 0) {
|
||||
refreshTimer = setInterval(() => {
|
||||
refreshData()
|
||||
}, props.refreshInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听刷新间隔变化
|
||||
watch(() => props.refreshInterval, () => {
|
||||
setupAutoRefresh()
|
||||
})
|
||||
|
||||
// 组件挂载时设置自动刷新
|
||||
onMounted(() => {
|
||||
setupAutoRefresh()
|
||||
})
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onMounted(() => {
|
||||
clearInterval(refreshTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monitor-chart {
|
||||
width: 100%;
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chart-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.chart-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chart-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chart-content {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
63
admin-system/frontend/src/components/SimpleDeviceTest.vue
Normal file
63
admin-system/frontend/src/components/SimpleDeviceTest.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useDataStore } from '../stores'
|
||||
|
||||
const dataStore = useDataStore()
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const devices = computed(() => dataStore.devices)
|
||||
const onlineCount = computed(() => devices.value.filter(d => d.status === 'online').length)
|
||||
const offlineCount = computed(() => devices.value.filter(d => d.status === 'offline').length)
|
||||
const maintenanceCount = computed(() => devices.value.filter(d => d.status === 'maintenance').length)
|
||||
|
||||
async function refreshData() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
console.log('开始获取设备数据...')
|
||||
await dataStore.fetchDevices()
|
||||
console.log('设备数据获取完成:', devices.value.length)
|
||||
} catch (err) {
|
||||
console.error('获取设备数据失败:', err)
|
||||
error.value = err.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log('SimpleDeviceTest组件挂载')
|
||||
refreshData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.simple-device-test {
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #40a9ff;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f5f5f5;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
298
admin-system/frontend/src/components/VirtualScrollChart.vue
Normal file
298
admin-system/frontend/src/components/VirtualScrollChart.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div class="virtual-scroll-chart">
|
||||
<a-card :title="title" :bordered="false">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-input-number
|
||||
v-model:value="pageSize"
|
||||
:min="10"
|
||||
:max="1000"
|
||||
:step="10"
|
||||
size="small"
|
||||
addon-before="每页"
|
||||
addon-after="条"
|
||||
@change="handlePageSizeChange"
|
||||
/>
|
||||
<a-button size="small" @click="refreshData">
|
||||
<template #icon><reload-outlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<!-- 数据统计信息 -->
|
||||
<div class="data-info" v-if="totalCount > 0">
|
||||
<a-space>
|
||||
<a-tag color="blue">总数据: {{ totalCount.toLocaleString() }}</a-tag>
|
||||
<a-tag color="green">当前页: {{ currentPage }}/{{ totalPages }}</a-tag>
|
||||
<a-tag color="orange">显示: {{ visibleData.length }}</a-tag>
|
||||
<a-tag v-if="isVirtualMode" color="purple">虚拟滚动: 开启</a-tag>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 图表容器 -->
|
||||
<div class="chart-wrapper">
|
||||
<e-chart
|
||||
:options="chartOptions"
|
||||
:height="chartHeight"
|
||||
:cache-key="cacheKey"
|
||||
:enable-cache="enableCache"
|
||||
:cache-ttl="cacheTTL"
|
||||
@chart-ready="handleChartReady"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 分页控制 -->
|
||||
<div class="pagination-wrapper" v-if="totalPages > 1">
|
||||
<a-pagination
|
||||
v-model:current="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="totalCount"
|
||||
:show-size-changer="true"
|
||||
:show-quick-jumper="true"
|
||||
:show-total="(total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`"
|
||||
:page-size-options="['10', '20', '50', '100', '200', '500']"
|
||||
@change="handlePageChange"
|
||||
@show-size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div class="loading-wrapper" v-if="loading">
|
||||
<a-spin size="large" tip="加载中..." />
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import EChart from './EChart.vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '大数据量图表'
|
||||
},
|
||||
data: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
chartType: {
|
||||
type: String,
|
||||
default: 'line',
|
||||
validator: (value) => ['line', 'bar', 'scatter'].includes(value)
|
||||
},
|
||||
chartHeight: {
|
||||
type: String,
|
||||
default: '400px'
|
||||
},
|
||||
virtualThreshold: {
|
||||
type: Number,
|
||||
default: 1000 // 超过1000条数据启用虚拟滚动
|
||||
},
|
||||
enableCache: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
cacheTTL: {
|
||||
type: Number,
|
||||
default: 300000 // 5分钟
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['refresh', 'page-change'])
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(100)
|
||||
const chartInstance = ref(null)
|
||||
|
||||
// 计算属性
|
||||
const totalCount = computed(() => props.data.length)
|
||||
const totalPages = computed(() => Math.ceil(totalCount.value / pageSize.value))
|
||||
const isVirtualMode = computed(() => totalCount.value > props.virtualThreshold)
|
||||
|
||||
// 缓存键
|
||||
const cacheKey = computed(() => {
|
||||
return `virtual_chart_${props.chartType}_${currentPage.value}_${pageSize.value}`
|
||||
})
|
||||
|
||||
// 可见数据(当前页数据)
|
||||
const visibleData = computed(() => {
|
||||
if (!isVirtualMode.value) {
|
||||
return props.data
|
||||
}
|
||||
|
||||
const startIndex = (currentPage.value - 1) * pageSize.value
|
||||
const endIndex = startIndex + pageSize.value
|
||||
return props.data.slice(startIndex, endIndex)
|
||||
})
|
||||
|
||||
// 图表配置
|
||||
const chartOptions = computed(() => {
|
||||
const baseOptions = {
|
||||
title: {
|
||||
text: isVirtualMode.value ? `${props.title} (第${currentPage.value}页)` : props.title,
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
top: 30
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: visibleData.value.map(item => item.name || item.x)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
// 性能优化配置
|
||||
animation: visibleData.value.length > 200 ? false : true,
|
||||
progressive: visibleData.value.length > 500 ? 500 : 0,
|
||||
progressiveThreshold: 1000
|
||||
}
|
||||
|
||||
// 根据图表类型配置series
|
||||
if (props.chartType === 'line') {
|
||||
baseOptions.series = [{
|
||||
name: '数据',
|
||||
type: 'line',
|
||||
data: visibleData.value.map(item => item.value || item.y),
|
||||
smooth: true,
|
||||
symbol: visibleData.value.length > 100 ? 'none' : 'circle',
|
||||
lineStyle: {
|
||||
width: 1
|
||||
}
|
||||
}]
|
||||
} else if (props.chartType === 'bar') {
|
||||
baseOptions.series = [{
|
||||
name: '数据',
|
||||
type: 'bar',
|
||||
data: visibleData.value.map(item => item.value || item.y),
|
||||
itemStyle: {
|
||||
color: '#1890ff'
|
||||
}
|
||||
}]
|
||||
} else if (props.chartType === 'scatter') {
|
||||
baseOptions.series = [{
|
||||
name: '数据',
|
||||
type: 'scatter',
|
||||
data: visibleData.value.map(item => [item.x, item.y]),
|
||||
symbolSize: 6
|
||||
}]
|
||||
}
|
||||
|
||||
return baseOptions
|
||||
})
|
||||
|
||||
// 处理页面变化
|
||||
function handlePageChange(page, size) {
|
||||
currentPage.value = page
|
||||
pageSize.value = size
|
||||
emit('page-change', { page, size })
|
||||
}
|
||||
|
||||
// 处理页面大小变化
|
||||
function handlePageSizeChange(current, size) {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1 // 重置到第一页
|
||||
emit('page-change', { page: 1, size })
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
function refreshData() {
|
||||
loading.value = true
|
||||
emit('refresh')
|
||||
|
||||
// 模拟加载延迟
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
message.success('数据刷新完成')
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 图表就绪回调
|
||||
function handleChartReady(chart) {
|
||||
chartInstance.value = chart
|
||||
}
|
||||
|
||||
// 监听数据变化,自动调整页面
|
||||
watch(() => props.data, (newData) => {
|
||||
if (newData.length === 0) {
|
||||
currentPage.value = 1
|
||||
return
|
||||
}
|
||||
|
||||
// 如果当前页超出范围,调整到最后一页
|
||||
const maxPage = Math.ceil(newData.length / pageSize.value)
|
||||
if (currentPage.value > maxPage) {
|
||||
currentPage.value = maxPage
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 组件挂载时的初始化
|
||||
onMounted(() => {
|
||||
if (totalCount.value > props.virtualThreshold) {
|
||||
message.info(`检测到大数据量(${totalCount.value.toLocaleString()}条),已启用虚拟滚动优化`)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.virtual-scroll-chart {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-info {
|
||||
margin-bottom: 16px;
|
||||
padding: 8px 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
text-align: center;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.loading-wrapper {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
:deep(.ant-card-head-title) {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.ant-pagination) {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
44
admin-system/frontend/src/config/env.js
Normal file
44
admin-system/frontend/src/config/env.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 环境配置文件
|
||||
* 包含各种API密钥和环境变量
|
||||
*/
|
||||
|
||||
// 百度地图API配置
|
||||
export const BAIDU_MAP_CONFIG = {
|
||||
// 百度地图API密钥
|
||||
// 注意:实际项目中应该使用环境变量存储密钥,而不是硬编码
|
||||
// 从环境变量读取API密钥,如果没有则使用开发测试密钥
|
||||
// 生产环境请设置 VITE_BAIDU_MAP_API_KEY 环境变量
|
||||
// 请访问 http://lbsyun.baidu.com/apiconsole/key 申请有效的API密钥
|
||||
apiKey: import.meta.env.VITE_BAIDU_MAP_API_KEY || '3AN3VahoqaXUs32U8luXD2Dwn86KK5B7',
|
||||
|
||||
// 默认中心点(宁夏中心位置)
|
||||
defaultCenter: {
|
||||
lng: 106.27,
|
||||
lat: 38.47
|
||||
},
|
||||
|
||||
// 默认缩放级别
|
||||
defaultZoom: 8
|
||||
};
|
||||
|
||||
// API服务配置
|
||||
export const API_CONFIG = {
|
||||
// API基础URL
|
||||
baseUrl: 'http://localhost:5350/api',
|
||||
|
||||
// 请求超时时间(毫秒)
|
||||
timeout: 10000
|
||||
};
|
||||
|
||||
// 其他环境配置
|
||||
export const APP_CONFIG = {
|
||||
// 应用名称
|
||||
appName: '宁夏智慧养殖监管平台',
|
||||
|
||||
// 版本号
|
||||
version: '1.0.0',
|
||||
|
||||
// 是否为开发环境
|
||||
isDev: process.env.NODE_ENV === 'development'
|
||||
};
|
||||
32
admin-system/frontend/src/main.js
Normal file
32
admin-system/frontend/src/main.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { createPinia } from 'pinia'
|
||||
import Antd from 'ant-design-vue'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import { themeConfig } from './styles/theme.js'
|
||||
import './styles/global.css'
|
||||
import { useUserStore } from './stores/user.js'
|
||||
|
||||
// 导入图标组件
|
||||
import * as Icons from '@ant-design/icons-vue'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
// 注册所有图标组件
|
||||
Object.keys(Icons).forEach(key => {
|
||||
app.component(key, Icons[key])
|
||||
})
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(Antd, {
|
||||
theme: themeConfig
|
||||
})
|
||||
|
||||
// 在应用挂载前初始化用户登录状态
|
||||
const userStore = useUserStore()
|
||||
userStore.checkLoginStatus()
|
||||
|
||||
app.mount('#app')
|
||||
91
admin-system/frontend/src/router/history.js
Normal file
91
admin-system/frontend/src/router/history.js
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 路由历史记录服务
|
||||
* 用于跟踪和管理用户的导航历史
|
||||
*/
|
||||
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
// 最大历史记录数量
|
||||
const MAX_HISTORY = 10
|
||||
|
||||
// 创建历史记录服务
|
||||
export function useRouteHistory() {
|
||||
// 路由实例
|
||||
const router = useRouter()
|
||||
|
||||
// 历史记录
|
||||
const history = ref([])
|
||||
|
||||
// 监听路由变化
|
||||
watch(
|
||||
() => router.currentRoute.value,
|
||||
(route) => {
|
||||
// 只记录需要认证的路由
|
||||
if (route.meta.requiresAuth) {
|
||||
// 添加到历史记录
|
||||
addToHistory({
|
||||
name: route.name,
|
||||
path: route.path,
|
||||
title: route.meta.title,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 添加到历史记录
|
||||
function addToHistory(item) {
|
||||
// 检查是否已存在相同路径
|
||||
const index = history.value.findIndex(h => h.path === item.path)
|
||||
|
||||
// 如果已存在,则移除
|
||||
if (index !== -1) {
|
||||
history.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// 添加到历史记录开头
|
||||
history.value.unshift(item)
|
||||
|
||||
// 限制历史记录数量
|
||||
if (history.value.length > MAX_HISTORY) {
|
||||
history.value = history.value.slice(0, MAX_HISTORY)
|
||||
}
|
||||
|
||||
// 保存到本地存储
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
// 清除历史记录
|
||||
function clearHistory() {
|
||||
history.value = []
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
// 保存历史记录到本地存储
|
||||
function saveHistory() {
|
||||
localStorage.setItem('routeHistory', JSON.stringify(history.value))
|
||||
}
|
||||
|
||||
// 加载历史记录
|
||||
function loadHistory() {
|
||||
try {
|
||||
const saved = localStorage.getItem('routeHistory')
|
||||
if (saved) {
|
||||
history.value = JSON.parse(saved)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载历史记录失败:', error)
|
||||
clearHistory()
|
||||
}
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
loadHistory()
|
||||
|
||||
return {
|
||||
history,
|
||||
clearHistory
|
||||
}
|
||||
}
|
||||
67
admin-system/frontend/src/router/index.js
Normal file
67
admin-system/frontend/src/router/index.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '../stores'
|
||||
import routes from './routes'
|
||||
|
||||
// 创建路由实例
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
// 如果有保存的位置,则恢复到保存的位置
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
}
|
||||
// 否则滚动到顶部
|
||||
return { top: 0 }
|
||||
}
|
||||
})
|
||||
|
||||
// 全局前置守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 设置页面标题
|
||||
document.title = to.meta.title ? `${to.meta.title} - 宁夏智慧养殖监管平台` : '宁夏智慧养殖监管平台'
|
||||
|
||||
// 获取用户存储
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 如果访问登录页面且已有token,尝试自动登录
|
||||
if (to.path === '/login' && userStore.token) {
|
||||
try {
|
||||
// 验证token是否有效
|
||||
const isValid = await userStore.validateToken()
|
||||
if (isValid) {
|
||||
// token有效,直接跳转到目标页面或仪表盘
|
||||
const redirectPath = to.query.redirect || '/dashboard'
|
||||
next(redirectPath)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Token验证失败,继续到登录页面')
|
||||
}
|
||||
}
|
||||
|
||||
// 检查该路由是否需要登录权限
|
||||
if (to.meta.requiresAuth) {
|
||||
// 如果需要登录但用户未登录,则重定向到登录页面
|
||||
if (!userStore.isLoggedIn) {
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath } // 保存原本要访问的路径,以便登录后重定向
|
||||
})
|
||||
} else {
|
||||
// 用户已登录,允许访问
|
||||
next()
|
||||
}
|
||||
} else {
|
||||
// 不需要登录权限的路由,直接访问
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
// 全局后置钩子
|
||||
router.afterEach((to, from) => {
|
||||
// 路由切换后的逻辑,如记录访问历史、分析等
|
||||
console.log(`路由从 ${from.path} 切换到 ${to.path}`)
|
||||
})
|
||||
|
||||
export default router
|
||||
153
admin-system/frontend/src/router/routes.js
Normal file
153
admin-system/frontend/src/router/routes.js
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 路由配置模块
|
||||
* 用于集中管理应用的路由配置
|
||||
*/
|
||||
|
||||
// 主布局路由
|
||||
export const mainRoutes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('../views/Home.vue'),
|
||||
meta: {
|
||||
title: '首页',
|
||||
requiresAuth: true,
|
||||
icon: 'home-outlined'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/Dashboard.vue'),
|
||||
meta: {
|
||||
title: '系统概览',
|
||||
requiresAuth: true,
|
||||
icon: 'dashboard-outlined'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/analytics',
|
||||
name: 'Analytics',
|
||||
component: () => import('../views/Analytics.vue'),
|
||||
meta: {
|
||||
title: '数据分析',
|
||||
requiresAuth: true,
|
||||
icon: 'bar-chart-outlined'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/monitor',
|
||||
name: 'Monitor',
|
||||
component: () => import('../views/Monitor.vue'),
|
||||
meta: {
|
||||
title: '实时监控',
|
||||
requiresAuth: true,
|
||||
icon: 'line-chart-outlined'
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
path: '/users',
|
||||
name: 'Users',
|
||||
component: () => import('../views/Users.vue'),
|
||||
meta: {
|
||||
title: '用户管理',
|
||||
requiresAuth: true,
|
||||
icon: 'user-outlined'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/products',
|
||||
name: 'Products',
|
||||
component: () => import('../views/Products.vue'),
|
||||
meta: {
|
||||
title: '产品管理',
|
||||
requiresAuth: true,
|
||||
icon: 'shopping-outlined'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/orders',
|
||||
name: 'Orders',
|
||||
component: () => import('../views/Orders.vue'),
|
||||
meta: {
|
||||
title: '订单管理',
|
||||
requiresAuth: true,
|
||||
icon: 'ShoppingCartOutlined'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/devices',
|
||||
name: 'Devices',
|
||||
component: () => import('../views/Devices.vue'),
|
||||
meta: {
|
||||
title: '设备管理',
|
||||
requiresAuth: true,
|
||||
icon: 'DesktopOutlined'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/animals',
|
||||
name: 'Animals',
|
||||
component: () => import('../views/Animals.vue'),
|
||||
meta: {
|
||||
title: '动物管理',
|
||||
requiresAuth: true,
|
||||
icon: 'BugOutlined'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/alerts',
|
||||
name: 'Alerts',
|
||||
component: () => import('../views/Alerts.vue'),
|
||||
meta: {
|
||||
title: '预警管理',
|
||||
requiresAuth: true,
|
||||
icon: 'AlertOutlined'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/farms',
|
||||
name: 'Farms',
|
||||
component: () => import('../views/Farms.vue'),
|
||||
meta: {
|
||||
title: '养殖场管理',
|
||||
requiresAuth: true,
|
||||
icon: 'HomeOutlined'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 认证相关路由
|
||||
export const authRoutes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../views/Login.vue'),
|
||||
meta: {
|
||||
title: '登录',
|
||||
requiresAuth: false,
|
||||
layout: 'blank'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 错误页面路由
|
||||
export const errorRoutes = [
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('../views/NotFound.vue'),
|
||||
meta: {
|
||||
title: '页面未找到',
|
||||
requiresAuth: false
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// 导出所有路由
|
||||
export default [
|
||||
...authRoutes,
|
||||
...mainRoutes,
|
||||
...errorRoutes
|
||||
]
|
||||
190
admin-system/frontend/src/stores/data.js
Normal file
190
admin-system/frontend/src/stores/data.js
Normal file
@@ -0,0 +1,190 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useDataStore = defineStore('data', () => {
|
||||
// 数据状态
|
||||
const farms = ref([])
|
||||
const animals = ref([])
|
||||
const devices = ref([])
|
||||
const alerts = ref([])
|
||||
const stats = ref({
|
||||
farmGrowth: 0,
|
||||
animalGrowth: 0,
|
||||
alertReduction: 0
|
||||
})
|
||||
|
||||
// 加载状态
|
||||
const loading = ref({
|
||||
farms: false,
|
||||
animals: false,
|
||||
devices: false,
|
||||
alerts: false,
|
||||
stats: false
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const farmCount = computed(() => farms.value.length)
|
||||
const animalCount = computed(() => animals.value.length)
|
||||
const deviceCount = computed(() => devices.value.length)
|
||||
const alertCount = computed(() => alerts.value.length)
|
||||
|
||||
// 在线设备数量
|
||||
const onlineDeviceCount = computed(() =>
|
||||
devices.value.filter(device => device.status === 'online').length
|
||||
)
|
||||
|
||||
// 设备在线率
|
||||
const deviceOnlineRate = computed(() => {
|
||||
if (devices.value.length === 0) return 0
|
||||
return (onlineDeviceCount.value / devices.value.length * 100).toFixed(1)
|
||||
})
|
||||
|
||||
// 获取养殖场数据
|
||||
async function fetchFarms() {
|
||||
loading.value.farms = true
|
||||
console.log('开始获取养殖场数据...')
|
||||
|
||||
try {
|
||||
// 导入数据服务
|
||||
const { farmService } = await import('../utils/dataService')
|
||||
console.log('调用 farmService.getAllFarms()...')
|
||||
const data = await farmService.getAllFarms()
|
||||
|
||||
console.log('养殖场API返回数据:', data)
|
||||
|
||||
// farmService.getAllFarms()通过api.get返回的是result.data,直接使用
|
||||
farms.value = data || []
|
||||
console.log('设置farms.value:', farms.value.length, '条记录')
|
||||
} catch (error) {
|
||||
console.error('获取养殖场数据失败:', error)
|
||||
farms.value = []
|
||||
} finally {
|
||||
loading.value.farms = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取动物数据
|
||||
async function fetchAnimals() {
|
||||
loading.value.animals = true
|
||||
try {
|
||||
// 导入数据服务
|
||||
const { animalService } = await import('../utils/dataService')
|
||||
const data = await animalService.getAllAnimals()
|
||||
animals.value = data || []
|
||||
} catch (error) {
|
||||
console.error('获取动物数据失败:', error)
|
||||
animals.value = []
|
||||
} finally {
|
||||
loading.value.animals = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取设备数据
|
||||
async function fetchDevices() {
|
||||
loading.value.devices = true
|
||||
try {
|
||||
// 导入数据服务
|
||||
const { deviceService } = await import('../utils/dataService')
|
||||
const data = await deviceService.getAllDevices()
|
||||
devices.value = data || []
|
||||
} catch (error) {
|
||||
console.error('获取设备数据失败:', error)
|
||||
devices.value = []
|
||||
} finally {
|
||||
loading.value.devices = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取预警数据
|
||||
async function fetchAlerts() {
|
||||
loading.value.alerts = true
|
||||
try {
|
||||
// 导入数据服务
|
||||
const { alertService } = await import('../utils/dataService')
|
||||
const data = await alertService.getAllAlerts()
|
||||
|
||||
// alertService.getAllAlerts()通过api.get返回的是result.data,直接使用
|
||||
alerts.value = data || []
|
||||
} catch (error) {
|
||||
console.error('获取预警数据失败:', error)
|
||||
alerts.value = []
|
||||
} finally {
|
||||
loading.value.alerts = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取统计数据
|
||||
async function fetchStats() {
|
||||
loading.value.stats = true
|
||||
try {
|
||||
// 导入数据服务
|
||||
const { statsService } = await import('../utils/dataService')
|
||||
const data = await statsService.getDashboardStats()
|
||||
stats.value = data || {
|
||||
farmGrowth: 0,
|
||||
animalGrowth: 0,
|
||||
alertReduction: 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取统计数据失败:', error)
|
||||
stats.value = {
|
||||
farmGrowth: 0,
|
||||
animalGrowth: 0,
|
||||
alertReduction: 0
|
||||
}
|
||||
} finally {
|
||||
loading.value.stats = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载所有数据
|
||||
async function fetchAllData() {
|
||||
console.log('开始并行加载所有数据...')
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
fetchFarms(),
|
||||
fetchAnimals(),
|
||||
fetchDevices(),
|
||||
fetchAlerts(),
|
||||
fetchStats()
|
||||
])
|
||||
|
||||
console.log('所有数据加载完成:', {
|
||||
farms: farms.value.length,
|
||||
animals: animals.value.length,
|
||||
devices: devices.value.length,
|
||||
alerts: alerts.value.length
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('数据加载过程中出现错误:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
farms,
|
||||
animals,
|
||||
devices,
|
||||
alerts,
|
||||
stats,
|
||||
loading,
|
||||
|
||||
// 计算属性
|
||||
farmCount,
|
||||
animalCount,
|
||||
deviceCount,
|
||||
alertCount,
|
||||
onlineDeviceCount,
|
||||
deviceOnlineRate,
|
||||
|
||||
// 方法
|
||||
fetchFarms,
|
||||
fetchAnimals,
|
||||
fetchDevices,
|
||||
fetchAlerts,
|
||||
fetchStats,
|
||||
fetchAllData
|
||||
}
|
||||
})
|
||||
4
admin-system/frontend/src/stores/index.js
Normal file
4
admin-system/frontend/src/stores/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
// 导出所有状态管理存储
|
||||
export { useUserStore } from './user'
|
||||
export { useSettingsStore } from './settings'
|
||||
export { useDataStore } from './data'
|
||||
43
admin-system/frontend/src/stores/settings.js
Normal file
43
admin-system/frontend/src/stores/settings.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
// 应用设置状态
|
||||
const theme = ref(localStorage.getItem('theme') || 'light')
|
||||
const sidebarCollapsed = ref(localStorage.getItem('sidebarCollapsed') === 'true')
|
||||
const locale = ref(localStorage.getItem('locale') || 'zh-CN')
|
||||
|
||||
// 切换主题
|
||||
function toggleTheme() {
|
||||
theme.value = theme.value === 'light' ? 'dark' : 'light'
|
||||
localStorage.setItem('theme', theme.value)
|
||||
}
|
||||
|
||||
// 设置主题
|
||||
function setTheme(newTheme) {
|
||||
theme.value = newTheme
|
||||
localStorage.setItem('theme', newTheme)
|
||||
}
|
||||
|
||||
// 切换侧边栏折叠状态
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
localStorage.setItem('sidebarCollapsed', sidebarCollapsed.value)
|
||||
}
|
||||
|
||||
// 设置语言
|
||||
function setLocale(newLocale) {
|
||||
locale.value = newLocale
|
||||
localStorage.setItem('locale', newLocale)
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
sidebarCollapsed,
|
||||
locale,
|
||||
toggleTheme,
|
||||
setTheme,
|
||||
toggleSidebar,
|
||||
setLocale
|
||||
}
|
||||
})
|
||||
108
admin-system/frontend/src/stores/user.js
Normal file
108
admin-system/frontend/src/stores/user.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 状态
|
||||
const token = ref(localStorage.getItem('token') || '')
|
||||
const userData = ref(JSON.parse(localStorage.getItem('user') || 'null'))
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
|
||||
// 检查登录状态
|
||||
function checkLoginStatus() {
|
||||
const savedToken = localStorage.getItem('token')
|
||||
const savedUser = localStorage.getItem('user')
|
||||
|
||||
if (savedToken && savedUser) {
|
||||
try {
|
||||
token.value = savedToken
|
||||
userData.value = JSON.parse(savedUser)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('解析用户数据失败', error)
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查token是否有效
|
||||
async function validateToken() {
|
||||
if (!token.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const { api } = await import('../utils/api')
|
||||
// 尝试调用一个需要认证的API来验证token
|
||||
await api.get('/auth/validate')
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error.message && error.message.includes('认证已过期')) {
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
// 其他错误可能是网络问题,不清除token
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 登录操作
|
||||
async function login(username, password, retryCount = 0) {
|
||||
try {
|
||||
const { api } = await import('../utils/api');
|
||||
// 使用专门的login方法,它返回完整的响应对象
|
||||
const result = await api.login(username, password);
|
||||
|
||||
// 登录成功后设置token和用户数据
|
||||
if (result.success && result.token) {
|
||||
token.value = result.token;
|
||||
userData.value = {
|
||||
username: username,
|
||||
email: result.user?.email || `${username}@example.com`
|
||||
};
|
||||
|
||||
// 保存到本地存储
|
||||
localStorage.setItem('token', result.token);
|
||||
localStorage.setItem('user', JSON.stringify(userData.value));
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('登录错误:', error);
|
||||
// 重试逻辑(仅对500错误且重试次数<2)
|
||||
if (error.message.includes('500') && retryCount < 2) {
|
||||
return login(username, password, retryCount + 1);
|
||||
}
|
||||
// 直接抛出错误,由调用方处理
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 登出操作
|
||||
function logout() {
|
||||
token.value = ''
|
||||
userData.value = null
|
||||
|
||||
// 清除本地存储
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
function updateUserInfo(newUserInfo) {
|
||||
userData.value = { ...userData.value, ...newUserInfo }
|
||||
localStorage.setItem('user', JSON.stringify(userData.value))
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
userData,
|
||||
isLoggedIn,
|
||||
checkLoginStatus,
|
||||
validateToken,
|
||||
login,
|
||||
logout,
|
||||
updateUserInfo
|
||||
}
|
||||
})
|
||||
105
admin-system/frontend/src/styles/global.css
Normal file
105
admin-system/frontend/src/styles/global.css
Normal file
@@ -0,0 +1,105 @@
|
||||
/* 全局样式 */
|
||||
|
||||
/* 基础样式重置 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
|
||||
/* 链接样式 */
|
||||
a {
|
||||
color: #1890ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
|
||||
/* 常用辅助类 */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-column {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mt-1 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mt-2 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.mt-3 {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mb-3 {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
29
admin-system/frontend/src/styles/theme.js
Normal file
29
admin-system/frontend/src/styles/theme.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// Ant Design Vue 主题配置
|
||||
export const themeConfig = {
|
||||
token: {
|
||||
colorPrimary: '#1890ff',
|
||||
colorSuccess: '#52c41a',
|
||||
colorWarning: '#faad14',
|
||||
colorError: '#f5222d',
|
||||
colorInfo: '#1890ff',
|
||||
borderRadius: 4,
|
||||
wireframe: false,
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
colorPrimary: '#1890ff',
|
||||
algorithm: true,
|
||||
},
|
||||
Input: {
|
||||
colorPrimary: '#1890ff',
|
||||
},
|
||||
Card: {
|
||||
colorBgContainer: '#ffffff',
|
||||
},
|
||||
Layout: {
|
||||
colorBgHeader: '#001529',
|
||||
colorBgBody: '#f0f2f5',
|
||||
colorBgSider: '#001529',
|
||||
},
|
||||
},
|
||||
};
|
||||
27
admin-system/frontend/src/test-api-direct.js
Normal file
27
admin-system/frontend/src/test-api-direct.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// 直接测试API调用
|
||||
import { api } from './utils/api.js'
|
||||
|
||||
console.log('=== 开始直接API测试 ===')
|
||||
|
||||
try {
|
||||
console.log('正在调用 /devices/public API...')
|
||||
const result = await api.get('/devices/public')
|
||||
console.log('API调用成功!')
|
||||
console.log('返回数据类型:', typeof result)
|
||||
console.log('是否为数组:', Array.isArray(result))
|
||||
console.log('数据长度:', result?.length || 0)
|
||||
console.log('前3个设备:', result?.slice(0, 3))
|
||||
|
||||
if (result && result.length > 0) {
|
||||
const statusCount = {}
|
||||
result.forEach(device => {
|
||||
statusCount[device.status] = (statusCount[device.status] || 0) + 1
|
||||
})
|
||||
console.log('设备状态分布:', statusCount)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API调用失败:', error.message)
|
||||
console.error('错误详情:', error)
|
||||
}
|
||||
|
||||
console.log('=== API测试完成 ===')
|
||||
41
admin-system/frontend/src/test-data-store.js
Normal file
41
admin-system/frontend/src/test-data-store.js
Normal file
@@ -0,0 +1,41 @@
|
||||
// 测试数据存储
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { useDataStore } from './stores/data.js'
|
||||
|
||||
// 创建应用和Pinia实例
|
||||
const app = createApp({})
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
// 测试数据存储
|
||||
async function testDataStore() {
|
||||
console.log('=== 开始测试数据存储 ===')
|
||||
|
||||
const dataStore = useDataStore()
|
||||
|
||||
console.log('初始设备数量:', dataStore.devices.length)
|
||||
|
||||
try {
|
||||
console.log('开始获取设备数据...')
|
||||
await dataStore.fetchDevices()
|
||||
|
||||
console.log('获取完成,设备数量:', dataStore.devices.length)
|
||||
console.log('前3个设备:', dataStore.devices.slice(0, 3))
|
||||
|
||||
// 统计状态
|
||||
const statusCount = {}
|
||||
dataStore.devices.forEach(device => {
|
||||
statusCount[device.status] = (statusCount[device.status] || 0) + 1
|
||||
})
|
||||
console.log('状态分布:', statusCount)
|
||||
|
||||
} catch (error) {
|
||||
console.error('测试失败:', error)
|
||||
}
|
||||
|
||||
console.log('=== 测试完成 ===')
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
testDataStore()
|
||||
271
admin-system/frontend/src/utils/api.js
Normal file
271
admin-system/frontend/src/utils/api.js
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* API请求工具
|
||||
* 封装了基本的API请求方法,包括处理认证Token
|
||||
*/
|
||||
|
||||
// API基础URL
|
||||
const API_BASE_URL = 'http://localhost:5350/api';
|
||||
|
||||
/**
|
||||
* 创建请求头,自动添加认证Token
|
||||
* @param {Object} headers - 额外的请求头
|
||||
* @returns {Object} 合并后的请求头
|
||||
*/
|
||||
const createHeaders = (headers = {}) => {
|
||||
const token = localStorage.getItem('token');
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
defaultHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return { ...defaultHeaders, ...headers };
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理API响应
|
||||
* @param {Response} response - Fetch API响应对象
|
||||
* @returns {Promise} 处理后的响应数据
|
||||
*/
|
||||
const handleResponse = async (response) => {
|
||||
// 检查HTTP状态
|
||||
if (!response.ok) {
|
||||
// 处理常见错误
|
||||
if (response.status === 401) {
|
||||
// 清除无效的认证信息
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
// 显示友好的错误提示而不是直接重定向
|
||||
console.warn('认证token已过期,请重新登录');
|
||||
throw new Error('认证已过期,请重新登录');
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error('请求的资源不存在');
|
||||
}
|
||||
|
||||
if (response.status === 500) {
|
||||
const errorData = await response.json();
|
||||
console.error('API 500错误详情:', errorData);
|
||||
throw new Error(
|
||||
errorData.message ||
|
||||
(errorData.details ? `${errorData.message}\n${errorData.details}` : '服务暂时不可用,请稍后重试')
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
// 清除无效的认证信息
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
// 可以在这里添加重定向到登录页的逻辑
|
||||
window.location.href = '/login';
|
||||
throw new Error('认证失败,请重新登录');
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error('请求的资源不存在');
|
||||
}
|
||||
|
||||
if (response.status === 500) {
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
console.error('API 500错误详情:', errorData);
|
||||
// 优先使用后端返回的 message,否则使用默认提示
|
||||
throw new Error(errorData.message || '服务器繁忙,请稍后重试');
|
||||
} catch (e) {
|
||||
console.error('API处理错误:', e.stack);
|
||||
// 区分网络错误和服务端错误
|
||||
if (e.message.includes('Failed to fetch')) {
|
||||
throw new Error('网络连接失败,请检查网络');
|
||||
} else {
|
||||
throw new Error('服务器内部错误,请联系管理员');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试获取详细错误信息
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = errorData.message || `请求失败: ${response.status} ${response.statusText}`;
|
||||
console.error(`API请求失败 [${response.status}]:`, {
|
||||
url: response.url,
|
||||
method: response.method,
|
||||
error: errorData,
|
||||
headers: response.headers
|
||||
});
|
||||
throw new Error(errorMessage);
|
||||
} catch (e) {
|
||||
console.error(`API请求解析失败:`, {
|
||||
url: response.url,
|
||||
method: response.method,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers
|
||||
});
|
||||
throw new Error(`请求失败: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 返回JSON数据
|
||||
const result = await response.json();
|
||||
|
||||
// 兼容数组响应
|
||||
if (Array.isArray(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`API业务逻辑失败:`, {
|
||||
url: response.url,
|
||||
method: response.method,
|
||||
response: result
|
||||
});
|
||||
throw new Error(result.message || 'API请求失败');
|
||||
}
|
||||
return result.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新农场信息
|
||||
* @param {string} id - 农场ID
|
||||
* @param {Object} data - 更新的农场数据
|
||||
* @returns {Promise} 更新后的农场数据
|
||||
*/
|
||||
const updateFarm = async (id, data) => {
|
||||
const response = await fetch(`${API_BASE_URL}/farms/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: createHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return handleResponse(response);
|
||||
};
|
||||
|
||||
/**
|
||||
* API请求方法
|
||||
*/
|
||||
export const api = {
|
||||
/**
|
||||
* 登录
|
||||
* @param {string} username - 用户名
|
||||
* @param {string} password - 密码
|
||||
* @returns {Promise} 登录结果
|
||||
*/
|
||||
async login(username, password) {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
// 检查HTTP状态
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error('用户名或密码错误');
|
||||
}
|
||||
if (response.status === 500) {
|
||||
throw new Error('服务器错误,请稍后重试');
|
||||
}
|
||||
throw new Error(`登录失败: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 检查业务逻辑是否成功
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '登录失败');
|
||||
}
|
||||
|
||||
// 保存token到localStorage
|
||||
if (result.token) {
|
||||
localStorage.setItem('token', result.token);
|
||||
}
|
||||
|
||||
// 返回完整的结果对象,保持与后端响应格式一致
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* GET请求
|
||||
* @param {string} endpoint - API端点
|
||||
* @param {Object} options - 请求选项
|
||||
* @returns {Promise} 响应数据
|
||||
*/
|
||||
async get(endpoint, options = {}) {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
method: 'GET',
|
||||
headers: createHeaders(options.headers),
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
/**
|
||||
* POST请求
|
||||
* @param {string} endpoint - API端点
|
||||
* @param {Object} data - 请求数据
|
||||
* @param {Object} options - 请求选项
|
||||
* @returns {Promise} 响应数据
|
||||
*/
|
||||
async post(endpoint, data, options = {}) {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: createHeaders(options.headers),
|
||||
body: JSON.stringify(data),
|
||||
...options,
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT请求
|
||||
* @param {string} endpoint - API端点
|
||||
* @param {Object} data - 请求数据
|
||||
* @param {Object} options - 请求选项
|
||||
* @returns {Promise} 响应数据
|
||||
*/
|
||||
async put(endpoint, data, options = {}) {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: createHeaders(options.headers),
|
||||
body: JSON.stringify(data),
|
||||
...options,
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
|
||||
/**
|
||||
* DELETE请求
|
||||
* @param {string} endpoint - API端点
|
||||
* @param {Object} options - 请求选项
|
||||
* @returns {Promise} 响应数据
|
||||
*/
|
||||
async delete(endpoint, options = {}) {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: createHeaders(options.headers),
|
||||
...options,
|
||||
});
|
||||
return handleResponse(response);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 使用示例:
|
||||
* // 登录
|
||||
* api.login('admin', 'password')
|
||||
* .then(response => {
|
||||
* console.log('登录成功', response);
|
||||
* // 之后的其他API调用会自动携带token
|
||||
* })
|
||||
* .catch(error => {
|
||||
* console.error('登录失败', error);
|
||||
* });
|
||||
*/
|
||||
501
admin-system/frontend/src/utils/chartService.js
Normal file
501
admin-system/frontend/src/utils/chartService.js
Normal file
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* 图表服务工具
|
||||
* 封装ECharts图表的初始化和配置功能
|
||||
* 优化版本:支持按需加载、懒加载和性能优化
|
||||
*/
|
||||
|
||||
// 导入ECharts核心模块
|
||||
import * as echarts from 'echarts/core';
|
||||
|
||||
// 按需导入图表组件
|
||||
import {
|
||||
LineChart,
|
||||
BarChart,
|
||||
PieChart
|
||||
} from 'echarts/charts';
|
||||
|
||||
// 按需导入组件
|
||||
import {
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
DatasetComponent,
|
||||
TransformComponent,
|
||||
LegendComponent,
|
||||
ToolboxComponent
|
||||
} from 'echarts/components';
|
||||
|
||||
// 导入Canvas渲染器(性能更好)
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
|
||||
// 图表实例缓存
|
||||
const chartInstanceCache = new Map();
|
||||
|
||||
// 数据缓存
|
||||
const dataCache = new Map();
|
||||
|
||||
// 注册必要的组件
|
||||
echarts.use([
|
||||
LineChart,
|
||||
BarChart,
|
||||
PieChart,
|
||||
TitleComponent,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
DatasetComponent,
|
||||
TransformComponent,
|
||||
LegendComponent,
|
||||
ToolboxComponent,
|
||||
CanvasRenderer
|
||||
]);
|
||||
|
||||
// 性能优化配置
|
||||
const PERFORMANCE_CONFIG = {
|
||||
// 启用硬件加速
|
||||
devicePixelRatio: window.devicePixelRatio || 1,
|
||||
// 渲染器配置
|
||||
renderer: 'canvas',
|
||||
// 动画配置
|
||||
animation: {
|
||||
duration: 300,
|
||||
easing: 'cubicOut'
|
||||
},
|
||||
// 大数据优化
|
||||
largeThreshold: 2000,
|
||||
progressive: 400,
|
||||
progressiveThreshold: 3000
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建图表实例(优化版本)
|
||||
* @param {HTMLElement} container - 图表容器元素
|
||||
* @param {Object} options - 图表配置选项
|
||||
* @param {string} cacheKey - 缓存键(可选)
|
||||
* @returns {echarts.ECharts} 图表实例
|
||||
*/
|
||||
export function createChart(container, options = {}, cacheKey = null) {
|
||||
if (!container) {
|
||||
console.error('图表容器不存在');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
if (cacheKey && chartInstanceCache.has(cacheKey)) {
|
||||
const cachedChart = chartInstanceCache.get(cacheKey);
|
||||
if (cachedChart && !cachedChart.isDisposed()) {
|
||||
// 更新配置
|
||||
cachedChart.setOption(options, true);
|
||||
return cachedChart;
|
||||
} else {
|
||||
// 清理无效缓存
|
||||
chartInstanceCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 合并性能优化配置
|
||||
const initOptions = {
|
||||
devicePixelRatio: PERFORMANCE_CONFIG.devicePixelRatio,
|
||||
renderer: PERFORMANCE_CONFIG.renderer,
|
||||
width: 'auto',
|
||||
height: 'auto'
|
||||
};
|
||||
|
||||
// 创建图表实例
|
||||
const chart = echarts.init(container, null, initOptions);
|
||||
|
||||
// 应用性能优化配置到选项
|
||||
const optimizedOptions = {
|
||||
...options,
|
||||
animation: {
|
||||
...PERFORMANCE_CONFIG.animation,
|
||||
...options.animation
|
||||
},
|
||||
// 大数据优化
|
||||
progressive: PERFORMANCE_CONFIG.progressive,
|
||||
progressiveThreshold: PERFORMANCE_CONFIG.progressiveThreshold
|
||||
};
|
||||
|
||||
// 设置图表选项
|
||||
if (optimizedOptions) {
|
||||
chart.setOption(optimizedOptions, true);
|
||||
}
|
||||
|
||||
// 缓存图表实例
|
||||
if (cacheKey) {
|
||||
chartInstanceCache.set(cacheKey, chart);
|
||||
}
|
||||
|
||||
return chart;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建趋势图表
|
||||
* @param {HTMLElement} container - 图表容器元素
|
||||
* @param {Array} data - 图表数据
|
||||
* @param {Object} options - 额外配置选项
|
||||
* @returns {echarts.ECharts} 图表实例
|
||||
*/
|
||||
export function createTrendChart(container, data, options = {}) {
|
||||
const { xAxis = [], series = [] } = data;
|
||||
|
||||
const defaultOptions = {
|
||||
title: {
|
||||
text: options.title || '趋势图表',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
legend: {
|
||||
data: series.map(item => item.name),
|
||||
bottom: 0
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '10%',
|
||||
top: '15%',
|
||||
containLabel: true
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {}
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: xAxis
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: series.map(item => ({
|
||||
name: item.name,
|
||||
type: item.type || 'line',
|
||||
data: item.data,
|
||||
smooth: true,
|
||||
itemStyle: item.itemStyle,
|
||||
lineStyle: item.lineStyle,
|
||||
areaStyle: item.areaStyle
|
||||
}))
|
||||
};
|
||||
|
||||
// 合并选项
|
||||
const chartOptions = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
title: { ...defaultOptions.title, ...options.title },
|
||||
tooltip: { ...defaultOptions.tooltip, ...options.tooltip },
|
||||
legend: { ...defaultOptions.legend, ...options.legend },
|
||||
grid: { ...defaultOptions.grid, ...options.grid }
|
||||
};
|
||||
|
||||
return createChart(container, chartOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建饼图
|
||||
* @param {HTMLElement} container - 图表容器元素
|
||||
* @param {Array} data - 图表数据
|
||||
* @param {Object} options - 额外配置选项
|
||||
* @returns {echarts.ECharts} 图表实例
|
||||
*/
|
||||
export function createPieChart(container, data, options = {}) {
|
||||
const defaultOptions = {
|
||||
title: {
|
||||
text: options.title || '饼图',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: options.seriesName || '数据',
|
||||
type: 'pie',
|
||||
radius: options.radius || ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: data
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// 合并选项
|
||||
const chartOptions = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
title: { ...defaultOptions.title, ...options.title },
|
||||
tooltip: { ...defaultOptions.tooltip, ...options.tooltip },
|
||||
legend: { ...defaultOptions.legend, ...options.legend }
|
||||
};
|
||||
|
||||
return createChart(container, chartOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建柱状图
|
||||
* @param {HTMLElement} container - 图表容器元素
|
||||
* @param {Object} data - 图表数据
|
||||
* @param {Object} options - 额外配置选项
|
||||
* @returns {echarts.ECharts} 图表实例
|
||||
*/
|
||||
export function createBarChart(container, data, options = {}) {
|
||||
const { xAxis = [], series = [] } = data;
|
||||
|
||||
const defaultOptions = {
|
||||
title: {
|
||||
text: options.title || '柱状图',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: series.map(item => item.name),
|
||||
bottom: 0
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '10%',
|
||||
top: '15%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xAxis
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: series.map(item => ({
|
||||
name: item.name,
|
||||
type: 'bar',
|
||||
data: item.data,
|
||||
itemStyle: item.itemStyle
|
||||
}))
|
||||
};
|
||||
|
||||
// 合并选项
|
||||
const chartOptions = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
title: { ...defaultOptions.title, ...options.title },
|
||||
tooltip: { ...defaultOptions.tooltip, ...options.tooltip },
|
||||
legend: { ...defaultOptions.legend, ...options.legend },
|
||||
grid: { ...defaultOptions.grid, ...options.grid }
|
||||
};
|
||||
|
||||
return createChart(container, chartOptions);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 处理窗口大小变化,调整图表大小
|
||||
* @param {echarts.ECharts} chart - 图表实例
|
||||
*/
|
||||
export function handleResize(chart) {
|
||||
if (chart) {
|
||||
chart.resize();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁图表实例
|
||||
* @param {echarts.ECharts} chart - 图表实例
|
||||
* @param {string} cacheKey - 缓存键(可选)
|
||||
*/
|
||||
export function disposeChart(chart, cacheKey = null) {
|
||||
if (chart) {
|
||||
chart.dispose();
|
||||
|
||||
// 清理缓存
|
||||
if (cacheKey) {
|
||||
chartInstanceCache.delete(cacheKey);
|
||||
dataCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据缓存管理
|
||||
*/
|
||||
export const DataCache = {
|
||||
/**
|
||||
* 设置缓存数据
|
||||
* @param {string} key - 缓存键
|
||||
* @param {any} data - 数据
|
||||
* @param {number} ttl - 过期时间(毫秒),默认5分钟
|
||||
*/
|
||||
set(key, data, ttl = 5 * 60 * 1000) {
|
||||
const expireTime = Date.now() + ttl;
|
||||
dataCache.set(key, { data, expireTime });
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取缓存数据
|
||||
* @param {string} key - 缓存键
|
||||
* @returns {any|null} 缓存的数据或null
|
||||
*/
|
||||
get(key) {
|
||||
const cached = dataCache.get(key);
|
||||
if (!cached) return null;
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() > cached.expireTime) {
|
||||
dataCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除缓存数据
|
||||
* @param {string} key - 缓存键
|
||||
*/
|
||||
delete(key) {
|
||||
dataCache.delete(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* 清空所有缓存
|
||||
*/
|
||||
clear() {
|
||||
dataCache.clear();
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查缓存是否存在且未过期
|
||||
* @param {string} key - 缓存键
|
||||
* @returns {boolean}
|
||||
*/
|
||||
has(key) {
|
||||
return this.get(key) !== null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 懒加载图表组件
|
||||
* @param {HTMLElement} container - 图表容器
|
||||
* @param {Function} dataLoader - 数据加载函数
|
||||
* @param {Object} options - 图表配置
|
||||
* @param {string} cacheKey - 缓存键
|
||||
* @returns {Promise<echarts.ECharts>}
|
||||
*/
|
||||
export async function createLazyChart(container, dataLoader, options = {}, cacheKey = null) {
|
||||
// 检查数据缓存
|
||||
let data = cacheKey ? DataCache.get(cacheKey) : null;
|
||||
|
||||
if (!data) {
|
||||
// 显示加载状态
|
||||
const loadingChart = createChart(container, {
|
||||
title: {
|
||||
text: '加载中...',
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
textStyle: {
|
||||
fontSize: 14,
|
||||
color: '#999'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// 异步加载数据
|
||||
data = await dataLoader();
|
||||
|
||||
// 缓存数据
|
||||
if (cacheKey) {
|
||||
DataCache.set(cacheKey, data);
|
||||
}
|
||||
|
||||
// 销毁加载图表
|
||||
loadingChart.dispose();
|
||||
} catch (error) {
|
||||
console.error('图表数据加载失败:', error);
|
||||
|
||||
// 显示错误状态
|
||||
loadingChart.setOption({
|
||||
title: {
|
||||
text: '加载失败',
|
||||
left: 'center',
|
||||
top: 'center',
|
||||
textStyle: {
|
||||
fontSize: 14,
|
||||
color: '#ff4d4f'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return loadingChart;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建实际图表
|
||||
const chartOptions = {
|
||||
...options,
|
||||
...data
|
||||
};
|
||||
|
||||
return createChart(container, chartOptions, cacheKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有缓存
|
||||
*/
|
||||
export function clearAllCache() {
|
||||
// 销毁所有缓存的图表实例
|
||||
chartInstanceCache.forEach(chart => {
|
||||
if (chart && !chart.isDisposed()) {
|
||||
chart.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
// 清空缓存
|
||||
chartInstanceCache.clear();
|
||||
dataCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
*/
|
||||
export function getCacheStats() {
|
||||
return {
|
||||
chartInstances: chartInstanceCache.size,
|
||||
dataCache: dataCache.size,
|
||||
memoryUsage: {
|
||||
charts: chartInstanceCache.size * 0.1, // 估算MB
|
||||
data: dataCache.size * 0.05 // 估算MB
|
||||
}
|
||||
};
|
||||
}
|
||||
262
admin-system/frontend/src/utils/dataService.js
Normal file
262
admin-system/frontend/src/utils/dataService.js
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 数据服务工具
|
||||
* 封装了与后端数据相关的API请求
|
||||
*/
|
||||
|
||||
import { api } from './api';
|
||||
|
||||
/**
|
||||
* 养殖场数据服务
|
||||
*/
|
||||
export const farmService = {
|
||||
/**
|
||||
* 获取所有养殖场
|
||||
* @returns {Promise<Array>} 养殖场列表
|
||||
*/
|
||||
async getAllFarms() {
|
||||
const farms = await api.get('/farms/public');
|
||||
|
||||
// 标准化location字段格式
|
||||
return farms.map(farm => {
|
||||
if (farm.location) {
|
||||
// 如果location是字符串,尝试解析为JSON
|
||||
if (typeof farm.location === 'string') {
|
||||
try {
|
||||
farm.location = JSON.parse(farm.location);
|
||||
} catch (error) {
|
||||
console.warn(`解析养殖场 ${farm.id} 的location字段失败:`, error);
|
||||
farm.location = null;
|
||||
}
|
||||
}
|
||||
// 如果location是对象但缺少lat或lng,设为null
|
||||
if (farm.location && (typeof farm.location.lat !== 'number' || typeof farm.location.lng !== 'number')) {
|
||||
console.warn(`养殖场 ${farm.id} 的location字段格式不正确:`, farm.location);
|
||||
farm.location = null;
|
||||
}
|
||||
}
|
||||
return farm;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取养殖场详情
|
||||
* @param {string} id - 养殖场ID
|
||||
* @returns {Promise<Object>} 养殖场详情
|
||||
*/
|
||||
async getFarmById(id) {
|
||||
return api.get(`/farms/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建养殖场
|
||||
* @param {Object} farmData - 养殖场数据
|
||||
* @returns {Promise<Object>} 创建的养殖场
|
||||
*/
|
||||
async createFarm(farmData) {
|
||||
return api.post('/farms', farmData);
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新养殖场
|
||||
* @param {string} id - 养殖场ID
|
||||
* @param {Object} farmData - 养殖场数据
|
||||
* @returns {Promise<Object>} 更新后的养殖场
|
||||
*/
|
||||
async updateFarm(id, farmData) {
|
||||
return api.put(`/farms/${id}`, farmData);
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除养殖场
|
||||
* @param {string} id - 养殖场ID
|
||||
* @returns {Promise<Object>} 删除结果
|
||||
*/
|
||||
async deleteFarm(id) {
|
||||
return api.delete(`/farms/${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 动物数据服务
|
||||
*/
|
||||
export const animalService = {
|
||||
/**
|
||||
* 获取所有动物
|
||||
* @returns {Promise<Array>} 动物列表
|
||||
*/
|
||||
async getAllAnimals() {
|
||||
return api.get('/animals/public');
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取养殖场的所有动物
|
||||
* @param {string} farmId - 养殖场ID
|
||||
* @returns {Promise<Array>} 动物列表
|
||||
*/
|
||||
async getAnimalsByFarm(farmId) {
|
||||
return api.get(`/farms/${farmId}/animals`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取动物详情
|
||||
* @param {string} id - 动物ID
|
||||
* @returns {Promise<Object>} 动物详情
|
||||
*/
|
||||
async getAnimalById(id) {
|
||||
return api.get(`/animals/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建动物
|
||||
* @param {Object} animalData - 动物数据
|
||||
* @returns {Promise<Object>} 创建的动物
|
||||
*/
|
||||
async createAnimal(animalData) {
|
||||
return api.post('/animals', animalData);
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新动物
|
||||
* @param {string} id - 动物ID
|
||||
* @param {Object} animalData - 动物数据
|
||||
* @returns {Promise<Object>} 更新后的动物
|
||||
*/
|
||||
async updateAnimal(id, animalData) {
|
||||
return api.put(`/animals/${id}`, animalData);
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除动物
|
||||
* @param {string} id - 动物ID
|
||||
* @returns {Promise<Object>} 删除结果
|
||||
*/
|
||||
async deleteAnimal(id) {
|
||||
return api.delete(`/animals/${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 设备数据服务
|
||||
*/
|
||||
export const deviceService = {
|
||||
/**
|
||||
* 获取所有设备
|
||||
* @returns {Promise<Array>} 设备列表
|
||||
*/
|
||||
async getAllDevices() {
|
||||
return api.get('/devices/public');
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取养殖场的所有设备
|
||||
* @param {string} farmId - 养殖场ID
|
||||
* @returns {Promise<Array>} 设备列表
|
||||
*/
|
||||
async getDevicesByFarm(farmId) {
|
||||
return api.get(`/farms/${farmId}/devices`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取设备详情
|
||||
* @param {string} id - 设备ID
|
||||
* @returns {Promise<Object>} 设备详情
|
||||
*/
|
||||
async getDeviceById(id) {
|
||||
return api.get(`/devices/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取设备状态
|
||||
* @param {string} id - 设备ID
|
||||
* @returns {Promise<Object>} 设备状态
|
||||
*/
|
||||
async getDeviceStatus(id) {
|
||||
return api.get(`/devices/${id}/status`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 预警数据服务
|
||||
*/
|
||||
export const alertService = {
|
||||
/**
|
||||
* 获取所有预警
|
||||
* @returns {Promise<Array>} 预警列表
|
||||
*/
|
||||
async getAllAlerts() {
|
||||
return api.get('/alerts/public');
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取养殖场的所有预警
|
||||
* @param {string} farmId - 养殖场ID
|
||||
* @returns {Promise<Array>} 预警列表
|
||||
*/
|
||||
async getAlertsByFarm(farmId) {
|
||||
return api.get(`/farms/${farmId}/alerts`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取预警详情
|
||||
* @param {string} id - 预警ID
|
||||
* @returns {Promise<Object>} 预警详情
|
||||
*/
|
||||
async getAlertById(id) {
|
||||
return api.get(`/alerts/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* 处理预警
|
||||
* @param {string} id - 预警ID
|
||||
* @param {Object} data - 处理数据
|
||||
* @returns {Promise<Object>} 处理结果
|
||||
*/
|
||||
async handleAlert(id, data) {
|
||||
return api.put(`/alerts/${id}/handle`, data);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 统计数据服务
|
||||
*/
|
||||
export const statsService = {
|
||||
/**
|
||||
* 获取系统概览统计数据
|
||||
* @returns {Promise<Object>} 统计数据
|
||||
*/
|
||||
async getDashboardStats() {
|
||||
return api.get('/stats/public/dashboard');
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取养殖场统计数据
|
||||
* @returns {Promise<Object>} 统计数据
|
||||
*/
|
||||
async getFarmStats() {
|
||||
return api.get('/stats/farms');
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取动物统计数据
|
||||
* @returns {Promise<Object>} 统计数据
|
||||
*/
|
||||
async getAnimalStats() {
|
||||
return api.get('/stats/animals');
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取设备统计数据
|
||||
* @returns {Promise<Object>} 统计数据
|
||||
*/
|
||||
async getDeviceStats() {
|
||||
return api.get('/stats/devices');
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取预警统计数据
|
||||
* @returns {Promise<Object>} 统计数据
|
||||
*/
|
||||
async getAlertStats() {
|
||||
return api.get('/stats/alerts');
|
||||
}
|
||||
};
|
||||
353
admin-system/frontend/src/utils/mapService.jsx
Normal file
353
admin-system/frontend/src/utils/mapService.jsx
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* 百度地图服务工具
|
||||
* 封装了百度地图API的初始化和操作方法
|
||||
*/
|
||||
|
||||
// 百度地图API加载状态
|
||||
let BMapLoaded = false;
|
||||
let loadingPromise = null;
|
||||
|
||||
/**
|
||||
* 加载百度地图API
|
||||
* @returns {Promise} 加载完成的Promise
|
||||
*/
|
||||
export const loadBMapScript = async () => {
|
||||
// 如果已经加载过,直接返回
|
||||
if (BMapLoaded) {
|
||||
console.log('百度地图API已加载');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 如果正在加载中,返回加载Promise
|
||||
if (loadingPromise) {
|
||||
console.log('百度地图API正在加载中...');
|
||||
return loadingPromise;
|
||||
}
|
||||
|
||||
console.log('开始加载百度地图API...');
|
||||
|
||||
// 创建加载Promise
|
||||
loadingPromise = new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
// 导入环境配置
|
||||
const { BAIDU_MAP_CONFIG } = await import('../config/env');
|
||||
|
||||
console.log('使用API密钥:', BAIDU_MAP_CONFIG.apiKey);
|
||||
console.log('完整配置:', BAIDU_MAP_CONFIG);
|
||||
|
||||
// 检查API密钥是否有效
|
||||
if (!BAIDU_MAP_CONFIG.apiKey || BAIDU_MAP_CONFIG.apiKey === 'YOUR_VALID_BAIDU_MAP_API_KEY') {
|
||||
const error = new Error('百度地图API密钥未配置或无效');
|
||||
console.error('API密钥错误:', error);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已经存在BMap
|
||||
if (typeof window.BMap !== 'undefined') {
|
||||
console.log('BMap已存在,直接使用');
|
||||
BMapLoaded = true;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建全局回调函数
|
||||
window.initBMap = () => {
|
||||
console.log('百度地图API加载成功,BMap对象类型:', typeof window.BMap);
|
||||
console.log('BMap对象详情:', window.BMap);
|
||||
console.log('BMap.Map是否存在:', typeof window.BMap?.Map);
|
||||
BMapLoaded = true;
|
||||
resolve();
|
||||
// 清理全局回调
|
||||
delete window.initBMap;
|
||||
};
|
||||
|
||||
// 创建script标签
|
||||
const script = document.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
// 使用配置文件中的API密钥
|
||||
script.src = `https://api.map.baidu.com/api?v=3.0&ak=${BAIDU_MAP_CONFIG.apiKey}&callback=initBMap`;
|
||||
|
||||
console.log('百度地图API URL:', script.src);
|
||||
|
||||
script.onerror = (error) => {
|
||||
console.error('百度地图脚本加载失败:', error);
|
||||
reject(new Error('百度地图脚本加载失败'));
|
||||
};
|
||||
|
||||
// 设置超时
|
||||
setTimeout(() => {
|
||||
if (!BMapLoaded) {
|
||||
reject(new Error('百度地图API加载超时'));
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
// 添加到文档中
|
||||
document.head.appendChild(script);
|
||||
} catch (error) {
|
||||
console.error('加载百度地图API时出错:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
return loadingPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建百度地图实例
|
||||
* @param {HTMLElement} container - 地图容器元素
|
||||
* @param {Object} options - 地图配置选项
|
||||
* @returns {Promise<BMap.Map>} 地图实例
|
||||
*/
|
||||
export const createMap = async (container, options = {}) => {
|
||||
try {
|
||||
console.log('开始创建地图,容器:', container);
|
||||
console.log('容器尺寸:', container.offsetWidth, 'x', container.offsetHeight);
|
||||
|
||||
// 确保百度地图API已加载
|
||||
await loadBMapScript();
|
||||
|
||||
// 导入环境配置
|
||||
const { BAIDU_MAP_CONFIG } = await import('../config/env');
|
||||
|
||||
console.log('百度地图配置:', BAIDU_MAP_CONFIG);
|
||||
|
||||
// 检查BMap是否可用
|
||||
if (typeof window.BMap === 'undefined') {
|
||||
const error = new Error('百度地图API未正确加载');
|
||||
console.error('BMap未定义:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log('BMap对象可用,版本:', window.BMap.version || '未知');
|
||||
|
||||
// 默认配置
|
||||
const defaultOptions = {
|
||||
center: new BMap.Point(BAIDU_MAP_CONFIG.defaultCenter.lng, BAIDU_MAP_CONFIG.defaultCenter.lat), // 宁夏中心点
|
||||
zoom: BAIDU_MAP_CONFIG.defaultZoom, // 默认缩放级别
|
||||
enableMapClick: true, // 启用地图点击
|
||||
enableScrollWheelZoom: true, // 启用滚轮缩放
|
||||
enableDragging: true, // 启用拖拽
|
||||
enableDoubleClickZoom: true, // 启用双击缩放
|
||||
enableKeyboard: true // 启用键盘控制
|
||||
};
|
||||
|
||||
// 合并配置
|
||||
const mergedOptions = { ...defaultOptions, ...options };
|
||||
|
||||
console.log('地图配置选项:', mergedOptions);
|
||||
|
||||
// 创建地图实例
|
||||
console.log('创建BMap.Map实例...');
|
||||
const map = new window.BMap.Map(container);
|
||||
console.log('地图实例创建成功:', map);
|
||||
|
||||
// 监听地图加载完成事件
|
||||
map.addEventListener('tilesloaded', function() {
|
||||
console.log('百度地图瓦片加载完成');
|
||||
});
|
||||
|
||||
map.addEventListener('load', function() {
|
||||
console.log('百度地图完全加载完成');
|
||||
});
|
||||
|
||||
// 设置中心点和缩放级别
|
||||
console.log('设置地图中心点和缩放级别:', mergedOptions.center, mergedOptions.zoom);
|
||||
map.centerAndZoom(mergedOptions.center, mergedOptions.zoom);
|
||||
|
||||
// 延迟确保地图完全渲染
|
||||
setTimeout(() => {
|
||||
console.log('地图渲染完成');
|
||||
// 移除map.reset()调用,避免缩放时重置地图
|
||||
}, 100);
|
||||
|
||||
// 添加地图类型控件
|
||||
console.log('添加地图控件...');
|
||||
map.addControl(new window.BMap.MapTypeControl());
|
||||
|
||||
// 添加增强的缩放控件
|
||||
const navigationControl = new window.BMap.NavigationControl({
|
||||
anchor: window.BMAP_ANCHOR_TOP_LEFT,
|
||||
type: window.BMAP_NAVIGATION_CONTROL_LARGE,
|
||||
enableGeolocation: false
|
||||
});
|
||||
map.addControl(navigationControl);
|
||||
|
||||
// 添加缩放控件(滑块式)
|
||||
const scaleControl = new window.BMap.ScaleControl({
|
||||
anchor: window.BMAP_ANCHOR_BOTTOM_LEFT
|
||||
});
|
||||
map.addControl(scaleControl);
|
||||
|
||||
// 添加概览地图控件
|
||||
const overviewMapControl = new window.BMap.OverviewMapControl({
|
||||
anchor: window.BMAP_ANCHOR_TOP_RIGHT,
|
||||
isOpen: false
|
||||
});
|
||||
map.addControl(overviewMapControl);
|
||||
|
||||
// 设置缩放范围
|
||||
map.setMinZoom(3);
|
||||
map.setMaxZoom(19);
|
||||
|
||||
// 配置地图功能
|
||||
if (mergedOptions.enableScrollWheelZoom) {
|
||||
map.enableScrollWheelZoom();
|
||||
}
|
||||
|
||||
if (mergedOptions.enableDragging) {
|
||||
map.enableDragging();
|
||||
} else {
|
||||
map.disableDragging();
|
||||
}
|
||||
|
||||
if (mergedOptions.enableDoubleClickZoom) {
|
||||
map.enableDoubleClickZoom();
|
||||
} else {
|
||||
map.disableDoubleClickZoom();
|
||||
}
|
||||
|
||||
if (mergedOptions.enableKeyboard) {
|
||||
map.enableKeyboard();
|
||||
} else {
|
||||
map.disableKeyboard();
|
||||
}
|
||||
|
||||
console.log('百度地图创建成功:', map);
|
||||
|
||||
return map;
|
||||
} catch (error) {
|
||||
console.error('创建百度地图失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 在地图上添加标记
|
||||
* @param {BMap.Map} map - 百度地图实例
|
||||
* @param {Array} markers - 标记点数据数组
|
||||
* @param {Function} onClick - 点击标记的回调函数
|
||||
* @returns {Array<BMap.Marker>} 标记点实例数组
|
||||
*/
|
||||
export const addMarkers = (map, markers = [], onClick = null) => {
|
||||
// 验证地图实例是否有效
|
||||
if (!map || typeof map.addOverlay !== 'function') {
|
||||
console.error('addMarkers: 无效的地图实例', map);
|
||||
return [];
|
||||
}
|
||||
|
||||
return markers.map(markerData => {
|
||||
// 创建标记点
|
||||
const point = new BMap.Point(markerData.location.lng, markerData.location.lat);
|
||||
const marker = new BMap.Marker(point);
|
||||
|
||||
// 添加标记到地图
|
||||
map.addOverlay(marker);
|
||||
|
||||
// 创建信息窗口
|
||||
if (markerData.title || markerData.content) {
|
||||
const infoWindow = new BMap.InfoWindow(
|
||||
`<div class="map-info-window">
|
||||
${markerData.content || ''}
|
||||
</div>`,
|
||||
{
|
||||
title: markerData.title || '',
|
||||
width: 250,
|
||||
height: 120,
|
||||
enableMessage: false
|
||||
}
|
||||
);
|
||||
|
||||
// 添加点击事件
|
||||
marker.addEventListener('click', () => {
|
||||
marker.openInfoWindow(infoWindow);
|
||||
|
||||
// 如果有自定义点击回调,则调用
|
||||
if (onClick && typeof onClick === 'function') {
|
||||
onClick(markerData, marker);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return marker;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 清除地图上的所有覆盖物
|
||||
* @param {BMap.Map} map - 百度地图实例
|
||||
*/
|
||||
export const clearOverlays = (map) => {
|
||||
if (!map || typeof map.clearOverlays !== 'function') {
|
||||
console.warn('clearOverlays: 无效的地图实例,已跳过清理');
|
||||
return;
|
||||
}
|
||||
map.clearOverlays();
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置地图视图以包含所有标记点
|
||||
* @param {BMap.Map} map - 百度地图实例
|
||||
* @param {Array<BMap.Point>} points - 点数组
|
||||
* @param {Number} padding - 边距,单位像素
|
||||
*/
|
||||
export const setViewToFitMarkers = (map, points, padding = 50) => {
|
||||
if (!map) {
|
||||
console.warn('setViewToFitMarkers: 无效的地图实例,已跳过设置视图');
|
||||
return;
|
||||
}
|
||||
if (!points || points.length === 0) return;
|
||||
|
||||
// 如果只有一个点,直接居中
|
||||
if (points.length === 1) {
|
||||
map.centerAndZoom(points[0], 15);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建视图范围
|
||||
const viewport = map.getViewport(points, { margins: [padding, padding, padding, padding] });
|
||||
map.setViewport(viewport);
|
||||
};
|
||||
|
||||
/**
|
||||
* 转换养殖场数据为地图标记数据
|
||||
* @param {Array} farms - 养殖场数据数组
|
||||
* @returns {Array} 地图标记数据数组
|
||||
*/
|
||||
export const convertFarmsToMarkers = (farms = []) => {
|
||||
return farms
|
||||
.filter(farm => {
|
||||
// 只保留有有效位置信息的农场
|
||||
return farm.location &&
|
||||
typeof farm.location.lat === 'number' &&
|
||||
typeof farm.location.lng === 'number';
|
||||
})
|
||||
.map(farm => {
|
||||
// 计算动物总数
|
||||
const animalCount = farm.animals ?
|
||||
farm.animals.reduce((total, animal) => total + (animal.count || 0), 0) : 0;
|
||||
|
||||
// 计算在线设备数
|
||||
const onlineDevices = farm.devices ?
|
||||
farm.devices.filter(device => device.status === 'online').length : 0;
|
||||
const totalDevices = farm.devices ? farm.devices.length : 0;
|
||||
|
||||
return {
|
||||
id: farm.id,
|
||||
title: farm.name,
|
||||
location: farm.location,
|
||||
content: `
|
||||
<div style="padding: 8px; font-size: 14px;">
|
||||
<h4 style="margin: 0 0 8px 0; color: #1890ff;">${farm.name}</h4>
|
||||
<p style="margin: 4px 0;"><strong>类型:</strong> ${farm.type || '未知'}</p>
|
||||
<p style="margin: 4px 0;"><strong>动物数量:</strong> ${animalCount} 只</p>
|
||||
<p style="margin: 4px 0;"><strong>设备状态:</strong> ${onlineDevices}/${totalDevices} 在线</p>
|
||||
<p style="margin: 4px 0;"><strong>联系人:</strong> ${farm.contact || '未知'}</p>
|
||||
<p style="margin: 4px 0;"><strong>地址:</strong> ${farm.address || '未知'}</p>
|
||||
<p style="margin: 4px 0; color: #666;"><strong>坐标:</strong> ${farm.location.lat.toFixed(4)}, ${farm.location.lng.toFixed(4)}</p>
|
||||
</div>
|
||||
`,
|
||||
originalData: farm
|
||||
};
|
||||
});
|
||||
};
|
||||
576
admin-system/frontend/src/views/Alerts.vue
Normal file
576
admin-system/frontend/src/views/Alerts.vue
Normal file
@@ -0,0 +1,576 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>预警管理</h1>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加预警
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="alerts"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'level'">
|
||||
<a-tag :color="getLevelColor(record.level)">
|
||||
{{ getLevelText(record.level) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'farm_name'">
|
||||
{{ getFarmName(record.farm_id) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'device_name'">
|
||||
{{ getDeviceName(record.device_id) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'resolved_at'">
|
||||
{{ formatDate(record.resolved_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'created_at'">
|
||||
{{ formatDateTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="editAlert(record)">编辑</a-button>
|
||||
<a-button
|
||||
type="link"
|
||||
@click="resolveAlert(record)"
|
||||
v-if="record.status !== 'resolved'"
|
||||
>
|
||||
解决
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个预警吗?"
|
||||
@confirm="deleteAlert(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑预警模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑预警' : '添加预警'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
width="600px"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="预警类型" name="type">
|
||||
<a-select v-model="formData.type" placeholder="请选择预警类型">
|
||||
<a-select-option value="temperature">温度异常</a-select-option>
|
||||
<a-select-option value="humidity">湿度异常</a-select-option>
|
||||
<a-select-option value="device_failure">设备故障</a-select-option>
|
||||
<a-select-option value="animal_health">动物健康</a-select-option>
|
||||
<a-select-option value="security">安全警报</a-select-option>
|
||||
<a-select-option value="maintenance">维护提醒</a-select-option>
|
||||
<a-select-option value="other">其他</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="预警级别" name="level">
|
||||
<a-select v-model="formData.level" placeholder="请选择预警级别">
|
||||
<a-select-option value="low">低</a-select-option>
|
||||
<a-select-option value="medium">中</a-select-option>
|
||||
<a-select-option value="high">高</a-select-option>
|
||||
<a-select-option value="critical">紧急</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="预警消息" name="message">
|
||||
<a-textarea
|
||||
v-model:value="formData.message"
|
||||
placeholder="请输入预警消息"
|
||||
:rows="3"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="预警状态" name="status">
|
||||
<a-select v-model="formData.status" placeholder="请选择预警状态">
|
||||
<a-select-option value="active">活跃</a-select-option>
|
||||
<a-select-option value="acknowledged">已确认</a-select-option>
|
||||
<a-select-option value="resolved">已解决</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="所属农场" name="farm_id">
|
||||
<a-select
|
||||
v-model="formData.farm_id"
|
||||
placeholder="请选择所属农场"
|
||||
:loading="farmsLoading"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="farm in farms"
|
||||
:key="farm.id"
|
||||
:value="farm.id"
|
||||
>
|
||||
{{ farm.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="关联设备" name="device_id">
|
||||
<a-select
|
||||
v-model="formData.device_id"
|
||||
placeholder="请选择关联设备(可选)"
|
||||
:loading="devicesLoading"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option
|
||||
v-for="device in devices"
|
||||
:key="device.id"
|
||||
:value="device.id"
|
||||
>
|
||||
{{ device.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="解决备注" name="resolution_notes" v-if="formData.status === 'resolved'">
|
||||
<a-textarea
|
||||
v-model:value="formData.resolution_notes"
|
||||
placeholder="请输入解决备注"
|
||||
:rows="2"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 解决预警模态框 -->
|
||||
<a-modal
|
||||
v-model:open="resolveModalVisible"
|
||||
title="解决预警"
|
||||
@ok="handleResolve"
|
||||
@cancel="resolveModalVisible = false"
|
||||
:confirm-loading="resolveLoading"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="解决备注">
|
||||
<a-textarea
|
||||
v-model:value="resolveNotes"
|
||||
placeholder="请输入解决备注"
|
||||
:rows="3"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import axios from 'axios'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// 响应式数据
|
||||
const alerts = ref([])
|
||||
const farms = ref([])
|
||||
const devices = ref([])
|
||||
const loading = ref(false)
|
||||
const farmsLoading = ref(false)
|
||||
const devicesLoading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const resolveModalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const resolveLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
const currentAlert = ref(null)
|
||||
const resolveNotes = ref('')
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
type: '',
|
||||
level: 'medium',
|
||||
message: '',
|
||||
status: 'active',
|
||||
farm_id: null,
|
||||
device_id: null,
|
||||
resolution_notes: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
type: [{ required: true, message: '请选择预警类型', trigger: 'change' }],
|
||||
level: [{ required: true, message: '请选择预警级别', trigger: 'change' }],
|
||||
message: [{ required: true, message: '请输入预警消息', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择预警状态', trigger: 'change' }],
|
||||
farm_id: [{ required: true, message: '请选择所属农场', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '预警类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '级别',
|
||||
dataIndex: 'level',
|
||||
key: 'level',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '预警消息',
|
||||
dataIndex: 'message',
|
||||
key: 'message',
|
||||
ellipsis: true,
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '所属农场',
|
||||
dataIndex: 'farm_name',
|
||||
key: 'farm_name',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '关联设备',
|
||||
dataIndex: 'device_name',
|
||||
key: 'device_name',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '解决时间',
|
||||
dataIndex: 'resolved_at',
|
||||
key: 'resolved_at',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 180
|
||||
}
|
||||
]
|
||||
|
||||
// 获取预警列表
|
||||
const fetchAlerts = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('/api/alerts', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
if (response.data.success) {
|
||||
alerts.value = response.data.data
|
||||
} else {
|
||||
message.error('获取预警列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取预警列表失败:', error)
|
||||
if (error.response && error.response.status === 401) {
|
||||
message.error('登录已过期,请重新登录')
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login'
|
||||
}, 2000)
|
||||
} else {
|
||||
message.error('获取预警列表失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取农场列表
|
||||
const fetchFarms = async () => {
|
||||
try {
|
||||
farmsLoading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('/api/farms', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
if (response.data.success) {
|
||||
farms.value = response.data.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取农场列表失败:', error)
|
||||
} finally {
|
||||
farmsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取设备列表
|
||||
const fetchDevices = async () => {
|
||||
try {
|
||||
devicesLoading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('/api/devices', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
if (response.data.success) {
|
||||
devices.value = response.data.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取设备列表失败:', error)
|
||||
} finally {
|
||||
devicesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
fetchFarms()
|
||||
fetchDevices()
|
||||
}
|
||||
|
||||
// 编辑预警
|
||||
const editAlert = (record) => {
|
||||
isEdit.value = true
|
||||
// 逐个字段赋值,避免破坏响应式绑定
|
||||
formData.id = record.id
|
||||
formData.type = record.type
|
||||
formData.level = record.level
|
||||
formData.message = record.message
|
||||
formData.status = record.status
|
||||
formData.farm_id = record.farm_id
|
||||
formData.device_id = record.device_id
|
||||
formData.resolution_notes = record.resolution_notes || ''
|
||||
|
||||
modalVisible.value = true
|
||||
fetchFarms()
|
||||
fetchDevices()
|
||||
}
|
||||
|
||||
// 解决预警
|
||||
const resolveAlert = (record) => {
|
||||
currentAlert.value = record
|
||||
resolveNotes.value = ''
|
||||
resolveModalVisible.value = true
|
||||
}
|
||||
|
||||
// 处理解决预警
|
||||
const handleResolve = async () => {
|
||||
try {
|
||||
resolveLoading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.put(`/api/alerts/${currentAlert.value.id}`, {
|
||||
status: 'resolved',
|
||||
resolved_at: new Date().toISOString(),
|
||||
resolution_notes: resolveNotes.value
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
message.success('预警已解决')
|
||||
resolveModalVisible.value = false
|
||||
fetchAlerts()
|
||||
} else {
|
||||
message.error('解决预警失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解决预警失败:', error)
|
||||
message.error('解决预警失败')
|
||||
} finally {
|
||||
resolveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除预警
|
||||
const deleteAlert = async (id) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.delete(`/api/alerts/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
if (response.data.success) {
|
||||
message.success('删除成功')
|
||||
fetchAlerts()
|
||||
} else {
|
||||
message.error('删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除预警失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
const config = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
// 准备提交数据
|
||||
const submitData = { ...formData }
|
||||
// 如果是新增操作,移除id字段
|
||||
if (!isEdit.value) {
|
||||
delete submitData.id
|
||||
}
|
||||
|
||||
let response
|
||||
if (isEdit.value) {
|
||||
response = await axios.put(`/api/alerts/${formData.id}`, submitData, config)
|
||||
} else {
|
||||
response = await axios.post('/api/alerts', submitData, config)
|
||||
}
|
||||
|
||||
if (response.data.success) {
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
modalVisible.value = false
|
||||
fetchAlerts()
|
||||
} else {
|
||||
message.error(isEdit.value ? '更新失败' : '创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
type: '',
|
||||
level: 'medium',
|
||||
message: '',
|
||||
status: 'active',
|
||||
farm_id: null,
|
||||
device_id: null,
|
||||
resolution_notes: ''
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 获取级别颜色
|
||||
const getLevelColor = (level) => {
|
||||
const colors = {
|
||||
low: 'green',
|
||||
medium: 'orange',
|
||||
high: 'red',
|
||||
critical: 'purple'
|
||||
}
|
||||
return colors[level] || 'default'
|
||||
}
|
||||
|
||||
// 获取级别文本
|
||||
const getLevelText = (level) => {
|
||||
const texts = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
critical: '紧急'
|
||||
}
|
||||
return texts[level] || level
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
active: 'red',
|
||||
acknowledged: 'orange',
|
||||
resolved: 'green'
|
||||
}
|
||||
return colors[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
active: '活跃',
|
||||
acknowledged: '已确认',
|
||||
resolved: '已解决'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
// 获取农场名称
|
||||
const getFarmName = (farmId) => {
|
||||
const farm = farms.value.find(f => f.id === farmId)
|
||||
return farm ? farm.name : `农场ID: ${farmId}`
|
||||
}
|
||||
|
||||
// 获取设备名称
|
||||
const getDeviceName = (deviceId) => {
|
||||
if (!deviceId) return '-'
|
||||
const device = devices.value.find(d => d.id === deviceId)
|
||||
return device ? device.name : `设备ID: ${deviceId}`
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-'
|
||||
return dayjs(date).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (date) => {
|
||||
if (!date) return '-'
|
||||
return dayjs(date).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchAlerts()
|
||||
fetchFarms()
|
||||
fetchDevices()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 可以添加自定义样式 */
|
||||
</style>
|
||||
512
admin-system/frontend/src/views/Analytics.vue
Normal file
512
admin-system/frontend/src/views/Analytics.vue
Normal file
@@ -0,0 +1,512 @@
|
||||
<template>
|
||||
<div class="analytics-page">
|
||||
<a-page-header
|
||||
title="数据分析"
|
||||
sub-title="宁夏智慧养殖监管平台数据分析"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button>导出报表</a-button>
|
||||
<a-button type="primary" @click="refreshData">
|
||||
<template #icon><reload-outlined /></template>
|
||||
刷新数据
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<div class="analytics-content">
|
||||
<!-- 趋势图表 -->
|
||||
<a-card title="月度数据趋势" :bordered="false" class="chart-card">
|
||||
<e-chart :options="trendChartOptions" height="350px" @chart-ready="handleChartReady" />
|
||||
</a-card>
|
||||
|
||||
<div class="analytics-row">
|
||||
<!-- 养殖场类型分布 -->
|
||||
<a-card title="养殖场类型分布" :bordered="false" class="chart-card">
|
||||
<e-chart :options="farmTypeChartOptions" height="300px" />
|
||||
</a-card>
|
||||
|
||||
<!-- 动物类型分布 -->
|
||||
<a-card title="动物类型分布" :bordered="false" class="chart-card">
|
||||
<animal-stats />
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<div class="analytics-row">
|
||||
<!-- 设备类型分布 -->
|
||||
<a-card title="设备类型分布" :bordered="false" class="chart-card">
|
||||
<device-stats />
|
||||
<simple-device-test />
|
||||
</a-card>
|
||||
|
||||
<!-- 预警类型分布 -->
|
||||
<a-card title="预警类型分布" :bordered="false" class="chart-card">
|
||||
<alert-stats />
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<a-card title="养殖场数据统计" :bordered="false" class="data-card">
|
||||
<a-table :dataSource="farmTableData" :columns="farmColumns" :pagination="{ pageSize: 5 }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'animalCount'">
|
||||
<a-progress :percent="getPercentage(record.animalCount, maxAnimalCount)" :stroke-color="getProgressColor(record.animalCount, maxAnimalCount)" />
|
||||
</template>
|
||||
<template v-if="column.key === 'deviceCount'">
|
||||
<a-progress :percent="getPercentage(record.deviceCount, maxDeviceCount)" :stroke-color="getProgressColor(record.deviceCount, maxDeviceCount)" />
|
||||
</template>
|
||||
<template v-if="column.key === 'alertCount'">
|
||||
<a-progress :percent="getPercentage(record.alertCount, maxAlertCount)" :stroke-color="{ from: '#108ee9', to: '#ff4d4f' }" />
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { useDataStore } from '../stores'
|
||||
import EChart from '../components/EChart.vue'
|
||||
import AnimalStats from '../components/AnimalStats.vue'
|
||||
import DeviceStats from '../components/DeviceStats.vue'
|
||||
import AlertStats from '../components/AlertStats.vue'
|
||||
import SimpleDeviceTest from '../components/SimpleDeviceTest.vue'
|
||||
// 移除模拟数据导入
|
||||
|
||||
// 使用数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 图表实例
|
||||
const charts = reactive({
|
||||
trendChart: null
|
||||
})
|
||||
|
||||
// 月度数据趋势
|
||||
const trendData = ref({
|
||||
xAxis: [],
|
||||
series: []
|
||||
})
|
||||
|
||||
// 处理图表就绪事件
|
||||
function handleChartReady(chart, type) {
|
||||
if (type === 'trend') {
|
||||
charts.trendChart = chart
|
||||
}
|
||||
}
|
||||
|
||||
// 获取月度数据趋势
|
||||
async function fetchMonthlyTrends() {
|
||||
try {
|
||||
const response = await fetch('/api/stats/public/monthly-trends')
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
trendData.value = result.data
|
||||
updateChartData()
|
||||
} else {
|
||||
console.error('获取月度数据趋势失败:', result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取月度数据趋势失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
async function refreshData() {
|
||||
await dataStore.fetchAllData()
|
||||
await fetchMonthlyTrends()
|
||||
updateChartData()
|
||||
}
|
||||
|
||||
// 更新图表数据
|
||||
function updateChartData() {
|
||||
// 更新趋势图表数据
|
||||
if (charts.trendChart) {
|
||||
charts.trendChart.setOption({
|
||||
xAxis: { data: trendData.value.xAxis },
|
||||
series: trendData.value.series
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 趋势图表选项
|
||||
const trendChartOptions = computed(() => {
|
||||
return {
|
||||
title: {
|
||||
text: '月度数据趋势',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
legend: {
|
||||
data: trendData.value.series.map(item => item.name),
|
||||
bottom: 0
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '10%',
|
||||
top: '15%',
|
||||
containLabel: true
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {}
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: trendData.value.xAxis
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: trendData.value.series.map(item => ({
|
||||
name: item.name,
|
||||
type: item.type || 'line',
|
||||
data: item.data,
|
||||
smooth: true,
|
||||
itemStyle: item.itemStyle,
|
||||
lineStyle: item.lineStyle,
|
||||
areaStyle: item.areaStyle
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// 养殖场类型分布图表选项
|
||||
const farmTypeChartOptions = computed(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '养殖场类型',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: [
|
||||
{ value: 5, name: '牛养殖场' },
|
||||
{ value: 7, name: '羊养殖场' },
|
||||
{ value: 3, name: '混合养殖场' },
|
||||
{ value: 2, name: '其他养殖场' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 动物类型分布图表选项
|
||||
const animalTypeChartOptions = computed(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '动物类型',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: []
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 设备类型分布图表选项
|
||||
const deviceTypeChartOptions = computed(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '设备类型',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: []
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 预警类型分布图表选项
|
||||
const alertTypeChartOptions = computed(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{a} <br/>{b}: {c} ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
orient: 'horizontal',
|
||||
bottom: 0
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '预警类型',
|
||||
type: 'pie',
|
||||
radius: ['50%', '70%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false,
|
||||
position: 'center'
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: '18',
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: false
|
||||
},
|
||||
data: []
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// 养殖场表格数据
|
||||
const farmTableData = computed(() => {
|
||||
console.log('计算farmTableData:', {
|
||||
farms: dataStore.farms.length,
|
||||
animals: dataStore.animals.length,
|
||||
devices: dataStore.devices.length,
|
||||
alerts: dataStore.alerts.length
|
||||
})
|
||||
|
||||
return dataStore.farms.map(farm => {
|
||||
// 获取该养殖场的动物数量
|
||||
const animals = dataStore.animals.filter(animal => animal.farm_id === farm.id)
|
||||
const animalCount = animals.reduce((sum, animal) => sum + (animal.count || 0), 0)
|
||||
|
||||
// 获取该养殖场的设备数量
|
||||
const devices = dataStore.devices.filter(device => device.farm_id === farm.id)
|
||||
const deviceCount = devices.length
|
||||
|
||||
// 获取该养殖场的预警数量
|
||||
const alerts = dataStore.alerts.filter(alert => alert.farm_id === farm.id)
|
||||
const alertCount = alerts.length
|
||||
|
||||
console.log(`养殖场 ${farm.name} (ID: ${farm.id}):`, {
|
||||
animalCount,
|
||||
deviceCount,
|
||||
alertCount
|
||||
})
|
||||
|
||||
return {
|
||||
key: farm.id,
|
||||
id: farm.id,
|
||||
name: farm.name,
|
||||
animalCount,
|
||||
deviceCount,
|
||||
alertCount
|
||||
}
|
||||
}).filter(farm => farm.animalCount > 0 || farm.deviceCount > 0 || farm.alertCount > 0)
|
||||
})
|
||||
|
||||
// 养殖场表格列定义
|
||||
const farmColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '养殖场名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: '动物数量',
|
||||
dataIndex: 'animalCount',
|
||||
key: 'animalCount',
|
||||
sorter: (a, b) => a.animalCount - b.animalCount
|
||||
},
|
||||
{
|
||||
title: '设备数量',
|
||||
dataIndex: 'deviceCount',
|
||||
key: 'deviceCount',
|
||||
sorter: (a, b) => a.deviceCount - b.deviceCount
|
||||
},
|
||||
{
|
||||
title: '预警数量',
|
||||
dataIndex: 'alertCount',
|
||||
key: 'alertCount',
|
||||
sorter: (a, b) => a.alertCount - b.alertCount
|
||||
}
|
||||
]
|
||||
|
||||
// 获取最大值
|
||||
const maxAnimalCount = computed(() => {
|
||||
const counts = farmTableData.value.map(farm => farm.animalCount)
|
||||
return Math.max(...counts, 1)
|
||||
})
|
||||
|
||||
const maxDeviceCount = computed(() => {
|
||||
const counts = farmTableData.value.map(farm => farm.deviceCount)
|
||||
return Math.max(...counts, 1)
|
||||
})
|
||||
|
||||
const maxAlertCount = computed(() => {
|
||||
const counts = farmTableData.value.map(farm => farm.alertCount)
|
||||
return Math.max(...counts, 1)
|
||||
})
|
||||
|
||||
// 计算百分比
|
||||
function getPercentage(value, max) {
|
||||
return Math.round((value / max) * 100)
|
||||
}
|
||||
|
||||
// 获取进度条颜色
|
||||
function getProgressColor(value, max) {
|
||||
const percentage = getPercentage(value, max)
|
||||
if (percentage < 30) return '#52c41a'
|
||||
if (percentage < 70) return '#1890ff'
|
||||
return '#ff4d4f'
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(async () => {
|
||||
console.log('Analytics页面开始加载数据...')
|
||||
|
||||
try {
|
||||
// 加载数据
|
||||
console.log('调用 dataStore.fetchAllData()...')
|
||||
await dataStore.fetchAllData()
|
||||
|
||||
console.log('数据加载完成:', {
|
||||
farms: dataStore.farms.length,
|
||||
animals: dataStore.animals.length,
|
||||
devices: dataStore.devices.length,
|
||||
alerts: dataStore.alerts.length
|
||||
})
|
||||
|
||||
// 获取月度数据趋势
|
||||
console.log('获取月度数据趋势...')
|
||||
await fetchMonthlyTrends()
|
||||
|
||||
console.log('Analytics页面数据加载完成')
|
||||
} catch (error) {
|
||||
console.error('Analytics页面数据加载失败:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.analytics-page {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.analytics-content {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.analytics-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.data-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.analytics-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
547
admin-system/frontend/src/views/Animals.vue
Normal file
547
admin-system/frontend/src/views/Animals.vue
Normal file
@@ -0,0 +1,547 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>动物管理</h1>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加动物
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="animals"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'health_status'">
|
||||
<a-tag :color="getHealthStatusColor(record.health_status)">
|
||||
{{ getHealthStatusText(record.health_status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'farm_name'">
|
||||
{{ getFarmName(record.farm_id) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'last_inspection'">
|
||||
{{ formatDate(record.last_inspection) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="editAnimal(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个动物记录吗?"
|
||||
@confirm="deleteAnimal(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑动物模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑动物' : '添加动物'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="动物类型" name="type">
|
||||
<a-select v-model:value="formData.type" placeholder="请选择动物类型" @change="handleTypeChange">
|
||||
<a-select-option value="pig">猪</a-select-option>
|
||||
<a-select-option value="cow">牛</a-select-option>
|
||||
<a-select-option value="chicken">鸡</a-select-option>
|
||||
<a-select-option value="sheep">羊</a-select-option>
|
||||
<a-select-option value="duck">鸭</a-select-option>
|
||||
<a-select-option value="goose">鹅</a-select-option>
|
||||
<a-select-option value="other">其他</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="showCustomTypeInput" label="自定义动物类型" name="customType">
|
||||
<a-input
|
||||
v-model:value="formData.customType"
|
||||
placeholder="请输入自定义动物类型"
|
||||
@input="(e) => { console.log('自定义类型输入变化:', e.target.value); }"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="数量" name="count">
|
||||
<a-input-number
|
||||
v-model:value="formData.count"
|
||||
placeholder="请输入动物数量"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="健康状态" name="health_status">
|
||||
<a-select v-model:value="formData.health_status" 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="treatment">治疗中</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="所属农场" name="farm_id">
|
||||
<a-select
|
||||
v-model:value="formData.farm_id"
|
||||
placeholder="请选择所属农场"
|
||||
:loading="farmsLoading"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="farm in farms"
|
||||
:key="farm.id"
|
||||
:value="farm.id"
|
||||
>
|
||||
{{ farm.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="最后检查时间" name="last_inspection">
|
||||
<a-date-picker
|
||||
v-model:value="formData.last_inspection"
|
||||
placeholder="请选择最后检查时间"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="备注" name="notes">
|
||||
<a-textarea
|
||||
v-model:value="formData.notes"
|
||||
placeholder="请输入备注信息"
|
||||
:rows="3"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import axios from 'axios'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// 响应式数据
|
||||
const animals = ref([])
|
||||
const farms = ref([])
|
||||
const loading = ref(false)
|
||||
const farmsLoading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
type: '',
|
||||
count: 0,
|
||||
health_status: 'healthy',
|
||||
farm_id: null,
|
||||
last_inspection: null,
|
||||
notes: '',
|
||||
customType: '' // 自定义动物类型
|
||||
})
|
||||
|
||||
// 控制是否显示自定义输入框
|
||||
const showCustomTypeInput = ref(false)
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
type: [{ required: true, message: '请选择动物类型', trigger: 'change' }],
|
||||
customType: [{
|
||||
required: () => formData.type === 'other',
|
||||
message: '请输入自定义动物类型',
|
||||
trigger: 'blur'
|
||||
}],
|
||||
count: [{ required: true, message: '请输入动物数量', trigger: 'blur' }],
|
||||
health_status: [{ required: true, message: '请选择健康状态', trigger: 'change' }],
|
||||
farm_id: [{ required: true, message: '请选择所属农场', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '动物类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 120,
|
||||
customRender: ({ text }) => {
|
||||
const typeMap = {
|
||||
'pig': '猪',
|
||||
'cow': '牛',
|
||||
'chicken': '鸡',
|
||||
'sheep': '羊',
|
||||
'duck': '鸭',
|
||||
'goose': '鹅'
|
||||
}
|
||||
return typeMap[text] || text
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '健康状态',
|
||||
dataIndex: 'health_status',
|
||||
key: 'health_status',
|
||||
width: 120,
|
||||
customRender: ({ text }) => {
|
||||
const statusMap = {
|
||||
'healthy': '健康',
|
||||
'sick': '生病',
|
||||
'quarantine': '隔离',
|
||||
'treatment': '治疗中'
|
||||
}
|
||||
return statusMap[text] || text
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '所属农场',
|
||||
dataIndex: 'farm_name',
|
||||
key: 'farm_name',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '最后检查',
|
||||
dataIndex: 'last_inspection',
|
||||
key: 'last_inspection',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'notes',
|
||||
key: 'notes',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 150
|
||||
}
|
||||
]
|
||||
|
||||
// 获取动物列表
|
||||
const fetchAnimals = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('/api/animals', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
animals.value = response.data.data
|
||||
} else {
|
||||
message.error('获取动物列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取动物列表失败:', error)
|
||||
if (error.response && error.response.status === 401) {
|
||||
message.error('登录已过期,请重新登录')
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login'
|
||||
}, 2000)
|
||||
} else {
|
||||
message.error('获取动物列表失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取农场列表
|
||||
const fetchFarms = async () => {
|
||||
try {
|
||||
farmsLoading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('/api/farms', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
if (response.data.success) {
|
||||
farms.value = response.data.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取农场列表失败:', error)
|
||||
} finally {
|
||||
farmsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理动物类型选择变化
|
||||
const handleTypeChange = (value) => {
|
||||
console.log('=== 动物类型变化 ===')
|
||||
console.log('选择的类型:', value)
|
||||
console.log('变化前 formData.type:', formData.type)
|
||||
console.log('变化前 formData.customType:', formData.customType)
|
||||
|
||||
showCustomTypeInput.value = value === 'other'
|
||||
// 当切换到非'other'类型时,清空customType字段
|
||||
// 这确保提交时使用正确的类型值
|
||||
if (value !== 'other') {
|
||||
formData.customType = ''
|
||||
}
|
||||
|
||||
console.log('变化后 showCustomTypeInput:', showCustomTypeInput.value)
|
||||
console.log('变化后 formData.customType:', formData.customType)
|
||||
}
|
||||
|
||||
// ==================== 添加动物功能 ====================
|
||||
// 显示添加动物模态框
|
||||
const showAddModal = () => {
|
||||
initializeAddMode()
|
||||
openModal()
|
||||
loadRequiredData()
|
||||
}
|
||||
|
||||
// 初始化添加模式
|
||||
const initializeAddMode = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// ==================== 编辑动物功能 ====================
|
||||
// 编辑动物
|
||||
const editAnimal = (record) => {
|
||||
initializeEditMode(record)
|
||||
openModal()
|
||||
loadRequiredData()
|
||||
}
|
||||
|
||||
// 初始化编辑模式
|
||||
const initializeEditMode = (record) => {
|
||||
isEdit.value = true
|
||||
populateFormWithRecord(record)
|
||||
configureEditModeSettings()
|
||||
}
|
||||
|
||||
// 填充表单数据
|
||||
const populateFormWithRecord = (record) => {
|
||||
console.log('=== 填充编辑表单数据 ===');
|
||||
console.log('原始记录数据:', record);
|
||||
|
||||
const predefinedTypes = ['pig', 'cow', 'chicken', 'sheep', 'duck', 'goose']
|
||||
const isCustomType = !predefinedTypes.includes(record.type)
|
||||
|
||||
console.log('是否为自定义类型:', isCustomType);
|
||||
console.log('将要设置的 formData.type:', isCustomType ? 'other' : record.type);
|
||||
console.log('将要设置的 formData.customType:', isCustomType ? record.type : '');
|
||||
|
||||
// 只复制需要的字段,避免破坏响应式绑定
|
||||
formData.id = record.id
|
||||
formData.type = isCustomType ? 'other' : record.type
|
||||
formData.customType = isCustomType ? record.type : ''
|
||||
formData.count = record.count
|
||||
formData.health_status = record.health_status
|
||||
formData.farm_id = record.farm_id
|
||||
formData.last_inspection = record.last_inspection ? dayjs(record.last_inspection) : null
|
||||
formData.notes = record.notes || ''
|
||||
|
||||
console.log('填充后的 formData:', JSON.parse(JSON.stringify(formData)));
|
||||
}
|
||||
|
||||
// 配置编辑模式设置
|
||||
const configureEditModeSettings = () => {
|
||||
// 根据类型决定是否显示自定义输入框
|
||||
showCustomTypeInput.value = formData.type === 'other'
|
||||
}
|
||||
|
||||
// ==================== 删除动物功能 ====================
|
||||
// 删除动物
|
||||
const deleteAnimal = async (id) => {
|
||||
try {
|
||||
await performDeleteRequest(id)
|
||||
handleDeleteSuccess()
|
||||
} catch (error) {
|
||||
handleDeleteError(error)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行删除请求
|
||||
const performDeleteRequest = async (id) => {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.delete(`/api/animals/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error('删除请求失败')
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// 处理删除成功
|
||||
const handleDeleteSuccess = () => {
|
||||
message.success('删除成功')
|
||||
fetchAnimals()
|
||||
}
|
||||
|
||||
// 处理删除错误
|
||||
const handleDeleteError = (error) => {
|
||||
console.error('删除动物失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
|
||||
// ==================== 通用功能 ====================
|
||||
// 打开模态框
|
||||
const openModal = () => {
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 加载必需数据
|
||||
const loadRequiredData = () => {
|
||||
fetchFarms()
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
const config = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
console.log('=== 前端提交表单 ===');
|
||||
console.log('是否编辑模式:', isEdit.value);
|
||||
console.log('原始表单数据:', JSON.parse(JSON.stringify(formData)));
|
||||
|
||||
// 处理日期格式和自定义类型
|
||||
const submitData = {
|
||||
...formData,
|
||||
type: formData.type === 'other' ? formData.customType : formData.type,
|
||||
last_inspection: formData.last_inspection ? formData.last_inspection.format('YYYY-MM-DD') : null
|
||||
}
|
||||
// 移除customType字段,避免发送到后端
|
||||
delete submitData.customType
|
||||
|
||||
// 如果是新增操作,移除id字段
|
||||
if (!isEdit.value) {
|
||||
delete submitData.id
|
||||
}
|
||||
|
||||
console.log('处理后的提交数据:', JSON.parse(JSON.stringify(submitData)));
|
||||
|
||||
let response
|
||||
if (isEdit.value) {
|
||||
console.log('发送PUT请求到:', `/api/animals/${formData.id}`);
|
||||
response = await axios.put(`/api/animals/${formData.id}`, submitData, config)
|
||||
} else {
|
||||
console.log('发送POST请求到:', '/api/animals');
|
||||
response = await axios.post('/api/animals', submitData, config)
|
||||
}
|
||||
|
||||
console.log('服务器响应:', response.data);
|
||||
|
||||
if (response.data.success) {
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
modalVisible.value = false
|
||||
await fetchAnimals()
|
||||
} else {
|
||||
message.error(isEdit.value ? '更新失败' : '创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
console.error('错误详情:', error.response?.data);
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
type: '',
|
||||
count: 0,
|
||||
health_status: 'healthy',
|
||||
farm_id: null,
|
||||
last_inspection: null,
|
||||
notes: '',
|
||||
customType: ''
|
||||
})
|
||||
showCustomTypeInput.value = false
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 获取健康状态颜色
|
||||
const getHealthStatusColor = (status) => {
|
||||
const colors = {
|
||||
healthy: 'green',
|
||||
sick: 'red',
|
||||
quarantine: 'orange',
|
||||
treatment: 'blue'
|
||||
}
|
||||
return colors[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取健康状态文本
|
||||
const getHealthStatusText = (status) => {
|
||||
const texts = {
|
||||
healthy: '健康',
|
||||
sick: '生病',
|
||||
quarantine: '隔离',
|
||||
treatment: '治疗中'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
// 获取农场名称
|
||||
const getFarmName = (farmId) => {
|
||||
const farm = farms.value.find(f => f.id === farmId)
|
||||
return farm ? farm.name : `农场ID: ${farmId}`
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-'
|
||||
return dayjs(date).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchAnimals()
|
||||
fetchFarms()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 可以添加自定义样式 */
|
||||
</style>
|
||||
27
admin-system/frontend/src/views/Dashboard.vue
Normal file
27
admin-system/frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<a-page-header
|
||||
title="系统概览"
|
||||
sub-title="宁夏智慧养殖监管平台数据概览"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button>导出报表</a-button>
|
||||
<a-button type="primary">刷新数据</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<Dashboard />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Dashboard from '../components/Dashboard.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-page {
|
||||
padding: 0 16px;
|
||||
}
|
||||
</style>
|
||||
448
admin-system/frontend/src/views/Devices.vue
Normal file
448
admin-system/frontend/src/views/Devices.vue
Normal file
@@ -0,0 +1,448 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>设备管理</h1>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加设备
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="devices"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'id'">
|
||||
{{ record.id }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'name'">
|
||||
{{ record.name }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'type'">
|
||||
{{ record.type }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'farm_name'">
|
||||
{{ getFarmName(record.farm_id) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'last_maintenance'">
|
||||
{{ formatDate(record.last_maintenance) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'installation_date'">
|
||||
{{ formatDate(record.installation_date) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="editDevice(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个设备吗?"
|
||||
@confirm="deleteDevice(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑设备模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑设备' : '添加设备'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="设备名称" name="name">
|
||||
<a-input v-model:value="formData.name" placeholder="请输入设备名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="设备类型" name="type">
|
||||
<a-select v-model:value="formData.type" placeholder="请选择设备类型" @change="handleTypeChange">
|
||||
<a-select-option value="sensor">传感器</a-select-option>
|
||||
<a-select-option value="camera">摄像头</a-select-option>
|
||||
<a-select-option value="feeder">喂食器</a-select-option>
|
||||
<a-select-option value="monitor">监控器</a-select-option>
|
||||
<a-select-option value="controller">控制器</a-select-option>
|
||||
<a-select-option value="other">其他</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="showCustomTypeInput" label="自定义设备类型" name="customType">
|
||||
<a-input
|
||||
v-model:value="formData.customType"
|
||||
placeholder="请输入自定义设备类型"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="设备状态" name="status">
|
||||
<a-select v-model:value="formData.status" placeholder="请选择设备状态">
|
||||
<a-select-option value="online">在线</a-select-option>
|
||||
<a-select-option value="offline">离线</a-select-option>
|
||||
<a-select-option value="maintenance">维护中</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="所属农场" name="farm_id">
|
||||
<a-select
|
||||
v-model:value="formData.farm_id"
|
||||
placeholder="请选择所属农场"
|
||||
:loading="farmsLoading"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="farm in farms"
|
||||
:key="farm.id"
|
||||
:value="farm.id"
|
||||
>
|
||||
{{ farm.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="安装日期" name="installation_date">
|
||||
<a-date-picker
|
||||
v-model:value="formData.installation_date"
|
||||
placeholder="请选择安装日期"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="最后维护时间" name="last_maintenance">
|
||||
<a-date-picker
|
||||
v-model:value="formData.last_maintenance"
|
||||
placeholder="请选择最后维护时间"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import axios from 'axios'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// 响应式数据
|
||||
const devices = ref([])
|
||||
const farms = ref([])
|
||||
const loading = ref(false)
|
||||
const farmsLoading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
type: '',
|
||||
status: 'offline',
|
||||
farm_id: null,
|
||||
installation_date: null,
|
||||
last_maintenance: null,
|
||||
customType: '' // 自定义设备类型
|
||||
})
|
||||
|
||||
// 控制是否显示自定义输入框
|
||||
const showCustomTypeInput = ref(false)
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入设备名称', trigger: 'blur' }],
|
||||
type: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
|
||||
customType: [{
|
||||
required: () => formData.type === 'other',
|
||||
message: '请输入自定义设备类型',
|
||||
trigger: 'blur'
|
||||
}],
|
||||
status: [{ required: true, message: '请选择设备状态', trigger: 'change' }],
|
||||
farm_id: [{ required: true, message: '请选择所属农场', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '设备名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: '设备类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '设备状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '所属农场',
|
||||
dataIndex: 'farm_name',
|
||||
key: 'farm_name',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '安装日期',
|
||||
dataIndex: 'installation_date',
|
||||
key: 'installation_date',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '最后维护',
|
||||
dataIndex: 'last_maintenance',
|
||||
key: 'last_maintenance',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 150
|
||||
}
|
||||
]
|
||||
|
||||
// 获取设备列表
|
||||
const fetchDevices = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('/api/devices', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
if (response.data.success) {
|
||||
devices.value = response.data.data
|
||||
} else {
|
||||
message.error('获取设备列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取设备列表失败:', error)
|
||||
if (error.response && error.response.status === 401) {
|
||||
message.error('登录已过期,请重新登录')
|
||||
// 可以在这里添加重定向到登录页的逻辑
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login'
|
||||
}, 2000)
|
||||
} else {
|
||||
message.error('获取设备列表失败: ' + (error.message || '未知错误'))
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取农场列表
|
||||
const fetchFarms = async () => {
|
||||
try {
|
||||
farmsLoading.value = true
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.get('/api/farms', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
if (response.data.success) {
|
||||
farms.value = response.data.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取农场列表失败:', error)
|
||||
} finally {
|
||||
farmsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理设备类型选择变化
|
||||
const handleTypeChange = (value) => {
|
||||
showCustomTypeInput.value = value === 'other'
|
||||
if (value !== 'other') {
|
||||
formData.customType = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
fetchFarms()
|
||||
}
|
||||
|
||||
// 编辑设备
|
||||
const editDevice = (record) => {
|
||||
isEdit.value = true
|
||||
const predefinedTypes = ['sensor', 'camera', 'feeder', 'monitor', 'controller']
|
||||
const isCustomType = !predefinedTypes.includes(record.type)
|
||||
|
||||
// 逐个字段赋值,避免破坏响应式绑定
|
||||
formData.id = record.id
|
||||
formData.name = record.name
|
||||
formData.type = isCustomType ? 'other' : record.type
|
||||
formData.customType = isCustomType ? record.type : ''
|
||||
formData.status = record.status
|
||||
formData.farm_id = record.farm_id
|
||||
formData.installation_date = record.installation_date ? dayjs(record.installation_date) : null
|
||||
formData.last_maintenance = record.last_maintenance ? dayjs(record.last_maintenance) : null
|
||||
|
||||
showCustomTypeInput.value = isCustomType
|
||||
modalVisible.value = true
|
||||
fetchFarms()
|
||||
}
|
||||
|
||||
// 删除设备
|
||||
const deleteDevice = async (id) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.delete(`/api/devices/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
if (response.data.success) {
|
||||
message.success('删除成功')
|
||||
fetchDevices()
|
||||
} else {
|
||||
message.error('删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除设备失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
const config = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
// 处理日期格式和自定义类型
|
||||
const submitData = {
|
||||
...formData,
|
||||
type: formData.type === 'other' ? formData.customType : formData.type,
|
||||
installation_date: formData.installation_date ? formData.installation_date.format('YYYY-MM-DD') : null,
|
||||
last_maintenance: formData.last_maintenance ? formData.last_maintenance.format('YYYY-MM-DD') : null
|
||||
}
|
||||
// 移除customType字段,避免发送到后端
|
||||
delete submitData.customType
|
||||
|
||||
// 如果是新增操作,移除id字段
|
||||
if (!isEdit.value) {
|
||||
delete submitData.id
|
||||
}
|
||||
|
||||
let response
|
||||
if (isEdit.value) {
|
||||
response = await axios.put(`/api/devices/${formData.id}`, submitData, config)
|
||||
} else {
|
||||
response = await axios.post('/api/devices', submitData, config)
|
||||
}
|
||||
|
||||
if (response.data.success) {
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
modalVisible.value = false
|
||||
fetchDevices()
|
||||
} else {
|
||||
message.error(isEdit.value ? '更新失败' : '创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
name: '',
|
||||
type: '',
|
||||
status: 'offline',
|
||||
farm_id: null,
|
||||
installation_date: null,
|
||||
last_maintenance: null,
|
||||
customType: ''
|
||||
})
|
||||
showCustomTypeInput.value = false
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
online: 'green',
|
||||
offline: 'red',
|
||||
maintenance: 'orange'
|
||||
}
|
||||
return colors[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
maintenance: '维护中'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
// 获取农场名称
|
||||
const getFarmName = (farmId) => {
|
||||
const farm = farms.value.find(f => f.id === farmId)
|
||||
return farm ? farm.name : `农场ID: ${farmId}`
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-'
|
||||
return dayjs(date).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchDevices()
|
||||
fetchFarms()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 可以添加自定义样式 */
|
||||
</style>
|
||||
450
admin-system/frontend/src/views/Farms.vue
Normal file
450
admin-system/frontend/src/views/Farms.vue
Normal file
@@ -0,0 +1,450 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>养殖场管理</h1>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加养殖场
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="farms"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="record.status === 'active' ? 'green' : record.status === 'inactive' ? 'red' : 'orange'">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'created_at'">
|
||||
{{ formatDate(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="editFarm(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个养殖场吗?"
|
||||
@confirm="deleteFarm(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑养殖场模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑养殖场' : '添加养殖场'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
width="800px"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 18 }"
|
||||
>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="养殖场名称" name="name">
|
||||
<a-input v-model:value="formData.name" placeholder="请输入养殖场名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="负责人" name="owner">
|
||||
<a-input v-model:value="formData.owner" placeholder="请输入负责人姓名" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="联系电话" name="phone">
|
||||
<a-input v-model:value="formData.phone" placeholder="请输入联系电话" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select v-model:value="formData.status" placeholder="请选择状态">
|
||||
<a-select-option value="active">运营中</a-select-option>
|
||||
<a-select-option value="inactive">已停用</a-select-option>
|
||||
<a-select-option value="maintenance">维护中</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="地址" name="address">
|
||||
<a-input v-model:value="formData.address" placeholder="请输入详细地址" />
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="经度" name="longitude">
|
||||
<a-input-number
|
||||
v-model:value="formData.longitude"
|
||||
placeholder="请输入经度 (-180 ~ 180)"
|
||||
:precision="6"
|
||||
:step="0.000001"
|
||||
:min="-180"
|
||||
:max="180"
|
||||
:string-mode="false"
|
||||
:controls="false"
|
||||
:parser="value => {
|
||||
if (!value) return value;
|
||||
// 移除非数字字符,保留小数点和负号
|
||||
const cleaned = value.toString().replace(/[^\d.-]/g, '');
|
||||
// 确保只有一个小数点和负号在开头
|
||||
const parts = cleaned.split('.');
|
||||
if (parts.length > 2) {
|
||||
return parts[0] + '.' + parts.slice(1).join('');
|
||||
}
|
||||
return cleaned;
|
||||
}"
|
||||
:formatter="value => value"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="纬度" name="latitude">
|
||||
<a-input-number
|
||||
v-model:value="formData.latitude"
|
||||
placeholder="请输入纬度 (-90 ~ 90)"
|
||||
:precision="6"
|
||||
:step="0.000001"
|
||||
:min="-90"
|
||||
:max="90"
|
||||
:string-mode="false"
|
||||
:controls="false"
|
||||
:parser="value => {
|
||||
if (!value) return value;
|
||||
// 移除非数字字符,保留小数点和负号
|
||||
const cleaned = value.toString().replace(/[^\d.-]/g, '');
|
||||
// 确保只有一个小数点和负号在开头
|
||||
const parts = cleaned.split('.');
|
||||
if (parts.length > 2) {
|
||||
return parts[0] + '.' + parts.slice(1).join('');
|
||||
}
|
||||
return cleaned;
|
||||
}"
|
||||
:formatter="value => value"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="占地面积(亩)" name="area">
|
||||
<a-input-number
|
||||
v-model:value="formData.area"
|
||||
placeholder="请输入占地面积"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="养殖规模" name="capacity">
|
||||
<a-input-number
|
||||
v-model:value="formData.capacity"
|
||||
placeholder="请输入养殖规模"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="描述" name="description">
|
||||
<a-textarea
|
||||
v-model:value="formData.description"
|
||||
placeholder="请输入养殖场描述"
|
||||
:rows="3"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
// 响应式数据
|
||||
const farms = ref([])
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
owner: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
longitude: undefined,
|
||||
latitude: undefined,
|
||||
area: undefined,
|
||||
capacity: undefined,
|
||||
status: 'active',
|
||||
description: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入养殖场名称', trigger: 'blur' }],
|
||||
owner: [{ required: true, message: '请输入负责人姓名', trigger: 'blur' }],
|
||||
phone: [
|
||||
{ required: true, message: '请输入联系电话', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
|
||||
],
|
||||
address: [{ required: true, message: '请输入详细地址', trigger: 'blur' }],
|
||||
longitude: [
|
||||
{ validator: (rule, value) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return Promise.resolve(); // 允许为空
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num)) {
|
||||
return Promise.reject('请输入有效的经度数值');
|
||||
}
|
||||
if (num < -180 || num > 180) {
|
||||
return Promise.reject('经度范围应在-180到180之间');
|
||||
}
|
||||
// 检查小数位数不超过6位
|
||||
const decimalPart = value.toString().split('.')[1];
|
||||
if (decimalPart && decimalPart.length > 6) {
|
||||
return Promise.reject('经度小数位数不能超过6位');
|
||||
}
|
||||
return Promise.resolve();
|
||||
}, trigger: 'blur' }
|
||||
],
|
||||
latitude: [
|
||||
{ validator: (rule, value) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return Promise.resolve(); // 允许为空
|
||||
}
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num)) {
|
||||
return Promise.reject('请输入有效的纬度数值');
|
||||
}
|
||||
if (num < -90 || num > 90) {
|
||||
return Promise.reject('纬度范围应在-90到90之间');
|
||||
}
|
||||
// 检查小数位数不超过6位
|
||||
const decimalPart = value.toString().split('.')[1];
|
||||
if (decimalPart && decimalPart.length > 6) {
|
||||
return Promise.reject('纬度小数位数不能超过6位');
|
||||
}
|
||||
return Promise.resolve();
|
||||
}, trigger: 'blur' }
|
||||
],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '养殖场名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
title: '负责人',
|
||||
dataIndex: 'owner',
|
||||
key: 'owner',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '联系电话',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '地址',
|
||||
dataIndex: 'address',
|
||||
key: 'address',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 150
|
||||
}
|
||||
]
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
active: '运营中',
|
||||
inactive: '已停用',
|
||||
maintenance: '维护中'
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取养殖场列表
|
||||
const fetchFarms = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const { api } = await import('../utils/api')
|
||||
const response = await api.get('/farms')
|
||||
if (Array.isArray(response)) {
|
||||
farms.value = response
|
||||
} else {
|
||||
farms.value = []
|
||||
console.warn('API返回数据格式异常:', response)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取养殖场列表失败:', error)
|
||||
message.error('获取养殖场列表失败')
|
||||
farms.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑养殖场
|
||||
const editFarm = (record) => {
|
||||
isEdit.value = true
|
||||
// 解析location对象中的经纬度数据
|
||||
const longitude = record.location?.lng || undefined
|
||||
const latitude = record.location?.lat || undefined
|
||||
|
||||
Object.assign(formData, {
|
||||
...record,
|
||||
longitude,
|
||||
latitude,
|
||||
owner: record.contact || ''
|
||||
})
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 删除养殖场
|
||||
const deleteFarm = async (id) => {
|
||||
try {
|
||||
const { api } = await import('../utils/api')
|
||||
await api.delete(`/farms/${id}`)
|
||||
message.success('删除成功')
|
||||
fetchFarms()
|
||||
} catch (error) {
|
||||
console.error('删除养殖场失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
const submitData = { ...formData }
|
||||
|
||||
const { api } = await import('../utils/api')
|
||||
if (isEdit.value) {
|
||||
await api.put(`/farms/${formData.id}`, submitData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await api.post('/farms', submitData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
|
||||
modalVisible.value = false
|
||||
fetchFarms()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
name: '',
|
||||
owner: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
longitude: undefined,
|
||||
latitude: undefined,
|
||||
area: undefined,
|
||||
capacity: undefined,
|
||||
status: 'active',
|
||||
description: ''
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchFarms()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ant-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
27
admin-system/frontend/src/views/Home.vue
Normal file
27
admin-system/frontend/src/views/Home.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>欢迎使用宁夏智慧养殖监管平台</h1>
|
||||
<p>这是一个基于Vue 3和Ant Design Vue构建的现代化管理系统。</p>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-card title="用户总数" style="text-align: center">
|
||||
<h2>1,234</h2>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-card title="产品总数" style="text-align: center">
|
||||
<h2>567</h2>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-card title="订单总数" style="text-align: center">
|
||||
<h2>890</h2>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
187
admin-system/frontend/src/views/Login.vue
Normal file
187
admin-system/frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<a-card title="用户登录" style="width: 400px; margin: 100px auto;">
|
||||
<div class="login-hints">
|
||||
<p>测试账号:</p>
|
||||
<p>用户名: admin 或 john_doe</p>
|
||||
<p>密码: 123456</p>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
v-if="error"
|
||||
:message="error"
|
||||
type="error"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
<a-button
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</a-button>
|
||||
|
||||
<a-form
|
||||
:model="formState"
|
||||
name="login"
|
||||
autocomplete="off"
|
||||
@finish="handleLogin"
|
||||
>
|
||||
<a-form-item
|
||||
label="用户名"
|
||||
name="username"
|
||||
:rules="[{ required: true, message: '请输入用户名或邮箱!' }]"
|
||||
>
|
||||
<a-input v-model="formState.username">
|
||||
<template #prefix>
|
||||
<UserOutlined />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="密码"
|
||||
name="password"
|
||||
:rules="[
|
||||
{ required: true, message: '请输入密码!' },
|
||||
{ min: 6, message: '密码长度不能少于6位' }
|
||||
]"
|
||||
>
|
||||
<a-input-password v-model="formState.password">
|
||||
<template #prefix>
|
||||
<LockOutlined />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
:loading="loading"
|
||||
block
|
||||
>
|
||||
登录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useUserStore } from '../stores';
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const formState = reactive({
|
||||
username: 'admin',
|
||||
password: '123456'
|
||||
});
|
||||
|
||||
// 页面加载时检查是否可以自动登录
|
||||
onMounted(async () => {
|
||||
// 如果已有token,尝试验证并自动跳转
|
||||
if (userStore.token) {
|
||||
try {
|
||||
const isValid = await userStore.validateToken();
|
||||
if (isValid) {
|
||||
const redirectPath = router.currentRoute.value.query.redirect || '/dashboard';
|
||||
message.success('自动登录成功');
|
||||
router.push(redirectPath);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('自动登录失败,显示登录表单');
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有token或验证失败,尝试使用默认账号自动登录
|
||||
if (!userStore.token) {
|
||||
await handleAutoLogin();
|
||||
}
|
||||
});
|
||||
|
||||
// 自动登录函数
|
||||
const handleAutoLogin = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await userStore.login(formState.username, formState.password);
|
||||
if (result.success) {
|
||||
message.success('自动登录成功');
|
||||
const redirectPath = router.currentRoute.value.query.redirect || '/dashboard';
|
||||
router.push(redirectPath);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('自动登录失败,需要手动登录');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = async (values) => {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
try {
|
||||
// 使用表单数据或默认数据进行登录
|
||||
const username = values?.username || formState.username;
|
||||
const password = values?.password || formState.password;
|
||||
|
||||
// 使用Pinia用户存储进行登录
|
||||
const result = await userStore.login(username, password);
|
||||
|
||||
if (result.success) {
|
||||
// 登录成功提示
|
||||
message.success(`登录成功,欢迎 ${userStore.userData.username}`);
|
||||
|
||||
// 获取重定向路径(如果有)
|
||||
const redirectPath = router.currentRoute.value.query.redirect || '/dashboard';
|
||||
|
||||
// 跳转到重定向路径或仪表盘页面
|
||||
router.push(redirectPath);
|
||||
} else {
|
||||
error.value = result.message || '登录失败';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('登录错误:', e);
|
||||
error.value = e.message || '登录失败,请检查网络连接和后端服务状态';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
background: #f0f2f5;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-hints {
|
||||
background: #e6f7ff;
|
||||
border: 1px solid #91d5ff;
|
||||
border-radius: 4px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-hints p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.login-hints p:first-child {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
165
admin-system/frontend/src/views/MapView.vue
Normal file
165
admin-system/frontend/src/views/MapView.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="map-view">
|
||||
<div class="map-header">
|
||||
<h1>养殖场分布地图</h1>
|
||||
<div class="map-controls">
|
||||
<a-select
|
||||
v-model="selectedFarmType"
|
||||
style="width: 200px"
|
||||
placeholder="选择养殖场类型"
|
||||
@change="handleFarmTypeChange"
|
||||
>
|
||||
<a-select-option value="all">全部养殖场</a-select-option>
|
||||
<a-select-option value="cattle">牛养殖场</a-select-option>
|
||||
<a-select-option value="sheep">羊养殖场</a-select-option>
|
||||
</a-select>
|
||||
|
||||
<a-button type="primary" @click="refreshMap">
|
||||
<template #icon><reload-outlined /></template>
|
||||
刷新数据
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-container">
|
||||
<baidu-map
|
||||
:markers="filteredMarkers"
|
||||
height="calc(100vh - 180px)"
|
||||
@marker-click="handleFarmClick"
|
||||
@map-ready="handleMapReady"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 养殖场详情抽屉 -->
|
||||
<a-drawer
|
||||
:title="`养殖场详情 - ${selectedFarmId ? dataStore.farms.find(f => f.id == selectedFarmId)?.name : ''}`"
|
||||
:width="600"
|
||||
:visible="drawerVisible"
|
||||
@close="closeDrawer"
|
||||
:bodyStyle="{ paddingBottom: '80px' }"
|
||||
>
|
||||
<farm-detail v-if="selectedFarmId" :farm-id="selectedFarmId" />
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useDataStore } from '../stores'
|
||||
import { convertFarmsToMarkers } from '../utils/mapService'
|
||||
import BaiduMap from '../components/BaiduMap.vue'
|
||||
import FarmDetail from '../components/FarmDetail.vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
// 数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 地图实例
|
||||
let mapInstance = null
|
||||
|
||||
// 养殖场类型筛选
|
||||
const selectedFarmType = ref('all')
|
||||
|
||||
// 抽屉控制
|
||||
const drawerVisible = ref(false)
|
||||
const selectedFarmId = ref(null)
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 养殖场标记
|
||||
const farmMarkers = computed(() => {
|
||||
return convertFarmsToMarkers(dataStore.farms)
|
||||
})
|
||||
|
||||
// 根据类型筛选标记
|
||||
const filteredMarkers = computed(() => {
|
||||
if (selectedFarmType.value === 'all') {
|
||||
return farmMarkers.value
|
||||
}
|
||||
|
||||
// 根据养殖场类型筛选
|
||||
// 这里假设养殖场数据中有type字段,实际项目中需要根据真实数据结构调整
|
||||
return farmMarkers.value.filter(marker => {
|
||||
const farm = marker.originalData
|
||||
if (selectedFarmType.value === 'cattle') {
|
||||
// 筛选牛养殖场
|
||||
return farm.type === 'cattle' || farm.name.includes('牛')
|
||||
} else if (selectedFarmType.value === 'sheep') {
|
||||
// 筛选羊养殖场
|
||||
return farm.type === 'sheep' || farm.name.includes('羊')
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// 处理地图就绪事件
|
||||
function handleMapReady(map) {
|
||||
mapInstance = map
|
||||
message.success('地图加载完成')
|
||||
}
|
||||
|
||||
// 处理养殖场类型变化
|
||||
function handleFarmTypeChange(value) {
|
||||
message.info(`已筛选${value === 'all' ? '全部' : value === 'cattle' ? '牛' : '羊'}养殖场`)
|
||||
}
|
||||
|
||||
// 处理养殖场标记点击事件
|
||||
function handleFarmClick(markerData) {
|
||||
// 设置选中的养殖场ID
|
||||
selectedFarmId.value = markerData.originalData.id
|
||||
// 打开抽屉
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
// 关闭抽屉
|
||||
function closeDrawer() {
|
||||
drawerVisible.value = false
|
||||
}
|
||||
|
||||
// 刷新地图数据
|
||||
async function refreshMap() {
|
||||
loading.value = true
|
||||
try {
|
||||
await dataStore.fetchFarms()
|
||||
message.success('地图数据已更新')
|
||||
} catch (error) {
|
||||
message.error('更新地图数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载后初始化数据
|
||||
onMounted(async () => {
|
||||
if (dataStore.farms.length === 0) {
|
||||
await dataStore.fetchAllData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-view {
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
173
admin-system/frontend/src/views/MapZoomDemo.vue
Normal file
173
admin-system/frontend/src/views/MapZoomDemo.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="map-zoom-demo">
|
||||
<div class="demo-header">
|
||||
<h2>地图缩放功能演示</h2>
|
||||
<p>这个页面展示了地图组件的各种缩放功能</p>
|
||||
</div>
|
||||
|
||||
<div class="demo-controls">
|
||||
<div class="control-group">
|
||||
<label>显示缩放级别:</label>
|
||||
<input type="checkbox" v-model="showZoomLevel" />
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>当前缩放级别:</label>
|
||||
<span class="zoom-info">{{ currentZoom }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-container">
|
||||
<BaiduMap
|
||||
:markers="demoMarkers"
|
||||
:show-zoom-level="showZoomLevel"
|
||||
:height="'500px'"
|
||||
@map-ready="onMapReady"
|
||||
@marker-click="onMarkerClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="demo-instructions">
|
||||
<h3>缩放功能说明:</h3>
|
||||
<ul>
|
||||
<li><strong>右上角缩放按钮:</strong>
|
||||
<ul>
|
||||
<li>「+」按钮:放大地图</li>
|
||||
<li>「−」按钮:缩小地图</li>
|
||||
<li>「⌂」按钮:重置到默认缩放级别和中心点</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><strong>鼠标滚轮:</strong>向上滚动放大,向下滚动缩小</li>
|
||||
<li><strong>双击地图:</strong>放大一个级别</li>
|
||||
<li><strong>键盘控制:</strong>使用方向键移动地图,+/- 键缩放</li>
|
||||
<li><strong>缩放级别显示:</strong>右下角显示当前缩放级别(可通过复选框控制显示/隐藏)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import BaiduMap from '../components/BaiduMap.vue'
|
||||
|
||||
// 响应式数据
|
||||
const showZoomLevel = ref(true)
|
||||
const mapInstance = ref(null)
|
||||
|
||||
// 演示标记数据
|
||||
const demoMarkers = ref([
|
||||
{
|
||||
location: { lng: 106.27, lat: 38.47 },
|
||||
title: '银川市',
|
||||
content: '宁夏回族自治区首府'
|
||||
},
|
||||
{
|
||||
location: { lng: 106.15, lat: 38.35 },
|
||||
title: '永宁县',
|
||||
content: '银川市下辖县'
|
||||
},
|
||||
{
|
||||
location: { lng: 106.45, lat: 38.55 },
|
||||
title: '贺兰县',
|
||||
content: '银川市下辖县'
|
||||
}
|
||||
])
|
||||
|
||||
// 计算当前缩放级别
|
||||
const currentZoom = computed(() => {
|
||||
return mapInstance.value ? mapInstance.value.currentZoom : '未知'
|
||||
})
|
||||
|
||||
// 地图就绪事件
|
||||
const onMapReady = (map) => {
|
||||
console.log('地图就绪:', map)
|
||||
mapInstance.value = map
|
||||
}
|
||||
|
||||
// 标记点击事件
|
||||
const onMarkerClick = (markerData, marker) => {
|
||||
console.log('标记被点击:', markerData)
|
||||
alert(`点击了标记: ${markerData.title}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-zoom-demo {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.demo-header h2 {
|
||||
color: #1890ff;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.zoom-info {
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
border: 2px solid #d9d9d9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.demo-instructions {
|
||||
background: #fafafa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #1890ff;
|
||||
}
|
||||
|
||||
.demo-instructions h3 {
|
||||
color: #1890ff;
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.demo-instructions ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.demo-instructions li {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.demo-instructions ul ul {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
</style>
|
||||
534
admin-system/frontend/src/views/Monitor.vue
Normal file
534
admin-system/frontend/src/views/Monitor.vue
Normal file
@@ -0,0 +1,534 @@
|
||||
<template>
|
||||
<div class="monitor-page">
|
||||
<a-page-header
|
||||
title="实时监控"
|
||||
sub-title="宁夏智慧养殖监管平台实时监控"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-select
|
||||
v-model="selectedFarm"
|
||||
style="width: 200px"
|
||||
placeholder="选择养殖场"
|
||||
@change="handleFarmChange"
|
||||
>
|
||||
<a-select-option value="all">全部养殖场</a-select-option>
|
||||
<a-select-option v-for="farm in dataStore.farms" :key="farm.id" :value="farm.id">
|
||||
{{ farm.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<a-button type="primary" @click="refreshAllData">
|
||||
<template #icon><reload-outlined /></template>
|
||||
刷新数据
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<!-- 性能监控组件 -->
|
||||
<chart-performance-monitor style="display: none;" />
|
||||
|
||||
<div class="monitor-content">
|
||||
<!-- 实时数据概览 -->
|
||||
<div class="monitor-stats">
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<span>温度监控</span>
|
||||
<a-tag color="orange" style="margin-left: 8px">{{ getLatestValue(temperatureData) }}°C</a-tag>
|
||||
</template>
|
||||
<monitor-chart
|
||||
title="温度变化趋势"
|
||||
type="line"
|
||||
:data="temperatureData"
|
||||
height="250px"
|
||||
:refresh-interval="30000"
|
||||
cache-key="temperature-chart"
|
||||
:enable-cache="true"
|
||||
:cache-ttl="60000"
|
||||
@refresh="refreshTemperatureData"
|
||||
/>
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<span>湿度监控</span>
|
||||
<a-tag color="blue" style="margin-left: 8px">{{ getLatestValue(humidityData) }}%</a-tag>
|
||||
</template>
|
||||
<monitor-chart
|
||||
title="湿度变化趋势"
|
||||
type="line"
|
||||
:data="humidityData"
|
||||
height="250px"
|
||||
:refresh-interval="30000"
|
||||
cache-key="humidity-chart"
|
||||
:enable-cache="true"
|
||||
:cache-ttl="60000"
|
||||
@refresh="refreshHumidityData"
|
||||
/>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<div class="monitor-stats">
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<span>设备状态</span>
|
||||
<a-tag color="green" style="margin-left: 8px">在线率: {{ deviceOnlineRate }}%</a-tag>
|
||||
</template>
|
||||
<monitor-chart
|
||||
title="设备状态分布"
|
||||
type="pie"
|
||||
:data="deviceStatusData"
|
||||
height="250px"
|
||||
:refresh-interval="60000"
|
||||
cache-key="device-status-chart"
|
||||
:enable-cache="true"
|
||||
:cache-ttl="120000"
|
||||
@refresh="refreshDeviceData"
|
||||
/>
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<span>预警监控</span>
|
||||
<a-tag color="red" style="margin-left: 8px">{{ alertCount }}个预警</a-tag>
|
||||
</template>
|
||||
<monitor-chart
|
||||
title="预警类型分布"
|
||||
type="pie"
|
||||
:data="alertTypeData"
|
||||
height="250px"
|
||||
:refresh-interval="60000"
|
||||
cache-key="alert-type-chart"
|
||||
:enable-cache="true"
|
||||
:cache-ttl="120000"
|
||||
@refresh="refreshAlertData"
|
||||
/>
|
||||
</a-card>
|
||||
</div>
|
||||
|
||||
<!-- 实时预警列表 -->
|
||||
<a-card title="实时预警信息" :bordered="false" class="alert-card">
|
||||
<a-table
|
||||
:dataSource="alertTableData"
|
||||
:columns="alertColumns"
|
||||
:pagination="{ pageSize: 5 }"
|
||||
:loading="dataStore.loading.alerts"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'type'">
|
||||
{{ getAlertTypeText(record.type) }}
|
||||
</template>
|
||||
<template v-if="column.key === 'level'">
|
||||
<a-tag :color="getAlertLevelColor(record.level)">
|
||||
{{ getAlertLevelText(record.level) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'created_at'">
|
||||
{{ formatTime(record.created_at) }}
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="handleAlert(record.id)">
|
||||
处理
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { useDataStore } from '../stores'
|
||||
import MonitorChart from '../components/MonitorChart.vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { api } from '../utils/api'
|
||||
import ChartPerformanceMonitor from '../components/ChartPerformanceMonitor.vue'
|
||||
import { DataCache } from '../utils/chartService'
|
||||
|
||||
// 使用数据存储
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 选中的养殖场
|
||||
const selectedFarm = ref('all')
|
||||
|
||||
// 处理养殖场变化
|
||||
function handleFarmChange(farmId) {
|
||||
refreshAllData()
|
||||
}
|
||||
|
||||
// 刷新所有数据(优化版本)
|
||||
async function refreshAllData() {
|
||||
// 清理相关缓存以强制刷新
|
||||
DataCache.delete(`temperature_data_${selectedFarm.value}`)
|
||||
DataCache.delete(`humidity_data_${selectedFarm.value}`)
|
||||
|
||||
await dataStore.fetchAllData()
|
||||
refreshTemperatureData()
|
||||
refreshHumidityData()
|
||||
refreshDeviceData()
|
||||
refreshAlertData()
|
||||
|
||||
message.success('数据刷新完成')
|
||||
}
|
||||
|
||||
// 温度数据
|
||||
const temperatureData = reactive({
|
||||
xAxis: [],
|
||||
series: [{
|
||||
name: '温度',
|
||||
data: [],
|
||||
itemStyle: { color: '#ff7a45' },
|
||||
areaStyle: { opacity: 0.2 }
|
||||
}]
|
||||
})
|
||||
|
||||
// 湿度数据
|
||||
const humidityData = reactive({
|
||||
xAxis: [],
|
||||
series: [{
|
||||
name: '湿度',
|
||||
data: [],
|
||||
itemStyle: { color: '#1890ff' },
|
||||
areaStyle: { opacity: 0.2 }
|
||||
}]
|
||||
})
|
||||
|
||||
// 设备状态数据
|
||||
const deviceStatusData = computed(() => {
|
||||
const devices = selectedFarm.value === 'all'
|
||||
? dataStore.devices
|
||||
: dataStore.devices.filter(d => (d.farm_id || d.farmId) == selectedFarm.value)
|
||||
|
||||
const online = devices.filter(d => d.status === 'online').length
|
||||
const offline = devices.length - online
|
||||
|
||||
return [
|
||||
{ value: online, name: '在线设备', itemStyle: { color: '#52c41a' } },
|
||||
{ value: offline, name: '离线设备', itemStyle: { color: '#d9d9d9' } }
|
||||
]
|
||||
})
|
||||
|
||||
// 设备在线率
|
||||
const deviceOnlineRate = computed(() => {
|
||||
const devices = selectedFarm.value === 'all'
|
||||
? dataStore.devices
|
||||
: dataStore.devices.filter(d => (d.farm_id || d.farmId) == selectedFarm.value)
|
||||
|
||||
if (devices.length === 0) return 0
|
||||
|
||||
const online = devices.filter(d => d.status === 'online').length
|
||||
return ((online / devices.length) * 100).toFixed(1)
|
||||
})
|
||||
|
||||
// 预警类型数据
|
||||
const alertTypeData = computed(() => {
|
||||
const alerts = selectedFarm.value === 'all'
|
||||
? dataStore.alerts
|
||||
: dataStore.alerts.filter(a => a.farm_id == selectedFarm.value)
|
||||
|
||||
// 按类型分组统计(使用中文类型名)
|
||||
const typeCount = {}
|
||||
alerts.forEach(alert => {
|
||||
const chineseType = getAlertTypeText(alert.type)
|
||||
typeCount[chineseType] = (typeCount[chineseType] || 0) + 1
|
||||
})
|
||||
|
||||
// 转换为图表数据格式
|
||||
return Object.keys(typeCount).map(type => ({
|
||||
value: typeCount[type],
|
||||
name: type,
|
||||
itemStyle: {
|
||||
color: type.includes('温度') ? '#ff4d4f' :
|
||||
type.includes('湿度') ? '#1890ff' :
|
||||
type.includes('设备') ? '#faad14' : '#52c41a'
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
// 预警数量
|
||||
const alertCount = computed(() => {
|
||||
return selectedFarm.value === 'all'
|
||||
? dataStore.alertCount
|
||||
: dataStore.alerts.filter(a => a.farm_id == selectedFarm.value).length
|
||||
})
|
||||
|
||||
// 预警表格数据
|
||||
const alertTableData = computed(() => {
|
||||
const alerts = selectedFarm.value === 'all'
|
||||
? dataStore.alerts
|
||||
: dataStore.alerts.filter(a => a.farm_id == selectedFarm.value)
|
||||
|
||||
return alerts.map(alert => ({
|
||||
key: alert.id,
|
||||
id: alert.id,
|
||||
type: alert.type,
|
||||
level: alert.level,
|
||||
farmId: alert.farm_id,
|
||||
farmName: dataStore.farms.find(f => f.id == alert.farm_id)?.name || '',
|
||||
created_at: alert.created_at
|
||||
}))
|
||||
})
|
||||
|
||||
// 预警表格列定义
|
||||
const alertColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '预警类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type'
|
||||
},
|
||||
{
|
||||
title: '预警级别',
|
||||
dataIndex: 'level',
|
||||
key: 'level'
|
||||
},
|
||||
{
|
||||
title: '养殖场',
|
||||
dataIndex: 'farmName',
|
||||
key: 'farmName'
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
sorter: (a, b) => new Date(a.created_at) - new Date(b.created_at),
|
||||
defaultSortOrder: 'descend'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取预警级别颜色
|
||||
function getAlertLevelColor(level) {
|
||||
switch (level) {
|
||||
case 'high': return 'red'
|
||||
case 'medium': return 'orange'
|
||||
case 'low': return 'blue'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取预警级别文本
|
||||
function getAlertLevelText(level) {
|
||||
switch (level) {
|
||||
case 'high': return '高'
|
||||
case 'medium': return '中'
|
||||
case 'low': return '低'
|
||||
default: return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取预警类型中文
|
||||
function getAlertTypeText(type) {
|
||||
const typeMap = {
|
||||
'温度异常': '温度异常',
|
||||
'湿度异常': '湿度异常',
|
||||
'设备离线': '设备离线',
|
||||
'temperature_high': '温度过高',
|
||||
'temperature_low': '温度过低',
|
||||
'humidity_high': '湿度过高',
|
||||
'humidity_low': '湿度过低',
|
||||
'device_offline': '设备离线',
|
||||
'device_error': '设备故障',
|
||||
'sensor_error': '传感器故障',
|
||||
'power_failure': '电源故障',
|
||||
'network_error': '网络异常'
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
|
||||
// 格式化时间显示
|
||||
function formatTime(timestamp) {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
// 如果是今天
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 如果是昨天
|
||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
if (date.toDateString() === yesterday.toDateString()) {
|
||||
return '昨天 ' + date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 如果是一周内
|
||||
if (diff < 7 * 24 * 60 * 60 * 1000) {
|
||||
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
return weekdays[date.getDay()] + ' ' + date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 其他情况显示完整日期
|
||||
return date.toLocaleDateString('zh-CN') + ' ' + date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 处理预警
|
||||
function handleAlert(alertId) {
|
||||
message.success(`已处理预警 #${alertId}`)
|
||||
}
|
||||
|
||||
// 刷新温度数据(优化版本)
|
||||
async function refreshTemperatureData() {
|
||||
const cacheKey = `temperature_data_${selectedFarm.value}`
|
||||
|
||||
try {
|
||||
// 检查缓存
|
||||
let data = DataCache.get(cacheKey)
|
||||
|
||||
if (!data) {
|
||||
// 调用后端监控数据API
|
||||
data = await api.get('/stats/public/monitoring')
|
||||
|
||||
// 缓存数据(2分钟)
|
||||
if (data) {
|
||||
DataCache.set(cacheKey, data, 2 * 60 * 1000)
|
||||
}
|
||||
} else {
|
||||
console.log('使用缓存的温度数据')
|
||||
}
|
||||
|
||||
// api.get返回的是result.data,直接检查数据结构
|
||||
if (data && data.environmentData && data.environmentData.temperature && data.environmentData.temperature.history) {
|
||||
const temperatureInfo = data.environmentData.temperature
|
||||
|
||||
// 使用后端返回的温度历史数据,格式化时间标签
|
||||
const timeLabels = temperatureInfo.history.map(item => formatTime(item.time))
|
||||
const tempValues = temperatureInfo.history.map(item => item.value)
|
||||
|
||||
console.log('温度数据处理成功:', { timeLabels, tempValues })
|
||||
|
||||
temperatureData.xAxis = timeLabels
|
||||
temperatureData.series[0].data = tempValues
|
||||
return
|
||||
}
|
||||
|
||||
// 如果API调用失败,显示错误信息
|
||||
console.error('无法获取温度数据,请检查后端服务')
|
||||
} catch (error) {
|
||||
console.error('获取温度数据出错:', error)
|
||||
// 显示错误状态
|
||||
temperatureData.xAxis = ['无数据']
|
||||
temperatureData.series[0].data = [0]
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新湿度数据(优化版本)
|
||||
async function refreshHumidityData() {
|
||||
const cacheKey = `humidity_data_${selectedFarm.value}`
|
||||
|
||||
try {
|
||||
// 检查缓存
|
||||
let data = DataCache.get(cacheKey)
|
||||
|
||||
if (!data) {
|
||||
// 调用后端监控数据API
|
||||
data = await api.get('/stats/public/monitoring')
|
||||
|
||||
// 缓存数据(2分钟)
|
||||
if (data) {
|
||||
DataCache.set(cacheKey, data, 2 * 60 * 1000)
|
||||
}
|
||||
} else {
|
||||
console.log('使用缓存的湿度数据')
|
||||
}
|
||||
|
||||
if (data && data.environmentData && data.environmentData.humidity && data.environmentData.humidity.history) {
|
||||
const timeLabels = data.environmentData.humidity.history.map(item => {
|
||||
const date = new Date(item.time);
|
||||
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
||||
});
|
||||
const humidityValues = data.environmentData.humidity.history.map(item => item.value);
|
||||
|
||||
humidityData.xAxis = timeLabels;
|
||||
humidityData.series[0].data = humidityValues;
|
||||
} else {
|
||||
console.error('无法获取湿度数据,请检查后端服务');
|
||||
humidityData.series[0].data = [];
|
||||
humidityData.xAxis = ['无数据'];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取湿度数据出错:', error)
|
||||
// 显示错误状态
|
||||
humidityData.xAxis = ['无数据']
|
||||
humidityData.series[0].data = [0]
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新设备数据
|
||||
function refreshDeviceData() {
|
||||
// 设备状态数据是计算属性,会自动更新
|
||||
}
|
||||
|
||||
// 刷新预警数据
|
||||
function refreshAlertData() {
|
||||
// 预警数据是计算属性,会自动更新
|
||||
}
|
||||
|
||||
// 获取最新值
|
||||
function getLatestValue(data) {
|
||||
const seriesData = data.series?.[0]?.data
|
||||
return seriesData?.length > 0 ? seriesData[seriesData.length - 1] : '0'
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(async () => {
|
||||
// 加载数据
|
||||
await dataStore.fetchAllData()
|
||||
|
||||
// 初始化图表数据
|
||||
refreshTemperatureData()
|
||||
refreshHumidityData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monitor-page {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.monitor-content {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.monitor-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.alert-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.monitor-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
31
admin-system/frontend/src/views/NotFound.vue
Normal file
31
admin-system/frontend/src/views/NotFound.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<a-result
|
||||
status="404"
|
||||
title="404"
|
||||
sub-title="抱歉,您访问的页面不存在。"
|
||||
>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="$router.push('/')">
|
||||
返回首页
|
||||
</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
}
|
||||
</style>
|
||||
531
admin-system/frontend/src/views/Orders.vue
Normal file
531
admin-system/frontend/src/views/Orders.vue
Normal file
@@ -0,0 +1,531 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>订单管理</h1>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加订单
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="orders"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'total_amount'">
|
||||
¥{{ record.total_amount }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'created_at'">
|
||||
{{ formatDate(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="viewOrder(record)">查看</a-button>
|
||||
<a-button type="link" @click="editOrder(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个订单吗?"
|
||||
@confirm="deleteOrder(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑订单模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑订单' : '添加订单'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
width="800px"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="用户" name="user_id">
|
||||
<a-select v-model:value="formData.user_id" placeholder="请选择用户" :loading="usersLoading">
|
||||
<a-select-option v-for="user in users" :key="user.id" :value="user.id">
|
||||
{{ user.username }} ({{ user.email }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="订单状态" name="status">
|
||||
<a-select v-model:value="formData.status" placeholder="请选择状态">
|
||||
<a-select-option value="pending">待处理</a-select-option>
|
||||
<a-select-option value="paid">已支付</a-select-option>
|
||||
<a-select-option value="shipped">已发货</a-select-option>
|
||||
<a-select-option value="delivered">已送达</a-select-option>
|
||||
<a-select-option value="cancelled">已取消</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="订单项目">
|
||||
<div v-for="(item, index) in formData.items" :key="index" style="border: 1px solid #d9d9d9; padding: 16px; margin-bottom: 8px; border-radius: 6px;">
|
||||
<a-row :gutter="16" align="middle">
|
||||
<a-col :span="8">
|
||||
<a-form-item :name="['items', index, 'product_id']" label="产品" :rules="[{ required: true, message: '请选择产品' }]">
|
||||
<a-select v-model:value="item.product_id" placeholder="请选择产品" :loading="productsLoading">
|
||||
<a-select-option v-for="product in products" :key="product.id" :value="product.id">
|
||||
{{ product.name }} (¥{{ product.price }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-form-item :name="['items', index, 'quantity']" label="数量" :rules="[{ required: true, message: '请输入数量' }]">
|
||||
<a-input-number v-model:value="item.quantity" :min="1" style="width: 100%" @change="calculateItemTotal(index)" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-form-item :name="['items', index, 'price']" label="单价" :rules="[{ required: true, message: '请输入单价' }]">
|
||||
<a-input-number v-model:value="item.price" :min="0" :precision="2" style="width: 100%" @change="calculateItemTotal(index)" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="3">
|
||||
<div style="margin-top: 30px;">小计: ¥{{ (item.quantity * item.price || 0).toFixed(2) }}</div>
|
||||
</a-col>
|
||||
<a-col :span="1">
|
||||
<a-button type="text" danger @click="removeItem(index)" style="margin-top: 30px;">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
<a-button type="dashed" @click="addItem" style="width: 100%; margin-top: 8px;">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加订单项
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<div style="text-align: right; font-size: 16px; font-weight: bold;">
|
||||
总金额: ¥{{ calculateTotalAmount() }}
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 查看订单详情模态框 -->
|
||||
<a-modal
|
||||
v-model:open="viewModalVisible"
|
||||
title="订单详情"
|
||||
:footer="null"
|
||||
width="800px"
|
||||
>
|
||||
<div v-if="viewOrderData">
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="订单ID">{{ viewOrderData.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="用户">{{ getUserName(viewOrderData.user_id) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="总金额">¥{{ viewOrderData.total_amount }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="getStatusColor(viewOrderData.status)">
|
||||
{{ getStatusText(viewOrderData.status) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ formatDate(viewOrderData.created_at) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="更新时间">{{ formatDate(viewOrderData.updated_at) }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider>订单项目</a-divider>
|
||||
<a-table
|
||||
:columns="orderItemColumns"
|
||||
:data-source="viewOrderData.items || []"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'product_name'">
|
||||
{{ getProductName(record.product_id) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'price'">
|
||||
¥{{ record.price }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'total'">
|
||||
¥{{ (record.quantity * record.price).toFixed(2) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PlusOutlined,
|
||||
DeleteOutlined
|
||||
},
|
||||
setup() {
|
||||
const orders = ref([])
|
||||
const users = ref([])
|
||||
const products = ref([])
|
||||
const loading = ref(false)
|
||||
const usersLoading = ref(false)
|
||||
const productsLoading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const viewModalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
const viewOrderData = ref(null)
|
||||
|
||||
const formData = reactive({
|
||||
user_id: undefined,
|
||||
status: 'pending',
|
||||
items: [{
|
||||
product_id: undefined,
|
||||
quantity: 1,
|
||||
price: 0
|
||||
}]
|
||||
})
|
||||
|
||||
const rules = {
|
||||
user_id: [{ required: true, message: '请选择用户' }],
|
||||
status: [{ required: true, message: '请选择状态' }]
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'user_id',
|
||||
key: 'user_id',
|
||||
customRender: ({ record }) => getUserName(record.user_id)
|
||||
},
|
||||
{
|
||||
title: '总金额',
|
||||
dataIndex: 'total_amount',
|
||||
key: 'total_amount'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status'
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 200
|
||||
}
|
||||
]
|
||||
|
||||
const orderItemColumns = [
|
||||
{
|
||||
title: '产品',
|
||||
dataIndex: 'product_name',
|
||||
key: 'product_name'
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'quantity',
|
||||
key: 'quantity'
|
||||
},
|
||||
{
|
||||
title: '单价',
|
||||
dataIndex: 'price',
|
||||
key: 'price'
|
||||
},
|
||||
{
|
||||
title: '小计',
|
||||
dataIndex: 'total',
|
||||
key: 'total'
|
||||
}
|
||||
]
|
||||
|
||||
// 获取所有订单
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await axios.get('/api/orders')
|
||||
if (response.data.success) {
|
||||
orders.value = response.data.data || []
|
||||
} else {
|
||||
message.error('获取订单失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取订单失败:', error)
|
||||
message.error('获取订单失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有用户
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
usersLoading.value = true
|
||||
const response = await axios.get('/api/users')
|
||||
if (response.data.success) {
|
||||
users.value = response.data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户失败:', error)
|
||||
} finally {
|
||||
usersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有产品
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
productsLoading.value = true
|
||||
const response = await axios.get('/api/products')
|
||||
if (response.data.success) {
|
||||
products.value = response.data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取产品失败:', error)
|
||||
} finally {
|
||||
productsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
fetchUsers()
|
||||
fetchProducts()
|
||||
}
|
||||
|
||||
// 查看订单
|
||||
const viewOrder = (record) => {
|
||||
viewOrderData.value = record
|
||||
viewModalVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑订单
|
||||
const editOrder = (record) => {
|
||||
isEdit.value = true
|
||||
formData.user_id = record.user_id
|
||||
formData.status = record.status
|
||||
formData.items = record.items || [{
|
||||
product_id: undefined,
|
||||
quantity: 1,
|
||||
price: 0
|
||||
}]
|
||||
formData.id = record.id
|
||||
modalVisible.value = true
|
||||
fetchUsers()
|
||||
fetchProducts()
|
||||
}
|
||||
|
||||
// 删除订单
|
||||
const deleteOrder = async (id) => {
|
||||
try {
|
||||
await axios.delete(`/api/orders/${id}`)
|
||||
message.success('删除成功')
|
||||
fetchOrders()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
const orderData = {
|
||||
user_id: formData.user_id,
|
||||
status: formData.status,
|
||||
items: formData.items.filter(item => item.product_id && item.quantity && item.price),
|
||||
total_amount: calculateTotalAmount()
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await axios.put(`/api/orders/${formData.id}`, orderData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await axios.post('/api/orders', orderData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
|
||||
modalVisible.value = false
|
||||
fetchOrders()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('提交失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
formData.user_id = undefined
|
||||
formData.status = 'pending'
|
||||
formData.items = [{
|
||||
product_id: undefined,
|
||||
quantity: 1,
|
||||
price: 0
|
||||
}]
|
||||
delete formData.id
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 添加订单项
|
||||
const addItem = () => {
|
||||
formData.items.push({
|
||||
product_id: undefined,
|
||||
quantity: 1,
|
||||
price: 0
|
||||
})
|
||||
}
|
||||
|
||||
// 删除订单项
|
||||
const removeItem = (index) => {
|
||||
if (formData.items.length > 1) {
|
||||
formData.items.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算订单项小计
|
||||
const calculateItemTotal = (index) => {
|
||||
const item = formData.items[index]
|
||||
if (item.product_id) {
|
||||
const product = products.value.find(p => p.id === item.product_id)
|
||||
if (product && !item.price) {
|
||||
item.price = product.price
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算总金额
|
||||
const calculateTotalAmount = () => {
|
||||
return formData.items.reduce((total, item) => {
|
||||
return total + (item.quantity * item.price || 0)
|
||||
}, 0).toFixed(2)
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status) => {
|
||||
const colors = {
|
||||
pending: 'orange',
|
||||
paid: 'blue',
|
||||
shipped: 'cyan',
|
||||
delivered: 'green',
|
||||
cancelled: 'red'
|
||||
}
|
||||
return colors[status] || 'default'
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status) => {
|
||||
const texts = {
|
||||
pending: '待处理',
|
||||
paid: '已支付',
|
||||
shipped: '已发货',
|
||||
delivered: '已送达',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取用户名
|
||||
const getUserName = (userId) => {
|
||||
const user = users.value.find(u => u.id === userId)
|
||||
return user ? `${user.username} (${user.email})` : `用户ID: ${userId}`
|
||||
}
|
||||
|
||||
// 获取产品名
|
||||
const getProductName = (productId) => {
|
||||
const product = products.value.find(p => p.id === productId)
|
||||
return product ? product.name : `产品ID: ${productId}`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchOrders()
|
||||
})
|
||||
|
||||
return {
|
||||
orders,
|
||||
users,
|
||||
products,
|
||||
loading,
|
||||
usersLoading,
|
||||
productsLoading,
|
||||
modalVisible,
|
||||
viewModalVisible,
|
||||
submitLoading,
|
||||
isEdit,
|
||||
formRef,
|
||||
formData,
|
||||
rules,
|
||||
columns,
|
||||
orderItemColumns,
|
||||
viewOrderData,
|
||||
fetchOrders,
|
||||
showAddModal,
|
||||
viewOrder,
|
||||
editOrder,
|
||||
deleteOrder,
|
||||
handleSubmit,
|
||||
handleCancel,
|
||||
resetForm,
|
||||
addItem,
|
||||
removeItem,
|
||||
calculateItemTotal,
|
||||
calculateTotalAmount,
|
||||
getStatusColor,
|
||||
getStatusText,
|
||||
formatDate,
|
||||
getUserName,
|
||||
getProductName
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
277
admin-system/frontend/src/views/Products.vue
Normal file
277
admin-system/frontend/src/views/Products.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>产品管理</h1>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加产品
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="products"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'price'">
|
||||
¥{{ record.price }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'status'">
|
||||
<a-tag :color="record.status === 'active' ? 'green' : 'red'">
|
||||
{{ record.status === 'active' ? '活跃' : '停用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="editProduct(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个产品吗?"
|
||||
@confirm="deleteProduct(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑产品模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑产品' : '添加产品'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="产品名称" name="name">
|
||||
<a-input v-model:value="formData.name" placeholder="请输入产品名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="产品描述" name="description">
|
||||
<a-textarea v-model:value="formData.description" placeholder="请输入产品描述" :rows="3" />
|
||||
</a-form-item>
|
||||
<a-form-item label="价格" name="price">
|
||||
<a-input-number
|
||||
v-model:value="formData.price"
|
||||
placeholder="请输入价格"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="库存" name="stock">
|
||||
<a-input-number
|
||||
v-model:value="formData.stock"
|
||||
placeholder="请输入库存数量"
|
||||
:min="0"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select v-model="formData.status" placeholder="请选择状态">
|
||||
<a-select-option value="active">活跃</a-select-option>
|
||||
<a-select-option value="inactive">停用</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import axios from 'axios'
|
||||
|
||||
// 响应式数据
|
||||
const products = ref([])
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
price: null,
|
||||
stock: null,
|
||||
status: 'active'
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入产品名称', trigger: 'blur' }],
|
||||
price: [{ required: true, message: '请输入价格', trigger: 'blur' }],
|
||||
stock: [{ required: true, message: '请输入库存数量', trigger: 'blur' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '产品名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: '价格',
|
||||
dataIndex: 'price',
|
||||
key: 'price',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '库存',
|
||||
dataIndex: 'stock',
|
||||
key: 'stock',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 150
|
||||
}
|
||||
]
|
||||
|
||||
// 获取产品列表
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await axios.get('/api/products')
|
||||
if (response.data.success) {
|
||||
products.value = response.data.data
|
||||
} else {
|
||||
message.error('获取产品列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取产品列表失败:', error)
|
||||
message.error('获取产品列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑产品
|
||||
const editProduct = (record) => {
|
||||
isEdit.value = true
|
||||
Object.assign(formData, record)
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 删除产品
|
||||
const deleteProduct = async (id) => {
|
||||
try {
|
||||
const token = localStorage.getItem('token')
|
||||
const response = await axios.delete(`/api/products/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
if (response.data.success) {
|
||||
message.success('删除成功')
|
||||
fetchProducts()
|
||||
} else {
|
||||
message.error('删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除产品失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
const config = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
|
||||
let response
|
||||
if (isEdit.value) {
|
||||
response = await axios.put(`/api/products/${formData.id}`, formData, config)
|
||||
} else {
|
||||
response = await axios.post('/api/products', formData, config)
|
||||
}
|
||||
|
||||
if (response.data.success) {
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
modalVisible.value = false
|
||||
fetchProducts()
|
||||
} else {
|
||||
message.error(isEdit.value ? '更新失败' : '创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
price: null,
|
||||
stock: null,
|
||||
status: 'active'
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchProducts()
|
||||
})
|
||||
</script>
|
||||
211
admin-system/frontend/src/views/TableStats.vue
Normal file
211
admin-system/frontend/src/views/TableStats.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div class="table-stats-container">
|
||||
<div class="header">
|
||||
<h2>统计数据表格</h2>
|
||||
<p>基于MySQL数据库的真实统计数据</p>
|
||||
</div>
|
||||
|
||||
<div class="canvas-container">
|
||||
<canvas
|
||||
ref="tableCanvas"
|
||||
:width="canvasWidth"
|
||||
:height="canvasHeight"
|
||||
class="stats-canvas"
|
||||
></canvas>
|
||||
</div>
|
||||
|
||||
<div class="loading" v-if="loading">
|
||||
<a-spin size="large" />
|
||||
<p>正在加载数据...</p>
|
||||
</div>
|
||||
|
||||
<div class="error" v-if="error">
|
||||
<a-alert
|
||||
:message="error"
|
||||
type="error"
|
||||
show-icon
|
||||
@close="error = null"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import api from '@/utils/api'
|
||||
|
||||
// 响应式数据
|
||||
const tableCanvas = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const tableData = ref([])
|
||||
const canvasWidth = ref(600)
|
||||
const canvasHeight = ref(300)
|
||||
|
||||
// 获取统计数据
|
||||
const fetchTableStats = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
const response = await api.get('/stats/public/table')
|
||||
|
||||
if (response.data.success) {
|
||||
tableData.value = response.data.data
|
||||
await nextTick()
|
||||
drawTable()
|
||||
} else {
|
||||
throw new Error(response.data.message || '获取数据失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取统计数据失败:', err)
|
||||
error.value = err.message || '获取统计数据失败'
|
||||
message.error('获取统计数据失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制表格
|
||||
const drawTable = () => {
|
||||
if (!tableCanvas.value || !tableData.value.length) return
|
||||
|
||||
const canvas = tableCanvas.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
// 清空画布
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 设置字体和样式
|
||||
ctx.font = '16px Arial, sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// 表格参数
|
||||
const startX = 50
|
||||
const startY = 50
|
||||
const rowHeight = 50
|
||||
const col1Width = 200
|
||||
const col2Width = 150
|
||||
const tableWidth = col1Width + col2Width
|
||||
const tableHeight = (tableData.value.length + 1) * rowHeight
|
||||
|
||||
// 绘制表格边框
|
||||
ctx.strokeStyle = '#d9d9d9'
|
||||
ctx.lineWidth = 1
|
||||
|
||||
// 绘制外边框
|
||||
ctx.strokeRect(startX, startY, tableWidth, tableHeight)
|
||||
|
||||
// 绘制列分隔线
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(startX + col1Width, startY)
|
||||
ctx.lineTo(startX + col1Width, startY + tableHeight)
|
||||
ctx.stroke()
|
||||
|
||||
// 绘制表头
|
||||
ctx.fillStyle = '#f5f5f5'
|
||||
ctx.fillRect(startX, startY, tableWidth, rowHeight)
|
||||
|
||||
// 绘制表头边框
|
||||
ctx.strokeRect(startX, startY, tableWidth, rowHeight)
|
||||
|
||||
// 绘制表头文字
|
||||
ctx.fillStyle = '#262626'
|
||||
ctx.font = 'bold 16px Arial, sans-serif'
|
||||
ctx.fillText('数据描述', startX + 10, startY + rowHeight / 2)
|
||||
ctx.fillText('统计数值', startX + col1Width + 10, startY + rowHeight / 2)
|
||||
|
||||
// 绘制数据行
|
||||
ctx.font = '16px Arial, sans-serif'
|
||||
tableData.value.forEach((item, index) => {
|
||||
const y = startY + (index + 1) * rowHeight
|
||||
|
||||
// 绘制行分隔线
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(startX, y)
|
||||
ctx.lineTo(startX + tableWidth, y)
|
||||
ctx.stroke()
|
||||
|
||||
// 绘制数据
|
||||
ctx.fillStyle = '#262626'
|
||||
ctx.fillText(item.description, startX + 10, y + rowHeight / 2)
|
||||
|
||||
// 数值用不同颜色显示
|
||||
ctx.fillStyle = '#1890ff'
|
||||
ctx.font = 'bold 16px Arial, sans-serif'
|
||||
ctx.fillText(item.value.toLocaleString(), startX + col1Width + 10, y + rowHeight / 2)
|
||||
ctx.font = '16px Arial, sans-serif'
|
||||
})
|
||||
|
||||
// 绘制标题
|
||||
ctx.fillStyle = '#262626'
|
||||
ctx.font = 'bold 20px Arial, sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('农场统计数据表', startX + tableWidth / 2, 25)
|
||||
|
||||
// 绘制数据来源说明
|
||||
ctx.font = '12px Arial, sans-serif'
|
||||
ctx.fillStyle = '#8c8c8c'
|
||||
ctx.fillText('数据来源:MySQL数据库实时查询', startX + tableWidth / 2, startY + tableHeight + 30)
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchTableStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-stats-container {
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #262626;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.stats-canvas {
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.loading p {
|
||||
margin-top: 16px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
173
admin-system/frontend/src/views/TestAnalytics.vue
Normal file
173
admin-system/frontend/src/views/TestAnalytics.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="test-analytics">
|
||||
<h2>数据加载测试页面</h2>
|
||||
|
||||
<div class="debug-info">
|
||||
<h3>数据状态</h3>
|
||||
<p>养殖场数量: {{ dataStore.farms.length }}</p>
|
||||
<p>动物数量: {{ dataStore.animals.length }}</p>
|
||||
<p>设备数量: {{ dataStore.devices.length }}</p>
|
||||
<p>预警数量: {{ dataStore.alerts.length }}</p>
|
||||
</div>
|
||||
|
||||
<div class="raw-data">
|
||||
<h3>原始数据</h3>
|
||||
<div class="data-section">
|
||||
<h4>养殖场数据</h4>
|
||||
<pre>{{ JSON.stringify(dataStore.farms, null, 2) }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="data-section">
|
||||
<h4>动物数据</h4>
|
||||
<pre>{{ JSON.stringify(dataStore.animals, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-data">
|
||||
<h3>计算后的表格数据</h3>
|
||||
<pre>{{ JSON.stringify(farmTableData, null, 2) }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="actual-table">
|
||||
<h3>实际表格</h3>
|
||||
<a-table
|
||||
:columns="farmColumns"
|
||||
:data-source="farmTableData"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a-button @click="refreshData" type="primary">刷新数据</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useDataStore } from '../stores/data'
|
||||
|
||||
const dataStore = useDataStore()
|
||||
|
||||
// 养殖场表格数据
|
||||
const farmTableData = computed(() => {
|
||||
console.log('计算farmTableData:', {
|
||||
farms: dataStore.farms.length,
|
||||
animals: dataStore.animals.length,
|
||||
devices: dataStore.devices.length,
|
||||
alerts: dataStore.alerts.length
|
||||
})
|
||||
|
||||
return dataStore.farms.map(farm => {
|
||||
// 获取该养殖场的动物数量
|
||||
const animals = dataStore.animals.filter(animal => animal.farm_id === farm.id)
|
||||
const animalCount = animals.reduce((sum, animal) => sum + (animal.count || 0), 0)
|
||||
|
||||
// 获取该养殖场的设备数量
|
||||
const devices = dataStore.devices.filter(device => device.farm_id === farm.id)
|
||||
const deviceCount = devices.length
|
||||
|
||||
// 获取该养殖场的预警数量
|
||||
const alerts = dataStore.alerts.filter(alert => alert.farm_id === farm.id)
|
||||
const alertCount = alerts.length
|
||||
|
||||
console.log(`养殖场 ${farm.name} (ID: ${farm.id}):`, {
|
||||
animalCount,
|
||||
deviceCount,
|
||||
alertCount,
|
||||
animals: animals.map(a => ({ id: a.id, farm_id: a.farm_id, count: a.count })),
|
||||
devices: devices.map(d => ({ id: d.id, farm_id: d.farm_id })),
|
||||
alerts: alerts.map(a => ({ id: a.id, farm_id: a.farm_id }))
|
||||
})
|
||||
|
||||
return {
|
||||
key: farm.id,
|
||||
id: farm.id,
|
||||
name: farm.name,
|
||||
animalCount,
|
||||
deviceCount,
|
||||
alertCount
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 养殖场表格列定义
|
||||
const farmColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '养殖场名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name'
|
||||
},
|
||||
{
|
||||
title: '动物数量',
|
||||
dataIndex: 'animalCount',
|
||||
key: 'animalCount',
|
||||
sorter: (a, b) => a.animalCount - b.animalCount
|
||||
},
|
||||
{
|
||||
title: '设备数量',
|
||||
dataIndex: 'deviceCount',
|
||||
key: 'deviceCount',
|
||||
sorter: (a, b) => a.deviceCount - b.deviceCount
|
||||
},
|
||||
{
|
||||
title: '预警数量',
|
||||
dataIndex: 'alertCount',
|
||||
key: 'alertCount',
|
||||
sorter: (a, b) => a.alertCount - b.alertCount
|
||||
}
|
||||
]
|
||||
|
||||
const refreshData = async () => {
|
||||
console.log('手动刷新数据...')
|
||||
await dataStore.fetchAllData()
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(async () => {
|
||||
console.log('TestAnalytics页面开始加载数据...')
|
||||
await dataStore.fetchAllData()
|
||||
console.log('TestAnalytics页面数据加载完成')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.test-analytics {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.debug-info {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.data-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.data-section h4 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #f8f8f8;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
257
admin-system/frontend/src/views/Users.vue
Normal file
257
admin-system/frontend/src/views/Users.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h1>用户管理</h1>
|
||||
<a-button type="primary" @click="showAddModal">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
添加用户
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="users"
|
||||
:loading="loading"
|
||||
:pagination="{ pageSize: 10 }"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'role'">
|
||||
<a-tag :color="record.role === 'admin' ? 'red' : 'blue'">
|
||||
{{ record.role === 'admin' ? '管理员' : '普通用户' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'created_at'">
|
||||
{{ formatDate(record.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" @click="editUser(record)">编辑</a-button>
|
||||
<a-popconfirm
|
||||
title="确定要删除这个用户吗?"
|
||||
@confirm="deleteUser(record.id)"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
>
|
||||
<a-button type="link" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 添加/编辑用户模态框 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑用户' : '添加用户'"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
:confirm-loading="submitLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
layout="vertical"
|
||||
>
|
||||
<a-form-item label="用户名" name="username">
|
||||
<a-input v-model:value="formData.username" placeholder="请输入用户名" :disabled="isEdit" />
|
||||
</a-form-item>
|
||||
<a-form-item label="邮箱" name="email">
|
||||
<a-input v-model:value="formData.email" placeholder="请输入邮箱" />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码" name="password" v-if="!isEdit">
|
||||
<a-input-password v-model:value="formData.password" placeholder="请输入密码" />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色" name="role">
|
||||
<a-select v-model:value="formData.role" placeholder="请选择角色">
|
||||
<a-select-option value="user">普通用户</a-select-option>
|
||||
<a-select-option value="admin">管理员</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import axios from 'axios'
|
||||
|
||||
// 响应式数据
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const modalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: null,
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user'
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
|
||||
],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
role: [{ required: true, message: '请选择角色', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 表格列配置
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username'
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email'
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
width: 120
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 150
|
||||
}
|
||||
]
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const { api } = await import('../utils/api')
|
||||
const response = await api.get('/users')
|
||||
// api.get已经处理了响应格式,直接返回data部分
|
||||
if (Array.isArray(response)) {
|
||||
users.value = response
|
||||
} else {
|
||||
users.value = []
|
||||
console.warn('API返回数据格式异常:', response)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error)
|
||||
message.error('获取用户列表失败')
|
||||
users.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加模态框
|
||||
const showAddModal = () => {
|
||||
isEdit.value = false
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑用户
|
||||
const editUser = (record) => {
|
||||
isEdit.value = true
|
||||
Object.assign(formData, {
|
||||
...record,
|
||||
password: '' // 编辑时不显示密码
|
||||
})
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
const deleteUser = async (id) => {
|
||||
try {
|
||||
const { api } = await import('../utils/api')
|
||||
await api.delete(`/users/${id}`)
|
||||
message.success('删除成功')
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('删除用户失败:', error)
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
const submitData = { ...formData }
|
||||
if (isEdit.value && !submitData.password) {
|
||||
delete submitData.password // 编辑时如果密码为空则不更新密码
|
||||
}
|
||||
|
||||
const { api } = await import('../utils/api')
|
||||
if (isEdit.value) {
|
||||
await api.put(`/users/${formData.id}`, submitData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await api.post('/users', submitData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
|
||||
modalVisible.value = false
|
||||
fetchUsers()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消操作
|
||||
const handleCancel = () => {
|
||||
modalVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
id: null,
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user'
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(() => {
|
||||
fetchUsers()
|
||||
})
|
||||
</script>
|
||||
158
admin-system/frontend/test-devices-frontend.js
Normal file
158
admin-system/frontend/test-devices-frontend.js
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 测试前端设备管理功能是否能正确显示数据库中的设备数据
|
||||
*/
|
||||
|
||||
const axios = require('axios');
|
||||
|
||||
// API基础配置
|
||||
const API_BASE_URL = 'http://localhost:5350/api';
|
||||
let authToken = '';
|
||||
|
||||
// 登录获取token
|
||||
async function login() {
|
||||
try {
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/login`, {
|
||||
username: 'admin',
|
||||
password: '123456'
|
||||
});
|
||||
|
||||
if (response.data.success && response.data.token) {
|
||||
authToken = response.data.token;
|
||||
console.log('✅ 登录成功,获取到认证token');
|
||||
return true;
|
||||
} else {
|
||||
console.log('❌ 登录失败:', response.data.message);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('❌ 登录请求失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试设备API
|
||||
async function testDevicesAPI() {
|
||||
try {
|
||||
console.log('\n=== 测试前端设备管理功能数据导入 ===\n');
|
||||
|
||||
// 1. 测试获取设备列表
|
||||
console.log('1. 测试获取设备列表API:');
|
||||
const response = await axios.get(`${API_BASE_URL}/devices`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const devices = response.data.data;
|
||||
console.log(` ✅ 成功获取设备列表,共 ${devices.length} 个设备`);
|
||||
|
||||
// 2. 验证数据结构
|
||||
console.log('\n2. 验证设备数据结构:');
|
||||
if (devices.length > 0) {
|
||||
const firstDevice = devices[0];
|
||||
const requiredFields = ['id', 'name', 'type', 'status', 'farm_id', 'installation_date', 'last_maintenance'];
|
||||
|
||||
console.log(' 检查必需字段:');
|
||||
requiredFields.forEach(field => {
|
||||
if (firstDevice.hasOwnProperty(field)) {
|
||||
console.log(` ✅ ${field}: ${firstDevice[field]}`);
|
||||
} else {
|
||||
console.log(` ❌ 缺少字段: ${field}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 检查农场关联信息
|
||||
if (firstDevice.farm) {
|
||||
console.log(` ✅ 农场信息: ${firstDevice.farm.name}`);
|
||||
} else {
|
||||
console.log(' ❌ 缺少农场关联信息');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 统计设备类型分布
|
||||
console.log('\n3. 设备类型分布:');
|
||||
const typeStats = {};
|
||||
devices.forEach(device => {
|
||||
typeStats[device.type] = (typeStats[device.type] || 0) + 1;
|
||||
});
|
||||
|
||||
Object.entries(typeStats).forEach(([type, count]) => {
|
||||
console.log(` - ${type}: ${count} 个`);
|
||||
});
|
||||
|
||||
// 4. 统计设备状态分布
|
||||
console.log('\n4. 设备状态分布:');
|
||||
const statusStats = {};
|
||||
devices.forEach(device => {
|
||||
statusStats[device.status] = (statusStats[device.status] || 0) + 1;
|
||||
});
|
||||
|
||||
Object.entries(statusStats).forEach(([status, count]) => {
|
||||
console.log(` - ${status}: ${count} 个`);
|
||||
});
|
||||
|
||||
// 5. 检查农场关联
|
||||
console.log('\n5. 农场关联情况:');
|
||||
const farmStats = {};
|
||||
devices.forEach(device => {
|
||||
if (device.farm) {
|
||||
farmStats[device.farm.name] = (farmStats[device.farm.name] || 0) + 1;
|
||||
} else {
|
||||
farmStats['未关联'] = (farmStats['未关联'] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(farmStats).forEach(([farm, count]) => {
|
||||
console.log(` - ${farm}: ${count} 个设备`);
|
||||
});
|
||||
|
||||
console.log('\n=== 前端设备管理功能数据导入测试完成 ===');
|
||||
console.log('✅ 数据库中的设备数据已成功导入到设备管理功能模块');
|
||||
console.log('✅ 前端页面可以正常显示所有设备信息,包括:');
|
||||
console.log(' - 设备基本信息(ID、名称、类型、状态)');
|
||||
console.log(' - 农场关联信息');
|
||||
console.log(' - 安装和维护日期');
|
||||
console.log(' - 设备指标数据');
|
||||
|
||||
} else {
|
||||
console.log('❌ 获取设备列表失败:', response.data.message);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log('❌ 测试过程中出现错误:', error.message);
|
||||
if (error.response) {
|
||||
console.log(' 响应状态:', error.response.status);
|
||||
console.log(' 响应数据:', error.response.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 主测试函数
|
||||
async function runTest() {
|
||||
console.log('开始测试前端设备管理功能...');
|
||||
|
||||
// 先登录获取token
|
||||
const loginSuccess = await login();
|
||||
if (!loginSuccess) {
|
||||
console.log('❌ 无法获取认证token,测试终止');
|
||||
return;
|
||||
}
|
||||
|
||||
// 测试设备API
|
||||
await testDevicesAPI();
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
if (require.main === module) {
|
||||
runTest().then(() => {
|
||||
console.log('\n测试完成');
|
||||
process.exit(0);
|
||||
}).catch((error) => {
|
||||
console.error('测试失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runTest };
|
||||
79
admin-system/frontend/test-map.html
Normal file
79
admin-system/frontend/test-map.html
Normal file
@@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>百度地图API测试</title>
|
||||
<style>
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
#log {
|
||||
margin-top: 20px;
|
||||
padding: 10px;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>百度地图API测试</h1>
|
||||
<div id="map"></div>
|
||||
<div id="log"></div>
|
||||
|
||||
<script>
|
||||
const log = document.getElementById('log');
|
||||
function addLog(message) {
|
||||
log.textContent += new Date().toLocaleTimeString() + ': ' + message + '\n';
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
// 测试API密钥
|
||||
const API_KEY = '3AN3VahoqaXUs32U8luXD2Dwn86KK5B7';
|
||||
addLog('使用API密钥: ' + API_KEY);
|
||||
|
||||
// 创建全局回调函数
|
||||
window.initBMap = function() {
|
||||
addLog('百度地图API加载成功');
|
||||
addLog('BMap对象类型: ' + typeof window.BMap);
|
||||
addLog('BMap.Map是否存在: ' + typeof window.BMap.Map);
|
||||
|
||||
try {
|
||||
// 创建地图实例
|
||||
const map = new BMap.Map("map");
|
||||
addLog('地图实例创建成功');
|
||||
|
||||
// 设置中心点
|
||||
const point = new BMap.Point(106.27, 38.47); // 宁夏中心
|
||||
map.centerAndZoom(point, 8);
|
||||
addLog('地图中心点设置成功');
|
||||
|
||||
// 添加控件
|
||||
map.addControl(new BMap.MapTypeControl());
|
||||
map.addControl(new BMap.NavigationControl());
|
||||
addLog('地图控件添加成功');
|
||||
|
||||
addLog('地图初始化完成!');
|
||||
} catch (error) {
|
||||
addLog('地图创建失败: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载百度地图API
|
||||
addLog('开始加载百度地图API...');
|
||||
const script = document.createElement('script');
|
||||
script.type = 'text/javascript';
|
||||
script.src = `https://api.map.baidu.com/api?v=3.0&ak=${API_KEY}&callback=initBMap`;
|
||||
|
||||
script.onerror = function(error) {
|
||||
addLog('脚本加载失败: ' + error);
|
||||
};
|
||||
|
||||
addLog('API URL: ' + script.src);
|
||||
document.head.appendChild(script);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
67
admin-system/frontend/test-users-frontend.js
Normal file
67
admin-system/frontend/test-users-frontend.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 测试前端用户管理功能
|
||||
*/
|
||||
const axios = require('axios');
|
||||
|
||||
// 模拟localStorage
|
||||
const mockLocalStorage = {
|
||||
getItem: (key) => {
|
||||
const storage = {
|
||||
'token': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwidXNlcm5hbWUiOiJhZG1pbiIsImVtYWlsIjoiYWRtaW5Abnh4bWRhdGEuY29tIiwiaWF0IjoxNzU2MTAyNzU2LCJleHAiOjE3NTYxODkxNTZ9.2Pq25hFiMiTyWB-GBdS5vIXOhI2He9oxjcuSDAytV50'
|
||||
};
|
||||
return storage[key] || null;
|
||||
}
|
||||
};
|
||||
|
||||
// 测试用户API调用
|
||||
async function testUsersAPI() {
|
||||
try {
|
||||
console.log('测试前端用户API调用...');
|
||||
console.log('=' .repeat(50));
|
||||
|
||||
// 模拟前端API调用
|
||||
const API_BASE_URL = 'http://localhost:5350/api';
|
||||
const token = mockLocalStorage.getItem('token');
|
||||
|
||||
console.log('Token存在:', !!token);
|
||||
console.log('Token长度:', token ? token.length : 0);
|
||||
|
||||
if (!token) {
|
||||
console.log('❌ 没有找到认证token');
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用用户API
|
||||
const response = await axios.get(`${API_BASE_URL}/users`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('API响应状态:', response.status);
|
||||
console.log('API响应成功:', response.data.success);
|
||||
console.log('用户数据数量:', response.data.data ? response.data.data.length : 0);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
console.log('✅ 前端可以正常获取用户数据');
|
||||
console.log('用户列表:');
|
||||
response.data.data.forEach((user, index) => {
|
||||
console.log(` ${index + 1}. ${user.username} (${user.email}) - 角色: ${user.role}`);
|
||||
});
|
||||
} else {
|
||||
console.log('❌ API返回数据格式异常');
|
||||
console.log('响应数据:', JSON.stringify(response.data, null, 2));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.log('❌ 前端API调用失败:', error.message);
|
||||
if (error.response) {
|
||||
console.log('错误状态码:', error.response.status);
|
||||
console.log('错误响应:', error.response.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
testUsersAPI().catch(console.error);
|
||||
18
admin-system/frontend/vite.config.js
Normal file
18
admin-system/frontend/vite.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5300,
|
||||
host: '0.0.0.0',
|
||||
historyApiFallback: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5350',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user