chore(hmos): 导入生产环境鸿蒙工程

- 以 D:\SmartSenior 生产工程为源,纳入 AppScope、entry、cjagent、hvigor 及根配置
- 移除 build-profile.json5 的 signingConfigs(p12/p7b/cer 路径与加密口令)及 products.signingConfig 引用
- 不纳入本地与开发产物:local.properties、.idea、.hvigor、.codegenie、demo、backup_a2a_native、SESSION_LOG.md、build_err.txt、build/、oh_modules
- .gitignore 追加 .codegenie 与密钥文件通配(*.p12/*.p7b/*.cer/*.jks/*.keystore/*.pem)
This commit is contained in:
CAPTAIN1275
2026-09-29 18:44:37 +08:00
parent d7f0e2f8a8
commit baf307e9d5
184 changed files with 31334 additions and 0 deletions
+244
View File
@@ -0,0 +1,244 @@
// 智慧养老(数智护老)客户端主页面
// 结构:HdsNavigation + HdsTabs,五个页签(工作台/预警/业务/更多/我的)
// 业务内容分别在 pages/workbench、pages/alerts、pages/business、pages/profile 中维护
import { curves } from '@kit.ArkUI';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { HdsTabs, HdsTabsAttribute, HdsTabsController, HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { router } from '@kit.ArkUI';
import { Workbench } from './workbench/Workbench';
import { AlertsTab } from './alerts/AlertsTab';
import { BusinessTab } from './business/BusinessTab';
import { ProfileTab } from './profile/ProfileTab';
import { AuthStore } from '../utils/AuthStore';
import { parseRouteTarget } from '../common/RouteTarget';
@Entry
@Component
struct Index {
@State message: string = '数智护老';
@State subTitle: string = 'SmartSenior';
@State currentIndex: number = 0;
private hdsTabsController: HdsTabsController = new HdsTabsController();
aboutToAppear(): void {
// 老人角色使用独立的老人端首页
AuthStore.getState().then((state) => {
if (state.role === 'elderly') {
router.replaceUrl({ url: 'pages/elderly/ElderlyHome' });
}
// 冷启动带目标页(卡片/快捷方式/小艺/通知点击跳转):进首页后再跳目标页
const pending = AppStorage.get<string>('pendingCardTarget') ?? '';
if (pending.length > 0) {
AppStorage.setOrCreate<string>('pendingCardTarget', '');
const rt = parseRouteTarget(pending);
setTimeout(() => {
router.pushUrl({ url: rt.url, params: rt.params }).catch(() => {});
}, 500);
}
});
}
@Builder
TabBarItem(icon: string, text: string, index: number) {
Column({ space: 4 }) {
// 处理"更多"页签的特殊图标
if (index === 3) {
// 使用自定义的SymbolSpan图标
Stack() {
// 未选中状态的图标
Text() {
SymbolSpan($r("sys.symbol.list_bullet"))
}
.fontSize(24)
.fontColor('#888888')
.opacity((this.currentIndex !== index) ? 1 : 0)
.animation({
duration: 200,
curve: curves.springMotion(0.4, 0.8)
})
// 选中状态的图标
Text() {
SymbolSpan($r("sys.symbol.list_bullet"))
}
.fontSize(24)
.fontColor('#007AFF')
.opacity((this.currentIndex === index) ? 1 : 0)
.animation({
duration: 200,
curve: curves.springMotion(0.4, 0.8)
})
}
} else {
// 其他页签保持原有逻辑
Stack() {
// 未选中状态的图标
Text(icon)
.fontSize(24)
.fontColor('#888888')
.opacity((this.currentIndex !== index) ? 1 : 0)
.animation({
duration: 200,
curve: curves.springMotion(0.4, 0.8)
})
// 选中状态的图标
Text(icon)
.fontSize(24)
.fontColor('#007AFF')
.opacity((this.currentIndex === index) ? 1 : 0)
.animation({
duration: 200,
curve: curves.springMotion(0.4, 0.8)
})
}
}
// 文字部分
Stack() {
// 未选中状态的文字
Text(text)
.fontSize(12)
.fontColor('#888888')
.opacity((this.currentIndex !== index) ? 1 : 0)
.animation({
duration: 200,
curve: curves.springMotion(0.4, 0.8)
})
// 选中状态的文字
Text(text)
.fontSize(12)
.fontColor('#007AFF')
.opacity((this.currentIndex === index) ? 1 : 0)
.animation({
duration: 200,
curve: curves.springMotion(0.4, 0.8)
})
}
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
// 直接更新当前选中的索引
this.currentIndex = index;
// 更新HdsTabs控制器
this.hdsTabsController.changeIndex(index);
})
}
@Builder
TabContentBuilder(index: number) {
TabContent() {
Stack() {
if (index === 0) {
Workbench()
}
if (index === 1) {
AlertsTab()
}
if (index === 2) {
BusinessTab()
}
if (index === 3) {
Column() {
Text('更多功能开发中')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
if (index === 4) {
ProfileTab()
}
}
.width('100%')
.height('100%')
// 避让顶部 Hds 标题栏(MINI 模式 + 状态栏)
.padding({ top: 92 })
}
.tabBar(this.TabBarItem(
index === 0 ? '󰁋' :
index === 1 ? '󰑷' :
index === 2 ? '󰆝' :
index === 3 ? '' : '󰗳',
index === 0 ? '工作台' :
index === 1 ? '预警' :
index === 2 ? '业务' :
index === 3 ? '更多' : '我的',
index
))
}
build() {
Column() {
// 主应用界面
Stack() {
HdsNavigation() {
HdsTabs({ controller: this.hdsTabsController }) {
ForEach([0, 1, 2, 3, 4], (index: number) => {
this.TabContentBuilder(index)
})
}
.barOverlap(true)
.barPosition(BarPosition.End)
.vertical(false)
.scrollable(false)
.animationDuration(200)
.onChange((index: number) => {
this.currentIndex = index;
})
.barFloatingStyle({
barWidth: { smallWidth: 300, mediumWidth: 440, largeWidth: 560 },
barBottomMargin: 28,
gradientMask: { maskColor: '#00000000', maskHeight: 92 },
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.IMMERSIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE
}
})
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: this.message,
subTitle: this.subTitle,
},
// 右上角菜单按钮暂时隐藏
// menu: this.menus,
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE
}
}
})
.hideBackButton(true)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.background'))
.alignContent(Alignment.Top)
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.background'))
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
}
}
+348
View File
@@ -0,0 +1,348 @@
// 登录页(ArkTS 严格类型,无 any)—— 账号密码 + 邮箱验证码 + 家属注册
import { router, promptAction, uiMaterial } from '@kit.ArkUI';
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { BusinessError, deviceInfo } from '@kit.BasicServicesKit';
import {
LoginResponse,
PREFS_NAME,
PREFS_AGREED,
ROLE_NAME_MAP,
normalizeError,
} from '../common/ApiConstants';
import { api, RequestError } from '../utils/Request';
import { AuthStore } from '../utils/AuthStore';
import { Constants } from '../common/Constants';
import { PushManager } from '../utils/PushManager';
import { AlertSocket } from '../utils/AlertSocket';
import { requestLocationPermission } from '../utils/PermissionUtil';
import { loadFontScale, resetFontScaleRuntime } from '../utils/FontScale';
import { parseRouteTarget } from '../common/RouteTarget';
interface HomeRoute {
route: string;
name: string;
}
// 登录请求体
interface LoginPayload {
username: string;
password: string;
}
function getHomeRoute(role: string, familyVerified: boolean): HomeRoute {
if (role === 'elderly') {
return { route: 'pages/Index', name: '老人端' };
}
if (role === 'family' && !familyVerified) {
return { route: 'pages/Index', name: '家属端' };
}
return { route: 'pages/Index', name: ROLE_NAME_MAP[role] ?? '工作台' };
}
@Entry
@Component
struct LoginPage {
@State username: string = '';
@State password: string = '';
@State loading: boolean = false;
@State showPassword: boolean = false;
@State showAgreement: boolean = false;
@StorageProp('needPrivacyCheck') @Watch('onNeedPrivacyChanged') needPrivacyCheck: boolean = false;
// 应用初始化(EntryAbility)判定未同意 → 登录页一渲染就弹
onNeedPrivacyChanged(): void {
if (this.needPrivacyCheck) {
this.showAgreement = true;
}
}
async aboutToAppear(): Promise<void> {
// 兜底:AppStorage 标志未就绪时直接读偏好,确保首次启动必弹
const context = getContext(this) as common.UIAbilityContext;
const prefs: preferences.Preferences = await preferences.getPreferences(context, PREFS_NAME);
const agreed = await prefs.get(PREFS_AGREED, 0);
if (agreed !== 1) {
this.showAgreement = true;
}
// 登录持久化:已登录用户直接进入主页
const loggedIn = await AuthStore.isLoggedIn();
if (loggedIn) {
router.replaceUrl({ url: 'pages/Index' });
}
}
private async agree(): Promise<void> {
const context = getContext(this) as common.UIAbilityContext;
const prefs: preferences.Preferences = await preferences.getPreferences(context, PREFS_NAME);
await prefs.put(PREFS_AGREED, 1);
await prefs.flush();
AppStorage.setOrCreate<boolean>('needPrivacyCheck', false);
this.showAgreement = false;
// 隐私政策同意后一次性完成授权:先通知,再定位(系统授权弹窗依次排队)
try {
await PushManager.init(context);
} catch (err) {
console.error('[Login] PushManager init failed:', normalizeError(err as BusinessError | Error));
}
await requestLocationPermission(context);
}
private async handleLogin(): Promise<void> {
if (this.username.trim().length === 0) {
promptAction.showToast({ message: '请输入账号' });
return;
}
if (this.password.length === 0) {
promptAction.showToast({ message: '请输入密码' });
return;
}
if (this.loading) {
return;
}
this.loading = true;
try {
const payload: LoginPayload = {
username: this.username.trim(),
password: this.password,
};
const res = await api.post<LoginResponse>('/auth/login/', payload, { anonymous: true });
await AuthStore.setAuth({
token: res.access,
refreshToken: res.refresh,
role: res.user.role,
userId: res.user.id,
userName: res.user.name,
familyVerified: res.user.family_verified,
});
// Push Kit:把推送令牌绑定到当前登录用户
PushManager.uploadTokenAfterLogin().catch(() => {});
// 字体设置:老人/家属登录恢复持久化档位,其他角色强制跟随系统
if (res.user.role === 'elderly' || res.user.role === 'family') {
loadFontScale(getContext(this) as common.UIAbilityContext).catch(() => {});
} else {
resetFontScaleRuntime();
}
// 预警实时推送:建立 WebSocket 连接
AlertSocket.connect().catch(() => {});
const home = getHomeRoute(res.user.role, res.user.family_verified);
router.replaceUrl({ url: home.route });
// 冷启动带目标页(卡片/快捷方式/小艺/通知点击跳转):进入首页后再跳目标页
const pending = AppStorage.get<string>('pendingCardTarget') ?? '';
if (pending.length > 0) {
AppStorage.setOrCreate<string>('pendingCardTarget', '');
const rt = parseRouteTarget(pending);
setTimeout(() => {
router.pushUrl({ url: rt.url, params: rt.params }).catch(() => {});
}, 500);
}
} catch (err) {
// 后端不可用时,写死测试账号作为离线兜底(正常联调不会走到这里)
const username = this.username.trim();
if (username === '0001' && this.password === '0001') {
await AuthStore.setAuth({
token: 'local-0001',
refreshToken: '',
role: 'admin',
userId: 1,
userName: '测试用户',
familyVerified: false,
});
router.replaceUrl({ url: 'pages/Index' });
return;
}
if (username === '0002' && this.password === '0002') {
await AuthStore.setAuth({
token: 'local-0002',
refreshToken: '',
role: 'elderly',
userId: 2,
userName: '测试老人',
familyVerified: false,
});
router.replaceUrl({ url: 'pages/Index' });
return;
}
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
// 隐私政策弹窗的沉浸式系统材质:uiMaterial 从 API 26 起才提供,
// 低版本设备上该命名空间不存在,直接访问 uiMaterial.ImmersiveStyle 会抛 TypeError 导致应用退出
private buildAgreementMaterial(): SystemUiMaterial | undefined {
if (deviceInfo.sdkApiVersion < 26) {
return undefined;
}
try {
return new uiMaterial.ImmersiveMaterial({
style: uiMaterial.ImmersiveStyle.ULTRA_THIN,
});
} catch (err) {
console.warn('[Login] immersive material unavailable:', normalizeError(err as BusinessError | Error));
return undefined;
}
}
build() {
Stack() {
// 背景(延伸到状态栏与导航栏,沉浸式)
Column()
.width('100%')
.height('100%')
.backgroundColor($r('app.color.background'))
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
Column({ space: 20 }) {
Blank()
.height(56) // 状态栏安全间距
// Logo
Image($r('app.media.logo1'))
.width(88)
.height(88)
.borderRadius(20)
.shadow({ radius: 10, color: '#33007AFF', offsetX: 0, offsetY: 5 })
Text('数智护老')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text('请输入账号信息以继续使用智慧照护端')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
// 账号输入
Column({ space: 14 }) {
TextInput({ placeholder: '请输入账号 / 手机号 / 邮箱', text: this.username })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.height(50)
.padding({ left: 16, right: 16 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.onChange((value: string) => {
this.username = value;
})
// 密码输入
Row() {
TextInput({ placeholder: '请输入登录密码', text: this.password })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.type(InputType.Password)
.height(50)
.layoutWeight(1)
.padding({ left: 16, right: 16 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.onChange((value: string) => {
this.password = value;
})
}
.width('100%')
// 登录按钮
Button(this.loading ? '登录中...' : '登录')
.width('100%')
.height(50)
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
.enabled(!this.loading)
.onClick(() => {
this.handleLogin();
})
}
.width('100%')
.padding({ left: 24, right: 24 })
}
.width('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.padding({ left: 24, right: 24, bottom: 60 })
}
.width('100%')
.height('100%')
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
// 隐私政策弹窗(与 Index 一致的透明沉浸式样式,应用打开时弹出)
.bindSheet($$this.showAgreement, this.AgreementSheetBuilder(), {
preferType: SheetType.CENTER,
detents: [SheetSize.MEDIUM],
dragBar: true,
showClose: false,
title: { title: '用户协议与隐私政策' },
backgroundColor: Color.Transparent,
systemMaterial: this.buildAgreementMaterial(),
onWillDismiss: (action: DismissSheetAction) => {
// 未同意前禁止下拉/点遮罩关闭
if (this.showAgreement) {
promptAction.showToast({ message: '请先阅读并同意隐私政策' });
}
},
})
}
// 隐私政策半模态内容
@Builder
AgreementSheetBuilder() {
Column() {
Scroll() {
Column() {
Text('欢迎使用前请仔细阅读以下政策全文:')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.margin({ bottom: 12 })
ForEach(Constants.PRIVACY_DETAIL, (item: string) => {
Text(item)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.textAlign(TextAlign.Start)
.margin({ bottom: 8 })
}, (item: string) => item.substring(0, 12))
}
.width('100%')
.padding({ left: 24, right: 24 })
}
.layoutWeight(1)
.scrollBar(BarState.Auto)
.align(Alignment.Top)
Row({ space: 12 }) {
Button('不同意并退出')
.layoutWeight(1)
.height(44)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_red'))
.borderRadius(10)
.onClick(() => {
this.handleDisagree();
})
Button('同意并继续')
.layoutWeight(1)
.height(44)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(10)
.onClick(() => {
this.agree();
})
}
.width('100%')
.padding({ left: 24, right: 24, top: 8, bottom: 24 })
}
.width('100%')
.height('100%')
}
private async handleDisagree(): Promise<void> {
this.showAgreement = false;
// 按原交互:不同意则退出应用
const context = getContext(this) as common.UIAbilityContext;
context.terminateSelf();
}
}
@@ -0,0 +1,323 @@
// 管理员 · 意见反馈工单管理(ArkTS 严格类型,无 any)
// 接口:GET /feedback/records/、PATCH /feedback/records/{id}/
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, RequestError } from '../../utils/Request';
interface FeedbackItem {
id: number;
content: string;
contact: string;
status: string;
status_label: string;
user_name: string;
user_alias: string;
user_role: string;
created_at: string;
resolved_at: string;
}
function roleLabel(role: string): string {
if (role === 'family') {
return '家属';
}
if (role === 'elderly') {
return '长者';
}
if (role === 'nurse') {
return '护理员';
}
if (role === 'staff_nurse') {
return '护士';
}
if (role === 'admin') {
return '管理员';
}
return role || '--';
}
function formatTime(iso: string): string {
if (!iso || iso.length === 0) {
return '';
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return iso;
}
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 AdminFeedback {
@State items: Array<FeedbackItem> = [];
@State loading: boolean = true;
@State page: number = 1;
@State count: number = 0;
@State statusFilter: string = 'all';
@State processingId: number = 0;
private pageSize: number = 10;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.reload();
}
private reload(): void {
this.page = 1;
this.items = [];
this.fetch(true);
}
private async fetch(isReset: boolean): Promise<void> {
this.loading = true;
try {
let res: Page<FeedbackItem>;
if (this.statusFilter !== 'all') {
res = await api.get<Page<FeedbackItem>>('/feedback/records/', {
page: this.page,
page_size: this.pageSize,
status: this.statusFilter,
});
} else {
res = await api.get<Page<FeedbackItem>>('/feedback/records/', {
page: this.page,
page_size: this.pageSize,
});
}
this.count = res.count;
if (isReset) {
this.items = res.results;
} else {
this.items = this.items.concat(res.results);
}
if (res.results.length > 0) {
this.page += 1;
}
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
private switchStatus(mode: string): void {
if (this.statusFilter === mode) {
return;
}
this.statusFilter = mode;
this.reload();
}
private async toggleStatus(item: FeedbackItem): Promise<void> {
if (this.processingId === item.id) {
return;
}
this.processingId = item.id;
try {
const next = item.status === 'resolved' ? 'pending' : 'resolved';
const updated = await api.patch<FeedbackItem>(`/feedback/records/${item.id}/`, { status: next });
this.items = this.items.map((it: FeedbackItem): FeedbackItem => {
if (it.id === item.id) {
return {
id: it.id,
content: it.content,
contact: it.contact,
status: updated.status,
status_label: updated.status === 'resolved' ? '已处理' : '待处理',
user_name: it.user_name,
user_alias: it.user_alias,
user_role: it.user_role,
created_at: it.created_at,
resolved_at: it.resolved_at,
};
}
return it;
});
promptAction.showToast({ message: updated.status === 'resolved' ? '已标记为已处理' : '已设为待处理' });
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.processingId = 0;
}
}
@Builder
ItemCard(item: FeedbackItem) {
Column({ space: 8 }) {
Row({ space: 8 }) {
Text(item.user_name || item.user_alias || '用户')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(roleLabel(item.user_role))
.fontSize(11)
.fontColor('#007AFF')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor('#1A007AFF')
.borderRadius(6)
Blank()
Text(item.status === 'resolved' ? '已处理' : '待处理')
.fontSize(12)
.fontColor(item.status === 'resolved' ? $r('app.color.accent_green') : '#FF9500')
if (item.status !== 'resolved') {
Text('处理')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('#007AFF')
.borderRadius(8)
.onClick(() => {
this.toggleStatus(item);
})
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
Text(item.content)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.width('100%')
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
Text(formatTime(item.created_at))
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
if (item.contact && item.contact.length > 0) {
Text(`联系方式:${item.contact}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Column() {
Text('正在加载')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding({ top: 60 })
} else if (this.items.length === 0) {
Column({ space: 8 }) {
Text('暂无反馈')
.fontSize(16)
.fontColor($r('app.color.text_primary'))
Text('还没有意见反馈工单')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding({ top: 160 })
.alignItems(HorizontalAlign.Center)
} else {
Scroll(this.scroller) {
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('全部')
.fontSize(14)
.fontColor(this.statusFilter === 'all' ? '#FFFFFF' : $r('app.color.text_secondary'))
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(this.statusFilter === 'all' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.switchStatus('all');
})
Text('待处理')
.fontSize(14)
.fontColor(this.statusFilter === 'pending' ? '#FFFFFF' : $r('app.color.text_secondary'))
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(this.statusFilter === 'pending' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.switchStatus('pending');
})
Text('已处理')
.fontSize(14)
.fontColor(this.statusFilter === 'resolved' ? '#FFFFFF' : $r('app.color.text_secondary'))
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(this.statusFilter === 'resolved' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.switchStatus('resolved');
})
Blank()
Text(`共 ${this.count} 条`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.alignItems(VerticalAlign.Center)
ForEach(this.items, (item: FeedbackItem) => {
this.ItemCard(item)
}, (item: FeedbackItem) => String(item.id))
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.onReachEnd(() => {
if (!this.loading && this.items.length < this.count) {
this.fetch(false);
}
})
}
}
.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(true)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,334 @@
// 管理员 · 护理员详情(ArkTS 严格类型,无 any)
// 接口:GET /users/{id}/、GET /stats/supervisor/nurse-detail/{id}/
import { router, promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface NurseProfile {
id: number;
name: string;
phone: string;
group: string;
role: string;
duty_status: string;
account_status: string;
date_joined: string;
}
interface Summary {
record_count: number;
elderly_served: number;
}
interface WindowProgress {
status: string; // completed / current / upcoming / overdue
}
interface ServiceItem {
service_name: string;
category_name: string;
status: string;
remaining: number;
window_progress?: Array<WindowProgress>;
}
interface ServiceProgress {
elderly_id: number;
elderly_name: string;
room: string;
bed: string;
items: Array<ServiceItem>;
}
interface NurseDetail {
date: string;
nurse_id: number;
nurse_name: string;
duty_status: string;
group: string;
summary: Summary;
overall_status: string;
overall_completion_rate: number;
service_progress: Array<ServiceProgress>;
}
function statusLabel(item: ServiceItem): string {
if (item.status === 'completed') {
return '完成';
}
if (item.status === 'partial') {
return '部分';
}
if (item.status === 'missed') {
// 后端把"完成数=0"都标 missed,需按时间窗区分:
// 还有进行中/未到的窗口 → 未开始;全部窗口已过 → 遗漏
const wp: Array<WindowProgress> = item.window_progress ?? [];
const hasOpen = wp.some((w: WindowProgress): boolean => w.status === 'current' || w.status === 'upcoming');
return hasOpen ? '未开始' : '遗漏';
}
return '遗漏';
}
function statusColor(item: ServiceItem): string {
if (item.status === 'completed') {
return '#34C759';
}
if (item.status === 'partial') {
return '#FF9500';
}
if (item.status === 'missed') {
const wp: Array<WindowProgress> = item.window_progress ?? [];
const hasOpen = wp.some((w: WindowProgress): boolean => w.status === 'current' || w.status === 'upcoming');
return hasOpen ? '#8E8E93' : '#FF3B30';
}
return '#FF3B30';
}
function dutyLabel(status: string): string {
if (status === 'on_duty') {
return '在岗';
}
if (status === 'rest') {
return '休息';
}
if (status === 'offboard') {
return '离职';
}
return '--';
}
function percentText(rate: number): string {
if (rate === undefined || rate === null) {
return '--';
}
return `${Math.round(rate * 100)}%`;
}
@Entry
@Component
struct AdminNurseDetail {
@State nurseId: number = 0;
@State profile: NurseProfile | null = null;
@State detail: NurseDetail | null = null;
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
const params = router.getParams() as Record<string, string | number>;
if (params) {
const id = params['id'];
if (id !== undefined) {
this.nurseId = Number(id);
}
}
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const results: Array<NurseProfile | NurseDetail | null> = await Promise.all([
api.get<NurseProfile>(`/auth/users/${this.nurseId}/`).catch((): NurseProfile | null => null),
api.get<NurseDetail>(`/stats/supervisor/nurse-detail/${this.nurseId}/`).catch((): NurseDetail | null => null),
]);
this.profile = results[0] as NurseProfile | null;
this.detail = results[1] as NurseDetail | null;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
InfoRow(label: string, value: string) {
Row({ space: 12 }) {
Text(label)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(72)
Text(value)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
}
.width('100%')
}
@Builder
StatTile(value: string, label: string, color: string) {
Column({ space: 4 }) {
Text(value)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(color)
Text(label)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
@Builder
ElderlyProgress(p: ServiceProgress) {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(p.elderly_name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
if (p.room && p.room.length > 0) {
Text(`${p.room}${p.bed ? '-' + p.bed : ''}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
Blank()
Text(`${p.items.length} 项服务`)
.fontSize(12)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
ForEach(p.items, (item: ServiceItem) => {
Row({ space: 8 }) {
Text(item.service_name)
.fontSize(13)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(statusLabel(item))
.fontSize(12)
.fontColor(statusColor(item))
}
.width('100%')
}, (item: ServiceItem) => `${p.elderly_id}-${item.service_name}`)
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.profile) {
Column({ space: 12 }) {
this.InfoRow('姓名', this.profile.name || '--')
this.InfoRow('手机', this.profile.phone || '--')
this.InfoRow('所属组', this.profile.group || '--')
this.InfoRow('值班状态', dutyLabel(this.profile.duty_status))
this.InfoRow('账号状态', this.profile.account_status || '--')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
}
if (this.detail) {
// 今日统计
Column({ space: 12 }) {
Row() {
Text('今日工作')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(`完成率 ${percentText(this.detail.overall_completion_rate)}`)
.fontSize(13)
.fontColor($r('app.color.accent_green'))
}
.width('100%')
Row() {
this.StatTile(String(this.detail.summary.record_count), '完成记录', '#007AFF')
this.StatTile(String(this.detail.summary.elderly_served), '服务长者', '#34C759')
this.StatTile(percentText(this.detail.overall_completion_rate), '整体完成率', '#007AFF')
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
// 服务进度
if (this.detail.service_progress.length > 0) {
Column({ space: 8 }) {
Text('今日服务进度')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
ForEach(this.detail.service_progress, (p: ServiceProgress) => {
Column() {
this.ElderlyProgress(p)
}
.width('100%')
}, (p: ServiceProgress) => String(p.elderly_id))
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
}
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 24 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
}
.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'))
}
}
@@ -0,0 +1,190 @@
// 管理员 · 护理员列表(ArkTS 严格类型,无 any)
// 接口:GET /users/?role=nurse
import { router, promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface NurseUser {
id: number;
username: string;
name: string;
phone: string;
group: string;
duty_status: string;
account_status: string;
}
function dutyLabel(status: string): string {
if (status === 'on_duty') {
return '在岗';
}
if (status === 'rest') {
return '休息';
}
if (status === 'offboard') {
return '离职';
}
return '--';
}
function dutyColor(status: string): string {
if (status === 'on_duty') {
return '#34C759';
}
if (status === 'rest') {
return '#FF9500';
}
return '#8E8E93';
}
@Entry
@Component
struct AdminNurseList {
@State nurses: Array<NurseUser> = [];
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const res = await api.get<Array<NurseUser>>('/auth/users/', { role: 'nurse', page_size: 50 });
this.nurses = res;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
private openDetail(nurse: NurseUser): void {
router.pushUrl({
url: 'pages/admin/AdminNurseDetail',
params: { id: nurse.id, name: nurse.name },
}).catch((err: BusinessError) => {
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
});
}
@Builder
NurseCard(nurse: NurseUser) {
Row({ space: 12 }) {
Column() {
Text(nurse.name.substring(0, 1))
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(48)
.height(48)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(nurse.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(dutyLabel(nurse.duty_status))
.fontSize(11)
.fontColor(dutyColor(nurse.duty_status))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor('#14000000')
.borderRadius(6)
}
.width('100%')
Text(`${nurse.phone || '--'}${nurse.group ? ' · ' + nurse.group : ''}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('›')
.fontSize(20)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.openDetail(nurse);
})
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else if (this.nurses.length === 0) {
Text('暂无护理员')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.margin({ top: 160 })
} else {
Scroll(this.scroller) {
Column({ space: 10 }) {
ForEach(this.nurses, (nurse: NurseUser) => {
Column() {
this.NurseCard(nurse)
}
.width('100%')
}, (nurse: NurseUser) => String(nurse.id))
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
}
.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'))
}
}
@@ -0,0 +1,346 @@
// 管理员 · 护理记录列表(ArkTS 严格类型,无 any)
// 接口:GET /tasks/nursing-records/,支持 全部/今日 筛选 + 滚动分页
import { router, 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, RequestError } from '../../utils/Request';
interface VitalPart {
text?: string;
numeric?: string;
trend?: string;
systolic?: string;
diastolic?: string;
}
interface TextPart {
text?: string;
trend?: string;
}
interface ExtractedJson {
temperature?: VitalPart;
blood_pressure?: VitalPart;
weight?: VitalPart;
medication?: TextPart;
diet?: TextPart;
mood?: TextPart;
}
interface NursingRecordItem {
id: number;
elderly: number | null;
nurse: number | null;
elderly_name: string;
nurse_name: string;
images: Array<string>;
raw_text: string;
extracted_json: object;
work_item: string;
created_at: string;
}
function todayYMD(): string {
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
return `${now.getFullYear()}-${mm}-${dd}`;
}
function formatRecordTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return iso;
}
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}`;
}
function recordSummary(record: NursingRecordItem): string {
const e = record.extracted_json as ExtractedJson;
const parts: Array<string> = [];
if (e.temperature && e.temperature.text && e.temperature.text.length > 0) {
parts.push(`体温 ${e.temperature.text}`);
}
if (e.blood_pressure && e.blood_pressure.text && e.blood_pressure.text.length > 0) {
parts.push(`血压 ${e.blood_pressure.text}`);
}
if (e.weight && e.weight.text && e.weight.text.length > 0) {
parts.push(`体重 ${e.weight.text}`);
}
if (e.medication && e.medication.text && e.medication.text.length > 0) {
parts.push(`服药 ${e.medication.text}`);
}
if (e.diet && e.diet.text && e.diet.text.length > 0) {
parts.push(`进食 ${e.diet.text}`);
}
if (e.mood && e.mood.text && e.mood.text.length > 0) {
parts.push(`心理 ${e.mood.text}`);
}
return parts.join(' · ');
}
@Entry
@Component
struct AdminNursingRecords {
@State records: Array<NursingRecordItem> = [];
@State loading: boolean = true;
@State loadingMore: boolean = false;
@State page: number = 1;
@State count: number = 0;
@State dateFilter: string = 'all';
private pageSize: number = 10;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.reload();
}
private reload(): void {
this.page = 1;
this.records = [];
this.fetch(true);
}
private async fetch(isReset: boolean): Promise<void> {
if (isReset) {
this.loading = true;
} else {
this.loadingMore = true;
}
try {
let res: Page<NursingRecordItem>;
if (this.dateFilter === 'today') {
res = await api.get<Page<NursingRecordItem>>('/tasks/nursing-records/', {
page: this.page,
page_size: this.pageSize,
ordering: '-created_at',
date: todayYMD(),
});
} else {
res = await api.get<Page<NursingRecordItem>>('/tasks/nursing-records/', {
page: this.page,
page_size: this.pageSize,
ordering: '-created_at',
});
}
this.count = res.count;
if (isReset) {
this.records = res.results;
} else {
this.records = this.records.concat(res.results);
}
if (res.results.length > 0) {
this.page += 1;
}
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
this.loadingMore = false;
}
}
private switchFilter(mode: string): void {
if (this.dateFilter === mode) {
return;
}
this.dateFilter = mode;
this.reload();
}
private openDetail(record: NursingRecordItem): void {
router.pushUrl({
url: 'pages/admin/AdminRecordDetail',
params: { id: record.id },
}).catch((err: BusinessError) => {
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
});
}
@Builder
RecordCard(record: NursingRecordItem) {
Row({ space: 12 }) {
Column() {
Text(record.elderly_name.length > 0 ? record.elderly_name.substring(0, 1) : '护')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(48)
.height(48)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
Column({ space: 5 }) {
Row({ space: 8 }) {
Text(record.elderly_name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(formatRecordTime(record.created_at))
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
if (record.raw_text && record.raw_text.length > 0) {
Text(record.raw_text)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
if (recordSummary(record).length > 0) {
Text(recordSummary(record))
.fontSize(12)
.fontColor($r('app.color.accent_blue'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
Row({ space: 8 }) {
Text(record.nurse_name || '--')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
if (record.images.length > 0) {
Text(`图片 ${record.images.length}`)
.fontSize(12)
.fontColor($r('app.color.accent_green'))
}
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.openDetail(record);
})
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Column() {
Text('正在加载')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding({ top: 60 })
} else if (this.records.length === 0) {
Column({ space: 8 }) {
Text('暂无护理记录')
.fontSize(16)
.fontColor($r('app.color.text_primary'))
Text(this.dateFilter === 'today' ? '今日还没有护理记录' : '还没有护理记录')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding({ top: 160 })
.alignItems(HorizontalAlign.Center)
} else {
Scroll(this.scroller) {
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('全部')
.fontSize(14)
.fontColor(this.dateFilter === 'all' ? '#FFFFFF' : $r('app.color.text_secondary'))
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(this.dateFilter === 'all' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.switchFilter('all');
})
Text('今日')
.fontSize(14)
.fontColor(this.dateFilter === 'today' ? '#FFFFFF' : $r('app.color.text_secondary'))
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(this.dateFilter === 'today' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.switchFilter('today');
})
Blank()
Text(`共 ${this.count} 条`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.alignItems(VerticalAlign.Center)
ForEach(this.records, (record: NursingRecordItem) => {
this.RecordCard(record)
}, (record: NursingRecordItem) => String(record.id))
if (this.loadingMore) {
Text('加载中...')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.padding(12)
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.onReachEnd(() => {
if (!this.loading && !this.loadingMore && this.records.length < this.count) {
this.fetch(false);
}
})
}
}
.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(true)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,603 @@
// 管理员 · 信息概览(ArkTS 严格类型,无 any)
// 接口:GET /dashboard/summary/
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface HealthDistribution {
total: number;
good: number;
attention: number;
bedridden: number;
good_percent: string;
attention_percent: string;
bedridden_percent: string;
}
interface UnresolvedAlert {
id: number;
title: string;
description: string;
level: string;
elderly_name: string;
created_at: string;
}
interface RecentRecord {
id: number;
elderly_name: string;
nurse_name: string;
raw_text: string;
created_at: string;
}
interface DailyTrend {
date: string;
count: number;
}
interface MealSummary {
category: string;
reserved_count: number;
menu_items: Array<string>;
}
interface MealsOverview {
total_reserved_count: number;
meals: Array<MealSummary>;
}
interface CareOverview {
on_duty_count: number;
today_record_count: number;
daily_trend: Array<DailyTrend>;
}
interface OperationOverview {
care: CareOverview;
meals: MealsOverview;
}
interface DashboardSummary {
summary_date: string;
overview_elderly: number;
focused_elderly: number;
on_duty_nurses: number;
active_accounts: number;
today_alert_count: number;
pending_alert_count: number;
today_record_count: number;
health_distribution: HealthDistribution;
unresolved_alerts: Array<UnresolvedAlert>;
recent_records: Array<RecentRecord>;
operation_overview: OperationOverview;
}
function levelColor(level: string): string {
if (level === 'emergency') {
return '#FF3B30';
}
if (level === 'warning') {
return '#FF9500';
}
return '#007AFF';
}
function levelLabel(level: string): string {
if (level === 'emergency') {
return '紧急';
}
if (level === 'warning') {
return '注意';
}
return '正常';
}
function mealLabel(category: string): string {
if (category === 'breakfast') {
return '早餐';
}
if (category === 'lunch') {
return '午餐';
}
return '晚餐';
}
function formatDateTime(iso: string): string {
if (!iso || iso.length === 0) {
return '--';
}
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 AdminOverview {
@State data: DashboardSummary | null = null;
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const res = await api.get<DashboardSummary>('/dashboard/summary/');
this.data = res;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
StatTile(value: number, unit: string, label: string, color: string) {
Column({ space: 4 }) {
Row({ space: 2 }) {
Text(String(value))
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor(color)
Text(unit)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.margin({ top: 6 })
}
Text(label)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
@Builder
BarRow(label: string, count: number, percent: string, color: string) {
Row({ space: 10 }) {
Text(label)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.width(56)
Progress({ value: count, total: Math.max(this.totalSafe(), 1) })
.color(color)
.style({ strokeWidth: 8 })
.layoutWeight(1)
Text(percent)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width(44)
.textAlign(TextAlign.End)
}
.width('100%')
}
private totalSafe(): number {
const d = this.data;
if (d !== null && d.health_distribution !== null) {
return d.health_distribution.total;
}
return 1;
}
@Builder
AlertCard(alert: UnresolvedAlert) {
Row({ space: 8 }) {
Text(levelLabel(alert.level))
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(levelColor(alert.level))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(alert.level === 'emergency' ? '#14FF3B30' : (alert.level === 'warning' ? '#14FF9500' : '#14007AFF'))
.borderRadius(4)
Column({ space: 4 }) {
Text(alert.title)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text(`${alert.elderly_name} · ${formatDateTime(alert.created_at)}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
}
@Builder
RecordCard(record: RecentRecord) {
Column({ space: 4 }) {
Row() {
Text(record.elderly_name)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Blank()
Text(formatDateTime(record.created_at))
.fontSize(11)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
Text(record.raw_text)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
private healthValues(): Array<number> {
const d = this.data;
if (d !== null && d.health_distribution !== null) {
return [d.health_distribution.good, d.health_distribution.attention, d.health_distribution.bedridden];
}
return [0, 0, 0];
}
private trendMax(): number {
const d = this.data;
if (d !== null && d.operation_overview !== null && d.operation_overview.care !== null) {
let max = 1;
d.operation_overview.care.daily_trend.forEach((t: DailyTrend) => {
if (t.count > max) {
max = t.count;
}
});
return max;
}
return 1;
}
private barHeight(count: number): number {
return Math.round(60 * count / this.trendMax());
}
private shortDate(dateStr: string): string {
if (!dateStr || dateStr.length < 5) {
return dateStr;
}
return dateStr.substring(5);
}
@Builder
HealthRingCard() {
Column({ space: 12 }) {
Row() {
Text('健康分布')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(`${this.data?.health_distribution.total ?? 0} 人`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
Stack() {
DataPanel({
values: this.healthValues(),
max: Math.max(this.data?.health_distribution.total ?? 1, 1),
type: DataPanelType.Circle,
})
.width(168)
.height(168)
.strokeWidth(24)
.valueColors(['#34C759', '#FF9500', '#FF3B30'])
Column({ space: 2 }) {
Text(String(this.data?.health_distribution.total ?? 0))
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text('在院对象')
.fontSize(11)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.height(180)
.alignContent(Alignment.Center)
Row({ space: 8 }) {
Circle().width(10).height(10).fill('#34C759')
Text('良好')
.fontSize(13)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
Text(`${this.data?.health_distribution.good ?? 0} 人 · ${this.data?.health_distribution.good_percent ?? '0%'}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
Row({ space: 8 }) {
Circle().width(10).height(10).fill('#FF9500')
Text('需关注')
.fontSize(13)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
Text(`${this.data?.health_distribution.attention ?? 0} 人 · ${this.data?.health_distribution.attention_percent ?? '0%'}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
Row({ space: 8 }) {
Circle().width(10).height(10).fill('#FF3B30')
Text('卧床')
.fontSize(13)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
Text(`${this.data?.health_distribution.bedridden ?? 0} 人 · ${this.data?.health_distribution.bedridden_percent ?? '0%'}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
@Builder
TrendChartCard() {
Column({ space: 10 }) {
Row() {
Text('近7日护理记录')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text('每日条数')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
Row({ space: 8 }) {
ForEach(this.data?.operation_overview.care.daily_trend ?? [], (t: DailyTrend) => {
Column({ space: 4 }) {
Text(String(t.count))
.fontSize(9)
.fontColor($r('app.color.text_secondary'))
Column()
.width(22)
.height(this.barHeight(t.count))
.backgroundColor('#007AFF')
.borderRadius(4)
Text(this.shortDate(t.date))
.fontSize(9)
.fontColor($r('app.color.text_secondary'))
}
.justifyContent(FlexAlign.End)
}, (t: DailyTrend) => t.date)
}
.width('100%')
.height(120)
.alignItems(VerticalAlign.Bottom)
.justifyContent(FlexAlign.SpaceAround)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else if (this.data) {
// 顶部统计
Row() {
this.StatTile(this.data.overview_elderly, '位', '在院对象', '#007AFF')
this.StatTile(this.data.on_duty_nurses, '名', '在岗护理', '#34C759')
this.StatTile(this.data.today_record_count, '条', '今日记录', '#FF9500')
}
.width('100%')
.height(96)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.padding(8)
Row() {
this.StatTile(this.data.today_alert_count, '条', '今日预警', '#FF3B30')
this.StatTile(this.data.pending_alert_count, '条', '待处理', '#FF3B30')
this.StatTile(this.data.focused_elderly, '位', '重点关注', '#AF52DE')
}
.width('100%')
.height(96)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.padding(8)
// 健康分布(环形图)
this.HealthRingCard()
// 运营概览 · 护理
Column({ space: 10 }) {
Text('运营概览')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Row({ space: 12 }) {
Text(`在岗护理 ${this.data.operation_overview.care.on_duty_count} 名`)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
Text(`今日记录 ${this.data.operation_overview.care.today_record_count} 条`)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 近7日护理记录趋势
this.TrendChartCard()
// 订餐
Column({ space: 10 }) {
Text(`今日订餐 ${this.data.operation_overview.meals.total_reserved_count} 份`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
ForEach(this.data.operation_overview.meals.meals, (m: MealSummary) => {
Row({ space: 8 }) {
Text(mealLabel(m.category))
.fontSize(14)
.fontColor($r('app.color.accent_blue'))
Text(`${m.reserved_count} 份`)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
Text(m.menu_items.join('、'))
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
}, (m: MealSummary) => m.category)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 未处理预警
Column({ space: 8 }) {
Text(`未处理预警 (${this.data.unresolved_alerts.length})`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.data.unresolved_alerts.length === 0) {
Text('暂无未处理预警')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
} else {
ForEach(this.data.unresolved_alerts, (alert: UnresolvedAlert) => {
Column() {
this.AlertCard(alert)
}
.width('100%')
}, (alert: UnresolvedAlert) => String(alert.id))
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 最近护理记录
Column({ space: 8 }) {
Text('最近护理记录')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.data.recent_records.length === 0) {
Text('暂无护理记录')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
} else {
ForEach(this.data.recent_records, (record: RecentRecord) => {
Column() {
this.RecordCard(record)
}
.width('100%')
}, (record: RecentRecord) => String(record.id))
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 10 })
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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'))
}
}
@@ -0,0 +1,213 @@
// 管理员 · 智能查询(ArkTS 严格类型,无 any)
// 接口:POST /tasks/admin-query/ {query} → {answer, summaries, matched_record}
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface ChatMessage {
id: number;
role: string;
content: string;
}
interface SummaryItem {
label: string;
value: string;
}
interface AdminQueryResult {
answer: string;
summaries: Array<SummaryItem>;
}
interface QueryPayload {
query: string;
}
let messageSeq = 1;
@Entry
@Component
struct AdminQuery {
@State inputText: string = '';
@State messages: Array<ChatMessage> = [];
@State summaries: Array<SummaryItem> = [];
@State sending: boolean = false;
private scroller: Scroller = new Scroller();
private async send(): Promise<void> {
const text = this.inputText.trim();
if (text.length === 0 || this.sending) {
return;
}
const userMsg: ChatMessage = { id: messageSeq++, role: 'user', content: text };
this.messages = this.messages.concat([userMsg]);
this.inputText = '';
this.sending = true;
setTimeout(() => {
this.scroller.scrollEdge(Edge.Bottom);
}, 50);
try {
const payload: QueryPayload = { query: text };
const res = await api.post<AdminQueryResult>('/tasks/admin-query/', payload);
this.summaries = res.summaries;
const answer = res.answer && res.answer.length > 0 ? res.answer : '未查询到相关信息。';
this.messages = this.messages.concat([{ id: messageSeq++, role: 'assistant', content: answer }]);
this.scroller.scrollEdge(Edge.Bottom);
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
this.messages = this.messages.concat([{ id: messageSeq++, role: 'assistant', content: message }]);
this.scroller.scrollEdge(Edge.Bottom);
} finally {
this.sending = false;
}
}
@Builder
SummaryRow(item: SummaryItem) {
Row({ space: 10 }) {
Text(item.label)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(60)
Text(item.value)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
}
.width('100%')
}
@Builder
MessageView(msg: ChatMessage) {
Row() {
if (msg.role === 'user') {
Blank()
Text(msg.content)
.fontSize(16)
.fontColor('#FFFFFF')
.constraintSize({ maxWidth: '72%' })
.padding(14)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
} else {
Text(msg.content)
.fontSize(16)
.fontColor($r('app.color.text_primary'))
.constraintSize({ maxWidth: '88%' })
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
Blank()
}
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 12 })
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 6 }) {
if (this.messages.length === 0) {
Text('您好,我是管理员智能查询助手 🌟\n可以用文字或语音查询护理记录与健康数据,例如:\n“查询张三今天的体温”')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
.textAlign(TextAlign.Center)
.width('100%')
.padding({ top: 60 })
}
ForEach(this.messages, (msg: ChatMessage) => {
this.MessageView(msg)
}, (msg: ChatMessage) => String(msg.id))
if (this.summaries.length > 0) {
Column({ space: 8 }) {
ForEach(this.summaries, (item: SummaryItem) => {
this.SummaryRow(item)
}, (item: SummaryItem) => item.label)
}
.width('92%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
}
}
.width('100%')
.padding({ top: 92, bottom: 20 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.align(Alignment.Top)
Row({ space: 10 }) {
TextInput({ placeholder: '输入查询内容...', text: this.inputText })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.fontSize(16)
.height(52)
.layoutWeight(1)
.padding({ left: 16, right: 16 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.onChange((value: string) => {
this.inputText = value;
})
.onSubmit((enterKey: EnterKeyType) => {
this.send();
})
Button(this.sending ? '…' : '查询')
.height(52)
.padding({ left: 18, right: 18 })
.constraintSize({ minWidth: 80 })
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
.enabled(!this.sending)
.onClick(() => {
this.send();
})
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 20 })
}
.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'))
}
}
@@ -0,0 +1,256 @@
// 管理员 · 护理记录详情(ArkTS 严格类型,无 any)
// 接口:GET /tasks/nursing-records/{id}/
import { router, promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface VitalPart {
text?: string;
numeric?: string;
trend?: string;
systolic?: string;
diastolic?: string;
}
interface TextPart {
text?: string;
trend?: string;
}
interface ExtractedJson {
record_time?: string;
elderly_name?: string;
elderly_id_card?: string;
temperature?: VitalPart;
blood_pressure?: VitalPart;
weight?: VitalPart;
medication?: TextPart;
diet?: TextPart;
mood?: TextPart;
other?: TextPart;
}
interface NursingRecordDetail {
id: number;
elderly: number | null;
elderly_name: string;
nurse: number | null;
nurse_name: string;
raw_text: string;
images: Array<string>;
extracted_json: object;
work_item: string;
created_at: string;
}
function formatFullTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return iso;
}
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 `${d.getFullYear()}-${mm}-${dd} ${hh}:${mi}`;
}
interface VitalRow {
label: string;
value: string;
}
function buildVitalRows(record: NursingRecordDetail): Array<VitalRow> {
const e = record.extracted_json as ExtractedJson;
const rows: Array<VitalRow> = [];
const add = (label: string, value: string): void => {
if (value && value.length > 0) {
rows.push({ label: label, value: value });
}
};
add('记录时间', e.record_time || '');
add('体温', e.temperature ? e.temperature.text || e.temperature.numeric || '' : '');
add('血压', e.blood_pressure ? e.blood_pressure.text || '' : '');
add('体重', e.weight ? e.weight.text || e.weight.numeric || '' : '');
add('服药', e.medication ? e.medication.text || '' : '');
add('进食', e.diet ? e.diet.text || '' : '');
add('情绪', e.mood ? e.mood.text || '' : '');
add('其他', e.other ? e.other.text || '' : '');
return rows;
}
@Entry
@Component
struct AdminRecordDetail {
@State detail: NursingRecordDetail | null = null;
@State loading: boolean = true;
private recordId: number = 0;
aboutToAppear(): void {
const params = router.getParams() as Record<string, Object>;
this.recordId = Number(params['id'] ?? 0);
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const res = await api.get<NursingRecordDetail>(`/tasks/nursing-records/${this.recordId}/`);
this.detail = res;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
FieldRow(row: VitalRow) {
Row({ space: 12 }) {
Text(row.label)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(72)
Text(row.value)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.padding({ top: 6, bottom: 6 })
}
build() {
HdsNavigation() {
if (this.loading) {
Column() {
Text('正在加载')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
} else if (this.detail === null) {
Column() {
Text('记录不存在或已删除')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
} else {
Scroll() {
Column({ space: 12 }) {
// 老人头卡
Row({ space: 12 }) {
Column() {
Text(this.detail.elderly_name.length > 0 ? this.detail.elderly_name.substring(0, 1) : '护')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(52)
.height(52)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(16)
Column({ space: 5 }) {
Text(this.detail.elderly_name)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(`${this.detail.nurse_name || '--'} · ${formatFullTime(this.detail.created_at)}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(VerticalAlign.Center)
// 体征与评估
if (buildVitalRows(this.detail).length > 0) {
Column({ space: 2 }) {
Text('体征与评估')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
ForEach(buildVitalRows(this.detail), (row: VitalRow) => {
this.FieldRow(row)
}, (row: VitalRow) => `${row.label}-${row.value}`)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
// 护理描述
Column({ space: 8 }) {
Text('护理描述')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Text(this.detail.raw_text || '无')
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Auto)
}
}
.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,
})
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,452 @@
// 管理员 · 销售详情(ArkTS 严格类型,无 any)
// 接口:GET /market/cycles/overview/
// GET /market/leads/
// GET /market/contracts/
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, RequestError } from '../../utils/Request';
import { router } from '@kit.ArkUI';
interface CycleOverview {
month: string;
total_count: number;
assigned_count: number;
unassigned_count: number;
pending_count: number;
in_progress_count: number;
won_count: number;
lost_count: number;
active_sales_count: number;
sales_breakdown: Array<SalesBreakdown>;
}
interface SalesBreakdown {
sales_id: number;
sales_name: string;
assigned_count: number;
pending_count: number;
in_progress_count: number;
won_count: number;
lost_count: number;
won_amount: number;
}
interface SalesLead {
id: number;
name: string;
gender: string;
age: number | null;
contact_method: string;
status: string;
deal_amount: number | null;
current_sales_name: string;
current_cycle_month: string;
latest_note: string;
last_follow_up_at: string;
created_at: string;
}
interface Contract {
id: number;
lead_name: string;
customer_name: string;
sales_name: string;
contract_amount: number | null;
contract_code: string;
contract_date_range: string;
room_number: string;
signed_date: string;
occupancy_status: string;
care_level_label: string;
admitted_elderly_name: string;
created_at: string;
}
function statusLabel(status: string): string {
if (status === 'won') {
return '成交';
}
if (status === 'in_progress') {
return '跟进中';
}
if (status === 'lost') {
return '失败';
}
return '待跟进';
}
function statusColor(status: string): string {
if (status === 'won') {
return '#34C759';
}
if (status === 'in_progress') {
return '#007AFF';
}
if (status === 'lost') {
return '#8E8E93';
}
return '#FF9500';
}
function formatDate(iso: string): string {
if (!iso || iso.length === 0) {
return '--';
}
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');
return `${mm}/${dd}`;
}
function monthLabel(month: string): string {
if (!month || month.length === 0) {
return '';
}
const parts = month.split('-');
if (parts.length !== 2) {
return month;
}
return `${parts[0]}年${parts[1]}月`;
}
function amountText(amount: number | null): string {
if (amount === null || amount === undefined) {
return '--';
}
return `¥${amount}`;
}
@Entry
@Component
struct AdminSalesDetail {
@State cycle: CycleOverview | null = null;
@State leads: Array<SalesLead> = [];
@State contracts: Array<Contract> = [];
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const results: Array<CycleOverview | Page<SalesLead> | Page<Contract> | null> = await Promise.all([
api.get<CycleOverview>('/market/cycles/overview/').catch((): CycleOverview | null => null),
api.get<Page<SalesLead>>('/market/leads/', { page_size: 30, ordering: '-created_at' }).catch((): Page<SalesLead> | null => null),
api.get<Page<Contract>>('/market/contracts/', { page_size: 30 }).catch((): Page<Contract> | null => null),
]);
this.cycle = results[0] as CycleOverview | null;
const leadRes = results[1] as Page<SalesLead> | null;
const contractRes = results[2] as Page<Contract> | null;
this.leads = leadRes ? leadRes.results : [];
this.contracts = contractRes ? contractRes.results : [];
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
StatTile(value: number, label: string, color: string) {
Column({ space: 4 }) {
Text(String(value))
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(color)
Text(label)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
@Builder
LeadCard(lead: SalesLead) {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(lead.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
if (lead.age !== null && lead.age !== undefined) {
Text(`${lead.age}岁`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
Blank()
Text(statusLabel(lead.status))
.fontSize(12)
.fontColor(statusColor(lead.status))
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.backgroundColor('#14000000')
.borderRadius(6)
}
.width('100%')
if (lead.contact_method && lead.contact_method.length > 0) {
Text(`📞 ${lead.contact_method}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
Row({ space: 12 }) {
if (lead.current_sales_name && lead.current_sales_name.length > 0) {
Text(`负责:${lead.current_sales_name}`)
.fontSize(12)
.fontColor($r('app.color.accent_blue'))
}
Text(amountText(lead.deal_amount))
.fontSize(13)
.fontColor('#FF9500')
Blank()
Text(formatDate(lead.created_at))
.fontSize(12)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
}
@Builder
ContractCard(c: Contract) {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(c.customer_name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(amountText(c.contract_amount))
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FF9500')
}
.width('100%')
Row({ space: 12 }) {
if (c.contract_code && c.contract_code.length > 0) {
Text(c.contract_code)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
if (c.contract_date_range && c.contract_date_range.length > 0) {
Text(c.contract_date_range)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
Blank()
}
.width('100%')
Row({ space: 12 }) {
if (c.sales_name && c.sales_name.length > 0) {
Text(`销售:${c.sales_name}`)
.fontSize(12)
.fontColor($r('app.color.accent_blue'))
}
if (c.room_number && c.room_number.length > 0) {
Text(`房号:${c.room_number}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.cycle) {
// 周期概览
Column({ space: 12 }) {
Row() {
Text(monthLabel(this.cycle.month))
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(`活跃销售 ${this.cycle.active_sales_count} 名`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
Row() {
this.StatTile(this.cycle.total_count, '总线索', '#007AFF')
this.StatTile(this.cycle.won_count, '成交', '#34C759')
this.StatTile(this.cycle.in_progress_count, '跟进中', '#FF9500')
this.StatTile(this.cycle.lost_count, '失败', '#8E8E93')
}
.width('100%')
if (this.cycle.sales_breakdown.length > 0) {
Column({ space: 8 }) {
ForEach(this.cycle.sales_breakdown, (s: SalesBreakdown) => {
Row({ space: 10 }) {
Text(s.sales_name)
.fontSize(13)
.fontColor($r('app.color.text_primary'))
.width(72)
Text(`${s.assigned_count}条`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Text(`成交 ${s.won_count}`)
.fontSize(12)
.fontColor($r('app.color.accent_green'))
Text(`¥${s.won_amount}`)
.fontSize(12)
.fontColor('#FF9500')
Blank()
}
.width('100%')
}, (s: SalesBreakdown) => String(s.sales_id))
}
.width('100%')
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
// 线索列表
Column({ space: 8 }) {
Row() {
Text('销售线索')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(`共 ${this.leads.length} 条`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
if (this.leads.length === 0) {
Text('暂无销售线索')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.leads, (lead: SalesLead) => {
Column() {
this.LeadCard(lead)
}
.width('100%')
}, (lead: SalesLead) => String(lead.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 合同列表
Column({ space: 8 }) {
Row() {
Text('签约合同')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(`共 ${this.contracts.length} 份`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
if (this.contracts.length === 0) {
Text('暂无合同')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.contracts, (c: Contract) => {
Column() {
this.ContractCard(c)
}
.width('100%')
}, (c: Contract) => String(c.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 20 })
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 24 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
}
.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'))
}
}
@@ -0,0 +1,285 @@
// 管理员 · 排班管理(ArkTS 严格类型,无 any)
// 接口:GET /shifts/floor-duty-assignments/?duty_date=
// GET /shifts/caregiver-schedules/?month=
// GET /shifts/caregiver-duty-assignments/?schedule_version=
import { router } from '@kit.ArkUI';
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, RequestError } from '../../utils/Request';
interface FloorDuty {
id: number;
duty_date: string;
floor: number;
shift_name: string;
shift_time: string;
staff_nurse_name: string;
}
interface CaregiverAssignment {
id: number;
duty_date: string;
caregiver_name: string;
source_name: string;
target_floor: number | null;
target_room: string;
scope_type: string;
shift_code: string;
is_day_duty: boolean;
}
interface ScheduleVersion {
id: number;
target_month: string;
version: number;
status: string;
assignments: Array<CaregiverAssignment>;
}
function todayStr(): string {
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
return `${now.getFullYear()}-${mm}-${dd}`;
}
function monthStr(): string {
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
return `${now.getFullYear()}-${mm}`;
}
function weekdayLabel(): string {
const weekdays: Array<string> = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
return weekdays[new Date().getDay()];
}
function scopeLabel(scope: string): string {
if (scope === 'floor') {
return '楼层';
}
if (scope === 'private_room') {
return '专护';
}
return '辅助';
}
@Entry
@Component
struct AdminSchedule {
@State floorDuties: Array<FloorDuty> = [];
@State caregiverAssignments: Array<CaregiverAssignment> = [];
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const responses: Array<Page<FloorDuty> | Page<ScheduleVersion> | null> = await Promise.all([
api.get<Page<FloorDuty>>('/shifts/floor-duty-assignments/', { duty_date: todayStr(), page_size: 100 })
.catch((): Page<FloorDuty> | null => null),
api.get<Page<ScheduleVersion>>('/shifts/caregiver-schedules/', { month: monthStr(), page_size: 20 })
.catch((): Page<ScheduleVersion> | null => null),
]);
const floorRes = responses[0] as Page<FloorDuty> | null;
this.floorDuties = floorRes ? floorRes.results : [];
const versionRes = responses[1] as Page<ScheduleVersion> | null;
if (versionRes) {
const versions: Array<ScheduleVersion> = versionRes.results;
// 取已发布的版本;如无发布取第一个
const published = versions.find((v: ScheduleVersion) => v.status === 'published');
const target = published ?? (versions.length > 0 ? versions[0] : null);
if (target && target.assignments) {
this.caregiverAssignments = target.assignments.filter(
(a: CaregiverAssignment) => a.duty_date === todayStr());
}
}
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
FloorCard(duty: FloorDuty) {
Row({ space: 12 }) {
Column() {
Text(String(duty.floor))
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(48)
.height(48)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
Column({ space: 4 }) {
Text(`${duty.floor}楼 · ${duty.shift_name}`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(duty.shift_time)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(duty.staff_nurse_name || '--')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.accent_green'))
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
}
@Builder
CaregiverCard(a: CaregiverAssignment) {
Row({ space: 12 }) {
Text(a.caregiver_name || a.source_name || '未校对')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(a.is_day_duty ? '日值' : scopeLabel(a.scope_type))
.fontSize(11)
.fontColor('#FFFFFF')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(a.is_day_duty ? '#AF52DE' : '#FF9500')
.borderRadius(4)
Blank()
Text(a.target_floor ? `${a.target_floor}楼` : (a.target_room ? a.target_room : '--'))
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
Text(a.shift_code)
.fontSize(13)
.fontColor($r('app.color.accent_blue'))
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else {
// 楼层护士值班
Column({ space: 8 }) {
Text('今日楼层护士值班')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.floorDuties.length === 0) {
Text('今日暂无楼层值班排班')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.floorDuties, (duty: FloorDuty) => {
Column() {
this.FloorCard(duty)
}
.width('100%')
}, (duty: FloorDuty) => String(duty.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 护理员今日值班
Column({ space: 8 }) {
Text('护理员今日值班')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.caregiverAssignments.length === 0) {
Text('当前月份暂无护理员排班数据')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.caregiverAssignments, (a: CaregiverAssignment) => {
Column() {
this.CaregiverCard(a)
}
.width('100%')
}, (a: CaregiverAssignment) => String(a.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 20 })
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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'))
}
}
@@ -0,0 +1,419 @@
// 管理员 · 设备监测/睡眠(ArkTS 严格类型,无 any)
// 接口:GET /devices/types/sleep_monitor/overview/、/devices/types/body_sensing/overview/
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface TypeInfo {
code: string;
name: string;
device_count: number;
bound_elderly_count: number;
unresolved_alert_count: number;
}
interface ElderlyBrief {
name: string;
gender: string;
age: number;
care_level: string;
}
interface DeviceAlert {
key: string;
severity: string;
description: string;
}
interface SleepInfo {
status: string;
deep_minutes: number;
light_minutes: number;
awake_minutes: number;
start_at: string;
wake_at: string;
report_date: string;
}
interface Vitals {
heart_rate: number | null;
respiratory_rate: number | null;
body_temperature: number | null;
}
interface SleepResident {
id: number;
device_code: string;
floor: string;
room: string;
bed: string;
elderly: ElderlyBrief;
presence: boolean;
body_movement: boolean;
sleep: SleepInfo;
vitals: Vitals;
alerts: Array<DeviceAlert>;
}
interface SleepOverview {
type: TypeInfo;
pending_alert_count: number;
monitoring_residents: Array<SleepResident>;
}
interface BodySensing {
online: boolean;
motion_label: string;
fall_status: boolean;
long_unattended: boolean;
people_count: number;
}
interface BodyResident {
id: number;
device_code: string;
floor: string;
room: string;
bed: string;
elderly: ElderlyBrief;
presence: boolean;
body_sensing: BodySensing;
alerts: Array<DeviceAlert>;
}
interface BodyOverview {
type: TypeInfo;
pending_alert_count: number;
monitoring_residents: Array<BodyResident>;
}
function roomLabel(r: SleepResident | BodyResident): string {
if (r.floor && r.floor.length > 0) {
return `${r.floor}${r.room ? '/' + r.room : ''}${r.bed ? '/' + r.bed : ''}`;
}
return r.room || r.bed || '--';
}
function vitalText(v: number | null): string {
return v === null || v === undefined ? '--' : String(v);
}
@Entry
@Component
struct DeviceMonitoring {
@State sleep: SleepOverview | null = null;
@State body: BodyOverview | null = null;
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const results: Array<SleepOverview | BodyOverview | null> = await Promise.all([
api.get<SleepOverview>('/devices/types/sleep_monitor/overview/').catch((): SleepOverview | null => null),
api.get<BodyOverview>('/devices/types/body_sensing/overview/').catch((): BodyOverview | null => null),
]);
this.sleep = results[0] as SleepOverview | null;
this.body = results[1] as BodyOverview | null;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
SleepCard(r: SleepResident) {
Column({ space: 8 }) {
Row({ space: 10 }) {
Column() {
Text((r.elderly.name || '?').substring(0, 1))
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(44)
.height(44)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
Column({ space: 4 }) {
Row({ space: 8 }) {
Text(r.elderly.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(roomLabel(r))
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Blank()
}
.width('100%')
Text(r.sleep.status || '--')
.fontSize(13)
.fontColor(r.presence ? $r('app.color.accent_blue') : $r('app.color.accent_green'))
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
if (r.sleep) {
Row({ space: 10 }) {
Text(`深睡 ${r.sleep.deep_minutes}分`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Text(`浅睡 ${r.sleep.light_minutes}分`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Text(`清醒 ${r.sleep.awake_minutes}分`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Blank()
Text(`${r.sleep.start_at}~${r.sleep.wake_at}`)
.fontSize(12)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
}
if (r.vitals) {
Row({ space: 10 }) {
Text(`心率 ${vitalText(r.vitals.heart_rate)}`)
.fontSize(12)
.fontColor($r('app.color.accent_red'))
Text(`呼吸 ${vitalText(r.vitals.respiratory_rate)}`)
.fontSize(12)
.fontColor($r('app.color.accent_blue'))
Text(`体温 ${vitalText(r.vitals.body_temperature)}`)
.fontSize(12)
.fontColor('#FF9500')
Blank()
}
.width('100%')
}
if (r.alerts && r.alerts.length > 0) {
ForEach(r.alerts, (a: DeviceAlert) => {
Text(`⚠ ${a.description || a.key}`)
.fontSize(12)
.fontColor('#FF3B30')
.width('100%')
}, (a: DeviceAlert) => `${r.id}-${a.key}`)
}
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
@Builder
BodyCard(r: BodyResident) {
Column({ space: 8 }) {
Row({ space: 10 }) {
Column() {
Text((r.elderly.name || '?').substring(0, 1))
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(44)
.height(44)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor('#7C3AED')
.borderRadius(12)
Column({ space: 4 }) {
Row({ space: 8 }) {
Text(r.elderly.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(roomLabel(r))
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Blank()
}
.width('100%')
Text(r.body_sensing && r.body_sensing.motion_label ? r.body_sensing.motion_label : '--')
.fontSize(13)
.fontColor(r.presence ? $r('app.color.accent_blue') : $r('app.color.inactive_tab'))
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
if (r.body_sensing) {
Row({ space: 10 }) {
Text(r.body_sensing.online ? '在线' : '离线')
.fontSize(12)
.fontColor(r.body_sensing.online ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
if (r.body_sensing.fall_status) {
Text('跌倒!')
.fontSize(12)
.fontColor('#FF3B30')
}
if (r.body_sensing.long_unattended) {
Text('长期无人')
.fontSize(12)
.fontColor('#FF9500')
}
Blank()
}
.width('100%')
}
if (r.alerts && r.alerts.length > 0) {
ForEach(r.alerts, (a: DeviceAlert) => {
Text(`⚠ ${a.description || a.key}`)
.fontSize(12)
.fontColor('#FF3B30')
.width('100%')
}, (a: DeviceAlert) => `${r.id}-${a.key}`)
}
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else {
Scroll(this.scroller) {
Column({ space: 14 }) {
// 睡眠监测
Column({ space: 10 }) {
Row() {
Text('睡眠监测')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
if (this.sleep) {
Text(`设备 ${this.sleep.type.device_count} · 绑定 ${this.sleep.type.bound_elderly_count}`)
.fontSize(12)
.fontColor($r('app.color.inactive_tab'))
}
}
.width('100%')
if (!this.sleep) {
Text('暂无睡眠设备数据')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.sleep.monitoring_residents, (r: SleepResident) => {
Column() {
this.SleepCard(r)
}
.width('100%')
}, (r: SleepResident) => String(r.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 体感监测
Column({ space: 10 }) {
Row() {
Text('体感监测')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
if (this.body) {
Text(`设备 ${this.body.type.device_count} · 绑定 ${this.body.type.bound_elderly_count}`)
.fontSize(12)
.fontColor($r('app.color.inactive_tab'))
}
}
.width('100%')
if (!this.body) {
Text('暂无体感设备数据')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.body.monitoring_residents, (r: BodyResident) => {
Column() {
this.BodyCard(r)
}
.width('100%')
}, (r: BodyResident) => String(r.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 20 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
}
.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'))
}
}
@@ -0,0 +1,172 @@
// 预警页签(ArkTS 严格类型,无 any)
// 数据来源:/alerts/
import { BusinessError } from '@kit.BasicServicesKit';
import { Page, normalizeError } from '../../common/ApiConstants';
import { api } from '../../utils/Request';
import { AlertSocket } from '../../utils/AlertSocket';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface AlertItem {
id: number;
title: string;
description: string;
level: string;
created_at: 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}`;
}
function levelColor(level: string): string {
const lv = (level || '').toLowerCase();
if (lv.indexOf('critical') >= 0 || lv.indexOf('emergency') >= 0 || lv.indexOf('严重') >= 0) {
return ('app.color.accent_red');
}
if (lv.indexOf('warning') >= 0 || lv.indexOf('警告') >= 0) {
return '#FF9500';
}
return ('app.color.accent_blue');
}
@Component
export struct AlertsTab {
@State alerts: Array<AlertItem> = [];
@State loading: boolean = true;
@State refreshing: boolean = false;
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
aboutToAppear(): void {
this.loadAlerts();
// 注册预警实时推送:收到推送刷新列表
AlertSocket.setListener(() => {
this.loadAlerts();
});
}
aboutToDisappear(): void {
AlertSocket.setListener(null);
}
private async loadAlerts(): Promise<void> {
this.loading = true;
try {
const res = await api.get<Page<AlertItem>>('/alerts/', {
page_size: 50,
ordering: '-created_at',
});
this.alerts = res.results;
} catch (err) {
// 静默降级:显示空态
console.error('[AlertsTab] load failed:', normalizeError(err as BusinessError | Error));
} finally {
this.loading = false;
this.refreshing = false;
}
}
@Builder
AlertCard(item: AlertItem) {
Column({ space: 8 }) {
Row({ space: 8 }) {
Circle()
.width(8)
.height(8)
.fill(levelColor(item.level))
Text(item.title)
.fontSize(Math.round(15 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(formatDateTime(item.created_at))
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
Text(item.description && item.description.length > 0 ? item.description : '(无详情)')
.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)
}
build() {
Refresh({ refreshing: $$this.refreshing }) {
Scroll() {
Column({ space: 10 }) {
Row() {
Text('预警消息')
.fontSize(Math.round(18 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text('刷新')
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.accent_blue'))
.onClick(() => {
this.loadAlerts();
})
}
.width('92%')
.margin({ top: 20, bottom: 4 })
if (this.loading) {
Text('加载中...')
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else if (this.alerts.length === 0) {
Column({ space: 8 }) {
Text('🔔')
.fontSize(Math.round(32 * this.scaleFor()))
Text('暂无预警消息')
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.margin({ top: 80 })
.alignItems(HorizontalAlign.Center)
} else {
ForEach(this.alerts, (item: AlertItem) => {
this.AlertCard(item)
}, (item: AlertItem) => String(item.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ bottom: 120 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
.onRefreshing(() => {
this.loadAlerts();
})
}
}
@@ -0,0 +1,187 @@
// 业务中心页签(按角色自适应入口列表,ArkTS 严格类型,无 any)
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { uiMaterial } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { UnifiedRole, normalizeError } from '../../common/ApiConstants';
import { AuthStore } from '../../utils/AuthStore';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface BusinessEntry {
key: string;
title: string;
desc: string;
route: string; // 空字符串表示未实现
}
interface BusinessSection {
title: string;
entries: Array<BusinessEntry>;
}
@Component
export struct BusinessTab {
@State role: UnifiedRole | null = null;
@State sections: Array<BusinessSection> = [];
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
aboutToAppear(): void {
this.loadRole();
}
private async loadRole(): Promise<void> {
const state = await AuthStore.getState();
this.role = state.role;
this.applyRoleConfig();
}
private applyRoleConfig(): void {
const role = this.role;
if (role === 'nurse' || role === 'staff_nurse') {
this.sections = [
{
title: '护理工作',
entries: [
{ key: 'record', title: '护理记录录入', desc: '提交文字/图片护理记录', route: 'pages/nurse/RecordEntry' },
{ key: 'history', title: '护理记录列表', desc: '查看与搜索历史记录', route: 'pages/nurse/History' },
{ key: 'work-center', title: '护理中心', desc: '当前楼层与护理要务', route: 'pages/nurse/WorkCenter' },
],
},
{
title: '老人服务',
entries: [
{ key: 'elderly', title: '老人列表', desc: '负责长者的档案信息', route: 'pages/elderly/ElderlyList' },
{ key: 'skin', title: '皮肤检查', desc: '压疮风险巡检记录', route: 'pages/nurse/RecordEntry' },
{ key: 'medications', title: '用药管理', desc: '长者用药提醒与记录', route: 'pages/nurse/MedicationToDo' },
],
},
];
} else if (role === 'family') {
this.sections = [
{
title: '看护服务',
entries: [
{ key: 'records', title: '护理记录', desc: '查看长者护理记录', route: 'pages/family/Records' },
{ key: 'elderly', title: '长者档案', desc: '查看长者档案信息', route: 'pages/family/FamilyElderly' },
{ key: 'history', title: '长者历史', desc: '健康与护理历史', route: 'pages/family/FamilyElderlyHistory' },
{ key: 'assistant', title: '智能助手', desc: '随时咨询家人状况', route: 'pages/family/FamilyAssistant' },
{ key: 'verify', title: '安全验证', desc: '验证家属身份', route: 'pages/family/FamilyVerify' },
],
},
];
} else if (role === 'fire_control') {
this.sections = [
{
title: '消防工作',
entries: [
{ key: 'patrol', title: '消防巡更', desc: '扫码打卡与巡更记录', route: 'pages/fire-control/FirePatrolHome' },
],
},
];
} else if (role === 'sales') {
this.sections = [
{
title: '销售工作',
entries: [
{ key: 'sales', title: '销售工作台', desc: '线索、跟进与合同', route: 'pages/sales/SalesHome' },
],
},
];
} else {
// 管理员/默认
this.sections = [
{
title: '记录管理',
entries: [
{ key: 'today', title: '今日记录', desc: '查看今日全部护理记录', route: 'pages/nurse/History' },
{ key: 'records', title: '记录概览', desc: '搜索与筛选护理记录', route: 'pages/nurse/History' },
{ key: 'alerts', title: '预警概览', desc: '查看预警处理情况', route: 'pages/nurse/Alerts' },
],
},
{
title: '运营管理',
entries: [
{ key: 'meal', title: '订餐查询', desc: '今日菜单与订餐记录', route: 'pages/meals/MealOrders' },
{ key: 'schedule', title: '排班管理', desc: '查看和调整护理排班', route: 'pages/admin/AdminSchedule' },
{ key: 'feedback', title: '意见反馈', desc: '收集家属及员工建议', route: 'pages/profile/Feedback' },
],
},
];
}
}
private onEntryClick(entry: BusinessEntry): void {
if (entry.route.length === 0) {
promptAction.showToast({ message: '功能开发中' });
return;
}
router.pushUrl({ url: entry.route }).catch((err: BusinessError) => {
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
});
}
@Builder
EntryRow(entry: BusinessEntry) {
Row({ space: 12 }) {
Column({ space: 4 }) {
Text(entry.title)
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text(entry.desc)
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('›')
.fontSize(Math.round(22 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
.padding(16)
.backgroundColor(Color.Transparent)
.backgroundBlurStyle(BlurStyle.Thin)
.borderRadius(14)
.onClick(() => {
this.onEntryClick(entry);
})
}
build() {
Scroll() {
Column({ space: 18 }) {
ForEach(this.sections, (section: BusinessSection) => {
Column({ space: 10 }) {
Text(section.title)
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Column({ space: 8 }) {
ForEach(section.entries, (entry: BusinessEntry) => {
this.EntryRow(entry)
}, (entry: BusinessEntry) => entry.key)
}
.width('100%')
}
.width('92%')
.alignItems(HorizontalAlign.Start)
}, (section: BusinessSection) => section.title)
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 20, bottom: 120 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.width('100%')
.height('100%')
}
}
@@ -0,0 +1,258 @@
// 老人端 · 活动安排(ArkTS 严格类型,无 any)
// 接口:GET /activities/weeks/<week_start>/
import { router, promptAction } from '@kit.ArkUI';
import { InsightIntentPage, common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { normalizeError } from '../../common/ApiConstants';
import { api } from '../../utils/Request';
import { addCalendarEvent } from '../../utils/CalendarHelper';
interface ActivityItem {
id: number;
activity_name: string;
activity_description: string;
activity_date: string;
start_time: string;
end_time: string;
location: string;
participants: number;
}
interface ActivityWeek {
week_start: string;
status: string;
items: Array<ActivityItem>;
}
function weekStart(date: Date): string {
const d = new Date(date);
const day = d.getDay() === 0 ? 7 : d.getDay();
d.setDate(d.getDate() - day + 1);
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}
function formatDateLabel(iso: string): string {
const d = new Date(iso + 'T00:00:00');
if (Number.isNaN(d.getTime())) {
return iso;
}
const weekdays: Array<string> = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
return `${d.getMonth() + 1}/${d.getDate()} ${weekdays[d.getDay()]}`;
}
function formatTime(t: string): string {
return t && t.length > 0 ? t : '--';
}
function formatYMD(d: Date): string {
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}
@InsightIntentPage({
intentName: 'ViewActivities',
domain: 'ElderlyCare',
intentVersion: '1.0.0',
displayName: '查看院内活动',
displayDescription: '打开智慧护老的活动页,查看本周和下周的院内活动安排',
uiAbility: 'EntryAbility',
pagePath: 'ets/pages/elderly/Activities',
keywords: ['活动', '健身', '八段锦', '娱乐', '课程'],
llmDescription: '查看养老院本周及未来的院内活动安排与报名信息',
})
@Entry
@Component
struct Activities {
@State items: Array<ActivityItem> = [];
@State loading: boolean = true;
@State weekStartText: string = weekStart(new Date());
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.loadActivities();
}
// 从本周起向后查找最近一个已发布且有活动的周,避免本周未发布时误显示空周
private async loadActivities(): Promise<void> {
this.loading = true;
try {
const base = new Date(this.weekStartText + 'T00:00:00');
for (let i = 0; i < 5; i++) {
const ws = new Date(base);
ws.setDate(ws.getDate() + i * 7);
const week = formatYMD(ws);
const res = await api.get<ActivityWeek>(`/activities/weeks/${week}/`);
if (res.items && res.items.length > 0) {
this.items = res.items;
this.weekStartText = week;
break;
}
this.items = [];
}
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
@Builder
ActivityCard(item: ActivityItem) {
Column({ space: 8 }) {
Row() {
Text(item.activity_name)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(formatDateLabel(item.activity_date))
.fontSize(13)
.fontColor($r('app.color.accent_blue'))
}
.width('100%')
if (item.activity_description && item.activity_description.length > 0) {
Text(item.activity_description)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
Row({ space: 12 }) {
Text(`🕐 ${formatTime(item.start_time)} - ${formatTime(item.end_time)}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
if (item.location && item.location.length > 0) {
Text(`📍 ${item.location}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
Blank()
Text('+ 日历')
.fontSize(13)
.fontColor($r('app.color.accent_blue'))
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor($r('app.color.background'))
.onClick(() => {
this.addEventToCalendar(item);
})
.accessibilityLevel('yes')
.accessibilityText(`将${item.activity_name}加入系统日历`)
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
// 解析活动日期与时间 → 写入系统日历
private async addEventToCalendar(item: ActivityItem): Promise<void> {
try {
// 后端时间为 HH:mm:ss,兼容 HH:mm;避免拼出非法 ISO 串
const startT = item.start_time.length >= 8 ? item.start_time.substring(0, 5) : item.start_time;
const endT = item.end_time.length >= 8 ? item.end_time.substring(0, 5) : item.end_time;
const startTimeStr = `${item.activity_date}T${startT.length > 0 ? startT : '09:00'}:00`;
const endTimeStr = `${item.activity_date}T${endT.length > 0 ? endT : '10:00'}:00`;
const start = new Date(startTimeStr).getTime();
const end = new Date(endTimeStr).getTime();
if (Number.isNaN(start) || Number.isNaN(end)) {
promptAction.showToast({ message: '活动时间无法解析' });
return;
}
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
const errText = await addCalendarEvent(context, `活动:${item.activity_name}`, start, end,
item.location ?? '', item.activity_description ?? '');
if (errText.length === 0) {
promptAction.showToast({ message: '已加入系统日历' });
} else {
promptAction.showToast({ message: errText });
}
} catch (err) {
promptAction.showToast({ message: `写入日历失败:${normalizeError(err as BusinessError | Error)}` });
}
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 80 })
} else if (this.items.length === 0) {
Column({ space: 8 }) {
Text('🎉')
.fontSize(40)
Text('本周暂无活动安排')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.margin({ top: 100 })
.alignItems(HorizontalAlign.Center)
} else {
Scroll(this.scroller) {
Column({ space: 10 }) {
Text(`活动安排(${this.weekStartText} 起)`)
.fontSize(13)
.fontColor($r('app.color.inactive_tab'))
.width('100%')
ForEach(this.items, (item: ActivityItem) => {
this.ActivityCard(item)
}, (item: ActivityItem) => String(item.id))
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 20 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.width('100%')
}
}
.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'))
}
}
@@ -0,0 +1,146 @@
// 老人助手(ArkTS 严格类型,无 any)
// 数据来源:/elderly-portal/assistant/reply/
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
interface ReplyResult {
reply: string;
auto_reserved: boolean;
reservations: Array<Record<string, string | number | boolean>>;
}
// 助手消息请求体
interface AssistantPayload {
message: string;
}
interface ReservationItem {
id: string;
meal_name: string;
time: string;
status: string;
}
@Entry
@Component
struct Assistant {
@State inputText: string = '';
@State messages: ChatMessage[] = [];
@State sending: boolean = false;
private scroller: Scroller = new Scroller();
private async send(): Promise<void> {
const text = this.inputText.trim();
if (text.length === 0 || this.sending) {
return;
}
const nextMessages = this.messages.concat([{ role: 'user', content: text }]);
this.messages = nextMessages;
this.inputText = '';
this.sending = true;
try {
const payload: AssistantPayload = { message: text };
const res = await api.post<ReplyResult>('/elderly-portal/assistant/reply/', payload);
const reply = res.reply && res.reply.length > 0 ? res.reply : '暂时无法回答,请稍后再试。';
this.messages = this.messages.concat([{ role: 'assistant', content: reply }]);
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
this.messages = this.messages.concat([{ role: 'assistant', content: message }]);
} finally {
this.sending = false;
}
}
@Builder
MessageView(msg: ChatMessage) {
Row() {
if (msg.role === 'user') {
Blank()
Column() {
Text(msg.content)
.fontSize(15)
.fontColor($r('app.color.text_primary'))
}
.width('75%')
.padding(12)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
} else {
Column() {
Text(msg.content)
.fontSize(15)
.fontColor($r('app.color.text_primary'))
}
.width('75%')
.padding(12)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
Blank()
}
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 10 })
}
build() {
Column() {
Text('老人助手')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
.padding({ left: 20, right: 20, top: 20, bottom: 8 })
Scroll(this.scroller) {
Column({ space: 6 }) {
ForEach(this.messages, (msg: ChatMessage) => {
this.MessageView(msg)
}, (msg: ChatMessage) => `${msg.role}-${this.messages.indexOf(msg)}`)
}
.width('100%')
.padding({ bottom: 20 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.align(Alignment.Top)
Row({ space: 10 }) {
TextArea({ placeholder: '输入想说的话,例如:我想吃清淡点的晚餐', text: this.inputText })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.height(46)
.layoutWeight(1)
.padding({ left: 14, right: 14 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.onChange((value: string) => {
this.inputText = value;
})
Button(this.sending ? '…' : '发送')
.width(64)
.height(46)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
.enabled(!this.sending)
.onClick(() => {
this.send();
})
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 20 })
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,383 @@
// 新增对象(录入老人档案)(ArkTS 严格类型,无 any)
// 接口:POST /elderly/
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { common } from '@kit.AbilityKit';
import { takePhoto, pickOneImage, recognizeTextFromUri, extractIdCardFields } from '../../utils/OcrHelper';
// 新增老人请求体
interface ElderlyCreatePayload {
name: string;
gender: string;
birth_date: string;
id_card: string;
phone: string;
room: string;
bed: string;
floor: string;
care_level: string;
medical_history: string;
allergies: string;
medications: string;
}
const CARE_LEVELS: Array<string> = ['self_care', 'semi_care', 'full_care'];
const GENDERS: Array<string> = ['male', 'female'];
function careLevelLabel(value: string): string {
if (value === 'self_care') {
return '自理';
}
if (value === 'semi_care') {
return '半护理';
}
if (value === 'full_care') {
return '全护理';
}
return '自理';
}
@Entry
@Component
struct ElderlyAdd {
@State name: string = '';
@State gender: string = 'male';
@State birthDate: string = '';
@State idCard: string = '';
@State phone: string = '';
@State room: string = '';
@State bed: string = '';
@State floor: string = '';
@State careLevel: string = 'self_care';
@State medicalHistory: string = '';
@State allergies: string = '';
@State medications: string = '';
@State submitting: boolean = false;
@State ocrRunning: boolean = false;
private scroller: Scroller = new Scroller();
// 拍身份证识别:端侧 OCR 提取证件号/性别/出生日期/姓名,自动填表(可核对修改)
private async runIdCardOcr(useCamera: boolean): Promise<void> {
if (this.ocrRunning) {
return;
}
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
const uri = useCamera ? await takePhoto(context) : await pickOneImage(context);
if (uri === null) {
return;
}
this.ocrRunning = true;
promptAction.showToast({ message: '识别中...' });
try {
const text = await recognizeTextFromUri(uri);
const fields = extractIdCardFields(text);
if (fields.idNumber.length === 0) {
promptAction.showToast({ message: '未识别到身份证号,请光线充足后重拍' });
return;
}
this.idCard = fields.idNumber;
if (fields.gender.length > 0) {
this.gender = fields.gender;
}
if (fields.birthDate.length > 0) {
this.birthDate = fields.birthDate;
}
if (this.name.trim().length === 0 && fields.name.length > 0) {
this.name = fields.name;
}
promptAction.showToast({ message: '识别完成,请核对后提交' });
} catch (err) {
console.error('[ElderlyAdd] id card ocr failed:', normalizeError(err as BusinessError | Error));
promptAction.showToast({ message: '识别失败,请重试或手动输入' });
} finally {
this.ocrRunning = false;
}
}
// 跨端 OCR 菜单(bindMenu 自定义项)
@Builder
OcrMenu() {
Column() {
Text('📷 拍照识别')
.width('100%')
.padding(12)
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.onClick(() => {
this.runIdCardOcr(true);
})
Divider().color($r('app.color.background'))
Text('🖼️ 从相册识别')
.width('100%')
.padding(12)
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.onClick(() => {
this.runIdCardOcr(false);
})
}
.width(170)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
}
private async submit(): Promise<void> {
if (this.name.trim().length === 0) {
promptAction.showToast({ message: '请输入老人姓名' });
return;
}
if (this.idCard.trim().length === 0) {
promptAction.showToast({ message: '请输入身份证号' });
return;
}
if (this.submitting) {
return;
}
this.submitting = true;
try {
const body: ElderlyCreatePayload = {
name: this.name.trim(),
gender: this.gender,
birth_date: this.birthDate.trim(),
id_card: this.idCard.trim(),
phone: this.phone.trim(),
room: this.room.trim(),
bed: this.bed.trim(),
floor: this.floor.trim(),
care_level: this.careLevel,
medical_history: this.medicalHistory.trim(),
allergies: this.allergies.trim(),
medications: this.medications.trim(),
};
await api.post('/elderly/', body);
promptAction.showToast({ message: '老人档案已创建' });
router.back();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.submitting = false;
}
}
@Builder
Field(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
Column({ space: 6 }) {
Text(label)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.width('100%')
TextInput({ placeholder: placeholder, text: value })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.height(44)
.padding({ left: 12, right: 12 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(10)
.onChange((v: string) => {
onChange(v);
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 14 }) {
// 基本信息
Column({ space: 12 }) {
Text('基本信息')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
this.Field('姓名 *', '请输入老人姓名', this.name, (v: string) => {
this.name = v;
})
// 性别
Column({ space: 6 }) {
Text('性别')
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.width('100%')
Row({ space: 10 }) {
ForEach(GENDERS, (g: string) => {
Text(g === 'male' ? '男' : '女')
.fontSize(14)
.fontColor(this.gender === g ? '#FFFFFF' : $r('app.color.text_primary'))
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.backgroundColor(this.gender === g ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(10)
.onClick(() => {
this.gender = g;
})
}, (g: string) => g)
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
this.Field('出生日期', '如 1950-01-01', this.birthDate, (v: string) => {
this.birthDate = v;
})
this.Field('身份证号 *', '请输入身份证号', this.idCard, (v: string) => {
this.idCard = v;
})
// 身份证拍照识别(端侧 OCR,可选)
Button(this.ocrRunning ? '识别中...' : '📷 拍身份证自动填入')
.fontSize(14)
.height(40)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(10)
.enabled(!this.ocrRunning)
.bindMenu(this.OcrMenu)
.accessibilityLevel('yes')
.accessibilityText('拍身份证自动填入')
.accessibilityDescription('拍照或从相册选择身份证照片,自动识别证件号、性别、出生日期')
this.Field('联系电话', '选填', this.phone, (v: string) => {
this.phone = v;
})
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
// 位置与护理
Column({ space: 12 }) {
Text('位置与护理')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
this.Field('房间', '如 801', this.room, (v: string) => {
this.room = v;
})
this.Field('床位', '如 12', this.bed, (v: string) => {
this.bed = v;
})
this.Field('楼层', '如 8F', this.floor, (v: string) => {
this.floor = v;
})
// 护理等级
Column({ space: 6 }) {
Text('护理等级')
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.width('100%')
Row({ space: 10 }) {
ForEach(CARE_LEVELS, (level: string) => {
Text(careLevelLabel(level))
.fontSize(14)
.fontColor(this.careLevel === level ? '#FFFFFF' : $r('app.color.text_primary'))
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor(this.careLevel === level ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(10)
.onClick(() => {
this.careLevel = level;
})
}, (level: string) => level)
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
// 健康信息
Column({ space: 12 }) {
Text('健康信息')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
this.Field('病史', '选填', this.medicalHistory, (v: string) => {
this.medicalHistory = v;
})
this.Field('过敏史', '选填', this.allergies, (v: string) => {
this.allergies = v;
})
this.Field('用药情况', '选填', this.medications, (v: string) => {
this.medications = v;
})
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
Button(this.submitting ? '提交中...' : '保存老人档案')
.width('100%')
.height(50)
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
.enabled(!this.submitting)
.onClick(() => {
this.submit();
})
}
.width('92%')
.padding({ top: 120, bottom: 30 })
.alignItems(HorizontalAlign.Center)
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.width('100%')
}
.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'))
}
}
@@ -0,0 +1,299 @@
// 老人端 · 聊天助手面板(ArkTS 严格类型,无 any)
// 数据来源:/elderly-portal/assistant/reply/
// 历史对话记录本地持久化(preferences)
import { promptAction } from '@kit.ArkUI';
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
const CHAT_PREFS_NAME = 'ElderlyChat';
const CHAT_MESSAGES_KEY = 'messages';
interface ChatMessage {
id: number; // 唯一消息 id
role: string;
content: string;
displayLen: number; // 已显示字符数(打字机效果),-1 表示全文
}
// 自增 id 计数器
let chatMessageSeq = 1;
interface AssistantReservation {
slot_id: string;
slot_name: string;
ordered: boolean;
service_date: string;
}
interface ReplyResult {
reply: string;
auto_reserved: boolean;
reservations: Array<AssistantReservation>;
}
// 助手消息请求体
interface AssistantPayload {
message: string;
}
@Component
export struct ElderlyAssistantPanel {
@State inputText: string = '';
@State messages: Array<ChatMessage> = [];
@State sending: boolean = false;
@State typingKey: string = '';
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
// 缩放倍数(读 @StorageProp,变化触发重渲)
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
private scroller: Scroller = new Scroller();
private typingTimer: number = -1;
aboutToAppear(): void {
this.loadHistory().then(() => {
// 打开默认滚动到最新消息
setTimeout(() => {
this.scrollToBottom();
}, 100);
});
}
// 读取本地历史对话
private async loadHistory(): Promise<void> {
try {
const context = getContext(this) as common.UIAbilityContext;
const prefs = await preferences.getPreferences(context, CHAT_PREFS_NAME);
const value = await prefs.get(CHAT_MESSAGES_KEY, '');
if (typeof value === 'string' && value.length > 0) {
const parsed = JSON.parse(value) as Array<ChatMessage>;
if (parsed && parsed.length > 0) {
// 历史消息补 displayLen(全文显示)
const restored = parsed.map((m: ChatMessage): ChatMessage => ({
id: chatMessageSeq++,
role: m.role,
content: m.content,
displayLen: m.displayLen !== undefined ? m.displayLen : -1,
}));
this.messages = restored;
}
}
} catch (err) {
console.warn('[ElderlyChat] load history failed:', normalizeError(err as BusinessError | Error));
}
}
// 保存历史对话(限制最近 100 条;保存时全部以全文 displayLen=-1 存储)
private async saveHistory(): Promise<void> {
try {
const context = getContext(this) as common.UIAbilityContext;
const prefs = await preferences.getPreferences(context, CHAT_PREFS_NAME);
const toSave = this.messages.length > 100 ? this.messages.slice(this.messages.length - 100) : this.messages;
const fullText = toSave.map((m: ChatMessage): ChatMessage => ({
id: m.id,
role: m.role,
content: m.content,
displayLen: -1,
}));
await prefs.put(CHAT_MESSAGES_KEY, JSON.stringify(fullText));
await prefs.flush();
} catch (err) {
console.warn('[ElderlyChat] save history failed:', normalizeError(err as BusinessError | Error));
}
}
// 滚动到底部聚焦最新消息
private scrollToBottom(): void {
this.scroller.scrollEdge(Edge.Bottom);
}
// 启动打字机效果:逐字显示回复
private startTyping(id: number, fullText: string): void {
this.clearTyping();
this.typingKey = String(id);
this.messages = this.messages.map((m: ChatMessage): ChatMessage => {
if (m.id === id) {
return { id: m.id, role: m.role, content: m.content, displayLen: 0 };
}
return m;
});
let count = 0;
this.typingTimer = setInterval(() => {
count += 2;
if (count >= fullText.length) {
this.clearTyping();
}
this.messages = this.messages.map((m: ChatMessage): ChatMessage => {
if (m.id === id) {
const len = Math.min(count, fullText.length);
return { id: m.id, role: m.role, content: m.content, displayLen: len };
}
return m;
});
this.scrollToBottom();
}, 30);
}
private clearTyping(): void {
if (this.typingTimer !== -1) {
clearInterval(this.typingTimer);
this.typingTimer = -1;
}
this.typingKey = '';
}
private async send(): Promise<void> {
const text = this.inputText.trim();
if (text.length === 0 || this.sending) {
return;
}
const userMsg: ChatMessage = { id: chatMessageSeq++, role: 'user', content: text, displayLen: -1 };
this.messages = this.messages.concat([userMsg]);
this.inputText = '';
this.sending = true;
// 发送后聚焦新消息
setTimeout(() => {
this.scrollToBottom();
}, 50);
try {
const payload: AssistantPayload = { message: text };
const res = await api.post<ReplyResult>('/elderly-portal/assistant/reply/', payload);
let reply = res.reply && res.reply.length > 0 ? res.reply : '我会继续陪您聊天。';
if (res.auto_reserved && res.reservations.length > 0) {
reply += '\n已为您自动预订:' + res.reservations.map((r: AssistantReservation) => r.slot_name).join('、');
}
const botMsg: ChatMessage = { id: chatMessageSeq++, role: 'assistant', content: reply, displayLen: 0 };
this.messages = this.messages.concat([botMsg]);
// 打字机逐字显示回复
this.startTyping(botMsg.id, reply);
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
const botMsg: ChatMessage = { id: chatMessageSeq++, role: 'assistant', content: message, displayLen: -1 };
this.messages = this.messages.concat([botMsg]);
this.scrollToBottom();
} finally {
this.sending = false;
// 保存历史对话
this.saveHistory();
}
}
// 按打字机进度截取显示文本
private displayText(msg: ChatMessage): string {
if (msg.displayLen < 0) {
return msg.content;
}
return msg.content.substring(0, msg.displayLen);
}
// 按 id 从 @State messages 读取(保证打字机/更新重渲)
private messageById(id: number): ChatMessage {
const found = this.messages.find((m: ChatMessage) => m.id === id);
if (found) {
return found;
}
return { id: id, role: 'assistant', content: '', displayLen: -1 };
}
@Builder
MessageView(id: number) {
Row() {
if (this.messageById(id).role === 'user') {
Blank()
Text(this.displayText(this.messageById(id)))
.fontSize(Math.round(18 * this.scaleFor()))
.fontColor('#FFFFFF')
.constraintSize({ maxWidth: '70%' })
.padding(14)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
} else {
Text(this.displayText(this.messageById(id)))
.fontSize(Math.round(18 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.constraintSize({ maxWidth: '70%' })
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
Blank()
}
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 12 })
}
build() {
Column() {
Scroll(this.scroller) {
Column({ space: 6 }) {
if (this.messages.length === 0) {
Text('您好,我是您的智能助手 🌟\n可以直接对我说,例如:\n“帮我预订明天的午餐”')
.fontSize(Math.round(18 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.textAlign(TextAlign.Center)
.width('100%')
.padding({ top: 60 })
}
ForEach(this.messages, (msg: ChatMessage) => {
this.MessageView(msg.id)
}, (msg: ChatMessage) => String(msg.id))
}
.width('100%')
.padding({ bottom: 20 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.align(Alignment.Top)
Row({ space: 10 }) {
TextInput({ placeholder: '输入想说的话...', text: this.inputText })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.fontSize(Math.round(18 * this.scaleFor()))
.height(52)
.layoutWeight(1)
.padding({ left: 16, right: 16 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.onChange((value: string) => {
this.inputText = value;
})
.onSubmit((enterKey: EnterKeyType) => {
// 回车直接发送
this.send();
})
Button(this.sending ? '…' : '发送')
.height(52)
.padding({ left: 18, right: 18 })
.constraintSize({ minWidth: 80 })
.fontSize(Math.round(18 * this.scaleFor()))
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
.enabled(!this.sending)
.onClick(() => {
this.send();
})
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 12 })
// AI 声明
Text('内容由 AI 生成,仅供参考,如有疑问请咨询护理人员')
.fontSize(11)
.fontColor($r('app.color.inactive_tab'))
.textAlign(TextAlign.Center)
.width('100%')
.padding({ bottom: 8 })
}
.width('100%')
.height('100%')
}
}
@@ -0,0 +1,353 @@
// 老人详情页(ArkTS 严格类型,无 any)
// 数据来源:GET /elderly/{id}/
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { normalizeError } from '../../common/ApiConstants';
import { api } from '../../utils/Request';
interface ElderlyDetailData {
id: number;
name: string | null;
gender: string | null;
age: number | null;
birth_date: string | null;
plain_id_card: string | null;
room_bed: string | null;
floor_room_bed: string | null;
care_level: string | null;
care_level_label: string | null;
care_status: string | null;
overview_status: string | null;
risks: Array<string> | null;
medications: string | null;
medical_history: string | null;
allergies: string | null;
current_caregiver_name: string | null;
primary_family_name: string | null;
primary_family_relation: string | null;
primary_family_phone: string | null;
emergency_contact: string | null;
emergency_phone: string | null;
phone: string | null;
}
function genderLabel(gender: string | null): string {
if (gender === 'male') {
return '男';
}
if (gender === 'female') {
return '女';
}
return '未录入';
}
function displayEmpty(value: string | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
const text = String(value).trim();
return text.length > 0 ? text : '—';
}
@Entry
@Component
struct ElderlyDetail {
@State detail: ElderlyDetailData | null = null;
@State loading: boolean = true;
private elderlyId: string = '';
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
const params = router.getParams() as Record<string, string> | undefined;
if (params) {
this.elderlyId = params['id'] ?? '';
}
this.loadDetail();
}
private async loadDetail(): Promise<void> {
if (this.elderlyId.length === 0) {
this.loading = false;
return;
}
try {
this.detail = await api.get<ElderlyDetailData>(`/elderly/${this.elderlyId}/`);
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
@Builder
InfoRow(label: string, value: string) {
Row({ space: 12 }) {
Text(label)
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.width(84)
Text(displayEmpty(value))
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.textAlign(TextAlign.Start)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
}
@Builder
RiskTag(tag: string) {
Text(tag)
.fontSize(13)
.fontColor($r('app.color.accent_red'))
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor('#22FF4D4D')
.borderRadius(8)
.margin({ right: 8, bottom: 8 })
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else if (!this.detail) {
Text('未找到该老人信息')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.margin({ top: 60 })
} else {
Scroll(this.scroller) {
Column({ space: 14 }) {
// 基本信息卡
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(this.detail.name ?? '')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
if (this.detail.overview_status && this.detail.overview_status.length > 0) {
Text(this.detail.overview_status)
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor($r('app.color.accent_green'))
.borderRadius(8)
}
}
Text(`${genderLabel(this.detail.gender)} | 年龄:${this.detail.age ?? '未录入'} | ID: ${this.detail.id}`)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
if (this.detail.plain_id_card && this.detail.plain_id_card.length > 0) {
Text(this.detail.plain_id_card)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
}
}
.width('100%')
.padding(18)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 护理等级 + 房间
Column({ space: 6 }) {
Row() {
Text('护理等级')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
Blank()
Text(displayEmpty(this.detail.care_level_label))
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.accent_blue'))
}
.width('100%')
Divider().color($r('app.color.inactive_tab'))
Text(displayEmpty(this.detail.floor_room_bed))
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.width('100%')
}
.width('100%')
.padding(18)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 风险标签
if (this.detail.risks && this.detail.risks.length > 0) {
Column({ space: 8 }) {
Text('风险关注')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.detail.risks, (risk: string) => {
if (risk && risk.length > 0) {
this.RiskTag(risk)
}
}, (risk: string) => risk)
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
// 状态卡
Column({ space: 6 }) {
Text('护理状态')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Row() {
Text('状态')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
Blank()
Text(displayEmpty(this.detail.care_status))
.fontSize(15)
.fontColor($r('app.color.accent_green'))
}
.width('100%')
Row() {
Text('用药')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
Blank()
Text(displayEmpty(this.detail.medications))
.fontSize(15)
.fontColor($r('app.color.text_primary'))
}
.width('100%')
Row() {
Text('备注')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
Blank()
Text(displayEmpty(this.detail.medical_history))
.fontSize(15)
.fontColor($r('app.color.text_primary'))
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 护理员
Column({ space: 6 }) {
Text('护理员')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Text(displayEmpty(this.detail.current_caregiver_name))
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 联系人
Column({ space: 10 }) {
Text('联系人')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.detail.primary_family_name && this.detail.primary_family_name.trim().length > 0) {
Row() {
Text(this.detail.primary_family_name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
if (this.detail.primary_family_relation && this.detail.primary_family_relation.trim().length > 0) {
Text(this.detail.primary_family_relation)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.margin({ left: 8 })
}
Blank()
if (this.detail.primary_family_phone && this.detail.primary_family_phone.trim().length > 0) {
Text(this.detail.primary_family_phone)
.fontSize(14)
.fontColor($r('app.color.accent_blue'))
}
}
.width('100%')
} else {
Text('暂无配置')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 30 })
}
.width('92%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 60 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.width('100%')
}
}
}
.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'))
}
}
@@ -0,0 +1,202 @@
// 长者端 · 快速拨号(ArkTS 严格类型,无 any)
// 拨号:call.makeCall 跳转拨号界面,由用户确认拨打
// 家属列表:GET /elderly/bindings/(长者角色只能看到绑定自己的记录,含家属姓名/电话)
import { promptAction } from '@kit.ArkUI';
import { InsightIntentPage } from '@kit.AbilityKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { call } from '@kit.TelephonyKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { Page } from '../../common/ApiConstants';
import { api } from '../../utils/Request';
interface DialContact {
name: string;
phone: string;
desc: string;
color: string;
}
interface ApiFamilyBinding {
id: number;
user_name: string;
user_phone: string;
}
const CONTACTS: Array<DialContact> = [
{ name: '服务中心', phone: '020-12345678', desc: '院内服务台,任何需求都可以找', color: '#007AFF' },
{ name: '急救电话', phone: '120', desc: '突发身体不适时拨打', color: '#FF3B30' },
{ name: '火警电话', phone: '119', desc: '发现火情时拨打', color: '#FF9500' },
{ name: '报警电话', phone: '110', desc: '需要警察帮助时拨打', color: '#8E8E93' },
];
@InsightIntentPage({
intentName: 'CallFamily',
domain: 'ElderlyCare',
intentVersion: '1.0.0',
displayName: '呼叫家人',
displayDescription: '打开智慧护老的拨号页,一键拨打家人或护理员电话',
uiAbility: 'EntryAbility',
pagePath: 'ets/pages/elderly/ElderlyDial',
keywords: ['打电话', '呼叫', '家人', '护理员', '电话', '求助'],
llmDescription: '打开一键拨号页,拨打家人、护理员或养老院前台电话',
})
@Entry
@Component
struct ElderlyDial {
@State familyContacts: Array<DialContact> = [];
@State familyLoading: boolean = true;
aboutToAppear(): void {
this.loadFamily();
}
// 拉取绑定自己的家属(无电话的不展示)
private async loadFamily(): Promise<void> {
this.familyLoading = true;
try {
const res = await api.get<Page<ApiFamilyBinding>>('/elderly/bindings/', { page_size: 50 });
const contacts: Array<DialContact> = [];
res.results.forEach((b: ApiFamilyBinding): void => {
const phone = typeof b.user_phone === 'string' ? b.user_phone.trim() : '';
const name = typeof b.user_name === 'string' ? b.user_name.trim() : '';
if (phone.length > 0) {
contacts.push({
name: name.length > 0 ? name : '家属',
phone: phone,
desc: '您的家属,有事随时联系',
color: '#34C759',
});
}
});
this.familyContacts = contacts;
} catch (err) {
console.error('[ElderlyDial] load family bindings failed:', (err as Error).message);
} finally {
this.familyLoading = false;
}
}
private dial(phone: string): void {
call.makeCall(phone).catch(() => {
promptAction.showToast({ message: '拨号失败,请手动拨打' });
});
}
@Builder
ContactCard(contact: DialContact) {
Row({ space: 14 }) {
Column() {
Text(contact.name.substring(0, 1))
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(56)
.height(56)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor(contact.color)
.borderRadius(28)
Column({ space: 4 }) {
Text(contact.name)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(contact.desc)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
Text(contact.phone)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#007AFF')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('拨打')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
.backgroundColor('#34C759')
.borderRadius(22)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(18)
.alignItems(VerticalAlign.Center)
.onClick(() => {
this.dial(contact.phone);
})
}
build() {
HdsNavigation() {
Scroll() {
Column({ space: 14 }) {
Text('点击卡片即可拨打电话')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width('100%')
// 家属(动态:绑定我的家属账号)
if (this.familyContacts.length > 0) {
Text('我的家属')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
}
ForEach(this.familyContacts, (contact: DialContact) => {
this.ContactCard(contact)
}, (contact: DialContact) => contact.phone)
// 常用电话
Text('常用电话')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
ForEach(CONTACTS, (contact: DialContact) => {
this.ContactCard(contact)
}, (contact: DialContact) => contact.phone)
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 30 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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,
})
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,167 @@
// 老人端 · 历史订餐(ArkTS 严格类型,无 any)
// 数据来源:/elderly-portal/reservations/history/
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface MealHistoryItem {
slot_id: string;
service_date: string;
category: string;
menu: string;
ordered: boolean;
updated_at: string;
}
interface MealHistoryResponse {
reservations: Array<MealHistoryItem>;
}
@Component
export struct ElderlyHistoryPanel {
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
@State items: Array<MealHistoryItem> = [];
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
aboutToAppear(): void {
this.loadHistory();
}
private async loadHistory(): Promise<void> {
this.loading = true;
try {
const res = await api.get<MealHistoryResponse>('/elderly-portal/reservations/history/');
this.items = res.reservations;
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
@Builder
HistoryCard(item: MealHistoryItem) {
Column({ space: 8 }) {
Row() {
Text(item.category)
.fontSize(Math.round(18 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.accent_blue'))
Blank()
Text(item.service_date)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
if (item.menu && item.menu.length > 0) {
Text(item.menu)
.fontSize(Math.round(16 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.width('100%')
}
Text(item.ordered ? '已预订' : '已取消')
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor('#FFFFFF')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(item.ordered ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
.borderRadius(8)
}
.width('100%')
.padding(18)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 80 })
} else if (this.items.length === 0) {
Column({ space: 8 }) {
Text('🍽️')
.fontSize(40)
Text('暂无订餐历史')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.margin({ top: 100 })
.alignItems(HorizontalAlign.Center)
} else {
Scroll(this.scroller) {
Column({ space: 10 }) {
ForEach(this.items, (item: MealHistoryItem) => {
this.HistoryCard(item)
}, (item: MealHistoryItem) => `${item.slot_id}-${item.service_date}`)
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 24 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
}
.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'))
}
}
@Entry
@Component
struct ElderlyHistory {
build() {
Column() {
ElderlyHistoryPanel()
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.background'))
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,205 @@
// 关注对象(老人列表)页(ArkTS 严格类型,无 any)
// 数据来源:GET /elderly/?page_size=
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { Page, normalizeError } from '../../common/ApiConstants';
import { api } from '../../utils/Request';
interface ElderlyItem {
id: number;
name: string;
gender: string;
age: number | null;
room_bed: string;
floor_room_bed: string;
care_level_label: string;
overview_status: string;
}
// 详情页路由参数
interface DetailParams {
id: string;
}
function genderLabel(gender: string): string {
if (gender === 'male') {
return '男';
}
if (gender === 'female') {
return '女';
}
return '未录入';
}
@Entry
@Component
struct ElderlyList {
@State elderly: Array<ElderlyItem> = [];
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.loadList();
}
private async loadList(): Promise<void> {
this.loading = true;
try {
const res = await api.get<Page<ElderlyItem>>('/elderly/', { page_size: 100 });
this.elderly = res.results;
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
private openDetail(item: ElderlyItem): void {
const params: DetailParams = { id: String(item.id) };
router.pushUrl({
url: 'pages/elderly/ElderlyDetail',
params: params,
}).catch((err: BusinessError) => {
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
});
}
@Builder
ElderlyCard(item: ElderlyItem) {
Row({ space: 12 }) {
// 首字头像
Column() {
Text(item.name.length > 0 ? item.name.substring(0, 1) : '老')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(52)
.height(52)
.justifyContent(FlexAlign.Center)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(26)
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(item.name)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
if (item.overview_status && item.overview_status.length > 0) {
Text(item.overview_status)
.fontSize(12)
.fontColor('#FFFFFF')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor($r('app.color.accent_green'))
.borderRadius(6)
}
Blank()
Text('›')
.fontSize(20)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
Row({ space: 8 }) {
Text(`${genderLabel(item.gender)}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
Text(`年龄:${item.age ?? '未录入'}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
Text(item.care_level_label && item.care_level_label.length > 0 ? item.care_level_label : '')
.fontSize(13)
.fontColor($r('app.color.accent_blue'))
}
.width('100%')
if (item.floor_room_bed && item.floor_room_bed.length > 0) {
Text(item.floor_room_bed)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(VerticalAlign.Center)
.onClick(() => {
this.openDetail(item);
})
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else if (this.elderly.length === 0) {
Column({ space: 8 }) {
Text('👴')
.fontSize(36)
Text('暂无关注对象')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.margin({ top: 80 })
.alignItems(HorizontalAlign.Center)
} else {
Scroll(this.scroller) {
Column({ space: 12 }) {
ForEach(this.elderly, (item: ElderlyItem) => {
this.ElderlyCard(item)
}, (item: ElderlyItem) => String(item.id))
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 20 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.width('100%')
}
}
.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'))
}
}
@@ -0,0 +1,240 @@
// 老人端 · 餐食详情(ArkTS 严格类型,无 any)
// 数据来源:GET /elderly-portal/meals/{id}/?service_date=
// 操作:POST /elderly-portal/meals/{id}/reservation/
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface ElderlyMeal {
id: string;
name: string;
time: string;
menu: string;
description: string;
ordered: boolean;
reservable?: boolean;
reservation_hint?: string;
type: string;
service_date?: string;
}
interface ReservationPayload {
ordered: boolean;
service_date?: string;
}
function todayText(): string {
const d = new Date();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}
// 校验日期是否为 YYYY-MM-DD 格式
function isValidDate(date: string): boolean {
const pattern = /^\d{4}-\d{2}-\d{2}$/;
return pattern.test(date);
}
@Entry
@Component
struct ElderlyMealDetail {
@State meal: ElderlyMeal | null = null;
@State loading: boolean = true;
@State toggling: boolean = false;
private mealId: string = '';
private serviceDate: string = '';
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
const params = router.getParams() as Record<string, string> | undefined;
if (params) {
this.mealId = params['mealId'] ?? '';
this.serviceDate = params['serviceDate'] ?? '';
}
this.loadMeal();
}
private async loadMeal(): Promise<void> {
if (this.mealId.length === 0) {
this.loading = false;
return;
}
// 标准化 service_date 为 YYYY-MM-DD(截取前 10 位,避免带时间/空格)
const normalized = this.serviceDate.length >= 10 ? this.serviceDate.substring(0, 10) : this.serviceDate;
const finalDate = isValidDate(normalized) ? normalized : todayText();
try {
const url = `/elderly-portal/meals/${this.mealId}/?service_date=${finalDate}`;
console.info(`[MealDetail] request url=${url} rawServiceDate=${this.serviceDate} normalized=${normalized}`);
this.meal = await api.get<ElderlyMeal>(url);
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
private async toggleOrder(): Promise<void> {
const meal = this.meal;
if (!meal || this.toggling) {
return;
}
if (meal.reservable === false) {
promptAction.showToast({ message: meal.reservation_hint || '当前餐次不可操作' });
return;
}
this.toggling = true;
try {
const payload: ReservationPayload = { ordered: !meal.ordered };
if (this.serviceDate.length > 0) {
payload.service_date = this.serviceDate;
}
const updated = await api.post<ElderlyMeal>(`/elderly-portal/meals/${meal.id}/reservation/`, payload);
this.meal = updated;
promptAction.showToast({ message: updated.ordered ? '预订成功' : '已取消预订' });
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.toggling = false;
}
}
private statusLabel(): string {
const meal = this.meal;
if (!meal) {
return '';
}
if (meal.ordered) {
return '已预订';
}
if (meal.reservable === false) {
return '不可预订';
}
return '未预订';
}
@Builder
InfoRow(label: string, value: string) {
Row({ space: 12 }) {
Text(label)
.fontSize(18)
.fontColor($r('app.color.text_secondary'))
.width(90)
Text(value && value.length > 0 ? value : '--')
.fontSize(18)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(16)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else if (!this.meal) {
Text('未找到该餐次信息')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
.margin({ top: 60 })
} else {
Scroll(this.scroller) {
Column({ space: 16 }) {
Text(this.meal.name)
.fontSize(30)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Column({ space: 14 }) {
this.InfoRow('用餐时间', this.meal.time)
this.InfoRow('服务日期', this.meal.service_date ?? this.serviceDate)
this.InfoRow('菜单', this.meal.menu)
this.InfoRow('说明', this.meal.description)
}
.width('100%')
.padding(20)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
if (this.meal.reservation_hint && this.meal.reservation_hint.length > 0) {
Text(this.meal.reservation_hint)
.fontSize(15)
.fontColor('#FF9500')
.width('100%')
}
Row({ space: 10 }) {
Text(this.statusLabel())
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor(this.meal.ordered ? $r('app.color.accent_green') : $r('app.color.text_secondary'))
Blank()
Button(this.toggling ? '处理中...' : (this.meal.ordered ? '取消预订' : '立即预订'))
.height(52)
.fontSize(20)
.fontColor('#FFFFFF')
.backgroundColor(this.meal.ordered ? $r('app.color.accent_red') : $r('app.color.accent_green'))
.borderRadius(14)
.enabled(!this.toggling)
.onClick(() => {
this.toggleOrder();
})
}
.width('100%')
.padding(20)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
}
.width('92%')
.padding({ top: 60, bottom: 30 })
.alignItems(HorizontalAlign.Center)
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.width('100%')
}
}
.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'))
}
}
@@ -0,0 +1,546 @@
// 老人端 · 餐饮服务(定制餐点单)(ArkTS 严格类型,无 any)
// 接口(与 Web 管理端/小程序一致):
// GET /meals/custom-menu/?service_date=&category=
// GET /meals/custom-orders/
// POST /meals/custom-orders/
// POST /meals/custom-orders/{id}/cancel/
import { router } from '@kit.ArkUI';
import { InsightIntentPage } from '@kit.AbilityKit';
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, EmptyBody, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
type MealCategory = string; // '早餐' | '午餐' | '晚餐'
interface StandardMenuItem {
id: number;
name: string;
dish_type: string;
}
interface CustomMenuItem {
id: number;
source: string; // 'menu' | 'recommendation'
name: string;
price: string;
dish_type: string;
reason: string;
}
interface CustomMenuResponse {
standard_menu: Array<StandardMenuItem>;
custom_menu: Array<CustomMenuItem>;
}
interface OrderItemBrief {
dish_name: string;
quantity: number;
}
interface CustomOrder {
id: number;
service_date: string;
category: string;
status: string;
items: Array<OrderItemBrief>;
}
interface CustomOrdersResponse {
orders: Array<CustomOrder>;
}
function customOrderStatusLabel(status: string): string {
if (status === 'active') {
return '有效';
}
if (status === 'cancelled') {
return '已取消';
}
return status;
}
function customOrderStatusColor(status: string): string {
if (status === 'active') {
return '#34C759';
}
if (status === 'cancelled') {
return '#8E8E93';
}
return '#8E8E93';
}
interface OrderItemPayload {
menu_item_id?: number;
recommendation_id?: number;
quantity: number;
}
interface CreateOrderPayload {
service_date: string;
category: string;
items: Array<OrderItemPayload>;
}
interface DishKeyItem {
item: CustomMenuItem;
key: string;
}
const CATEGORIES: Array<string> = ['早餐', '午餐', '晚餐'];
function tomorrowText(): string {
const d = new Date();
d.setDate(d.getDate() + 1);
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}
function dishKey(item: CustomMenuItem): string {
return `${item.source}:${item.id}`;
}
function categoryIcon(category: string): string {
if (category === '早餐') {
return '🌅';
}
if (category === '午餐') {
return '🍚';
}
return '🌙';
}
@InsightIntentPage({
intentName: 'ViewMealOrder',
domain: 'ElderlyCare',
intentVersion: '1.0.0',
displayName: '查看我的订餐',
displayDescription: '打开智慧护老的订餐页,查看今日和明日的菜单并预订',
uiAbility: 'EntryAbility',
pagePath: 'ets/pages/elderly/ElderlyMealOrder',
keywords: ['订餐', '点餐', '吃饭', '菜单', '午餐', '晚餐', '早餐'],
llmDescription: '查看养老院长者的今日订餐与菜单,并可预订或取消餐食',
})
@Entry
@Component
struct ElderlyMealOrder {
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
@State serviceDate: string = tomorrowText();
@State category: string = '午餐';
@State customMenu: Array<CustomMenuItem> = [];
@State standardMenu: Array<StandardMenuItem> = [];
@State selectedKeys: Array<string> = [];
@State orders: Array<CustomOrder> = [];
@State loading: boolean = true;
@State submitting: boolean = false;
private scroller: Scroller = new Scroller();
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const responses: Array<CustomMenuResponse | CustomOrdersResponse | null> = await Promise.all([
api.get<CustomMenuResponse>('/meals/custom-menu/', {
service_date: this.serviceDate,
category: this.category,
}).catch((): CustomMenuResponse | null => null),
api.get<CustomOrdersResponse>('/meals/custom-orders/').catch((): CustomOrdersResponse | null => null),
]);
const menuRes = responses[0] as CustomMenuResponse | null;
const orderRes = responses[1] as CustomOrdersResponse | null;
this.standardMenu = menuRes ? menuRes.standard_menu : [];
this.customMenu = menuRes ? menuRes.custom_menu : [];
this.orders = orderRes ? orderRes.orders : [];
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
private personalRecommendations(): Array<CustomMenuItem> {
return this.customMenu.filter((item: CustomMenuItem) => item.source === 'recommendation');
}
private selectableDishes(): Array<CustomMenuItem> {
return this.customMenu.filter((item: CustomMenuItem) => item.source === 'menu');
}
private isSelected(item: CustomMenuItem): boolean {
return this.selectedKeys.indexOf(dishKey(item)) >= 0;
}
private toggleDish(item: CustomMenuItem): void {
const key = dishKey(item);
if (this.selectedKeys.indexOf(key) >= 0) {
this.selectedKeys = this.selectedKeys.filter((value: string) => value !== key);
} else {
this.selectedKeys = this.selectedKeys.concat([key]);
}
}
private submitBtnText(): string {
if (this.submitting) {
return '提交中...';
}
if (this.selectedKeys.length === 0) {
return '请先选择菜品';
}
if (this.scaleFor() >= 1.5) {
return `提交定制餐(${this.selectedKeys.length})`;
}
return `提交定制餐(已选${this.selectedKeys.length} 项)`;
}
private selectedDishes(): Array<CustomMenuItem> {
return this.customMenu.filter((item: CustomMenuItem) => this.selectedKeys.indexOf(dishKey(item)) >= 0);
}
private async createOrder(): Promise<void> {
const selected = this.selectedDishes();
if (selected.length === 0 || this.submitting) {
return;
}
this.submitting = true;
try {
const items: Array<OrderItemPayload> = selected.map((item: CustomMenuItem): OrderItemPayload => {
const payload: OrderItemPayload = { quantity: 1 };
if (item.source === 'recommendation') {
payload.recommendation_id = item.id;
} else {
payload.menu_item_id = item.id;
}
return payload;
});
const body: CreateOrderPayload = {
service_date: this.serviceDate,
category: this.category,
items: items,
};
await api.post('/meals/custom-orders/', body);
this.selectedKeys = [];
promptAction.showToast({ message: '已提交定制餐' });
await this.load();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.submitting = false;
}
}
private async cancelOrder(orderId: number): Promise<void> {
if (this.submitting) {
return;
}
this.submitting = true;
try {
await api.post(`/meals/custom-orders/${orderId}/cancel/`, new EmptyBody());
promptAction.showToast({ message: '已取消定制餐' });
await this.load();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.submitting = false;
}
}
@Builder
CategoryTab(category: string) {
Column({ space: 4 }) {
Text(categoryIcon(category))
.fontSize(Math.round(22 * this.scaleFor()))
Text(category)
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(this.category === category ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.category === category ? $r('app.color.accent_blue') : $r('app.color.text_secondary'))
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor(this.category === category ? '#14007AFF' : Color.Transparent)
.borderRadius(12)
.onClick(() => {
if (this.category !== category) {
this.category = category;
this.selectedKeys = [];
this.load();
}
})
}
@Builder
DishCard(item: CustomMenuItem) {
Row({ space: 10 }) {
Column({ space: 4 }) {
Text(item.name)
.fontSize(Math.round(17 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 6 }) {
if (item.price && item.price.length > 0) {
Text(`¥${item.price}`)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor('#FF9500')
}
if (item.source === 'recommendation' && item.reason && item.reason.length > 0) {
Text(item.reason)
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.accent_blue'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(this.isSelected(item) ? '✓' : '+')
.fontSize(Math.round(18 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width(32)
.height(32)
.textAlign(TextAlign.Center)
.backgroundColor(this.isSelected(item) ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
.borderRadius(16)
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.border({
width: 2,
color: this.isSelected(item) ? $r('app.color.accent_green') : Color.Transparent
})
.onClick(() => {
this.toggleDish(item);
})
}
@Builder
OrderCard(order: CustomOrder) {
Column({ space: 8 }) {
Row() {
Text(`${order.category} · ${order.service_date}`)
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Blank()
Text(customOrderStatusLabel(order.status))
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor(customOrderStatusColor(order.status))
}
.width('100%')
Text(order.items.map((item: OrderItemBrief) => `${item.dish_name}×${item.quantity}`).join('、'))
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width('100%')
if (order.status === 'active') {
Button('取消定制餐')
.height(32)
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_red'))
.borderRadius(10)
.onClick(() => {
this.cancelOrder(order.id);
})
}
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 10 }) {
// 日期 + 餐次切换
Column({ space: 10 }) {
Text(`服务日期${this.serviceDate}(明日)`)
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width('100%')
Row({ space: 8 }) {
ForEach(CATEGORIES, (category: string) => {
this.CategoryTab(category)
}, (category: string) => category)
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
if (this.loading) {
Text('加载中...')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
.width('100%')
.padding({ top: 40, bottom: 40 })
.textAlign(TextAlign.Center)
} else {
// 个人推荐
if (this.personalRecommendations().length > 0) {
Text('为您推荐')
.fontSize(Math.round(19 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
ForEach(this.personalRecommendations(), (item: CustomMenuItem) => {
this.DishCard(item)
}, (item: CustomMenuItem) => dishKey(item))
}
// 标准菜单(只读)
if (this.standardMenu.length > 0) {
Text('标准菜单')
.fontSize(Math.round(19 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
ForEach(this.standardMenu, (item: StandardMenuItem) => {
Row({ space: 10 }) {
Text(item.name)
.fontSize(Math.round(16 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (item.dish_type && item.dish_type.length > 0) {
Text(item.dish_type)
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
}, (item: StandardMenuItem) => String(item.id))
}
// 可选菜品(可定制加菜)
Text('可选定制菜')
.fontSize(Math.round(19 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.selectableDishes().length === 0) {
Text('暂无可选定制菜')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.textAlign(TextAlign.Center)
} else {
ForEach(this.selectableDishes(), (item: CustomMenuItem) => {
this.DishCard(item)
}, (item: CustomMenuItem) => dishKey(item))
}
// 提交
Button(this.submitBtnText())
.width('100%')
.height(52)
.fontSize(Math.round(18 * this.scaleFor()))
.fontColor('#FFFFFF')
.backgroundColor(this.selectedKeys.length > 0 ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
.borderRadius(14)
.enabled(this.selectedKeys.length > 0 && !this.submitting)
.onClick(() => {
this.createOrder();
})
// 我的定制餐
Text('我的定制餐')
.fontSize(Math.round(19 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.orders.length === 0) {
Text('暂无定制餐订单')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.textAlign(TextAlign.Center)
} else {
ForEach(this.orders, (order: CustomOrder) => {
this.OrderCard(order)
}, (order: CustomOrder) => String(order.id))
}
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 24 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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'))
}
}
@@ -0,0 +1,225 @@
// 老人端 · 我的面板(ArkTS 严格类型,无 any)
// 对齐小程序 ElderlyProfilePanel:账号卡 + 菜单 + 退出登录
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { AuthStore } from '../../utils/AuthStore';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { FontScaleLevel, FontScaleOption, FONT_SCALE_LABELS, FONT_SCALE_MAP, loadFontScale, setFontScale } from '../../utils/FontScale';
interface ProfileDashboard {
elderly_id: number | null;
elderly_name: string;
service_date: string;
}
interface ProfileMenu {
key: string;
title: string;
desc: string;
route: string; // 空字符串表示未实现
}
@Component
export struct ElderlyProfilePanel {
@State userName: string = '';
@State userId: number = 0;
@State elderlyName: string = '';
@State fontScale: FontScaleLevel = 'normal';
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
menus: Array<ProfileMenu> = [
{ key: 'meal', title: '餐饮服务', desc: '查看并选择定制餐', route: 'pages/elderly/ElderlyMealOrder' },
{ key: 'activities', title: '活动安排', desc: '查看今天和本周的社工活动', route: 'pages/elderly/Activities' },
{ key: 'notifications', title: '消息通知', desc: '查看护理记录和订餐状态消息', route: 'pages/notifications/Notifications' },
{ key: 'history', title: '历史订餐', desc: '查看过往和近期的订餐记录', route: 'pages/elderly/ElderlyHistory' },
];
aboutToAppear(): void {
this.loadAuth();
this.loadDashboard();
loadFontScale(getContext(this) as common.UIAbilityContext).then((level: FontScaleLevel) => {
this.fontScale = level;
});
}
private async loadDashboard(): Promise<void> {
try {
const res = await api.get<ProfileDashboard>('/elderly-portal/dashboard/');
this.elderlyName = res.elderly_name || '';
} catch (err) {
console.warn('[ElderlyProfile] load dashboard failed:', normalizeError(err as BusinessError | Error));
}
}
private changeFontScale(level: FontScaleLevel): void {
this.fontScale = level;
setFontScale(getContext(this) as common.UIAbilityContext, level);
promptAction.showToast({ message: `字体已调整为${FONT_SCALE_LABELS.find((item) => item.level === level)?.label ?? ''}` });
}
private async loadAuth(): Promise<void> {
const state = await AuthStore.getState();
this.userName = state.userName;
this.userId = state.userId;
}
private onMenuClick(menu: ProfileMenu): void {
if (menu.route.length === 0) {
promptAction.showToast({ message: '功能开发中' });
return;
}
router.pushUrl({ url: menu.route }).catch(() => {});
}
private async logout(): Promise<void> {
await AuthStore.logout();
promptAction.showToast({ message: '已退出登录' });
router.replaceUrl({ url: 'pages/Login' }).catch(() => {});
}
@Builder
MenuRow(menu: ProfileMenu) {
Row({ space: 12 }) {
Column({ space: 6 }) {
Text(menu.title)
.fontSize(Math.round(17 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text(menu.desc)
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('›')
.fontSize(Math.round(22 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.onMenuClick(menu);
})
}
build() {
Scroll() {
Column({ space: 12 }) {
// 用户信息卡
Column({ space: 8 }) {
Stack() {
Circle()
.width(64)
.height(64)
.fill($r('app.color.accent_blue'))
Text((this.elderlyName || this.userName || '客').substring(0, 1))
.fontSize(Math.round(26 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(64)
.height(64)
Text(this.elderlyName || this.userName || '用户')
.fontSize(Math.round(22 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(this.elderlyName.length > 0 ? '长者账号' : '账号')
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.width('92%')
.padding({ top: 16, bottom: 14 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.margin({ top: 16 })
// 账号编号
Row() {
Text('账号编号')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
Blank()
Text(this.userId > 0 ? String(this.userId) : '--')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
}
.width('92%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
// 字体大小
Column({ space: 8 }) {
Text('字体大小')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.width('100%')
Flex({ wrap: FlexWrap.Wrap, alignItems: ItemAlign.Center }) {
ForEach(FONT_SCALE_LABELS, (item: FontScaleOption) => {
Text(item.label)
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor(this.fontScale === item.level ? '#FFFFFF' : $r('app.color.text_primary'))
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
.backgroundColor(this.fontScale === item.level ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(10)
.margin({ right: 10, bottom: 10 })
.onClick(() => {
this.changeFontScale(item.level);
})
}, (item: FontScaleOption) => item.level)
}
.width('100%')
}
.width('92%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
// 菜单
Column({ space: 8 }) {
ForEach(this.menus, (menu: ProfileMenu) => {
this.MenuRow(menu)
}, (menu: ProfileMenu) => menu.key)
}
.width('92%')
// 退出登录
Text('退出登录')
.fontSize(Math.round(17 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.accent_red'))
.textAlign(TextAlign.Center)
.width('92%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
// 避让底部悬浮 Tab
.margin({ bottom: 130 })
.onClick(() => {
this.logout();
})
}
.width('100%')
.alignItems(HorizontalAlign.Center)
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.width('100%')
.height('100%')
}
}
@@ -0,0 +1,211 @@
// 老人端 · 膳食推荐(ArkTS 严格类型,无 any)
// 接口:GET /elderly-portal/recommendations/
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface Recommendation {
id: number;
service_date: string;
meal_period: string;
dish_name: string;
price: string;
reason: string;
}
interface RecommendationsResponse {
elderly_id: number | null;
recommendations: Array<Recommendation>;
}
function periodIcon(period: string): string {
if (period === '早餐') {
return '🌅';
}
if (period === '午餐') {
return '🍚';
}
return '🌙';
}
function periodColor(period: string): string {
if (period === '早餐') {
return '#FF9500';
}
if (period === '午餐') {
return '#007AFF';
}
return '#34C759';
}
function formatDateLabel(dateStr: string): string {
if (!dateStr || dateStr.length === 0) {
return '';
}
const parts: Array<string> = dateStr.split('-');
if (parts.length !== 3) {
return dateStr;
}
const month = parseInt(parts[1], 10);
const day = parseInt(parts[2], 10);
return `${month}月${day}日`;
}
function isToday(dateStr: string): boolean {
if (!dateStr || dateStr.length === 0) {
return false;
}
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
return dateStr === `${now.getFullYear()}-${mm}-${dd}`;
}
@Entry
@Component
struct ElderlyRecommendations {
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
@State recommendations: Array<Recommendation> = [];
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const res = await api.get<RecommendationsResponse>('/elderly-portal/recommendations/');
this.recommendations = res.recommendations;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
RecommendCard(item: Recommendation) {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text(periodIcon(item.meal_period))
.fontSize(Math.round(24 * this.scaleFor()))
Text(`${item.meal_period} · ${isToday(item.service_date) ? '今日' : formatDateLabel(item.service_date)}`)
.fontSize(Math.round(17 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(`¥${item.price}`)
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor('#FF9500')
}
.width('100%')
Text(item.dish_name)
.fontSize(Math.round(20 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor(periodColor(item.meal_period))
.width('100%')
if (item.reason && item.reason.length > 0) {
Text(item.reason)
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.lineHeight(22)
.width('100%')
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(18)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.loading) {
Text('加载中...')
.fontSize(Math.round(16 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else if (this.recommendations.length === 0) {
Column({ space: 8 }) {
Text('暂无膳食推荐')
.fontSize(Math.round(20 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text('护理团队会根据您的健康状况推送个性化餐食建议')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.textAlign(TextAlign.Center)
}
.width('100%')
.padding(24)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Center)
} else {
ForEach(this.recommendations, (item: Recommendation) => {
Column() {
this.RecommendCard(item)
}
.width('100%')
}, (item: Recommendation) => String(item.id))
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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'))
}
}
@@ -0,0 +1,329 @@
// 长者端 · 天气(ArkTS 严格类型,无 any)
// 数据:Open-Meteo(utils/OpenMeteo.ts),广州 7 天预报
import { router } from '@kit.ArkUI';
import { InsightIntentPage } from '@kit.AbilityKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { fetchWeather, weatherCodeLabel, todayYMD, WeatherData, WeatherDay } from '../../utils/OpenMeteo';
function dateLabel(dateStr: string): string {
const today = todayYMD();
if (dateStr === today) {
return '今天';
}
if (dateStr.length < 5) {
return dateStr;
}
const md = dateStr.substring(5);
return md.replace('-', '/');
}
// 天气图标(与 weatherCodeLabel 同一套 code 区间)
function weatherEmoji(code: number): string {
if (code === 0 || code === 1) {
return '☀️';
}
if (code === 2) {
return '⛅';
}
if (code === 3) {
return '☁️';
}
if (code === 45 || code === 48) {
return '🌫️';
}
if (code >= 51 && code <= 57) {
return '🌦️';
}
if (code >= 71 && code <= 77 || code === 85 || code === 86) {
return '❄️';
}
if (code >= 95) {
return '⛈️';
}
return '🌧️';
}
// 温度图表常量:0~40° 映射到 160px
const CHART_MIN_T = 0;
const CHART_MAX_T = 40;
const CHART_HEIGHT = 160;
const CHART_PX_PER_DEGREE = CHART_HEIGHT / (CHART_MAX_T - CHART_MIN_T);
@InsightIntentPage({
intentName: 'ViewWeather',
domain: 'ElderlyCare',
intentVersion: '1.0.0',
displayName: '查看天气',
displayDescription: '打开智慧护老的天气页,查看养老院所在地七天天气',
uiAbility: 'EntryAbility',
pagePath: 'ets/pages/elderly/ElderlyWeather',
keywords: ['天气', '下雨', '气温', '出门', '降温'],
llmDescription: '查看养老院所在地的今日与未来七天天气预报',
})
@Entry
@Component
struct ElderlyWeather {
@State weather: WeatherData | null = null;
@State loading: boolean = true;
@State errorMsg: string = '';
aboutToAppear(): void {
// 与首页同源:优先用首页传入的定位坐标(-1 表示未定位,回退广州);无参数直接进入也回退
const params = router.getParams() as Record<string, Object> | null | undefined;
let lat = -1;
let lon = -1;
if (params !== null && params !== undefined) {
lat = Number(params['lat'] ?? -1);
lon = Number(params['lon'] ?? -1);
}
if (lat > 0 && lon > 0) {
this.load(lat, lon);
} else {
this.load(undefined, undefined);
}
}
private async load(lat?: number, lon?: number): Promise<void> {
this.loading = true;
this.errorMsg = '';
try {
const res = await fetchWeather(lat, lon);
this.weather = res;
} catch (err) {
this.errorMsg = (err as Error).message;
} finally {
this.loading = false;
}
}
// 当前温度映射到 0-40° 仪表区间
private gaugeValue(): number {
if (this.weather === null) {
return 0;
}
const t = Math.round(this.weather.current.temperature);
return Math.max(0, Math.min(40, t));
}
private todayRainText(): string {
const list = this.weather?.daily ?? [];
if (list.length > 0) {
return `今日降雨概率 ${list[0].rainPct}%`;
}
return '';
}
build() {
HdsNavigation() {
Scroll() {
Column({ space: 24 }) {
if (this.loading) {
Text('正在获取天气...')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
.padding({ top: 60 })
} else if (this.errorMsg.length > 0) {
Column({ space: 10 }) {
Text('天气获取失败')
.fontSize(18)
.fontColor($r('app.color.text_primary'))
Text(this.errorMsg)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding({ top: 60 })
.alignItems(HorizontalAlign.Center)
} else {
// 今日温度仪表(官方 Gauge 样式)
Column({ space: 8 }) {
Row() {
Gauge({ value: this.gaugeValue(), min: 0, max: 40 }) {
Column() {
Text(this.weather !== null ? `${this.weather.current.temperature.toFixed(1)}°C` : '--')
.fontSize(34)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
.textAlign(TextAlign.Center)
.margin({ top: '35%' })
.maxLines(1)
Text(this.weather !== null ? `${weatherCodeLabel(this.weather.current.code)} · 广州今天` : '--')
.fontSize(14)
.fontColor($r('sys.color.ohos_id_color_text_secondary'))
.width('100%')
.textAlign(TextAlign.Center)
Text(this.todayRainText())
.fontSize(13)
.fontColor($r('sys.color.ohos_id_color_text_secondary'))
.width('100%')
.textAlign(TextAlign.Center)
}
.width('100%')
.height('100%')
.alignItems(HorizontalAlign.Center)
}
.startAngle(210)
.endAngle(150)
.colors([
[new LinearGradient([{ color: '#61cfbe', offset: 0 }, { color: '#46b1e3', offset: 1 }]), 1],
[new LinearGradient([{ color: '#c1e4be', offset: 0 }, { color: '#64bb5c', offset: 1 }]), 1],
[new LinearGradient([{ color: '#fceb99', offset: 0 }, { color: '#f7ce00', offset: 1 }]), 1],
[new LinearGradient([{ color: '#f8c5a6', offset: 0 }, { color: '#ed6f21', offset: 1 }]), 1]
])
.width(240)
.height(240)
.strokeWidth(18)
.trackShadow({ radius: 7, offsetX: 7, offsetY: 7 })
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
// 未来 7 天温度范围图表(竖向柱状:柱底=最低温,柱顶=最高温)
Text('未来 7 天')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Column({ space: 12 }) {
Row({ space: 8 }) {
// 纵轴刻度
Column() {
Text('40°')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Text('30°')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Text('20°')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Text('10°')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Text('0°')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.height(CHART_HEIGHT)
.justifyContent(FlexAlign.SpaceBetween)
.alignItems(HorizontalAlign.End)
// 每日一列:图标 + 双柱(蓝=最低温/橙=最高温,柱高=温度) + 日期 + 降雨
ForEach(this.weather?.daily ?? [], (day: WeatherDay) => {
Column({ space: 4 }) {
Text(weatherEmoji(day.code))
.fontSize(20)
Row({ space: 6 }) {
Column({ space: 3 }) {
Text(`${Math.round(Math.max(day.minT, CHART_MIN_T))}°`)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor('#46b1e3')
Column()
.width(14)
.height(Math.min(Math.max(12, Math.round((Math.max(day.minT, CHART_MIN_T)) * CHART_PX_PER_DEGREE)), CHART_HEIGHT - 24))
.backgroundColor('#46b1e3')
.borderRadius(7)
}
.alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text(`${Math.round(Math.min(day.maxT, CHART_MAX_T))}°`)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor('#ed6f21')
Column()
.width(14)
.height(Math.min(Math.max(12, Math.round((Math.min(day.maxT, CHART_MAX_T)) * CHART_PX_PER_DEGREE)), CHART_HEIGHT - 24))
.backgroundColor('#ed6f21')
.borderRadius(7)
}
.alignItems(HorizontalAlign.Center)
}
.height(CHART_HEIGHT)
.width('100%')
.alignItems(VerticalAlign.Bottom)
.justifyContent(FlexAlign.Center)
Text(dateLabel(day.date))
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
Text(`雨${day.rainPct}%`)
.fontSize(11)
.fontColor(day.rainPct >= 60 ? '#007AFF' : $r('app.color.text_secondary'))
.maxLines(1)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.accessibilityLevel('yes')
.accessibilityText(`${dateLabel(day.date)},${weatherCodeLabel(day.code)},最低${Math.round(day.minT)}度,最高${Math.round(day.maxT)}度,降雨概率${day.rainPct}%`)
}, (day: WeatherDay) => day.date)
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
Row({ space: 6 }) {
Column().width(14).height(14).borderRadius(7).backgroundColor('#46b1e3')
Text('最低温')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Column().width(14).height(14).borderRadius(7).backgroundColor('#ed6f21')
Text('最高温')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(20)
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 30 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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,
})
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,266 @@
// 家属 · 预警详情(ArkTS 严格类型,无 any)
// 接口:GET /alerts/{id}/
import { router, promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface AlertProcess {
id: number;
nurse_name: string;
description: string;
created_at: string;
}
interface AlertDetail {
id: number;
elderly_name: string;
level: string;
metric: string;
actual_value: string;
title: string;
description: string;
status_display: string;
processing_by_name: string;
resolved_at: string;
created_at: string;
processes: Array<AlertProcess>;
}
function levelLabel(level: string): string {
if (level === 'emergency') {
return '紧急';
}
if (level === 'warning') {
return '注意';
}
return '正常';
}
function levelColor(level: string): string {
if (level === 'emergency') {
return '#FF3B30';
}
if (level === 'warning') {
return '#FF9500';
}
return '#007AFF';
}
function formatDateTime(iso: string): string {
if (!iso || iso.length === 0) {
return '--';
}
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 FamilyAlertDetail {
@State alert: AlertDetail | null = null;
@State loading: boolean = true;
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
const params = router.getParams() as Record<string, string | number>;
const id = params ? params['id'] : null;
if (id === null || id === undefined) {
router.back();
return;
}
this.load(Number(id));
}
private async load(id: number): Promise<void> {
this.loading = true;
try {
const res = await api.get<AlertDetail>(`/alerts/${id}/`);
this.alert = res;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
InfoRow(label: string, value: string) {
Row({ space: 12 }) {
Text(label)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width(72)
Text(value)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
}
.width('100%')
}
@Builder
ProcessCard(process: AlertProcess) {
Column({ space: 6 }) {
Row() {
Text(process.nurse_name)
.fontSize(Math.round(14 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Blank()
Text(formatDateTime(process.created_at))
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
Text(process.description)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else if (this.alert) {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text(levelLabel(this.alert.level))
.fontSize(Math.round(13 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor(levelColor(this.alert.level))
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.backgroundColor(this.alert.level === 'emergency' ? '#14FF3B30' : (this.alert.level === 'warning' ? '#14FF9500' : '#14007AFF'))
.borderRadius(6)
Text(this.alert.status_display)
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
Text(this.alert.title)
.fontSize(Math.round(20 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.alert.description && this.alert.description.length > 0) {
Text(this.alert.description)
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.lineHeight(Math.round(22 * this.scaleFor()))
.width('100%')
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
Column({ space: 12 }) {
this.InfoRow('长者', this.alert.elderly_name)
if (this.alert.metric && this.alert.metric.length > 0) {
this.InfoRow('指标', this.alert.metric)
}
if (this.alert.actual_value && this.alert.actual_value.length > 0) {
this.InfoRow('实际值', this.alert.actual_value)
}
this.InfoRow('触发时间', formatDateTime(this.alert.created_at))
if (this.alert.resolved_at && this.alert.resolved_at.length > 0) {
this.InfoRow('处理时间', formatDateTime(this.alert.resolved_at))
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
if (this.alert.processes && this.alert.processes.length > 0) {
Column({ space: 8 }) {
Text('处理记录')
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
ForEach(this.alert.processes, (process: AlertProcess) => {
Column() {
this.ProcessCard(process)
}
.width('100%')
}, (process: AlertProcess) => String(process.id))
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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'))
}
}
@@ -0,0 +1,230 @@
// 家属 · 健康预警列表(ArkTS 严格类型,无 any)
// 接口:GET /alerts/
import { router, 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, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface FamilyAlert {
id: number;
elderly_name: string;
level: string;
metric: string;
actual_value: string;
title: string;
description: string;
status_display: string;
created_at: string;
}
function levelColor(level: string): string {
if (level === 'emergency') {
return '#FF3B30';
}
if (level === 'warning') {
return '#FF9500';
}
return '#007AFF';
}
function levelLabel(level: string): string {
if (level === 'emergency') {
return '紧急';
}
if (level === 'warning') {
return '注意';
}
return '正常';
}
function formatDateTime(iso: string): string {
if (!iso || iso.length === 0) {
return '--';
}
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 FamilyAlerts {
@State alerts: Array<FamilyAlert> = [];
@State loading: boolean = true;
@State refreshing: boolean = false;
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const res = await api.get<Page<FamilyAlert>>('/alerts/', {
page_size: 50,
ordering: '-created_at',
});
this.alerts = res.results;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
this.refreshing = false;
}
}
private openDetail(alert: FamilyAlert): void {
router.pushUrl({ url: 'pages/family/FamilyAlertDetail', params: { id: alert.id } })
.catch((err: BusinessError) => {
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
});
}
@Builder
AlertCard(alert: FamilyAlert) {
Row({ space: 10 }) {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(levelLabel(alert.level))
.fontSize(Math.round(12 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor(levelColor(alert.level))
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.backgroundColor(alert.level === 'emergency' ? '#14FF3B30' : (alert.level === 'warning' ? '#14FF9500' : '#14007AFF'))
.borderRadius(6)
Text(alert.title)
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
if (alert.description && alert.description.length > 0) {
Text(alert.description)
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
Row({ space: 8 }) {
Text(alert.elderly_name)
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.accent_blue'))
Blank()
Text(formatDateTime(alert.created_at))
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('›')
.fontSize(20)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.openDetail(alert);
})
}
build() {
HdsNavigation() {
Column() {
Refresh({ refreshing: $$this.refreshing }) {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else if (this.alerts.length === 0) {
Text('暂无预警')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(24)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.textAlign(TextAlign.Center)
} else {
ForEach(this.alerts, (alert: FamilyAlert) => {
Column() {
this.AlertCard(alert)
}
.width('100%')
}, (alert: FamilyAlert) => String(alert.id))
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.width('100%')
.layoutWeight(1)
.onRefreshing(() => {
this.load();
})
}
.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'))
}
}
@@ -0,0 +1,177 @@
// 家属 · 智能助手(ArkTS 严格类型,无 any)
// 接口:POST /robots/family-assistant/query/ {message} → {reply}
import { router, promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface ChatMessage {
id: number;
role: string;
content: string;
}
interface AssistantReply {
reply: string;
}
let messageSeq = 1;
@Entry
@Component
struct FamilyAssistant {
@State inputText: string = '';
@State messages: Array<ChatMessage> = [];
@State sending: boolean = false;
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
private scroller: Scroller = new Scroller();
private async send(): Promise<void> {
const text = this.inputText.trim();
if (text.length === 0 || this.sending) {
return;
}
const userMsg: ChatMessage = { id: messageSeq++, role: 'user', content: text };
this.messages = this.messages.concat([userMsg]);
this.inputText = '';
this.sending = true;
setTimeout(() => {
this.scroller.scrollEdge(Edge.Bottom);
}, 50);
try {
const res = await api.post<AssistantReply>('/robots/family-assistant/query/', { message: text });
const reply = res.reply && res.reply.length > 0 ? res.reply : '好的,我会继续为您查询。';
this.messages = this.messages.concat([{ id: messageSeq++, role: 'assistant', content: reply }]);
this.scroller.scrollEdge(Edge.Bottom);
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
this.messages = this.messages.concat([{ id: messageSeq++, role: 'assistant', content: message }]);
this.scroller.scrollEdge(Edge.Bottom);
} finally {
this.sending = false;
}
}
@Builder
MessageView(msg: ChatMessage) {
Row() {
if (msg.role === 'user') {
Blank()
Text(msg.content)
.fontSize(Math.round(17 * this.scaleFor()))
.fontColor('#FFFFFF')
.constraintSize({ maxWidth: '72%' })
.padding(14)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
} else {
Text(msg.content)
.fontSize(Math.round(17 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.constraintSize({ maxWidth: '72%' })
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
Blank()
}
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 12 })
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 6 }) {
if (this.messages.length === 0) {
Text('您好,我是家属智能助手 🌟\n可以问询您家人的健康与照护情况,例如:\n“我妈今天状态怎么样?”')
.fontSize(Math.round(17 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.textAlign(TextAlign.Center)
.width('100%')
.padding({ top: 60 })
}
ForEach(this.messages, (msg: ChatMessage) => {
this.MessageView(msg)
}, (msg: ChatMessage) => String(msg.id))
}
.width('100%')
.padding({ top: 92, bottom: 20 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.align(Alignment.Top)
Row({ space: 10 }) {
TextInput({ placeholder: '输入您的问题...', text: this.inputText })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.fontSize(Math.round(17 * this.scaleFor()))
.height(52)
.layoutWeight(1)
.padding({ left: 16, right: 16 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.onChange((value: string) => {
this.inputText = value;
})
.onSubmit((enterKey: EnterKeyType) => {
this.send();
})
Button(this.sending ? '…' : '发送')
.height(52)
.padding({ left: 18, right: 18 })
.constraintSize({ minWidth: 80 })
.fontSize(Math.round(17 * this.scaleFor()))
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
.enabled(!this.sending)
.onClick(() => {
this.send();
})
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 20 })
}
.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'))
}
}
@@ -0,0 +1,174 @@
// 家属绑定验证(ArkTS 严格类型,无 any)
// 接口:POST /elderly/add/ {elderly_name, elderly_id_card, relation}
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
// 家属绑定请求体
interface FamilyBindPayload {
elderly_name: string;
elderly_id_card: string;
relation?: string;
}
@Entry
@Component
struct FamilyBind {
@State elderlyName: string = '';
@State idCard: string = '';
@State relation: string = '';
@State submitting: boolean = false;
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
private scroller: Scroller = new Scroller();
private async submit(): Promise<void> {
if (this.elderlyName.trim().length === 0) {
promptAction.showToast({ message: '请输入长者姓名' });
return;
}
if (this.idCard.trim().length === 0) {
promptAction.showToast({ message: '请输入长者身份证号' });
return;
}
if (this.submitting) {
return;
}
this.submitting = true;
try {
const body: FamilyBindPayload = {
elderly_name: this.elderlyName.trim(),
elderly_id_card: this.idCard.trim().toUpperCase(),
};
if (this.relation.trim().length > 0) {
body.relation = this.relation.trim();
}
await api.post('/elderly/add/', body);
promptAction.showToast({ message: '绑定成功' });
router.back();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.submitting = false;
}
}
@Builder
Field(label: string, placeholder: string, value: string, onChange: (v: string) => void, type?: number) {
Column({ space: 6 }) {
Text(label)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.width('100%')
TextInput({ placeholder: placeholder, text: value })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.type(type !== undefined ? type : InputType.Normal)
.height(44)
.padding({ left: 12, right: 12 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(10)
.onChange((v: string) => {
onChange(v);
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 14 }) {
Column({ space: 8 }) {
Text('👨‍👩‍👧 添加我的长者')
.fontSize(Math.round(20 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text('输入长者的姓名与身份证号,核验后即可查看长者的护理记录与健康信息')
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.alignItems(HorizontalAlign.Start)
Column({ space: 12 }) {
this.Field('长者姓名 *', '请输入长者姓名', this.elderlyName, (v: string) => {
this.elderlyName = v;
})
this.Field('身份证号 *', '请输入 18 位身份证号', this.idCard, (v: string) => {
this.idCard = v;
})
this.Field('与长者关系', '如:家属 / 子女', this.relation, (v: string) => {
this.relation = v;
})
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
Button(this.submitting ? '提交中...' : '确认绑定')
.width('100%')
.height(50)
.fontSize(Math.round(16 * this.scaleFor()))
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
.enabled(!this.submitting)
.onClick(() => {
this.submit();
})
}
.width('92%')
.padding({ top: 60, bottom: 30 })
.alignItems(HorizontalAlign.Center)
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.width('100%')
}
.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'))
}
}
@@ -0,0 +1,205 @@
// 家属 · 长者档案列表(ArkTS 严格类型,无 any)
// 接口:GET /elderly/
import { router, 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, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface FamilyElderlyItem {
id: number;
name: string;
gender: string;
age: number;
room_bed: string;
care_level_label: string;
overview_status: string;
risks: Array<string>;
}
function genderLabel(gender: string): string {
if (gender === 'male') {
return '男';
}
if (gender === 'female') {
return '女';
}
return '';
}
@Entry
@Component
struct FamilyElderly {
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
@State elderly: Array<FamilyElderlyItem> = [];
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const res = await api.get<Page<FamilyElderlyItem>>('/elderly/', { page_size: 50 });
this.elderly = res.results;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
private openDetail(item: FamilyElderlyItem): void {
router.pushUrl({ url: 'pages/family/FamilyElderlyDetail', params: { id: item.id } })
.catch((err: BusinessError) => {
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
});
}
@Builder
ElderlyCard(item: FamilyElderlyItem) {
Row({ space: 12 }) {
Column() {
Text(item.name.substring(0, 1))
.fontSize(Math.round(20 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(52)
.height(52)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(16)
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(item.name)
.fontSize(Math.round(17 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(`${item.age}岁 · ${genderLabel(item.gender)}`)
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
Blank()
Text(item.overview_status)
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.accent_green'))
}
.width('100%')
Text(item.care_level_label)
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.accent_blue'))
.width('100%')
Row({ space: 8 }) {
if (item.room_bed && item.room_bed.length > 0) {
Text(item.room_bed)
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
if (item.risks && item.risks.length > 0) {
Text(item.risks.join('、'))
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor('#FF9500')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('›')
.fontSize(Math.round(20 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.openDetail(item);
})
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.loading) {
Text('加载中...')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else if (this.elderly.length === 0) {
Text('暂无绑定的长者')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(24)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.textAlign(TextAlign.Center)
} else {
ForEach(this.elderly, (item: FamilyElderlyItem) => {
Column() {
this.ElderlyCard(item)
}
.width('100%')
}, (item: FamilyElderlyItem) => String(item.id))
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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'))
}
}
@@ -0,0 +1,340 @@
// 家属 · 长者档案详情(ArkTS 严格类型,无 any)
// 接口:GET /elderly/{id}/
import { router, promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { KnockShareSession, KnockSharePayload, HealthReportData } from '../../utils/KnockShare';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface ElderlyDetail {
id: number;
name: string;
gender: string;
age: number;
floor_room_bed: string;
room_bed: string;
care_level_label: string;
overview_status: string;
medical_history: string;
allergies: string;
medications: Array<string>;
emergency_contact: string;
emergency_phone: string;
health_score: number | null;
risks: Array<string>;
current_caregiver_name: string;
primary_family_name: string;
primary_family_relation: string;
is_active: boolean;
}
interface HealthReportRow {
id: number;
title: string;
period_start: string;
period_end: string;
summary: string;
llm_analysis: string;
is_generated: boolean;
}
interface HealthReportListResp {
results?: Array<HealthReportRow>;
}
function genderLabel(gender: string): string {
if (gender === 'male') {
return '男';
}
if (gender === 'female') {
return '女';
}
return '';
}
@Entry
@Component
struct FamilyElderlyDetail {
@State detail: ElderlyDetail | null = null;
@State loading: boolean = true;
@State latestReport: HealthReportRow | null = null;
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
private scroller: Scroller = new Scroller();
private knockSession: KnockShareSession = new KnockShareSession();
aboutToAppear(): void {
const params = router.getParams() as Record<string, string | number>;
const id = params ? params['id'] : null;
if (id === null || id === undefined) {
router.back();
return;
}
this.load(Number(id));
this.registerKnockShare();
}
aboutToDisappear(): void {
this.knockSession.unregister();
}
// 碰一碰分享:两台设备轻触即把长者健康报告传给对方
private registerKnockShare(): void {
this.knockSession.register((): KnockSharePayload | null => {
const d = this.detail;
if (d === null) {
return null;
}
const report: HealthReportData = {
name: d.name,
gender: genderLabel(d.gender),
age: d.age,
room: d.floor_room_bed || d.room_bed || '',
care_level: d.care_level_label,
health_score: d.health_score,
status: d.overview_status,
risks: d.risks ?? [],
medical_history: d.medical_history,
allergies: d.allergies,
medications: d.medications ?? [],
caregiver: d.current_caregiver_name,
emergency_contact: (d.emergency_contact || '') + (d.emergency_phone ? ` ${d.emergency_phone}` : ''),
shared_at: new Date().toLocaleString('zh-CN', { hour12: false }),
report_title: this.latestReport !== null ? this.latestReport.title : '',
report_period: this.latestReport !== null ? `${this.latestReport.period_start} ~ ${this.latestReport.period_end}` : '',
report_summary: this.latestReport !== null ? this.latestReport.summary : '',
report_ai: this.latestReport !== null ? this.latestReport.llm_analysis : ''
};
const desc = this.latestReport !== null
? `${this.latestReport.title} · 含 AI 分析`
: `${d.age}岁 · ${d.care_level_label} · 健康评分 ${d.health_score !== null ? d.health_score : '--'}`;
return {
title: `数智护老健康报告 · ${d.name}`,
description: desc,
url: `smartsenior://report?data=${encodeURIComponent(JSON.stringify(report))}`
};
});
}
private knockShareHint(): void {
promptAction.showToast({ message: '请与对方设备轻触,即可分享健康报告', duration: 2500 });
}
private async load(id: number): Promise<void> {
this.loading = true;
try {
const res = await api.get<ElderlyDetail>(`/elderly/${id}/`);
this.detail = res;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
// 最新一期 AI 健康报告:碰一碰分享内容带上 AI 分析
try {
const rep = await api.get<HealthReportListResp | Array<HealthReportRow>>(`/reports/?elderly=${id}`);
const rows: Array<HealthReportRow> = Array.isArray(rep) ? rep : (rep.results ?? []);
this.latestReport = rows.length > 0 ? rows[0] : null;
} catch (err) {
console.warn('[FamilyElderlyDetail] reports load failed:', (err as Error).message);
}
}
private openHistory(id: number): void {
router.pushUrl({ url: 'pages/family/FamilyElderlyHistory', params: { id: id } })
.catch((err: BusinessError) => {
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
});
}
private openHealthReport(id: number): void {
router.pushUrl({ url: 'pages/family/FamilyHealthReport', params: { id: id } })
.catch((err: BusinessError) => {
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
});
}
@Builder
InfoRow(label: string, value: string) {
Row({ space: 12 }) {
Text(label)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width(72)
Text(value)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
}
.width('100%')
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.loading) {
Text('加载中...')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else if (this.detail) {
// 头部
Row({ space: 12 }) {
Column() {
Text(this.detail.name.substring(0, 1))
.fontSize(Math.round(24 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(64)
.height(64)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(20)
Column({ space: 4 }) {
Row({ space: 8 }) {
Text(this.detail.name)
.fontSize(Math.round(22 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(`${this.detail.age}岁 · ${genderLabel(this.detail.gender)}`)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
Text(this.detail.care_level_label)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.accent_blue'))
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
// 档案信息
Column({ space: 12 }) {
Text('基本信息')
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
this.InfoRow('床位', this.detail.floor_room_bed || this.detail.room_bed || '--')
this.InfoRow('健康评分', this.detail.health_score !== null ? String(this.detail.health_score) : '--')
this.InfoRow('主责护理员', this.detail.current_caregiver_name || '--')
this.InfoRow('紧急联系人', (this.detail.emergency_contact || '') + (this.detail.emergency_phone ? ` ${this.detail.emergency_phone}` : '') || '--')
if (this.detail.primary_family_name && this.detail.primary_family_name.length > 0) {
this.InfoRow('绑定家属', `${this.detail.primary_family_name}${this.detail.primary_family_relation ? '(' + this.detail.primary_family_relation + ')' : ''}`)
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
// 健康信息
Column({ space: 12 }) {
Text('健康信息')
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
this.InfoRow('风险标签', this.detail.risks && this.detail.risks.length > 0 ? this.detail.risks.join('、') : '--')
this.InfoRow('病史', this.detail.medical_history || '--')
this.InfoRow('过敏', this.detail.allergies || '--')
this.InfoRow('常用药物', this.detail.medications && this.detail.medications.length > 0 ? this.detail.medications.join('、') : '--')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
Button('AI 健康报告')
.width('100%')
.height(48)
.fontColor($r('app.color.accent_blue'))
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.border({ width: 1, color: $r('app.color.accent_blue') })
.onClick(() => {
this.openHealthReport(this.detail!.id);
})
Button('碰一碰分享健康报告')
.width('100%')
.height(48)
.fontColor($r('app.color.accent_blue'))
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.border({ width: 1, color: $r('app.color.accent_blue') })
.onClick(() => {
this.knockShareHint();
})
Button('查看护理记录')
.width('100%')
.height(48)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
.onClick(() => {
this.openHistory(this.detail!.id);
})
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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'))
}
}
@@ -0,0 +1,198 @@
// 家属 · 长者护理记录历史(ArkTS 严格类型,无 any)
// 接口:GET /tasks/nursing-records/?elderly={id}
import { router, 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, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface HistoryRecord {
id: number;
raw_text: string;
nurse_name: string;
elderly_name: string;
created_at: string;
}
interface ElderlyLite {
id: number;
name: string;
}
function formatDateTime(iso: string): string {
if (!iso || iso.length === 0) {
return '--';
}
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 FamilyElderlyHistory {
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
@State elderlyId: number = 0;
@State elderlyName: string = '';
@State records: Array<HistoryRecord> = [];
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
const params = router.getParams() as Record<string, string | number>;
if (params) {
const id = params['id'];
const name = params['name'];
if (id !== undefined) {
this.elderlyId = Number(id);
}
if (typeof name === 'string') {
this.elderlyName = name;
}
}
if (this.elderlyId === 0) {
this.resolveFirstElderly();
} else {
this.load();
}
}
// 未指定长者(如从"长者历史"入口进入)时自动取绑定长者的第一条
private async resolveFirstElderly(): Promise<void> {
try {
const res = await api.get<Page<ElderlyLite>>('/elderly/', { page_size: 1 });
if (res.results.length > 0) {
const first: ElderlyLite = res.results[0];
this.elderlyId = first.id;
this.elderlyName = first.name;
}
} catch (err) {
console.warn('[FamilyElderlyHistory] resolve first elderly failed:', normalizeError(err as BusinessError | Error));
}
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const res = await api.get<Page<HistoryRecord>>('/tasks/nursing-records/', {
elderly: this.elderlyId,
page_size: 50,
ordering: '-created_at',
});
this.records = res.results;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
RecordCard(record: HistoryRecord) {
Column({ space: 6 }) {
Row() {
Text(record.nurse_name || '护理员')
.fontSize(Math.round(14 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.accent_blue'))
Blank()
Text(formatDateTime(record.created_at))
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
Text(record.raw_text)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.lineHeight(Math.round(21 * this.scaleFor()))
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.loading) {
Text('加载中...')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else if (this.records.length === 0) {
Text('暂无护理记录')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(24)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.textAlign(TextAlign.Center)
} else {
ForEach(this.records, (record: HistoryRecord) => {
Column() {
this.RecordCard(record)
}
.width('100%')
}, (record: HistoryRecord) => String(record.id))
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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'))
}
}
@@ -0,0 +1,422 @@
// 家属 · AI 健康报告:页面直接预览健康风险报告 PDF,并交给端侧智能体解读
// 链路:GET /reports/health-risk/pdf/ → 沙箱 → PdfView 预览 → 抽文本(文字层/端侧 OCR)→ 智能体同沙箱读取
import { router, promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError, deviceInfo } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';
import { PdfView, pdfService, pdfViewManager } from '@kit.PDFKit';
import { AgentController, FunctionComponent, FunctionOptions, ButtonType } from '@kit.AgentFrameworkKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
import {
downloadHealthRiskPdf,
extractPdfText,
fetchReportTextFromServer,
cacheReportContextForAgent,
clearReportContext,
readReportContext,
hasLocalReportPdf,
reportPdfPath,
loadPdfForPreview,
releasePdfPreview,
} from '../../utils/HealthReportPdf';
// 传给智能体的报告正文上限(queryText 过长会被截断或拒绝)
const AGENT_INLINE_MAX_CHARS = 1200;
// 报告总结专用:小艺开放平台的"单Agent"(云侧 DeepSeek-V3),不关联端侧智能体扩展
// 这样点按钮后由云侧模型直接依据 queryText 里的报告正文总结,不会被 A2AServer 抢答、也不依赖我们后端
// (老人端首页仍用端 A2A 智能体 agent8ca28632414640028b76ee6bf69efa80,两者互不影响)
const REPORT_SUMMARY_AGENT_ID = 'agent3f66fdeb52504b99a12288b691df2eab';
interface HealthReportRow {
id: number;
title: string;
period_start: string;
period_end: string;
summary: string;
llm_analysis: string;
is_generated: boolean;
elderly_name?: string;
}
interface HealthReportListResp {
results?: Array<HealthReportRow>;
}
@Entry
@Component
struct FamilyHealthReport {
// PDF 顶部占位高度:避免报告内容被 HDS 标题栏遮挡
// 内容区从屏幕顶端算起,所以取 状态栏(~38) + HDS MINI 标题栏(~56) ≈ 94,留一点余量取 96
private readonly PDF_TOP_BLANK: number = 96;
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
// 浮在 PDF 上的控件单独收窄字体缩放:PDF 是固定尺寸的,
// 大字档(xlarge=1.9)会把状态文案和按钮撑到互相重叠、盖住报告
private overlayScaleFor(): number {
const scale = this.scaleFor();
return scale > 1.2 ? 1.2 : scale;
}
// 智能体按钮的提问:把报告正文直接带给小艺,让云侧模型(DeepSeek-V3)总结
// FunctionOptions 只有 queryText 一个能带内容进去的字段,正文太长会被截断,这里限长
private agentQueryText(): string {
const cleaned = this.cleanReportForAgent(this.reportText);
const body = cleaned.length > AGENT_INLINE_MAX_CHARS
? cleaned.substring(0, AGENT_INLINE_MAX_CHARS)
: cleaned;
if (body.length === 0) {
return '帮我总结这份报告';
}
return `请直接总结下面这份健康风险报告,面向家属用中文给出 3-5 条要点,只使用报告里出现的数据,不要编造:\n${body}`;
}
// 去掉 OCR 带出来的页脚噪声:页码、"本报告由…自动生成"、重复的生成时间、免责声明
// 这些既撑长对话气泡,也会干扰模型抓重点
private cleanReportForAgent(text: string): string {
const kept: string[] = [];
for (const raw of text.split('\n')) {
const line = raw.trim();
if (line.length === 0) {
continue;
}
if (line.indexOf('页/共') >= 0 || line.indexOf('页/ 共') >= 0) {
continue;
}
if (line.startsWith('本报告') || line.startsWith('生成时间') || line.startsWith('内容由')) {
continue;
}
kept.push(line);
}
return kept.join('\n');
}
@State reports: Array<HealthReportRow> = [];
@State selected: HealthReportRow | null = null;
// 端侧智能体总结:报告上下文准备状态
@State agentSupported: boolean = false;
@State preparing: boolean = false;
@State reportReady: boolean = false;
@State prepMessage: string = '';
// 已提取的报告正文:点击智能体按钮时随 queryText 一起带给小艺
@State reportText: string = '';
// 报告 PDF 预览状态
@State pdfReady: boolean = false;
@State pdfMessage: string = '正在加载报告...';
// 标题栏(含返回按钮)沉浸光感材质等级:按设备支持能力选择
@State customMaterialLevel: hdsMaterial.MaterialLevel = hdsMaterial.MaterialLevel.EXQUISITE;
private elderlyId: number = 0;
private pdfController: pdfViewManager.PdfController = new pdfViewManager.PdfController();
aboutToAppear(): void {
const params = router.getParams() as Record<string, string | number>;
this.elderlyId = params ? Number(params['id']) : 0;
if (!(this.elderlyId > 0)) {
router.back();
return;
}
// 设备不支持 IMMERSIVE 材质时降级为 SMOOTH,避免卡顿/发热
const materialTypes: Array<hdsMaterial.MaterialType> = hdsMaterial.getSystemMaterialTypes();
if (materialTypes.indexOf(hdsMaterial.MaterialType.IMMERSIVE) < 0) {
this.customMaterialLevel = hdsMaterial.MaterialLevel.SMOOTH;
}
this.checkAgentSupport();
this.load();
this.prepareReport();
}
aboutToDisappear(): void {
releasePdfPreview(this.pdfController);
}
// 检测智能体可用性(设备/华为账号),不可用则只显示普通按钮
private checkAgentSupport(): void {
const model = deviceInfo.productModel.toLowerCase();
if (model.indexOf('emulator') >= 0 || model.indexOf('x86') >= 0) {
this.agentSupported = false;
return;
}
this.tryCheckAgentSupport(1);
}
// isAgentSupport 偶发直接返回 false(智能体服务刚起来/未联网时),重试 3 次,任一次通过即可用
private tryCheckAgentSupport(attempt: number): void {
try {
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
new AgentController().isAgentSupport(context, REPORT_SUMMARY_AGENT_ID).then((supported: boolean) => {
console.info(`[FamilyHealthReport] agent supported: ${supported} (attempt ${attempt})`);
if (supported) {
this.agentSupported = true;
return;
}
this.scheduleAgentRetry(attempt);
}).catch((err: BusinessError) => {
console.error('[FamilyHealthReport] isAgentSupport failed:', normalizeError(err));
this.scheduleAgentRetry(attempt);
});
} catch (err) {
console.error('[FamilyHealthReport] isAgentSupport threw:', normalizeError(err as BusinessError | Error));
this.scheduleAgentRetry(attempt);
}
}
private scheduleAgentRetry(attempt: number): void {
if (attempt >= 3) {
this.agentSupported = false;
return;
}
setTimeout(() => {
this.tryCheckAgentSupport(attempt + 1);
}, 1500);
}
// 准备报告:下载 PDF → 页面预览;正文三级来源(后端正文 → PDF 文字层/端侧 OCR → 报告概要)
// 不依赖报告列表接口:健康风险报告 PDF 是无参接口,列表为空也能准备
private async prepareReport(): Promise<void> {
if (this.preparing) {
return;
}
this.preparing = true;
this.reportReady = false;
this.pdfReady = false;
this.prepMessage = '正在准备报告...';
this.pdfMessage = '正在加载报告...';
try {
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
// 1. 页面直接预览 PDF(PDF Kit 重复 loadDocument 前必须先 release,见 loadPdfForPreview)
// 沙箱已缓存过报告时不再阻塞在下载上:断网/后端不可用也能立刻预览并进入正文提取(OCR)
let pdfPath = reportPdfPath(context);
if (hasLocalReportPdf(context)) {
console.info('[FamilyHealthReport] local pdf found, skip download');
} else {
try {
pdfPath = await downloadHealthRiskPdf(context);
} catch (err) {
// ArkTS 不允许直接 throw unknown,包成 Error 再抛
throw new Error(normalizeError(err as BusinessError | Error));
}
}
this.pdfReady = await loadPdfForPreview(this.pdfController, pdfPath);
this.pdfMessage = this.pdfReady ? '' : '报告预览加载失败';
// 2. 正文:优先后端正文接口,同时并行跑端侧提取
// 端侧 OCR 实测两页只要 ~0.4s,并行后总耗时取决于较慢的一方;断网时只等正文接口超时,不再叠加
const localTextPromise: Promise<string> = extractPdfText(pdfPath);
let text = await fetchReportTextFromServer();
let source = '后端正文';
if (text.length === 0) {
// 3. 退回端侧解析:文字层 / 图片型 PDF 走端侧 OCR(部分机型 OCR 服务不可用)
text = await localTextPromise;
source = '端侧识别';
}
if (text.length === 0) {
// 4. 最后退回报告列表已有的周期总结 / AI 分析
const current = this.selected;
const parts: string[] = [];
if (current !== null && current.summary.length > 0) {
parts.push(`周期总结:${current.summary}`);
}
if (current !== null && current.llm_analysis.length > 0) {
parts.push(`AI 分析:${current.llm_analysis}`);
}
text = parts.join('\n');
source = '报告概要';
}
if (text.length > 0) {
const chars = cacheReportContextForAgent(text, '健康风险报告');
this.reportText = text;
this.reportReady = true;
this.prepMessage = `报告已就绪(${source} ${chars} 字)`;
} else if (readReportContext().length > 0) {
// 取不到新正文时沿用上次内容(沙箱里预置的正文也能保住),避免智能体无数据可总结
this.reportReady = true;
this.prepMessage = '未取到新正文,沿用上次内容';
} else {
this.reportReady = false;
this.prepMessage = '暂未取到报告正文,可稍后重试';
clearReportContext();
}
} catch (err) {
// 异常时同样不删已有正文,只有本来就空才清理
if (readReportContext().length === 0) {
clearReportContext();
}
this.reportReady = readReportContext().length > 0;
this.prepMessage = this.reportReady ? '报告加载失败,沿用上次内容' : '报告加载失败,请稍后重试';
this.pdfMessage = '报告加载失败';
console.error('[FamilyHealthReport] prepare failed:', normalizeError(err as BusinessError | Error));
} finally {
this.preparing = false;
}
}
private async load(): Promise<void> {
try {
const res = await api.get<HealthReportListResp | Array<HealthReportRow>>(`/reports/?elderly=${this.elderlyId}`);
const rows: Array<HealthReportRow> = Array.isArray(res) ? res : (res.results ?? []);
this.reports = rows;
this.selected = rows.length > 0 ? rows[0] : null;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
}
}
// 报告 PDF 全屏区域:铺满整屏(含状态栏下方),不需要预留标题栏/底部条的高度
@Builder
PdfPreviewCard() {
if (this.pdfReady) {
PdfView({
controller: this.pdfController,
pageLayout: pdfService.PageLayout.LAYOUT_SINGLE,
isContinuous: true,
showScroll: true,
pageFit: pdfService.PageFit.FIT_WIDTH,
})
.width('100%')
.layoutWeight(1)
.backgroundColor($r('app.color.background_card'))
} else {
Column({ space: 12 }) {
Text(this.preparing ? '报告加载中...' : (this.pdfMessage.length > 0 ? this.pdfMessage : '报告暂不可用'))
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
Button(this.preparing ? '加载中...' : '重新加载报告')
.fontSize(Math.round(14 * this.scaleFor()))
.height(40)
.backgroundColor($r('app.color.background_card'))
.fontColor($r('app.color.accent_blue'))
.enabled(!this.preparing)
.onClick(() => {
this.prepareReport();
})
}
.width('100%')
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
}
// 底部操作条:状态 + 智能体按钮(底部居中)——正文缓存到沙箱后,点击即由小艺带着"帮我总结这份报告"拉起智能体
@Builder
AgentSummaryCard() {
Column({ space: 8 }) {
Text(this.prepMessage.length > 0 ? this.prepMessage : '等待报告')
.fontSize(Math.round(12 * this.overlayScaleFor()))
.fontColor($r('app.color.text_secondary'))
.textAlign(TextAlign.Center)
.width('100%')
// 不加底色容器:状态文案直接浮在 PDF 上
.visibility(this.prepMessage.length > 0 ? Visibility.Visible : Visibility.None)
Row({ space: 8 }) {
// 只要正文就绪就给出智能体入口:isAgentSupport 偶发误判,不能让探测结果把演示按钮藏掉
// (真不支持时点按钮由 FunctionComponent 的 onError 提示)
if (this.reportReady) {
FunctionComponent({
agentId: REPORT_SUMMARY_AGENT_ID,
options: {
title: 'AI 总结报告',
queryText: this.agentQueryText(),
buttonType: ButtonType.CAPSULE,
controlSize: ControlSize.NORMAL,
titleFontSize: 14,
iconSize: 20,
isShowShadow: true,
} as FunctionOptions,
onError: (err: BusinessError) => {
promptAction.showToast({ message: `智能体启动失败:${err.message}` });
},
})
}
// 「重新准备报告」只在正文没就绪时出现,避免和智能体按钮在同一行挤在一起
if (!this.reportReady) {
Button(this.preparing ? '报告准备中...' : '重新准备报告')
.fontSize(Math.round(14 * this.overlayScaleFor()))
.height(40)
.backgroundColor($r('app.color.background'))
.fontColor($r('app.color.accent_blue'))
.enabled(!this.preparing)
.onClick(() => {
this.prepareReport();
})
}
}
.width('100%')
.justifyContent(FlexAlign.Center)
// 注意:不要再根据 agentSupported 提示"设备不支持"——isAgentSupport 会去云端校验,
// 断网/账号校验失败时会误报 false,但实测点按钮照样能唤起智能体(小艺会连上本应用的端侧智能体)
if (!this.reportReady) {
// 智能体本身可用,只是报告正文还没取到——别让文案误导成"设备不支持"
Text('报告正文未取到,点「重新准备报告」重试')
.fontSize(Math.round(12 * this.overlayScaleFor()))
.fontColor($r('app.color.inactive_tab'))
.textAlign(TextAlign.Center)
.width('100%')
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 18 })
.alignItems(HorizontalAlign.Center)
}
// 悬浮返回按钮已移除:HDS 顶栏保留,返回按钮由标题栏提供
build() {
HdsNavigation() {
// Stack(alignContent 底部):PDF 铺满,智能体按钮覆盖在 PDF 之上
Stack({ alignContent: Alignment.Bottom }) {
Column() {
// 顶部占位:避免报告内容被 HDS 标题栏遮挡
Blank()
.height(this.PDF_TOP_BLANK)
this.PdfPreviewCard()
}
.width('100%')
.height('100%')
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
// 底部悬浮:状态文案 + 智能体按钮(居中),覆盖在 PDF 上
this.AgentSummaryCard()
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '健康报告',
},
},
style: {
// 常驻模糊:不跟随滚动变化,静止时顶栏就是毛玻璃
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
// 官方沉浸光感写法:materialType 用 ADAPTIVE,等级由设备支持的材质能力决定
// (IMMERSIVE 是"能力枚举",不是 titleBar 的 materialType;写成 IMMERSIVE 会导致不生效、整条透明)
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: this.customMaterialLevel,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.titleMode(HdsNavigationTitleMode.MINI)
// 顶部越界:状态栏与 HDS 顶栏覆盖在报告之上,靠沉浸光感材质做模糊(不是实心挡板)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,235 @@
// 家属看护首页(ArkTS 严格类型,无 any)
// 数据来源:/elderly/、/alerts/、/tasks/nursing-records/
import { router, promptAction, uiMaterial } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { Page, normalizeError } from '../../common/ApiConstants';
import { api } from '../../utils/Request';
import { AuthStore } from '../../utils/AuthStore';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface ApiRecord {
id: number;
elderly_name: string;
raw_text: string;
created_at: string;
}
interface ElderlyRecord {
id: number;
name: string;
}
interface AlertRecord {
id: number;
title: string;
created_at: 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}`;
}
interface QuickAction {
key: string;
title: string;
desc: string;
route: string;
}
@Entry
@Component
struct FamilyHome {
@State userName: string = '';
@State boundCount: number = 0;
@State recordCount: number = 0;
@State alertCount: number = 0;
@State latestRecordText: string = '';
@State latestRecordTime: string = '';
@State hasRecord: boolean = false;
@State loading: boolean = true;
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
private actions: QuickAction[] = [
{ key: 'records', title: '护理记录', desc: '查看护理记录', route: 'pages/family/Records' },
{ key: 'alerts', title: '预警', desc: '查看预警信息', route: 'pages/family/FamilyAlerts' },
{ key: 'elderly', title: '长者档案', desc: '查看长者信息', route: 'pages/family/FamilyElderly' },
{ key: 'assistant', title: '智能助手', desc: '随时咨询家人状况', route: 'pages/family/FamilyAssistant' },
];
async aboutToAppear(): Promise<void> {
const state = await AuthStore.getState();
this.userName = state.userName;
await this.loadData();
}
private async loadData(): Promise<void> {
this.loading = true;
try {
const results = await Promise.all([
api.get<Page<ElderlyRecord>>('/elderly/', { page_size: 1 }).catch(() => null),
api.get<Page<AlertRecord>>('/alerts/', { page_size: 1, ordering: '-created_at' }).catch(() => null),
api.get<Page<ApiRecord>>('/tasks/nursing-records/', { page_size: 1, ordering: '-created_at' }).catch(() => null),
]);
const elderlyRes = results[0];
const alertRes = results[1];
const recordRes = results[2];
if (elderlyRes) {
this.boundCount = elderlyRes.count;
}
if (alertRes) {
this.alertCount = alertRes.count;
}
if (recordRes) {
this.recordCount = recordRes.count;
if (recordRes.results.length > 0) {
const r: ApiRecord = recordRes.results[0];
this.latestRecordText = r.raw_text;
this.latestRecordTime = formatDateTime(r.created_at);
this.hasRecord = true;
}
}
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
private go(route: string): void {
router.pushUrl({ url: route }).catch((err: BusinessError) => {
promptAction.showToast({ message: `页面开发中:${normalizeError(err)}` });
});
}
@Builder
StatCard(value: number, label: string, color: string) {
Column({ space: 4 }) {
Text(String(value))
.fontSize(Math.round(26 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor(color)
Text(label)
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
@Builder
QuickActionCard(action: QuickAction) {
Column({ space: 6 }) {
Text(action.title)
.fontSize(Math.round(15 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text(action.desc)
.fontSize(Math.round(11 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.width('30%')
.height(60)
.margin({ bottom: 10 })
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor(Color.Transparent)
.backgroundBlurStyle(BlurStyle.Thin)
.borderRadius(12)
.onClick(() => {
this.go(action.route);
})
}
build() {
Column() {
Scroll() {
Column({ space: 16 }) {
Column({ space: 6 }) {
Text(`${this.userName || '家人'},欢迎回来`)
.fontSize(Math.round(24 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text('随时关注长者健康状态')
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding({ left: 20, right: 20, top: 40, bottom: 8 })
.alignItems(HorizontalAlign.Start)
Row() {
this.StatCard(this.boundCount, '位长者', ('app.color.accent_blue'))
this.StatCard(this.recordCount, '条记录', ('app.color.accent_green'))
this.StatCard(this.alertCount, '条预警', ('app.color.accent_red'))
}
.width('92%')
.height(96)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.padding(8)
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(this.actions, (action: QuickAction) => {
this.QuickActionCard(action)
}, (action: QuickAction) => action.key)
}
.width('92%')
Column({ space: 8 }) {
Text('最新护理记录')
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.width('100%')
Column({ space: 6 }) {
if (this.hasRecord) {
Text(this.latestRecordText)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text(this.latestRecordTime)
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
} else {
Text('暂无护理记录')
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
.width('92%')
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 30 })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,203 @@
// 家属 · 碰一碰分享接收页:渲染对方设备碰一碰传来的长者健康报告卡
// 数据来源:EntryAbility.reportData 解析 smartsenior://report?data=<JSON> 写入 AppStorage.pendingReportData
import { HealthReportData } from '../../utils/KnockShare';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
@Entry
@Component
struct FamilyReportCard {
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
@StorageProp('pendingReportData') reportStr: string = '';
@State report: HealthReportData | null = null;
@State parseFailed: boolean = false;
aboutToAppear(): void {
if (this.reportStr.length === 0) {
this.parseFailed = true;
return;
}
try {
const raw = JSON.parse(this.reportStr) as HealthReportData;
// 防御旧版发送端:报告字段可能整体缺失,统一补默认值避免 undefined 崩溃
this.report = {
name: raw.name ?? '',
gender: raw.gender ?? '',
age: raw.age ?? 0,
room: raw.room ?? '',
care_level: raw.care_level ?? '',
health_score: raw.health_score ?? null,
status: raw.status ?? '',
risks: raw.risks ?? [],
medical_history: raw.medical_history ?? '',
allergies: raw.allergies ?? '',
medications: raw.medications ?? [],
caregiver: raw.caregiver ?? '',
emergency_contact: raw.emergency_contact ?? '',
report_title: raw.report_title ?? '',
report_period: raw.report_period ?? '',
report_summary: raw.report_summary ?? '',
report_ai: raw.report_ai ?? '',
shared_at: raw.shared_at ?? '',
};
} catch (err) {
this.parseFailed = true;
}
}
@Builder
InfoRow(label: string, value: string) {
Row({ space: 12 }) {
Text(label)
.fontSize(Math.round(16 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width(88)
Text(value.length > 0 ? value : '--')
.fontSize(Math.round(16 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
}
.width('100%')
}
build() {
Scroll() {
Column({ space: 16 }) {
if (this.parseFailed) {
Text('未收到健康报告数据')
.fontSize(Math.round(18 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.margin({ top: 120 })
Text('请与对方设备碰一碰后重新接收')
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
} else if (this.report !== null) {
// 报告头
Column({ space: 8 }) {
Text('长者健康报告')
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor('#FFFFFF')
.opacity(0.9)
Text(this.report.name)
.fontSize(Math.round(32 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text(`${this.report.age}岁 · ${this.report.gender}${this.report.room.length > 0 ? ' · ' + this.report.room : ''}`)
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor('#FFFFFF')
.opacity(0.9)
if (this.report.health_score !== null) {
Text(`健康评分 ${this.report.health_score}`)
.fontSize(Math.round(15 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor('#33FFFFFF')
.borderRadius(16)
}
}
.width('100%')
.padding(24)
.borderRadius(18)
.linearGradient({
angle: 135,
colors: [[$r('app.color.accent_blue'), 0], ['#5B8DEF', 1]]
})
.alignItems(HorizontalAlign.Center)
Text(this.report.care_level)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.accent_blue'))
.width('100%')
// 报告内容
Column({ space: 14 }) {
Text('健康信息')
.fontSize(Math.round(17 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
this.InfoRow('健康状态', this.report.status)
this.InfoRow('风险标签', this.report.risks.length > 0 ? this.report.risks.join('、') : '')
this.InfoRow('病史', this.report.medical_history)
this.InfoRow('过敏', this.report.allergies)
this.InfoRow('常用药物', this.report.medications.length > 0 ? this.report.medications.join('、') : '')
this.InfoRow('主责护理员', this.report.caregiver)
this.InfoRow('紧急联系人', this.report.emergency_contact)
}
.width('100%')
.padding(18)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
if (this.report.report_title.length > 0) {
// AI 健康报告(周期总结 + AI 分析)
Column({ space: 10 }) {
Row({ space: 6 }) {
Text('AI 健康报告')
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.accent_blue'))
Text(this.report.report_title)
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
if (this.report.report_period.length > 0) {
Text(this.report.report_period)
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
.width('100%')
}
if (this.report.report_summary.length > 0) {
Text(this.report.report_summary)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.width('100%')
.lineHeight(Math.round(22 * this.scaleFor()))
}
if (this.report.report_ai.length > 0) {
Divider().color($r('app.color.background'))
Text('AI 分析')
.fontSize(Math.round(13 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.accent_blue'))
.width('100%')
Text(this.report.report_ai)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.width('100%')
.lineHeight(Math.round(22 * this.scaleFor()))
}
}
.width('100%')
.padding(18)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
Text(`分享时间:${this.report.shared_at}`)
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
.width('100%')
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 72, bottom: 30 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.align(Alignment.Top)
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,182 @@
// 家属 · 安全验证(ArkTS 严格类型,无 any)
// 接口:POST /elderly/bindings/verify/
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, EmptyBody, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface VerifyResult {
verified: boolean;
elderly_name: string;
error?: string;
}
@Entry
@Component
struct FamilyVerify {
@State result: VerifyResult | null = null;
@State loading: boolean = false;
@State verified: boolean = false;
@State elderlyName: string = '';
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.verify();
}
private async verify(): Promise<void> {
if (this.loading) {
return;
}
this.loading = true;
try {
const res = await api.post<VerifyResult>('/elderly/bindings/verify/', new EmptyBody());
this.result = res;
this.verified = res.verified;
this.elderlyName = res.elderly_name;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
this.verified = false;
this.elderlyName = '';
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 16 }) {
// 状态图标
Column({ space: 12 }) {
Column() {
Text(this.loading ? '⏳' : (this.verified ? '✅' : '❓'))
.fontSize(Math.round(48 * this.scaleFor()))
}
.width(96)
.height(96)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor(this.verified ? '#1400C853' : '#14FF9500')
.borderRadius(24)
Text(this.loading ? '验证中...' : (this.verified ? '身份已验证' : '待验证'))
.fontSize(Math.round(20 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
}
.width('100%')
.padding({ top: 12 })
.alignItems(HorizontalAlign.Center)
// 绑定长者信息
Column({ space: 10 }) {
Row({ space: 12 }) {
Text('绑定长者')
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width(72)
Text(this.elderlyName.length > 0 ? this.elderlyName : '--')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
}
.width('100%')
Row({ space: 12 }) {
Text('验证状态')
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width(72)
Text(this.verified ? '已验证' : '未验证')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor(this.verified ? $r('app.color.accent_green') : '#FF9500')
.layoutWeight(1)
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
Text('验证通过后,您可查看绑定长者的健康、护理与预警信息。')
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.lineHeight(Math.round(20 * this.scaleFor()))
.width('100%')
.padding({ left: 4, right: 4 })
if (!this.verified) {
Text('当前账号尚未绑定长者,请先在"绑定长者"中完成绑定后再验证。')
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
.lineHeight(Math.round(20 * this.scaleFor()))
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
}
Button(this.loading ? '验证中...' : '重新验证')
.width('100%')
.height(48)
.fontSize(Math.round(16 * this.scaleFor()))
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
.enabled(!this.loading)
.onClick(() => {
this.verify();
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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'))
}
}
+188
View File
@@ -0,0 +1,188 @@
// 家属端护理记录(ArkTS 严格类型,无 any)
// 数据来源:/tasks/nursing-records/
import { router, 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 } from '../../utils/Request';
import { ProtectedImage } from '../../components/ProtectedImage';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
// 记录详情页路由参数
interface RecordDetailParams {
id: string;
}
interface RecordItem {
id: number;
elderly_name: string;
nurse_name: string | null;
raw_text: string;
images: Array<string> | null;
created_at: string;
}
function formatTime(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 FamilyRecords {
@State records: RecordItem[] = [];
@State loading: boolean = true;
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
private scroller: Scroller = new Scroller();
async aboutToAppear(): Promise<void> {
await this.loadRecords();
}
private async loadRecords(): Promise<void> {
this.loading = true;
try {
const res = await api.get<Page<RecordItem>>('/tasks/nursing-records/', {
page_size: 50,
ordering: '-created_at',
});
this.records = res.results;
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
private openDetail(item: RecordItem): void {
const params: RecordDetailParams = { id: String(item.id) };
router.pushUrl({
url: 'pages/nurse/RecordDetail',
params: params,
}).catch((err: BusinessError) => {
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
});
}
@Builder
RecordItemView(item: RecordItem) {
Column({ space: 8 }) {
Row() {
Text(item.elderly_name)
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Blank()
Text(formatTime(item.created_at))
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
Text(item.raw_text && item.raw_text.length > 0 ? item.raw_text : '(无文字内容)')
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.maxLines(4)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
if (item.images && item.images.length > 0) {
Row({ space: 8 }) {
ForEach(item.images.slice(0, 3), (img: string) => {
Stack() {
ProtectedImage({ mediaPath: img })
}
.width(64)
.height(64)
.borderRadius(8)
.clip(true)
}, (img: string) => img)
if (item.images.length > 3) {
Text(`+${item.images.length - 3}`)
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
}
.width('100%')
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 10 }) {
ForEach(this.records, (item: RecordItem) => {
Column() {
this.RecordItemView(item)
}
.width('100%')
.onClick(() => {
this.openDetail(item);
})
}, (item: RecordItem) => String(item.id))
if (this.loading) {
Text('加载中...')
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
.padding(12)
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 24 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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'))
}
}
@@ -0,0 +1,681 @@
// 消防 · 工作台与巡更(ArkTS 严格类型,无 any)
// 接口:GET /fire-patrol/stats/、/fire-patrol/points/、/fire-patrol/my-records/
// POST /fire-patrol/my-records/
// 跨设备拍照:ServiceCollaboration Kit,平板调用附近同账号手机相机,照片回传后入上传列表
import { promptAction, router } from '@kit.ArkUI';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { CollaborationServiceStateDialog, createCollaborationServiceMenuItems, CollaborationServiceFilter } from '@kit.ServiceCollaborationKit';
import { fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { scanBarcode, scanCore } from '@kit.ScanKit';
import { Page, normalizeError } from '../../common/ApiConstants';
import { api, uploadMultipartFiles, RequestError } from '../../utils/Request';
const MAX_IMAGES = 9;
interface FireStats {
total_records: number;
today_records: number;
photo_records: number;
abnormal_records: number;
point_count: number;
active_point_count: number;
}
interface FirePoint {
id: string;
code: string;
name: string;
location: string;
manager: string;
status: string;
require_photo: boolean;
}
interface FireRecord {
id: string;
patrolman: string;
point: string;
time: string;
status: string;
note: string;
}
interface CheckinPayload {
point: string;
status: string;
note: string;
photos?: Array<CheckinPhoto>;
}
// 扫码识别巡更点响应(POST /fire-patrol/mobile/resolve-point/)
interface ResolvedPoint {
pk: number;
id: string;
name: string;
location: string;
manager: string;
status: string;
requirePhoto: boolean;
}
interface CheckinPhoto {
id: string;
label: string;
url: string;
originalName: string;
}
interface UploadPhotosResponse {
photos: Array<CheckinPhoto>;
}
function todayStr(): string {
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
return `${now.getFullYear()}-${mm}-${dd}`;
}
function formatTime(t: string): string {
if (!t) {
return '--';
}
const d = new Date(t);
if (Number.isNaN(d.getTime())) {
return t;
}
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${hh}:${mi}`;
}
@Entry
@Component
struct FirePatrolHome {
@State stats: FireStats | null = null;
@State points: Array<FirePoint> = [];
@State records: Array<FireRecord> = [];
@State loading: boolean = true;
@State checkingId: string = '';
@State showPointPicker: boolean = false;
@State selectedPoint: string = '';
@State note: string = '';
@State selectedUris: Array<string> = [];
@State uploadedPhotos: Array<CheckinPhoto> = [];
@State uploading: boolean = false;
@State resolving: boolean = false;
@State resolvedInfo: string = '';
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private pickImages(): void {
if (this.uploading) {
return;
}
const remain = MAX_IMAGES - this.selectedUris.length;
if (remain <= 0) {
promptAction.showToast({ message: `最多选择 ${MAX_IMAGES} 张图片` });
return;
}
const options = new photoAccessHelper.PhotoSelectOptions();
options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
options.maxSelectNumber = remain;
const picker = new photoAccessHelper.PhotoViewPicker();
picker.select(options).then((result: photoAccessHelper.PhotoSelectResult) => {
if (result.photoUris && result.photoUris.length > 0) {
this.selectedUris = this.selectedUris.concat(result.photoUris);
}
}).catch((err: BusinessError) => {
console.error('[FirePatrol] pick images failed:', err.message);
});
}
// 跨设备设备列表菜单(配合 bindMenu 使用):拍照 + 调用其他设备图库
@Builder
CollabMenu() {
Menu() {
createCollaborationServiceMenuItems([CollaborationServiceFilter.TAKE_PHOTO, CollaborationServiceFilter.IMAGE_PICKER], MAX_IMAGES);
}
}
// 接收远端设备回传的图片(ArrayBuffer),写入沙箱后并入上传列表
private onCollabState(stateCode: number, bufferType: string, buffer: ArrayBuffer): void {
if (stateCode !== 0 || bufferType !== 'general.image' || buffer.byteLength === 0) {
return;
}
try {
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
const path = `${context.cacheDir}/collab_${Date.now()}.jpg`;
const file = fileIo.openSync(path, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
try {
fileIo.writeSync(file.fd, buffer);
} finally {
fileIo.closeSync(file);
}
this.selectedUris = this.selectedUris.concat([`file://${path}`]);
promptAction.showToast({ message: '已接收远端设备照片' });
} catch (err) {
console.error('[FirePatrol] save collab image failed:', normalizeError(err as BusinessError | Error));
promptAction.showToast({ message: '远端照片保存失败' });
}
}
private async uploadPhotos(): Promise<Array<CheckinPhoto>> {
if (this.selectedUris.length === 0) {
return [];
}
this.uploading = true;
try {
const res = await uploadMultipartFiles<UploadPhotosResponse>(
'/fire-patrol/upload-images/', this.selectedUris, 'images');
this.uploadedPhotos = res.photos || [];
return this.uploadedPhotos;
} finally {
this.uploading = false;
}
}
private async load(): Promise<void> {
this.loading = true;
try {
const results: Array<FireStats | Page<FirePoint> | Page<FireRecord> | null> = await Promise.all([
api.get<FireStats>('/fire-patrol/stats/').catch((): FireStats | null => null),
api.get<Page<FirePoint>>('/fire-patrol/points/', { page_size: 100 }).catch((): Page<FirePoint> | null => null),
api.get<Page<FireRecord>>('/fire-patrol/my-records/', { start: todayStr(), end: todayStr(), page_size: 50 }).catch((): Page<FireRecord> | null => null),
]);
this.stats = results[0] as FireStats | null;
const pRes = results[1] as Page<FirePoint> | null;
this.points = pRes ? pRes.results : [];
const rRes = results[2] as Page<FireRecord> | null;
this.records = rRes ? rRes.results : [];
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
// 扫码识别成功后打开打卡面板(唯一打卡入口:必须到场扫码)
private openScannedCheckin(point: ResolvedPoint): void {
this.selectedPoint = point.name || point.id;
this.resolvedInfo = [
point.location && point.location.length > 0 ? `位置:${point.location}` : '',
point.manager && point.manager.length > 0 ? `负责人:${point.manager}` : '',
].filter((s: string): boolean => s.length > 0).join(' · ');
this.note = '';
this.selectedUris = [];
this.uploadedPhotos = [];
this.showPointPicker = true;
}
// 从扫码原文提取二维码载荷:兼容纯加密串与带 scene/qr_content 参数的 URL(对齐小程序 normalize 逻辑)
private extractQrContent(raw: string): string {
const decoded = raw.trim();
if (decoded.length === 0) {
return '';
}
const qIndex = decoded.indexOf('?');
if (qIndex < 0) {
return decoded;
}
const params = new Map<string, string>();
decoded.substring(qIndex + 1).split('&').forEach((pair: string) => {
const eq = pair.indexOf('=');
if (eq > 0) {
const key = decodeURIComponent(pair.substring(0, eq)).trim();
let value = pair.substring(eq + 1);
try {
value = decodeURIComponent(value);
} catch (err) {
// 保留原值
}
if (key.length > 0 && !params.has(key)) {
params.set(key, value.trim());
}
}
});
// q 参数可能内嵌一层 ?a=b&c=d
const q = params.get('q') ?? '';
if (q.indexOf('?') >= 0) {
const nested = q.substring(q.indexOf('?') + 1);
nested.split('&').forEach((pair: string) => {
const eq = pair.indexOf('=');
if (eq > 0) {
const key = decodeURIComponent(pair.substring(0, eq)).trim();
let value = pair.substring(eq + 1);
try {
value = decodeURIComponent(value);
} catch (err) {
// 保留原值
}
if (key.length > 0 && !params.has(key)) {
params.set(key, value.trim());
}
}
});
}
const keys: Array<string> = ['scene', 'content', 'qr_content', 'qrContent', 'raw', 'text'];
for (let i = 0; i < keys.length; i++) {
const v = params.get(keys[i]) ?? '';
if (v.length > 0) {
return v;
}
}
return decoded;
}
// 调起系统扫码页识别巡更点二维码
private scanPoint(): void {
if (this.resolving) {
return;
}
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
const options: scanBarcode.ScanOptions = {
scanTypes: [scanCore.ScanType.QR_CODE],
enableAlbum: true,
};
scanBarcode.startScanForResult(context, options).then((result: scanBarcode.ScanResult) => {
const raw = result.originalValue ?? '';
const content = this.extractQrContent(raw);
if (content.length === 0) {
promptAction.showToast({ message: '未识别到二维码内容' });
return;
}
this.resolvePoint(content);
}).catch((err: BusinessError) => {
// 用户取消扫码(1000500001)静默
if (err.code !== 1000500001) {
promptAction.showToast({ message: `扫码失败:${err.message}` });
}
});
}
// 后端解密二维码并返回巡更点信息
private async resolvePoint(content: string): Promise<void> {
this.resolving = true;
try {
const res = await api.post<ResolvedPoint>('/fire-patrol/mobile/resolve-point/', { content: content });
promptAction.showToast({ message: `巡更点已识别:${res.name || res.id}` });
this.openScannedCheckin(res);
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.resolving = false;
}
}
private async submitCheckin(): Promise<void> {
if (this.checkingId.length > 0) {
return;
}
const target = this.selectedPoint;
this.checkingId = target;
try {
const photos: Array<CheckinPhoto> = await this.uploadPhotos();
const payload: CheckinPayload = {
point: target,
status: '正常',
note: this.note.trim(),
photos: photos,
};
await api.post('/fire-patrol/my-records/', payload);
this.showPointPicker = false;
promptAction.showToast({ message: '巡更打卡成功' });
await this.load();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.checkingId = '';
}
}
@Builder
StatTile(value: number, label: string, color: string) {
Column({ space: 4 }) {
Text(String(value))
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(color)
Text(label)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
@Builder
PointCard(p: FirePoint) {
Row({ space: 12 }) {
Column({ space: 4 }) {
Text(p.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (p.location && p.location.length > 0) {
Text(p.location)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(p.status === '正常' ? '使用中' : p.status)
.fontSize(12)
.fontColor(p.status === '正常' ? $r('app.color.accent_green') : $r('app.color.text_secondary'))
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
}
@Builder
RecordCard(r: FireRecord) {
Row({ space: 10 }) {
Column({ space: 4 }) {
Text(r.point)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(`${r.patrolman || ''} · ${formatTime(r.time)}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(r.status)
.fontSize(12)
.fontColor(r.status === '正常' ? $r('app.color.accent_green') : '#FF9500')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.onClick(() => {
router.pushUrl({
url: 'pages/fire-control/FireRecordDetail',
params: { id: r.id },
}).catch((err: BusinessError) => {
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
});
})
.accessibilityLevel('yes')
.accessibilityText(`巡检记录,${r.point},${r.status},点按查看详情`)
}
@Builder
CheckinSheet() {
Column({ space: 12 }) {
Text('巡更打卡')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Row({ space: 10 }) {
Text('巡更点')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
if (this.selectedPoint.length > 0) {
Column({ space: 2 }) {
Text(this.selectedPoint)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
if (this.resolvedInfo.length > 0) {
Text(this.resolvedInfo)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
} else {
Text('未选择')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.layoutWeight(1)
}
}
.width('100%')
TextInput({ placeholder: '备注(可选)', text: this.note })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.fontSize(15)
.height(46)
.padding({ left: 14, right: 14 })
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.onChange((value: string) => {
this.note = value;
})
Column({ space: 8 }) {
Row() {
Text(`照片(${this.selectedUris.length}/${MAX_IMAGES})`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
Blank()
Button('跨设备拍照/选图')
.fontSize(13)
.height(30)
.fontColor($r('app.color.text_primary'))
.backgroundColor(Color.Transparent)
.borderRadius(8)
.enabled(!this.uploading)
.bindMenu(this.CollabMenu)
.accessibilityLevel('yes')
.accessibilityText('跨设备拍照')
.accessibilityDescription('调用附近设备的相机拍照,照片自动加入上传列表')
Button(this.uploading ? '上传中...' : '+ 添加照片')
.fontSize(13)
.height(30)
.fontColor($r('app.color.accent_blue'))
.backgroundColor(Color.Transparent)
.borderRadius(8)
.enabled(!this.uploading)
.onClick(() => {
this.pickImages();
})
}
.width('100%')
if (this.selectedUris.length > 0) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.selectedUris, (uri: string) => {
Image(uri)
.width(64)
.height(64)
.borderRadius(8)
.margin({ right: 8, bottom: 8 })
}, (uri: string) => uri)
}
.width('100%')
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
Button(this.checkingId.length > 0 ? '打卡中...' : '确认打卡')
.width('100%')
.height(48)
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_green'))
.borderRadius(12)
.enabled(this.checkingId.length === 0)
.onClick(() => {
this.submitCheckin();
})
}
.width('100%')
.padding(20)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
}
build() {
HdsNavigation() {
Column() {
// 跨设备互通状态弹窗(接收远端相机回传数据)
CollaborationServiceStateDialog({
onState: (stateCode: number, bufferType: string, buffer: ArrayBuffer): void => {
this.onCollabState(stateCode, bufferType, buffer);
}
})
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.stats) {
Row() {
this.StatTile(this.stats.today_records, '今日打卡', '#34C759')
this.StatTile(this.stats.abnormal_records, '异常', '#FF3B30')
this.StatTile(this.stats.active_point_count, '巡更点', '#007AFF')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
}
// 唯一打卡入口:必须到场扫描点位二维码
Column({ space: 6 }) {
Button(this.resolving ? '识别中...' : '󰀨 现场扫码巡更')
.width('100%')
.height(52)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_green'))
.borderRadius(14)
.enabled(!this.resolving)
.onClick(() => {
this.scanPoint();
})
.accessibilityLevel('yes')
.accessibilityText('现场扫码巡更打卡')
}
.width('100%')
Column({ space: 8 }) {
Row() {
Text('巡更点')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(`共 ${this.points.length} 个`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
if (this.points.length === 0) {
Text('暂无巡更点')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.points, (p: FirePoint) => {
Column() {
this.PointCard(p)
}
.width('100%')
}, (p: FirePoint) => p.id || p.code)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
Column({ space: 8 }) {
Text('今日巡更记录')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.records.length === 0) {
Text('今日暂无打卡记录')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.records, (r: FireRecord) => {
Column() {
this.RecordCard(r)
}
.width('100%')
}, (r: FireRecord) => r.id)
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 20 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
}
.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'))
.bindSheet($$this.showPointPicker, this.CheckinSheet(), {
height: 440,
dragBar: true,
showClose: true,
backgroundColor: $r('app.color.background_card'),
})
}
}
@@ -0,0 +1,241 @@
// 消防 · 巡检记录详情(ArkTS 严格类型,无 any)
// 接口:GET /fire-patrol/my-records/{code}/
import { router } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { ProtectedImage } from '../../components/ProtectedImage';
import { api, RequestError } from '../../utils/Request';
interface FireRecordInfo {
id: string;
patrolman: string;
point: string;
point_pk: number;
time: string;
status: string;
hasPhoto: boolean;
note: string;
photos: Array<string>;
recorder: string;
created_at: string;
updated_at: string;
}
function statusColor(status: string): ResourceColor {
return status === '正常' ? $r('app.color.accent_green') : '#FF9500';
}
@Entry
@Component
struct FireRecordDetail {
@State record: FireRecordInfo | null = null;
@State loading: boolean = true;
@State errorMsg: string = '';
@State recordId: string = '';
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
const params = router.getParams() as Record<string, Object> | null | undefined;
if (params !== null && params !== undefined) {
const id = params['id'];
this.recordId = typeof id === 'string' ? id : String(id ?? '');
}
if (this.recordId.length > 0) {
this.load();
} else {
this.loading = false;
this.errorMsg = '缺少记录参数';
}
}
// 后端字段可能缺失(undefined 会让 .length 崩溃),统一归一化
private normalizeRecord(res: FireRecordInfo): FireRecordInfo {
const s = (v: string | null | undefined): string => (typeof v === 'string' ? v : '');
const photos: Array<string> = Array.isArray(res.photos) ? res.photos : [];
return {
id: s(res.id),
patrolman: s(res.patrolman),
point: s(res.point),
point_pk: typeof res.point_pk === 'number' ? res.point_pk : 0,
time: s(res.time),
status: s(res.status),
hasPhoto: res.hasPhoto === true,
note: s(res.note),
photos: photos,
recorder: s(res.recorder),
created_at: s(res.created_at),
updated_at: s(res.updated_at),
};
}
private async load(): Promise<void> {
this.loading = true;
this.errorMsg = '';
try {
const res = await api.get<FireRecordInfo>(`/fire-patrol/my-records/${encodeURIComponent(this.recordId)}/`);
this.record = this.normalizeRecord(res);
} catch (err) {
this.errorMsg = err instanceof RequestError ? err.message : `${err}`;
} finally {
this.loading = false;
}
}
@Builder
InfoRow(label: string, value: string) {
Row({ space: 12 }) {
Text(label)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(76)
Text((value ?? '').length > 0 ? value : '--')
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
}
.width('100%')
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.loading) {
Text('加载中...')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding({ top: 40 })
.textAlign(TextAlign.Center)
} else if (this.errorMsg.length > 0) {
Column({ space: 8 }) {
Text('加载失败')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(this.errorMsg)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding({ top: 40 })
.alignItems(HorizontalAlign.Center)
} else if (this.record !== null) {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text(this.record.point)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
Text(this.record.status)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor(statusColor(this.record.status))
.borderRadius(10)
}
.width('100%')
this.InfoRow('巡检员', this.record.patrolman)
this.InfoRow('打卡时间', this.record.time)
this.InfoRow('登记人', this.record.recorder)
this.InfoRow('创建时间', this.record.created_at)
this.InfoRow('更新时间', this.record.updated_at)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
.accessibilityLevel('yes')
.accessibilityText(`巡检记录,${this.record.point},${this.record.status},巡检员${this.record.patrolman},时间${this.record.time}`)
// 备注
Column({ space: 6 }) {
Text('备注')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Text(this.record.note.length > 0 ? this.record.note : '无备注')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 现场照片
if (this.record.photos.length > 0) {
Column({ space: 8 }) {
Text(`现场照片 (${this.record.photos.length})`)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.record.photos, (img: string) => {
ProtectedImage({ mediaPath: img })
.width('32%')
.aspectRatio(1)
.borderRadius(10)
.margin({ right: '2%', bottom: 8 })
.clip(true)
}, (img: string) => img)
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.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'))
}
}
@@ -0,0 +1,518 @@
// 订餐查询(管理端)(ArkTS 严格类型,无 any)
// 接口:GET /meals/orders/list/?service_date=&category=&search=&page=
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { Page, normalizeError } from '../../common/ApiConstants';
import { api } from '../../utils/Request';
interface OrderItem {
id: number;
elderly_name: string;
room: string;
bed: string;
category: string;
service_date: string;
is_reserved: boolean;
created_at: string;
}
// 每日用餐统计
interface MealCounts {
total: number;
breakfast: number;
lunch: number;
dinner: number;
}
interface MealGroups {
administrative: MealCounts;
elderly_dining: MealCounts;
floor_elderly: MealCounts;
floor_caregiver: MealCounts;
}
interface OrderSummary {
summary_date: string;
today_breakfast: Array<string>;
today_lunch: Array<string>;
today_dinner: Array<string>;
today_count: number;
week_count: number;
month_count: number;
breakfast_count: number;
lunch_count: number;
dinner_count: number;
meal_groups: MealGroups;
}
// 每日用餐报告(行政/长者/楼层统计来源于此,对齐 web)
interface DailyReport {
service_date: string;
meal_groups: MealGroups;
}
const CATEGORIES: Array<string> = ['全部', '早餐', '午餐', '晚餐'];
function today(): string {
const d = new Date();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}
function formatSummaryTime(): string {
const d = new Date();
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mi}:${ss}`;
}
function categoryIcon(category: string): string {
if (category === '早餐') {
return '🌅';
}
if (category === '午餐') {
return '🍚';
}
if (category === '晚餐') {
return '🌙';
}
return '☀';
}
@Entry
@Component
struct MealOrders {
@State orders: Array<OrderItem> = [];
@State loading: boolean = true;
@State total: number = 0;
@State page: number = 1;
@State hasMore: boolean = true;
@State category: string = '全部';
@State serviceDate: string = today();
@State searchText: string = '';
@State summary: OrderSummary | null = null;
@State reportGroups: MealGroups | null = null;
@State summaryLoading: boolean = true;
@State noPermission: boolean = false;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.loadSummary();
this.loadPage(1);
}
private async loadSummary(): Promise<void> {
this.summaryLoading = true;
try {
// 同时拉每日用餐报告(行政/长者/楼层统计来源)与订餐统计
const report = await api.get<DailyReport>('/meals/reports/daily/', { service_date: this.serviceDate })
.catch((err: BusinessError | Error): DailyReport | null => {
console.warn('[MealOrders] daily report error:', normalizeError(err));
return null;
});
const summary = await api.get<OrderSummary>('/meals/orders/summary/', { service_date: this.serviceDate })
.catch((err: BusinessError | Error): OrderSummary | null => {
console.warn('[MealOrders] order summary error:', normalizeError(err));
return null;
});
this.reportGroups = report ? report.meal_groups : null;
this.summary = summary;
// 两个接口都失败(如家属无权限 403):视为无权查看
this.noPermission = (!report && !summary);
// 调试:显示 daily 返回是否成功
console.info(`[MealOrders] report=${report ? 'OK' : 'NULL'} administrative_total=${report ? report.meal_groups.administrative.total : 'N/A'}`);
} catch (err) {
this.noPermission = true;
this.reportGroups = null;
this.summary = null;
console.warn('[MealOrders] load summary failed:', normalizeError(err as BusinessError | Error));
} finally {
this.summaryLoading = false;
}
}
// 选择日期(点击日期打开选择器)
private pickDate(): void {
const parts: Array<string> = this.serviceDate.split('-');
const year = parts.length > 0 ? Number(parts[0]) : 2026;
const month = parts.length > 1 ? Number(parts[1]) : 1;
const day = parts.length > 2 ? Number(parts[2]) : 1;
DatePickerDialog.show({
start: new Date('2020-01-01'),
end: new Date('2035-12-31'),
selected: new Date(year, month - 1, day),
onDateAccept: (value: Date) => {
const mm = String(value.getMonth() + 1).padStart(2, '0');
const dd = String(value.getDate()).padStart(2, '0');
this.serviceDate = `${value.getFullYear()}-${mm}-${dd}`;
this.loadSummary();
this.loadPage(1);
},
});
}
private async loadPage(p: number): Promise<void> {
if (p === 1) {
this.loading = true;
}
try {
// 内联 Record 字面量(.ets 中不允许 Record 局部变量赋值)
const res = await api.get<Page<OrderItem>>('/meals/orders/list/', {
page: p,
page_size: 20,
service_date: this.serviceDate,
category: this.category === '全部' ? '' : this.category,
search: this.searchText.trim(),
});
this.total = res.count;
this.page = p;
this.hasMore = (p - 1) * 20 + res.results.length < res.count;
if (p === 1) {
this.orders = res.results;
} else {
this.orders = this.orders.concat(res.results);
}
} catch (err) {
console.warn('[MealOrders] load orders failed:', normalizeError(err as BusinessError | Error));
if (this.orders.length === 0) {
promptAction.showToast({ message: '订餐记录加载失败,请稍后重试' });
}
} finally {
this.loading = false;
}
}
private loadMore(): void {
if (!this.loading && this.hasMore) {
this.loadPage(this.page + 1);
}
}
private groupByKey(groups: MealGroups, key: string): MealCounts | null {
if (key === 'administrative') {
return groups.administrative;
}
if (key === 'elderly_dining') {
return groups.elderly_dining;
}
if (key === 'floor_elderly') {
return groups.floor_elderly;
}
if (key === 'floor_caregiver') {
return groups.floor_caregiver;
}
return null;
}
private selectCategory(category: string): void {
if (this.category !== category) {
this.category = category;
this.loadPage(1);
}
}
private search(): void {
this.loadPage(1);
}
@Builder
MealCountRow(key: string, title: string) {
Column({ space: 6 }) {
Row() {
Text(title)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Blank()
if (this.countOf(key).total > 0) {
Text(`${this.countOf(key).total} 人次`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.accent_blue'))
Row({ space: 10 }) {
Text(`早 ${this.countOf(key).breakfast}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
Text(`午 ${this.countOf(key).lunch}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
Text(`晚 ${this.countOf(key).dinner}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.margin({ left: 12 })
} else {
Text('无数据')
.fontSize(13)
.fontColor($r('app.color.inactive_tab'))
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
}
// 取分组统计(直接读 @State reportGroups,保证渲染响应)
private countOf(key: string): MealCounts {
const groups = this.reportGroups;
if (!groups) {
return { total: 0, breakfast: 0, lunch: 0, dinner: 0 };
}
const counts = this.groupByKey(groups, key);
if (counts) {
return counts;
}
return { total: 0, breakfast: 0, lunch: 0, dinner: 0 };
}
@Builder
SummarySection() {
Column({ space: 10 }) {
Row() {
Text('每日用餐统计')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(`更新于 ${formatSummaryTime()}`)
.fontSize(12)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
Text(this.serviceDate)
.fontSize(13)
.fontColor($r('app.color.accent_blue'))
.width('100%')
Column({ space: 0 }) {
this.MealCountRow('administrative', '行政用餐')
Divider().color($r('app.color.inactive_tab')).opacity(0.3)
this.MealCountRow('elderly_dining', '长者饭堂')
Divider().color($r('app.color.inactive_tab')).opacity(0.3)
this.MealCountRow('floor_elderly', '楼层长者')
Divider().color($r('app.color.inactive_tab')).opacity(0.3)
this.MealCountRow('floor_caregiver', '楼层护理员')
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
@Builder
OrderCard(item: OrderItem) {
Column({ space: 8 }) {
Row() {
Text(item.elderly_name)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(` ${item.room}${item.bed ? `-${item.bed}` : ''}`)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.margin({ left: 6 })
Blank()
Text(item.category)
.fontSize(13)
.fontColor($r('app.color.accent_blue'))
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.border({ width: 1, color: $r('app.color.accent_blue') })
.borderRadius(8)
}
.width('100%')
Row() {
Text(item.service_date)
.fontSize(13)
.fontColor($r('app.color.inactive_tab'))
Blank()
Text(item.is_reserved ? '已预订' : '未预订')
.fontSize(13)
.fontColor(item.is_reserved ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
@Builder
CategoryTab(category: string) {
Column({ space: 2 }) {
Text(categoryIcon(category))
.fontSize(18)
Text(category)
.fontSize(14)
.fontWeight(this.category === category ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.category === category ? $r('app.color.accent_blue') : $r('app.color.text_secondary'))
}
.layoutWeight(1)
.padding({ top: 8, bottom: 8 })
.backgroundColor(this.category === category ? '#14007AFF' : Color.Transparent)
.borderRadius(12)
.onClick(() => {
this.selectCategory(category);
})
}
build() {
HdsNavigation() {
Column() {
if (this.noPermission) {
Column({ space: 8 }) {
Text('🔒')
.fontSize(40)
Text('仅管理员/护士可查看订餐统计')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.margin({ top: 120 })
.alignItems(HorizontalAlign.Center)
} else {
// 筛选栏
Column({ space: 10 }) {
// 日期 + 搜索
Row({ space: 10 }) {
Text(this.serviceDate)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(10)
.onClick(() => {
this.pickDate();
})
TextInput({ placeholder: '搜索老人/房间', text: this.searchText })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.height(36)
.layoutWeight(1)
.padding({ left: 12, right: 12 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(10)
.onChange((value: string) => {
this.searchText = value;
})
Button('搜索')
.height(36)
.fontSize(13)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(10)
.onClick(() => {
this.search();
})
}
.width('100%')
// 餐次切换
Row({ space: 8 }) {
ForEach(CATEGORIES, (category: string) => {
this.CategoryTab(category)
}, (category: string) => category)
}
.width('100%')
}
.width('92%')
.padding({ top: 120, bottom: 8 })
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else if (this.orders.length === 0 && !this.reportGroups) {
Column({ space: 8 }) {
Text('🍚')
.fontSize(36)
Text('暂无订餐记录')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.margin({ top: 80 })
.alignItems(HorizontalAlign.Center)
} else {
Scroll(this.scroller) {
Column({ space: 10 }) {
// 每日用餐统计
Column() {
this.SummarySection()
}
.width('100%')
.margin({ bottom: 4 })
Text(`共 ${this.total} 条订餐记录`)
.fontSize(13)
.fontColor($r('app.color.inactive_tab'))
.width('100%')
ForEach(this.orders, (item: OrderItem) => {
this.OrderCard(item)
}, (item: OrderItem) => String(item.id))
if (this.hasMore) {
Text('加载更多...')
.fontSize(13)
.fontColor($r('app.color.inactive_tab'))
.width('100%')
.textAlign(TextAlign.Center)
.padding(10)
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 20 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.width('100%')
.onReachEnd(() => {
this.loadMore();
})
}
}
}
.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'))
}
}
@@ -0,0 +1,299 @@
// 消息通知页(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<FeedItem> = [];
@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<void> {
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<UserNotice> | Page<AlertItem> | null> = await Promise.all([
api.get<Page<UserNotice>>('/auth/notifications/', { page_size: 30 }).catch((): Page<UserNotice> | null => null),
includeAlerts
? api.get<Page<AlertItem>>('/alerts/', { page_size: 30, ordering: '-created_at' }).catch((): Page<AlertItem> | null => null)
: Promise.resolve(null),
]);
const noticeRes = responses[0] as Page<UserNotice> | null;
const alertRes = responses[1] as Page<AlertItem> | null;
const items: Array<FeedItem> = [];
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<void> {
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<void> {
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'))
}
}
+167
View File
@@ -0,0 +1,167 @@
// 预警中心(ArkTS 严格类型,无 any)
// 数据来源:/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 } from '../../utils/Request';
interface AlertItem {
id: number;
title: string;
description: string;
level: string;
status: string;
created_at: string;
}
function formatTime(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}`;
}
function levelColor(level: string): string {
const lv = (level || '').toLowerCase();
if (lv.indexOf('critical') >= 0 || lv.indexOf('严重') >= 0) {
return ('app.color.accent_red');
}
if (lv.indexOf('warning') >= 0 || lv.indexOf('警告') >= 0) {
return '#FF9500';
}
return ('app.color.accent_blue');
}
@Entry
@Component
struct Alerts {
@State alerts: AlertItem[] = [];
@State loading: boolean = true;
@State refreshing: boolean = false;
private scroller: Scroller = new Scroller();
async aboutToAppear(): Promise<void> {
await this.loadAlerts();
}
private async loadAlerts(): Promise<void> {
this.loading = true;
try {
const res = await api.get<Page<AlertItem>>('/alerts/', {
page_size: 50,
ordering: '-created_at',
});
this.alerts = res.results;
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
this.refreshing = false;
}
}
@Builder
AlertItemView(item: AlertItem) {
Column({ space: 8 }) {
Row({ space: 8 }) {
Circle()
.width(8)
.height(8)
.fill(levelColor(item.level))
Text(item.title)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
Text(formatTime(item.created_at))
.fontSize(12)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
Text(item.description && item.description.length > 0 ? item.description : '(无详情)')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
Refresh({ refreshing: $$this.refreshing }) {
Scroll(this.scroller) {
Column({ space: 10 }) {
ForEach(this.alerts, (item: AlertItem) => {
Column() {
this.AlertItemView(item)
}
.width('100%')
}, (item: AlertItem) => String(item.id))
if (this.loading) {
Text('加载中...')
.fontSize(13)
.fontColor($r('app.color.inactive_tab'))
.padding(12)
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 24 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.width('100%')
.layoutWeight(1)
.onRefreshing(() => {
this.loadAlerts();
})
}
.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'))
}
}
@@ -0,0 +1,296 @@
// 护理员 · 代取消订餐(内部饭堂,仅明日,ArkTS 严格类型)
// 长者:GET /meals/elderly-cancellation-candidates/ ;状态:GET /meals/internal-meals/elderly/{id}/ ;取消:POST /meals/internal-meals/elderly/{id}/
import { router, 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, RequestError } from '../../utils/Request';
interface Candidate {
id: number;
name: string;
gender: string;
age: number;
room: string;
bed: string;
}
interface InternalMeal {
category: string;
reserved: boolean;
}
interface InternalMealResponse {
service_date: string;
meals: Array<InternalMeal>;
}
interface CancelBody {
service_date: string;
category: string;
reserved: boolean;
whole_grain_rice: boolean;
}
function tomorrowYMD(): string {
const d = new Date();
d.setDate(d.getDate() + 1);
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}
@Entry
@Component
struct CancelMeal {
@State list: Array<Candidate> = [];
@State loading: boolean = true;
@State selected: Candidate | null = null;
@State meals: Array<InternalMeal> = [];
@State mealsLoading: boolean = false;
@State cancelingId: string = '';
@State keyword: string = '';
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.loadCandidates();
}
private async loadCandidates(): Promise<void> {
this.loading = true;
try {
const res = await api.get<Page<Candidate>>('/meals/elderly-cancellation-candidates/', { page_size: 100 });
this.list = res.results;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
private filtered(): Array<Candidate> {
const kw = this.keyword.trim();
if (kw.length === 0) {
return this.list;
}
return this.list.filter((c: Candidate): boolean => {
return c.name.indexOf(kw) >= 0 || (c.room || '').indexOf(kw) >= 0 || (c.bed || '').indexOf(kw) >= 0;
});
}
private async select(candidate: Candidate): Promise<void> {
this.selected = candidate;
this.meals = [];
this.mealsLoading = true;
try {
const date = tomorrowYMD();
const res = await api.get<InternalMealResponse>(`/meals/internal-meals/elderly/${candidate.id}/`, {
service_date: date,
});
this.meals = res.meals;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.mealsLoading = false;
}
}
private async cancelMeal(meal: InternalMeal): Promise<void> {
if (this.cancelingId.length > 0) {
return;
}
if (this.selected === null) {
return;
}
this.cancelingId = meal.category;
try {
const body: CancelBody = {
service_date: tomorrowYMD(),
category: meal.category,
reserved: false,
whole_grain_rice: false,
};
await api.post(`/meals/internal-meals/elderly/${this.selected.id}/`, body);
this.meals = this.meals.map((m: InternalMeal): InternalMeal => {
if (m.category === meal.category) {
return { category: m.category, reserved: false };
}
return m;
});
promptAction.showToast({ message: `已取消明日${meal.category}` });
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.cancelingId = '';
}
}
@Builder
CandidateRow(c: Candidate) {
Row({ space: 10 }) {
Text(c.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(`${c.room || ''}${c.bed ? ' ' + c.bed : ''}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Blank()
Text(this.selected !== null && this.selected.id === c.id ? '已选' : '›')
.fontSize(13)
.fontColor(this.selected !== null && this.selected.id === c.id ? '#007AFF' : $r('app.color.inactive_tab'))
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.backgroundColor(this.selected !== null && this.selected.id === c.id ? '#1A007AFF' : $r('app.color.background_card'))
.borderRadius(10)
.onClick(() => {
this.select(c);
})
}
@Builder
MealRow(meal: InternalMeal) {
Row({ space: 10 }) {
Text(meal.category)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
if (meal.reserved) {
Text('已预订')
.fontSize(12)
.fontColor($r('app.color.accent_green'))
Text(this.cancelingId === meal.category ? '取消中...' : '取消')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor(this.cancelingId === meal.category ? '#A9A9A9' : '#FF3B30')
.borderRadius(8)
.onClick(() => {
this.cancelMeal(meal);
})
} else {
Text('未预订')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(10)
.alignItems(VerticalAlign.Center)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
// 长者搜索
TextInput({ placeholder: '搜索姓名 / 房间 / 床位', text: this.keyword })
.width('100%')
.backgroundColor($r('app.color.background_card'))
.borderRadius(10)
.onChange((value: string) => {
this.keyword = value;
})
// 长者列表
if (this.loading) {
Text('正在加载长者...')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.padding(16)
} else if (this.filtered().length === 0) {
Text('暂无符合条件的长者')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.padding(16)
} else {
ForEach(this.filtered(), (c: Candidate) => {
this.CandidateRow(c)
}, (c: Candidate) => String(c.id))
}
// 明日订餐面板
if (this.selected !== null) {
Column({ space: 8 }) {
Row() {
Text(`明日订餐 · ${this.selected.name}`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(tomorrowYMD())
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
if (this.mealsLoading) {
Text('正在加载订餐状态...')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.padding(12)
} else if (this.meals.length === 0) {
Text('明日暂无订餐数据')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.padding(12)
} else {
ForEach(this.meals, (meal: InternalMeal) => {
this.MealRow(meal)
}, (meal: InternalMeal) => meal.category)
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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,
})
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
+208
View File
@@ -0,0 +1,208 @@
// 护理记录历史列表(ArkTS 严格类型,无 any)
// 数据来源:/tasks/nursing-records/
import { router, 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 } from '../../utils/Request';
import { ProtectedImage } from '../../components/ProtectedImage';
interface ApiRecord {
id: number;
elderly_name: string;
nurse_name: string | null;
raw_text: string;
images: Array<string> | null;
created_at: string;
}
// 记录详情页路由参数
interface RecordDetailParams {
id: string;
}
function formatTime(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 History {
@State records: ApiRecord[] = [];
@State loading: boolean = true;
@State total: number = 0;
@State page: number = 1;
@State hasMore: boolean = true;
private scroller: Scroller = new Scroller();
async aboutToAppear(): Promise<void> {
await this.loadPage(1);
}
async onPageShow(): Promise<void> {
await this.loadPage(1);
}
private async loadPage(p: number): Promise<void> {
if (p === 1) {
this.loading = true;
}
try {
const res = await api.get<Page<ApiRecord>>('/tasks/nursing-records/', {
page: p,
page_size: 20,
ordering: '-created_at',
});
this.total = res.count;
this.page = p;
this.hasMore = this.records.length + res.results.length < res.count;
if (p === 1) {
this.records = res.results;
} else {
this.records = this.records.concat(res.results);
}
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
private loadMore(): void {
if (!this.loading && this.hasMore) {
this.loadPage(this.page + 1);
}
}
private openDetail(item: ApiRecord): void {
const params: RecordDetailParams = { id: String(item.id) };
router.pushUrl({
url: 'pages/nurse/RecordDetail',
params: params,
}).catch((err: BusinessError) => {
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
});
}
@Builder
RecordItem(item: ApiRecord) {
Column({ space: 8 }) {
Row() {
Text(item.elderly_name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Blank()
Text(formatTime(item.created_at))
.fontSize(12)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
Text(item.raw_text && item.raw_text.length > 0 ? item.raw_text : '(无文字内容)')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
if (item.images && item.images.length > 0) {
Row({ space: 8 }) {
ForEach(item.images.slice(0, 3), (img: string) => {
Stack() {
ProtectedImage({ mediaPath: img })
}
.width(64)
.height(64)
.borderRadius(8)
.clip(true)
}, (img: string) => img)
if (item.images.length > 3) {
Text(`+${item.images.length - 3}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
}
.width('100%')
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 10 }) {
ForEach(this.records, (item: ApiRecord) => {
Column() {
this.RecordItem(item)
}
.width('100%')
.onClick(() => {
this.openDetail(item);
})
}, (item: ApiRecord) => String(item.id))
if (this.loading) {
Text('加载中...')
.fontSize(13)
.fontColor($r('app.color.inactive_tab'))
.padding(12)
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 24 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.onReachEnd(() => {
this.loadMore();
})
}
.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'))
}
}
@@ -0,0 +1,392 @@
// 护理员 · 今日喂药待办(ArkTS 严格类型,无 any)
// 接口:GET /care-plans/medication-administrations/today/
// POST /care-plans/medication-administrations/{id}/execute/
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface MedicationAdministration {
id: number;
elderly_name: string;
elderly: number;
medicine_name: string;
dosage: string;
route: string;
meal_timing: string;
prescribed_note: string;
scheduled_time: string;
scheduled_at: string;
status: string;
effective_status: string;
actual_at: string | null;
reason: string;
administered_by_name: string;
}
interface ExecutePayload {
status: string;
actual_at?: string;
reason?: string;
}
function mealTimingLabel(timing: string): string {
if (timing === 'before_meal') {
return '餐前';
}
if (timing === 'after_meal') {
return '餐后';
}
return '不限';
}
function statusColor(status: string): string {
if (status === 'administered') {
return '#34C759';
}
if (status === 'refused') {
return '#FF3B30';
}
if (status === 'deferred') {
return '#FF9500';
}
if (status === 'missed') {
return '#8E8E93';
}
if (status === 'overdue') {
return '#FF3B30';
}
if (status === 'processing') {
return '#007AFF';
}
return '#8E8E93';
}
function statusLabel(status: string): string {
if (status === 'administered') {
return '已给药';
}
if (status === 'refused') {
return '已拒服';
}
if (status === 'deferred') {
return '已暂缓';
}
if (status === 'missed') {
return '已漏服';
}
if (status === 'overdue') {
return '已逾期';
}
if (status === 'processing') {
return '处理中';
}
return '待执行';
}
function formatSlot(iso: string): string {
if (!iso || iso.length === 0) {
return '--';
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return '--';
}
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${hh}:${mi}`;
}
function todayStr(): string {
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
return `${now.getFullYear()}-${mm}-${dd}`;
}
@Entry
@Component
struct MedicationToDo {
@State items: Array<MedicationAdministration> = [];
@State loading: boolean = true;
@State executingId: number = 0;
// 异常原因输入
@State showReasonSheet: boolean = false;
@State reasonTargetId: number = 0;
@State reasonTargetStatus: string = '';
@State reasonText: string = '';
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const res = await api.get<Array<MedicationAdministration>>(
'/care-plans/medication-administrations/today/');
this.items = res;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
private async execute(item: MedicationAdministration, status: string): Promise<void> {
if (this.executingId !== 0) {
return;
}
if (status !== 'administered') {
this.reasonTargetId = item.id;
this.reasonTargetStatus = status;
this.reasonText = '';
this.showReasonSheet = true;
return;
}
this.executingId = item.id;
try {
const payload: ExecutePayload = { status: 'administered' };
await api.post(`/care-plans/medication-administrations/${item.id}/execute/`, payload);
promptAction.showToast({ message: `已记录:${item.medicine_name}` });
await this.load();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.executingId = 0;
}
}
private async submitReason(): Promise<void> {
if (this.reasonText.trim().length === 0) {
promptAction.showToast({ message: '请填写原因' });
return;
}
this.executingId = this.reasonTargetId;
try {
const payload: ExecutePayload = {
status: this.reasonTargetStatus,
reason: this.reasonText.trim(),
};
await api.post(`/care-plans/medication-administrations/${this.reasonTargetId}/execute/`, payload);
this.showReasonSheet = false;
promptAction.showToast({ message: '已记录异常' });
await this.load();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.executingId = 0;
}
}
@Builder
MedCard(item: MedicationAdministration) {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text(item.elderly_name)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(statusLabel(item.effective_status))
.fontSize(12)
.fontColor(statusColor(item.effective_status))
}
.width('100%')
Row() {
Text(item.medicine_name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.accent_blue'))
Text(item.dosage)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.margin({ left: 8 })
}
.width('100%')
Row({ space: 12 }) {
Text(`⏰ ${formatSlot(item.scheduled_at)}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
Text(item.route)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
Text(mealTimingLabel(item.meal_timing))
.fontSize(13)
.fontColor($r('app.color.accent_blue'))
}
.width('100%')
if (item.prescribed_note && item.prescribed_note.length > 0) {
Text(item.prescribed_note)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
if (item.effective_status !== 'administered' &&
item.effective_status !== 'refused' &&
item.effective_status !== 'deferred' &&
item.effective_status !== 'missed') {
Row({ space: 10 }) {
Button(this.executingId === item.id ? '处理中...' : '已给药')
.height(36)
.fontSize(14)
.fontColor('#FFFFFF')
.backgroundColor('#34C759')
.borderRadius(10)
.layoutWeight(1)
.enabled(this.executingId === 0)
.onClick(() => {
this.execute(item, 'administered');
})
Button('拒服')
.height(36)
.fontSize(14)
.fontColor('#FF3B30')
.backgroundColor('#14FF3B30')
.borderRadius(10)
.layoutWeight(1)
.enabled(this.executingId === 0)
.onClick(() => {
this.execute(item, 'refused');
})
Button('暂缓')
.height(36)
.fontSize(14)
.fontColor('#FF9500')
.backgroundColor('#14FF9500')
.borderRadius(10)
.layoutWeight(1)
.enabled(this.executingId === 0)
.onClick(() => {
this.execute(item, 'deferred');
})
}
.width('100%')
}
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
@Builder
ReasonSheet() {
Column({ space: 12 }) {
Text(`说明${statusLabel(this.reasonTargetStatus)}原因`)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
TextArea({ placeholder: '请输入原因(必填)', text: this.reasonText })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.height(120)
.padding(14)
.backgroundColor($r('app.color.background'))
.borderRadius(12)
.onChange((value: string) => {
this.reasonText = value;
})
Button(this.executingId === this.reasonTargetId ? '提交中...' : '确认提交')
.width('100%')
.height(48)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
.enabled(this.executingId === 0)
.onClick(() => {
this.submitReason();
})
}
.width('100%')
.padding(20)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else if (this.items.length === 0) {
Text('今日暂无喂药任务')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(24)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.textAlign(TextAlign.Center)
} else {
ForEach(this.items, (item: MedicationAdministration) => {
Column() {
this.MedCard(item)
}
.width('100%')
}, (item: MedicationAdministration) => String(item.id))
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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'))
.bindSheet($$this.showReasonSheet, this.ReasonSheet(), {
height: 300,
dragBar: true,
showClose: true,
backgroundColor: $r('app.color.background_card'),
})
}
}
@@ -0,0 +1,284 @@
// 护士 · 预警处理留证(ArkTS 严格类型,无 any)
// 接口:GET /alerts/、GET /alerts/{id}/、POST /alerts/{id}/start_process/、POST /alerts/{id}/process/
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, RequestError } from '../../utils/Request';
interface AlertItem {
id: number;
elderly_name: string;
level: string;
title: string;
description: string;
status: string;
status_display: string;
created_at: string;
}
function levelColor(level: string): string {
if (level === 'emergency') {
return '#FF3B30';
}
if (level === 'warning') {
return '#FF9500';
}
return '#007AFF';
}
function levelLabel(level: string): string {
if (level === 'emergency') {
return '紧急';
}
if (level === 'warning') {
return '注意';
}
return '信息';
}
function formatDateTime(iso: string): string {
if (!iso) {
return '--';
}
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 NurseAlertProcess {
@State alerts: Array<AlertItem> = [];
@State loading: boolean = true;
@State processing: boolean = false;
@State targetAlert: AlertItem | null = null;
@State showSheet: boolean = false;
@State processNote: string = '';
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const res = await api.get<Page<AlertItem>>('/alerts/', {
status: 'pending',
page_size: 50,
ordering: '-created_at',
});
this.alerts = res.results;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
private openProcess(alert: AlertItem): void {
this.targetAlert = alert;
this.processNote = '';
this.showSheet = true;
}
private async submit(): Promise<void> {
if (this.processing || this.targetAlert === null) {
return;
}
if (this.processNote.trim().length === 0) {
promptAction.showToast({ message: '请填写处理说明' });
return;
}
this.processing = true;
const id = this.targetAlert.id;
try {
await api.post(`/alerts/${id}/start_process/`, new EmptyBody()).catch((err: BusinessError | Error) => {
console.warn('[NurseAlertProcess] start_process:', normalizeError(err));
});
await api.post(`/alerts/${id}/process/`, { description: this.processNote.trim(), images: [] });
this.showSheet = false;
promptAction.showToast({ message: '预警已处理并留证' });
await this.load();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.processing = false;
}
}
@Builder
AlertCard(alert: AlertItem) {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(levelLabel(alert.level))
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(levelColor(alert.level))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor('#14000000')
.borderRadius(4)
Text(alert.title)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
if (alert.description && alert.description.length > 0) {
Text(alert.description)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
Row({ space: 10 }) {
Text(alert.elderly_name)
.fontSize(12)
.fontColor($r('app.color.accent_blue'))
Blank()
Text(formatDateTime(alert.created_at))
.fontSize(12)
.fontColor($r('app.color.inactive_tab'))
Button('处理')
.height(30)
.fontSize(12)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_green'))
.borderRadius(8)
.onClick(() => {
this.openProcess(alert);
})
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
}
@Builder
ProcessSheet() {
Column({ space: 12 }) {
Text('处理预警')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(this.targetAlert ? `${this.targetAlert.elderly_name} · ${this.targetAlert.title}` : '')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width('100%')
TextArea({ placeholder: '填写处理说明(必填)', text: this.processNote })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.height(120)
.padding(14)
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.onChange((value: string) => {
this.processNote = value;
})
Button(this.processing ? '提交中...' : '确认处理并留证')
.width('100%')
.height(48)
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_green'))
.borderRadius(12)
.enabled(!this.processing)
.onClick(() => {
this.submit();
})
}
.width('100%')
.padding(20)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else if (this.alerts.length === 0) {
Text('暂无待处理预警')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.margin({ top: 160 })
} else {
Scroll(this.scroller) {
Column({ space: 10 }) {
ForEach(this.alerts, (alert: AlertItem) => {
Column() {
this.AlertCard(alert)
}
.width('100%')
}, (alert: AlertItem) => String(alert.id))
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
}
.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'))
.bindSheet($$this.showSheet, this.ProcessSheet(), {
height: 340,
dragBar: true,
showClose: true,
backgroundColor: $r('app.color.background_card'),
})
}
}
@@ -0,0 +1,272 @@
// 护士 · 体检评估与护理方案(ArkTS 严格类型,无 any)
// 接口:GET /care-plans/plans/、GET /care-plans/assessments/
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, RequestError } from '../../utils/Request';
interface CarePlan {
id: number;
elderly_name: string;
version: number;
status: string;
generation_status: string;
review_status: string;
summary: string;
suggested_care_level: string;
suggested_care_level_reason: string;
effective_from: string;
}
interface Assessment {
id: number;
elderly_name: string;
source: string;
title: string;
report_date: string;
ocr_status: string;
created_at: string;
}
function planStatusLabel(status: string): string {
if (status === 'draft') {
return '草稿';
}
if (status === 'published') {
return '已发布';
}
return '已取代';
}
function planStatusColor(status: string): string {
if (status === 'published') {
return '#34C759';
}
if (status === 'draft') {
return '#FF9500';
}
return '#8E8E93';
}
function careLevelLabel(level: string): string {
if (level === 'self_care') {
return '自理';
}
if (level === 'semi_care') {
return '半护理';
}
if (level === 'full_care') {
return '全护理';
}
if (level === 'special_care') {
return '特殊护理';
}
if (level === 'cognitive_care') {
return '认知护理';
}
if (level === 'intensive_care') {
return '专护';
}
return level || '--';
}
@Entry
@Component
struct NurseCarePlan {
@State plans: Array<CarePlan> = [];
@State assessments: Array<Assessment> = [];
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const results: Array<Page<CarePlan> | Page<Assessment> | null> = await Promise.all([
api.get<Page<CarePlan>>('/care-plans/plans/', { page_size: 50 }).catch((): Page<CarePlan> | null => null),
api.get<Page<Assessment>>('/care-plans/assessments/', { page_size: 50 }).catch((): Page<Assessment> | null => null),
]);
const p = results[0] as Page<CarePlan> | null;
const a = results[1] as Page<Assessment> | null;
this.plans = p ? p.results : [];
this.assessments = a ? a.results : [];
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
PlanCard(p: CarePlan) {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(p.elderly_name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(`v${p.version}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Blank()
Text(planStatusLabel(p.status))
.fontSize(12)
.fontColor(planStatusColor(p.status))
}
.width('100%')
Text(careLevelLabel(p.suggested_care_level))
.fontSize(13)
.fontColor($r('app.color.accent_blue'))
.width('100%')
if (p.summary && p.summary.length > 0) {
Text(p.summary)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
}
@Builder
AssessmentCard(a: Assessment) {
Row({ space: 10 }) {
Column({ space: 4 }) {
Text(a.elderly_name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(`${a.source === 'upload' ? '上传报告' : '人工录入'} · ${a.report_date || '--'}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(a.ocr_status === 'succeeded' ? '已解析' : (a.ocr_status === 'pending' ? '解析中' : a.ocr_status || '--'))
.fontSize(12)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else {
Scroll(this.scroller) {
Column({ space: 12 }) {
Column({ space: 8 }) {
Text(`护理方案(${this.plans.length})`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.plans.length === 0) {
Text('暂无护理方案')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.plans, (p: CarePlan) => {
Column() {
this.PlanCard(p)
}
.width('100%')
}, (p: CarePlan) => String(p.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
Column({ space: 8 }) {
Text(`体检评估(${this.assessments.length})`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.assessments.length === 0) {
Text('暂无体检评估')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.assessments, (a: Assessment) => {
Column() {
this.AssessmentCard(a)
}
.width('100%')
}, (a: Assessment) => String(a.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 20 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
}
.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'))
}
}
@@ -0,0 +1,290 @@
// 护理员工作台(ArkTS 严格类型,无 any)
// 数据来源:/tasks/work-center/、/alerts/、/auth/notifications/
import { router, promptAction, uiMaterial } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError, Page } from '../../common/ApiConstants';
import { api } from '../../utils/Request';
import { AuthStore } from '../../utils/AuthStore';
interface CareSummary {
current: number;
overdue: number;
upcoming: number;
completed: number;
}
interface ShiftLocation {
floor: number | null;
}
interface ShiftInfo {
locations: ShiftLocation[];
}
interface ElderlyBrief {
id: number;
}
interface CareCenterResponse {
current_shift: ShiftInfo | null;
summary: CareSummary;
elderly?: ElderlyBrief[];
}
interface ApiAlertItem {
id: number;
title: string;
description: string;
created_at: string;
}
interface ApiNoticeItem {
id: number;
title: string;
content: string;
created_at: string;
}
function formatDateTime(iso: string): string {
if (!iso) {
return '--';
}
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}`;
}
function greeting(): string {
const hour = new Date().getHours();
if (hour < 12) {
return '早上好';
}
if (hour < 18) {
return '下午好';
}
return '晚上好';
}
interface QuickAction {
key: string;
title: string;
desc: string;
route: string;
}
@Entry
@Component
struct NurseHome {
@State userName: string = '';
@State elderlyCount: number = 0;
@State activeCareCount: number = 0;
@State overdueCount: number = 0;
@State latestAlert: string = '';
@State latestAlertTime: string = '';
@State hasAlert: boolean = false;
@State loading: boolean = true;
private actions: QuickAction[] = [
{ key: 'record', title: '事件录入', desc: '快速记录护理事件', route: 'pages/nurse/RecordEntry' },
{ key: 'history', title: '历史记录', desc: '查看过往护理记录', route: 'pages/nurse/History' },
{ key: 'alerts', title: '预警中心', desc: '查看实时预警', route: 'pages/nurse/Alerts' },
{ key: 'medication', title: '喂药待办', desc: '今日喂药执行', route: 'pages/nurse/MedicationToDo' },
{ key: 'patrol', title: '风险巡视', desc: '风险点逐项确认', route: 'pages/nurse/RiskPatrol' },
{ key: 'workcenter', title: '护理中心', desc: '认领与完成工作项', route: 'pages/nurse/WorkCenter' },
{ key: 'dashboard', title: '数据看板', desc: '运营数据一览', route: 'pages/admin/AdminOverview' },
];
async aboutToAppear(): Promise<void> {
const state = await AuthStore.getState();
this.userName = state.userName;
await this.loadData();
}
async onPageShow(): Promise<void> {
await this.loadData();
}
private async loadData(): Promise<void> {
this.loading = true;
try {
const results = await Promise.all([
api.get<CareCenterResponse>('/tasks/work-center/', { view: 'all' }).catch(() => null),
api.get<Page<ApiAlertItem>>('/alerts/', { page_size: 1, ordering: '-created_at' }).catch(() => null),
api.get<Page<ApiNoticeItem>>('/auth/notifications/', { page_size: 1 }).catch(() => null),
]);
if (results[0]) {
const careRes = results[0];
this.elderlyCount = careRes.elderly ? careRes.elderly.length : 0;
const summary = careRes.summary;
this.activeCareCount = summary.current + summary.overdue + summary.upcoming;
this.overdueCount = summary.overdue;
}
const alert = results[1] && results[1].results.length > 0 ? results[1].results[0] : null;
const notice = results[2] && results[2].results.length > 0 ? results[2].results[0] : null;
if (alert) {
this.latestAlert = alert.title;
this.latestAlertTime = formatDateTime(alert.created_at);
this.hasAlert = true;
} else if (notice) {
this.latestAlert = notice.title;
this.latestAlertTime = formatDateTime(notice.created_at);
this.hasAlert = true;
} else {
this.latestAlert = '';
this.latestAlertTime = '';
this.hasAlert = false;
}
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
private go(route: string): void {
router.pushUrl({ url: route }).catch((err: BusinessError) => {
promptAction.showToast({ message: `页面开发中:${normalizeError(err)}` });
});
}
@Builder
StatCard(value: number, unit: string, label: string, color: string) {
Column({ space: 4 }) {
Row({ space: 2 }) {
Text(String(value))
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(color)
Text(unit)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.margin({ top: 8 })
}
Text(label)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
@Builder
QuickActionCard(action: QuickAction) {
Column({ space: 6 }) {
Text(action.title)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text(action.desc)
.fontSize(11)
.fontColor($r('app.color.text_secondary'))
}
.width('30%')
.height(60)
.margin({ bottom: 10 })
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor(Color.Transparent)
.backgroundBlurStyle(BlurStyle.Thin)
.borderRadius(12)
.onClick(() => {
this.go(action.route);
})
}
build() {
Column() {
Scroll() {
Column({ space: 16 }) {
// 顶部问候
Column({ space: 6 }) {
Text(`${this.userName || '护理员'},${greeting()}`)
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text('护理员 · 今日在岗')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding({ left: 20, right: 20, top: 40, bottom: 8 })
.alignItems(HorizontalAlign.Start)
// 数据看板
Row() {
this.StatCard(this.elderlyCount, '位', '负责长者', ('app.color.accent_blue'))
this.StatCard(this.activeCareCount, '项', '待完成', ('app.color.accent_red'))
this.StatCard(this.overdueCount, '项', '已经逾期', '#FF9500')
}
.width('92%')
.height(96)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.padding(8)
// 快捷操作
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(this.actions, (action: QuickAction) => {
this.QuickActionCard(action)
}, (action: QuickAction) => action.key)
}
.width('92%')
// 消息通知
Column({ space: 8 }) {
Row() {
Text('消息通知')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Blank()
Text('查看全部')
.fontSize(13)
.fontColor($r('app.color.accent_blue'))
}
.width('100%')
Column({ space: 8 }) {
if (this.hasAlert) {
Text(this.latestAlert)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text(this.latestAlertTime)
.fontSize(12)
.fontColor($r('app.color.inactive_tab'))
} else {
Text('当前没有新的消息通知')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
.width('92%')
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 30 })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,250 @@
// 护理记录详情页(ArkTS 严格类型,无 any)
// 接口:GET /tasks/nursing-records/{id}/
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { normalizeError } from '../../common/ApiConstants';
import { api } from '../../utils/Request';
import { ProtectedImage } from '../../components/ProtectedImage';
// 结构化块
interface StructBlock {
text?: string;
numeric?: number | null;
unit?: string;
trend?: string | null;
}
interface NursingRecordDetail {
id: number;
elderly: number;
elderly_name: string;
nurse_name: string | null;
raw_text: string;
images: Array<string> | null;
created_at: string;
extracted_json: Record<string, StructBlock> | null;
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return iso || '--';
}
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 RecordDetail {
@State detail: NursingRecordDetail | null = null;
@State loading: boolean = true;
private recordId: string = '';
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
const params = router.getParams() as Record<string, string> | undefined;
if (params) {
this.recordId = params['id'] ?? '';
}
this.loadDetail();
}
private async loadDetail(): Promise<void> {
if (this.recordId.length === 0) {
this.loading = false;
return;
}
try {
this.detail = await api.get<NursingRecordDetail>(`/tasks/nursing-records/${this.recordId}/`);
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
// 提取结构化块文本
private blockText(key: string, label: string): string {
const extracted = this.detail?.extracted_json;
if (!extracted) {
return '';
}
const block = extracted[key];
if (!block || !block.text) {
return '';
}
return `${label}:${block.text}`;
}
@Builder
BlockRow(text: string) {
if (text.length > 0) {
Text(text)
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.width('100%')
}
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 80 })
} else if (!this.detail) {
Text('未找到该记录')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.margin({ top: 80 })
} else {
Scroll(this.scroller) {
Column({ space: 14 }) {
// 头部信息
Column({ space: 6 }) {
Row() {
Text(this.detail.elderly_name)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(formatTime(this.detail.created_at))
.fontSize(13)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
if (this.detail.nurse_name && this.detail.nurse_name.length > 0) {
Text(`护理员:${this.detail.nurse_name}`)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 原始文字
if (this.detail.raw_text && this.detail.raw_text.length > 0) {
Column({ space: 8 }) {
Text('记录内容')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Text(this.detail.raw_text)
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
// 图片
if (this.detail.images && this.detail.images.length > 0) {
Column({ space: 8 }) {
Text('图片')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.detail.images, (img: string) => {
Stack() {
ProtectedImage({ mediaPath: img })
}
.width(96)
.height(96)
.borderRadius(10)
.clip(true)
.margin({ right: 8, bottom: 8 })
}, (img: string) => img)
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
// 结构化解析
if (this.detail.extracted_json) {
Column({ space: 8 }) {
Text('结构化解析')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
this.BlockRow(this.blockText('temperature', '体温'))
this.BlockRow(this.blockText('blood_pressure', '血压'))
this.BlockRow(this.blockText('weight', '体重'))
this.BlockRow(this.blockText('medication', '用药'))
this.BlockRow(this.blockText('diet', '饮食'))
this.BlockRow(this.blockText('mood', '情绪'))
this.BlockRow(this.blockText('other', '其他'))
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
.padding({ top: 60, bottom: 30 })
.alignItems(HorizontalAlign.Center)
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.width('100%')
}
}
.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'))
}
}
@@ -0,0 +1,668 @@
// 护理事件录入(ArkTS 严格类型,无 any)
// 图片:/tasks/upload-images/(multipart)→ 记录:/tasks/nursing-records/
// 结构化解析:/tasks/parse/(raw_text → extracted_json,可编辑后随记录提交)
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { Page, normalizeError } from '../../common/ApiConstants';
import { api, uploadMultipartFiles, RequestError } from '../../utils/Request';
import { AuthStore } from '../../utils/AuthStore';
import { common } from '@kit.AbilityKit';
import { takePhoto, pickOneImage, recognizeTextFromUri } from '../../utils/OcrHelper';
interface ElderlyOption {
id: number;
name: string;
room: string | null;
bed: string | null;
}
interface UploadResponse {
urls: Array<string>;
}
// ── 结构化解析结果类型(对齐后端 normalize_structured_result) ──
interface NumericBlock {
text: string;
numeric: number | null;
unit: string;
trend: string | null;
}
interface BPBlock {
text: string;
systolic: number | null;
diastolic: number | null;
unit: string;
trend: string | null;
}
interface TextBlock {
text: string;
trend: string | null;
}
interface OtherBlock {
text: string;
}
interface PressureUlcerBlock {
body_position: string;
skin_statuses: Array<string>;
pressure_site: string;
protective_aid: string;
shift: string;
recorder_role: string;
wound_area: string;
}
interface PatrolBlock {
completed: boolean;
count: number;
note: string;
}
interface StructuredResult {
record_time: string;
elderly_name: string;
elderly_id_card: string;
temperature: NumericBlock;
blood_pressure: BPBlock;
weight: NumericBlock;
medication: TextBlock;
diet: TextBlock;
mood: TextBlock;
other: OtherBlock;
pressure_ulcer: PressureUlcerBlock;
completed_services: Array<Record<string, string | number | boolean>>;
patrol: PatrolBlock;
}
interface ParseResponse {
extracted_json: StructuredResult;
}
// /tasks/parse/ 请求体
interface ParsePayload {
raw_text: string;
elderly?: number;
}
// /tasks/nursing-records/ 创建请求体
interface RecordCreatePayload {
elderly: number;
raw_text: string;
nurse: number;
images: Array<string>;
extracted_json?: StructuredResult;
}
const MAX_IMAGES = 9;
@Entry
@Component
struct RecordEntry {
@State elderlyOptions: Array<ElderlyOption> = [];
@State selectedElderlyId: number = 0;
@State recordText: string = '';
@State ocrRunning: boolean = false;
@State selectedUris: Array<string> = [];
@State showElderlyPicker: boolean = false;
@State submitting: boolean = false;
@State parsing: boolean = false;
@State loading: boolean = true;
// 结构化解析结果(原始对象,用于提交)与可编辑字段(平铺 @State)
@State parsedOriginal: StructuredResult | null = null;
@State eTime: string = '';
@State eTemp: string = '';
@State eBP: string = '';
@State eWeight: string = '';
@State eMedication: string = '';
@State eDiet: string = '';
@State eMood: string = '';
@State eOther: string = '';
@State ePressureSite: string = '';
private scroller: Scroller = new Scroller();
async aboutToAppear(): Promise<void> {
await this.loadElderly();
}
private async loadElderly(): Promise<void> {
this.loading = true;
try {
const res = await api.get<Page<ElderlyOption>>('/elderly/', { page_size: 100 });
this.elderlyOptions = res.results;
if (this.elderlyOptions.length > 0) {
this.selectedElderlyId = this.elderlyOptions[0].id;
}
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
private selectedElderlyName(): string {
const found = this.elderlyOptions.find((item) => item.id === this.selectedElderlyId);
return found ? found.name : '请选择长者';
}
private pickImages(): void {
if (this.selectedUris.length >= MAX_IMAGES) {
promptAction.showToast({ message: `最多选择 ${MAX_IMAGES} 张图片` });
return;
}
const options = new photoAccessHelper.PhotoSelectOptions();
options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
options.maxSelectNumber = MAX_IMAGES - this.selectedUris.length;
const picker = new photoAccessHelper.PhotoViewPicker();
picker.select(options).then((result: photoAccessHelper.PhotoSelectResult) => {
if (result.photoUris && result.photoUris.length > 0) {
this.selectedUris = this.selectedUris.concat(result.photoUris);
}
}).catch((err: BusinessError) => {
console.error('[RecordEntry] pick images failed:', err.message);
});
}
// AI 结构化解析
// 拍纸质护理单:端侧 OCR 识别文字填入内容框(可编辑后走 AI 结构化解析)
private async runSheetOcr(useCamera: boolean): Promise<void> {
if (this.ocrRunning) {
return;
}
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
const uri = useCamera ? await takePhoto(context) : await pickOneImage(context);
if (uri === null) {
return;
}
this.ocrRunning = true;
try {
const text = await recognizeTextFromUri(uri);
if (text.trim().length === 0) {
promptAction.showToast({ message: '未识别到文字,请光线充足后重拍' });
return;
}
this.recordText = text.length > 800 ? text.substring(0, 800) : text;
promptAction.showToast({ message: '已识别文字,请核对后点击 AI 解析' });
} catch (err) {
console.error('[RecordEntry] sheet ocr failed:', normalizeError(err as BusinessError | Error));
promptAction.showToast({ message: '识别失败,请重试或手动输入' });
} finally {
this.ocrRunning = false;
}
}
// 跨端 OCR 菜单(bindMenu 自定义项)
@Builder
OcrMenu() {
Column() {
Text('📷 拍照识别')
.width('100%')
.padding(12)
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.onClick(() => {
this.runSheetOcr(true);
})
Divider().color($r('app.color.background'))
Text('🖼️ 从相册识别')
.width('100%')
.padding(12)
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.onClick(() => {
this.runSheetOcr(false);
})
}
.width(190)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
}
private async parseText(): Promise<void> {
if (this.recordText.trim().length === 0) {
promptAction.showToast({ message: '请先输入护理内容再解析' });
return;
}
if (this.parsing) {
return;
}
this.parsing = true;
try {
const payload: ParsePayload = { raw_text: this.recordText.trim() };
if (this.selectedElderlyId > 0) {
payload.elderly = this.selectedElderlyId;
}
const res = await api.post<ParseResponse>('/tasks/parse/', payload);
this.parsedOriginal = res.extracted_json;
// 平铺到可编辑字段
this.eTime = res.extracted_json.record_time ?? '';
this.eTemp = res.extracted_json.temperature?.text ?? '';
this.eBP = res.extracted_json.blood_pressure?.text ?? '';
this.eWeight = res.extracted_json.weight?.text ?? '';
this.eMedication = res.extracted_json.medication?.text ?? '';
this.eDiet = res.extracted_json.diet?.text ?? '';
this.eMood = res.extracted_json.mood?.text ?? '';
this.eOther = res.extracted_json.other?.text ?? '';
this.ePressureSite = res.extracted_json.pressure_ulcer?.pressure_site ?? '';
promptAction.showToast({ message: '解析完成,请核对结果' });
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.parsing = false;
}
}
// 用编辑后的平铺字段覆盖原始解析结果
private buildExtracted(): StructuredResult | null {
const original = this.parsedOriginal;
if (!original) {
return null;
}
const copy: StructuredResult = JSON.parse(JSON.stringify(original)) as StructuredResult;
copy.record_time = this.eTime;
if (copy.temperature) {
copy.temperature.text = this.eTemp;
}
if (copy.blood_pressure) {
copy.blood_pressure.text = this.eBP;
}
if (copy.weight) {
copy.weight.text = this.eWeight;
}
if (copy.medication) {
copy.medication.text = this.eMedication;
}
if (copy.diet) {
copy.diet.text = this.eDiet;
}
if (copy.mood) {
copy.mood.text = this.eMood;
}
if (copy.other) {
copy.other.text = this.eOther;
}
if (copy.pressure_ulcer) {
copy.pressure_ulcer.pressure_site = this.ePressureSite;
}
return copy;
}
private async submit(): Promise<void> {
if (this.selectedElderlyId === 0) {
promptAction.showToast({ message: '请选择长者' });
return;
}
if (this.recordText.trim().length === 0) {
promptAction.showToast({ message: '请输入护理内容' });
return;
}
if (this.submitting) {
return;
}
this.submitting = true;
try {
// 1. 先上传图片(如有)
let imagePaths: Array<string> = [];
if (this.selectedUris.length > 0) {
const uploadRes = await uploadMultipartFiles<UploadResponse>(
'/tasks/upload-images/', this.selectedUris, 'images');
imagePaths = uploadRes.urls;
}
// 2. 创建护理记录(带结构化结果,如有)
const state = await AuthStore.getState();
const body: RecordCreatePayload = {
elderly: this.selectedElderlyId,
raw_text: this.recordText.trim(),
nurse: state.userId,
images: imagePaths,
};
const extracted = this.buildExtracted();
if (extracted) {
body.extracted_json = extracted;
}
await api.post('/tasks/nursing-records/', body);
promptAction.showToast({ message: '记录已提交' });
this.recordText = '';
this.selectedUris = [];
this.parsedOriginal = null;
router.back();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.submitting = false;
}
}
@Builder
ElderlyPickerModal() {
Column() {
Text('选择长者')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.margin({ bottom: 12 })
List() {
ForEach(this.elderlyOptions, (item: ElderlyOption) => {
ListItem() {
Row() {
Text(item.name)
.fontSize(15)
.fontColor($r('app.color.text_primary'))
if (item.room) {
Text(` · ${item.room}床` + (item.bed ? `-${item.bed}` : ''))
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.margin({ left: 6 })
}
Blank()
if (item.id === this.selectedElderlyId) {
Text('✓')
.fontSize(16)
.fontColor($r('app.color.accent_blue'))
}
}
.width('100%')
.padding(14)
.onClick(() => {
this.selectedElderlyId = item.id;
this.showElderlyPicker = false;
})
}
}, (item: ElderlyOption) => String(item.id))
}
.layoutWeight(1)
.scrollBar(BarState.Auto)
}
.width('100%')
.padding(20)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.height(400)
}
@Builder
ImageThumb(uri: string, index: number) {
Stack() {
Image(uri)
.width(72)
.height(72)
.borderRadius(10)
.objectFit(ImageFit.Cover)
Text('×')
.fontSize(14)
.fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
.width(20)
.height(20)
.backgroundColor('#CC000000')
.borderRadius(10)
.position({ x: 56, y: -4 })
.onClick(() => {
const next: Array<string> = [];
this.selectedUris.forEach((item: string, idx: number) => {
if (idx !== index) {
next.push(item);
}
});
this.selectedUris = next;
})
}
.width(72)
.height(72)
.margin({ right: 8, top: 4 })
}
@Builder
ParsedField(label: string, value: string, onChange: (v: string) => void) {
Row({ space: 10 }) {
Text(label)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width(64)
TextInput({ text: value })
.fontColor($r('app.color.text_primary'))
.fontSize(14)
.height(38)
.layoutWeight(1)
.padding({ left: 10, right: 10 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(8)
.onChange((v: string) => {
onChange(v);
})
}
.width('100%')
}
@Builder
ParsedCard() {
Column({ space: 10 }) {
Row() {
Text('结构化结果(可编辑)')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text('撤销')
.fontSize(13)
.fontColor($r('app.color.accent_red'))
.onClick(() => {
this.parsedOriginal = null;
})
}
.width('100%')
this.ParsedField('时间', this.eTime, (v: string) => {
this.eTime = v;
})
this.ParsedField('体温', this.eTemp, (v: string) => {
this.eTemp = v;
})
this.ParsedField('血压', this.eBP, (v: string) => {
this.eBP = v;
})
this.ParsedField('体重', this.eWeight, (v: string) => {
this.eWeight = v;
})
this.ParsedField('用药', this.eMedication, (v: string) => {
this.eMedication = v;
})
this.ParsedField('饮食', this.eDiet, (v: string) => {
this.eDiet = v;
})
this.ParsedField('情绪', this.eMood, (v: string) => {
this.eMood = v;
})
this.ParsedField('压疮部位', this.ePressureSite, (v: string) => {
this.ePressureSite = v;
})
this.ParsedField('其他', this.eOther, (v: string) => {
this.eOther = v;
})
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 16 }) {
// 长者选择
Row() {
Text('长者')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
Blank()
Text(this.selectedElderlyName())
.fontSize(15)
.fontColor($r('app.color.text_primary'))
Text(' ›')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ left: 4 })
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.onClick(() => {
this.showElderlyPicker = true;
})
// 记录内容
TextArea({ placeholder: '请输入护理内容,如:体温37.2℃,血压130/85,精神状态良好', text: this.recordText })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.height(120)
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.onChange((value: string) => {
this.recordText = value;
})
// 拍纸质护理单识别:端侧 OCR 文字识别,灌入内容框,再走 AI 结构化解析
Row({ space: 8 }) {
Button(this.ocrRunning ? '识别中...' : '📄 拍护理单识别')
.fontSize(13)
.height(34)
.fontColor($r('app.color.text_primary'))
.backgroundColor(Color.Transparent)
.borderRadius(8)
.enabled(!this.ocrRunning)
.bindMenu(this.OcrMenu)
.accessibilityLevel('yes')
.accessibilityText('拍护理单识别')
.accessibilityDescription('拍照或从相册选择纸质护理单,自动识别文字填入内容框')
if (this.ocrRunning) {
Text('正在识别文字...')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
}
.width('100%')
// AI 解析按钮
Button(this.parsing ? '解析中...' : '✨ AI 结构化解析')
.width('100%')
.height(42)
.fontSize(14)
.fontColor('#FFFFFF')
.backgroundColor('#7C3AED')
.borderRadius(12)
.enabled(!this.parsing)
.onClick(() => {
this.parseText();
})
// 解析结果(可编辑)
if (this.parsedOriginal) {
this.ParsedCard()
}
// 图片区
Column({ space: 8 }) {
Row() {
Text(`图片(${this.selectedUris.length}/${MAX_IMAGES})`)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
Blank()
Button('+ 添加图片')
.fontSize(13)
.height(30)
.fontColor($r('app.color.accent_blue'))
.backgroundColor(Color.Transparent)
.borderRadius(8)
.onClick(() => {
this.pickImages();
})
}
.width('100%')
if (this.selectedUris.length > 0) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.selectedUris, (uri: string, index: number) => {
this.ImageThumb(uri, index)
}, (uri: string) => uri)
}
.width('100%')
}
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
Button(this.submitting ? '提交中...' : '提交记录')
.width('100%')
.height(50)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
.enabled(!this.submitting)
.onClick(() => {
this.submit();
})
}
.width('92%')
.margin({ top: 108, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.width('100%')
.height('100%')
.alignItems(HorizontalAlign.Center)
}
.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'))
.bindSheet($$this.showElderlyPicker, this.ElderlyPickerModal(), {
height: 400,
dragBar: true,
showClose: true,
backgroundColor: $r('app.color.background_card'),
})
}
}
@@ -0,0 +1,398 @@
// 护理员 · 风险巡视(ArkTS 严格类型,无 any)
// 接口:GET /rules/patrol-records/today/
// POST /rules/patrol-records/
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface RiskItem {
key: string;
label: string;
}
interface PatrolRecord {
id: number;
elderly: number;
elderly_name: string;
elderly_room: string;
elderly_bed: string;
scheduled_slot: string;
actual_time: string;
is_on_time: boolean;
remark: string;
}
interface PatrolTodayResponse {
date: string;
time_slots: Array<string>;
risk_items: Array<RiskItem>;
records: Array<PatrolRecord>;
}
interface RiskChecklistPayload {
fall: boolean;
bed_fall: boolean;
choking: boolean;
ingestion: boolean;
scald: boolean;
self_harm: boolean;
wandering: boolean;
pressure_ulcer: boolean;
recreation_accident: boolean;
}
interface SubmitPatrolPayload {
scheduled_slot: string;
elderly: number;
risk_checklist: RiskChecklistPayload;
remark: string;
}
interface ElderlyLite {
id: number;
name: string;
}
interface ElderlyListResponse {
results: Array<ElderlyLite>;
}
function formatTime(iso: string): string {
if (!iso || iso.length === 0) {
return '--';
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return '--';
}
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${hh}:${mi}`;
}
@Entry
@Component
struct RiskPatrol {
@State riskItems: Array<RiskItem> = [];
@State timeSlots: Array<string> = [];
@State records: Array<PatrolRecord> = [];
@State loading: boolean = true;
@State showForm: boolean = false;
@State submitting: boolean = false;
// 表单
@State slot: string = '';
@State elderlyName: string = '';
@State checked: Record<string, boolean> = {};
@State remark: string = '';
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const res = await api.get<PatrolTodayResponse>('/rules/patrol-records/today/');
this.riskItems = res.risk_items;
this.timeSlots = res.time_slots;
this.records = res.records;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
private openForm(): void {
if (this.timeSlots.length === 0) {
promptAction.showToast({ message: '当前无可巡视时段' });
return;
}
this.slot = this.timeSlots[0];
this.elderlyName = '';
this.remark = '';
this.checked = {};
this.showForm = true;
}
private toggleCheck(key: string): void {
const next: Record<string, boolean> = JSON.parse(JSON.stringify(this.checked)) as Record<string, boolean>;
next[key] = !next[key];
this.checked = next;
}
private async submit(): Promise<void> {
if (this.elderlyName.trim().length === 0) {
promptAction.showToast({ message: '请填写长者姓名' });
return;
}
if (this.submitting) {
return;
}
const allTrue = this.riskItems.every((item: RiskItem) => this.checked[item.key] === true);
if (!allTrue) {
promptAction.showToast({ message: '请逐项确认所有风险点' });
return;
}
this.submitting = true;
try {
const elderlyId = await this.resolveElderlyId(this.elderlyName.trim());
if (elderlyId === 0) {
promptAction.showToast({ message: '未找到该长者,请核对姓名' });
return;
}
const checklist: RiskChecklistPayload = {
fall: this.checked['fall'],
bed_fall: this.checked['bed_fall'],
choking: this.checked['choking'],
ingestion: this.checked['ingestion'],
scald: this.checked['scald'],
self_harm: this.checked['self_harm'],
wandering: this.checked['wandering'],
pressure_ulcer: this.checked['pressure_ulcer'],
recreation_accident: this.checked['recreation_accident'],
};
const payload: SubmitPatrolPayload = {
scheduled_slot: this.slot,
elderly: elderlyId,
risk_checklist: checklist,
remark: this.remark.trim(),
};
await api.post('/rules/patrol-records/', payload);
this.showForm = false;
promptAction.showToast({ message: '巡视已提交' });
await this.load();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.submitting = false;
}
}
private async resolveElderlyId(name: string): Promise<number> {
try {
const res = await api.get<ElderlyListResponse>('/elderly/', { page_size: 200 });
const found = res.results.find((item: ElderlyLite) => item.name === name);
return found ? found.id : 0;
} catch (err) {
return 0;
}
}
@Builder
RecordCard(record: PatrolRecord) {
Row({ space: 10 }) {
Text(record.elderly_name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(`${record.elderly_room}${record.elderly_bed ? '-' + record.elderly_bed : ''}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
Blank()
Text(`${record.scheduled_slot} 已巡`)
.fontSize(13)
.fontColor($r('app.color.accent_green'))
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
}
@Builder
SlotChip(slot: string) {
Text(slot)
.fontSize(15)
.fontColor(this.slot === slot ? '#FFFFFF' : $r('app.color.text_primary'))
.padding({ left: 18, right: 18, top: 8, bottom: 8 })
.backgroundColor(this.slot === slot ? $r('app.color.accent_blue') : $r('app.color.background'))
.borderRadius(16)
.onClick(() => {
this.slot = slot;
})
}
@Builder
CheckRow(item: RiskItem) {
Row({ space: 10 }) {
Text(item.label)
.fontSize(16)
.fontColor($r('app.color.text_primary'))
Blank()
Text(this.checked[item.key] ? '✓ 正常' : '○')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(this.checked[item.key] ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
}
.width('100%')
.padding(14)
.backgroundColor(this.checked[item.key] ? '#1400C853' : $r('app.color.background'))
.borderRadius(10)
.onClick(() => {
this.toggleCheck(item.key);
})
}
@Builder
PatrolFormSheet() {
Column({ space: 12 }) {
Text('风险巡视登记')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Row({ space: 10 }) {
ForEach(this.timeSlots, (slot: string) => {
this.SlotChip(slot)
}, (slot: string) => slot)
}
.width('100%')
TextInput({ placeholder: '长者姓名', text: this.elderlyName })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.fontSize(16)
.height(46)
.padding({ left: 14, right: 14 })
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.onChange((value: string) => {
this.elderlyName = value;
})
Text('逐项确认(全部正常方可提交)')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width('100%')
Scroll() {
Column({ space: 8 }) {
ForEach(this.riskItems, (item: RiskItem) => {
Column() {
this.CheckRow(item)
}
.width('100%')
}, (item: RiskItem) => item.key)
}
.width('100%')
}
.layoutWeight(1)
.scrollBar(BarState.Auto)
TextInput({ placeholder: '备注(可选)', text: this.remark })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.fontSize(14)
.height(42)
.padding({ left: 14, right: 14 })
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.onChange((value: string) => {
this.remark = value;
})
Button(this.submitting ? '提交中...' : '提交巡视')
.width('100%')
.height(48)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_green'))
.borderRadius(12)
.enabled(!this.submitting)
.onClick(() => {
this.submit();
})
}
.width('100%')
.padding(20)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else if (this.records.length === 0) {
Column({ space: 8 }) {
Text('今日暂无巡视记录')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text('点击右上角"登记"开始风险巡视')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding(24)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Center)
} else {
ForEach(this.records, (record: PatrolRecord) => {
Column() {
this.RecordCard(record)
}
.width('100%')
}, (record: PatrolRecord) => String(record.id))
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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'))
.bindSheet($$this.showForm, this.PatrolFormSheet(), {
height: 560,
dragBar: true,
showClose: true,
backgroundColor: $r('app.color.background_card'),
})
}
}
@@ -0,0 +1,290 @@
// 护理员 · 周身皮肤/压疮检查(ArkTS 严格类型,无 any)
// 老人列表:GET /elderly/ ;保存:POST /tasks/nursing-records/
import { router, 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, RequestError } from '../../utils/Request';
import { AuthStore } from '../../utils/AuthStore';
interface ElderlyMini {
id: number;
name: string;
room: string;
bed: string;
}
interface PressureUlcerData {
body_position: string;
skin_statuses: Array<string>;
pressure_site: string;
protective_aid: string;
shift: string;
recorder_role: string;
wound_area: string;
}
interface SkinCheckExtracted {
pressure_ulcer: PressureUlcerData;
}
interface SkinCheckPayload {
elderly: number;
nurse: number;
raw_text: string;
extracted_json: SkinCheckExtracted;
images: Array<string>;
}
@Entry
@Component
struct SkinCheck {
@State elderlyList: Array<ElderlyMini> = [];
@State loading: boolean = true;
@State saving: boolean = false;
@State selectedId: number = 0;
@State shift: string = 'day';
@State detailText: string = '';
@State woundArea: string = '';
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.loadElderly();
}
private async loadElderly(): Promise<void> {
this.loading = true;
try {
const res = await api.get<Page<ElderlyMini>>('/elderly/', { page_size: 200 });
this.elderlyList = res.results;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
private shiftLabel(): string {
return this.shift === 'day' ? '白班' : '夜班';
}
private async submit(): Promise<void> {
if (this.selectedId === 0) {
promptAction.showToast({ message: '请选择老人' });
return;
}
if (this.detailText.trim().length === 0) {
promptAction.showToast({ message: '请输入破损部位' });
return;
}
if (this.saving) {
return;
}
this.saving = true;
try {
const state = await AuthStore.getState();
const pu: PressureUlcerData = {
body_position: this.detailText.trim(),
skin_statuses: [],
pressure_site: this.detailText.trim(),
protective_aid: '',
shift: this.shift,
recorder_role: 'nurse',
wound_area: this.woundArea.trim(),
};
const extracted: SkinCheckExtracted = { pressure_ulcer: pu };
const body: SkinCheckPayload = {
elderly: this.selectedId,
nurse: state.userId,
raw_text: `${this.shiftLabel()}周身皮肤检查:${this.detailText.trim()}`,
extracted_json: extracted,
images: [],
};
await api.post('/tasks/nursing-records/', body);
promptAction.showToast({ message: '皮肤检查已保存' });
router.back();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.saving = false;
}
}
@Builder
ElderlyOption(item: ElderlyMini) {
Row({ space: 10 }) {
Text(item.name)
.fontSize(15)
.fontWeight(this.selectedId === item.id ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.selectedId === item.id ? '#007AFF' : $r('app.color.text_primary'))
Text(`${item.room || ''}${item.bed ? ' ' + item.bed : ''}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Blank()
if (this.selectedId === item.id) {
Text('✓')
.fontSize(16)
.fontColor('#007AFF')
}
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.backgroundColor(this.selectedId === item.id ? '#1A007AFF' : $r('app.color.background_card'))
.borderRadius(10)
.onClick(() => {
this.selectedId = item.id;
})
}
build() {
HdsNavigation() {
Scroll(this.scroller) {
Column({ space: 16 }) {
// 护理对象
Column({ space: 8 }) {
Text('护理对象')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.loading) {
Text('正在加载老人列表...')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.padding(16)
} else if (this.elderlyList.length === 0) {
Text('暂无老人')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.padding(16)
} else {
ForEach(this.elderlyList, (item: ElderlyMini) => {
this.ElderlyOption(item)
}, (item: ElderlyMini) => String(item.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 班次
Column({ space: 8 }) {
Text('班次')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Row({ space: 10 }) {
Text('白班')
.fontSize(15)
.fontColor(this.shift === 'day' ? '#FFFFFF' : $r('app.color.text_primary'))
.padding({ left: 20, right: 20, top: 8, bottom: 8 })
.backgroundColor(this.shift === 'day' ? '#007AFF' : $r('app.color.background_card'))
.borderRadius(10)
.onClick(() => {
this.shift = 'day';
})
Text('夜班')
.fontSize(15)
.fontColor(this.shift === 'night' ? '#FFFFFF' : $r('app.color.text_primary'))
.padding({ left: 20, right: 20, top: 8, bottom: 8 })
.backgroundColor(this.shift === 'night' ? '#007AFF' : $r('app.color.background_card'))
.borderRadius(10)
.onClick(() => {
this.shift = 'night';
})
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 破损部位
Column({ space: 8 }) {
Text('破损部位及状况')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
TextArea({
placeholder: '描述破损部位及皮肤状况,如:右踝外侧压疮,皮肤发红',
text: this.detailText,
})
.width('100%')
.height(100)
.backgroundColor($r('app.color.background_card'))
.borderRadius(10)
.onChange((value: string) => {
this.detailText = value;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 创面面积
Column({ space: 8 }) {
Text('创面面积(可选)')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
TextInput({ placeholder: '如:2cm × 3cm', text: this.woundArea })
.width('100%')
.backgroundColor($r('app.color.background_card'))
.borderRadius(10)
.onChange((value: string) => {
this.woundArea = value;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
Button(this.saving ? '保存中...' : '保存皮肤检查')
.width('100%')
.height(48)
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor('#007AFF')
.borderRadius(12)
.enabled(!this.saving)
.onClick(() => {
this.submit();
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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,
})
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,595 @@
// 护理员 · 护理中心/工作中心(ArkTS 严格类型,无 any)
// 接口:GET /tasks/work-center/?view=current
// 写操作 /tasks/work-items/{pk}/claim|release|complete-duty|mark-not-needed/
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, EmptyBody, RequestError } from '../../utils/Request';
import { publishLocalNotify } from '../../utils/LocalNotify';
interface CareItemMetadata {
on_demand?: boolean;
}
interface WorkItem {
id: number;
work_type: string;
title: string;
service_name: string;
elderly: number | null;
elderly_name: string;
floor: number | null;
room: string;
bed: string;
scope_type: string;
coverage_risk: boolean;
scheduled_start: string;
scheduled_end: string;
status: string;
display_status: string;
time_bucket: string;
claimed_by: number | null;
claimed_by_name: string;
claim_expires_at: string | null;
completed_by_name: string;
completed_at: string | null;
outcome: string;
assigned_nurse_name: string;
metadata: CareItemMetadata;
}
interface CareSummary {
current: number;
overdue: number;
upcoming: number;
completed: number;
}
interface CompleteDutyPayload {
note: string;
images: Array<string>;
}
interface WorkCenterResponse {
current_shift: object | null;
schedule_status: string;
summary: CareSummary;
items: Array<WorkItem>;
}
function workTypeLabel(type: string): string {
if (type === 'service') {
return '护理';
}
if (type === 'risk_patrol') {
return '巡视';
}
if (type === 'medication') {
return '给药';
}
if (type === 'day_duty') {
return '日值';
}
return '任务';
}
function workTypeColor(type: string): string {
if (type === 'service') {
return '#007AFF';
}
if (type === 'risk_patrol') {
return '#FF9500';
}
if (type === 'medication') {
return '#34C759';
}
if (type === 'day_duty') {
return '#AF52DE';
}
return '#8E8E93';
}
function workTypeBg(type: string): string {
if (type === 'service') {
return '#33007AFF';
}
if (type === 'risk_patrol') {
return '#33FF9500';
}
if (type === 'medication') {
return '#3334C759';
}
if (type === 'day_duty') {
return '#33AF52DE';
}
return '#338E8E93';
}
function statusLabel(status: string): string {
if (status === 'pending') {
return '待护理';
}
if (status === 'in_progress') {
return '护理中';
}
if (status === 'completed') {
return '已完成';
}
if (status === 'resolved') {
return '已处置';
}
if (status === 'cancelled') {
return '已取消';
}
if (status === 'overdue') {
return '已逾期';
}
return status;
}
function statusColor(status: string): string {
if (status === 'overdue') {
return '#FF3B30';
}
if (status === 'in_progress') {
return '#007AFF';
}
if (status === 'completed' || status === 'resolved') {
return '#34C759';
}
return '#8E8E93';
}
function formatTime(iso: string): string {
if (!iso || iso.length === 0) {
return '--';
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return '--';
}
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${hh}:${mi}`;
}
function isOnDemand(item: WorkItem): boolean {
return item.metadata.on_demand === true;
}
@Entry
@Component
struct WorkCenter {
@State items: Array<WorkItem> = [];
@State scheduleStatus: string = 'unassigned';
@State summary: CareSummary = { current: 0, overdue: 0, upcoming: 0, completed: 0 };
@State loading: boolean = true;
@State operatingId: number = 0;
// 完成日值备注
@State showDutySheet: boolean = false;
@State dutyTargetId: number = 0;
@State dutyNote: string = '';
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const res = await api.get<WorkCenterResponse>('/tasks/work-center/', { view: 'current' });
this.scheduleStatus = res.schedule_status;
this.summary = res.summary;
this.items = res.items;
// 逾期工作项:发本地通知栏提醒(每天最多一条,避免打扰)
if (res.summary.overdue > 0) {
const today = new Date();
const dayKey = `${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}`;
if (AppStorage.get<string>('overdueNotifyDay') !== dayKey) {
AppStorage.setOrCreate<string>('overdueNotifyDay', dayKey);
publishLocalNotify(Date.now() % 100000, '逾期提醒', `您有 ${res.summary.overdue} 项护理工作已逾期,请尽快处理`);
}
}
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
private async claim(item: WorkItem): Promise<void> {
if (this.operatingId !== 0) {
return;
}
this.operatingId = item.id;
try {
await api.post(`/tasks/work-items/${item.id}/claim/`, new EmptyBody());
promptAction.showToast({ message: '已认领,请在 30 分钟内完成' });
await this.load();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.operatingId = 0;
}
}
private async release(item: WorkItem): Promise<void> {
if (this.operatingId !== 0) {
return;
}
this.operatingId = item.id;
try {
await api.post(`/tasks/work-items/${item.id}/release/`, new EmptyBody());
promptAction.showToast({ message: '已释放' });
await this.load();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.operatingId = 0;
}
}
private async markNotNeeded(item: WorkItem): Promise<void> {
if (this.operatingId !== 0) {
return;
}
this.operatingId = item.id;
try {
await api.post(`/tasks/work-items/${item.id}/mark-not-needed/`, new EmptyBody());
promptAction.showToast({ message: '已标记今日无需' });
await this.load();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.operatingId = 0;
}
}
private openDutyComplete(item: WorkItem): void {
this.dutyTargetId = item.id;
this.dutyNote = '';
this.showDutySheet = true;
}
private async submitDutyComplete(): Promise<void> {
if (this.operatingId !== 0) {
return;
}
this.operatingId = this.dutyTargetId;
try {
const body: CompleteDutyPayload = {
note: this.dutyNote.trim(),
images: [],
};
await api.post(`/tasks/work-items/${this.dutyTargetId}/complete-duty/`, body);
this.showDutySheet = false;
promptAction.showToast({ message: '日值职责已完成' });
await this.load();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.operatingId = 0;
}
}
// 当前项可执行的操作:0 无 / 1 认领 / 2 完成日值 / 3 标记无需 / 4 释放
private actionType(item: WorkItem): number {
if (item.status === 'completed' || item.status === 'resolved' || item.status === 'cancelled') {
return 0;
}
const mine = item.claimed_by !== null;
if (!mine) {
return 1;
}
if (item.work_type === 'day_duty') {
return 2;
}
if (item.work_type === 'service' && isOnDemand(item)) {
return 3;
}
return 4;
}
@Builder
WorkItemCard(item: WorkItem) {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text(workTypeLabel(item.work_type))
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(workTypeColor(item.work_type))
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.backgroundColor(workTypeBg(item.work_type))
.borderRadius(8)
Text(item.title)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(statusLabel(item.display_status))
.fontSize(12)
.fontColor(statusColor(item.display_status))
}
.width('100%')
Row({ space: 12 }) {
if (item.elderly_name && item.elderly_name.length > 0) {
Text(item.elderly_name)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
if (item.room && item.room.length > 0) {
Text(`${item.room}${item.bed ? '-' + item.bed : ''}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
Blank()
Text(`${formatTime(item.scheduled_start)}-${formatTime(item.scheduled_end)}`)
.fontSize(13)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
if (item.claimed_by_name && item.claimed_by_name.length > 0) {
Text(`执行人:${item.claimed_by_name}`)
.fontSize(12)
.fontColor($r('app.color.accent_blue'))
.width('100%')
}
if (this.actionType(item) > 0) {
Row({ space: 10 }) {
if (this.actionType(item) === 1) {
Button(this.operatingId === item.id ? '处理中...' : '认领')
.height(34)
.fontSize(14)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(10)
.layoutWeight(1)
.enabled(this.operatingId === 0)
.onClick(() => {
this.claim(item);
})
} else if (this.actionType(item) === 2) {
Button(this.operatingId === item.id ? '处理中...' : '完成日值')
.height(34)
.fontSize(14)
.fontColor('#FFFFFF')
.backgroundColor('#AF52DE')
.borderRadius(10)
.layoutWeight(1)
.enabled(this.operatingId === 0)
.onClick(() => {
this.openDutyComplete(item);
})
Button('释放')
.height(34)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.layoutWeight(1)
.enabled(this.operatingId === 0)
.onClick(() => {
this.release(item);
})
} else if (this.actionType(item) === 3) {
Button(this.operatingId === item.id ? '处理中...' : '今日无需')
.height(34)
.fontSize(14)
.fontColor('#FFFFFF')
.backgroundColor('#FF9500')
.borderRadius(10)
.layoutWeight(1)
.enabled(this.operatingId === 0)
.onClick(() => {
this.markNotNeeded(item);
})
Button('释放')
.height(34)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.layoutWeight(1)
.enabled(this.operatingId === 0)
.onClick(() => {
this.release(item);
})
} else if (this.actionType(item) === 4) {
Button(this.operatingId === item.id ? '处理中...' : '释放')
.height(34)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.layoutWeight(1)
.enabled(this.operatingId === 0)
.onClick(() => {
this.release(item);
})
Text('护理类任务请通过"事件录入"完成')
.fontSize(11)
.fontColor($r('app.color.inactive_tab'))
.layoutWeight(1)
}
}
.width('100%')
}
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
@Builder
DutyCompleteSheet() {
Column({ space: 12 }) {
Text('完成日值职责')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
TextArea({ placeholder: '交接备注(可选)', text: this.dutyNote })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.height(120)
.padding(14)
.backgroundColor($r('app.color.background'))
.borderRadius(12)
.onChange((value: string) => {
this.dutyNote = value;
})
Button(this.operatingId === this.dutyTargetId ? '提交中...' : '确认完成')
.width('100%')
.height(48)
.fontColor('#FFFFFF')
.backgroundColor('#AF52DE')
.borderRadius(12)
.enabled(this.operatingId === 0)
.onClick(() => {
this.submitDutyComplete();
})
}
.width('100%')
.padding(20)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
// 待办计数
Row({ space: 10 }) {
Column({ space: 4 }) {
Text(String(this.summary.current))
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.accent_blue'))
Text('进行中')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
Column({ space: 4 }) {
Text(String(this.summary.overdue))
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#FF3B30')
Text('已逾期')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
Column({ space: 4 }) {
Text(String(this.summary.upcoming))
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#FF9500')
Text('待开始')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
Column({ space: 4 }) {
Text(String(this.summary.completed))
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.accent_green'))
Text('已完成')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else if (this.items.length === 0) {
Text(this.scheduleStatus === 'unassigned' ?
'当前无排班,无待办工作项' : '当前没有待办工作项')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(24)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.textAlign(TextAlign.Center)
} else {
ForEach(this.items, (item: WorkItem) => {
Column() {
this.WorkItemCard(item)
}
.width('100%')
}, (item: WorkItem) => String(item.id))
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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'))
.bindSheet($$this.showDutySheet, this.DutyCompleteSheet(), {
height: 280,
dragBar: true,
showClose: true,
backgroundColor: $r('app.color.background_card'),
})
}
}
+133
View File
@@ -0,0 +1,133 @@
// 关于应用(ArkTS 严格类型,无 any)
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
@Entry
@Component
struct About {
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scroller: Scroller = new Scroller();
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 16 }) {
// Logo
Column({ space: 10 }) {
Column() {
Text('养')
.fontSize(44)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(88)
.height(88)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(24)
Text('数智护老')
.fontSize(Math.round(24 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text('SmartSenior · 智慧养老')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
Text('版本 1.0.0')
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
.padding({ top: 20 })
.alignItems(HorizontalAlign.Center)
// 说明
Column({ space: 8 }) {
Text('关于本应用')
.fontSize(Math.round(17 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Text('数智护老是面向养老机构的智慧照护客户端,为护理员、护士、家属、老人及管理人员提供护理记录、健康预警、订餐服务、智能助手等功能,帮助提升照护质量与管理效率。')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.lineHeight(Math.round(24 * this.scaleFor()))
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
// 免责声明
Column({ space: 8 }) {
Text('免责声明')
.fontSize(Math.round(17 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Text('本应用内由 AI 生成的内容仅供参考,不构成医疗建议。老人的健康与照护请以医护人员专业判断为准。')
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.lineHeight(Math.round(22 * this.scaleFor()))
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
Text('© 2026 SmartSenior鸿蒙版.CAPTAIN 1275 All rights reserved.')
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
.margin({ bottom: 20 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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'))
}
}
@@ -0,0 +1,421 @@
// 意见反馈页(ArkTS 严格类型,无 any)
// 接口:POST /feedback/ {content, contact, images, diagnostics}
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { Page, normalizeError } from '../../common/ApiConstants';
import { api, uploadMultipartFiles, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface UploadResponse {
urls: Array<string>;
}
// 反馈记录
interface FeedbackRecordItem {
id: number;
content: string;
status: string;
status_label: string;
created_at: string;
handler_name: string | null;
resolved_at: string | null;
}
// 反馈请求体
interface FeedbackPayload {
content: string;
contact?: string;
images?: Array<string>;
diagnostics?: DiagnosticsPayload;
}
// 诊断信息(问题类型)
interface DiagnosticsPayload {
device_model: string;
system_version: string;
app_version: string;
}
const MAX_IMAGES = 6;
function formatRecordTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return iso || '--';
}
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 Feedback {
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
@State content: string = '';
@State contact: string = '';
@State selectedUris: Array<string> = [];
@State submitting: boolean = false;
@State records: Array<FeedbackRecordItem> = [];
@State recordsLoading: boolean = true;
@State showRecords: boolean = false;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.loadRecords();
}
private async loadRecords(): Promise<void> {
this.recordsLoading = true;
try {
const res = await api.get<Page<FeedbackRecordItem>>('/feedback/records/', {
page_size: 20,
});
this.records = res.results;
this.showRecords = true;
} catch (err) {
// 无查看权限(如家属):隐藏记录区,不打扰
console.warn('[Feedback] load records failed:', normalizeError(err as BusinessError | Error));
this.showRecords = false;
} finally {
this.recordsLoading = false;
}
}
private pickImages(): void {
if (this.selectedUris.length >= MAX_IMAGES) {
promptAction.showToast({ message: `最多选择 ${MAX_IMAGES} 张图片` });
return;
}
const options = new photoAccessHelper.PhotoSelectOptions();
options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
options.maxSelectNumber = MAX_IMAGES - this.selectedUris.length;
const picker = new photoAccessHelper.PhotoViewPicker();
picker.select(options).then((result: photoAccessHelper.PhotoSelectResult) => {
if (result.photoUris && result.photoUris.length > 0) {
this.selectedUris = this.selectedUris.concat(result.photoUris);
}
}).catch((err: BusinessError) => {
console.error('[Feedback] pick images failed:', err.message);
});
}
private async submit(): Promise<void> {
if (this.content.trim().length === 0) {
promptAction.showToast({ message: '请输入反馈内容' });
return;
}
if (this.submitting) {
return;
}
this.submitting = true;
try {
let imagePaths: Array<string> = [];
if (this.selectedUris.length > 0) {
try {
const uploadRes = await uploadMultipartFiles<UploadResponse>(
'/feedback/upload-images/', this.selectedUris, 'images');
imagePaths = uploadRes.urls;
} catch (uploadErr) {
// 图片上传失败(如当前账号不支持图片):提示但跳过,仍可提交文字反馈
console.warn('[Feedback] upload images failed:', normalizeError(uploadErr as BusinessError | Error));
promptAction.showToast({ message: '图片上传失败,将仅提交文字反馈' });
this.selectedUris = [];
}
}
const diagnostics: DiagnosticsPayload = {
device_model: 'HarmonyOS',
system_version: '',
app_version: '1.0.0',
};
const body: FeedbackPayload = { content: this.content.trim() };
if (this.contact.trim().length > 0) {
body.contact = this.contact.trim();
}
if (imagePaths.length > 0) {
body.images = imagePaths;
}
body.diagnostics = diagnostics;
await api.post('/feedback/records/', body);
promptAction.showToast({ message: '反馈已提交,感谢您的建议' });
this.content = '';
this.contact = '';
this.selectedUris = [];
await this.loadRecords();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.submitting = false;
}
}
// 状态颜色
private statusColor(status: string): ResourceColor {
if (status === 'pending') {
return '#FF9500';
}
if (status === 'processing') {
return $r('app.color.accent_blue');
}
return $r('app.color.accent_green');
}
@Builder
FeedbackRecordCard(item: FeedbackRecordItem) {
Column({ space: 8 }) {
Row() {
Text(item.status_label || '待处理')
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor('#FFFFFF')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(this.statusColor(item.status))
.borderRadius(8)
Blank()
Text(formatRecordTime(item.created_at))
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
Text(item.content)
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
if (item.handler_name && item.handler_name.length > 0) {
Text(`处理人:${item.handler_name}`)
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
@Builder
ImageThumb(uri: string, index: number) {
Stack() {
Image(uri)
.width(72)
.height(72)
.borderRadius(10)
.objectFit(ImageFit.Cover)
Text('×')
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor('#FFFFFF')
.textAlign(TextAlign.Center)
.width(20)
.height(20)
.backgroundColor('#CC000000')
.borderRadius(10)
.position({ x: 56, y: -4 })
.onClick(() => {
const next: Array<string> = [];
this.selectedUris.forEach((item: string, idx: number) => {
if (idx !== index) {
next.push(item);
}
});
this.selectedUris = next;
})
}
.width(72)
.height(72)
.margin({ right: 8, top: 4 })
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 16 }) {
// 反馈内容
Column({ space: 8 }) {
Text('反馈内容 *')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.width('100%')
TextArea({ placeholder: '请描述您遇到的问题或建议', text: this.content })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.height(140)
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.onChange((value: string) => {
this.content = value;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 联系方式
Column({ space: 8 }) {
Text('联系方式')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.width('100%')
TextInput({ placeholder: '选填:手机号或微信号', text: this.contact })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.height(46)
.padding({ left: 14, right: 14 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.onChange((value: string) => {
this.contact = value;
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 图片
Column({ space: 8 }) {
Row() {
Text(`反馈图片(${this.selectedUris.length}/${MAX_IMAGES})`)
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
Blank()
Button('+ 添加')
.fontSize(Math.round(13 * this.scaleFor()))
.height(30)
.fontColor($r('app.color.accent_blue'))
.backgroundColor(Color.Transparent)
.borderRadius(8)
.onClick(() => {
this.pickImages();
})
}
.width('100%')
if (this.selectedUris.length > 0) {
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.selectedUris, (uri: string, index: number) => {
this.ImageThumb(uri, index)
}, (uri: string) => uri)
}
.width('100%')
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 提交
Button(this.submitting ? '提交中...' : '提交反馈')
.width('100%')
.height(50)
.fontSize(Math.round(16 * this.scaleFor()))
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
.enabled(!this.submitting)
.onClick(() => {
this.submit();
})
// 我的反馈记录(仅管理员有查看权限)
if (this.showRecords) {
Column({ space: 12 }) {
Row() {
Text('我的反馈记录')
.fontSize(Math.round(18 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text('刷新')
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.accent_blue'))
.onClick(() => {
this.loadRecords();
})
}
.width('100%')
if (this.recordsLoading) {
Text('加载中...')
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
.width('100%')
} else if (this.records.length === 0) {
Column({ space: 6 }) {
Text('暂无反馈记录')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding(20)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Center)
} else {
ForEach(this.records, (item: FeedbackRecordItem) => {
Column() {
this.FeedbackRecordCard(item)
}
.width('100%')
.margin({ bottom: 10 })
}, (item: FeedbackRecordItem) => String(item.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
.width('92%')
.padding({ top: 60, bottom: 30 })
.alignItems(HorizontalAlign.Center)
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.width('100%')
}
.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'))
}
}
@@ -0,0 +1,263 @@
// 我的页签(对齐小程序 profile:用户信息 + 菜单 + 退出登录,ArkTS 严格类型,无 any)
import { router, promptAction, uiMaterial } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { pasteboard } from '@kit.BasicServicesKit';
import { UnifiedRole, ROLE_NAME_MAP } from '../../common/ApiConstants';
import { AuthStore } from '../../utils/AuthStore';
import { PushManager } from '../../utils/PushManager';
import { FontScaleLevel, FontScaleOption, FONT_SCALE_LABELS, loadFontScale, setFontScale } from '../../utils/FontScale';
interface ProfileMenu {
key: string;
title: string;
desc: string;
route: string; // 空字符串表示未实现
}
@Component
export struct ProfileTab {
@State userName: string = '';
@State role: UnifiedRole | null = null;
@State isLoggedIn: boolean = false;
@State pushToken: string = '';
@State fontScale: FontScaleLevel = 'system';
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
menus: Array<ProfileMenu> = [
{ key: 'notifications', title: '消息通知', desc: '系统与预警通知', route: 'pages/notifications/Notifications' },
{ key: 'records', title: '我的记录', desc: '我提交过的护理记录', route: 'pages/nurse/History' },
{ key: 'feedback', title: '意见反馈', desc: '提交使用建议', route: 'pages/profile/Feedback' },
{ key: 'about', title: '关于应用', desc: '版本与说明', route: 'pages/profile/About' },
];
// 按角色过滤"我的"菜单:消防的记录是巡更记录,销售无记录入口
private visibleMenus(): Array<ProfileMenu> {
const role = this.role;
if (role === 'fire_control') {
return this.menus.map((m: ProfileMenu): ProfileMenu => {
if (m.key === 'records') {
return { key: 'records_fire', title: '我的巡更记录', desc: '我提交过的巡更打卡', route: 'pages/fire-control/FirePatrolHome' } as ProfileMenu;
}
return m;
});
}
if (role === 'sales') {
return this.menus.filter((m: ProfileMenu): boolean => m.key !== 'records');
}
return this.menus;
}
aboutToAppear(): void {
this.loadAuth();
}
private async loadAuth(): Promise<void> {
const state = await AuthStore.getState();
this.userName = state.userName;
this.role = state.role;
this.isLoggedIn = state.token.length > 0;
this.pushToken = PushManager.getToken();
loadFontScale(getContext(this) as common.UIAbilityContext).then((level: FontScaleLevel) => {
this.fontScale = level;
});
}
private changeFontScale(level: FontScaleLevel): void {
this.fontScale = level;
setFontScale(getContext(this) as common.UIAbilityContext, level);
const label = FONT_SCALE_LABELS.find((item: FontScaleOption) => item.level === level)?.label ?? '';
promptAction.showToast({ message: `字体已调整为${label}` });
}
private copyPushToken(): void {
if (this.pushToken.length === 0) {
promptAction.showToast({ message: '推送令牌为空(未获取成功)' });
return;
}
const data = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, this.pushToken);
pasteboard.getSystemPasteboard().setData(data).then(() => {
promptAction.showToast({ message: '推送令牌已复制' });
}).catch(() => {
promptAction.showToast({ message: '复制失败' });
});
}
private onMenuClick(menu: ProfileMenu): void {
if (menu.route.length === 0) {
promptAction.showToast({ message: '功能开发中' });
return;
}
router.pushUrl({ url: menu.route }).catch(() => {});
}
private async logout(): Promise<void> {
await AuthStore.logout();
this.isLoggedIn = false;
promptAction.showToast({ message: '已退出登录' });
router.replaceUrl({ url: 'pages/Login' }).catch(() => {});
}
private roleName(): string {
if (!this.isLoggedIn) {
return '未登录';
}
return this.role ? (ROLE_NAME_MAP[this.role] ?? '员工') : '员工';
}
@Builder
MenuRow(menu: ProfileMenu) {
Row({ space: 12 }) {
Column({ space: 4 }) {
Text(menu.title)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text(menu.desc)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('›')
.fontSize(22)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
.padding(16)
.backgroundColor(Color.Transparent)
.backgroundBlurStyle(BlurStyle.Thin)
.borderRadius(14)
.onClick(() => {
this.onMenuClick(menu);
})
}
build() {
Scroll() {
Column({ space: 16 }) {
// 用户信息卡
Row({ space: 14 }) {
Stack() {
Circle()
.width(60)
.height(60)
.fill(this.isLoggedIn ? $r('app.color.accent_blue') : $r('app.color.inactive_tab'))
Text(this.isLoggedIn && this.userName.length > 0 ? this.userName.substring(0, 1) : '未')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(60)
.height(60)
Column({ space: 4 }) {
Text(this.isLoggedIn ? this.userName : '未登录')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(this.roleName())
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('92%')
.padding(20)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.margin({ top: 24 })
// 推送令牌(调试用)
Column({ space: 6 }) {
Text('推送令牌(点按复制)')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
Text(this.pushToken.length > 0 ? this.pushToken : '未获取到推送令牌')
.fontSize(11)
.fontColor($r('app.color.text_primary'))
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
.width('92%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
.onClick(() => {
this.copyPushToken();
})
// 字体大小(家属)
if (this.role === 'family') {
Column({ space: 8 }) {
Text('字体大小')
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.width('100%')
Flex({ wrap: FlexWrap.Wrap, alignItems: ItemAlign.Center }) {
ForEach(FONT_SCALE_LABELS, (item: FontScaleOption) => {
Text(item.label)
.fontSize(15)
.fontColor(this.fontScale === item.level ? '#FFFFFF' : $r('app.color.text_primary'))
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
.backgroundColor(this.fontScale === item.level ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(10)
.margin({ right: 10, bottom: 10 })
.onClick(() => {
this.changeFontScale(item.level);
})
}, (item: FontScaleOption) => item.level)
}
.width('100%')
}
.width('92%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
// 菜单列表
Column({ space: 8 }) {
ForEach(this.visibleMenus(), (menu: ProfileMenu) => {
this.MenuRow(menu)
}, (menu: ProfileMenu) => menu.key)
}
.width('92%')
// 登录/退出
if (this.isLoggedIn) {
Button('退出登录')
.width('92%')
.height(48)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_red'))
.borderRadius(12)
.onClick(() => {
this.logout();
})
} else {
Button('去登录')
.width('92%')
.height(48)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
.onClick(() => {
router.pushUrl({ url: 'pages/Login' }).catch(() => {});
})
}
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ bottom: 120 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.width('100%')
.height('100%')
}
}
@@ -0,0 +1,994 @@
// 销售 · 工作台(ArkTS 严格类型,无 any)
// 接口:GET /market/leads/、/market/contracts/;PATCH /market/leads/{id}/;POST /market/leads/
import { promptAction, router } 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, RequestError } from '../../utils/Request';
interface SalesLead {
id: number;
name: string;
gender: string;
age: number | null;
contact_method: string;
status: string;
deal_amount: number | null;
current_sales_name: string;
latest_note: string;
created_at: string;
}
interface Contract {
id: number;
customer_name: string;
sales_name: string;
contract_amount: number | null;
contract_code: string;
contract_date_range: string;
signed_date: string;
}
interface FollowPayload {
status: string;
latest_note?: string;
deal_amount?: string;
}
interface NewLeadPayload {
name: string;
visit_date: string;
gender: string;
age: number | null;
contact_method: string;
id_card_number: string;
original_follower: string;
is_elderly_consultation: boolean;
is_key_person: boolean | null;
information_sources: Array<string>;
status: string;
deal_amount: string | null;
latest_note: string;
last_follow_up_at: string;
}
const GENDER_OPTIONS: Array<string> = ['unknown', 'male', 'female'];
const KEY_PERSON_OPTIONS: Array<string> = ['', 'yes', 'no'];
const SOURCE_OPTIONS: Array<string> = ['美团', '活动', '摊位', '单张', '路过', '朋友', '小红书', '户外广告', '其他'];
const LEAD_STATUS_OPTIONS: Array<string> = ['pending', 'in_progress', 'won', 'lost'];
function statusLabel(status: string): string {
if (status === 'won') {
return '成交';
}
if (status === 'in_progress') {
return '跟进中';
}
if (status === 'lost') {
return '失败';
}
return '待跟进';
}
function statusColor(status: string): string {
if (status === 'won') {
return '#34C759';
}
if (status === 'in_progress') {
return '#007AFF';
}
if (status === 'lost') {
return '#8E8E93';
}
return '#FF9500';
}
function formatDate(iso: string): string {
if (!iso) {
return '--';
}
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');
return `${mm}/${dd}`;
}
function amountText(a: number | null): string {
return formatAmount(a);
}
// 金额规范化:去前导零、最多两位小数、千分位分组(114514.00 → ¥114,514)
export function formatAmount(a: string | number | null | undefined): string {
if (a === null || a === undefined || a === '') {
return '--';
}
const n = Number(a);
if (Number.isNaN(n) || !Number.isFinite(n)) {
return `¥${a}`;
}
const abs = Math.abs(n);
const fixed = Number.isInteger(abs) ? abs.toFixed(0) : abs.toFixed(2);
const parts = fixed.split('.');
let grouped = '';
const intPart = parts[0];
for (let i = 0; i < intPart.length; i++) {
const idxFromEnd = intPart.length - i;
grouped += intPart[i];
if (idxFromEnd > 1 && idxFromEnd % 3 === 1) {
grouped += ',';
}
}
const dec = parts.length > 1 ? `.${parts[1]}` : '';
return `${n < 0 ? '-' : ''}¥${grouped}${dec}`;
}
// 金额输入规范化:'0114514.00' → '114514'、'01.5' → '1.5';非法输入返回空串
export function normalizeAmountInput(raw: string): string {
const n = Number(raw);
if (raw.length === 0 || Number.isNaN(n) || !Number.isFinite(n) || n < 0) {
return '';
}
return String(n);
}
function genderLabel(g: string): string {
if (g === 'male') {
return '男';
}
if (g === 'female') {
return '女';
}
return '未知';
}
function keyPersonLabel(k: string): string {
if (k === 'yes') {
return '是';
}
if (k === 'no') {
return '否';
}
return '未填写';
}
@Entry
@Component
struct SalesHome {
@State leads: Array<SalesLead> = [];
@State contracts: Array<Contract> = [];
@State loading: boolean = true;
@State showSheet: boolean = false;
@State sheetMode: string = 'new';
@State followTarget: SalesLead | null = null;
@State newName: string = '';
@State newPhone: string = '';
@State noteText: string = '';
@State followStatus: string = 'in_progress';
@State sheetDealAmount: string = '';
@State submitting: boolean = false;
// 新建线索字段(对齐 Web)
@State visitDate: string = '';
@State gender: string = 'unknown';
@State ageText: string = '';
@State idCard: string = '';
@State originalFollower: string = '';
@State isElderlyConsultation: boolean = false;
@State isKeyPerson: string = '';
@State informationSources: Array<string> = [];
@State dealAmount: string = '';
@State lastFollowUpAt: string = '';
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const results: Array<Page<SalesLead> | Page<Contract> | null> = await Promise.all([
api.get<Page<SalesLead>>('/market/leads/', { page_size: 50, ordering: '-created_at' }).catch((): Page<SalesLead> | null => null),
api.get<Page<Contract>>('/market/contracts/', { page_size: 50 }).catch((): Page<Contract> | null => null),
]);
const l = results[0] as Page<SalesLead> | null;
const c = results[1] as Page<Contract> | null;
this.leads = l ? l.results : [];
this.contracts = c ? c.results : [];
console.info(`[SalesHome] leads=${this.leads.length} contracts=${this.contracts.length} rawL=${l ? 'ok' : 'null'} rawC=${c ? 'ok' : 'null'}`);
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
private totalLeads(): number {
return this.leads.length;
}
private pendingCount(): number {
return this.leads.filter((l: SalesLead) => l.status === 'pending').length;
}
private wonCount(): number {
return this.leads.filter((l: SalesLead) => l.status === 'won').length;
}
private wonAmount(): number {
let sum = 0;
this.leads.forEach((l: SalesLead) => {
if (l.status === 'won' && l.deal_amount !== null && l.deal_amount !== undefined) {
// 后端 deal_amount 可能是字符串 "114514.00",必须转数字再累加,否则字符串拼接
sum += Number(l.deal_amount);
}
});
return sum;
}
private openNewLead(): void {
this.newName = '';
this.newPhone = '';
this.visitDate = '';
this.gender = 'unknown';
this.ageText = '';
this.idCard = '';
this.originalFollower = '';
this.isElderlyConsultation = false;
this.isKeyPerson = '';
this.informationSources = [];
this.dealAmount = '';
this.lastFollowUpAt = '';
this.noteText = '';
this.followStatus = 'pending';
this.sheetMode = 'new';
this.showSheet = true;
}
private keyPersonValue(): boolean | null {
if (this.isKeyPerson === 'yes') {
return true;
}
if (this.isKeyPerson === 'no') {
return false;
}
return null;
}
private toggleSource(source: string): void {
if (this.informationSources.indexOf(source) >= 0) {
this.informationSources = this.informationSources.filter((s: string) => s !== source);
} else {
this.informationSources = this.informationSources.concat([source]);
}
}
private pickVisitDate(): void {
DatePickerDialog.show({
start: new Date('2020-01-01'),
end: new Date('2035-12-31'),
selected: new Date(),
onDateAccept: (value: Date) => {
const mm = String(value.getMonth() + 1).padStart(2, '0');
const dd = String(value.getDate()).padStart(2, '0');
this.visitDate = `${value.getFullYear()}-${mm}-${dd}`;
},
});
}
private pickLastFollowUp(): void {
DatePickerDialog.show({
start: new Date('2020-01-01'),
end: new Date('2035-12-31'),
selected: new Date(),
onDateAccept: (value: Date) => {
const mm = String(value.getMonth() + 1).padStart(2, '0');
const dd = String(value.getDate()).padStart(2, '0');
const hh = String(value.getHours()).padStart(2, '0');
const mi = String(value.getMinutes()).padStart(2, '0');
this.lastFollowUpAt = `${value.getFullYear()}-${mm}-${dd}T${hh}:${mi}:00`;
},
});
}
private async submitNewLead(): Promise<void> {
if (this.submitting) {
return;
}
if (this.newName.trim().length === 0) {
promptAction.showToast({ message: '请输入客户姓名' });
return;
}
if (this.isElderlyConsultation && this.idCard.trim().length === 0) {
promptAction.showToast({ message: '长者咨询需填写身份证号' });
return;
}
this.submitting = true;
try {
const payload: NewLeadPayload = {
name: this.newName.trim(),
visit_date: this.visitDate,
gender: this.gender,
age: this.ageText.trim().length > 0 ? Number(this.ageText.trim()) : null,
contact_method: this.newPhone.trim(),
id_card_number: this.idCard.trim(),
original_follower: this.originalFollower.trim(),
is_elderly_consultation: this.isElderlyConsultation,
is_key_person: this.keyPersonValue(),
information_sources: this.informationSources,
status: this.followStatus,
deal_amount: this.followStatus === 'won' ? normalizeAmountInput(this.dealAmount.trim()) : null,
latest_note: this.noteText.trim(),
last_follow_up_at: this.lastFollowUpAt,
};
await api.post('/market/leads/', payload);
this.showSheet = false;
promptAction.showToast({ message: '线索已创建' });
await this.load();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.submitting = false;
}
}
private openFollow(lead: SalesLead): void {
this.followTarget = lead;
this.noteText = '';
this.followStatus = lead.status;
this.sheetDealAmount = lead.status === 'won' && lead.deal_amount !== null && lead.deal_amount !== undefined
? normalizeAmountInput(String(lead.deal_amount))
: '';
this.sheetMode = 'follow';
this.showSheet = true;
}
private async submitFollow(): Promise<void> {
if (this.submitting || this.followTarget === null) {
return;
}
if (this.followStatus === 'won' && normalizeAmountInput(this.sheetDealAmount.trim()).length === 0) {
promptAction.showToast({ message: '标记成交请填写成交金额' });
return;
}
this.submitting = true;
const id = this.followTarget.id;
try {
const body: FollowPayload = {
status: this.followStatus,
};
if (this.noteText.trim().length > 0) {
body.latest_note = this.noteText.trim();
}
if (this.followStatus === 'won') {
body.deal_amount = normalizeAmountInput(this.sheetDealAmount.trim());
}
await api.patch(`/market/leads/${id}/`, body);
this.showSheet = false;
promptAction.showToast({ message: '已更新' });
await this.load();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.submitting = false;
}
}
@Builder
LeadCard(lead: SalesLead) {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(lead.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
if (lead.age !== null && lead.age !== undefined) {
Text(`${lead.age}岁`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
Blank()
Text(statusLabel(lead.status))
.fontSize(12)
.fontColor(statusColor(lead.status))
}
.width('100%')
if (lead.contact_method && lead.contact_method.length > 0) {
Text(`📞 ${lead.contact_method}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
if (lead.latest_note && lead.latest_note.length > 0) {
Text(lead.latest_note)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
Row({ space: 10 }) {
Text(amountText(lead.deal_amount))
.fontSize(13)
.fontColor('#FF9500')
Blank()
Button('详情')
.height(30)
.fontSize(12)
.fontColor($r('app.color.accent_blue'))
.backgroundColor($r('app.color.background'))
.borderRadius(8)
.onClick(() => {
router.pushUrl({
url: 'pages/sales/SalesLeadDetail',
params: { leadId: lead.id },
}).catch((err: BusinessError) => {
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
});
})
Button('跟进')
.height(30)
.fontSize(12)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(8)
.onClick(() => {
this.openFollow(lead);
})
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
}
@Builder
ContractCard(c: Contract) {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(c.customer_name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(amountText(c.contract_amount))
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FF9500')
}
.width('100%')
if (c.contract_code && c.contract_code.length > 0) {
Text(c.contract_code)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
}
@Builder
FieldInput(label: string, text: string, onChangeCb: (v: string) => void, type: string = '') {
Row({ space: 12 }) {
Text(label)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(88)
TextInput({ placeholder: label, text: text })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.fontSize(15)
.height(44)
.layoutWeight(1)
.padding({ left: 12, right: 12 })
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.type(type === 'number' ? InputType.Number : (type === 'phone' ? InputType.PhoneNumber : InputType.Normal))
.onChange(onChangeCb)
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.background'))
.borderRadius(10)
}
@Builder
SheetContent() {
if (this.sheetMode === 'new') {
Scroll() {
Column({ space: 10 }) {
Text('新建线索')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
// 来访日期
Row({ space: 12 }) {
Text('来访日期')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(88)
Text(this.visitDate.length > 0 ? this.visitDate : '选择日期')
.fontSize(15)
.fontColor(this.visitDate.length > 0 ? $r('app.color.text_primary') : $r('app.color.inactive_tab'))
.layoutWeight(1)
Text('›')
.fontSize(18)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.onClick(() => {
this.pickVisitDate();
})
this.FieldInput('姓名', this.newName, (v: string) => { this.newName = v; })
// 性别
Row({ space: 12 }) {
Text('性别')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(88)
Row({ space: 8 }) {
ForEach(GENDER_OPTIONS, (g: string) => {
Text(genderLabel(g))
.fontSize(13)
.fontColor(this.gender === g ? '#FFFFFF' : $r('app.color.text_primary'))
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.gender === g ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(8)
.onClick(() => {
this.gender = g;
})
}, (g: string) => g)
}
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.background'))
.borderRadius(10)
this.FieldInput('年龄', this.ageText, (v: string) => { this.ageText = v; }, 'number')
this.FieldInput('联系方式', this.newPhone, (v: string) => { this.newPhone = v; }, 'phone')
this.FieldInput('身份证号', this.idCard, (v: string) => { this.idCard = v; })
this.FieldInput('原表跟进人', this.originalFollower, (v: string) => { this.originalFollower = v; })
// 是否为长者咨询
Row({ space: 12 }) {
Text('长者咨询')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(88)
Row({ space: 8 }) {
ForEach([false, true], (v: boolean) => {
Text(v ? '是' : '否')
.fontSize(13)
.fontColor(this.isElderlyConsultation === v ? '#FFFFFF' : $r('app.color.text_primary'))
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.isElderlyConsultation === v ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(8)
.onClick(() => {
this.isElderlyConsultation = v;
})
}, (v: boolean) => String(v))
}
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.background'))
.borderRadius(10)
// 是否重点关注
Row({ space: 12 }) {
Text('重点关注')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(88)
Row({ space: 8 }) {
ForEach(KEY_PERSON_OPTIONS, (k: string) => {
Text(keyPersonLabel(k))
.fontSize(13)
.fontColor(this.isKeyPerson === k ? '#FFFFFF' : $r('app.color.text_primary'))
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.isKeyPerson === k ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(8)
.onClick(() => {
this.isKeyPerson = k;
})
}, (k: string) => k)
}
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.background'))
.borderRadius(10)
// 信息来源(多选)
Column({ space: 8 }) {
Text('信息来源')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(SOURCE_OPTIONS, (s: string) => {
Text(s)
.fontSize(13)
.fontColor(this.informationSources.indexOf(s) >= 0 ? '#FFFFFF' : $r('app.color.text_primary'))
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.informationSources.indexOf(s) >= 0 ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(8)
.margin({ right: 8, bottom: 8 })
.onClick(() => {
this.toggleSource(s);
})
}, (s: string) => s)
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.alignItems(HorizontalAlign.Start)
// 状态
Row({ space: 12 }) {
Text('状态')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(88)
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(LEAD_STATUS_OPTIONS, (s: string) => {
Text(statusLabel(s))
.fontSize(13)
.fontColor(this.followStatus === s ? '#FFFFFF' : $r('app.color.text_primary'))
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.followStatus === s ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(8)
.margin({ right: 8, bottom: 8 })
.onClick(() => {
this.followStatus = s;
})
}, (s: string) => s)
}
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.background'))
.borderRadius(10)
if (this.followStatus === 'won') {
this.FieldInput('成交金额', this.dealAmount, (v: string) => { this.dealAmount = v; }, 'number')
}
// 备注
Column({ space: 8 }) {
Text('备注')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width('100%')
TextArea({ placeholder: '填写最新跟进情况', text: this.noteText })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.height(70)
.padding(12)
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.onChange((value: string) => {
this.noteText = value;
})
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.alignItems(HorizontalAlign.Start)
// 最后跟进时间
Row({ space: 12 }) {
Text('最后跟进')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(88)
Text(this.lastFollowUpAt.length > 0 ? this.lastFollowUpAt : '选择时间')
.fontSize(15)
.fontColor(this.lastFollowUpAt.length > 0 ? $r('app.color.text_primary') : $r('app.color.inactive_tab'))
.layoutWeight(1)
Text('›')
.fontSize(18)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.onClick(() => {
this.pickLastFollowUp();
})
Button(this.submitting ? '提交中...' : '创建线索')
.width('100%')
.height(48)
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
.enabled(!this.submitting)
.onClick(() => {
this.submitNewLead();
})
}
.width('100%')
.padding(20)
}
.width('100%')
.height('100%')
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
} else {
Column({ space: 12 }) {
Text(`跟进:${this.followTarget ? this.followTarget.name : ''}`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Row({ space: 8 }) {
ForEach(['pending', 'in_progress', 'won', 'lost'], (s: string) => {
Text(statusLabel(s))
.fontSize(13)
.fontColor(this.followStatus === s ? '#FFFFFF' : $r('app.color.text_primary'))
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(this.followStatus === s ? $r('app.color.accent_blue') : $r('app.color.background'))
.borderRadius(8)
.onClick(() => {
this.followStatus = s;
})
}, (s: string) => s)
}
.width('100%')
if (this.followStatus === 'won') {
this.FieldInput('成交金额', this.sheetDealAmount, (v: string) => { this.sheetDealAmount = v; }, 'number')
}
TextArea({ placeholder: '跟进备注', text: this.noteText })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.height(90)
.padding(14)
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.onChange((value: string) => {
this.noteText = value;
})
Button(this.submitting ? '提交中...' : '保存跟进')
.width('100%')
.height(48)
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
.enabled(!this.submitting)
.onClick(() => {
this.submitFollow();
})
}
.width('100%')
.padding(20)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
}
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
// 统计
Row() {
Column({ space: 4 }) {
Text(String(this.leads.length))
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#007AFF')
Text('总线索')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Text(String(this.pendingCount()))
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#FF9500')
Text('待跟进')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Text(String(this.wonCount()))
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#34C759')
Text('成交')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Text(formatAmount(this.wonAmount()))
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#FF3B30')
Text('成交额')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
Button('+ 新建线索')
.width('100%')
.height(44)
.fontSize(15)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
.onClick(() => {
this.openNewLead();
})
// 线索列表
Column({ space: 8 }) {
Row() {
Text('我的线索')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(`共 ${this.leads.length} 条`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
if (this.leads.length === 0) {
Text('暂无线索')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.leads, (lead: SalesLead) => {
Column() {
this.LeadCard(lead)
}
.width('100%')
.onClick(() => {
router.pushUrl({
url: 'pages/sales/SalesLeadDetail',
params: { leadId: lead.id },
}).catch((err: BusinessError) => {
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
});
})
}, (lead: SalesLead) => String(lead.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 合同
Column({ space: 8 }) {
Row() {
Text('签约合同')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(`共 ${this.contracts.length} 份`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
if (this.contracts.length === 0) {
Text('暂无合同')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.contracts, (c: Contract) => {
Column() {
this.ContractCard(c)
}
.width('100%')
}, (c: Contract) => String(c.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 20 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 90 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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'))
.bindSheet($$this.showSheet, this.SheetContent(), {
height: 560,
dragBar: true,
showClose: true,
backgroundColor: $r('app.color.background_card'),
})
}
}
@@ -0,0 +1,582 @@
// 销售端 · 线索详情(ArkTS 严格类型,无 any)
// 接口:GET /market/leads/{id}/;PATCH /market/leads/{id}/(跟进)
// 展示对齐小程序 sales/detail:基础信息 + 线索信息 + 操作信息 + 备注,底部跟进操作
import { promptAction, router } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { formatAmount, normalizeAmountInput } from './SalesHome';
interface LeadDetail {
id: number;
name: string;
gender: string;
age: number | null;
contact_method: string;
id_card_number: string;
original_follower: string;
is_elderly_consultation: boolean | null;
is_key_person: boolean | null;
information_sources: Array<string>;
status: string;
deal_amount: string | number | null;
latest_note: string;
last_follow_up_at: string;
visit_date: string;
current_sales_name: string;
current_cycle_month: string;
linked_elderly_name: string;
linked_elderly_account_username: string;
created_via: string;
created_by_name: string;
updated_by_name: string;
created_at: string;
updated_at: string;
}
interface FollowPayload {
status: string;
latest_note?: string;
deal_amount?: string;
last_follow_up_at?: string;
}
const STATUS_OPTIONS: Array<string> = ['pending', 'in_progress', 'won', 'lost'];
function statusLabel(status: string): string {
if (status === 'won') {
return '成交';
}
if (status === 'in_progress') {
return '跟进中';
}
if (status === 'lost') {
return '失败';
}
return '待跟进';
}
function statusColor(status: string): string {
if (status === 'won') {
return '#34C759';
}
if (status === 'in_progress') {
return '#007AFF';
}
if (status === 'lost') {
return '#8E8E93';
}
return '#FF9500';
}
function genderLabel(g: string): string {
if (g === 'male') {
return '男';
}
if (g === 'female') {
return '女';
}
return '未知';
}
function boolLabel(v: boolean | null): string {
if (v === true) {
return '是';
}
if (v === false) {
return '否';
}
return '未填写';
}
function amountText(a: string | number | null): string {
return formatAmount(a);
}
function dateLabel(iso: string): string {
if (!iso) {
return '未填写';
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return iso;
}
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function dateTimeLabel(iso: string): string {
if (!iso) {
return '未填写';
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return iso;
}
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}`;
}
function sourcesText(list: Array<string>): string {
if (list.length === 0) {
return '未填写';
}
return list.join('、');
}
@Entry
@Component
struct SalesLeadDetail {
@State lead: LeadDetail | null = null;
@State loading: boolean = true;
@State errorMsg: string = '';
@State leadId: number = 0;
@State followStatus: string = 'pending';
@State noteText: string = '';
@State dealAmount: string = '';
@State lastFollowUp: string = '';
@State submitting: boolean = false;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
const params = router.getParams() as Record<string, Object> | null | undefined;
if (params !== null && params !== undefined) {
this.leadId = Number(params['leadId'] ?? 0);
}
if (this.leadId > 0) {
this.load();
} else {
this.loading = false;
this.errorMsg = '缺少线索参数';
}
}
// 后端字段可能缺失(undefined 会让 .length 崩溃),统一归一化为空串/空数组
private normalizeLead(res: LeadDetail): LeadDetail {
const s = (v: string | null | undefined): string => (typeof v === 'string' ? v : '');
const sources: Array<string> = Array.isArray(res.information_sources) ? res.information_sources : [];
return {
id: res.id,
name: s(res.name),
gender: s(res.gender),
age: res.age !== undefined ? res.age : null,
contact_method: s(res.contact_method),
id_card_number: s(res.id_card_number),
original_follower: s(res.original_follower),
is_elderly_consultation: res.is_elderly_consultation === true ? true : (res.is_elderly_consultation === false ? false : null),
is_key_person: res.is_key_person === true ? true : (res.is_key_person === false ? false : null),
information_sources: sources,
status: s(res.status) === '' ? 'pending' : s(res.status),
deal_amount: res.deal_amount !== undefined ? res.deal_amount : null,
latest_note: s(res.latest_note),
last_follow_up_at: s(res.last_follow_up_at),
visit_date: s(res.visit_date),
current_sales_name: s(res.current_sales_name),
current_cycle_month: s(res.current_cycle_month),
linked_elderly_name: s(res.linked_elderly_name),
linked_elderly_account_username: s(res.linked_elderly_account_username),
created_via: s(res.created_via),
created_by_name: s(res.created_by_name),
updated_by_name: s(res.updated_by_name),
created_at: s(res.created_at),
updated_at: s(res.updated_at),
};
}
private async load(): Promise<void> {
this.loading = true;
this.errorMsg = '';
try {
const res = await api.get<LeadDetail>(`/market/leads/${this.leadId}/`);
this.lead = this.normalizeLead(res);
this.followStatus = this.lead.status;
this.dealAmount = this.lead.status === 'won' && this.lead.deal_amount !== null && this.lead.deal_amount !== undefined
? normalizeAmountInput(String(this.lead.deal_amount))
: '';
this.lastFollowUp = this.lead.last_follow_up_at;
} catch (err) {
this.errorMsg = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
} finally {
this.loading = false;
}
}
// 选最后跟进日期:保留原时间部分(无则用当前时刻),合成 datetime 提交
private pickLastFollowUp(): void {
const current = this.lastFollowUp.length > 0 && !Number.isNaN(new Date(this.lastFollowUp).getTime())
? new Date(this.lastFollowUp)
: new Date();
DatePickerDialog.show({
start: new Date('2020-01-01'),
end: new Date('2035-12-31'),
selected: current,
onDateAccept: (value: Date) => {
const mm = String(value.getMonth() + 1).padStart(2, '0');
const dd = String(value.getDate()).padStart(2, '0');
const hh = String(current.getHours()).padStart(2, '0');
const mi = String(current.getMinutes()).padStart(2, '0');
this.lastFollowUp = `${value.getFullYear()}-${mm}-${dd}T${hh}:${mi}:00`;
},
});
}
private async submitFollow(): Promise<void> {
if (this.submitting || this.lead === null) {
return;
}
if (this.followStatus === 'won' && normalizeAmountInput(this.dealAmount.trim()).length === 0) {
promptAction.showToast({ message: '标记成交请填写成交金额' });
return;
}
this.submitting = true;
try {
const body: FollowPayload = { status: this.followStatus };
if (this.noteText.trim().length > 0) {
body.latest_note = this.noteText.trim();
}
if (this.followStatus === 'won') {
body.deal_amount = normalizeAmountInput(this.dealAmount.trim());
}
if (this.lastFollowUp.length > 0) {
body.last_follow_up_at = this.lastFollowUp;
}
await api.patch(`/market/leads/${this.leadId}/`, body);
promptAction.showToast({ message: '已更新' });
this.noteText = '';
await this.load();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.submitting = false;
}
}
@Builder
Hero() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text(this.lead?.name ?? '--')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
Text(statusLabel(this.lead?.status ?? ''))
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF')
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor(statusColor(this.lead?.status ?? ''))
.borderRadius(10)
}
.width('100%')
Row({ space: 8 }) {
Text(this.lead?.current_sales_name && this.lead.current_sales_name.length > 0 ? this.lead.current_sales_name : '未分配')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor($r('app.color.background'))
.borderRadius(8)
Text(this.lead !== null && this.lead.status === 'won' ? amountText(this.lead.deal_amount) : '未成交')
.fontSize(12)
.fontColor(this.lead !== null && this.lead.status === 'won' ? '#FF9500' : $r('app.color.text_secondary'))
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor($r('app.color.background'))
.borderRadius(8)
Blank()
Text(`跟进 ${dateTimeLabel(this.lead?.last_follow_up_at ?? '')}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
}
@Builder
DetailItem(label: string, value: string) {
Column({ space: 4 }) {
Text(label)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Text((value ?? '').length > 0 ? value : '未填写')
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.width('50%')
.padding({ top: 8, bottom: 8 })
}
@Builder
SectionCard(title: string) {
Text(title)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.loading) {
Text('详情加载中...')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding({ top: 40 })
.textAlign(TextAlign.Center)
} else if (this.errorMsg.length > 0) {
Column({ space: 8 }) {
Text('加载失败')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(this.errorMsg)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding({ top: 40 })
.alignItems(HorizontalAlign.Center)
} else if (this.lead !== null) {
this.Hero()
// 基础信息
Column({ space: 6 }) {
this.SectionCard('基础信息')
Flex({ wrap: FlexWrap.Wrap }) {
this.DetailItem('来访日期', dateLabel(this.lead!.visit_date))
this.DetailItem('性别', genderLabel(this.lead!.gender))
this.DetailItem('年龄', this.lead!.age !== null && this.lead!.age !== undefined ? `${this.lead!.age}岁` : '未填写')
this.DetailItem('联系方式', this.lead!.contact_method)
this.DetailItem('身份证号', this.lead!.id_card_number)
this.DetailItem('原表跟进人', this.lead!.original_follower)
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 线索信息
Column({ space: 6 }) {
this.SectionCard('线索信息')
Flex({ wrap: FlexWrap.Wrap }) {
this.DetailItem('信息来源', sourcesText(this.lead!.information_sources))
this.DetailItem('是否为长者咨询', boolLabel(this.lead!.is_elderly_consultation))
this.DetailItem('是否重点关注', boolLabel(this.lead!.is_key_person))
this.DetailItem('当前状态', statusLabel(this.lead!.status))
this.DetailItem('成交金额', this.lead!.status === 'won' ? amountText(this.lead!.deal_amount) : '未成交')
this.DetailItem('最后跟进时间', dateTimeLabel(this.lead!.last_follow_up_at))
this.DetailItem('负责销售', this.lead!.current_sales_name)
this.DetailItem('当前周期', this.lead!.current_cycle_month)
this.DetailItem('关联长者档案', this.lead!.linked_elderly_name)
this.DetailItem('长者账号', this.lead!.linked_elderly_account_username)
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 操作信息
Column({ space: 6 }) {
this.SectionCard('操作信息')
Flex({ wrap: FlexWrap.Wrap }) {
this.DetailItem('创建方式', this.lead!.created_via === 'import' ? '导入' : '手工录入')
this.DetailItem('创建人', this.lead!.created_by_name)
this.DetailItem('更新人', this.lead!.updated_by_name)
this.DetailItem('创建时间', dateTimeLabel(this.lead!.created_at))
this.DetailItem('更新时间', dateTimeLabel(this.lead!.updated_at))
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 备注
Column({ space: 6 }) {
Text('备注')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Text(this.lead!.latest_note !== null && this.lead!.latest_note !== undefined && this.lead!.latest_note.trim().length > 0 ? this.lead!.latest_note.trim() : '暂无备注')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 跟进操作
Column({ space: 10 }) {
Text('跟进')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Row({ space: 8 }) {
ForEach(STATUS_OPTIONS, (s: string) => {
Text(statusLabel(s))
.fontSize(13)
.fontColor(this.followStatus === s ? '#FFFFFF' : $r('app.color.text_primary'))
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.backgroundColor(this.followStatus === s ? statusColor(s) : $r('app.color.background'))
.borderRadius(9)
.onClick(() => {
this.followStatus = s;
})
.accessibilityLevel('yes')
.accessibilityText(`状态设为${statusLabel(s)}${this.followStatus === s ? ',已选中' : ''}`)
}, (s: string) => s)
}
.width('100%')
if (this.followStatus === 'won') {
Row({ space: 12 }) {
Text('成交金额')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(88)
TextInput({ placeholder: '请输入成交金额(元)', text: this.dealAmount })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.fontSize(15)
.height(44)
.layoutWeight(1)
.padding({ left: 12, right: 12 })
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.type(InputType.NUMBER_DECIMAL)
.onChange((value: string) => {
this.dealAmount = value;
})
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.background'))
.borderRadius(10)
}
Row({ space: 12 }) {
Text('最后跟进')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(88)
Text(this.lastFollowUp.length > 0 ? dateTimeLabel(this.lastFollowUp) : '选择日期')
.fontSize(15)
.fontColor(this.lastFollowUp.length > 0 ? $r('app.color.text_primary') : $r('app.color.inactive_tab'))
.layoutWeight(1)
Text('›')
.fontSize(18)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.onClick(() => {
this.pickLastFollowUp();
})
.accessibilityLevel('yes')
.accessibilityText(`最后跟进时间${this.lastFollowUp.length > 0 ? dateTimeLabel(this.lastFollowUp) : '未选择'},点按修改`)
TextArea({ placeholder: '跟进备注(选填)', text: this.noteText })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.height(80)
.padding(12)
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.onChange((value: string) => {
this.noteText = value;
})
Button(this.submitting ? '提交中...' : '保存跟进')
.width('100%')
.height(46)
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
.enabled(!this.submitting)
.onClick(() => {
this.submitFollow();
})
.accessibilityLevel('yes')
.accessibilityText('保存跟进')
.accessibilityDescription('点按保存状态与备注')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 20 })
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 90 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.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'))
}
}
File diff suppressed because it is too large Load Diff