// 消息通知页(ArkTS 严格类型,无 any) // 数据来源:/auth/notifications/(用户通知)+ /alerts/(预警),合并时间线 import { promptAction } from '@kit.ArkUI'; import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit'; import { hdsMaterial } from '@hms.hds.hdsMaterial'; import { BusinessError } from '@kit.BasicServicesKit'; import { Page, normalizeError } from '../../common/ApiConstants'; import { api, EmptyBody } from '../../utils/Request'; import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale'; import { AuthStore } from '../../utils/AuthStore'; interface UserNotice { id: number; title: string; content: string; is_read: boolean; created_at: string; } interface AlertItem { id: number; title: string; description: string; created_at: string; } // 合并后的信息流条目(source 区分类型) interface FeedItem { key: string; source: string; // 'user' | 'alert' id: number; title: string; content: string; isRead: boolean; createdAt: string; } function formatDateTime(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) { return '--'; } const mm = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); const hh = String(d.getHours()).padStart(2, '0'); const mi = String(d.getMinutes()).padStart(2, '0'); return `${mm}/${dd} ${hh}:${mi}`; } @Entry @Component struct Notifications { @StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system'; @State feed: Array = []; @State loading: boolean = true; @State markingAll: boolean = false; private scroller: Scroller = new Scroller(); private scaleFor(): number { return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0; } aboutToAppear(): void { this.loadData(); } private async loadData(): Promise { this.loading = true; try { // 护理预警仅限护理/管理角色:消防、销售不拉取 /alerts/ const state = await AuthStore.getState(); const role = state.role; const includeAlerts = role === null || role === 'admin' || role === 'nurse' || role === 'staff_nurse'; const responses: Array | Page | null> = await Promise.all([ api.get>('/auth/notifications/', { page_size: 30 }).catch((): Page | null => null), includeAlerts ? api.get>('/alerts/', { page_size: 30, ordering: '-created_at' }).catch((): Page | null => null) : Promise.resolve(null), ]); const noticeRes = responses[0] as Page | null; const alertRes = responses[1] as Page | null; const items: Array = []; if (noticeRes) { noticeRes.results.forEach((n: UserNotice) => { items.push({ key: `user-${n.id}`, source: 'user', id: n.id, title: n.title, content: n.content, isRead: n.is_read, createdAt: n.created_at, }); }); } if (alertRes) { alertRes.results.forEach((a: AlertItem) => { items.push({ key: `alert-${a.id}`, source: 'alert', id: a.id, title: a.title, content: a.description, isRead: true, createdAt: a.created_at, }); }); } items.sort((a: FeedItem, b: FeedItem): number => { return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); }); this.feed = items; } catch (err) { promptAction.showToast({ message: normalizeError(err as BusinessError | Error) }); } finally { this.loading = false; } } private async markRead(item: FeedItem): Promise { if (item.source !== 'user' || item.isRead) { return; } try { await api.post(`/auth/notifications/${item.id}/mark-read/`, new EmptyBody()); this.feed = this.feed.map((f: FeedItem): FeedItem => { if (f.key === item.key) { return { key: f.key, source: f.source, id: f.id, title: f.title, content: f.content, isRead: true, createdAt: f.createdAt } as FeedItem; } return f; }); } catch (err) { console.error('[Notifications] mark read failed:', normalizeError(err as BusinessError | Error)); } } private async markAllRead(): Promise { if (this.markingAll) { return; } this.markingAll = true; try { await api.post('/auth/notifications/mark-all-read/', new EmptyBody()); this.feed = this.feed.map((f: FeedItem): FeedItem => ({ key: f.key, source: f.source, id: f.id, title: f.title, content: f.content, isRead: true, createdAt: f.createdAt }) as FeedItem); promptAction.showToast({ message: '已全部标记为已读' }); } catch (err) { promptAction.showToast({ message: normalizeError(err as BusinessError | Error) }); } finally { this.markingAll = false; } } private hasUnread(): boolean { return this.feed.some((f: FeedItem) => f.source === 'user' && !f.isRead); } @Builder FeedCard(key: string) { Column({ space: 8 }) { Row({ space: 8 }) { if (!this.feedItem(key).isRead) { Circle() .width(8) .height(8) .fill($r('app.color.accent_red')) } Text(this.feedItem(key).source === 'user' ? '通知' : '预警') .fontSize(Math.round(11 * this.scaleFor())) .fontColor(this.feedItem(key).source === 'user' ? $r('app.color.accent_blue') : '#FF9500') .padding({ left: 6, right: 6, top: 2, bottom: 2 }) .borderRadius(6) .border({ width: 1, color: this.feedItem(key).source === 'user' ? $r('app.color.accent_blue') : '#FF9500' }) Blank() Text(formatDateTime(this.feedItem(key).createdAt)) .fontSize(Math.round(12 * this.scaleFor())) .fontColor($r('app.color.inactive_tab')) } .width('100%') Text(this.feedItem(key).title) .fontSize(Math.round(15 * this.scaleFor())) .fontWeight(FontWeight.Medium) .fontColor($r('app.color.text_primary')) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') if (this.feedItem(key).content && this.feedItem(key).content.length > 0) { Text(this.feedItem(key).content) .fontSize(Math.round(13 * this.scaleFor())) .fontColor($r('app.color.text_secondary')) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') } } .width('100%') .padding(16) .backgroundColor($r('app.color.background_card')) .borderRadius(14) .alignItems(HorizontalAlign.Start) .onClick(() => { this.markRead(this.feedItem(key)); }) } // 按 key 从 @State feed 读取(保证 markAllRead 后重渲红点) private feedItem(key: string): FeedItem { const found = this.feed.find((f: FeedItem) => f.key === key); if (found) { return found; } return { key: key, source: 'user', id: 0, title: '', content: '', isRead: true, createdAt: '' }; } build() { HdsNavigation() { Column() { if (this.loading) { Text('加载中...') .fontSize(Math.round(14 * this.scaleFor())) .fontColor($r('app.color.inactive_tab')) .margin({ top: 60 }) } else if (this.feed.length === 0) { Column({ space: 8 }) { Text('🔔') .fontSize(Math.round(32 * this.scaleFor())) Text('暂无消息通知') .fontSize(Math.round(15 * this.scaleFor())) .fontColor($r('app.color.text_secondary')) } .width('100%') .margin({ top: 80 }) .alignItems(HorizontalAlign.Center) } else { Scroll(this.scroller) { Column({ space: 10 }) { if (this.hasUnread()) { Row() { Text(`共 ${this.feed.length} 条 · 有新通知`) .fontSize(Math.round(13 * this.scaleFor())) .fontColor($r('app.color.text_secondary')) Blank() Text(this.markingAll ? '处理中...' : '全部已读') .fontSize(Math.round(14 * this.scaleFor())) .fontColor($r('app.color.accent_blue')) .onClick(() => { this.markAllRead(); }) } .width('100%') .padding({ left: 4, right: 4, top: 8, bottom: 8 }) } ForEach(this.feed, (item: FeedItem) => { this.FeedCard(item.key) }, (item: FeedItem) => item.key) } .width('100%') .padding({ left: 16, right: 16, top: 108, bottom: 24 }) } .width('100%') .layoutWeight(1) .scrollBar(BarState.Auto) .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true }) } } .width('100%') .height('100%') } .mode(NavigationMode.Stack) .titleBar({ content: { title: { mainTitle: '消息通知', }, }, style: { scrollEffectOpts: { enableScrollEffect: false, scrollEffectType: ScrollEffectType.GRADIENT_BLUR, }, systemMaterialEffect: { materialType: hdsMaterial.MaterialType.ADAPTIVE, materialLevel: hdsMaterial.MaterialLevel.EXQUISITE, }, }, avoidLayoutSafeArea: false, enableComponentSafeArea: false, }) .bindToScrollable([this.scroller]) .hideBackButton(false) .titleMode(HdsNavigationTitleMode.MINI) .ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM]) .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) .backgroundColor($r('app.color.background')) } }