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

This commit is contained in:
weijuesen
2026-08-10 22:30:53 +08:00
commit 84abf4454c
358 changed files with 75993 additions and 0 deletions
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '仪表盘',
enablePullDownRefresh: true,
});
@@ -0,0 +1,303 @@
@use '@/styles/variables.scss' as *;
.dashboardPage {
min-height: 100vh;
background: $color-bg-page;
padding-bottom: $spacing-xl;
}
.header {
background: linear-gradient(135deg, $color-primary 0%, $color-primary-dark 100%);
padding: $spacing-lg $spacing-lg $spacing-xl;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.headerLeft {
display: flex;
flex-direction: column;
}
.greeting {
font-size: $font-size-sm;
color: rgba(255, 255, 255, 0.8);
margin-bottom: $spacing-xs;
}
.userName {
font-size: $font-size-xl;
font-weight: $font-weight-semibold;
color: $color-text-white;
}
.settingsBtn {
width: 72rpx;
height: 72rpx;
border-radius: $radius-round;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
transition: all $transition-base;
&:active {
opacity: 0.8;
transform: scale(0.95);
}
}
.statsGrid {
display: flex;
flex-wrap: wrap;
gap: $spacing-md;
padding: 0 $spacing-lg;
margin-top: -40rpx;
position: relative;
z-index: 1;
}
.statsGrid > view {
width: calc(50% - 12rpx);
}
.section {
margin: $spacing-lg $spacing-lg 0;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-md;
}
.sectionTitle {
font-size: $font-size-lg;
font-weight: $font-weight-semibold;
color: $color-text-primary;
}
.sectionAction {
font-size: $font-size-sm;
color: $color-primary;
}
.roomSelector {
display: flex;
gap: $spacing-sm;
overflow-x: auto;
padding-bottom: $spacing-xs;
white-space: nowrap;
}
.roomChip {
display: inline-flex;
align-items: center;
padding: $spacing-xs $spacing-md;
border-radius: $radius-round;
background: $color-bg-card;
border: 2rpx solid $color-border;
font-size: $font-size-sm;
color: $color-text-secondary;
transition: all $transition-base;
white-space: nowrap;
&:active {
opacity: 0.8;
}
}
.roomChipActive {
background: $color-primary;
border-color: $color-primary;
color: $color-text-white;
}
.telemetryGrid {
display: flex;
flex-wrap: wrap;
gap: $spacing-md;
}
.telemetryCard {
width: calc(50% - 12rpx);
background: $color-bg-card;
border-radius: $radius-md;
padding: $spacing-md $spacing-lg;
box-shadow: $shadow-card;
display: flex;
flex-direction: column;
}
.telemetryTop {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-sm;
}
.telemetryLabel {
font-size: $font-size-sm;
color: $color-text-secondary;
}
.telemetryIcon {
font-size: 32rpx;
}
.telemetryValue {
font-size: $font-size-xxl;
font-weight: $font-weight-bold;
color: $color-text-primary;
line-height: 1.2;
}
.telemetryUnit {
font-size: $font-size-sm;
color: $color-text-tertiary;
margin-left: 4rpx;
}
.miniChart {
height: 60rpx;
display: flex;
align-items: flex-end;
gap: 4rpx;
margin-top: $spacing-sm;
}
.bar {
flex: 1;
background: linear-gradient(180deg, $color-primary-light 0%, $color-primary 100%);
border-radius: 4rpx 4rpx 0 0;
min-height: 8rpx;
opacity: 0.7;
transition: height $transition-base;
}
.barActive {
opacity: 1;
}
.loadingWrap {
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.loadingText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.errorWrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.errorIcon {
font-size: 56rpx;
margin-bottom: $spacing-sm;
}
.errorText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.retryBtn {
margin-top: $spacing-md;
padding: $spacing-xs $spacing-lg;
border-radius: $radius-button;
background: $color-primary;
color: $color-text-white;
font-size: $font-size-sm;
}
.alarmPreview {
background: $color-bg-card;
border-radius: $radius-md;
box-shadow: $shadow-card;
overflow: hidden;
}
.alarmItem {
display: flex;
align-items: center;
padding: $spacing-md $spacing-lg;
border-bottom: 2rpx solid $color-divider;
&:last-child {
border-bottom: none;
}
}
.alarmDot {
width: 16rpx;
height: 16rpx;
border-radius: $radius-round;
margin-right: $spacing-md;
flex-shrink: 0;
}
.alarmContent {
flex: 1;
min-width: 0;
}
.alarmTitle {
font-size: $font-size-md;
color: $color-text-primary;
@include text-ellipsis;
}
.alarmTime {
font-size: $font-size-xs;
color: $color-text-tertiary;
margin-top: 4rpx;
}
.alarmSeverity {
font-size: $font-size-xs;
padding: 4rpx $spacing-sm;
border-radius: $radius-xs;
margin-left: $spacing-sm;
}
.emptyWrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $spacing-xl 0;
}
.emptyIcon {
font-size: 64rpx;
margin-bottom: $spacing-sm;
}
.emptyText {
font-size: $font-size-sm;
color: $color-text-tertiary;
}
.trendCard {
background: $color-bg-card;
border-radius: $radius-md;
padding: $spacing-md $spacing-lg;
box-shadow: $shadow-card;
}
.trendLoading {
display: flex;
align-items: center;
justify-content: center;
height: 200rpx;
}
+387
View File
@@ -0,0 +1,387 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useDidShow, useDidHide, usePullDownRefresh } from '@tarojs/taro';
import styles from './index.module.scss';
import StatCard from '@/components/StatCard';
import { getRooms } from '@/api/rooms';
import { getDevices } from '@/api/devices';
import { getAlarms } from '@/api/alarms';
import { getLatestTelemetry, fetchTrend } from '@/api/telemetry';
import { useStore } from '@/store/useStore';
import { wsManager } from '@/utils/ws';
import {
formatRelativeTime,
metricLabel,
metricUnit,
severityLabel,
severityColor,
formatNumber,
} from '@/utils/format';
import type { Room, Device, Alarm, TelemetryRecord, DashboardStats, TrendPoint } from '@/types';
import MiniChart from '@/components/MiniChart';
const DashboardPage: React.FC = () => {
const { user, token } = useStore();
const [rooms, setRooms] = useState<Room[]>([]);
const [devices, setDevices] = useState<Device[]>([]);
const [alarms, setAlarms] = useState<Alarm[]>([]);
const [selectedRoomId, setSelectedRoomId] = useState<string>('');
const [telemetry, setTelemetry] = useState<TelemetryRecord[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [stats, setStats] = useState<DashboardStats>({
roomCount: 0,
deviceCount: 0,
onlineDeviceCount: 0,
alarmCount: 0,
openAlarmCount: 0,
});
const [chartData, setChartData] = useState<number[]>([]);
const [trend, setTrend] = useState<TrendPoint[]>([]);
const [trendLoading, setTrendLoading] = useState(false);
const fetchData = useCallback(async () => {
try {
setError('');
const [roomsRes, devicesRes, alarmsRes] = await Promise.all([
getRooms().catch(() => [] as Room[]),
getDevices().catch(() => [] as Device[]),
getAlarms({ openOnly: true }).catch(() => [] as Alarm[]),
]);
setRooms(roomsRes);
setDevices(devicesRes);
setAlarms(alarmsRes);
const onlineCount = devicesRes.filter((d) => d.onlineStatus === 'online').length;
const openCount = alarmsRes.filter((a) => a.open).length;
setStats({
roomCount: roomsRes.length,
deviceCount: devicesRes.length,
onlineDeviceCount: onlineCount,
alarmCount: alarmsRes.length,
openAlarmCount: openCount,
});
// 默认选中第一个蚕房
if (roomsRes.length > 0 && !selectedRoomId) {
setSelectedRoomId(roomsRes[0].id);
}
} catch (err) {
console.error('[Dashboard] 数据加载失败:', err);
setError(err instanceof Error ? err.message : '数据加载失败');
} finally {
setLoading(false);
}
}, [selectedRoomId]);
const fetchTelemetry = useCallback(async () => {
if (!selectedRoomId) return;
const roomDevices = devices.filter((d) => d.roomId === selectedRoomId);
if (roomDevices.length === 0) {
setTelemetry([]);
return;
}
try {
const firstDevice = roomDevices[0];
const telemetryRes = await getLatestTelemetry(firstDevice.deviceKey).catch(
() => [] as TelemetryRecord[]
);
setTelemetry(telemetryRes);
// 用遥测数据生成迷你图表数据
const values = telemetryRes.map((t) => t.value);
setChartData(values.length > 0 ? values.slice(-8) : []);
} catch (err) {
console.error('[Dashboard] 遥测数据加载失败:', err);
}
}, [selectedRoomId, devices]);
const loadTrend = useCallback(async () => {
setTrendLoading(true);
try {
const trendData = await fetchTrend(24).catch(() => [] as TrendPoint[]);
setTrend(trendData);
} finally {
setTrendLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
fetchTelemetry();
}, [fetchTelemetry]);
useEffect(() => {
loadTrend();
}, [loadTrend]);
// 30秒轮询刷新
useEffect(() => {
const timer = setInterval(() => {
fetchData();
fetchTelemetry();
loadTrend();
}, 30000);
return () => clearInterval(timer);
}, [fetchData, fetchTelemetry, loadTrend]);
// WebSocket 实时数据
useEffect(() => {
if (!token) return;
const unsub = wsManager.on('telemetry', (data) => {
console.log('[Dashboard] WS telemetry:', data);
fetchData();
});
return () => {
unsub();
};
}, [token, fetchData]);
useDidShow(() => {
fetchData();
});
useDidHide(() => {
// 页面隐藏时不需要额外操作,定时器在 unmount 时清理
});
usePullDownRefresh(() => {
Promise.all([fetchData(), loadTrend()]).then(() => {
Taro.stopPullDownRefresh();
});
});
const handleSettings = () => {
Taro.navigateTo({ url: '/pages/settings/index' });
};
const handleRoomTap = (roomId: string) => {
setSelectedRoomId(roomId);
fetchTelemetry();
};
const handleRoomDetail = (roomId: string) => {
Taro.navigateTo({ url: `/pages/rooms/detail/index?id=${roomId}` });
};
const handleRetry = () => {
setLoading(true);
fetchData();
};
const handleAlarmTap = () => {
Taro.switchTab({ url: '/pages/alerts/index' });
};
const selectedRoom = rooms.find((r) => r.id === selectedRoomId);
const recentAlarms = alarms.slice(0, 3);
const maxChartValue = Math.max(...chartData, 1);
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 6) return '凌晨好';
if (hour < 12) return '早上好';
if (hour < 14) return '中午好';
if (hour < 18) return '下午好';
return '晚上好';
};
if (loading && stats.roomCount === 0) {
return (
<View className={styles.dashboardPage}>
<View className={styles.loadingWrap}>
<Text className={styles.loadingText}>...</Text>
</View>
</View>
);
}
if (error && stats.roomCount === 0) {
return (
<View className={styles.dashboardPage}>
<View className={styles.errorWrap}>
<Text className={styles.errorIcon}></Text>
<Text className={styles.errorText}>{error}</Text>
<View className={styles.retryBtn} onClick={handleRetry}>
<Text style={{ color: '#fff' }}></Text>
</View>
</View>
</View>
);
}
return (
<View className={styles.dashboardPage}>
<View className={styles.header}>
<View className={styles.headerLeft}>
<Text className={styles.greeting}>{getGreeting()}</Text>
<Text className={styles.userName}>{user?.fullName || user?.username || '管理员'}</Text>
</View>
<View className={styles.settingsBtn} onClick={handleSettings}>
<Text></Text>
</View>
</View>
{/* 统计卡片 */}
<View className={styles.statsGrid}>
<StatCard
label="蚕房数量"
value={stats.roomCount}
unit="间"
icon="🏠"
color="#10b981"
/>
<StatCard
label="设备在线"
value={`${stats.onlineDeviceCount}/${stats.deviceCount}`}
icon="📡"
color="#165dff"
/>
<StatCard
label="活跃告警"
value={stats.openAlarmCount}
unit="条"
icon="🔔"
color="#f53f3f"
trend={stats.openAlarmCount > 0 ? '需处理' : '正常'}
trendType={stats.openAlarmCount > 0 ? 'up' : 'down'}
onClick={handleAlarmTap}
/>
<StatCard
label="设备总数"
value={stats.deviceCount}
unit="台"
icon="📊"
color="#ff7d00"
/>
</View>
{/* 24小时温度趋势 */}
<View className={styles.section}>
<View className={styles.sectionHeader}>
<Text className={styles.sectionTitle}>24</Text>
</View>
<View className={styles.trendCard}>
{trendLoading && trend.length === 0 ? (
<View className={styles.trendLoading}>
<Text className={styles.loadingText}>...</Text>
</View>
) : (
<MiniChart data={trend} metric="temp" color="#f53f3f" height={200} />
)}
</View>
</View>
{/* 蚕房选择器 */}
{rooms.length > 0 && (
<View className={styles.section}>
<View className={styles.sectionHeader}>
<Text className={styles.sectionTitle}></Text>
{selectedRoom && (
<Text
className={styles.sectionAction}
onClick={() => handleRoomDetail(selectedRoom.id)}
>
>
</Text>
)}
</View>
<ScrollView scrollX className={styles.roomSelector}>
{rooms.map((room) => (
<View
key={room.id}
className={`${styles.roomChip} ${selectedRoomId === room.id ? styles.roomChipActive : ''}`}
onClick={() => handleRoomTap(room.id)}
>
{room.name}
</View>
))}
</ScrollView>
</View>
)}
{/* 遥测数据 */}
{telemetry.length > 0 ? (
<View className={styles.section}>
<View className={styles.telemetryGrid}>
{telemetry.map((item) => (
<View key={`${item.deviceKey}-${item.metric}`} className={styles.telemetryCard}>
<View className={styles.telemetryTop}>
<Text className={styles.telemetryLabel}>{metricLabel(item.metric)}</Text>
<Text className={styles.telemetryIcon}>
{item.metric === 'temperature' ? '🌡' : item.metric === 'humidity' ? '💧' : '📈'}
</Text>
</View>
<View>
<Text className={styles.telemetryValue}>{formatNumber(item.value)}</Text>
<Text className={styles.telemetryUnit}>{metricUnit(item.metric)}</Text>
</View>
<View className={styles.miniChart}>
{chartData.map((v, i) => (
<View
key={i}
className={`${styles.bar} ${i === chartData.length - 1 ? styles.barActive : ''}`}
style={{ height: `${Math.max((v / maxChartValue) * 100, 10)}%` }}
/>
))}
</View>
</View>
))}
</View>
</View>
) : (
!loading && (
<View className={styles.section}>
<View className={styles.emptyWrap}>
<Text className={styles.emptyIcon}>📭</Text>
<Text className={styles.emptyText}></Text>
</View>
</View>
)
)}
{/* 最近告警 */}
<View className={styles.section}>
<View className={styles.sectionHeader}>
<Text className={styles.sectionTitle}></Text>
<Text className={styles.sectionAction} onClick={handleAlarmTap}> ></Text>
</View>
{recentAlarms.length > 0 ? (
<View className={styles.alarmPreview}>
{recentAlarms.map((alarm) => (
<View key={alarm.id} className={styles.alarmItem} onClick={handleAlarmTap}>
<View
className={styles.alarmDot}
style={{ background: severityColor(alarm.severity) }}
/>
<View className={styles.alarmContent}>
<Text className={styles.alarmTitle}>{alarm.title || alarm.message || alarm.code || '告警'}</Text>
<Text className={styles.alarmTime}>{formatRelativeTime(alarm.triggeredAt)}</Text>
</View>
<View
className={styles.alarmSeverity}
style={{
background: `${severityColor(alarm.severity)}20`,
color: severityColor(alarm.severity),
}}
>
{severityLabel(alarm.severity)}
</View>
</View>
))}
</View>
) : (
<View className={styles.emptyWrap}>
<Text className={styles.emptyIcon}></Text>
<Text className={styles.emptyText}></Text>
</View>
)}
</View>
</View>
);
};
export default DashboardPage;