feat(event): 告警列表/详情接真实字段与新增告警详情页

- HomeAlarm 类型按 2026-09-23 联调实测重写:content/createTime/
  deviceAlias/otherInfo 扩展等,status 枚举确认 0-待处理 1-已忽略
  2-已处理 3-自动处理;splitAlerts 待处理判定改 status===0,单测同步
- 新增 pages/event/feature/detail 告警详情页(getAlarmDetail、状态徽章、
  设备/房间/时间/扩展信息行),路由与分包注册,事件卡片与首页告警
  点击均跳详情
- 事件页/首页改用 resolveCurrentHome 共享当前家庭,卡片展示 content、
  deviceAlias 优先、两行截断与红点行标
- 家庭圈成员行点击改群昵称(showModal editable + updateMemberNickName),
  转让/改昵称入口放开给所有成员(成员响应无 userId,isOwner 不可靠,
  由后端 400 校验兜底)
- ListRow value 推到行尾并支持 valueClass 自定义样式
This commit is contained in:
ozh 2026-09-23 15:18:07 +08:00
parent 6ec6fc7138
commit 82aa7d6925
14 changed files with 461 additions and 81 deletions

View File

@ -12,16 +12,46 @@ export interface AlarmListReq {
pageSize?: number pageSize?: number
} }
// 骨架:本期仅锁定接口路径,字段类型联调时修正(spec D7) // 2026-09-23 联调实测字段(.getList 真实响应):
// - 无 homeId 字段;alarmType 是告警类型字典 ID(非文案),文案在 content
// - 时间字段是 createTime / lastUpdateTime,无 alarmTime
// - status: 0-待处理 1-已忽略 2-已处理 3-自动处理(2026-09-23 后端确认)
// - level: 5(告警级别,语义待字典确认);category: 'DEVICE'
export interface HomeAlarm { export interface HomeAlarm {
alarmId: string alarmId: string
homeId: string content: string // 告警文案,如「跌倒检测器 No.01于2026-09-23 14:19:08发生确认跌倒」
roomId: string level?: number
status: number
category?: string
alarmType: string // 告警类型字典 ID,前端不直接展示
deviceId: string deviceId: string
deviceName: string deviceName: string // 设备序列号
alarmType: string deviceAlias?: string | null // 设备别名(如「跌倒检测器 No.01」),展示优先于 deviceName
alarmTime: string roomId: string
status: number // 告警状态:0-未处理 1-已处理 2-已忽略(待实测确认枚举) roomName?: string | null
productId?: string
createTime: string
lastUpdateTime?: string
// 详情接口附加的告警扩展信息(列表接口为 null)
otherInfo?: {
appName?: string
description?: string
alarmName?: string // 告警名称,如「跌倒报警」
deviceSn?: string
installId?: string
deviceName?: string
content?: string
realName?: string
alertTypeName?: string // 告警分类名,如「设备告警消息」
systemName?: string
cameraId?: string
appId?: string
alertTime?: string
cameraName?: string
deviceLocation?: string
} | null
videoUrl?: string | null
capturePicUrl?: string | null
} }
export function getAlarmList(params: AlarmListReq): Promise<HomeAlarm[]> { export function getAlarmList(params: AlarmListReq): Promise<HomeAlarm[]> {

View File

@ -18,6 +18,10 @@ export default {
{ {
root: 'pages/family', root: 'pages/family',
pages: ['index', 'detail', 'contact/add', 'contact/detail'] pages: ['index', 'detail', 'contact/add', 'contact/detail']
},
{
root: 'pages/event/feature',
pages: ['detail']
} }
], ],
window: { window: {

View File

@ -33,9 +33,11 @@
} }
.value { .value {
margin-left: 24px; // 推到行尾紧贴箭头:无箭头时贴右边距;否则三元素 space-between 会把 value 挤到中间
margin-left: auto;
font-size: 28px; font-size: 28px;
color: $color-text; color: $color-text;
text-align: right;
} }
.arrow { .arrow {

View File

@ -5,12 +5,13 @@ import styles from './index.module.scss'
interface ListRowProps { interface ListRowProps {
label: string label: string
value?: string value?: string
valueClass?: string
arrow?: boolean arrow?: boolean
onClick?: () => void onClick?: () => void
danger?: boolean danger?: boolean
} }
export function ListRow({ label, value, arrow, onClick, danger }: ListRowProps) { export function ListRow({ label, value, valueClass, arrow, onClick, danger }: ListRowProps) {
const cls = [styles.row, danger ? styles.rowDanger : ''].filter(Boolean).join(' ') const cls = [styles.row, danger ? styles.rowDanger : ''].filter(Boolean).join(' ')
return ( return (
<View <View
@ -19,7 +20,7 @@ export function ListRow({ label, value, arrow, onClick, danger }: ListRowProps)
onClick={onClick} onClick={onClick}
> >
<Text className={styles.label}>{label}</Text> <Text className={styles.label}>{label}</Text>
{value && <Text className={styles.value}>{value}</Text>} {value && <Text className={`${styles.value} ${valueClass ?? ''}`.trim()}>{value}</Text>}
{arrow && <Text className={styles.arrow}>›</Text>} {arrow && <Text className={styles.arrow}>›</Text>}
</View> </View>
) )

View File

@ -2,6 +2,7 @@ export const ROUTES = Object.freeze({
LOGIN: '/pages/login/index', LOGIN: '/pages/login/index',
HOME: '/pages/index/index', HOME: '/pages/index/index',
EVENT: '/pages/event/index', EVENT: '/pages/event/index',
EVENT_DETAIL: '/pages/event/feature/detail',
MINE: '/pages/mine/index', MINE: '/pages/mine/index',
DEVICE_LIST: '/pages/device/index', DEVICE_LIST: '/pages/device/index',
DEVICE_ADD: '/pages/device/feature/add', DEVICE_ADD: '/pages/device/feature/add',

View File

@ -0,0 +1,107 @@
.page {
min-height: 100vh;
background: $color-bg;
padding-bottom: 40px;
}
.stateWrap {
margin-top: 120px;
display: flex;
flex-direction: column;
align-items: center;
}
// 告警头卡:白底渐变浮卡,左侧语义色竖条强调紧急度
.headCard {
margin: 24px;
padding: 32px;
background: $color-bg-white;
border-radius: $radius-lg;
box-shadow: $shadow-card;
display: flex;
flex-direction: column;
}
.headTop {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.alarmName {
font-size: 34px;
font-weight: 600;
color: $color-text;
}
// 状态徽章
.tag {
display: inline-block;
padding: 6px 20px;
border-radius: $radius-pill;
font-size: 22px;
font-weight: 500;
flex-shrink: 0;
margin-left: 16px;
}
// 待处理:红(需关注)
.tagPending {
background: $color-danger-light-bg;
color: $color-danger;
}
// 已处理:绿
.tagDone {
background: $color-success-light-bg;
color: $color-success;
}
// 已忽略/未知:灰
.tagIgnored {
background: $color-bg-disabled;
color: $color-text-secondary;
}
.content {
font-size: 28px;
color: $color-text;
line-height: 1.6;
}
.headMeta {
margin-top: 16px;
display: flex;
align-items: center;
}
.metaText {
font-size: 24px;
color: $color-text-secondary;
}
.metaDot {
margin: 0 12px;
font-size: 24px;
color: $color-text-placeholder;
}
// 现场抓拍图
.capture {
margin-top: 24px;
width: 100%;
height: 360px;
border-radius: $radius-md;
}
.section {
margin: 0 24px 24px;
}
.sectionTitle {
display: block;
font-size: 26px;
color: $color-text-secondary;
margin: 0 8px 16px;
}

View File

@ -0,0 +1,113 @@
import { useState, useEffect } from 'react'
import { useRouter } from '@tarojs/taro'
import { View, Text, Image } from '@tarojs/components'
import { Navbar } from '@/components/navbar'
import { Loading } from '@/components/loading'
import { StateView } from '@/components/state-view'
import { ListRow, ListRowGroup } from '@/components/list-row'
import { getAlarmDetail, type HomeAlarm } from '@/api/homeAlarm'
import { navigateTo } from '@/utils/navigation'
import { ROUTES } from '@/constants/routes'
import styles from './detail.module.scss'
// 状态 → 徽章文案与语义色(2026-09-23 后端确认:0-待处理 1-已忽略 2-已处理 3-自动处理)
const STATUS_META: Record<number, { label: string; cls: string }> = {
0: { label: '待处理', cls: 'tagPending' },
1: { label: '已忽略', cls: 'tagIgnored' },
2: { label: '已处理', cls: 'tagDone' },
3: { label: '自动处理', cls: 'tagIgnored' }
}
export default function AlarmDetail() {
const router = useRouter()
const alarmId = router.params.id ?? ''
const [loading, setLoading] = useState(true)
const [alarm, setAlarm] = useState<HomeAlarm | null>(null)
useEffect(() => {
if (!alarmId) {
setLoading(false)
return
}
setLoading(true)
getAlarmDetail(alarmId)
.then(setAlarm)
.catch(() => setAlarm(null))
.finally(() => setLoading(false))
}, [alarmId])
if (loading) {
return (
<View className={styles.page}>
<Navbar title="告警详情" variant="hero" />
<View className={styles.stateWrap}><Loading /></View>
</View>
)
}
if (!alarm) {
return (
<View className={styles.page}>
<Navbar title="告警详情" variant="hero" />
<StateView status="error" mode="list" text="该告警不存在或已删除" />
</View>
)
}
const meta = STATUS_META[alarm.status] ?? { label: '未知状态', cls: 'tagIgnored' }
const deviceLabel = alarm.deviceAlias || alarm.deviceName || alarm.deviceId || '未知设备'
const ext = alarm.otherInfo
const goDevice = () => {
if (ext?.installId) navigateTo(`${ROUTES.DEVICE_DETAIL}?id=${ext.installId}&name=${encodeURIComponent(deviceLabel)}`)
}
return (
<View className={styles.page}>
<Navbar title="告警详情" variant="hero" />
{/* 告警头卡:名称 + 状态徽章 + 文案 + 时间 */}
<View className={styles.headCard}>
<View className={styles.headTop}>
<Text className={styles.alarmName}>{ext?.alarmName || '设备告警'}</Text>
<Text className={`${styles.tag} ${styles[meta.cls]}`}>{meta.label}</Text>
</View>
<Text className={styles.content}>{alarm.content}</Text>
<View className={styles.headMeta}>
<Text className={styles.metaText}>{ext?.alertTypeName || '设备告警消息'}</Text>
<Text className={styles.metaDot}>·</Text>
<Text className={styles.metaText}>{alarm.createTime}</Text>
</View>
{/* 现场抓拍:capturePicUrl 存在时展示 */}
{alarm.capturePicUrl && (
<Image className={styles.capture} src={alarm.capturePicUrl} mode="aspectFill" />
)}
</View>
{/* 告警信息组 */}
<View className={styles.section}>
<Text className={styles.sectionTitle}>告警信息</Text>
<ListRowGroup>
<ListRow label="告警时间" value={ext?.alertTime || alarm.createTime} />
<ListRow label="更新时间" value={alarm.lastUpdateTime || alarm.createTime} />
<ListRow label="告警设备" value={deviceLabel} arrow={!!ext?.installId} onClick={ext?.installId ? goDevice : undefined} />
<ListRow label="所在房间" value={alarm.roomName || '--'} />
<ListRow label="设备序列号" value={alarm.deviceName || '--'} />
{ext?.deviceLocation && <ListRow label="设备位置" value={ext.deviceLocation} />}
</ListRowGroup>
</View>
{/* 现场视频:videoUrl 存在时提示跳转(视频播放器暂未接入) */}
{alarm.videoUrl && (
<View className={styles.section}>
<Text className={styles.sectionTitle}>现场记录</Text>
<ListRowGroup>
<ListRow label="告警录像" value="点击查看" arrow onClick={() => navigateTo(`${ROUTES.DEVICE_DETAIL}?id=${alarm.deviceId}`)} />
</ListRowGroup>
</View>
)}
</View>
)
}

View File

@ -78,6 +78,7 @@
border-radius: $radius-lg; border-radius: $radius-lg;
box-shadow: $shadow-card; box-shadow: $shadow-card;
padding: 28px 32px; padding: 28px 32px;
border: 1px solid transparent;
& + & { margin-top: 20px; } & + & { margin-top: 20px; }
} }
@ -90,20 +91,52 @@
} }
.alarm-type { .alarm-type {
flex: 1;
min-width: 0;
font-size: 30px; font-size: 30px;
font-weight: 600; font-weight: 600;
color: $color-text; color: $color-text;
// 多行文案截断到两行:跌倒类告警 content 常带完整时间戳较长
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
// 告警类型左侧色条行标:红点提示设备类告警语义
.alarm-type-dot { display: inline-block;
width: 14px;
height: 14px;
border-radius: $radius-circle;
background: $color-danger;
margin-right: 12px;
flex-shrink: 0;
} }
.alarm-tag { .alarm-tag {
display: inline-block;
font-size: 22px; font-size: 22px;
padding: 4px 16px; padding: 6px 16px;
border-radius: $radius-pill; border-radius: $radius-pill;
color: $color-text-secondary; color: $color-text-secondary;
background: $color-bg-gray; background: $color-bg-gray;
flex-shrink: 0;
} }
/* 状态枚举待实测,先按未处理红色、已处理绿色、已忽略灰的常见语义 */ // 标题行:红点 + 两行截断文案
.alarmTypeWrap {
flex: 1;
min-width: 0;
margin-right: 16px;
display: flex;
align-items: center;
}
.alarmCardPressed {
border-color: $color-primary-light-border;
}
/* 状态语义色:0-待处理红 2-已处理绿 1/3-已忽略/自动处理灰(2026-09-23 后端确认枚举) */
.tag1 { .tag1 {
color: $color-danger; color: $color-danger;
background: $color-danger-light-bg; background: $color-danger-light-bg;

View File

@ -4,8 +4,11 @@ import { View, Text } from '@tarojs/components'
import { Navbar } from '@/components/navbar' import { Navbar } from '@/components/navbar'
import { Empty } from '@/components/empty' import { Empty } from '@/components/empty'
import { getHomeList, type HomeInfo } from '@/api/home' import { getHomeList, type HomeInfo } from '@/api/home'
import { resolveCurrentHome } from '@/store/currentHome'
import { getRoomList, type HomeRoom } from '@/api/homeRoom' import { getRoomList, type HomeRoom } from '@/api/homeRoom'
import { getAlarmList, type HomeAlarm } from '@/api/homeAlarm' import { getAlarmList, type HomeAlarm } from '@/api/homeAlarm'
import { navigateTo } from '@/utils/navigation'
import { ROUTES } from '@/constants/routes'
import { CalendarDrawer } from './calendar-drawer' import { CalendarDrawer } from './calendar-drawer'
import styles from './index.module.scss' import styles from './index.module.scss'
@ -14,11 +17,12 @@ interface FilterOption {
name: string name: string
} }
// 告警状态筛选枚举(后端 integer,待实测确认:1-未处理 2-已处理 3-已忽略) // 告警状态枚举(2026-09-23 后端确认:0-待处理 1-已忽略 2-已处理 3-自动处理)
const ALARM_STATUS: Record<string, string> = { const ALARM_STATUS: Record<string, string> = {
'1': '未处理', '0': '待处理',
'1': '已忽略',
'2': '已处理', '2': '已处理',
'3': '已忽略' '3': '自动处理'
} }
export default function Event() { export default function Event() {
@ -37,16 +41,18 @@ export default function Event() {
const [roomOptions, setRoomOptions] = useState<FilterOption[]>([{ id: '', name: '全部' }]) const [roomOptions, setRoomOptions] = useState<FilterOption[]>([{ id: '', name: '全部' }])
const statusOptions: FilterOption[] = [ const statusOptions: FilterOption[] = [
{ id: '', name: '全部' }, { id: '', name: '全部' },
{ id: '1', name: '未处理' }, { id: '0', name: '待处理' },
{ id: '1', name: '已忽略' },
{ id: '2', name: '已处理' }, { id: '2', name: '已处理' },
{ id: '3', name: '已忽略' } { id: '3', name: '自动处理' }
] ]
// 默认家庭 + 其房间列表:失败时保留"全部"选项不阻塞筛选 // 当前家庭 + 其房间列表:与智能设备页共享选择(resolveCurrentHome 读持久化),
// 失败时保留"全部"选项不阻塞筛选
useEffect(() => { useEffect(() => {
getHomeList() getHomeList()
.then(async (list) => { .then(async (list) => {
const first = list[0] ?? null const first = resolveCurrentHome(list)
setHome(first) setHome(first)
if (!first) return if (!first) return
const rooms = await getRoomList(first.homeId) const rooms = await getRoomList(first.homeId)
@ -135,9 +141,10 @@ export default function Event() {
// CSS Modules 动态 key 映射对象(避免索引签名问题) // CSS Modules 动态 key 映射对象(避免索引签名问题)
const tagClsMap: Record<string, string> = { const tagClsMap: Record<string, string> = {
'1': styles.tag1, '0': styles.tag1, // 待处理-红
'2': styles.tag2, '1': styles.tag3, // 已忽略-灰
'3': styles.tag3 '2': styles.tag2, // 已处理-绿
'3': styles.tag3 // 自动处理-灰
} }
return ( return (
@ -181,18 +188,26 @@ export default function Event() {
) : ( ) : (
<View className={styles.alarmList}> <View className={styles.alarmList}>
{alarmList.map((alarm) => ( {alarmList.map((alarm) => (
<View key={alarm.alarmId} className={styles.alarmCard}> <View
key={alarm.alarmId}
className={styles.alarmCard}
hoverClass={styles.alarmCardPressed}
onClick={() => navigateTo(`${ROUTES.EVENT_DETAIL}?id=${alarm.alarmId}`)}
>
<View className={styles.alarmHead}> <View className={styles.alarmHead}>
<Text className={styles.alarmType}>{alarm.alarmType}</Text> <View className={styles.alarmTypeWrap}>
<View className={styles.alarmTypeDot} />
<Text className={styles.alarmType}>{alarm.content || '告警消息'}</Text>
</View>
<Text className={`${styles.alarmTag} ${tagClsMap[String(alarm.status)] ?? ''}`}>{statusTagOf(alarm)}</Text> <Text className={`${styles.alarmTag} ${tagClsMap[String(alarm.status)] ?? ''}`}>{statusTagOf(alarm)}</Text>
</View> </View>
<View className={styles.alarmRow}> <View className={styles.alarmRow}>
<Text className={styles.alarmLabel}>设备</Text> <Text className={styles.alarmLabel}>设备</Text>
<Text className={styles.alarmValue}>{alarm.deviceName || alarm.deviceId}</Text> <Text className={styles.alarmValue}>{alarm.deviceAlias || alarm.deviceName || alarm.deviceId}</Text>
</View> </View>
<View className={styles.alarmRow}> <View className={styles.alarmRow}>
<Text className={styles.alarmLabel}>时间</Text> <Text className={styles.alarmLabel}>时间</Text>
<Text className={styles.alarmValue}>{alarm.alarmTime}</Text> <Text className={styles.alarmValue}>{alarm.createTime}</Text>
</View> </View>
</View> </View>
))} ))}

View File

@ -125,6 +125,21 @@
color: $color-text-tertiary; color: $color-text-tertiary;
} }
// 家庭成员行「群主」徽章:浅绿底胶囊,右贴箭头
// margin-left:auto 在 flex 行里把它推到最右;Text 需 inline-block 撑高背景
.valueTag {
display: inline-block;
line-height: 1.4;
padding: 6px 20px;
background: $color-primary-light-bg;
border-radius: $radius-pill;
font-size: 22px;
font-weight: 500;
color: $color-primary-deep;
margin-left: auto;
flex-shrink: 0;
}
// 转让群主选人弹层(Modal 内容) // 转让群主选人弹层(Modal 内容)
.transferTitle { .transferTitle {
display: block; display: block;

View File

@ -9,7 +9,7 @@ import { StateView } from '@/components/state-view'
import { FamilyCard } from '@/components/family-card' import { FamilyCard } from '@/components/family-card'
import { ListRow, ListRowGroup } from '@/components/list-row' import { ListRow, ListRowGroup } from '@/components/list-row'
import { Modal } from '@/components/modal' import { Modal } from '@/components/modal'
import { getHomeDetail, getHomeMemberList, joinHome, transferOwner, type HomeInfo, type HomeMember } from '@/api/home' import { getHomeDetail, getHomeMemberList, joinHome, transferOwner, updateMemberNickName, type HomeInfo, type HomeMember } from '@/api/home'
import { getCareMemberList, type CareMember } from '@/api/careMember' import { getCareMemberList, type CareMember } from '@/api/careMember'
import { MAX_CARED_PER_FAMILY } from '@/constants/family' import { MAX_CARED_PER_FAMILY } from '@/constants/family'
import { ROUTES } from '@/constants/routes' import { ROUTES } from '@/constants/routes'
@ -17,8 +17,22 @@ import { navigateBack, navigateTo, reLaunch } from '@/utils/navigation'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import styles from './detail.module.scss' import styles from './detail.module.scss'
// Taro 类型未跟进 wx.showModal 的 editable 扩展(微信 2.17.1+),运行时真实存在
interface EditableModalOption extends Taro.showModal.Option {
editable?: boolean
placeholderText?: string
}
interface EditableModalResult extends Taro.showModal.SuccessCallbackResult {
content?: string
}
const showEditableModal = (option: EditableModalOption) =>
Taro.showModal(option as Taro.showModal.Option) as Promise<EditableModalResult>
// 成员显示名:家庭内昵称优先,未设置回落微信昵称 // 成员显示名:家庭内昵称优先,未设置回落微信昵称
const memberName = (m: HomeMember): string => m.nickName ?? m.userNickName ?? '成员' // 注意 2026-09-23 实测:清空昵称后端返回空串 '' 而非 null,需按空白处理
const memberName = (m: HomeMember): string => m.nickName?.trim() || m.userNickName?.trim() || '成员'
// 关爱对象联系人摘要:最多显示前 2 位姓名,其余以 +N 概括;未填则不显示 // 关爱对象联系人摘要:最多显示前 2 位姓名,其余以 +N 概括;未填则不显示
const contactsSummary = (c: CareMember): string => { const contactsSummary = (c: CareMember): string => {
@ -45,6 +59,8 @@ export default function FamilyDetail() {
// 转让群主:底部入口仅群主可见,点击后弹选人 Modal // 转让群主:底部入口仅群主可见,点击后弹选人 Modal
const [transferOpen, setTransferOpen] = useState(false) const [transferOpen, setTransferOpen] = useState(false)
const [transferring, setTransferring] = useState(false) const [transferring, setTransferring] = useState(false)
// 改昵称:点击成员行触发
const [renaming, setRenaming] = useState(false)
const load = async () => { const load = async () => {
// 分享卡直达本页且未登录:先去登录,登录后回跳本页 // 分享卡直达本页且未登录:先去登录,登录后回跳本页
@ -114,9 +130,13 @@ export default function FamilyDetail() {
// 我在成员列表里的角色:转让入口只对群主开放 // 我在成员列表里的角色:转让入口只对群主开放
const myMember = members.find((m) => m.memberId === myUserId) ?? null const myMember = members.find((m) => m.memberId === myUserId) ?? null
const isOwner = myMember?.role === 'OWNER' const isOwner = myMember?.role === 'OWNER'
// 注意:成员响应无 userId 字段(2026-09-23 实测),memberId 是家庭圈成员记录 ID,
// 与登录 userId 不同源,isOwner 恒为 false——仅作未来后端补字段后的预留,勿用于门禁
// 转让/改昵称入口全开:前端无法可靠识别自己,由后端权威校验
// (transferOwner 实测已有 400「不能转让给自己」「成员不存在」等完整校验)
const openTransfer = () => { const openTransfer = () => {
if (!isOwner || transferring) return if (transferring) return
setTransferOpen(true) setTransferOpen(true)
} }
const closeTransfer = () => { const closeTransfer = () => {
@ -138,6 +158,37 @@ export default function FamilyDetail() {
} }
} }
// 点击成员行改昵称:wx.showModal editable 输入框,预填当前昵称
const renameMember = async (target: HomeMember) => {
if (renaming) return
setRenaming(true)
try {
const { confirm, content } = await showEditableModal({
title: '修改群昵称',
editable: true,
placeholderText: '请输入新的昵称',
content: target.nickName ?? ''
})
if (!confirm) return
const next = (content ?? '').trim()
if (!next) {
showToast('昵称不能为空')
return
}
if (next === (target.nickName ?? '').trim()) {
showToast('昵称未变化')
return
}
await updateMemberNickName({ memberId: target.memberId, nickName: next })
showToast('已修改')
await load()
} catch (e) {
showToast(e instanceof Error ? e.message : '修改失败')
} finally {
setRenaming(false)
}
}
if (loading) { if (loading) {
return ( return (
<View className={styles.page}> <View className={styles.page}>
@ -219,7 +270,14 @@ export default function FamilyDetail() {
) : ( ) : (
<ListRowGroup> <ListRowGroup>
{members.map((m) => ( {members.map((m) => (
<ListRow key={m.memberId} label={memberName(m)} value={m.role === 'OWNER' ? '群主' : '成员'} /> <ListRow
key={m.memberId}
label={memberName(m)}
value={m.role === 'OWNER' ? '群主' : undefined}
valueClass={styles.valueTag}
arrow
onClick={() => renameMember(m)}
/>
))} ))}
</ListRowGroup> </ListRowGroup>
)} )}
@ -237,22 +295,19 @@ export default function FamilyDetail() {
<Text className={styles.shareLabel}>邀请他人</Text> <Text className={styles.shareLabel}>邀请他人</Text>
<Text className={styles.shareArrow}>›</Text> <Text className={styles.shareArrow}>›</Text>
</Button> </Button>
<ListRow label="转让群主" arrow onClick={isOwner ? openTransfer : stub('仅群主可转让')} /> <ListRow label="转让群主" arrow onClick={openTransfer} />
<ListRow label="修改群昵称" arrow onClick={stub('修改昵称功能即将上线')} />
</ListRowGroup> </ListRowGroup>
</View> </View>
</View> </View>
{/* 转让群主选人弹层:列出除自己外的成员,选中即调用 transferOwner */} {/* 转让群主选人弹层:全部成员可选(含自己),后端对「转让给自己」返回 400 由 toast 透出 */}
<Modal visible={transferOpen} onClose={closeTransfer}> <Modal visible={transferOpen} onClose={closeTransfer}>
<Text className={styles.transferTitle}>转让群主给谁?</Text> <Text className={styles.transferTitle}>转让群主给谁?</Text>
<View className={styles.transferList}> <View className={styles.transferList}>
{members.filter((m) => m.memberId !== myUserId).length === 0 ? ( {members.length === 0 ? (
<Text className={styles.transferEmpty}>暂无可转让的成员</Text> <Text className={styles.transferEmpty}>暂无可转让的成员</Text>
) : ( ) : (
members members.map((m) => (
.filter((m) => m.memberId !== myUserId)
.map((m) => (
<View <View
key={m.memberId} key={m.memberId}
className={styles.transferItem} className={styles.transferItem}
@ -261,7 +316,7 @@ export default function FamilyDetail() {
> >
<Text className={styles.transferItemName}>{memberName(m)}</Text> <Text className={styles.transferItemName}>{memberName(m)}</Text>
<Text className={styles.transferItemMeta}> <Text className={styles.transferItemMeta}>
{m.role === 'OWNER' ? '群主' : '成员'} · 群昵称:{m.nickName ?? '未设置'} {m.role === 'OWNER' ? '群主' : '成员'} · 群昵称:{m.nickName || '未设置'}
</Text> </Text>
</View> </View>
)) ))

View File

@ -6,12 +6,12 @@ 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 { getHomeList, type HomeInfo } from '@/api/home' import { getHomeList, type HomeInfo } from '@/api/home'
import { resolveCurrentHome } from '@/store/currentHome'
import { getAlarmList, type HomeAlarm } from '@/api/homeAlarm' import { getAlarmList, type HomeAlarm } from '@/api/homeAlarm'
import { store } from '@/store' import { store } from '@/store'
import { storage } from '@/utils/storage' import { storage } from '@/utils/storage'
import { ROUTES } from '@/constants/routes' import { ROUTES } from '@/constants/routes'
import { navigateTo, reLaunch } from '@/utils/navigation' import { navigateTo, reLaunch } from '@/utils/navigation'
import { showToast } from '@/utils/toast'
import { splitAlerts } from './splitAlerts' import { splitAlerts } from './splitAlerts'
import styles from './index.module.scss' import styles from './index.module.scss'
@ -26,7 +26,8 @@ export default function Index() {
setLoadError(false) setLoadError(false)
try { try {
const homes = await getHomeList() const homes = await getHomeList()
const first: HomeInfo | undefined = homes[0] // 与智能设备页共享当前家庭:优先持久化选择,失效回落第一条(resolveCurrentHome 内重新落盘)
const first: HomeInfo | null = resolveCurrentHome(homes)
if (!first) { if (!first) {
setAlertList([]) setAlertList([])
return return
@ -52,7 +53,7 @@ export default function Index() {
const { unhandled, messages } = useMemo(() => splitAlerts(alertList), [alertList]) const { unhandled, messages } = useMemo(() => splitAlerts(alertList), [alertList])
const goFamily = () => navigateTo(ROUTES.FAMILY) const goFamily = () => navigateTo(ROUTES.FAMILY)
const goAlertDetail = () => showToast('告警详情即将上线') const goAlertDetail = (alarmId: string) => navigateTo(`${ROUTES.EVENT_DETAIL}?id=${alarmId}`)
return ( return (
<View className={styles.page}> <View className={styles.page}>
@ -67,13 +68,13 @@ export default function Index() {
<FamilyEntry onClick={goFamily} /> <FamilyEntry onClick={goFamily} />
{unhandled.length > 0 && ( {unhandled.length > 0 && (
<UnhandledAlert alert={unhandled[0]} onClick={goAlertDetail} /> <UnhandledAlert alert={unhandled[0]} onClick={() => goAlertDetail(unhandled[0].alarmId)} />
)} )}
{messages.length > 0 && ( {messages.length > 0 && (
<View className={styles.messageList}> <View className={styles.messageList}>
{messages.map((alert) => ( {messages.map((alert) => (
<AlertMessageItem key={alert.alarmId} alert={alert} onClick={goAlertDetail} /> <AlertMessageItem key={alert.alarmId} alert={alert} onClick={() => goAlertDetail(alert.alarmId)} />
))} ))}
</View> </View>
)} )}
@ -108,19 +109,19 @@ function UnhandledAlert({ alert, onClick }: { alert: HomeAlarm; onClick: () => v
<View className={styles.unhandledIcon}> <View className={styles.unhandledIcon}>
<Text className={styles.unhandledIconText}>!</Text> <Text className={styles.unhandledIconText}>!</Text>
</View> </View>
<Text className={styles.unhandledTitle}>{alert.deviceName || alert.deviceId || '未知设备'}</Text> <Text className={styles.unhandledTitle}>{alert.deviceAlias || alert.deviceName || alert.deviceId || '未知设备'}</Text>
<View className={styles.unhandledTag}> <View className={styles.unhandledTag}>
<Text className={styles.unhandledTagText}>未处理</Text> <Text className={styles.unhandledTagText}>待处理</Text>
</View> </View>
</View> </View>
{alert.alarmType && ( {alert.content && (
<View className={styles.unhandledMeta}> <View className={styles.unhandledMeta}>
<Text className={styles.unhandledMetaText}>{alert.alarmType}</Text> <Text className={styles.unhandledMetaText}>{alert.content}</Text>
</View> </View>
)} )}
{alert.alarmTime && ( {alert.createTime && (
<View className={styles.unhandledMeta}> <View className={styles.unhandledMeta}>
<Text className={styles.unhandledMetaText}>{alert.alarmTime}</Text> <Text className={styles.unhandledMetaText}>{alert.createTime}</Text>
</View> </View>
)} )}
</View> </View>
@ -135,11 +136,11 @@ function AlertMessageItem({ alert, onClick }: { alert: HomeAlarm; onClick: () =>
</View> </View>
<View className={styles.messageBody}> <View className={styles.messageBody}>
<View className={styles.messageHead}> <View className={styles.messageHead}>
<Text className={styles.messageTitle}>{alert.alarmType || '告警消息'}</Text> <Text className={styles.messageTitle}>{alert.content || '告警消息'}</Text>
{alert.alarmTime && <Text className={styles.messageTime}>{alert.alarmTime}</Text>} {alert.createTime && <Text className={styles.messageTime}>{alert.createTime}</Text>}
</View> </View>
{alert.deviceName && ( {(alert.deviceAlias || alert.deviceName) && (
<Text className={styles.messageDevice}>{alert.deviceName}</Text> <Text className={styles.messageDevice}>{alert.deviceAlias || alert.deviceName}</Text>
)} )}
</View> </View>
</View> </View>

View File

@ -1,6 +1,7 @@
import type { HomeAlarm } from '@/api/homeAlarm' import type { HomeAlarm } from '@/api/homeAlarm'
// 首页告警分流:status=1 未处理(首页只展示最新一条),其余全部进消息列表 // 首页告警分流:status=0 待处理(首页只展示最新一条),其余全部进消息列表
// (2026-09-23 后端确认:0-待处理 1-已忽略 2-已处理 3-自动处理)
export function splitAlerts(list: HomeAlarm[]): { export function splitAlerts(list: HomeAlarm[]): {
unhandled: HomeAlarm[] unhandled: HomeAlarm[]
messages: HomeAlarm[] messages: HomeAlarm[]
@ -8,7 +9,7 @@ export function splitAlerts(list: HomeAlarm[]): {
const unhandled: HomeAlarm[] = [] const unhandled: HomeAlarm[] = []
const messages: HomeAlarm[] = [] const messages: HomeAlarm[] = []
for (const item of list) { for (const item of list) {
if (item.status === 1) unhandled.push(item) if (item.status === 0) unhandled.push(item)
else messages.push(item) else messages.push(item)
} }
return { unhandled, messages } return { unhandled, messages }

View File

@ -2,35 +2,37 @@ import { describe, it, expect } from 'vitest'
import { splitAlerts } from '@/pages/index/splitAlerts' import { splitAlerts } from '@/pages/index/splitAlerts'
import type { HomeAlarm } from '@/api/homeAlarm' import type { HomeAlarm } from '@/api/homeAlarm'
// 字段对齐 2026-09-23 实测响应(content/createTime;status 0-待处理 1-已忽略 2-已处理 3-自动处理)
const item = (over: Partial<HomeAlarm>): HomeAlarm => ({ const item = (over: Partial<HomeAlarm>): HomeAlarm => ({
alarmId: '1', alarmId: '1',
homeId: 'h1',
roomId: 'r1', roomId: 'r1',
deviceId: 'd1', deviceId: 'd1',
deviceName: '体征监测仪', deviceName: '1463937a07a5',
alarmType: '心率异常', deviceAlias: '跌倒检测器 No.01',
alarmTime: '2026-09-07 21:22:12', content: '跌倒检测器 No.01发生确认跌倒',
status: 2, alarmType: '1299830603847491584',
createTime: '2026-09-23 14:19:08',
status: 1,
...over ...over
}) })
describe('splitAlerts', () => { describe('splitAlerts', () => {
it('status=1 进入未处理组,其余进入消息组', () => { it('status=0 进入待处理组,其余进入消息组', () => {
const list = [ const list = [
item({ alarmId: '1', status: 1 }), item({ alarmId: '1', status: 0 }),
item({ alarmId: '2', status: 2 }), item({ alarmId: '2', status: 1 }),
item({ alarmId: '3', status: 3 }) item({ alarmId: '3', status: 2 })
] ]
const { unhandled, messages } = splitAlerts(list) const { unhandled, messages } = splitAlerts(list)
expect(unhandled.map((i) => i.alarmId)).toEqual(['1']) expect(unhandled.map((i) => i.alarmId)).toEqual(['1'])
expect(messages.map((i) => i.alarmId)).toEqual(['2', '3']) expect(messages.map((i) => i.alarmId)).toEqual(['2', '3'])
}) })
it('status 异常值(如 0、99)落入消息组不丢弃', () => { it('status 异常值(如 99)落入消息组不丢弃', () => {
const list = [item({ alarmId: '1', status: 0 }), item({ alarmId: '2', status: 99 })] const list = [item({ alarmId: '1', status: 99 })]
const { unhandled, messages } = splitAlerts(list) const { unhandled, messages } = splitAlerts(list)
expect(unhandled).toEqual([]) expect(unhandled).toEqual([])
expect(messages.map((i) => i.alarmId)).toEqual(['1', '2']) expect(messages.map((i) => i.alarmId)).toEqual(['1'])
}) })
it('空列表两组均为空', () => { it('空列表两组均为空', () => {
@ -41,9 +43,9 @@ describe('splitAlerts', () => {
it('两组均保持原有相对顺序', () => { it('两组均保持原有相对顺序', () => {
const list = [ const list = [
item({ alarmId: '3', status: 2 }), item({ alarmId: '3', status: 1 }),
item({ alarmId: '1', status: 1 }), item({ alarmId: '1', status: 0 }),
item({ alarmId: '2', status: 2 }) item({ alarmId: '2', status: 1 })
] ]
const { unhandled, messages } = splitAlerts(list) const { unhandled, messages } = splitAlerts(list)
expect(unhandled.map((i) => i.alarmId)).toEqual(['1']) expect(unhandled.map((i) => i.alarmId)).toEqual(['1'])