chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user