feat(device): 设备列表/添加/解绑/房间管理接入真实接口,移除设备与场景 mock 层
This commit is contained in:
parent
5761a5fed2
commit
cb2dc3d2aa
|
|
@ -1,75 +0,0 @@
|
|||
import { request } from '@/http'
|
||||
import type { Device } from '@/store/deviceSlice'
|
||||
|
||||
const USE_MOCK = true
|
||||
|
||||
const MOCK_DEVICES: Device[] = [
|
||||
{ id: 1, name: '客厅体征监测仪', model: 'ED713', online: true },
|
||||
{ id: 2, name: '卧室体征监测仪', model: 'ED713', online: false },
|
||||
{ id: 3, name: '书房跌倒检测雷达', model: 'ED719', online: true },
|
||||
{ id: 4, name: '走廊跌倒检测雷达', model: 'ED719', online: false }
|
||||
]
|
||||
|
||||
export function getDeviceList(sceneId: number | null): Promise<Device[]> {
|
||||
if (USE_MOCK) {
|
||||
return new Promise((resolve) => setTimeout(() => resolve(MOCK_DEVICES), 500))
|
||||
}
|
||||
return request.get<Device[]>('/device/list', { sceneId })
|
||||
}
|
||||
|
||||
// getVitalSigns / getHeartRateHistory 在 P3 Task 3.2 加入本文件
|
||||
|
||||
const MOCK_VITALS = {
|
||||
heartRate: 72,
|
||||
respiratoryRate: 16,
|
||||
bodyMovement: 3,
|
||||
moveState: 3,
|
||||
onbedStatus: 1,
|
||||
bodyStatus: 2,
|
||||
bodyPosition: 8
|
||||
}
|
||||
|
||||
const heartState: { series: number[]; value: number } = { series: [], value: 72 }
|
||||
|
||||
function driftHeartRate(): number {
|
||||
heartState.value += Math.round((Math.random() - 0.5) * 4)
|
||||
heartState.value = Math.min(90, Math.max(60, heartState.value))
|
||||
return heartState.value
|
||||
}
|
||||
|
||||
for (let i = 0; i < 50; i++) heartState.series.push(driftHeartRate())
|
||||
|
||||
function pushHeartReadings() {
|
||||
const n = 2 + Math.round(Math.random())
|
||||
for (let i = 0; i < n; i++) heartState.series.push(driftHeartRate())
|
||||
if (heartState.series.length > 50) heartState.series = heartState.series.slice(-50)
|
||||
}
|
||||
|
||||
export interface VitalSigns {
|
||||
heartRate: number
|
||||
respiratoryRate: number
|
||||
bodyMovement: number
|
||||
moveState: number
|
||||
onbedStatus: number
|
||||
bodyStatus: number
|
||||
bodyPosition: number
|
||||
deviceId: string
|
||||
}
|
||||
|
||||
export function getVitalSigns(deviceId: string): Promise<VitalSigns> {
|
||||
if (USE_MOCK) {
|
||||
const heartRate = driftHeartRate()
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => resolve({ ...MOCK_VITALS, heartRate, deviceId }), 300)
|
||||
)
|
||||
}
|
||||
return request.get<VitalSigns>('/device/vitals', { deviceId })
|
||||
}
|
||||
|
||||
export function getHeartRateHistory(deviceId: string): Promise<number[]> {
|
||||
if (USE_MOCK) {
|
||||
pushHeartReadings()
|
||||
return new Promise((resolve) => setTimeout(() => resolve([...heartState.series]), 300))
|
||||
}
|
||||
return request.get<number[]>('/device/heart-history', { deviceId })
|
||||
}
|
||||
|
|
@ -1,24 +1,43 @@
|
|||
import { request } from '@/http'
|
||||
|
||||
// 入参对齐 apifox YlHomeDeviceListReq:homeId 与 roomId 至少填一个
|
||||
export interface HomeDeviceListReq {
|
||||
homeId?: string
|
||||
roomId?: string // 传入则只查该房间设备
|
||||
}
|
||||
|
||||
// 入参对齐 apifox YlHomeDeviceAddReq
|
||||
export interface HomeDeviceAddReq {
|
||||
deviceName: string // 设备序列号
|
||||
homeId: string
|
||||
roomId: string
|
||||
}
|
||||
|
||||
// 入参对齐 apifox YlHomeDeviceUnbindReq
|
||||
export interface HomeDeviceUnbindReq {
|
||||
installId: string // 设备安装 ID
|
||||
}
|
||||
|
||||
// 骨架:本期仅锁定接口路径,字段类型联调时修正(spec D7)
|
||||
// 注意与 api/device.ts(mock 设备列表)并存,切换时由页面改引用并删除旧模块
|
||||
export interface HomeDevice {
|
||||
installId: string // 设备安装 ID(unbind 入参,按文档推断)
|
||||
deviceId: string
|
||||
homeId: string
|
||||
roomId: string
|
||||
roomName: string
|
||||
deviceName: string
|
||||
deviceType: string
|
||||
online: boolean
|
||||
}
|
||||
|
||||
export function addHomeDevice(params: Record<string, unknown>): Promise<void> {
|
||||
return request.post('/yanglao/app/ylHomeDevice/add', params)
|
||||
export function getHomeDeviceList(params: HomeDeviceListReq): Promise<HomeDevice[]> {
|
||||
return request.post('/yanglao/app/ylHomeDevice/getList', params as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export function unbindHomeDevice(params: Record<string, unknown>): Promise<void> {
|
||||
return request.post('/yanglao/app/ylHomeDevice/unbind', params)
|
||||
export function addHomeDevice(params: HomeDeviceAddReq): Promise<void> {
|
||||
return request.post('/yanglao/app/ylHomeDevice/add', params as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export function getHomeDeviceList(homeId: string): Promise<HomeDevice[]> {
|
||||
return request.get('/yanglao/app/ylHomeDevice/getList', { homeId })
|
||||
export function unbindHomeDevice(params: HomeDeviceUnbindReq): Promise<void> {
|
||||
return request.post('/yanglao/app/ylHomeDevice/unbind', params as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
import { request } from '@/http'
|
||||
import { storage } from '@/utils/storage'
|
||||
import type { Scene } from '@/store/sceneSlice'
|
||||
|
||||
const USE_MOCK = true
|
||||
|
||||
const SCENE_STORAGE_KEY = 'sceneData'
|
||||
const MOCK_ID_SEED = 9000
|
||||
|
||||
const SEED_SCENES: Scene[] = [
|
||||
{ id: 1, name: '客厅' },
|
||||
{ id: 2, name: '卧室' },
|
||||
{ id: 3, name: '书房' },
|
||||
{ id: 4, name: '走廊' }
|
||||
]
|
||||
|
||||
// 读全量列表;storage 为空时写入种子(首次启动初始化)
|
||||
function loadScenes(): Scene[] {
|
||||
const saved = storage.get<Scene[]>(SCENE_STORAGE_KEY)
|
||||
if (Array.isArray(saved) && saved.length > 0) return saved
|
||||
storage.set(SCENE_STORAGE_KEY, SEED_SCENES)
|
||||
return SEED_SCENES
|
||||
}
|
||||
|
||||
function saveScenes(scenes: Scene[]): void {
|
||||
storage.set(SCENE_STORAGE_KEY, scenes)
|
||||
}
|
||||
|
||||
// mock 异步语义:与真实 API 调用形式一致
|
||||
const delay = <T>(value: T, ms = 200): Promise<T> =>
|
||||
new Promise((resolve) => setTimeout(() => resolve(value), ms))
|
||||
|
||||
export function getSceneList(): Promise<Scene[]> {
|
||||
if (USE_MOCK) {
|
||||
return delay(loadScenes())
|
||||
}
|
||||
return request.get<Scene[]>('/scene/list')
|
||||
}
|
||||
|
||||
export function addScene(name: string): Promise<Scene> {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return Promise.reject(new Error('请输入房间名称'))
|
||||
if (USE_MOCK) {
|
||||
const scenes = loadScenes()
|
||||
if (scenes.some((s) => s.name === trimmed)) {
|
||||
return Promise.reject(new Error('该房间已存在'))
|
||||
}
|
||||
const scene: Scene = { id: MOCK_ID_SEED + scenes.length, name: trimmed }
|
||||
saveScenes([...scenes, scene])
|
||||
return delay(scene)
|
||||
}
|
||||
return request.post<Scene>('/scene/add', { name: trimmed } as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
|
@ -39,6 +39,39 @@
|
|||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
// 安装房间选择:label + 横向 chip 组
|
||||
.roomPicker {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.roomLabel {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
color: $color-text-secondary;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.roomOptions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.roomChip {
|
||||
padding: 12px 32px;
|
||||
background: $color-bg-gray;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $radius-pill;
|
||||
font-size: 26px;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.roomChipActive {
|
||||
background: $color-primary-light-bg;
|
||||
border-color: $color-primary;
|
||||
color: $color-primary-deep;
|
||||
}
|
||||
|
||||
.submitBtn {
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
|
|
@ -49,6 +82,11 @@
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
// 提交中防重复点击的视觉降级
|
||||
.submitBtnDisabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.submitBtnPressed {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,90 @@
|
|||
import { useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { useDidShow } from '@tarojs/taro'
|
||||
import { View, Text, Input } from '@tarojs/components'
|
||||
import { Navbar } from '@/components/navbar'
|
||||
import { Loading } from '@/components/loading'
|
||||
import { StateView } from '@/components/state-view'
|
||||
import { addHomeDevice } from '@/api/homeDevice'
|
||||
import { getHomeList, type HomeInfo } from '@/api/home'
|
||||
import { getRoomList, type HomeRoom } from '@/api/homeRoom'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { navigateBack } from '@/utils/navigation'
|
||||
import styles from './add.module.scss'
|
||||
|
||||
export default function DeviceAdd() {
|
||||
const [deviceId, setDeviceId] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const submitManual = () => {
|
||||
if (!deviceId.trim()) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
const [home, setHome] = useState<HomeInfo | null>(null)
|
||||
const [rooms, setRooms] = useState<HomeRoom[]>([])
|
||||
const [roomId, setRoomId] = useState('')
|
||||
|
||||
// 数据加载体:useDidShow 首次与错误重试共用(state 变化触发 useDidShow 不可行,用计数器驱动 effect)
|
||||
const [reload, setReload] = useState(0)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
|
||||
// 默认家庭 + 房间列表:addHomeDevice 需 homeId + roomId
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setLoadError(false)
|
||||
getHomeList()
|
||||
.then(async (list) => {
|
||||
const first = list[0] ?? null
|
||||
if (cancelled) return
|
||||
setHome(first)
|
||||
if (!first) return
|
||||
const roomList = await getRoomList(first.homeId)
|
||||
if (cancelled) return
|
||||
setRooms(roomList ?? [])
|
||||
setRoomId((prev) => prev || roomList[0]?.roomId || '')
|
||||
})
|
||||
.catch(() => !cancelled && setLoadError(true))
|
||||
.finally(() => {
|
||||
if (cancelled) return
|
||||
setLoading(false)
|
||||
setLoaded(true)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [reload])
|
||||
|
||||
// 从房间管理等页面返回时刷新(房间可能已变更)
|
||||
useDidShow(() => {
|
||||
if (loaded) setReload((n) => n + 1)
|
||||
})
|
||||
|
||||
const submitManual = async () => {
|
||||
if (submitting) return
|
||||
const code = deviceId.trim()
|
||||
if (!code) {
|
||||
showToast('请输入设备编号')
|
||||
return
|
||||
}
|
||||
showToast('设备添加功能即将接入')
|
||||
if (!home || !roomId) {
|
||||
showToast('请选择安装房间')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await addHomeDevice({ deviceName: code, homeId: home.homeId, roomId })
|
||||
showToast('添加成功')
|
||||
navigateBack()
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : '添加失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 点击扫码区拉起相机扫码,识别结果回填到输入框,确认后提交
|
||||
const scanQRCode = async () => {
|
||||
// 测试日志:完整输出扫码回调,用于真机验证扫描功能(上线前移除)
|
||||
console.log('[SCAN] scanCode invoked')
|
||||
try {
|
||||
const res = await Taro.scanCode({ scanType: ['qrCode', 'barCode'] })
|
||||
console.log('[SCAN] success:', JSON.stringify(res))
|
||||
const code = String(res.result ?? '').trim()
|
||||
if (!code) {
|
||||
showToast('未识别到有效编号')
|
||||
|
|
@ -30,12 +92,35 @@ export default function DeviceAdd() {
|
|||
}
|
||||
setDeviceId(code)
|
||||
showToast('已填入扫码结果')
|
||||
} catch (error) {
|
||||
console.log('[SCAN] fail:', JSON.stringify(error))
|
||||
} catch {
|
||||
showToast('扫码取消')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className={styles.page}>
|
||||
<Navbar title="添加设备" autoBack variant="hero" />
|
||||
<View className={styles.stateWrap}><Loading /></View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (loadError || !home) {
|
||||
return (
|
||||
<View className={styles.page}>
|
||||
<Navbar title="添加设备" autoBack variant="hero" />
|
||||
<StateView
|
||||
status="error"
|
||||
mode="list"
|
||||
text={loadError ? '加载失败' : '暂无家庭圈'}
|
||||
retryText={loadError ? '重试' : '返回'}
|
||||
onRetry={() => (loadError ? setReload((n) => n + 1) : navigateBack())}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={styles.page}>
|
||||
<Navbar title="添加设备" autoBack={true} variant="hero" />
|
||||
|
|
@ -43,7 +128,7 @@ export default function DeviceAdd() {
|
|||
<View className={styles.section}>
|
||||
<Text className={styles.sectionTitle}>手动输入</Text>
|
||||
<Text className={styles.sectionDesc}>
|
||||
将智能设备添加至当前家庭圈,可实时查看设备状态和告警信息。
|
||||
将智能设备添加至「{home.name}」,可实时查看设备状态和告警信息。
|
||||
</Text>
|
||||
<Input
|
||||
className={styles.input}
|
||||
|
|
@ -53,8 +138,28 @@ export default function DeviceAdd() {
|
|||
value={deviceId}
|
||||
onInput={(e) => setDeviceId(e.detail.value)}
|
||||
/>
|
||||
<View className={styles.submitBtn} hoverClass={styles.submitBtnPressed} onClick={submitManual}>
|
||||
<Text className={styles.submitBtnText}>提交</Text>
|
||||
{rooms.length > 0 && (
|
||||
<View className={styles.roomPicker}>
|
||||
<Text className={styles.roomLabel}>安装房间</Text>
|
||||
<View className={styles.roomOptions}>
|
||||
{rooms.map((room) => (
|
||||
<View
|
||||
key={room.roomId}
|
||||
className={`${styles.roomChip} ${roomId === room.roomId ? styles.roomChipActive : ''}`}
|
||||
onClick={() => setRoomId(room.roomId)}
|
||||
>
|
||||
<Text>{room.roomName}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<View
|
||||
className={`${styles.submitBtn} ${submitting ? styles.submitBtnDisabled : ''}`}
|
||||
hoverClass={styles.submitBtnPressed}
|
||||
onClick={submitManual}
|
||||
>
|
||||
<Text className={styles.submitBtnText}>{submitting ? '提交中...' : '提交'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,26 +1,14 @@
|
|||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import Taro, { useRouter, useDidShow, useDidHide } from '@tarojs/taro'
|
||||
import { useRouter } from '@tarojs/taro'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Navbar } from '@/components/navbar'
|
||||
import { ListRow, ListRowGroup } from '@/components/list-row'
|
||||
import { Loading } from '@/components/loading'
|
||||
import { StateView } from '@/components/state-view'
|
||||
import { VitalWave } from '@/components/vital-wave'
|
||||
import { getVitalSigns, getHeartRateHistory, type VitalSigns } from '@/api/device'
|
||||
import { MOVE_STATE_MAP, BODY_STATUS_MAP, ONBED_STATUS_MAP, type DeviceModel } from '@/constants/enums'
|
||||
import { ROUTES } from '@/constants/routes'
|
||||
import { navigateTo } from '@/utils/navigation'
|
||||
import type { DeviceModel } from '@/constants/enums'
|
||||
import styles from './detail.module.scss'
|
||||
|
||||
const REFRESH_INTERVAL = 3000
|
||||
|
||||
const METRIC_COLORS = {
|
||||
breath: '#1677ff',
|
||||
motion: '#fa8c16',
|
||||
moveState: '#52c41a',
|
||||
bedState: '#f5222d',
|
||||
bodyState: '#1677ff',
|
||||
bodyPosition: '#52c41a'
|
||||
}
|
||||
|
||||
// 体征数据后端暂无对应接口:详情页先保留设备信息静态展示,
|
||||
// 心率/呼吸等实时体征待接口定型后接入
|
||||
export default function Detail() {
|
||||
const router = useRouter()
|
||||
const params = router.params as { id?: string; name?: string; model?: DeviceModel; online?: string }
|
||||
|
|
@ -30,124 +18,36 @@ export default function Detail() {
|
|||
const deviceModel = params.model ?? ''
|
||||
const online = params.online === '1'
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
const [vitals, setVitals] = useState<VitalSigns>({} as VitalSigns)
|
||||
const [wavePoints, setWavePoints] = useState<number[]>([])
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const valueOf = (v: unknown): string | number =>
|
||||
v === undefined || v === null || v === '' ? '-' : (v as string | number)
|
||||
|
||||
const metrics = useMemo(() => {
|
||||
const v = vitals as unknown as Record<string, unknown>
|
||||
return [
|
||||
{ label: '呼吸率', value: valueOf(v.respiratoryRate), unit: '/min', color: METRIC_COLORS.breath },
|
||||
{ label: '体动', value: valueOf(v.bodyMovement), unit: '', color: METRIC_COLORS.motion },
|
||||
{ label: '运动状态', value: MOVE_STATE_MAP[v.moveState as number] ?? valueOf(v.moveState), unit: '', color: METRIC_COLORS.moveState },
|
||||
{ label: '在床状态', value: ONBED_STATUS_MAP[v.onbedStatus as number] ?? valueOf(v.onbedStatus), unit: '', color: METRIC_COLORS.bedState },
|
||||
{ label: '人体状态', value: BODY_STATUS_MAP[v.bodyStatus as number] ?? valueOf(v.bodyStatus), unit: '', color: METRIC_COLORS.bodyState },
|
||||
{ label: '人体位置', value: valueOf(v.bodyPosition), unit: '分米', color: METRIC_COLORS.bodyPosition }
|
||||
]
|
||||
}, [vitals])
|
||||
|
||||
const fetchVitals = async () => {
|
||||
const [v, history] = await Promise.all([
|
||||
getVitalSigns(deviceId),
|
||||
getHeartRateHistory(deviceId)
|
||||
])
|
||||
setVitals(v)
|
||||
setWavePoints(Array.isArray(history) ? history : [])
|
||||
}
|
||||
|
||||
const loadVitals = async () => {
|
||||
setLoading(true)
|
||||
setLoadError(false)
|
||||
try {
|
||||
await fetchVitals()
|
||||
} catch {
|
||||
setLoadError(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 轮询生命周期与 uni-app 版对齐(I1 修复):
|
||||
// 首次挂载拉数据;useDidShow 启动轮询 / useDidHide 停止轮询——
|
||||
// 页面被压栈后台(navigateTo 其它页)时轮询不空跑,回到前台恢复
|
||||
useEffect(() => {
|
||||
loadVitals()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [deviceId])
|
||||
|
||||
useDidShow(() => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
timerRef.current = setInterval(() => {
|
||||
fetchVitals().catch(() => {})
|
||||
}, REFRESH_INTERVAL)
|
||||
})
|
||||
|
||||
useDidHide(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载兜底清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
}
|
||||
}, [])
|
||||
const goBleConfig = () => navigateTo(ROUTES.DEVICE_BLE_CONFIG)
|
||||
const goWifiConfig = () => navigateTo(ROUTES.DEVICE_BLE_CONFIG)
|
||||
|
||||
return (
|
||||
<View className={styles.page}>
|
||||
<Navbar title={deviceName} variant="hero" />
|
||||
|
||||
{loading ? (
|
||||
<View className={styles.stateWrap}><Loading /></View>
|
||||
) : loadError ? (
|
||||
<StateView status="error" onRetry={loadVitals} />
|
||||
) : (
|
||||
<>
|
||||
<View className={styles.heartCard}>
|
||||
<View className={styles.heartLeft}>
|
||||
<Text className={styles.heartIcon}>❤</Text>
|
||||
<Text className={styles.heartValue}>{valueOf(vitals.heartRate)}</Text>
|
||||
<Text className={styles.heartValue}>--</Text>
|
||||
<Text className={styles.heartUnit}>BPM</Text>
|
||||
<View className={`${styles.badge} ${online ? styles.badgeOn : styles.badgeOff}`}>
|
||||
<View className={`${styles.badgeDot} ${online ? styles.badgeDotOn : styles.badgeDotOff}`} />
|
||||
<Text>{online ? '正常' : '离线'}</Text>
|
||||
<Text>{online ? '在线' : '离线'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className={styles.heartRight}>
|
||||
<Text className={styles.heartHealthLabel}>心率健康</Text>
|
||||
<VitalWave points={wavePoints} canvasId="heartWave" />
|
||||
<Text className={styles.heartHealthLabel}>体征数据接入中</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className={styles.metricGrid}>
|
||||
{metrics.map((m) => (
|
||||
<View key={m.label} className={styles.metricCard}>
|
||||
<Text className={styles.metricLabel}>{m.label}</Text>
|
||||
<View className={styles.metricRow}>
|
||||
<Text className={styles.metricValue} style={{ color: m.color }}>{m.value}</Text>
|
||||
{!!m.unit && <Text className={styles.metricUnit}>{m.unit}</Text>}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className={styles.infoCard}>
|
||||
<ListRowGroup>
|
||||
<ListRow label="设备型号" value={deviceModel || '-'} />
|
||||
<ListRow label="设备 ID" value={deviceId || '-'} />
|
||||
<ListRow label="网络配置" arrow onClick={goWifiConfig} />
|
||||
<ListRow label="设备配置" arrow onClick={goBleConfig} />
|
||||
</ListRowGroup>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@
|
|||
.card {
|
||||
width: 48.5%;
|
||||
margin-bottom: 24px;
|
||||
padding: 36px 20px 28px;
|
||||
padding: 36px 20px 24px;
|
||||
box-sizing: border-box;
|
||||
background: $color-bg-white;
|
||||
border-radius: $radius-lg;
|
||||
|
|
@ -129,6 +129,14 @@
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
// 上半区(图标/名称/状态)点击进详情,下方解绑按钮独立命中
|
||||
.cardTop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
|
|
@ -178,6 +186,27 @@
|
|||
.statusText { font-size: 24px; color: $color-primary-deep; }
|
||||
.statusTextOff { color: $color-text-secondary; }
|
||||
|
||||
// 解绑按钮:卡片底部通栏浅红描边,与详情点击区分
|
||||
.unbindBtn {
|
||||
width: 100%;
|
||||
margin-top: 20px;
|
||||
padding: 12px 0;
|
||||
border: 1px solid $color-danger;
|
||||
border-radius: $radius-pill;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.unbindBtnPressed {
|
||||
background: $color-danger-light-bg;
|
||||
}
|
||||
|
||||
.unbindBtnText {
|
||||
font-size: 24px;
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
// 切换家庭圈弹层内的列表项
|
||||
.sheetList {
|
||||
padding-bottom: 16px;
|
||||
|
|
|
|||
|
|
@ -1,73 +1,111 @@
|
|||
import { useState, useCallback } from 'react'
|
||||
import { useDidShow } from '@tarojs/taro'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { useAppSelector, useAppDispatch } from '@/store/hooks'
|
||||
import { setDeviceList, type Device } from '@/store/deviceSlice'
|
||||
import { Navbar } from '@/components/navbar'
|
||||
import { Loading } from '@/components/loading'
|
||||
import { StateView } from '@/components/state-view'
|
||||
import { BottomSheet } from '@/components/bottom-sheet'
|
||||
import { getDeviceList } from '@/api/device'
|
||||
import { DEVICE_ICON_MAP, type DeviceModel } from '@/constants/enums'
|
||||
import { getHomeDeviceList, unbindHomeDevice, type HomeDevice } from '@/api/homeDevice'
|
||||
import { getHomeList, type HomeInfo } from '@/api/home'
|
||||
import { ROUTES } from '@/constants/routes'
|
||||
import { navigateTo } from '@/utils/navigation'
|
||||
import { getHomeList, type HomeInfo } from '@/api/home'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import styles from './index.module.scss'
|
||||
|
||||
const FALLBACK_ICON = '⌂'
|
||||
|
||||
export default function DeviceList() {
|
||||
const dispatch = useAppDispatch()
|
||||
const scene = useAppSelector((s) => s.scene.current)
|
||||
const deviceList = useAppSelector((s) => s.device.list)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
const [deviceList, setDeviceList] = useState<HomeDevice[]>([])
|
||||
const [unbinding, setUnbinding] = useState(false)
|
||||
|
||||
const [families, setFamilies] = useState<HomeInfo[]>([])
|
||||
const [currentFamily, setCurrentFamily] = useState<HomeInfo | null>(null)
|
||||
const [familySheetOpen, setFamilySheetOpen] = useState(false)
|
||||
const [pendingFamily, setPendingFamily] = useState<HomeInfo | null>(null)
|
||||
|
||||
// 在线状态需每次进入页面都新鲜:useDidShow 覆盖
|
||||
// 首次进入、从详情页 navigateBack 返回、tab 切换回三种场景
|
||||
const loadDevices = useCallback(async () => {
|
||||
// 设备列表随家庭联动:useDidShow 覆盖首次进入/返回/tab 切回三种场景
|
||||
const loadDevices = useCallback(async (homeId?: string) => {
|
||||
const targetHomeId = homeId ?? currentFamily?.homeId
|
||||
if (!targetHomeId) {
|
||||
setDeviceList([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setLoadError(false)
|
||||
try {
|
||||
const data = await getDeviceList(scene?.id ?? null)
|
||||
dispatch(setDeviceList(data))
|
||||
const data = await getHomeDeviceList({ homeId: targetHomeId })
|
||||
setDeviceList(data ?? [])
|
||||
} catch {
|
||||
setLoadError(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [dispatch, scene?.id])
|
||||
}, [currentFamily?.homeId])
|
||||
|
||||
const loadFamilies = useCallback(async () => {
|
||||
try {
|
||||
const list = await getHomeList()
|
||||
setFamilies(list)
|
||||
// 若当前未选中,默认选第一条
|
||||
setCurrentFamily((prev) => prev ?? list[0] ?? null)
|
||||
setCurrentFamily((prev) => {
|
||||
const next = prev ?? list[0] ?? null
|
||||
if (next && next.homeId !== prev?.homeId) loadDevices(next.homeId)
|
||||
return next
|
||||
})
|
||||
} catch {
|
||||
// 静默:家庭圈加载失败不阻断设备列表
|
||||
}
|
||||
}, [])
|
||||
}, [loadDevices])
|
||||
|
||||
useDidShow(() => {
|
||||
loadDevices()
|
||||
loadFamilies()
|
||||
})
|
||||
|
||||
const goDetail = (device: Device) => {
|
||||
const name = encodeURIComponent(device.name)
|
||||
navigateTo(`${ROUTES.DEVICE_DETAIL}?id=${device.id}&name=${name}&model=${device.model}&online=${device.online ? 1 : 0}`)
|
||||
const goDetail = (device: HomeDevice) => {
|
||||
const name = encodeURIComponent(device.deviceName)
|
||||
navigateTo(
|
||||
`${ROUTES.DEVICE_DETAIL}?id=${device.deviceId}&name=${name}&model=${device.deviceType}&online=${device.online ? 1 : 0}`
|
||||
)
|
||||
}
|
||||
const goAdd = () => navigateTo(ROUTES.DEVICE_ADD)
|
||||
const goRoom = () => navigateTo(ROUTES.SCENE_SELECT)
|
||||
|
||||
const iconOf = (model: DeviceModel) => DEVICE_ICON_MAP[model] ?? FALLBACK_ICON
|
||||
// 解绑确认:防误触,成功后原地刷新列表
|
||||
const confirmUnbind = (device: HomeDevice) => {
|
||||
if (unbinding) return
|
||||
Taro.showModal({
|
||||
title: '解绑设备',
|
||||
content: `确定解绑「${device.deviceName}」吗?解绑后需重新添加。`,
|
||||
confirmText: '解绑',
|
||||
confirmColor: '#EE4B4B',
|
||||
success: (res) => {
|
||||
if (!res.confirm) return
|
||||
doUnbind(device)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const doUnbind = async (device: HomeDevice) => {
|
||||
if (!device.installId) {
|
||||
showToast('缺少设备安装 ID,无法解绑')
|
||||
return
|
||||
}
|
||||
setUnbinding(true)
|
||||
try {
|
||||
await unbindHomeDevice({ installId: device.installId })
|
||||
showToast('已解绑')
|
||||
await loadDevices()
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : '解绑失败')
|
||||
} finally {
|
||||
setUnbinding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const openFamilySheet = () => {
|
||||
setPendingFamily(currentFamily)
|
||||
|
|
@ -78,7 +116,10 @@ export default function DeviceList() {
|
|||
setPendingFamily(null)
|
||||
}
|
||||
const confirmFamilySheet = () => {
|
||||
if (pendingFamily) setCurrentFamily(pendingFamily)
|
||||
if (pendingFamily && pendingFamily.homeId !== currentFamily?.homeId) {
|
||||
setCurrentFamily(pendingFamily)
|
||||
loadDevices(pendingFamily.homeId)
|
||||
}
|
||||
closeFamilySheet()
|
||||
}
|
||||
|
||||
|
|
@ -93,7 +134,7 @@ export default function DeviceList() {
|
|||
<View className={styles.familyCard}>
|
||||
<View className={styles.familyRow}>
|
||||
<Text className={styles.familyLabel}>设备应用家庭</Text>
|
||||
<Text className={styles.familyValue}>{currentFamily?.name ?? scene?.name ?? '未选择'}</Text>
|
||||
<Text className={styles.familyValue}>{currentFamily?.name ?? '未选择'}</Text>
|
||||
</View>
|
||||
<View className={styles.familyRow}>
|
||||
<Text className={styles.familyLabel}>关爱对象</Text>
|
||||
|
|
@ -126,17 +167,18 @@ export default function DeviceList() {
|
|||
{loading ? (
|
||||
<View className={styles.stateWrap}><Loading /></View>
|
||||
) : loadError ? (
|
||||
<StateView status="error" onRetry={loadDevices} />
|
||||
<StateView status="error" onRetry={() => loadDevices()} />
|
||||
) : deviceList.length === 0 ? (
|
||||
<StateView status="error" mode="list" text="暂无设备" retryText="添加设备" onRetry={goAdd} />
|
||||
) : (
|
||||
<View className={styles.grid}>
|
||||
{deviceList.map((device) => (
|
||||
<View key={device.id} className={styles.card} onClick={() => goDetail(device)}>
|
||||
<View key={device.installId || device.deviceId} className={styles.card}>
|
||||
<View className={styles.cardTop} onClick={() => goDetail(device)}>
|
||||
<View className={`${styles.icon} ${!device.online ? styles.iconOffline : ''}`}>
|
||||
<Text className={styles.iconText}>{iconOf(device.model)}</Text>
|
||||
<Text className={styles.iconText}>{FALLBACK_ICON}</Text>
|
||||
</View>
|
||||
<Text className={styles.name}>{device.name}</Text>
|
||||
<Text className={styles.name}>{device.deviceName}</Text>
|
||||
<View className={styles.statusRow}>
|
||||
<View className={`${styles.dot} ${device.online ? styles.dotOn : styles.dotOff}`} />
|
||||
<Text className={`${styles.statusText} ${!device.online ? styles.statusTextOff : ''}`}>
|
||||
|
|
@ -144,6 +186,10 @@ export default function DeviceList() {
|
|||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className={styles.unbindBtn} hoverClass={styles.unbindBtnPressed} onClick={() => confirmUnbind(device)}>
|
||||
<Text className={styles.unbindBtnText}>解绑</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { useAppDispatch, useAppSelector } from '@/store/hooks'
|
||||
import { setScene, type Scene } from '@/store/sceneSlice'
|
||||
import { Navbar } from '@/components/navbar'
|
||||
import { Loading } from '@/components/loading'
|
||||
import { Empty } from '@/components/empty'
|
||||
import { StateView } from '@/components/state-view'
|
||||
import { getSceneList, addScene } from '@/api/scene'
|
||||
import { getHomeList, type HomeInfo } from '@/api/home'
|
||||
import { getRoomList, addRoom, updateRoom, deleteRoom, type HomeRoom } from '@/api/homeRoom'
|
||||
import { showToast } from '@/utils/toast'
|
||||
import { navigateBack } from '@/utils/navigation'
|
||||
import styles from './select.module.scss'
|
||||
|
||||
// Taro 类型未跟进 wx.showModal 的 editable 扩展(微信 2.17.1+),运行时真实存在
|
||||
|
|
@ -25,20 +23,29 @@ interface EditableModalResult extends Taro.showModal.SuccessCallbackResult {
|
|||
const showEditableModal = (option: EditableModalOption) =>
|
||||
Taro.showModal(option as Taro.showModal.Option) as Promise<EditableModalResult>
|
||||
|
||||
export default function SceneSelect() {
|
||||
const dispatch = useAppDispatch()
|
||||
const current = useAppSelector((s) => s.scene.current)
|
||||
// 房间操作菜单:编辑名称 / 删除
|
||||
const showActionSheet = () =>
|
||||
Taro.showActionSheet({ itemList: ['编辑名称', '删除房间'] }) as Promise<Taro.showActionSheet.SuccessCallbackResult>
|
||||
|
||||
export default function SceneSelect() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
const [list, setList] = useState<Scene[]>([])
|
||||
const [home, setHome] = useState<HomeInfo | null>(null)
|
||||
const [list, setList] = useState<HomeRoom[]>([])
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
setLoadError(false)
|
||||
try {
|
||||
const data = await getSceneList()
|
||||
setList(data ?? [])
|
||||
const homes = await getHomeList()
|
||||
const first = homes[0] ?? null
|
||||
setHome(first)
|
||||
if (!first) {
|
||||
setList([])
|
||||
return
|
||||
}
|
||||
const rooms = await getRoomList(first.homeId)
|
||||
setList(rooms ?? [])
|
||||
} catch {
|
||||
setLoadError(true)
|
||||
} finally {
|
||||
|
|
@ -48,21 +55,16 @@ export default function SceneSelect() {
|
|||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const selectScene = (scene: Scene) => {
|
||||
dispatch(setScene(scene))
|
||||
navigateBack()
|
||||
}
|
||||
|
||||
// 添加房间:可编辑弹窗输入名称,成功后刷新列表
|
||||
const addRoom = () => {
|
||||
const addRoomByName = () => {
|
||||
showEditableModal({
|
||||
title: '添加房间',
|
||||
editable: true,
|
||||
placeholderText: '请输入房间名称(如:阳台)'
|
||||
}).then(async ({ confirm, content }) => {
|
||||
if (!confirm) return
|
||||
if (!confirm || !home) return
|
||||
try {
|
||||
await addScene(content ?? '')
|
||||
await addRoom({ homeId: home.homeId, name: (content ?? '').trim() })
|
||||
showToast('添加成功')
|
||||
await load()
|
||||
} catch (error) {
|
||||
|
|
@ -71,6 +73,40 @@ export default function SceneSelect() {
|
|||
})
|
||||
}
|
||||
|
||||
// 长按/操作:编辑名称或删除
|
||||
const onRoomAction = (room: HomeRoom) => {
|
||||
if (!home) return
|
||||
showActionSheet()
|
||||
.then(async ({ tapIndex }) => {
|
||||
if (tapIndex === 0) {
|
||||
const { confirm, content } = await showEditableModal({
|
||||
title: '编辑房间名称',
|
||||
editable: true,
|
||||
content: room.roomName,
|
||||
placeholderText: '请输入房间名称'
|
||||
})
|
||||
if (!confirm) return
|
||||
await updateRoom({ roomId: room.roomId, name: (content ?? '').trim() })
|
||||
showToast('已修改')
|
||||
await load()
|
||||
} else if (tapIndex === 1) {
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '删除房间',
|
||||
content: `确定删除「${room.roomName}」吗?`,
|
||||
confirmText: '删除',
|
||||
confirmColor: '#EE4B4B'
|
||||
})
|
||||
if (!confirm) return
|
||||
await deleteRoom({ roomId: room.roomId })
|
||||
showToast('已删除')
|
||||
await load()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// 取消操作静默
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={styles.page}>
|
||||
<Navbar title="房间管理" variant="hero" />
|
||||
|
|
@ -80,21 +116,21 @@ export default function SceneSelect() {
|
|||
<StateView status="error" onRetry={load} />
|
||||
) : (
|
||||
<View className={styles.list}>
|
||||
{list.map((scene) => (
|
||||
{list.map((room) => (
|
||||
<View
|
||||
key={scene.id}
|
||||
className={`${styles.item} ${scene.id === current?.id ? styles.itemActive : ''}`}
|
||||
onClick={() => selectScene(scene)}
|
||||
key={room.roomId}
|
||||
className={styles.item}
|
||||
onLongPress={() => onRoomAction(room)}
|
||||
>
|
||||
<Text className={styles.name}>{scene.name}</Text>
|
||||
<Text className={styles.check}>{scene.id === current?.id ? '✓' : '›'}</Text>
|
||||
<Text className={styles.name}>{room.roomName}</Text>
|
||||
<Text className={styles.check} onClick={() => onRoomAction(room)}>编辑</Text>
|
||||
</View>
|
||||
))}
|
||||
{/* 空列表时也保留添加入口 */}
|
||||
{list.length === 0 && (
|
||||
<View className={styles.stateWrap}><Empty text="暂无房间,点击下方添加" /></View>
|
||||
)}
|
||||
<View className={styles.addBtn} hoverClass={styles.addBtnPressed} onClick={addRoom}>
|
||||
<View className={styles.addBtn} hoverClass={styles.addBtnPressed} onClick={addRoomByName}>
|
||||
<Text className={styles.addIcon}>+</Text>
|
||||
<Text className={styles.addText}>添加房间</Text>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import type { DeviceModel } from '@/constants/enums'
|
||||
|
||||
export interface Device {
|
||||
id: number
|
||||
name: string
|
||||
model: DeviceModel
|
||||
online: boolean
|
||||
}
|
||||
|
||||
interface State {
|
||||
list: Device[]
|
||||
}
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'device',
|
||||
initialState: { list: [] } as State,
|
||||
reducers: {
|
||||
setDeviceList(state, action: PayloadAction<Device[]>) {
|
||||
state.list = action.payload
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const { setDeviceList } = slice.actions
|
||||
export default slice.reducer
|
||||
|
|
@ -1,15 +1,9 @@
|
|||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import sceneReducer from './sceneSlice'
|
||||
import authReducer from './authSlice'
|
||||
import deviceReducer from './deviceSlice'
|
||||
import eventReducer from './eventSlice'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
scene: sceneReducer,
|
||||
auth: authReducer,
|
||||
device: deviceReducer,
|
||||
event: eventReducer
|
||||
auth: authReducer
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
|
||||
import { storage } from '@/utils/storage'
|
||||
|
||||
// Scene 类型唯一定义处:api/scene.ts 与页面均从此导入(避免双定义漂移)
|
||||
export interface Scene {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
interface State {
|
||||
current: Scene | null
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'current_scene'
|
||||
|
||||
const initialState: State = {
|
||||
current: storage.get<Scene>(STORAGE_KEY)
|
||||
}
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'scene',
|
||||
initialState,
|
||||
reducers: {
|
||||
setScene(state, action: PayloadAction<Scene>) {
|
||||
state.current = action.payload
|
||||
storage.set(STORAGE_KEY, action.payload)
|
||||
},
|
||||
clearScene(state) {
|
||||
state.current = null
|
||||
storage.remove(STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const { setScene, clearScene } = slice.actions
|
||||
export default slice.reducer
|
||||
Loading…
Reference in New Issue