chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>蚕房环境监控 - Silkworm Environment Monitor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3849
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.3.2",
|
||||
"@ant-design/pro-components": "^2.8.10",
|
||||
"antd": "^5.29.3",
|
||||
"axios": "^1.18.1",
|
||||
"dayjs": "^1.11.21",
|
||||
"echarts": "^6.1.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"flv.js": "^1.6.2",
|
||||
"hls.js": "^1.6.16",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^18.3.27",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"oxlint": "^1.71.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Optional dev mock for vite-plugin-mock or msw (placeholder).
|
||||
// If backend is not up, pages still render with fallback UI already built in each page.
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
+140
@@ -0,0 +1,140 @@
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const zlib = require('zlib');
|
||||
|
||||
const DIST = '/home/pan/silk/web/dist';
|
||||
const PORT = 5174;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
// 统一安全响应头(静态资源)
|
||||
const SECURITY_HEADERS = {
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Frame-Options': 'DENY',
|
||||
'Referrer-Policy': 'no-referrer',
|
||||
'Content-Security-Policy':
|
||||
"default-src 'self'; img-src 'self' data: blob:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:; font-src 'self' data:; object-src 'none'; frame-ancestors 'none'",
|
||||
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
|
||||
};
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
console.log(`${req.method} ${req.url}`);
|
||||
|
||||
// API proxy to Go backend
|
||||
if (req.url.startsWith('/api')) {
|
||||
const options = {
|
||||
hostname: '127.0.0.1',
|
||||
port: 3000,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: { ...req.headers, host: 'localhost:3000' },
|
||||
};
|
||||
const proxy = http.request(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
proxy.on('error', (e) => {
|
||||
console.error('API proxy error:', e.message);
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Bad Gateway', detail: e.message }));
|
||||
});
|
||||
req.pipe(proxy);
|
||||
return;
|
||||
}
|
||||
|
||||
// FLV/fMP4/HLS stream proxy to ZLMediaKit (avoids CORS/network issues)
|
||||
const streamPath = req.url.split('?')[0];
|
||||
if (streamPath.match(/^\/rtp\//) || streamPath.match(/\.(flv|mp4|m3u8|ts)$/)) {
|
||||
const proxy = http.request({
|
||||
hostname: '127.0.0.1',
|
||||
port: 8081,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: { ...req.headers, host: 'localhost:8081' },
|
||||
}, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
});
|
||||
proxy.on('error', (e) => {
|
||||
console.error('Stream proxy error:', e.message);
|
||||
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
||||
res.end('Stream proxy error: ' + e.message);
|
||||
});
|
||||
req.pipe(proxy);
|
||||
return;
|
||||
}
|
||||
|
||||
// Static file serving with gzip
|
||||
let urlPath = req.url.split('?')[0];
|
||||
let filePath = path.join(DIST, urlPath === '/' ? 'index.html' : urlPath);
|
||||
|
||||
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
|
||||
filePath = path.join(DIST, 'index.html');
|
||||
}
|
||||
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath);
|
||||
const contentType = MIME[ext] || 'application/octet-stream';
|
||||
const acceptEncoding = req.headers['accept-encoding'] || '';
|
||||
const useGzip = acceptEncoding.includes('gzip') && data.length > 1024;
|
||||
// index.html 不缓存,带 hash 的静态资源缓存 7 天
|
||||
const cacheControl = ext === '.html'
|
||||
? 'no-cache, no-store, must-revalidate'
|
||||
: 'public, max-age=604800';
|
||||
|
||||
if (useGzip) {
|
||||
zlib.gzip(data, (err, compressed) => {
|
||||
if (err) {
|
||||
res.writeHead(500);
|
||||
res.end('Compression Error');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': contentType,
|
||||
'Content-Encoding': 'gzip',
|
||||
'Content-Length': compressed.length,
|
||||
'Cache-Control': cacheControl,
|
||||
...SECURITY_HEADERS,
|
||||
});
|
||||
res.end(compressed);
|
||||
});
|
||||
} else {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': data.length,
|
||||
'Cache-Control': cacheControl,
|
||||
...SECURITY_HEADERS,
|
||||
});
|
||||
res.end(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.on('error', (e) => {
|
||||
console.error('Server error:', e);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (e) => {
|
||||
console.error('Uncaught:', e);
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Server running on http://0.0.0.0:${PORT} (gzip enabled)`);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function App() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosError, AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
|
||||
import { message } from 'antd';
|
||||
|
||||
export const TOKEN_KEY = 'silkworm_access_token';
|
||||
export const REFRESH_TOKEN_KEY = 'silkworm_refresh_token';
|
||||
|
||||
export interface RequestConfig extends AxiosRequestConfig {
|
||||
silent?: boolean;
|
||||
/** 内部标记:本次请求是 refresh 重试,避免无限循环 */
|
||||
_retried?: boolean;
|
||||
}
|
||||
|
||||
export const http: AxiosInstance = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
if (token) {
|
||||
config.headers = config.headers ?? {};
|
||||
(config.headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
let refreshing: Promise<string | null> | null = null;
|
||||
|
||||
const refreshToken = async (): Promise<string | null> => {
|
||||
const refresh = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (!refresh) return null;
|
||||
try {
|
||||
const res = await axios.post('/api/v1/auth/refresh', { refreshToken: refresh });
|
||||
const { accessToken, refreshToken: newRefresh } = res.data || {};
|
||||
if (!accessToken) return null;
|
||||
localStorage.setItem(TOKEN_KEY, accessToken);
|
||||
if (newRefresh) localStorage.setItem(REFRESH_TOKEN_KEY, newRefresh);
|
||||
return accessToken;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearAuthAndRedirect = () => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
if (location.pathname !== '/login') {
|
||||
message.warning('登录已过期,请重新登录');
|
||||
location.replace('/login');
|
||||
}
|
||||
};
|
||||
|
||||
http.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (err: AxiosError) => {
|
||||
const status = err.response?.status;
|
||||
const cfg = err.config as RequestConfig | undefined;
|
||||
|
||||
// 访问令牌过期:尝试用 refresh 令牌换新后重试一次
|
||||
if (status === 401 && cfg && !cfg._retried && !cfg.url?.includes('/auth/')) {
|
||||
cfg._retried = true;
|
||||
refreshing = refreshing ?? refreshToken();
|
||||
const newToken = await refreshing;
|
||||
refreshing = null;
|
||||
if (newToken) {
|
||||
cfg.headers = cfg.headers ?? ({} as InternalAxiosRequestConfig['headers']);
|
||||
(cfg.headers as Record<string, string>)['Authorization'] = `Bearer ${newToken}`;
|
||||
return http.request(cfg);
|
||||
}
|
||||
clearAuthAndRedirect();
|
||||
} else if (status === 401) {
|
||||
clearAuthAndRedirect();
|
||||
} else if (!cfg?.silent) {
|
||||
const msg =
|
||||
(err.response?.data as any)?.error ||
|
||||
(err.response?.data as any)?.message ||
|
||||
(err.response?.data as any)?.msg ||
|
||||
err.message ||
|
||||
'请求失败';
|
||||
message.error(typeof msg === 'string' ? msg : '请求失败');
|
||||
}
|
||||
return Promise.reject(err);
|
||||
},
|
||||
);
|
||||
|
||||
export const request = <T = unknown>(config: RequestConfig): Promise<T> =>
|
||||
http.request<T>(config).then((r) => r.data);
|
||||
|
||||
export const get = <T = unknown>(url: string, config?: RequestConfig) =>
|
||||
request<T>({ ...config, method: 'GET', url });
|
||||
|
||||
export const post = <T = unknown>(url: string, data?: unknown, config?: RequestConfig) =>
|
||||
request<T>({ ...config, method: 'POST', url, data });
|
||||
|
||||
export const put = <T = unknown>(url: string, data?: unknown, config?: RequestConfig) =>
|
||||
request<T>({ ...config, method: 'PUT', url, data });
|
||||
|
||||
export const patch = <T = unknown>(url: string, data?: unknown, config?: RequestConfig) =>
|
||||
request<T>({ ...config, method: 'PATCH', url, data });
|
||||
|
||||
export const del = <T = unknown>(url: string, config?: RequestConfig) =>
|
||||
request<T>({ ...config, method: 'DELETE', url });
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { authService } from '../services/auth';
|
||||
|
||||
interface HasPermissionProps {
|
||||
/** 需要的权限码,如 device:control */
|
||||
permission: string;
|
||||
children: ReactNode;
|
||||
/** 无权限时渲染的替代内容,默认不渲染 */
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按钮级权限控制组件
|
||||
* 当当前用户拥有指定权限码时渲染 children,否则渲染 fallback
|
||||
*/
|
||||
export const HasPermission = ({ permission, children, fallback = null }: HasPermissionProps) => {
|
||||
if (authService.hasPermission(permission)) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return <>{fallback}</>;
|
||||
};
|
||||
|
||||
export default HasPermission;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Card, Col, Row, Statistic } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
items: {
|
||||
title: string;
|
||||
value: number | string;
|
||||
suffix?: string;
|
||||
color?: string;
|
||||
icon?: ReactNode;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const StatCard = ({ items }: Props) => (
|
||||
<Row gutter={[16, 16]}>
|
||||
{items.map((it) => (
|
||||
<Col key={it.title} xs={24} sm={12} md={8} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={it.title}
|
||||
value={it.value}
|
||||
suffix={it.suffix}
|
||||
valueStyle={it.color ? { color: it.color } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
@@ -0,0 +1,274 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import Hls from 'hls.js';
|
||||
import flvjs from 'flv.js';
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
/** Video stream URL */
|
||||
url: string;
|
||||
/** Stream format: 'hls' uses hls.js, 'mp4' uses native video */
|
||||
format?: 'hls' | 'mp4' | 'flv' | 'webrtc';
|
||||
/** Auto play on load */
|
||||
autoPlay?: boolean;
|
||||
/** Muted by default (needed for autoplay in most browsers) */
|
||||
muted?: boolean;
|
||||
/** Callback when error occurs */
|
||||
onError?: (error: string) => void;
|
||||
/** Custom style */
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function VideoPlayer({
|
||||
url,
|
||||
format = 'hls',
|
||||
autoPlay = true,
|
||||
muted = true,
|
||||
onError,
|
||||
style,
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const flvRef = useRef<flvjs.Player | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorMsg, setErrorMsg] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !url) return;
|
||||
|
||||
setLoading(true);
|
||||
setErrorMsg('');
|
||||
|
||||
// Clean up previous HLS/FLV instance
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
if (flvRef.current) {
|
||||
flvRef.current.destroy();
|
||||
flvRef.current = null;
|
||||
}
|
||||
|
||||
const handleError = (msg: string) => {
|
||||
setErrorMsg(msg);
|
||||
setLoading(false);
|
||||
onError?.(msg);
|
||||
};
|
||||
|
||||
if (format === 'hls') {
|
||||
if (Hls.isSupported()) {
|
||||
// Use hls.js for browsers that don't natively support HLS (Chrome, Edge, Firefox)
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
backBufferLength: 30,
|
||||
});
|
||||
hlsRef.current = hls;
|
||||
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
setLoading(false);
|
||||
if (autoPlay) {
|
||||
video.play().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
hls.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
handleError(`视频播放失败: ${data.details || data.type}`);
|
||||
hls.destroy();
|
||||
hlsRef.current = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Safari natively supports HLS
|
||||
video.src = url;
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
setLoading(false);
|
||||
if (autoPlay) {
|
||||
video.play().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
video.addEventListener('error', () => {
|
||||
handleError('视频播放失败');
|
||||
});
|
||||
} else {
|
||||
handleError('当前浏览器不支持 HLS 播放');
|
||||
}
|
||||
} else if (format === 'flv') {
|
||||
// FLV format - use flv.js (supports H265 if browser MSE supports it)
|
||||
if (flvjs.isSupported()) {
|
||||
let retryCount = 0;
|
||||
const maxRetries = 5;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const createFlvPlayer = () => {
|
||||
if (flvRef.current) {
|
||||
flvRef.current.destroy();
|
||||
flvRef.current = null;
|
||||
}
|
||||
const flvPlayer = flvjs.createPlayer({
|
||||
type: 'flv',
|
||||
url,
|
||||
isLive: true,
|
||||
}, {
|
||||
enableWorker: false,
|
||||
enableStashBuffer: true,
|
||||
stashInitialSize: 512,
|
||||
fixAudioTimestampGap: false,
|
||||
liveBufferLatencyChasing: true,
|
||||
liveBufferLatencyMaxLatency: 3,
|
||||
liveBufferLatencyMinRemain: 1,
|
||||
} as any);
|
||||
flvRef.current = flvPlayer;
|
||||
flvPlayer.attachMediaElement(video);
|
||||
flvPlayer.load();
|
||||
flvPlayer.on(flvjs.Events.ERROR, (errorType, errorDetail) => {
|
||||
// MediaMSEError / MediaError: auto-retry for live streams
|
||||
if (errorType === flvjs.ErrorTypes.MEDIA_ERROR && retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
console.warn(`FLV 播放错误,第 ${retryCount} 次重连: ${errorType} - ${errorDetail}`);
|
||||
retryTimer = setTimeout(() => {
|
||||
createFlvPlayer();
|
||||
if (autoPlay) video.play().catch(() => undefined);
|
||||
}, 1000);
|
||||
} else if (errorType === flvjs.ErrorTypes.NETWORK_ERROR && retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
console.warn(`FLV 网络错误,第 ${retryCount} 次重连: ${errorType} - ${errorDetail}`);
|
||||
retryTimer = setTimeout(() => {
|
||||
createFlvPlayer();
|
||||
if (autoPlay) video.play().catch(() => undefined);
|
||||
}, 2000);
|
||||
} else {
|
||||
handleError(`FLV播放失败: ${errorType} - ${errorDetail}`);
|
||||
flvPlayer.destroy();
|
||||
flvRef.current = null;
|
||||
}
|
||||
});
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
setLoading(false);
|
||||
if (autoPlay) {
|
||||
video.play().catch(() => undefined);
|
||||
}
|
||||
}, { once: true });
|
||||
};
|
||||
|
||||
createFlvPlayer();
|
||||
|
||||
// Cleanup retry timer on unmount
|
||||
return () => {
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
if (flvRef.current) {
|
||||
flvRef.current.destroy();
|
||||
flvRef.current = null;
|
||||
}
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
};
|
||||
} else {
|
||||
handleError('当前浏览器不支持 FLV 播放');
|
||||
}
|
||||
} else {
|
||||
// MP4 and other native formats
|
||||
video.src = url;
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
setLoading(false);
|
||||
if (autoPlay) {
|
||||
video.play().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
video.addEventListener('error', () => {
|
||||
handleError('视频播放失败');
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
if (flvRef.current) {
|
||||
flvRef.current.destroy();
|
||||
flvRef.current = null;
|
||||
}
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
};
|
||||
}, [url, format, autoPlay, onError]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: '16 / 9',
|
||||
background: '#000',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted={muted}
|
||||
playsInline
|
||||
controls
|
||||
preload="metadata"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: errorMsg ? 'none' : 'block',
|
||||
}}
|
||||
/>
|
||||
{loading && !errorMsg && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
>
|
||||
<Spin tip="加载中..." />
|
||||
</div>
|
||||
)}
|
||||
{errorMsg && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#ff4d4f',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { get, post } from '../api/http';
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
level?: 'info' | 'warn' | 'danger';
|
||||
severity?: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
message?: string;
|
||||
houseId?: string;
|
||||
deviceId?: string;
|
||||
deviceKey?: string;
|
||||
read?: boolean;
|
||||
acknowledged?: boolean;
|
||||
open?: boolean;
|
||||
createdAt?: string;
|
||||
triggeredAt?: string;
|
||||
}
|
||||
|
||||
export interface AlarmClip {
|
||||
id: string;
|
||||
playbackUrl?: string;
|
||||
cameraId?: string;
|
||||
startAt?: string;
|
||||
endAt?: string;
|
||||
mock?: boolean;
|
||||
}
|
||||
|
||||
const toList = (data: Alert[] | { items: Alert[]; total?: number }) => {
|
||||
const items = Array.isArray(data) ? data : data.items;
|
||||
return { items, total: Array.isArray(data) ? data.length : data.total ?? items.length };
|
||||
};
|
||||
|
||||
export const listAlerts = async (params?: any) =>
|
||||
toList(await get<Alert[] | { items: Alert[]; total?: number }>('/alarms', { params }));
|
||||
export const markAlertRead = (id: string) => post(`/alarms/${id}/ack`);
|
||||
export const markAllRead = async () => {
|
||||
const res = await listAlerts({ openOnly: true });
|
||||
await Promise.all(res.items.filter((item) => !item.acknowledged).map((item) => markAlertRead(item.id)));
|
||||
return { ok: true };
|
||||
};
|
||||
export const getAlertClip = (id: string) => get<AlarmClip>(`/alarms/${id}/clip`);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { get, post } from '../api/http';
|
||||
|
||||
export interface LoginPayload {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResp {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
token?: string;
|
||||
user: { id: string; username?: string; name?: string; fullName?: string; role?: string; permissions?: string[] };
|
||||
}
|
||||
|
||||
export interface ChangePasswordPayload {
|
||||
oldPassword: string;
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export const loginApi = (payload: LoginPayload) =>
|
||||
post<LoginResp>('/auth/login', payload, { silent: true });
|
||||
|
||||
export const logoutApi = () => post('/auth/logout', {});
|
||||
|
||||
export const changePasswordApi = (payload: ChangePasswordPayload) =>
|
||||
post('/auth/change-password', payload);
|
||||
|
||||
export interface MeResp {
|
||||
sub: string;
|
||||
username: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export const meApi = () => get<MeResp>('/auth/me');
|
||||
@@ -0,0 +1,148 @@
|
||||
import { get } from '../api/http';
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
location?: string;
|
||||
status?: 'active' | 'inactive' | 'running' | 'idle' | 'alarm';
|
||||
}
|
||||
|
||||
export interface RealtimeMetric {
|
||||
key: string;
|
||||
name: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
status: 'normal' | 'warn' | 'danger';
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
time: string;
|
||||
temp?: number;
|
||||
humidity?: number;
|
||||
co2?: number;
|
||||
}
|
||||
|
||||
export interface DeviceSummary {
|
||||
total: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
alarm: number;
|
||||
/** 蚕房运行中数量(status=running/active) */
|
||||
running: number;
|
||||
/** 蚕房停用数量(status=inactive) */
|
||||
inactive: number;
|
||||
}
|
||||
|
||||
interface TelemetryRecord {
|
||||
id?: string;
|
||||
deviceKey: string;
|
||||
metric: string;
|
||||
value: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const METRIC_NAME: Record<string, string> = {
|
||||
temperature: '温度',
|
||||
temp: '温度',
|
||||
humidity: '湿度',
|
||||
co2: 'CO₂',
|
||||
light: '光照',
|
||||
ph: 'PH',
|
||||
};
|
||||
|
||||
const METRIC_UNIT: Record<string, string> = {
|
||||
temperature: '℃',
|
||||
temp: '℃',
|
||||
humidity: '%',
|
||||
co2: 'ppm',
|
||||
light: 'lux',
|
||||
ph: '',
|
||||
};
|
||||
|
||||
const toArray = <T>(data: T[] | { items?: T[] }) => (Array.isArray(data) ? data : data.items ?? []);
|
||||
|
||||
const normalizeMetricKey = (metric: string) => (metric === 'temperature' ? 'temp' : metric);
|
||||
|
||||
const metricStatus = (key: string, value: number): RealtimeMetric['status'] => {
|
||||
if (key === 'temp') return value < 20 || value > 30 ? 'danger' : value < 22 || value > 28 ? 'warn' : 'normal';
|
||||
if (key === 'humidity') return value < 55 || value > 85 ? 'danger' : value < 60 || value > 80 ? 'warn' : 'normal';
|
||||
if (key === 'co2') return value > 1500 ? 'danger' : value > 1000 ? 'warn' : 'normal';
|
||||
return 'normal';
|
||||
};
|
||||
|
||||
const normalizeRealtime = (records: TelemetryRecord[]): RealtimeMetric[] => {
|
||||
const latestByMetric = new Map<string, TelemetryRecord>();
|
||||
for (const record of records) {
|
||||
const key = normalizeMetricKey(record.metric);
|
||||
const prev = latestByMetric.get(key);
|
||||
if (!prev || new Date(record.timestamp).getTime() > new Date(prev.timestamp).getTime()) {
|
||||
latestByMetric.set(key, record);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(latestByMetric.entries()).map(([key, record]) => ({
|
||||
key,
|
||||
name: METRIC_NAME[key] || record.metric,
|
||||
value: Number(record.value),
|
||||
unit: METRIC_UNIT[key] || '',
|
||||
status: metricStatus(key, Number(record.value)),
|
||||
}));
|
||||
};
|
||||
|
||||
export const fetchRooms = async () => toArray(await get<Room[] | { items: Room[] }>('/rooms', { silent: true }));
|
||||
|
||||
export const fetchRealtime = async () => {
|
||||
const records = await get<TelemetryRecord[]>('/telemetry', {
|
||||
params: { limit: 100 },
|
||||
silent: true,
|
||||
});
|
||||
return normalizeRealtime(records);
|
||||
};
|
||||
|
||||
export const fetchRoomRealtime = async (roomId: string) => {
|
||||
try {
|
||||
const data = await get<TelemetryRecord[] | RealtimeMetric[]>(`/rooms/${roomId}/telemetry/latest`, { silent: true });
|
||||
const arr = toArray(data as TelemetryRecord[] | { items: TelemetryRecord[] });
|
||||
if (arr.length > 0 && 'metric' in arr[0]) return normalizeRealtime(arr as TelemetryRecord[]);
|
||||
return arr as unknown as RealtimeMetric[];
|
||||
} catch {
|
||||
return fetchRealtime();
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchTrend = async (hours = 24) => {
|
||||
const to = new Date();
|
||||
const from = new Date(to.getTime() - hours * 3600 * 1000);
|
||||
const records = await get<TelemetryRecord[]>('/telemetry', {
|
||||
params: { from: from.toISOString(), to: to.toISOString(), limit: 1000 },
|
||||
silent: true,
|
||||
});
|
||||
return records
|
||||
.slice()
|
||||
.reverse()
|
||||
.reduce<TrendPoint[]>((acc, record) => {
|
||||
const key = normalizeMetricKey(record.metric);
|
||||
if (!['temp', 'humidity', 'co2'].includes(key)) return acc;
|
||||
const time = new Date(record.timestamp).toLocaleTimeString().slice(0, 5);
|
||||
const point = acc.find((item) => item.time === time) ?? { time };
|
||||
(point as unknown as Record<string, string | number | undefined>)[key] = Number(record.value);
|
||||
if (!acc.includes(point)) acc.push(point);
|
||||
return acc;
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const fetchDeviceSummary = async (): Promise<DeviceSummary> => {
|
||||
const [rooms, alarms] = await Promise.all([
|
||||
fetchRooms().catch(() => []),
|
||||
get<any[]>('/alarms', { params: { openOnly: true }, silent: true }).catch(() => []),
|
||||
]);
|
||||
return {
|
||||
total: rooms.length,
|
||||
online: rooms.filter((room) => room.status !== 'inactive').length,
|
||||
offline: rooms.filter((room) => room.status === 'inactive').length,
|
||||
running: rooms.filter((room) => room.status === 'running' || room.status === 'active').length,
|
||||
inactive: rooms.filter((room) => room.status === 'inactive').length,
|
||||
alarm: alarms.length,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { get, post, patch, del } from '../api/http';
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
deviceKey?: string;
|
||||
name: string;
|
||||
type?: 'sensor' | 'actuator' | 'gateway';
|
||||
kind?: 'sensor' | 'actuator' | 'gateway';
|
||||
houseId?: string;
|
||||
roomId?: string;
|
||||
status?: 'online' | 'offline' | 'unknown';
|
||||
onlineStatus?: 'online' | 'offline' | 'unknown';
|
||||
lastSeen?: string;
|
||||
meta?: Record<string, any>;
|
||||
}
|
||||
|
||||
const toList = (data: Device[] | { items: Device[]; total?: number }) => {
|
||||
const items = Array.isArray(data) ? data : data.items;
|
||||
return { items, total: Array.isArray(data) ? data.length : data.total ?? items.length };
|
||||
};
|
||||
|
||||
const toBackendDevice = (data: Partial<Device>) => ({
|
||||
...data,
|
||||
kind: data.kind || data.type,
|
||||
roomId: data.roomId || data.houseId,
|
||||
});
|
||||
|
||||
export const listDevices = async (params?: any) =>
|
||||
toList(await get<Device[] | { items: Device[]; total?: number }>('/devices', { params }));
|
||||
export const getDevice = (id: string) => get<Device>(`/devices/${id}`);
|
||||
export const createDevice = (data: Partial<Device>) =>
|
||||
post<Device>('/devices', toBackendDevice(data));
|
||||
export const updateDevice = (id: string, data: Partial<Device>) =>
|
||||
patch<Device>(`/devices/${id}`, toBackendDevice(data));
|
||||
export const deleteDevice = (id: string) => del(`/devices/${id}`);
|
||||
export const sendDeviceCommand = (deviceKey: string, action: string, payload?: any) =>
|
||||
post<{ ok?: boolean; success?: boolean }>('/control/send', { deviceKey, action, payload });
|
||||
@@ -0,0 +1,14 @@
|
||||
import { get } from '../api/http';
|
||||
|
||||
export interface ControlLog {
|
||||
id: string;
|
||||
deviceId?: string;
|
||||
deviceName?: string;
|
||||
command: string;
|
||||
operator?: string;
|
||||
success?: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const listLogs = (params?: any) =>
|
||||
get<{ items: ControlLog[]; total: number }>('/logs/control', { params });
|
||||
@@ -0,0 +1,27 @@
|
||||
import { get, post, patch, del } from '../api/http';
|
||||
|
||||
export interface SilkwormHouse {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
location?: string;
|
||||
description?: string;
|
||||
status?: 'active' | 'inactive' | 'running' | 'idle' | 'alarm';
|
||||
capacity?: number;
|
||||
stage?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
const toList = (data: SilkwormHouse[] | { items: SilkwormHouse[]; total?: number }) => {
|
||||
const items = Array.isArray(data) ? data : data.items;
|
||||
return { items, total: Array.isArray(data) ? data.length : data.total ?? items.length };
|
||||
};
|
||||
|
||||
export const listHouses = async (params?: any) =>
|
||||
toList(await get<SilkwormHouse[] | { items: SilkwormHouse[]; total?: number }>('/rooms', { params }));
|
||||
export const getHouse = (id: string) => get<SilkwormHouse>(`/rooms/${id}`);
|
||||
export const createHouse = (data: Partial<SilkwormHouse>) =>
|
||||
post<SilkwormHouse>('/rooms', data);
|
||||
export const updateHouse = (id: string, data: Partial<SilkwormHouse>) =>
|
||||
patch<SilkwormHouse>(`/rooms/${id}`, data);
|
||||
export const deleteHouse = (id: string) => del(`/rooms/${id}`);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { get, post, patch, del } from '../api/http';
|
||||
|
||||
export interface Threshold {
|
||||
id: string;
|
||||
name?: string;
|
||||
metric?: string;
|
||||
houseId?: string;
|
||||
roomId?: string;
|
||||
sensorId?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
debounceSeconds?: number;
|
||||
severity?: number;
|
||||
unit?: string;
|
||||
enabled?: boolean;
|
||||
sensor?: { id: string; name?: string; metric?: string; unit?: string };
|
||||
}
|
||||
|
||||
const toList = (data: Threshold[] | { items: Threshold[]; total?: number }) => {
|
||||
const items = Array.isArray(data) ? data : data.items;
|
||||
return { items, total: Array.isArray(data) ? data.length : data.total ?? items.length };
|
||||
};
|
||||
|
||||
const toBackendThreshold = (data: Partial<Threshold>) => ({
|
||||
...data,
|
||||
minValue: data.minValue ?? data.min,
|
||||
maxValue: data.maxValue ?? data.max,
|
||||
});
|
||||
|
||||
export const listThresholds = async (params?: any) =>
|
||||
toList(await get<Threshold[] | { items: Threshold[]; total?: number }>('/thresholds', { params }));
|
||||
export const createThreshold = (data: Partial<Threshold>) =>
|
||||
post<Threshold>('/thresholds', toBackendThreshold(data));
|
||||
export const updateThreshold = (id: string, data: Partial<Threshold>) =>
|
||||
patch<Threshold>(`/thresholds/${id}`, toBackendThreshold(data));
|
||||
export const deleteThreshold = (id: string) => del(`/thresholds/${id}`);
|
||||
@@ -0,0 +1,217 @@
|
||||
import { del, get, patch, post } from '../api/http';
|
||||
|
||||
export interface Camera {
|
||||
id: string;
|
||||
name: string;
|
||||
online?: boolean;
|
||||
isOnline?: boolean;
|
||||
enabled?: boolean;
|
||||
roomId?: string;
|
||||
streamUrl?: string;
|
||||
rtspUrl?: string;
|
||||
httpUrl?: string;
|
||||
hlsUrl?: string;
|
||||
flvUrl?: string;
|
||||
webrtcUrl?: string;
|
||||
snapshotUrl?: string;
|
||||
username?: string;
|
||||
passwordEnc?: string;
|
||||
position?: string;
|
||||
resolution?: string;
|
||||
fps?: number;
|
||||
gbDeviceId?: string;
|
||||
gbChannelId?: string;
|
||||
gbAuthId?: string;
|
||||
gbAuthPassword?: string;
|
||||
gbStreamType?: string;
|
||||
gbTransport?: string;
|
||||
gbAlarmChannelId?: string;
|
||||
gbVoiceChannelId?: string;
|
||||
gbManufacturer?: string;
|
||||
manufacturerId?: string;
|
||||
}
|
||||
|
||||
export interface WvpSipConfig {
|
||||
sipId: string;
|
||||
sipDomain: string;
|
||||
sipPassword: string;
|
||||
sipPort: number;
|
||||
sipShowIp: string;
|
||||
}
|
||||
|
||||
export interface PlayInfo {
|
||||
cameraId: string;
|
||||
code?: string;
|
||||
format: 'hls' | 'flv' | 'webrtc';
|
||||
url: string;
|
||||
expiresAt?: string;
|
||||
mock?: boolean;
|
||||
gbDeviceId?: string | null;
|
||||
gbChannelId?: string | null;
|
||||
}
|
||||
|
||||
export interface LiveInfo {
|
||||
cameraId: string;
|
||||
gbDeviceId: string | null;
|
||||
gbChannelId: string | null;
|
||||
format: 'hls' | 'flv' | 'webrtc';
|
||||
url: string;
|
||||
expiresAt?: string;
|
||||
mock?: boolean;
|
||||
}
|
||||
|
||||
export interface VideoClip {
|
||||
id: string;
|
||||
cameraId: string;
|
||||
trigger: 'alarm' | 'manual' | 'schedule' | 'motion';
|
||||
format: 'hls' | 'flv' | 'webrtc' | 'mp4';
|
||||
startAt: string;
|
||||
endAt?: string;
|
||||
durationSec: number;
|
||||
resolution?: string;
|
||||
sizeBytes?: string | number;
|
||||
playbackUrl?: string;
|
||||
mock?: boolean;
|
||||
}
|
||||
|
||||
export interface ClipListResponse {
|
||||
items: VideoClip[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ClipPlayInfo {
|
||||
clipId: string;
|
||||
url: string;
|
||||
format: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface ClipQuery {
|
||||
cameraId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const toArray = <T>(data: T[] | { items?: T[] }) => (Array.isArray(data) ? data : data.items ?? []);
|
||||
|
||||
export const listCameras = async () => toArray(await get<Camera[] | { items: Camera[] }>('/video/cameras'));
|
||||
|
||||
export const playCamera = (id: string, format: PlayInfo['format'] = 'hls') =>
|
||||
post<PlayInfo>(`/video/cameras/${id}/play`, { format });
|
||||
|
||||
export const getLiveUrl = (id: string, format: 'hls' | 'flv' | 'webrtc' = 'hls') =>
|
||||
post<LiveInfo>(`/video/cameras/${id}/live`, { format });
|
||||
|
||||
export const listClips = (query: ClipQuery = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (query.cameraId) params.set('cameraId', query.cameraId);
|
||||
if (query.from) params.set('from', query.from);
|
||||
if (query.to) params.set('to', query.to);
|
||||
if (query.limit) params.set('limit', String(query.limit));
|
||||
const qs = params.toString();
|
||||
return get<ClipListResponse>(`/video/clips${qs ? `?${qs}` : ''}`);
|
||||
};
|
||||
|
||||
export const getClipPlayUrl = (clipId: string) =>
|
||||
get<ClipPlayInfo>(`/video/clips/${clipId}/play`);
|
||||
|
||||
export const createCamera = (data: Partial<Camera>) =>
|
||||
post<Camera>('/video/cameras', data);
|
||||
|
||||
export const updateCamera = (id: string, data: Partial<Camera>) =>
|
||||
patch<Camera>(`/video/cameras/${id}`, data);
|
||||
|
||||
export const deleteCamera = (id: string) =>
|
||||
del<{ id: string }>(`/video/cameras/${id}`);
|
||||
|
||||
export const getWvpConfig = () =>
|
||||
get<WvpSipConfig>('/video/wvp-config');
|
||||
|
||||
export interface WvpDevice {
|
||||
id: number;
|
||||
deviceId: string;
|
||||
name: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
onLine: boolean;
|
||||
transport: string;
|
||||
streamMode: string;
|
||||
hostAddress: string;
|
||||
}
|
||||
|
||||
export interface WvpChannel {
|
||||
id: number;
|
||||
deviceId: string;
|
||||
channelId: string;
|
||||
name: string;
|
||||
onLine: boolean;
|
||||
}
|
||||
|
||||
export const listWvpDevices = (query?: string) =>
|
||||
get<WvpDevice[]>(`/video/wvp/devices${query ? `?query=${encodeURIComponent(query)}` : ''}`);
|
||||
|
||||
export const listWvpChannels = (deviceId: string) =>
|
||||
get<WvpChannel[]>(`/video/wvp/devices/${encodeURIComponent(deviceId)}/channels`);
|
||||
|
||||
export const syncWvpDevice = (deviceId: string) =>
|
||||
post<{ ok: boolean }>(`/video/wvp/devices/${encodeURIComponent(deviceId)}/sync`);
|
||||
|
||||
export interface ActiveRecording {
|
||||
cameraId: string;
|
||||
deviceId: string;
|
||||
channelId: string;
|
||||
stream: string;
|
||||
app: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
/** 开始录制 */
|
||||
export const startRecording = (cameraId: string) =>
|
||||
post<{ ok: boolean }>(`/video/cameras/${cameraId}/record/start`);
|
||||
|
||||
/** 停止录制并归档 */
|
||||
export const stopRecording = (cameraId: string) =>
|
||||
post<VideoClip>(`/video/cameras/${cameraId}/record/stop`);
|
||||
|
||||
/** 获取正在录制的摄像头 */
|
||||
export const getActiveRecordings = () =>
|
||||
get<ActiveRecording[]>(`/video/recordings/active`);
|
||||
|
||||
// ===== 存储状态 =====
|
||||
export interface CephStorageData {
|
||||
df: {
|
||||
stats: {
|
||||
total_bytes: number;
|
||||
total_used_bytes: number;
|
||||
total_avail_bytes: number;
|
||||
};
|
||||
pools: Array<{
|
||||
name: string;
|
||||
id: number;
|
||||
stats: {
|
||||
stored: number;
|
||||
objects: number;
|
||||
max_avail: number;
|
||||
percent_used: number;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
osdTree: {
|
||||
nodes: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
weight: number;
|
||||
class?: string;
|
||||
}>;
|
||||
};
|
||||
health: {
|
||||
status: string;
|
||||
checks?: Record<string, { severity: string; summary: { message: string; count: number } }>;
|
||||
};
|
||||
}
|
||||
|
||||
export const getCephStorage = () =>
|
||||
get<CephStorageData>('/storage/ceph');
|
||||
@@ -0,0 +1,16 @@
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root .ant-layout,
|
||||
#root .ant-pro-layout {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { App, Dropdown, Form, Input, Modal, Space, theme } from 'antd';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
HomeOutlined,
|
||||
ControlOutlined,
|
||||
BellOutlined,
|
||||
VideoCameraOutlined,
|
||||
FileTextOutlined,
|
||||
SettingOutlined,
|
||||
LogoutOutlined,
|
||||
UserOutlined,
|
||||
ThunderboltOutlined,
|
||||
ApiOutlined,
|
||||
ControlOutlined as ControlOutlinedIcon,
|
||||
KeyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { ProLayout } from '@ant-design/pro-components';
|
||||
import { useState } from 'react';
|
||||
import { authService } from '../services/auth';
|
||||
|
||||
const menuData = [
|
||||
{ path: '/dashboard', name: '仪表盘', icon: <DashboardOutlined />, permission: 'dashboard:view' },
|
||||
{ path: '/houses', name: '蚕房管理', icon: <HomeOutlined />, permission: 'room:read' },
|
||||
{ path: '/devices', name: '设备管理', icon: <ControlOutlined />, permission: 'device:read' },
|
||||
{ path: '/energy', name: '能耗管理', icon: <ThunderboltOutlined />, permission: 'energy:view' },
|
||||
{ path: '/control', name: '设备控制', icon: <ApiOutlined />, permission: 'device:control' },
|
||||
{ path: '/ir', name: '红外遥控', icon: <ControlOutlinedIcon />, permission: 'device:control' },
|
||||
{ path: '/thresholds', name: '阈值配置', icon: <SettingOutlined />, permission: 'threshold:read' },
|
||||
{ path: '/alerts', name: '告警中心', icon: <BellOutlined />, permission: 'alarm:read' },
|
||||
{ path: '/videos', name: '视频监控', icon: <VideoCameraOutlined />, permission: 'video:read' },
|
||||
{ path: '/logs', name: '控制日志', icon: <FileTextOutlined />, permission: 'log:read' },
|
||||
];
|
||||
|
||||
// 角色中文名
|
||||
const roleNames: Record<string, string> = {
|
||||
admin: '管理员',
|
||||
operator: '操作员',
|
||||
viewer: '查看者',
|
||||
farmer: '养殖员',
|
||||
};
|
||||
|
||||
export const BasicLayout = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const {
|
||||
token: { colorBgContainer, borderRadiusLG },
|
||||
} = theme.useToken();
|
||||
const [pwdOpen, setPwdOpen] = useState(false);
|
||||
const [pwdForm] = Form.useForm<{ oldPassword: string; newPassword: string; confirm: string }>();
|
||||
const [pwdLoading, setPwdLoading] = useState(false);
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
const v = await pwdForm.validateFields();
|
||||
if (v.newPassword !== v.confirm) {
|
||||
message.error('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
setPwdLoading(true);
|
||||
try {
|
||||
await authService.changePassword({ oldPassword: v.oldPassword, newPassword: v.newPassword });
|
||||
message.success('密码修改成功,请重新登录');
|
||||
setPwdOpen(false);
|
||||
pwdForm.resetFields();
|
||||
await authService.logout();
|
||||
navigate('/login');
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.error || '密码修改失败';
|
||||
message.error(typeof msg === 'string' ? msg : '密码修改失败');
|
||||
} finally {
|
||||
setPwdLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ProLayout
|
||||
title="蚕房环境监控"
|
||||
layout="mix"
|
||||
navTheme="light"
|
||||
contentStyle={{ margin: 0, padding: 0 }}
|
||||
menuItemRender={(item, dom) => (
|
||||
<div
|
||||
onClick={() => item.path && navigate(item.path)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{dom}
|
||||
</div>
|
||||
)}
|
||||
menuDataRender={() => menuData.filter((m) => authService.hasPermission(m.permission))}
|
||||
location={{ pathname: location.pathname }}
|
||||
headerTitleRender={(logo, title) => (
|
||||
<a onClick={() => navigate('/dashboard')}>
|
||||
<Space>{logo}{title}</Space>
|
||||
</a>
|
||||
)}
|
||||
avatarProps={{
|
||||
icon: <UserOutlined />,
|
||||
size: 'small',
|
||||
title: (() => {
|
||||
const user = authService.getUser();
|
||||
return user ? `${user.username}(${roleNames[user.role ?? ''] ?? user.role})` : 'admin';
|
||||
})(),
|
||||
render: (_, dom) => (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'changePassword', icon: <KeyOutlined />, label: '修改密码' },
|
||||
{ type: 'divider' },
|
||||
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录' },
|
||||
],
|
||||
onClick: async ({ key }) => {
|
||||
if (key === 'logout') {
|
||||
await authService.logout();
|
||||
navigate('/login');
|
||||
} else if (key === 'changePassword') {
|
||||
pwdForm.resetFields();
|
||||
setPwdOpen(true);
|
||||
}
|
||||
},
|
||||
}}
|
||||
>
|
||||
{dom}
|
||||
</Dropdown>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
margin: 16,
|
||||
padding: 16,
|
||||
background: colorBgContainer,
|
||||
borderRadius: borderRadiusLG,
|
||||
minHeight: 280,
|
||||
}}
|
||||
>
|
||||
<Outlet />
|
||||
</div>
|
||||
<Modal
|
||||
title="修改密码"
|
||||
open={pwdOpen}
|
||||
onCancel={() => setPwdOpen(false)}
|
||||
onOk={handleChangePassword}
|
||||
confirmLoading={pwdLoading}
|
||||
okText="提交"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form form={pwdForm} layout="vertical">
|
||||
<Form.Item label="原密码" name="oldPassword" rules={[{ required: true, message: '请输入原密码' }]}>
|
||||
<Input.Password autoComplete="current-password" placeholder="请输入原密码" />
|
||||
</Form.Item>
|
||||
<Form.Item label="新密码" name="newPassword" rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '至少 6 位' }]}>
|
||||
<Input.Password autoComplete="new-password" placeholder="至少 6 位" />
|
||||
</Form.Item>
|
||||
<Form.Item label="确认新密码" name="confirm" rules={[{ required: true, message: '请再次输入新密码' }]}>
|
||||
<Input.Password autoComplete="new-password" placeholder="再次输入新密码" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</ProLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { ConfigProvider, App as AntdApp, theme as antdTheme } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { router } from './router';
|
||||
import './index.css';
|
||||
|
||||
export default function Root() {
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
algorithm: antdTheme.defaultAlgorithm,
|
||||
token: { colorPrimary: '#1677ff' },
|
||||
}}
|
||||
>
|
||||
<AntdApp>
|
||||
<RouterProvider router={router} />
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<Root />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Button, Tag, message } from 'antd';
|
||||
import { CheckOutlined } from '@ant-design/icons';
|
||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import { useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { getAlertClip, listAlerts, markAlertRead, markAllRead, type Alert } from '../dal/alert';
|
||||
|
||||
const LEVEL_MAP: Record<string, { color: string; text: string }> = {
|
||||
info: { color: 'blue', text: '提示' },
|
||||
warn: { color: 'gold', text: '警告' },
|
||||
danger: { color: 'red', text: '严重' },
|
||||
'1': { color: 'blue', text: '一级' },
|
||||
'2': { color: 'gold', text: '二级' },
|
||||
'3': { color: 'orange', text: '三级' },
|
||||
'4': { color: 'red', text: '四级' },
|
||||
'5': { color: 'red', text: '五级' },
|
||||
};
|
||||
|
||||
const levelOf = (alert: Alert) => alert.level || alert.severity || 'info';
|
||||
const isRead = (alert: Alert) => alert.read ?? alert.acknowledged ?? false;
|
||||
|
||||
export default function AlertPage() {
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
const columns: ProColumns<Alert>[] = [
|
||||
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
|
||||
{
|
||||
title: '等级',
|
||||
dataIndex: 'level',
|
||||
valueType: 'select',
|
||||
valueEnum: {
|
||||
info: { text: '提示', status: 'Default' },
|
||||
warn: { text: '警告', status: 'Warning' },
|
||||
danger: { text: '严重', status: 'Error' },
|
||||
},
|
||||
render: (_, r) => {
|
||||
const l = LEVEL_MAP[levelOf(r)] || LEVEL_MAP.info;
|
||||
return <Tag color={l.color}>{l.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '标题', dataIndex: 'title' },
|
||||
{ title: '内容', dataIndex: 'message', search: false, ellipsis: true, render: (_, r) => r.message || r.content },
|
||||
{ title: '设备', dataIndex: 'deviceKey', search: false },
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'triggeredAt',
|
||||
search: false,
|
||||
valueType: 'dateTime',
|
||||
render: (_, r) => dayjs(r.triggeredAt || r.createdAt).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'acknowledged',
|
||||
search: false,
|
||||
render: (_, r) =>
|
||||
isRead(r) ? <Tag>已读</Tag> : <Tag color="red">未读</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
valueType: 'option',
|
||||
render: (_, r) => [
|
||||
!isRead(r) && (
|
||||
<a
|
||||
key="ack"
|
||||
onClick={async () => {
|
||||
await markAlertRead(r.id);
|
||||
message.success('已标记为已读');
|
||||
setTick((v) => v + 1);
|
||||
}}
|
||||
>
|
||||
标为已读
|
||||
</a>
|
||||
),
|
||||
<a
|
||||
key="clip"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const clip = await getAlertClip(r.id);
|
||||
message.info(clip.playbackUrl ? `告警视频:${clip.playbackUrl}` : '暂无告警视频地址');
|
||||
} catch {
|
||||
message.info('暂无告警视频');
|
||||
}
|
||||
}}
|
||||
>
|
||||
告警视频
|
||||
</a>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ProTable<Alert>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
request={async (p) => {
|
||||
const res = await listAlerts(p);
|
||||
return { data: res.items, total: res.total, success: true };
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button
|
||||
key="all"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={async () => {
|
||||
await markAllRead();
|
||||
message.success('全部已读');
|
||||
setTick((v) => v + 1);
|
||||
}}
|
||||
>
|
||||
全部标为已读
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, Col, Row, Switch, Tag, Button, Space, message, Spin, Empty, Statistic } from 'antd';
|
||||
import { ApiOutlined, ReloadOutlined, InfoCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { get, post } from '../api/http';
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
deviceKey?: string;
|
||||
name: string;
|
||||
kind?: string;
|
||||
onlineStatus?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface MetricRecord {
|
||||
metric: string;
|
||||
value: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
const Control = () => {
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [latestData, setLatestData] = useState<Record<string, MetricRecord[]>>({});
|
||||
const [sending, setSending] = useState<string>('');
|
||||
// 本地开关状态,提供即时视觉反馈
|
||||
const [switchOverride, setSwitchOverride] = useState<Record<string, boolean>>({});
|
||||
|
||||
const fetchDevices = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await get<Device[] | { items: Device[] }>('/devices', { params: { kind: 'actuator' } });
|
||||
const items = Array.isArray(data) ? data : data.items ?? [];
|
||||
setDevices(items);
|
||||
// 拉取每个设备的最新数据
|
||||
for (const d of items) {
|
||||
const key = d.deviceKey || d.id;
|
||||
try {
|
||||
const metrics = await get<MetricRecord[]>(`/telemetry/${key}/latest`, { silent: true });
|
||||
setLatestData((prev) => ({ ...prev, [key]: Array.isArray(metrics) ? metrics : [] }));
|
||||
} catch {}
|
||||
}
|
||||
} catch {
|
||||
message.error('加载设备列表失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevices();
|
||||
const timer = setInterval(fetchDevices, 15000);
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchDevices]);
|
||||
|
||||
const sendCommand = async (device: Device, action: 'on' | 'off' | 'info' | 'statistic') => {
|
||||
const key = device.deviceKey || device.id;
|
||||
setSending(device.id + action);
|
||||
// 即时更新本地开关状态
|
||||
if (action === 'on') setSwitchOverride((prev) => ({ ...prev, [key]: true }));
|
||||
if (action === 'off') setSwitchOverride((prev) => ({ ...prev, [key]: false }));
|
||||
try {
|
||||
await post(`/devices/${device.id}/plug/${action}`);
|
||||
const label = action === 'on' ? '通电' : action === 'off' ? '断电' : action === 'info' ? '查询信息' : '查询电量';
|
||||
message.success(`${label}指令已发送`);
|
||||
setTimeout(fetchDevices, 3000);
|
||||
} catch {
|
||||
message.error('指令发送失败');
|
||||
// 失败时恢复本地状态
|
||||
setSwitchOverride((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setSending('');
|
||||
};
|
||||
|
||||
const getMetric = (deviceKey: string, metric: string) => {
|
||||
const records = latestData[deviceKey];
|
||||
if (!records) return undefined;
|
||||
const rec = records.find((r) => r.metric === metric);
|
||||
return rec ? Number(rec.value) : undefined;
|
||||
};
|
||||
|
||||
const isOnline = (d: Device) => d.onlineStatus === 'online' || d.status === 'online';
|
||||
|
||||
if (!loading && devices.length === 0) {
|
||||
return <Empty description="暂无控制器设备" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<Row gutter={[16, 16]}>
|
||||
{devices.map((d) => {
|
||||
const key = d.deviceKey || d.id;
|
||||
const online = isOnline(d);
|
||||
const metricKey = getMetric(key, 'key');
|
||||
// 优先使用本地覆盖状态,否则用遥测数据
|
||||
const powerOn = key in switchOverride ? switchOverride[key] : metricKey === 1;
|
||||
return (
|
||||
<Col key={d.id} span={8}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<ApiOutlined />
|
||||
{d.name}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Tag color={online ? 'green' : 'default'}>{online ? '在线' : '离线'}</Tag>
|
||||
}
|
||||
>
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<ThunderboltOutlined style={{ fontSize: 48, color: powerOn ? '#1890ff' : '#ccc' }} />
|
||||
</div>
|
||||
<Switch
|
||||
checked={powerOn}
|
||||
checkedChildren="通电"
|
||||
unCheckedChildren="断电"
|
||||
disabled={!online}
|
||||
loading={sending === d.id + 'on' || sending === d.id + 'off'}
|
||||
onChange={(checked) => sendCommand(d, checked ? 'on' : 'off')}
|
||||
style={{ transform: 'scale(1.2)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Statistic title="电压" value={online ? (getMetric(key, 'voltage') ?? '--') : '--'} precision={1} suffix="V" />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="功率" value={online ? (getMetric(key, 'power') ?? '--') : '--'} precision={2} suffix="W" />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic title="电量" value={online ? (getMetric(key, 'energy') ?? '--') : '--'} precision={3} suffix="kWh" />
|
||||
</Col>
|
||||
</Row>
|
||||
{!online && (
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#999', textAlign: 'center' }}>
|
||||
设备离线,数据为历史缓存,已隐藏
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Space style={{ marginTop: 16, width: '100%', justifyContent: 'center' }}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<InfoCircleOutlined />}
|
||||
disabled={!online}
|
||||
loading={sending === d.id + 'info'}
|
||||
onClick={() => sendCommand(d, 'info')}
|
||||
>
|
||||
查询信息
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
disabled={!online}
|
||||
loading={sending === d.id + 'statistic'}
|
||||
onClick={() => sendCommand(d, 'statistic')}
|
||||
>
|
||||
查询电量
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Spin>
|
||||
);
|
||||
};
|
||||
|
||||
export default Control;
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Col, Row, Tag, Spin } from 'antd';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { StatCard } from '../components/StatCard';
|
||||
import {
|
||||
fetchDeviceSummary,
|
||||
fetchRealtime,
|
||||
fetchRooms,
|
||||
fetchTrend,
|
||||
type RealtimeMetric,
|
||||
type Room,
|
||||
type TrendPoint,
|
||||
} from '../dal/dashboard';
|
||||
|
||||
const METRIC_UNIT: Record<string, string> = {
|
||||
temp: '℃',
|
||||
humidity: '%',
|
||||
co2: 'ppm',
|
||||
light: 'lux',
|
||||
ph: '',
|
||||
};
|
||||
|
||||
const mockRooms: Room[] = [
|
||||
{ id: 'room-01', name: '主蚕房1号', status: 'active' },
|
||||
{ id: 'room-02', name: '主蚕房2号', status: 'active' },
|
||||
{ id: 'room-03', name: '育幼蚕房', status: 'active' },
|
||||
{ id: 'room-04', name: '备用蚕房', status: 'inactive' },
|
||||
];
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [metrics, setMetrics] = useState<RealtimeMetric[]>([]);
|
||||
const [trend, setTrend] = useState<TrendPoint[]>([]);
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [summary, setSummary] = useState({ total: 0, online: 0, offline: 0, alarm: 0, running: 0, inactive: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [m, t, r, s] = await Promise.all([
|
||||
fetchRealtime().catch(() => []),
|
||||
fetchTrend(24).catch(() => []),
|
||||
fetchRooms().catch(() => []),
|
||||
fetchDeviceSummary().catch(() => ({ total: 0, online: 0, offline: 0, alarm: 0, running: 0, inactive: 0 })),
|
||||
]);
|
||||
setMetrics(m);
|
||||
setTrend(t);
|
||||
setRooms(r);
|
||||
setSummary(s);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
const timer = setInterval(load, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const metricCards = (metrics.length
|
||||
? metrics
|
||||
: [
|
||||
{ key: 'temp', name: '温度', value: 24.6, status: 'normal' },
|
||||
{ key: 'humidity', name: '湿度', value: 72, status: 'warn' },
|
||||
{ key: 'co2', name: 'CO₂', value: 480, status: 'normal' },
|
||||
{ key: 'light', name: '光照', value: 320, status: 'normal' },
|
||||
]
|
||||
).map((m) => ({
|
||||
title: m.name,
|
||||
value: m.value,
|
||||
suffix: 'unit' in m ? m.unit : METRIC_UNIT[m.key as string] || '',
|
||||
color: m.status === 'normal' ? '#16a34a' : m.status === 'warn' ? '#d97706' : '#dc2626',
|
||||
}));
|
||||
|
||||
const chartData = trend.length ? trend : (() => {
|
||||
const arr: TrendPoint[] = [];
|
||||
const now = Date.now();
|
||||
for (let i = 24; i >= 0; i--) {
|
||||
arr.push({
|
||||
time: new Date(now - i * 3600 * 1000).toLocaleTimeString().slice(0, 5),
|
||||
temp: +(22 + Math.random() * 6).toFixed(1),
|
||||
humidity: +(65 + Math.random() * 15).toFixed(0),
|
||||
co2: +(420 + Math.random() * 80).toFixed(0),
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
})();
|
||||
|
||||
const option = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: { data: ['温度', '湿度', 'CO₂'] },
|
||||
grid: { left: 40, right: 20, top: 40, bottom: 40 },
|
||||
xAxis: { type: 'category', data: chartData.map((d) => d.time) },
|
||||
yAxis: [
|
||||
{ type: 'value', name: '温度/湿度', position: 'left' },
|
||||
{ type: 'value', name: 'CO₂(ppm)', position: 'right' },
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '温度',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: chartData.map((d) => d.temp),
|
||||
lineStyle: { color: '#f5222d' },
|
||||
},
|
||||
{
|
||||
name: '湿度',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
data: chartData.map((d) => d.humidity),
|
||||
lineStyle: { color: '#1677ff' },
|
||||
},
|
||||
{
|
||||
name: 'CO₂',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
yAxisIndex: 1,
|
||||
data: chartData.map((d) => d.co2),
|
||||
lineStyle: { color: '#722ed1' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const roomList = rooms.length ? rooms : mockRooms;
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<StatCard items={metricCards} />
|
||||
<Row gutter={[16, 16]} style={{ marginTop: 16 }}>
|
||||
<Col xs={24} lg={16}>
|
||||
<Card title="最近24小时趋势曲线">
|
||||
<ReactECharts option={option} style={{ height: 320 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card title="蚕房状态概览">
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
运行中 <span style={{ color: '#16a34a', fontWeight: 600 }}>{summary.running || roomList.filter((d) => d.status !== 'inactive').length}</span> /
|
||||
停用 <span style={{ color: '#888', fontWeight: 600 }}>{summary.inactive || roomList.filter((d) => d.status === 'inactive').length}</span> /
|
||||
告警 <span style={{ color: '#dc2626', fontWeight: 600 }}>{summary.alarm}</span> /
|
||||
共 {summary.total || roomList.length} 间
|
||||
</div>
|
||||
<Row gutter={[8, 8]}>
|
||||
{roomList.map((room) => {
|
||||
const running = room.status !== 'inactive';
|
||||
return (
|
||||
<Col span={12} key={room.id}>
|
||||
<Card size="small" hoverable>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>{room.name}</span>
|
||||
<Tag color={running ? 'green' : 'default'}>{running ? '运行中' : '停用'}</Tag>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Spin>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Button, Form, Input, Modal, Popconfirm, Select, Tag, Space, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import { createDevice, deleteDevice, listDevices, sendDeviceCommand, updateDevice, type Device } from '../dal/device';
|
||||
import { fetchRooms } from '../dal/dashboard';
|
||||
|
||||
const COMMANDS = [
|
||||
{ label: '开启通风', value: 'fan_on' },
|
||||
{ label: '关闭通风', value: 'fan_off' },
|
||||
{ label: '开启加湿', value: 'humid_on' },
|
||||
{ label: '关闭加湿', value: 'humid_off' },
|
||||
{ label: '开灯', value: 'light_on' },
|
||||
{ label: '关灯', value: 'light_off' },
|
||||
];
|
||||
|
||||
export default function DevicePage() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm<Partial<Device>>();
|
||||
const [editing, setEditing] = useState<Device | null>(null);
|
||||
const [rooms, setRooms] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
useEffect(() => { fetchRooms().then(setRooms).catch(() => {}); }, []);
|
||||
|
||||
const columns: ProColumns<Device>[] = [
|
||||
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
|
||||
{ title: '设备标识', dataIndex: 'deviceKey' },
|
||||
{ title: '设备名称', dataIndex: 'name' },
|
||||
{
|
||||
title: '设备类型',
|
||||
dataIndex: 'kind',
|
||||
render: (_, r) => {
|
||||
const kind = r.kind || r.type || '';
|
||||
const map: Record<string, { label: string; color: string }> = {
|
||||
sensor: { label: '传感器', color: 'blue' },
|
||||
actuator: { label: '控制器', color: 'orange' },
|
||||
controller: { label: '控制器', color: 'orange' },
|
||||
gateway: { label: '网关', color: 'purple' },
|
||||
};
|
||||
const item = map[kind] || { label: kind || '未知', color: 'default' };
|
||||
return <Tag color={item.color}>{item.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '所属蚕房', dataIndex: 'roomId', search: false, render: (_, r) => rooms.find(rm => rm.id === r.roomId)?.name || r.roomId || '-' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'onlineStatus',
|
||||
search: false,
|
||||
render: (_, r) => {
|
||||
const status = r.onlineStatus || r.status;
|
||||
return (
|
||||
<Tag color={status === 'online' ? 'green' : 'default'}>
|
||||
{status === 'online' ? '在线' : '离线'}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '最后在线', dataIndex: 'lastSeen', search: false, valueType: 'dateTime' },
|
||||
{
|
||||
title: '操作',
|
||||
valueType: 'option',
|
||||
render: (_, r) => [
|
||||
<Space key={`op-${r.id}`} size={4}>
|
||||
<a
|
||||
onClick={() => {
|
||||
setEditing(r);
|
||||
form.setFieldsValue({ ...r, kind: r.kind || r.type, roomId: r.roomId || r.houseId });
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</a>
|
||||
<Popconfirm
|
||||
title="确认删除?"
|
||||
onConfirm={async () => {
|
||||
await deleteDevice(r.id);
|
||||
message.success('已删除');
|
||||
}}
|
||||
>
|
||||
<a style={{ color: '#ff4d4f' }}>删除</a>
|
||||
</Popconfirm>
|
||||
</Space>,
|
||||
<Select
|
||||
key={`cmd-${r.id}`}
|
||||
style={{ width: 120, marginLeft: 8 }}
|
||||
size="small"
|
||||
placeholder="远程控制"
|
||||
options={COMMANDS}
|
||||
onChange={async (v) => {
|
||||
try {
|
||||
await sendDeviceCommand(r.deviceKey || r.id, v);
|
||||
message.success('指令已下发');
|
||||
} catch {
|
||||
message.error('指令下发失败');
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProTable<Device>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
request={async (p) => {
|
||||
const res = await listDevices(p);
|
||||
return { data: res.items, total: res.total, success: true };
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button
|
||||
key="new"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
添加设备
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? '编辑设备' : '添加设备'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await form.validateFields();
|
||||
if (editing) await updateDevice(editing.id, v);
|
||||
else await createDevice(v);
|
||||
message.success('保存成功');
|
||||
setModalOpen(false);
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="设备标识" name="deviceKey" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 SILK-001" />
|
||||
</Form.Item>
|
||||
<Form.Item label="设备名称" name="name" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="类型" name="kind" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: '传感器', value: 'sensor' },
|
||||
{ label: '控制器', value: 'actuator' },
|
||||
{ label: '网关', value: 'gateway' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="所属蚕房" name="roomId" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择蚕房" options={rooms.map(rm => ({ label: rm.name, value: rm.id }))} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, Col, Row, Statistic, Select, Spin } from 'antd';
|
||||
import ThunderboltOutlined from '@ant-design/icons/ThunderboltOutlined';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { get } from '../api/http';
|
||||
|
||||
interface MetricRecord {
|
||||
metric: string;
|
||||
value: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
deviceKey?: string;
|
||||
name: string;
|
||||
kind?: string;
|
||||
onlineStatus?: string;
|
||||
}
|
||||
|
||||
const METRIC_LABELS: Record<string, { label: string; unit: string; color: string }> = {
|
||||
voltage: { label: '电压', unit: 'V', color: '#1890ff' },
|
||||
current: { label: '电流', unit: 'A', color: '#52c41a' },
|
||||
power: { label: '功率', unit: 'W', color: '#faad14' },
|
||||
energy: { label: '电量', unit: 'kWh', color: '#eb2f96' },
|
||||
key: { label: '通断', unit: '', color: '#722ed1' },
|
||||
};
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ label: '最近 1 小时', value: 1 },
|
||||
{ label: '最近 6 小时', value: 6 },
|
||||
{ label: '最近 24 小时', value: 24 },
|
||||
];
|
||||
|
||||
const Energy = () => {
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [selectedDevice, setSelectedDevice] = useState<string>('');
|
||||
const [latest, setLatest] = useState<MetricRecord[]>([]);
|
||||
const [history, setHistory] = useState<Record<string, { time: string; value: number }[]>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hours, setHours] = useState(1);
|
||||
|
||||
// 加载控制器设备列表
|
||||
useEffect(() => {
|
||||
get<Device[] | { items: Device[] }>('/devices', { params: { kind: 'actuator' }, silent: true })
|
||||
.then((data) => {
|
||||
const items = Array.isArray(data) ? data : data.items ?? [];
|
||||
setDevices(items);
|
||||
if (items.length > 0 && !selectedDevice) {
|
||||
setSelectedDevice(items[0].deviceKey || items[0].id);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 加载最新数据
|
||||
const fetchLatest = useCallback(async () => {
|
||||
if (!selectedDevice) return;
|
||||
try {
|
||||
const data = await get<MetricRecord[]>(`/telemetry/${selectedDevice}/latest`, { silent: true });
|
||||
setLatest(Array.isArray(data) ? data : []);
|
||||
} catch {}
|
||||
}, [selectedDevice]);
|
||||
|
||||
// 加载历史图表数据
|
||||
const fetchHistory = useCallback(async () => {
|
||||
if (!selectedDevice) return;
|
||||
setLoading(true);
|
||||
const to = new Date();
|
||||
const from = new Date(to.getTime() - hours * 3600 * 1000);
|
||||
const metrics = ['voltage', 'current', 'power', 'energy'];
|
||||
const results = await Promise.all(
|
||||
metrics.map((m) =>
|
||||
get<{ time: string; value: number }[]>(`/telemetry/${selectedDevice}/${m}/history`, {
|
||||
params: { from: from.toISOString(), to: to.toISOString(), bucketMin: hours > 6 ? 10 : 2 },
|
||||
silent: true,
|
||||
}).catch(() => []),
|
||||
),
|
||||
);
|
||||
const map: Record<string, { time: string; value: number }[]> = {};
|
||||
metrics.forEach((m, i) => {
|
||||
map[m] = results[i] || [];
|
||||
});
|
||||
setHistory(map);
|
||||
setLoading(false);
|
||||
}, [selectedDevice, hours]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLatest();
|
||||
fetchHistory();
|
||||
const timer = setInterval(fetchLatest, 15000);
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchLatest, fetchHistory]);
|
||||
|
||||
const getMetricValue = (metric: string) => {
|
||||
const rec = latest.find((r) => r.metric === metric);
|
||||
return rec ? Number(rec.value) : undefined;
|
||||
};
|
||||
|
||||
const buildChartOption = (metric: string) => {
|
||||
const data = history[metric] || [];
|
||||
const config = METRIC_LABELS[metric];
|
||||
return {
|
||||
tooltip: { trigger: 'axis', formatter: (p: any) => `${p[0].name}<br/>${config.label}: ${p[0].value} ${config.unit}` },
|
||||
xAxis: { type: 'category', data: data.map((d) => new Date(d.time).toLocaleTimeString().slice(0, 5)) },
|
||||
yAxis: { type: 'value', name: config.unit },
|
||||
series: [{ data: data.map((d) => Number(d.value)), type: 'line', smooth: true, areaStyle: { opacity: 0.1 }, itemStyle: { color: config.color } }],
|
||||
grid: { left: 50, right: 20, top: 30, bottom: 30 },
|
||||
};
|
||||
};
|
||||
|
||||
const selected = devices.find((d) => (d.deviceKey || d.id) === selectedDevice);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col>
|
||||
<Select
|
||||
style={{ width: 200 }}
|
||||
placeholder="选择设备"
|
||||
value={selectedDevice || undefined}
|
||||
onChange={setSelectedDevice}
|
||||
options={devices.map((d) => ({ label: d.name, value: d.deviceKey || d.id }))}
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Select style={{ width: 150 }} value={hours} onChange={setHours} options={TIME_RANGES} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="电压"
|
||||
value={getMetricValue('voltage') ?? '--'}
|
||||
precision={1}
|
||||
suffix="V"
|
||||
prefix={<ThunderboltOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="电流" value={getMetricValue('current') ?? '--'} precision={3} suffix="A" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="功率" value={getMetricValue('power') ?? '--'} precision={2} suffix="W" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic title="累计电量" value={getMetricValue('energy') ?? '--'} precision={4} suffix="kWh" />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{selected && (
|
||||
<div style={{ marginBottom: 16, color: '#888' }}>
|
||||
设备: {selected.name} | 状态: {selected.onlineStatus === 'online' ? '在线' : '离线'} | 通断:{' '}
|
||||
{getMetricValue('key') === 1 ? '通电' : '断电'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card title="电压趋势">
|
||||
<ReactECharts option={buildChartOption('voltage')} style={{ height: 250 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="电流趋势">
|
||||
<ReactECharts option={buildChartOption('current')} style={{ height: 250 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||
<Col span={12}>
|
||||
<Card title="功率趋势">
|
||||
<ReactECharts option={buildChartOption('power')} style={{ height: 250 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="电量累计">
|
||||
<ReactECharts option={buildChartOption('energy')} style={{ height: 250 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Spin>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Energy;
|
||||
@@ -0,0 +1,717 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { ComponentType } from 'react';
|
||||
import { Card, Col, Row, Tag, Button, Space, message, Spin, Empty, InputNumber, Modal, Alert, Result, Popconfirm } from 'antd';
|
||||
import {
|
||||
ApiOutlined,
|
||||
BulbOutlined,
|
||||
SendOutlined,
|
||||
StopOutlined,
|
||||
DeleteOutlined,
|
||||
InfoCircleOutlined,
|
||||
PoweroffOutlined,
|
||||
ThunderboltOutlined,
|
||||
CloudOutlined,
|
||||
FireOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { get, post } from '../api/http';
|
||||
|
||||
interface Device {
|
||||
id: string;
|
||||
deviceKey?: string;
|
||||
name: string;
|
||||
onlineStatus?: string;
|
||||
}
|
||||
|
||||
interface IRResult {
|
||||
hasResult: boolean;
|
||||
result?: {
|
||||
action: string;
|
||||
success: boolean;
|
||||
no: number;
|
||||
timestamp: string;
|
||||
};
|
||||
learnedCodes?: number[];
|
||||
}
|
||||
|
||||
// 空调遥控按钮配置(顺序学习)
|
||||
const AIRCON_BUTTONS = [
|
||||
{ key: 'power-off', label: '关机', no: 1, icon: 'PoweroffOutlined' },
|
||||
{ key: 'power-on', label: '开机', no: 2, icon: 'PoweroffOutlined' },
|
||||
{ key: 'cooling', label: '制冷', no: 6, icon: 'CloudOutlined' },
|
||||
{ key: 'heating', label: '制热', no: 7, icon: 'FireOutlined' },
|
||||
{ key: 'dehumid', label: '除湿', no: 5, icon: 'ThunderboltOutlined' },
|
||||
];
|
||||
|
||||
// 温度按钮配置(单独学习)
|
||||
const TEMP_BUTTONS = [
|
||||
{ key: 'temp-23', label: '23度', no: 3 },
|
||||
{ key: 'temp-25', label: '25度', no: 4 },
|
||||
{ key: 'temp-27', label: '27度', no: 8 },
|
||||
{ key: 'temp-29', label: '29度', no: 9 },
|
||||
];
|
||||
|
||||
// 图标名 -> 图标组件映射
|
||||
const ICON_MAP: Record<string, ComponentType> = {
|
||||
PoweroffOutlined,
|
||||
ThunderboltOutlined,
|
||||
CloudOutlined,
|
||||
FireOutlined,
|
||||
};
|
||||
|
||||
// 顺序学习的编号顺序(与 AIRCON_BUTTONS 的 no 字段一致)
|
||||
const LEARN_SEQUENCE = [1, 2, 6, 7, 5];
|
||||
|
||||
// 所有可学习的按钮(用于查找按钮标签)
|
||||
const ALL_BUTTONS = [...AIRCON_BUTTONS, ...TEMP_BUTTONS];
|
||||
|
||||
// 闪烁动画样式(正在学习的按钮高亮闪烁)
|
||||
const blinkStyle = `
|
||||
@keyframes ir-blink {
|
||||
0%, 100% { background-color: #faad14; }
|
||||
50% { background-color: #fffbe6; }
|
||||
}
|
||||
.ir-blinking {
|
||||
animation: ir-blink 0.8s infinite;
|
||||
}
|
||||
`;
|
||||
|
||||
const IRControl = () => {
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sending, setSending] = useState('');
|
||||
const [learnModal, setLearnModal] = useState<{ open: boolean; deviceId: string; no: number }>({ open: false, deviceId: '', no: 100 });
|
||||
const [emitNo, setEmitNo] = useState<Record<string, number>>({});
|
||||
const [irResults, setIrResults] = useState<Record<string, { action: string; success: boolean; no: number } | undefined>>({});
|
||||
const [deviceInfo, setDeviceInfo] = useState<Record<string, { signal?: number; lastSeen?: string; learnedCodes?: number[] } | undefined>>({});
|
||||
// 空调顺序学习状态:记录当前正在学习的设备与编号
|
||||
const [learningDevice, setLearningDevice] = useState<{ deviceId: string; currentNo: number } | null>(null);
|
||||
// 使用 ref 跟踪 learningDevice,避免轮询中的闭包陷阱
|
||||
const learningDeviceRef = useRef<{ deviceId: string; currentNo: number } | null>(null);
|
||||
// 空调按钮发射 loading(key = deviceId + button.key)
|
||||
const [airconSending, setAirconSending] = useState<string>('');
|
||||
|
||||
// 同步更新 state 与 ref
|
||||
const updateLearningDevice = (val: { deviceId: string; currentNo: number } | null) => {
|
||||
learningDeviceRef.current = val;
|
||||
setLearningDevice(val);
|
||||
};
|
||||
|
||||
const fetchDevices = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await get<Device[] | { items: Device[] }>('/devices');
|
||||
const all = Array.isArray(data) ? data : data.items ?? [];
|
||||
// 只显示红外控制器(名称包含"红外")
|
||||
const items = all.filter((d) => d.name.includes('红外'));
|
||||
setDevices(items);
|
||||
} catch {
|
||||
message.error('加载设备列表失败');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevices();
|
||||
}, [fetchDevices]);
|
||||
|
||||
const actionLabel = (action: string) => {
|
||||
const labels: Record<string, string> = { learn: '学习', emit: '发射', learnCancel: '取消学习', erase: '擦除' };
|
||||
return labels[action] || action;
|
||||
};
|
||||
|
||||
// 擦除结果轮询:erase 命令可能不返回响应,短时间轮询确认
|
||||
const pollEraseResult = async (deviceId: string) => {
|
||||
const startTime = Date.now();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
try {
|
||||
const data = await get<IRResult>(`/devices/${deviceId}/ir/status`, { silent: true });
|
||||
if (data.hasResult && data.result) {
|
||||
const resultTime = new Date(data.result.timestamp).getTime();
|
||||
if (resultTime >= startTime && data.result.action === 'erase') {
|
||||
if (data.result.success) {
|
||||
message.success('擦除成功');
|
||||
} else {
|
||||
message.warning('设备已进入擦除模式,本地列表已清空');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// 超时:设备可能不返回 erase 响应,本地列表已清空
|
||||
message.info('设备未响应擦除结果,本地红外码列表已清空');
|
||||
};
|
||||
|
||||
// 轮询红外操作结果(原有功能:用于手动学习/发射/擦除)
|
||||
const pollIRResult = async (deviceId: string, action: string, no?: number) => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
try {
|
||||
const data = await get<IRResult>(`/devices/${deviceId}/ir/status`, { silent: true });
|
||||
if (data.hasResult && data.result) {
|
||||
// 检查是否是本次操作的结果(action 匹配,且时间戳在最近 30 秒内)
|
||||
const resultTime = new Date(data.result.timestamp).getTime();
|
||||
if (Date.now() - resultTime < 30000) {
|
||||
if (data.result.action === action && (no === undefined || data.result.no === no || data.result.no === 0)) {
|
||||
// success=false 表示设备已进入操作模式(如进入学习/擦除模式),不是真正失败,继续轮询
|
||||
if (!data.result.success) {
|
||||
continue;
|
||||
}
|
||||
setIrResults((prev) => ({ ...prev, [deviceId]: data.result }));
|
||||
// 擦除成功后清空本地已学习红外码列表
|
||||
if (action === 'erase') {
|
||||
setDeviceInfo((prev) => {
|
||||
const curInfo = prev?.[deviceId];
|
||||
return { ...prev, [deviceId]: { ...curInfo, learnedCodes: [] } };
|
||||
});
|
||||
}
|
||||
message.success(`${actionLabel(data.result.action)}成功${data.result.no ? `(编号 ${data.result.no})` : ''}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
message.warning(`${actionLabel(action)}等待结果超时,请重试`);
|
||||
};
|
||||
|
||||
// 轮询设备信息(info 响应后 signal 会被入库)
|
||||
const pollDeviceInfo = async (device: Device) => {
|
||||
const key = device.deviceKey || device.id;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
try {
|
||||
// 查询遥测数据(signal)
|
||||
const telemetry = await get<{ metric: string; value: number; timestamp: string }[]>(
|
||||
`/telemetry/${key}/latest`, { silent: true }
|
||||
);
|
||||
// 查询已学习的红外码
|
||||
const irStatus = await get<{ hasResult: boolean; learnedCodes?: number[] }>(
|
||||
`/devices/${device.id}/ir/status`, { silent: true }
|
||||
);
|
||||
const signalRec = Array.isArray(telemetry) ? telemetry.find((r) => r.metric === 'signal') : undefined;
|
||||
if (signalRec || irStatus.learnedCodes) {
|
||||
setDeviceInfo((prev) => ({
|
||||
...prev,
|
||||
[device.id]: {
|
||||
signal: signalRec?.value,
|
||||
lastSeen: signalRec?.timestamp,
|
||||
learnedCodes: irStatus.learnedCodes || [],
|
||||
},
|
||||
}));
|
||||
if (signalRec) {
|
||||
const codes = irStatus.learnedCodes || [];
|
||||
message.success(`设备信息已更新:信号 ${signalRec.value},已学习红外码 ${codes.length} 个`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
message.warning('设备未响应,可能已离线');
|
||||
};
|
||||
|
||||
// 原有通用指令发送(左侧按钮使用)
|
||||
const sendIR = async (device: Device, action: 'learn' | 'emit' | 'cancel' | 'erase' | 'info', no?: number) => {
|
||||
const key = device.id + action + (no ?? '');
|
||||
setSending(key);
|
||||
// 清除之前的结果
|
||||
setIrResults((prev) => ({ ...prev, [device.id]: undefined }));
|
||||
try {
|
||||
if (action === 'learn' && no !== undefined) {
|
||||
await post(`/devices/${device.id}/ir/learn`, { no });
|
||||
message.success(`学习指令已发送(编号 ${no}),请将遥控器对准设备按键`);
|
||||
setLearnModal({ open: true, deviceId: device.id, no });
|
||||
pollIRResult(device.id, 'learn', no);
|
||||
} else if (action === 'emit' && no !== undefined) {
|
||||
await post(`/devices/${device.id}/ir/emit`, { no });
|
||||
message.success(`发射指令已发送(编号 ${no})`);
|
||||
pollIRResult(device.id, 'emit', no);
|
||||
} else if (action === 'cancel') {
|
||||
await post(`/devices/${device.id}/ir/cancel`);
|
||||
message.success('取消学习指令已发送');
|
||||
setLearnModal({ ...learnModal, open: false });
|
||||
// learnCancel 仅在学习模式下有响应,不轮询结果
|
||||
} else if (action === 'erase') {
|
||||
await post(`/devices/${device.id}/ir/erase`);
|
||||
// 立即清空本地已学习红外码列表
|
||||
setDeviceInfo((prev) => {
|
||||
const curInfo = prev?.[device.id];
|
||||
return { ...prev, [device.id]: { ...curInfo, learnedCodes: [] } };
|
||||
});
|
||||
message.success('擦除指令已发送,已清空本地红外码列表');
|
||||
// erase 可能像 learnCancel 一样不返回响应,短时间轮询确认
|
||||
pollEraseResult(device.id);
|
||||
} else if (action === 'info') {
|
||||
await post(`/devices/${device.id}/plug/info`);
|
||||
message.success('查询信息指令已发送,等待设备响应...');
|
||||
// 轮询遥测数据(info 响应中的 signal 会被入库)
|
||||
pollDeviceInfo(device);
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errMsg = err?.response?.data?.error || '指令发送失败';
|
||||
message.error(errMsg);
|
||||
}
|
||||
setSending('');
|
||||
};
|
||||
|
||||
// 空调按钮发射(右侧空调面板使用)
|
||||
const emitAircon = async (deviceId: string, button: typeof AIRCON_BUTTONS[number]) => {
|
||||
// 正在顺序学习中,不触发发射
|
||||
if (learningDeviceRef.current && learningDeviceRef.current.deviceId === deviceId) {
|
||||
return;
|
||||
}
|
||||
const key = deviceId + button.key;
|
||||
setAirconSending(key);
|
||||
try {
|
||||
await post(`/devices/${deviceId}/ir/emit`, { no: button.no });
|
||||
message.success(`【${button.label}】指令已发送`);
|
||||
} catch (err: any) {
|
||||
const errMsg = err?.response?.data?.error || `${button.label}指令发送失败`;
|
||||
message.error(errMsg);
|
||||
}
|
||||
setAirconSending('');
|
||||
};
|
||||
|
||||
// 顺序学习轮询:循环查询学习结果,成功后自动进入下一个编号
|
||||
// startTime 用于过滤旧结果,只接受该时间之后产生的学习结果
|
||||
const pollSequentialLearn = async (deviceId: string, no: number, startTime: number) => {
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
// 通过 ref 检查是否已被取消或已切换到下一个编号
|
||||
const cur = learningDeviceRef.current;
|
||||
if (!cur || cur.deviceId !== deviceId || cur.currentNo !== no) {
|
||||
return; // 已被取消或已切换
|
||||
}
|
||||
try {
|
||||
const data = await get<IRResult>(`/devices/${deviceId}/ir/status`, { silent: true });
|
||||
if (data.hasResult && data.result) {
|
||||
const resultTime = new Date(data.result.timestamp).getTime();
|
||||
// 只接受 startTime 之后的结果,避免读到上一个编号的旧结果
|
||||
// 设备学习成功时返回 no=0,失败时返回发送的编号,因此匹配条件需兼容 no=0
|
||||
if (resultTime >= startTime && data.result.action === 'learn' && (data.result.no === no || data.result.no === 0)) {
|
||||
const curButton = ALL_BUTTONS.find((b) => b.no === no);
|
||||
if (data.result.success) {
|
||||
// 学习成功,更新本地 learnedCodes
|
||||
setDeviceInfo((prev) => {
|
||||
const curInfo = prev?.[deviceId];
|
||||
const codes = curInfo?.learnedCodes ?? [];
|
||||
if (!codes.includes(no)) codes.push(no);
|
||||
return { ...prev, [deviceId]: { ...curInfo, learnedCodes: [...codes] } };
|
||||
});
|
||||
// 查找当前编号在学习序列中的位置,决定下一个学习的编号
|
||||
const seqIndex = LEARN_SEQUENCE.indexOf(no);
|
||||
const nextNo = seqIndex >= 0 && seqIndex + 1 < LEARN_SEQUENCE.length ? LEARN_SEQUENCE[seqIndex + 1] : null;
|
||||
if (nextNo !== null) {
|
||||
const nextStartTime = Date.now();
|
||||
await post(`/devices/${deviceId}/ir/learn`, { no: nextNo });
|
||||
updateLearningDevice({ deviceId, currentNo: nextNo });
|
||||
const nextLabel = ALL_BUTTONS.find((b) => b.no === nextNo)?.label;
|
||||
message.success(`【${curButton?.label}】学习成功,请按下遥控器的【${nextLabel}】键`);
|
||||
pollSequentialLearn(deviceId, nextNo, nextStartTime);
|
||||
} else {
|
||||
// 全部完成
|
||||
updateLearningDevice(null);
|
||||
message.success('全部学习完成');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// success=false 表示设备已进入学习模式,等待用户按遥控器,继续轮询
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// 超时:仅当仍处于当前学习状态时才提示
|
||||
const cur = learningDeviceRef.current;
|
||||
if (cur && cur.deviceId === deviceId && cur.currentNo === no) {
|
||||
updateLearningDevice(null);
|
||||
message.warning('学习超时,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 开始顺序学习:从学习序列的第一个编号开始
|
||||
const startSequentialLearn = async (deviceId: string) => {
|
||||
try {
|
||||
const firstNo = LEARN_SEQUENCE[0];
|
||||
const startTime = Date.now();
|
||||
await post(`/devices/${deviceId}/ir/learn`, { no: firstNo });
|
||||
updateLearningDevice({ deviceId, currentNo: firstNo });
|
||||
const firstLabel = ALL_BUTTONS.find((b) => b.no === firstNo)?.label;
|
||||
message.success(`开始学习:请按下遥控器的【${firstLabel}】键`);
|
||||
pollSequentialLearn(deviceId, firstNo, startTime);
|
||||
} catch (err: any) {
|
||||
const errMsg = err?.response?.data?.error || '学习指令发送失败';
|
||||
message.error(errMsg);
|
||||
}
|
||||
};
|
||||
|
||||
// 取消顺序学习
|
||||
const cancelSequentialLearn = async (deviceId: string) => {
|
||||
try {
|
||||
await post(`/devices/${deviceId}/ir/cancel`, {});
|
||||
} catch {}
|
||||
updateLearningDevice(null);
|
||||
message.success('已取消学习');
|
||||
};
|
||||
|
||||
// 温度按钮单独学习
|
||||
const learnSingleTemp = async (deviceId: string, no: number, label: string) => {
|
||||
// 如果正在顺序学习,不允许单独学习
|
||||
if (learningDeviceRef.current !== null) {
|
||||
message.warning('正在顺序学习中,请先取消');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
updateLearningDevice({ deviceId, currentNo: no });
|
||||
await post(`/devices/${deviceId}/ir/learn`, { no });
|
||||
message.success(`请按下遥控器的【${label}】键`);
|
||||
// 轮询单次学习结果
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
const cur = learningDeviceRef.current as { deviceId: string; currentNo: number } | null;
|
||||
if (!cur || cur.deviceId !== deviceId || cur.currentNo !== no) {
|
||||
return; // 已被取消
|
||||
}
|
||||
try {
|
||||
const data = await get<IRResult>(`/devices/${deviceId}/ir/status`, { silent: true });
|
||||
if (data.hasResult && data.result) {
|
||||
const resultTime = new Date(data.result.timestamp).getTime();
|
||||
if (resultTime >= startTime && data.result.action === 'learn' && (data.result.no === no || data.result.no === 0)) {
|
||||
if (!data.result.success) {
|
||||
continue; // 进入学习模式,继续等
|
||||
}
|
||||
// 学习成功
|
||||
setDeviceInfo((prev) => {
|
||||
const curInfo = prev?.[deviceId];
|
||||
const codes = curInfo?.learnedCodes ?? [];
|
||||
if (!codes.includes(no)) codes.push(no);
|
||||
return { ...prev, [deviceId]: { ...curInfo, learnedCodes: [...codes] } };
|
||||
});
|
||||
updateLearningDevice(null);
|
||||
message.success(`【${label}】学习成功`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// 超时
|
||||
const cur = learningDeviceRef.current as { deviceId: string; currentNo: number } | null;
|
||||
if (cur && cur.deviceId === deviceId && cur.currentNo === no) {
|
||||
updateLearningDevice(null);
|
||||
message.warning(`【${label}】学习超时,请重试`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
updateLearningDevice(null);
|
||||
const errMsg = err?.response?.data?.error || '学习指令发送失败';
|
||||
message.error(errMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const isOnline = (d: Device) => d.onlineStatus === 'online';
|
||||
|
||||
if (!loading && devices.length === 0) {
|
||||
return <Empty description="暂无设备" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<style>{blinkStyle}</style>
|
||||
<Row gutter={[16, 16]}>
|
||||
{devices.map((d) => {
|
||||
const online = isOnline(d);
|
||||
const currentNo = emitNo[d.id] ?? 100;
|
||||
const irResult = irResults[d.id];
|
||||
const info = deviceInfo[d.id];
|
||||
const learnedCodes = info?.learnedCodes ?? [];
|
||||
// 当前设备是否处于顺序学习
|
||||
const isLearning = learningDevice?.deviceId === d.id;
|
||||
const learningNo = isLearning ? learningDevice!.currentNo : null;
|
||||
|
||||
return (
|
||||
<Col key={d.id} span={24}>
|
||||
<Card
|
||||
title={<Space><ApiOutlined />{d.name}</Space>}
|
||||
extra={<Tag color={online ? 'green' : 'default'}>{online ? '在线' : '离线'}</Tag>}
|
||||
>
|
||||
<Row gutter={[24, 0]}>
|
||||
{/* 左侧:原有通用功能 */}
|
||||
<Col span={12}>
|
||||
{/* 操作结果反馈 */}
|
||||
{irResult && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Result
|
||||
status={irResult.success ? 'success' : 'error'}
|
||||
title={`${actionLabel(irResult.action)}${irResult.success ? '成功' : '失败'}`}
|
||||
subTitle={irResult.no ? `红外码编号: ${irResult.no}` : undefined}
|
||||
style={{ padding: '12px 0' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 设备信息 */}
|
||||
{info && (
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
|
||||
<div style={{ fontSize: 13, color: '#666' }}>设备信息</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
4G 信号:<Tag color={(info.signal ?? 0) >= 20 ? 'green' : (info.signal ?? 0) >= 10 ? 'orange' : 'red'}>{info.signal ?? '--'}/31</Tag>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
||||
最后上报:{info.lastSeen ? new Date(info.lastSeen).toLocaleTimeString() : '--'}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 13, color: '#666' }}>
|
||||
已学习红外码:
|
||||
{info.learnedCodes && info.learnedCodes.length > 0 ? (
|
||||
<Space size={4} wrap style={{ marginTop: 4 }}>
|
||||
{info.learnedCodes.slice().sort((a, b) => a - b).map((no) => (
|
||||
<Tag key={no} color="blue" style={{ cursor: 'pointer' }} onClick={() => setEmitNo({ ...emitNo, [d.id]: no })}>
|
||||
{no}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<span style={{ color: '#999' }}>暂无</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 发射红外码 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>发射红外码</div>
|
||||
<Space>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={248}
|
||||
value={currentNo}
|
||||
onChange={(v) => setEmitNo({ ...emitNo, [d.id]: v ?? 100 })}
|
||||
style={{ width: 80 }}
|
||||
disabled={isLearning}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
loading={sending === d.id + 'emit' + currentNo}
|
||||
onClick={() => sendIR(d, 'emit', currentNo)}
|
||||
disabled={isLearning}
|
||||
>
|
||||
发射
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 学习红外码 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>学习红外码</div>
|
||||
<Space>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={248}
|
||||
value={learnModal.no}
|
||||
onChange={(v) => setLearnModal({ ...learnModal, no: v ?? 100 })}
|
||||
style={{ width: 80 }}
|
||||
disabled={isLearning}
|
||||
/>
|
||||
<Button
|
||||
icon={<BulbOutlined />}
|
||||
loading={sending === d.id + 'learn' + learnModal.no}
|
||||
onClick={() => sendIR(d, 'learn', learnModal.no)}
|
||||
disabled={isLearning}
|
||||
>
|
||||
开始学习
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 其他操作 */}
|
||||
<Space style={{ width: '100%', justifyContent: 'center' }}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<StopOutlined />}
|
||||
loading={sending === d.id + 'cancel'}
|
||||
onClick={() => sendIR(d, 'cancel')}
|
||||
>
|
||||
取消学习
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<InfoCircleOutlined />}
|
||||
loading={sending === d.id + 'info'}
|
||||
onClick={() => sendIR(d, 'info')}
|
||||
>
|
||||
查询信息
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认擦除全部红外码?"
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => sendIR(d, 'erase')}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={sending === d.id + 'erase'}
|
||||
>
|
||||
擦除全部
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
{/* 右侧:空调遥控面板 */}
|
||||
<Col span={12}>
|
||||
<div style={{ marginBottom: 12, fontWeight: 600, fontSize: 15 }}>
|
||||
空调遥控面板
|
||||
{isLearning && (
|
||||
<Tag color="processing" style={{ marginLeft: 8 }}>学习中...</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 开始学习 / 取消学习 按钮 */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{isLearning ? (
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
style={{ height: 60, fontSize: 16, fontWeight: 500 }}
|
||||
onClick={() => cancelSequentialLearn(d.id)}
|
||||
>
|
||||
取消学习
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
type="primary"
|
||||
icon={<BulbOutlined />}
|
||||
style={{ height: 60, fontSize: 16, fontWeight: 500 }}
|
||||
onClick={() => startSequentialLearn(d.id)}
|
||||
>
|
||||
开始学习
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 空调控制按钮(顺序学习) */}
|
||||
<Row gutter={[12, 12]}>
|
||||
{AIRCON_BUTTONS.map((btn) => {
|
||||
const Icon = ICON_MAP[btn.icon];
|
||||
const learned = learnedCodes.includes(btn.no);
|
||||
const isBlinking = isLearning && learningNo === btn.no;
|
||||
const isLoading = airconSending === d.id + btn.key;
|
||||
return (
|
||||
<Col key={btn.key} span={12}>
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
icon={Icon ? <Icon /> : undefined}
|
||||
loading={isLoading}
|
||||
className={isBlinking ? 'ir-blinking' : undefined}
|
||||
style={{
|
||||
height: 80,
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
...(learned && !isBlinking
|
||||
? { borderColor: '#52c41a', color: '#52c41a' }
|
||||
: {}),
|
||||
}}
|
||||
onClick={() => emitAircon(d.id, btn)}
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
|
||||
{/* 温度按钮(单独学习) */}
|
||||
<div style={{ marginTop: 16, marginBottom: 8, fontSize: 13, color: '#999' }}>
|
||||
温度按钮(点击未学习的按钮开始单独学习)
|
||||
</div>
|
||||
<Row gutter={[12, 12]}>
|
||||
{TEMP_BUTTONS.map((btn) => {
|
||||
const learned = learnedCodes.includes(btn.no);
|
||||
const isBlinking = isLearning && learningNo === btn.no;
|
||||
return (
|
||||
<Col key={btn.key} span={6}>
|
||||
<Button
|
||||
block
|
||||
size="large"
|
||||
className={isBlinking ? 'ir-blinking' : undefined}
|
||||
style={{
|
||||
height: 60,
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
...(learned && !isBlinking
|
||||
? { borderColor: '#52c41a', color: '#52c41a' }
|
||||
: {}),
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isBlinking) {
|
||||
// 正在学习,取消
|
||||
cancelSequentialLearn(d.id);
|
||||
} else if (learned) {
|
||||
// 已学习,发射
|
||||
emitAircon(d.id, { ...btn, icon: '' });
|
||||
} else {
|
||||
// 未学习,开始单独学习
|
||||
learnSingleTemp(d.id, btn.no, btn.label);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
|
||||
{isLearning && (
|
||||
<div style={{ marginTop: 12, fontSize: 13, color: '#666' }}>
|
||||
当前学习:【{ALL_BUTTONS.find((b) => b.no === learningNo)?.label}】,请将遥控器对准设备按键
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
|
||||
{/* 学习中提示弹窗(保留原有功能) */}
|
||||
<Modal
|
||||
open={learnModal.open}
|
||||
title="红外码学习中..."
|
||||
onCancel={() => {
|
||||
post(`/devices/${learnModal.deviceId}/ir/cancel`, {}).catch(() => {});
|
||||
setLearnModal({ ...learnModal, open: false });
|
||||
}}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={() => {
|
||||
post(`/devices/${learnModal.deviceId}/ir/cancel`, {}).catch(() => {});
|
||||
setLearnModal({ ...learnModal, open: false });
|
||||
}}>
|
||||
取消学习
|
||||
</Button>,
|
||||
<Button key="done" type="primary" onClick={() => setLearnModal({ ...learnModal, open: false })}>
|
||||
学习完成
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`正在学习编号 ${learnModal.no} 的红外码`}
|
||||
description={'请将遥控器对准红外控制器,按一下对应按键。听到"嘀"一声表示学习成功。'}
|
||||
/>
|
||||
</Modal>
|
||||
</Spin>
|
||||
);
|
||||
};
|
||||
|
||||
export default IRControl;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import { Tag } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { listLogs, type ControlLog } from '../dal/log';
|
||||
|
||||
export default function LogPage() {
|
||||
const columns: ProColumns<ControlLog>[] = [
|
||||
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
|
||||
{ title: '设备', dataIndex: 'deviceName' },
|
||||
{ title: '指令', dataIndex: 'command' },
|
||||
{ title: '操作人', dataIndex: 'operator', search: false },
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'success',
|
||||
search: false,
|
||||
render: (_, r) =>
|
||||
r.success === undefined ? (
|
||||
<Tag>未知</Tag>
|
||||
) : r.success ? (
|
||||
<Tag color="green">成功</Tag>
|
||||
) : (
|
||||
<Tag color="red">失败</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
search: false,
|
||||
valueType: 'dateTimeRange',
|
||||
render: (_, r) => dayjs(r.createdAt).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ProTable<ControlLog>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
request={async (p) => {
|
||||
const res = await listLogs(p);
|
||||
return { data: res.items, total: res.total, success: true };
|
||||
}}
|
||||
search={{ labelWidth: 'auto' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Button, Card, Col, Form, Input, Row, Typography, App } from 'antd';
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { authService } from '../services/auth';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onFinish = async (v: { username: string; password: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await authService.login(v.username, v.password);
|
||||
navigate('/dashboard');
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.error || '登录失败,请重试';
|
||||
message.error(typeof msg === 'string' ? msg : '登录失败,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg,#1677ff 0%,#36cfc9 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Row>
|
||||
<Col>
|
||||
<Card style={{ width: 380, borderRadius: 12 }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 24 }}>
|
||||
<Title level={3}>蚕房环境监控</Title>
|
||||
<Text type="secondary">Silkworm Environment Monitor</Text>
|
||||
</div>
|
||||
<Form layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item label="用户名" name="username" rules={[{ required: true }]}>
|
||||
<Input prefix={<UserOutlined />} placeholder="请输入用户名" autoComplete="username" />
|
||||
</Form.Item>
|
||||
<Form.Item label="密码" name="password" rules={[{ required: true }]}>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="请输入密码" autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block loading={loading}>
|
||||
登 录
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Button, Result } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Result
|
||||
status="404"
|
||||
title="404"
|
||||
subTitle="抱歉,您访问的页面不存在。"
|
||||
extra={
|
||||
<Button type="primary" onClick={() => navigate('/dashboard')}>
|
||||
返回首页
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Form, Input, Modal, Popconfirm, Select, Tag, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import { listHouses, createHouse, updateHouse, deleteHouse, type SilkwormHouse } from '../dal/silkworm';
|
||||
|
||||
export default function SilkwormPage() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm<Partial<SilkwormHouse>>();
|
||||
const [editing, setEditing] = useState<SilkwormHouse | null>(null);
|
||||
|
||||
const columns: ProColumns<SilkwormHouse>[] = [
|
||||
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '位置', dataIndex: 'location', search: false, render: (_, r) => r.location || '-' },
|
||||
{ title: '容量(盒)', dataIndex: 'capacity', search: false, render: (_, r) => r.capacity ?? '-' },
|
||||
{
|
||||
title: '阶段',
|
||||
dataIndex: 'stage',
|
||||
valueType: 'select',
|
||||
valueEnum: {
|
||||
egg: { text: '卵期' },
|
||||
larva: { text: '幼虫期' },
|
||||
pupa: { text: '蛹期' },
|
||||
moth: { text: '蛾期' },
|
||||
},
|
||||
render: (_, r) => {
|
||||
const map: Record<string, string> = { egg: '卵期', larva: '幼虫期', pupa: '蛹期', moth: '蛾期' };
|
||||
return r.stage ? (map[r.stage] || r.stage) : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
search: false,
|
||||
render: (_, r) => {
|
||||
const color = r.status === 'running' ? 'green' : r.status === 'alarm' ? 'red' : 'default';
|
||||
const text = r.status === 'running' ? '运行中' : r.status === 'alarm' ? '告警' : '空闲';
|
||||
return <Tag color={color}>{text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
valueType: 'option',
|
||||
render: (_, r) => [
|
||||
<a
|
||||
key="edit"
|
||||
onClick={() => {
|
||||
setEditing(r);
|
||||
form.setFieldsValue(r);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</a>,
|
||||
<Popconfirm
|
||||
key="del"
|
||||
title="确认删除?"
|
||||
onConfirm={async () => {
|
||||
await deleteHouse(r.id);
|
||||
message.success('已删除');
|
||||
}}
|
||||
>
|
||||
<a style={{ color: '#ff4d4f' }}>删除</a>
|
||||
</Popconfirm>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProTable<SilkwormHouse>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
search={{ labelWidth: 'auto' }}
|
||||
request={async (params) => {
|
||||
const res = await listHouses(params);
|
||||
return { data: res.items, total: res.total, success: true };
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button
|
||||
key="new"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新建蚕房
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? '编辑蚕房' : '新建蚕房'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await form.validateFields();
|
||||
if (editing) await updateHouse(editing.id, v);
|
||||
else await createHouse(v);
|
||||
message.success('保存成功');
|
||||
setModalOpen(false);
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="位置" name="location" rules={[{ required: true, message: '请输入位置' }]}>
|
||||
<Input placeholder="如:1号厂房东侧" />
|
||||
</Form.Item>
|
||||
<Form.Item label="容量(盒)" name="capacity" rules={[{ required: true, message: '请输入容量' }]}>
|
||||
<Input type="number" min={0} placeholder="蚕盒数量" />
|
||||
</Form.Item>
|
||||
<Form.Item label="蚕阶段" name="stage" rules={[{ required: true, message: '请选择阶段' }]}>
|
||||
<Select
|
||||
placeholder="请选择阶段"
|
||||
options={[
|
||||
{ label: '卵期', value: 'egg' },
|
||||
{ label: '幼虫期', value: 'larva' },
|
||||
{ label: '蛹期', value: 'pupa' },
|
||||
{ label: '蛾期', value: 'moth' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Switch, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useState } from 'react';
|
||||
import { ProTable, type ProColumns } from '@ant-design/pro-components';
|
||||
import { createThreshold, deleteThreshold, listThresholds, updateThreshold, type Threshold } from '../dal/threshold';
|
||||
|
||||
const METRICS = [
|
||||
{ label: '温度', value: 'temperature' },
|
||||
{ label: '湿度', value: 'humidity' },
|
||||
{ label: 'CO₂', value: 'co2' },
|
||||
{ label: '光照', value: 'light' },
|
||||
];
|
||||
|
||||
const UNITS: Record<string, string> = {
|
||||
temperature: '℃',
|
||||
temp: '℃',
|
||||
humidity: '%',
|
||||
co2: 'ppm',
|
||||
light: 'lux',
|
||||
};
|
||||
|
||||
const metricText = (value?: string) => METRICS.find((item) => item.value === value)?.label || value || '-';
|
||||
|
||||
export default function ThresholdPage() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm<Partial<Threshold>>();
|
||||
const [editing, setEditing] = useState<Threshold | null>(null);
|
||||
|
||||
const columns: ProColumns<Threshold>[] = [
|
||||
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{
|
||||
title: '指标',
|
||||
dataIndex: 'metric',
|
||||
valueType: 'select',
|
||||
valueEnum: Object.fromEntries(METRICS.map((m) => [m.value, { text: m.label }])),
|
||||
render: (_, r) => metricText(r.metric || r.sensor?.metric),
|
||||
},
|
||||
{ title: '传感器ID', dataIndex: 'sensorId', search: false },
|
||||
{ title: '下限', dataIndex: 'minValue', search: false, render: (_, r) => r.minValue ?? r.min },
|
||||
{ title: '上限', dataIndex: 'maxValue', search: false, render: (_, r) => r.maxValue ?? r.max },
|
||||
{ title: '防抖(秒)', dataIndex: 'debounceSeconds', search: false },
|
||||
{ title: '级别', dataIndex: 'severity', search: false },
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enabled',
|
||||
search: false,
|
||||
valueType: 'switch',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
valueType: 'option',
|
||||
render: (_, r) => [
|
||||
<a
|
||||
key="edit"
|
||||
onClick={() => {
|
||||
setEditing(r);
|
||||
form.setFieldsValue({
|
||||
...r,
|
||||
metric: r.metric || r.sensor?.metric,
|
||||
minValue: r.minValue ?? r.min,
|
||||
maxValue: r.maxValue ?? r.max,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</a>,
|
||||
<Popconfirm
|
||||
key="del"
|
||||
title="确认删除?"
|
||||
onConfirm={async () => {
|
||||
await deleteThreshold(r.id);
|
||||
message.success('已删除');
|
||||
}}
|
||||
>
|
||||
<a style={{ color: '#ff4d4f' }}>删除</a>
|
||||
</Popconfirm>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProTable<Threshold>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
request={async (p) => {
|
||||
const res = await listThresholds(p);
|
||||
return { data: res.items, total: res.total, success: true };
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button
|
||||
key="new"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新建阈值
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? '编辑阈值' : '新建阈值'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await form.validateFields();
|
||||
v.unit = UNITS[v.metric as string] ?? v.unit;
|
||||
if (editing) await updateThreshold(editing.id, v);
|
||||
else await createThreshold(v);
|
||||
message.success('保存成功');
|
||||
setModalOpen(false);
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={{ enabled: true, debounceSeconds: 5, severity: 3 }}>
|
||||
<Form.Item label="名称" name="name">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="指标" name="metric" rules={[{ required: true }]}>
|
||||
<Select options={METRICS} />
|
||||
</Form.Item>
|
||||
<Form.Item label="传感器ID" name="sensorId" rules={[{ required: true }]}>
|
||||
<Input placeholder="后端阈值接口需要 sensorId" />
|
||||
</Form.Item>
|
||||
<Form.Item label="下限" name="minValue" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="上限" name="maxValue" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="防抖(秒)" name="debounceSeconds">
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
<Form.Item label="告警级别" name="severity">
|
||||
<InputNumber style={{ width: '100%' }} min={1} max={5} />
|
||||
</Form.Item>
|
||||
<Form.Item label="启用" name="enabled" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
import { Navigate, createBrowserRouter } from 'react-router-dom';
|
||||
import Login from './pages/Login';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Silkworm from './pages/Silkworm';
|
||||
import Device from './pages/Device';
|
||||
import Energy from './pages/Energy';
|
||||
import Control from './pages/Control';
|
||||
import IRControl from './pages/IRControl';
|
||||
import Threshold from './pages/Threshold';
|
||||
import Alert from './pages/Alert';
|
||||
import Video from './pages/Video';
|
||||
import Log from './pages/Log';
|
||||
import NotFound from './pages/NotFound';
|
||||
import { BasicLayout } from './layout/BasicLayout';
|
||||
import { authService } from './services/auth';
|
||||
|
||||
const RequireAuth = ({ children }: { children: React.ReactNode }) => {
|
||||
if (!authService.isLoggedIn()) return <Navigate to="/login" replace />;
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// 路由权限守卫:未登录跳登录页,无权限跳仪表盘
|
||||
const RequirePermission = ({ permission, children }: { permission: string; children: React.ReactNode }) => {
|
||||
if (!authService.isLoggedIn()) return <Navigate to="/login" replace />;
|
||||
if (!authService.hasPermission(permission)) return <Navigate to="/dashboard" replace />;
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: '/login', element: <Login /> },
|
||||
{
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<BasicLayout />
|
||||
</RequireAuth>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <RequirePermission permission="dashboard:view"><Dashboard /></RequirePermission> },
|
||||
{ path: 'houses', element: <RequirePermission permission="room:read"><Silkworm /></RequirePermission> },
|
||||
{ path: 'devices', element: <RequirePermission permission="device:read"><Device /></RequirePermission> },
|
||||
{ path: 'energy', element: <RequirePermission permission="energy:view"><Energy /></RequirePermission> },
|
||||
{ path: 'control', element: <RequirePermission permission="device:control"><Control /></RequirePermission> },
|
||||
{ path: 'ir', element: <RequirePermission permission="device:control"><IRControl /></RequirePermission> },
|
||||
{ path: 'thresholds', element: <RequirePermission permission="threshold:read"><Threshold /></RequirePermission> },
|
||||
{ path: 'alerts', element: <RequirePermission permission="alarm:read"><Alert /></RequirePermission> },
|
||||
{ path: 'videos', element: <RequirePermission permission="video:read"><Video /></RequirePermission> },
|
||||
{ path: 'logs', element: <RequirePermission permission="log:read"><Log /></RequirePermission> },
|
||||
{ path: '404', element: <NotFound /> },
|
||||
],
|
||||
},
|
||||
{ path: '*', element: <NotFound /> },
|
||||
]);
|
||||
@@ -0,0 +1,58 @@
|
||||
import { REFRESH_TOKEN_KEY, TOKEN_KEY } from '../api/http';
|
||||
import { changePasswordApi, loginApi, logoutApi, meApi, type ChangePasswordPayload } from '../dal/auth';
|
||||
|
||||
export const USER_KEY = 'silkworm_user';
|
||||
|
||||
export const authService = {
|
||||
async login(username: string, password: string) {
|
||||
const res = await loginApi({ username, password });
|
||||
localStorage.setItem(TOKEN_KEY, res.accessToken || res.token || '');
|
||||
if (res.refreshToken) localStorage.setItem(REFRESH_TOKEN_KEY, res.refreshToken);
|
||||
if (res.user) localStorage.setItem(USER_KEY, JSON.stringify(res.user));
|
||||
return res;
|
||||
},
|
||||
async logout() {
|
||||
// 通知后端吊销令牌(失败不阻塞前端清理)
|
||||
try {
|
||||
await logoutApi();
|
||||
} catch {}
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
},
|
||||
async changePassword(payload: ChangePasswordPayload) {
|
||||
return changePasswordApi(payload);
|
||||
},
|
||||
isLoggedIn() {
|
||||
return !!localStorage.getItem(TOKEN_KEY);
|
||||
},
|
||||
getUser(): { id: string; username?: string; role?: string; permissions?: string[] } | null {
|
||||
const raw = localStorage.getItem(USER_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
getPermissions(): string[] {
|
||||
const user = this.getUser();
|
||||
return user?.permissions ?? [];
|
||||
},
|
||||
hasPermission(code: string): boolean {
|
||||
const user = this.getUser();
|
||||
if (user?.role === 'admin') return true;
|
||||
return this.getPermissions().includes(code);
|
||||
},
|
||||
async refreshUser() {
|
||||
// 从后端 /auth/me 刷新当前用户信息和权限
|
||||
try {
|
||||
const res = await meApi();
|
||||
const user = { id: res.sub, username: res.username, role: res.role, permissions: res.permissions };
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
return user;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path,
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user