feat(device): 设备列表/添加/解绑/房间管理接入真实接口,移除设备与场景 mock 层

This commit is contained in:
ozh 2026-09-17 14:40:01 +08:00
parent 5761a5fed2
commit cb2dc3d2aa
12 changed files with 381 additions and 404 deletions

View File

@ -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 })
}

View File

@ -1,24 +1,43 @@
import { request } from '@/http' 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) // 骨架:本期仅锁定接口路径,字段类型联调时修正(spec D7)
// 注意与 api/device.ts(mock 设备列表)并存,切换时由页面改引用并删除旧模块
export interface HomeDevice { export interface HomeDevice {
installId: string // 设备安装 ID(unbind 入参,按文档推断)
deviceId: string deviceId: string
homeId: string homeId: string
roomId: string roomId: string
roomName: string
deviceName: string deviceName: string
deviceType: string deviceType: string
online: boolean online: boolean
} }
export function addHomeDevice(params: Record<string, unknown>): Promise<void> { export function getHomeDeviceList(params: HomeDeviceListReq): Promise<HomeDevice[]> {
return request.post('/yanglao/app/ylHomeDevice/add', params) return request.post('/yanglao/app/ylHomeDevice/getList', params as unknown as Record<string, unknown>)
} }
export function unbindHomeDevice(params: Record<string, unknown>): Promise<void> { export function addHomeDevice(params: HomeDeviceAddReq): Promise<void> {
return request.post('/yanglao/app/ylHomeDevice/unbind', params) return request.post('/yanglao/app/ylHomeDevice/add', params as unknown as Record<string, unknown>)
} }
export function getHomeDeviceList(homeId: string): Promise<HomeDevice[]> { export function unbindHomeDevice(params: HomeDeviceUnbindReq): Promise<void> {
return request.get('/yanglao/app/ylHomeDevice/getList', { homeId }) return request.post('/yanglao/app/ylHomeDevice/unbind', params as unknown as Record<string, unknown>)
} }

View File

@ -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>)
}

View File

@ -39,6 +39,39 @@
margin-bottom: 32px; 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 { .submitBtn {
width: 100%; width: 100%;
height: 96px; height: 96px;
@ -49,6 +82,11 @@
justify-content: center; justify-content: center;
} }
// 提交中防重复点击的视觉降级
.submitBtnDisabled {
opacity: 0.6;
}
.submitBtnPressed { .submitBtnPressed {
opacity: 0.85; opacity: 0.85;
} }

View File

@ -1,28 +1,90 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import { useDidShow } from '@tarojs/taro'
import { View, Text, Input } from '@tarojs/components' import { View, Text, Input } from '@tarojs/components'
import { Navbar } from '@/components/navbar' 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 { showToast } from '@/utils/toast'
import { navigateBack } from '@/utils/navigation'
import styles from './add.module.scss' import styles from './add.module.scss'
export default function DeviceAdd() { export default function DeviceAdd() {
const [deviceId, setDeviceId] = useState('') const [deviceId, setDeviceId] = useState('')
const [submitting, setSubmitting] = useState(false)
const submitManual = () => { const [loading, setLoading] = useState(true)
if (!deviceId.trim()) { 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('请输入设备编号') showToast('请输入设备编号')
return 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 () => { const scanQRCode = async () => {
// 测试日志:完整输出扫码回调,用于真机验证扫描功能(上线前移除)
console.log('[SCAN] scanCode invoked')
try { try {
const res = await Taro.scanCode({ scanType: ['qrCode', 'barCode'] }) const res = await Taro.scanCode({ scanType: ['qrCode', 'barCode'] })
console.log('[SCAN] success:', JSON.stringify(res))
const code = String(res.result ?? '').trim() const code = String(res.result ?? '').trim()
if (!code) { if (!code) {
showToast('未识别到有效编号') showToast('未识别到有效编号')
@ -30,12 +92,35 @@ export default function DeviceAdd() {
} }
setDeviceId(code) setDeviceId(code)
showToast('已填入扫码结果') showToast('已填入扫码结果')
} catch (error) { } catch {
console.log('[SCAN] fail:', JSON.stringify(error))
showToast('扫码取消') 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 ( return (
<View className={styles.page}> <View className={styles.page}>
<Navbar title="添加设备" autoBack={true} variant="hero" /> <Navbar title="添加设备" autoBack={true} variant="hero" />
@ -43,7 +128,7 @@ export default function DeviceAdd() {
<View className={styles.section}> <View className={styles.section}>
<Text className={styles.sectionTitle}>手动输入</Text> <Text className={styles.sectionTitle}>手动输入</Text>
<Text className={styles.sectionDesc}> <Text className={styles.sectionDesc}>
将智能设备添加至当前家庭圈,可实时查看设备状态和告警信息。 将智能设备添加至「{home.name}」,可实时查看设备状态和告警信息。
</Text> </Text>
<Input <Input
className={styles.input} className={styles.input}
@ -53,8 +138,28 @@ export default function DeviceAdd() {
value={deviceId} value={deviceId}
onInput={(e) => setDeviceId(e.detail.value)} onInput={(e) => setDeviceId(e.detail.value)}
/> />
<View className={styles.submitBtn} hoverClass={styles.submitBtnPressed} onClick={submitManual}> {rooms.length > 0 && (
<Text className={styles.submitBtnText}>提交</Text> <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>
</View> </View>

View File

@ -1,26 +1,14 @@
import { useState, useEffect, useRef, useMemo } from 'react' import { useRouter } from '@tarojs/taro'
import Taro, { useRouter, useDidShow, useDidHide } from '@tarojs/taro'
import { View, Text } from '@tarojs/components' import { View, Text } from '@tarojs/components'
import { Navbar } from '@/components/navbar' import { Navbar } from '@/components/navbar'
import { ListRow, ListRowGroup } from '@/components/list-row' import { ListRow, ListRowGroup } from '@/components/list-row'
import { Loading } from '@/components/loading' import { ROUTES } from '@/constants/routes'
import { StateView } from '@/components/state-view' import { navigateTo } from '@/utils/navigation'
import { VitalWave } from '@/components/vital-wave' import type { DeviceModel } from '@/constants/enums'
import { getVitalSigns, getHeartRateHistory, type VitalSigns } from '@/api/device'
import { MOVE_STATE_MAP, BODY_STATUS_MAP, ONBED_STATUS_MAP, type DeviceModel } from '@/constants/enums'
import styles from './detail.module.scss' 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() { export default function Detail() {
const router = useRouter() const router = useRouter()
const params = router.params as { id?: string; name?: string; model?: DeviceModel; online?: string } 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 deviceModel = params.model ?? ''
const online = params.online === '1' const online = params.online === '1'
const [loading, setLoading] = useState(true) const goBleConfig = () => navigateTo(ROUTES.DEVICE_BLE_CONFIG)
const [loadError, setLoadError] = useState(false) const goWifiConfig = () => navigateTo(ROUTES.DEVICE_BLE_CONFIG)
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)
}
}, [])
return ( return (
<View className={styles.page}> <View className={styles.page}>
<Navbar title={deviceName} variant="hero" /> <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.heartCard}>
<View className={styles.heartLeft}> <View className={styles.heartLeft}>
<Text className={styles.heartIcon}>❤</Text> <Text className={styles.heartIcon}>❤</Text>
<Text className={styles.heartValue}>{valueOf(vitals.heartRate)}</Text> <Text className={styles.heartValue}>--</Text>
<Text className={styles.heartUnit}>BPM</Text> <Text className={styles.heartUnit}>BPM</Text>
<View className={`${styles.badge} ${online ? styles.badgeOn : styles.badgeOff}`}> <View className={`${styles.badge} ${online ? styles.badgeOn : styles.badgeOff}`}>
<View className={`${styles.badgeDot} ${online ? styles.badgeDotOn : styles.badgeDotOff}`} /> <View className={`${styles.badgeDot} ${online ? styles.badgeDotOn : styles.badgeDotOff}`} />
<Text>{online ? '正常' : '离线'}</Text> <Text>{online ? '在线' : '离线'}</Text>
</View> </View>
</View> </View>
<View className={styles.heartRight}> <View className={styles.heartRight}>
<Text className={styles.heartHealthLabel}>心率健康</Text> <Text className={styles.heartHealthLabel}>体征数据接入中</Text>
<VitalWave points={wavePoints} canvasId="heartWave" />
</View> </View>
</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}> <View className={styles.infoCard}>
<ListRowGroup> <ListRowGroup>
<ListRow label="设备型号" value={deviceModel || '-'} /> <ListRow label="设备型号" value={deviceModel || '-'} />
<ListRow label="设备 ID" value={deviceId || '-'} /> <ListRow label="设备 ID" value={deviceId || '-'} />
<ListRow label="网络配置" arrow onClick={goWifiConfig} />
<ListRow label="设备配置" arrow onClick={goBleConfig} />
</ListRowGroup> </ListRowGroup>
</View> </View>
</>
)}
</View> </View>
) )
} }

View File

@ -119,7 +119,7 @@
.card { .card {
width: 48.5%; width: 48.5%;
margin-bottom: 24px; margin-bottom: 24px;
padding: 36px 20px 28px; padding: 36px 20px 24px;
box-sizing: border-box; box-sizing: border-box;
background: $color-bg-white; background: $color-bg-white;
border-radius: $radius-lg; border-radius: $radius-lg;
@ -129,6 +129,14 @@
align-items: center; align-items: center;
} }
// 上半区(图标/名称/状态)点击进详情,下方解绑按钮独立命中
.cardTop {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.icon { .icon {
width: 108px; width: 108px;
height: 108px; height: 108px;
@ -178,6 +186,27 @@
.statusText { font-size: 24px; color: $color-primary-deep; } .statusText { font-size: 24px; color: $color-primary-deep; }
.statusTextOff { color: $color-text-secondary; } .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 { .sheetList {
padding-bottom: 16px; padding-bottom: 16px;

View File

@ -1,73 +1,111 @@
import { useState, useCallback } from 'react' import { useState, useCallback } from 'react'
import { useDidShow } from '@tarojs/taro' import { useDidShow } from '@tarojs/taro'
import Taro from '@tarojs/taro'
import { View, Text } from '@tarojs/components' 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 { Navbar } from '@/components/navbar'
import { Loading } from '@/components/loading' import { Loading } from '@/components/loading'
import { StateView } from '@/components/state-view' import { StateView } from '@/components/state-view'
import { BottomSheet } from '@/components/bottom-sheet' import { BottomSheet } from '@/components/bottom-sheet'
import { getDeviceList } from '@/api/device' import { getHomeDeviceList, unbindHomeDevice, type HomeDevice } from '@/api/homeDevice'
import { DEVICE_ICON_MAP, type DeviceModel } from '@/constants/enums' import { getHomeList, type HomeInfo } from '@/api/home'
import { ROUTES } from '@/constants/routes' import { ROUTES } from '@/constants/routes'
import { navigateTo } from '@/utils/navigation' import { navigateTo } from '@/utils/navigation'
import { getHomeList, type HomeInfo } from '@/api/home' import { showToast } from '@/utils/toast'
import styles from './index.module.scss' import styles from './index.module.scss'
const FALLBACK_ICON = '⌂' const FALLBACK_ICON = '⌂'
export default function DeviceList() { 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 [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState(false) const [loadError, setLoadError] = useState(false)
const [deviceList, setDeviceList] = useState<HomeDevice[]>([])
const [unbinding, setUnbinding] = useState(false)
const [families, setFamilies] = useState<HomeInfo[]>([]) const [families, setFamilies] = useState<HomeInfo[]>([])
const [currentFamily, setCurrentFamily] = useState<HomeInfo | null>(null) const [currentFamily, setCurrentFamily] = useState<HomeInfo | null>(null)
const [familySheetOpen, setFamilySheetOpen] = useState(false) const [familySheetOpen, setFamilySheetOpen] = useState(false)
const [pendingFamily, setPendingFamily] = useState<HomeInfo | null>(null) const [pendingFamily, setPendingFamily] = useState<HomeInfo | null>(null)
// 在线状态需每次进入页面都新鲜:useDidShow 覆盖 // 设备列表随家庭联动:useDidShow 覆盖首次进入/返回/tab 切回三种场景
// 首次进入、从详情页 navigateBack 返回、tab 切换回三种场景 const loadDevices = useCallback(async (homeId?: string) => {
const loadDevices = useCallback(async () => { const targetHomeId = homeId ?? currentFamily?.homeId
if (!targetHomeId) {
setDeviceList([])
setLoading(false)
return
}
setLoading(true) setLoading(true)
setLoadError(false) setLoadError(false)
try { try {
const data = await getDeviceList(scene?.id ?? null) const data = await getHomeDeviceList({ homeId: targetHomeId })
dispatch(setDeviceList(data)) setDeviceList(data ?? [])
} catch { } catch {
setLoadError(true) setLoadError(true)
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [dispatch, scene?.id]) }, [currentFamily?.homeId])
const loadFamilies = useCallback(async () => { const loadFamilies = useCallback(async () => {
try { try {
const list = await getHomeList() const list = await getHomeList()
setFamilies(list) 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 { } catch {
// 静默:家庭圈加载失败不阻断设备列表 // 静默:家庭圈加载失败不阻断设备列表
} }
}, []) }, [loadDevices])
useDidShow(() => { useDidShow(() => {
loadDevices() loadDevices()
loadFamilies() loadFamilies()
}) })
const goDetail = (device: Device) => { const goDetail = (device: HomeDevice) => {
const name = encodeURIComponent(device.name) const name = encodeURIComponent(device.deviceName)
navigateTo(`${ROUTES.DEVICE_DETAIL}?id=${device.id}&name=${name}&model=${device.model}&online=${device.online ? 1 : 0}`) navigateTo(
`${ROUTES.DEVICE_DETAIL}?id=${device.deviceId}&name=${name}&model=${device.deviceType}&online=${device.online ? 1 : 0}`
)
} }
const goAdd = () => navigateTo(ROUTES.DEVICE_ADD) const goAdd = () => navigateTo(ROUTES.DEVICE_ADD)
const goRoom = () => navigateTo(ROUTES.SCENE_SELECT) 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 = () => { const openFamilySheet = () => {
setPendingFamily(currentFamily) setPendingFamily(currentFamily)
@ -78,7 +116,10 @@ export default function DeviceList() {
setPendingFamily(null) setPendingFamily(null)
} }
const confirmFamilySheet = () => { const confirmFamilySheet = () => {
if (pendingFamily) setCurrentFamily(pendingFamily) if (pendingFamily && pendingFamily.homeId !== currentFamily?.homeId) {
setCurrentFamily(pendingFamily)
loadDevices(pendingFamily.homeId)
}
closeFamilySheet() closeFamilySheet()
} }
@ -93,7 +134,7 @@ export default function DeviceList() {
<View className={styles.familyCard}> <View className={styles.familyCard}>
<View className={styles.familyRow}> <View className={styles.familyRow}>
<Text className={styles.familyLabel}>设备应用家庭</Text> <Text className={styles.familyLabel}>设备应用家庭</Text>
<Text className={styles.familyValue}>{currentFamily?.name ?? scene?.name ?? '未选择'}</Text> <Text className={styles.familyValue}>{currentFamily?.name ?? '未选择'}</Text>
</View> </View>
<View className={styles.familyRow}> <View className={styles.familyRow}>
<Text className={styles.familyLabel}>关爱对象</Text> <Text className={styles.familyLabel}>关爱对象</Text>
@ -126,17 +167,18 @@ export default function DeviceList() {
{loading ? ( {loading ? (
<View className={styles.stateWrap}><Loading /></View> <View className={styles.stateWrap}><Loading /></View>
) : loadError ? ( ) : loadError ? (
<StateView status="error" onRetry={loadDevices} /> <StateView status="error" onRetry={() => loadDevices()} />
) : deviceList.length === 0 ? ( ) : deviceList.length === 0 ? (
<StateView status="error" mode="list" text="暂无设备" retryText="添加设备" onRetry={goAdd} /> <StateView status="error" mode="list" text="暂无设备" retryText="添加设备" onRetry={goAdd} />
) : ( ) : (
<View className={styles.grid}> <View className={styles.grid}>
{deviceList.map((device) => ( {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 : ''}`}> <View className={`${styles.icon} ${!device.online ? styles.iconOffline : ''}`}>
<Text className={styles.iconText}>{iconOf(device.model)}</Text> <Text className={styles.iconText}>{FALLBACK_ICON}</Text>
</View> </View>
<Text className={styles.name}>{device.name}</Text> <Text className={styles.name}>{device.deviceName}</Text>
<View className={styles.statusRow}> <View className={styles.statusRow}>
<View className={`${styles.dot} ${device.online ? styles.dotOn : styles.dotOff}`} /> <View className={`${styles.dot} ${device.online ? styles.dotOn : styles.dotOff}`} />
<Text className={`${styles.statusText} ${!device.online ? styles.statusTextOff : ''}`}> <Text className={`${styles.statusText} ${!device.online ? styles.statusTextOff : ''}`}>
@ -144,6 +186,10 @@ export default function DeviceList() {
</Text> </Text>
</View> </View>
</View> </View>
<View className={styles.unbindBtn} hoverClass={styles.unbindBtnPressed} onClick={() => confirmUnbind(device)}>
<Text className={styles.unbindBtnText}>解绑</Text>
</View>
</View>
))} ))}
</View> </View>
)} )}

View File

@ -1,15 +1,13 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import { View, Text } from '@tarojs/components' 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 { Navbar } from '@/components/navbar'
import { Loading } from '@/components/loading' import { Loading } from '@/components/loading'
import { Empty } from '@/components/empty' import { Empty } from '@/components/empty'
import { StateView } from '@/components/state-view' 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 { showToast } from '@/utils/toast'
import { navigateBack } from '@/utils/navigation'
import styles from './select.module.scss' import styles from './select.module.scss'
// Taro 类型未跟进 wx.showModal 的 editable 扩展(微信 2.17.1+),运行时真实存在 // Taro 类型未跟进 wx.showModal 的 editable 扩展(微信 2.17.1+),运行时真实存在
@ -25,20 +23,29 @@ interface EditableModalResult extends Taro.showModal.SuccessCallbackResult {
const showEditableModal = (option: EditableModalOption) => const showEditableModal = (option: EditableModalOption) =>
Taro.showModal(option as Taro.showModal.Option) as Promise<EditableModalResult> Taro.showModal(option as Taro.showModal.Option) as Promise<EditableModalResult>
export default function SceneSelect() { // 房间操作菜单:编辑名称 / 删除
const dispatch = useAppDispatch() const showActionSheet = () =>
const current = useAppSelector((s) => s.scene.current) Taro.showActionSheet({ itemList: ['编辑名称', '删除房间'] }) as Promise<Taro.showActionSheet.SuccessCallbackResult>
export default function SceneSelect() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState(false) 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 () => { const load = async () => {
setLoading(true) setLoading(true)
setLoadError(false) setLoadError(false)
try { try {
const data = await getSceneList() const homes = await getHomeList()
setList(data ?? []) const first = homes[0] ?? null
setHome(first)
if (!first) {
setList([])
return
}
const rooms = await getRoomList(first.homeId)
setList(rooms ?? [])
} catch { } catch {
setLoadError(true) setLoadError(true)
} finally { } finally {
@ -48,21 +55,16 @@ export default function SceneSelect() {
useEffect(() => { load() }, []) useEffect(() => { load() }, [])
const selectScene = (scene: Scene) => {
dispatch(setScene(scene))
navigateBack()
}
// 添加房间:可编辑弹窗输入名称,成功后刷新列表 // 添加房间:可编辑弹窗输入名称,成功后刷新列表
const addRoom = () => { const addRoomByName = () => {
showEditableModal({ showEditableModal({
title: '添加房间', title: '添加房间',
editable: true, editable: true,
placeholderText: '请输入房间名称(如:阳台)' placeholderText: '请输入房间名称(如:阳台)'
}).then(async ({ confirm, content }) => { }).then(async ({ confirm, content }) => {
if (!confirm) return if (!confirm || !home) return
try { try {
await addScene(content ?? '') await addRoom({ homeId: home.homeId, name: (content ?? '').trim() })
showToast('添加成功') showToast('添加成功')
await load() await load()
} catch (error) { } 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 ( return (
<View className={styles.page}> <View className={styles.page}>
<Navbar title="房间管理" variant="hero" /> <Navbar title="房间管理" variant="hero" />
@ -80,21 +116,21 @@ export default function SceneSelect() {
<StateView status="error" onRetry={load} /> <StateView status="error" onRetry={load} />
) : ( ) : (
<View className={styles.list}> <View className={styles.list}>
{list.map((scene) => ( {list.map((room) => (
<View <View
key={scene.id} key={room.roomId}
className={`${styles.item} ${scene.id === current?.id ? styles.itemActive : ''}`} className={styles.item}
onClick={() => selectScene(scene)} onLongPress={() => onRoomAction(room)}
> >
<Text className={styles.name}>{scene.name}</Text> <Text className={styles.name}>{room.roomName}</Text>
<Text className={styles.check}>{scene.id === current?.id ? '✓' : '›'}</Text> <Text className={styles.check} onClick={() => onRoomAction(room)}>编辑</Text>
</View> </View>
))} ))}
{/* 空列表时也保留添加入口 */} {/* 空列表时也保留添加入口 */}
{list.length === 0 && ( {list.length === 0 && (
<View className={styles.stateWrap}><Empty text="暂无房间,点击下方添加" /></View> <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.addIcon}>+</Text>
<Text className={styles.addText}>添加房间</Text> <Text className={styles.addText}>添加房间</Text>
</View> </View>

View File

@ -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

View File

@ -1,15 +1,9 @@
import { configureStore } from '@reduxjs/toolkit' import { configureStore } from '@reduxjs/toolkit'
import sceneReducer from './sceneSlice'
import authReducer from './authSlice' import authReducer from './authSlice'
import deviceReducer from './deviceSlice'
import eventReducer from './eventSlice'
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
scene: sceneReducer, auth: authReducer
auth: authReducer,
device: deviceReducer,
event: eventReducer
} }
}) })

View File

@ -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