341 lines
12 KiB
TypeScript
341 lines
12 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { View, Text, ScrollView } from '@tarojs/components';
|
|
import Taro, { useRouter, usePullDownRefresh } from '@tarojs/taro';
|
|
import styles from './index.module.scss';
|
|
import { getRoomById } from '@/api/rooms';
|
|
import { getDevices } from '@/api/devices';
|
|
import { getLatestTelemetry, fetchTrend } from '@/api/telemetry';
|
|
import { getTrays, getBatches, batchStatusLabel, type Tray, type Batch } from '@/api/trayBatch';
|
|
import MiniChart from '@/components/MiniChart';
|
|
import {
|
|
roomStatusLabel,
|
|
onlineStatusLabel,
|
|
deviceKindLabel,
|
|
metricLabel,
|
|
metricUnit,
|
|
formatNumber,
|
|
formatRelativeTime,
|
|
} from '@/utils/format';
|
|
import type { Room, Device, TelemetryRecord, TrendPoint } from '@/types';
|
|
|
|
const RoomDetailPage: React.FC = () => {
|
|
const router = useRouter();
|
|
const roomId = router.params.id || '';
|
|
const [room, setRoom] = useState<Room | null>(null);
|
|
const [devices, setDevices] = useState<Device[]>([]);
|
|
const [telemetryMap, setTelemetryMap] = useState<Record<string, TelemetryRecord[]>>({});
|
|
const [trend, setTrend] = useState<TrendPoint[]>([]);
|
|
const [trays, setTrays] = useState<Tray[]>([]);
|
|
const [batches, setBatches] = useState<Batch[]>([]);
|
|
const [trendLoading, setTrendLoading] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState('');
|
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
if (!roomId) {
|
|
setError('缺少蚕房ID');
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
try {
|
|
setError('');
|
|
const [roomRes, devicesRes, traysRes, batchesRes] = await Promise.all([
|
|
getRoomById(roomId).catch((e) => {
|
|
console.error('[RoomDetail] 获取蚕房失败:', e);
|
|
return null;
|
|
}),
|
|
getDevices().catch(() => [] as Device[]),
|
|
getTrays(roomId).catch(() => [] as Tray[]),
|
|
getBatches(roomId).catch(() => [] as Batch[]),
|
|
]);
|
|
|
|
if (roomRes) {
|
|
setRoom(roomRes);
|
|
}
|
|
|
|
const roomDevices = devicesRes.filter((d) => d.roomId === roomId);
|
|
setDevices(roomDevices);
|
|
setTrays(traysRes);
|
|
setBatches(batchesRes);
|
|
|
|
// 获取每个设备的最新遥测
|
|
const telemetryEntries = await Promise.all(
|
|
roomDevices.map(async (device) => {
|
|
try {
|
|
const telemetry = await getLatestTelemetry(device.deviceKey).catch(
|
|
() => [] as TelemetryRecord[]
|
|
);
|
|
return [device.deviceKey, telemetry] as [string, TelemetryRecord[]];
|
|
} catch {
|
|
return [device.deviceKey, []] as [string, TelemetryRecord[]];
|
|
}
|
|
})
|
|
);
|
|
const map: Record<string, TelemetryRecord[]> = {};
|
|
telemetryEntries.forEach(([key, val]) => {
|
|
map[key] = val;
|
|
});
|
|
setTelemetryMap(map);
|
|
|
|
// 加载24小时趋势数据
|
|
setTrendLoading(true);
|
|
const trendData = await fetchTrend(24).catch(() => [] as TrendPoint[]);
|
|
setTrend(trendData);
|
|
} catch (err) {
|
|
console.error('[RoomDetail] 数据加载失败:', err);
|
|
setError(err instanceof Error ? err.message : '数据加载失败');
|
|
} finally {
|
|
setLoading(false);
|
|
setTrendLoading(false);
|
|
}
|
|
}, [roomId]);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
intervalRef.current = setInterval(() => {
|
|
fetchData();
|
|
}, 30000);
|
|
return () => {
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
};
|
|
}, [fetchData]);
|
|
|
|
usePullDownRefresh(() => {
|
|
fetchData().then(() => {
|
|
Taro.stopPullDownRefresh();
|
|
});
|
|
});
|
|
|
|
if (loading) {
|
|
return (
|
|
<View className={styles.detailPage}>
|
|
<View className={styles.loadingWrap}>
|
|
<Text className={styles.loadingText}>加载中...</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (error && !room) {
|
|
return (
|
|
<View className={styles.detailPage}>
|
|
<View className={styles.emptyWrap}>
|
|
<Text className={styles.emptyIcon}>⚠️</Text>
|
|
<Text className={styles.emptyText}>{error}</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (!room) {
|
|
return (
|
|
<View className={styles.detailPage}>
|
|
<View className={styles.emptyWrap}>
|
|
<Text className={styles.emptyIcon}>🏠</Text>
|
|
<Text className={styles.emptyText}>未找到蚕房信息</Text>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View className={styles.detailPage}>
|
|
{/* 蚕房信息卡片 */}
|
|
<View className={styles.infoCard}>
|
|
<View className={styles.infoHeader}>
|
|
<Text className={styles.infoName}>{room.name}</Text>
|
|
<View className={styles.infoStatus}>{roomStatusLabel(room.status)}</View>
|
|
</View>
|
|
<View className={styles.infoMeta}>
|
|
{room.code && (
|
|
<View className={styles.metaItem}>
|
|
<Text>编码: {room.code}</Text>
|
|
</View>
|
|
)}
|
|
{room.location && (
|
|
<View className={styles.metaItem}>
|
|
<Text>📍 {room.location}</Text>
|
|
</View>
|
|
)}
|
|
<View className={styles.metaItem}>
|
|
<Text>📡 设备: {devices.length}</Text>
|
|
</View>
|
|
</View>
|
|
{room.description && (
|
|
<Text style={{ fontSize: '24rpx', color: 'rgba(255,255,255,0.8)', marginTop: '16rpx', lineHeight: '1.6' }}>
|
|
{room.description}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
{/* 最新遥测数据 */}
|
|
{devices.length > 0 && (
|
|
<View className={styles.section}>
|
|
<Text className={styles.sectionTitle}>实时环境数据</Text>
|
|
<ScrollView scrollY>
|
|
{devices.map((device) => {
|
|
const telemetry = telemetryMap[device.deviceKey] || [];
|
|
if (telemetry.length === 0) return null;
|
|
return (
|
|
<View key={device.deviceKey} style={{ marginBottom: '16rpx' }}>
|
|
<Text style={{ fontSize: '24rpx', color: '#86909c', marginBottom: '8rpx', display: 'block' }}>
|
|
{device.name}
|
|
</Text>
|
|
<View className={styles.telemetryGrid}>
|
|
{telemetry.map((item) => (
|
|
<View key={`${item.deviceKey}-${item.metric}`} className={styles.telemetryCard}>
|
|
<View className={styles.telemetryTop}>
|
|
<Text className={styles.telemetryLabel}>{metricLabel(item.metric)}</Text>
|
|
<Text className={styles.telemetryIcon}>
|
|
{item.metric === 'temperature' ? '🌡' : item.metric === 'humidity' ? '💧' : '📈'}
|
|
</Text>
|
|
</View>
|
|
<View>
|
|
<Text className={styles.telemetryValue}>{formatNumber(item.value)}</Text>
|
|
<Text className={styles.telemetryUnit}>{metricUnit(item.metric)}</Text>
|
|
</View>
|
|
<Text className={styles.telemetryTime}>{formatRelativeTime(item.timestamp)}</Text>
|
|
</View>
|
|
))}
|
|
</View>
|
|
</View>
|
|
);
|
|
})}
|
|
</ScrollView>
|
|
</View>
|
|
)}
|
|
|
|
{/* 24小时趋势图 */}
|
|
<View className={styles.section}>
|
|
<Text className={styles.sectionTitle}>24小时趋势</Text>
|
|
<View className={styles.trendCard}>
|
|
{trendLoading && trend.length === 0 ? (
|
|
<View className={styles.trendLoading}>
|
|
<Text className={styles.loadingText}>趋势数据加载中...</Text>
|
|
</View>
|
|
) : trend.length > 0 ? (
|
|
<>
|
|
<Text className={styles.trendLabel}>温度 (°C)</Text>
|
|
<MiniChart data={trend} metric="temp" color="#f53f3f" height={200} />
|
|
<Text className={styles.trendLabel}>湿度 (%)</Text>
|
|
<MiniChart data={trend} metric="humidity" color="#10b981" height={200} />
|
|
<View className={styles.legendRow}>
|
|
<View className={styles.legendItem}>
|
|
<View className={styles.legendDot} style={{ background: '#f53f3f' }} />
|
|
<Text className={styles.legendText}>温度</Text>
|
|
</View>
|
|
<View className={styles.legendItem}>
|
|
<View className={styles.legendDot} style={{ background: '#10b981' }} />
|
|
<Text className={styles.legendText}>湿度</Text>
|
|
</View>
|
|
</View>
|
|
</>
|
|
) : (
|
|
<View className={styles.trendLoading}>
|
|
<Text className={styles.loadingText}>暂无趋势数据</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
{/* 设备列表 */}
|
|
<View className={styles.section}>
|
|
<Text className={styles.sectionTitle}>蚕房设备</Text>
|
|
{devices.length > 0 ? (
|
|
<View className={styles.deviceList}>
|
|
{devices.map((device) => (
|
|
<View key={device.id} className={styles.deviceItem}>
|
|
<View className={styles.deviceLeft}>
|
|
<Text className={styles.deviceIcon}>
|
|
{device.kind === 'camera' ? '📹' : device.kind === 'sensor' ? '🌡' : '🔌'}
|
|
</Text>
|
|
<View className={styles.deviceInfo}>
|
|
<Text className={styles.deviceName}>{device.name}</Text>
|
|
<Text className={styles.deviceMeta}>
|
|
{deviceKindLabel(device.kind)} · {device.deviceKey}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<View className={styles.deviceStatus}>
|
|
<View
|
|
className={styles.statusDot}
|
|
style={{
|
|
background: device.onlineStatus === 'online' ? '#00b42a' : '#c9cdd4',
|
|
}}
|
|
/>
|
|
<Text style={{ color: device.onlineStatus === 'online' ? '#00b42a' : '#86909c' }}>
|
|
{onlineStatusLabel(device.onlineStatus)}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : (
|
|
<View className={styles.emptyWrap}>
|
|
<Text className={styles.emptyIcon}>📭</Text>
|
|
<Text className={styles.emptyText}>该蚕房暂无设备</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* 蚕匾 */}
|
|
<View className={styles.section}>
|
|
<Text className={styles.sectionTitle}>蚕匾</Text>
|
|
{trays.length > 0 ? (
|
|
<View className={styles.deviceList}>
|
|
{trays.map((t) => (
|
|
<View key={t.id} className={styles.deviceItem}>
|
|
<View className={styles.deviceLeft}>
|
|
<Text className={styles.deviceIcon}>🪵</Text>
|
|
<View className={styles.deviceInfo}>
|
|
<Text className={styles.deviceName}>{t.name}</Text>
|
|
<Text className={styles.deviceMeta}>
|
|
{[t.code, t.position].filter(Boolean).join(' · ') || '无位置信息'}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : (
|
|
<View className={styles.emptyWrap}>
|
|
<Text className={styles.emptyIcon}>📭</Text>
|
|
<Text className={styles.emptyText}>该蚕房暂无蚕匾</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
|
|
{/* 当前批次 */}
|
|
<View className={styles.section}>
|
|
<Text className={styles.sectionTitle}>当前批次</Text>
|
|
{batches.length > 0 ? (
|
|
<View className={styles.deviceList}>
|
|
{batches.map((b) => (
|
|
<View key={b.id} className={styles.deviceItem}>
|
|
<View className={styles.deviceLeft}>
|
|
<Text className={styles.deviceIcon}>🐛</Text>
|
|
<View className={styles.deviceInfo}>
|
|
<Text className={styles.deviceName}>{b.name}</Text>
|
|
<Text className={styles.deviceMeta}>
|
|
{batchStatusLabel(b.status)} · {b.instar ? `${b.instar}龄` : '龄期未设置'}
|
|
{b.variety ? ` · ${b.variety}` : ''}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
) : (
|
|
<View className={styles.emptyWrap}>
|
|
<Text className={styles.emptyIcon}>📭</Text>
|
|
<Text className={styles.emptyText}>该蚕房暂无批次</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
</View>
|
|
);
|
|
};
|
|
|
|
export default RoomDetailPage;
|