chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '告警',
|
||||
enablePullDownRefresh: true,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '登录',
|
||||
navigationStyle: 'custom',
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '蚕房',
|
||||
enablePullDownRefresh: true,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '视频监控',
|
||||
enablePullDownRefresh: true,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user