chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)

This commit is contained in:
weijuesen
2026-08-10 22:30:53 +08:00
commit 84abf4454c
358 changed files with 75993 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '告警',
enablePullDownRefresh: true,
});
+208
View File
@@ -0,0 +1,208 @@
@use '@/styles/variables.scss' as *;
.alertsPage {
min-height: 100vh;
background: $color-bg-page;
padding-bottom: $spacing-xl;
}
.filterBar {
display: flex;
background: $color-bg-card;
padding: $spacing-sm $spacing-lg;
gap: $spacing-md;
box-shadow: $shadow-card;
}
.filterTab {
flex: 1;
text-align: center;
padding: $spacing-xs 0;
font-size: $font-size-sm;
color: $color-text-secondary;
border-radius: $radius-sm;
transition: all $transition-base;
}
.filterTabActive {
background: $color-primary;
color: $color-text-white;
font-weight: $font-weight-medium;
}
.alarmList {
padding: $spacing-md $spacing-lg;
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.alarmCard {
background: $color-bg-card;
border-radius: $radius-md;
padding: $spacing-md $spacing-lg;
box-shadow: $shadow-card;
border-left: 8rpx solid transparent;
}
.alarmHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-sm;
}
.alarmTitleRow {
display: flex;
align-items: center;
gap: $spacing-sm;
flex: 1;
min-width: 0;
}
.alarmIcon {
font-size: 32rpx;
flex-shrink: 0;
}
.alarmTitle {
font-size: $font-size-md;
font-weight: $font-weight-medium;
color: $color-text-primary;
@include text-ellipsis;
}
.alarmSeverity {
font-size: $font-size-xs;
padding: 4rpx $spacing-sm;
border-radius: $radius-xs;
white-space: nowrap;
flex-shrink: 0;
margin-left: $spacing-sm;
}
.alarmMessage {
font-size: $font-size-sm;
color: $color-text-secondary;
line-height: $line-height-loose;
margin-bottom: $spacing-sm;
@include text-ellipsis-multi(2);
}
.alarmMeta {
display: flex;
flex-wrap: wrap;
gap: $spacing-md;
margin-bottom: $spacing-sm;
}
.metaItem {
display: flex;
align-items: center;
gap: 4rpx;
font-size: $font-size-xs;
color: $color-text-tertiary;
}
.alarmFooter {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: $spacing-sm;
border-top: 2rpx solid $color-divider;
}
.alarmTime {
font-size: $font-size-xs;
color: $color-text-tertiary;
}
.ackBtn {
padding: $spacing-xs $spacing-md;
border-radius: $radius-button;
font-size: $font-size-xs;
white-space: nowrap;
transition: all $transition-base;
&:active {
opacity: 0.85;
transform: scale(0.98);
}
}
.ackBtnPrimary {
background: $color-primary;
color: $color-text-white;
}
.ackBtnDone {
background: $color-bg-hover;
color: $color-text-tertiary;
}
.ackBtnClip {
background: rgba(22, 93, 255, 0.1);
color: #165dff;
}
.clipBtn {
display: flex;
gap: $spacing-sm;
}
.loadingWrap {
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.loadingText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.errorWrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.errorIcon {
font-size: 56rpx;
margin-bottom: $spacing-sm;
}
.errorText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.retryBtn {
margin-top: $spacing-md;
padding: $spacing-xs $spacing-lg;
border-radius: $radius-button;
background: $color-primary;
color: $color-text-white;
font-size: $font-size-sm;
}
.emptyWrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.emptyIcon {
font-size: 80rpx;
margin-bottom: $spacing-md;
}
.emptyText {
font-size: $font-size-md;
color: $color-text-tertiary;
}
+242
View File
@@ -0,0 +1,242 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import styles from './index.module.scss';
import { getAlarms, acknowledgeAlarm, getAlarmClip } from '@/api/alarms';
import {
formatDateTime,
formatRelativeTime,
severityLabel,
severityColor,
metricLabel,
formatNumber,
formatDuration,
} from '@/utils/format';
import type { Alarm } from '@/types';
const AlertsPage: React.FC = () => {
const [alarms, setAlarms] = useState<Alarm[]>([]);
const [filterOpen, setFilterOpen] = useState(true);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const fetchData = useCallback(async () => {
try {
setError('');
const res = await getAlarms({ openOnly: filterOpen }).catch(() => [] as Alarm[]);
setAlarms(res);
} catch (err) {
console.error('[Alerts] 数据加载失败:', err);
setError(err instanceof Error ? err.message : '数据加载失败');
} finally {
setLoading(false);
}
}, [filterOpen]);
useEffect(() => {
fetchData();
}, [fetchData]);
useDidShow(() => {
if (alarms.length > 0) fetchData();
});
usePullDownRefresh(() => {
fetchData().then(() => Taro.stopPullDownRefresh());
});
const handleAck = async (alarmId: string) => {
Taro.showLoading({ title: '处理中...' });
try {
await acknowledgeAlarm(alarmId);
console.log('[Alerts] 告警已确认:', alarmId);
// 更新本地状态
setAlarms((prev) =>
prev.map((a) => (a.id === alarmId ? { ...a, acknowledged: true } : a))
);
if (filterOpen) {
// 如果是只看未处理,确认后从列表移除
setTimeout(() => {
setAlarms((prev) => prev.filter((a) => a.id !== alarmId));
}, 500);
}
Taro.showToast({ title: '已确认', icon: 'success' });
} catch (err) {
console.error('[Alerts] 确认告警失败:', err);
Taro.showToast({
title: err instanceof Error ? err.message : '操作失败',
icon: 'none',
});
} finally {
Taro.hideLoading();
}
};
const handleViewClip = async (alarmId: string) => {
Taro.showLoading({ title: '获取录像...' });
try {
const clip = await getAlarmClip(alarmId) as {
playbackUrl?: string;
startAt?: string;
endAt?: string;
durationSec?: number;
};
console.log('[Alerts] 获取录像成功:', alarmId, clip);
if (clip && (clip.startAt || clip.endAt)) {
const startStr = clip.startAt ? formatDateTime(clip.startAt) : '-';
const endStr = clip.endAt ? formatDateTime(clip.endAt) : '-';
const durationStr = clip.durationSec
? `\n时长: ${formatDuration(clip.durationSec)}`
: '';
Taro.showModal({
title: '告警片段',
content: `开始时间: ${startStr}\n结束时间: ${endStr}${durationStr}`,
showCancel: false,
confirmText: '知道了',
});
} else {
Taro.showToast({ title: '暂无关联的视频片段', icon: 'none' });
}
} catch (err) {
console.error('[Alerts] 获取录像失败:', err, alarmId);
Taro.showToast({ title: '录像暂不可用', icon: 'none' });
} finally {
Taro.hideLoading();
}
};
if (loading) {
return (
<View className={styles.alertsPage}>
<View className={styles.loadingWrap}>
<Text className={styles.loadingText}>...</Text>
</View>
</View>
);
}
if (error) {
return (
<View className={styles.alertsPage}>
<View className={styles.errorWrap}>
<Text className={styles.errorIcon}></Text>
<Text className={styles.errorText}>{error}</Text>
<View className={styles.retryBtn} onClick={fetchData}>
<Text style={{ color: '#fff' }}></Text>
</View>
</View>
</View>
);
}
return (
<View className={styles.alertsPage}>
{/* 筛选栏 */}
<View className={styles.filterBar}>
<View
className={`${styles.filterTab} ${filterOpen ? styles.filterTabActive : ''}`}
onClick={() => setFilterOpen(true)}
>
({alarms.length})
</View>
<View
className={`${styles.filterTab} ${!filterOpen ? styles.filterTabActive : ''}`}
onClick={() => setFilterOpen(false)}
>
</View>
</View>
{alarms.length === 0 ? (
<View className={styles.emptyWrap}>
<Text className={styles.emptyIcon}>{filterOpen ? '✅' : '🔔'}</Text>
<Text className={styles.emptyText}>
{filterOpen ? '暂无未处理告警' : '暂无告警记录'}
</Text>
</View>
) : (
<View className={styles.alarmList}>
{alarms.map((alarm) => {
const color = severityColor(alarm.severity);
return (
<View
key={alarm.id}
className={styles.alarmCard}
style={{ borderLeftColor: color }}
>
<View className={styles.alarmHeader}>
<View className={styles.alarmTitleRow}>
<Text className={styles.alarmIcon}>
{alarm.severity === 'critical' || alarm.severity === 1 ? '🔴' : '🟡'}
</Text>
<Text className={styles.alarmTitle}>
{alarm.title || alarm.code || '环境告警'}
</Text>
</View>
<View
className={styles.alarmSeverity}
style={{ background: `${color}20`, color }}
>
{severityLabel(alarm.severity)}
</View>
</View>
{alarm.message && (
<Text className={styles.alarmMessage}>{alarm.message}</Text>
)}
<View className={styles.alarmMeta}>
{alarm.deviceKey && (
<View className={styles.metaItem}>
<Text>📡 {alarm.deviceKey}</Text>
</View>
)}
{alarm.metric && (
<View className={styles.metaItem}>
<Text>
{metricLabel(alarm.metric)}: {formatNumber(alarm.value)}
{alarm.thresholdMin !== undefined && alarm.thresholdMax !== undefined && (
` (范围: ${alarm.thresholdMin}~${alarm.thresholdMax})`
)}
</Text>
</View>
)}
</View>
<View className={styles.alarmFooter}>
<Text className={styles.alarmTime}>
{formatRelativeTime(alarm.triggeredAt)}
{' · '}
{formatDateTime(alarm.triggeredAt)}
</Text>
<View className={styles.clipBtn}>
<View
className={`${styles.ackBtn} ${styles.ackBtnClip}`}
onClick={() => handleViewClip(alarm.id)}
>
<Text></Text>
</View>
{alarm.acknowledged ? (
<View className={`${styles.ackBtn} ${styles.ackBtnDone}`}>
<Text></Text>
</View>
) : (
<View
className={`${styles.ackBtn} ${styles.ackBtnPrimary}`}
onClick={() => handleAck(alarm.id)}
>
<Text></Text>
</View>
)}
</View>
</View>
</View>
);
})}
</View>
)}
</View>
);
};
export default AlertsPage;
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '仪表盘',
enablePullDownRefresh: true,
});
@@ -0,0 +1,303 @@
@use '@/styles/variables.scss' as *;
.dashboardPage {
min-height: 100vh;
background: $color-bg-page;
padding-bottom: $spacing-xl;
}
.header {
background: linear-gradient(135deg, $color-primary 0%, $color-primary-dark 100%);
padding: $spacing-lg $spacing-lg $spacing-xl;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.headerLeft {
display: flex;
flex-direction: column;
}
.greeting {
font-size: $font-size-sm;
color: rgba(255, 255, 255, 0.8);
margin-bottom: $spacing-xs;
}
.userName {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: $color-text-white;
}
.settingsBtn {
width: 72rpx;
height: 72rpx;
border-radius: $radius-round;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
transition: all $transition-base;
&:active {
opacity: 0.8;
transform: scale(0.95);
}
}
.statsGrid {
display: flex;
flex-wrap: wrap;
gap: $spacing-md;
padding: 0 $spacing-lg;
margin-top: -40rpx;
position: relative;
z-index: 1;
}
.statsGrid > view {
width: calc(50% - 12rpx);
}
.section {
margin: $spacing-lg $spacing-lg 0;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-md;
}
.sectionTitle {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $color-text-primary;
}
.sectionAction {
font-size: $font-size-sm;
color: $color-primary;
}
.roomSelector {
display: flex;
gap: $spacing-sm;
overflow-x: auto;
padding-bottom: $spacing-xs;
white-space: nowrap;
}
.roomChip {
display: inline-flex;
align-items: center;
padding: $spacing-xs $spacing-md;
border-radius: $radius-round;
background: $color-bg-card;
border: 2rpx solid $color-border;
font-size: $font-size-sm;
color: $color-text-secondary;
transition: all $transition-base;
white-space: nowrap;
&:active {
opacity: 0.8;
}
}
.roomChipActive {
background: $color-primary;
border-color: $color-primary;
color: $color-text-white;
}
.telemetryGrid {
display: flex;
flex-wrap: wrap;
gap: $spacing-md;
}
.telemetryCard {
width: calc(50% - 12rpx);
background: $color-bg-card;
border-radius: $radius-md;
padding: $spacing-md $spacing-lg;
box-shadow: $shadow-card;
display: flex;
flex-direction: column;
}
.telemetryTop {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-sm;
}
.telemetryLabel {
font-size: $font-size-sm;
color: $color-text-secondary;
}
.telemetryIcon {
font-size: 32rpx;
}
.telemetryValue {
font-size: $font-size-xxl;
font-weight: $font-weight-bold;
color: $color-text-primary;
line-height: 1.2;
}
.telemetryUnit {
font-size: $font-size-sm;
color: $color-text-tertiary;
margin-left: 4rpx;
}
.miniChart {
height: 60rpx;
display: flex;
align-items: flex-end;
gap: 4rpx;
margin-top: $spacing-sm;
}
.bar {
flex: 1;
background: linear-gradient(180deg, $color-primary-light 0%, $color-primary 100%);
border-radius: 4rpx 4rpx 0 0;
min-height: 8rpx;
opacity: 0.7;
transition: height $transition-base;
}
.barActive {
opacity: 1;
}
.loadingWrap {
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.loadingText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.errorWrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.errorIcon {
font-size: 56rpx;
margin-bottom: $spacing-sm;
}
.errorText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.retryBtn {
margin-top: $spacing-md;
padding: $spacing-xs $spacing-lg;
border-radius: $radius-button;
background: $color-primary;
color: $color-text-white;
font-size: $font-size-sm;
}
.alarmPreview {
background: $color-bg-card;
border-radius: $radius-md;
box-shadow: $shadow-card;
overflow: hidden;
}
.alarmItem {
display: flex;
align-items: center;
padding: $spacing-md $spacing-lg;
border-bottom: 2rpx solid $color-divider;
&:last-child {
border-bottom: none;
}
}
.alarmDot {
width: 16rpx;
height: 16rpx;
border-radius: $radius-round;
margin-right: $spacing-md;
flex-shrink: 0;
}
.alarmContent {
flex: 1;
min-width: 0;
}
.alarmTitle {
font-size: $font-size-md;
color: $color-text-primary;
@include text-ellipsis;
}
.alarmTime {
font-size: $font-size-xs;
color: $color-text-tertiary;
margin-top: 4rpx;
}
.alarmSeverity {
font-size: $font-size-xs;
padding: 4rpx $spacing-sm;
border-radius: $radius-xs;
margin-left: $spacing-sm;
}
.emptyWrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.emptyIcon {
font-size: 64rpx;
margin-bottom: $spacing-sm;
}
.emptyText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.trendCard {
background: $color-bg-card;
border-radius: $radius-md;
padding: $spacing-md $spacing-lg;
box-shadow: $shadow-card;
}
.trendLoading {
display: flex;
align-items: center;
justify-content: center;
height: 200rpx;
}
+387
View File
@@ -0,0 +1,387 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useDidShow, useDidHide, usePullDownRefresh } from '@tarojs/taro';
import styles from './index.module.scss';
import StatCard from '@/components/StatCard';
import { getRooms } from '@/api/rooms';
import { getDevices } from '@/api/devices';
import { getAlarms } from '@/api/alarms';
import { getLatestTelemetry, fetchTrend } from '@/api/telemetry';
import { useStore } from '@/store/useStore';
import { wsManager } from '@/utils/ws';
import {
formatRelativeTime,
metricLabel,
metricUnit,
severityLabel,
severityColor,
formatNumber,
} from '@/utils/format';
import type { Room, Device, Alarm, TelemetryRecord, DashboardStats, TrendPoint } from '@/types';
import MiniChart from '@/components/MiniChart';
const DashboardPage: React.FC = () => {
const { user, token } = useStore();
const [rooms, setRooms] = useState<Room[]>([]);
const [devices, setDevices] = useState<Device[]>([]);
const [alarms, setAlarms] = useState<Alarm[]>([]);
const [selectedRoomId, setSelectedRoomId] = useState<string>('');
const [telemetry, setTelemetry] = useState<TelemetryRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [stats, setStats] = useState<DashboardStats>({
roomCount: 0,
deviceCount: 0,
onlineDeviceCount: 0,
alarmCount: 0,
openAlarmCount: 0,
});
const [chartData, setChartData] = useState<number[]>([]);
const [trend, setTrend] = useState<TrendPoint[]>([]);
const [trendLoading, setTrendLoading] = useState(false);
const fetchData = useCallback(async () => {
try {
setError('');
const [roomsRes, devicesRes, alarmsRes] = await Promise.all([
getRooms().catch(() => [] as Room[]),
getDevices().catch(() => [] as Device[]),
getAlarms({ openOnly: true }).catch(() => [] as Alarm[]),
]);
setRooms(roomsRes);
setDevices(devicesRes);
setAlarms(alarmsRes);
const onlineCount = devicesRes.filter((d) => d.onlineStatus === 'online').length;
const openCount = alarmsRes.filter((a) => a.open).length;
setStats({
roomCount: roomsRes.length,
deviceCount: devicesRes.length,
onlineDeviceCount: onlineCount,
alarmCount: alarmsRes.length,
openAlarmCount: openCount,
});
// 默认选中第一个蚕房
if (roomsRes.length > 0 && !selectedRoomId) {
setSelectedRoomId(roomsRes[0].id);
}
} catch (err) {
console.error('[Dashboard] 数据加载失败:', err);
setError(err instanceof Error ? err.message : '数据加载失败');
} finally {
setLoading(false);
}
}, [selectedRoomId]);
const fetchTelemetry = useCallback(async () => {
if (!selectedRoomId) return;
const roomDevices = devices.filter((d) => d.roomId === selectedRoomId);
if (roomDevices.length === 0) {
setTelemetry([]);
return;
}
try {
const firstDevice = roomDevices[0];
const telemetryRes = await getLatestTelemetry(firstDevice.deviceKey).catch(
() => [] as TelemetryRecord[]
);
setTelemetry(telemetryRes);
// 用遥测数据生成迷你图表数据
const values = telemetryRes.map((t) => t.value);
setChartData(values.length > 0 ? values.slice(-8) : []);
} catch (err) {
console.error('[Dashboard] 遥测数据加载失败:', err);
}
}, [selectedRoomId, devices]);
const loadTrend = useCallback(async () => {
setTrendLoading(true);
try {
const trendData = await fetchTrend(24).catch(() => [] as TrendPoint[]);
setTrend(trendData);
} finally {
setTrendLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
fetchTelemetry();
}, [fetchTelemetry]);
useEffect(() => {
loadTrend();
}, [loadTrend]);
// 30秒轮询刷新
useEffect(() => {
const timer = setInterval(() => {
fetchData();
fetchTelemetry();
loadTrend();
}, 30000);
return () => clearInterval(timer);
}, [fetchData, fetchTelemetry, loadTrend]);
// WebSocket 实时数据
useEffect(() => {
if (!token) return;
const unsub = wsManager.on('telemetry', (data) => {
console.log('[Dashboard] WS telemetry:', data);
fetchData();
});
return () => {
unsub();
};
}, [token, fetchData]);
useDidShow(() => {
fetchData();
});
useDidHide(() => {
// 页面隐藏时不需要额外操作,定时器在 unmount 时清理
});
usePullDownRefresh(() => {
Promise.all([fetchData(), loadTrend()]).then(() => {
Taro.stopPullDownRefresh();
});
});
const handleSettings = () => {
Taro.navigateTo({ url: '/pages/settings/index' });
};
const handleRoomTap = (roomId: string) => {
setSelectedRoomId(roomId);
fetchTelemetry();
};
const handleRoomDetail = (roomId: string) => {
Taro.navigateTo({ url: `/pages/rooms/detail/index?id=${roomId}` });
};
const handleRetry = () => {
setLoading(true);
fetchData();
};
const handleAlarmTap = () => {
Taro.switchTab({ url: '/pages/alerts/index' });
};
const selectedRoom = rooms.find((r) => r.id === selectedRoomId);
const recentAlarms = alarms.slice(0, 3);
const maxChartValue = Math.max(...chartData, 1);
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 6) return '凌晨好';
if (hour < 12) return '早上好';
if (hour < 14) return '中午好';
if (hour < 18) return '下午好';
return '晚上好';
};
if (loading && stats.roomCount === 0) {
return (
<View className={styles.dashboardPage}>
<View className={styles.loadingWrap}>
<Text className={styles.loadingText}>...</Text>
</View>
</View>
);
}
if (error && stats.roomCount === 0) {
return (
<View className={styles.dashboardPage}>
<View className={styles.errorWrap}>
<Text className={styles.errorIcon}></Text>
<Text className={styles.errorText}>{error}</Text>
<View className={styles.retryBtn} onClick={handleRetry}>
<Text style={{ color: '#fff' }}></Text>
</View>
</View>
</View>
);
}
return (
<View className={styles.dashboardPage}>
<View className={styles.header}>
<View className={styles.headerLeft}>
<Text className={styles.greeting}>{getGreeting()}</Text>
<Text className={styles.userName}>{user?.fullName || user?.username || '管理员'}</Text>
</View>
<View className={styles.settingsBtn} onClick={handleSettings}>
<Text></Text>
</View>
</View>
{/* 统计卡片 */}
<View className={styles.statsGrid}>
<StatCard
label="蚕房数量"
value={stats.roomCount}
unit="间"
icon="🏠"
color="#10b981"
/>
<StatCard
label="设备在线"
value={`${stats.onlineDeviceCount}/${stats.deviceCount}`}
icon="📡"
color="#165dff"
/>
<StatCard
label="活跃告警"
value={stats.openAlarmCount}
unit="条"
icon="🔔"
color="#f53f3f"
trend={stats.openAlarmCount > 0 ? '需处理' : '正常'}
trendType={stats.openAlarmCount > 0 ? 'up' : 'down'}
onClick={handleAlarmTap}
/>
<StatCard
label="设备总数"
value={stats.deviceCount}
unit="台"
icon="📊"
color="#ff7d00"
/>
</View>
{/* 24小时温度趋势 */}
<View className={styles.section}>
<View className={styles.sectionHeader}>
<Text className={styles.sectionTitle}>24</Text>
</View>
<View className={styles.trendCard}>
{trendLoading && trend.length === 0 ? (
<View className={styles.trendLoading}>
<Text className={styles.loadingText}>...</Text>
</View>
) : (
<MiniChart data={trend} metric="temp" color="#f53f3f" height={200} />
)}
</View>
</View>
{/* 蚕房选择器 */}
{rooms.length > 0 && (
<View className={styles.section}>
<View className={styles.sectionHeader}>
<Text className={styles.sectionTitle}></Text>
{selectedRoom && (
<Text
className={styles.sectionAction}
onClick={() => handleRoomDetail(selectedRoom.id)}
>
>
</Text>
)}
</View>
<ScrollView scrollX className={styles.roomSelector}>
{rooms.map((room) => (
<View
key={room.id}
className={`${styles.roomChip} ${selectedRoomId === room.id ? styles.roomChipActive : ''}`}
onClick={() => handleRoomTap(room.id)}
>
{room.name}
</View>
))}
</ScrollView>
</View>
)}
{/* 遥测数据 */}
{telemetry.length > 0 ? (
<View className={styles.section}>
<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>
<View className={styles.miniChart}>
{chartData.map((v, i) => (
<View
key={i}
className={`${styles.bar} ${i === chartData.length - 1 ? styles.barActive : ''}`}
style={{ height: `${Math.max((v / maxChartValue) * 100, 10)}%` }}
/>
))}
</View>
</View>
))}
</View>
</View>
) : (
!loading && (
<View className={styles.section}>
<View className={styles.emptyWrap}>
<Text className={styles.emptyIcon}>📭</Text>
<Text className={styles.emptyText}></Text>
</View>
</View>
)
)}
{/* 最近告警 */}
<View className={styles.section}>
<View className={styles.sectionHeader}>
<Text className={styles.sectionTitle}></Text>
<Text className={styles.sectionAction} onClick={handleAlarmTap}> ></Text>
</View>
{recentAlarms.length > 0 ? (
<View className={styles.alarmPreview}>
{recentAlarms.map((alarm) => (
<View key={alarm.id} className={styles.alarmItem} onClick={handleAlarmTap}>
<View
className={styles.alarmDot}
style={{ background: severityColor(alarm.severity) }}
/>
<View className={styles.alarmContent}>
<Text className={styles.alarmTitle}>{alarm.title || alarm.message || alarm.code || '告警'}</Text>
<Text className={styles.alarmTime}>{formatRelativeTime(alarm.triggeredAt)}</Text>
</View>
<View
className={styles.alarmSeverity}
style={{
background: `${severityColor(alarm.severity)}20`,
color: severityColor(alarm.severity),
}}
>
{severityLabel(alarm.severity)}
</View>
</View>
))}
</View>
) : (
<View className={styles.emptyWrap}>
<Text className={styles.emptyIcon}></Text>
<Text className={styles.emptyText}></Text>
</View>
)}
</View>
</View>
);
};
export default DashboardPage;
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '设备控制',
});
@@ -0,0 +1,250 @@
@use '@/styles/variables.scss' as *;
.controlPage {
min-height: 100vh;
background: $color-bg-page;
padding: $spacing-md $spacing-lg;
padding-bottom: $spacing-xl;
}
// ===== 头部卡片 =====
.headerCard {
background: $color-bg-card;
border-radius: $radius-lg;
padding: $spacing-lg;
margin-bottom: $spacing-md;
box-shadow: $shadow-card;
}
.headerRow {
display: flex;
justify-content: space-between;
align-items: center;
}
.headerInfo {
flex: 1;
min-width: 0;
}
.deviceName {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $color-text-primary;
@include text-ellipsis;
}
.deviceKey {
font-size: $font-size-xs;
color: $color-text-tertiary;
margin-top: 4rpx;
}
.powerRow {
display: flex;
align-items: center;
gap: $spacing-xs;
flex-shrink: 0;
}
.powerLabel {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
// ===== 通用卡片 =====
.card {
background: $color-bg-card;
border-radius: $radius-lg;
padding: $spacing-lg;
margin-bottom: $spacing-md;
box-shadow: $shadow-card;
}
.sectionTitle {
font-size: $font-size-md;
font-weight: $font-weight-semibold;
color: $color-text-primary;
margin-bottom: $spacing-md;
}
// ===== 控制面板 =====
.controlLabel {
font-size: $font-size-sm;
color: $color-text-tertiary;
margin-bottom: $spacing-sm;
margin-top: $spacing-sm;
}
.segmentGroup {
display: flex;
gap: $spacing-xs;
margin-bottom: $spacing-sm;
}
.segmentBtn {
flex: 1;
height: $button-height-sm;
border-radius: $radius-sm;
background: $color-bg-hover;
border: 2rpx solid $color-border;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-size-sm;
color: $color-text-secondary;
transition: all $transition-base;
white-space: nowrap;
&:active {
opacity: 0.85;
transform: scale(0.98);
}
}
.segmentBtnActive {
background: linear-gradient(135deg, $color-primary 0%, $color-primary-light 100%);
border-color: $color-primary;
color: $color-text-white;
}
.divider {
height: 2rpx;
background: $color-divider;
margin: $spacing-md 0;
}
.tempRow {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.tempInput {
flex: 1;
height: $button-height-sm;
background: $color-bg-hover;
border: 2rpx solid $color-border;
border-radius: $radius-sm;
padding: 0 $spacing-md;
font-size: $font-size-md;
color: $color-text-primary;
box-sizing: border-box;
}
.sendBtn {
padding: 0 $spacing-lg;
height: $button-height-sm;
border-radius: $radius-button;
background: linear-gradient(135deg, $color-primary 0%, $color-primary-light 100%);
color: $color-text-white;
font-size: $font-size-sm;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
&:active {
opacity: 0.85;
transform: scale(0.98);
}
}
.sendBtnDisabled {
opacity: 0.5;
}
.actionRow {
display: flex;
gap: $spacing-sm;
margin-bottom: $spacing-sm;
}
.actionBtn {
flex: 1;
height: $button-height-sm;
border-radius: $radius-button;
background: $color-bg-hover;
color: $color-text-secondary;
border: 2rpx solid $color-border;
font-size: $font-size-sm;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
&:active {
opacity: 0.85;
transform: scale(0.98);
}
}
.actionBtnDisabled {
opacity: 0.5;
}
// ===== 实时数据 =====
.metricsGrid {
display: flex;
flex-wrap: wrap;
gap: $spacing-sm;
}
.metricCard {
flex: 0 0 calc(50% - #{$spacing-sm} / 2);
background: $color-bg-card;
border-radius: $radius-md;
padding: $spacing-md;
border-left: 6rpx solid $color-primary;
box-shadow: $shadow-card;
box-sizing: border-box;
}
.metricName {
font-size: $font-size-xs;
color: $color-text-tertiary;
margin-bottom: $spacing-xs;
}
.metricValue {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $color-text-primary;
}
.metricUnit {
font-size: $font-size-sm;
font-weight: $font-weight-normal;
color: $color-text-tertiary;
}
// ===== 空状态/加载 =====
.emptyWrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 0;
}
.emptyIcon {
font-size: 80rpx;
margin-bottom: $spacing-md;
}
.emptyText {
font-size: $font-size-md;
color: $color-text-tertiary;
}
.loadingWrap {
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.loadingText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
+239
View File
@@ -0,0 +1,239 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { View, Text, ScrollView, Switch, Input } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import styles from './index.module.scss';
import { sendControl } from '@/api/devices';
import { getTelemetry, normalizeRealtime } from '@/api/telemetry';
import { formatNumber } from '@/utils/format';
import type { RealtimeMetric } from '@/types';
const MODES = [
{ value: 'cool', label: '制冷' },
{ value: 'heat', label: '制热' },
{ value: 'fan', label: '通风' },
{ value: 'auto', label: '自动' },
];
const SPEEDS = [
{ value: '0', label: '自动' },
{ value: '1', label: '低' },
{ value: '2', label: '中' },
{ value: '3', label: '高' },
];
const ControlPage: React.FC = () => {
const router = useRouter();
const deviceKey = router.params.deviceKey || '';
const deviceName = router.params.deviceName || '';
const [loading, setLoading] = useState(true);
const [metrics, setMetrics] = useState<RealtimeMetric[]>([]);
const [isOn, setIsOn] = useState(false);
const [sending, setSending] = useState(false);
const [mode, setMode] = useState('cool');
const [speed, setSpeed] = useState('1');
const [tempValue, setTempValue] = useState('26');
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const loadData = useCallback(async () => {
try {
const telemetryData = await getTelemetry({ deviceKey, limit: 50 }).catch(
() => [] as never[]
);
setMetrics(normalizeRealtime(telemetryData));
} catch {
// ignore
} finally {
setLoading(false);
}
}, [deviceKey]);
useEffect(() => {
loadData();
timerRef.current = setInterval(loadData, 30000);
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, [loadData]);
const handleSend = async (action: string, value?: string | number) => {
setSending(true);
Taro.showLoading({ title: '发送指令...' });
try {
await sendControl({ deviceKey, action, value });
if (action === 'power') {
setIsOn(value === 'on');
}
Taro.showToast({ title: `命令已发送: ${action}`, icon: 'success' });
} catch (err) {
Taro.showToast({
title: err instanceof Error ? `发送失败: ${err.message}` : '发送失败',
icon: 'none',
});
} finally {
setSending(false);
Taro.hideLoading();
}
};
const handleToggle = (newVal: boolean) => {
handleSend('power', newVal ? 'on' : 'off');
};
const getMetricColor = (status: string) => {
if (status === 'danger') return '#f53f3f';
if (status === 'warn') return '#ff7d00';
return '#10b981';
};
if (loading) {
return (
<View className={styles.controlPage}>
<View className={styles.loadingWrap}>
<Text className={styles.loadingText}>...</Text>
</View>
</View>
);
}
return (
<View className={styles.controlPage}>
<ScrollView scrollY style={{ height: '100%' }}>
{/* 头部卡片 */}
<View className={styles.headerCard}>
<View className={styles.headerRow}>
<View className={styles.headerInfo}>
<Text className={styles.deviceName}>{deviceName}</Text>
<Text className={styles.deviceKey}>Key: {deviceKey}</Text>
</View>
<View className={styles.powerRow}>
<Text className={styles.powerLabel}>{isOn ? '开启' : '关闭'}</Text>
<Switch
checked={isOn}
onChange={handleToggle}
disabled={sending}
color="#10b981"
/>
</View>
</View>
</View>
{/* 控制面板 */}
<View className={styles.card}>
<Text className={styles.sectionTitle}></Text>
<Text className={styles.controlLabel}></Text>
<View className={styles.segmentGroup}>
{MODES.map((m) => (
<View
key={m.value}
className={`${styles.segmentBtn} ${mode === m.value ? styles.segmentBtnActive : ''}`}
onClick={() => !sending && setMode(m.value)}
>
<Text>{m.label}</Text>
</View>
))}
</View>
<View className={styles.divider} />
<Text className={styles.controlLabel}></Text>
<View className={styles.segmentGroup}>
{SPEEDS.map((s) => (
<View
key={s.value}
className={`${styles.segmentBtn} ${speed === s.value ? styles.segmentBtnActive : ''}`}
onClick={() => !sending && setSpeed(s.value)}
>
<Text>{s.label}</Text>
</View>
))}
</View>
<View className={styles.divider} />
<Text className={styles.controlLabel}></Text>
<View className={styles.tempRow}>
<Input
className={styles.tempInput}
type="digit"
value={tempValue}
onInput={(e) => setTempValue(e.detail.value)}
placeholder="26"
/>
<View
className={`${styles.sendBtn} ${sending ? styles.sendBtnDisabled : ''}`}
onClick={() => !sending && handleSend('set_temp', parseInt(tempValue, 10) || 26)}
>
<Text></Text>
</View>
</View>
<View className={styles.divider} />
<View className={styles.actionRow}>
<View
className={`${styles.actionBtn} ${sending ? styles.actionBtnDisabled : ''}`}
onClick={() => !sending && handleSend('fan_on')}
>
<Text></Text>
</View>
<View
className={`${styles.actionBtn} ${sending ? styles.actionBtnDisabled : ''}`}
onClick={() => !sending && handleSend('fan_off')}
>
<Text></Text>
</View>
</View>
<View className={styles.actionRow}>
<View
className={`${styles.actionBtn} ${sending ? styles.actionBtnDisabled : ''}`}
onClick={() => !sending && handleSend('dehumidifier_on')}
>
<Text>湿</Text>
</View>
<View
className={`${styles.actionBtn} ${sending ? styles.actionBtnDisabled : ''}`}
onClick={() => !sending && handleSend('dehumidifier_off')}
>
<Text>湿</Text>
</View>
</View>
</View>
{/* 实时数据 */}
<Text className={styles.sectionTitle}></Text>
{metrics.length === 0 ? (
<View className={styles.emptyWrap}>
<Text className={styles.emptyIcon}>📊</Text>
<Text className={styles.emptyText}></Text>
</View>
) : (
<View className={styles.metricsGrid}>
{metrics.map((m) => {
const color = getMetricColor(m.status);
return (
<View
key={m.key}
className={styles.metricCard}
style={{ borderLeftColor: color }}
>
<Text className={styles.metricName}>{m.name}</Text>
<Text className={styles.metricValue} style={{ color }}>
{formatNumber(m.value, 1)}
<Text className={styles.metricUnit}> {m.unit}</Text>
</Text>
</View>
);
})}
</View>
)}
</ScrollView>
</View>
);
};
export default ControlPage;
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '设备',
enablePullDownRefresh: true,
});
+233
View File
@@ -0,0 +1,233 @@
@use '@/styles/variables.scss' as *;
.devicesPage {
min-height: 100vh;
background: $color-bg-page;
padding: $spacing-md $spacing-lg;
padding-bottom: $spacing-xl;
}
.filterBar {
display: flex;
gap: $spacing-sm;
margin-bottom: $spacing-md;
overflow-x: auto;
white-space: nowrap;
}
.filterChip {
display: inline-flex;
align-items: center;
padding: $spacing-xs $spacing-md;
border-radius: $radius-round;
background: $color-bg-card;
border: 2rpx solid $color-border;
font-size: $font-size-sm;
color: $color-text-secondary;
white-space: nowrap;
transition: all $transition-base;
}
.filterChipActive {
background: $color-primary;
border-color: $color-primary;
color: $color-text-white;
}
.deviceList {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.deviceCard {
background: $color-bg-card;
border-radius: $radius-md;
padding: $spacing-lg;
box-shadow: $shadow-card;
}
.deviceHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-md;
}
.deviceLeft {
display: flex;
align-items: center;
gap: $spacing-sm;
flex: 1;
min-width: 0;
}
.deviceIcon {
width: 72rpx;
height: 72rpx;
border-radius: $radius-md;
background: rgba(16, 185, 129, 0.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
flex-shrink: 0;
}
.deviceInfo {
flex: 1;
min-width: 0;
}
.deviceName {
font-size: $font-size-md;
font-weight: $font-weight-medium;
color: $color-text-primary;
@include text-ellipsis;
}
.deviceKey {
font-size: $font-size-xs;
color: $color-text-tertiary;
margin-top: 4rpx;
}
.deviceStatusBadge {
display: flex;
align-items: center;
gap: 4rpx;
padding: 4rpx $spacing-sm;
border-radius: $radius-round;
font-size: $font-size-xs;
white-space: nowrap;
flex-shrink: 0;
}
.statusOnline {
background: rgba(0, 180, 42, 0.1);
color: $color-success;
}
.statusOffline {
background: rgba(134, 144, 156, 0.1);
color: $color-text-tertiary;
}
.statusDot {
width: 12rpx;
height: 12rpx;
border-radius: $radius-round;
}
.deviceMeta {
display: flex;
flex-wrap: wrap;
gap: $spacing-md;
margin-bottom: $spacing-md;
}
.metaItem {
font-size: $font-size-sm;
color: $color-text-secondary;
}
.metaLabel {
color: $color-text-tertiary;
margin-right: 4rpx;
}
.controlRow {
display: flex;
gap: $spacing-sm;
padding-top: $spacing-md;
border-top: 2rpx solid $color-divider;
}
.controlBtn {
flex: 1;
height: $button-height-sm;
border-radius: $radius-button;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-size-sm;
transition: all $transition-base;
white-space: nowrap;
&:active {
opacity: 0.85;
transform: scale(0.98);
}
}
.controlBtnPrimary {
background: linear-gradient(135deg, $color-primary 0%, $color-primary-light 100%);
color: $color-text-white;
}
.controlBtnSecondary {
background: $color-bg-hover;
color: $color-text-secondary;
}
.controlBtnDanger {
background: rgba(245, 63, 63, 0.1);
color: $color-error;
}
.loadingWrap {
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.loadingText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.errorWrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.errorIcon {
font-size: 56rpx;
margin-bottom: $spacing-sm;
}
.errorText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.retryBtn {
margin-top: $spacing-md;
padding: $spacing-xs $spacing-lg;
border-radius: $radius-button;
background: $color-primary;
color: $color-text-white;
font-size: $font-size-sm;
}
.emptyWrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.emptyIcon {
font-size: 80rpx;
margin-bottom: $spacing-md;
}
.emptyText {
font-size: $font-size-md;
color: $color-text-tertiary;
}
+266
View File
@@ -0,0 +1,266 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import styles from './index.module.scss';
import { getDevices } from '@/api/devices';
import { sendControl } from '@/api/devices';
import { getRooms } from '@/api/rooms';
import { deviceKindLabel, onlineStatusLabel, formatRelativeTime } from '@/utils/format';
import type { Device, Room } from '@/types';
const DevicesPage: React.FC = () => {
const [devices, setDevices] = useState<Device[]>([]);
const [rooms, setRooms] = useState<Room[]>([]);
const [filter, setFilter] = useState<string>('all');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const fetchData = useCallback(async () => {
try {
setError('');
const [devicesRes, roomsRes] = await Promise.all([
getDevices().catch(() => [] as Device[]),
getRooms().catch(() => [] as Room[]),
]);
setDevices(devicesRes);
setRooms(roomsRes);
} catch (err) {
console.error('[Devices] 数据加载失败:', err);
setError(err instanceof Error ? err.message : '数据加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
useDidShow(() => {
if (devices.length > 0) fetchData();
});
usePullDownRefresh(() => {
fetchData().then(() => Taro.stopPullDownRefresh());
});
const handleControl = async (device: Device, action: string, value?: string | number) => {
Taro.showLoading({ title: '发送指令...' });
try {
await sendControl({
deviceKey: device.deviceKey,
action,
value,
});
console.log('[Control] 指令发送成功:', device.deviceKey, action);
Taro.showToast({ title: '指令已发送', icon: 'success' });
} catch (err) {
console.error('[Control] 指令发送失败:', err);
Taro.showToast({
title: err instanceof Error ? err.message : '指令发送失败',
icon: 'none',
});
} finally {
Taro.hideLoading();
}
};
const filteredDevices = filter === 'all'
? devices
: filter === 'online'
? devices.filter((d) => d.onlineStatus === 'online')
: filter === 'offline'
? devices.filter((d) => d.onlineStatus !== 'online')
: devices.filter((d) => d.kind === filter);
const filters = [
{ key: 'all', label: '全部' },
{ key: 'online', label: '在线' },
{ key: 'offline', label: '离线' },
{ key: 'sensor', label: '传感器' },
{ key: 'actuator', label: '执行器' },
{ key: 'camera', label: '摄像头' },
];
const getDeviceIcon = (kind: string) => {
if (kind === 'camera') return '📹';
if (kind === 'sensor') return '🌡';
if (kind === 'actuator') return '⚙️';
if (kind === 'gateway') return '🌐';
return '🔌';
};
const getRoomName = (roomId?: string) => {
if (!roomId) return null;
const room = rooms.find((r) => r.id === roomId);
return room ? room.name : null;
};
const isControllable = (kind: string) => {
return ['actuator', 'controller', 'fan', 'ac', 'dehumidifier'].includes(kind);
};
const goToControl = (device: Device) => {
const params = `deviceKey=${encodeURIComponent(device.deviceKey)}&deviceName=${encodeURIComponent(device.name)}`;
Taro.navigateTo({ url: `/pages/devices/control/index?${params}` });
};
if (loading) {
return (
<View className={styles.devicesPage}>
<View className={styles.loadingWrap}>
<Text className={styles.loadingText}>...</Text>
</View>
</View>
);
}
if (error) {
return (
<View className={styles.devicesPage}>
<View className={styles.errorWrap}>
<Text className={styles.errorIcon}></Text>
<Text className={styles.errorText}>{error}</Text>
<View className={styles.retryBtn} onClick={fetchData}>
<Text style={{ color: '#fff' }}></Text>
</View>
</View>
</View>
);
}
return (
<View className={styles.devicesPage}>
{/* 筛选栏 */}
<ScrollView scrollX className={styles.filterBar}>
{filters.map((f) => (
<View
key={f.key}
className={`${styles.filterChip} ${filter === f.key ? styles.filterChipActive : ''}`}
onClick={() => setFilter(f.key)}
>
{f.label}
</View>
))}
</ScrollView>
{filteredDevices.length === 0 ? (
<View className={styles.emptyWrap}>
<Text className={styles.emptyIcon}>📡</Text>
<Text className={styles.emptyText}></Text>
</View>
) : (
<View className={styles.deviceList}>
{filteredDevices.map((device) => {
const roomName = getRoomName(device.roomId);
const isOnline = device.onlineStatus === 'online';
return (
<View key={device.id} className={styles.deviceCard}>
<View className={styles.deviceHeader}>
<View className={styles.deviceLeft}>
<View className={styles.deviceIcon}>
<Text>{getDeviceIcon(device.kind)}</Text>
</View>
<View className={styles.deviceInfo}>
<Text className={styles.deviceName}>{device.name}</Text>
<Text className={styles.deviceKey}>{device.deviceKey}</Text>
</View>
</View>
<View
className={`${styles.deviceStatusBadge} ${isOnline ? styles.statusOnline : styles.statusOffline}`}
>
<View
className={styles.statusDot}
style={{ background: isOnline ? '#00b42a' : '#c9cdd4' }}
/>
{onlineStatusLabel(device.onlineStatus)}
</View>
</View>
<View className={styles.deviceMeta}>
<View className={styles.metaItem}>
<Text className={styles.metaLabel}>:</Text>
<Text>{deviceKindLabel(device.kind)}</Text>
</View>
{device.model && (
<View className={styles.metaItem}>
<Text className={styles.metaLabel}>:</Text>
<Text>{device.model}</Text>
</View>
)}
{roomName && (
<View className={styles.metaItem}>
<Text className={styles.metaLabel}>:</Text>
<Text>{roomName}</Text>
</View>
)}
{device.lastSeen && (
<View className={styles.metaItem}>
<Text className={styles.metaLabel}>线:</Text>
<Text>{formatRelativeTime(device.lastSeen)}</Text>
</View>
)}
</View>
{isOnline && (
<View className={styles.controlRow}>
{isControllable(device.kind) && (
<View
className={`${styles.controlBtn} ${styles.controlBtnPrimary}`}
onClick={() => goToControl(device)}
>
<Text></Text>
</View>
)}
{device.kind === 'actuator' && (
<>
<View
className={`${styles.controlBtn} ${styles.controlBtnSecondary}`}
onClick={() => handleControl(device, 'turn_on')}
>
<Text></Text>
</View>
<View
className={`${styles.controlBtn} ${styles.controlBtnSecondary}`}
onClick={() => handleControl(device, 'turn_off')}
>
<Text></Text>
</View>
</>
)}
{device.kind === 'sensor' && (
<View
className={`${styles.controlBtn} ${styles.controlBtnPrimary}`}
onClick={() => handleControl(device, 'read')}
>
<Text></Text>
</View>
)}
{device.kind === 'camera' && (
<View
className={`${styles.controlBtn} ${styles.controlBtnPrimary}`}
onClick={() => Taro.switchTab({ url: '/pages/video/index' })}
>
<Text></Text>
</View>
)}
{device.kind !== 'sensor' && device.kind !== 'actuator' && device.kind !== 'camera' && !isControllable(device.kind) && (
<View
className={`${styles.controlBtn} ${styles.controlBtnSecondary}`}
onClick={() => handleControl(device, 'status')}
>
<Text></Text>
</View>
)}
</View>
)}
</View>
);
})}
</View>
)}
</View>
);
};
export default DevicesPage;
+4
View File
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '登录',
navigationStyle: 'custom',
});
+120
View File
@@ -0,0 +1,120 @@
@use '@/styles/variables.scss' as *;
.loginPage {
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(160deg, #10b981 0%, #059669 40%, #f5f6f7 40%, #f5f6f7 100%);
padding: 0 $spacing-lg;
}
.header {
padding-top: 140rpx;
padding-bottom: 60rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.logo {
width: 120rpx;
height: 120rpx;
border-radius: $radius-round;
background: rgba(255, 255, 255, 0.25);
display: flex;
align-items: center;
justify-content: center;
font-size: 56rpx;
margin-bottom: $spacing-md;
}
.title {
font-size: $font-size-xxl;
font-weight: $font-weight-bold;
color: $color-text-white;
margin-bottom: $spacing-xs;
}
.subtitle {
font-size: $font-size-sm;
color: rgba(255, 255, 255, 0.85);
}
.formCard {
background: $color-bg-card;
border-radius: $radius-lg;
padding: $spacing-xl $spacing-lg;
box-shadow: $shadow-popup;
margin-top: $spacing-xl;
}
.formItem {
margin-bottom: $spacing-lg;
}
.formLabel {
font-size: $font-size-sm;
color: $color-text-secondary;
margin-bottom: $spacing-sm;
display: block;
}
.inputWrap {
display: flex;
align-items: center;
border-bottom: 2rpx solid $color-border;
padding: $spacing-sm 0;
}
.inputIcon {
font-size: 36rpx;
margin-right: $spacing-sm;
color: $color-text-tertiary;
}
.input {
flex: 1;
font-size: $font-size-md;
color: $color-text-primary;
height: 64rpx;
}
.loginButton {
width: 100%;
height: $button-height-lg;
background: linear-gradient(135deg, $color-primary 0%, $color-primary-light 100%);
border-radius: $radius-button;
display: flex;
align-items: center;
justify-content: center;
margin-top: $spacing-lg;
transition: all $transition-base;
&:active {
opacity: 0.9;
transform: scale(0.98);
}
&:disabled {
opacity: 0.5;
}
}
.loginButtonText {
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $color-text-white;
}
.hint {
text-align: center;
margin-top: $spacing-lg;
font-size: $font-size-xs;
color: $color-text-tertiary;
line-height: $line-height-loose;
}
.defaultCred {
color: $color-primary;
font-weight: $font-weight-medium;
}
+108
View File
@@ -0,0 +1,108 @@
import React, { useState, useEffect } from 'react';
import { View, Text, Input, Button } from '@tarojs/components';
import Taro from '@tarojs/taro';
import styles from './index.module.scss';
import { login } from '@/api/auth';
import { useStore } from '@/store/useStore';
const LoginPage: React.FC = () => {
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('silk@123');
const [loading, setLoading] = useState(false);
const { setAuth, token } = useStore();
useEffect(() => {
// 已登录则跳转仪表盘
if (token) {
Taro.switchTab({ url: '/pages/dashboard/index' });
}
}, [token]);
const handleLogin = async () => {
if (!username.trim() || !password.trim()) {
Taro.showToast({ title: '请输入账号和密码', icon: 'none' });
return;
}
setLoading(true);
try {
const res = await login({ username: username.trim(), password });
setAuth(res.accessToken, res.refreshToken, res.user);
console.log('[Auth] 登录成功:', res.user.username);
Taro.showToast({ title: '登录成功', icon: 'success' });
setTimeout(() => {
Taro.switchTab({ url: '/pages/dashboard/index' });
}, 500);
} catch (err) {
console.error('[Auth] 登录失败:', err);
Taro.showToast({
title: err instanceof Error ? err.message : '登录失败',
icon: 'none',
});
} finally {
setLoading(false);
}
};
return (
<View className={styles.loginPage}>
<View className={styles.header}>
<View className={styles.logo}>
<Text>🐛</Text>
</View>
<Text className={styles.title}></Text>
<Text className={styles.subtitle}></Text>
</View>
<View className={styles.formCard}>
<View className={styles.formItem}>
<Text className={styles.formLabel}></Text>
<View className={styles.inputWrap}>
<Text className={styles.inputIcon}>👤</Text>
<Input
className={styles.input}
type="text"
placeholder="请输入账号"
value={username}
onInput={(e) => setUsername(e.detail.value)}
maxlength={50}
/>
</View>
</View>
<View className={styles.formItem}>
<Text className={styles.formLabel}></Text>
<View className={styles.inputWrap}>
<Text className={styles.inputIcon}>🔒</Text>
<Input
className={styles.input}
password
placeholder="请输入密码"
value={password}
onInput={(e) => setPassword(e.detail.value)}
maxlength={50}
/>
</View>
</View>
<Button
className={styles.loginButton}
loading={loading}
disabled={loading}
onClick={handleLogin}
>
<Text className={styles.loginButtonText}>{loading ? '登录中...' : '登 录'}</Text>
</Button>
<View className={styles.hint}>
<Text>: </Text>
<Text className={styles.defaultCred}>admin</Text>
<Text> / : </Text>
<Text className={styles.defaultCred}>silk@123</Text>
</View>
</View>
</View>
);
};
export default LoginPage;
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '蚕房详情',
enablePullDownRefresh: true,
});
@@ -0,0 +1,244 @@
@use '@/styles/variables.scss' as *;
.detailPage {
min-height: 100vh;
background: $color-bg-page;
padding-bottom: $spacing-xl;
}
.infoCard {
background: linear-gradient(135deg, $color-primary 0%, $color-primary-dark 100%);
padding: $spacing-lg;
color: $color-text-white;
}
.infoHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-md;
}
.infoName {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $color-text-white;
}
.infoStatus {
font-size: $font-size-xs;
padding: 4rpx $spacing-sm;
border-radius: $radius-xs;
background: rgba(255, 255, 255, 0.25);
color: $color-text-white;
white-space: nowrap;
}
.infoMeta {
display: flex;
flex-wrap: wrap;
gap: $spacing-md;
}
.metaItem {
display: flex;
align-items: center;
gap: 4rpx;
font-size: $font-size-sm;
color: rgba(255, 255, 255, 0.9);
}
.section {
margin: $spacing-md $spacing-lg 0;
}
.sectionTitle {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $color-text-primary;
margin-bottom: $spacing-md;
}
.telemetryGrid {
display: flex;
flex-wrap: wrap;
gap: $spacing-md;
}
.telemetryCard {
width: calc(50% - 12rpx);
background: $color-bg-card;
border-radius: $radius-md;
padding: $spacing-md $spacing-lg;
box-shadow: $shadow-card;
}
.telemetryTop {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-sm;
}
.telemetryLabel {
font-size: $font-size-sm;
color: $color-text-secondary;
}
.telemetryIcon {
font-size: 32rpx;
}
.telemetryValue {
font-size: $font-size-xxl;
font-weight: $font-weight-bold;
color: $color-text-primary;
}
.telemetryUnit {
font-size: $font-size-sm;
color: $color-text-tertiary;
margin-left: 4rpx;
}
.telemetryTime {
font-size: $font-size-xs;
color: $color-text-tertiary;
margin-top: $spacing-xs;
}
.deviceList {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.deviceItem {
background: $color-bg-card;
border-radius: $radius-md;
padding: $spacing-md $spacing-lg;
box-shadow: $shadow-card;
display: flex;
align-items: center;
justify-content: space-between;
}
.deviceLeft {
display: flex;
align-items: center;
gap: $spacing-sm;
flex: 1;
min-width: 0;
}
.deviceIcon {
font-size: 36rpx;
flex-shrink: 0;
}
.deviceInfo {
flex: 1;
min-width: 0;
}
.deviceName {
font-size: $font-size-md;
color: $color-text-primary;
@include text-ellipsis;
}
.deviceMeta {
font-size: $font-size-xs;
color: $color-text-tertiary;
margin-top: 4rpx;
}
.deviceStatus {
display: flex;
align-items: center;
gap: 4rpx;
font-size: $font-size-xs;
white-space: nowrap;
}
.statusDot {
width: 12rpx;
height: 12rpx;
border-radius: $radius-round;
}
.loadingWrap {
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.loadingText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.emptyWrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.emptyIcon {
font-size: 64rpx;
margin-bottom: $spacing-sm;
}
.emptyText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.trendCard {
background: $color-bg-card;
border-radius: $radius-md;
padding: $spacing-md $spacing-lg;
box-shadow: $shadow-card;
}
.trendLoading {
display: flex;
align-items: center;
justify-content: center;
height: 200rpx;
}
.trendLabel {
font-size: $font-size-sm;
color: $color-text-tertiary;
margin-top: $spacing-sm;
margin-bottom: $spacing-xs;
display: block;
}
.legendRow {
display: flex;
justify-content: center;
gap: $spacing-xl;
margin-top: $spacing-md;
}
.legendItem {
display: flex;
align-items: center;
gap: $spacing-xs;
}
.legendDot {
width: 16rpx;
height: 16rpx;
border-radius: $radius-round;
}
.legendText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
+278
View File
@@ -0,0 +1,278 @@
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 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 [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] = await Promise.all([
getRoomById(roomId).catch((e) => {
console.error('[RoomDetail] 获取蚕房失败:', e);
return null;
}),
getDevices().catch(() => [] as Device[]),
]);
if (roomRes) {
setRoom(roomRes);
}
const roomDevices = devicesRes.filter((d) => d.roomId === roomId);
setDevices(roomDevices);
// 获取每个设备的最新遥测
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>
);
};
export default RoomDetailPage;
+4
View File
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '蚕房',
enablePullDownRefresh: true,
});
+175
View File
@@ -0,0 +1,175 @@
@use '@/styles/variables.scss' as *;
.roomsPage {
min-height: 100vh;
background: $color-bg-page;
padding: $spacing-md $spacing-lg;
padding-bottom: $spacing-xl;
}
.roomList {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.roomCard {
background: $color-bg-card;
border-radius: $radius-md;
padding: $spacing-lg;
box-shadow: $shadow-card;
transition: all $transition-base;
&:active {
transform: scale(0.98);
opacity: 0.95;
}
}
.roomCardHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-md;
}
.roomNameRow {
display: flex;
align-items: center;
gap: $spacing-sm;
flex: 1;
min-width: 0;
}
.roomIcon {
font-size: 36rpx;
flex-shrink: 0;
}
.roomName {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $color-text-primary;
@include text-ellipsis;
}
.roomStatus {
font-size: $font-size-xs;
padding: 4rpx $spacing-sm;
border-radius: $radius-xs;
white-space: nowrap;
flex-shrink: 0;
margin-left: $spacing-sm;
}
.statusActive {
background: rgba(0, 180, 42, 0.1);
color: $color-success;
}
.statusAlarm {
background: rgba(245, 63, 63, 0.1);
color: $color-error;
}
.statusMaintenance {
background: rgba(255, 125, 0, 0.1);
color: $color-warning;
}
.statusInactive {
background: rgba(134, 144, 156, 0.1);
color: $color-text-tertiary;
}
.roomInfo {
display: flex;
flex-wrap: wrap;
gap: $spacing-md;
margin-bottom: $spacing-md;
}
.infoItem {
display: flex;
align-items: center;
gap: 4rpx;
}
.infoIcon {
font-size: 24rpx;
}
.infoText {
font-size: $font-size-sm;
color: $color-text-secondary;
}
.roomDesc {
font-size: $font-size-sm;
color: $color-text-tertiary;
line-height: $line-height-loose;
@include text-ellipsis-multi(2);
}
.roomCode {
font-size: $font-size-xs;
color: $color-text-tertiary;
margin-top: $spacing-sm;
}
.loadingWrap {
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.loadingText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.errorWrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.errorIcon {
font-size: 56rpx;
margin-bottom: $spacing-sm;
}
.errorText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.retryBtn {
margin-top: $spacing-md;
padding: $spacing-xs $spacing-lg;
border-radius: $radius-button;
background: $color-primary;
color: $color-text-white;
font-size: $font-size-sm;
}
.emptyWrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.emptyIcon {
font-size: 80rpx;
margin-bottom: $spacing-md;
}
.emptyText {
font-size: $font-size-md;
color: $color-text-tertiary;
}
+152
View File
@@ -0,0 +1,152 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import styles from './index.module.scss';
import { getRooms } from '@/api/rooms';
import { getDevices } from '@/api/devices';
import { getAlarms } from '@/api/alarms';
import { roomStatusLabel } from '@/utils/format';
import type { Room, Device, Alarm } from '@/types';
const RoomsPage: React.FC = () => {
const [rooms, setRooms] = useState<Room[]>([]);
const [devices, setDevices] = useState<Device[]>([]);
const [alarms, setAlarms] = useState<Alarm[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const fetchData = useCallback(async () => {
try {
setError('');
const [roomsRes, devicesRes, alarmsRes] = await Promise.all([
getRooms().catch(() => [] as Room[]),
getDevices().catch(() => [] as Device[]),
getAlarms({ openOnly: true }).catch(() => [] as Alarm[]),
]);
setRooms(roomsRes);
setDevices(devicesRes);
setAlarms(alarmsRes);
} catch (err) {
console.error('[Rooms] 数据加载失败:', err);
setError(err instanceof Error ? err.message : '数据加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
useDidShow(() => {
if (rooms.length > 0) fetchData();
});
usePullDownRefresh(() => {
fetchData().then(() => Taro.stopPullDownRefresh());
});
const handleRoomTap = (roomId: string) => {
Taro.navigateTo({ url: `/pages/rooms/detail/index?id=${roomId}` });
};
const getStatusClass = (status?: string) => {
switch (status) {
case 'active':
return styles.statusActive;
case 'alarm':
return styles.statusAlarm;
case 'maintenance':
return styles.statusMaintenance;
default:
return styles.statusInactive;
}
};
if (loading) {
return (
<View className={styles.roomsPage}>
<View className={styles.loadingWrap}>
<Text className={styles.loadingText}>...</Text>
</View>
</View>
);
}
if (error) {
return (
<View className={styles.roomsPage}>
<View className={styles.errorWrap}>
<Text className={styles.errorIcon}></Text>
<Text className={styles.errorText}>{error}</Text>
<View className={styles.retryBtn} onClick={fetchData}>
<Text style={{ color: '#fff' }}></Text>
</View>
</View>
</View>
);
}
if (rooms.length === 0) {
return (
<View className={styles.roomsPage}>
<View className={styles.emptyWrap}>
<Text className={styles.emptyIcon}>🏠</Text>
<Text className={styles.emptyText}></Text>
</View>
</View>
);
}
return (
<View className={styles.roomsPage}>
<View className={styles.roomList}>
{rooms.map((room) => {
const roomDevices = devices.filter((d) => d.roomId === room.id);
const onlineCount = roomDevices.filter((d) => d.onlineStatus === 'online').length;
const roomAlarms = alarms.filter((a) => a.deviceKey && roomDevices.some((d) => d.deviceKey === a.deviceKey));
return (
<View key={room.id} className={styles.roomCard} onClick={() => handleRoomTap(room.id)}>
<View className={styles.roomCardHeader}>
<View className={styles.roomNameRow}>
<Text className={styles.roomIcon}>🏠</Text>
<Text className={styles.roomName}>{room.name}</Text>
</View>
<View className={`${styles.roomStatus} ${getStatusClass(room.status)}`}>
{roomStatusLabel(room.status)}
</View>
</View>
<View className={styles.roomInfo}>
<View className={styles.infoItem}>
<Text className={styles.infoIcon}>📡</Text>
<Text className={styles.infoText}> {onlineCount}/{roomDevices.length}</Text>
</View>
{roomAlarms.length > 0 && (
<View className={styles.infoItem}>
<Text className={styles.infoIcon}>🔔</Text>
<Text className={styles.infoText} style={{ color: '#f53f3f' }}> {roomAlarms.length}</Text>
</View>
)}
{room.location && (
<View className={styles.infoItem}>
<Text className={styles.infoIcon}>📍</Text>
<Text className={styles.infoText}>{room.location}</Text>
</View>
)}
</View>
{room.description && (
<Text className={styles.roomDesc}>{room.description}</Text>
)}
{room.code && (
<Text className={styles.roomCode}>: {room.code}</Text>
)}
</View>
);
})}
</View>
</View>
);
};
export default RoomsPage;
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '设置',
});
@@ -0,0 +1,223 @@
@use '@/styles/variables.scss' as *;
.settingsPage {
min-height: 100vh;
background: $color-bg-page;
padding-bottom: $spacing-xl;
}
.profileCard {
background: linear-gradient(135deg, $color-primary 0%, $color-primary-dark 100%);
padding: $spacing-xl $spacing-lg;
display: flex;
align-items: center;
gap: $spacing-md;
}
.avatar {
width: 120rpx;
height: 120rpx;
border-radius: $radius-round;
background: rgba(255, 255, 255, 0.25);
display: flex;
align-items: center;
justify-content: center;
font-size: 56rpx;
flex-shrink: 0;
}
.profileInfo {
flex: 1;
min-width: 0;
}
.profileName {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: $color-text-white;
@include text-ellipsis;
}
.profileRole {
font-size: $font-size-sm;
color: rgba(255, 255, 255, 0.85);
margin-top: 4rpx;
}
.profileEmail {
font-size: $font-size-xs;
color: rgba(255, 255, 255, 0.7);
margin-top: 4rpx;
@include text-ellipsis;
}
.section {
margin: $spacing-md $spacing-lg 0;
}
.sectionTitle {
font-size: $font-size-sm;
color: $color-text-tertiary;
margin-bottom: $spacing-sm;
padding-left: $spacing-xs;
}
.menuList {
background: $color-bg-card;
border-radius: $radius-md;
box-shadow: $shadow-card;
overflow: hidden;
}
.menuItem {
display: flex;
align-items: center;
padding: $spacing-md $spacing-lg;
border-bottom: 2rpx solid $color-divider;
transition: all $transition-base;
&:active {
background: $color-bg-hover;
}
&:last-child {
border-bottom: none;
}
}
.menuIcon {
font-size: 36rpx;
margin-right: $spacing-md;
width: 48rpx;
text-align: center;
flex-shrink: 0;
}
.menuLabel {
flex: 1;
font-size: $font-size-md;
color: $color-text-primary;
}
.menuArrow {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.menuValue {
font-size: $font-size-sm;
color: $color-text-tertiary;
margin-right: $spacing-xs;
}
// 服务器配置卡片
.serverCard {
background: $color-bg-card;
border-radius: $radius-md;
box-shadow: $shadow-card;
padding: $spacing-lg;
}
.serverLabel {
font-size: $font-size-sm;
color: $color-text-secondary;
margin-bottom: $spacing-sm;
display: block;
}
.serverInput {
width: 100%;
height: 80rpx;
background: $color-bg-page;
border-radius: $radius-sm;
padding: 0 $spacing-md;
font-size: $font-size-sm;
color: $color-text-primary;
box-sizing: border-box;
}
.serverHint {
font-size: $font-size-xs;
color: $color-text-tertiary;
line-height: $line-height-normal;
margin-top: $spacing-sm;
margin-bottom: $spacing-md;
}
.serverBtns {
display: flex;
gap: $spacing-sm;
}
.serverBtn {
flex: 1;
height: $button-height-sm;
border-radius: $radius-button;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
line-height: normal;
box-sizing: border-box;
transition: all $transition-base;
&::after {
border: none;
}
&:active {
opacity: 0.85;
transform: scale(0.98);
}
}
.serverBtnPrimary {
background: $color-primary;
}
.serverBtnDefault {
background: $color-bg-page;
}
.serverBtnText {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $color-text-white;
}
.serverBtnTextDefault {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $color-text-secondary;
}
.logoutBtn {
margin: $spacing-xl $spacing-lg 0;
height: $button-height-lg;
border-radius: $radius-button;
background: $color-bg-card;
display: flex;
align-items: center;
justify-content: center;
box-shadow: $shadow-card;
transition: all $transition-base;
&:active {
opacity: 0.85;
transform: scale(0.98);
}
}
.logoutText {
font-size: $font-size-md;
font-weight: $font-weight-medium;
color: $color-error;
}
.versionText {
text-align: center;
margin-top: $spacing-xl;
font-size: $font-size-xs;
color: $color-text-tertiary;
}
+198
View File
@@ -0,0 +1,198 @@
import React, { useState } from 'react';
import { View, Text, Input, Button } from '@tarojs/components';
import Taro from '@tarojs/taro';
import styles from './index.module.scss';
import { useStore } from '@/store/useStore';
import { wsManager } from '@/utils/ws';
import { getServerUrl, setApiBaseUrl } from '@/api/config';
const SettingsPage: React.FC = () => {
const { user, logout } = useStore();
const [serverUrl, setServerUrl] = useState(getServerUrl());
const handleSaveServerUrl = () => {
const trimmed = serverUrl.trim();
if (!trimmed) {
Taro.showToast({ title: '请输入服务器地址', icon: 'none' });
return;
}
// 确保以 /api/v1 结尾
const baseUrl = trimmed.endsWith('/api/v1') ? trimmed : `${trimmed.replace(/\/$/, '')}/api/v1`;
setApiBaseUrl(baseUrl);
Taro.showToast({ title: '已保存,重启小程序后生效', icon: 'none' });
};
const handleResetServerUrl = () => {
setApiBaseUrl('');
setServerUrl(getServerUrl());
Taro.showToast({ title: '已恢复默认地址', icon: 'success' });
};
const handleLogout = () => {
Taro.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
confirmColor: '#f53f3f',
success: (res) => {
if (res.confirm) {
logout();
console.log('[Settings] 用户已退出登录');
}
},
});
};
const handleMenuTap = (action: string) => {
switch (action) {
case 'thresholds':
Taro.navigateTo({ url: '/pages/thresholds/index' });
break;
case 'about':
Taro.showModal({
title: '关于',
content: '蚕房环境监控系统 v1.0.0\n智能蚕业环境监测管理平台',
showCancel: false,
confirmText: '知道了',
});
break;
case 'clearCache':
Taro.showModal({
title: '清除缓存',
content: '确定要清除本地缓存吗?',
success: (res) => {
if (res.confirm) {
// 保留 token、user 和 apiBaseUrl,清除其他缓存
const savedToken = Taro.getStorageSync('token');
const savedRefreshToken = Taro.getStorageSync('refreshToken');
const savedUser = Taro.getStorageSync('user');
const savedApiBaseUrl = Taro.getStorageSync('apiBaseUrl');
Taro.clearStorageSync();
if (savedToken) Taro.setStorageSync('token', savedToken);
if (savedRefreshToken) Taro.setStorageSync('refreshToken', savedRefreshToken);
if (savedUser) Taro.setStorageSync('user', savedUser);
if (savedApiBaseUrl) Taro.setStorageSync('apiBaseUrl', savedApiBaseUrl);
Taro.showToast({ title: '缓存已清除', icon: 'success' });
}
},
});
break;
case 'wsStatus':
const connected = wsManager.isConnected();
Taro.showToast({
title: connected ? 'WebSocket 已连接' : 'WebSocket 未连接',
icon: 'none',
});
break;
default:
break;
}
};
const roleLabel = (role: string) => {
const labels: Record<string, string> = {
admin: '管理员',
operator: '操作员',
viewer: '查看者',
user: '用户',
};
return labels[role] || role;
};
return (
<View className={styles.settingsPage}>
{/* 用户信息卡片 */}
<View className={styles.profileCard}>
<View className={styles.avatar}>
<Text>👤</Text>
</View>
<View className={styles.profileInfo}>
<Text className={styles.profileName}>
{user?.fullName || user?.username || '管理员'}
</Text>
<Text className={styles.profileRole}>{roleLabel(user?.role || 'user')}</Text>
{user?.email && (
<Text className={styles.profileEmail}>{user.email}</Text>
)}
</View>
</View>
{/* 服务器配置 */}
<View className={styles.section}>
<Text className={styles.sectionTitle}></Text>
<View className={styles.serverCard}>
<Text className={styles.serverLabel}>API </Text>
<Input
className={styles.serverInput}
type="text"
placeholder="例如:http://192.168.1.100:3000"
value={serverUrl}
onInput={(e) => setServerUrl(e.detail.value)}
maxlength={200}
/>
<Text className={styles.serverHint}>
/api/v1
</Text>
<View className={styles.serverBtns}>
<Button
className={`${styles.serverBtn} ${styles.serverBtnPrimary}`}
onClick={handleSaveServerUrl}
>
<Text className={styles.serverBtnText}></Text>
</Button>
<Button
className={`${styles.serverBtn} ${styles.serverBtnDefault}`}
onClick={handleResetServerUrl}
>
<Text className={styles.serverBtnTextDefault}></Text>
</Button>
</View>
</View>
</View>
{/* 功能菜单 */}
<View className={styles.section}>
<Text className={styles.sectionTitle}></Text>
<View className={styles.menuList}>
<View className={styles.menuItem} onClick={() => handleMenuTap('thresholds')}>
<Text className={styles.menuIcon}></Text>
<Text className={styles.menuLabel}></Text>
<Text className={styles.menuArrow}></Text>
</View>
<View className={styles.menuItem} onClick={() => handleMenuTap('wsStatus')}>
<Text className={styles.menuIcon}>🔗</Text>
<Text className={styles.menuLabel}></Text>
<Text className={styles.menuValue}></Text>
<Text className={styles.menuArrow}></Text>
</View>
</View>
</View>
{/* 系统设置 */}
<View className={styles.section}>
<Text className={styles.sectionTitle}></Text>
<View className={styles.menuList}>
<View className={styles.menuItem} onClick={() => handleMenuTap('clearCache')}>
<Text className={styles.menuIcon}>🗑</Text>
<Text className={styles.menuLabel}></Text>
<Text className={styles.menuArrow}></Text>
</View>
<View className={styles.menuItem} onClick={() => handleMenuTap('about')}>
<Text className={styles.menuIcon}></Text>
<Text className={styles.menuLabel}></Text>
<Text className={styles.menuValue}>v1.0.0</Text>
<Text className={styles.menuArrow}></Text>
</View>
</View>
</View>
{/* 退出登录 */}
<View className={styles.logoutBtn} onClick={handleLogout}>
<Text className={styles.logoutText}>退</Text>
</View>
<Text className={styles.versionText}> v1.0.0</Text>
</View>
);
};
export default SettingsPage;
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '阈值管理',
enablePullDownRefresh: true,
});
@@ -0,0 +1,335 @@
@use '@/styles/variables.scss' as *;
.thresholdsPage {
min-height: 100vh;
background: $color-bg-page;
padding: $spacing-md $spacing-lg;
padding-bottom: 160rpx;
}
.thresholdList {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.thresholdCard {
background: $color-bg-card;
border-radius: $radius-md;
padding: $spacing-lg;
box-shadow: $shadow-card;
}
.thresholdHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $spacing-md;
}
.thresholdNameRow {
display: flex;
align-items: center;
gap: $spacing-sm;
flex: 1;
min-width: 0;
}
.thresholdIcon {
font-size: 36rpx;
}
.thresholdName {
font-size: $font-size-md;
font-weight: $font-weight-medium;
color: $color-text-primary;
@include text-ellipsis;
}
.enabledBadge {
font-size: $font-size-xs;
padding: 4rpx $spacing-sm;
border-radius: $radius-round;
white-space: nowrap;
flex-shrink: 0;
margin-left: $spacing-sm;
}
.enabledTrue {
background: rgba(0, 180, 42, 0.1);
color: $color-success;
}
.enabledFalse {
background: rgba(134, 144, 156, 0.1);
color: $color-text-tertiary;
}
.thresholdRange {
display: flex;
align-items: center;
gap: $spacing-sm;
margin-bottom: $spacing-md;
}
.rangeValue {
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $color-primary;
}
.rangeSeparator {
font-size: $font-size-md;
color: $color-text-tertiary;
}
.rangeUnit {
font-size: $font-size-sm;
color: $color-text-tertiary;
margin-left: 4rpx;
align-self: center;
}
.thresholdMeta {
display: flex;
flex-wrap: wrap;
gap: $spacing-md;
margin-bottom: $spacing-md;
}
.metaItem {
font-size: $font-size-sm;
color: $color-text-secondary;
}
.metaLabel {
color: $color-text-tertiary;
margin-right: 4rpx;
}
.severityBadge {
font-size: $font-size-xs;
padding: 4rpx $spacing-sm;
border-radius: $radius-xs;
}
.actionRow {
display: flex;
gap: $spacing-sm;
padding-top: $spacing-md;
border-top: 2rpx solid $color-divider;
}
.actionBtn {
flex: 1;
height: $button-height-sm;
border-radius: $radius-button;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-size-sm;
transition: all $transition-base;
white-space: nowrap;
&:active {
opacity: 0.85;
transform: scale(0.98);
}
}
.editBtn {
background: rgba(16, 185, 129, 0.1);
color: $color-primary;
}
.deleteBtn {
background: rgba(245, 63, 63, 0.1);
color: $color-error;
}
.fab {
position: fixed;
right: $spacing-lg;
bottom: 200rpx;
width: 96rpx;
height: 96rpx;
border-radius: $radius-round;
background: linear-gradient(135deg, $color-primary 0%, $color-primary-light 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 48rpx;
color: $color-text-white;
box-shadow: 0 8rpx 24rpx rgba(16, 185, 129, 0.4);
z-index: 100;
transition: all $transition-base;
&:active {
transform: scale(0.9);
}
}
/* 模态弹窗 */
.modalMask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
display: flex;
align-items: flex-end;
}
.modalContent {
width: 100%;
background: $color-bg-card;
border-radius: $radius-xl $radius-xl 0 0;
padding: $spacing-lg;
max-height: 80vh;
overflow-y: auto;
}
.modalHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-lg;
}
.modalTitle {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $color-text-primary;
}
.closeBtn {
font-size: 40rpx;
color: $color-text-tertiary;
padding: $spacing-xs;
}
.formItem {
margin-bottom: $spacing-md;
}
.formLabel {
font-size: $font-size-sm;
color: $color-text-secondary;
margin-bottom: $spacing-sm;
display: block;
}
.formInput {
width: 100%;
height: 80rpx;
border: 2rpx solid $color-border;
border-radius: $radius-sm;
padding: 0 $spacing-md;
font-size: $font-size-md;
color: $color-text-primary;
box-sizing: border-box;
}
.pickerDisplay {
width: 100%;
height: 80rpx;
border: 2rpx solid $color-border;
border-radius: $radius-sm;
padding: 0 $spacing-md;
font-size: $font-size-md;
color: $color-text-primary;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
}
.pickerArrow {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.formRow {
display: flex;
gap: $spacing-md;
}
.formRow > view {
flex: 1;
}
.switchRow {
display: flex;
justify-content: space-between;
align-items: center;
padding: $spacing-md 0;
}
.switchLabel {
font-size: $font-size-md;
color: $color-text-primary;
}
.modalActions {
display: flex;
gap: $spacing-md;
margin-top: $spacing-lg;
}
.modalBtn {
flex: 1;
height: $button-height-lg;
border-radius: $radius-button;
display: flex;
align-items: center;
justify-content: center;
font-size: $font-size-md;
transition: all $transition-base;
&:active {
opacity: 0.85;
transform: scale(0.98);
}
}
.cancelBtn {
background: $color-bg-hover;
color: $color-text-secondary;
}
.confirmBtn {
background: linear-gradient(135deg, $color-primary 0%, $color-primary-light 100%);
color: $color-text-white;
}
.loadingWrap {
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.loadingText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.emptyWrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.emptyIcon {
font-size: 80rpx;
margin-bottom: $spacing-md;
}
.emptyText {
font-size: $font-size-md;
color: $color-text-tertiary;
}
+395
View File
@@ -0,0 +1,395 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, Text, Input, Switch, ScrollView, Picker } from '@tarojs/components';
import Taro, { usePullDownRefresh } from '@tarojs/taro';
import styles from './index.module.scss';
import { getThresholds, createThreshold, updateThreshold, deleteThreshold } from '@/api/thresholds';
import { severityLabel, severityColor, metricLabel, metricUnit } from '@/utils/format';
import type { Threshold, ThresholdInput } from '@/types';
const METRIC_OPTIONS = [
{ value: 'temperature', label: '温度' },
{ value: 'humidity', label: '湿度' },
{ value: 'co2', label: '二氧化碳' },
{ value: 'light', label: '光照' },
{ value: 'pressure', label: '气压' },
{ value: 'pm25', label: 'PM2.5' },
];
type ThresholdWithMetric = Threshold & { metric?: string };
type ThresholdForm = ThresholdInput & { metric?: string };
const ThresholdsPage: React.FC = () => {
const [thresholds, setThresholds] = useState<ThresholdWithMetric[]>([]);
const [loading, setLoading] = useState(true);
const [showModal, setShowModal] = useState(false);
const [editingId, setEditingId] = useState<string>('');
const [togglingId, setTogglingId] = useState<string>('');
const [form, setForm] = useState<ThresholdForm>({
name: '',
metric: 'temperature',
minValue: 0,
maxValue: 100,
debounceSeconds: 30,
severity: 3,
enabled: true,
sensorId: '',
});
const fetchData = useCallback(async () => {
try {
const res = await getThresholds().catch(() => [] as Threshold[]);
setThresholds(res as ThresholdWithMetric[]);
} catch (err) {
console.error('[Thresholds] 数据加载失败:', err);
Taro.showToast({
title: err instanceof Error ? err.message : '数据加载失败',
icon: 'none',
});
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
usePullDownRefresh(() => {
fetchData().then(() => Taro.stopPullDownRefresh());
});
const handleOpenCreate = () => {
setEditingId('');
setForm({
name: '',
metric: 'temperature',
minValue: 0,
maxValue: 100,
debounceSeconds: 30,
severity: 3,
enabled: true,
sensorId: '',
});
setShowModal(true);
};
const handleOpenEdit = (threshold: ThresholdWithMetric) => {
setEditingId(threshold.id);
setForm({
name: threshold.name || '',
metric: threshold.metric || 'temperature',
minValue: threshold.minValue,
maxValue: threshold.maxValue,
debounceSeconds: threshold.debounceSeconds,
severity: threshold.severity,
enabled: threshold.enabled,
sensorId: threshold.sensorId || '',
});
setShowModal(true);
};
const handleToggleEnabled = async (threshold: ThresholdWithMetric) => {
const newVal = !threshold.enabled;
setTogglingId(threshold.id);
try {
await updateThreshold(threshold.id, { enabled: newVal });
console.log('[Thresholds] 切换启用状态:', threshold.id, newVal);
setThresholds((prev) =>
prev.map((t) => (t.id === threshold.id ? { ...t, enabled: newVal } : t))
);
} catch (err) {
console.error('[Thresholds] 切换状态失败:', err);
Taro.showToast({
title: err instanceof Error ? err.message : '切换失败',
icon: 'none',
});
} finally {
setTogglingId('');
}
};
const handleDelete = (id: string) => {
Taro.showModal({
title: '确认删除',
content: '确定要删除此阈值配置吗?',
confirmColor: '#f53f3f',
success: async (res) => {
if (res.confirm) {
Taro.showLoading({ title: '删除中...' });
try {
await deleteThreshold(id);
console.log('[Thresholds] 删除成功:', id);
setThresholds((prev) => prev.filter((t) => t.id !== id));
Taro.showToast({ title: '已删除', icon: 'success' });
} catch (err) {
console.error('[Thresholds] 删除失败:', err);
Taro.showToast({
title: err instanceof Error ? err.message : '删除失败',
icon: 'none',
});
} finally {
Taro.hideLoading();
}
}
},
});
};
const handleSubmit = async () => {
if (form.minValue >= form.maxValue) {
Taro.showToast({ title: '最小值须小于最大值', icon: 'none' });
return;
}
Taro.showLoading({ title: '保存中...' });
try {
const submitData: ThresholdForm = {
...form,
minValue: Number(form.minValue),
maxValue: Number(form.maxValue),
debounceSeconds: Number(form.debounceSeconds),
severity: Number(form.severity),
};
if (editingId) {
const updated = await updateThreshold(editingId, submitData);
console.log('[Thresholds] 更新成功:', editingId);
setThresholds((prev) =>
prev.map((t) => (t.id === editingId ? { ...t, ...updated } as ThresholdWithMetric : t))
);
} else {
const created = await createThreshold(submitData);
console.log('[Thresholds] 创建成功:', created.id);
setThresholds((prev) => [...prev, created as ThresholdWithMetric]);
}
setShowModal(false);
Taro.showToast({ title: '保存成功', icon: 'success' });
} catch (err) {
console.error('[Thresholds] 保存失败:', err);
Taro.showToast({
title: err instanceof Error ? err.message : '保存失败',
icon: 'none',
});
} finally {
Taro.hideLoading();
}
};
const metricIndex = Math.max(
0,
METRIC_OPTIONS.findIndex((o) => o.value === (form.metric || 'temperature'))
);
if (loading) {
return (
<View className={styles.thresholdsPage}>
<View className={styles.loadingWrap}>
<Text className={styles.loadingText}>...</Text>
</View>
</View>
);
}
return (
<View className={styles.thresholdsPage}>
{thresholds.length === 0 ? (
<View className={styles.emptyWrap}>
<Text className={styles.emptyIcon}></Text>
<Text className={styles.emptyText}></Text>
</View>
) : (
<View className={styles.thresholdList}>
{thresholds.map((threshold) => {
const color = severityColor(threshold.severity);
return (
<View key={threshold.id} className={styles.thresholdCard}>
<View className={styles.thresholdHeader}>
<View className={styles.thresholdNameRow}>
<Text className={styles.thresholdIcon}>📊</Text>
<Text className={styles.thresholdName}>
{threshold.name || (threshold.metric ? metricLabel(threshold.metric) : `阈值 ${threshold.id.slice(0, 8)}`)}
</Text>
</View>
<Switch
checked={threshold.enabled}
color="#10b981"
disabled={togglingId === threshold.id}
onChange={() => handleToggleEnabled(threshold)}
/>
</View>
<View className={styles.thresholdRange}>
<Text className={styles.rangeValue}>{threshold.minValue}</Text>
<Text className={styles.rangeSeparator}>~</Text>
<Text className={styles.rangeValue}>{threshold.maxValue}</Text>
{threshold.metric && (
<Text className={styles.rangeUnit}>{metricUnit(threshold.metric)}</Text>
)}
</View>
<View className={styles.thresholdMeta}>
{threshold.metric && (
<View className={styles.metaItem}>
<Text className={styles.metaLabel}>:</Text>
<Text>{metricLabel(threshold.metric)}</Text>
</View>
)}
<View className={styles.metaItem}>
<Text className={styles.metaLabel}>:</Text>
<Text>{threshold.debounceSeconds}</Text>
</View>
<View className={styles.metaItem}>
<Text className={styles.metaLabel}>:</Text>
<View
className={styles.severityBadge}
style={{ background: `${color}20`, color }}
>
{severityLabel(threshold.severity)}
</View>
</View>
{threshold.sensorId && (
<View className={styles.metaItem}>
<Text className={styles.metaLabel}>:</Text>
<Text>{threshold.sensorId}</Text>
</View>
)}
</View>
<View className={styles.actionRow}>
<View className={`${styles.actionBtn} ${styles.editBtn}`} onClick={() => handleOpenEdit(threshold)}>
<Text></Text>
</View>
<View className={`${styles.actionBtn} ${styles.deleteBtn}`} onClick={() => handleDelete(threshold.id)}>
<Text></Text>
</View>
</View>
</View>
);
})}
</View>
)}
{/* 新建按钮 */}
<View className={styles.fab} onClick={handleOpenCreate}>
<Text>+</Text>
</View>
{/* 创建/编辑弹窗 */}
{showModal && (
<View className={styles.modalMask} onClick={() => setShowModal(false)}>
<View className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
<View className={styles.modalHeader}>
<Text className={styles.modalTitle}>{editingId ? '编辑阈值' : '新建阈值'}</Text>
<Text className={styles.closeBtn} onClick={() => setShowModal(false)}></Text>
</View>
<ScrollView scrollY style={{ maxHeight: '60vh' }}>
<View className={styles.formItem}>
<Text className={styles.formLabel}></Text>
<Input
className={styles.formInput}
placeholder="请输入阈值名称"
value={form.name || ''}
onInput={(e) => setForm({ ...form, name: e.detail.value })}
maxlength={50}
/>
</View>
<View className={styles.formItem}>
<Text className={styles.formLabel}></Text>
<Picker
mode='selector'
range={METRIC_OPTIONS.map((o) => o.label)}
value={metricIndex}
onChange={(e) => setForm({ ...form, metric: METRIC_OPTIONS[Number(e.detail.value)].value })}
>
<View className={styles.pickerDisplay}>
<Text>{metricLabel(form.metric || 'temperature')}</Text>
<Text className={styles.pickerArrow}></Text>
</View>
</Picker>
</View>
<View className={styles.formRow}>
<View className={styles.formItem}>
<Text className={styles.formLabel}></Text>
<Input
className={styles.formInput}
type="digit"
placeholder="最小值"
value={String(form.minValue)}
onInput={(e) => setForm({ ...form, minValue: Number(e.detail.value) || 0 })}
/>
</View>
<View className={styles.formItem}>
<Text className={styles.formLabel}></Text>
<Input
className={styles.formInput}
type="digit"
placeholder="最大值"
value={String(form.maxValue)}
onInput={(e) => setForm({ ...form, maxValue: Number(e.detail.value) || 0 })}
/>
</View>
</View>
<View className={styles.formRow}>
<View className={styles.formItem}>
<Text className={styles.formLabel}>()</Text>
<Input
className={styles.formInput}
type="number"
placeholder="防抖秒数"
value={String(form.debounceSeconds)}
onInput={(e) => setForm({ ...form, debounceSeconds: Number(e.detail.value) || 0 })}
/>
</View>
<View className={styles.formItem}>
<Text className={styles.formLabel}> (1-5)</Text>
<Input
className={styles.formInput}
type="number"
placeholder="1=严重 5=信息"
value={String(form.severity)}
onInput={(e) => setForm({ ...form, severity: Number(e.detail.value) || 3 })}
/>
</View>
</View>
<View className={styles.formItem}>
<Text className={styles.formLabel}>ID ()</Text>
<Input
className={styles.formInput}
placeholder="关联的传感器ID"
value={form.sensorId || ''}
onInput={(e) => setForm({ ...form, sensorId: e.detail.value })}
maxlength={100}
/>
</View>
<View className={styles.switchRow}>
<Text className={styles.switchLabel}></Text>
<Switch
checked={form.enabled}
color="#10b981"
onChange={(e) => setForm({ ...form, enabled: e.detail.value })}
/>
</View>
</ScrollView>
<View className={styles.modalActions}>
<View className={`${styles.modalBtn} ${styles.cancelBtn}`} onClick={() => setShowModal(false)}>
<Text></Text>
</View>
<View className={`${styles.modalBtn} ${styles.confirmBtn}`} onClick={handleSubmit}>
<Text></Text>
</View>
</View>
</View>
</View>
)}
</View>
);
};
export default ThresholdsPage;
+4
View File
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '视频监控',
enablePullDownRefresh: true,
});
+280
View File
@@ -0,0 +1,280 @@
@use '@/styles/variables.scss' as *;
.videoPage {
min-height: 100vh;
background: $color-bg-page;
padding: $spacing-md $spacing-lg;
padding-bottom: $spacing-xl;
}
.cameraGrid {
display: flex;
flex-wrap: wrap;
gap: $spacing-md;
}
.cameraCard {
width: 100%;
background: $color-bg-card;
border-radius: $radius-md;
overflow: hidden;
box-shadow: $shadow-card;
transition: all $transition-base;
&:active {
transform: scale(0.98);
opacity: 0.95;
}
}
.cameraThumb {
width: 100%;
height: 320rpx;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.cameraPlaceholder {
font-size: 64rpx;
color: rgba(255, 255, 255, 0.3);
}
.cameraOverlay {
position: absolute;
top: $spacing-sm;
left: $spacing-sm;
display: flex;
align-items: center;
gap: 4rpx;
padding: 4rpx $spacing-sm;
border-radius: $radius-round;
background: rgba(0, 0, 0, 0.5);
font-size: $font-size-xs;
color: $color-text-white;
}
.liveDot {
width: 12rpx;
height: 12rpx;
border-radius: $radius-round;
background: $color-error;
}
.liveDotGray {
background: $color-text-tertiary;
}
.cameraResolution {
position: absolute;
bottom: $spacing-sm;
right: $spacing-sm;
padding: 4rpx $spacing-sm;
border-radius: $radius-xs;
background: rgba(0, 0, 0, 0.5);
font-size: $font-size-xs;
color: $color-text-white;
}
.cameraInfo {
padding: $spacing-md $spacing-lg;
}
.cameraName {
font-size: $font-size-md;
font-weight: $font-weight-medium;
color: $color-text-primary;
@include text-ellipsis;
}
.cameraMeta {
display: flex;
flex-wrap: wrap;
gap: $spacing-md;
margin-top: $spacing-xs;
}
.metaItem {
font-size: $font-size-xs;
color: $color-text-tertiary;
}
.playBtn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100rpx;
height: 100rpx;
border-radius: $radius-round;
background: rgba(16, 185, 129, 0.9);
display: flex;
align-items: center;
justify-content: center;
font-size: 48rpx;
color: $color-text-white;
}
.clipsSection {
margin-top: $spacing-lg;
}
.sectionTitle {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $color-text-primary;
margin-bottom: $spacing-md;
}
.clipList {
display: flex;
flex-direction: column;
gap: $spacing-sm;
}
.clipItem {
background: $color-bg-card;
border-radius: $radius-md;
padding: $spacing-md $spacing-lg;
box-shadow: $shadow-card;
display: flex;
align-items: center;
justify-content: space-between;
}
.clipLeft {
display: flex;
align-items: center;
gap: $spacing-sm;
flex: 1;
min-width: 0;
}
.clipIcon {
font-size: 36rpx;
flex-shrink: 0;
}
.clipInfo {
flex: 1;
min-width: 0;
}
.clipName {
font-size: $font-size-sm;
color: $color-text-primary;
@include text-ellipsis;
}
.clipTime {
font-size: $font-size-xs;
color: $color-text-tertiary;
margin-top: 4rpx;
}
.clipDuration {
font-size: $font-size-xs;
color: $color-primary;
white-space: nowrap;
}
.clipTitleRow {
display: flex;
align-items: center;
gap: $spacing-sm;
}
.clipTag {
font-size: $font-size-xs;
padding: 2rpx $spacing-sm;
border-radius: $radius-xs;
white-space: nowrap;
flex-shrink: 0;
}
.clipTagAlarm {
background: rgba(245, 63, 63, 0.1);
color: $color-error;
}
.clipTagManual {
background: rgba(22, 93, 255, 0.1);
color: #165dff;
}
.clipTagSchedule {
background: rgba(16, 185, 129, 0.1);
color: $color-primary;
}
.clipMetaRow {
display: flex;
flex-wrap: wrap;
gap: 4rpx;
margin-top: 4rpx;
}
.clipMetaText {
font-size: $font-size-xs;
color: $color-text-tertiary;
}
.loadingWrap {
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.loadingText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.errorWrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.errorIcon {
font-size: 56rpx;
margin-bottom: $spacing-sm;
}
.errorText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.retryBtn {
margin-top: $spacing-md;
padding: $spacing-xs $spacing-lg;
border-radius: $radius-button;
background: $color-primary;
color: $color-text-white;
font-size: $font-size-sm;
}
.emptyWrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 0;
}
.emptyIcon {
font-size: 80rpx;
margin-bottom: $spacing-md;
}
.emptyText {
font-size: $font-size-md;
color: $color-text-tertiary;
}
+206
View File
@@ -0,0 +1,206 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import styles from './index.module.scss';
import { getCameras, getVideoClips } from '@/api/video';
import { resolveUrl } from '@/api/config';
import { formatDateTime, formatDuration, formatFileSize } from '@/utils/format';
import type { Camera, VideoClip } from '@/types';
const VideoPage: React.FC = () => {
const [cameras, setCameras] = useState<Camera[]>([]);
const [clips, setClips] = useState<VideoClip[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const fetchData = useCallback(async () => {
try {
setError('');
const [camerasRes, clipsRes] = await Promise.all([
getCameras().catch(() => [] as Camera[]),
getVideoClips({ limit: 10 }).catch(() => [] as VideoClip[]),
]);
setCameras(camerasRes);
setClips(clipsRes);
} catch (err) {
console.error('[Video] 数据加载失败:', err);
setError(err instanceof Error ? err.message : '数据加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
useDidShow(() => {
if (cameras.length > 0) fetchData();
});
usePullDownRefresh(() => {
fetchData().then(() => Taro.stopPullDownRefresh());
});
const handlePlayCamera = (camera: Camera) => {
if (!camera.isOnline) {
Taro.showToast({ title: '摄像头离线', icon: 'none' });
return;
}
Taro.navigateTo({
url: `/pages/video/player/index?id=${camera.id}&name=${encodeURIComponent(camera.name)}`,
});
};
const handlePlayClip = (clip: VideoClip) => {
const playbackUrl = clip.playbackUrl || clip.url;
if (!playbackUrl) {
Taro.showToast({ title: '录像暂不可用', icon: 'none' });
return;
}
Taro.navigateTo({
url: `/pages/video/player/index?url=${encodeURIComponent(resolveUrl(playbackUrl))}&name=${encodeURIComponent('录像回放')}`,
});
};
if (loading) {
return (
<View className={styles.videoPage}>
<View className={styles.loadingWrap}>
<Text className={styles.loadingText}>...</Text>
</View>
</View>
);
}
if (error) {
return (
<View className={styles.videoPage}>
<View className={styles.errorWrap}>
<Text className={styles.errorIcon}></Text>
<Text className={styles.errorText}>{error}</Text>
<View className={styles.retryBtn} onClick={fetchData}>
<Text style={{ color: '#fff' }}></Text>
</View>
</View>
</View>
);
}
return (
<View className={styles.videoPage}>
<ScrollView scrollY style={{ height: 'calc(100vh - 120rpx)' }}>
{cameras.length === 0 && clips.length === 0 ? (
<View className={styles.emptyWrap}>
<Text className={styles.emptyIcon}>📹</Text>
<Text className={styles.emptyText}></Text>
</View>
) : (
<>
{/* 摄像头列表 */}
{cameras.length > 0 && (
<View className={styles.cameraGrid}>
{cameras.map((camera) => (
<View
key={camera.id}
className={styles.cameraCard}
onClick={() => handlePlayCamera(camera)}
>
<View className={styles.cameraThumb}>
<Text className={styles.cameraPlaceholder}>📹</Text>
<View className={styles.cameraOverlay}>
<View
className={`${styles.liveDot} ${!camera.isOnline ? styles.liveDotGray : ''}`}
/>
<Text>{camera.isOnline ? 'LIVE' : '离线'}</Text>
</View>
{camera.resolution && (
<View className={styles.cameraResolution}>{camera.resolution}</View>
)}
{camera.isOnline && (
<View className={styles.playBtn}>
<Text></Text>
</View>
)}
</View>
<View className={styles.cameraInfo}>
<Text className={styles.cameraName}>{camera.name}</Text>
<View className={styles.cameraMeta}>
<Text className={styles.metaItem}>: {camera.code}</Text>
{camera.position && (
<Text className={styles.metaItem}>📍 {camera.position}</Text>
)}
</View>
</View>
</View>
))}
</View>
)}
{/* 录像片段 */}
{clips.length > 0 && (
<View className={styles.clipsSection}>
<Text className={styles.sectionTitle}></Text>
<View className={styles.clipList}>
{clips.map((clip) => {
const triggerLabel =
clip.trigger === 'alarm' ? '告警' :
clip.trigger === 'manual' ? '手动' :
clip.trigger === 'schedule' ? '定时' : '';
const triggerClass =
clip.trigger === 'alarm' ? styles.clipTagAlarm :
clip.trigger === 'manual' ? styles.clipTagManual :
styles.clipTagSchedule;
return (
<View
key={clip.id}
className={styles.clipItem}
onClick={() => handlePlayClip(clip)}
>
<View className={styles.clipLeft}>
<Text className={styles.clipIcon}>🎞</Text>
<View className={styles.clipInfo}>
<View className={styles.clipTitleRow}>
<Text className={styles.clipName}>
#{clip.cameraId}
</Text>
{triggerLabel && (
<View className={`${styles.clipTag} ${triggerClass}`}>
<Text>{triggerLabel}</Text>
</View>
)}
</View>
<Text className={styles.clipTime}>
{formatDateTime(clip.startTime || clip.startAt)}
</Text>
<View className={styles.clipMetaRow}>
{clip.resolution && (
<Text className={styles.clipMetaText}>{clip.resolution}</Text>
)}
{clip.sizeBytes && (
<Text className={styles.clipMetaText}>
· {formatFileSize(clip.sizeBytes)}
</Text>
)}
</View>
</View>
</View>
{(clip.durationSec || clip.duration) && (
<Text className={styles.clipDuration}>
{formatDuration(clip.durationSec || clip.duration || 0)}
</Text>
)}
</View>
);
})}
</View>
</View>
)}
</>
)}
</ScrollView>
</View>
);
};
export default VideoPage;
@@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '视频播放',
navigationBarBackgroundColor: '#000000',
navigationBarTextStyle: 'white',
backgroundColor: '#000000',
});
@@ -0,0 +1,162 @@
@use '@/styles/variables.scss' as *;
.playerPage {
min-height: 100vh;
background: #000;
display: flex;
flex-direction: column;
}
.videoContainer {
width: 100%;
height: 422rpx;
background: #000;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.video {
width: 100%;
height: 100%;
}
.videoPlaceholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.5);
}
.placeholderIcon {
font-size: 80rpx;
margin-bottom: $spacing-md;
}
.placeholderText {
font-size: $font-size-sm;
}
.loadingOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
}
.loadingText {
font-size: $font-size-md;
color: $color-text-white;
}
.controls {
padding: $spacing-lg;
background: #1a1a1a;
}
.cameraTitle {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $color-text-white;
margin-bottom: $spacing-md;
}
.formatSelector {
display: flex;
gap: $spacing-sm;
margin-bottom: $spacing-md;
}
.formatChip {
flex: 1;
text-align: center;
padding: $spacing-sm 0;
border-radius: $radius-sm;
background: #333;
font-size: $font-size-sm;
color: rgba(255, 255, 255, 0.6);
transition: all $transition-base;
white-space: nowrap;
}
.formatChipActive {
background: $color-primary;
color: $color-text-white;
}
.playBtn {
width: 100%;
height: $button-height-lg;
border-radius: $radius-button;
background: linear-gradient(135deg, $color-primary 0%, $color-primary-light 100%);
display: flex;
align-items: center;
justify-content: center;
transition: all $transition-base;
&:active {
opacity: 0.9;
transform: scale(0.98);
}
}
.playBtnText {
font-size: $font-size-md;
font-weight: $font-weight-medium;
color: $color-text-white;
}
.infoSection {
padding: $spacing-lg;
background: #1a1a1a;
margin-top: 2rpx;
}
.infoTitle {
font-size: $font-size-md;
font-weight: $font-weight-medium;
color: $color-text-white;
margin-bottom: $spacing-md;
}
.infoRow {
display: flex;
justify-content: space-between;
padding: $spacing-sm 0;
border-bottom: 2rpx solid #333;
}
.infoLabel {
font-size: $font-size-sm;
color: rgba(255, 255, 255, 0.6);
}
.infoValue {
font-size: $font-size-sm;
color: $color-text-white;
}
.errorWrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.errorIcon {
font-size: 56rpx;
margin-bottom: $spacing-sm;
}
.errorText {
font-size: $font-size-sm;
color: rgba(255, 255, 255, 0.6);
}
+172
View File
@@ -0,0 +1,172 @@
import React, { useState, useEffect } from 'react';
import { View, Text, Video } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import styles from './index.module.scss';
import { getCameras, playCamera } from '@/api/video';
import { resolveUrl } from '@/api/config';
import type { Camera, VideoPlayResponse } from '@/types';
const VideoPlayerPage: React.FC = () => {
const router = useRouter();
const cameraId = router.params.id ? Number(router.params.id) : 0;
const cameraName = router.params.name ? decodeURIComponent(router.params.name) : '视频播放';
const directUrl = router.params.url ? decodeURIComponent(router.params.url) : '';
const [camera, setCamera] = useState<Camera | null>(null);
const [videoUrl, setVideoUrl] = useState<string>(directUrl);
const [format, setFormat] = useState<'hls' | 'flv' | 'webrtc'>('hls');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (directUrl) {
setVideoUrl(resolveUrl(directUrl));
return;
}
const fetchCamera = async () => {
try {
const cameras = await getCameras().catch(() => [] as Camera[]);
const found = cameras.find((c) => c.id === cameraId);
if (found) {
setCamera(found);
const url = found.hlsUrl || found.streamUrl || found.flvUrl;
if (url) {
setVideoUrl(resolveUrl(url));
}
}
} catch (err) {
console.error('[VideoPlayer] 获取摄像头信息失败:', err);
}
};
fetchCamera();
}, [cameraId, directUrl]);
const handlePlay = async () => {
if (!cameraId && !camera) return;
setLoading(true);
setError('');
const streamUrl = resolveUrl(`/api/v1/video/cameras/${cameraId || camera!.id}/live/stream`);
console.log('[VideoPlayer] 获取播放地址成功:', streamUrl);
setVideoUrl(streamUrl);
setLoading(false);
};
const formats: { key: 'hls' | 'flv' | 'webrtc'; label: string }[] = [
{ key: 'hls', label: 'HLS' },
{ key: 'flv', label: 'FLV' },
{ key: 'webrtc', label: 'WebRTC' },
];
const isLive = !directUrl;
return (
<View className={styles.playerPage}>
{/* 视频播放区域 */}
<View className={styles.videoContainer}>
{videoUrl ? (
<Video
className={styles.video}
src={videoUrl}
autoplay
controls
loop={!isLive}
muted={false}
showFullscreenBtn
showPlayBtn
showCenterPlayBtn
objectFit="contain"
onError={(e) => {
console.error('[VideoPlayer] 视频播放错误:', e);
setError('视频播放失败');
}}
/>
) : (
<View className={styles.videoPlaceholder}>
<Text className={styles.placeholderIcon}>📹</Text>
<Text className={styles.placeholderText}>
{loading ? '正在获取视频流...' : '点击下方按钮开始播放'}
</Text>
</View>
)}
{loading && videoUrl && (
<View className={styles.loadingOverlay}>
<Text className={styles.loadingText}>...</Text>
</View>
)}
</View>
{/* 控制区域 */}
{isLive && (
<View className={styles.controls}>
<Text className={styles.cameraTitle}>{camera?.name || cameraName}</Text>
<View className={styles.formatSelector}>
{formats.map((f) => (
<View
key={f.key}
className={`${styles.formatChip} ${format === f.key ? styles.formatChipActive : ''}`}
onClick={() => setFormat(f.key)}
>
<Text>{f.label}</Text>
</View>
))}
</View>
<View
className={styles.playBtn}
onClick={handlePlay}
>
<Text className={styles.playBtnText}>
{loading ? '加载中...' : videoUrl ? '重新播放' : '开始播放'}
</Text>
</View>
</View>
)}
{/* 摄像头信息 */}
{camera && (
<View className={styles.infoSection}>
<Text className={styles.infoTitle}></Text>
<View className={styles.infoRow}>
<Text className={styles.infoLabel}></Text>
<Text className={styles.infoValue}>{camera.code}</Text>
</View>
<View className={styles.infoRow}>
<Text className={styles.infoLabel}></Text>
<Text className={styles.infoValue}>{camera.isOnline ? '在线' : '离线'}</Text>
</View>
{camera.position && (
<View className={styles.infoRow}>
<Text className={styles.infoLabel}></Text>
<Text className={styles.infoValue}>{camera.position}</Text>
</View>
)}
{camera.resolution && (
<View className={styles.infoRow}>
<Text className={styles.infoLabel}></Text>
<Text className={styles.infoValue}>{camera.resolution}</Text>
</View>
)}
{camera.gbDeviceId && (
<View className={styles.infoRow}>
<Text className={styles.infoLabel}>ID</Text>
<Text className={styles.infoValue}>{camera.gbDeviceId}</Text>
</View>
)}
</View>
)}
{error && (
<View className={styles.errorWrap}>
<Text className={styles.errorIcon}></Text>
<Text className={styles.errorText}>{error}</Text>
</View>
)}
</View>
);
};
export default VideoPlayerPage;