From baf307e9d51057ac02966b4e787d589933495785 Mon Sep 17 00:00:00 2001 From: CAPTAIN1275 Date: Tue, 29 Sep 2026 18:44:37 +0800 Subject: [PATCH] =?UTF-8?q?chore(hmos):=20=E5=AF=BC=E5=85=A5=E7=94=9F?= =?UTF-8?q?=E4=BA=A7=E7=8E=AF=E5=A2=83=E9=B8=BF=E8=92=99=E5=B7=A5=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 以 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) --- .gitignore | 29 + AppScope/app.json5 | 15 + AppScope/resources/base/element/string.json | 8 + AppScope/resources/base/media/background.png | Bin 0 -> 6536 bytes AppScope/resources/base/media/foreground.png | Bin 0 -> 14290 bytes .../resources/base/media/layered_image.json | 7 + build-profile.json5 | 59 + cjagent/.gitignore | 8 + cjagent/BuildProfile.ets | 17 + cjagent/Index.d.ets | 0 cjagent/build-profile.json5 | 21 + cjagent/cjpm.lock | 3 + cjagent/cjpm.toml | 37 + cjagent/cjpm_log.txt | 8 + cjagent/hvigorfile.ts | 6 + cjagent/oh-package.json5 | 11 + .../main/cangjie/IDL_Custom_Type_Whitelist~ | 0 .../ark_interop_api/ark_interop_api.d.ts | 9 + .../cangjie/ark_interop_api/oh-package.json5 | 6 + cjagent/src/main/cangjie/index.cj | 7 + cjagent/src/main/cangjie/intent.cj | 294 ++++ .../libohos_app_cangjie_cjagent/Index.d.ts | 8 + .../oh-package.json5 | 6 + cjagent/src/main/module.json5 | 10 + .../main/resources/base/element/string.json | 8 + .../main/resources/en_US/element/string.json | 8 + .../main/resources/zh_CN/element/string.json | 8 + cjagent/src/ohosTest/cangjie/ability_stage.cj | 10 + cjagent/src/ohosTest/cangjie/cjpm.toml | 44 + cjagent/src/ohosTest/cangjie/example.cj | 14 + cjagent/src/ohosTest/cangjie/example_test.cj | 17 + cjagent/src/ohosTest/cangjie/index.cj | 36 + cjagent/src/ohosTest/cangjie/list_test.cj | 9 + cjagent/src/ohosTest/cangjie/main_ability.cj | 28 + .../unittest_support/unittest_engine.cj | 269 ++++ cjagent/src/ohosTest/module.json5 | 27 + .../resources/base/element/color.json | 8 + .../resources/base/element/string.json | 16 + .../resources/base/media/background.png | Bin 0 -> 57364 bytes .../resources/base/media/foreground.png | Bin 0 -> 12430 bytes .../resources/base/media/layered_image.json | 7 + .../resources/base/media/startIcon.png | Bin 0 -> 20093 bytes .../resources/base/profile/main_pages.json | 2 + cjagent/src/test/cangjie/cjpm.toml | 22 + cjagent/src/test/cangjie/example.cj | 14 + cjagent/src/test/cangjie/example_test.cj | 17 + code-linter.json5 | 33 + entry/.gitignore | 6 + entry/build-profile.json5 | 33 + entry/hvigorfile.ts | 6 + entry/obfuscation-rules.txt | 23 + entry/oh-package-lock.json5 | 19 + entry/oh-package.json5 | 12 + .../SmartSeniorAgentExtAbility.ets | 474 +++++++ entry/src/main/ets/common/ApiConstants.ts | 188 +++ entry/src/main/ets/common/Constants.ets | 171 +++ entry/src/main/ets/common/RouteTarget.ts | 26 + .../main/ets/components/ProtectedImage.ets | 84 ++ .../main/ets/entryability/EntryAbility.ets | 262 ++++ .../entrybackupability/EntryBackupAbility.ets | 16 + .../ets/intents/CancelBreakfastIntent.ets | 34 + .../main/ets/intents/CancelDinnerIntent.ets | 34 + .../main/ets/intents/CancelLunchIntent.ets | 34 + .../src/main/ets/intents/CancelMealIntent.ets | 30 + .../main/ets/intents/OrderBreakfastIntent.ets | 34 + .../main/ets/intents/OrderDinnerIntent.ets | 34 + .../src/main/ets/intents/OrderLunchIntent.ets | 34 + .../src/main/ets/intents/OrderMealIntent.ets | 69 + entry/src/main/ets/pages/Index.ets | 244 ++++ entry/src/main/ets/pages/Login.ets | 348 +++++ .../main/ets/pages/admin/AdminFeedback.ets | 323 +++++ .../main/ets/pages/admin/AdminNurseDetail.ets | 334 +++++ .../main/ets/pages/admin/AdminNurseList.ets | 190 +++ .../ets/pages/admin/AdminNursingRecords.ets | 346 +++++ .../main/ets/pages/admin/AdminOverview.ets | 603 ++++++++ entry/src/main/ets/pages/admin/AdminQuery.ets | 213 +++ .../ets/pages/admin/AdminRecordDetail.ets | 256 ++++ .../main/ets/pages/admin/AdminSalesDetail.ets | 452 ++++++ .../main/ets/pages/admin/AdminSchedule.ets | 285 ++++ .../main/ets/pages/admin/DeviceMonitoring.ets | 419 ++++++ entry/src/main/ets/pages/alerts/AlertsTab.ets | 172 +++ .../main/ets/pages/business/BusinessTab.ets | 187 +++ .../src/main/ets/pages/elderly/Activities.ets | 258 ++++ .../src/main/ets/pages/elderly/Assistant.ets | 146 ++ .../src/main/ets/pages/elderly/ElderlyAdd.ets | 383 ++++++ .../pages/elderly/ElderlyAssistantPanel.ets | 299 ++++ .../main/ets/pages/elderly/ElderlyDetail.ets | 353 +++++ .../main/ets/pages/elderly/ElderlyDial.ets | 202 +++ .../main/ets/pages/elderly/ElderlyHistory.ets | 167 +++ .../main/ets/pages/elderly/ElderlyHome.ets | 1148 ++++++++++++++++ .../main/ets/pages/elderly/ElderlyHome1.ets | 1131 +++++++++++++++ .../main/ets/pages/elderly/ElderlyList.ets | 205 +++ .../ets/pages/elderly/ElderlyMealDetail.ets | 240 ++++ .../ets/pages/elderly/ElderlyMealOrder.ets | 546 ++++++++ .../ets/pages/elderly/ElderlyProfilePanel.ets | 225 +++ .../pages/elderly/ElderlyRecommendations.ets | 211 +++ .../main/ets/pages/elderly/ElderlyWeather.ets | 329 +++++ .../ets/pages/family/FamilyAlertDetail.ets | 266 ++++ .../main/ets/pages/family/FamilyAlerts.ets | 230 ++++ .../main/ets/pages/family/FamilyAssistant.ets | 177 +++ .../src/main/ets/pages/family/FamilyBind.ets | 174 +++ .../main/ets/pages/family/FamilyElderly.ets | 205 +++ .../ets/pages/family/FamilyElderlyDetail.ets | 340 +++++ .../ets/pages/family/FamilyElderlyHistory.ets | 198 +++ .../ets/pages/family/FamilyHealthReport.ets | 422 ++++++ .../src/main/ets/pages/family/FamilyHome.ets | 235 ++++ .../ets/pages/family/FamilyReportCard.ets | 203 +++ .../main/ets/pages/family/FamilyVerify.ets | 182 +++ entry/src/main/ets/pages/family/Records.ets | 188 +++ .../ets/pages/fire-control/FirePatrolHome.ets | 681 +++++++++ .../pages/fire-control/FireRecordDetail.ets | 241 ++++ entry/src/main/ets/pages/meals/MealOrders.ets | 518 +++++++ .../ets/pages/notifications/Notifications.ets | 299 ++++ entry/src/main/ets/pages/nurse/Alerts.ets | 167 +++ entry/src/main/ets/pages/nurse/CancelMeal.ets | 296 ++++ entry/src/main/ets/pages/nurse/History.ets | 208 +++ .../main/ets/pages/nurse/MedicationToDo.ets | 392 ++++++ .../ets/pages/nurse/NurseAlertProcess.ets | 284 ++++ .../main/ets/pages/nurse/NurseCarePlan.ets | 272 ++++ entry/src/main/ets/pages/nurse/NurseHome.ets | 290 ++++ .../src/main/ets/pages/nurse/RecordDetail.ets | 250 ++++ .../src/main/ets/pages/nurse/RecordEntry.ets | 668 +++++++++ entry/src/main/ets/pages/nurse/RiskPatrol.ets | 398 ++++++ entry/src/main/ets/pages/nurse/SkinCheck.ets | 290 ++++ entry/src/main/ets/pages/nurse/WorkCenter.ets | 595 ++++++++ entry/src/main/ets/pages/profile/About.ets | 133 ++ entry/src/main/ets/pages/profile/Feedback.ets | 421 ++++++ .../src/main/ets/pages/profile/ProfileTab.ets | 263 ++++ entry/src/main/ets/pages/sales/SalesHome.ets | 994 +++++++++++++ .../main/ets/pages/sales/SalesLeadDetail.ets | 582 ++++++++ .../main/ets/pages/workbench/Workbench.ets | 1224 +++++++++++++++++ entry/src/main/ets/utils/A2AServer.ts | 900 ++++++++++++ entry/src/main/ets/utils/AlertSocket.ts | 130 ++ entry/src/main/ets/utils/AuthStore.ts | 166 +++ entry/src/main/ets/utils/CalendarHelper.ts | 148 ++ entry/src/main/ets/utils/FontScale.ts | 89 ++ entry/src/main/ets/utils/GrayScaleDates.ts | 50 + entry/src/main/ets/utils/HealthReportPdf.ts | 346 +++++ entry/src/main/ets/utils/IntentEngine.ts | 51 + entry/src/main/ets/utils/KnockShare.ets | 104 ++ entry/src/main/ets/utils/LiveDataService.ets | 409 ++++++ entry/src/main/ets/utils/LiveViewUtil.ets | 875 ++++++++++++ entry/src/main/ets/utils/LiveViewUtil1.ts | 26 + entry/src/main/ets/utils/LocalNotify.ts | 50 + entry/src/main/ets/utils/OcrHelper.ts | 112 ++ entry/src/main/ets/utils/OpenMeteo.ts | 133 ++ entry/src/main/ets/utils/PermissionUtil.ts | 35 + entry/src/main/ets/utils/PushManager.ts | 102 ++ entry/src/main/ets/utils/Request.ts | 354 +++++ entry/src/main/ets/utils/TtsPlayer.ts | 49 + entry/src/main/ets/utils/VoiceCommand.ts | 141 ++ .../src/main/ets/widget/EntryFormAbility.ets | 566 ++++++++ .../ets/widget/entry_card/EntryActCard.ets | 86 ++ .../main/ets/widget/entry_card/EntryCard.ets | 114 ++ .../ets/widget/entry_card/EntryOrderCard.ets | 87 ++ .../ets/widget/entry_card/EntryRecCard.ets | 86 ++ entry/src/main/module.json5 | 178 +++ .../main/resources/base/element/color.json | 60 + .../main/resources/base/element/float.json | 24 + .../main/resources/base/element/string.json | 80 ++ .../resources/base/media/airport_icon.png | Bin 0 -> 839 bytes .../main/resources/base/media/background.png | Bin 0 -> 6536 bytes .../resources/base/media/default_avatar.png | Bin 0 -> 907 bytes .../main/resources/base/media/foreground.png | Bin 0 -> 14290 bytes .../main/resources/base/media/foreground1.png | Bin 0 -> 907 bytes .../resources/base/media/layered_image.json | 7 + entry/src/main/resources/base/media/logo1.png | Bin 0 -> 14290 bytes .../main/resources/base/media/startIcon.png | Bin 0 -> 20093 bytes .../resources/base/profile/agent_config.json | 62 + .../resources/base/profile/backup_config.json | 3 + .../resources/base/profile/form_config.json | 92 ++ .../resources/base/profile/main_pages.json | 60 + .../base/profile/shortcuts_config.json | 49 + .../main/resources/dark/element/color.json | 60 + entry/src/mock/mock-config.json5 | 2 + entry/src/ohosTest/ets/test/Ability.test.ets | 35 + entry/src/ohosTest/ets/test/List.test.ets | 5 + entry/src/ohosTest/module.json5 | 13 + entry/src/test/List.test.ets | 5 + entry/src/test/LocalUnit.test.ets | 33 + hvigor/hvigor-config.json5 | 23 + hvigorfile.ts | 6 + oh-package-lock.json5 | 28 + oh-package.json5 | 10 + 184 files changed, 31334 insertions(+) create mode 100644 .gitignore create mode 100644 AppScope/app.json5 create mode 100644 AppScope/resources/base/element/string.json create mode 100644 AppScope/resources/base/media/background.png create mode 100644 AppScope/resources/base/media/foreground.png create mode 100644 AppScope/resources/base/media/layered_image.json create mode 100644 build-profile.json5 create mode 100644 cjagent/.gitignore create mode 100644 cjagent/BuildProfile.ets create mode 100644 cjagent/Index.d.ets create mode 100644 cjagent/build-profile.json5 create mode 100644 cjagent/cjpm.lock create mode 100644 cjagent/cjpm.toml create mode 100644 cjagent/cjpm_log.txt create mode 100644 cjagent/hvigorfile.ts create mode 100644 cjagent/oh-package.json5 create mode 100644 cjagent/src/main/cangjie/IDL_Custom_Type_Whitelist~ create mode 100644 cjagent/src/main/cangjie/ark_interop_api/ark_interop_api.d.ts create mode 100644 cjagent/src/main/cangjie/ark_interop_api/oh-package.json5 create mode 100644 cjagent/src/main/cangjie/index.cj create mode 100644 cjagent/src/main/cangjie/intent.cj create mode 100644 cjagent/src/main/cangjie/types/libohos_app_cangjie_cjagent/Index.d.ts create mode 100644 cjagent/src/main/cangjie/types/libohos_app_cangjie_cjagent/oh-package.json5 create mode 100644 cjagent/src/main/module.json5 create mode 100644 cjagent/src/main/resources/base/element/string.json create mode 100644 cjagent/src/main/resources/en_US/element/string.json create mode 100644 cjagent/src/main/resources/zh_CN/element/string.json create mode 100644 cjagent/src/ohosTest/cangjie/ability_stage.cj create mode 100644 cjagent/src/ohosTest/cangjie/cjpm.toml create mode 100644 cjagent/src/ohosTest/cangjie/example.cj create mode 100644 cjagent/src/ohosTest/cangjie/example_test.cj create mode 100644 cjagent/src/ohosTest/cangjie/index.cj create mode 100644 cjagent/src/ohosTest/cangjie/list_test.cj create mode 100644 cjagent/src/ohosTest/cangjie/main_ability.cj create mode 100644 cjagent/src/ohosTest/cangjie/unittest_support/unittest_engine.cj create mode 100644 cjagent/src/ohosTest/module.json5 create mode 100644 cjagent/src/ohosTest/resources/base/element/color.json create mode 100644 cjagent/src/ohosTest/resources/base/element/string.json create mode 100644 cjagent/src/ohosTest/resources/base/media/background.png create mode 100644 cjagent/src/ohosTest/resources/base/media/foreground.png create mode 100644 cjagent/src/ohosTest/resources/base/media/layered_image.json create mode 100644 cjagent/src/ohosTest/resources/base/media/startIcon.png create mode 100644 cjagent/src/ohosTest/resources/base/profile/main_pages.json create mode 100644 cjagent/src/test/cangjie/cjpm.toml create mode 100644 cjagent/src/test/cangjie/example.cj create mode 100644 cjagent/src/test/cangjie/example_test.cj create mode 100644 code-linter.json5 create mode 100644 entry/.gitignore create mode 100644 entry/build-profile.json5 create mode 100644 entry/hvigorfile.ts create mode 100644 entry/obfuscation-rules.txt create mode 100644 entry/oh-package-lock.json5 create mode 100644 entry/oh-package.json5 create mode 100644 entry/src/main/ets/agentability/SmartSeniorAgentExtAbility.ets create mode 100644 entry/src/main/ets/common/ApiConstants.ts create mode 100644 entry/src/main/ets/common/Constants.ets create mode 100644 entry/src/main/ets/common/RouteTarget.ts create mode 100644 entry/src/main/ets/components/ProtectedImage.ets create mode 100644 entry/src/main/ets/entryability/EntryAbility.ets create mode 100644 entry/src/main/ets/entrybackupability/EntryBackupAbility.ets create mode 100644 entry/src/main/ets/intents/CancelBreakfastIntent.ets create mode 100644 entry/src/main/ets/intents/CancelDinnerIntent.ets create mode 100644 entry/src/main/ets/intents/CancelLunchIntent.ets create mode 100644 entry/src/main/ets/intents/CancelMealIntent.ets create mode 100644 entry/src/main/ets/intents/OrderBreakfastIntent.ets create mode 100644 entry/src/main/ets/intents/OrderDinnerIntent.ets create mode 100644 entry/src/main/ets/intents/OrderLunchIntent.ets create mode 100644 entry/src/main/ets/intents/OrderMealIntent.ets create mode 100644 entry/src/main/ets/pages/Index.ets create mode 100644 entry/src/main/ets/pages/Login.ets create mode 100644 entry/src/main/ets/pages/admin/AdminFeedback.ets create mode 100644 entry/src/main/ets/pages/admin/AdminNurseDetail.ets create mode 100644 entry/src/main/ets/pages/admin/AdminNurseList.ets create mode 100644 entry/src/main/ets/pages/admin/AdminNursingRecords.ets create mode 100644 entry/src/main/ets/pages/admin/AdminOverview.ets create mode 100644 entry/src/main/ets/pages/admin/AdminQuery.ets create mode 100644 entry/src/main/ets/pages/admin/AdminRecordDetail.ets create mode 100644 entry/src/main/ets/pages/admin/AdminSalesDetail.ets create mode 100644 entry/src/main/ets/pages/admin/AdminSchedule.ets create mode 100644 entry/src/main/ets/pages/admin/DeviceMonitoring.ets create mode 100644 entry/src/main/ets/pages/alerts/AlertsTab.ets create mode 100644 entry/src/main/ets/pages/business/BusinessTab.ets create mode 100644 entry/src/main/ets/pages/elderly/Activities.ets create mode 100644 entry/src/main/ets/pages/elderly/Assistant.ets create mode 100644 entry/src/main/ets/pages/elderly/ElderlyAdd.ets create mode 100644 entry/src/main/ets/pages/elderly/ElderlyAssistantPanel.ets create mode 100644 entry/src/main/ets/pages/elderly/ElderlyDetail.ets create mode 100644 entry/src/main/ets/pages/elderly/ElderlyDial.ets create mode 100644 entry/src/main/ets/pages/elderly/ElderlyHistory.ets create mode 100644 entry/src/main/ets/pages/elderly/ElderlyHome.ets create mode 100644 entry/src/main/ets/pages/elderly/ElderlyHome1.ets create mode 100644 entry/src/main/ets/pages/elderly/ElderlyList.ets create mode 100644 entry/src/main/ets/pages/elderly/ElderlyMealDetail.ets create mode 100644 entry/src/main/ets/pages/elderly/ElderlyMealOrder.ets create mode 100644 entry/src/main/ets/pages/elderly/ElderlyProfilePanel.ets create mode 100644 entry/src/main/ets/pages/elderly/ElderlyRecommendations.ets create mode 100644 entry/src/main/ets/pages/elderly/ElderlyWeather.ets create mode 100644 entry/src/main/ets/pages/family/FamilyAlertDetail.ets create mode 100644 entry/src/main/ets/pages/family/FamilyAlerts.ets create mode 100644 entry/src/main/ets/pages/family/FamilyAssistant.ets create mode 100644 entry/src/main/ets/pages/family/FamilyBind.ets create mode 100644 entry/src/main/ets/pages/family/FamilyElderly.ets create mode 100644 entry/src/main/ets/pages/family/FamilyElderlyDetail.ets create mode 100644 entry/src/main/ets/pages/family/FamilyElderlyHistory.ets create mode 100644 entry/src/main/ets/pages/family/FamilyHealthReport.ets create mode 100644 entry/src/main/ets/pages/family/FamilyHome.ets create mode 100644 entry/src/main/ets/pages/family/FamilyReportCard.ets create mode 100644 entry/src/main/ets/pages/family/FamilyVerify.ets create mode 100644 entry/src/main/ets/pages/family/Records.ets create mode 100644 entry/src/main/ets/pages/fire-control/FirePatrolHome.ets create mode 100644 entry/src/main/ets/pages/fire-control/FireRecordDetail.ets create mode 100644 entry/src/main/ets/pages/meals/MealOrders.ets create mode 100644 entry/src/main/ets/pages/notifications/Notifications.ets create mode 100644 entry/src/main/ets/pages/nurse/Alerts.ets create mode 100644 entry/src/main/ets/pages/nurse/CancelMeal.ets create mode 100644 entry/src/main/ets/pages/nurse/History.ets create mode 100644 entry/src/main/ets/pages/nurse/MedicationToDo.ets create mode 100644 entry/src/main/ets/pages/nurse/NurseAlertProcess.ets create mode 100644 entry/src/main/ets/pages/nurse/NurseCarePlan.ets create mode 100644 entry/src/main/ets/pages/nurse/NurseHome.ets create mode 100644 entry/src/main/ets/pages/nurse/RecordDetail.ets create mode 100644 entry/src/main/ets/pages/nurse/RecordEntry.ets create mode 100644 entry/src/main/ets/pages/nurse/RiskPatrol.ets create mode 100644 entry/src/main/ets/pages/nurse/SkinCheck.ets create mode 100644 entry/src/main/ets/pages/nurse/WorkCenter.ets create mode 100644 entry/src/main/ets/pages/profile/About.ets create mode 100644 entry/src/main/ets/pages/profile/Feedback.ets create mode 100644 entry/src/main/ets/pages/profile/ProfileTab.ets create mode 100644 entry/src/main/ets/pages/sales/SalesHome.ets create mode 100644 entry/src/main/ets/pages/sales/SalesLeadDetail.ets create mode 100644 entry/src/main/ets/pages/workbench/Workbench.ets create mode 100644 entry/src/main/ets/utils/A2AServer.ts create mode 100644 entry/src/main/ets/utils/AlertSocket.ts create mode 100644 entry/src/main/ets/utils/AuthStore.ts create mode 100644 entry/src/main/ets/utils/CalendarHelper.ts create mode 100644 entry/src/main/ets/utils/FontScale.ts create mode 100644 entry/src/main/ets/utils/GrayScaleDates.ts create mode 100644 entry/src/main/ets/utils/HealthReportPdf.ts create mode 100644 entry/src/main/ets/utils/IntentEngine.ts create mode 100644 entry/src/main/ets/utils/KnockShare.ets create mode 100644 entry/src/main/ets/utils/LiveDataService.ets create mode 100644 entry/src/main/ets/utils/LiveViewUtil.ets create mode 100644 entry/src/main/ets/utils/LiveViewUtil1.ts create mode 100644 entry/src/main/ets/utils/LocalNotify.ts create mode 100644 entry/src/main/ets/utils/OcrHelper.ts create mode 100644 entry/src/main/ets/utils/OpenMeteo.ts create mode 100644 entry/src/main/ets/utils/PermissionUtil.ts create mode 100644 entry/src/main/ets/utils/PushManager.ts create mode 100644 entry/src/main/ets/utils/Request.ts create mode 100644 entry/src/main/ets/utils/TtsPlayer.ts create mode 100644 entry/src/main/ets/utils/VoiceCommand.ts create mode 100644 entry/src/main/ets/widget/EntryFormAbility.ets create mode 100644 entry/src/main/ets/widget/entry_card/EntryActCard.ets create mode 100644 entry/src/main/ets/widget/entry_card/EntryCard.ets create mode 100644 entry/src/main/ets/widget/entry_card/EntryOrderCard.ets create mode 100644 entry/src/main/ets/widget/entry_card/EntryRecCard.ets create mode 100644 entry/src/main/module.json5 create mode 100644 entry/src/main/resources/base/element/color.json create mode 100644 entry/src/main/resources/base/element/float.json create mode 100644 entry/src/main/resources/base/element/string.json create mode 100644 entry/src/main/resources/base/media/airport_icon.png create mode 100644 entry/src/main/resources/base/media/background.png create mode 100644 entry/src/main/resources/base/media/default_avatar.png create mode 100644 entry/src/main/resources/base/media/foreground.png create mode 100644 entry/src/main/resources/base/media/foreground1.png create mode 100644 entry/src/main/resources/base/media/layered_image.json create mode 100644 entry/src/main/resources/base/media/logo1.png create mode 100644 entry/src/main/resources/base/media/startIcon.png create mode 100644 entry/src/main/resources/base/profile/agent_config.json create mode 100644 entry/src/main/resources/base/profile/backup_config.json create mode 100644 entry/src/main/resources/base/profile/form_config.json create mode 100644 entry/src/main/resources/base/profile/main_pages.json create mode 100644 entry/src/main/resources/base/profile/shortcuts_config.json create mode 100644 entry/src/main/resources/dark/element/color.json create mode 100644 entry/src/mock/mock-config.json5 create mode 100644 entry/src/ohosTest/ets/test/Ability.test.ets create mode 100644 entry/src/ohosTest/ets/test/List.test.ets create mode 100644 entry/src/ohosTest/module.json5 create mode 100644 entry/src/test/List.test.ets create mode 100644 entry/src/test/LocalUnit.test.ets create mode 100644 hvigor/hvigor-config.json5 create mode 100644 hvigorfile.ts create mode 100644 oh-package-lock.json5 create mode 100644 oh-package.json5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c6cb06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +/node_modules +/oh_modules +/local.properties +/.idea +**/build +/.hvigor +/.codegenie +/demo +/backup_a2a_native +SESSION_LOG.md +build_err.txt +*.p12 +*.p7b +*.cer +*.jks +*.keystore +*.pem +.cxx +/.clangd +/.clang-format +/.clang-tidy +**/.test +/.appanalyzer +**/cj_res +**/*.cj.macrocall +**/ability_mainability_entry.cj +**/module_**_entry.cj +**/IDL_Dependencies_List~ +**/build-script-cache \ No newline at end of file diff --git a/AppScope/app.json5 b/AppScope/app.json5 new file mode 100644 index 0000000..fc12605 --- /dev/null +++ b/AppScope/app.json5 @@ -0,0 +1,15 @@ +{ + "app": { + "bundleName": "com.captain1275.smartseniorhmos", + "vendor": "CAPTAIN 1275", + "versionCode": 1000000, + "versionName": "1.0.0", + "buildVersion": "1", + "icon": "$media:layered_image", + "label": "$string:app_name", + "multiAppMode": { + "multiAppModeType": "appClone", + "maxCount": 2 + } + } +} diff --git a/AppScope/resources/base/element/string.json b/AppScope/resources/base/element/string.json new file mode 100644 index 0000000..39f0061 --- /dev/null +++ b/AppScope/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "app_name", + "value": "数智护老" + } + ] +} diff --git a/AppScope/resources/base/media/background.png b/AppScope/resources/base/media/background.png new file mode 100644 index 0000000000000000000000000000000000000000..f59cb9eb044746a53875ba64639a91bd81af16fd GIT binary patch literal 6536 zcmeAS@N?(olHy`uVBq!ia0y~yU;#2&7&zE~RK2WrGmv5|4sv&5Sa(k5B}gjW(btiI zVPik{pF~z5pR>RtvY3H^?=T269?xHq!oVOY>*?YcQW5v|#zIC01)c*2U;h0Uw^3gs zXlT1IJxvd&Ys2lI%pj_t5lAvHf-GZTkpPhb2_Vv;VN?Pb45L9Znizl~Fq%I=$zZf( z7%dZl$zZfh1Z9TN3Uai~Fj^*#W`ohD0jM+_Z5oU=4S>mDv}pj!45Lkh(Wb#@nK+sa yMw0qMm$~pT-G@yGywqNRe3T1 literal 0 HcmV?d00001 diff --git a/AppScope/resources/base/media/foreground.png b/AppScope/resources/base/media/foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..b2affffd13550ddbe2eb57cc81e834f6885bb573 GIT binary patch literal 14290 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 literal 0 HcmV?d00001 diff --git a/AppScope/resources/base/media/layered_image.json b/AppScope/resources/base/media/layered_image.json new file mode 100644 index 0000000..fb49920 --- /dev/null +++ b/AppScope/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/build-profile.json5 b/build-profile.json5 new file mode 100644 index 0000000..47e45ee --- /dev/null +++ b/build-profile.json5 @@ -0,0 +1,59 @@ +{ + "app": { + "products": [ + { + "name": "default", + "targetSdkVersion": "26.0.0", + "compatibleSdkVersion": "6.1.1(24)", + "runtimeOS": "HarmonyOS", + "buildOption": { + "strictMode": { + "caseSensitiveCheck": true, + "useNormalizedOHMUrl": true + } + } + } + ], + "buildModeSet": [ + { + "name": "debug" + }, + { + "name": "release" + } + ], + "capabilities": [ + { + "bundleName": "com.captain1275.smartseniorhmos", + "config": [ + { + "capability": "com.huawei.service.push.base_service", + "name": "Push Kit" + }, + { + "capability": "com.huawei.service.mapkit", + "name": "Map Kit" + } + ] + } + ] + }, + "modules": [ + { + "name": "entry", + "srcPath": "./entry", + "targets": [ + { + "name": "default", + "applyToProducts": [ + "default" + ] + } + ] + }, + { + "name": "cjagent", + "srcPath": "./cjagent" + } + ] +} diff --git a/cjagent/.gitignore b/cjagent/.gitignore new file mode 100644 index 0000000..972768f --- /dev/null +++ b/cjagent/.gitignore @@ -0,0 +1,8 @@ +/node_modules +/oh_modules +/.preview +/build +/.cxx +/.test +**/IDL_Dependencies_List~ +**/build-script-cache \ No newline at end of file diff --git a/cjagent/BuildProfile.ets b/cjagent/BuildProfile.ets new file mode 100644 index 0000000..3a501e5 --- /dev/null +++ b/cjagent/BuildProfile.ets @@ -0,0 +1,17 @@ +/** + * Use these variables when you tailor your ArkTS code. They must be of the const type. + */ +export const HAR_VERSION = '1.0.0'; +export const BUILD_MODE_NAME = 'debug'; +export const DEBUG = true; +export const TARGET_NAME = 'default'; + +/** + * BuildProfile Class is used only for compatibility purposes. + */ +export default class BuildProfile { + static readonly HAR_VERSION = HAR_VERSION; + static readonly BUILD_MODE_NAME = BUILD_MODE_NAME; + static readonly DEBUG = DEBUG; + static readonly TARGET_NAME = TARGET_NAME; +} \ No newline at end of file diff --git a/cjagent/Index.d.ets b/cjagent/Index.d.ets new file mode 100644 index 0000000..e69de29 diff --git a/cjagent/build-profile.json5 b/cjagent/build-profile.json5 new file mode 100644 index 0000000..a96c4d6 --- /dev/null +++ b/cjagent/build-profile.json5 @@ -0,0 +1,21 @@ +{ + "apiType": "stageMode", + "buildOption": { + "cangjieOptions": { + "path": "./cjpm.toml", + "abiFilters": ["arm64-v8a","x86_64"] + }, + "nativeLib": { + "filter": { + "enableOverride": true + } + } + }, + "buildOptionSet": [ + ], + "targets": [ + { + "name": "default" + } + ] +} \ No newline at end of file diff --git a/cjagent/cjpm.lock b/cjagent/cjpm.lock new file mode 100644 index 0000000..c42311c --- /dev/null +++ b/cjagent/cjpm.lock @@ -0,0 +1,3 @@ +version = 0 + +[requires] diff --git a/cjagent/cjpm.toml b/cjagent/cjpm.toml new file mode 100644 index 0000000..8537aa1 --- /dev/null +++ b/cjagent/cjpm.toml @@ -0,0 +1,37 @@ +[package] + cjc-version = "1.2.0" + compile-option = "--dy-std --cfg=\"${COMPILE_CONDITION}\"" + override-compile-option = "${OVERRIDE_COMPILE_OPTION}" + description = "CangjieUI Application" + name = "ohos_app_cangjie_cjagent" + output-type = "dynamic" + src-dir = "./src/main/cangjie" + target-dir = "" + version = "1.0.0" + +[profile] + [profile.build] + incremental = true + lto = "" + [profile.build.combined] + ohos_app_cangjie_cjagent = "dynamic" + [profile.customized-option] + debug = "-g -Woff all -Won apilevel-check" + release = "--fast-math -O2 -s -Woff all -Won apilevel-check" + asan = "--sanitize=address -lclang_rt.asan" + [profile.test] + +[target.aarch64-linux-ohos] + compile-option = "-B \"${DEVECO_CANGJIE_HOME}/build-tools/third_party/llvm/bin\" -B \"${DEVECO_OH_NATIVE_HOME}/sysroot/usr/lib/aarch64-linux-ohos\" -L \"${DEVECO_OH_NATIVE_HOME}/sysroot/usr/lib/aarch64-linux-ohos\" -L \"${DEVECO_OH_NATIVE_HOME}/llvm/lib/clang/15.0.4/lib/aarch64-linux-ohos\" -L \"${DEVECO_OH_NATIVE_HOME}/llvm/lib/aarch64-linux-ohos\" --sysroot \"${DEVECO_OH_NATIVE_HOME}/sysroot\"" + [target.aarch64-linux-ohos.bin-dependencies] + path-option = ["${AARCH64_LIBS}", "${AARCH64_MACRO_LIBS}", "${AARCH64_KIT_LIBS}"] + package-option = { } + +[target.x86_64-linux-ohos] + compile-option = "-B \"${DEVECO_CANGJIE_HOME}/build-tools/third_party/llvm/bin\" -B \"${DEVECO_OH_NATIVE_HOME}/sysroot/usr/lib/x86_64-linux-ohos\" -L \"${DEVECO_OH_NATIVE_HOME}/sysroot/usr/lib/x86_64-linux-ohos\" -L \"${DEVECO_OH_NATIVE_HOME}/llvm/lib/clang/15.0.4/lib/x86_64-linux-ohos\" -L \"${DEVECO_OH_NATIVE_HOME}/llvm/lib/x86_64-linux-ohos\" --sysroot \"${DEVECO_OH_NATIVE_HOME}/sysroot\"" + [target.x86_64-linux-ohos.bin-dependencies] + path-option = ["${X86_64_OHOS_LIBS}", "${X86_64_OHOS_MACRO_LIBS}", "${X86_64_OHOS_KIT_LIBS}"] + +[target.x86_64-w64-mingw32.bin-dependencies] + path-option = ["${X86_64_LIBS}", "${X86_64_MACRO_LIBS}", "${X86_64_KIT_LIBS}"] + package-option = { } diff --git a/cjagent/cjpm_log.txt b/cjagent/cjpm_log.txt new file mode 100644 index 0000000..83caabc --- /dev/null +++ b/cjagent/cjpm_log.txt @@ -0,0 +1,8 @@ +cjpm.exe : Warning: field 'profile.build.combined' in '.\cjpm.toml' is experimental, it is unstable and has some limita +tions, please refer to related documentation for more details +所在位置 行:4 字符: 582 ++ ... t=default"; & "$sdk\build-tools\tools\bin\cjpm.exe" build --target=aa ... ++ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + CategoryInfo : NotSpecified: (Warning: field ...or more details:String) [], RemoteException + + FullyQualifiedErrorId : NativeCommandError + diff --git a/cjagent/hvigorfile.ts b/cjagent/hvigorfile.ts new file mode 100644 index 0000000..4218707 --- /dev/null +++ b/cjagent/hvigorfile.ts @@ -0,0 +1,6 @@ +import { harTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: harTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins:[] /* Custom plugin to extend the functionality of Hvigor. */ +} diff --git a/cjagent/oh-package.json5 b/cjagent/oh-package.json5 new file mode 100644 index 0000000..493d5c6 --- /dev/null +++ b/cjagent/oh-package.json5 @@ -0,0 +1,11 @@ +{ + "name": "cjagent", + "version": "1.0.0", + "description": "Please describe the basic information.", + "main": "", + "author": "", + "types": "./Index.d.ets", + "license": "Apache-2.0", + "dependencies": { + } +} diff --git a/cjagent/src/main/cangjie/IDL_Custom_Type_Whitelist~ b/cjagent/src/main/cangjie/IDL_Custom_Type_Whitelist~ new file mode 100644 index 0000000..e69de29 diff --git a/cjagent/src/main/cangjie/ark_interop_api/ark_interop_api.d.ts b/cjagent/src/main/cangjie/ark_interop_api/ark_interop_api.d.ts new file mode 100644 index 0000000..a6c84af --- /dev/null +++ b/cjagent/src/main/cangjie/ark_interop_api/ark_interop_api.d.ts @@ -0,0 +1,9 @@ +export declare interface CustomLib { + parseIntent(text: string): string + cjAgentVersion(): string + sm4EncryptText(keyHex: string, plain: string): string + sm4DecryptText(keyHex: string, cipherHex: string): string + sm4SelfTest(): boolean + splitIntents(text: string): string + inferSlot(hour: number): string +} diff --git a/cjagent/src/main/cangjie/ark_interop_api/oh-package.json5 b/cjagent/src/main/cangjie/ark_interop_api/oh-package.json5 new file mode 100644 index 0000000..678a03f --- /dev/null +++ b/cjagent/src/main/cangjie/ark_interop_api/oh-package.json5 @@ -0,0 +1,6 @@ +{ + "name": "libark_interop_api.so", + "types": "./ark_interop_api.d.ts", + "version": "1.0.0", + "description": "" +} \ No newline at end of file diff --git a/cjagent/src/main/cangjie/index.cj b/cjagent/src/main/cangjie/index.cj new file mode 100644 index 0000000..da2c3fa --- /dev/null +++ b/cjagent/src/main/cangjie/index.cj @@ -0,0 +1,7 @@ +package ohos_app_cangjie_cjagent + +import kit.PerformanceAnalysisKit.Hilog + +public func sayHello(): Unit { + Hilog.info(0, "Cangjie", "Hello Cangjie!") +} diff --git a/cjagent/src/main/cangjie/intent.cj b/cjagent/src/main/cangjie/intent.cj new file mode 100644 index 0000000..6ee7ad2 --- /dev/null +++ b/cjagent/src/main/cangjie/intent.cj @@ -0,0 +1,294 @@ +package ohos_app_cangjie_cjagent + +// 数智护老 · 仓颉意图引擎 +// 输入小艺转发的用户文本,输出结构化意图 JSON:{"action":"...","slot":"...","date":"...","jump":"..."} +// 纯逻辑零依赖:只做文本解析,后端调用仍由 ArkTS 侧执行(业务零风险) + +import ohos.ark_interop.* +import ohos.ark_interop_macro.* +import kit.PerformanceAnalysisKit.Hilog +import std.collection.ArrayList + +// 日志过滤约定:仓颉侧统一用 Domain=0xC1C1 Tag=CJCJAGENT +let CJ_DOMAIN: UInt32 = 0xC1C1 +let CJ_TAG: String = "CJCJAGENT" + +// 包含判断的小工具 +func has(text: String, key: String): Bool { + return text.contains(key) +} + +// ASR 转写容错:方言/口音经小艺转写后常见错别字归一 +// 例:四川话"定个餐"→"定餐","叁"被听成数字三等 +let ASR_FIXES: Array> = [ + ["定餐", "订餐"], ["定饭", "订饭"], ["定个", "订个"], ["定明", "订明"], + ["订叁", "订餐"], ["订叄", "订餐"], ["订散", "订餐"], + ["午叁", "午餐"], ["晚叁", "晚餐"], ["早叁", "早餐"], + ["天汽", "天气"], ["查寻", "查询"], ["枢查", "查询"], + ["抱告", "报告"], ["健慷", "健康"] +] + +func normalizeAsr(text: String): String { + var out = text + for (pair in ASR_FIXES) { + if (pair.size >= 2 && out.contains(pair[0])) { + out = out.replace(pair[0], pair[1]) + } + } + return out +} + +func detectSlot(text: String): String { + if (has(text, "早")) { + return "breakfast" + } + if (has(text, "晚")) { + return "dinner" + } + return "" +} + +func detectDate(text: String): String { + if (has(text, "明天") || has(text, "明日")) { + return "tomorrow" + } + return "today" +} + +// 跳转页面判断 +func detectJump(text: String): String { + if (has(text, "天气") || has(text, "气温") || has(text, "下雨")) { + return "pages/elderly/ElderlyWeather" + } + if (has(text, "活动") || has(text, "八段锦") || has(text, "健身")) { + return "pages/elderly/Activities" + } + if ((has(text, "订餐") && has(text, "看")) || has(text, "菜单")) { + return "pages/elderly/ElderlyMealOrder" + } + return "" +} + +// 主意图分类:直接返回动作名(避免枚举相等性比较) +func classify(text: String): String { + // 时间/日期问答:纯本地播报 + if (has(text, "几点") || has(text, "时间") || has(text, "几号") || has(text, "几月") + || has(text, "星期几") || has(text, "周几") || has(text, "日期")) { + return "time" + } + // 菜单查询:今天有什么餐/吃什么("看菜单"仍走页面跳转) + if (has(text, "有什么餐") || has(text, "有什么菜") || has(text, "有什么吃的") + || (has(text, "吃什么") && !has(text, "订")) || (has(text, "菜单") && !has(text, "看"))) { + return "menu" + } + // 活动查询:有什么活动/活动安排 + if (has(text, "活动") && !has(text, "看") && !has(text, "打开") && !has(text, "去")) { + return "activities" + } + // 跳转类优先(看天气/看活动/看菜单) + if (!detectJump(text).isEmpty()) { + return "jump" + } + // 问候:仍走 ArkTS 侧(带时段个性化),此处归 fallback + if (has(text, "你好") || has(text, "您好") || has(text, "早上好") || has(text, "下午好") + || has(text, "晚上好") || has(text, "嗨") || text == "在吗") { + return "fallback" + } + let wantCancel = has(text, "取消") || has(text, "退订") || has(text, "不订") + || has(text, "不要") || has(text, "不吃了") + let mealWord = has(text, "餐") || has(text, "饭") || has(text, "订") || has(text, "吃") + // 状态查询:先于下单/取消判断,避免"我订了什么"被当成下单 + let statusWord = has(text, "状态") || has(text, "订了") || has(text, "订没订") + || has(text, "订的") || has(text, "订好") || has(text, "记录") || has(text, "查询") + || has(text, "查查") || has(text, "查一下") || has(text, "哪些") || has(text, "吃什么") + || (has(text, "看看") && has(text, "订")) + if (mealWord && statusWord && !wantCancel) { + return "status" + } + if (wantCancel) { + return "cancel" + } + // 跨业务编排 回家 + if (has(text, "回家") || has(text, "回家去") || has(text, "回家里")) { + return "gohome" + } + // 下单 + if (has(text, "回家") || has(text, "回家去") || has(text, "回家里")) { + return "gohome" + } + // 求助 + if (has(text, "护士") || has(text, "护理员") || has(text, "救命") || has(text, "紧急") + || has(text, "不舒服") || has(text, "摔倒") || has(text, "疼") || has(text, "头晕")) { + return "help" + } + // AI 健康报告 + if (has(text, "报告") && (has(text, "健康") || has(text, "看") || has(text, "查"))) { + return "report" + } + return "fallback" +} + +// JSON 字符串转义(按 Rune 处理,中文原样保留) +func jsonEscape(s: String): String { + var out = "" + for (r in s.runes()) { + if (r == r'"') { + out = out + "\\\"" + } else if (r == r'\\') { + out = out + "\\\\" + } else if (r == r'\n') { + out = out + "\\n" + } else { + out = out + r.toString() + } + } + return out +} + +//导出给ArkTS调用 +@Interop[ArkTS] +public func parseIntent(text: String): String { + let normalized = normalizeAsr(text) + let action = classify(normalized) + var jump = "" + if (action == "jump") { + jump = detectJump(normalized) + } + var slot = "" + var date = "" + if (action == "order" || action == "cancel" || action == "status") { + slot = detectSlot(normalized) + date = detectDate(normalized) + } + let result = "{\"action\":\"" + action + + "\",\"slot\":\"" + slot + + "\",\"date\":\"" + date + + "\",\"jump\":\"" + jsonEscape(jump) + + "\"}" + Hilog.info(CJ_DOMAIN, CJ_TAG, "parseIntent input=<${text}> result=<${result}>") + return result +} + +// 餐次推断:按当前小时推断该订/取消哪一餐(订餐决策链下沉仓颉) +// 9 点前早餐、9-15 点午餐、15 点后晚餐;返回 JSON 保持与 parseIntent 同构的互操作风格 +@Interop[ArkTS] +public func inferSlot(hour: Int64): String { + var slot = "lunch" + if (hour < 9) { + slot = "breakfast" + } else if (hour >= 15) { + slot = "dinner" + } + let result = "{\"slot\":\"" + slot + "\"}" + Hilog.info(CJ_DOMAIN, CJ_TAG, "inferSlot hour=<" + hour.toString() + "> result=<" + result + ">") + return result +} + +// 健康检查:ArkTS 侧启动时调用,确认仓颉 so 加载成功 +@Interop[ArkTS] +public func cjAgentVersion(): String { + return "cjagent-1.1.0" +} + +// ── 多意图拆分 ── +// "订明天的晚饭,然后看看活动" → ["订明天的晚饭", "看看活动"] +// 拆分依据:标点(,。!?;、)与连接词(然后/接着/再/还/另外/并且/以及/顺便/同时) + +func toRunes(s: String): ArrayList { + let out = ArrayList() + for (r in s.runes()) { + out.add(r) + } + return out +} + +func runeEqAt(hay: ArrayList, needle: ArrayList, off: Int64): Bool { + if (off + needle.size > hay.size) { + return false + } + var i: Int64 = 0 + while (i < needle.size) { + if (hay[off + i] != needle[i]) { + return false + } + i = i + 1 + } + return true +} + +func isPunct(r: Rune): Bool { + return r == r',' || r == r'。' || r == r'!' || r == r'?' || r == r';' + || r == r'、' || r == r',' || r == r'.' || r == r'!' || r == r'?' +} + +let CONNECTORS: Array> = [ + toRunes("然后"), toRunes("接着"), toRunes("再帮我"), toRunes("帮我再"), + toRunes("另外"), toRunes("并且"), toRunes("以及"), toRunes("顺便"), + toRunes("同时"), toRunes("还要"), toRunes("再") +] + +// 主入口:返回 {"segments":[...]};单意图时 segments 只有原文一段 +@Interop[ArkTS] +public func splitIntents(text: String): String { + let rs = toRunes(text) + let parts = ArrayList() + var buf = "" + var i: Int64 = 0 + while (i < rs.size) { + let ch = rs[i] + if (isPunct(ch)) { + if (!buf.isEmpty()) { + parts.add(buf) + buf = "" + } + i = i + 1 + continue + } + var matched = false + var connLen: Int64 = 0 + for (c in CONNECTORS) { + if (runeEqAt(rs, c, i)) { + matched = true + connLen = c.size + break + } + } + if (matched) { + if (!buf.isEmpty()) { + parts.add(buf) + buf = "" + } + i = i + connLen + continue + } + buf = buf + ch.toString() + i = i + 1 + } + if (!buf.isEmpty()) { + parts.add(buf) + } + // 过滤过短片段(多半是"再""还"残余),最多取 3 段 + let segs = ArrayList() + for (p in parts) { + if (segs.size < 3 && p.size >= 2) { + segs.add(p) + } + } + var out = "{\"segments\":[" + var n: Int64 = 0 + if (segs.size <= 1) { + out = out + "\"" + jsonEscape(text) + "\"" + n = 1 + } else { + for (s in segs) { + if (n > 0) { + out = out + "," + } + out = out + "\"" + jsonEscape(s) + "\"" + n = n + 1 + } + } + out = out + "]}" + Hilog.info(CJ_DOMAIN, CJ_TAG, "splitIntents input=<${text}> segments=<${out}>") + return out +} diff --git a/cjagent/src/main/cangjie/types/libohos_app_cangjie_cjagent/Index.d.ts b/cjagent/src/main/cangjie/types/libohos_app_cangjie_cjagent/Index.d.ts new file mode 100644 index 0000000..4fc9be5 --- /dev/null +++ b/cjagent/src/main/cangjie/types/libohos_app_cangjie_cjagent/Index.d.ts @@ -0,0 +1,8 @@ + +export declare function parseIntent(text: string): string + +export declare function cjAgentVersion(): string + +export declare function splitIntents(text: string): string + +export declare function inferSlot(hour: number): string diff --git a/cjagent/src/main/cangjie/types/libohos_app_cangjie_cjagent/oh-package.json5 b/cjagent/src/main/cangjie/types/libohos_app_cangjie_cjagent/oh-package.json5 new file mode 100644 index 0000000..f640377 --- /dev/null +++ b/cjagent/src/main/cangjie/types/libohos_app_cangjie_cjagent/oh-package.json5 @@ -0,0 +1,6 @@ +{ + "name": "libohos_app_cangjie_cjagent.so", + "types": "./Index.d.ts", + "version": "1.0.0", + "description": "" +} \ No newline at end of file diff --git a/cjagent/src/main/module.json5 b/cjagent/src/main/module.json5 new file mode 100644 index 0000000..a88d2d6 --- /dev/null +++ b/cjagent/src/main/module.json5 @@ -0,0 +1,10 @@ +{ + "module": { + "name": "cjagent", + "type": "har", + "deviceTypes": [ + "default", + "tablet" + ] + } +} diff --git a/cjagent/src/main/resources/base/element/string.json b/cjagent/src/main/resources/base/element/string.json new file mode 100644 index 0000000..c7473b1 --- /dev/null +++ b/cjagent/src/main/resources/base/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "page_show", + "value": "page from npm package" + } + ] +} \ No newline at end of file diff --git a/cjagent/src/main/resources/en_US/element/string.json b/cjagent/src/main/resources/en_US/element/string.json new file mode 100644 index 0000000..c7473b1 --- /dev/null +++ b/cjagent/src/main/resources/en_US/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "page_show", + "value": "page from npm package" + } + ] +} \ No newline at end of file diff --git a/cjagent/src/main/resources/zh_CN/element/string.json b/cjagent/src/main/resources/zh_CN/element/string.json new file mode 100644 index 0000000..c7473b1 --- /dev/null +++ b/cjagent/src/main/resources/zh_CN/element/string.json @@ -0,0 +1,8 @@ +{ + "string": [ + { + "name": "page_show", + "value": "page from npm package" + } + ] +} \ No newline at end of file diff --git a/cjagent/src/ohosTest/cangjie/ability_stage.cj b/cjagent/src/ohosTest/cangjie/ability_stage.cj new file mode 100644 index 0000000..261ebb3 --- /dev/null +++ b/cjagent/src/ohosTest/cangjie/ability_stage.cj @@ -0,0 +1,10 @@ +package ohos_app_cangjie_cjagent_test + +import kit.AbilityKit.AbilityStage +import kit.PerformanceAnalysisKit.Hilog + +class MyAbilityStage <: AbilityStage { + public override func onCreate(): Unit { + Hilog.info(1, "Cangjie", "MyAbilityStage onCreated.") + } +} diff --git a/cjagent/src/ohosTest/cangjie/cjpm.toml b/cjagent/src/ohosTest/cangjie/cjpm.toml new file mode 100644 index 0000000..21f6196 --- /dev/null +++ b/cjagent/src/ohosTest/cangjie/cjpm.toml @@ -0,0 +1,44 @@ +[package] + cjc-version = "1.2.0" + compile-option = "--dy-std --cfg=\"${COMPILE_CONDITION}\"" + override-compile-option = "${OVERRIDE_COMPILE_OPTION}" + description = "CangjieUI Application" + link-option = "--unresolved-symbols=ignore-all" + name = "ohos_app_cangjie_cjagent_test" + output-type = "dynamic" + src-dir = "." + target-dir = "" + version = "1.0.0" + +[dependencies] + [dependencies.ohos_app_cangjie_cjagent] + path = "../../../" + +[profile] + [profile.build] + incremental = true + lto = "" + [profile.build.combined] + ohos_app_cangjie_cjagent_test = "dynamic" + [profile.customized-option] + debug = "-g -Woff all -Won apilevel-check" + release = "--fast-math -O2 -s -Woff all -Won apilevel-check" + withoutCoverage = "--cfg=\"coverage=false\"" + withCoverage = "--cfg=\"coverage=true\"" + asan = "--sanitize=address -lclang_rt.asan" + [profile.test] + +[target.aarch64-linux-ohos] + compile-option = "-B \"${DEVECO_CANGJIE_HOME}/build-tools/third_party/llvm/bin\" -B \"${DEVECO_OH_NATIVE_HOME}/sysroot/usr/lib/aarch64-linux-ohos\" -L \"${DEVECO_OH_NATIVE_HOME}/sysroot/usr/lib/aarch64-linux-ohos\" -L \"${DEVECO_OH_NATIVE_HOME}/llvm/lib/clang/15.0.4/lib/aarch64-linux-ohos\" -L \"${DEVECO_OH_NATIVE_HOME}/llvm/lib/aarch64-linux-ohos\" --sysroot \"${DEVECO_OH_NATIVE_HOME}/sysroot\"" + [target.aarch64-linux-ohos.bin-dependencies] + path-option = ["${AARCH64_LIBS}", "${AARCH64_MACRO_LIBS}", "${AARCH64_KIT_LIBS}"] + package-option = { } + +[target.x86_64-linux-ohos] + compile-option = "-B \"${DEVECO_CANGJIE_HOME}/build-tools/third_party/llvm/bin\" -B \"${DEVECO_OH_NATIVE_HOME}/sysroot/usr/lib/x86_64-linux-ohos\" -L \"${DEVECO_OH_NATIVE_HOME}/sysroot/usr/lib/x86_64-linux-ohos\" -L \"${DEVECO_OH_NATIVE_HOME}/llvm/lib/clang/15.0.4/lib/x86_64-linux-ohos\" -L \"${DEVECO_OH_NATIVE_HOME}/llvm/lib/x86_64-linux-ohos\" --sysroot \"${DEVECO_OH_NATIVE_HOME}/sysroot\"" + [target.x86_64-linux-ohos.bin-dependencies] + path-option = ["${X86_64_OHOS_LIBS}", "${X86_64_OHOS_MACRO_LIBS}", "${X86_64_OHOS_KIT_LIBS}"] + +[target.x86_64-w64-mingw32.bin-dependencies] + path-option = ["${X86_64_LIBS}", "${X86_64_MACRO_LIBS}", "${X86_64_KIT_LIBS}"] + package-option = { } diff --git a/cjagent/src/ohosTest/cangjie/example.cj b/cjagent/src/ohosTest/cangjie/example.cj new file mode 100644 index 0000000..00656b9 --- /dev/null +++ b/cjagent/src/ohosTest/cangjie/example.cj @@ -0,0 +1,14 @@ +/** + * Created on 2025/2/26 + */ +package ohos_app_cangjie_cjagent_test + +import std.unittest.expectEqual +import std.unittest.testmacro.Expect + +func unittest() { + let a = "abc" + let b = "b" + @Expect(a.contains(b)) + @Expect(a, a) +} \ No newline at end of file diff --git a/cjagent/src/ohosTest/cangjie/example_test.cj b/cjagent/src/ohosTest/cangjie/example_test.cj new file mode 100644 index 0000000..90a7f98 --- /dev/null +++ b/cjagent/src/ohosTest/cangjie/example_test.cj @@ -0,0 +1,17 @@ +package ohos_app_cangjie_cjagent_test + +import std.unittest.TestClass +import std.unittest.TestPackage +import std.unittest.TestSuite +import std.unittest.UnitTestCase +import std.unittest.common.Configuration +import std.unittest.testmacro.Test +import std.unittest.testmacro.TestCase + +@Test +class TestExample00 { + @TestCase + func test00(): Unit { + unittest() + } +} diff --git a/cjagent/src/ohosTest/cangjie/index.cj b/cjagent/src/ohosTest/cangjie/index.cj new file mode 100644 index 0000000..ad200ac --- /dev/null +++ b/cjagent/src/ohosTest/cangjie/index.cj @@ -0,0 +1,36 @@ +package ohos_app_cangjie_cjagent_test + +import kit.ArkUI.LengthProp +import kit.ArkUI.Column +import kit.ArkUI.Row +import kit.ArkUI.Text +import kit.ArkUI.CustomView +import kit.ArkUI.CJEntry +import kit.ArkUI.loadNativeView +import kit.ArkUI.FontWeight +import kit.ArkUI.SubscriberManager +import kit.ArkUI.ObservedProperty +import kit.ArkUI.LocalStorage +import ohos.arkui.state_macro_manage.Entry +import ohos.arkui.state_macro_manage.Component +import ohos.arkui.state_macro_manage.State + +@Entry +@Component +class CjagentView { + @State + var message: String = "Hello World" + + func build() { + Row { + Column { + Text(this.message) + .fontSize(50) + .fontWeight(FontWeight.Bold) + .onClick ({ + evt => this.message = "Hello Cangjie" + }) + }.width(100.percent) + }.height(100.percent) + } +} diff --git a/cjagent/src/ohosTest/cangjie/list_test.cj b/cjagent/src/ohosTest/cangjie/list_test.cj new file mode 100644 index 0000000..053a8d4 --- /dev/null +++ b/cjagent/src/ohosTest/cangjie/list_test.cj @@ -0,0 +1,9 @@ +package ohos_app_cangjie_cjagent_test + +import ohos_app_cangjie_cjagent_test.unittest_support.registerTestSuite + +private let register = testsuite() + +func testsuite() { + registerTestSuite {TestExample00()} +} diff --git a/cjagent/src/ohosTest/cangjie/main_ability.cj b/cjagent/src/ohosTest/cangjie/main_ability.cj new file mode 100644 index 0000000..2fe8865 --- /dev/null +++ b/cjagent/src/ohosTest/cangjie/main_ability.cj @@ -0,0 +1,28 @@ +package ohos_app_cangjie_cjagent_test + +import kit.PerformanceAnalysisKit.Hilog +import kit.AbilityKit.Want +import kit.AbilityKit.UIAbility +import kit.AbilityKit.LaunchParam +import kit.AbilityKit.LaunchReason +import kit.ArkUI.WindowStage + +class MainAbility <: UIAbility { + public init() { + super() + registerSelf() + } + + public override func onCreate(want: Want, launchParam: LaunchParam): Unit { + Hilog.info(1, "Cangjie", "MainAbility OnCreated.${want.abilityName}") + match (launchParam.launchReason) { + case LaunchReason.StartAbility => Hilog.info(1, "Cangjie", "START_ABILITY") + case _ => () + } + } + + public override func onWindowStageCreate(windowStage: WindowStage): Unit { + Hilog.info(1, "Cangjie", "MainAbility onWindowStageCreate.") + windowStage.loadContent("CjagentView") + } +} diff --git a/cjagent/src/ohosTest/cangjie/unittest_support/unittest_engine.cj b/cjagent/src/ohosTest/cangjie/unittest_support/unittest_engine.cj new file mode 100644 index 0000000..d107de1 --- /dev/null +++ b/cjagent/src/ohosTest/cangjie/unittest_support/unittest_engine.cj @@ -0,0 +1,269 @@ +package ohos_app_cangjie_cjagent_test.unittest_support + +import std.fs.Path +import std.fs.Directory +import std.collection.filter +import std.fs.FileInfo +import std.collection.first +import std.fs.File +import std.collection.forEach +import std.convert.Parsable +import std.collection.any +import std.collection.HashMap +import std.collection.ArrayList +import std.unittest.common.Configuration +import std.unittest.TestClass +import std.unittest.TestSuite +import std.unittest.XmlReporter +import kit.AbilityKit.BundleManager +import kit.AbilityKit.BundleFlag +import kit.AbilityKit.Want +import kit.PerformanceAnalysisKit.Hilog +import kit.TestKit.AbilityDelegator +import kit.TestKit.AbilityDelegatorRegistry +import kit.TestKit.TestRunner +import std.env.setVariable + +@When[coverage == "true"] +foreign func __gcov_dump(): Unit + +@When[coverage == "true"] +public func gcov_dump(coveragePath: String): Unit { + Hilog.info(0, "Cangjie-Test", "execute test with coverage") + unsafe { __gcov_dump() } + let abilityDelegator = AbilityDelegatorRegistry.getAbilityDelegator() + abilityDelegator.print("GENERATE_COVERAGE: ${coveragePath}") +} + +@When[coverage == "false"] +public func gcov_dump(coveragePath: String): Unit { + Hilog.info(0, "Cangjie-Test", "execute test without coverage") +} + +public func registerTestSuite(testClassInstanceGenerator: () -> TestClass): Unit { + OpenHarmonyTestRunner + .testClassInstanceGenerators + .add(testClassInstanceGenerator) +} + +public class OpenHarmonyTestRunner <: TestRunner { + public static let testSuiteMap = HashMap() + public static let testClassInstanceGenerators = ArrayList<() -> TestClass>() + private static func getDetailedInfoOf(e: Exception): String { + let sb = StringBuilder("exception: ${e.toString()}\n") + e.getStackTrace() |> + forEach { + s => sb.append(" at ${s.declaringClass}.${s.methodName}(${s.fileName}:${s.lineNumber})\n") + } + sb.toString() + } + + private static func joinStrings(list: ArrayList, separator: String): String { + if (list.isEmpty()) { + return "" + } + let sb = StringBuilder() + for (i in 0..list.size) { + if (i > 0) { + sb.append(separator) + } + sb.append(list[i]) + } + sb.toString() + } + + private func executeTestClass(testClassName: String): Unit { + let abilityDelegator = AbilityDelegatorRegistry.getAbilityDelegator() + Hilog.info(0, "Cangjie-Test", "EXECUTE_CLASS_START: \"${testClassName}\"") + abilityDelegator.print("EXECUTE_CLASS_START: \"${testClassName}\"") + let testSuite = match (OpenHarmonyTestRunner + .testSuiteMap + .get(testClassName)) { + case None => return + case Some(testClassInstanceGenerator) => testClassInstanceGenerator + } + + testSuite + .runTests() + .reportTo(XmlReporter(Path("/data/storage/el2/base"))) + + let testReportXmlFileName = "test-default.${testClassName}.xml" + match (Directory.readFrom("/data/storage/el2/base/tests") |> + filter {fileInfo => fileInfo.name == testReportXmlFileName} |> first) { + case None => Hilog.error(1, "Cangjie-Test", "${testReportXmlFileName} not found.") + case Some(testReportXmlFileInfo) => + let testReportXmlFilePath = testReportXmlFileInfo.path + let testReportXmlFileContent = File.readFrom(testReportXmlFilePath) + let xmlReport = String.fromUtf8(testReportXmlFileContent) + reportTestReport("test-default.${testClassName}.xml") + Hilog.info(0, "Cangjie-Test", "XML_REPORT_CONTENT_LENGTH: ${xmlReport.size}") + abilityDelegator.print("XML_REPORT_CONTENT_LENGTH: ${xmlReport.size}") + splitPrint(xmlReport, abilityDelegator) + Hilog.info(0, "Cangjie-Test", "EXECUTE_CLASS_END: \"${testClassName}\"") + abilityDelegator.print("EXECUTE_CLASS_END: \"${testClassName}\"") + } + } + private func executeAllTestClasses(): Unit { + for (testClassName in OpenHarmonyTestRunner + .testSuiteMap + .keys()) { + executeTestClass(testClassName) + } + } + private func executeTestCase(testClassName: String, testCaseName: String): Unit { + let abilityDelegator = AbilityDelegatorRegistry.getAbilityDelegator() + abilityDelegator.print("EXECUTE_CLASS_START: \"${testClassName}\"") + Hilog.info(0, "Cangjie-Test", "EXECUTE_METHOD_START: \"${testClassName}#${testCaseName}\"") + let testSuite = match (OpenHarmonyTestRunner + .testSuiteMap + .get(testClassName)) { + case None => return + case Some(testClassInstanceGenerator) => testClassInstanceGenerator + } + let configuration = Configuration() + configuration.setByName("filter", "${testClassName}.${testCaseName}") + testSuite + .runTests(configuration) + .reportTo(XmlReporter(Path("/data/storage/el2/base"))) + + let testReportXmlFileName = "test-default.${testClassName}.xml" + match (Directory.readFrom("/data/storage/el2/base/tests") |> + filter {fileInfo => fileInfo.name == testReportXmlFileName} |> first) { + case None => Hilog.error(1, "Cangjie-Test", "${testReportXmlFileName} not found.") + case Some(testReportXmlFileInfo) => + let testReportXmlFilePath = testReportXmlFileInfo.path + let testReportXmlFileContent = File.readFrom(testReportXmlFilePath) + let xmlReport = String.fromUtf8(testReportXmlFileContent) + reportTestReport("test-default.${testClassName}.xml") + Hilog.info(0, "Cangjie-Test", "XML_REPORT_CONTENT_LENGTH: ${xmlReport.size}") + abilityDelegator.print("XML_REPORT_CONTENT_LENGTH: ${xmlReport.size}") + splitPrint(xmlReport, abilityDelegator) + Hilog.info(0, "Cangjie-Test", "EXECUTE_METHOD_END: \"${testClassName}#${testCaseName}\"") + abilityDelegator.print("EXECUTE_CLASS_END: \"${testClassName}\"") + } + } + private func splitPrint(xmlReport: String, abilityDelegator: AbilityDelegator): Unit { + let lines = xmlReport.split("\n", removeEmpty: true) + let tempArray = ArrayList() + for (line in lines) { + let trimmedLine = line.trimAscii() + if (trimmedLine.isEmpty()) { + continue + } + tempArray.add(line) + if (tempArray.size == 10) { + let output = OpenHarmonyTestRunner.joinStrings(tempArray, "\n") + Hilog.error(0, "Cangjie-Test", output) + abilityDelegator.print(output) + tempArray.clear() + } + } + if (tempArray.size > 0) { + let output = OpenHarmonyTestRunner.joinStrings(tempArray, "\n") + Hilog.error(0, "Cangjie-Test", output) + abilityDelegator.print(output) + } + } + private func reportTestReport(testReportName: String): Unit { + let abilityDelegator = AbilityDelegatorRegistry.getAbilityDelegator() + let abilityDelegatorArguments = AbilityDelegatorRegistry.getArguments() + let bundleName = abilityDelegatorArguments.bundleName + abilityDelegator.print("REPORT_TEST_RESULT: /data/app/el2/100/base/${bundleName}/tests/${testReportName}") + } + public override func onPrepare(): Unit { + Hilog.info(0, "Cangjie-Test", "OpenHarmonyTestRunner onPrepare") + for (testClassInstanceGenerator in OpenHarmonyTestRunner.testClassInstanceGenerators) { + let testClassInstance = try { + testClassInstanceGenerator() + } catch (e: Exception) { + Hilog.error(1, "Cangjie-Test", "exception occurred during testsuite generation:\n${getDetailedInfoOf(e)}") + continue + } + let testSuite = testClassInstance.asTestSuite() + let testSuiteName = testSuite.name + Hilog.info(0, "Cangjie-Test", "Registering TestSuite ${testSuiteName}") + OpenHarmonyTestRunner + .testSuiteMap + .add(testSuiteName, testSuite) + } + } + public override func onRun(): Unit { + Hilog.info(0, "Cangjie-Test", "OpenHarmonyTestRunner onRun") + let abilityDelegatorArguments = AbilityDelegatorRegistry.getArguments() + let abilityDelegator = AbilityDelegatorRegistry.getAbilityDelegator() + Hilog.info(0, "Cangjie-Test", "bundleName = ${abilityDelegatorArguments.bundleName}") + Hilog.info(0, "Cangjie-Test", "abilityDelegatorArguments.testRunnerClassName = ${abilityDelegatorArguments.testRunnerClassName}") + Hilog.info(0, "Cangjie-Test", "abilityDelegatorArguments.testCaseNames = ${abilityDelegatorArguments.testCaseNames}") + Hilog.info(0, "Cangjie-Test", "abilityDelegatorArguments.parameters = ${abilityDelegatorArguments.parameters}") + Hilog.info(0, "Cangjie-Test", "Started running tests.") + abilityDelegator.print("OpenHarmonyTestRunner onRun") + let parameters = abilityDelegatorArguments.parameters + let bundleName = abilityDelegatorArguments.bundleName + let bundleInfo = BundleManager.getBundleInfoForSelf( + BundleFlag.GET_BUNDLE_INFO_WITH_HAP_MODULE | BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION) + let moduleName = match (parameters.get("-m")) { + case None => + abilityDelegator.finishTest("Module name was not specified.", -1) + return + case Some(moduleName) => + if (bundleInfo.hapModulesInfo |> any {hapModuleInfo => hapModuleInfo.name == moduleName}) { + moduleName + } else { + abilityDelegator.finishTest( + "No module with name \"${moduleName}\" was found in bundle ${bundleName}.", -1) + return + } + } + let abilityName = match (bundleInfo.hapModulesInfo |> filter {hapModuleInfo => hapModuleInfo.name == moduleName} |> + first) { + case None => + abilityDelegator.finishTest("No module with name \"${moduleName}\" was found in bundle ${bundleName}.", + -1) + return + case Some(hapModuleInfo) => + let mainElementName = hapModuleInfo.mainElementName + Hilog.info(0, "Cangjie-Test", "mainElementName = ${mainElementName}") + mainElementName + } + let want = Want(bundleName: bundleName, moduleName: moduleName, abilityName: abilityName) + abilityDelegator.startAbility(want) + let timeOut = match (parameters.get("-s timeout")) { + case None => None + case Some(timeOutText) => match (Int64.tryParse(timeOutText)) { + case None => + abilityDelegator.finishTest("The parameter \"timeout\": ${timeOutText} is invalid", -1) + return + case Some(timeOutValue) => timeOutValue + } + } + spawn { + try { + match (parameters.get("-s class")) { + case None => executeAllTestClasses() + case Some(text) => text.split(",", removeEmpty: true) |> + forEach { + t: String => + let pair = t.split("#", removeEmpty: true) + let testClassName = match (pair.get(0)) { + case None => return + case Some(text) => text.trimAscii() + } + match (pair.get(1)) { + case None => executeTestClass(testClassName) + case Some(testCaseName) => executeTestCase(testClassName, testCaseName) + } + } + } + } catch (e: Exception) { + Hilog.error(1, "Cangjie-Test", "an exception occurred:\n${getDetailedInfoOf(e)}") + } + var coverage = false + setVariable("GCOV_PREFIX", "/data/storage/el2/base/cov") + gcov_dump("/data/app/el2/100/base/${bundleName}/cov/") + + abilityDelegator.finishTest("Test finished.", 0) + } + } +} + +let _ = TestRunner.registerCreator("OpenHarmonyTestRunner") {OpenHarmonyTestRunner()} diff --git a/cjagent/src/ohosTest/module.json5 b/cjagent/src/ohosTest/module.json5 new file mode 100644 index 0000000..ba65d95 --- /dev/null +++ b/cjagent/src/ohosTest/module.json5 @@ -0,0 +1,27 @@ +{ + "module": { + "name": "cjagent_test", + "type": "feature", + "description": "$string:module_desc", + "mainElement": "EntryAbility", + "deviceTypes": [ + "default", + "tablet" + ], + "deliveryWithInstall": true, + "installationFree": false, + "srcEntry": "ohos_app_cangjie_cjagent_test.MyAbilityStage", + "abilities": [ + { + "name": "EntryAbility", + "srcEntry": "ohos_app_cangjie_cjagent_test.MainAbility", + "description": "$string:EntryAbility_desc", + "icon": "$media:layered_image", + "label": "$string:EntryAbility_label", + "startWindowIcon": "$media:startIcon", + "startWindowBackground": "$color:start_window_background", + "exported": true + } + ] + } +} \ No newline at end of file diff --git a/cjagent/src/ohosTest/resources/base/element/color.json b/cjagent/src/ohosTest/resources/base/element/color.json new file mode 100644 index 0000000..3c71296 --- /dev/null +++ b/cjagent/src/ohosTest/resources/base/element/color.json @@ -0,0 +1,8 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#FFFFFF" + } + ] +} \ No newline at end of file diff --git a/cjagent/src/ohosTest/resources/base/element/string.json b/cjagent/src/ohosTest/resources/base/element/string.json new file mode 100644 index 0000000..6bb7db7 --- /dev/null +++ b/cjagent/src/ohosTest/resources/base/element/string.json @@ -0,0 +1,16 @@ +{ + "string": [ + { + "name": "module_desc", + "value": "module description" + }, + { + "name": "EntryAbility_desc", + "value": "description" + }, + { + "name": "EntryAbility_label", + "value": "label" + } + ] +} diff --git a/cjagent/src/ohosTest/resources/base/media/background.png b/cjagent/src/ohosTest/resources/base/media/background.png new file mode 100644 index 0000000000000000000000000000000000000000..f939c9fa8cc8914832e602198745f592a0dfa34d GIT binary patch literal 57364 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 A4*&oF literal 0 HcmV?d00001 diff --git a/cjagent/src/ohosTest/resources/base/media/foreground.png b/cjagent/src/ohosTest/resources/base/media/foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..4483ddad1f079e1089d685bd204ee1cfe1d01902 GIT binary patch literal 12430 zcmeHuS6EX)+pUO#NL3(IK|}&d7YKwF5CM@UBE5tjTBw4Q5KwvxB2pw25vBJIB27p@ zOaSQt5eZd#CxmkF|4+F-=Q)?(#XNgvmzlk1)~tDFz3+~Fs;5bRo%8yoOPA=i9zS|^ z=@P~5f9V?4rAwDs!Yjfq4p(5Rx~i8hRVUG&*j~LT%Q>2AIqB+Nx_^yhg70E+c&i!%2~zqE0}mxIX= zz1$7|sWj&3yL#7D|4uLjQqV+x(Rz4WC{A9|^m@1A6`BNi38Cf3B^aJyqxF{TjS&2q=3$BC zB1Fu04C;%o9V_Yg;Ed;xpmge>%b<|5q52W_pTd9o;Qty2mQ+-Peu)^(K)RH^d5byH z>AGB-I7$|~9l)J0H_LPDsUUL#brIHpjO1>dJ9@_5&W zLV)s!AVn7*Hy{o<1zLA_Ky-TWzJ_^1=W=Gfyc#1ssqeY_2ww>;ANX%JT)(9uNHOtU zeqU2_{Wu6pLvCMBLgy+dx=13ZG-+cMrBf;#8KezD^}_F2x>_Nob0^iXEv>aML;8RQ@@sN(#bq~VsOa>) zW9RDe#_!zLkj)PyQ<05AjbPk5yJ^|B6q=sMX2L0JE|(P%=v2$6+4QL)cu$c*yt`EC z?)p#@xE12zK?QF2u^(xb0>KieYWS%DH`?=eOiFd!6)WRmCo6Joq6}7e=Nl_;oNJ{1 zu&szm^c0s*wAxfHSlk^+hb)aB<&B?9+_YvxC1LEy$(dDJ8J)d!>rwz?q zGTpJ5&uVwR#t4%B`T{*~RAd_Unnf&`*9c^zbZfsVc;v*@=BHOCX7VbyhnS5G*Pik} z@`U!W&dq$A-&GCYAWg@rG3W6ANL_2a)|;&HJSig{zyfyO87W{;ej&@-)yx~eu|G6S zO)U5U?QD)!ey@XcxEKX?m{R4VZN!*V9gT}6_lv@YD^}}y4OM(*#%kMMBij<9x4*by zCkGRQ3vqoZ)HvQ4oY~=kh{c09u`@Lzqk8)3R+$+hcYuhqajQqgq8qWy8X_QMy@1+T z0&yU)D$XzuW+GZpAB%%|^3*{x!r`8nOWhu6>t(2mvERH# zwD(@F(UyHL)A@d0q#?|SOaIrK7`~^_KhtD69y6E{G70hSpvkOuvhEmR1(|2efAmi@Xw9*}m%vZb>kVqe?t6*aL%179k2-;CD<(T2&{-rQ;%g&4b= zStwf@&UH8&T6lBt>jybuLy}~>HTF7(kmQuR6(8*l&xSQq79o~y=t@1Z0aSiA&-LWp z0NQ{@*q$n1m#1Z}?sFj0=6jxX!@eHh_D<=qD}vOG`kCQ^44In=iDu`srXYt8{4c&) z7G9;S9(*ydG({X#u#N%3l}&Yaq*lzrY-E%htNRQTrjCrX1NMi~a!soU$|=0*dXokbDxSFnm6OHLV@%5(K&ZQB%e+ZFne-TrP|veCOrVj;0pG zdbMMl{Z%MBfVA6b>SKLi zXyRQXFc}Krl(owbvDh?Um&9l0#P)rbdiZxK)8=RY8XvSG1@0=@vGxtW|3E{`T&9Zk zC0==A6=d?8`t>?}z3d12SZ$YU4KZHQPf~|w zJD7n^6bjSS+&0Kq6nxhj*9}9qDZC~A`nzEz{<+9lxx)v#qaCsGWko<{ahFVncU-R|715> z33|Jp;8Iq?Z)NXe;h$K{z8#lRB#JC*XUod!9+#hCfkg#-^FD5Jq@>Dt!SzYr@q0(& z;I!1>qg(PU*HMX7>G-#T5V;IOw~4L@XQ&5le>B4Va!sx0P1pm1PMa!%L##WB{CukUKwQLR#mw_r{d1DneIIJT(j#O#-det^FD zbdwZ-8R%84+Bo+g5iyd(a6x;*5F0xuclibP*ff{7PNPESiBNJu^Q2?h!4}38?XKcb z1cb%?RlBpM10D9~`7(D`#uzQxY}K)shcU_}%#WJZ`~FU)C1j&^b5i=Wc7uJW8^-NB z(rs3^Wms@#S~)+us~_(~uocjV^vU^euJHB^upc~CY%6gqBXHR3{FJ}D^V0uB8xrdo z%j>^}CvVUV6jaGJf5i$e;gXng&>{)uK?nWhEUaVrv+x8njtfCz>cqP8uUTn1`McQ;CD+jm zGle#Cefq~0!!v@W2XnNsA~8j@Gaaj+fT)QzP<&gR$L=bGEJ8^z*tHxS)sZ=vZPV!4 zw*)4rK3To_7<;de8PvEPu4Q5d;D=g00$bPnaG|sEP6(kDsxwc2+y=l@=8Gy3^DW?X z$=3@Y|B6^8mUadWxX-6z(Oh@9|3%Nv*Hz=bA3)}AiK3MrA@eOvp)YSd(Nf|v;6dz-v zI5xYnKImXz)PTM}jxK=GJh_OrE2HXqKgh*KB!U~;4W!DpXN6A98^kNt%~i7+I+`g5 zW}~Qod0A;Lw*Q@m73+!Rfuir!WXqcTd5mXE^DWV3AUSVk>5EA&b6Svd&!yh*!z+6( zh^>CvoV~2?y`UJ#Jho<+PlUEw=Y?Hyd8C#Oj$c!5d!Du*w4OQ9G&OxhDmQ=)tzD()srM-?#=f>aw-$x}3Z?qLOIJ{gnZu zd`Y3Pu@-6CD7)$*a6189&`vfy%c7^DmCj90Mw>5FgU_yh15-*dsMPOLpn%G&Gbq@c z)NN;i4jF!g3-}@w-}i(YUbp4WY;xYi8`sa3ep2V_UXf_!7A{;Fhp25CGF=6{xLd&d z!Mvrklt74KI=0hsCRMYBXM0Z?v1sDfN=Y&W2dW!hUyqiiU@A}R-XCxbIudes32?<&DQ!Hr>qn`aYQ?jSq?4X|x(CCDAB;b=wcWVCH1CfwqU1di z!|LlwpE@R5*{9XlM;`OM$(VZBN$c{`%$ZT3S3aYJwVO}kw)@4_EyP4SXgXkd)Q z7PtWeexnE98(N{TMKt-aG+YpQs`a~e_Y;}upm;CRXlTWI->sMI?cj%D`$7K@mQ<-e z6c3=23v>}kQ!+Z{G2&KQ99s+el!e053~lQJc`8%`$;xt_RQ&16M-jjl$HK)VZG-0esPL)%m(*xgTxhvj>YKkE?dOv3G%g-W9;dgR&pG1FoW|wrm7v|b_Y-VU zKV&S7NcSkHSjm4nrPIy#Wvwp8(lbN>^x7o60ICQ5m?QwOuUY9q(q~<6`0+a7 z_`Zhdli4>YUiT%XT1&z74m|S7pZ;||I*2@$Zd5=|9{V~xFLGS|sAE`ZQ=toXwPUzSz%(Ar!@#M}4%I2r*Ca<9 ze?7@cjo0^QC6zocYls~PXjm{I-w|^|?Hpmvl_!6;&?vERiS^(A2e-)2qxQ#IfuJ_M zgEhyUo8K;fE}w8OE$6nq26w$M-YgMyeYnhwguXF-@5ca=0xYn%I)Rl=_lZaUn5tgl zq{GPw`_E=ilA8s)Jy=%ks{*^ijmr0SqHYg5D%zYfzlqy~#fp6GHI7wm_SN!mo*B=(4jED535Cy$0WQgpMk_!VjQ zhjwgVnse1csNUVP_rkF)3q*bk`=D| zRm=kyT3qxBA7a}d4b433h)JR1r_zBVy6)DMRyM?5%=@^}YMnjurETi?w8)8Y2lox+B2Mc9(WcW709kmg&QO^PydT;QZ_K7tmYO8aA8M?Y);N zSn^>S4^jpy!tF}ZAn_;hcCNY$eyakky`&>*Nh{Yf8H17GR#{9&%f^ps6IAlo`0a7| z-5WT~hwWze!uONxb4D$Was0UyM#f|Al`@rMWg(+oyWOL{(2>P6$`ht&d;q3uD6W+D zQQKN!nzWpx$Ya8CUKa3dgn={(ad!Lm7qDcu`SB#dKHvAM#GW}Z>EZmS6yG22dWcVi zef}3H%>*xQE6XidovM|h{PD;~31ijm0ia9g=-tnlFk!0PDn12luSSt7gWP{nbUK-G z_;*xp66cFpR2OkYg+1wGZF$3SCHuNOh~T{QxmE}&DI?a%s+Q&BqRkJ^37TgbKmAKA z-lXW9)FAv@J#Z=C2lSk4@W5q7S0~BpAs>m(p{^)b2MCFka=_0~yTtPvSKJEH%6&GW zKv;f{iTBYXA0^wmTAmssRXI(3556s-FYRfgXSs2F7D?)Muw3X(n96>Fe~#_y!;5dQ zdOQ?Kp<{m8r8ee4PPIETr3Sr=L{BgNp=Hl~>nSiYS!vY-rs7>zJE&K9>k00!&bs>P zD`CMT*(GNFuh#^fdZE?R`V};&3K^rq3z5UT^^KE~V+Yq@nxU<{+Ug^t(FEIk@f~5* zgnEN(6_Zcdmg55!i|T1Xn2NBcinnnFghvgYxT5oG<#r&$ky|k5SaFs(+Vr@W6W!wc zhr8=;xACvw0kVQ6m+uK@w0M_|3*`l1D1SbQ1B%k-HMIa!=~kGkCfuQ8^C^ZQ&7xn%?zUs@ zJv~f?$}gE-(aEgrt|vKx z;}Q@0S-w8jTszP4_+Em>MvCg@+IT%eNk_MIr)gA`;*lhuP%vm}{=>pIah-$r^3{Da zp;l8BZIY#N3v`sN%POMh>Q=e-o^BM2OK_7-ztamrbZ{m49XWXIgg1Gqa+C!XfX?gxVvl@Yc z?lm`jKKariU3($HdVP4LPtp4+4mV=+tw*rjI~_q%R6DfIW|6`<`}My)W_VK!6c^i* zIvi5RI=c%+#{fOc1^%pnKBkmGk{n2 zC<)woa7^dmGd|$2v77jNVg{v9cP;?R<5Hz&w)i1YTrbpNc6%p0{Khx8hi!J94klTx zC9LuDS+2u)()U%ug}~voR<>Cq}#OQfXF2)TCm)4nk4dkJK<{Ji<% zcP30SBMi`eN&Lves%5zi8b`z0j<83Tc~cBqc7F%;N9zZcNAe!JR3!n;@j1h z1lCS;R&Xw6EFbwYNCw_`r4_DiPb}ogRDYy^watxfz7Xy(zQ=RKaRMV#RY}`WgLrrF zVY?S>T2T_0_gmfEc1P>euBpQk$h-TAw(GijhS$+YK=Tg$zQ6?>D}F1vFkHMoukc{a zEy_ED8Uf0r#&yr0HH7|2|B-{vV9-6x6%+AEp3Hd}4fvb`f5|t#1a^r!L``xWv0pYp zK_sWYo?M7Ka~?Ti?_2#VSWzD;+NOTq_0`+=>-+<27aH>r;wtxc2mAJdsVzr(62hGT z)&mW2D1I;#ot)2O9iIWid6J}Na=-qm<@K(sk9ppYVwcO*IkP(P8P9ER7!PsMfNBn& za^K3zdtRPHN^c^l9lmBs5m>rjxgOV7Io|5p!v}X)j;Ax&u7K?;q%XjX_~o%@lPr_8 z*9Uqq$6~D2?gL>l^=mP&+~8z3yT!99Io|+z9QCQwYR2S? z(t}t86UG(B`86l3E&Y`O1p($K!sj_~Szh|(peg0h(+?ymZ?)sk6C*iUD89q@SVAIS z4_&>H|FtF3pZ<_*-;w|rv%!y93`xISUXVWp-T~!8n*#@16?Q}v>{P^~9I69_ z%n*6qXY%Yy!%fWkW5OADjlkEKjP5d$8>`wRrhp=ra6@iEL)prjHQ=o3@+N$WN7maZarII1Zz-rqUrBVRY znukG8!4Q$))$$`IcgoPA;izr~)m2%Wl&%&EHeRmOXUJsiSwge{CQ5;l6K*f{(Y$dK zr+Ms$jZr918R?`Rysv0Z+#6wT~L%t0b;+Q^{rT$Y_J%=|3^Wd zt6$*epNax{<>cRLLyEm2t&MjM8j1U)pYxwc-MDWDwN~$V|G#;ney}e?-YB~f0-n-M zw?G0{JBvufZPvKoY*5O85X8y3)1IFwLkMFr+5G1knQdDje8Y{BGoelP12*9EUN%KY zxk|^L1xHs)rNCp_@p0*`=#9{%r)_7IsX3T&x{b&X;mgnjUOMtgKs#ylC}%kSdtkjl z8!FE;zg-elNMzzYzDjZ0)^Ieq?HW_G)|Sg=4mBA1EloCGZTG(+tr)OPwRZ{J7OY5O z-u^rg$|QACu3Cq*Al+><3gPrW!35XM#YAriTfXw+!m_NkpMN$HY+wKfNr4L9PYUX6 zzlS_jplR*TFaNt8ide7lbsipOGdSE!+zhi$@D8y%FCwjQ$r9L{z>FOk9`c^?Kjmj` zMuYzJ3lU=4n6Q;tr@a$L?%8~af{fraE2*s=hn>Cp;YCQ#>re~C6xoCO7}(mj#Xh*k zba*^&l5yo%qnHQd!W*<-IXZ+8vnMb>c^cM={07F5{v1ulw!aVecf>C42Ir44Vz);s zT-%=b<-{YEZ*nD{U;m4uIi#wyf4G^ggB0@5%#DRIbN7hz&!Bb!hl?A6#(~|dZ%%iN z%o^Sc0oq?wn5_;1HQ*s%km5+`HK!Bq9^dL$ZL7!o2j@&piKs-)bi>dGD9BCC4PSIk zrGJIk0P-Fv?{`4G0`eU>*i`V_XN2xXw%*xTUlVENh%_|iZDkl5p@Y866#=@Xg{cbE zjZtS75AB(^xEogv2B)1x^m!0XZdCqOZ~=~2%7kuI!6E74!u_j2iau*{do^aD^2Vk^O2eW~KSv(BzRD>xw` z&*Gb6ksujl^_Fg<9{Nxn%B8jSv6jcmU+Kw5-Q&psk7EU|G|_)%rogKwNzemwy6QX^ z@ujX`ZkT$alQ%3oWJ2VOJGz{G(ukN|LF&Ga)nKml$M>IY@1F)}2mL&m6~?A)CN|YS zLi^lZj;aN$DQnmlc~AgqcDB7)?<<0=D*JMD zM3%;`BX_AsO%3+;YjwAbOnkT+m^;*q5X>@S2hO@Aa1J zJCCx~6B|ewT}HQECVls)>JqY95!(x8tJTl^D9t}c_G8p6;&167Z{2*+*qbjZdPBKR zwYTwFdQwnL?Q_fZ1S5+O2`Bi&@(s_P_cQY7?>NOU&FL}U5YmlM6yw@TASK}~;pon& z&{?aE)kw+rf)rVR1R!KIA&R@6^&5tt+oJ8h+P)7GWpbZ0xhG1hCCSz8pFjdYT5mJUum4y`e6ST z&@%+@8U+Bx-^#X6vpu~G2`=~;;97zryltTvX_;q&`r%A)oV7(xhxX1-Obw!r%_aBq zXumue@LLi`iFY=9t~-zHYJC&!zW;W6TKK3YgAe-4E5@wu_HwjtlH4Ep5vqLS-2C5$ zSxHdkc#a7g$_vSgCJ_dxxPL&~SeaPflc=j>z18KsBxhHfhSRvim6wzyuJBI@*m2g@ zc2$Hh#1|Nide`x;s zFEY{lfS)AO1(&M2`md$eil6mNBxu2_M(#la)vUt>ub2uO+!3=jb#6Ic2xq$*jBF`n z%L9sP{NK&^17myQl!*yca`I%e*{%{^D5ld#5&5Dbmw2He%xl{Z?Bv@+UmIbjXEHB5 zH5Sh@UPidw19)2ZMmXkn`O@)IsF`Fbj+RLtb$qTJ#B-vXrZ?7??}cA6N56t|TzFj4 z=rAukcL+Zk?vE$J3_QP=HeaZiJ>sPUrar&8Ao}%X-FpDz+o?UsRbtr6!(ES)@vCo94^P>R%u%q(-9wy%Duenrn)jXuW z+2hV;WWLbrH-awRI4^BBwkb{USY=a|U+=L6IJbHc+!%aSb|KB}H$ z?;wmaMfCf`2o^LLsVRHayM++C2aVlLWRbMjawRSh!|`u4I8tjLx>H>?ZR&ba(LJXj z?DRP5gyUNUnznwc)C%qsQ!aTlw6i(@viQ+~|0fLN?FR=&Mz z!m?8%ms9Zm`@?A{S+a>p-JQ}TICnZa{gktp_;s>#3Wv_=7#GC;f$M! z&TRADKS2F7Grq42P=N2(^g3PHSv9Sr5khe~OZap~yE3UUWM-{Fh{H-BGK9MOV3L#y zw*TZQX^enrYRj7iXkEaCLTZF5z%T)MU*{_RxA-*;G{sl{7ry_e1h+X~HM>NyBnnV6 zzcFEEZvv5PId&nY^VG0nqu!l%4Ln9L8OVmkfQi1}=-j_u=t%I1_~|`SZ_zv+SV@2>e1;w+Y$vY75F((`NKQU2vax&tTw!~HE>c2M3z3d>g zk@W;ee$-qtx3IgJ&cQ;-5AmGPIIdtV0YQvcV7G)N!(PWkx#qq=;AiOzb$C@x+Z zu##CR=Q`hVF-LGTr?w9-umq+&6PrkTr)T1CJ!@XV9i+em9sS#E=UO}BNMwuBrCayH zAub{V#`%5ecrycz1$eSV8<2Ikv6CQ5E=h^K%3m6h74APzqFYP{oejD^Y7o_E2b3p| zeA*LbkS?zNs8`f>wX`CuZF=Vcnc?D9l|P;QF8KedIQiHkm!f>Y3}# zl9AL|w=FC#e&CG1Vj1SX@K&6z&wEdwI}i+9}=0 zD)hP8t2qSqGq-zz1>nRbHpsOX+Ou&rc&B>1K5Z`l|60?OVRG!%y@dyXhC`Y)1x&pBnbuTa%|7f^nM;OIHu%(W6&Ci`84e(2e5z z*ThM)rgG_sjP#cQ+Xs8;_5jS%p3?)1Cd0epUI+qH6)RAoaWyIr#O{wWN#wI+_de=e zPHAv`+(8DcYwZezvF?o<#{{xGw05-!dGx*J-i6B-YsG?>W6ke;g4Hg#P+$=@?s0UEI-*Bw6RE<{1I7> zjBlz61z%K{w(Fbs@*+5i`|zyRlh@qP_iu#(*1Wcpz$is&$q|YHc+dRFT7N)#@B@znBGn$2wXOi+ggc5BJ<+2( zlI3ksg*I$2(gaUp4h9pJY${1?hgh6#mU-3e=N{4cTb2V_4R`HbSASd)X&1AJD{hd8 z^}36_R=S?hhh>k{b|Q{V4g^$!<)__{4ZCIAOzE}*nn%8FpA_Bmaub%88)q94qdSj& zU&K}EwoAH(N;V`V{ZfKgP}7P8xX{2STb>)D)y3#SF&&=+6Jz=_o8pqGbBI1lUdL(1 zD2L567hm`YXfrYLV3fz4yv?7yE!3uaicqZ7ufRny<0U&B6qh8bcqsL`r9)-JOxkXy z+l@a1(ptpJ`{M2l$g!g@DX;KZcoPP93JT=vi}|dQ!tn5*k@U)brT5a*!NEAJ2Apj0 z3jNsKvYjiiy-sUG06+A3T)f+N_X|`ZAX$1+M8W1ZaK3Nm6Dd}Xw#CnL+A?Xi*n>}B z+g^J-yeBCQ;(6yjA1~5bLwIzXXp>6syw2d^&DXBrf$G@}~y*QOne;u_UdZD^Cl zXxza$QKpgXzp22W4GZI|8N{0M2?78Z`$wi+S>waN@uSr9`u5+ghvrjfhcjQNuoDp; zk9szfi0j_VBAd2M+55}LBoF!BASF5?QV6q5zf94lQ$2goh8#I@&N4tiMK&5WOgt0H zRiGPL-7G)N zj%2#teK$kweDwBL1+DK?B#>r?tjR02JIr zUq=)|zME?3CA9?-DRGfqM+;h7w&xgGmLjhTAOdy`b%#?iM;>=l7v)^GADOA64 zy}x#1eDIpJ^iQ-mHzp5#R2_{6(~wo;npi>z4tuCy@Z6Ovw1EGFOaCWi{Qog*{?+*F cSLciz6AsI{U0tD9;7S&f z3`9H(<`G*WCN>bN493AFOi{!!!L|afI7%o`6&6lXK&2`L1YumJiZTQ+5doQ^Fu|gz zI6Nvw1cME>!8`;4iI*N+z3;u_gZtzG5&vyF~^*1 z?S1yyXYbweAFzGO*PdLxe&gE9j&{c{J=rY}9i1#6cCzdq+ASx~UzXhiC(H6orN{Ar zj;qq$yDTU7NWP@ws1J2_*G}Ykx7%{iE$G@-7-eF^Y3#}`(v#ySiIZdTj}`y+a>=Im9Vq=f1W5yxR*!@kj+Rxz&v=+4_?qb>2v z^P8^zTt$BB=j8B|JpIS7`QY>Jz4z#w<>ZT>lB09T6nS2-t-LNa`Yg!ixr}^gvZsB` z{B;rQ@uVEqwOt7oA8%Sn=e2VBs;^`dNc~|xx$^LKH+*6BuO8<1`K9&UDuw8t_%!FY zoV0NZ!^eH~qhBH?uakr4K4~ZC5VHnAA|L9#J5r^|-)7;Y zUl$mM>pDMqeipwr+7#N+YO&F-3t!twD#tH9_S*S{wQ+C`@f*(uNuw}s=xXMh&DI;Q z;_u$0c(3`5*FEq(O?pz@6#ee_pZMDAFS)(D{hdnlGw+UhHaZ&vMC3y~_HorR=oT!) zD&Jv0*w5!@vBS?MX~$>r(d*!xjZ=9%U3__Gl0?W|%cDAF&TIVSk@)+3cqc!3boGhhYzil=`)k_5%wL2pqQz`Ju@50G)sNfVj zoXGZ|Q(f3+@xx0`O2~K<`L6lJ-SXStp$#*Nk@$Du%RKJ9@n>4_fX zCq4RXG{SB86?4nquk-Hy-E#B;AN86?zpBs|J16`d(I5ZXNB^!~KL7eV0uKN-_1L$Q zfhXMkzP+y=*8|%=cJL*vJ8JS$i*h!V@e z?gp)OZL3q^qPRQ$mTS*l z!1Lo9sgwA)pzOQd7ry0nSAP)8dF^z>J#;@|{wb*sK5UU+HV4!!`0VEJLKou6^E1;q z{-F(t{g8gMTs+F%4CL8B(dE++Be1u} zQa1d_@^?2B{4?(K#G2gBZ2YKxYj^wS1vv8wb2h-K`rtLS+C4j5oS5zZQT6pjk(( zJ4B5)x)C<~DS-Jn#3lX27u>p0yp_M+jn)mGYaUy>+T%Nnb1#0!>tbyAQ%)nklRSgJ z&7=Ic?ks-hoA@5fJ^x~JiY`PYkDmW0C(plGd!Q$Ex;t|N@d~qieC9rdJUa(Jbmg%% zxJoLcUW^RY7oUugb$iXkOVyLI8AJG+ zNchYly!4G7Y^6~5nrXo&e$8p}lUVB0m<1UOEOBY-ht5+)-??6hPx|GZjRV(b``>-$ zM|{PjUt-09)0*964ZWy4qG3A!iZuCL5J4vSq$?ol?wO2=1e&!;9t z{HK#&d2T{`aKZSSV$8nw`5IF+b?d?_&_RB2Nn@S=KEJHRZ&{wfFD-HANt+d!8=g@V${FeVy<@Q=p|RCl}k1iW;RIY+rXYw+ro1J ztScYrS3bq4R+FlcH(!!*-yB2t`NcV#59x0CP?FiqC-VdG1vMIuAg3o=Td=#P|3Z0B%|-@17rLGk-6p<6~!$6~POh1kU3(XXZO`=|>$d z!lw$=5_RyEi#Jr~RP#^%iC^4A^2m;K+VClBHe2;z6Z14*Mk&|$%X0f<_lmdugY8>E zPThfcKaZ0b)2b2Pn1`Dkmvb_pUZ*zC08jjo)ep|hccB`;;R{6kL;Ts-DL%Zk@M}Ec zYe??S-~5VIlRb~$9A!25WQb$>P5#6re$4=RZ7!m^$ICJHQwLq8^3qO zSIW*0ziJfhY2#Np#+5qaD29V6USiSHHu0r%dVQte1>d!Te30L9h<8T(gM1~;2HMmK zAIaG=K2h~u$+A`Ao#yL~^C@rnmi3*Dn>*0%_Q|VFij#Is9D-CUfq|-t52LPSO>Mf;|h8QzG9r>i*kxj)D&%wf12-@hxpQE(boL;`OLW% z&4ra*97R9KXL{m{MVR>LH~jeO-Z?hkb&`yq#K-O6lT$@0DD?-g)^Uzc7T&5n8gw__ z0DpXP`45D@vQE5>CYLA9MXJba02$ioVhjTWVS5bZ6(4zN`ENe`p5>!H^k})NKh(Lb zKhik@lUA-Xx~smjY)TJqEB4J>%kshNC(AGX&hhfC|NQ3id+))>f~iYr%eBS5L6diS z0c(T7VNUk2yzB*+mM{H`dzO#=6GzJf`m=$1G@nblG}%hD(09V$W~@UCQLSS;5BqEV zWae*vfSYo>EH@?Gc;aOFp#GTWmw)f}@_j#ZYkBJ*Le`;RxE%9>G%3oHFxKHSfF_;E zFF&fw_1jO}dg1SWTfI@g(_fZ9_1ee&mj2x4J1a|pX>wLqgaW;Whu>GnNZR9Y^4s;%W zx4i1NzvUU8TZ6Uq$a?oX>%J5^9jAU9em|0;-_C;e(1}uEYG}e zr$t+qTP`-spu!U-M~AgevS79|o^g>`wAc>y@e7Vk`?z91a^qxq>GOBXzxbc8ET8gX z-7Xxv6CigTGJZUUv*`9=vmA1gzg4h49N+Y^ODZ8#@KI9`q-_X zaPu5;fuSS!*@le$mhP;#HK&jK(B1NbUvXvmPhY0_kiYDk{5AHRoIkT@vw@Z8z;F1q z7l7fCCi(MA@@nf@5q}|i{jv8-IsM&M6%o3LI{BfEQREKp4HG$@wUJ1eYx}Q!%BAIh z`K$LWk8838tEq&7|H$p$UeKq__MwZg*U!9Rnw3=(J#1>imzU))z3%$*uKvrZuZ{Wd>ES!5dgNmrfBPTZ zSl;rks&UNFhD?$g9J)KT33%MPXFTyAfBeSP=e+&fch`Iedi2_(FPHhgB&G`tFhZFY^iGZTPO8%A6S;JedWE&6Z7VgKJMLTtbV@Au;oe}a$|fo@8QFpeTE;~ z=(!{4cwATZ_x+vv)3p?oK6COMai}`b-FNw9`G;R}pRW2^Ajgt*_)SjojgA<};ZV-D zH)q&q4iEL*eWU|BFmM=S?>NY;&)5I;`<6?(5sl{jyXGx}^8>dxQX%Vtv5PEo8w6JK zToHH6efQkYp6Q3Mqvhz+s$i(tXF7XpLn?CV%Z6Oqu_p_+nw!5{zT;K*3%heMNzF;f zzun5oTzGVll(CU?9of+U+nP1y(OpU zvv~w9Sr;nLG5?3p<|70ueyyDbUY}Yd!E0=`V+1F2S@%7DUU z!+3G5v_Yp@FhhD(9o{OXys6YM@?dLP0LotS!( zZ~o{ThY!62s*m!Sg&e-XdU0#<$S=0*Pb|w{eYqaXoLkS+K6Rp~Y^EN+{G*Qi6P;tq z8XuKI#YV0>%Nz^2?6yhv9fh2b=evx?JV#`6&=bQOMZM+dz(~P{OOO4g=JV%2_LA3t zIWdLGe~6_L*6U?ZoidN$t=;E~mp$XEY0L*5)a)#9%C_**_ejXj1}SaGL~lF&7ro-L z5_Il{V)fCw*fu?YZqYMj%cgB7z3S~eAahn{_@cQMlFic3)%3UY#Noj!JH4cEvRr#S z^9EDCiHH1&FTSjo9Q4r{^K&2ha-QnFK^=vKuFYqvdxW=7K2uz)M)&XO4}*2S)oU;32*?s`tzhPoNdy zMK~{~T*=4;PVlC()T`0MfB8pTs;kbv+GgKHr(Rq!;3+S|5(B&y+n5*@z^5dLrcGjDVs3` zF=w9B8T=Q$;LA>~9`X4+qVFJ-liI=f8qb5;adlP9$i*t%;M>z~dBL;M7jh(|v1O@a za}jzx7Y{1+b#a=fVe#WfJ$C)~F&^GD!hg8&3xD97hwY{wLOxnA2;wJqo|?br07>n| zdc9}P-SQkmio~mhtX%z&MJycY7!O^|^}~~L*w+vLY!DscBm0>6jPaAr#6u#lPtl}a zn^g8A4RF_SY<9BpclX?P?PZtsH(oFGD^X@u>A2cxb^Xba#{f#>E7Bp? ztFxkR`P@dmpq)Vyx9`@uFnA8e#&tpr-DGb_G^IYIlqLQGW*i-bW1&6e29O6Y4AR#5 zvw3QcRQo|aIrZklmvExE$M4X$oUyA07_9mhM=sXuWE_~5;nT=?xmN7c}VZTZ(}?rL~jVuDCHDd zW0I>4RkJL)P{rpZ{mdS{51lA{3Pf+T`jPlbs|k>vbZN6ZbRkPI+fmPp0DeI6t7Nc~ z$NhZ%nT)>k;6(Zz50&~yf1iG^fs4sKviK#}-Dl{r>Bu~hY2DR;F}T*pmL9|4wUTbw z@xnlPQdFhr&E%R&<~6QfTI+#VgCJrYF+`(acGqTfD_@rASLH)IiT<#`a<+xCqjpL` z>#D>_%Q%UnL=``~nBcrnhfBLfp$0UGM~}`pY-%%xL2Su?1!0>O+=jhV^Q|SHHsi~S zD~0ov1zlYjfNIlt^GFNNb-;qpg1EPAM(ME^ps)?4i@M~QXic5q&!wGA8~zyJ#}kr& z^`4JJ%2R4dCKVL9!V%6$c5)Gv^*q_xt7|K06))bGDUPP7^FtSfX;?h<0|XKb062A zIY|b0!pj0C)Y$7;i^P=d-~9Mh&zQKh^`h&1%>hsw!5hUsnpx4t z<}nU3;cAnu{B7X&Vn5^sgN95?k&<*Nw-dMSz$p_Pc^$xvIFk*X^*T}DEO_*uml7(B z&nEcAJ#m?Xu}#P#5u(vuOElFSM`G;J(?_?d0s0skGYz4+p=0BMwY@=f?C04B`6n16 z7Y+?9wH$J zAxS-==YiY@80*`{n1+s)KEk056AV77g?$%2H0xq(Q))9XS&VWbRL_G=l_J9>UJl0D zL}N3`NDj2QCw^L+J)AKpGPZ04N*&EdoH2o<_uVvg5ExqK?h8cD!pAn(v{$fP*#~QU zh>wrmGmlPAjvv4qPUcCCWLhX|Ka2&~1>W*WY1;yK(tBoXnGCEf#s(&kaR8=O7&`Rb z4)NokexjR!kF~8MOFmU5aQ$lW3aOlWOo#8pn)8ot^lQLVQZO5XoZ}x``u%x;$Cmjs zwt{}jE1RV@QuzczTVvNF(%{QMY#aX3$pievr_W(l1ZA{3C6z9Llh!WOKW`#3*AYhq z-tucRhL5MYjUq^yq;P4yz(j=;Uhu<*6tg}0;12PFp$~4~hxPm_+Zg8Ct>f7*BneZNsSb8?%&Jh@KlZTTrOg zc*d4a&)A=--&QSt^&=aCKtMfi2RM(tjY0_3lN)$zC%(pMOo(G{xaW#VQD)ml*8}*( zn%f398D{+~2NGYgRbLr0gOY-ta%{uQ8}bVGoMs=E!xb*`2zR1d+}H1qgGY~B`-@YJ z>*a;j$od&444i_t&M>U#WibY2>CmtI+6%Qc>JFq&fKMxFac!J|LFhSyp@oAfvh|$Q!ky#K zhS(4BtuuI=bE{5uez>A2b4!3M+hm`g$1$&w|CB6iS~rUj(~}eO8bJK3dJ?_67ebx{ zSHS|R%y8%`=YQMnAR>?_}JgGOix59Mum~lwBBOj7l{Dr%(^B9~CeuB#Ukb0`^qvuU*Y(62BICR)&Tg!A&&-M+!2eTcS zQp|kcb?_I5@TRuW`$zm0SeN?*o>tHfJx!tLIT3p}glz!EcCx$YvH;wLhF24aiOPLh zoyM4vMhXD7pn%KA%I|SJ3pjFVbc&HshPKa%R-zM#w$p3fhA+q*C$x=DN^`o8SMD%{ zlYy6XyKVf(AvWYbX0=U|B7A&%L$qy^lSpgCbq?mNVK#inCYah3&VIO?=1DXw=#`qC zbt3TAho;;JwjNhLV1kW_T;f+5&f5zw$zb{>8{!V`+%h~%KVy-DqlO+=H=VZ=FkY%TPJGOKbO-eUMZb@k`Qw5*kXQI4 zNn-VY-V}k{dvi=NgDj)aFv2b;9&Lhj62jH0Xgt5%4NV`a$nS9VFeZ8jwL3ZT-35mn zvUwAUQ9a=cgBJ%U^%9B`*>UXEt~NPJ9a#K=jILPgIq5_LF4);`bivL2J}%hVmz_pI z&(zfWn4ASNsVrtA?CTky6@SLgnCP>dnQ&s$k2bCduV@v=0M<$2v&?X_w&f?0 zdVL4q!ob4O|06wo;ixOrj>l#y;~Gg=-=WAx*pV-hTSqte=+)3!U&FCJJ(R7IGj_tH zSk_m_@)csRD}7KQl3@|As*N?`C_c!U@vo=O(oUUM9HYTXr$fev>%5uanu%NzjR zCb4pse%58Ff_FbT99ZTs=22SCWBp8Il>D>{j4u>gKeWxhWg0&$HJ{gkdPXCf61P@& ztiI#OvjYd~D)hvhL4pdPanYqKH?T(AS0xsJjcpoa4(T1TJw`VIoTCqRpI?P*;>dsN z5f0BOf=znyxkaZ2tJWn8N$N>lK}c;lWS?W5vOBR=JKko}KC|$3Z%PH$J5|jKJ-NqE z_ZknrZ7W~D$^f(y8P~onU3Oty2J4NY*@llDx%i|JpU9&wHDK(xtG@VU#^kYat*h>i zdSLC^jL7(-#cz$a=M=p%&kPDtW4)wR`B-^()-G4{E(m^LY+5LRq%6%7l<6vOPNhVCyvY=4yUI zIx&MxLE28(nmXlm7viLOLSs$b4|GCD7I{^>sJ)bo<7qB^r=YAS^^JFY6;xwEh zZpDM~;ZEeb0~BvkTQTEG0U3VZL5j9H_mXvxdHwoPMGk8H%GZ$DSUoG};o!Bp*+kXX z`qy7&0LlzDGC5UnIv&!hC5g%LKEG*AaEI$`J|`zF9*~_UC6v2ef%Yt=w?iGS=`x{m`*tc1v}Pz zf~slY{K=p-7He#u7L@_cNMwKhd*f^(-Vaneam*r{gTf>LelwEqaEL>^IXTI3UTi}^ zZkltHCYX)!fRgkGlZFWF0F?CZ*bebcbNh5(fov2_4=P{4lkUMPb=`l~2uhFxu>7&DseW}mFpI(L7m<98w3m<&s^gYwzKLS`@ ziH2UU5yjHI=Sa0E5;z6n)mm>R$Iaaa0HpF2H=cyKrST)6aY5j>Y2EFa4KyaOJpi`Y z0cR0NFVNX;eH&s&2RLs_Wk`!X1Ktl5EXMuVY^M5^Na4ay{PgzMr(hU*GqwVm<`|tx zHqpMHc}$IYj}CnPhO8RSa9ryZ-xY7p0CWe2u`wOua|f#J0CPySsjO015zUoj^|=$R z&P!8a>m2?Q`plg2TfXWox!mch;lqB)b!%4}(i&%-8hjt^C)?8v8krgXwGp&JSbXUmUuKNKj;seLQ@+i{*gD4%I@RALNg?5Nv zHQN3d?-dcg{ZuEQo!};N-E}JHlr|#Z=D+=Y^?ah~?(8cL)5{VsbD?G)a@Zyct*NHxP>~FNNVt39Nz-u{udkt;$vC~g<^Q~(o z@!$ErW946qkAsrqYR=YH5b{$F!kam>41*1>C($G?Qu;QuA8=!KcHIVdWNDr-8-7uK zNuNiULdrZEx{d!~v71dXW?a|C=vhDe#uyuYWb4hW)6k0ypF8ER{BAwTAx;YE-wb!) zU;16Was^(;$OUp5dXvkJY0hDAS|8fn=gyP6&xSuan8cZ0vW)z(=x@DiJPDG%HphC= z- zpYdSh-(EFF=R=BYI@>x#_%jYWdLEjhM|USaBzVpNLG3+y_(R$BD_RmMas$MWs~oG^0ClV~+&9ED$w?cD|Yz+=nu2k$xd2U}uu6PP0V zCo+iBf#`{lqWxs#{-;()(J&9)cV& z*MIxg+j{>(@hd`~jcXbH;1z zth?n%0u(-3tD58KJI#tQPuPp_{T#@NnLsv#(utmIWON>=r)G}FN{F5lNBD@6U;Bn9 z>MqnKn+0+&Jbe!0Sg#XY1|IL>WT_VXUT;oA+Kv6ir{@DlMjpC8`1rDX*N^ifn3Oa- zP>v=r{|3wSjsMrp<+?rvZ1#&IQ%o*?Q%fUy9{OfIvd7w82leqs-`IVe19y5!^8?p+ z%lE(O);9mymq@O`lr{MH-Gap%a!lvK(+9_5!wv_d}s`<0wzR2F;-6sG^f)1 zfAhBE<$Hhn)^a}|--)B-fGBwkg|A}DfUPxB;ADB-k7x(+!4Wu(Z^V|l+qB6&n>1q*9dcD_jHBlT z*vR|+hTp{?KmT(AyX9Nn__#hpI{B~9Yw%ik6(uW2wP}cuI}>`1H0k-6=fBTqX`C$v zyXpzH+GeRX%|8xjW>_S<&=S+Pnr``~H$Jia)W5&2PruNUE@20Cie;tIvIjt59r&b0 zjV=c|+__#ALk??qI+k=+1B_gv^QeSsUl&j? z;p|tZ|KgJ`FMscq_bfcG=0&dhz{tYj7c4!e`8Av9+C(?nNM0J_+A`~hL2+5Y%lGV- zcj`{^cVGXwo}+cX;<;dQvT7u2?0R+qYFq{XM198e*L=}E%d_>lL3~zo=0om&Voy%^ z%h9>f^lD0ytPpr zg~{1jZAiO~^T97J@yeh09w`1xwSh24F`NSEhCjRLSXJn`%mH@4#+$x@;up2ebwIl&_3snm%EJ(YEoj{-clclgY{Q#$UL- z{G^^VuQM1Gu)n(U2vif97a;}2J2D&cm4Ei0<mZtf?9#n|`tkjxXn6KX&EI1=R@*$+Kyw>;|^ zN6TfsKa#H^pu#R*_}$O*#n-X_6q!ggu8IzGT!q@a0d4&GoYsxW{s08 zxcb6`!zl91*VjDiv#}r4pKJ1goci!UFDRc`2%OJ$tT_0@2dCnL<$j-qr9L&M`lL5D z(Jg%h*(2AFmk(S^Onhux>cB?H;>YJE=cKZwR~3}pmJcYob}zo~KupBx=(Nh~M4*nz zFreXsw&7fy?>G)Rb7uLh_>fd0az4fHf;q3Jlg~yVw=Ucr;=5V{Uqw2b-#L3OowL9U z9j+Ix`1q<;8v}WtQ-xXig+I)9(3;nXc|pGNB1^pvR0~0A$kl-?YrweTR}h1GVi

c)ijgxDm}8EsRXFt3h@+Ufr7@DN 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 literal 0 HcmV?d00001 diff --git a/cjagent/src/ohosTest/resources/base/profile/main_pages.json b/cjagent/src/ohosTest/resources/base/profile/main_pages.json new file mode 100644 index 0000000..2c63c08 --- /dev/null +++ b/cjagent/src/ohosTest/resources/base/profile/main_pages.json @@ -0,0 +1,2 @@ +{ +} diff --git a/cjagent/src/test/cangjie/cjpm.toml b/cjagent/src/test/cangjie/cjpm.toml new file mode 100644 index 0000000..6da8b1d --- /dev/null +++ b/cjagent/src/test/cangjie/cjpm.toml @@ -0,0 +1,22 @@ +[package] + cjc-version = "1.2.0" + compile-option = "--dy-std --cfg=\"${COMPILE_CONDITION}\"" + override-compile-option = "${OVERRIDE_COMPILE_OPTION}" + description = "CangjieUI Application" + name = "ohos_app_cangjie_cjagent_local_test" + output-type = "dynamic" + src-dir = "." + target-dir = "" + version = "1.0.0" + +[profile] + [profile.build] + incremental = true + lto = "" + [profile.build.combined] + ohos_app_cangjie_cjagent_local_test = "dynamic" + [profile.customized-option] + debug = "-g -Woff all -Won apilevel-check" + release = "--fast-math -O2 -s -Woff all -Won apilevel-check" + asan = "--sanitize=address -lclang_rt.asan" + [profile.test] diff --git a/cjagent/src/test/cangjie/example.cj b/cjagent/src/test/cangjie/example.cj new file mode 100644 index 0000000..5817c4c --- /dev/null +++ b/cjagent/src/test/cangjie/example.cj @@ -0,0 +1,14 @@ +/** + * Created on 2025/2/26 + */ +package ohos_app_cangjie_cjagent_local_test + +import std.unittest.expectEqual +import std.unittest.testmacro.Expect + +func unittest() { + let a = "abc" + let b = "b" + @Expect(a.contains(b)) + @Expect(a, a) +} \ No newline at end of file diff --git a/cjagent/src/test/cangjie/example_test.cj b/cjagent/src/test/cangjie/example_test.cj new file mode 100644 index 0000000..bf1c3a5 --- /dev/null +++ b/cjagent/src/test/cangjie/example_test.cj @@ -0,0 +1,17 @@ +package ohos_app_cangjie_cjagent_local_test + +import std.unittest.TestClass +import std.unittest.TestPackage +import std.unittest.TestSuite +import std.unittest.UnitTestCase +import std.unittest.common.Configuration +import std.unittest.testmacro.Test +import std.unittest.testmacro.TestCase + +@Test +class TestExample00 { + @TestCase + func test00(): Unit { + unittest() + } +} diff --git a/code-linter.json5 b/code-linter.json5 new file mode 100644 index 0000000..912818d --- /dev/null +++ b/code-linter.json5 @@ -0,0 +1,33 @@ +{ + "files": [ + "**/*.ets", + "**/*.cj" + ], + "ignore": [ + "**/src/ohosTest/**/*", + "**/src/test/**/*", + "**/src/mock/**/*", + "**/node_modules/**/*", + "**/oh_modules/**/*", + "**/build/**/*", + "**/.preview/**/*" + ], + "ruleSet": [ + "plugin:@performance/recommended", + "plugin:@typescript-eslint/recommended" + ], + "rules": { + "@security/no-unsafe-aes": "error", + "@security/no-unsafe-hash": "error", + "@security/no-unsafe-mac": "warn", + "@security/no-unsafe-dh": "error", + "@security/no-unsafe-dsa": "error", + "@security/no-unsafe-ecdsa": "error", + "@security/no-unsafe-rsa-encrypt": "error", + "@security/no-unsafe-rsa-sign": "error", + "@security/no-unsafe-rsa-key": "error", + "@security/no-unsafe-dsa-key": "error", + "@security/no-unsafe-dh-key": "error", + "@security/no-unsafe-3des": "error" + } +} \ No newline at end of file diff --git a/entry/.gitignore b/entry/.gitignore new file mode 100644 index 0000000..e2713a2 --- /dev/null +++ b/entry/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/oh_modules +/.preview +/build +/.cxx +/.test \ No newline at end of file diff --git a/entry/build-profile.json5 b/entry/build-profile.json5 new file mode 100644 index 0000000..6bd6457 --- /dev/null +++ b/entry/build-profile.json5 @@ -0,0 +1,33 @@ +{ + "apiType": "stageMode", + "buildOption": { + "resOptions": { + "copyCodeResource": { + "enable": false + } + } + }, + "buildOptionSet": [ + { + "name": "release", + "arkOptions": { + "obfuscation": { + "ruleOptions": { + "enable": false, + "files": [ + "./obfuscation-rules.txt" + ] + } + } + } + }, + ], + "targets": [ + { + "name": "default" + }, + { + "name": "ohosTest", + } + ] +} \ No newline at end of file diff --git a/entry/hvigorfile.ts b/entry/hvigorfile.ts new file mode 100644 index 0000000..b0e3a1a --- /dev/null +++ b/entry/hvigorfile.ts @@ -0,0 +1,6 @@ +import { hapTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: hapTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ +} \ No newline at end of file diff --git a/entry/obfuscation-rules.txt b/entry/obfuscation-rules.txt new file mode 100644 index 0000000..1e7e54e --- /dev/null +++ b/entry/obfuscation-rules.txt @@ -0,0 +1,23 @@ +# Define project specific obfuscation rules here. +# You can include the obfuscation configuration files in the current module's build-profile.json5. +# +# For more details, see +# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/source-obfuscation + +# Obfuscation options: +# -disable-obfuscation: disable all obfuscations +# -enable-property-obfuscation: obfuscate the property names +# -enable-toplevel-obfuscation: obfuscate the names in the global scope +# -compact: remove unnecessary blank spaces and all line feeds +# -remove-log: remove all console.* statements +# -print-namecache: print the name cache that contains the mapping from the old names to new names +# -apply-namecache: reuse the given cache file + +# Keep options: +# -keep-property-name: specifies property names that you want to keep +# -keep-global-name: specifies names that you want to keep in the global scope + +-enable-property-obfuscation +-enable-toplevel-obfuscation +-enable-filename-obfuscation +-enable-export-obfuscation \ No newline at end of file diff --git a/entry/oh-package-lock.json5 b/entry/oh-package-lock.json5 new file mode 100644 index 0000000..02026aa --- /dev/null +++ b/entry/oh-package-lock.json5 @@ -0,0 +1,19 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "cjagent@../cjagent": "cjagent@../cjagent" + }, + "packages": { + "cjagent@../cjagent": { + "name": "cjagent", + "version": "1.0.0", + "resolved": "../cjagent", + "registryType": "local" + } + } +} \ No newline at end of file diff --git a/entry/oh-package.json5 b/entry/oh-package.json5 new file mode 100644 index 0000000..6c30398 --- /dev/null +++ b/entry/oh-package.json5 @@ -0,0 +1,12 @@ +{ + "name": "entry", + "version": "1.0.0", + "description": "Please describe the basic information.", + "main": "", + "author": "", + "license": "", + "dependencies": { + "cjagent": "file:../cjagent" + } +} + diff --git a/entry/src/main/ets/agentability/SmartSeniorAgentExtAbility.ets b/entry/src/main/ets/agentability/SmartSeniorAgentExtAbility.ets new file mode 100644 index 0000000..fac5d04 --- /dev/null +++ b/entry/src/main/ets/agentability/SmartSeniorAgentExtAbility.ets @@ -0,0 +1,474 @@ +// 端侧智能体扩展组件:与小艺进行 A2A 协议通信(AgentExtensionAbility) +// 架构:module.json5(type:agent) + agent_config.json 声明能力 → 小艺连接 → onData 收发 +// 协议:JSON-RPC 2.0 帧,按官方《对话交互》《动态开场白和引导语》场景回帧 +import { AgentExtensionAbility, common } from '@kit.AbilityKit'; +import Want from '@ohos.app.ability.Want'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { dispatchIntent, registerJumpHandler, takeSuggestions } from '../utils/A2AServer'; +import { BUNDLE_NAME } from '../common/ApiConstants'; +import { A2A_TOKEN_FILE } from '../utils/AuthStore'; +import { fileIo } from '@kit.CoreFileKit'; + +const DOMAIN = 0x0000; +const TAG = 'AgentExt'; + +interface RpcPartData { + ttsText?: string; + openingTexts?: RpcOpeningTexts; + suggestionCandidates?: Array; + suggestions?: Array; + jump?: RpcJump; +} + +interface RpcSuggestion { + text: string; + reply?: string; + jump?: RpcJump; +} + +interface RpcOpeningTexts { + openingText: string; + openingQuestions: Array; +} + +interface RpcPart { + text?: string; + mediaType?: string; + data?: RpcPartData; +} + +interface RpcArtifact { + artifactId: string; + parts: Array; +} + +interface RpcStatus { + state: string; + timestamp?: string; + message?: RpcAgentMessage; +} + +interface RpcAgentMessage { + role?: string; + messageId?: string; + parts?: Array; +} + +interface RpcJump { + overlayMode?: string; + deepLink?: RpcDeepLink; +} + +interface RpcDeepLink { + url: string; + appName?: string; + appPackage?: string; +} + +interface RpcTask { + id: string; + contextId: string; + status: RpcStatus; + artifacts?: Array; +} + +interface RpcFrame { + jsonrpc: string; + id: string; + result: RpcFrameResult; + metadata?: RpcMetadata; +} + +interface RpcMetadata { + traceId: string; + agentId: string; + agentVersion?: string; +} + +interface RpcFrameResult { + task?: RpcTask; + statusUpdate?: RpcStatusUpdate; + artifactUpdate?: RpcArtifactUpdate; +} + +interface RpcStatusUpdate { + taskId: string; + contextId: string; + status: RpcStatus; +} + +interface RpcArtifactUpdate { + taskId: string; + contextId: string; + artifact: RpcArtifact; + append: boolean; + lastChunk: boolean; +} + +interface RpcRequest { + id?: string; + method?: string; + params?: RpcParams; +} + +interface RpcParams { + message?: RpcMessage; + metadata?: RpcMetadata; +} + +interface RpcMessage { + parts?: Array; + taskId?: string; + contextId?: string; + messageId?: string; +} + +interface RpcPartIn { + text?: string; +} + +function uuidLike(): string { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c: string): string => { + const r = Math.floor(Math.random() * 16); + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +export default class SmartSeniorAgentExtAbility extends AgentExtensionAbility { + private comProxy: common.AgentHostProxy | null = null; + private appContext: common.Context | undefined = undefined; + private curReqId: string = ''; + private curTaskId: string = ''; + private curCtxId: string = ''; + private curTraceId: string = ''; + // 原生操控:本轮待下发的跳转(随 COMPLETED 单帧送达,避免 WORKING 中间帧被 returnImmediately 丢弃) + private curJump: RpcJump | null = null; + private static readonly DEVICE_AGENT_ID = 'agent9dc781abfa3d482fad2a9f28224106d9'; + + // 规范要求:响应 metadata 必须原样回传请求 traceId 并带 agentId + private meta(): RpcMetadata { + return { + traceId: this.curTraceId, + agentId: SmartSeniorAgentExtAbility.DEVICE_AGENT_ID, + agentVersion: '1.0.0', + }; + } + + private captureContext(): void { + if (this.appContext !== undefined) { + return; + } + try { + // 直接用扩展自身的 stage 上下文(applicationContext 缺 stage 信息会被 preferences 拒绝) + this.appContext = this.context; + hilog.info(DOMAIN, TAG, '%{public}s', 'appContext captured'); + } catch (err) { + hilog.error(DOMAIN, TAG, 'capture context failed: %{public}s', (err as BusinessError).message); + } + } + + onCreate(want: Want): void { + hilog.info(DOMAIN, TAG, '%{public}s', 'AgentExt onCreate'); + this.captureContext(); + } + + onConnect(want: Want, proxy: common.AgentHostProxy): void { + hilog.info(DOMAIN, TAG, '%{public}s', 'AgentExt onConnect'); + this.comProxy = proxy; + this.captureContext(); + // 原生操控跳转:这版小艺(11.7.6.335)a2a 库收到同任务第二个状态帧即抛 + // "Task is already set" 并把错误串当 JSON 解析 → SyntaxError 自杀。 + // 因此不再发 WORKING 帧,只缓存 curJump,随 onData 末尾的 COMPLETED 单帧送达(可能渲染两遍,可接受)。 + registerJumpHandler(async (targetPage: string, processText?: string): Promise => { + if (this.comProxy === null) { + return false; + } + // 优先用裸链接(官方示例均为 scheme://host 形态,小艺执行器可能不认带查询参数的 url) + // GOHOME:回家编排——小艺执行 smartsenior://gohome 拉起主应用,前台弹打车 + const bare = targetPage === 'pages/elderly/ElderlyWeather' ? 'smartsenior://weather' + : targetPage === 'GOHOME' ? 'smartsenior://gohome' : ''; + const link: RpcDeepLink = { + url: bare.length > 0 ? bare : `smartsenior://page?target=${encodeURIComponent(targetPage)}`, + appName: '数智护老', + appPackage: BUNDLE_NAME, + }; + this.curJump = { overlayMode: 'capsule', deepLink: link }; + return true; + }); + } + + onDisconnect(want: Want, proxy: common.AgentHostProxy): void { + hilog.info(DOMAIN, TAG, '%{public}s', 'AgentExt onDisconnect'); + this.comProxy = null; + } + + onAuth(proxy: common.AgentHostProxy, handshakeData: string): void { + hilog.info(DOMAIN, TAG, 'AgentExt onAuth len=%{public}d', handshakeData.length); + try { + proxy.authorize('auth success'); + } catch (err) { + hilog.error(DOMAIN, TAG, 'authorize failed: %{public}s', (err as BusinessError).message); + } + } + + private sendFrame(proxy: common.AgentHostProxy, frame: RpcFrame): void { + try { + proxy.sendData(JSON.stringify(frame)); + } catch (err) { + hilog.error(DOMAIN, TAG, 'sendData failed: %{public}s', (err as BusinessError).message); + } + } + + private nowIso(): string { + return new Date().toISOString(); + } + + private taskFrame(reqId: string, taskId: string, contextId: string, state: string): RpcFrame { + const status: RpcStatus = { state: state, timestamp: this.nowIso() }; + const frame: RpcFrame = { + jsonrpc: '2.0', + id: reqId, + result: { + task: { + id: taskId, + contextId: contextId, + status: status, + }, + }, + metadata: this.meta(), + }; + return frame; + } + + private completedFrame(reqId: string, taskId: string, contextId: string, result: string, jump?: RpcJump, + suggestions?: Array): RpcFrame { + const parts: Array = [ + { text: result, mediaType: 'text/plain' }, + { data: { ttsText: result } }, + ]; + if (suggestions && suggestions.length > 0) { + parts.unshift({ data: { suggestions: suggestions } }); + } + if (jump) { + parts.unshift({ data: { jump: jump } }); + } + const artifact: RpcArtifact = { + artifactId: uuidLike(), + parts: parts, + }; + const status: RpcStatus = { state: 'TASK_STATE_COMPLETED', timestamp: this.nowIso() }; + const frame: RpcFrame = { + jsonrpc: '2.0', + id: reqId, + result: { + task: { + id: taskId, + contextId: contextId, + status: status, + artifacts: [artifact], + }, + }, + metadata: this.meta(), + }; + return frame; + } + + private isLoggedIn(): boolean { + try { + return fileIo.readTextSync(A2A_TOKEN_FILE).trim().length > 0; + } catch (err) { + return false; + } + } + + // 动态开场白与引导语:按时段和登录状态生成个性化内容(官方《动态开场白和引导语》场景) + // 单帧回传:returnImmediately 模式下中间帧会被丢弃,开场白必须随 COMPLETED 帧一起送达 + private openingFrames(reqId: string, taskId: string, contextId: string): Array { + const hour = new Date().getHours(); + let greeting = '您好'; + let questions: Array = ['我要订午餐', '帮我叫护士过来一下', '今日有什么活动']; + if (hour < 11) { + greeting = '早上好'; + questions = ['我要订早餐', '看看天气', '今日有什么活动']; + } else if (hour < 14) { + greeting = '中午好'; + questions = ['我要订午餐', '看看今日菜单', '看看天气']; + } else if (hour < 19) { + greeting = '下午好'; + questions = ['我要订晚餐', '今日有什么活动', '看看天气']; + } else { + greeting = '晚上好'; + questions = ['我要订晚餐', '帮我叫护士过来一下', '看看天气']; + } + let openingText = `${greeting},我是数智护老助手~可以帮您订餐、呼叫护理员、查活动查天气,也可以陪您聊天。`; + if (!this.isLoggedIn()) { + openingText = `${greeting},我是数智护老助手~请先打开应用登录,登录后我可以帮您订餐、呼叫护理员、查活动查天气。`; + questions = ['打开应用登录']; + } + const openingData: RpcPartData = { + openingTexts: { + openingText: openingText, + openingQuestions: questions, + }, + }; + const openingParts: Array = [{ data: openingData }]; + const artifact: RpcArtifact = { + artifactId: uuidLike(), + parts: openingParts, + }; + const status: RpcStatus = { state: 'TASK_STATE_COMPLETED', timestamp: this.nowIso() }; + const frame: RpcFrame = { + jsonrpc: '2.0', + id: reqId, + result: { + task: { + id: taskId, + contextId: contextId, + status: status, + artifacts: [artifact], + }, + }, + metadata: this.meta(), + }; + return [frame]; + } + + // chips 话题推荐:小艺感知用户所在 App 后请求推荐操作建议(官方《chips(话题)推荐》场景) + private perceptionFrames(reqId: string, taskId: string, contextId: string): Array { + const hour = new Date().getHours(); + const candidates: Array = []; + if (hour < 11) { + candidates.push({ text: '我要订早餐', reply: '我要订早餐' }); + } else if (hour < 14) { + candidates.push({ text: '我要订午餐', reply: '我要订午餐' }, { text: '看看今日菜单', reply: '看看今日菜单' }); + } else { + candidates.push({ text: '我要订晚餐', reply: '我要订晚餐' }); + } + candidates.push( + { text: '看看天气', reply: '看看天气' }, + { text: '今日有什么活动', reply: '今日有什么活动' }, + { text: '帮我叫护士过来一下', reply: '帮我叫护士过来一下' }, + ); + const data: RpcPartData = { suggestionCandidates: candidates }; + const parts: Array = [{ data: data }]; + const artifact: RpcArtifact = { artifactId: uuidLike(), parts: parts }; + const status: RpcStatus = { state: 'TASK_STATE_COMPLETED', timestamp: this.nowIso() }; + const frame: RpcFrame = { + jsonrpc: '2.0', + id: reqId, + result: { + task: { + id: taskId, + contextId: contextId, + status: status, + artifacts: [artifact], + }, + }, + metadata: this.meta(), + }; + return [frame]; + } + + onData(proxy: common.AgentHostProxy, data: string): void { + hilog.info(DOMAIN, TAG, 'AgentExt onData len=%{public}d', data.length); + hilog.info(DOMAIN, TAG, 'onData raw=%{public}s', data.substring(0, Math.min(600, data.length))); + let reqId = ''; + let method = ''; + let userText = ''; + let messageId = ''; + let taskId = uuidLike(); + let contextId = uuidLike(); + let traceId = ''; + try { + const req: RpcRequest = JSON.parse(data) as RpcRequest; + reqId = typeof req.id === 'string' ? req.id : ''; + method = typeof req.method === 'string' ? req.method : ''; + if (req.params && req.params.metadata && typeof req.params.metadata.traceId === 'string') { + traceId = req.params.metadata.traceId; + } + if (req.params && req.params.message) { + const msg = req.params.message; + if (typeof msg.messageId === 'string' && msg.messageId.length > 0) { + messageId = msg.messageId; + } + if (typeof msg.taskId === 'string' && msg.taskId.length > 0) { + taskId = msg.taskId; + } + if (typeof msg.contextId === 'string' && msg.contextId.length > 0) { + contextId = msg.contextId; + } + if (msg.parts) { + const texts: Array = []; + msg.parts.forEach((p: RpcPartIn): void => { + if (typeof p.text === 'string' && p.text.length > 0) { + texts.push(p.text); + } + }); + userText = texts.join(' '); + } + } + } catch (err) { + hilog.error(DOMAIN, TAG, 'onData parse failed: %{public}s', (err as BusinessError).message); + } + hilog.info(DOMAIN, TAG, 'method=%{public}s userText=%{public}s', method, userText.substring(0, Math.min(200, userText.length))); + hilog.info(DOMAIN, TAG, 'dedup-diag reqId=%{public}s messageId=%{public}s traceId=%{public}s taskId=%{public}s', reqId, messageId, traceId, taskId); + this.curReqId = reqId; + this.curTaskId = taskId; + this.curCtxId = contextId; + this.curTraceId = traceId; + + if (method === 'GetOpening') { + const frames = this.openingFrames(reqId, taskId, contextId); + frames.forEach((frame: RpcFrame): void => { + this.sendFrame(proxy, frame); + }); + return; + } + if (method === 'PerceptionSuggest') { + const frames = this.perceptionFrames(reqId, taskId, contextId); + frames.forEach((frame: RpcFrame): void => { + this.sendFrame(proxy, frame); + }); + return; + } + if (method === 'CancelTask') { + this.sendFrame(proxy, this.taskFrame(reqId, taskId, contextId, 'TASK_STATE_CANCELED')); + return; + } + if (userText.length === 0) { + return; + } + if (this.appContext === undefined) { + this.captureContext(); + } + if (this.appContext === undefined) { + hilog.error(DOMAIN, TAG, 'no valid context, cannot execute'); + this.sendFrame(proxy, this.completedFrame(reqId, taskId, contextId, '终端上下文未就绪,请稍后重试。')); + return; + } + // returnImmediately=true:小艺收到第一帧即结束请求,必须直接回完整结果的 COMPLETED 单帧 + dispatchIntent(userText, messageId, this.curCtxId).then((result: string): void => { + hilog.info(DOMAIN, TAG, 'dispatch result=%{public}s', result.substring(0, Math.min(120, result.length))); + const jump = this.curJump; + this.curJump = null; + if (jump !== null) { + hilog.info(DOMAIN, TAG, 'completed frame carries jump: %{public}s', JSON.stringify(jump)); + } + const sugg = takeSuggestions(); + if (sugg.length > 0) { + hilog.info(DOMAIN, TAG, 'completed frame carries %{public}d suggestion(s)', sugg.length); + } + this.sendFrame(proxy, this.completedFrame(reqId, taskId, contextId, result, jump ?? undefined, sugg)); + }).catch((err: BusinessError): void => { + hilog.error(DOMAIN, TAG, 'dispatch failed: %{public}s', err.message); + this.curJump = null; + this.sendFrame(proxy, this.completedFrame(reqId, taskId, contextId, `处理失败:${err.message}`)); + }); + } + + onDestroy(): void { + hilog.info(DOMAIN, TAG, '%{public}s', 'AgentExt onDestroy'); + } +} diff --git a/entry/src/main/ets/common/ApiConstants.ts b/entry/src/main/ets/common/ApiConstants.ts new file mode 100644 index 0000000..39acf2c --- /dev/null +++ b/entry/src/main/ets/common/ApiConstants.ts @@ -0,0 +1,188 @@ +// 智慧养老客户端全局常量 +import { BusinessError } from '@kit.BasicServicesKit'; + +// ============================================================ +// 后端地址:全局唯一配置点,改这一处即可切换环境 +// - 模拟器访问宿主机:http://10.0.2.2:8000 +// - 真机访问电脑(同一 Wi-Fi):http://<电脑局域网IP>:8000,如 http://192.168.1.100:8000 +// - 生产环境:https://<正式域名> +// ============================================================ +export const API_BASE_URL: string = 'https://t.gzitvs.cn'; +export const API_PREFIX: string = '/api/v1'; +export const DEFAULT_REQUEST_TIMEOUT: number = 20000; +export const BUNDLE_NAME: string = 'com.captain1275.smartseniorhmos'; + +// 自建 PHP 推送服务端(上报 push token 落库 + 直推华为,不经过 gzitvs) +// 部署地址:https://test.ldpfly.cn +export const PUSH_SERVER_BASE_URL: string = 'https://test.ldpfly.cn'; + +// 拼接后端媒体文件完整地址(图片/附件等返回相对路径时使用) +export function toFullMediaUrl(path: string | null | undefined): string { + if (!path || path.length === 0) { + return ''; + } + if (path.startsWith('http://') || path.startsWith('https://')) { + return path; + } + const prefix = path.startsWith('/') ? '' : '/'; + return `${API_BASE_URL}${prefix}${path}`; +} + +// 媒体 API 前缀(带鉴权的媒体访问走这里,如护理记录图片) +export const MEDIA_API_PREFIX: string = '/api/v1/tasks/media/'; + +// 规范媒体路径 → 需要鉴权的媒体 API 完整地址 +// 输入形如 '/media/nursing_records/u1/xxx.jpg'(上传接口返回值) +export function toAuthMediaUrl(canonicalPath: string | null | undefined): string { + if (!canonicalPath || canonicalPath.length === 0) { + return ''; + } + if (canonicalPath.startsWith('http://') || canonicalPath.startsWith('https://')) { + return canonicalPath; + } + let rest = canonicalPath; + if (rest.startsWith('/media/')) { + rest = rest.substring('/media/'.length); + } else if (rest.startsWith(MEDIA_API_PREFIX)) { + rest = rest.substring(MEDIA_API_PREFIX.length); + } else if (rest.startsWith('/')) { + rest = rest.substring(1); + } + return `${API_BASE_URL}${MEDIA_API_PREFIX}${rest}`; +} + +// 认证匿名路径(无需 Authorization) +export const ANONYMOUS_AUTH_PATHS: string[] = [ + '/auth/login/', + '/auth/register/', + '/auth/family/register/', + '/auth/refresh/', + '/auth/email/login/', + '/auth/email/code/', + '/auth/email/reset-password/', + '/auth/wechat/login/', + '/auth/wechat/bind/', +]; + +// 角色统一类型 +export type UnifiedRole = + | 'admin' + | 'sales' + | 'fire_control' + | 'nurse' + | 'staff_nurse' + | 'family' + | 'elderly' + | 'employee'; + +// 登录响应中的用户信息 +export interface LoginUser { + id: number; + username: string; + name: string; + role: UnifiedRole; + phone: string; + wechat_bound: boolean; + family_verified: boolean; + available_agents: string[]; + must_change_password: boolean; +} + +// 登录响应 +export interface LoginResponse { + access: string; + refresh: string; + user: LoginUser; +} + +// 刷新令牌响应 +export interface RefreshResponse { + access: string; + refresh: string; +} + +// 通用分页响应 +export interface Page { + count: number; + next: string | null; + previous: string | null; + results: T[]; +} + +// 深色主题色板(沿用原 HDS 深色 UI 风格) +export const ThemeColors = { + background: '#0A0A0A', + cardBg: '#1E1E1E', + textPrimary: '#FFFFFF', + textSecondary: '#F0F0F0', + buttonSuccess: '#00FF99', + buttonDanger: '#FF4D4D', + highlight: '#FF4D4D', + primaryBlue: '#007AFF', + inactiveTab: '#888888', +} as const; + +// 工作台标题映射 +export const WORKBENCH_TITLE_MAP: Record = { + admin: '工作台', + sales: '销售工作台', + fire_control: '消防工作台', + nurse: '护理工作台', + staff_nurse: '护士工作台', + family: '看护', + elderly: '老人端', + employee: '工作台', +}; + +// 角色中文名 +export const ROLE_NAME_MAP: Record = { + admin: '管理员', + sales: '销售', + fire_control: '消防', + nurse: '护理员', + staff_nurse: '护士', + family: '家属', + elderly: '老人', + employee: '员工', +}; + +// preferences 存储键 +export const PREFS_NAME = 'SmartSeniorPrefs'; +export const PREFS_TOKEN = 'accessToken'; +export const PREFS_REFRESH = 'refreshToken'; +export const PREFS_ROLE = 'userRole'; +export const PREFS_USER_ID = 'userId'; +export const PREFS_USER_NAME = 'userName'; +export const PREFS_FAMILY_VERIFIED = 'familyVerified'; +export const PREFS_AGREED = 'agreedState'; + +// 家属端健康风险报告:PDF 本地文件名 + 供端侧智能体直读的报告上下文文件(沙箱绝对路径) +// 智能体扩展进程(AgentExtensionAbility)与主进程同沙箱,按此绝对路径直读(与 a2a_token.txt 同一套路) +export const REPORT_PDF_FILE_NAME = 'health_risk_report.pdf'; +export const REPORT_CONTEXT_FILE = '/data/storage/el2/base/files/current_report.txt'; +// 健康风险报告 AI 总结接口(后端就绪后生效;未就绪/失败时端侧自动回退到报告 llm_analysis) +export const REPORT_SUMMARY_API = '/reports/health-risk/summary/'; +// 健康风险报告正文接口(PDF 由后端生成,正文其后端本就有;端侧 OCR 在部分设备不可用,优先取正文) +export const REPORT_TEXT_API = '/reports/health-risk/text/'; + +// 是否匿名路径 +export function isAnonymousAuthPath(path: string): boolean { + const normalized = path.split('?')[0]; + const withSlash = normalized.endsWith('/') ? normalized : `${normalized}/`; + return ANONYMOUS_AUTH_PATHS.indexOf(withSlash) >= 0; +} + +// 从异常中提取用户可读信息 +export function normalizeError(err: BusinessError | Error): string { + if (err && typeof err === 'object') { + const message = (err as Error).message; + if (typeof message === 'string' && message.trim().length > 0) { + return message.trim(); + } + const code = (err as BusinessError).code; + if (typeof code === 'number') { + return `错误码 ${code}`; + } + } + return '请求失败,请稍后重试'; +} diff --git a/entry/src/main/ets/common/Constants.ets b/entry/src/main/ets/common/Constants.ets new file mode 100644 index 0000000..2b773db --- /dev/null +++ b/entry/src/main/ets/common/Constants.ets @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2025 Huawei Device Co., Ltd. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export class Constants { + /** + * Percent of 100% + */ + static readonly FULL_PERCENT: string = '100%'; + + /** + * Agreement Consent Status: 0-disagree; 1-agree + */ + static readonly AGREEMENT_STATE: number[] = [0, 1]; + + /** + * Delay time + */ + static readonly DELAY_TIME: number = 500; + + /** + * FontColor list + */ + static readonly FONT_COLOR: string[] = ['rgba(0, 0, 0, 0.6)', 'rgba(0, 0, 0, 0.9)', 'rgb(10, 89, 247)']; + + /** + * Button color + */ + static readonly BUTTON_COLOR: string = 'rgba(0, 0, 0, 0.05)'; + + /** + * Agreement and privacy content + */ + static readonly AGREEMENT_AND_PRIVACY_CONTENT: string = '亲爱的用户:\n感谢您选择LDP FLY EFB For HarmonyOS NEXT!\n为了加强对您个人信息的保护,根据最新的法律法规要求,我们更新了 和 ,请您仔细阅读并确认。本应用非常重视对用户信息、用户隐私的保护,承诺将按照相关安全标准,采取相应的安全保障措施来保护用户的信息。本隐私政策将向您阐述系统如何收集、使用和存储您的个人信息及您享有何种权利,请您在注册、使用本系统前,务必仔细阅读并充分了解本隐私政策的全部条款,特别是以粗体标识的条款,您应重点阅读,在确认充分理解并同意后再开始使用。若您点击“同意”按钮,即表示您同意并接受本隐私政策的全部条款。'; + + /** + * Agreement and privacy context for click + */ + static readonly AGREEMENT_OR_PRIVACY: string[] = ['《用户协议》','《隐私政策》']; + + /** + * Agreement`s position + */ + static readonly AGREEMENT_POSTION: number[] = [105, 57]; + + /** + * Privacy`s position + */ + static readonly PRIVACY_POSTION: number[] = [210, 57]; + + /** + * FontWeight 600 + */ + static readonly FONT_WEIGHT_600: number = 600; + + /** + * User Agreement + */ + static readonly DETAIL: string[] = [ + ]; + + /** + * User Agreement detail + */ + static readonly AGREEMENT_DETAIL: string[] = [ + 'LDP FLY EFB For HarmonyOS NEXT 用户协议\n\n更新日期:2025年5月13日\n', + ' 欢迎使用 LDP FLY EFB For HarmonyOS NEXT(以下简称“本应用”)。在您使用本应用之前,请仔细阅读以下条款。\n', + '1. 关于我们\n', + ' 本应用是一款专为提升模拟飞行飞行操作效率而设计的电子飞行包(EFB)应用程序。\n', + '2. 使用条款\n', + ' 您需要拥有运行 HarmonyOS NEXT / HarmonyOS 5 及以上版本的设备才能使用本应用。\n', + '3. 用户行为规范\n', + ' 您在使用本应用时,应遵守所有适用的法律法规,并且不得从事任何危害网络安全、侵犯他人合法权益的行为。\n', + '4. 知识产权声明\n', + ' 本应用的所有知识产权归开发者所有,未经许可,不得复制、修改或分发本应用的任何部分。\n', + '5. 免责声明\n', + ' 在法律允许的最大范围内,本应用按“现状”提供,不提供任何形式的保证。开发者不对因使用或无法使用本应用而导致的任何直接或间接损失承担责任。\n', + '6. 协议变更\n', + ' 我们保留在任何时候修改本用户协议的权利,修改后的协议将在本应用中公布。\n', + '7. 联系方式\n', + ' 如有任何疑问,请通过以下邮箱联系我们:1510006135@qq.com\n' + ]; + + /** + * Privacy Policy detail + */ + static readonly PRIVACY_DETAIL: string[] = [ + '数智护老 隐私政策\n\n更新日期:2026年8月28日\n\n', + ' 数智护老(以下简称“本应用”)是一款面向养老机构的多端协同客户端,用于护理记录查看、健康预警提醒、餐食预订与家属看护等场景。我们致力于保护您的隐私和数据安全。本隐私政策详细说明了我们如何处理您的信息。\n', + '1. 介绍\n', + ' 本应用仅支持运行在搭载 HarmonyOS NEXT / HarmonyOS 5 及以上的设备上。我们建议您在兼容的操作系统版本上使用本应用以获得最佳体验。\n', + '2. 数据收集与使用\n', + ' 账号信息:您登录时需要提供机构分配的账号,应用会收集您的登录凭证用于身份识别。\n', + ' 业务数据:护理记录、健康指标、预警消息、订餐信息等数据来自您所属养老机构的服务端,仅用于向您提供相应功能。\n', + ' 数据存储:所有业务数据均保存在机构指定的服务端,本应用本地仅缓存登录状态与必要的展示数据。\n', + '3. 敏感信息保护\n', + ' 老人身份信息、健康数据等敏感字段在服务端加密存储;应用展示时遵循最小可见原则,家属端仅能看到已绑定长者的授权信息。\n', + '4. 第三方服务\n', + ' 我们的应用不包含任何第三方广告或跟踪服务,确保您的使用体验纯净无干扰。\n', + '5. 用户权利\n', + ' 您可以随时退出登录清除本地登录状态;如需查询、更正或删除您的个人信息,请联系所在机构的管理员。\n', + '6. 联系我们\n', + ' 如有任何关于隐私政策的问题,请联系机构管理员或通过应用内的意见反馈功能提交。\n' + ]; + + /** + * Duration time + */ + static readonly DURATION_TIME: number = 1000; + + /** + * Space + */ + static readonly SPACE: number = 20; + + /** + * initial index is 3 + */ + static readonly INITIAL_INDEX = 3; + + /** + * Divider Stroke Width + */ + static readonly STROKE_WIDTH_DEFAULT: number = 1; + + /** + * Divider color + */ + static readonly DIVIDER_COLOR = 'rgba(0, 0, 0, 0.2)'; + + /** + * router + */ + static readonly ROUTER: string = 'Home'; + + /** + * Component`s id + */ + static readonly COMPONENT_ID: string[] = ['agreement', 'privacy']; + + /** + * Title for agreement and privacy dialog + */ + static readonly AGREEMENT_TITLE: string = '用户协议 隐私保护'; + + /** + * Cancel button text + */ + static readonly CANCEL_TEXT: string = '取消'; + + /** + * Agree button text + */ + static readonly AGREE_TEXT: string = '同意'; + + /** + * Privacy policy sheet detents + */ + static readonly PRIVACY_SHEET_DETENTS: SheetSize[] = [SheetSize.LARGE]; +} diff --git a/entry/src/main/ets/common/RouteTarget.ts b/entry/src/main/ets/common/RouteTarget.ts new file mode 100644 index 0000000..1e106e8 --- /dev/null +++ b/entry/src/main/ets/common/RouteTarget.ts @@ -0,0 +1,26 @@ +// 跳转目标解析:'pages/X?id=1' -> { url: 'pages/X', params: { id: '1' } } +// router.pushUrl 的 url 不能带 query,参数必须走 params 字段 +export interface RouteTarget { + url: string; + params: Record; +} + +export function parseRouteTarget(target: string): RouteTarget { + const q = target.indexOf('?'); + if (q < 0) { + const empty: Record = {}; + const plain: RouteTarget = { url: target, params: empty }; + return plain; + } + const url = target.substring(0, q); + const params: Record = {}; + const pairs = target.substring(q + 1).split('&'); + for (const p of pairs) { + const kv = p.split('='); + if (kv.length === 2 && kv[0].length > 0) { + params[kv[0]] = decodeURIComponent(kv[1]); + } + } + const result: RouteTarget = { url: url, params: params }; + return result; +} diff --git a/entry/src/main/ets/components/ProtectedImage.ets b/entry/src/main/ets/components/ProtectedImage.ets new file mode 100644 index 0000000..9842d7b --- /dev/null +++ b/entry/src/main/ets/components/ProtectedImage.ets @@ -0,0 +1,84 @@ +// 带鉴权的图片组件:通过 http 拉取二进制(携带 token)再渲染 +// 用于护理记录图片等需要 Authorization 的媒体(/api/v1/tasks/media/...) +// ArkTS 严格类型,无 any +import { image } from '@kit.ImageKit'; +import { toAuthMediaUrl } from '../common/ApiConstants'; +import { fetchMediaBuffer, RequestError } from '../utils/Request'; + +@Component +export struct ProtectedImage { + // 后端返回的规范媒体路径,如 /media/nursing_records/u1/xxx.jpg + @Prop mediaPath: string = ''; + @State pixelMap: image.PixelMap | null = null; + @State failed: boolean = false; + @State loading: boolean = false; + + // 同一路径只拉一次(内存缓存) + static cache: Map = new Map(); + + aboutToAppear(): void { + this.load(); + } + + private async load(): Promise { + if (this.mediaPath.length === 0) { + this.failed = true; + return; + } + const cached = ProtectedImage.cache.get(this.mediaPath); + if (cached) { + this.pixelMap = cached; + return; + } + this.loading = true; + try { + const url = toAuthMediaUrl(this.mediaPath); + const buffer = await fetchMediaBuffer(url); + const source = image.createImageSource(buffer); + const pm = await source.createPixelMap(); + source.release(); + ProtectedImage.cache.set(this.mediaPath, pm); + this.pixelMap = pm; + } catch (err) { + this.failed = true; + console.error('[ProtectedImage] load failed:', (err as RequestError).message); + } finally { + this.loading = false; + } + } + + build() { + Stack() { + if (this.pixelMap) { + Image(this.pixelMap) + .objectFit(ImageFit.Cover) + .width('100%') + .height('100%') + } else if (this.failed) { + Column({ space: 4 }) { + Text('⚠') + .fontSize(20) + Text('图片加载失败') + .fontSize(11) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .backgroundColor($r('app.color.background_card')) + } else { + Column() { + Text(this.loading ? '加载中...' : '') + .fontSize(11) + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .backgroundColor($r('app.color.background_card')) + } + } + .width('100%') + .height('100%') + } +} diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets new file mode 100644 index 0000000..6b1d3ed --- /dev/null +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -0,0 +1,262 @@ +import { AbilityConstant, ConfigurationConstant, UIAbility, Want } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { router, window } from '@kit.ArkUI'; +import { preferences } from '@kit.ArkData'; +import { PushManager } from '../utils/PushManager'; +import { loadFontScale, refreshCareMode, FontScaleLevel } from '../utils/FontScale'; +import { PREFS_NAME, PREFS_AGREED, PREFS_ROLE } from '../common/ApiConstants'; +import { parseRouteTarget } from '../common/RouteTarget'; +// 引用执行型意图:确保意图文件进入编译图,被 insight_intent 提取器扫描 +import OrderMealIntent from '../intents/OrderMealIntent'; +import CancelMealIntent from '../intents/CancelMealIntent'; +import CancelDinnerIntent from '../intents/CancelDinnerIntent'; +import CancelBreakfastIntent from '../intents/CancelBreakfastIntent'; +import CancelLunchIntent from '../intents/CancelLunchIntent'; +import OrderBreakfastIntent from '../intents/OrderBreakfastIntent'; +import OrderLunchIntent from '../intents/OrderLunchIntent'; +import OrderDinnerIntent from '../intents/OrderDinnerIntent'; + +const DOMAIN = 0x0000; + +export default class EntryAbility extends UIAbility { + private windowStage: window.WindowStage | null = null; + // 持有引用,防止意图执行器被摇树剔除 + readonly intentProbe: OrderMealIntent = new OrderMealIntent(); + readonly intentProbeCancelMeal: CancelMealIntent = new CancelMealIntent(); + readonly intentProbeCancelDinner: CancelDinnerIntent = new CancelDinnerIntent(); + readonly intentProbeCancelBreakfast: CancelBreakfastIntent = new CancelBreakfastIntent(); + readonly intentProbeCancelLunch: CancelLunchIntent = new CancelLunchIntent(); + readonly intentProbeBreakfast: OrderBreakfastIntent = new OrderBreakfastIntent(); + readonly intentProbeLunch: OrderLunchIntent = new OrderLunchIntent(); + readonly intentProbeDinner: OrderDinnerIntent = new OrderDinnerIntent(); + onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate'); + hilog.info(DOMAIN, 'testTag', 'launch want params: %{public}s', JSON.stringify(want.parameters ?? {})); + // 隐私政策检查提前到应用初始化:未同意则通知 UI 立即弹窗 + // 通知授权必须在隐私政策同意之后才请求,避免系统弹窗抢占隐私弹窗 + preferences.getPreferences(this.context, PREFS_NAME).then(async (prefs) => { + const agreed = await prefs.get(PREFS_AGREED, 0); + const needCheck = agreed !== 1; + AppStorage.setOrCreate('needPrivacyCheck', needCheck); + // 字体设置:老人/家属会话恢复持久化档位,其他角色跟随系统 + const lastRole = await prefs.get(PREFS_ROLE, ''); + if (typeof lastRole === 'string' && (lastRole === 'elderly' || lastRole === 'family')) { + loadFontScale(this.context).catch((err: BusinessError) => { + hilog.error(DOMAIN, 'testTag', 'loadFontScale failed: %{public}s', err.message); + }); + } else { + AppStorage.setOrCreate('fontScale', 'system'); + } + if (!needCheck) { + PushManager.init(this.context).catch((err: BusinessError) => { + hilog.error(DOMAIN, 'testTag', 'PushManager init failed: %{public}s', err.message); + }); + } + }).catch(() => { + AppStorage.setOrCreate('needPrivacyCheck', true); + }); + // 服务卡片点击跳转:冷启动时记录目标页,登录进入主页后由 ElderlyHome 兜底跳转 + const target = want.parameters ? String(want.parameters['targetPage'] ?? '') : ''; + if (target.length > 0) { + AppStorage.setOrCreate('pendingCardTarget', target); + } + // 小艺意图(页面意图):系统传入 @normalized 页面路径,由 App 自行路由 + const intentPage = this.insightTargetPage(want); + if (intentPage.length > 0) { + AppStorage.setOrCreate('pendingCardTarget', intentPage); + } + // 小艺原生操控 deepLink:smartsenior://page?target=<页面路径> + const linkPage = this.deepLinkTarget(want); + if (linkPage.length > 0) { + AppStorage.setOrCreate('pendingCardTarget', linkPage); + } + // 碰一碰分享的健康报告:smartsenior://report?data= + this.reportData(want); + // 回家编排:小艺执行 smartsenior://gohome deepLink(或 startAbility 带参),前台弹打车 + const coldUri = typeof want.uri === 'string' ? want.uri : ''; + if (coldUri.indexOf('smartsenior://gohome') === 0 || + (want.parameters && String(want.parameters['gohomeRide'] ?? '').length > 0)) { + AppStorage.setOrCreate('pendingGohomeRide', true); + } + // 加载持久化字体大小设置写入 AppStorage,让全局 @StorageProp('fontScale') 启动即生效 + loadFontScale(this.context).catch((err: BusinessError) => { + hilog.error(DOMAIN, 'testTag', 'loadFontScale failed: %{public}s', err.message); + }); + // 关怀模式:检测系统无障碍开关,写入 AppStorage 供各页面响应 + refreshCareMode(); + } + + onNewWant(want: Want): void { + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onNewWant'); + hilog.info(DOMAIN, 'testTag', 'newWant params: %{public}s', JSON.stringify(want.parameters ?? {})); + // 热启动:卡片/快捷方式跳转目标页(走窗口 UIContext 路由) + let target = want.parameters ? String(want.parameters['targetPage'] ?? '') : ''; + const linkPage = this.deepLinkTarget(want); + if (linkPage.length > 0) { + target = linkPage; + AppStorage.setOrCreate('pendingCardTarget', linkPage); + } + // 热启动:碰一碰分享的健康报告(必须先于跳转逻辑) + const reportPage = this.reportData(want); + // 热启动:回家编排弹打车(deepLink 或 startAbility 带参) + const warmUri = typeof want.uri === 'string' ? want.uri : ''; + if (warmUri.indexOf('smartsenior://gohome') === 0 || + (want.parameters && String(want.parameters['gohomeRide'] ?? '').length > 0)) { + AppStorage.setOrCreate('pendingGohomeRide', true); + } + if (reportPage.length > 0) { + target = reportPage; + } + if (target.length > 0) { + AppStorage.setOrCreate('pendingCardTarget', target); + } + // 热启动:小艺意图跳转目标页(走窗口 UIContext 的路由,Ability 级 router 解析不到页面) + const intentPage = this.insightTargetPage(want); + if (intentPage.length > 0 && target.length === 0) { + target = intentPage; + AppStorage.setOrCreate('pendingCardTarget', intentPage); + } + if (target.length > 0 && this.windowStage !== null) { + const uiRouter = this.windowStage.getMainWindowSync().getUIContext().getRouter(); + const rt = parseRouteTarget(target); + // 延迟到 onForeground 后再跳,避免窗口尚未就绪 + setTimeout(() => { + try { + uiRouter.pushUrl({ url: rt.url, params: rt.params }).catch((err: BusinessError) => { + hilog.error(DOMAIN, 'testTag', 'intent jump failed: %{public}s %{public}s', err.code.toString(), err.message); + }); + } catch (err) { + hilog.error(DOMAIN, 'testTag', 'intent jump threw: %{public}s', (err as BusinessError).message); + } + }, 200); + } + } + + // 碰一碰分享的健康报告:smartsenior://report?data=,存 AppStorage 由报告页渲染;返回应跳转的页面路径 + private reportData(want: Want): string { + const uri = typeof want.uri === 'string' ? want.uri : ''; + if (uri.length === 0 || uri.indexOf('smartsenior://report?data=') !== 0) { + return ''; + } + try { + const data = decodeURIComponent(uri.substring('smartsenior://report?data='.length)); + AppStorage.setOrCreate('pendingReportData', data); + AppStorage.setOrCreate('pendingCardTarget', 'pages/family/FamilyReportCard'); + hilog.info(DOMAIN, 'testTag', 'report data received, length %{public}d', data.length); + return 'pages/family/FamilyReportCard'; + } catch (err) { + hilog.error(DOMAIN, 'testTag', 'report data parse failed: %{public}s', (err as Error).message); + return ''; + } + } + + // 从意图 want 中解析目标页路由:@normalized:N&&&entry/src/main/ets/pages/elderly/X& -> pages/elderly/X + private insightTargetPage(want: Want): string { + const raw = want.parameters ? String(want.parameters['ohos.insightIntent.pageParam.pagePath'] ?? '') : ''; + if (raw.length === 0) { + return ''; + } + const marker = 'src/main/'; + const start = raw.indexOf(marker); + if (start < 0) { + return ''; + } + let page = raw.substring(start + marker.length); + const amp = page.indexOf('&'); + if (amp >= 0) { + page = page.substring(0, amp); + } + return page; + } + + // 从 deepLink 解析目标页:smartsenior://page?target=<页面路径>、smartsenior://page?id=N(预警详情) + private deepLinkTarget(want: Want): string { + let uri = typeof want.uri === 'string' ? want.uri : ''; + if (uri.length === 0 || uri.indexOf('smartsenior://') !== 0) { + return ''; + } + // 推送/系统传入的 uri 可能整体 URL 编码过,先解码再解析 + try { + const decoded = decodeURIComponent(uri); + if (decoded.indexOf('smartsenior://') === 0) { + uri = decoded; + } + } catch (err) { + // 解码失败按原始 uri 处理 + } + hilog.info(DOMAIN, 'testTag', 'deepLink uri: %{public}s', uri); + if (uri.indexOf('smartsenior://weather') === 0) { + return 'pages/elderly/ElderlyWeather'; + } + if (uri.indexOf('smartsenior://meal') === 0) { + return 'pages/elderly/ElderlyMealOrder'; + } + if (uri.indexOf('smartsenior://activities') === 0) { + return 'pages/elderly/Activities'; + } + const marker = 'target='; + const start = uri.indexOf(marker); + if (start >= 0) { + // 保留 target 之后的完整 query(如 &id=90),由消费方 parseRouteTarget 拆成 url + params。 + // 之前在这里按 '&' 截断会丢掉参数,导致需要 id 的页面(如家属健康报告)拿不到参数被回退。 + return decodeURIComponent(uri.substring(start + marker.length)); + } + // 兜底:smartsenior://page?id=N(或 alert/detail host)→ 家属预警详情 + const qIndex = uri.indexOf('?'); + if (qIndex >= 0) { + const query = uri.substring(qIndex + 1); + const pairs = query.split('&'); + let alertId = ''; + for (const p of pairs) { + const kv = p.split('='); + if (kv.length === 2 && kv[0] === 'id') { + alertId = kv[1]; + } + } + if (alertId.length > 0 && /^\d+$/.test(alertId)) { + return `pages/family/FamilyAlertDetail?id=${alertId}`; + } + } + return ''; + } + + onDestroy(): void { + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onDestroy'); + } + + onWindowStageCreate(windowStage: window.WindowStage): void { + // Main window is created, set main page for this ability + hilog.info(DOMAIN, 'testTag', 'Ability onWindowStageCreate'); + this.windowStage = windowStage; + + windowStage.loadContent('pages/Login', (err) => { + if (err.code) { + hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err)); + return; + } + hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.'); + }); + + try { + this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); + } catch (err) { + hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err)); + } + } + + onWindowStageDestroy(): void { + // Main window is destroyed, release UI related resources + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onWindowStageDestroy'); + } + + onForeground(): void { + // Ability has brought to foreground + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onForeground'); + } + + onBackground(): void { + // Ability has back to background + hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onBackground'); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets b/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets new file mode 100644 index 0000000..8e4de99 --- /dev/null +++ b/entry/src/main/ets/entrybackupability/EntryBackupAbility.ets @@ -0,0 +1,16 @@ +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { BackupExtensionAbility, BundleVersion } from '@kit.CoreFileKit'; + +const DOMAIN = 0x0000; + +export default class EntryBackupAbility extends BackupExtensionAbility { + async onBackup() { + hilog.info(DOMAIN, 'testTag', 'onBackup ok'); + await Promise.resolve(); + } + + async onRestore(bundleVersion: BundleVersion) { + hilog.info(DOMAIN, 'testTag', 'onRestore ok %{public}s', JSON.stringify(bundleVersion)); + await Promise.resolve(); + } +} \ No newline at end of file diff --git a/entry/src/main/ets/intents/CancelBreakfastIntent.ets b/entry/src/main/ets/intents/CancelBreakfastIntent.ets new file mode 100644 index 0000000..7964324 --- /dev/null +++ b/entry/src/main/ets/intents/CancelBreakfastIntent.ets @@ -0,0 +1,34 @@ +// 小艺意图框架 · 执行型意图:取消早餐(路由即槽位) +import { insightIntent, InsightIntentEntry, InsightIntentEntryExecutor } from '@kit.AbilityKit'; +import { cancelMealByIntent } from '../utils/A2AServer'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +const INTENT_DOMAIN = 0x0000; + +async function runCancel(): Promise> { + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'CancelBreakfast execute'); + const reply = await cancelMealByIntent('breakfast', false); + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'CancelBreakfast reply=%{public}s', reply); + return { code: 0, result: reply }; +} + +@InsightIntentEntry({ + intentName: 'CancelBreakfast', + domain: 'ElderlyCare', + intentVersion: '1.0.0', + displayName: '取消早餐', + displayDescription: '取消老人已预订的早餐', + keywords: ['取消早餐', '取消早饭', '不吃早饭', '退订早餐'], + llmDescription: '取消老人已预订的早餐。当用户表达"取消早餐""不吃早饭了"等意图时调用。执行后返回取消结果说明文本。', + parameters: { type: 'object', properties: {} }, + result: { type: 'string', description: '取消结果说明文本' }, + abilityName: 'EntryAbility', + executeMode: [insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND], +}) +export default class CancelBreakfastIntent extends InsightIntentEntryExecutor { + executeMode: insightIntent.ExecuteMode = insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND; + + async onExecute(): Promise> { + return runCancel(); + } +} diff --git a/entry/src/main/ets/intents/CancelDinnerIntent.ets b/entry/src/main/ets/intents/CancelDinnerIntent.ets new file mode 100644 index 0000000..5e54de3 --- /dev/null +++ b/entry/src/main/ets/intents/CancelDinnerIntent.ets @@ -0,0 +1,34 @@ +// 小艺意图框架 · 执行型意图:取消晚餐(路由即槽位) +import { insightIntent, InsightIntentEntry, InsightIntentEntryExecutor } from '@kit.AbilityKit'; +import { cancelMealByIntent } from '../utils/A2AServer'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +const INTENT_DOMAIN = 0x0000; + +async function runCancel(): Promise> { + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'CancelDinner execute'); + const reply = await cancelMealByIntent('dinner', false); + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'CancelDinner reply=%{public}s', reply); + return { code: 0, result: reply }; +} + +@InsightIntentEntry({ + intentName: 'CancelDinner', + domain: 'ElderlyCare', + intentVersion: '1.0.0', + displayName: '取消晚餐', + displayDescription: '取消老人已预订的晚餐', + keywords: ['取消晚餐', '取消晚饭', '不吃晚饭', '退订晚餐'], + llmDescription: '取消老人已预订的晚餐。当用户表达"取消晚餐""晚上不吃了"等意图时调用。执行后返回取消结果说明文本。', + parameters: { type: 'object', properties: {} }, + result: { type: 'string', description: '取消结果说明文本' }, + abilityName: 'EntryAbility', + executeMode: [insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND], +}) +export default class CancelDinnerIntent extends InsightIntentEntryExecutor { + executeMode: insightIntent.ExecuteMode = insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND; + + async onExecute(): Promise> { + return runCancel(); + } +} diff --git a/entry/src/main/ets/intents/CancelLunchIntent.ets b/entry/src/main/ets/intents/CancelLunchIntent.ets new file mode 100644 index 0000000..1828eb9 --- /dev/null +++ b/entry/src/main/ets/intents/CancelLunchIntent.ets @@ -0,0 +1,34 @@ +// 小艺意图框架 · 执行型意图:取消午餐(路由即槽位) +import { insightIntent, InsightIntentEntry, InsightIntentEntryExecutor } from '@kit.AbilityKit'; +import { cancelMealByIntent } from '../utils/A2AServer'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +const INTENT_DOMAIN = 0x0000; + +async function runCancel(): Promise> { + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'CancelLunch execute'); + const reply = await cancelMealByIntent('lunch', false); + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'CancelLunch reply=%{public}s', reply); + return { code: 0, result: reply }; +} + +@InsightIntentEntry({ + intentName: 'CancelLunch', + domain: 'ElderlyCare', + intentVersion: '1.0.0', + displayName: '取消午餐', + displayDescription: '取消老人已预订的午餐', + keywords: ['取消午餐', '取消午饭', '不吃午饭', '退订午餐'], + llmDescription: '取消老人已预订的午餐。当用户表达"取消午餐""中午不吃了"等意图时调用。执行后返回取消结果说明文本。', + parameters: { type: 'object', properties: {} }, + result: { type: 'string', description: '取消结果说明文本' }, + abilityName: 'EntryAbility', + executeMode: [insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND], +}) +export default class CancelLunchIntent extends InsightIntentEntryExecutor { + executeMode: insightIntent.ExecuteMode = insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND; + + async onExecute(): Promise> { + return runCancel(); + } +} diff --git a/entry/src/main/ets/intents/CancelMealIntent.ets b/entry/src/main/ets/intents/CancelMealIntent.ets new file mode 100644 index 0000000..2bf25ec --- /dev/null +++ b/entry/src/main/ets/intents/CancelMealIntent.ets @@ -0,0 +1,30 @@ +// 小艺意图框架 · 执行型意图:取消订餐(餐次按当前时间兜底:9点前早餐、9-15点午餐、15点后晚餐) +import { insightIntent, InsightIntentEntry, InsightIntentEntryExecutor } from '@kit.AbilityKit'; +import { cancelMealByIntent } from '../utils/A2AServer'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +const INTENT_DOMAIN = 0x0000; + +@InsightIntentEntry({ + intentName: 'CancelMeal', + domain: 'ElderlyCare', + intentVersion: '1.0.0', + displayName: '取消订餐', + displayDescription: '取消老人已预订的养老院餐食', + keywords: ['取消订餐', '取消午餐', '取消晚餐', '取消早餐', '不吃了', '退订'], + llmDescription: '取消老人已预订的养老院餐食。当用户表达"取消订餐""取消午餐""不吃了"等意图时调用。执行后返回取消结果说明文本。', + parameters: { type: 'object', properties: {} }, + result: { type: 'string', description: '取消结果说明文本' }, + abilityName: 'EntryAbility', + executeMode: [insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND], +}) +export default class CancelMealIntent extends InsightIntentEntryExecutor { + executeMode: insightIntent.ExecuteMode = insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND; + + async onExecute(): Promise> { + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'CancelMeal execute'); + const reply = await cancelMealByIntent('', false); + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'CancelMeal reply=%{public}s', reply); + return { code: 0, result: reply }; + } +} diff --git a/entry/src/main/ets/intents/OrderBreakfastIntent.ets b/entry/src/main/ets/intents/OrderBreakfastIntent.ets new file mode 100644 index 0000000..89b8ad9 --- /dev/null +++ b/entry/src/main/ets/intents/OrderBreakfastIntent.ets @@ -0,0 +1,34 @@ +// 小艺意图框架 · OrderBreakfast:路由即槽位(本地测试态平台不下发参数,用意图名区分餐次) +import { insightIntent, InsightIntentEntry, InsightIntentEntryExecutor } from '@kit.AbilityKit'; +import { orderMealByIntent } from '../utils/A2AServer'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +const INTENT_DOMAIN = 0x0000; + +async function runMeal(): Promise> { + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'breakfast execute'); + const reply = await orderMealByIntent('breakfast', false); + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'breakfast reply=%{public}s', reply); + return { code: 0, result: reply }; +} + +@InsightIntentEntry({ + intentName: 'OrderBreakfast', + domain: 'ElderlyCare', + intentVersion: '1.0.0', + displayName: '帮老人订早餐', + displayDescription: '用一句话为老人预订养老院的早餐', + keywords: ['订早餐', '订早饭', '早餐', '早饭', '吃早餐'], + llmDescription: '为用户预订养老院的早餐。当用户表达"订早餐"等意图时调用。执行后返回预订结果说明文本。', + parameters: { type: 'object', properties: {} }, + result: { type: 'string', description: '预订结果说明文本' }, + abilityName: 'EntryAbility', + executeMode: [insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND], +}) +export default class OrderBreakfastIntent extends InsightIntentEntryExecutor { + executeMode: insightIntent.ExecuteMode = insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND; + + async onExecute(): Promise> { + return runMeal(); + } +} diff --git a/entry/src/main/ets/intents/OrderDinnerIntent.ets b/entry/src/main/ets/intents/OrderDinnerIntent.ets new file mode 100644 index 0000000..c7f70bf --- /dev/null +++ b/entry/src/main/ets/intents/OrderDinnerIntent.ets @@ -0,0 +1,34 @@ +// 小艺意图框架 · OrderDinner:路由即槽位(本地测试态平台不下发参数,用意图名区分餐次) +import { insightIntent, InsightIntentEntry, InsightIntentEntryExecutor } from '@kit.AbilityKit'; +import { orderMealByIntent } from '../utils/A2AServer'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +const INTENT_DOMAIN = 0x0000; + +async function runMeal(): Promise> { + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'dinner execute'); + const reply = await orderMealByIntent('dinner', false); + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'dinner reply=%{public}s', reply); + return { code: 0, result: reply }; +} + +@InsightIntentEntry({ + intentName: 'OrderDinner', + domain: 'ElderlyCare', + intentVersion: '1.0.0', + displayName: '帮老人订晚餐', + displayDescription: '用一句话为老人预订养老院的晚餐', + keywords: ['订晚餐', '订晚饭', '晚餐', '晚饭', '吃晚餐'], + llmDescription: '为用户预订养老院的晚餐。当用户表达"订晚餐"等意图时调用。执行后返回预订结果说明文本。', + parameters: { type: 'object', properties: {} }, + result: { type: 'string', description: '预订结果说明文本' }, + abilityName: 'EntryAbility', + executeMode: [insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND], +}) +export default class OrderDinnerIntent extends InsightIntentEntryExecutor { + executeMode: insightIntent.ExecuteMode = insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND; + + async onExecute(): Promise> { + return runMeal(); + } +} diff --git a/entry/src/main/ets/intents/OrderLunchIntent.ets b/entry/src/main/ets/intents/OrderLunchIntent.ets new file mode 100644 index 0000000..7bc658b --- /dev/null +++ b/entry/src/main/ets/intents/OrderLunchIntent.ets @@ -0,0 +1,34 @@ +// 小艺意图框架 · OrderLunch:路由即槽位(本地测试态平台不下发参数,用意图名区分餐次) +import { insightIntent, InsightIntentEntry, InsightIntentEntryExecutor } from '@kit.AbilityKit'; +import { orderMealByIntent } from '../utils/A2AServer'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +const INTENT_DOMAIN = 0x0000; + +async function runMeal(): Promise> { + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'lunch execute'); + const reply = await orderMealByIntent('lunch', false); + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'lunch reply=%{public}s', reply); + return { code: 0, result: reply }; +} + +@InsightIntentEntry({ + intentName: 'OrderLunch', + domain: 'ElderlyCare', + intentVersion: '1.0.0', + displayName: '帮老人订午餐', + displayDescription: '用一句话为老人预订养老院的午餐', + keywords: ['订午餐', '订午饭', '午餐', '午饭', '吃午餐'], + llmDescription: '为用户预订养老院的午餐。当用户表达"订午餐"等意图时调用。执行后返回预订结果说明文本。', + parameters: { type: 'object', properties: {} }, + result: { type: 'string', description: '预订结果说明文本' }, + abilityName: 'EntryAbility', + executeMode: [insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND], +}) +export default class OrderLunchIntent extends InsightIntentEntryExecutor { + executeMode: insightIntent.ExecuteMode = insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND; + + async onExecute(): Promise> { + return runMeal(); + } +} diff --git a/entry/src/main/ets/intents/OrderMealIntent.ets b/entry/src/main/ets/intents/OrderMealIntent.ets new file mode 100644 index 0000000..55bf986 --- /dev/null +++ b/entry/src/main/ets/intents/OrderMealIntent.ets @@ -0,0 +1,69 @@ +// 小艺意图框架 · 执行型意图:订餐(对齐便单 CreateNewEvent 的后台执行路线) +// 链路:小艺主对话 LLM 抽槽位 → aidispatch 意图分发 → executeInsightIntentBackground → onExecute 调生产 API +import { insightIntent, InsightIntentEntry, InsightIntentEntryExecutor } from '@kit.AbilityKit'; +import { orderMealByIntent } from '../utils/A2AServer'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +const INTENT_DOMAIN = 0x0000; + +@InsightIntentEntry({ + intentName: 'OrderMeal', + domain: 'ElderlyCare', + intentVersion: '1.0.0', + displayName: '帮老人订餐', + displayDescription: '用一句话为老人预订养老院的早餐、午餐或晚餐', + keywords: ['订餐', '订午餐', '订晚饭', '订早餐', '吃饭', '午餐', '晚餐', '早餐', '加餐'], + llmDescription: '为用户预订养老院餐食。当用户表达"订午餐""订明天的晚饭""帮我订早餐"等订餐意图时调用。slot 为餐次(breakfast=早餐,lunch=午餐,dinner=晚餐);wantTomorrow 为 true 表示订明天的,默认订今天。执行后返回预订结果说明文本。', + parameters: { + type: 'object', + properties: { + slot: { type: 'string', description: '餐次:breakfast早餐/lunch午餐/dinner晚餐' }, + wantTomorrow: { type: 'boolean', description: '是否预订明天,用户说明天/明日时为true,默认false' }, + query: { type: 'string', description: '用户的原始话语,请把用户说的这句话原文放入query' }, + }, + required: ['slot'], + }, + result: { type: 'string', description: '预订结果说明文本' }, + abilityName: 'EntryAbility', + executeMode: [insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND], +}) +export default class OrderMealIntent extends InsightIntentEntryExecutor { + slot: string = ''; + wantTomorrow: boolean = false; + executeMode: insightIntent.ExecuteMode = insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND; + // 候选原始话语参数:平台可能以其中某个 key 传下用户原话,端侧自行抽槽位 + query: string = ''; + text: string = ''; + utterance: string = ''; + content: string = ''; + originalText: string = ''; + userUtterance: string = ''; + + async onExecute(): Promise> { + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'onExecute slot=%{public}s wantTomorrow=%{public}s', this.slot, String(this.wantTomorrow)); + // 平台槽位缺失时,从原始话语抽"晚餐/午餐/早餐/明天"(LLM 分发不给参数的兜底) + let slot = this.slot; + let tomorrow = this.wantTomorrow; + const raw = [this.query, this.text, this.utterance, this.content, this.originalText, this.userUtterance] + .find(t => t && t.length > 0) ?? ''; + if (raw.length > 0) { + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'raw utterance: %{public}s', raw); + if (slot.length === 0) { + if (raw.indexOf('晚') >= 0) { + slot = 'dinner'; + } else if (raw.indexOf('早') >= 0) { + slot = 'breakfast'; + } else if (raw.indexOf('午') >= 0) { + slot = 'lunch'; + } + } + if (!tomorrow && (raw.indexOf('明天') >= 0 || raw.indexOf('明日') >= 0)) { + tomorrow = true; + } + } + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'resolved slot=%{public}s tomorrow=%{public}s', slot, String(tomorrow)); + const reply = await orderMealByIntent(slot, tomorrow); + hilog.info(INTENT_DOMAIN, 'OrderMealIntent', 'onExecute reply=%{public}s', reply); + return { code: 0, result: reply }; + } +} diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets new file mode 100644 index 0000000..eac7f82 --- /dev/null +++ b/entry/src/main/ets/pages/Index.ets @@ -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('pendingCardTarget') ?? ''; + if (pending.length > 0) { + AppStorage.setOrCreate('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]) + } +} diff --git a/entry/src/main/ets/pages/Login.ets b/entry/src/main/ets/pages/Login.ets new file mode 100644 index 0000000..b8903d5 --- /dev/null +++ b/entry/src/main/ets/pages/Login.ets @@ -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 { + // 兜底: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 { + 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('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 { + 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('/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('pendingCardTarget') ?? ''; + if (pending.length > 0) { + AppStorage.setOrCreate('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 { + this.showAgreement = false; + // 按原交互:不同意则退出应用 + const context = getContext(this) as common.UIAbilityContext; + context.terminateSelf(); + } +} diff --git a/entry/src/main/ets/pages/admin/AdminFeedback.ets b/entry/src/main/ets/pages/admin/AdminFeedback.ets new file mode 100644 index 0000000..6b90cfe --- /dev/null +++ b/entry/src/main/ets/pages/admin/AdminFeedback.ets @@ -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 = []; + @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 { + this.loading = true; + try { + let res: Page; + if (this.statusFilter !== 'all') { + res = await api.get>('/feedback/records/', { + page: this.page, + page_size: this.pageSize, + status: this.statusFilter, + }); + } else { + res = await api.get>('/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 { + if (this.processingId === item.id) { + return; + } + this.processingId = item.id; + try { + const next = item.status === 'resolved' ? 'pending' : 'resolved'; + const updated = await api.patch(`/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')) + } +} diff --git a/entry/src/main/ets/pages/admin/AdminNurseDetail.ets b/entry/src/main/ets/pages/admin/AdminNurseDetail.ets new file mode 100644 index 0000000..71eebf7 --- /dev/null +++ b/entry/src/main/ets/pages/admin/AdminNurseDetail.ets @@ -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; +} + +interface ServiceProgress { + elderly_id: number; + elderly_name: string; + room: string; + bed: string; + items: Array; +} + +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; +} + +function statusLabel(item: ServiceItem): string { + if (item.status === 'completed') { + return '完成'; + } + if (item.status === 'partial') { + return '部分'; + } + if (item.status === 'missed') { + // 后端把"完成数=0"都标 missed,需按时间窗区分: + // 还有进行中/未到的窗口 → 未开始;全部窗口已过 → 遗漏 + const wp: Array = 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 = 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; + if (params) { + const id = params['id']; + if (id !== undefined) { + this.nurseId = Number(id); + } + } + this.load(); + } + + private async load(): Promise { + this.loading = true; + try { + const results: Array = await Promise.all([ + api.get(`/auth/users/${this.nurseId}/`).catch((): NurseProfile | null => null), + api.get(`/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')) + } +} diff --git a/entry/src/main/ets/pages/admin/AdminNurseList.ets b/entry/src/main/ets/pages/admin/AdminNurseList.ets new file mode 100644 index 0000000..7930533 --- /dev/null +++ b/entry/src/main/ets/pages/admin/AdminNurseList.ets @@ -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 = []; + @State loading: boolean = true; + private scroller: Scroller = new Scroller(); + + aboutToAppear(): void { + this.load(); + } + + private async load(): Promise { + this.loading = true; + try { + const res = await api.get>('/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')) + } +} diff --git a/entry/src/main/ets/pages/admin/AdminNursingRecords.ets b/entry/src/main/ets/pages/admin/AdminNursingRecords.ets new file mode 100644 index 0000000..c145de5 --- /dev/null +++ b/entry/src/main/ets/pages/admin/AdminNursingRecords.ets @@ -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; + 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 = []; + 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 = []; + @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 { + if (isReset) { + this.loading = true; + } else { + this.loadingMore = true; + } + try { + let res: Page; + if (this.dateFilter === 'today') { + res = await api.get>('/tasks/nursing-records/', { + page: this.page, + page_size: this.pageSize, + ordering: '-created_at', + date: todayYMD(), + }); + } else { + res = await api.get>('/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')) + } +} diff --git a/entry/src/main/ets/pages/admin/AdminOverview.ets b/entry/src/main/ets/pages/admin/AdminOverview.ets new file mode 100644 index 0000000..28f4d14 --- /dev/null +++ b/entry/src/main/ets/pages/admin/AdminOverview.ets @@ -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; +} + +interface MealsOverview { + total_reserved_count: number; + meals: Array; +} + +interface CareOverview { + on_duty_count: number; + today_record_count: number; + daily_trend: Array; +} + +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; + recent_records: Array; + 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 { + this.loading = true; + try { + const res = await api.get('/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 { + 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')) + } +} diff --git a/entry/src/main/ets/pages/admin/AdminQuery.ets b/entry/src/main/ets/pages/admin/AdminQuery.ets new file mode 100644 index 0000000..3ab2830 --- /dev/null +++ b/entry/src/main/ets/pages/admin/AdminQuery.ets @@ -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; +} + +interface QueryPayload { + query: string; +} + +let messageSeq = 1; + +@Entry +@Component +struct AdminQuery { + @State inputText: string = ''; + @State messages: Array = []; + @State summaries: Array = []; + @State sending: boolean = false; + + private scroller: Scroller = new Scroller(); + + private async send(): Promise { + 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('/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')) + } +} diff --git a/entry/src/main/ets/pages/admin/AdminRecordDetail.ets b/entry/src/main/ets/pages/admin/AdminRecordDetail.ets new file mode 100644 index 0000000..002d132 --- /dev/null +++ b/entry/src/main/ets/pages/admin/AdminRecordDetail.ets @@ -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; + 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 { + const e = record.extracted_json as ExtractedJson; + const rows: Array = []; + 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; + this.recordId = Number(params['id'] ?? 0); + this.load(); + } + + private async load(): Promise { + this.loading = true; + try { + const res = await api.get(`/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')) + } +} diff --git a/entry/src/main/ets/pages/admin/AdminSalesDetail.ets b/entry/src/main/ets/pages/admin/AdminSalesDetail.ets new file mode 100644 index 0000000..cc7404a --- /dev/null +++ b/entry/src/main/ets/pages/admin/AdminSalesDetail.ets @@ -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; +} + +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 = []; + @State contracts: Array = []; + @State loading: boolean = true; + private scroller: Scroller = new Scroller(); + + aboutToAppear(): void { + this.load(); + } + + private async load(): Promise { + this.loading = true; + try { + const results: Array | Page | null> = await Promise.all([ + api.get('/market/cycles/overview/').catch((): CycleOverview | null => null), + api.get>('/market/leads/', { page_size: 30, ordering: '-created_at' }).catch((): Page | null => null), + api.get>('/market/contracts/', { page_size: 30 }).catch((): Page | null => null), + ]); + this.cycle = results[0] as CycleOverview | null; + const leadRes = results[1] as Page | null; + const contractRes = results[2] as Page | 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')) + } +} diff --git a/entry/src/main/ets/pages/admin/AdminSchedule.ets b/entry/src/main/ets/pages/admin/AdminSchedule.ets new file mode 100644 index 0000000..c6e0611 --- /dev/null +++ b/entry/src/main/ets/pages/admin/AdminSchedule.ets @@ -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; +} + +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 = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + 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 = []; + @State caregiverAssignments: Array = []; + @State loading: boolean = true; + + private scroller: Scroller = new Scroller(); + + aboutToAppear(): void { + this.load(); + } + + private async load(): Promise { + this.loading = true; + try { + const responses: Array | Page | null> = await Promise.all([ + api.get>('/shifts/floor-duty-assignments/', { duty_date: todayStr(), page_size: 100 }) + .catch((): Page | null => null), + api.get>('/shifts/caregiver-schedules/', { month: monthStr(), page_size: 20 }) + .catch((): Page | null => null), + ]); + const floorRes = responses[0] as Page | null; + this.floorDuties = floorRes ? floorRes.results : []; + + const versionRes = responses[1] as Page | null; + if (versionRes) { + const versions: Array = 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')) + } +} diff --git a/entry/src/main/ets/pages/admin/DeviceMonitoring.ets b/entry/src/main/ets/pages/admin/DeviceMonitoring.ets new file mode 100644 index 0000000..5263607 --- /dev/null +++ b/entry/src/main/ets/pages/admin/DeviceMonitoring.ets @@ -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; +} + +interface SleepOverview { + type: TypeInfo; + pending_alert_count: number; + monitoring_residents: Array; +} + +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; +} + +interface BodyOverview { + type: TypeInfo; + pending_alert_count: number; + monitoring_residents: Array; +} + +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 { + this.loading = true; + try { + const results: Array = await Promise.all([ + api.get('/devices/types/sleep_monitor/overview/').catch((): SleepOverview | null => null), + api.get('/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')) + } +} diff --git a/entry/src/main/ets/pages/alerts/AlertsTab.ets b/entry/src/main/ets/pages/alerts/AlertsTab.ets new file mode 100644 index 0000000..d0dc51c --- /dev/null +++ b/entry/src/main/ets/pages/alerts/AlertsTab.ets @@ -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 = []; + @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 { + this.loading = true; + try { + const res = await api.get>('/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(); + }) + } +} diff --git a/entry/src/main/ets/pages/business/BusinessTab.ets b/entry/src/main/ets/pages/business/BusinessTab.ets new file mode 100644 index 0000000..bd89192 --- /dev/null +++ b/entry/src/main/ets/pages/business/BusinessTab.ets @@ -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; +} + +@Component +export struct BusinessTab { + @State role: UnifiedRole | null = null; + @State sections: Array = []; + @StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system'; + + private scaleFor(): number { + return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0; + } + + aboutToAppear(): void { + this.loadRole(); + } + + private async loadRole(): Promise { + 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%') + } +} diff --git a/entry/src/main/ets/pages/elderly/Activities.ets b/entry/src/main/ets/pages/elderly/Activities.ets new file mode 100644 index 0000000..357ddaf --- /dev/null +++ b/entry/src/main/ets/pages/elderly/Activities.ets @@ -0,0 +1,258 @@ +// 老人端 · 活动安排(ArkTS 严格类型,无 any) +// 接口:GET /activities/weeks// +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; +} + +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 = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + 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 = []; + @State loading: boolean = true; + @State weekStartText: string = weekStart(new Date()); + private scroller: Scroller = new Scroller(); + + aboutToAppear(): void { + this.loadActivities(); + } + + // 从本周起向后查找最近一个已发布且有活动的周,避免本周未发布时误显示空周 + private async loadActivities(): Promise { + 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(`/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 { + 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')) + } +} + diff --git a/entry/src/main/ets/pages/elderly/Assistant.ets b/entry/src/main/ets/pages/elderly/Assistant.ets new file mode 100644 index 0000000..7310960 --- /dev/null +++ b/entry/src/main/ets/pages/elderly/Assistant.ets @@ -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>; +} + +// 助手消息请求体 +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 { + 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('/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')) + } +} diff --git a/entry/src/main/ets/pages/elderly/ElderlyAdd.ets b/entry/src/main/ets/pages/elderly/ElderlyAdd.ets new file mode 100644 index 0000000..d1971af --- /dev/null +++ b/entry/src/main/ets/pages/elderly/ElderlyAdd.ets @@ -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 = ['self_care', 'semi_care', 'full_care']; +const GENDERS: Array = ['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 { + 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 { + 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')) + } +} + diff --git a/entry/src/main/ets/pages/elderly/ElderlyAssistantPanel.ets b/entry/src/main/ets/pages/elderly/ElderlyAssistantPanel.ets new file mode 100644 index 0000000..e8a96b5 --- /dev/null +++ b/entry/src/main/ets/pages/elderly/ElderlyAssistantPanel.ets @@ -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; +} + +// 助手消息请求体 +interface AssistantPayload { + message: string; +} + +@Component +export struct ElderlyAssistantPanel { + @State inputText: string = ''; + @State messages: Array = []; + @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 { + 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; + 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 { + 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 { + 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('/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%') + } +} diff --git a/entry/src/main/ets/pages/elderly/ElderlyDetail.ets b/entry/src/main/ets/pages/elderly/ElderlyDetail.ets new file mode 100644 index 0000000..ea0be18 --- /dev/null +++ b/entry/src/main/ets/pages/elderly/ElderlyDetail.ets @@ -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 | 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 | undefined; + if (params) { + this.elderlyId = params['id'] ?? ''; + } + this.loadDetail(); + } + + private async loadDetail(): Promise { + if (this.elderlyId.length === 0) { + this.loading = false; + return; + } + try { + this.detail = await api.get(`/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')) + } +} diff --git a/entry/src/main/ets/pages/elderly/ElderlyDial.ets b/entry/src/main/ets/pages/elderly/ElderlyDial.ets new file mode 100644 index 0000000..f9f067e --- /dev/null +++ b/entry/src/main/ets/pages/elderly/ElderlyDial.ets @@ -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 = [ + { 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 = []; + @State familyLoading: boolean = true; + + aboutToAppear(): void { + this.loadFamily(); + } + + // 拉取绑定自己的家属(无电话的不展示) + private async loadFamily(): Promise { + this.familyLoading = true; + try { + const res = await api.get>('/elderly/bindings/', { page_size: 50 }); + const contacts: Array = []; + 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')) + } +} + diff --git a/entry/src/main/ets/pages/elderly/ElderlyHistory.ets b/entry/src/main/ets/pages/elderly/ElderlyHistory.ets new file mode 100644 index 0000000..aa9e887 --- /dev/null +++ b/entry/src/main/ets/pages/elderly/ElderlyHistory.ets @@ -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; +} + +@Component +export struct ElderlyHistoryPanel { + @StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system'; + @State items: Array = []; + @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 { + this.loading = true; + try { + const res = await api.get('/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]) + } +} diff --git a/entry/src/main/ets/pages/elderly/ElderlyHome.ets b/entry/src/main/ets/pages/elderly/ElderlyHome.ets new file mode 100644 index 0000000..1abdde4 --- /dev/null +++ b/entry/src/main/ets/pages/elderly/ElderlyHome.ets @@ -0,0 +1,1148 @@ +// 长者端首页外壳(ArkTS 严格类型,无 any) +// 框架与管理端 Index 一致:HdsNavigation + HdsTabs 悬浮底栏 +// 首页 = 天气日期 + 四大功能卡片(查看天气/快速拨号/快捷打车/订餐预定)+ 聊天助手保留 +import { curves } from '@kit.ArkUI'; +import { hdsMaterial } from '@hms.hds.hdsMaterial'; +import { HdsTabs, HdsTabsController, HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit'; +import { router, promptAction } from '@kit.ArkUI'; +import { common, Want } from '@kit.AbilityKit'; +import { BusinessError, commonEventManager } from '@kit.BasicServicesKit'; +import { deviceInfo } from '@kit.BasicServicesKit'; +import { petalMaps } from '@kit.MapKit'; +import { normalizeError } from '../../common/ApiConstants'; +import { api, RequestError } from '../../utils/Request'; +import { requestMicPermission } from '../../utils/PermissionUtil'; +import { getVoiceEngine, startVoice, stopVoice, VoiceHandler } from '../../utils/VoiceCommand'; +import { speechRecognizer } from '@kit.CoreSpeechKit'; +import { speakText } from '../../utils/TtsPlayer'; +import { ElderlyAssistantPanel } from './ElderlyAssistantPanel'; +import { ElderlyProfilePanel } from './ElderlyProfilePanel'; +import { FontScaleLevel, FONT_SCALE_MAP, CARE_MODE_EXTRA_SCALE } from '../../utils/FontScale'; +import { AuthStore } from '../../utils/AuthStore'; +import { fetchWeather, weatherCodeLabel, todayYMD, WeatherData } from '../../utils/OpenMeteo'; +import { publishLocalNotify } from '../../utils/LocalNotify'; +import { FunctionComponent, FunctionOptions, FunctionController, AgentController, ButtonType } from '@kit.AgentFrameworkKit'; + +// 小艺开放平台智能体:AI健康助手(蚂蚁阿福)+ 数智护老助手(端A2A模式自建,闭环执行)+ 数智护老管家(编排型,插件操控) +const AGENT_ID = 'agent8ca28632414640028b76ee6bf69efa80'; +const HEALTH_AGENT_ID = 'agent940dc0d539da4d46bb26150823a0b4a6'; +const BUTLER_AGENT_ID = 'agent3f66fdeb52504b99a12288b691df2eab'; + +function tomorrowYMD(): string { + const d = new Date(Date.now() + 24 * 60 * 60 * 1000); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${d.getFullYear()}-${mm}-${dd}`; +} + +function greeting(): string { + const hour = new Date().getHours(); + if (hour < 12) { + return '上午好'; + } + if (hour < 18) { + return '下午好'; + } + return '晚上好'; +} + +function currentDateLabel(): string { + const d = new Date(); + const weekdays: Array = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; + return `${d.getMonth() + 1}月${d.getDate()}日 ${weekdays[d.getDay()]}`; +} + +interface ElderlyMeal { + id: string; + name: string; + menu: string; + ordered: boolean; + reservable: boolean; + reservation_hint: string; + service_date: string; +} + +interface DashboardResponse { + service_date: string; + meals: Array; +} + +// 首页院内活动简报(数据源:/activities/weeks/) +interface HomeActivity { + name: string; + when: string; + location: string; +} + +interface ActivityItem { + activity_name: string; + activity_date: string; + start_time: string; + location: string; +} + +interface ActivityWeek { + status: string; + items: Array; +} + +// '2026-09-01' → '9月1日' +function mdLabel(ymd: string): string { + const parts = ymd.split('-'); + if (parts.length < 3) { + return ymd; + } + const mm = Number(parts[1]); + const dd = Number(parts[2]); + if (Number.isNaN(mm) || Number.isNaN(dd)) { + return ymd; + } + return `${mm}月${dd}日`; +} + +function activityWeekStart(base: Date): string { + const d = new Date(base); + 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 activityDateLabel(iso: string): string { + const d = new Date(iso + 'T00:00:00'); + if (Number.isNaN(d.getTime())) { + return iso; + } + const weekdays: Array = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + return `${d.getMonth() + 1}/${d.getDate()} ${weekdays[d.getDay()]}`; +} + +// 后端 AI 助手(Qwen Agent)响应 +interface AssistantReservation { + slot_id: string; + slot_name: string; + ordered: boolean; + service_date: string; +} + +interface AssistantReply { + reply: string; + auto_reserved: boolean; + reservations: Array; +} + +// 长者端首页:天气日期 + 四大功能卡片 + 我的订餐 +@Component +export struct ElderlyMealsContent { + @State elderlyName: string = ''; + @State weather: WeatherData | null = null; + @State weatherLoading: boolean = true; + @State locationLabel: string = '广州'; + @State meals: Array = []; + @State mealsLoading: boolean = true; + @State mealsDate: string = ''; + @State activities: Array = []; + @State togglingId: string = ''; + @State listening: boolean = false; + @State agentSupported: boolean = false; + @State healthAgentSupported: boolean = false; + @State butlerAgentSupported: boolean = false; + private agentController: FunctionController | null = null; + private healthAgentController: FunctionController | null = null; + private butlerAgentController: FunctionController | null = null; + + private onAgentOpened = (): void => { + console.info('[ElderlyHome] agent dialog opened'); + }; + + private onAgentClosed = (): void => { + console.info('[ElderlyHome] agent dialog closed'); + }; + @State voiceText: string = ''; + @State aiThinking: boolean = false; + @State lastReply: string = ''; + @StorageProp('weatherRefreshTick') @Watch('onWeatherTick') weatherRefreshTick: number = 0; + @State lastLat: number | undefined = undefined; + @State lastLon: number | undefined = undefined; + private voiceEngine: speechRecognizer.SpeechRecognitionEngine | null = null; + @StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system'; + @StorageProp('careMode') careMode: boolean = false; + + private scaleFor(): number { + const base = FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0; + // 关怀模式(系统无障碍开启)下字号再加一档 + return base * (this.careMode ? CARE_MODE_EXTRA_SCALE : 1.0); + } + + // 页面重新显示时实时刷新天气 + onWeatherTick(): void { + if (this.weatherRefreshTick > 0) { + this.loadWeatherWithLocation(); + } + } + + aboutToAppear(): void { + this.loadProfile(); + this.loadWeatherWithLocation(); + this.loadMeals(); + this.loadActivities(); + this.checkAgentSupport(); + if (!this.isEmulator()) { + this.agentController = new FunctionController(); + this.agentController.on('agentDialogOpened', this.onAgentOpened); + this.agentController.on('agentDialogClosed', this.onAgentClosed); + } + } + + aboutToDisappear(): void { + if (this.agentController !== null) { + this.agentController.off('agentDialogOpened'); + this.agentController.off('agentDialogClosed'); + } + } + + // 检测智能体可用性(设备/地区/华为账号),不可用则隐藏入口 + private isEmulator(): boolean { + const pm = deviceInfo.productModel.toLowerCase(); + return pm.indexOf('emulator') >= 0 || pm.indexOf('模拟器') >= 0 || pm.indexOf('x86') >= 0; + } + + private checkAgentSupport(): void { + if (this.isEmulator()) { + this.agentSupported = false; + this.healthAgentSupported = false; + this.butlerAgentSupported = false; + return; + } + try { + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + new AgentController().isAgentSupport(context, AGENT_ID).then((supported: boolean) => { + this.agentSupported = supported; + }).catch((err: BusinessError) => { + console.error('[ElderlyHome] isAgentSupport failed:', normalizeError(err)); + this.agentSupported = false; + }); + new AgentController().isAgentSupport(context, HEALTH_AGENT_ID).then((supported: boolean) => { + this.healthAgentSupported = supported; + }).catch((err: BusinessError) => { + console.error('[ElderlyHome] health isAgentSupport failed:', normalizeError(err)); + this.healthAgentSupported = false; + }); + new AgentController().isAgentSupport(context, BUTLER_AGENT_ID).then((supported: boolean) => { + this.butlerAgentSupported = supported; + }).catch((err: BusinessError) => { + console.error('[ElderlyHome] butler isAgentSupport failed:', normalizeError(err)); + this.butlerAgentSupported = false; + }); + } catch (err) { + this.agentSupported = false; + this.healthAgentSupported = false; + this.butlerAgentSupported = false; + } + } + + // 院内活动简报:从本周起向后找,显示今天、明天及未来的活动,取前 2 条 + private async loadActivities(): Promise { + try { + const today = todayYMD(); + const base = new Date(todayYMD() + 'T00:00:00'); + for (let i = 0; i < 5; i++) { + const ws = new Date(base); + ws.setDate(ws.getDate() + i * 7); + const week = activityWeekStart(ws); + const res = await api.get(`/activities/weeks/${week}/`); + if (res.items && res.items.length > 0) { + const upcoming = res.items.filter((item: ActivityItem): boolean => item.activity_date >= today); + if (upcoming.length > 0) { + this.activities = upcoming.slice(0, 2).map((item: ActivityItem): HomeActivity => { + return { + name: item.activity_name, + when: `${activityDateLabel(item.activity_date)} ${item.start_time && item.start_time.length > 0 ? item.start_time : ''}`, + location: item.location, + }; + }); + return; + } + } + } + this.activities = []; + } catch (err) { + console.error('[ElderlyHome] load activities failed:', normalizeError(err as BusinessError | Error)); + } + } + + private async loadProfile(): Promise { + try { + const state = await AuthStore.getState(); + this.elderlyName = state.userName; + } catch (err) { + console.error('[ElderlyHome] load profile failed:', normalizeError(err as BusinessError | Error)); + } + } + + // 天气统一使用养老院固定坐标(广州),保证首页/播报/详情页三处数据永远一致 + private async loadWeatherWithLocation(): Promise { + this.lastLat = undefined; + this.lastLon = undefined; + this.locationLabel = '广州'; + this.weatherLoading = true; + try { + const res = await fetchWeather(undefined, undefined); + this.weather = res; + } catch (err) { + console.error('[ElderlyHome] load weather failed:', normalizeError(err as BusinessError | Error)); + } finally { + this.weatherLoading = false; + } + } + + private async loadMeals(): Promise { + this.mealsLoading = true; + try { + const res = await api.get('/elderly-portal/dashboard/'); + let meals = res.meals; + let date = typeof res.service_date === 'string' ? res.service_date : todayYMD(); + meals.forEach((m: ElderlyMeal): void => { + console.info(`[ElderlyHome] meal ${m.id} reservable=${m.reservable} ordered=${m.ordered}`); + }); + // 今天三餐都已过(既不可预订也未预订)时,才自动切换为明天的菜单 + // 注意:已预订的餐次 reservable 可能为 false,不能据此判定"已过" + const allPassed = meals.length > 0 + && meals.every((m: ElderlyMeal) => !m.reservable && !m.ordered) + && date === todayYMD(); + console.info(`[ElderlyHome] allPassed=${allPassed} date=${date}`); + if (allPassed) { + date = tomorrowYMD(); + const slotKeys: Array = ['breakfast', 'lunch', 'dinner']; + const slots: Array = await Promise.all(slotKeys.map((key: string): Promise => + api.get(`/elderly-portal/meals/${key}/?service_date=${date}`).catch((): ElderlyMeal | null => null) + )); + const nextMeals: Array = []; + slots.forEach((slot: ElderlyMeal | null): void => { + if (slot !== null) { + nextMeals.push(slot); + } + }); + if (nextMeals.length > 0) { + meals = nextMeals; + } + } + this.meals = meals; + this.mealsDate = date; + this.notifyUnorderedMeals(); + } catch (err) { + console.error('[ElderlyHome] load meals failed:', normalizeError(err as BusinessError | Error)); + } finally { + this.mealsLoading = false; + } + } + + // 订餐提醒:可订但未订的餐次发本地通知(每餐每自然日最多一次) + private notifyUnorderedMeals(): void { + const today = todayYMD(); + this.meals.forEach((m: ElderlyMeal): void => { + if (m.reservable && !m.ordered) { + const key = `mealNotify_${today}_${m.id}`; + if (AppStorage.get(key) === undefined) { + AppStorage.setOrCreate(key, '1'); + publishLocalNotify(Date.now() % 100000, '订餐提醒', `您还未预订${m.name},记得及时预订`); + } + } + }); + } + + private openPage(url: string): void { + router.pushUrl({ url: url }).catch((err: BusinessError) => { + promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` }); + }); + } + + // 快捷打车:官方 Map Kit 拉起 Petal 地图打车页(目的地暂预填养老院,可在打车页修改) + private openRideApp(): void { + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + const params: petalMaps.TaxiParams = { + destinationPosition: { + latitude: 23.1291, + longitude: 113.2644, + }, + destinationName: '养老院', + }; + petalMaps.openMapTaxi(context, params).catch((err: BusinessError) => { + if (err.code === 1002600014) { + promptAction.showToast({ message: '未找到地图应用,请在应用市场安装 Petal 地图' }); + } else { + promptAction.showToast({ message: `打开打车失败:${err.message}` }); + } + }); + } + + // 语音命令分发:天气/电话/打车本地直达(数据真实、响应快);订餐/菜单/闲聊走后端 AI(Qwen Agent) + private async dispatchVoiceCommand(text: string): Promise { + const t = text.trim(); + if (t.length < 2) { + promptAction.showToast({ message: '没听清,请再说一遍' }); + return; + } + const wantsWeather = t.indexOf('天气') >= 0; + const wantsDial = t.indexOf('电话') >= 0 || t.indexOf('拨打') >= 0 || t.indexOf('打给') >= 0; + const wantsRide = t.indexOf('打车') >= 0 || t.indexOf('叫车') >= 0 || t.indexOf('出租') >= 0 || t.indexOf('网约') >= 0; + const wantsMeal = t.indexOf('订餐') >= 0 || t.indexOf('订饭') >= 0 || t.indexOf('吃饭') >= 0 || t.indexOf('订') >= 0; + + // 天气:本地 Open-Meteo 实时播报 + 跳页(AI 没有天气数据源,不问 AI) + if (wantsWeather) { + let report = '天气数据暂不可用'; + try { + const w = await fetchWeather(this.lastLat, this.lastLon); + const today = w.daily.length > 0 ? w.daily[0] : null; + const rain = today !== null ? `,降雨概率百分之${today.rainPct}` : ''; + report = `${this.locationLabel},${weatherCodeLabel(w.current.code)},当前气温${Math.round(w.current.temperature)}度${rain}`; + } catch (err) { + // 保持默认提示 + } + await speakText(report); + router.pushUrl({ + url: 'pages/elderly/ElderlyWeather', + params: { lat: this.lastLat ?? -1, lon: this.lastLon ?? -1 }, + }).catch((err: BusinessError) => { + promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` }); + }); + return; + } + if (wantsDial) { + await speakText('正在为您打开快速拨号'); + this.openPage('pages/elderly/ElderlyDial'); + return; + } + if (wantsRide) { + await speakText('正在为您呼叫网约车'); + this.openRideApp(); + return; + } + + // 订餐/菜单问答/闲聊:走后端 AI + let reply = ''; + let autoReserved = false; + this.aiThinking = true; + this.lastReply = ''; + try { + const res = await api.post('/elderly-portal/assistant/reply/', { message: t }); + reply = typeof res.reply === 'string' ? res.reply : ''; + autoReserved = res.auto_reserved === true; + } catch (err) { + console.error('[ElderlyHome] assistant reply failed:', normalizeError(err as BusinessError | Error)); + } finally { + this.aiThinking = false; + } + + if (reply.length > 0) { + // 播报 AI 回答(截断防止超长),同时展示在语音区 + const speak = reply.length > 150 ? reply.substring(0, 150) : reply; + this.lastReply = speak; + await speakText(speak); + if (wantsMeal && !autoReserved) { + // AI 没能自动订上时,带老人去订餐页手动确认 + this.openPage('pages/elderly/ElderlyMealOrder'); + } + return; + } + + // AI 不可用:本地兜底 + if (wantsMeal) { + await speakText('正在为您打开订餐'); + this.openPage('pages/elderly/ElderlyMealOrder'); + } else { + await speakText('没听懂,您可以说,看天气,打电话,叫车,或者订餐'); + promptAction.showToast({ message: '没听懂,请说:看天气、打电话、叫车或订餐' }); + } + } + + private stopListening(): void { + if (this.voiceEngine !== null) { + stopVoice(this.voiceEngine); + } + this.listening = false; + } + + private async toggleVoice(): Promise { + if (this.listening) { + this.stopListening(); + return; + } + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + const granted = await requestMicPermission(context); + if (!granted) { + promptAction.showToast({ message: '需要麦克风权限才能语音控制' }); + return; + } + try { + if (this.voiceEngine === null) { + this.voiceEngine = await getVoiceEngine(); + } + const handler: VoiceHandler = { + onText: (text: string, final: boolean) => { + this.voiceText = text; + }, + onError: (message: string) => { + promptAction.showToast({ message: `语音识别失败:${message}` }); + this.listening = false; + }, + onStopped: () => { + this.listening = false; + if (this.voiceText.length > 0) { + this.dispatchVoiceCommand(this.voiceText); + } + }, + }; + this.voiceText = '请说话...'; + startVoice(this.voiceEngine, handler); + this.listening = true; + } catch (err) { + promptAction.showToast({ message: `语音引擎启动失败:${normalizeError(err as BusinessError | Error)}` }); + } + } + + private weatherSummary(): string { + if (this.weatherLoading) { + return '天气获取中...'; + } + const w = this.weather; + if (w === null) { + return '天气暂不可用'; + } + const today = w.daily.length > 0 ? w.daily[0] : null; + const rain = today !== null ? ` · 降雨${today.rainPct}%` : ''; + return `${weatherCodeLabel(w.current.code)} ${w.current.temperature.toFixed(0)}°C${rain}`; + } + + // 首页直接预订/取消餐次 + private async toggleOrder(meal: ElderlyMeal): Promise { + if (this.togglingId.length > 0) { + return; + } + if (!meal.reservable && !meal.ordered) { + promptAction.showToast({ message: meal.reservation_hint || '该餐次暂不可预订' }); + return; + } + this.togglingId = meal.id; + try { + const updated = await api.post(`/elderly-portal/meals/${meal.id}/reservation/`, { + ordered: !meal.ordered, + service_date: meal.service_date, + }); + this.meals = this.meals.map((m: ElderlyMeal): ElderlyMeal => { + if (m.id === meal.id) { + return updated; + } + return m; + }); + promptAction.showToast({ message: updated.ordered ? `已预订${meal.name}` : `已取消${meal.name}` }); + await speakText(updated.ordered ? `已为您预订${meal.name}` : `已为您取消${meal.name}`); + // 结果同步发一条本地通知(验证通知通道;卡片链路修复后可去掉) + publishLocalNotify( + Date.now() % 100000, + updated.ordered ? '订餐成功' : '订餐已取消', + updated.ordered ? `已为您预订${meal.name}` : `已取消${meal.name}的预订`, + ); + } catch (err) { + const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error); + promptAction.showToast({ message: message }); + publishLocalNotify(Date.now() % 100000, '订餐失败', `${message}`); + } finally { + this.togglingId = ''; + } + } + + @Builder + MealOrderRow(meal: ElderlyMeal) { + Row({ space: 12 }) { + Column({ space: 4 }) { + Text(meal.name) + .fontSize(Math.round(19 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Text(meal.menu && meal.menu.length > 0 ? meal.menu : '菜单待更新') + .fontSize(Math.round(14 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + Text(this.togglingId === meal.id ? '...' : (meal.ordered ? '取消' : '预订')) + .fontSize(Math.round(17 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor('#FFFFFF') + .padding({ left: 20, right: 20, top: 10, bottom: 10 }) + .backgroundColor(meal.ordered ? '#FF3B30' : '#34C759') + .borderRadius(20) + .onClick(() => { + this.toggleOrder(meal); + }) + .accessibilityLevel('yes') + .accessibilityText(`${meal.ordered ? '取消预订' : '预订'}${meal.name}`) + .accessibilityDescription(`${meal.name}菜单:${meal.menu && meal.menu.length > 0 ? meal.menu : '待更新'},点按${meal.ordered ? '取消' : '预订'}${meal.name}`) + } + .width('100%') + .padding({ top: 10, bottom: 10 }) + .alignItems(VerticalAlign.Center) + } + + @Builder + FeatureCard(icon: string, title: string, desc: string, color: string, onTap: () => void) { + Column({ space: 10 }) { + Text(icon) + .fontSize(44) + Text(title) + .fontSize(Math.round(22 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Text(desc) + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .constraintSize({ minHeight: 150 }) + .padding({ top: 16, bottom: 16 }) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .backgroundColor($r('app.color.background_card')) + .borderRadius(20) + .border({ width: 2, color: color }) + .onClick(onTap) + .accessibilityLevel('yes') + .accessibilityText(`${title},${desc}`) + .accessibilityDescription(`点按进入${title}`) + } + + build() { + Scroll() { + Column({ space: 32 }) { + // 顶部问候 + 日期 + 天气 + Column({ space: 6 }) { + Text(`${this.elderlyName || '您好'},${greeting()}`) + .fontSize(Math.round(30 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Row({ space: 10 }) { + Text(currentDateLabel()) + .fontSize(Math.round(18 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + Text(this.weatherSummary()) + .fontSize(Math.round(18 * this.scaleFor())) + .fontWeight(FontWeight.Medium) + .layoutWeight(1) + .maxLines(2) + .fontColor('#007AFF') // max + .textOverflow({ overflow: TextOverflow.Ellipsis }) //Line + .onClick(() => { + this.openPage('pages/elderly/ElderlyWeather'); + }) + .accessibilityLevel('yes') + .accessibilityText(`天气,${this.weatherSummary()}`) + .accessibilityDescription('点按查看未来7天天气详情') + } + .width('100%') + } + .width('100%') + .alignItems(HorizontalAlign.Start) + + // 四大功能卡片 + Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) { + Column() { + this.FeatureCard('☀️', '查看天气', '未来7天预报', '#FF9500', (): void => { + this.openPage('pages/elderly/ElderlyWeather'); + }) + } + .width('47%') + .margin({ bottom: 20 }) + + Column() { + this.FeatureCard('📞', '快速拨号', '一键打电话', '#34C759', (): void => { + this.openPage('pages/elderly/ElderlyDial'); + }) + } + .width('47%') + .margin({ bottom: 20 }) + + Column() { + this.FeatureCard('🚕', '快捷打车', '一键呼叫网约车', '#007AFF', (): void => { + this.openRideApp(); + }) + } + .width('47%') + + Column() { + this.FeatureCard('🍽️', '订餐预定', '今日明日订餐', '#AF52DE', (): void => { + this.openPage('pages/elderly/ElderlyMealOrder'); + }) + } + .width('47%') + } + .width('100%') + + // 我的订餐(直接选菜品预订) + Column({ space: 8 }) { + Row() { + Text(this.mealsDate.length > 0 && this.mealsDate !== todayYMD() ? `我的订餐(明天 ${mdLabel(this.mealsDate)})` : '我的订餐') + .fontSize(Math.round(20 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Blank() + Text('更多 ›') + .fontSize(Math.round(15 * this.scaleFor())) + .fontColor('#007AFF') + } + .width('100%') + + if (this.mealsLoading) { + Text('订餐加载中...') + .fontSize(Math.round(15 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + .width('100%') + .padding({ top: 8, bottom: 8 }) + } else { + ForEach(this.meals, (meal: ElderlyMeal) => { + this.MealOrderRow(meal) + }, (meal: ElderlyMeal) => `${meal.id}-${meal.ordered}`) + } + } + .width('100%') + .padding(18) + .backgroundColor($r('app.color.background_card')) + .borderRadius(20) + .alignItems(HorizontalAlign.Start) + .onClick(() => { + this.openPage('pages/elderly/ElderlyMealOrder'); + }) + + // 院内活动(点击进入活动详情页) + if (this.activities.length > 0) { + Column({ space: 8 }) { + Row() { + Text('院内活动') + .fontSize(Math.round(20 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Blank() + Text('更多 ›') + .fontSize(Math.round(15 * this.scaleFor())) + .fontColor('#007AFF') + } + .width('100%') + + ForEach(this.activities, (a: HomeActivity) => { + Row({ space: 10 }) { + Column({ space: 3 }) { + Text(a.name) + .fontSize(Math.round(17 * this.scaleFor())) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(a.location.length > 0 ? `${a.when} · ${a.location}` : a.when) + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + Text('›') + .fontSize(20) + .fontColor($r('app.color.inactive_tab')) + } + .width('100%') + .padding({ top: 8, bottom: 8 }) + .alignItems(VerticalAlign.Center) + }, (a: HomeActivity) => `${a.name}-${a.when}`) + } + .width('100%') + .padding(18) + .backgroundColor($r('app.color.background_card')) + .borderRadius(20) + .alignItems(HorizontalAlign.Start) + .onClick(() => { + this.openPage('pages/elderly/Activities'); + }) + .accessibilityLevel('yes') + .accessibilityText(`院内活动,${this.activities[0].name},${this.activities[0].when},点按查看全部活动`) + } + + // 智能体入口:数智护老助手(端A2A自建,订餐/求助/活动天气)+ 数智护老管家(编排型,插件操控)+ AI健康助手(蚂蚁阿福,健康咨询) + // 单行三枚胶囊,短标题避免文字挤压 + if (this.agentSupported || this.healthAgentSupported || this.butlerAgentSupported) { + Row({ space: 12 }) { + if (this.agentSupported) { + Column() { + FunctionComponent({ + agentId: AGENT_ID, + options: { + title: '护老助手', + queryText: '你好', + buttonType: ButtonType.CAPSULE, + controlSize: ControlSize.NORMAL, + titleFontSize: 14, + iconSize: 20, + isShowShadow: true, + } as FunctionOptions, + onError: (err: BusinessError) => { + promptAction.showToast({ message: `智能体启动失败:${err.message}` }); + }, + controller: this.agentController ?? undefined, + }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) + } + if (this.butlerAgentSupported) { + Column() { + FunctionComponent({ + agentId: BUTLER_AGENT_ID, + options: { + title: '护老管家', + queryText: '看看天气', + buttonType: ButtonType.CAPSULE, + controlSize: ControlSize.NORMAL, + titleFontSize: 14, + iconSize: 20, + isShowShadow: true, + } as FunctionOptions, + onError: (err: BusinessError) => { + promptAction.showToast({ message: `管家启动失败:${err.message}` }); + }, + controller: this.butlerAgentController ?? undefined, + }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) + } + if (this.healthAgentSupported) { + Column() { + FunctionComponent({ + agentId: HEALTH_AGENT_ID, + options: { + title: '健康助手', + buttonType: ButtonType.CAPSULE, + controlSize: ControlSize.NORMAL, + titleFontSize: 14, + iconSize: 20, + isShowShadow: true, + } as FunctionOptions, + onError: (err: BusinessError) => { + promptAction.showToast({ message: `健康助手启动失败:${err.message}` }); + }, + controller: this.healthAgentController ?? undefined, + }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) + } + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + + // 语音控制 + Column({ space: 12 }) { + Button(this.listening ? '停止' : '🎤 语音控制') + .width('100%') + .constraintSize({ minHeight: 60 }) + .fontSize(Math.round(22 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor('#FFFFFF') + .backgroundColor(this.listening ? '#FF3B30' : '#AF52DE') + .borderRadius(30) + .onClick(() => { + this.toggleVoice(); + }) + .accessibilityLevel('yes') + .accessibilityText(this.listening ? '停止语音' : '语音控制') + .accessibilityDescription(this.listening ? '点按停止语音识别' : '点按开始语音控制,可以说看天气、打电话、叫车或订餐') + if (this.aiThinking) { + Text('正在思考,请稍等...') + .fontSize(Math.round(18 * this.scaleFor())) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_secondary')) + .width('100%') + .textAlign(TextAlign.Center) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } else if (this.listening && this.voiceText.length > 0) { + Text(this.voiceText) + .fontSize(Math.round(18 * this.scaleFor())) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + .width('100%') + .textAlign(TextAlign.Center) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } else if (this.lastReply.length > 0) { + Text(this.lastReply) + .fontSize(Math.round(16 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + .width('100%') + .textAlign(TextAlign.Center) + .maxLines(4) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + } + .width('100%') + .alignItems(HorizontalAlign.Center) + + Column({ space: 6 }) { + Text('需要帮助?') + .fontSize(Math.round(16 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + Text('点下方「聊天助手」随时提问') + .fontSize(Math.round(14 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + } + .width('100%') + .padding({ top: 4, bottom: 120 }) + .alignItems(HorizontalAlign.Center) + } + .width('92%') + .alignItems(HorizontalAlign.Center) + } + .scrollBar(BarState.Auto) + .edgeEffect(EdgeEffect.Spring) + .width('100%') + .height('100%') + .clipContent(ContentClipMode.SAFE_AREA) + } +} + +@Entry +@Component +struct ElderlyHome { + @State currentIndex: number = 0; + @StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system'; + @StorageProp('careMode') careMode: boolean = false; + + private hdsTabsController: HdsTabsController = new HdsTabsController(); + + private gohomeSubscriber: commonEventManager.CommonEventSubscriber | undefined = undefined; + + aboutToAppear(): void { + // 回家编排:A2AServer(:agent 进程)发跨进程公共事件,本页订阅后拉起打车 + commonEventManager.createSubscriber({ events: ['smartsenior_gohome_ride'] }) + .then((subscriber: commonEventManager.CommonEventSubscriber): void => { + this.gohomeSubscriber = subscriber; + commonEventManager.subscribe(subscriber, (): void => { + this.openGohomeRide(); + }); + }) + .catch((err: BusinessError): void => { + console.error('[ElderlyHome] gohome subscribe failed:', err.message); + }); + } + + aboutToDisappear(): void { + if (this.gohomeSubscriber !== undefined) { + commonEventManager.unsubscribe(this.gohomeSubscriber, (unsubErr: BusinessError): void => { + if (unsubErr) { + console.error('[ElderlyHome] gohome unsubscribe failed:', unsubErr.message); + } + }); + this.gohomeSubscriber = undefined; + } + } + + // 每次页面显示(从详情页返回等)通知首页实时刷新天气 + onPageShow(): void { + AppStorage.setOrCreate('weatherRefreshTick', Date.now()); + // 回家编排:通知 wantAgent 深链兜底(应用冷/热启动时) + if (AppStorage.get('pendingGohomeRide') === true) { + AppStorage.setOrCreate('pendingGohomeRide', false); + this.openGohomeRide(); + } + } + + // 回家编排:拉起 Petal 打车(语音 emitter 直达 + 通知深链共用) + private openGohomeRide(): void { + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + const params: petalMaps.TaxiParams = { + destinationPosition: { + latitude: 23.1291, + longitude: 113.2644, + }, + destinationName: '家', + }; + petalMaps.openMapTaxi(context, params).catch((err: BusinessError) => { + if (err.code === 1002600014) { + promptAction.showToast({ message: '未找到地图应用,请在应用市场安装 Petal 地图' }); + } else { + promptAction.showToast({ message: `打开打车失败:${err.message}` }); + } + }); + } + + // 缩放倍数(读 @StorageProp) + private scaleFor(): number { + return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0; + } + + // 与管理端 Index 同款的页签项(图标/文字双 Stack + spring 动画) + // 关怀模式下关闭切换动画,降低视觉干扰 + @Builder + TabBarItem(icon: string, text: string, index: number) { + Column({ space: 4 }) { + Stack() { + Text(icon) + .fontSize(24) + .fontColor('#888888') + .opacity((this.currentIndex !== index) ? 1 : 0) + .animation({ + duration: this.careMode ? 0 : 200, + curve: curves.springMotion(0.4, 0.8) + }) + + Text(icon) + .fontSize(24) + .fontColor('#007AFF') + .opacity((this.currentIndex === index) ? 1 : 0) + .animation({ + duration: this.careMode ? 0 : 200, + curve: curves.springMotion(0.4, 0.8) + }) + } + + Stack() { + Text(text) + .fontSize(12) + .fontColor('#888888') + .opacity((this.currentIndex !== index) ? 1 : 0) + .animation({ + duration: this.careMode ? 0 : 200, + curve: curves.springMotion(0.4, 0.8) + }) + + Text(text) + .fontSize(12) + .fontColor('#007AFF') + .opacity((this.currentIndex === index) ? 1 : 0) + .animation({ + duration: this.careMode ? 0 : 200, + curve: curves.springMotion(0.4, 0.8) + }) + } + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .onClick(() => { + this.currentIndex = index; + this.hdsTabsController.changeIndex(index); + }) + .accessibilityLevel('yes') + .accessibilityText(`${text}页签,${this.currentIndex === index ? '已选中' : '未选中'}`) + } + + @Builder + TabContentBuilder(index: number) { + TabContent() { + Stack() { + if (index === 0) { + ElderlyMealsContent() + } + if (index === 1) { + Column() { + ElderlyAssistantPanel() + } + .width('100%') + .height('100%') + // 避让悬浮底栏:输入框要在 Tab 上方 + .padding({ bottom: 90 }) + } + if (index === 2) { + Stack() { + ElderlyProfilePanel() + } + .width('100%') + .height('100%') + } + } + .width('100%') + .height('100%') + // 避让顶部 Hds 标题栏(MINI 模式 + 状态栏) + .padding({ top: 92 }) + } + .tabBar(this.TabBarItem( + index === 0 ? '󰁋' : + index === 1 ? '󰊔' : '󰗳', + index === 0 ? '首页' : + index === 1 ? '聊天助手' : '我的', + index + )) + } + + build() { + Column() { + Stack() { + HdsNavigation() { + HdsTabs({ controller: this.hdsTabsController }) { + ForEach([0, 1, 2], (index: number) => { + this.TabContentBuilder(index) + }) + } + .barOverlap(true) + .barPosition(BarPosition.End) + .vertical(false) + .scrollable(false) + .animationDuration(this.careMode ? 0 : 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.currentIndex === 1 ? '聊天助手' : + (this.currentIndex === 2 ? '我的' : '数智护老'), + subTitle: 'SmartSenior', + }, + }, + style: { + scrollEffectOpts: { + enableScrollEffect: false, + scrollEffectType: ScrollEffectType.GRADIENT_BLUR, + }, + systemMaterialEffect: { + materialType: hdsMaterial.MaterialType.IMMERSIVE, + 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]) + } +} diff --git a/entry/src/main/ets/pages/elderly/ElderlyHome1.ets b/entry/src/main/ets/pages/elderly/ElderlyHome1.ets new file mode 100644 index 0000000..c2ae480 --- /dev/null +++ b/entry/src/main/ets/pages/elderly/ElderlyHome1.ets @@ -0,0 +1,1131 @@ +// 长者端首页外壳(ArkTS 严格类型,无 any) +// 框架与管理端 Index 一致:HdsNavigation + HdsTabs 悬浮底栏 +// 首页 = 天气日期 + 四大功能卡片(查看天气/快速拨号/快捷打车/订餐预定)+ 聊天助手保留 +import { curves } from '@kit.ArkUI'; +import { hdsMaterial } from '@hms.hds.hdsMaterial'; +import { HdsTabs, HdsTabsController, HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit'; +import { router, promptAction } from '@kit.ArkUI'; +import { common, Want } from '@kit.AbilityKit'; +import { BusinessError, commonEventManager } from '@kit.BasicServicesKit'; +import { petalMaps } from '@kit.MapKit'; +import { normalizeError } from '../../common/ApiConstants'; +import { api, RequestError } from '../../utils/Request'; +import { requestMicPermission } from '../../utils/PermissionUtil'; +import { getVoiceEngine, startVoice, stopVoice, VoiceHandler } from '../../utils/VoiceCommand'; +import { speechRecognizer } from '@kit.CoreSpeechKit'; +import { speakText } from '../../utils/TtsPlayer'; +import { ElderlyAssistantPanel } from './ElderlyAssistantPanel'; +import { ElderlyProfilePanel } from './ElderlyProfilePanel'; +import { FontScaleLevel, FONT_SCALE_MAP, CARE_MODE_EXTRA_SCALE } from '../../utils/FontScale'; +import { AuthStore } from '../../utils/AuthStore'; +import { fetchWeather, weatherCodeLabel, todayYMD, WeatherData } from '../../utils/OpenMeteo'; +import { publishLocalNotify } from '../../utils/LocalNotify'; +import { FunctionComponent, FunctionOptions, FunctionController, AgentController, ButtonType } from '@kit.AgentFrameworkKit'; + +// 小艺开放平台智能体:AI健康助手(蚂蚁阿福)+ 数智护老助手(端A2A模式自建,闭环执行)+ 数智护老管家(编排型,插件操控) +const AGENT_ID = 'agent8ca28632414640028b76ee6bf69efa80'; +const HEALTH_AGENT_ID = 'agent940dc0d539da4d46bb26150823a0b4a6'; +const BUTLER_AGENT_ID = 'agent3f66fdeb52504b99a12288b691df2eab'; + +function tomorrowYMD(): string { + const d = new Date(Date.now() + 24 * 60 * 60 * 1000); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${d.getFullYear()}-${mm}-${dd}`; +} + +function greeting(): string { + const hour = new Date().getHours(); + if (hour < 12) { + return '上午好'; + } + if (hour < 18) { + return '下午好'; + } + return '晚上好'; +} + +function currentDateLabel(): string { + const d = new Date(); + const weekdays: Array = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']; + return `${d.getMonth() + 1}月${d.getDate()}日 ${weekdays[d.getDay()]}`; +} + +interface ElderlyMeal { + id: string; + name: string; + menu: string; + ordered: boolean; + reservable: boolean; + reservation_hint: string; + service_date: string; +} + +interface DashboardResponse { + service_date: string; + meals: Array; +} + +// 首页院内活动简报(数据源:/activities/weeks/) +interface HomeActivity { + name: string; + when: string; + location: string; +} + +interface ActivityItem { + activity_name: string; + activity_date: string; + start_time: string; + location: string; +} + +interface ActivityWeek { + status: string; + items: Array; +} + +// '2026-09-01' → '9月1日' +function mdLabel(ymd: string): string { + const parts = ymd.split('-'); + if (parts.length < 3) { + return ymd; + } + const mm = Number(parts[1]); + const dd = Number(parts[2]); + if (Number.isNaN(mm) || Number.isNaN(dd)) { + return ymd; + } + return `${mm}月${dd}日`; +} + +function activityWeekStart(base: Date): string { + const d = new Date(base); + 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 activityDateLabel(iso: string): string { + const d = new Date(iso + 'T00:00:00'); + if (Number.isNaN(d.getTime())) { + return iso; + } + const weekdays: Array = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + return `${d.getMonth() + 1}/${d.getDate()} ${weekdays[d.getDay()]}`; +} + +// 后端 AI 助手(Qwen Agent)响应 +interface AssistantReservation { + slot_id: string; + slot_name: string; + ordered: boolean; + service_date: string; +} + +interface AssistantReply { + reply: string; + auto_reserved: boolean; + reservations: Array; +} + +// 长者端首页:天气日期 + 四大功能卡片 + 我的订餐 +@Component +export struct ElderlyMealsContent { + @State elderlyName: string = ''; + @State weather: WeatherData | null = null; + @State weatherLoading: boolean = true; + @State locationLabel: string = '广州'; + @State meals: Array = []; + @State mealsLoading: boolean = true; + @State mealsDate: string = ''; + @State activities: Array = []; + @State togglingId: string = ''; + @State listening: boolean = false; + @State agentSupported: boolean = false; + @State healthAgentSupported: boolean = false; + @State butlerAgentSupported: boolean = false; + private agentController: FunctionController = new FunctionController(); + private healthAgentController: FunctionController = new FunctionController(); + private butlerAgentController: FunctionController = new FunctionController(); + + private onAgentOpened = (): void => { + console.info('[ElderlyHome] agent dialog opened'); + }; + + private onAgentClosed = (): void => { + console.info('[ElderlyHome] agent dialog closed'); + }; + @State voiceText: string = ''; + @State aiThinking: boolean = false; + @State lastReply: string = ''; + @StorageProp('weatherRefreshTick') @Watch('onWeatherTick') weatherRefreshTick: number = 0; + @State lastLat: number | undefined = undefined; + @State lastLon: number | undefined = undefined; + private voiceEngine: speechRecognizer.SpeechRecognitionEngine | null = null; + @StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system'; + @StorageProp('careMode') careMode: boolean = false; + + private scaleFor(): number { + const base = FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0; + // 关怀模式(系统无障碍开启)下字号再加一档 + return base * (this.careMode ? CARE_MODE_EXTRA_SCALE : 1.0); + } + + // 页面重新显示时实时刷新天气 + onWeatherTick(): void { + if (this.weatherRefreshTick > 0) { + this.loadWeatherWithLocation(); + } + } + + aboutToAppear(): void { + this.loadProfile(); + this.loadWeatherWithLocation(); + this.loadMeals(); + this.loadActivities(); + this.checkAgentSupport(); + this.agentController.on('agentDialogOpened', this.onAgentOpened); + this.agentController.on('agentDialogClosed', this.onAgentClosed); + } + + aboutToDisappear(): void { + this.agentController.off('agentDialogOpened'); + this.agentController.off('agentDialogClosed'); + } + + // 检测智能体可用性(设备/地区/华为账号),不可用则隐藏入口 + private checkAgentSupport(): void { + try { + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + new AgentController().isAgentSupport(context, AGENT_ID).then((supported: boolean) => { + this.agentSupported = supported; + }).catch((err: BusinessError) => { + console.error('[ElderlyHome] isAgentSupport failed:', normalizeError(err)); + this.agentSupported = false; + }); + new AgentController().isAgentSupport(context, HEALTH_AGENT_ID).then((supported: boolean) => { + this.healthAgentSupported = supported; + }).catch((err: BusinessError) => { + console.error('[ElderlyHome] health isAgentSupport failed:', normalizeError(err)); + this.healthAgentSupported = false; + }); + new AgentController().isAgentSupport(context, BUTLER_AGENT_ID).then((supported: boolean) => { + this.butlerAgentSupported = supported; + }).catch((err: BusinessError) => { + console.error('[ElderlyHome] butler isAgentSupport failed:', normalizeError(err)); + this.butlerAgentSupported = false; + }); + } catch (err) { + this.agentSupported = false; + this.healthAgentSupported = false; + this.butlerAgentSupported = false; + } + } + + // 院内活动简报:从本周起向后找,显示今天、明天及未来的活动,取前 2 条 + private async loadActivities(): Promise { + try { + const today = todayYMD(); + const base = new Date(todayYMD() + 'T00:00:00'); + for (let i = 0; i < 5; i++) { + const ws = new Date(base); + ws.setDate(ws.getDate() + i * 7); + const week = activityWeekStart(ws); + const res = await api.get(`/activities/weeks/${week}/`); + if (res.items && res.items.length > 0) { + const upcoming = res.items.filter((item: ActivityItem): boolean => item.activity_date >= today); + if (upcoming.length > 0) { + this.activities = upcoming.slice(0, 2).map((item: ActivityItem): HomeActivity => { + return { + name: item.activity_name, + when: `${activityDateLabel(item.activity_date)} ${item.start_time && item.start_time.length > 0 ? item.start_time : ''}`, + location: item.location, + }; + }); + return; + } + } + } + this.activities = []; + } catch (err) { + console.error('[ElderlyHome] load activities failed:', normalizeError(err as BusinessError | Error)); + } + } + + private async loadProfile(): Promise { + try { + const state = await AuthStore.getState(); + this.elderlyName = state.userName; + } catch (err) { + console.error('[ElderlyHome] load profile failed:', normalizeError(err as BusinessError | Error)); + } + } + + // 天气统一使用养老院固定坐标(广州),保证首页/播报/详情页三处数据永远一致 + private async loadWeatherWithLocation(): Promise { + this.lastLat = undefined; + this.lastLon = undefined; + this.locationLabel = '广州'; + this.weatherLoading = true; + try { + const res = await fetchWeather(undefined, undefined); + this.weather = res; + } catch (err) { + console.error('[ElderlyHome] load weather failed:', normalizeError(err as BusinessError | Error)); + } finally { + this.weatherLoading = false; + } + } + + private async loadMeals(): Promise { + this.mealsLoading = true; + try { + const res = await api.get('/elderly-portal/dashboard/'); + let meals = res.meals; + let date = typeof res.service_date === 'string' ? res.service_date : todayYMD(); + meals.forEach((m: ElderlyMeal): void => { + console.info(`[ElderlyHome] meal ${m.id} reservable=${m.reservable} ordered=${m.ordered}`); + }); + // 今天三餐都已过(既不可预订也未预订)时,才自动切换为明天的菜单 + // 注意:已预订的餐次 reservable 可能为 false,不能据此判定"已过" + const allPassed = meals.length > 0 + && meals.every((m: ElderlyMeal) => !m.reservable && !m.ordered) + && date === todayYMD(); + console.info(`[ElderlyHome] allPassed=${allPassed} date=${date}`); + if (allPassed) { + date = tomorrowYMD(); + const slotKeys: Array = ['breakfast', 'lunch', 'dinner']; + const slots: Array = await Promise.all(slotKeys.map((key: string): Promise => + api.get(`/elderly-portal/meals/${key}/?service_date=${date}`).catch((): ElderlyMeal | null => null) + )); + const nextMeals: Array = []; + slots.forEach((slot: ElderlyMeal | null): void => { + if (slot !== null) { + nextMeals.push(slot); + } + }); + if (nextMeals.length > 0) { + meals = nextMeals; + } + } + this.meals = meals; + this.mealsDate = date; + this.notifyUnorderedMeals(); + } catch (err) { + console.error('[ElderlyHome] load meals failed:', normalizeError(err as BusinessError | Error)); + } finally { + this.mealsLoading = false; + } + } + + // 订餐提醒:可订但未订的餐次发本地通知(每餐每自然日最多一次) + private notifyUnorderedMeals(): void { + const today = todayYMD(); + this.meals.forEach((m: ElderlyMeal): void => { + if (m.reservable && !m.ordered) { + const key = `mealNotify_${today}_${m.id}`; + if (AppStorage.get(key) === undefined) { + AppStorage.setOrCreate(key, '1'); + publishLocalNotify(Date.now() % 100000, '订餐提醒', `您还未预订${m.name},记得及时预订`); + } + } + }); + } + + private openPage(url: string): void { + router.pushUrl({ url: url }).catch((err: BusinessError) => { + promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` }); + }); + } + + // 快捷打车:官方 Map Kit 拉起 Petal 地图打车页(目的地暂预填养老院,可在打车页修改) + private openRideApp(): void { + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + const params: petalMaps.TaxiParams = { + destinationPosition: { + latitude: 23.1291, + longitude: 113.2644, + }, + destinationName: '养老院', + }; + petalMaps.openMapTaxi(context, params).catch((err: BusinessError) => { + if (err.code === 1002600014) { + promptAction.showToast({ message: '未找到地图应用,请在应用市场安装 Petal 地图' }); + } else { + promptAction.showToast({ message: `打开打车失败:${err.message}` }); + } + }); + } + + // 语音命令分发:天气/电话/打车本地直达(数据真实、响应快);订餐/菜单/闲聊走后端 AI(Qwen Agent) + private async dispatchVoiceCommand(text: string): Promise { + const t = text.trim(); + if (t.length < 2) { + promptAction.showToast({ message: '没听清,请再说一遍' }); + return; + } + const wantsWeather = t.indexOf('天气') >= 0; + const wantsDial = t.indexOf('电话') >= 0 || t.indexOf('拨打') >= 0 || t.indexOf('打给') >= 0; + const wantsRide = t.indexOf('打车') >= 0 || t.indexOf('叫车') >= 0 || t.indexOf('出租') >= 0 || t.indexOf('网约') >= 0; + const wantsMeal = t.indexOf('订餐') >= 0 || t.indexOf('订饭') >= 0 || t.indexOf('吃饭') >= 0 || t.indexOf('订') >= 0; + + // 天气:本地 Open-Meteo 实时播报 + 跳页(AI 没有天气数据源,不问 AI) + if (wantsWeather) { + let report = '天气数据暂不可用'; + try { + const w = await fetchWeather(this.lastLat, this.lastLon); + const today = w.daily.length > 0 ? w.daily[0] : null; + const rain = today !== null ? `,降雨概率百分之${today.rainPct}` : ''; + report = `${this.locationLabel},${weatherCodeLabel(w.current.code)},当前气温${Math.round(w.current.temperature)}度${rain}`; + } catch (err) { + // 保持默认提示 + } + await speakText(report); + router.pushUrl({ + url: 'pages/elderly/ElderlyWeather', + params: { lat: this.lastLat ?? -1, lon: this.lastLon ?? -1 }, + }).catch((err: BusinessError) => { + promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` }); + }); + return; + } + if (wantsDial) { + await speakText('正在为您打开快速拨号'); + this.openPage('pages/elderly/ElderlyDial'); + return; + } + if (wantsRide) { + await speakText('正在为您呼叫网约车'); + this.openRideApp(); + return; + } + + // 订餐/菜单问答/闲聊:走后端 AI + let reply = ''; + let autoReserved = false; + this.aiThinking = true; + this.lastReply = ''; + try { + const res = await api.post('/elderly-portal/assistant/reply/', { message: t }); + reply = typeof res.reply === 'string' ? res.reply : ''; + autoReserved = res.auto_reserved === true; + } catch (err) { + console.error('[ElderlyHome] assistant reply failed:', normalizeError(err as BusinessError | Error)); + } finally { + this.aiThinking = false; + } + + if (reply.length > 0) { + // 播报 AI 回答(截断防止超长),同时展示在语音区 + const speak = reply.length > 150 ? reply.substring(0, 150) : reply; + this.lastReply = speak; + await speakText(speak); + if (wantsMeal && !autoReserved) { + // AI 没能自动订上时,带老人去订餐页手动确认 + this.openPage('pages/elderly/ElderlyMealOrder'); + } + return; + } + + // AI 不可用:本地兜底 + if (wantsMeal) { + await speakText('正在为您打开订餐'); + this.openPage('pages/elderly/ElderlyMealOrder'); + } else { + await speakText('没听懂,您可以说,看天气,打电话,叫车,或者订餐'); + promptAction.showToast({ message: '没听懂,请说:看天气、打电话、叫车或订餐' }); + } + } + + private stopListening(): void { + if (this.voiceEngine !== null) { + stopVoice(this.voiceEngine); + } + this.listening = false; + } + + private async toggleVoice(): Promise { + if (this.listening) { + this.stopListening(); + return; + } + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + const granted = await requestMicPermission(context); + if (!granted) { + promptAction.showToast({ message: '需要麦克风权限才能语音控制' }); + return; + } + try { + if (this.voiceEngine === null) { + this.voiceEngine = await getVoiceEngine(); + } + const handler: VoiceHandler = { + onText: (text: string, final: boolean) => { + this.voiceText = text; + }, + onError: (message: string) => { + promptAction.showToast({ message: `语音识别失败:${message}` }); + this.listening = false; + }, + onStopped: () => { + this.listening = false; + if (this.voiceText.length > 0) { + this.dispatchVoiceCommand(this.voiceText); + } + }, + }; + this.voiceText = '请说话...'; + startVoice(this.voiceEngine, handler); + this.listening = true; + } catch (err) { + promptAction.showToast({ message: `语音引擎启动失败:${normalizeError(err as BusinessError | Error)}` }); + } + } + + private weatherSummary(): string { + if (this.weatherLoading) { + return '天气获取中...'; + } + const w = this.weather; + if (w === null) { + return '天气暂不可用'; + } + const today = w.daily.length > 0 ? w.daily[0] : null; + const rain = today !== null ? ` · 降雨${today.rainPct}%` : ''; + return `${weatherCodeLabel(w.current.code)} ${w.current.temperature.toFixed(0)}°C${rain}`; + } + + // 首页直接预订/取消餐次 + private async toggleOrder(meal: ElderlyMeal): Promise { + if (this.togglingId.length > 0) { + return; + } + if (!meal.reservable && !meal.ordered) { + promptAction.showToast({ message: meal.reservation_hint || '该餐次暂不可预订' }); + return; + } + this.togglingId = meal.id; + try { + const updated = await api.post(`/elderly-portal/meals/${meal.id}/reservation/`, { + ordered: !meal.ordered, + service_date: meal.service_date, + }); + this.meals = this.meals.map((m: ElderlyMeal): ElderlyMeal => { + if (m.id === meal.id) { + return updated; + } + return m; + }); + promptAction.showToast({ message: updated.ordered ? `已预订${meal.name}` : `已取消${meal.name}` }); + await speakText(updated.ordered ? `已为您预订${meal.name}` : `已为您取消${meal.name}`); + // 结果同步发一条本地通知(验证通知通道;卡片链路修复后可去掉) + publishLocalNotify( + Date.now() % 100000, + updated.ordered ? '订餐成功' : '订餐已取消', + updated.ordered ? `已为您预订${meal.name}` : `已取消${meal.name}的预订`, + ); + } catch (err) { + const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error); + promptAction.showToast({ message: message }); + publishLocalNotify(Date.now() % 100000, '订餐失败', `${message}`); + } finally { + this.togglingId = ''; + } + } + + @Builder + MealOrderRow(meal: ElderlyMeal) { + Row({ space: 12 }) { + Column({ space: 4 }) { + Text(meal.name) + .fontSize(Math.round(19 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Text(meal.menu && meal.menu.length > 0 ? meal.menu : '菜单待更新') + .fontSize(Math.round(14 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + Text(this.togglingId === meal.id ? '...' : (meal.ordered ? '取消' : '预订')) + .fontSize(Math.round(17 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor('#FFFFFF') + .padding({ left: 20, right: 20, top: 10, bottom: 10 }) + .backgroundColor(meal.ordered ? '#FF3B30' : '#34C759') + .borderRadius(20) + .onClick(() => { + this.toggleOrder(meal); + }) + .accessibilityLevel('yes') + .accessibilityText(`${meal.ordered ? '取消预订' : '预订'}${meal.name}`) + .accessibilityDescription(`${meal.name}菜单:${meal.menu && meal.menu.length > 0 ? meal.menu : '待更新'},点按${meal.ordered ? '取消' : '预订'}${meal.name}`) + } + .width('100%') + .padding({ top: 10, bottom: 10 }) + .alignItems(VerticalAlign.Center) + } + + @Builder + FeatureCard(icon: string, title: string, desc: string, color: string, onTap: () => void) { + Column({ space: 10 }) { + Text(icon) + .fontSize(44) + Text(title) + .fontSize(Math.round(22 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Text(desc) + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + .constraintSize({ minHeight: 150 }) + .padding({ top: 16, bottom: 16 }) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .backgroundColor($r('app.color.background_card')) + .borderRadius(20) + .border({ width: 2, color: color }) + .onClick(onTap) + .accessibilityLevel('yes') + .accessibilityText(`${title},${desc}`) + .accessibilityDescription(`点按进入${title}`) + } + + build() { + Scroll() { + Column({ space: 32 }) { + // 顶部问候 + 日期 + 天气 + Column({ space: 6 }) { + Text(`${this.elderlyName || '您好'},${greeting()}`) + .fontSize(Math.round(30 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Row({ space: 10 }) { + Text(currentDateLabel()) + .fontSize(Math.round(18 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + Text(`${this.locationLabel} · ${this.weatherSummary()}`) + .fontSize(Math.round(18 * this.scaleFor())) + .fontWeight(FontWeight.Medium) + .layoutWeight(1) + .maxLines(2) + .fontColor('#007AFF') // max + .textOverflow({ overflow: TextOverflow.Ellipsis }) //Line + .onClick(() => { + this.openPage('pages/elderly/ElderlyWeather'); + }) + .accessibilityLevel('yes') + .accessibilityText(`${this.locationLabel}天气,${this.weatherSummary()}`) + .accessibilityDescription('点按查看未来7天天气详情') + } + .width('100%') + } + .width('100%') + .alignItems(HorizontalAlign.Start) + + // 四大功能卡片 + Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) { + Column() { + this.FeatureCard('☀️', '查看天气', '未来7天预报', '#FF9500', (): void => { + this.openPage('pages/elderly/ElderlyWeather'); + }) + } + .width('47%') + .margin({ bottom: 20 }) + + Column() { + this.FeatureCard('📞', '快速拨号', '一键打电话', '#34C759', (): void => { + this.openPage('pages/elderly/ElderlyDial'); + }) + } + .width('47%') + .margin({ bottom: 20 }) + + Column() { + this.FeatureCard('🚕', '快捷打车', '一键呼叫网约车', '#007AFF', (): void => { + this.openRideApp(); + }) + } + .width('47%') + + Column() { + this.FeatureCard('🍽️', '订餐预定', '今日明日订餐', '#AF52DE', (): void => { + this.openPage('pages/elderly/ElderlyMealOrder'); + }) + } + .width('47%') + } + .width('100%') + + // 我的订餐(直接选菜品预订) + Column({ space: 8 }) { + Row() { + Text(this.mealsDate.length > 0 && this.mealsDate !== todayYMD() ? `我的订餐(明天 ${mdLabel(this.mealsDate)})` : '我的订餐') + .fontSize(Math.round(20 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Blank() + Text('更多 ›') + .fontSize(Math.round(15 * this.scaleFor())) + .fontColor('#007AFF') + } + .width('100%') + + if (this.mealsLoading) { + Text('订餐加载中...') + .fontSize(Math.round(15 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + .width('100%') + .padding({ top: 8, bottom: 8 }) + } else { + ForEach(this.meals, (meal: ElderlyMeal) => { + this.MealOrderRow(meal) + }, (meal: ElderlyMeal) => `${meal.id}-${meal.ordered}`) + } + } + .width('100%') + .padding(18) + .backgroundColor($r('app.color.background_card')) + .borderRadius(20) + .alignItems(HorizontalAlign.Start) + .onClick(() => { + this.openPage('pages/elderly/ElderlyMealOrder'); + }) + + // 院内活动(点击进入活动详情页) + if (this.activities.length > 0) { + Column({ space: 8 }) { + Row() { + Text('院内活动') + .fontSize(Math.round(20 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Blank() + Text('更多 ›') + .fontSize(Math.round(15 * this.scaleFor())) + .fontColor('#007AFF') + } + .width('100%') + + ForEach(this.activities, (a: HomeActivity) => { + Row({ space: 10 }) { + Column({ space: 3 }) { + Text(a.name) + .fontSize(Math.round(17 * this.scaleFor())) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(a.location.length > 0 ? `${a.when} · ${a.location}` : a.when) + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + Text('›') + .fontSize(20) + .fontColor($r('app.color.inactive_tab')) + } + .width('100%') + .padding({ top: 8, bottom: 8 }) + .alignItems(VerticalAlign.Center) + }, (a: HomeActivity) => `${a.name}-${a.when}`) + } + .width('100%') + .padding(18) + .backgroundColor($r('app.color.background_card')) + .borderRadius(20) + .alignItems(HorizontalAlign.Start) + .onClick(() => { + this.openPage('pages/elderly/Activities'); + }) + .accessibilityLevel('yes') + .accessibilityText(`院内活动,${this.activities[0].name},${this.activities[0].when},点按查看全部活动`) + } + + // 智能体入口:数智护老助手(端A2A自建,订餐/求助/活动天气)+ 数智护老管家(编排型,插件操控)+ AI健康助手(蚂蚁阿福,健康咨询) + // 单行三枚胶囊,短标题避免文字挤压 + if (this.agentSupported || this.healthAgentSupported || this.butlerAgentSupported) { + Row({ space: 12 }) { + if (this.agentSupported) { + Column() { + FunctionComponent({ + agentId: AGENT_ID, + options: { + title: '护老助手', + queryText: '你好', + buttonType: ButtonType.CAPSULE, + controlSize: ControlSize.NORMAL, + titleFontSize: 14, + iconSize: 20, + isShowShadow: true, + } as FunctionOptions, + onError: (err: BusinessError) => { + promptAction.showToast({ message: `智能体启动失败:${err.message}` }); + }, + controller: this.agentController, + }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) + } + if (this.butlerAgentSupported) { + Column() { + FunctionComponent({ + agentId: BUTLER_AGENT_ID, + options: { + title: '护老管家', + queryText: '看看天气', + buttonType: ButtonType.CAPSULE, + controlSize: ControlSize.NORMAL, + titleFontSize: 14, + iconSize: 20, + isShowShadow: true, + } as FunctionOptions, + onError: (err: BusinessError) => { + promptAction.showToast({ message: `管家启动失败:${err.message}` }); + }, + controller: this.butlerAgentController, + }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) + } + if (this.healthAgentSupported) { + Column() { + FunctionComponent({ + agentId: HEALTH_AGENT_ID, + options: { + title: '健康助手', + buttonType: ButtonType.CAPSULE, + controlSize: ControlSize.NORMAL, + titleFontSize: 14, + iconSize: 20, + isShowShadow: true, + } as FunctionOptions, + onError: (err: BusinessError) => { + promptAction.showToast({ message: `健康助手启动失败:${err.message}` }); + }, + controller: this.healthAgentController, + }) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Center) + } + } + .width('100%') + .alignItems(VerticalAlign.Center) + } + + // 语音控制 + Column({ space: 12 }) { + Button(this.listening ? '停止' : '🎤 语音控制') + .width('100%') + .constraintSize({ minHeight: 60 }) + .fontSize(Math.round(22 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor('#FFFFFF') + .backgroundColor(this.listening ? '#FF3B30' : '#AF52DE') + .borderRadius(30) + .onClick(() => { + this.toggleVoice(); + }) + .accessibilityLevel('yes') + .accessibilityText(this.listening ? '停止语音' : '语音控制') + .accessibilityDescription(this.listening ? '点按停止语音识别' : '点按开始语音控制,可以说看天气、打电话、叫车或订餐') + if (this.aiThinking) { + Text('正在思考,请稍等...') + .fontSize(Math.round(18 * this.scaleFor())) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_secondary')) + .width('100%') + .textAlign(TextAlign.Center) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } else if (this.listening && this.voiceText.length > 0) { + Text(this.voiceText) + .fontSize(Math.round(18 * this.scaleFor())) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + .width('100%') + .textAlign(TextAlign.Center) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } else if (this.lastReply.length > 0) { + Text(this.lastReply) + .fontSize(Math.round(16 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + .width('100%') + .textAlign(TextAlign.Center) + .maxLines(4) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + } + .width('100%') + .alignItems(HorizontalAlign.Center) + + Column({ space: 6 }) { + Text('需要帮助?') + .fontSize(Math.round(16 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + Text('点下方「聊天助手」随时提问') + .fontSize(Math.round(14 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + } + .width('100%') + .padding({ top: 4, bottom: 120 }) + .alignItems(HorizontalAlign.Center) + } + .width('92%') + .alignItems(HorizontalAlign.Center) + } + .scrollBar(BarState.Auto) + .edgeEffect(EdgeEffect.Spring) + .width('100%') + .height('100%') + .clipContent(ContentClipMode.SAFE_AREA) + } +} + +@Entry +@Component +struct ElderlyHome { + @State currentIndex: number = 0; + @StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system'; + @StorageProp('careMode') careMode: boolean = false; + + private hdsTabsController: HdsTabsController = new HdsTabsController(); + + private gohomeSubscriber: commonEventManager.CommonEventSubscriber | undefined = undefined; + + aboutToAppear(): void { + // 回家编排:A2AServer(:agent 进程)发跨进程公共事件,本页订阅后拉起打车 + commonEventManager.createSubscriber({ events: ['smartsenior_gohome_ride'] }) + .then((subscriber: commonEventManager.CommonEventSubscriber): void => { + this.gohomeSubscriber = subscriber; + commonEventManager.subscribe(subscriber, (): void => { + this.openGohomeRide(); + }); + }) + .catch((err: BusinessError): void => { + console.error('[ElderlyHome] gohome subscribe failed:', err.message); + }); + } + + aboutToDisappear(): void { + if (this.gohomeSubscriber !== undefined) { + commonEventManager.unsubscribe(this.gohomeSubscriber, (unsubErr: BusinessError): void => { + if (unsubErr) { + console.error('[ElderlyHome] gohome unsubscribe failed:', unsubErr.message); + } + }); + this.gohomeSubscriber = undefined; + } + } + + // 每次页面显示(从详情页返回等)通知首页实时刷新天气 + onPageShow(): void { + AppStorage.setOrCreate('weatherRefreshTick', Date.now()); + // 回家编排:通知 wantAgent 深链兜底(应用冷/热启动时) + if (AppStorage.get('pendingGohomeRide') === true) { + AppStorage.setOrCreate('pendingGohomeRide', false); + this.openGohomeRide(); + } + } + + // 回家编排:拉起 Petal 打车(语音 emitter 直达 + 通知深链共用) + private openGohomeRide(): void { + const context = this.getUIContext().getHostContext() as common.UIAbilityContext; + const params: petalMaps.TaxiParams = { + destinationPosition: { + latitude: 23.1291, + longitude: 113.2644, + }, + destinationName: '家', + }; + petalMaps.openMapTaxi(context, params).catch((err: BusinessError) => { + if (err.code === 1002600014) { + promptAction.showToast({ message: '未找到地图应用,请在应用市场安装 Petal 地图' }); + } else { + promptAction.showToast({ message: `打开打车失败:${err.message}` }); + } + }); + } + + // 缩放倍数(读 @StorageProp) + private scaleFor(): number { + return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0; + } + + // 与管理端 Index 同款的页签项(图标/文字双 Stack + spring 动画) + // 关怀模式下关闭切换动画,降低视觉干扰 + @Builder + TabBarItem(icon: string, text: string, index: number) { + Column({ space: 4 }) { + Stack() { + Text(icon) + .fontSize(24) + .fontColor('#888888') + .opacity((this.currentIndex !== index) ? 1 : 0) + .animation({ + duration: this.careMode ? 0 : 200, + curve: curves.springMotion(0.4, 0.8) + }) + + Text(icon) + .fontSize(24) + .fontColor('#007AFF') + .opacity((this.currentIndex === index) ? 1 : 0) + .animation({ + duration: this.careMode ? 0 : 200, + curve: curves.springMotion(0.4, 0.8) + }) + } + + Stack() { + Text(text) + .fontSize(12) + .fontColor('#888888') + .opacity((this.currentIndex !== index) ? 1 : 0) + .animation({ + duration: this.careMode ? 0 : 200, + curve: curves.springMotion(0.4, 0.8) + }) + + Text(text) + .fontSize(12) + .fontColor('#007AFF') + .opacity((this.currentIndex === index) ? 1 : 0) + .animation({ + duration: this.careMode ? 0 : 200, + curve: curves.springMotion(0.4, 0.8) + }) + } + } + .width('100%') + .height('100%') + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .onClick(() => { + this.currentIndex = index; + this.hdsTabsController.changeIndex(index); + }) + .accessibilityLevel('yes') + .accessibilityText(`${text}页签,${this.currentIndex === index ? '已选中' : '未选中'}`) + } + + @Builder + TabContentBuilder(index: number) { + TabContent() { + Stack() { + if (index === 0) { + ElderlyMealsContent() + } + if (index === 1) { + Column() { + ElderlyAssistantPanel() + } + .width('100%') + .height('100%') + // 避让悬浮底栏:输入框要在 Tab 上方 + .padding({ bottom: 90 }) + } + if (index === 2) { + Stack() { + ElderlyProfilePanel() + } + .width('100%') + .height('100%') + } + } + .width('100%') + .height('100%') + // 避让顶部 Hds 标题栏(MINI 模式 + 状态栏) + .padding({ top: 92 }) + } + .tabBar(this.TabBarItem( + index === 0 ? '󰁋' : + index === 1 ? '󰊔' : '󰗳', + index === 0 ? '首页' : + index === 1 ? '聊天助手' : '我的', + index + )) + } + + build() { + Column() { + Stack() { + HdsNavigation() { + HdsTabs({ controller: this.hdsTabsController }) { + ForEach([0, 1, 2], (index: number) => { + this.TabContentBuilder(index) + }) + } + .barOverlap(true) + .barPosition(BarPosition.End) + .vertical(false) + .scrollable(false) + .animationDuration(this.careMode ? 0 : 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.currentIndex === 1 ? '聊天助手' : + (this.currentIndex === 2 ? '我的' : '数智护老'), + subTitle: 'SmartSenior', + }, + }, + style: { + scrollEffectOpts: { + enableScrollEffect: false, + scrollEffectType: ScrollEffectType.GRADIENT_BLUR, + }, + systemMaterialEffect: { + materialType: hdsMaterial.MaterialType.IMMERSIVE, + 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]) + } +} diff --git a/entry/src/main/ets/pages/elderly/ElderlyList.ets b/entry/src/main/ets/pages/elderly/ElderlyList.ets new file mode 100644 index 0000000..e37c45a --- /dev/null +++ b/entry/src/main/ets/pages/elderly/ElderlyList.ets @@ -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 = []; + @State loading: boolean = true; + private scroller: Scroller = new Scroller(); + + aboutToAppear(): void { + this.loadList(); + } + + private async loadList(): Promise { + this.loading = true; + try { + const res = await api.get>('/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')) + } +} diff --git a/entry/src/main/ets/pages/elderly/ElderlyMealDetail.ets b/entry/src/main/ets/pages/elderly/ElderlyMealDetail.ets new file mode 100644 index 0000000..b8dea8b --- /dev/null +++ b/entry/src/main/ets/pages/elderly/ElderlyMealDetail.ets @@ -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 | undefined; + if (params) { + this.mealId = params['mealId'] ?? ''; + this.serviceDate = params['serviceDate'] ?? ''; + } + this.loadMeal(); + } + + private async loadMeal(): Promise { + 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(url); + } catch (err) { + promptAction.showToast({ message: normalizeError(err as BusinessError | Error) }); + } finally { + this.loading = false; + } + } + + private async toggleOrder(): Promise { + 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(`/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')) + } +} diff --git a/entry/src/main/ets/pages/elderly/ElderlyMealOrder.ets b/entry/src/main/ets/pages/elderly/ElderlyMealOrder.ets new file mode 100644 index 0000000..c92672d --- /dev/null +++ b/entry/src/main/ets/pages/elderly/ElderlyMealOrder.ets @@ -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; + custom_menu: Array; +} + +interface OrderItemBrief { + dish_name: string; + quantity: number; +} + +interface CustomOrder { + id: number; + service_date: string; + category: string; + status: string; + items: Array; +} + +interface CustomOrdersResponse { + orders: Array; +} + +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; +} + +interface DishKeyItem { + item: CustomMenuItem; + key: string; +} + +const CATEGORIES: Array = ['早餐', '午餐', '晚餐']; + +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 = []; + @State standardMenu: Array = []; + @State selectedKeys: Array = []; + @State orders: Array = []; + @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 { + this.loading = true; + try { + const responses: Array = await Promise.all([ + api.get('/meals/custom-menu/', { + service_date: this.serviceDate, + category: this.category, + }).catch((): CustomMenuResponse | null => null), + api.get('/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 { + return this.customMenu.filter((item: CustomMenuItem) => item.source === 'recommendation'); + } + + private selectableDishes(): Array { + 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 { + return this.customMenu.filter((item: CustomMenuItem) => this.selectedKeys.indexOf(dishKey(item)) >= 0); + } + + private async createOrder(): Promise { + const selected = this.selectedDishes(); + if (selected.length === 0 || this.submitting) { + return; + } + this.submitting = true; + try { + const items: Array = 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 { + 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')) + } +} + diff --git a/entry/src/main/ets/pages/elderly/ElderlyProfilePanel.ets b/entry/src/main/ets/pages/elderly/ElderlyProfilePanel.ets new file mode 100644 index 0000000..e2ec0a7 --- /dev/null +++ b/entry/src/main/ets/pages/elderly/ElderlyProfilePanel.ets @@ -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 = [ + { 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 { + try { + const res = await api.get('/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 { + 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 { + 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%') + } +} diff --git a/entry/src/main/ets/pages/elderly/ElderlyRecommendations.ets b/entry/src/main/ets/pages/elderly/ElderlyRecommendations.ets new file mode 100644 index 0000000..bfaca38 --- /dev/null +++ b/entry/src/main/ets/pages/elderly/ElderlyRecommendations.ets @@ -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; +} + +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 = 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 = []; + @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 { + this.loading = true; + try { + const res = await api.get('/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')) + } +} diff --git a/entry/src/main/ets/pages/elderly/ElderlyWeather.ets b/entry/src/main/ets/pages/elderly/ElderlyWeather.ets new file mode 100644 index 0000000..eb444ca --- /dev/null +++ b/entry/src/main/ets/pages/elderly/ElderlyWeather.ets @@ -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 | 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 { + 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')) + } +} + diff --git a/entry/src/main/ets/pages/family/FamilyAlertDetail.ets b/entry/src/main/ets/pages/family/FamilyAlertDetail.ets new file mode 100644 index 0000000..12a9947 --- /dev/null +++ b/entry/src/main/ets/pages/family/FamilyAlertDetail.ets @@ -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; +} + +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; + const id = params ? params['id'] : null; + if (id === null || id === undefined) { + router.back(); + return; + } + this.load(Number(id)); + } + + private async load(id: number): Promise { + this.loading = true; + try { + const res = await api.get(`/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')) + } +} diff --git a/entry/src/main/ets/pages/family/FamilyAlerts.ets b/entry/src/main/ets/pages/family/FamilyAlerts.ets new file mode 100644 index 0000000..23b30c9 --- /dev/null +++ b/entry/src/main/ets/pages/family/FamilyAlerts.ets @@ -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 = []; + @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 { + this.loading = true; + try { + const res = await api.get>('/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')) + } +} diff --git a/entry/src/main/ets/pages/family/FamilyAssistant.ets b/entry/src/main/ets/pages/family/FamilyAssistant.ets new file mode 100644 index 0000000..5c26a59 --- /dev/null +++ b/entry/src/main/ets/pages/family/FamilyAssistant.ets @@ -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 = []; + @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 { + 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('/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')) + } +} diff --git a/entry/src/main/ets/pages/family/FamilyBind.ets b/entry/src/main/ets/pages/family/FamilyBind.ets new file mode 100644 index 0000000..7028f38 --- /dev/null +++ b/entry/src/main/ets/pages/family/FamilyBind.ets @@ -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 { + 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')) + } +} diff --git a/entry/src/main/ets/pages/family/FamilyElderly.ets b/entry/src/main/ets/pages/family/FamilyElderly.ets new file mode 100644 index 0000000..14263ad --- /dev/null +++ b/entry/src/main/ets/pages/family/FamilyElderly.ets @@ -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; +} + +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 = []; + @State loading: boolean = true; + + private scroller: Scroller = new Scroller(); + + aboutToAppear(): void { + this.load(); + } + + private async load(): Promise { + this.loading = true; + try { + const res = await api.get>('/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')) + } +} diff --git a/entry/src/main/ets/pages/family/FamilyElderlyDetail.ets b/entry/src/main/ets/pages/family/FamilyElderlyDetail.ets new file mode 100644 index 0000000..cccdc0b --- /dev/null +++ b/entry/src/main/ets/pages/family/FamilyElderlyDetail.ets @@ -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; + emergency_contact: string; + emergency_phone: string; + health_score: number | null; + risks: Array; + 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; +} + +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; + 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 { + this.loading = true; + try { + const res = await api.get(`/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>(`/reports/?elderly=${id}`); + const rows: Array = 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')) + } +} diff --git a/entry/src/main/ets/pages/family/FamilyElderlyHistory.ets b/entry/src/main/ets/pages/family/FamilyElderlyHistory.ets new file mode 100644 index 0000000..47f7c04 --- /dev/null +++ b/entry/src/main/ets/pages/family/FamilyElderlyHistory.ets @@ -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 = []; + @State loading: boolean = true; + + private scroller: Scroller = new Scroller(); + + aboutToAppear(): void { + const params = router.getParams() as Record; + 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 { + try { + const res = await api.get>('/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 { + this.loading = true; + try { + const res = await api.get>('/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')) + } +} diff --git a/entry/src/main/ets/pages/family/FamilyHealthReport.ets b/entry/src/main/ets/pages/family/FamilyHealthReport.ets new file mode 100644 index 0000000..f43fedd --- /dev/null +++ b/entry/src/main/ets/pages/family/FamilyHealthReport.ets @@ -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; +} + +@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 = []; + @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; + this.elderlyId = params ? Number(params['id']) : 0; + if (!(this.elderlyId > 0)) { + router.back(); + return; + } + // 设备不支持 IMMERSIVE 材质时降级为 SMOOTH,避免卡顿/发热 + const materialTypes: Array = 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 { + 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 = 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 { + try { + const res = await api.get>(`/reports/?elderly=${this.elderlyId}`); + const rows: Array = 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')) + } +} diff --git a/entry/src/main/ets/pages/family/FamilyHome.ets b/entry/src/main/ets/pages/family/FamilyHome.ets new file mode 100644 index 0000000..8ce9999 --- /dev/null +++ b/entry/src/main/ets/pages/family/FamilyHome.ets @@ -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 { + const state = await AuthStore.getState(); + this.userName = state.userName; + await this.loadData(); + } + + private async loadData(): Promise { + this.loading = true; + try { + const results = await Promise.all([ + api.get>('/elderly/', { page_size: 1 }).catch(() => null), + api.get>('/alerts/', { page_size: 1, ordering: '-created_at' }).catch(() => null), + api.get>('/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')) + } +} diff --git a/entry/src/main/ets/pages/family/FamilyReportCard.ets b/entry/src/main/ets/pages/family/FamilyReportCard.ets new file mode 100644 index 0000000..d5fc218 --- /dev/null +++ b/entry/src/main/ets/pages/family/FamilyReportCard.ets @@ -0,0 +1,203 @@ +// 家属 · 碰一碰分享接收页:渲染对方设备碰一碰传来的长者健康报告卡 +// 数据来源:EntryAbility.reportData 解析 smartsenior://report?data= 写入 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')) + } +} diff --git a/entry/src/main/ets/pages/family/FamilyVerify.ets b/entry/src/main/ets/pages/family/FamilyVerify.ets new file mode 100644 index 0000000..a7b1890 --- /dev/null +++ b/entry/src/main/ets/pages/family/FamilyVerify.ets @@ -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 { + if (this.loading) { + return; + } + this.loading = true; + try { + const res = await api.post('/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')) + } +} diff --git a/entry/src/main/ets/pages/family/Records.ets b/entry/src/main/ets/pages/family/Records.ets new file mode 100644 index 0000000..c020f30 --- /dev/null +++ b/entry/src/main/ets/pages/family/Records.ets @@ -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 | 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 { + await this.loadRecords(); + } + + private async loadRecords(): Promise { + this.loading = true; + try { + const res = await api.get>('/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')) + } +} diff --git a/entry/src/main/ets/pages/fire-control/FirePatrolHome.ets b/entry/src/main/ets/pages/fire-control/FirePatrolHome.ets new file mode 100644 index 0000000..aac445f --- /dev/null +++ b/entry/src/main/ets/pages/fire-control/FirePatrolHome.ets @@ -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; +} + +// 扫码识别巡更点响应(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; +} + +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 = []; + @State records: Array = []; + @State loading: boolean = true; + @State checkingId: string = ''; + @State showPointPicker: boolean = false; + @State selectedPoint: string = ''; + @State note: string = ''; + @State selectedUris: Array = []; + @State uploadedPhotos: Array = []; + @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> { + if (this.selectedUris.length === 0) { + return []; + } + this.uploading = true; + try { + const res = await uploadMultipartFiles( + '/fire-patrol/upload-images/', this.selectedUris, 'images'); + this.uploadedPhotos = res.photos || []; + return this.uploadedPhotos; + } finally { + this.uploading = false; + } + } + + private async load(): Promise { + this.loading = true; + try { + const results: Array | Page | null> = await Promise.all([ + api.get('/fire-patrol/stats/').catch((): FireStats | null => null), + api.get>('/fire-patrol/points/', { page_size: 100 }).catch((): Page | null => null), + api.get>('/fire-patrol/my-records/', { start: todayStr(), end: todayStr(), page_size: 50 }).catch((): Page | null => null), + ]); + this.stats = results[0] as FireStats | null; + const pRes = results[1] as Page | null; + this.points = pRes ? pRes.results : []; + const rRes = results[2] as Page | 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(); + 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 = ['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 { + this.resolving = true; + try { + const res = await api.post('/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 { + if (this.checkingId.length > 0) { + return; + } + const target = this.selectedPoint; + this.checkingId = target; + try { + const photos: Array = 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'), + }) + } +} diff --git a/entry/src/main/ets/pages/fire-control/FireRecordDetail.ets b/entry/src/main/ets/pages/fire-control/FireRecordDetail.ets new file mode 100644 index 0000000..092631b --- /dev/null +++ b/entry/src/main/ets/pages/fire-control/FireRecordDetail.ets @@ -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; + 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 | 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 = 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 { + this.loading = true; + this.errorMsg = ''; + try { + const res = await api.get(`/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')) + } +} diff --git a/entry/src/main/ets/pages/meals/MealOrders.ets b/entry/src/main/ets/pages/meals/MealOrders.ets new file mode 100644 index 0000000..0467f30 --- /dev/null +++ b/entry/src/main/ets/pages/meals/MealOrders.ets @@ -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; + today_lunch: Array; + today_dinner: Array; + 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 = ['全部', '早餐', '午餐', '晚餐']; + +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 = []; + @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 { + this.summaryLoading = true; + try { + // 同时拉每日用餐报告(行政/长者/楼层统计来源)与订餐统计 + const report = await api.get('/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('/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 = 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 { + if (p === 1) { + this.loading = true; + } + try { + // 内联 Record 字面量(.ets 中不允许 Record 局部变量赋值) + const res = await api.get>('/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')) + } +} diff --git a/entry/src/main/ets/pages/notifications/Notifications.ets b/entry/src/main/ets/pages/notifications/Notifications.ets new file mode 100644 index 0000000..302c344 --- /dev/null +++ b/entry/src/main/ets/pages/notifications/Notifications.ets @@ -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 = []; + @State loading: boolean = true; + @State markingAll: boolean = false; + + private scroller: Scroller = new Scroller(); + + private scaleFor(): number { + return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0; + } + + aboutToAppear(): void { + this.loadData(); + } + + private async loadData(): Promise { + this.loading = true; + try { + // 护理预警仅限护理/管理角色:消防、销售不拉取 /alerts/ + const state = await AuthStore.getState(); + const role = state.role; + const includeAlerts = role === null || role === 'admin' || role === 'nurse' || role === 'staff_nurse'; + const responses: Array | Page | null> = await Promise.all([ + api.get>('/auth/notifications/', { page_size: 30 }).catch((): Page | null => null), + includeAlerts + ? api.get>('/alerts/', { page_size: 30, ordering: '-created_at' }).catch((): Page | null => null) + : Promise.resolve(null), + ]); + const noticeRes = responses[0] as Page | null; + const alertRes = responses[1] as Page | null; + const items: Array = []; + if (noticeRes) { + noticeRes.results.forEach((n: UserNotice) => { + items.push({ + key: `user-${n.id}`, + source: 'user', + id: n.id, + title: n.title, + content: n.content, + isRead: n.is_read, + createdAt: n.created_at, + }); + }); + } + if (alertRes) { + alertRes.results.forEach((a: AlertItem) => { + items.push({ + key: `alert-${a.id}`, + source: 'alert', + id: a.id, + title: a.title, + content: a.description, + isRead: true, + createdAt: a.created_at, + }); + }); + } + items.sort((a: FeedItem, b: FeedItem): number => { + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + }); + this.feed = items; + } catch (err) { + promptAction.showToast({ message: normalizeError(err as BusinessError | Error) }); + } finally { + this.loading = false; + } + } + + private async markRead(item: FeedItem): Promise { + if (item.source !== 'user' || item.isRead) { + return; + } + try { + await api.post(`/auth/notifications/${item.id}/mark-read/`, new EmptyBody()); + this.feed = this.feed.map((f: FeedItem): FeedItem => { + if (f.key === item.key) { + return { key: f.key, source: f.source, id: f.id, title: f.title, content: f.content, isRead: true, createdAt: f.createdAt } as FeedItem; + } + return f; + }); + } catch (err) { + console.error('[Notifications] mark read failed:', normalizeError(err as BusinessError | Error)); + } + } + + private async markAllRead(): Promise { + if (this.markingAll) { + return; + } + this.markingAll = true; + try { + await api.post('/auth/notifications/mark-all-read/', new EmptyBody()); + this.feed = this.feed.map((f: FeedItem): FeedItem => + ({ key: f.key, source: f.source, id: f.id, title: f.title, content: f.content, isRead: true, createdAt: f.createdAt }) as FeedItem); + promptAction.showToast({ message: '已全部标记为已读' }); + } catch (err) { + promptAction.showToast({ message: normalizeError(err as BusinessError | Error) }); + } finally { + this.markingAll = false; + } + } + + private hasUnread(): boolean { + return this.feed.some((f: FeedItem) => f.source === 'user' && !f.isRead); + } + + @Builder + FeedCard(key: string) { + Column({ space: 8 }) { + Row({ space: 8 }) { + if (!this.feedItem(key).isRead) { + Circle() + .width(8) + .height(8) + .fill($r('app.color.accent_red')) + } + Text(this.feedItem(key).source === 'user' ? '通知' : '预警') + .fontSize(Math.round(11 * this.scaleFor())) + .fontColor(this.feedItem(key).source === 'user' ? $r('app.color.accent_blue') : '#FF9500') + .padding({ left: 6, right: 6, top: 2, bottom: 2 }) + .borderRadius(6) + .border({ width: 1, color: this.feedItem(key).source === 'user' ? $r('app.color.accent_blue') : '#FF9500' }) + Blank() + Text(formatDateTime(this.feedItem(key).createdAt)) + .fontSize(Math.round(12 * this.scaleFor())) + .fontColor($r('app.color.inactive_tab')) + } + .width('100%') + + Text(this.feedItem(key).title) + .fontSize(Math.round(15 * this.scaleFor())) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + + if (this.feedItem(key).content && this.feedItem(key).content.length > 0) { + Text(this.feedItem(key).content) + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + } + } + .width('100%') + .padding(16) + .backgroundColor($r('app.color.background_card')) + .borderRadius(14) + .alignItems(HorizontalAlign.Start) + .onClick(() => { + this.markRead(this.feedItem(key)); + }) + } + + // 按 key 从 @State feed 读取(保证 markAllRead 后重渲红点) + private feedItem(key: string): FeedItem { + const found = this.feed.find((f: FeedItem) => f.key === key); + if (found) { + return found; + } + return { key: key, source: 'user', id: 0, title: '', content: '', isRead: true, createdAt: '' }; + } + + build() { + HdsNavigation() { + Column() { + if (this.loading) { + Text('加载中...') + .fontSize(Math.round(14 * this.scaleFor())) + .fontColor($r('app.color.inactive_tab')) + .margin({ top: 60 }) + } else if (this.feed.length === 0) { + Column({ space: 8 }) { + Text('🔔') + .fontSize(Math.round(32 * this.scaleFor())) + Text('暂无消息通知') + .fontSize(Math.round(15 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + } + .width('100%') + .margin({ top: 80 }) + .alignItems(HorizontalAlign.Center) + } else { + Scroll(this.scroller) { + Column({ space: 10 }) { + if (this.hasUnread()) { + Row() { + Text(`共 ${this.feed.length} 条 · 有新通知`) + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + Blank() + Text(this.markingAll ? '处理中...' : '全部已读') + .fontSize(Math.round(14 * this.scaleFor())) + .fontColor($r('app.color.accent_blue')) + .onClick(() => { + this.markAllRead(); + }) + } + .width('100%') + .padding({ left: 4, right: 4, top: 8, bottom: 8 }) + } + ForEach(this.feed, (item: FeedItem) => { + this.FeedCard(item.key) + }, (item: FeedItem) => item.key) + } + .width('100%') + .padding({ left: 16, right: 16, top: 108, bottom: 24 }) + } + .width('100%') + .layoutWeight(1) + .scrollBar(BarState.Auto) + .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true }) + } + } + .width('100%') + .height('100%') + } + .mode(NavigationMode.Stack) + .titleBar({ + content: { + title: { + mainTitle: '消息通知', + }, + }, + style: { + scrollEffectOpts: { + enableScrollEffect: false, + scrollEffectType: ScrollEffectType.GRADIENT_BLUR, + }, + systemMaterialEffect: { + materialType: hdsMaterial.MaterialType.ADAPTIVE, + materialLevel: hdsMaterial.MaterialLevel.EXQUISITE, + }, + }, + avoidLayoutSafeArea: false, + enableComponentSafeArea: false, + }) + .bindToScrollable([this.scroller]) + .hideBackButton(false) + .titleMode(HdsNavigationTitleMode.MINI) + .ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM]) + .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) + .backgroundColor($r('app.color.background')) + } +} diff --git a/entry/src/main/ets/pages/nurse/Alerts.ets b/entry/src/main/ets/pages/nurse/Alerts.ets new file mode 100644 index 0000000..16d78f9 --- /dev/null +++ b/entry/src/main/ets/pages/nurse/Alerts.ets @@ -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 { + await this.loadAlerts(); + } + + private async loadAlerts(): Promise { + this.loading = true; + try { + const res = await api.get>('/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')) + } +} diff --git a/entry/src/main/ets/pages/nurse/CancelMeal.ets b/entry/src/main/ets/pages/nurse/CancelMeal.ets new file mode 100644 index 0000000..e8f2315 --- /dev/null +++ b/entry/src/main/ets/pages/nurse/CancelMeal.ets @@ -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; +} + +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 = []; + @State loading: boolean = true; + @State selected: Candidate | null = null; + @State meals: Array = []; + @State mealsLoading: boolean = false; + @State cancelingId: string = ''; + @State keyword: string = ''; + private scroller: Scroller = new Scroller(); + + aboutToAppear(): void { + this.loadCandidates(); + } + + private async loadCandidates(): Promise { + this.loading = true; + try { + const res = await api.get>('/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 { + 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 { + this.selected = candidate; + this.meals = []; + this.mealsLoading = true; + try { + const date = tomorrowYMD(); + const res = await api.get(`/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 { + 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')) + } +} diff --git a/entry/src/main/ets/pages/nurse/History.ets b/entry/src/main/ets/pages/nurse/History.ets new file mode 100644 index 0000000..6528803 --- /dev/null +++ b/entry/src/main/ets/pages/nurse/History.ets @@ -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 | 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 { + await this.loadPage(1); + } + + async onPageShow(): Promise { + await this.loadPage(1); + } + + private async loadPage(p: number): Promise { + if (p === 1) { + this.loading = true; + } + try { + const res = await api.get>('/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')) + } +} diff --git a/entry/src/main/ets/pages/nurse/MedicationToDo.ets b/entry/src/main/ets/pages/nurse/MedicationToDo.ets new file mode 100644 index 0000000..fe5ebd9 --- /dev/null +++ b/entry/src/main/ets/pages/nurse/MedicationToDo.ets @@ -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 = []; + @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 { + this.loading = true; + try { + const res = await api.get>( + '/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 { + 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 { + 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'), + }) + } +} diff --git a/entry/src/main/ets/pages/nurse/NurseAlertProcess.ets b/entry/src/main/ets/pages/nurse/NurseAlertProcess.ets new file mode 100644 index 0000000..88362f5 --- /dev/null +++ b/entry/src/main/ets/pages/nurse/NurseAlertProcess.ets @@ -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 = []; + @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 { + this.loading = true; + try { + const res = await api.get>('/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 { + 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'), + }) + } +} diff --git a/entry/src/main/ets/pages/nurse/NurseCarePlan.ets b/entry/src/main/ets/pages/nurse/NurseCarePlan.ets new file mode 100644 index 0000000..5b180cc --- /dev/null +++ b/entry/src/main/ets/pages/nurse/NurseCarePlan.ets @@ -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 = []; + @State assessments: Array = []; + @State loading: boolean = true; + private scroller: Scroller = new Scroller(); + + aboutToAppear(): void { + this.load(); + } + + private async load(): Promise { + this.loading = true; + try { + const results: Array | Page | null> = await Promise.all([ + api.get>('/care-plans/plans/', { page_size: 50 }).catch((): Page | null => null), + api.get>('/care-plans/assessments/', { page_size: 50 }).catch((): Page | null => null), + ]); + const p = results[0] as Page | null; + const a = results[1] as Page | 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')) + } +} diff --git a/entry/src/main/ets/pages/nurse/NurseHome.ets b/entry/src/main/ets/pages/nurse/NurseHome.ets new file mode 100644 index 0000000..8b67a88 --- /dev/null +++ b/entry/src/main/ets/pages/nurse/NurseHome.ets @@ -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 { + const state = await AuthStore.getState(); + this.userName = state.userName; + await this.loadData(); + } + + async onPageShow(): Promise { + await this.loadData(); + } + + private async loadData(): Promise { + this.loading = true; + try { + const results = await Promise.all([ + api.get('/tasks/work-center/', { view: 'all' }).catch(() => null), + api.get>('/alerts/', { page_size: 1, ordering: '-created_at' }).catch(() => null), + api.get>('/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')) + } +} diff --git a/entry/src/main/ets/pages/nurse/RecordDetail.ets b/entry/src/main/ets/pages/nurse/RecordDetail.ets new file mode 100644 index 0000000..bb7e9e0 --- /dev/null +++ b/entry/src/main/ets/pages/nurse/RecordDetail.ets @@ -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 | null; + created_at: string; + extracted_json: Record | 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 | undefined; + if (params) { + this.recordId = params['id'] ?? ''; + } + this.loadDetail(); + } + + private async loadDetail(): Promise { + if (this.recordId.length === 0) { + this.loading = false; + return; + } + try { + this.detail = await api.get(`/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')) + } +} diff --git a/entry/src/main/ets/pages/nurse/RecordEntry.ets b/entry/src/main/ets/pages/nurse/RecordEntry.ets new file mode 100644 index 0000000..0b3d2d4 --- /dev/null +++ b/entry/src/main/ets/pages/nurse/RecordEntry.ets @@ -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; +} + +// ── 结构化解析结果类型(对齐后端 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; + 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>; + 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; + extracted_json?: StructuredResult; +} + +const MAX_IMAGES = 9; + +@Entry +@Component +struct RecordEntry { + @State elderlyOptions: Array = []; + @State selectedElderlyId: number = 0; + @State recordText: string = ''; + @State ocrRunning: boolean = false; + @State selectedUris: Array = []; + @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 { + await this.loadElderly(); + } + + private async loadElderly(): Promise { + this.loading = true; + try { + const res = await api.get>('/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 { + 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 { + 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('/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 { + 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 = []; + if (this.selectedUris.length > 0) { + const uploadRes = await uploadMultipartFiles( + '/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 = []; + 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'), + }) + } +} + diff --git a/entry/src/main/ets/pages/nurse/RiskPatrol.ets b/entry/src/main/ets/pages/nurse/RiskPatrol.ets new file mode 100644 index 0000000..d508675 --- /dev/null +++ b/entry/src/main/ets/pages/nurse/RiskPatrol.ets @@ -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; + risk_items: Array; + records: Array; +} + +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; +} + +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 = []; + @State timeSlots: Array = []; + @State records: Array = []; + @State loading: boolean = true; + @State showForm: boolean = false; + @State submitting: boolean = false; + // 表单 + @State slot: string = ''; + @State elderlyName: string = ''; + @State checked: Record = {}; + @State remark: string = ''; + + private scroller: Scroller = new Scroller(); + + aboutToAppear(): void { + this.load(); + } + + private async load(): Promise { + this.loading = true; + try { + const res = await api.get('/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 = JSON.parse(JSON.stringify(this.checked)) as Record; + next[key] = !next[key]; + this.checked = next; + } + + private async submit(): Promise { + 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 { + try { + const res = await api.get('/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'), + }) + } +} diff --git a/entry/src/main/ets/pages/nurse/SkinCheck.ets b/entry/src/main/ets/pages/nurse/SkinCheck.ets new file mode 100644 index 0000000..149dbd9 --- /dev/null +++ b/entry/src/main/ets/pages/nurse/SkinCheck.ets @@ -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; + 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; +} + +@Entry +@Component +struct SkinCheck { + @State elderlyList: Array = []; + @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 { + this.loading = true; + try { + const res = await api.get>('/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 { + 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')) + } +} diff --git a/entry/src/main/ets/pages/nurse/WorkCenter.ets b/entry/src/main/ets/pages/nurse/WorkCenter.ets new file mode 100644 index 0000000..d4e0379 --- /dev/null +++ b/entry/src/main/ets/pages/nurse/WorkCenter.ets @@ -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; +} + +interface WorkCenterResponse { + current_shift: object | null; + schedule_status: string; + summary: CareSummary; + items: Array; +} + +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 = []; + @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 { + this.loading = true; + try { + const res = await api.get('/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('overdueNotifyDay') !== dayKey) { + AppStorage.setOrCreate('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 { + 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 { + 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 { + 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 { + 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'), + }) + } +} diff --git a/entry/src/main/ets/pages/profile/About.ets b/entry/src/main/ets/pages/profile/About.ets new file mode 100644 index 0000000..fe23775 --- /dev/null +++ b/entry/src/main/ets/pages/profile/About.ets @@ -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')) + } +} diff --git a/entry/src/main/ets/pages/profile/Feedback.ets b/entry/src/main/ets/pages/profile/Feedback.ets new file mode 100644 index 0000000..6955e2f --- /dev/null +++ b/entry/src/main/ets/pages/profile/Feedback.ets @@ -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; +} + +// 反馈记录 +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; + 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 = []; + @State submitting: boolean = false; + @State records: Array = []; + @State recordsLoading: boolean = true; + @State showRecords: boolean = false; + private scroller: Scroller = new Scroller(); + + aboutToAppear(): void { + this.loadRecords(); + } + + private async loadRecords(): Promise { + this.recordsLoading = true; + try { + const res = await api.get>('/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 { + if (this.content.trim().length === 0) { + promptAction.showToast({ message: '请输入反馈内容' }); + return; + } + if (this.submitting) { + return; + } + this.submitting = true; + try { + let imagePaths: Array = []; + if (this.selectedUris.length > 0) { + try { + const uploadRes = await uploadMultipartFiles( + '/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 = []; + 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')) + } +} diff --git a/entry/src/main/ets/pages/profile/ProfileTab.ets b/entry/src/main/ets/pages/profile/ProfileTab.ets new file mode 100644 index 0000000..4472bf4 --- /dev/null +++ b/entry/src/main/ets/pages/profile/ProfileTab.ets @@ -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 = [ + { 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 { + 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 { + 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 { + 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%') + } +} diff --git a/entry/src/main/ets/pages/sales/SalesHome.ets b/entry/src/main/ets/pages/sales/SalesHome.ets new file mode 100644 index 0000000..5d1d094 --- /dev/null +++ b/entry/src/main/ets/pages/sales/SalesHome.ets @@ -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; + status: string; + deal_amount: string | null; + latest_note: string; + last_follow_up_at: string; +} + +const GENDER_OPTIONS: Array = ['unknown', 'male', 'female']; +const KEY_PERSON_OPTIONS: Array = ['', 'yes', 'no']; +const SOURCE_OPTIONS: Array = ['美团', '活动', '摊位', '单张', '路过', '朋友', '小红书', '户外广告', '其他']; +const LEAD_STATUS_OPTIONS: Array = ['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 = []; + @State contracts: Array = []; + @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 = []; + @State dealAmount: string = ''; + @State lastFollowUpAt: string = ''; + private scroller: Scroller = new Scroller(); + + aboutToAppear(): void { + this.load(); + } + + private async load(): Promise { + this.loading = true; + try { + const results: Array | Page | null> = await Promise.all([ + api.get>('/market/leads/', { page_size: 50, ordering: '-created_at' }).catch((): Page | null => null), + api.get>('/market/contracts/', { page_size: 50 }).catch((): Page | null => null), + ]); + const l = results[0] as Page | null; + const c = results[1] as Page | 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 { + 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 { + 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'), + }) + } +} diff --git a/entry/src/main/ets/pages/sales/SalesLeadDetail.ets b/entry/src/main/ets/pages/sales/SalesLeadDetail.ets new file mode 100644 index 0000000..6771378 --- /dev/null +++ b/entry/src/main/ets/pages/sales/SalesLeadDetail.ets @@ -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; + 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 = ['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 { + 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 | 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 = 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 { + this.loading = true; + this.errorMsg = ''; + try { + const res = await api.get(`/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 { + 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')) + } +} diff --git a/entry/src/main/ets/pages/workbench/Workbench.ets b/entry/src/main/ets/pages/workbench/Workbench.ets new file mode 100644 index 0000000..d4bf855 --- /dev/null +++ b/entry/src/main/ets/pages/workbench/Workbench.ets @@ -0,0 +1,1224 @@ +// 工作台页签(按角色自适应,对齐小程序 workbench 结构) +// ArkTS 严格类型,无 any +import { router } from '@kit.ArkUI'; +import { promptAction } from '@kit.ArkUI'; +import { uiMaterial } from '@kit.ArkUI'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { Page, UnifiedRole, ROLE_NAME_MAP, normalizeError } from '../../common/ApiConstants'; +import { api } from '../../utils/Request'; +import { AuthStore } from '../../utils/AuthStore'; +import { AlertSocket } from '../../utils/AlertSocket'; +import { publishLocalNotify } from '../../utils/LocalNotify'; +import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale'; + +// 快捷入口 +interface WorkEntry { + key: string; + title: string; + desc: string; + route: string; // 空字符串表示未实现 +} + +// 看板数据 +interface BoardStat { + value: number; + unit: string; + label: string; + color: string; +} + +// 消防巡更统计(GET /fire-patrol/stats/) +interface FireStats { + total_records: number; + today_records: number; + photo_records: number; + abnormal_records: number; + point_count: number; + active_point_count: number; +} + +// 仅取分页 count 的响应 +interface CountOnly { + count: number; +} + +interface AlertItem { + id: number; + title: string; + description: string; + created_at: string; +} + +// 每日用餐统计(订餐提醒) +interface MealCounts { + total: number; + breakfast: number; + lunch: number; + dinner: number; +} + +interface MealGroups { + administrative: MealCounts | null; + elderly_dining: MealCounts | null; + floor_elderly: MealCounts | null; + floor_caregiver: MealCounts | null; +} + +interface DailyReport { + meal_groups: MealGroups; +} + +// 通知消息(含订餐消息) +interface NoticeMessage { + id: number; + title: string; + content: string; + created_at: string; + source: string; // 'alert' | 'notice' +} + +interface ShiftLocation { + floor: number | null; +} + +interface CurrentShift { + locations: Array; +} + +interface DailyTrend { + date: string; + count: number; +} + +// 订餐分布统计(/meals/orders/summary/) +interface MealOrderSummary { + summary_date: string; + today_order_count: number; + breakfast_count: number; + lunch_count: number; + dinner_count: number; +} + +interface MealDistStats { + date: string; + breakfast: number; + lunch: number; + dinner: number; + total: number; +} + +function tomorrowYMD(): string { + const d = new Date(Date.now() + 24 * 60 * 60 * 1000); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${d.getFullYear()}-${mm}-${dd}`; +} + +// '2026-09-01' → '9月1日' +function mdLabel(ymd: string): string { + const parts = ymd.split('-'); + if (parts.length < 3) { + return ymd; + } + const mm = Number(parts[1]); + const dd = Number(parts[2]); + if (Number.isNaN(mm) || Number.isNaN(dd)) { + return ymd; + } + return `${mm}月${dd}日`; +} + +interface HealthDistribution { + total: number; + good: number; + attention: number; + bedridden: number; + good_percent: string; + attention_percent: string; + bedridden_percent: string; +} + +interface OperationCare { + daily_trend: Array; +} + +interface MealSummary { + category: string; + reserved_count: number; +} + +interface MealsOverview { + total_reserved_count: number; + meals: Array; +} + +interface OperationOverview { + care: OperationCare; + meals: MealsOverview; +} + +interface UnresolvedAlert { + id: number; + title: string; + level: string; +} + +interface DashboardSummary { + health_distribution: HealthDistribution; + unresolved_alerts: Array; + operation_overview: OperationOverview; +} + +interface WorkCenterSummary { + current: number; + overdue: number; + upcoming: number; + completed: number; +} + +interface WorkCenterElderly { + id: number; +} + +interface WorkCenterResponse { + current_shift: CurrentShift | null; + summary: WorkCenterSummary; + elderly?: Array; +} + +function greeting(): string { + const hour = new Date().getHours(); + if (hour < 12) { + return '早上好'; + } + if (hour < 18) { + 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}`; +} + +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}`; +} + +@Component +export struct Workbench { + @State userName: string = ''; + @State role: UnifiedRole | null = null; + @State stats: Array = []; + @State entries: Array = []; + @State latestAlertTitle: string = ''; + @State latestAlertDesc: string = ''; + @State latestAlertTime: string = ''; + @State hasAlert: boolean = false; + @State recentMessages: Array = []; + @State mealMessages: Array = []; +@State loading: boolean = true; + @State refreshing: boolean = false; + @State adminData: DashboardSummary | null = null; + @State mealDist: MealDistStats | null = null; + @StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system'; + private pollTimer: number = -1; + + private scaleFor(): number { + return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0; + } + + aboutToAppear(): void { + this.loadAuth(); + // 实时推送:收到消息时刷新通知/订餐,并发本地通知栏提醒 + AlertSocket.setListener((message: string): void => { + this.loadData(); + let text = message; + try { + const parsed = JSON.parse(message) as Record; + const inner = parsed['message'] ?? parsed['content'] ?? parsed['text'] ?? ''; + if (typeof inner === 'string' && inner.length > 0) { + text = inner; + } + } catch (err) { + // 非 JSON 文本原样展示 + } + if (text.length > 80) { + text = text.substring(0, 80); + } + publishLocalNotify(Date.now() % 100000, '护理提醒', text.length > 0 ? text : '收到新的提醒消息'); + }); + // 定时轮询:每 30 秒自动刷新通知/订餐,保证主页及时更新 + this.pollTimer = setInterval(() => { + this.loadData(); + }, 30000); + } + + aboutToDisappear(): void { + AlertSocket.setListener(null); + if (this.pollTimer !== -1) { + clearInterval(this.pollTimer); + this.pollTimer = -1; + } + } + + private async loadAuth(): Promise { + const state = await AuthStore.getState(); + this.userName = state.userName; + this.role = state.role; + this.applyRoleConfig(); + await this.loadData(); + } + + private applyRoleConfig(): void { + const role = this.role; + if (role === 'nurse' || role === 'staff_nurse') { + if (role === 'staff_nurse') { + this.entries = [ + { key: 'alertProcess', title: '预警处理', desc: '处理待处理预警并留证', route: 'pages/nurse/NurseAlertProcess' }, + { key: 'carePlan', title: '护理方案', desc: '体检评估与护理方案', route: 'pages/nurse/NurseCarePlan' }, + { key: 'history', title: '历史记录', desc: '查看过往护理记录', route: 'pages/nurse/History' }, + ]; + } else { + this.entries = [ + { key: 'record', title: '事件录入', desc: '快速记录护理事件', route: 'pages/nurse/RecordEntry' }, + { key: 'skinCheck', title: '皮肤检查', desc: '周身皮肤/压疮检查录入', route: 'pages/nurse/SkinCheck' }, + { key: 'history', title: '历史记录', desc: '查看过往护理记录', route: 'pages/nurse/History' }, + { key: 'meal', title: '代取消订餐', desc: '帮助长者取消明日餐次', route: 'pages/nurse/CancelMeal' }, + ]; + } + } else if (role === 'family') { + this.entries = [ + { key: 'records', title: '护理记录', desc: '查看长者护理记录', route: 'pages/family/Records' }, + { key: 'alerts', title: '预警', desc: '查看预警信息', route: 'pages/family/FamilyAlerts' }, + { key: 'profile', title: '长者档案', desc: '查看长者信息', route: 'pages/family/FamilyElderly' }, + { key: 'assistant', title: '智能助手', desc: '随时咨询家人状况', route: 'pages/family/FamilyAssistant' }, + ]; + } else if (role === 'sales') { + this.entries = [ + { key: 'sales', title: '销售工作台', desc: '线索、跟进与合同', route: 'pages/sales/SalesHome' }, + ]; + } else if (role === 'fire_control') { + this.entries = [ + { key: 'patrol', title: '消防巡更', desc: '巡更打卡与记录', route: 'pages/fire-control/FirePatrolHome' }, + ]; + } else { + // 管理员/销售/默认 + this.entries = [ + { key: 'overview', title: '信息概览', desc: '机构运营总览', route: 'pages/admin/AdminOverview' }, + { key: 'elderly', title: '关注对象', desc: '查看长者档案与风险', route: 'pages/elderly/ElderlyList' }, + { key: 'meal', title: '订餐查询', desc: '今日菜单与记录', route: 'pages/meals/MealOrders' }, + { key: 'schedule', title: '排班管理', desc: '查看今日值班排班', route: 'pages/admin/AdminSchedule' }, + { key: 'adminQuery', title: '智能查询', desc: '语音/文字查询记录', route: 'pages/admin/AdminQuery' }, + { key: 'salesDetail', title: '销售详情', desc: '线索与签约合同', route: 'pages/admin/AdminSalesDetail' }, + { key: 'nurseList', title: '护理员', desc: '查看护理员工作与详情', route: 'pages/admin/AdminNurseList' }, + { key: 'records', title: '护理记录', desc: '全院护理记录(今日/历史)', route: 'pages/admin/AdminNursingRecords' }, + { key: 'device', title: '设备监测', desc: '睡眠与体感设备数据', route: 'pages/admin/DeviceMonitoring' }, + { key: 'history', title: '历史记录', desc: '查看过往护理记录', route: 'pages/nurse/History' }, + { key: 'add', title: '新增对象', desc: '录入长者基础档案', route: 'pages/elderly/ElderlyAdd' }, + { key: 'feedback', title: '意见反馈', desc: '处理家属及员工反馈工单', route: 'pages/admin/AdminFeedback' }, + ]; + } + } + + private async loadData(): Promise { + this.loading = true; + const role = this.role; + try { + if (role === 'nurse' || role === 'staff_nurse') { + await this.loadNurseData(); + } else if (role === 'family') { + await this.loadFamilyData(); + } else if (role === 'fire_control') { + await this.loadFireData(); + } else if (role === 'sales') { + await this.loadSalesData(); + } else { + await this.loadAdminData(); + } + } catch (err) { + // 静默降级:看板显示 0,不打断页面 + console.error('[Workbench] load failed:', normalizeError(err as BusinessError | Error)); + } finally { + this.loading = false; + } + } + + private async loadNurseData(): Promise { + const responses: Array | null> = await Promise.all([ + api.get('/tasks/work-center/', { view: 'all' }).catch((): WorkCenterResponse | null => null), + api.get>('/alerts/', { page_size: 1, ordering: '-created_at' }).catch((): Page | null => null), + api.get>('/auth/notifications/', { page_size: 3 }).catch((): Page | null => null), + ]); + const careRes = responses[0] as WorkCenterResponse | null; + const alertRes = responses[1] as Page | null; + const noticeRes = responses[2] as Page | null; + + const elderlyCount = careRes && careRes.elderly ? careRes.elderly.length : 0; + const overdue = careRes ? careRes.summary.overdue : 0; + const active = careRes ? careRes.summary.current + careRes.summary.overdue + careRes.summary.upcoming : 0; + this.stats = [ + { value: elderlyCount, unit: '位', label: '负责长者', color: ('app.color.accent_blue') }, + { value: active, unit: '项', label: '待完成', color: ('app.color.accent_red') }, + { value: overdue, unit: '项', label: '已经逾期', color: '#FF9500' }, + ]; + this.applyLatestMessage(alertRes, noticeRes); + } + + // 消防工作台:只取巡更统计与系统通知,不触碰护理数据 + private async loadFireData(): Promise { + const responses: Array | null> = await Promise.all([ + api.get('/fire-patrol/stats/').catch((): FireStats | null => null), + api.get>('/auth/notifications/', { page_size: 3 }).catch((): Page | null => null), + ]); + const fireRes = responses[0] as FireStats | null; + const noticeRes = responses[1] as Page | null; + this.stats = [ + { value: fireRes ? fireRes.today_records : 0, unit: '次', label: '今日打卡', color: ('app.color.accent_green') }, + { value: fireRes ? fireRes.abnormal_records : 0, unit: '次', label: '异常记录', color: ('app.color.accent_red') }, + { value: fireRes ? fireRes.active_point_count : 0, unit: '个', label: '巡更点', color: ('app.color.accent_blue') }, + ]; + this.applyLatestMessage(null, noticeRes); + } + + // 销售工作台:只取线索数与系统通知,不触碰护理数据 + private async loadSalesData(): Promise { + const responses: Array | null> = await Promise.all([ + api.get('/market/leads/', { page_size: 1 }).catch((): CountOnly | null => null), + api.get>('/auth/notifications/', { page_size: 3 }).catch((): Page | null => null), + ]); + const leadRes = responses[0] as CountOnly | null; + const noticeRes = responses[1] as Page | null; + this.stats = [ + { value: leadRes ? leadRes.count : 0, unit: '条', label: '销售线索', color: ('app.color.accent_blue') }, + { value: 0, unit: '条', label: ' ', color: ('app.color.text_secondary') }, + { value: 0, unit: '条', label: ' ', color: ('app.color.text_secondary') }, + ]; + this.applyLatestMessage(null, noticeRes); + } + + private async loadFamilyData(): Promise { + const responses: Array | null> = await Promise.all([ + api.get('/elderly/', { page_size: 1 }).catch((): CountOnly | null => null), + api.get>('/alerts/', { page_size: 1, ordering: '-created_at' }).catch((): Page | null => null), + api.get('/tasks/nursing-records/', { page_size: 1, ordering: '-created_at' }).catch((): CountOnly | null => null), + api.get>('/auth/notifications/', { page_size: 3 }).catch((): Page | null => null), + ]); + const elderlyRes = responses[0] as CountOnly | null; + const alertRes = responses[1] as Page | null; + const recordRes = responses[2] as CountOnly | null; + const noticeRes = responses[3] as Page | null; + + this.stats = [ + { value: elderlyRes ? elderlyRes.count : 0, unit: '位', label: '绑定长者', color: ('app.color.accent_blue') }, + { value: recordRes ? recordRes.count : 0, unit: '条', label: '护理记录', color: ('app.color.accent_green') }, + { value: alertRes ? alertRes.count : 0, unit: '条', label: '预警', color: ('app.color.accent_red') }, + ]; + this.applyLatestMessage(alertRes, noticeRes); + } + + private async loadAdminData(): Promise { + const responses: Array | DashboardSummary | null> = await Promise.all([ + api.get('/elderly/', { page_size: 1 }).catch((): CountOnly | null => null), + api.get>('/alerts/', { page_size: 1, ordering: '-created_at' }).catch((): Page | null => null), + api.get('/tasks/nursing-records/', { page_size: 1, date: today(), ordering: '-created_at' }).catch((): CountOnly | null => null), + api.get>('/auth/notifications/', { page_size: 3 }).catch((): Page | null => null), + api.get('/dashboard/summary/').catch((): DashboardSummary | null => null), + ]); + const elderlyRes = responses[0] as CountOnly | null; + const alertRes = responses[1] as Page | null; + const recordRes = responses[2] as CountOnly | null; + const noticeRes = responses[3] as Page | null; + // summary 偶发失败时保留旧数据,避免图表闪消失 + const summary = responses[4] as DashboardSummary | null; + if (summary !== null) { + this.adminData = summary; + } + console.info(`[Workbench] loadAdmin noticeRes=${noticeRes ? 'OK count=' + noticeRes.count : 'NULL'} alert=${alertRes ? 'OK' : 'NULL'}`); + + this.stats = [ + { value: elderlyRes ? elderlyRes.count : 0, unit: '位', label: '在院护理对象', color: ('app.color.accent_blue') }, + { value: alertRes ? alertRes.count : 0, unit: '条', label: '今日提醒', color: ('app.color.accent_red') }, + { value: recordRes ? recordRes.count : 0, unit: '项', label: '今日记录', color: '#FF9500' }, + ]; + // 订餐提醒:今日订餐人次 + this.loadMealAlert(); + // 订餐分布图表:今天无数据时回退显示明天 + this.loadMealDistribution(); + this.applyLatestMessage(alertRes, noticeRes); + } + + private async loadMealDistribution(): Promise { + const fetchOne = async (date: string): Promise => { + try { + const r = await api.get(`/meals/orders/summary/?service_date=${date}`); + return { + date: date, + breakfast: r.breakfast_count, + lunch: r.lunch_count, + dinner: r.dinner_count, + total: r.today_order_count, + }; + } catch (err) { + return null; + } + }; + const todayStats = await fetchOne(today()); + if (todayStats !== null && todayStats.total > 0) { + this.mealDist = todayStats; + return; + } + // 今天无订餐:显示明天的安排 + const tomorrowDate = tomorrowYMD(); + const tomorrowStats = await fetchOne(tomorrowDate); + if (tomorrowStats !== null && tomorrowStats.total > 0) { + this.mealDist = tomorrowStats; + return; + } + this.mealDist = todayStats; + } + + // 加载今日订餐统计,作为订餐提醒(仅当无预警时显示) + private async loadMealAlert(): Promise { + try { + const report = await api.get('/meals/reports/daily/', { service_date: today() }) + .catch((): DailyReport | null => null); + if (!report || !report.meal_groups) { + return; + } + const adminTotal = report.meal_groups.administrative?.total ?? 0; + const floorTotal = report.meal_groups.floor_elderly?.total ?? 0; + const total = adminTotal + floorTotal; + // 已有预警/通知时,订餐提醒不覆盖 + if (this.hasAlert) { + return; + } + if (total > 0) { + this.latestAlertTitle = '今日订餐提醒'; + this.latestAlertDesc = `今日行政用餐 ${adminTotal} 人次,楼层长者 ${floorTotal} 人次`; + this.latestAlertTime = '今日'; + this.hasAlert = true; + } + } catch (err) { + console.warn('[Workbench] load meal alert failed:', normalizeError(err as BusinessError | Error)); + } + } + + private applyLatestMessage(alertRes: Page | null, noticeRes: Page | null): void { + const alert = alertRes && alertRes.results.length > 0 ? alertRes.results[0] : null; + const notice = noticeRes && noticeRes.results.length > 0 ? noticeRes.results[0] : null; + // 合并预警与通知,生成最近消息列表(最多 3 条) + const list: Array = []; + if (alert) { + list.push({ + id: alert.id, + title: alert.title, + content: alert.description && alert.description.length > 0 ? alert.description : '', + created_at: alert.created_at, + source: 'alert', + }); + } + if (noticeRes) { + noticeRes.results.slice(0, 3).forEach((n: AlertItem) => { + list.push({ + id: n.id, + title: n.title, + content: n.description && n.description.length > 0 ? n.description : '', + created_at: n.created_at, + source: 'notice', + }); + }); + } + // 去重并取前 3 条 + this.recentMessages = list.slice(0, 3); + // 单独过滤订餐类通知(标题含餐品/预订/订餐) + const mealList: Array = []; + if (noticeRes) { + noticeRes.results.slice(0, 10).forEach((n: AlertItem) => { + if (this.isMealMessage(n.title)) { + mealList.push({ + id: n.id, + title: n.title, + content: n.description && n.description.length > 0 ? n.description : '', + created_at: n.created_at, + source: 'notice', + }); + } + }); + } + this.mealMessages = mealList.slice(0, 5); + if (alert) { + this.latestAlertTitle = alert.title; + this.latestAlertDesc = alert.description && alert.description.length > 0 ? alert.description : '请尽快处理对应异常情况。'; + this.latestAlertTime = formatDateTime(alert.created_at); + this.hasAlert = true; + } else if (notice) { + this.latestAlertTitle = notice.title; + this.latestAlertDesc = notice.description && notice.description.length > 0 ? notice.description : ''; + this.latestAlertTime = formatDateTime(notice.created_at); + this.hasAlert = true; + } else { + this.hasAlert = false; + this.latestAlertTitle = ''; + this.latestAlertDesc = ''; + this.latestAlertTime = ''; + } + } + + // 判断是否为订餐类消息 + private isMealMessage(title: string): boolean { + const t = title || ''; + return t.indexOf('餐品') >= 0 || t.indexOf('预订') >= 0 || t.indexOf('订餐') >= 0; + } + + private onEntryClick(entry: WorkEntry): void { + if (entry.route.length === 0) { + promptAction.showToast({ message: '功能开发中' }); + return; + } + router.pushUrl({ url: entry.route }).catch((err: BusinessError) => { + promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` }); + }); + } + + private roleSubtitle(): string { + const role = this.role; + const name = role ? (ROLE_NAME_MAP[role] ?? '员工') : '员工'; + if (role === 'nurse' || role === 'staff_nurse') { + return `${name} · 今日在岗`; + } + if (role === 'admin') { + return '管理员 · 今日值班'; + } + return `${name} · 欢迎`; + } + + @Builder + StatItem(stat: BoardStat, showDivider: boolean) { + Row() { + Column({ space: 4 }) { + Row({ space: 2 }) { + Text(String(stat.value)) + .fontSize(Math.round(26 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor(stat.color) + Text(stat.unit) + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + .margin({ top: 8 }) + } + Text(stat.label) + .fontSize(Math.round(12 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + } + .layoutWeight(1) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + + if (showDivider) { + Divider() + .vertical(true) + .height(36) + .color($r('app.color.inactive_tab')) + } + } + .layoutWeight(1) + } + + @Builder + EntryCard(entry: WorkEntry) { + Column({ space: 4 }) { + Text(entry.title) + .fontSize(Math.round(14 * this.scaleFor())) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(entry.desc) + .fontSize(Math.round(10 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('30%') + .height(58) + .margin({ bottom: 10 }) + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .backgroundColor(Color.Transparent) + .backgroundBlurStyle(BlurStyle.Thin) + .borderRadius(12) + .onClick(() => { + this.onEntryClick(entry); + }) + } + + private healthValues(): Array { + const d = this.adminData; + 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.adminData; + 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); + } + + private mealValue(category: string): number { + const d = this.adminData; + if (d !== null && d.operation_overview !== null && d.operation_overview.meals !== null) { + const found = d.operation_overview.meals.meals.find((m: MealSummary): boolean => m.category === category); + if (found !== undefined) { + return found.reserved_count; + } + } + return 0; + } + private alertCounts(): Array { + const d = this.adminData; + let emergency = 0; + let warning = 0; + let other = 0; + if (d !== null && d.unresolved_alerts !== null) { + d.unresolved_alerts.forEach((a: UnresolvedAlert) => { + if (a.level === 'emergency') { + emergency += 1; + } else if (a.level === 'warning') { + warning += 1; + } else { + other += 1; + } + }); + } + return [emergency, warning, other]; + } + + private mealLabel(category: string): string { + if (category === 'breakfast') { + return '早餐'; + } + if (category === 'lunch') { + return '午餐'; + } + return '晚餐'; + } + + @Builder + AdminChartSection() { + Column({ space: 12 }) { + // 健康分布环形 + Column({ space: 12 }) { + Row() { + Text('健康分布') + .fontSize(Math.round(16 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Blank() + Text(`${this.adminData?.health_distribution.total ?? 0} 人`) + .fontSize(Math.round(12 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + } + .width('100%') + + Stack() { + DataPanel({ + values: this.healthValues(), + max: Math.max(this.adminData?.health_distribution.total ?? 1, 1), + type: DataPanelType.Circle, + }) + .width(168) + .height(168) + .strokeWidth(24) + .valueColors(['#34C759', '#FF9500', '#FF3B30']) + Column({ space: 2 }) { + Text(String(this.adminData?.health_distribution.total ?? 0)) + .fontSize(Math.round(28 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Text('在院对象') + .fontSize(Math.round(11 * this.scaleFor())) + .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(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_primary')) + .layoutWeight(1) + Text(`${this.adminData?.health_distribution.good ?? 0} 人 · ${this.adminData?.health_distribution.good_percent ?? '0%'}`) + .fontSize(Math.round(12 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + } + .width('100%') + + Row({ space: 8 }) { + Circle().width(10).height(10).fill('#FF9500') + Text('需关注') + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_primary')) + .layoutWeight(1) + Text(`${this.adminData?.health_distribution.attention ?? 0} 人 · ${this.adminData?.health_distribution.attention_percent ?? '0%'}`) + .fontSize(Math.round(12 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + } + .width('100%') + + Row({ space: 8 }) { + Circle().width(10).height(10).fill('#FF3B30') + Text('卧床') + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_primary')) + .layoutWeight(1) + Text(`${this.adminData?.health_distribution.bedridden ?? 0} 人 · ${this.adminData?.health_distribution.bedridden_percent ?? '0%'}`) + .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(16) + .alignItems(HorizontalAlign.Start) + + // 近7日护理记录趋势 + Column({ space: 10 }) { + Row() { + Text('近7日护理记录') + .fontSize(Math.round(16 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Blank() + Text('每日条数') + .fontSize(Math.round(12 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + } + .width('100%') + + Row({ space: 8 }) { + ForEach(this.adminData?.operation_overview.care.daily_trend ?? [], (t: DailyTrend) => { + Column({ space: 4 }) { + Text(String(t.count)) + .fontSize(Math.round(9 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + Column() + .width(22) + .height(this.barHeight(t.count)) + .backgroundColor('#007AFF') + .borderRadius(4) + Text(this.shortDate(t.date)) + .fontSize(Math.round(9 * this.scaleFor())) + .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) + + // 今日订餐分布(今天无数据时显示明天的安排) + Column({ space: 12 }) { + Row() { + Text(this.mealDist !== null && this.mealDist.date !== today() + ? `订餐分布(${mdLabel(this.mealDist.date)})` + : '今日订餐分布') + .fontSize(Math.round(16 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Blank() + Text(`${this.mealDist?.total ?? 0} 份`) + .fontSize(Math.round(12 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + } + .width('100%') + + if (this.mealDist === null || (this.mealDist.breakfast + this.mealDist.lunch + this.mealDist.dinner) === 0) { + // 无订餐数据时显示空态,避免 DataPanel 全 0 画出误导性的满宽灰线 + Text('暂无订餐') + .fontSize(Math.round(14 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + .width('100%') + .textAlign(TextAlign.Center) + .padding({ top: 6, bottom: 6 }) + } else { + DataPanel({ + values: [this.mealDist.breakfast, this.mealDist.lunch, this.mealDist.dinner], + max: Math.max(this.mealDist.total, 1), + type: DataPanelType.Line, + }) + .width('100%') + .height(24) + .valueColors(['#FF9500', '#007AFF', '#34C759']) + .strokeWidth(24) + } + + Row({ space: 8 }) { + Circle().width(10).height(10).fill('#FF9500') + Text(`早餐 ${this.mealDist?.breakfast ?? 0} 份`) + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_primary')) + .layoutWeight(1) + Circle().width(10).height(10).fill('#007AFF') + Text(`午餐 ${this.mealDist?.lunch ?? 0} 份`) + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_primary')) + .layoutWeight(1) + Circle().width(10).height(10).fill('#34C759') + Text(`晚餐 ${this.mealDist?.dinner ?? 0} 份`) + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_primary')) + .layoutWeight(1) + } + .width('100%') + } + .width('100%') + .padding(16) + .backgroundColor($r('app.color.background_card')) + .borderRadius(16) + .alignItems(HorizontalAlign.Start) + + // 未处理预警等级分布 + Column({ space: 12 }) { + Row() { + Text('未处理预警等级') + .fontSize(Math.round(16 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Blank() + Text(`${this.adminData?.unresolved_alerts.length ?? 0} 条`) + .fontSize(Math.round(12 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + } + .width('100%') + + Stack() { + DataPanel({ + values: this.alertCounts(), + max: Math.max(this.adminData?.unresolved_alerts.length ?? 1, 1), + type: DataPanelType.Circle, + }) + .width(168) + .height(168) + .strokeWidth(24) + .valueColors(['#FF3B30', '#FF9500', '#8E8E93']) + Column({ space: 2 }) { + Text(String(this.adminData?.unresolved_alerts.length ?? 0)) + .fontSize(Math.round(26 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + Text('待处理') + .fontSize(Math.round(12 * this.scaleFor())) + .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('#FF3B30') + Text(`紧急 ${this.alertCounts()[0]}`) + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_primary')) + .layoutWeight(1) + Circle().width(10).height(10).fill('#FF9500') + Text(`注意 ${this.alertCounts()[1]}`) + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_primary')) + .layoutWeight(1) + Circle().width(10).height(10).fill('#8E8E93') + Text(`其他 ${this.alertCounts()[2]}`) + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_primary')) + .layoutWeight(1) + } + .width('100%') + } + .width('100%') + .padding(16) + .backgroundColor($r('app.color.background_card')) + .borderRadius(16) + .alignItems(HorizontalAlign.Start) + } + .width('92%') + } + + build() { + Refresh({ refreshing: $$this.refreshing }) { + Scroll() { + Column({ space: 16 }) { + // 问候区 + Row({ space: 12 }) { + // 头像(取姓名首字) + Stack() { + Circle() + .width(52) + .height(52) + .fill($r('app.color.accent_blue')) + Text(this.userName.length > 0 ? this.userName.substring(0, 1) : '养') + .fontSize(Math.round(20 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor('#FFFFFF') + } + .width(52) + .height(52) + + Column({ space: 4 }) { + Text(`${this.userName || '用户'},${greeting()}`) + .fontSize(Math.round(20 * this.scaleFor())) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_primary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.roleSubtitle()) + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + } + .width('100%') + .padding({ left: 20, right: 20, top: 24, bottom: 4 }) + .alignItems(VerticalAlign.Center) + + // 数据看板 + Row() { + ForEach(this.stats, (stat: BoardStat, idx: number) => { + this.StatItem(stat, idx < this.stats.length - 1) + }, (stat: BoardStat) => stat.label) + } + .width('92%') + .height(92) + .backgroundColor($r('app.color.background_card')) + .borderRadius(16) + .padding(8) + + // 管理端图表区 + if (this.role === 'admin' && this.adminData !== null) { + this.AdminChartSection() + } + + // 快捷入口宫格 + Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) { + ForEach(this.entries, (entry: WorkEntry) => { + this.EntryCard(entry) + }, (entry: WorkEntry) => entry.key) + } + .width('92%') + + // 最新提醒 + Column({ space: 8 }) { + Row() { + Text('最新提醒') + .fontSize(Math.round(16 * this.scaleFor())) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + Blank() + Text('查看全部') + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.accent_blue')) + .visibility(this.role === 'fire_control' || this.role === 'sales' ? Visibility.None : Visibility.Visible) + .onClick(() => { + router.pushUrl({ url: 'pages/nurse/Alerts' }).catch(() => {}); + }) + } + .width('100%') + + Column({ space: 12 }) { + if (this.recentMessages.length > 0) { + ForEach(this.recentMessages, (msg: NoticeMessage) => { + Column({ space: 6 }) { + Row({ space: 8 }) { + Text(msg.source === 'alert' ? '预警' : '通知') + .fontSize(Math.round(11 * this.scaleFor())) + .fontColor('#FFFFFF') + .padding({ left: 8, right: 8, top: 2, bottom: 2 }) + .backgroundColor(msg.source === 'alert' ? $r('app.color.accent_red') : $r('app.color.accent_blue')) + .borderRadius(6) + Blank() + Text(formatDateTime(msg.created_at)) + .fontSize(Math.round(12 * this.scaleFor())) + .fontColor($r('app.color.inactive_tab')) + } + .width('100%') + + Text(msg.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 (msg.content && msg.content.length > 0) { + Text(msg.content) + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + .maxLines(2) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + } + } + .width('100%') + }, (msg: NoticeMessage) => `${msg.source}-${msg.id}`) + } else { + Text(this.loading ? '加载中...' : '暂无消息通知') + .fontSize(Math.round(14 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + .width('100%') + .textAlign(TextAlign.Center) + .padding({ top: 8, bottom: 8 }) + } + } + .width('100%') + .padding(16) + .backgroundColor($r('app.color.background_card')) + .borderRadius(14) + .alignItems(HorizontalAlign.Start) + } + .width('92%') + .alignItems(HorizontalAlign.Start) + .margin({ bottom: 10 }) + + // 订餐通知 + if (this.mealMessages.length > 0) { + Column({ space: 8 }) { + Row() { + Text('订餐通知') + .fontSize(Math.round(16 * this.scaleFor())) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + Blank() + Text('查看全部') + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.accent_blue')) + .onClick(() => { + router.pushUrl({ url: 'pages/notifications/Notifications' }).catch(() => {}); + }) + } + .width('100%') + + Column({ space: 10 }) { + ForEach(this.mealMessages, (msg: NoticeMessage) => { + Row({ space: 8 }) { + Text('🍚') + .fontSize(Math.round(16 * this.scaleFor())) + Column({ space: 4 }) { + Text(msg.title) + .fontSize(Math.round(14 * this.scaleFor())) + .fontWeight(FontWeight.Medium) + .fontColor($r('app.color.text_primary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + Text(msg.content) + .fontSize(Math.round(13 * this.scaleFor())) + .fontColor($r('app.color.text_secondary')) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .width('100%') + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + Text(formatDateTime(msg.created_at)) + .fontSize(Math.round(11 * this.scaleFor())) + .fontColor($r('app.color.inactive_tab')) + } + .width('100%') + }, (msg: NoticeMessage) => `${msg.source}-${msg.id}`) + } + .width('100%') + .padding(14) + .backgroundColor($r('app.color.background_card')) + .borderRadius(14) + .alignItems(HorizontalAlign.Start) + } + .width('92%') + .alignItems(HorizontalAlign.Start) + .margin({ bottom: 120 }) + } else { + Column() { + Text('') + } + .width('92%') + .margin({ bottom: 120 }) + } + } + .width('100%') + .alignItems(HorizontalAlign.Center) + } + .scrollBar(BarState.Auto) + .edgeEffect(EdgeEffect.Spring) + .width('100%') + .height('100%') + } + .width('100%') + .height('100%') + .onRefreshing(() => { + this.refresh(); + }) + } + + // 下拉刷新 + private refresh(): void { + console.info('[Workbench] pull-to-refresh triggered'); + this.refreshing = true; + this.loadData().then(() => { + console.info('[Workbench] refresh done'); + this.refreshing = false; + }).catch(() => { + this.refreshing = false; + }); + } +} diff --git a/entry/src/main/ets/utils/A2AServer.ts b/entry/src/main/ets/utils/A2AServer.ts new file mode 100644 index 0000000..26df797 --- /dev/null +++ b/entry/src/main/ets/utils/A2AServer.ts @@ -0,0 +1,900 @@ +// A2A 意图分发:智能体(AgentExtensionAbility)收到小艺转发的用户请求后,在此执行业务闭环 +// 链路:小艺 → AgentExtensionAbility.onData → dispatchIntent → 后端接口/本地通知 → 回传结果 +import { api, RequestError } from './Request'; +import { A2A_TOKEN_FILE } from './AuthStore'; +import { publishLocalNotify } from './LocalNotify'; +import { fetchWeather, weatherCodeLabel } from './OpenMeteo'; +import { fileIo } from '@kit.CoreFileKit'; +import { commonEventManager } from '@kit.BasicServicesKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { wantAgent, Want } from '@kit.AbilityKit'; +import { BUNDLE_NAME, REPORT_CONTEXT_FILE, REPORT_SUMMARY_API } from '../common/ApiConstants'; +import { parseIntentCj, splitSegmentsCj, inferSlotCj, IntentResult } from './IntentEngine'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +const ENTRY_ABILITY = 'EntryAbility'; +const DOMAIN = 0x0000; + +// 跳转处理:由 AgentExtAbility 注册——回 TASK_STATE_WORKING 帧(data.jump.deepLink),由小艺执行跳转 +export type JumpHandler = (targetPage: string, processText?: string) => Promise; +let jumpHandler: JumpHandler | null = null; + +export function registerJumpHandler(fn: JumpHandler): void { + jumpHandler = fn; +} + +// 触发原生操控:回 WORKING + deepLink 帧,由小艺执行跳转(实验:平台目录路由下重测) +export async function fireJump(targetPage: string, processText?: string): Promise { + if (jumpHandler === null) { + return false; + } + try { + return await jumpHandler(targetPage, processText); + } catch (err) { + console.error('[A2AServer] fireJump failed:', (err as Error).message); + return false; + } +} + +let lastMessageId: string = ''; +let lastText: string = ''; +let lastTextAt: number = 0; +let lastResult: string = ''; + +// 随回复下发的推荐操作(Part 结构定义:suggestions[].jump 为用户点击跳转,不受自动操控执行权限限制) +interface AgentSuggestion { + text: string; + reply?: string; + jump?: { + overlayMode?: string; + deepLink: { url: string; appName?: string; appPackage?: string }; + }; +} +let pendingSuggestions: Array = []; + +export function setSuggestions(items: Array): void { + pendingSuggestions = items; +} + +export function takeSuggestions(): Array { + const s = pendingSuggestions; + pendingSuggestions = []; + return s; +} + +// 多轮追问:按 contextId 记录待补全餐次的意图(协议级会话,见端 A2A 规范 Context 机制) +interface PendingMeal { + action: 'order' | 'cancel'; + date: string; + slot: string; +} +const pendingIntent: Map = new Map(); + +// 订餐/取消的统一执行体:action 显式传入,多轮补全后不会误判方向 +async function executeMealIntent(action: 'order' | 'cancel', intent: IntentResult, text: string): Promise { + const slot = intent.slot; + const token = readTokenFile(); + if (token.length === 0) { + return '您还未登录,请先在应用中登录。'; + } + const wantCancel = action === 'cancel'; + // 取消:提到"明天"直接取消明天的;否则先试今天、失败自动试明天 + if (wantCancel) { + const wantTomorrow = intent.date === 'tomorrow' + || text.indexOf('明天') >= 0 || text.indexOf('明日') >= 0; + if (wantTomorrow) { + await api.post(`/elderly-portal/meals/${slot}/reservation/`, { ordered: false, service_date: tomorrowYMD() }); + publishLocalNotify(Date.now() % 100000, '订餐已取消', `已取消明天${slotName(slot)}的预订`); + return `已取消明天${slotName(slot)}的预订。`; + } + try { + await api.post(`/elderly-portal/meals/${slot}/reservation/`, { ordered: false, service_date: todayYMD() }); + publishLocalNotify(Date.now() % 100000, '订餐已取消', `已取消今日${slotName(slot)}的预订`); + return `已取消今日${slotName(slot)}的预订。`; + } catch (err) { + await api.post(`/elderly-portal/meals/${slot}/reservation/`, { ordered: false, service_date: tomorrowYMD() }); + publishLocalNotify(Date.now() % 100000, '订餐已取消', `今日无${slotName(slot)}预订,已取消明天${slotName(slot)}的预订`); + return `已取消明天${slotName(slot)}的预订。`; + } + } + // 防重复下单:今天该餐次已订则直接告知,不重复提交 + if (intent.date !== 'tomorrow') { + try { + const d = await api.get('/elderly-portal/dashboard/'); + const meals = d.meals ?? []; + const key = slot === 'breakfast' ? '早' : (slot === 'dinner' ? '晚' : '午'); + const row = meals.find((m: MealSlotInfo): boolean => (m.name ?? '').indexOf(key) >= 0); + if (row !== undefined && row.ordered === true) { + publishLocalNotify(Date.now() % 100000, '订餐提醒', `您今天${slotName(slot)}已经订过了,不用重复订。`); + return `您今天${slotName(slot)}已经订过了,不用重复订。想换可以说"取消${slotName(slot)}"再重新订。`; + } + } catch (err) { + console.error('[A2AServer] pre-order check failed:', (err as Error).message); + } + } + // 当餐次结束供应时自动顺延到明天预订 + try { + await api.post(`/elderly-portal/meals/${slot}/reservation/`, { ordered: true, service_date: todayYMD() }); + publishLocalNotify(Date.now() % 100000, '订餐成功', `已为您预订今日${slotName(slot)},可在订餐页查看。`); + return `已为您预订今日${slotName(slot)},可在订餐页查看。`; + } catch (err) { + const msg = (err as Error).message ?? ''; + console.error('[A2AServer] order today failed:', msg); + if (msg.indexOf('结束供应') < 0 && msg.indexOf('不可预订') < 0 && msg.indexOf('已截止') < 0) { + throw err; + } + await api.post(`/elderly-portal/meals/${slot}/reservation/`, { ordered: true, service_date: tomorrowYMD() }); + publishLocalNotify(Date.now() % 100000, '订餐成功', `今日${slotName(slot)}已截止,已为您预订明天${slotName(slot)}。`); + return `今天${slotName(slot)}已结束供应,已为您预订明天${slotName(slot)},可在订餐页查看。`; + } +} + +interface ElderlyRow { + id: number; + name?: string; +} + +interface ElderlyListResp { + results?: Array; +} + +interface ActivityRow { + activity_name: string; + activity_date: string; + start_time: string; + location?: string; +} + +interface ActivityWeekResp { + items?: Array; +} + +interface MealSlotInfo { + id?: string; + name?: string; + time?: string; + menu?: string; + ordered?: boolean; + reservable?: boolean; + reservation_hint?: string; +} + +interface MealDashboardResp { + elderly_name?: string; + service_date?: string; + meals?: Array; +} + +interface AssistantReplyResp { + reply?: string; +} + +interface ReportRow { + title: string; + summary: string; + llm_analysis: string; +} + +interface ReportListResp { + results?: Array; +} + +// 健康风险报告 AI 总结接口响应(字段兼容后端可能的多种命名) +interface ReportSummaryResp { + summary?: string; + conclusion?: string; + reply?: string; +} + +// 用 wantAgent 拉起主应用并携带目标页:应用未运行时冷启动,运行中触发 onNewWant 跳页 +async function launchToPage(targetPage: string): Promise { + const wantObj: Want = { + bundleName: BUNDLE_NAME, + abilityName: ENTRY_ABILITY, + parameters: { targetPage: targetPage }, + }; + const request: wantAgent.WantAgentInfo = { + wants: [wantObj], + operationType: wantAgent.OperationType.START_ABILITY, + requestCode: 0, + }; + const agent = await wantAgent.getWantAgent(request); + await wantAgent.trigger(agent, { code: 0 }); +} + +function jumpReplyText(page: string): string { + if (page === 'pages/elderly/ElderlyWeather') { + return '已为您打开天气页面。'; + } + if (page === 'pages/elderly/Activities') { + return '已为您打开活动页面。'; + } + if (page === 'pages/elderly/ElderlyHome') { + return '已为您打开应用。'; + } + return '已为您打开订餐页面。'; +} + +// 跳转类意图:看天气/活动/订餐页 +function detectJump(text: string): string { + if (text.indexOf('天气') >= 0 || text.indexOf('气温') >= 0 || text.indexOf('下雨') >= 0) { + return 'pages/elderly/ElderlyWeather'; + } + if (text.indexOf('活动') >= 0 || text.indexOf('八段锦') >= 0 || text.indexOf('健身') >= 0) { + return 'pages/elderly/Activities'; + } + if (text.indexOf('订餐') >= 0 && text.indexOf('看') >= 0 || text.indexOf('菜单') >= 0) { + return 'pages/elderly/ElderlyMealOrder'; + } + return ''; +} + +// 扩展进程绕开 context 限制:直接读主进程登录时写入的 token 文件(同应用沙箱路径固定) +function readTokenFile(): string { + try { + return fileIo.readTextSync(A2A_TOKEN_FILE).trim(); + } catch (err) { + return ''; + } +} + +// 家属端健康风险报告:报告页(主进程)把 PDF 正文写入同沙箱固定路径,此处直读 +function readReportContextFile(): string { + try { + if (!fileIo.accessSync(REPORT_CONTEXT_FILE)) { + return ''; + } + return fileIo.readTextSync(REPORT_CONTEXT_FILE).trim(); + } catch (err) { + return ''; + } +} + +// 报告正文交给后端 AI 总结:优先专用接口,未就绪时退回家属/老人助手接口(都把正文拼进消息) +async function summarizeReportContext(content: string): Promise { + const body = content.length > 1500 ? content.substring(0, 1500) : content; + const prompt = `请用三句到五句话总结这份健康报告,指出需要关注的项目并给出建议:\n${body}`; + // 1. 专用总结接口(后端就绪后生效) + try { + const res = await api.post(REPORT_SUMMARY_API, { report_text: body }, { timeout: 8000 }); + const text = (res.summary ?? res.conclusion ?? res.reply ?? '').trim(); + if (text.length > 0) { + return sanitizeDsml(text); + } + } catch (err) { + console.warn('[A2AServer] report summary api unavailable:', (err as Error).message); + } + // 2. 家属智能助手(家属账号权限:AGENT_ID_FAMILY_WEIXIN_ASSISTANT) + try { + const res = await api.post('/robots/family-assistant/query/', { message: prompt }, { timeout: 8000 }); + const reply = (res.reply ?? '').trim(); + if (reply.length > 0) { + return sanitizeDsml(reply); + } + } catch (err) { + console.warn('[A2AServer] family assistant summary failed:', (err as Error).message); + } + // 3. 老人端助手(老人账号登录时) + try { + const res = await api.post('/elderly-portal/assistant/reply/', { message: prompt }); + const reply = (res.reply ?? '').trim(); + if (reply.length > 0) { + return sanitizeDsml(reply); + } + } catch (err) { + console.warn('[A2AServer] elderly assistant summary failed:', (err as Error).message); + } + return ''; +} + +// 后端 AI 可能漏出 DSML 工具调用标记(<||DSML|| ...),截断净化 +function sanitizeDsml(text: string): string { + const dsml = text.indexOf('<|'); + return dsml >= 0 ? text.substring(0, dsml).trim() : text; +} + +function todayYMD(): 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 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}`; +} + +function detectSlot(text: string): string { + if (text.indexOf('早') >= 0) { + return 'breakfast'; + } + if (text.indexOf('晚') >= 0) { + return 'dinner'; + } + return 'lunch'; +} + +function slotName(slot: string): string { + if (slot === 'breakfast') { + return '早餐'; + } + if (slot === 'dinner') { + return '晚餐'; + } + return '午餐'; +} + +// 菜单查询本地实现:与主页同规则——今天三餐都已过(不可预订也未预订)时自动播报明天的 +// 本周活动查询:GET /activities/weeks//,取最近 4 条播报 +function activitiesWeekStart(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}`; +} + +interface ActivitiesItem { + activity_name: string; + activity_date: string; + start_time: string; + location: string; +} + +interface ActivitiesWeek { + items: Array; +} + +async function queryActivitiesLocal(): Promise { + const thisWeek = activitiesWeekStart(new Date()); + const nextDate = new Date(thisWeek + 'T00:00:00'); + nextDate.setDate(nextDate.getDate() + 7); + const nextWeek = activitiesWeekStart(nextDate); + let items: Array = []; + let label = '本周'; + try { + const res = await api.get(`/activities/weeks/${thisWeek}/`); + items = (res.items ?? []).slice(0, 4); + } catch (err) { + console.warn('[A2AServer] this-week activities failed, try next week:', (err as Error).message); + } + if (items.length === 0) { + const res = await api.get(`/activities/weeks/${nextWeek}/`); + items = (res.items ?? []).slice(0, 4); + label = '下周'; + } + if (items.length === 0) { + return '近两周暂无活动安排,您可以留意公告栏。'; + } + const parts = items.map((item: ActivitiesItem): string => { + const day = item.activity_date.slice(5, 10); + const time = (item.start_time ?? '').slice(0, 5); + const loc = (item.location ?? '').trim(); + const base = (time.length === 5 && time !== '00:00') ? `${day} ${time} ${item.activity_name}` : `${day} ${item.activity_name}`; + return loc.length > 0 ? `${base}(${loc})` : base; + }); + const lead = label === '本周' + ? `本周有${items.length}场活动` + : `最近的活动安排在下周,共${items.length}场`; + return `${lead}:${parts.join(';')}。需要参加哪场,可以告诉我或看活动页。`; +} + +async function queryMealMenuLocal(): Promise { + const d = await api.get('/elderly-portal/dashboard/'); + let meals = d.meals ?? []; + const date = typeof d.service_date === 'string' ? d.service_date : todayYMD(); + // 与主页 loadMeals 相同的"已过"判定:reservable 与 ordered 均为 false 才算过(已预订的餐次不算过) + const allPassed = meals.length > 0 + && meals.every((m: MealSlotInfo): boolean => m.reservable !== true && m.ordered !== true) + && date === todayYMD(); + let when = '今天'; + if (allPassed) { + const tomorrow = tomorrowYMD(); + const slotKeys: Array = ['breakfast', 'lunch', 'dinner']; + const nextMeals: Array = []; + for (const key of slotKeys) { + try { + const slot = await api.get(`/elderly-portal/meals/${key}/?service_date=${tomorrow}`); + nextMeals.push(slot); + } catch (err) { + console.error(`[A2AServer] menu tomorrow ${key} failed:`, (err as Error).message); + } + } + if (nextMeals.length > 0) { + meals = nextMeals; + when = '明天'; + } + } + const parts: Array = []; + for (const m of meals) { + const name = (m.name ?? '').trim(); + const menu = (m.menu ?? '').trim(); + if (name.length === 0 && menu.length === 0) { + continue; + } + parts.push(menu.length > 0 ? `${name}是${menu}` : name); + } + if (parts.length === 0) { + return '菜单还没出来,您可以在订餐页留意一下。'; + } + return `${when}供应${parts.join(';')}。想订哪一餐跟我说一声就行。`; +} + +// 订餐状态本地兜底:AI 助手不可用时直接读 dashboard 组装自然话术 +async function queryMealStatusLocal(): Promise { + const d = await api.get('/elderly-portal/dashboard/'); + const meals = d.meals ?? []; + const name = d.elderly_name ?? ''; + const call = name.length > 0 ? `${name},` : ''; + if (meals.length === 0) { + return `${call}今天暂时查不到餐次安排,请您稍后再问问我。`; + } + const ordered = meals.filter((m: MealSlotInfo): boolean => m.ordered === true); + const detail = meals.map((m: MealSlotInfo): string => { + const mealName = m.name ?? ''; + const menu = (m.menu !== undefined && m.menu.length > 0 && m.menu !== '今日菜单待更新') ? m.menu : '菜单待更新'; + return `${m.ordered === true ? '已订' : '未订'}${mealName}(${menu})`; + }); + if (ordered.length === 0) { + return `${call}您今天还没有预订餐次。${detail.join(',')}。想订哪餐直接说"我要订午餐"就行。`; + } + const orderedNames = ordered.map((m: MealSlotInfo): string => m.name ?? '').join('、'); + return `${call}您今天订了${orderedNames}。${detail.join(',')}。`; +} + +// 意图分发:订餐/取消 → 现有接口(带本地通知);天气/活动 → 跳转页面;求助/报修 → 本地通知 +async function dispatchIntentCore(input: string, messageId?: string, contextId?: string): Promise { + const text = input.trim(); + if (text.length === 0) { + return '请说出您的需求,例如:我要订午餐。'; + } + // 多轮追问:同 contextId 会话内有待补全意图时,本轮只当"槽位补答"处理 + if (typeof contextId === 'string' && contextId.length > 0 && pendingIntent.has(contextId)) { + const pending = pendingIntent.get(contextId)!; + const seg = text.indexOf('早') >= 0 ? 'breakfast' + : (text.indexOf('晚') >= 0 ? 'dinner' + : (text.indexOf('午') >= 0 ? 'lunch' : '')); + if (seg.length > 0) { + pendingIntent.delete(contextId); + pending.slot = seg; + const intent: IntentResult = { action: pending.action, slot: seg, date: pending.date, jump: '' }; + return executeMealIntent(pending.action, intent, text); + } + pendingIntent.delete(contextId); + return '没听清是哪一餐,您可以说"订午餐""取消晚餐"这样带餐次的指令。'; + } + // 去重 1:小艺可能对同一消息重发(messageId 相同)——重放上次结果,避免空回复 + if (typeof messageId === 'string' && messageId.length > 0) { + if (messageId === lastMessageId) { + console.error('[A2AServer] dedup hit: same messageId', messageId); + return lastResult; + } + lastMessageId = messageId; + } + // 去重 2:内容+时间窗。小艺重发时 messageId 会变,同句 15 秒内只执行一次, + // 防止"同一句话回两遍"刷屏;命中时重放上次结果而非空串 + const now = Date.now(); + if (text === lastText && now - lastTextAt < 15000) { + console.error('[A2AServer] dedup hit: same text within 15s gap =', now - lastTextAt); + return lastResult; + } + lastText = text; + lastTextAt = now; + // 问候语:友好应答并介绍能力 + if (text.indexOf('你好') >= 0 || text.indexOf('您好') >= 0 || text === '在吗' || text.indexOf('早上好') >= 0 + || text.indexOf('下午好') >= 0 || text.indexOf('晚上好') >= 0 || text.indexOf('嗨') >= 0 || text.indexOf('hi') >= 0) { + const hour = new Date().getHours(); + let greeting = '您好'; + if (hour < 11) { + greeting = '早上好'; + } else if (hour < 14) { + greeting = '中午好'; + } else if (hour < 19) { + greeting = '下午好'; + } else { + greeting = '晚上好'; + } + return `${greeting}!我是数智护老助手,能帮您订餐、取消订餐、查订餐状态、查活动和天气,也乐意陪您聊聊天。您可以说"我要订午餐"试试。`; + } + // 多意图编排:仓颉引擎拆句,逐段独立执行,播报按序合并 + // 门控:全部片段都是可办事意图(订餐/取消/查询/跳转/求助/报告)才拆分执行; + // 只要有一段是闲聊兜底(fallback),整句按单意图走,避免把寒暄剁碎刷屏 + const segs = splitSegmentsCj(text); + if (segs !== null) { + const allActionable = segs.every((seg: string): boolean => { + const it = parseIntentCj(seg); + return it !== null && it.action !== 'fallback'; + }); + if (allActionable) { + const replies: Array = []; + for (const seg of segs) { + const r = await dispatchIntent(seg, undefined, contextId); + if (r.length > 0) { + replies.push(r); + } + } + return replies.join(' '); + } + } + // 仓颉意图引擎:文本 → 结构化意图(cjagent.so),失败退回 ArkTS 关键词匹配 + const intent: IntentResult | null = parseIntentCj(text); + // 时间/日期问答:本地播报,零请求(参与多意图编排时可与天气等连答) + if (intent !== null && intent.action === 'time') { + const d = new Date(); + const week = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()]; + const m = d.getMinutes(); + return `今天是${d.getMonth() + 1}月${d.getDate()}日星期${week},现在${d.getHours()}点${m < 10 ? '零' : ''}${m}分。`; + } + // 跳转类:看天气/活动/菜单 → 打开对应页面 + const semanticAction = intent !== null && (intent.action === 'activities' || intent.action === 'menu'); + const jumpPage = (intent !== null && intent.action === 'jump' && intent.jump.length > 0) + ? intent.jump + : (semanticAction ? '' : detectJump(text)); + if (jumpPage === 'pages/elderly/ElderlyWeather') { + // 官方《原生操控》场景一标准流程:先发 WORKING 跳转帧(小艺执行跳转变胶囊态),再回 COMPLETED 正文 + await fireJump(jumpPage, '正在打开天气页面'); + // 双保险:回复带可点击的"打开天气页面"推荐操作(用户点击跳转,开发中态也可用) + setSuggestions([{ + text: '打开天气页面', + jump: { + overlayMode: 'none', + deepLink: { url: 'smartsenior://weather', appName: '数智护老', appPackage: BUNDLE_NAME }, + }, + }]); + try { + const w = await fetchWeather(); + const today = w.daily.length > 0 ? w.daily[0] : null; + const segs: Array = [`今天广州${weatherCodeLabel(w.current.code)},当前气温${Math.round(w.current.temperature)}度`]; + if (today !== null) { + segs.push(`今天${Math.round(today.minT)}到${Math.round(today.maxT)}度`, `降水概率${today.rainPct}%`); + if (today.rainPct >= 60) { + segs.push('出门记得带伞'); + } + } + segs.push('想看七天详情,请打开应用的天气页面'); + return segs.join(',') + '。'; + } catch (err) { + console.error('[A2AServer] weather failed:', (err as Error).message); + return '天气获取失败,请打开应用的天气页面查看。'; + } + } + if (jumpPage === 'pages/elderly/Activities') { + // 活动直接播报真实数据,不跳页面 + try { + const d = new Date(); + const day = d.getDay() === 0 ? 7 : d.getDay(); + d.setDate(d.getDate() - day + 1); + const week = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + const res = await api.get(`/activities/weeks/${week}/`); + const items: Array = res.items ?? []; + const todayYMDstr = todayYMD(); + const todays = items.filter((it: ActivityRow): boolean => it.activity_date === todayYMDstr); + const chosen = todays.length > 0 ? todays : items; + if (chosen.length === 0) { + return '本周暂无活动安排,下周的活动可以直接问我,我来播报。'; + } + const prefix = todays.length > 0 ? `今天有${todays.length}场活动` : '本周近期的活动有'; + const lines: Array = chosen.slice(0, 3).map((it: ActivityRow): string => { + const t = it.start_time.length >= 5 ? it.start_time.substring(0, 5) : it.start_time; + if (it.location !== undefined && it.location.length > 0) { + return `${t}的${it.activity_name}(${it.location})`; + } + return `${t}的${it.activity_name}`; + }); + return `${prefix}:${lines.join(';')}。想看全部活动请打开应用的活动页面。`; + } catch (err) { + console.error('[A2AServer] activities failed:', (err as Error).message); + return '活动查询失败,请打开应用的活动页面查看。'; + } + } + if (jumpPage.length > 0) { + // 优先走原生操控协议:小艺收到 data.jump 帧后自行拉起应用(应用不在前台也可) + if (jumpHandler !== null) { + try { + const ok = await jumpHandler(jumpPage); + if (ok) { + return jumpReplyText(jumpPage); + } + } catch (err) { + console.error('[A2AServer] jump handler failed:', (err as Error).message); + } + } + // 兜底:wantAgent 自行拉起(扩展进程可能被后台启动限制拦截) + try { + await launchToPage(jumpPage); + } catch (err) { + console.error('[A2AServer] launch app failed:', (err as Error).message); + return '打开页面失败,请手动进入应用查看。'; + } + return jumpReplyText(jumpPage); + } + // 打开应用登录:未登录用户点引导语 + if (text.indexOf('登录') >= 0 || text.indexOf('登陆') >= 0) { + if (jumpHandler !== null) { + try { + const ok = await jumpHandler('pages/elderly/ElderlyHome'); + if (ok) { + return '已为您打开应用,请输入账号密码登录。'; + } + } catch (err) { + console.error('[A2AServer] jump handler failed:', (err as Error).message); + } + } + try { + await launchToPage('pages/elderly/ElderlyHome'); + } catch (err) { + console.error('[A2AServer] launch app failed:', (err as Error).message); + return '打开应用失败,请手动点击桌面图标进入。'; + } + return '已为您打开应用,请输入账号密码登录。'; + } + const wantCancel = text.indexOf('取消') >= 0 || text.indexOf('退订') >= 0 || text.indexOf('不订') >= 0 + || text.indexOf('不要') >= 0 || text.indexOf('不吃了') >= 0 + || (intent !== null && intent.action === 'cancel'); + // 菜单查询:今天有什么餐(仓颉 menu 意图 + 关键词兜底;"看菜单"由 jump 分支处理) + const menuByCj = intent !== null && intent.action === 'menu'; + const menuWord = text.indexOf('看') < 0 && text.indexOf('订') < 0 + && (text.indexOf('有什么餐') >= 0 || text.indexOf('有什么菜') >= 0 || text.indexOf('有什么吃的') >= 0 + || text.indexOf('吃什么') >= 0 || text.indexOf('有哪些餐') >= 0 || text.indexOf('菜单') >= 0); + if (menuByCj || menuWord) { + const token = readTokenFile(); + if (token.length === 0) { + return '您还未登录,请先在应用中登录。'; + } + try { + return await queryMealMenuLocal(); + } catch (err) { + console.error('[A2AServer] menu query failed:', (err as Error).message); + return '菜单暂时取不到,您可以在订餐页查看,或稍后再问问我。'; + } + } + // 活动查询:仓颉 activities 意图(今天有什么活动/活动安排) + const activitiesByCj = intent !== null && intent.action === 'activities'; + if (activitiesByCj) { + const token = readTokenFile(); + if (token.length === 0) { + return '您还未登录,请先在应用中登录。'; + } + try { + return await queryActivitiesLocal(); + } catch (err) { + console.error('[A2AServer] activities query failed:', (err as Error).message); + return '活动安排暂时取不到,您可以在活动页查看,或稍后再问问我。'; + } + } + // 订餐/预订状态查询:优先交给后端 AI 助手(带工具和记忆,回答自然),失败再本地查数据 + const statusByCj = intent !== null && intent.action === 'status'; + const mealWord = text.indexOf('餐') >= 0 || text.indexOf('饭') >= 0 || text.indexOf('订') >= 0 || text.indexOf('吃') >= 0; + const statusWord = text.indexOf('状态') >= 0 || text.indexOf('订了') >= 0 || text.indexOf('订没订') >= 0 + || text.indexOf('订的') >= 0 || text.indexOf('订好') >= 0 || text.indexOf('记录') >= 0 || text.indexOf('查询') >= 0 + || text.indexOf('查查') >= 0 || text.indexOf('查一下') >= 0 || text.indexOf('哪些') >= 0 || text.indexOf('吃什么') >= 0 + || (text.indexOf('看看') >= 0 && text.indexOf('订') >= 0); + if ((mealWord && statusWord && !wantCancel) || statusByCj) { + const token = readTokenFile(); + if (token.length === 0) { + return '您还未登录,请先在应用中登录。'; + } + try { + const res = await api.post('/elderly-portal/assistant/reply/', { message: text }); + let replyText = (res.reply ?? '').trim(); + // 后端 AI 可能漏出 DSML 工具调用标记(<||DSML|| ...),截断净化 + const dsml = replyText.indexOf('<|'); + if (dsml >= 0) { + console.warn('[A2AServer] assistant reply contains DSML, truncated'); + replyText = replyText.substring(0, dsml).trim(); + } + if (replyText.length > 0) { + return replyText; + } + // DSML 截断后为空:落到底部本地查询兜底 + } catch (err) { + console.error('[A2AServer] status ai failed:', (err as Error).message); + } + try { + return await queryMealStatusLocal(); + } catch (err) { + console.error('[A2AServer] status query failed:', (err as Error).message); + return '订餐状态暂时查不到,您可以在应用的订餐页查看,或稍后再问问我。'; + } + } + // 订餐/取消:仓颉意图驱动;缺餐次时按 contextId 发起多轮追问 + if (intent !== null && (intent.action === 'order' || intent.action === 'cancel')) { + if (intent.slot.length === 0) { + if (typeof contextId === 'string' && contextId.length > 0) { + pendingIntent.set(contextId, { action: intent.action as 'order' | 'cancel', date: intent.date, slot: '' }); + return intent.action === 'cancel' ? '要取消哪一餐?早、午还是晚?' : '要订哪一餐?早、午还是晚?'; + } + intent.slot = 'lunch'; + } + return executeMealIntent(intent.action as 'order' | 'cancel', intent, text); + } + // 仓颉引擎不可用时的关键词兜底(保持原有行为) + if (intent === null && (wantCancel || ((text.indexOf('订') >= 0 || text.indexOf('饭') >= 0) + && (text.indexOf('餐') >= 0 || text.indexOf('饭') >= 0 || wantCancel)))) { + const legacyIntent: IntentResult = { + action: wantCancel ? 'cancel' : 'order', + slot: detectSlot(text), + date: (text.indexOf('明天') >= 0 || text.indexOf('明日') >= 0) ? 'tomorrow' : 'today', + jump: '', + }; + return executeMealIntent(legacyIntent.action as 'order' | 'cancel', legacyIntent, text); + } + // 回家:跨业务编排——停今日餐 + 拉起打车(一个意图,两个业务域) +if (intent !== null && intent.action === 'gohome') { + let mealMsg = ''; + try { + mealMsg = await cancelAllUpcomingMeals(); + } catch (err) { + console.error('[A2AServer] gohome cancel failed:', (err as Error).message); + mealMsg = '餐食未能自动取消,请告知护理站'; + } + let rideMsg = '打车页未能拉起,可从首页快捷打车'; + try { + // 跨进程公共事件:主应用(老人首页)订阅后立即拉起 Petal 打车 + commonEventManager.publish('smartsenior_gohome_ride', (publishErr: BusinessError): void => { + if (publishErr) { + console.error('[A2AServer] gohome event publish failed:', publishErr.message); + } + }); + rideMsg = '已为您叫车,请在地图上确认行程'; + } catch (err) { + console.error('[A2AServer] gohome ride failed:', (err as Error).message); + } + publishLocalNotify(Date.now() % 100000, '回家模式', `${mealMsg} · 已叫车`, 'smartsenior://gohome'); + return `好的,马上安排您回家。${mealMsg},${rideMsg}。`; +} +// 健康干预 冰品:健康档案标记血糖偏高,拒糖降冰并给替代建议(适老安全设计) +if (intent !== null && intent.action === 'icecream') { + return '爷爷,您上次体检血糖偏高,雪糕又冰又甜,容易引起血糖波动和肠胃不适。' + + '我让护理站给您备一份常温无糖酸奶,口感一样好,等下就送来,好吗?'; +} +if ((intent !== null && intent.action === 'help') || text.indexOf('护士') >= 0 || text.indexOf('护理员') >= 0 + || text.indexOf('救命') >= 0 || text.indexOf('紧急') >= 0 || text.indexOf('不舒服') >= 0 + || text.indexOf('摔倒') >= 0 || text.indexOf('疼') >= 0 || text.indexOf('头晕') >= 0) { + const token = readTokenFile(); + if (token.length === 0) { + return '您还未登录,请先在应用中登录。紧急情况请直接拨打护理员电话。'; + } + // 真实呼叫:创建紧急预警,护理端预警列表 + 实时推送可见 + try { + const list = await api.get>('/elderly/'); + const rows: Array = Array.isArray(list) ? list : (list.results ?? []); + if (rows.length === 0) { + publishLocalNotify(Date.now() % 100000, '求助未发送', '未找到您的老人档案,请直接拨打护理员电话。'); + return '未找到您的老人档案,请直接拨打护理员电话。'; + } + await api.post('/alerts/', { + elderly: rows[0].id, + level: 'emergency', + title: '紧急呼叫护理员', + description: `老人通过智能体呼叫:${text}`, + }); + publishLocalNotify(Date.now() % 100000, '求助已转发', '已通知护理站,请保持电话畅通,护理员将尽快到场。'); + return '已通知护理站,护理员将尽快赶到您的房间。如有紧急情况请直接拨打护理员电话。'; + } catch (err) { + console.error('[A2AServer] alert create failed:', (err as Error).message); + publishLocalNotify(Date.now() % 100000, '请拨打护理员电话', '系统暂不支持自动呼叫,紧急情况请直接拨打护理员电话。'); + return '系统暂时无法自动呼叫护理站,请直接拨打护理员电话,或按床头呼叫铃。'; + } +} +// 家属端健康风险报告:报告页已把 PDF 正文缓存到沙箱,直接交给后端 AI 总结 +// (放在云端报告分支之前:命中缓存就不再走"最新报告摘要"那条老路径) +if (text.indexOf('报告') >= 0 && (text.indexOf('总结') >= 0 || text.indexOf('解读') >= 0 || text.indexOf('分析') >= 0)) { + const cached = readReportContextFile(); + if (cached.length > 0) { + const token = readTokenFile(); + if (token.length === 0) { + return '您还未登录,请先在应用中登录后再查看健康报告。'; + } + const summarized = await summarizeReportContext(cached); + if (summarized.length > 0) { + return summarized; + } + console.warn('[A2AServer] report summary unavailable, fallback to cloud report'); + } +} +// AI 健康报告:仓颉引擎识别后播报最新报告摘要 +if (intent !== null && intent.action === 'report') { + const token = readTokenFile(); + if (token.length === 0) { + return '您还未登录,请先在应用中登录。'; + } + try { + const rows = await api.get>('/elderly/'); + const list: Array = Array.isArray(rows) ? rows : (rows.results ?? []); + if (list.length === 0) { + return '未找到您的老人档案,无法查询健康报告。'; + } + const rep = await api.get(`/reports/?elderly=${list[0].id}`); + const items: Array = Array.isArray(rep) ? rep : (rep.results ?? []); + if (items.length === 0) { + return '最近还没有生成健康报告,机构会定期为您生成,请留意通知。'; + } + const r = items[0]; + const ai = r.llm_analysis.length > 0 ? `AI 分析:${r.llm_analysis}` : r.summary; + return `${r.title}。${ai}`; + } catch (err) { + console.error('[A2AServer] report query failed:', (err as Error).message); + return '健康报告暂时取不到,请打开应用在健康报告页查看。'; + } +} +// 兜底:透传给后端 AI 助手(通义千问,带记忆/天气/订餐能力) +try { + const token = readTokenFile(); + if (token.length === 0) { + return `好嘞,我记下您说的"${text}"了。不过得先在应用里登录,我才能真正帮上忙。您也可以说"订餐""看天气""今日活动"或"叫护士"试试。`; + } + const res = await api.post('/elderly-portal/assistant/reply/', { message: text }); + if (res.reply !== undefined && res.reply.length > 0) { + return res.reply; + } + return `您的心意我收到啦。想让我动手办事的话,可以说"订餐""看天气""今日活动"或"叫护士"试试。`; +} catch (err) { + console.error('[A2AServer] ai fallback failed:', (err as Error).message); + return `您的心意我收到啦。想让我动手办事的话,可以说"订餐""看天气""今日活动"或"叫护士"试试。`; +} +} + +// 导出包装:记录最近一次结果,dedup 命中时重放而非空回复 +export async function dispatchIntent(input: string, messageId?: string, contextId?: string): Promise { + const r = await dispatchIntentCore(input, messageId, contextId); + if (r.length > 0) { + lastResult = r; + } + return r; +} + +// 供意图框架调用的取消订餐入口:slot 缺省时按当前时间推餐次 +export async function cancelMealByIntent(slot: string, wantTomorrow: boolean): Promise { + let safeSlot = (slot === 'breakfast' || slot === 'lunch' || slot === 'dinner') ? slot : ''; + if (safeSlot.length === 0) { + safeSlot = inferSlotCj(new Date().getHours()); + hilog.info(DOMAIN, 'OrderMealIntent', 'cancel slot missing, inferred by cangjie engine -> %{public}s', safeSlot); + } + const intent: IntentResult = { action: 'cancel', slot: safeSlot, date: wantTomorrow ? 'tomorrow' : '', jump: '' }; + return executeMealIntent('cancel', intent, wantTomorrow ? '取消明天的' : '取消今天的'); +} + +// 供意图框架(InsightIntentExecutor)调用的订餐入口:slot=breakfast/lunch/dinner +export async function orderMealByIntent(slot: string, wantTomorrow: boolean): Promise { + // 兜底:槽位缺省时按当前时间推餐次(9点前早餐、9-15点午餐、15点后晚餐),不再写死午餐 + let safeSlot = (slot === 'breakfast' || slot === 'lunch' || slot === 'dinner') ? slot : ''; + if (safeSlot.length === 0) { + safeSlot = inferSlotCj(new Date().getHours()); + hilog.info(DOMAIN, 'OrderMealIntent', 'slot missing, inferred by cangjie engine -> %{public}s', safeSlot); + } + const intent: IntentResult = { action: 'order', slot: safeSlot, date: wantTomorrow ? 'tomorrow' : '', jump: '' }; + return executeMealIntent('order', intent, wantTomorrow ? '订明天的' : '订今天的'); +} + +// 回家编排:离院停餐——今天剩余餐次 + 明天全天,逐餐次取消,退掉几顿报几顿 +async function cancelAllUpcomingMeals(): Promise { + const todaySlots: string[] = ['lunch', 'dinner']; + const tomorrowSlots: string[] = ['breakfast', 'lunch', 'dinner']; + const names: Record = { breakfast: '早餐', lunch: '午餐', dinner: '晚餐' }; + const cancelled: string[] = []; + const tries: Array> = []; + const tryCancel = (slot: string, ymd: string, label: string): Promise => + api.post(`/elderly-portal/meals/${slot}/reservation/`, { ordered: false, service_date: ymd }) + .then((): void => { + cancelled.push(label + names[slot]); + }) + .catch((): void => { }); + for (const s of todaySlots) { + tries.push(tryCancel(s, todayYMD(), '今天')); + } + for (const s of tomorrowSlots) { + tries.push(tryCancel(s, tomorrowYMD(), '明天')); + } + await Promise.all(tries); + if (cancelled.length > 0) { + publishLocalNotify(Date.now() % 100000, '订餐已取消', `已取消:${cancelled.join('、')}`); + return `已为您取消${cancelled.join('、')}`; + } + return '近期没有需要取消的餐食预订'; +} diff --git a/entry/src/main/ets/utils/AlertSocket.ts b/entry/src/main/ets/utils/AlertSocket.ts new file mode 100644 index 0000000..34a24c9 --- /dev/null +++ b/entry/src/main/ets/utils/AlertSocket.ts @@ -0,0 +1,130 @@ +// 预警实时推送 WebSocket(ArkTS 严格类型,无 any) +// 接口:wss:///ws/alerts// +import { webSocket } from '@kit.NetworkKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { API_BASE_URL, normalizeError } from '../common/ApiConstants'; +import { AuthStore } from './AuthStore'; +import { api } from './Request'; +import { publishLocalNotify } from './LocalNotify'; + +export type AlertSocketListener = (message: string) => void; + +interface ElderlyRow { + id: number; +} + +interface ElderlyListResp { + results?: Array; +} + +interface AlertPushPayload { + alert_id?: number; + title?: string; + description?: string; + level?: string; + elderly_name?: string; +} + +export class AlertSocket { + private static ws: webSocket.WebSocket | null = null; + private static listener: AlertSocketListener | null = null; + private static notifyInstalled: boolean = false; + private static lastAlertId: number = -1; + + // 建立连接(登录后调用) + static async connect(): Promise { + const state = await AuthStore.getState(); + if (state.token.length === 0) { + return; + } + if (state.role === null) { + return; + } + // 老人角色:后端按 alerts_{elderly_id} 广播健康预警,需用老人档案 ID 订阅(与 userId 不同) + let groupKey = String(state.userId); + if (state.role === 'elderly' || state.role === 'family') { + try { + const list = await api.get('/elderly/'); + const rows: Array = Array.isArray(list) ? list : (list.results ?? []); + if (rows.length > 0) { + groupKey = String(rows[0].id); + } + } catch (err) { + console.warn('[AlertSocket] resolve elderly id failed:', normalizeError(err as BusinessError | Error)); + } + AlertSocket.installNotifyListener(state.role === 'family' + ? 'smartsenior://page?target=pages/family/FamilyAlerts' + : 'smartsenior://page?target=pages/notifications/Notifications'); + } + AlertSocket.disconnect(); + const ws = webSocket.createWebSocket(); + AlertSocket.ws = ws; + + ws.on('open', () => { + console.info('[AlertSocket] connected'); + }); + ws.on('message', (err: BusinessError, value: string | ArrayBuffer) => { + if (err) { + console.warn('[AlertSocket] message error:', normalizeError(err)); + return; + } + // 收到预警推送:把消息文本交给回调(通知栏 + 刷新) + console.info('[AlertSocket] received alert push'); + if (AlertSocket.listener) { + const payload = typeof value === 'string' ? value : ''; + AlertSocket.listener(payload); + } + }); + ws.on('close', (err: BusinessError) => { + console.warn('[AlertSocket] closed:', normalizeError(err)); + }); + ws.on('error', (err: BusinessError) => { + console.warn('[AlertSocket] error:', normalizeError(err)); + }); + + const url = AlertSocket.buildUrl(groupKey); + try { + ws.connect(url, (err: BusinessError, value: boolean) => { + if (err) { + console.warn('[AlertSocket] connect failed:', normalizeError(err)); + } + }); + } catch (err) { + console.warn('[AlertSocket] connect error:', normalizeError(err as BusinessError | Error)); + } + } + + // 收到健康预警推送,弹系统通知栏(点击通知跳对应预警页) + private static installNotifyListener(linkUri: string): void { + AlertSocket.setListener((payload: string): void => { + const data = JSON.parse(payload) as AlertPushPayload; + if (data.title === undefined || data.title.length === 0) { + return; + } + const notifyTitle = data.level === 'emergency' ? '紧急健康提醒' : '健康提醒'; + const notifyText = `${data.elderly_name},${data.title}。`; + publishLocalNotify(data.alert_id ?? Date.now() % 100000, notifyTitle, notifyText, linkUri); + }); + } + + // 设置收到推送后的回调(如刷新预警列表) + static setListener(listener: AlertSocketListener | null): void { + AlertSocket.listener = listener; + } + + static disconnect(): void { + if (AlertSocket.ws) { + try { + AlertSocket.ws.close(); + } catch (err) { + console.warn('[AlertSocket] close error:', normalizeError(err as BusinessError | Error)); + } + AlertSocket.ws = null; + } + } + + private static buildUrl(userId: string): string { + const base = API_BASE_URL.replace(/^http/, (match: string) => (match === 'https' ? 'wss' : 'ws')); + return `${base}/ws/alerts/${userId}/`; + } +} diff --git a/entry/src/main/ets/utils/AuthStore.ts b/entry/src/main/ets/utils/AuthStore.ts new file mode 100644 index 0000000..04dc85e --- /dev/null +++ b/entry/src/main/ets/utils/AuthStore.ts @@ -0,0 +1,166 @@ +// 认证状态存储(ArkTS 严格类型,无 any) +import { preferences } from '@kit.ArkData'; +import { common } from '@kit.AbilityKit'; +import { + PREFS_NAME, + PREFS_TOKEN, + PREFS_REFRESH, + PREFS_ROLE, + PREFS_USER_ID, + PREFS_USER_NAME, + PREFS_FAMILY_VERIFIED, + UnifiedRole, +} from '../common/ApiConstants'; +import { resetFontScaleRuntime } from './FontScale'; +import { fileIo } from '@kit.CoreFileKit'; +import { BusinessError } from '@kit.BasicServicesKit'; + +// token 固定路径文件:扩展进程(AgentExtensionAbility)绕开 context 限制用 fileIo 直读 +export const A2A_TOKEN_FILE = '/data/storage/el2/base/files/a2a_token.txt'; + +function writeTokenFile(token: string): void { + try { + const file = fileIo.openSync(A2A_TOKEN_FILE, fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC); + fileIo.writeSync(file.fd, token); + fileIo.closeSync(file); + } catch (err) { + console.error('[AuthStore] writeTokenFile failed:', (err as BusinessError).message); + } +} + +export interface AuthState { + token: string; + refreshToken: string; + role: UnifiedRole | null; + userId: number; + userName: string; + familyVerified: boolean; +} + +// preferences 支持任意 Context;调用方传 UIAbility(主进程)或应用级(扩展进程)均可 +function getPreferences(context: common.UIAbilityContext | common.Context): Promise { + return preferences.getPreferences(context, PREFS_NAME); +} + +async function getString(context: common.UIAbilityContext | common.Context, key: string, def: string): Promise { + const prefs = await getPreferences(context); + const value = await prefs.get(key, def); + if (typeof value === 'string') { + return value; + } + return def; +} + +async function getNumber(context: common.UIAbilityContext | common.Context, key: string, def: number): Promise { + const prefs = await getPreferences(context); + const value = await prefs.get(key, def); + if (typeof value === 'number') { + return value; + } + return def; +} + +async function getBoolean(context: common.UIAbilityContext | common.Context, key: string, def: boolean): Promise { + const prefs = await getPreferences(context); + const value = await prefs.get(key, def); + if (typeof value === 'boolean') { + return value; + } + return def; +} + +export class AuthStore { + // 主进程走 preferences;扩展进程(AgentExtensionAbility)里 getContext() 无效, + // preferences 抛"Parameter error"时回退读 token 文件(登录/登出时同步写入) + static async getAccessToken(): Promise { + try { + return await getString(getContext() as common.UIAbilityContext, PREFS_TOKEN, ''); + } catch (err) { + try { + return fileIo.readTextSync(A2A_TOKEN_FILE).trim(); + } catch (e) { + return ''; + } + } + } + + static getAccessTokenIn(context: common.Context): Promise { + return AuthStore.getAccessToken(); + } + + static getRefreshToken(): Promise { + return getString(getContext() as common.UIAbilityContext, PREFS_REFRESH, ''); + } + + static getRole(): Promise { + return getString(getContext() as common.UIAbilityContext, PREFS_ROLE, '').then((v: string) => { + if (v.length === 0) { + return null; + } + return v as UnifiedRole; + }); + } + + static getState(): Promise { + const context = getContext() as common.UIAbilityContext; + return Promise.all([ + getString(context, PREFS_TOKEN, ''), + getString(context, PREFS_REFRESH, ''), + getString(context, PREFS_ROLE, ''), + getNumber(context, PREFS_USER_ID, 0), + getString(context, PREFS_USER_NAME, ''), + getBoolean(context, PREFS_FAMILY_VERIFIED, false), + ]).then((values: (string | number | boolean)[]) => { + const roleValue = values[2] as string; + return { + token: values[0] as string, + refreshToken: values[1] as string, + role: roleValue.length > 0 ? (roleValue as UnifiedRole) : null, + userId: values[3] as number, + userName: values[4] as string, + familyVerified: values[5] as boolean, + }; + }); + } + + static isLoggedIn(): Promise { + return AuthStore.getAccessToken().then((token: string) => token.length > 0); + } + + static async setAuth(state: AuthState): Promise { + const context = getContext() as common.UIAbilityContext; + const prefs = await getPreferences(context); + await prefs.put(PREFS_TOKEN, state.token); + await prefs.put(PREFS_REFRESH, state.refreshToken); + await prefs.put(PREFS_ROLE, state.role ?? ''); + await prefs.put(PREFS_USER_ID, state.userId); + await prefs.put(PREFS_USER_NAME, state.userName); + await prefs.put(PREFS_FAMILY_VERIFIED, state.familyVerified); + await prefs.flush(); + // 同步 token 到固定路径文件,供扩展进程(AgentExtensionAbility)用 fileIo 直读 + writeTokenFile(state.token); + } + + static async setTokens(access: string, refresh: string): Promise { + const context = getContext() as common.UIAbilityContext; + const prefs = await getPreferences(context); + await prefs.put(PREFS_TOKEN, access); + await prefs.put(PREFS_REFRESH, refresh); + await prefs.flush(); + writeTokenFile(access); + } + + static async logout(): Promise { + const context = getContext() as common.UIAbilityContext; + const prefs = await getPreferences(context); + await prefs.put(PREFS_TOKEN, ''); + await prefs.put(PREFS_REFRESH, ''); + await prefs.put(PREFS_ROLE, ''); + await prefs.put(PREFS_USER_ID, 0); + await prefs.put(PREFS_USER_NAME, ''); + await prefs.flush(); + writeTokenFile(''); + // 老人端大字设置只属于老人会话:退出后运行时立即恢复跟随系统 + resetFontScaleRuntime(); + } +} diff --git a/entry/src/main/ets/utils/CalendarHelper.ts b/entry/src/main/ets/utils/CalendarHelper.ts new file mode 100644 index 0000000..2899241 --- /dev/null +++ b/entry/src/main/ets/utils/CalendarHelper.ts @@ -0,0 +1,148 @@ +// Calendar Kit 工具:把院内活动写入本应用自建的专属日历 +// 权限:WRITE_CALENDAR / READ_CALENDAR(user_grant,运行时申请) +// 权限判断用 checkAccessToken(权威),authResults 在"无需弹窗"场景不可靠 +import { calendarManager } from '@kit.CalendarKit'; +import { common, Permissions } from '@kit.AbilityKit'; +import { abilityAccessCtrl, bundleManager } from '@kit.AbilityKit'; +import { preferences } from '@kit.ArkData'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { normalizeError } from '../common/ApiConstants'; + +const ACCOUNT_NAME = 'smartsenior_calendar'; +const DISPLAY_NAME = '数智护老活动日历'; +const PERMS_WRITE: Array = ['ohos.permission.WRITE_CALENDAR', 'ohos.permission.READ_CALENDAR']; +const PREFS_NAME = 'CalendarPrefs'; +const PREFS_CAL_ID = 'calendarId'; + +let cachedCalendar: calendarManager.Calendar | undefined = undefined; + +// 权威判断权限是否已授予 +async function checkGranted(tokenID: number, perm: Permissions): Promise { + try { + const atManager = abilityAccessCtrl.createAtManager(); + const state = await atManager.checkAccessToken(tokenID, perm); + return state === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED; + } catch (err) { + return false; + } +} + +// 确保写日历权限:先查已授状态,未授才弹窗,弹窗后用 checkAccessToken 复核 +async function ensureCalendarPermission(context: common.UIAbilityContext): Promise { + try { + const bundleInfo = await bundleManager.getBundleInfoForSelf(bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION); + const tokenID = bundleInfo.appInfo.accessTokenId; + const atManager = abilityAccessCtrl.createAtManager(); + let allGranted = true; + for (const p of PERMS_WRITE) { + if (!(await checkGranted(tokenID, p))) { + allGranted = false; + break; + } + } + if (allGranted) { + return true; + } + await atManager.requestPermissionsFromUser(context, PERMS_WRITE); + // 弹窗后以 checkAccessToken 结果为准(authResults 不可靠) + for (const p of PERMS_WRITE) { + if (!(await checkGranted(tokenID, p))) { + return false; + } + } + return true; + } catch (err) { + console.error('[Calendar] ensure permission failed:', normalizeError(err as Error)); + return false; + } +} + +async function getPrefs(context: common.UIAbilityContext): Promise { + return preferences.getPreferences(context, PREFS_NAME); +} + +async function getStoredCalendarId(context: common.UIAbilityContext): Promise { + try { + const prefs = await getPrefs(context); + const id = await prefs.get(PREFS_CAL_ID, -1); + return typeof id === 'number' ? id : -1; + } catch (err) { + return -1; + } +} + +async function storeCalendarId(context: common.UIAbilityContext, id: number): Promise { + try { + const prefs = await getPrefs(context); + await prefs.put(PREFS_CAL_ID, id); + await prefs.flush(); + } catch (err) { + // ignore + } +} + +// 获取本应用专属日历:优先用存储的 id 在全部日历中找回;找不到则创建并记录 id +async function ensureCalendar(context: common.UIAbilityContext): Promise { + if (cachedCalendar !== undefined) { + return cachedCalendar; + } + const calManager = calendarManager.getCalendarManager(context); + const storedId = await getStoredCalendarId(context); + + // 按 id 找回已创建的日历(需要 READ_CALENDAR) + if (storedId > 0) { + try { + const calendars = await calManager.getAllCalendars(); + for (const cal of calendars) { + if (cal.id === storedId) { + cachedCalendar = cal; + return cal; + } + } + } catch (err) { + const be = err as BusinessError; + console.error('[Calendar] getAllCalendars failed:', be.code, be.message); + } + } + + // 不存在则创建 + const account: calendarManager.CalendarAccount = { + name: ACCOUNT_NAME, + type: calendarManager.CalendarType.LOCAL, + displayName: DISPLAY_NAME, + }; + const created = await calManager.createCalendar(account); + await created.setConfig({ enableReminder: true, color: '#007AFF' }); + await storeCalendarId(context, created.id); + cachedCalendar = created; + return created; +} + +// 写入一条日历事件(毫秒时间戳),成功返回 '',失败返回错误信息 +export async function addCalendarEvent(context: common.UIAbilityContext, title: string, + startMs: number, endMs: number, location: string, description: string): Promise { + const granted = await ensureCalendarPermission(context); + if (!granted) { + return '未获得日历权限(写入日历)'; + } + try { + const calendar = await ensureCalendar(context); + const event: calendarManager.Event = { + type: calendarManager.EventType.NORMAL, + title: title, + startTime: startMs, + endTime: endMs, + description: description, + reminderTime: [30], + }; + if (location.length > 0) { + event.location = { location: location } as calendarManager.Location; + } + await calendar.addEvent(event); + return ''; + } catch (err) { + const be = err as BusinessError; + console.error('[Calendar] addEvent failed:', be.code, be.message); + return `写入失败:${be.code} ${be.message}`; + } +} diff --git a/entry/src/main/ets/utils/FontScale.ts b/entry/src/main/ets/utils/FontScale.ts new file mode 100644 index 0000000..d234e94 --- /dev/null +++ b/entry/src/main/ets/utils/FontScale.ts @@ -0,0 +1,89 @@ +// 全局字体缩放工具(ArkTS 严格类型,无 any) +// 用于老人端大字显示:存 preferences + AppStorage +import { preferences } from '@kit.ArkData'; +import { common } from '@kit.AbilityKit'; +import { accessibility } from '@kit.AccessibilityKit'; + +const FONT_PREFS = 'FontPrefs'; +const FONT_KEY = 'fontScale'; + +export type FontScaleLevel = 'system' | 'small' | 'normal' | 'large' | 'xlarge'; + +// 字体缩放选项 +export interface FontScaleOption { + level: FontScaleLevel; + label: string; +} + +// 各档位缩放倍数(system 表示跟随系统,用 fp 单位不额外缩放) +export const FONT_SCALE_MAP: Record = { + system: 1.0, + small: 1.0, + normal: 1.15, + large: 1.5, + xlarge: 1.9, +}; + +// 档位中文名 +export const FONT_SCALE_LABELS: Array = [ + { level: 'system', label: '跟随系统' }, + { level: 'small', label: '小' }, + { level: 'normal', label: '标准' }, + { level: 'large', label: '大' }, + { level: 'xlarge', label: '特大' }, +]; + +// 读取当前缩放档位(默认跟随系统) +export async function loadFontScale(context: common.UIAbilityContext): Promise { + try { + const prefs = await preferences.getPreferences(context, FONT_PREFS); + const value = await prefs.get(FONT_KEY, 'system'); + const level = typeof value === 'string' ? value as FontScaleLevel : 'system'; + AppStorage.setOrCreate('fontScale', level); + return level; + } catch (err) { + return 'system'; + } +} + +// 设置缩放档位并持久化 +export async function setFontScale(context: common.UIAbilityContext, level: FontScaleLevel): Promise { + try { + const prefs = await preferences.getPreferences(context, FONT_PREFS); + await prefs.put(FONT_KEY, level); + await prefs.flush(); + } catch (err) { + // ignore + } + AppStorage.setOrCreate('fontScale', level); +} + +// 获取当前缩放倍数(同步,供页面文字应用) +export function getFontScale(): number { + const level = AppStorage.get('fontScale'); + if (level && FONT_SCALE_MAP[level]) { + return FONT_SCALE_MAP[level]; + } + return 1.0; +} + +// 退出登录时恢复运行时字体为跟随系统(不清除持久化设置,老人重新登录后自动恢复) +export function resetFontScaleRuntime(): void { + AppStorage.setOrCreate('fontScale', 'system'); +} + +// 关怀模式:系统无障碍(读屏/TalkBack 类)开启时自动生效 +// 初始化在 EntryAbility.onCreate 调用;组件用 @StorageProp('careMode') 响应 +export function refreshCareMode(): boolean { + let care = false; + try { + care = accessibility.isOpenAccessibilitySync(); + } catch (err) { + care = false; + } + AppStorage.setOrCreate('careMode', care); + return care; +} + +// 关怀模式额外字号加成倍数(在 FONT_SCALE_MAP 之上再乘) +export const CARE_MODE_EXTRA_SCALE = 1.2; diff --git a/entry/src/main/ets/utils/GrayScaleDates.ts b/entry/src/main/ets/utils/GrayScaleDates.ts new file mode 100644 index 0000000..e0c7784 --- /dev/null +++ b/entry/src/main/ets/utils/GrayScaleDates.ts @@ -0,0 +1,50 @@ +// 灰度日期管理工具(ArkTS 严格类型) +// 用于管理特殊纪念日/哀悼日的页面置灰 +import http from '@ohos.net.http'; +import { BusinessError } from '@kit.BasicServicesKit'; + +interface GrayDateResponse { + dates: string[]; +} + +class GrayScaleDatesImpl { + private grayDates: Set = new Set(); + private initialized: boolean = false; + + /** + * 从远程获取灰度日期列表 + */ + async initRemoteData(context: object): Promise { + if (this.initialized) { + return; + } + try { + const httpRequest = http.createHttp(); + const response = await httpRequest.request('https://example.com/api/grayscale-dates/', { + method: http.RequestMethod.GET, + connectTimeout: 5000, + readTimeout: 5000 + }); + if (response.responseCode === 200) { + const body = response.result as string; + const data: GrayDateResponse = JSON.parse(body); + this.grayDates = new Set(data.dates || []); + } + httpRequest.destroy(); + } catch (_err) { + // 远程获取失败时使用默认空集合,不阻塞主流程 + console.warn('[GrayScaleDates] 远程获取灰度日期失败,使用默认值'); + } + this.initialized = true; + } + + /** + * 判断指定日期(YYYY-MM-DD)是否为灰度日期 + */ + isGrayScaleDate(dateStr: string): boolean { + return this.grayDates.has(dateStr); + } +} + +const GrayScaleDates = new GrayScaleDatesImpl(); +export default GrayScaleDates; diff --git a/entry/src/main/ets/utils/HealthReportPdf.ts b/entry/src/main/ets/utils/HealthReportPdf.ts new file mode 100644 index 0000000..345200d --- /dev/null +++ b/entry/src/main/ets/utils/HealthReportPdf.ts @@ -0,0 +1,346 @@ +// 家属端健康风险报告 PDF:下载 → 沙箱落盘 → 抽取文本(文字层 / 端侧 OCR)→ 写入智能体可直读的上下文文件 +// 链路:报告页(主进程)写好 current_report.txt → 小艺拉起端侧智能体(同沙箱)直接读该文件 +import { pdfService, pdfViewManager } from '@kit.PDFKit'; +import { textRecognition } from '@kit.CoreVisionKit'; +import { image } from '@kit.ImageKit'; +import { fileIo } from '@kit.CoreFileKit'; +import { common } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { + API_BASE_URL, + API_PREFIX, + REPORT_CONTEXT_FILE, + REPORT_PDF_FILE_NAME, + REPORT_TEXT_API, + normalizeError, +} from '../common/ApiConstants'; +import { fetchMediaBuffer, api } from './Request'; + +// 上下文文件里写入的正文上限:避免超长报告把智能体上下文撑爆(约 4000 字) +const MAX_CONTEXT_CHARS = 4000; + +// OCR 渲染分辨率:按 150dpi 取,A4 约 1240x1754,与报告内嵌图片原始分辨率一致 +// (默认 getPagePixelMap 只有 72dpi 的 595x841,"睡眠"会识成"唾眠"这类错字明显变多) +const OCR_BITMAP_WIDTH = 1240; +const OCR_BITMAP_HEIGHT = 1754; + +// 沙箱内 PDF 路径(每次下载覆盖) +export function reportPdfPath(context: common.UIAbilityContext): string { + return `${context.filesDir}/${REPORT_PDF_FILE_NAME}`; +} + +// 沙箱里是否已有 PDF(断网/后端不可用时用于离线预览) +export function hasLocalReportPdf(context: common.UIAbilityContext): boolean { + try { + return fileIo.accessSync(reportPdfPath(context)); + } catch (err) { + return false; + } +} + +// 页面预览:把已下载的 PDF 载入 PdfView 的控制器(失败返回 false,页面降级为仅智能体入口) +// 注意:PDF Kit 不支持在同一个 controller 上重复 loadDocument,必须先 releaseDocument, +// 否则旧文档的渲染任务还在渲染线程池里,新文档顶掉资源后会在 libpdf_framework 里段错误 +export async function loadPdfForPreview( + controller: pdfViewManager.PdfController, + path: string, +): Promise { + try { + try { + controller.releaseDocument(); + } catch (err) { + // 首次加载时无可释放文档,忽略 + } + const result = await controller.loadDocument(path); + return result === pdfService.ParseResult.PARSE_SUCCESS; + } catch (err) { + console.warn('[HealthReportPdf] preview load failed:', normalizeError(err as BusinessError | Error)); + return false; + } +} + +// 释放预览文档(页面销毁时调用) +export function releasePdfPreview(controller: pdfViewManager.PdfController): void { + try { + controller.releaseDocument(); + } catch (err) { + console.warn('[HealthReportPdf] preview release failed:', normalizeError(err as BusinessError | Error)); + } +} + +// 报告正文接口响应(字段兼容后端多种命名) +export interface ReportTextResp { + text?: string; + content?: string; + summary?: string; +} + +// 1.1 优先取后端报告正文:PDF 由后端生成,正文其后端本就有 +// (端侧 OCR 依赖设备 AI 服务,实测部分机型直接返回 run error,不能作为唯一来源) +export async function fetchReportTextFromServer(): Promise { + try { + // 断网时不能让它把后面的 OCR 拖住:端侧 OCR 只要 ~0.4s,这里单独压到 2.5s + // (接口在线时 404/正常响应都是毫秒级,缩短超时不影响取到后端正文) + const res = await api.get(REPORT_TEXT_API, undefined, { timeout: 2500 }); + const text = (res.text ?? res.content ?? res.summary ?? '').trim(); + return text; + } catch (err) { + console.warn('[HealthReportPdf] report text api unavailable:', normalizeError(err as BusinessError | Error)); + return ''; + } +} + +// 1. 下载云端 PDF(带登录态;401 自动刷新重试由 fetchMediaBuffer 处理) +export async function downloadHealthRiskPdf(context: common.UIAbilityContext): Promise { + const url = `${API_BASE_URL}${API_PREFIX}/reports/health-risk/pdf/`; + const buffer = await fetchMediaBuffer(url); + const path = reportPdfPath(context); + 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); + } + return path; +} + +// 2. PDF → 纯文本:优先文字层;无文字层(图片型 PDF)逐页渲染成位图后走端侧 OCR +export async function extractPdfText(path: string): Promise { + const doc = new pdfService.PdfDocument(); + let ocrReady = false; + try { + const result = doc.loadDocument(path); + if (result !== pdfService.ParseResult.PARSE_SUCCESS) { + console.warn(`[HealthReportPdf] loadDocument failed, parse result=${result}`); + return ''; + } + const pages = doc.getPageCount(); + const parts: string[] = []; + for (let i = 0; i < pages; i++) { + const page = doc.getPage(i); + let pageText = ''; + try { + pageText = page.getTextContent().trim(); + } catch (err) { + console.warn(`[HealthReportPdf] page ${i} text layer failed:`, normalizeError(err as BusinessError | Error)); + } + if (pageText.length === 0) { + // 图片型页面:渲染位图 → 端侧 OCR(不出端) + if (!ocrReady) { + ocrReady = await initOcr(); + } + if (ocrReady) { + pageText = await ocrPage(page); + } + } + if (pageText.length > 0) { + parts.push(pageText); + } + try { + page.release(); + } catch (err) { + console.warn(`[HealthReportPdf] page ${i} release failed:`, normalizeError(err as BusinessError | Error)); + } + } + return parts.join('\n'); + } catch (err) { + console.warn('[HealthReportPdf] extract failed:', normalizeError(err as BusinessError | Error)); + return ''; + } finally { + if (ocrReady) { + textRecognition.release().catch((err: BusinessError) => { + console.warn('[HealthReportPdf] ocr release failed:', normalizeError(err)); + }); + } + try { + doc.releaseDocument(); + } catch (err) { + console.warn('[HealthReportPdf] release failed:', normalizeError(err as BusinessError | Error)); + } + } +} + +// OCR 服务初始化(API 12+);失败时降级为无 OCR +async function initOcr(): Promise { + try { + return await textRecognition.init(); + } catch (err) { + console.warn('[HealthReportPdf] ocr init failed:', normalizeError(err as BusinessError | Error)); + return false; + } +} + +// 单页位图 → OCR 文本 +// 实测:getCustomPagePixelMap(new PdfMatrix(), false, true) 在本机不抛异常但返回 undefined;150dpi 位图 +// 渲染正常却让 OCR 静默返回空串。因此按清晰度列出候选,逐个试到出文本为止,最后保底 72dpi。 +async function ocrPage(page: pdfService.PdfPage): Promise { + const attempts = renderCandidates(page); + if (attempts.length === 0) { + console.warn('[HealthReportPdf] no pixelMap for page, skip ocr'); + return ''; + } + let result = ''; + let used = 0; + for (; used < attempts.length; used++) { + const attempt = attempts[used]; + console.info(`[HealthReportPdf] page pixelMap via ${attempt.label}`); + try { + result = await recognizeWithConvert(attempt.pixelMap); + } catch (err) { + console.warn('[HealthReportPdf] ocr attempt failed:', normalizeError(err as BusinessError | Error)); + } + attempt.pixelMap.release(); + if (result.length > 0) { + break; + } + } + // 没轮到的候选位图也要释放,避免大图占内存 + for (let i = used + 1; i < attempts.length; i++) { + attempts[i].pixelMap.release(); + } + return result; +} + +interface RenderAttempt { + label: string; + pixelMap: image.PixelMap; +} + +// 单页的候选位图(清晰度从高到低) +function renderCandidates(page: pdfService.PdfPage): RenderAttempt[] { + const candidates: RenderAttempt[] = []; + try { + const hires = page.getAreaPixelMap(new pdfService.PdfMatrix(), OCR_BITMAP_WIDTH, OCR_BITMAP_HEIGHT, false, true); + if (hires !== undefined && hires !== null) { + candidates.push({ label: `getAreaPixelMap ${OCR_BITMAP_WIDTH}x${OCR_BITMAP_HEIGHT}`, pixelMap: hires }); + } else { + console.warn('[HealthReportPdf] getAreaPixelMap returned empty'); + } + } catch (err) { + console.warn('[HealthReportPdf] getAreaPixelMap failed:', normalizeError(err as BusinessError | Error)); + } + try { + const base = page.getPagePixelMap(); + if (base !== undefined && base !== null) { + candidates.push({ label: 'getPagePixelMap', pixelMap: base }); + } else { + console.warn('[HealthReportPdf] getPagePixelMap returned empty'); + } + } catch (err) { + console.warn('[HealthReportPdf] getPagePixelMap failed:', normalizeError(err as BusinessError | Error)); + } + return candidates; +} + +// 一次位图的识别:先原样试(部分位图本身已是 RGBA_8888),空结果或报错再转 RGBA_8888 重试 +// 不释放 source,由调用方统一释放 +async function recognizeWithConvert(source: image.PixelMap): Promise { + try { + const text = await recognizePixelMap(source); + if (text.length > 0) { + return text; + } + } catch (err) { + console.warn('[HealthReportPdf] ocr with raw pixelMap failed:', normalizeError(err as BusinessError | Error)); + } + let converted: image.PixelMap | undefined = undefined; + try { + converted = await ensureRgba8888(source); + if (converted === undefined || converted === source) { + return ''; + } + return await recognizePixelMap(converted); + } catch (err) { + console.warn('[HealthReportPdf] ocr with RGBA_8888 failed:', normalizeError(err as BusinessError | Error)); + return ''; + } finally { + if (converted !== undefined && converted !== source) { + converted.release(); + } + } +} + +// 执行一次文字识别(按官方配置关闭朝向检测以提性能) +async function recognizePixelMap(pixelMap: image.PixelMap): Promise { + const info: textRecognition.VisionInfo = { pixelMap: pixelMap }; + const config: textRecognition.TextRecognitionConfiguration = { isDirectionDetectionSupported: false }; + const result = await textRecognition.recognizeText(info, config); + return result.value.trim(); +} + +// 把任意格式的 PixelMap 转成 OCR 要求的 RGBA_8888;已是 RGBA_8888 则原样返回,失败返回 undefined +async function ensureRgba8888(source: image.PixelMap): Promise { + try { + const info = source.getImageInfoSync(); + console.info(`[HealthReportPdf] pixelMap ${info.size.width}x${info.size.height} format=${info.pixelFormat}`); + if (info.pixelFormat === image.PixelMapFormat.RGBA_8888) { + return source; + } + const bytes = source.getPixelBytesNumber(); + const buffer = new ArrayBuffer(bytes); + await source.readPixelsToBuffer(buffer); + const options: image.InitializationOptions = { + size: { width: info.size.width, height: info.size.height }, + srcPixelFormat: info.pixelFormat, + pixelFormat: image.PixelMapFormat.RGBA_8888, + alphaType: info.alphaType, + }; + const converted = await image.createPixelMap(buffer, options); + console.info('[HealthReportPdf] pixelMap converted to RGBA_8888'); + return converted; + } catch (err) { + console.warn('[HealthReportPdf] convert to RGBA_8888 failed:', normalizeError(err as BusinessError | Error)); + return undefined; + } +} + +// 渲染整页(旧实现:手工填 matrix 调 2 倍渲染)已废弃—— +// 它触发 getMatrixArgs failed,产出的位图 OCR 直接 run error,改用官方 getCustomPagePixelMap。 + +// 3. 写智能体上下文文件(首行是标题与时间戳,供智能体判断报告是否新鲜) +export function cacheReportContextForAgent(text: string, title: string): number { + const body = text.length > MAX_CONTEXT_CHARS ? text.substring(0, MAX_CONTEXT_CHARS) : text; + const stamp = new Date().toLocaleString(); + const content = `【报告标题】${title}\n【缓存时间】${stamp}\n【报告正文】\n${body}`; + const file = fileIo.openSync( + REPORT_CONTEXT_FILE, + fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC, + ); + try { + fileIo.writeSync(file.fd, content); + } finally { + fileIo.closeSync(file); + } + return content.length; +} + +// 4. 清空上下文:PDF 解析失败时必须清,避免智能体总结上一份报告 +export function clearReportContext(): void { + try { + if (!fileIo.accessSync(REPORT_CONTEXT_FILE)) { + return; + } + const file = fileIo.openSync( + REPORT_CONTEXT_FILE, + fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC, + ); + fileIo.closeSync(file); + } catch (err) { + console.warn('[HealthReportPdf] clear failed:', normalizeError(err as BusinessError | Error)); + } +} + +// 5. 读上下文(供智能体侧复用;也可用于页面自检) +export function readReportContext(): string { + try { + if (!fileIo.accessSync(REPORT_CONTEXT_FILE)) { + return ''; + } + return fileIo.readTextSync(REPORT_CONTEXT_FILE).trim(); + } catch (err) { + return ''; + } +} diff --git a/entry/src/main/ets/utils/IntentEngine.ts b/entry/src/main/ets/utils/IntentEngine.ts new file mode 100644 index 0000000..3ef8e9f --- /dev/null +++ b/entry/src/main/ets/utils/IntentEngine.ts @@ -0,0 +1,51 @@ +// 仓颉意图引擎桥:entry(ArkTS) → cjagent(HAR, 仓颉 so) +// 仓颉侧只做文本→意图 JSON 的解析,后端调用仍在 ArkTS 侧执行 +import { parseIntent, splitIntents, inferSlot } from 'libohos_app_cangjie_cjagent.so' + +export interface IntentResult { + action: string; + slot: string; + date: string; + jump: string; +} + +export function parseIntentCj(text: string): IntentResult | null { + try { + const json: string = parseIntent(text); + return JSON.parse(json) as IntentResult; + } catch (err) { + console.error('[IntentEngine] cangjie parse failed:', (err as Error).message); + return null; + } +} + +export interface SegmentResult { + segments: string[]; +} + +// 多意图拆分:仓颉按连接词/标点切句;单意图时返回 null(走原路径零开销) +export function splitSegmentsCj(text: string): string[] | null { + try { + const json: string = splitIntents(text); + const parsed = JSON.parse(json) as SegmentResult; + if (parsed.segments.length > 1) { + return parsed.segments; + } + return null; + } catch (err) { + console.error('[IntentEngine] cangjie split failed:', (err as Error).message); + return null; + } +} + +// 餐次推断(仓颉决策链):按小时返回 breakfast/lunch/dinner,失败时 ArkTS 本地兜底 +export function inferSlotCj(hour: number): string { + try { + const json: string = inferSlot(hour); + const parsed = JSON.parse(json) as { slot: string }; + return parsed.slot; + } catch (err) { + console.error('[IntentEngine] cangjie inferSlot failed:', (err as Error).message); + return hour < 9 ? 'breakfast' : (hour < 15 ? 'lunch' : 'dinner'); + } +} diff --git a/entry/src/main/ets/utils/KnockShare.ets b/entry/src/main/ets/utils/KnockShare.ets new file mode 100644 index 0000000..ec190ba --- /dev/null +++ b/entry/src/main/ets/utils/KnockShare.ets @@ -0,0 +1,104 @@ +// 碰一碰分享:harmonyShare knockShare/gesturesShare 回调 → systemShare HYPERLINK 链接 +// 参照 LDP FLY EFB FlightPlanPage 实现;链接使用自定义 scheme smartsenior://report,接收端 EntryAbility 解析 +import { harmonyShare, systemShare } from '@kit.ShareKit'; +import { uniformTypeDescriptor as utd } from '@kit.ArkData'; + +// 健康报告卡数据结构:发送端打包、接收端渲染共用 +export interface HealthReportData { + name: string; + gender: string; + age: number; + room: string; + care_level: string; + health_score: number | null; + status: string; + risks: Array; + medical_history: string; + allergies: string; + medications: Array; + caregiver: string; + emergency_contact: string; + shared_at: string; + // AI 健康报告(/reports/ 最新一期,可能为空串) + report_title: string; + report_period: string; + report_summary: string; + report_ai: string; +} + +// 碰一碰分享载荷 +export interface KnockSharePayload { + title: string; + description: string; + url: string; +} + +// 一个页面一个会话:aboutToAppear 注册,aboutToDisappear 注销 +export class KnockShareSession { + private payloadGetter: (() => KnockSharePayload | null) | null = null; + private registered: boolean = false; + + register(getPayload: () => KnockSharePayload | null): void { + this.payloadGetter = getPayload; + if (this.registered) { + return; + } + try { + harmonyShare.on('knockShare', this.shareCallback); + harmonyShare.on('gesturesShare', this.knockCallback); + this.registered = true; + console.info('[KnockShare] callbacks registered'); + } catch (err) { + console.error('[KnockShare] register failed:', (err as Error).message); + } + } + + unregister(): void { + if (!this.registered) { + return; + } + try { + harmonyShare.off('knockShare', this.shareCallback); + harmonyShare.off('gesturesShare', this.knockCallback); + } catch (err) { + console.error('[KnockShare] unregister failed:', (err as Error).message); + } + this.registered = false; + this.payloadGetter = null; + } + + private doShare(target: harmonyShare.SharableTarget): void { + try { + const payload = this.payloadGetter !== null ? this.payloadGetter() : null; + if (payload === null) { + target.reject(harmonyShare.SharableErrorCode.NO_CONTENT_ERROR); + return; + } + const shareData: systemShare.SharedData = new systemShare.SharedData({ + utd: utd.UniformDataType.HYPERLINK, + content: payload.url, + title: payload.title, + description: payload.description + }); + target.share(shareData).catch(() => { + target.reject(harmonyShare.SharableErrorCode.NO_CONTENT_ERROR); + }); + console.info('[KnockShare] shared via hyperlink'); + } catch (err) { + console.error('[KnockShare] share failed:', (err as Error).message); + try { + target.reject(harmonyShare.SharableErrorCode.NO_CONTENT_ERROR); + } catch (rejectErr) { + console.error('[KnockShare] reject failed:', (rejectErr as Error).message); + } + } + } + + private shareCallback = (target: harmonyShare.SharableTarget): void => { + this.doShare(target); + }; + + private knockCallback = (target: harmonyShare.SharableTarget): void => { + this.doShare(target); + }; +} diff --git a/entry/src/main/ets/utils/LiveDataService.ets b/entry/src/main/ets/utils/LiveDataService.ets new file mode 100644 index 0000000..55683cb --- /dev/null +++ b/entry/src/main/ets/utils/LiveDataService.ets @@ -0,0 +1,409 @@ +// entry/src/main/ets/utils/LiveDataService.ets +import { http } from '@kit.NetworkKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { liveViewManager } from '@kit.LiveViewKit'; +import { WantAgent } from '@kit.AbilityKit'; +import { LiveViewController } from './LiveViewUtil'; + +const DOMAIN = 0x0000; + +export interface FlightData { + flightNumber: string; + altitude: number; + speed: number; + origin: string; + destination: string; + originName: string; + destinationName: string; + warning?: boolean; + statusCode: number; + statusText: string; +} + +export interface PilotData { + callsign: string; + cid: string; + realName: string; + type: string; + latitude: number; + longitude: number; + altitude: number; + groundSpeed: number; + aircraftType: string; + cruiseTAS: number; + departureAirport: string; + cruiseAltitude: string; + destinationAirport: string; + squawk: string; + route: string; + loginTimestamp: string; +} + +export interface ApiResponse { + success: boolean; + message?: string; + error?: string; + data?: object; +} + +interface OnlinePilotsResponse { + status: string; + data?: PilotData[]; + message?: string; +} + +interface PushTokenResponse { + token?: string; + error?: string; +} + +interface RemoteLiveViewRequest { + device_id: string; + flight_data: FlightData; +} + +interface EndRemoteLiveViewRequest { + device_id: string; +} + +export class LiveDataService { + private static readonly LIVE_VIEW_API = 'https://system.ldpfly.cn/hmos/LiveView.php'; + private static readonly REMOTE_LIVE_VIEW_API = 'https://system.ldpfly.cn/hmos/RemoteLiveView.php'; + private static readonly PUSH_TOKEN_API = 'https://system.ldpfly.cn/hmos/getPushToken.php'; + private static readonly ONLINE_PILOTS_API = 'https://system.ldpfly.cn/hmos/OnlinePilots.php'; + private static liveViewCreated: boolean = false; + + /** + * 获取航班实况数据 + */ + public static async fetchLiveViewData(): Promise { + try { + const httpRequest = http.createHttp(); + const response = await httpRequest.request(LiveDataService.LIVE_VIEW_API, { + method: http.RequestMethod.GET, + header: { + 'Content-Type': 'application/json' + } + }); + hilog.info(DOMAIN, 'LiveDataService', 'Flight data fetched successfully'); + const result: FlightData = JSON.parse(response.result as string); + if (result && typeof result === 'object') { + return result; + } else { + throw new Error('Invalid flight data format'); + } + } catch (error) { + const err = error as Error; + hilog.error(DOMAIN, 'LiveDataService', `Failed to fetch flight data: ${err.message}`); + throw new Error(`数据获取失败: ${err.message}`); + } + } + + /** + * 获取在线飞行员列表 + */ + public static async fetchOnlinePilots(): Promise { + try { + const httpRequest = http.createHttp(); + const response = await httpRequest.request(LiveDataService.ONLINE_PILOTS_API, { + method: http.RequestMethod.GET, + header: { + 'Content-Type': 'application/json' + } + }); + hilog.info(DOMAIN, 'LiveDataService', 'Online pilots data fetched successfully'); + const result: OnlinePilotsResponse = JSON.parse(response.result as string); + if (result.status === 'success') { + return result.data || []; + } else { + throw new Error(result.message || '获取在线飞行员列表失败'); + } + } catch (error) { + const err = error as Error; + hilog.error(DOMAIN, 'LiveDataService', `Failed to fetch online pilots data: ${err.message}`); + throw new Error(`在线飞行员数据获取失败: ${err.message}`); + } + } + + /** + * 创建远程实况窗 + * @param deviceId 设备ID + * @param flightData 航班数据 + */ + public static async createRemoteLiveView(deviceId: string, flightData: FlightData): Promise { + try { + // 首先创建/更新本地实况窗显示 + await LiveDataService.createOrUpdateLocalLiveView(flightData); + + const httpRequest = http.createHttp(); + // 使用formData方式发送数据 + const requestData: RemoteLiveViewRequest = { + device_id: deviceId, + flight_data: flightData + }; + + const response = await httpRequest.request( + `${LiveDataService.REMOTE_LIVE_VIEW_API}?endpoint=create`, + { + method: http.RequestMethod.POST, + header: { + 'Content-Type': 'application/json' + }, + extraData: JSON.stringify(requestData) + } + ); + const resultText = response.result as string; + hilog.info(DOMAIN, 'LiveDataService', `Create remote live view response: ${resultText}`); + + // 检查HTTP状态码 + if (response.responseCode !== 200) { + throw new Error(`HTTP Error: ${response.responseCode}`); + } + + // 检查返回内容是否为HTML(错误页面) + if (resultText.trim().startsWith(' { + try { + // 首先更新本地实况窗显示 + await LiveDataService.createOrUpdateLocalLiveView(flightData); + + const httpRequest = http.createHttp(); + // 使用formData方式发送数据 + const requestData: RemoteLiveViewRequest = { + device_id: deviceId, + flight_data: flightData + }; + + const response = await httpRequest.request( + `${LiveDataService.REMOTE_LIVE_VIEW_API}?endpoint=update`, + { + method: http.RequestMethod.POST, + header: { + 'Content-Type': 'application/json' + }, + extraData: JSON.stringify(requestData) + } + ); + const resultText = response.result as string; + hilog.info(DOMAIN, 'LiveDataService', `Update remote live view response: ${resultText}`); + + // 检查HTTP状态码 + if (response.responseCode !== 200) { + throw new Error(`HTTP Error: ${response.responseCode}`); + } + + // 检查返回内容是否为HTML(错误页面) + if (resultText.trim().startsWith(' { + try { + // 重置实况窗创建状态 + LiveDataService.liveViewCreated = false; + + const httpRequest = http.createHttp(); + // 使用formData方式发送数据 + const requestData: EndRemoteLiveViewRequest = { + device_id: deviceId + }; + + const response = await httpRequest.request( + `${LiveDataService.REMOTE_LIVE_VIEW_API}?endpoint=end`, + { + method: http.RequestMethod.POST, + header: { + 'Content-Type': 'application/json' + }, + extraData: JSON.stringify(requestData) + } + ); + const resultText = response.result as string; + hilog.info(DOMAIN, 'LiveDataService', `End remote live view response: ${resultText}`); + + // 检查HTTP状态码 + if (response.responseCode !== 200) { + throw new Error(`HTTP Error: ${response.responseCode}`); + } + + // 检查返回内容是否为HTML(错误页面) + if (resultText.trim().startsWith(' { + try { + hilog.info(DOMAIN, 'LiveDataService', `Fetching push token for device: ${userId}`); + const httpRequest = http.createHttp(); + const response = await httpRequest.request(LiveDataService.PUSH_TOKEN_API, { + method: http.RequestMethod.POST, + header: { + 'Content-Type': 'application/json' + }, + extraData: JSON.stringify({ userId: userId }) + }); + + hilog.info(DOMAIN, 'LiveDataService', `Push token request response code: ${response.responseCode}`); + + // 检查HTTP状态码 + if (response.responseCode !== 200) { + throw new Error(`HTTP Error: ${response.responseCode}`); + } + + const resultText = response.result as string; + hilog.info(DOMAIN, 'LiveDataService', `Push token response: ${resultText}`); + + // 检查返回内容是否为HTML(错误页面) + if (resultText.trim().startsWith(' { + try { + // 验证必要字段并提供默认值 + if (!flightData.speed) { + hilog.warn(DOMAIN, 'LiveDataService', 'Flight speed data is missing, using default value 0'); + flightData.speed = 0; + } + if (!flightData.altitude) { + hilog.warn(DOMAIN, 'LiveDataService', 'Flight altitude data is missing, using default value 0'); + flightData.altitude = 0; + } + + // 获取WantAgent实例 + const wantAgent: WantAgent | null = LiveViewController.getWantAgent(); + if (!wantAgent) { + throw new Error("WantAgent未初始化"); + } + + // 构造实况窗显示内容,包含胶囊数据 + const liveView: liveViewManager.LiveView = { + id: 0, + event: "FLIGHT", + liveViewData: { + primary: { + title: flightData.flightNumber, + content: [ + { text: "高度 " }, + { text: `${flightData.altitude}`, textColor: "#FF0A59F7" }, + { text: " ft" }, + { text: " | " }, + { text: "速度 " }, + { text: `${flightData.speed}`, textColor: "#FF0A59F7" }, + { text: " kts" }, + ], + keepTime: 15, + backgroundType: liveViewManager.BackgroundType.SYS_BACKGROUND_FLIGHT_SUNSET, + clickAction: wantAgent, + layoutData: { + layoutType: liveViewManager.LayoutType.LAYOUT_TYPE_FLIGHT, + spaceIcon: "flight_icon1.png", + firstTitle: flightData.origin, + firstContent: flightData.originName || "出发机场", + lastTitle: flightData.destination, + lastContent: flightData.destinationName || "到达机场", + additionalText: "数据来源于FSD服务器,仅供参考 | LDP FLY空天模拟飞行平台", + } + }, + // 添加实况胶囊数据 - 修改为新的显示格式 + capsule: { + type: 1, + status: 1, + icon: "icon.png", + backgroundColor: "#FF0A59F7", + title: "飞行中", // 固定显示"飞行中" + content: flightData.flightNumber // 显示航班号 + } + } + }; + + // 创建或更新实况窗 + if (!LiveDataService.liveViewCreated) { + await liveViewManager.startLiveView(liveView); + LiveDataService.liveViewCreated = true; + hilog.info(DOMAIN, 'LiveDataService', 'Local live view created successfully'); + } else { + await liveViewManager.updateLiveView(liveView); + hilog.info(DOMAIN, 'LiveDataService', 'Local live view updated successfully'); + } + } catch (error) { + const err = error as Error; + hilog.error(DOMAIN, 'LiveDataService', `Failed to create or update local live view: ${err.message}`); + throw new Error(`实况窗操作失败: ${err.message}`); + } + } +} diff --git a/entry/src/main/ets/utils/LiveViewUtil.ets b/entry/src/main/ets/utils/LiveViewUtil.ets new file mode 100644 index 0000000..a08df27 --- /dev/null +++ b/entry/src/main/ets/utils/LiveViewUtil.ets @@ -0,0 +1,875 @@ +// entry/src/main/ets/utils/LiveViewUtil.ets +import { liveViewManager } from '@kit.LiveViewKit'; +import { Want, wantAgent, WantAgent } from '@kit.AbilityKit'; +import { FlightData, LiveDataService, PilotData, ApiResponse } from './LiveDataService'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import deviceInfo from '@ohos.deviceInfo'; +import { http } from '@kit.NetworkKit'; + +// Push Kit相关类型定义 +interface ClickAction { + actionType: number; + action: string; +} + +interface RichProgress { + type: number; + nodeIcons: string[]; + indicatorIcon: string; + progress: number; + indicatorType: number; + color: string; + bgColor: string; +} + +interface CapsuleData { + type: number; + status: number; + icon: string; + bgColor: string; // Push Kit格式 + backgroundColor: string; // 本地API格式 + title: string; + content: string; // 显式声明必填 +} + +// Push Kit专用的胶囊数据接口 +interface PushKitCapsuleData { + type: number; + status: number; + icon: string; + bgColor: string; // Push Kit要求的字段名 + title: string; + content?: string; // 可选字段 +} + +// JWT令牌响应接口 +interface JwtTokenResponse { + token?: string; +} + +interface ContentTextItem { + text: string; + foregroundColor?: string; +} + +interface NotificationData { + type: number; + contentTitle: string; + contentText: Array; + clickAction: ClickAction; + richProgress: RichProgress; + largeIcon: string; // 添加largeIcon字段 + keywords?: Record; // 修改为可选的对象类型 +} + +interface ActivityData { + notificationData: NotificationData; + capsuleData: PushKitCapsuleData; // 使用Push Kit专用接口 +} + +// PHP接口响应接口 +interface PhpApiResponse { + success?: boolean; + error?: string; + code?: string | number; + msg?: string; +} + +// PHP接口请求数据接口 +interface StartLiveViewRequestData { + token: string; + flight_data: FlightData; + activity_id: number; + authorization: string; +} + +interface UpdateLiveViewRequestData { + token: string; + flight_data: FlightData; + activity_id: number; + authorization: string; +} + +interface EndLiveViewRequestData { + token: string; + activity_id: number; + authorization: string; +} + +const DOMAIN = 0x0000; + +/** + * 实况窗控制器类,负责管理实况窗的整个生命周期 + */ +export class LiveViewController { + private static selectedPilot: PilotData | null = null; + private static deviceId: string = ''; + private static wantAgentInstance: WantAgent | null = null; + private static readonly TEMPLATE_ID = 'flight_live_view_template'; // 模板ID + private static activityId: number = 0; // 实况活动ID + + // 添加PHP接口URL + private static readonly START_LIVE_VIEW_PUSH_API = 'https://system.ldpfly.cn/hmos/startLiveViewPush.php'; + private static readonly UPDATE_LIVE_VIEW_PUSH_API = 'https://system.ldpfly.cn/hmos/updateLiveViewPush.php'; + private static readonly END_LIVE_VIEW_PUSH_API = 'https://system.ldpfly.cn/hmos/endLiveViewPush.php'; + private static readonly ONLINE_PILOTS_API = 'https://system.ldpfly.cn/hmos/OnlinePilots.php'; // 使用OnlinePilots.php + + /** + * 设置选中的飞行员 + * @param pilot 选中的飞行员数据 + */ + public static setSelectedPilot(pilot: PilotData): void { + LiveViewController.selectedPilot = pilot; + // 获取设备ID + LiveViewController.deviceId = deviceInfo.productModel || 'unknown_device'; + hilog.info(DOMAIN, 'LiveViewController', + `Selected pilot: ${pilot.callsign}, Device ID: ${LiveViewController.deviceId}`); + } + + /** + * 设置实况活动ID + * @param id 实况活动ID + */ + public static setActivityId(id: number): void { + LiveViewController.activityId = id; + } + + /** + * 获取设备ID + * @returns string 设备ID + */ + public static getDeviceId(): string { + hilog.info(DOMAIN, 'LiveViewController', `Getting device ID: ${LiveViewController.deviceId}`); + return LiveViewController.deviceId; + } + + /** + * 启动实况窗 + * @returns Promise - 启动结果 + */ + public static async startLiveView(): Promise { + hilog.info(DOMAIN, 'LiveViewController', 'Starting live view'); + + if (!LiveViewController.selectedPilot) { + const errorMsg = "请先选择一个飞行员"; + hilog.error(DOMAIN, 'LiveViewController', errorMsg); + throw new Error(errorMsg); + } + + // 校验实况窗开关是否打开 + if (!await LiveViewController.isLiveViewEnabled()) { + const errorMsg = "Live view is disabled."; + hilog.error(DOMAIN, 'LiveViewController', errorMsg); + throw new Error(errorMsg); + } + + // 构建WantAgent + await LiveViewController.buildWantAgent(); + + // 转换选中的飞行员数据为航班数据格式 + const flightData = LiveViewController.convertPilotToFlightData(LiveViewController.selectedPilot); + hilog.info(DOMAIN, 'LiveViewController', `Converted flight data: ${JSON.stringify(flightData)}`); + + // 获取设备ID + const deviceId = LiveViewController.getDeviceId(); + if (!deviceId) { + throw new Error("设备ID获取失败"); + } + + // 获取Push Token + let pushToken: string; + try { + pushToken = await LiveDataService.fetchPushToken(deviceId); + if (!pushToken) { + throw new Error("未能获取到推送令牌"); + } + } catch (tokenError) { + hilog.error(DOMAIN, 'LiveViewController', `获取Push Token失败: ${tokenError.message}`); + throw new Error("获取推送令牌失败: " + tokenError.message); + } + + // 获取JWT令牌 + let authorization: string; + try { + authorization = await LiveViewController.getAuthorizationToken(); + if (!authorization) { + throw new Error("未能获取到授权令牌"); + } + } catch (authError) { + hilog.error(DOMAIN, 'LiveViewController', `获取JWT令牌失败: ${authError.message}`); + throw new Error("获取授权令牌失败: " + authError.message); + } + + // 生成activityId + LiveViewController.activityId = parseInt(Date.now().toString().slice(-9)); + + // 通过Push Kit启动实况窗 + await LiveViewController.startRemoteLiveViewViaPushKit( + pushToken, + flightData, + LiveViewController.activityId, + authorization + ); + + // 创建本地实况窗 + hilog.info(DOMAIN, 'LiveViewController', 'Creating local live view'); + return await LiveDataService.createRemoteLiveView(LiveViewController.deviceId, flightData); + } + + /** + * 更新实况窗 + * @returns Promise - 更新结果 + */ + public static async updateLiveView(): Promise { + hilog.info(DOMAIN, 'LiveViewController', 'Updating live view'); + + if (!LiveViewController.selectedPilot) { + const errorMsg = "没有选中的飞行员"; + hilog.error(DOMAIN, 'LiveViewController', errorMsg); + throw new Error(errorMsg); + } + + // 从OnlinePilots.php重新获取最新的航班数据 + hilog.info(DOMAIN, 'LiveViewController', 'Fetching latest flight data from OnlinePilots.php'); + const flightData = await LiveViewController.fetchFlightDataFromOnlinePilots(); + hilog.info(DOMAIN, 'LiveViewController', `Fetched flight data: ${JSON.stringify(flightData)}`); + + // 获取设备ID + const deviceId = LiveViewController.getDeviceId(); + if (!deviceId) { + throw new Error("设备ID获取失败"); + } + + // 获取Push Token + let pushToken: string; + try { + pushToken = await LiveDataService.fetchPushToken(deviceId); + if (!pushToken) { + throw new Error("未能获取到推送令牌"); + } + } catch (tokenError) { + hilog.error(DOMAIN, 'LiveViewController', `获取Push Token失败: ${tokenError.message}`); + throw new Error("获取推送令牌失败: " + tokenError.message); + } + + // 获取JWT令牌 + let authorization: string; + try { + authorization = await LiveViewController.getAuthorizationToken(); + if (!authorization) { + throw new Error("未能获取到授权令牌"); + } + } catch (authError) { + hilog.error(DOMAIN, 'LiveViewController', `获取JWT令牌失败: ${authError.message}`); + throw new Error("获取授权令牌失败: " + authError.message); + } + + // 通过Push Kit更新实况窗 + await LiveViewController.updateRemoteLiveViewViaPushKit( + pushToken, + flightData, + LiveViewController.activityId, + authorization + ); + + return await LiveDataService.updateRemoteLiveView(LiveViewController.deviceId, flightData); + } + + /** + * 结束实况窗 + * @returns Promise - 结束结果 + */ + public static async endLiveView(): Promise { + hilog.info(DOMAIN, 'LiveViewController', 'Ending live view'); + + if (!LiveViewController.deviceId) { + const errorMsg = "设备ID未获取"; + hilog.error(DOMAIN, 'LiveViewController', errorMsg); + throw new Error(errorMsg); + } + + // 获取设备ID + const deviceId = LiveViewController.getDeviceId(); + if (!deviceId) { + throw new Error("设备ID获取失败"); + } + + // 获取Push Token + let pushToken: string; + try { + pushToken = await LiveDataService.fetchPushToken(deviceId); + if (!pushToken) { + throw new Error("未能获取到推送令牌"); + } + } catch (tokenError) { + hilog.error(DOMAIN, 'LiveViewController', `获取Push Token失败: ${tokenError.message}`); + throw new Error("获取推送令牌失败: " + tokenError.message); + } + + // 获取JWT令牌 + let authorization: string; + try { + authorization = await LiveViewController.getAuthorizationToken(); + if (!authorization) { + throw new Error("未能获取到授权令牌"); + } + } catch (authError) { + hilog.error(DOMAIN, 'LiveViewController', `获取JWT令牌失败: ${authError.message}`); + throw new Error("获取授权令牌失败: " + authError.message); + } + + // 通过Push Kit结束实况窗 + await LiveViewController.endRemoteLiveViewViaPushKit( + pushToken, + LiveViewController.activityId, + authorization + ); + + // 清理WantAgent + if (LiveViewController.wantAgentInstance) { + try { + hilog.info(DOMAIN, 'LiveViewController', 'Cancelling wantAgent'); + await wantAgent.cancel(LiveViewController.wantAgentInstance); + LiveViewController.wantAgentInstance = null; + hilog.info(DOMAIN, 'LiveViewController', 'WantAgent cancelled successfully'); + } catch (error) { + hilog.error(DOMAIN, 'LiveViewController', `Failed to cancel wantAgent: ${error.message}`); + } + } + + // 结束本地实况窗 + try { + await LiveViewController.stopLiveView(); + hilog.info(DOMAIN, 'LiveViewController', 'Local live view stopped successfully'); + } catch (error) { + hilog.error(DOMAIN, 'LiveViewController', `Failed to stop local live view: ${error.message}`); + } + + hilog.info(DOMAIN, 'LiveViewController', 'Ending remote live view'); + return await LiveDataService.endRemoteLiveView(LiveViewController.deviceId); + } + + /** + * 将飞行员数据转换为航班数据格式 + * @param pilot 飞行员数据 + * @returns FlightData 航班数据 + */ + public static convertPilotToFlightData(pilot: PilotData): FlightData { + hilog.info(DOMAIN, 'LiveViewController', `Converting pilot to flight data: ${pilot.callsign}`); + + const flightData: FlightData = { + flightNumber: pilot.callsign, + altitude: pilot.altitude, + speed: pilot.groundSpeed, + origin: pilot.departureAirport || "未知", // 防止空字符串 + destination: pilot.destinationAirport || "未知", // 防止空字符串 + originName: "出发机场", // 固定显示 + destinationName: "到达机场", // 固定显示 + statusCode: 1, + statusText: "飞行中" + }; + + hilog.info(DOMAIN, 'LiveViewController', `Converted flight data: ${JSON.stringify(flightData)}`); + return flightData; + } + + /** + * 校验实况窗功能是否启用 + * @returns boolean - 是否启用 + */ + private static async isLiveViewEnabled(): Promise { + try { + hilog.info(DOMAIN, 'LiveViewController', 'Checking if live view is enabled'); + const enabled = await liveViewManager.isLiveViewEnabled(); + hilog.info(DOMAIN, 'LiveViewController', `Live view enabled status: ${enabled}`); + return enabled; + } catch (error) { + hilog.error(DOMAIN, 'LiveViewController', `Failed to check live view status: ${error.message}`); + return false; + } + } + + /** + * 构建点击动作WantAgent + * @returns Want - 点击后的跳转意图 + */ + public static async buildWantAgent(): Promise { + try { + hilog.info(DOMAIN, 'LiveViewController', 'Building WantAgent'); + + const wantAgentInfo: wantAgent.WantAgentInfo = { + wants: [ + { + bundleName: 'com.example.ldpflyefb', // 应用实际bundleName + abilityName: 'EntryAbility', + moduleName: 'entry', + parameters: { + targetPage: 'map', + flightNo: LiveViewController.selectedPilot?.callsign || '', + departure: LiveViewController.selectedPilot?.departureAirport || '', + destination: LiveViewController.selectedPilot?.destinationAirport || '' + } + } as Want + ], + operationType: wantAgent.OperationType.START_ABILITY, + requestCode: 0, + wantAgentFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG] + }; + + hilog.info(DOMAIN, 'LiveViewController', `WantAgent info: ${JSON.stringify(wantAgentInfo)}`); + LiveViewController.wantAgentInstance = await wantAgent.getWantAgent(wantAgentInfo); + hilog.info(DOMAIN, 'LiveViewController', 'WantAgent created successfully'); + } catch (error) { + hilog.error(DOMAIN, 'LiveViewController', `Failed to create WantAgent: ${error.message}`); + throw new Error(`WantAgent创建失败: ${error.message}`); + } + } + + /** + * 获取WantAgent实例 + * @returns WantAgent实例 + */ + public static getWantAgent(): WantAgent | null { + hilog.info(DOMAIN, 'LiveViewController', + `Getting WantAgent instance: ${LiveViewController.wantAgentInstance !== null}`); + return LiveViewController.wantAgentInstance; + } + + /** + * 构建实况胶囊数据(本地API格式) + * @param flightData 航班数据 + * @returns capsuleData 实况胶囊数据 + */ + public static buildCapsuleData(flightData: FlightData): CapsuleData { + hilog.info(DOMAIN, 'LiveViewController', `Building capsule data for flight: ${flightData.flightNumber}`); + + // 提供默认值而不是抛出错误 + const speed = flightData.speed || 0; + const altitude = flightData.altitude || 0; + + const capsuleData: CapsuleData = { + //实况胶囊数据显示 + type: 1, + status: 1, + icon: "icon.png", //实况胶囊左侧图标定义,你爹地默认使用LDP FLY的图标 + bgColor: "#FF0A59F7", // Push Kit格式 + backgroundColor: "#FF0A59F7", // 本地API格式 + title: "飞行中", // 固定显示"飞行中" + content: flightData.flightNumber // 显示航班号 + }; + + hilog.info(DOMAIN, 'LiveViewController', `Built capsule data: ${JSON.stringify(capsuleData)}`); + return capsuleData; + } + + /** + * 转换胶囊数据为Push Kit格式 + * @param capsule 本地胶囊数据 + * @returns PushKitCapsuleData Push Kit格式的胶囊数据 + */ + private static convertCapsuleForPushKit(capsule: CapsuleData): PushKitCapsuleData { + hilog.info(DOMAIN, 'LiveViewController', 'Converting capsule data for Push Kit'); + + // 验证数据完整性 + if (!capsule || !capsule.type || !capsule.status || capsule.icon === undefined || !capsule.bgColor || + !capsule.title) { + const errorMsg = "胶囊数据不完整"; + hilog.error(DOMAIN, 'LiveViewController', `${errorMsg}. Capsule data: ${JSON.stringify(capsule)}`); + throw new Error(errorMsg); + } + + // 验证胶囊类型 + if (capsule.type !== 1 && capsule.type !== 2) { + const errorMsg = "胶囊类型必须为1或2"; + hilog.error(DOMAIN, 'LiveViewController', `${errorMsg}. Type: ${capsule.type}`); + throw new Error(errorMsg); + } + + // 验证背景颜色格式 + if (!/^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(capsule.bgColor)) { + const errorMsg = "背景颜色格式必须为#RRGGBB或#AARRGGBB"; + hilog.error(DOMAIN, 'LiveViewController', `${errorMsg}. Color: ${capsule.bgColor}`); + throw new Error(errorMsg); + } + + const pushKitCapsuleData: PushKitCapsuleData = { + type: capsule.type, + status: capsule.status, + icon: capsule.icon || "flight_icon.png", // 提供默认图标 + bgColor: capsule.bgColor, // 字段名转换 + title: capsule.title, + content: capsule.content // 可选字段 + }; + + hilog.info(DOMAIN, 'LiveViewController', `Converted PushKit capsule data: ${JSON.stringify(pushKitCapsuleData)}`); + return pushKitCapsuleData; + } + + /** + * 通过PHP接口启动实况窗 + * @param token Push Token + * @param flightData 航班数据 + * @param activityId 实况活动ID + * @param authorization JWT授权令牌 + */ + public static async startRemoteLiveViewViaPushKit(token: string, flightData: FlightData, activityId: number, + authorization: string): Promise { + try { + hilog.info(DOMAIN, 'LiveViewController', `Starting remote live view via PHP API. Activity ID: ${activityId}`); + + const httpRequest = http.createHttp(); + const requestData: StartLiveViewRequestData = { + token: token, + flight_data: flightData, + activity_id: activityId, + authorization: authorization + }; + + hilog.info(DOMAIN, 'LiveViewController', `Request data: ${JSON.stringify(requestData)}`); + + const response = await httpRequest.request( + LiveViewController.START_LIVE_VIEW_PUSH_API, + { + method: http.RequestMethod.POST, + header: { + 'Content-Type': 'application/json' + }, + extraData: JSON.stringify(requestData) + } + ); + + hilog.info(DOMAIN, 'LiveViewController', + `Start remote live view via PHP API response code: ${response.responseCode}`); + hilog.info(DOMAIN, 'LiveViewController', `Start remote live view via PHP API response: ${response.result}`); + + const responseText = response.result as string; + const responseJson = JSON.parse(responseText) as PhpApiResponse; + + hilog.info(DOMAIN, 'LiveViewController', `Parsed response: ${JSON.stringify(responseJson)}`); + + // 修复错误判断逻辑:将"80000000"视为成功响应 + if ((responseJson.success === false) || + (responseJson.code && responseJson.code !== "80000000" && responseJson.code !== "200" && + responseJson.code !== 200)) { + const errorMsg = `PHP API Error: ${responseJson.msg || responseJson.error || responseJson.code}`; + hilog.error(DOMAIN, 'LiveViewController', errorMsg); + throw new Error(errorMsg); + } + + hilog.info(DOMAIN, 'LiveViewController', 'Remote live view started successfully via PHP API'); + } catch (error) { + const err = error as Error; + hilog.error(DOMAIN, 'LiveViewController', `Failed to start remote live view via PHP API: ${err.message}`); + hilog.error(DOMAIN, 'LiveViewController', `Stack trace: ${err.stack}`); + throw new Error(`通过PHP接口启动实况窗失败: ${err.message}`); + } + } + + /** + * 通过PHP接口更新实况窗 + * @param token Push Token + * @param flightData 航班数据 + * @param activityId 实况活动ID + * @param authorization JWT授权令牌 + */ + public static async updateRemoteLiveViewViaPushKit(token: string, flightData: FlightData, activityId: number, + authorization: string): Promise { + try { + hilog.info(DOMAIN, 'LiveViewController', `Updating remote live view via PHP API. Activity ID: ${activityId}`); + + const httpRequest = http.createHttp(); + const requestData: UpdateLiveViewRequestData = { + token: token, + flight_data: flightData, + activity_id: activityId, + authorization: authorization + }; + + hilog.info(DOMAIN, 'LiveViewController', `Request data: ${JSON.stringify(requestData)}`); + + const response = await httpRequest.request( + LiveViewController.UPDATE_LIVE_VIEW_PUSH_API, + { + method: http.RequestMethod.POST, + header: { + 'Content-Type': 'application/json' + }, + extraData: JSON.stringify(requestData) + } + ); + + hilog.info(DOMAIN, 'LiveViewController', + `Update remote live view via PHP API response code: ${response.responseCode}`); + hilog.info(DOMAIN, 'LiveViewController', `Update remote live view via PHP API response: ${response.result}`); + + const responseText = response.result as string; + const responseJson = JSON.parse(responseText) as PhpApiResponse; + + hilog.info(DOMAIN, 'LiveViewController', `Parsed response: ${JSON.stringify(responseJson)}`); + + // 修复错误判断逻辑:将"80000000"视为成功响应 + if ((responseJson.success === false) || + (responseJson.code && responseJson.code !== "80000000" && responseJson.code !== "200" && + responseJson.code !== 200)) { + const errorMsg = `PHP API Error: ${responseJson.msg || responseJson.error || responseJson.code}`; + hilog.error(DOMAIN, 'LiveViewController', errorMsg); + throw new Error(errorMsg); + } + + hilog.info(DOMAIN, 'LiveViewController', 'Remote live view updated successfully via PHP API'); + } catch (error) { + const err = error as Error; + hilog.error(DOMAIN, 'LiveViewController', `Failed to update remote live view via PHP API: ${err.message}`); + hilog.error(DOMAIN, 'LiveViewController', `Stack trace: ${err.stack}`); + throw new Error(`通过PHP接口更新实况窗失败: ${err.message}`); + } + } + + /** + * 通过PHP接口结束实况窗 + * @param token Push Token + * @param activityId 实况活动ID + * @param authorization JWT授权令牌 + */ + public static async endRemoteLiveViewViaPushKit(token: string, activityId: number, + authorization: string): Promise { + try { + hilog.info(DOMAIN, 'LiveViewController', `Ending remote live view via PHP API. Activity ID: ${activityId}`); + + const httpRequest = http.createHttp(); + const requestData: EndLiveViewRequestData = { + token: token, + activity_id: activityId, + authorization: authorization + }; + + hilog.info(DOMAIN, 'LiveViewController', `Request data: ${JSON.stringify(requestData)}`); + + const response = await httpRequest.request( + LiveViewController.END_LIVE_VIEW_PUSH_API, + { + method: http.RequestMethod.POST, + header: { + 'Content-Type': 'application/json' + }, + extraData: JSON.stringify(requestData) + } + ); + + hilog.info(DOMAIN, 'LiveViewController', + `End remote live view via PHP API response code: ${response.responseCode}`); + hilog.info(DOMAIN, 'LiveViewController', `End remote live view via PHP API response: ${response.result}`); + + const responseText = response.result as string; + const responseJson = JSON.parse(responseText) as PhpApiResponse; + + hilog.info(DOMAIN, 'LiveViewController', `Parsed response: ${JSON.stringify(responseJson)}`); + + // 修复错误判断逻辑:将"80000000"视为成功响应 + if ((responseJson.success === false) || + (responseJson.code && responseJson.code !== "80000000" && responseJson.code !== "200" && + responseJson.code !== 200)) { + const errorMsg = `PHP API Error: ${responseJson.msg || responseJson.error || responseJson.code}`; + hilog.error(DOMAIN, 'LiveViewController', errorMsg); + throw new Error(errorMsg); + } + + hilog.info(DOMAIN, 'LiveViewController', 'Remote live view ended successfully via PHP API'); + } catch (error) { + const err = error as Error; + hilog.error(DOMAIN, 'LiveViewController', `Failed to end remote live view via PHP API: ${err.message}`); + hilog.error(DOMAIN, 'LiveViewController', `Stack trace: ${err.stack}`); + throw new Error(`通过PHP接口结束实况窗失败: ${err.message}`); + } + } + + /** + * 构建本地实况窗视图 + * @returns Promise 实况窗对象 + */ + private static async buildLocalLiveView(flightData: FlightData): Promise { + // 获取WantAgent实例 + const wantAgent: WantAgent | null = LiveViewController.getWantAgent(); + if (!wantAgent) { + throw new Error("WantAgent未初始化"); + } + + return { + id: 0, + event: "FLIGHT", + liveViewData: { + primary: { + title: flightData.flightNumber, // 只显示航班号 + content: [ + { text: "高度 " }, + { text: `${flightData.altitude}`, textColor: "#FF0A59F7" }, + { text: " ft" }, + { text: " | " }, + { text: "速度 " }, + { text: `${flightData.speed}`, textColor: "#FF0A59F7" }, + { text: " kts" }, + ], + keepTime: 15, + backgroundType: liveViewManager.BackgroundType.SYS_BACKGROUND_FLIGHT_SUNSET, + clickAction: wantAgent, + layoutData: { + layoutType: liveViewManager.LayoutType.LAYOUT_TYPE_FLIGHT, + spaceIcon: "flight_icon1.png", + firstTitle: flightData.origin || "未知", // 防止空字符串导致参数错误 + firstContent: flightData.originName || "出发机场", + lastTitle: flightData.destination || "未知", // 防止空字符串导致参数错误 + lastContent: flightData.destinationName || "到达机场", + additionalText: "数据来源于FSD服务器,仅供参考 | LDP FLY空天模拟飞行平台", + } + }, + // 添加实况胶囊数据 - 修改为新的显示格式 + capsule: { + type: 1, + status: 1, + icon: "icon.png", + backgroundColor: "#FF0A59F7", + title: "飞行中", // 固定显示"飞行中" + content: flightData.flightNumber // 显示航班号 + } + } + }; + } + + /** + * 更新本地实况窗 + * @returns Promise 更新结果 + */ + public static async updateLocalLiveView(): Promise { + // 校验实况窗开关是否打开 + if (!await LiveViewController.isLiveViewEnabled()) { + throw new Error("Live view is disabled."); + } + + if (!LiveViewController.selectedPilot) { + throw new Error("没有选中的飞行员"); + } + + // 转换选中的飞行员数据为航班数据格式 + const flightData = LiveViewController.convertPilotToFlightData(LiveViewController.selectedPilot); + + // 构建实况窗视图 + const liveView = await LiveViewController.buildLocalLiveView(flightData); + + // 更新实况窗 + return await liveViewManager.updateLiveView(liveView); + } + + /** + * 结束本地实况窗 + * @returns Promise 结束结果 + */ + public static async stopLiveView(): Promise { + // 校验实况窗开关是否打开 + if (!await LiveViewController.isLiveViewEnabled()) { + throw new Error("Live view is disabled."); + } + + if (!LiveViewController.selectedPilot) { + throw new Error("没有选中的飞行员"); + } + + // 转换选中的飞行员数据为航班数据格式 + const flightData = LiveViewController.convertPilotToFlightData(LiveViewController.selectedPilot); + + // 修改实况窗内容为结束状态 + const liveView = await LiveViewController.buildLocalLiveView(flightData); + liveView.liveViewData.primary.title = `${flightData.flightNumber} 已到达`; + liveView.liveViewData.primary.content = [ + { text: '航班已安全降落,' }, + { text: '感谢您的飞行' } + ]; + liveView.liveViewData.primary.layoutData = { + layoutType: liveViewManager.LayoutType.LAYOUT_TYPE_FLIGHT, + spaceIcon: "flight_icon1.png", + firstTitle: flightData.origin || "未知", // 防止空字符串导致参数错误 + firstContent: flightData.originName || "出发机场", + lastTitle: flightData.destination || "未知", // 防止空字符串导致参数错误 + lastContent: flightData.destinationName || "到达机场", + additionalText: "数据来源于FSD服务器,仅供参考 | LDP FLY空天模拟飞行平台", + }; + liveView.liveViewData.capsule = { + type: 1, + status: 1, + icon: "icon.png", + backgroundColor: "#FF0A59F7", + title: `${flightData.flightNumber} 已到达` + }; + + // 结束实况窗 + return await liveViewManager.stopLiveView(liveView); + } + + /** + * 获取JWT授权令牌 + * @returns Promise JWT令牌 + */ + private static async getAuthorizationToken(): Promise { + try { + const httpRequest = http.createHttp(); + const response = await httpRequest.request( + 'https://system.ldpfly.cn/hmos/getJwtToken.php', + { + method: http.RequestMethod.GET, + header: { + 'Content-Type': 'application/json' + } + } + ); + + // 检查HTTP状态码 + if (response.responseCode !== 200) { + throw new Error(`HTTP Error: ${response.responseCode}`); + } + + const resultText = response.result as string; + + // 检查返回内容是否为HTML(错误页面) + if (resultText.trim().startsWith(' 航班数据 + */ + private static async fetchFlightDataFromOnlinePilots(): Promise { + try { + // 获取所有在线飞行员数据 + const onlinePilots = await LiveDataService.fetchOnlinePilots(); + + // 查找与当前选中飞行员匹配的数据 + const selectedPilotCallsign = LiveViewController.selectedPilot?.callsign; + const matchingPilot = onlinePilots.find(pilot => pilot.callsign === selectedPilotCallsign); + + if (matchingPilot) { + // 将PilotData转换为FlightData + return LiveViewController.convertPilotToFlightData(matchingPilot); + } else { + // 如果未找到匹配的飞行员,抛出错误 + throw new Error(`未找到呼号为 ${selectedPilotCallsign} 的飞行员数据`); + } + } catch (error) { + const err = error as Error; + hilog.error(DOMAIN, 'LiveViewController', `获取航班数据失败: ${err.message}`); + throw new Error('获取航班数据失败: ' + err.message); + } + } + +} diff --git a/entry/src/main/ets/utils/LiveViewUtil1.ts b/entry/src/main/ets/utils/LiveViewUtil1.ts new file mode 100644 index 0000000..a26cac4 --- /dev/null +++ b/entry/src/main/ets/utils/LiveViewUtil1.ts @@ -0,0 +1,26 @@ +// 实况窗工具类(ArkTS 严格类型) +import { liveViewManager } from '@kit.LiveViewKit'; +import { BusinessError } from '@kit.BasicServicesKit'; + +export class LiveViewController { + /** + * 启动实况窗 + */ + async startLiveView(): Promise { + try { + // 使用 LiveViewKit 的 liveViewManager 启动实况窗 + await liveViewManager.startLiveView({ + id: 1, + event: 'service_started', + liveViewData: { + primary: {} + } + }); + return '实况窗已启动'; + } catch (err) { + const error = err as BusinessError; + console.error('LiveView start failed:', error.message); + throw error; + } + } +} diff --git a/entry/src/main/ets/utils/LocalNotify.ts b/entry/src/main/ets/utils/LocalNotify.ts new file mode 100644 index 0000000..0f0d56e --- /dev/null +++ b/entry/src/main/ets/utils/LocalNotify.ts @@ -0,0 +1,50 @@ +// 本地通知工具(Notification Kit,进程运行时可用) +// 场景:实时告警(AlertSocket 收到即弹通知栏)、逾期工作项提醒、卡片订餐结果 +// 点击通知打开 App(wantAgent 启动 EntryAbility,失败不影响通知本身) +import { notificationManager } from '@kit.NotificationKit'; +import { wantAgent, Want } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; + +const BUNDLE_NAME = 'com.captain1275.smartseniorhmos'; + +// 发布一条文本通知;通知未授权时静默跳过 +export async function publishLocalNotify(id: number, title: string, text: string, linkUri?: string): Promise { + try { + const enabled = await notificationManager.isNotificationEnabled(); + if (!enabled) { + console.warn('[LocalNotify] notification disabled, skip'); + return; + } + const request: notificationManager.NotificationRequest = { + id: id, + content: { + notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT, + normal: { + title: title, + text: text, + additionalText: '智慧护老', + }, + }, + notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION, + }; + // 点击意图:失败不阻断通知发布 + try { + const wantItem: Want = { bundleName: BUNDLE_NAME, abilityName: 'EntryAbility' }; + if (linkUri && linkUri.length > 0) { + wantItem.uri = linkUri; + } + const wantAgentObj = await wantAgent.getWantAgent({ + wants: [wantItem], + actionType: wantAgent.OperationType.START_ABILITY, + requestCode: 0, + } as wantAgent.WantAgentInfo); + request.wantAgent = wantAgentObj; + } catch (err) { + console.warn('[LocalNotify] wantAgent failed:', (err as BusinessError).message); + } + await notificationManager.publish(request); + console.info('[LocalNotify] published:', title); + } catch (err) { + console.error('[LocalNotify] publish failed:', (err as BusinessError).code, (err as BusinessError).message); + } +} diff --git a/entry/src/main/ets/utils/OcrHelper.ts b/entry/src/main/ets/utils/OcrHelper.ts new file mode 100644 index 0000000..b369c1d --- /dev/null +++ b/entry/src/main/ets/utils/OcrHelper.ts @@ -0,0 +1,112 @@ +// 端侧 OCR 工具(Core Vision Kit textRecognition,API 10+,个人开发者可用) +// 提供:拍照/图库取图 → 文字识别 → 身份证字段提取 +import textRecognition from '@hms.ai.ocr.textRecognition'; +import { image } from '@kit.ImageKit'; +import { fileIo } from '@kit.CoreFileKit'; +import { photoAccessHelper } from '@kit.MediaLibraryKit'; +import { camera, cameraPicker } from '@kit.CameraKit'; +import { common } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; + +// 系统相机拍照(picker 机制,无需 CAMERA 权限),返回图片 uri;取消返回 null +export async function takePhoto(context: common.UIAbilityContext): Promise { + const profile: cameraPicker.PickerProfile = { + cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK, + }; + try { + const result = await cameraPicker.pick(context, [cameraPicker.PickerMediaType.PHOTO], profile); + if (result.resultCode === 0 && result.resultUri.length > 0) { + return result.resultUri; + } + return null; + } catch (err) { + console.error('[OcrHelper] takePhoto failed:', (err as BusinessError).message); + return null; + } +} + +// 图库单选一张,返回图片 uri;取消返回 null +export async function pickOneImage(context: common.UIAbilityContext): Promise { + const options = new photoAccessHelper.PhotoSelectOptions(); + options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE; + options.maxSelectNumber = 1; + const picker = new photoAccessHelper.PhotoViewPicker(); + try { + const result = await picker.select(options); + if (result.photoUris && result.photoUris.length > 0) { + return result.photoUris[0]; + } + return null; + } catch (err) { + console.error('[OcrHelper] pickOneImage failed:', (err as BusinessError).message); + return null; + } +} + +// uri → PixelMap(识别输入) +async function uriToPixelMap(uri: string): Promise { + const file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY); + try { + const source = image.createImageSource(file.fd); + try { + return await source.createPixelMap(); + } finally { + source.release(); + } + } finally { + fileIo.closeSync(file); + } +} + +// 对一张图片做端侧文字识别,返回按行拼接的全文 +export async function recognizeTextFromUri(uri: string): Promise { + const pixelMap = await uriToPixelMap(uri); + try { + const visionInfo: textRecognition.VisionInfo = { pixelMap: pixelMap }; + const result = await textRecognition.recognizeText(visionInfo); + return result.value; + } finally { + pixelMap.release(); + } +} + +export interface IdCardFields { + idNumber: string; + gender: string; // 'male' | 'female' + birthDate: string; // YYYY-MM-DD + name: string; // 可能为空(启发式提取失败时) +} + +// 从身份证 OCR 文本提取字段:证件号(含性别/出生推导)+ 姓名启发式 +export function extractIdCardFields(text: string): IdCardFields { + const result: IdCardFields = { idNumber: '', gender: '', birthDate: '', name: '' }; + // 18 位证件号(容忍前后贴着中文字符) + const match = text.match(/\d{17}[\dXx]/); + if (match !== null) { + const id = match[0].toUpperCase(); + result.idNumber = id; + // 第 17 位奇偶 → 性别 + const genderDigit = Number(id.charAt(16)); + result.gender = genderDigit % 2 === 1 ? 'male' : 'female'; + // 第 7-14 位 → 出生日期 + const birth = `${id.substring(6, 10)}-${id.substring(10, 12)}-${id.substring(12, 14)}`; + if (!Number.isNaN(new Date(birth).getTime())) { + result.birthDate = birth; + } + } + // 姓名启发式:找含"姓名"的行,取其后内容 + const lines = text.split(/\r?\n/); + for (const line of lines) { + const idx = line.indexOf('姓名'); + if (idx >= 0) { + const rest = line.substring(idx + 2).replace(/[::,,\s]/g, ''); + // 过滤掉"民族""性别"等粘连词开头的残片 + const cleaned = rest.replace(/^(民族|性别|出生)/, ''); + if (cleaned.length >= 2 && cleaned.length <= 12 && !/\d/.test(cleaned)) { + result.name = cleaned; + break; + } + } + } + return result; +} diff --git a/entry/src/main/ets/utils/OpenMeteo.ts b/entry/src/main/ets/utils/OpenMeteo.ts new file mode 100644 index 0000000..ae87eef --- /dev/null +++ b/entry/src/main/ets/utils/OpenMeteo.ts @@ -0,0 +1,133 @@ +// 天气客户端:直连 Open-Meteo(免费无需密钥),广州固定坐标 +// 文案映射与后端 common/weather.py 一致 +import { http } from '@kit.NetworkKit'; + +export interface WeatherCurrent { + temperature: number; + code: number; +} + +export interface WeatherDay { + date: string; + code: number; + minT: number; + maxT: number; + rainPct: number; +} + +export interface WeatherData { + current: WeatherCurrent; + daily: Array; +} + +const FORECAST_URL_BASE = 'https://api.open-meteo.com/v1/forecast' + + '?current=temperature_2m,weather_code' + + '&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max' + + '&timezone=Asia%2FShanghai&forecast_days=7'; + +// 默认坐标:广州(养老院所在地),定位失败时回退 +const DEFAULT_LAT = 23.1291; +const DEFAULT_LON = 113.2644; + +export function weatherCodeLabel(code: number): string { + if (code === 0) { + return '晴'; + } + if (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 >= 61 && code <= 67) { + return '雨'; + } + if (code >= 71 && code <= 77) { + return '雪'; + } + if (code >= 80 && code <= 82) { + return '阵雨'; + } + if (code === 85 || code === 86) { + return '阵雪'; + } + if (code >= 95) { + return '雷阵雨'; + } + return '未知'; +} + +interface OpenMeteoResponse { + current: OpenMeteoCurrent; + daily: OpenMeteoDaily; +} + +interface OpenMeteoCurrent { + temperature_2m: number; + weather_code: number; +} + +interface OpenMeteoDaily { + time: Array; + weather_code: Array; + temperature_2m_max: Array; + temperature_2m_min: Array; + precipitation_probability_max: Array; +} + +export async function fetchWeather(lat?: number, lon?: number): Promise { + const latitude = (lat !== undefined && !Number.isNaN(lat)) ? lat : DEFAULT_LAT; + const longitude = (lon !== undefined && !Number.isNaN(lon)) ? lon : DEFAULT_LON; + const url = `${FORECAST_URL_BASE}&latitude=${latitude}&longitude=${longitude}`; + const httpRequest = http.createHttp(); + try { + const response = await httpRequest.request(url, { + method: http.RequestMethod.GET, + connectTimeout: 10000, + readTimeout: 10000, + }); + if (response.responseCode !== 200) { + throw new Error(`weather http ${response.responseCode}`); + } + const parsed = JSON.parse(response.result as string) as OpenMeteoResponse; + const daily: Array = []; + const times = parsed.daily.time; + for (let i = 0; i < times.length; i++) { + daily.push({ + date: times[i], + code: parsed.daily.weather_code[i], + minT: parsed.daily.temperature_2m_min[i], + maxT: parsed.daily.temperature_2m_max[i], + rainPct: parsed.daily.precipitation_probability_max[i], + }); + } + return { + current: { + temperature: parsed.current.temperature_2m, + code: parsed.current.weather_code, + }, + daily: daily, + }; + } catch (err) { + const message = (err as Error).message ?? `${err}`; + throw new Error(`天气获取失败: ${message}`); + } finally { + httpRequest.destroy(); + } +} + +export 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}`; +} diff --git a/entry/src/main/ets/utils/PermissionUtil.ts b/entry/src/main/ets/utils/PermissionUtil.ts new file mode 100644 index 0000000..1eee8c6 --- /dev/null +++ b/entry/src/main/ets/utils/PermissionUtil.ts @@ -0,0 +1,35 @@ +// 权限工具:运行时申请定位权限(ArkTS 严格类型,无 any) +import { abilityAccessCtrl, common, Permissions } from '@kit.AbilityKit'; +import { normalizeError } from '../common/ApiConstants'; + +// 申请定位权限,返回是否全部/部分授权成功(authResults[0] === 0 表示已授权) +export async function requestLocationPermission(context: common.UIAbilityContext): Promise { + try { + const atManager = abilityAccessCtrl.createAtManager(); + const atList: Array = ['ohos.permission.APPROXIMATELY_LOCATION', 'ohos.permission.LOCATION']; + const result = await atManager.requestPermissionsFromUser(context, atList); + if (result.authResults.length > 0 && result.authResults[0] === 0) { + return true; + } + return false; + } catch (err) { + console.error('[PermissionUtil] request location permission failed:', normalizeError(err as Error)); + return false; + } +} + +// 申请麦克风权限(语音控制用) +export async function requestMicPermission(context: common.UIAbilityContext): Promise { + try { + const atManager = abilityAccessCtrl.createAtManager(); + const atList: Array = ['ohos.permission.MICROPHONE']; + const result = await atManager.requestPermissionsFromUser(context, atList); + if (result.authResults.length > 0 && result.authResults[0] === 0) { + return true; + } + return false; + } catch (err) { + console.error('[PermissionUtil] request mic permission failed:', normalizeError(err as Error)); + return false; + } +} diff --git a/entry/src/main/ets/utils/PushManager.ts b/entry/src/main/ets/utils/PushManager.ts new file mode 100644 index 0000000..e6a1d68 --- /dev/null +++ b/entry/src/main/ets/utils/PushManager.ts @@ -0,0 +1,102 @@ +// 华为 Push Kit 接入管理(ArkTS 严格类型,无 any) +// 职责:申请通知授权、获取 push token、登录后把 token 绑定到当前用户 +// 服务端端点(待后端实现,未就绪时静默降级):POST /auth/push-token/ +import { pushService } from '@kit.PushKit'; +import { notificationManager } from '@kit.NotificationKit'; +import { http } from '@kit.NetworkKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { common } from '@kit.AbilityKit'; +import { preferences } from '@kit.ArkData'; +import { deviceInfo } from '@kit.BasicServicesKit'; +import { API_BASE_URL, normalizeError } from '../common/ApiConstants'; + +const PREFS_NAME = 'SmartSeniorPrefs'; +const PREFS_TOKEN = 'accessToken'; + +interface PushTokenUploadResult { + success: boolean; + message: string; +} + +export class PushManager { + private static token: string = ''; + + // 应用启动时调用:申请通知授权 + 获取 push token + static async init(context: common.UIAbilityContext): Promise { + const enabled = await notificationManager.isNotificationEnabled(); + if (!enabled) { + await notificationManager.requestEnableNotification(context); + } + const token = await pushService.getToken(); + PushManager.token = token; + AppStorage.setOrCreate('push_token', token); + await PushManager.uploadTokenAfterLogin(); + } + + static getToken(): string { + return PushManager.token; + } + + static hasToken(): boolean { + return PushManager.token.length > 0; + } + + // 登录成功后调用:把 push token 绑定到当前登录用户,上报到后端 HuaweiPushToken(Celery 经 Push Kit v3 投递) + // 接口契约:POST /api/v1/auth/push-token/,Authorization 携带登录态,重复上报为更新绑定 + // 上报失败静默忽略,不影响登录流程 + static async uploadTokenAfterLogin(): Promise { + if (PushManager.token.length === 0) { + // Token 尚未就绪时再尝试取一次(如启动时 AGC 未就绪) + try { + PushManager.token = await pushService.getToken(); + } catch (err) { + console.warn('[PushManager] retry getToken failed:', normalizeError(err as BusinessError | Error)); + return; + } + } + try { + const httpRequest = http.createHttp(); + try { + // 从应用 preferences 读取当前登录态(登录页调用时 AuthStore 已落盘) + const context = getContext() as common.UIAbilityContext; + const prefs = await preferences.getPreferences(context, PREFS_NAME); + const tokenValue = await prefs.get(PREFS_TOKEN, ''); + const accessToken = typeof tokenValue === 'string' ? tokenValue : ''; + if (accessToken.length === 0) { + console.warn('[PushManager] upload skipped: not logged in'); + return; + } + + const header: Record = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + }; + const response = await httpRequest.request(`${API_BASE_URL}/api/v1/auth/push-token/`, { + method: http.RequestMethod.POST, + header: header, + extraData: JSON.stringify({ + token: PushManager.token, + platform: 'hmos', + device_model: deviceInfo.productModel, + }), + connectTimeout: 10000, + readTimeout: 10000, + }); + console.info(`[PushManager] upload token response: ${response.responseCode}`); + } finally { + httpRequest.destroy(); + } + } catch (err) { + // 网络异常静默忽略:下次登录会重试 + console.warn('[PushManager] upload token failed:', normalizeError(err as BusinessError | Error)); + } + } +} + +// 供推送点击跳转等场景使用的空接口占位(后续接通知消息点击事件时扩展) +export interface PushClickPayload { + page: string; + params: Record; +} + +export type PushTokenResult = PushTokenUploadResult; diff --git a/entry/src/main/ets/utils/Request.ts b/entry/src/main/ets/utils/Request.ts new file mode 100644 index 0000000..c62aa02 --- /dev/null +++ b/entry/src/main/ets/utils/Request.ts @@ -0,0 +1,354 @@ +// 轻量 HTTP 请求封装(ArkTS 严格类型,无 any) +import { http } from '@kit.NetworkKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { util } from '@kit.ArkTS'; +import { fileIo } from '@kit.CoreFileKit'; +import { + API_BASE_URL, + API_PREFIX, + DEFAULT_REQUEST_TIMEOUT, + RefreshResponse, + isAnonymousAuthPath, + normalizeError, +} from '../common/ApiConstants'; +import { AuthStore } from './AuthStore'; + +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +// 无请求体的 POST 调用占位 +export class EmptyBody { +} + +export interface RequestConfig { + url: string; + method?: HttpMethod; + data?: object; + params?: Record; + timeout?: number; + anonymous?: boolean; + retryOn401?: boolean; +} + +export class RequestError extends Error { + statusCode: number; + code: number; + + constructor(message: string, statusCode: number, code: number = -1) { + super(message); + this.statusCode = statusCode; + this.code = code; + } +} + +function buildQuery(params?: Record): string { + if (!params) { + return ''; + } + const keys: string[] = Object.keys(params); + if (keys.length === 0) { + return ''; + } + const parts: string[] = []; + keys.forEach((key: string) => { + const value: string = String(params[key]); + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); + }); + return `?${parts.join('&')}`; +} + +function buildUrl(url: string, params?: Record): string { + const full = `${API_BASE_URL}${API_PREFIX}${url}`; + // 加时间戳参数禁用 HTTP 缓存(避免 GET 返回旧数据) + const merged: Record = {}; + if (params) { + const keys: string[] = Object.keys(params); + keys.forEach((key: string) => { + merged[key] = params[key]; + }); + } + merged['_ts'] = String(Date.now()); + const query = buildQuery(merged); // 以 ? 开头 + // url 已含 ? 时用 & 拼接,避免双问号破坏 query + const hasQuery = full.indexOf('?') >= 0; + if (query.length === 0) { + return full; + } + return hasQuery ? full + query.replace('?', '&') : full + query; +} + +function parseResponse(response: http.HttpResponse): T { + const result = response.result; + if (typeof result === 'string') { + if (result.length === 0) { + return {} as T; + } + return JSON.parse(result) as T; + } + // result 是 ArrayBuffer 时按空处理 + return {} as T; +} + +function extractErrorDetail(response: http.HttpResponse): string { + try { + const parsed = parseResponse>(response); + const message = parsed['message']; + if (typeof message === 'string' && message.length > 0) { + return message; + } + const detail = parsed['detail']; + if (typeof detail === 'string' && detail.length > 0) { + return detail; + } + } catch (e) { + // ignore parse error + } + return `请求失败(${response.responseCode})`; +} + +async function doRequest( + config: RequestConfig, + accessToken: string | null, +): Promise { + const httpRequest = http.createHttp(); + try { + const header: Record = { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + }; + if (accessToken && accessToken.length > 0) { + header['Authorization'] = `Bearer ${accessToken}`; + } + const requestUrl = buildUrl(config.url, config.params); + console.info(`[Request] ${config.method ?? 'GET'} ${requestUrl}`); + const methodMap: Record = { + 'GET': http.RequestMethod.GET, + 'POST': http.RequestMethod.POST, + 'PUT': http.RequestMethod.PUT, + 'PATCH': http.RequestMethod.PATCH, + 'DELETE': http.RequestMethod.DELETE, + }; + const targetMethod: http.RequestMethod = methodMap[config.method ?? 'GET']; + const options: http.HttpRequestOptions = { + method: targetMethod, + header: header, + connectTimeout: config.timeout || DEFAULT_REQUEST_TIMEOUT, + readTimeout: config.timeout || DEFAULT_REQUEST_TIMEOUT, + }; + if (config.data) { + options.extraData = JSON.stringify(config.data); + } + const response = await httpRequest.request(requestUrl, options); + return response; + } catch (err) { + const message = normalizeError(err as BusinessError | Error); + throw new RequestError(message, 0, (err as BusinessError).code ?? -1); + } finally { + httpRequest.destroy(); + } +} + +async function refreshAccessToken(): Promise { + const refreshToken = await AuthStore.getRefreshToken(); + if (!refreshToken || refreshToken.length === 0) { + return null; + } + try { + const httpRequest = http.createHttp(); + try { + const response = await httpRequest.request( + buildUrl('/auth/refresh/', undefined), + { + method: http.RequestMethod.POST, + header: { 'Content-Type': 'application/json' }, + extraData: JSON.stringify({ refresh: refreshToken }), + connectTimeout: DEFAULT_REQUEST_TIMEOUT, + readTimeout: DEFAULT_REQUEST_TIMEOUT, + }, + ); + if (response.responseCode === 200) { + const data = parseResponse(response); + await AuthStore.setTokens(data.access, data.refresh); + return data.access; + } + return null; + } finally { + httpRequest.destroy(); + } + } catch (err) { + return null; + } +} + +export async function request(config: RequestConfig): Promise { + const anonymous = config.anonymous === true || isAnonymousAuthPath(config.url); + const accessToken = anonymous ? null : await AuthStore.getAccessToken(); + const shouldRetry = config.retryOn401 !== false && !anonymous; + + let response = await doRequest(config, accessToken); + + if (response.responseCode === 401 && shouldRetry) { + const newToken = await refreshAccessToken(); + if (newToken) { + response = await doRequest(config, newToken); + } else { + await AuthStore.logout(); + throw new RequestError('登录已过期,请重新登录', 401); + } + } + + if (response.responseCode >= 200 && response.responseCode < 300) { + return parseResponse(response); + } + throw new RequestError(extractErrorDetail(response), response.responseCode); +} + +export const api = { + get: (url: string, params?: Record, config?: Partial) => + request({ ...config, url, method: 'GET', params }), + post: (url: string, data?: object, config?: Partial) => + request({ ...config, url, method: 'POST', data }), + put: (url: string, data?: object, config?: Partial) => + request({ ...config, url, method: 'PUT', data }), + patch: (url: string, data?: object, config?: Partial) => + request({ ...config, url, method: 'PATCH', data }), + delete: (url: string, config?: Partial) => + request({ ...config, url, method: 'DELETE' }), +}; + +// 拉取需要鉴权的媒体二进制(图片等):GET + Authorization,401 时刷新重试一次 +export async function fetchMediaBuffer(url: string): Promise { + const accessToken = await AuthStore.getAccessToken(); + let response = await rawGet(url, accessToken); + if (response.responseCode === 401) { + const newToken = await refreshAccessToken(); + if (!newToken) { + await AuthStore.logout(); + throw new RequestError('登录已过期,请重新登录', 401); + } + response = await rawGet(url, newToken); + } + if (response.responseCode >= 200 && response.responseCode < 300) { + const result = response.result; + if (result instanceof ArrayBuffer) { + return result; + } + throw new RequestError('媒体数据格式异常', response.responseCode); + } + throw new RequestError(`媒体加载失败(${response.responseCode})`, response.responseCode); +} + +async function rawGet(url: string, accessToken: string | null): Promise { + const httpRequest = http.createHttp(); + try { + const header: Record = {}; + if (accessToken && accessToken.length > 0) { + header['Authorization'] = `Bearer ${accessToken}`; + } + return await httpRequest.request(url, { + method: http.RequestMethod.GET, + header: header, + expectDataType: http.HttpDataType.ARRAY_BUFFER, + connectTimeout: DEFAULT_REQUEST_TIMEOUT, + readTimeout: DEFAULT_REQUEST_TIMEOUT, + }); + } catch (err) { + const message = normalizeError(err as BusinessError | Error); + throw new RequestError(message, 0, (err as BusinessError).code ?? -1); + } finally { + httpRequest.destroy(); + } +} + +// multipart 上传:读取本地文件字节,手工组装表单,返回解析后的 JSON 响应 +// fileUris: 媒体库/沙箱 file:// URI;fieldName: 服务端字段名(如 images) +export async function uploadMultipartFiles( + apiPath: string, + fileUris: string[], + fieldName: string, +): Promise { + if (fileUris.length === 0) { + throw new RequestError('没有可上传的文件', 400); + } + const encoder = new util.TextEncoder(); + const boundary = '----SmartSeniorFormBoundary' + Date.now().toString(); + const bodyParts: Uint8Array[] = []; + + const pushString = (value: string): void => { + bodyParts.push(encoder.encode(value)); + }; + + for (let i = 0; i < fileUris.length; i++) { + const uri = fileUris[i]; + const file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY); + try { + const stat = fileIo.statSync(file.fd); + const buffer = new ArrayBuffer(stat.size); + fileIo.readSync(file.fd, buffer); + pushString(`--${boundary}\r\n`); + pushString(`Content-Disposition: form-data; name="${fieldName}"; filename="image${i}.jpg"\r\n`); + pushString('Content-Type: image/jpeg\r\n\r\n'); + bodyParts.push(new Uint8Array(buffer)); + pushString('\r\n'); + } finally { + fileIo.closeSync(file); + } + } + pushString(`--${boundary}--\r\n`); + + // 合并字节 + let total = 0; + bodyParts.forEach((part: Uint8Array) => { + total += part.byteLength; + }); + const body = new Uint8Array(total); + let offset = 0; + bodyParts.forEach((part: Uint8Array) => { + body.set(part, offset); + offset += part.byteLength; + }); + + const accessToken = await AuthStore.getAccessToken(); + const httpRequest = http.createHttp(); + try { + const header: Record = { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + }; + if (accessToken && accessToken.length > 0) { + header['Authorization'] = `Bearer ${accessToken}`; + } + const response = await httpRequest.request(buildUrl(apiPath, undefined), { + method: http.RequestMethod.POST, + header: header, + extraData: body.buffer as object as ArrayBuffer, + connectTimeout: 60000, + readTimeout: 60000, + }); + if (response.responseCode >= 200 && response.responseCode < 300) { + return parseResponse(response); + } + if (response.responseCode === 401) { + const newToken = await refreshAccessToken(); + if (newToken) { + header['Authorization'] = `Bearer ${newToken}`; + const retry = await httpRequest.request(buildUrl(apiPath, undefined), { + method: http.RequestMethod.POST, + header: header, + extraData: body.buffer as ArrayBuffer, + connectTimeout: 60000, + readTimeout: 60000, + }); + if (retry.responseCode >= 200 && retry.responseCode < 300) { + return parseResponse(retry); + } + } + } + throw new RequestError(extractErrorDetail(response), response.responseCode); + } catch (err) { + if (err instanceof RequestError) { + throw err; + } + throw new RequestError(normalizeError(err as BusinessError | Error), 0); + } finally { + httpRequest.destroy(); + } +} diff --git a/entry/src/main/ets/utils/TtsPlayer.ts b/entry/src/main/ets/utils/TtsPlayer.ts new file mode 100644 index 0000000..c0fe19c --- /dev/null +++ b/entry/src/main/ets/utils/TtsPlayer.ts @@ -0,0 +1,49 @@ +// 长者端 · TTS 语音播报(Core Speech Kit 端侧合成,ArkTS 严格类型,无 any) +// 离线中文(聆小珊女声),即发即弃播报:speak 前先 stop 打断上一段 +import { textToSpeech } from '@kit.CoreSpeechKit'; + +let engineCache: textToSpeech.TextToSpeechEngine | null = null; +let requestSeq: number = 0; + +async function getEngine(): Promise { + if (engineCache !== null) { + return engineCache; + } + const params: textToSpeech.CreateEngineParams = { + language: 'zh-CN', + person: 13, + online: 1, + }; + engineCache = await textToSpeech.createEngine(params); + engineCache.setListener({ + onStart: (requestId: string, response: textToSpeech.StartResponse) => { + }, + onComplete: (requestId: string, response: textToSpeech.CompleteResponse) => { + }, + onStop: (requestId: string, response: textToSpeech.StopResponse) => { + }, + onData: (requestId: string, audio: ArrayBuffer, response: textToSpeech.SynthesisResponse) => { + }, + onError: (requestId: string, errorCode: number, errorMessage: string) => { + console.error(`[TtsPlayer] speak failed: ${errorCode} ${errorMessage}`); + }, + }); + return engineCache; +} + +// 播报一段文本(自动打断上一段;空文本忽略) +export async function speakText(text: string): Promise { + const t = text.trim(); + if (t.length === 0) { + return; + } + try { + const engine = await getEngine(); + engine.stop(); + requestSeq += 1; + const requestId = `tts-${Date.now()}-${requestSeq}`; + engine.speak(t, { requestId: requestId }); + } catch (err) { + console.error('[TtsPlayer] engine error'); + } +} diff --git a/entry/src/main/ets/utils/VoiceCommand.ts b/entry/src/main/ets/utils/VoiceCommand.ts new file mode 100644 index 0000000..0932937 --- /dev/null +++ b/entry/src/main/ets/utils/VoiceCommand.ts @@ -0,0 +1,141 @@ +// 长者端 · 语音命令(Core Speech Kit 端侧识别,ArkTS 严格类型,无 any) +// 按真实 SDK 契约:createEngine(online=1 离线) → startListening(sessionId+audioInfo) +// → AudioCapturer 采集 16k/16bit/单声道 PCM → writeAudio(640字节/20ms) → finish(sessionId) +import { speechRecognizer } from '@kit.CoreSpeechKit'; +import { audio } from '@kit.AudioKit'; + +export interface VoiceHandler { + onText: (text: string, final: boolean) => void; + onError: (message: string) => void; + onStopped: () => void; +} + +let engineCache: speechRecognizer.SpeechRecognitionEngine | null = null; +let sessionId: string = ''; +let capturer: audio.AudioCapturer | null = null; +let feeding: boolean = false; +let finishTimer: number = -1; + +export async function getVoiceEngine(): Promise { + if (engineCache !== null) { + return engineCache; + } + const params: speechRecognizer.CreateEngineParams = { + language: 'zh-CN', + online: 1, + }; + engineCache = await speechRecognizer.createEngine(params); + return engineCache; +} + +function stopCapture(): void { + feeding = false; + if (capturer !== null) { + try { + capturer.stop(); + capturer.release(); + } catch (err) { + // 忽略释放异常 + } + capturer = null; + } +} + +async function loopRead(engine: speechRecognizer.SpeechRecognitionEngine, sid: string): Promise { + if (!feeding || capturer === null) { + return; + } + try { + const data = await capturer.read(640, true); + if (data.byteLength > 0) { + engine.writeAudio(sid, new Uint8Array(data)); + } + } catch (err) { + // 读失败则停止喂入,等待 finish + feeding = false; + return; + } + if (feeding) { + await loopRead(engine, sid); + } +} + +export async function startVoice(engine: speechRecognizer.SpeechRecognitionEngine, handler: VoiceHandler): Promise { + sessionId = `voice-${Date.now()}`; + engine.setListener({ + onStart: (sid: string, eventMessage: string) => { + }, + onEvent: (sid: string, eventCode: number, eventMessage: string) => { + // eventCode 1:语音开始(取消待定的 finish);eventCode 3:语音结束(延迟 finish 容忍停顿) + if (eventCode === 1 && finishTimer >= 0) { + clearTimeout(finishTimer); + finishTimer = -1; + } + if (eventCode === 3) { + if (finishTimer >= 0) { + clearTimeout(finishTimer); + } + finishTimer = setTimeout(() => { + finishTimer = -1; + try { + engine.finish(sid); + } catch (err) { + // 忽略 + } + }, 1000); + } + }, + onResult: (sid: string, result: speechRecognizer.SpeechRecognitionResult) => { + handler.onText(result.result, result.isFinal); + }, + onComplete: (sid: string, eventMessage: string) => { + stopCapture(); + handler.onStopped(); + }, + onError: (sid: string, errorCode: number, errorMessage: string) => { + stopCapture(); + handler.onError(errorMessage); + }, + }); + const audioInfo: speechRecognizer.AudioInfo = { + audioType: 'pcm', + sampleRate: 16000, + soundChannel: 1, + sampleBit: 16, + }; + const startParams: speechRecognizer.StartParams = { + sessionId: sessionId, + audioInfo: audioInfo, + }; + engine.startListening(startParams); + // 启动麦克风采集并持续喂入 + const streamInfo: audio.AudioStreamInfo = { + samplingRate: 16000, + channels: audio.AudioChannel.CHANNEL_1, + sampleFormat: audio.AudioSampleFormat.SAMPLE_FORMAT_S16LE, + encodingType: audio.AudioEncodingType.ENCODING_TYPE_RAW, + }; + const capturerInfo: audio.AudioCapturerInfo = { + // 通话场景源:系统自动启用回声消除与噪声抑制,显著减少环境杂音 + source: audio.SourceType.SOURCE_TYPE_VOICE_COMMUNICATION, + capturerFlags: 0, + }; + const options: audio.AudioCapturerOptions = { + streamInfo: streamInfo, + capturerInfo: capturerInfo, + }; + capturer = await audio.createAudioCapturer(options); + await capturer.start(); + feeding = true; + await loopRead(engine, sessionId); +} + +export function stopVoice(engine: speechRecognizer.SpeechRecognitionEngine): void { + feeding = false; + try { + engine.finish(sessionId); + } catch (err) { + // 忽略 + } + stopCapture(); +} diff --git a/entry/src/main/ets/widget/EntryFormAbility.ets b/entry/src/main/ets/widget/EntryFormAbility.ets new file mode 100644 index 0000000..fe9b784 --- /dev/null +++ b/entry/src/main/ets/widget/EntryFormAbility.ets @@ -0,0 +1,566 @@ +// 服务卡片 FormExtensionAbility:定时拉取老人今日订餐并更新卡片 +import { FormExtensionAbility, formProvider, formBindingData, formInfo } from '@kit.FormKit'; +import { Want } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { preferences } from '@kit.ArkData'; +import { http } from '@kit.NetworkKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { API_BASE_URL, API_PREFIX, PREFS_NAME, PREFS_TOKEN } from '../common/ApiConstants'; +import { publishLocalNotify } from '../utils/LocalNotify'; + +interface MealItem { + name: string; + menu: string; + ordered: boolean; + time: string; + reservable?: boolean; +} + +interface DashboardData { + elderly_id: number | null; + elderly_name: string; + service_date: string; + meals: Array; +} + +interface SlotResult { + menu: string; + ordered?: boolean; + reservable?: boolean; +} + +interface OrderItem { + service_date: string; + category: string; + menu: string; + ordered: boolean; +} + +interface OrderHistoryResponse { + reservations: Array; +} + +interface RecItem { + service_date: string; + meal_period: string; + dish_name: string; + reason: string; +} + +interface RecResponse { + elderly_id: number | null; + recommendations: Array; +} + +interface ActItem { + activity_name: string; + activity_date: string; + start_time: string; + end_time: string; + location: string; +} + +interface ActWeek { + week_start: string; + status: string; + items: Array; +} + +interface ReservationBody { + ordered: boolean; + service_date: string; +} + +// 卡片预订按钮状态 +interface MealButton { + text: string; + ordered: string; + date: string; + show: string; +} + +const DOMAIN = 0x0000; + +export default class EntryFormAbility extends FormExtensionAbility { + onAddForm(want: Want): formBindingData.FormBindingData { + hilog.info(DOMAIN, 'testTag', 'EntryFormAbility onAddForm'); + const formId = want.parameters ? String(want.parameters['ohos.extra.param.key.form_identity'] ?? '') : ''; + const formName = want.parameters ? String(want.parameters['ohos.extra.param.key.form_name'] ?? '') : ''; + this.saveFormType(formId, formName); + this.refreshCard(formId, formName); + return formBindingData.createFormBindingData({}); + } + + onUpdateForm(formId: string): void { + hilog.info(DOMAIN, 'testTag', 'EntryFormAbility onUpdateForm formId=%{public}s', formId); + this.refreshCardById(formId); + } + + onFormEvent(formId: string, message: string): void { + hilog.info(DOMAIN, 'testTag', 'EntryFormAbility onFormEvent: %{public}s', message); + // 卡片交互:预订/取消订单(FormLink MESSAGE) + let slot = ''; + let date = ''; + let orderedNow = false; + let isToggle = false; + try { + const params = JSON.parse(message) as Record; + if (String(params['action'] ?? '') === 'toggle_order') { + isToggle = true; + slot = String(params['slot'] ?? ''); + date = String(params['date'] ?? ''); + orderedNow = String(params['ordered'] ?? '0') === '1'; + } + } catch (err) { + hilog.warn(DOMAIN, 'testTag', 'onFormEvent parse message failed'); + } + if (isToggle && slot.length > 0) { + hilog.info(DOMAIN, 'testTag', 'toggle parsed: slot=%{public}s date=%{public}s orderedNow=%{public}d', slot, date, orderedNow ? 1 : 0); + this.handleToggleOrder(formId, slot, date, orderedNow); + return; + } + this.refreshCardById(formId); + // 点 ⟳ 后 5 分钟再自动刷一次,捕捉 App 内刚刚的预订/取消操作 + try { + formProvider.setFormNextRefreshTime(formId, 5, (err: BusinessError) => { + if (err) { + hilog.warn(DOMAIN, 'testTag', 'setFormNextRefreshTime failed: %{public}s', err.message); + } + }); + } catch (err) { + hilog.warn(DOMAIN, 'testTag', 'setFormNextRefreshTime threw'); + } + } + + // 卡片上一键预订/取消:调云端接口,结果发本地通知,并刷新卡片回真实状态 + private async handleToggleOrder(formId: string, slot: string, date: string, orderedNow: boolean): Promise { + const mealName = slot === 'breakfast' ? '早餐' : (slot === 'lunch' ? '午餐' : '晚餐'); + const when = date === this.todayStr() ? '今日' : '明日'; + try { + const token = await this.getToken(); + hilog.info(DOMAIN, 'testTag', 'toggle token len=%{public}d', token.length); + if (token.length === 0 || slot.length === 0 || date.length === 0) { + throw new Error('not ready'); + } + await this.httpPost( + `${API_BASE_URL}${API_PREFIX}/elderly-portal/meals/${slot}/reservation/`, + token, + { ordered: !orderedNow, service_date: date } as ReservationBody, + ); + hilog.info(DOMAIN, 'testTag', 'toggle http ok, publishing notify'); + const title = orderedNow ? '订餐已取消' : '订餐成功'; + const text = orderedNow ? `已取消${when}${mealName}的预订` : `已为您预订${when}${mealName}`; + // 先发通知再刷新卡片:避免刷新耗时后 FormExtensionAbility 进程被冻结导致通知丢失 + await publishLocalNotify(Date.now() % 100000, title, text); + hilog.info(DOMAIN, 'testTag', 'toggle notify published'); + } catch (err) { + hilog.warn(DOMAIN, 'testTag', 'toggle order failed: %{public}s code=%{public}d', (err as BusinessError).message, (err as BusinessError).code); + await publishLocalNotify(Date.now() % 100000, '订餐失败', `${when}${mealName}预订未成功,请打开App重试`); + } + await this.refreshCardById(formId); + } + + private async getToken(): Promise { + try { + const prefs = await preferences.getPreferences(this.context, PREFS_NAME); + const token = await prefs.get(PREFS_TOKEN, ''); + return typeof token === 'string' ? token : ''; + } catch (err) { + return ''; + } + } + + private async saveFormType(formId: string, formName: string): Promise { + try { + const prefs = await preferences.getPreferences(this.context, PREFS_NAME); + await prefs.put(`form_type_${formId}`, formName); + await prefs.flush(); + } catch (err) { + // 忽略持久化失败 + } + } + + private async getFormType(formId: string): Promise { + try { + const prefs = await preferences.getPreferences(this.context, PREFS_NAME); + const value = await prefs.get(`form_type_${formId}`, ''); + return typeof value === 'string' ? value : ''; + } catch (err) { + return ''; + } + } + + private async refreshCardById(formId: string): Promise { + const formName = await this.getFormType(formId); + await this.refreshCard(formId, formName); + } + + private async refreshCard(formId: string, formName: string): Promise { + if (!formId) { + return; + } + try { + const token = await this.getToken(); + if (formName === 'elderly_order_card') { + await this.refreshOrderCard(formId, token); + } else if (formName === 'elderly_rec_card') { + await this.refreshRecCard(formId, token); + } else if (formName === 'elderly_act_card') { + await this.refreshActCard(formId, token); + } else { + await this.refreshMealCard(formId, token); + } + } catch (err) { + hilog.error(DOMAIN, 'testTag', 'card refresh failed: %{public}s', (err as BusinessError).message); + } + } + + private async refreshMealCard(formId: string, token: string): Promise { + const today = await this.fetchDashboard(token); + const serviceDate = today.service_date || this.todayStr(); + const tomorrow = this.addDays(serviceDate, 1); + + const meals: Record = {}; + (today.meals || []).forEach((meal: MealItem) => { + meals[this.slotKey(meal.name)] = meal; + }); + hilog.info(DOMAIN, 'testTag', 'meal card: date=%{public}s meals=%{public}d name=%{public}s', + serviceDate, (today.meals || []).length, today.elderly_name || ''); + + // 每餐按钮数据:已订→✓(取消);可订→订(预订);过餐→隐藏;菜单来自明日时按明日槽位状态 + const mkButton = (slot: string, isToday: boolean, tomorrow: string): MealButton => { + const meal = meals[slot]; + if (meal && meal.ordered) { + return { text: '✓ 已订', ordered: '1', date: serviceDate, show: '1' }; + } + if (meal && meal.reservable === true) { + return { text: '预订', ordered: '0', date: serviceDate, show: '1' }; + } + if (!isToday) { + return { text: '订明日', ordered: '0', date: tomorrow, show: '1' }; + } + return { text: '', ordered: '0', date: serviceDate, show: '0' }; + }; + + // 今日各餐:菜单有效则用今日,否则尝试明日 + let breakfast = this.effectiveText(meals['breakfast']); + let lunch = this.effectiveText(meals['lunch']); + let dinner = this.effectiveText(meals['dinner']); + + const breakfastTomorrow = breakfast.length === 0 || breakfast.indexOf('待更新') >= 0; + const lunchTomorrow = lunch.length === 0 || lunch.indexOf('待更新') >= 0; + const dinnerTomorrow = dinner.length === 0 || dinner.indexOf('待更新') >= 0; + if (breakfastTomorrow) { + breakfast = await this.fetchSlotMenu(token, 'breakfast', tomorrow); + } + if (lunchTomorrow) { + lunch = await this.fetchSlotMenu(token, 'lunch', tomorrow); + } + if (dinnerTomorrow) { + dinner = await this.fetchSlotMenu(token, 'dinner', tomorrow); + } + + // 菜单为“待更新”或空时归一化为空串,卡片据此显示“暂无菜单” + breakfast = this.normalizeCardText(breakfast); + lunch = this.normalizeCardText(lunch); + dinner = this.normalizeCardText(dinner); + // 已订但菜单未发布时,显示“已订·菜单待更新”而不是“暂无菜单” + if (meals['breakfast'] && meals['breakfast'].ordered && breakfast.length === 0) { + breakfast = '已订·菜单待更新'; + } + if (meals['lunch'] && meals['lunch'].ordered && lunch.length === 0) { + lunch = '已订·菜单待更新'; + } + if (meals['dinner'] && meals['dinner'].ordered && dinner.length === 0) { + dinner = '已订·菜单待更新'; + } + + const bfBtn = mkButton('breakfast', !breakfastTomorrow, tomorrow); + const lcBtn = mkButton('lunch', !lunchTomorrow, tomorrow); + const dnBtn = mkButton('dinner', !dinnerTomorrow, tomorrow); + hilog.info(DOMAIN, 'testTag', 'meal card btns: bf=%{public}s/%{public}s lc=%{public}s/%{public}s dn=%{public}s/%{public}s', + bfBtn.text, bfBtn.show, lcBtn.text, lcBtn.show, dnBtn.text, dnBtn.show); + + const bindingData = formBindingData.createFormBindingData({ + serviceDate: serviceDate, + elderlyName: today.elderly_name || '', + breakfast: breakfast, + lunch: lunch, + dinner: dinner, + orderedCount: String((today.meals || []).filter((m: MealItem) => m.ordered).length), + orderBtnBfText: bfBtn.text, + orderBtnBfOrdered: bfBtn.ordered, + orderBtnBfDate: bfBtn.date, + orderBtnBfShow: bfBtn.show, + orderBtnLcText: lcBtn.text, + orderBtnLcOrdered: lcBtn.ordered, + orderBtnLcDate: lcBtn.date, + orderBtnLcShow: lcBtn.show, + orderBtnDnText: dnBtn.text, + orderBtnDnOrdered: dnBtn.ordered, + orderBtnDnDate: dnBtn.date, + orderBtnDnShow: dnBtn.show, + }); + await formProvider.updateForm(formId, bindingData); + hilog.info(DOMAIN, 'testTag', 'meal card updated'); + } + + private async refreshOrderCard(formId: string, token: string): Promise { + const orders = await this.fetchOrders(token); + const today = this.todayStr(); + const tomorrow = this.addDays(today, 1); + const lines: Array = []; + for (let i = 0; i < orders.length; i++) { + if (lines.length >= 3) { + break; + } + const order = orders[i]; + const when = order.service_date === today ? '今日' : (order.service_date === tomorrow ? '明日' : order.service_date); + let text = `${when}${order.category}`; + const menu = (order.menu || '').trim(); + if (menu.length > 0 && menu.indexOf('待更新') < 0) { + text += ` · ${menu}`; + } + lines.push(text); + } + const bindingData = formBindingData.createFormBindingData({ + orderCount: String(lines.length), + orderLine1: lines.length > 0 ? lines[0] : '', + orderLine2: lines.length > 1 ? lines[1] : '', + orderLine3: lines.length > 2 ? lines[2] : '', + elderlyName: '', + }); + await formProvider.updateForm(formId, bindingData); + hilog.info(DOMAIN, 'testTag', 'order card updated'); + } + + private async refreshRecCard(formId: string, token: string): Promise { + const today = this.todayStr(); + const res = await this.fetchRecommendations(token); + const todayRecs = (res.recommendations || []).filter((r: RecItem) => r.service_date === today); + const lines: Array = []; + for (let i = 0; i < todayRecs.length; i++) { + if (lines.length >= 3) { + break; + } + const rec = todayRecs[i]; + const period = (rec.meal_period || '').trim(); + const dish = (rec.dish_name || '').trim(); + let text = period; + if (dish.length > 0) { + text = `${period} · ${dish}`; + } + if (text.length > 0) { + lines.push(text); + } + } + const bindingData = formBindingData.createFormBindingData({ + recLine1: lines.length > 0 ? lines[0] : '', + recLine2: lines.length > 1 ? lines[1] : '', + recLine3: lines.length > 2 ? lines[2] : '', + elderlyName: '', + }); + await formProvider.updateForm(formId, bindingData); + hilog.info(DOMAIN, 'testTag', 'rec card updated'); + } + + private async refreshActCard(formId: string, token: string): Promise { + const now = new Date(); + const lines: Array = []; + let done = false; + for (let i = 0; i < 5 && !done; i++) { + const cursor = new Date(now); + cursor.setDate(cursor.getDate() + i * 7); + const ws = this.weekStartOf(this.formatYMD(cursor)); + const week = await this.fetchActivityWeek(token, ws); + if (week && week.items && week.items.length > 0 && week.status === 'published') { + for (let j = 0; j < week.items.length; j++) { + if (lines.length >= 3) { + done = true; + break; + } + const item = week.items[j]; + const name = (item.activity_name || '').trim(); + if (name.length > 0) { + lines.push(`${name} · ${this.cardDateLabel(item.activity_date)}`); + } + } + if (lines.length >= 3) { + done = true; + } + } + } + const bindingData = formBindingData.createFormBindingData({ + actLine1: lines.length > 0 ? lines[0] : '', + actLine2: lines.length > 1 ? lines[1] : '', + actLine3: lines.length > 2 ? lines[2] : '', + elderlyName: '', + }); + await formProvider.updateForm(formId, bindingData); + hilog.info(DOMAIN, 'testTag', 'act card updated'); + } + + private slotKey(mealName: string): string { + if (mealName && mealName.indexOf('早餐') >= 0) { + return 'breakfast'; + } + if (mealName && mealName.indexOf('午餐') >= 0) { + return 'lunch'; + } + return 'dinner'; + } + + private effectiveText(meal: MealItem | undefined): string { + if (!meal) { + return ''; + } + return (meal.menu || '').trim(); + } + + private normalizeCardText(text: string): string { + const t = (text || '').trim(); + if (t.length === 0 || t.indexOf('待更新') >= 0) { + return ''; + } + return t; + } + + private 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}`; + } + + private addDays(dateStr: string, days: number): string { + const d = new Date(dateStr + 'T00:00:00'); + if (Number.isNaN(d.getTime())) { + return dateStr; + } + d.setDate(d.getDate() + days); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${d.getFullYear()}-${mm}-${dd}`; + } + + private async fetchDashboard(token: string): Promise { + return this.httpGet(`${API_BASE_URL}${API_PREFIX}/elderly-portal/dashboard/?_ts=${Date.now()}`, token) as Promise; + } + + private async fetchOrders(token: string): Promise> { + try { + const url = `${API_BASE_URL}${API_PREFIX}/elderly-portal/reservations/history/?_ts=${Date.now()}`; + const res = await this.httpGet(url, token) as OrderHistoryResponse; + const today = this.todayStr(); + const recent = (res.reservations || []).filter((o: OrderItem) => o.ordered && o.service_date >= today); + recent.sort((a: OrderItem, b: OrderItem) => { + if (a.service_date < b.service_date) { + return -1; + } + if (a.service_date > b.service_date) { + return 1; + } + return 0; + }); + return recent; + } catch (err) { + return []; + } + } + + private async fetchRecommendations(token: string): Promise { + try { + const url = `${API_BASE_URL}${API_PREFIX}/elderly-portal/recommendations/?_ts=${Date.now()}`; + return await this.httpGet(url, token) as RecResponse; + } catch (err) { + const empty: RecResponse = { elderly_id: null, recommendations: [] }; + return empty; + } + } + + private async fetchActivityWeek(token: string, weekStart: string): Promise { + try { + const url = `${API_BASE_URL}${API_PREFIX}/activities/weeks/${weekStart}/?_ts=${Date.now()}`; + return await this.httpGet(url, token) as ActWeek; + } catch (err) { + return null; + } + } + + private weekStartOf(dateStr: string): string { + const d = new Date(dateStr + 'T00:00:00'); + if (Number.isNaN(d.getTime())) { + return dateStr; + } + const dow = d.getDay() === 0 ? 7 : d.getDay(); + d.setDate(d.getDate() - (dow - 1)); + return this.formatYMD(d); + } + + private 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}`; + } + + private cardDateLabel(iso: string): string { + const d = new Date(iso + 'T00:00:00'); + if (Number.isNaN(d.getTime())) { + return iso; + } + return `${d.getMonth() + 1}/${d.getDate()}`; + } + + private async fetchSlotMenu(token: string, slotKey: string, date: string): Promise { + try { + const url = `${API_BASE_URL}${API_PREFIX}/elderly-portal/meals/${slotKey}/?service_date=${date}&_ts=${Date.now()}`; + const res = await this.httpGet(url, token) as SlotResult; + const menu = (res.menu || '').trim(); + if (menu.length > 0 && menu.indexOf('待更新') < 0) { + return menu; + } + return '待更新'; + } catch (err) { + return '待更新'; + } + } + + private async httpPost(url: string, token: string, body: ReservationBody): Promise { + const httpRequest = http.createHttp(); + const header: Record = { 'Content-Type': 'application/json' }; + if (token.length > 0) { + header['Authorization'] = `Bearer ${token}`; + } + const response = await httpRequest.request(url, { + method: http.RequestMethod.POST, + header: header, + extraData: JSON.stringify(body), + connectTimeout: 10000, + readTimeout: 10000, + }); + httpRequest.destroy(); + hilog.info(DOMAIN, 'testTag', 'httpPost %{public}s -> %{public}d', url, response.responseCode); + if (response.responseCode >= 400) { + throw new Error(`http ${response.responseCode}`); + } + } + + private async httpGet(url: string, token: string): Promise { + const httpRequest = http.createHttp(); + const header: Record = { 'Content-Type': 'application/json' }; + if (token.length > 0) { + header['Authorization'] = `Bearer ${token}`; + } + const response = await httpRequest.request(url, { + method: http.RequestMethod.GET, + header: header, + connectTimeout: 10000, + readTimeout: 10000, + }); + httpRequest.destroy(); + if (response.responseCode !== 200) { + throw new Error(`http ${response.responseCode}`); + } + return JSON.parse(response.result as string) as object; + } +} diff --git a/entry/src/main/ets/widget/entry_card/EntryActCard.ets b/entry/src/main/ets/widget/entry_card/EntryActCard.ets new file mode 100644 index 0000000..abf8e3a --- /dev/null +++ b/entry/src/main/ets/widget/entry_card/EntryActCard.ets @@ -0,0 +1,86 @@ +// 老人端 · 院内活动服务卡片(ArkTS 卡片) +// 数据由 EntryFormAbility 定时从云端拉取近期活动,通过 formBindingData 注入 +let actStorage = new LocalStorage(); + +@Entry(actStorage) +@Component +struct EntryActCard { + @LocalStorageProp('actLine1') actLine1: string = ''; + @LocalStorageProp('actLine2') actLine2: string = ''; + @LocalStorageProp('actLine3') actLine3: string = ''; + @LocalStorageProp('elderlyName') elderlyName: string = ''; + + build() { + Column({ space: 8 }) { + // 头部 + Row() { + Text('🎈 院内活动') + .fontSize(18) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + Blank() + Text(this.elderlyName.length > 0 ? this.elderlyName : '') + .fontSize(12) + .fontColor('#888888') + Text('⟳') + .fontSize(16) + .fontColor('#AAAAAA') + .padding({ left: 8 }) + .onClick(() => { + postCardAction(this, { + action: 'message', + params: { action: 'refresh' } + }); + }) + } + .width('100%') + + if (this.actLine1.length > 0) { + this.ActRow(this.actLine1) + } + if (this.actLine2.length > 0) { + this.ActRow(this.actLine2) + } + if (this.actLine3.length > 0) { + this.ActRow(this.actLine3) + } + + if (this.actLine1.length === 0 && this.actLine2.length === 0 && this.actLine3.length === 0) { + Text('近期暂无活动') + .fontSize(13) + .fontColor('#BBBBBB') + .width('100%') + .padding({ top: 4 }) + } + } + .width('100%') + .height('100%') + .padding(16) + .backgroundColor('#FFFFFF') + .borderRadius(16) + .onClick(() => { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { targetPage: 'pages/elderly/Activities' } + }); + }) + } + + @Builder + ActRow(line: string) { + Row() { + Text('·') + .fontSize(14) + .fontColor('#007AFF') + .width(16) + Text(line) + .fontSize(13) + .fontColor('#333333') + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + } +} diff --git a/entry/src/main/ets/widget/entry_card/EntryCard.ets b/entry/src/main/ets/widget/entry_card/EntryCard.ets new file mode 100644 index 0000000..006be96 --- /dev/null +++ b/entry/src/main/ets/widget/entry_card/EntryCard.ets @@ -0,0 +1,114 @@ +// 老人端 · 今日订餐服务卡片(ArkTS 卡片) +// 数据由 EntryFormAbility 定时从云端拉取,通过 formBindingData 注入 +// 交互式卡片 2.0:每餐带「预订/取消」按钮(FormLink MESSAGE),不打开 App 即可完成订餐 + +let storage = new LocalStorage(); + +@Entry(storage) +@Component +struct EntryCard { + @LocalStorageProp('serviceDate') serviceDate: string = ''; + @LocalStorageProp('breakfast') breakfast: string = ''; + @LocalStorageProp('lunch') lunch: string = ''; + @LocalStorageProp('dinner') dinner: string = ''; + @LocalStorageProp('orderedCount') orderedCount: string = ''; + @LocalStorageProp('elderlyName') elderlyName: string = ''; + @LocalStorageProp('orderBtnBfText') orderBtnBfText: string = ''; + @LocalStorageProp('orderBtnBfOrdered') orderBtnBfOrdered: string = '0'; + @LocalStorageProp('orderBtnBfDate') orderBtnBfDate: string = ''; + @LocalStorageProp('orderBtnBfShow') orderBtnBfShow: string = '0'; + @LocalStorageProp('orderBtnLcText') orderBtnLcText: string = ''; + @LocalStorageProp('orderBtnLcOrdered') orderBtnLcOrdered: string = '0'; + @LocalStorageProp('orderBtnLcDate') orderBtnLcDate: string = ''; + @LocalStorageProp('orderBtnLcShow') orderBtnLcShow: string = '0'; + @LocalStorageProp('orderBtnDnText') orderBtnDnText: string = ''; + @LocalStorageProp('orderBtnDnOrdered') orderBtnDnOrdered: string = '0'; + @LocalStorageProp('orderBtnDnDate') orderBtnDnDate: string = ''; + @LocalStorageProp('orderBtnDnShow') orderBtnDnShow: string = '0'; + + build() { + Column({ space: 8 }) { + // 头部 + Row() { + Text('🍚 今日订餐') + .fontSize(18) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + Blank() + Text(this.elderlyName.length > 0 ? this.elderlyName : this.serviceDate) + .fontSize(12) + .fontColor('#888888') + Text('⟳') + .fontSize(16) + .fontColor('#AAAAAA') + .padding({ left: 8 }) + .onClick(() => { + postCardAction(this, { + action: 'message', + params: { action: 'refresh' } + }); + }) + } + .width('100%') + + // 三餐(含卡片内预订/取消按钮) + this.MealRow('🌅 早', this.breakfast, '#FF9500', this.orderBtnBfText, + this.orderBtnBfOrdered, this.orderBtnBfDate, this.orderBtnBfShow) + this.MealRow('🍚 午', this.lunch, '#007AFF', this.orderBtnLcText, + this.orderBtnLcOrdered, this.orderBtnLcDate, this.orderBtnLcShow) + this.MealRow('🌙 晚', this.dinner, '#34C759', this.orderBtnDnText, + this.orderBtnDnOrdered, this.orderBtnDnDate, this.orderBtnDnShow) + } + .width('100%') + .height('100%') + .padding(16) + .backgroundColor('#FFFFFF') + .borderRadius(16) + .onClick(() => { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { targetPage: 'pages/elderly/ElderlyMealOrder' } + }); + }) + } + + @Builder + MealRow(label: string, content: string, color: string, btnText: string, + ordered: string, date: string, show: string) { + Row() { + Text(label) + .fontSize(13) + .fontWeight(FontWeight.Medium) + .fontColor(color) + .width(44) + Text(content.length > 0 ? content : '暂无菜单') + .fontSize(13) + .fontColor(content.length > 0 ? '#333333' : '#BBBBBB') + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (show === '1' && btnText.length > 0) { + FormLink({ + action: 'message', + abilityName: 'EntryFormAbility', + params: { + action: 'toggle_order', + slot: label.indexOf('早') >= 0 ? 'breakfast' : (label.indexOf('午') >= 0 ? 'lunch' : 'dinner'), + date: date, + ordered: ordered + } + }) { + Text(btnText) + .fontSize(12) + .fontWeight(FontWeight.Bold) + .fontColor('#FFFFFF') + .padding({ left: 10, right: 10, top: 4, bottom: 4 }) + .backgroundColor(ordered === '1' ? '#34C759' : '#007AFF') + .borderRadius(10) + } + } + } + .width('100%') + } +} diff --git a/entry/src/main/ets/widget/entry_card/EntryOrderCard.ets b/entry/src/main/ets/widget/entry_card/EntryOrderCard.ets new file mode 100644 index 0000000..158fbbe --- /dev/null +++ b/entry/src/main/ets/widget/entry_card/EntryOrderCard.ets @@ -0,0 +1,87 @@ +// 老人端 · 我的预订服务卡片(ArkTS 卡片) +// 数据由 EntryFormAbility 定时从云端拉取已预订餐次,通过 formBindingData 注入 +let orderStorage = new LocalStorage(); + +@Entry(orderStorage) +@Component +struct EntryOrderCard { + @LocalStorageProp('orderCount') orderCount: string = ''; + @LocalStorageProp('orderLine1') orderLine1: string = ''; + @LocalStorageProp('orderLine2') orderLine2: string = ''; + @LocalStorageProp('orderLine3') orderLine3: string = ''; + @LocalStorageProp('elderlyName') elderlyName: string = ''; + + build() { + Column({ space: 8 }) { + // 头部 + Row() { + Text('✅ 我的预订') + .fontSize(18) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + Blank() + Text(this.elderlyName.length > 0 ? this.elderlyName : '') + .fontSize(12) + .fontColor('#888888') + Text('⟳') + .fontSize(16) + .fontColor('#AAAAAA') + .padding({ left: 8 }) + .onClick(() => { + postCardAction(this, { + action: 'message', + params: { action: 'refresh' } + }); + }) + } + .width('100%') + + if (this.orderLine1.length > 0) { + this.OrderRow(this.orderLine1) + } + if (this.orderLine2.length > 0) { + this.OrderRow(this.orderLine2) + } + if (this.orderLine3.length > 0) { + this.OrderRow(this.orderLine3) + } + + if (this.orderLine1.length === 0 && this.orderLine2.length === 0 && this.orderLine3.length === 0) { + Text('暂无预订') + .fontSize(13) + .fontColor('#BBBBBB') + .width('100%') + .padding({ top: 4 }) + } + } + .width('100%') + .height('100%') + .padding(16) + .backgroundColor('#FFFFFF') + .borderRadius(16) + .onClick(() => { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { targetPage: 'pages/elderly/ElderlyMealOrder' } + }); + }) + } + + @Builder + OrderRow(line: string) { + Row() { + Text('·') + .fontSize(14) + .fontColor('#FF9500') + .width(16) + Text(line) + .fontSize(13) + .fontColor('#333333') + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + } +} diff --git a/entry/src/main/ets/widget/entry_card/EntryRecCard.ets b/entry/src/main/ets/widget/entry_card/EntryRecCard.ets new file mode 100644 index 0000000..40b6aba --- /dev/null +++ b/entry/src/main/ets/widget/entry_card/EntryRecCard.ets @@ -0,0 +1,86 @@ +// 老人端 · 今日膳食推荐服务卡片(ArkTS 卡片) +// 数据由 EntryFormAbility 定时从云端拉取今日推荐菜,通过 formBindingData 注入 +let recStorage = new LocalStorage(); + +@Entry(recStorage) +@Component +struct EntryRecCard { + @LocalStorageProp('recLine1') recLine1: string = ''; + @LocalStorageProp('recLine2') recLine2: string = ''; + @LocalStorageProp('recLine3') recLine3: string = ''; + @LocalStorageProp('elderlyName') elderlyName: string = ''; + + build() { + Column({ space: 8 }) { + // 头部 + Row() { + Text('🧡 今日推荐') + .fontSize(18) + .fontWeight(FontWeight.Bold) + .fontColor('#333333') + Blank() + Text(this.elderlyName.length > 0 ? this.elderlyName : '') + .fontSize(12) + .fontColor('#888888') + Text('⟳') + .fontSize(16) + .fontColor('#AAAAAA') + .padding({ left: 8 }) + .onClick(() => { + postCardAction(this, { + action: 'message', + params: { action: 'refresh' } + }); + }) + } + .width('100%') + + if (this.recLine1.length > 0) { + this.RecRow(this.recLine1) + } + if (this.recLine2.length > 0) { + this.RecRow(this.recLine2) + } + if (this.recLine3.length > 0) { + this.RecRow(this.recLine3) + } + + if (this.recLine1.length === 0 && this.recLine2.length === 0 && this.recLine3.length === 0) { + Text('今日暂无推荐') + .fontSize(13) + .fontColor('#BBBBBB') + .width('100%') + .padding({ top: 4 }) + } + } + .width('100%') + .height('100%') + .padding(16) + .backgroundColor('#FFFFFF') + .borderRadius(16) + .onClick(() => { + postCardAction(this, { + action: 'router', + abilityName: 'EntryAbility', + params: { targetPage: 'pages/elderly/ElderlyMealOrder' } + }); + }) + } + + @Builder + RecRow(line: string) { + Row() { + Text('·') + .fontSize(14) + .fontColor('#FF9500') + .width(16) + Text(line) + .fontSize(13) + .fontColor('#333333') + .layoutWeight(1) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } + .width('100%') + } +} diff --git a/entry/src/main/module.json5 b/entry/src/main/module.json5 new file mode 100644 index 0000000..7cf1c73 --- /dev/null +++ b/entry/src/main/module.json5 @@ -0,0 +1,178 @@ +{ + "module": { + "name": "entry", + "type": "entry", + "description": "$string:module_desc", + "mainElement": "EntryAbility", + "deviceTypes": [ + "phone", + "tablet", + "2in1" + ], + "deliveryWithInstall": true, + "installationFree": false, + "pages": "$profile:main_pages", + "requestPermissions": [ + { + "name": "ohos.permission.INTERNET" + }, + { + "name": "ohos.permission.APPROXIMATELY_LOCATION", + "reason": "$string:location_reason", + "usedScene": { + "abilities": [ + "EntryAbility" + ], + "when": "inuse" + } + }, + { + "name": "ohos.permission.LOCATION", + "reason": "$string:location_reason", + "usedScene": { + "abilities": [ + "EntryAbility" + ], + "when": "inuse" + } + }, + { + "name": "ohos.permission.MICROPHONE", + "reason": "$string:mic_reason", + "usedScene": { + "abilities": [ + "EntryAbility" + ], + "when": "inuse" + } + }, + { + "name": "ohos.permission.WRITE_CALENDAR", + "reason": "$string:calendar_reason", + "usedScene": { + "abilities": [ + "EntryAbility" + ], + "when": "inuse" + } + }, + { + "name": "ohos.permission.READ_CALENDAR", + "reason": "$string:calendar_reason", + "usedScene": { + "abilities": [ + "EntryAbility" + ], + "when": "inuse" + } + }, + { + "name": "ohos.permission.PUBLISH_AGENT_REMINDER" + } + ], + "abilities": [ + { + "name": "EntryAbility", + "srcEntry": "./ets/entryability/EntryAbility.ets", + "description": "$string:EntryAbility_desc", + "icon": "$media:layered_image", + "label": "$string:EntryAbility_label", + "startWindowIcon": "$media:startIcon", + "startWindowBackground": "$color:start_window_background", + "exported": true, + "metadata": [ + { + "name": "ohos.ability.shortcuts", + "resource": "$profile:shortcuts_config" + } + ], + "skills": [ + { + "entities": [ + "entity.system.home" + ], + "actions": [ + "ohos.want.action.home" + ] + }, + { + "actions": [ + "ohos.want.action.viewData" + ], + "entities": [ + "entity.system.browsable" + ], + "uris": [ + { + "scheme": "smartsenior", + "host": "page" + }, + { + "scheme": "smartsenior", + "host": "weather" + }, + { + "scheme": "smartsenior", + "host": "meal" + }, + { + "scheme": "smartsenior", + "host": "report" + }, + { + "scheme": "smartsenior", + "host": "activities" + }, + { + "scheme": "smartsenior", + "host": "gohome" + } + ] + } + ] + } + ], + "extensionAbilities": [ + { + "name": "EntryBackupAbility", + "srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets", + "type": "backup", + "exported": false, + "metadata": [ + { + "name": "ohos.extension.backup", + "resource": "$profile:backup_config" + } + ], + }, + { + "name": "EntryFormAbility", + "srcEntry": "./ets/widget/EntryFormAbility.ets", + "label": "$string:EntryFormAbility_label", + "description": "$string:EntryFormAbility_desc", + "type": "form", + "exported": true, + "metadata": [ + { + "name": "ohos.extension.form", + "resource": "$profile:form_config" + } + ] + }, + { + "name": "SmartSeniorAgentExtAbility", + "icon": "$media:startIcon", + "description": "数智护老终端智能体:订餐/求助/报修闭环执行", + "type": "agent", + "exported": true, + "srcEntry": "./ets/agentability/SmartSeniorAgentExtAbility.ets", + "metadata": [ + { + "name": "ohos.extension.agent", + "resource": "$profile:agent_config" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/color.json b/entry/src/main/resources/base/element/color.json new file mode 100644 index 0000000..1697789 --- /dev/null +++ b/entry/src/main/resources/base/element/color.json @@ -0,0 +1,60 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#F4F6F8" + }, + { + "name": "background", + "value": "#F4F6F8" + }, + { + "name": "background_card", + "value": "#FFFFFF" + }, + { + "name": "text_primary", + "value": "#000000" + }, + { + "name": "text_secondary", + "value": "#555555" + }, + { + "name": "inactive_tab", + "value": "#999999" + }, + { + "name": "accent_blue", + "value": "#007AFF" + }, + { + "name": "accent_red", + "value": "#E5484D" + }, + { + "name": "accent_green", + "value": "#00B377" + }, + { + "name": "ohos_id_color_blue", + "value": "#007AFF" + }, + { + "name": "ohos_id_color_red", + "value": "#FF4D4D" + }, + { + "name": "ohos_id_color_green", + "value": "#00FF99" + }, + { + "name": "hds_light", + "value": "#00A6FF" + }, + { + "name": "info", + "value": "#1C1C1E" + } + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/element/float.json b/entry/src/main/resources/base/element/float.json new file mode 100644 index 0000000..4ec7d9c --- /dev/null +++ b/entry/src/main/resources/base/element/float.json @@ -0,0 +1,24 @@ +{ + "float": [ + { + "name": "font_size_14", + "value": "14fp" + }, + { + "name": "margin_16", + "value": "16vp" + }, + { + "name": "font_size_12", + "value": "12fp" + }, + { + "name": "font_size_16", + "value": "16fp" + }, + { + "name": "font_size_20", + "value": "20fp" + } + ] +} diff --git a/entry/src/main/resources/base/element/string.json b/entry/src/main/resources/base/element/string.json new file mode 100644 index 0000000..1ba0a91 --- /dev/null +++ b/entry/src/main/resources/base/element/string.json @@ -0,0 +1,80 @@ +{ + "string": [ + { + "name": "module_desc", + "value": "数智护老客户端模块" + }, + { + "name": "EntryAbility_desc", + "value": "数智护老客户端主入口" + }, + { + "name": "EntryAbility_label", + "value": "数智护老" + }, + { + "name": "shortcut_meal", + "value": "订餐" + }, + { + "name": "shortcut_weather", + "value": "天气" + }, + { + "name": "shortcut_dial", + "value": "呼叫家人" + }, + { + "name": "card_name", + "value": "今日订餐" + }, + { + "name": "card_desc", + "value": "查看今日早餐、午餐、晚餐菜单" + }, + { + "name": "EntryFormAbility_label", + "value": "今日订餐卡片" + }, + { + "name": "EntryFormAbility_desc", + "value": "定时更新老人今日订餐卡片" + }, + { + "name": "card_order_name", + "value": "我的预订" + }, + { + "name": "card_order_desc", + "value": "查看已预订的餐食" + }, + { + "name": "card_rec_name", + "value": "今日膳食推荐" + }, + { + "name": "card_rec_desc", + "value": "查看今日三餐推荐菜" + }, + { + "name": "card_act_name", + "value": "院内活动" + }, + { + "name": "card_act_desc", + "value": "查看近期院内活动安排" + }, + { + "name": "location_reason", + "value": "获取当前位置以展示当地天气,便于安排出行" + }, + { + "name": "mic_reason", + "value": "用于语音控制功能,长者说话即可打开对应服务" + }, + { + "name": "calendar_reason", + "value": "将院内活动写入系统日历,方便提醒参加" + } + ] +} diff --git a/entry/src/main/resources/base/media/airport_icon.png b/entry/src/main/resources/base/media/airport_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d32ccf5aa91f3ca556431ae61a7b9242cf7d9fe2 GIT binary patch literal 839 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|G!U;i$lZxy-8q?;Kn_c~qpu?a z!^VE@KZ&eBK4*bPWHAE+-(e7DJf6QIg@J+Tj;D)bNJZS+6NZco3JffYmv8NFWc$H! z{JRtvY3H^?=T269?xHq!oVOY>*?YcQW5v|#zIC01)c*2U;h0Uw^3gs zXlT1IJxvd&Ys2lI%pj_t5lAvHf-GZTkpPhb2_Vv;VN?Pb45L9Znizl~Fq%I=$zZf( z7%dZl$zZfh1Z9TN3Uai~Fj^*#W`ohD0jM+_Z5oU=4S>mDv}pj!45Lkh(Wb#@nK+sa yMw0qMm$~pT-G@yGywqNRe3T1 literal 0 HcmV?d00001 diff --git a/entry/src/main/resources/base/media/default_avatar.png b/entry/src/main/resources/base/media/default_avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..19775ee71b3638da9b9b701d4153778c71d4c6be GIT binary patch literal 907 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|G!U;i$lZxy-8q?;Kn_c~qpu?a z!^VE@KZ&eBK4*bPWHAE+-(e7DJf6QIg@J)t!PCVtq$2L^6+=b_1)c*NG#xp;KD=kH y(=lVhC`!hGz3Oq2*_zHmaKbg+u91uKn$L)elF{r5}E*0OY;u^ literal 0 HcmV?d00001 diff --git a/entry/src/main/resources/base/media/foreground.png b/entry/src/main/resources/base/media/foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..b2affffd13550ddbe2eb57cc81e834f6885bb573 GIT binary patch literal 14290 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 literal 0 HcmV?d00001 diff --git a/entry/src/main/resources/base/media/foreground1.png b/entry/src/main/resources/base/media/foreground1.png new file mode 100644 index 0000000000000000000000000000000000000000..503668245330aa083464f1e5495bcad2daccf88c GIT binary patch literal 907 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5893O0R7}x|G!U;i$lZxy-8q?;Kn_c~qpu?a z!^VE@KZ&eBK4*bPWHAE+-(e7DJf6QIg@J)t!PCVtq$2L^6+=b_1)c*NG#xp;KD=kH z(=l)DUHc1|ffzhp{an^LB{Ts5`J?kY literal 0 HcmV?d00001 diff --git a/entry/src/main/resources/base/media/layered_image.json b/entry/src/main/resources/base/media/layered_image.json new file mode 100644 index 0000000..fb49920 --- /dev/null +++ b/entry/src/main/resources/base/media/layered_image.json @@ -0,0 +1,7 @@ +{ + "layered-image": + { + "background" : "$media:background", + "foreground" : "$media:foreground" + } +} \ No newline at end of file diff --git a/entry/src/main/resources/base/media/logo1.png b/entry/src/main/resources/base/media/logo1.png new file mode 100644 index 0000000000000000000000000000000000000000..b2affffd13550ddbe2eb57cc81e834f6885bb573 GIT binary patch literal 14290 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 literal 0 HcmV?d00001 diff --git a/entry/src/main/resources/base/media/startIcon.png b/entry/src/main/resources/base/media/startIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..205ad8b5a8a42e8762fbe4899b8e5e31ce822b8b GIT binary patch literal 20093 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

c)ijgxDm}8EsRXFt3h@+Ufr7@DN z^55r2UpdZvo*$)c`MJ_3zXBARbH%T}ifygzYy6g*WBtspGU<*Ccb`wpyW!Ui$gZ}y zo>MwK`K>f-62KfvO2{S zXF|ni6T=gB=C>=mF~5ojWS?I%DBt!ouB^&}v*S8G>5&(6>bM<0W9)PIeSXbv;v2lq zgZx&0)nJZqzUPEz=3RZouldy~VSciFe9|fxrs_KoD#u$hYz3BTu8Twxs@yt>*lp{< zm_XbpVEfL5#v}%x;+@AY<0*cV$ZF-248A&7CXCUG-9e@z7Va=V8J*&{q4I$n{~M-~K{qUmg-Y{N~tC__Y!6wZ`uS zAN=8SKnb`wARia}P{>}4q*mFJ2rt$xz9z}40>2@prKgMpJ4y?1MK zsu;8LLY(s8tNKp-L`??i35r}^567PuI=u8S&*EdFoy9Nf;48%{S#m8d=h|q*N!*Hw zE&QzCc2jn4u4(uar*pTPKCQ7DC)&Cs49?>3$7+X~)XJA`!=HT>p7`~r%@S~FvIWT% zL)t28t$h|BY!xpHnSQNXihG*>p${(0U;hi2mrwZcOUrZh0ee^UiT1oYO{3$5Hop*u zLXEN0l1qM=vD`rN)XOLJdon_5oHz3`AzpsrE1f=|*Mk1={U^)6{EcJ3kodUYZmX=p z&l4~2a)h&L*mG4|<3d+3_?Prr)`vgu$Y1U7EWIl2?@iUEd5K>;n9zxxlFNU^0vTLl zH@o9AcfQkuuVr{d?>6N1tv`70$?|*eKGqA1!uC8^rS(s+P1LOQ9lYFac+7nk_^^=}_9|LQHrRm;gm z#jgtmwd-2xd;fSm;rGSZd-@wbDeXS|)%sP&lv@b1qs`Sf43!0V?3qvsHeeF4^Q(*h z^}o7zxuRcU@`@_U0N4FIMxo}rPTLvJc{K#}XhYWmowJJ2$Yjbl`u)zkPnNIv?#GvR zeQ>x@oZ)FOm|m&l>_ivC(ek;URCk@4f5BINBIPcJedSknv#$7sL09O4r%@qb_M zz2et2d?)PSD|vhJv?jf^coe^7;*5D_(i{GoNjc@GFgNZjMJ5=HK91L-#6s_k5ZsDS zGS%RQ&sF+5eNE*3{W~3);ByDsjH9O)4$S@$?yR>?gy?){V`EPI$n>{$7kZJt&E|jq z@9tl&>KhB0wjiX?fvux_ph<@^P`xU#l~@YcVmvoP|52 zFCDST=db-|m-UT`(xE24+%n&4gZ%FnLi&Yo)!)!<`8*?XqEn@~PlG4oI{hPQc|SBA-3UqQo@Ok7n} zIAZ21l@78Rn`X^sw|ukiJP&AnypS?sjm)BYgRrvd_2vm*-zj>cKd@`Ab&91Yp=>6{)F%4)7auKu@lUJhnvWozKNZb^uG+`E@Y3=U zeK~|@uUf1nf;jWRpXQgYuqA_|MTZQJmcB;TNR^GlS{T8}iC6rO{IH|tWqO{uY5h}C zK^05FmfvX7IMk$1hE*ehH{+tKyHIa1DdB;;rJvHi z@XysN8q8vy7k-&z&tLr~zqICPT-#vO+|kk)bI{UP%}!$rHS^6TDD1uXt~a|@W*~+c z8vo^wJW;Rw34f4ZJkG`2_D~Yj%WRNd2O^Mwn=s<$0*s{9@EYCPT5v)bA~e(n|~6M0EUxGtnrcN&$s(s zzN8S(XWAcol9+ za@NCPqQw`HsBTqo#8>DWj&U^~+CTP~&69^IHqX$ty#E|%_>m7|XO7~asM|V+|Xy_l(fh&fm#RNST>VcoN?=6S_DPi%0~BG=sQt4-78)-@|b)lahBHa~PL<9jHj zNE~dl9PG02qUPM@QPu+cEDu-Af8%z}zB%Ihfge*{9Wd$&G+)E(=&9+o!^CjO`cwNdjVRH+WU`h_MXAOitJp5x3ifW{$igPf9iBj$(b=HI#x==`-hy-E&gI#->XR(BW&pMdcoR19-nNcPkY4s2bR7uK27u z;T-wi{Jv$d3tg^Khr|3zu!D-f$3GV1rd-BjB{h8+psmB&uHFO}3e<>-KnIym}P_oSC zslstp61Dm&1NiV|^pEbaNt}ZX!rh1GA<@OoA~K`yhAgd{@foOROsg!`F}gM(u1!jB zP-&PeM7Vk8W1#d^)-p1e`o(13g|c~w?dj`;4_bZu^_E|g3d=E{cLES;rdxmDH283uG=7WUKG<2~ea{IxU4q0( zBCeM((XD0e;O571>R|^u&Ev*jpsQGwzvm-2(K$^ICifY)?_e`E(umG-isbY(H;sFS z_TV{-u;uIR9OWMt?$V=eCxZbQ9k$3lC>2^A@xz~@XvD&(_uWN31AO=Zpf(=jB!lHh zOT3|j8)NsuFr00(J`~5*Aa@-yCcZDeY#2MK^7+byjE?yuYo4B|14zoWZPTeh8BIOF zi#LZ9-0pPpQq1&2arSg`YF@vQoGhb26RLwnlb*1L_^M-Vlx>giHItHpV-y+pt6ZEK z556G7lZ4?GS?qbNp_S;OAM&IlDs9+mIL@;^vinA)D6z3H9OHAVWxzHP_n^luSJ#<< zbsIty2lS^g(Tp%sL>_Jx%DMrbLPR&IRuN*2au@Mv3b3wQaDyVnmOp4Ma3Q*l1@}l- z7!@6xqcC>X;&3#^WC@2>d~Pt-WCFI;DSS*he8-yHfN>hl!&k7gZRoJWX*}IU_<3Dv zFh%O=_d;$wPTu#$88_QzeaYlJH`gOD^~u}%0AtVi0{v!P<5awgzdH2uJ`V|wUL*2lawezA2~fq&{P;mfB?8T6HUC*4h6A&Uoa8O-j$RT~z$aZBVg6 zzF?cyl6N zdHw?sJ7Tp$XXHMr#>SS7hWS(q4Vv|F6FxR`qoAKa__u1W&%AQI4T^VKan^IyU>zfs zE|$R$NQPNwnbWKcmi{dLjG5%b9r@2i8f!K??SvY4H+*lPY@EblJRiC1P#E;CqroIW z@amJ2xy(A56v{9|GuaTpMMj+DK>H#%Xah4-!k=}#^ zneQH-ALI49-brtya+(0Rs?MoH;W4xa=7q~HKFb7Z1nBuy5&@vrkTKXDY=saRII;oP z3R%&P2^nF-NYearIVR*J3O2Ys934KH3%!qF8Ezacu`vg0S*Oab^yt!p+xLq-xy5gM z#Kw5jI=`XA!CkZ&zAqE&VEj1=NFmPhl*4MSO=PEas`~e2-T71-1sApc|fu*Q}= zsYFnC_DZcy+zSDb@&j)&>t^-n;oK7;%>Y=GI zf;q6^#lf=W>#ky4S#ll)lVVQT_DO*_|C(c%5cIB9nT$1w zdZdwu#x~{=-+@S!Al?*`YqRX_$W)w|mL<42l`iKk-%cwYqIN?eH8`i)kL=}d1?JZx ztLCs2KGwvGug#(X==ud4yo;s5T!B+uNNV9YMyc!;d~C+efEeaJa{IVw7aDzJFOkR6 zSlJt<<>?A3vyx@)YW!;#RD~3cJ<+yt$FWi*K*_8K6|i@y5t3Ja zJ+H|ads>I+vjj95MRGK=^x>=qv2joEMXBp_IFN4`AdHaye#ZCSN+T3ki zEEWhGJ-%>&Q^eAnKgqhuJba{|Jl+AxddOr{Cxi+(@50!IbHi4?hjyY5LQ=XVPTEpb zyqVjwx1@vOf~d3GC@cCi=V6PSGqd|Ua>`SZ|JP5mkUUL?=|EPi{@-nlH?JLkAw z*sMbLgtgvL+o_1?*wJfZjcXpC5>GR~M4yu?y`l7N54Pg1hB01ME2+8Z!14qfU-Yz@ zpP&@C_lf&Q^@(4j;1EbkPV$`KhCay2t@XoalE&DO(HG;)bGsV$(1$|8a365@r{WKw zNW$FkEp^Sm<|7b9uV3Ad{N#D~L@0goVuYqx6L^T_<{Zg#=0otZT7J0Sg93< zJ_mX2IquB#Bm6s#^rsweb>du#$y5q2icb}=oNpi;{UA7T{^iK)*yGw5d6=pq_?*D>mRC&iQRDaItw;A9 zUwyN}YMcO55)^&3H9%p>YklyFuHBgRqrZ5o{^}Fg-RyE2Q&BkPr4P7!;2dsBBY5kZ z6MOo=-HSke#!JD&S`O^!e_!8v^T8YV)+p1?{L!gB{K1puy1vT%sWe=-JBLXqC(&~o zh8QdS8g_rYT88wPo<6+$(H>5CKO8#&q^#c>*j4hprAvR9e{%Kyt8YGf`?u>?8Tz14 zS1k!Et{sV(!ehcu#U^0M9yMmukRS`=W<1D5*Xuj%0?f#3B#i1AuV%Dk0a#p(np`Z z@Ny<>{{ZDV5+@v)mOs>&&;9Vv>-)pHaOkS3YygE%;ePHnZ!h`bKx(H9HZuLnZ`piM z2ii=ClLN3rsu>=c{+jNjKd(=0rLpid^!u4*y(mWJPG6kjm0Yv8i=0jt@0q$c?3SO6 zo`T_+i0(Myt98b;JQvD(PJ8@c_^spR4R6xbATVp;gA^fWJoolt6Viy=aHkR(bL6>a z0*u#QIOR-CHs#1eI_@gp{LgMJH~1i?ZcMM{ufkCb2He+@V%l*Br$@ccN`(OGk)9u)8Cl^IS$70>cnNtJOD;^adIv1mfzOH@{j*A zpUGT+)Iu&-&YD8$81J|E-`Afpo?Sod(=~-f1KG?W4N<>A4H|trX(W)6k{Oa&+m(#9NV~FpO<-jgq5FpLo=R80h%`t-tc094&kfl2?<-(g>J|r?=r^r}OA> zmp&f(`pX~wSI3@L@|*kMoPV!t)up3lQ3afNHGkNJ?ukAA%&S+P!*d|=aQo0Nz5YfK zKR4s_UId|>uzYyqbjJt5=GTt(Ez-yS$U9G{Cqm(9+ajN> zgT~ide(a0*RMefm>R_qQXttNTKUJiWa#G(o>gibbxL(-&eO>l^>-4Yw{;}#f=Ndog zTpjgwLr5GKkp=Bm^VjU9%39U~*@|iCk3RCfSN<|`f4G7d?}tSDTy`AIwQL?;#$97+ ztSvnwvYK=4p}Io0?fv>@g@5oyeJpBc$rtZF^xS26hCWZ4#Yok->p2VeHu^YSPUGG2k^A|XtmgmW>+a9E=9)4OCk5TSW^(Rd;pI_JfySLre zQLOv*sbCN46V?6wuS}=FN|eBT_p(bFq*`MXpIA`Vg(EMp(umI{;a4t?=!xmyYV?&H2P7PMKv=d+vjRBWh(As6Lj0Qcn$#3?!%y6`&&<3aj!!;n$@xk0 z*`QFf2~yb7*ZgYBR84)J;s=KZ&x_vE!tWtII60`G5(@|IFyHPr=5zVG<@(X_<1hTc z_kGCwAo)o&!Uw+XL*A!{f;S*LxN;y5=0e-ZrK)pdNED2liw(!iVbw-%n7!XMpG8kA zGUJMmr0RBj5-MyJddQOpL{O*s7%s{`6u+WXrgQwlI?smCIg$&Q{AYgqCt0wKb7$_% zm%{TugWsEv_{Fa|uJO;}cZ_9uLpG0)>jq*Vhu`WPlbLjiH(IU~Fm-o{X+n|rIebs+ zBK*FBMohVN%r4@=_@qH>4)KXqe5CL#cK)Tu;+Dei@z-rsKEYOe;uO{W-~*^lGv{e} zg4af91r84J?WZul<4pXy&Q9bMAD7uEiayKu@j6WtFdw~+#;%<5b$dDfR;X#?4us;} z-~EhV6zs>~=Rof`?o~=VM~9%M_?8J+n!&AcCV)?AP=;fE71{~UeEA>#S{QucDki=r zzHybu$j{hvT>Nr&n2+r=zY;+&dlw*cHh$KbFJ$UN=-6jIG7AR2vDH_c$iN1FmhpRt z?{%2s!?BZglURd~-k|DP8~&9Flv)o?mLI$Jz3h>-Z8i{UeJRS<(K9vL#!-~$F*1Sp z9>4-|wb7EC2gB>kF9$2`EI#_O(HBeOdGZy+=Ze2BPH_+Mi?qgP47=j(>kB=mJ%oMS z9r<0iE@an9F`Z)KGra&4x%#2EIrCiSSMf=2pI?~4w>$UPbpC{gT;8zlrl=Bb2 zc!MuoiVfHWSDf^|NDlF(^ZW;&*`LSHX6X1EeyW$cIeN{P*pA<}=H;OUB#~>P2l%!Y z!u69#KlsSz*U2UJ{M*;+{q-Mwz4pdlJGFtZ-+TGiS1Ql<#B&y|xO2F8BP#-G95X!= zS3AtF&0v5*jT?Lk8~!j1%0_T}otooBko6is#Sgz&6@Aj7$ONp`$^7Ks*zOGN$=Vl+ z!3WfQyRB%BY(65Ff(S*v1=yWtyJ{I0gB$4W-~OP!g>&~BlI$ss{JeWJ0Y~lvE4La}LgwmJ{B^=-^LrxrR*K+!NY34Y z%M z<9FfUS32e(gAJbEtbl5ub8iasSIo+HYW6cI2(;PPCVrX9hj6>)HIID%gYPzH@6^%v zv^{*@-@5)2n!;y#NN$bBu|)+fn^0}89(_q=8AGE|lG!A3qm}-*G$sPd@g2 zSN`*ry_F8$fdaX8yu3>5_^=Mm3a>SxDq|(W496V3gthog+!l-+gI^0x3>K~U0B9_I z@g1v9#%%cbQY(J<)|7{e%NhR$c6@0R)3;{wt|Y5hT-qAn?23((Ie*Is_;P_4Gx3j1 z3^!RMCcZ=O#~*wM_}}BBm6H6+W|(D1K9`SA_)O&v{7zZehxLm7tBQH}eC`H%|3AL+ zwv$WC=ZSiwBbOHn*aasRMW->jDp-wcQfvqt$sDPv&GGOq`KuGkd^o;c>O`@?JJE_` zdU788%6;TNa;;()znFK!uf=i(n|UXb!}$}T5F5S&N6!Fu`(`Au^2Zij=Z|V?HNBZ# z{Jg_J&>P3Qlh3>HhAVHIXs5)?*?J{TB9TPPY-Gp32p`^F3!lv=`TY2MT!#Dn_EX5YDwXjm4@%zo zyA%j0dpPZ8aUi>rp!dHqyG~d+l6Q>+x9T-*oC&4dQmFv;TYcH~Spj>DJ0esIt zzWNO+#A`{>E5i(Xk;Z0`sjgNLsQM^ePYfMu`tZTDpWqGSgiZetwnduxeT7P8ynTsi zel~9SC}kpn5&t6m<~Z?*-@e9Xw_7%@1cxGiwOUv!*ZAgV{^YpI;WyoHSsAi`#H6j9 zt$aSe;%xY&tQ7Q@%CCLw|GfH*c7B0V=63;TLHuy07aBFXpK@e@kz6>#YSGcv3{ghz zzVXF3=^Q@()T&z5KP7&Q>i!XZTNu&$kfkNQnO!8-_aDL+?R~C8sjF4t! z6x@c9tB)3F@nK85F<=By?G&Gi4}X@LiXJ2XmM&tvDMDVeZJcH{s6W+y1bgFn`9~ZXTFjEjziZ(}(o3vn z`%X>ZGshK%2W48h%Jnqix>9=bSGbGC-{Va~Hp{r_k-l2)R5e=9GXJFTue#GuTPtHLO_kpoE;{;<|N8ou=yCIP zN<{A~WY5T@7mLhsKlK)EER*b9LF?v{dT-&+=Hpvd_~PVB{13->Hs|DD_AU++MKR^? zVbs#s_)ceV^X6!`7vaB08NBAP@4xarcZzYI{jMLv_MN@||G4r!x9+?3(b^}k&qm0m zIJo%3!Mf<)XVROminu6NX7e>E)#+h2O$}L)eu$)~=3}XaGUgyZ_V8KMnK#)7zjPHp z_Ts=j%wK(OAJ%4maf|Pa51wLAKZDR6(r+-k<@J}An;-pDHxE9y+0Rj)g#6$aUwirP zX!kYxQ0mVy-QN2yL-92;)+QS*i|kvrv|fAPK+-?Jmin%y1ZS6N0LGw(w2!|y(vgZ*y#F}>^b>-1db)Nj=f;xC|Ft8@YI zMIq1nn~#0+?)d1{!hey9e+8a5izk@{Oplez2GHqrSUlSN&@^wrvVyP!giSlmuO%9r zW`jOGD83?gYTjdlCEZT%G_f_YKb`yp!)N?Qcc8y6-5c~LFW-9YpKRX@b^v?Vs?#fW z*DlT`JnOH$|Jl3C_q|fP=kqnu&(d`7^YSrkS5(VraZMu&zIv_2t3qXyto_-1d=_pk z^vbJk!~$p|XLVszAW2V_Pv+Y=r{jaEb~--#@C&o@YkYyT{(x!uak=@SdyXFer}KN5 zFTlMk$hvZOMZ0@2f4q3@#*LTjFKs?eK|fUioJEMtmjUO-<02&yOE|p|V-%X=6Xv@X(oCxjr1jf2;npdQ$tQM<2QW z=azp~pZ|S`@O0`r&8O4l#eLPLy7n@?{`u15<>(>(HP?sj)ax^gp0C0^Q@=iWK*f2c zD)fL#sXs~F-K&MVM;neWi6M8@tERwteOT%%cv{JMqtu2a&-F?ld~arKwAH@y=LKKw z#h-2EA?L&VSjQ(K-_mq$Dl8u&b4}hKRXUGo8jtD{dqj15STlZy(C<7sI)2CQ_~fnE k9@EG3{4s5ok?kb>|H;3ubeVRY^#A|>07*qoM6N<$f~C=$asU7T literal 0 HcmV?d00001 diff --git a/entry/src/main/resources/base/profile/agent_config.json b/entry/src/main/resources/base/profile/agent_config.json new file mode 100644 index 0000000..afbc5f4 --- /dev/null +++ b/entry/src/main/resources/base/profile/agent_config.json @@ -0,0 +1,62 @@ +{ + "agentCards": [ + { + "agentId": "agent9dc781abfa3d482fad2a9f28224106d9", + "name": "数智护老助手", + "description": "智慧养老终端执行代理:接收老人订餐、呼叫求助、活动天气查询等请求,并在终端直接完成后返回结果", + "provider": { + "organization": "数智护老", + "url": "https://t.gzitvs.cn" + }, + "version": "1.1.0", + "extension": "{\"introduction\":\"您好,我是数智护老助手~可以帮您订餐、呼叫护理员、查活动查天气,也可以陪您聊天。\",\"protocolVersion\":\"1.0\"}", + "capabilities": { + "streaming": true, + "pushNotifications": false, + "stateTransitionHistory": false, + "extendedAgentCard": false + }, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [ + { + "id": "meal_order", + "name": "订餐", + "description": "为老人预订今日餐食(早餐/午餐/晚餐)", + "tags": ["订餐", "点餐", "吃饭", "午餐", "晚餐", "早餐"], + "examples": [ + "我要订今天的午餐", + "帮我订晚餐" + ] + }, + { + "id": "call_help", + "name": "呼叫求助", + "description": "通知护理站,转发老人的求助请求", + "tags": ["求助", "呼叫", "护士", "护理员", "紧急"], + "examples": [ + "帮我叫护士过来一下", + "我不太舒服" + ] + }, + { + "id": "activity_query", + "name": "活动查询", + "description": "查询院内今日与本周的活动安排", + "tags": ["活动", "八段锦", "健身", "文娱"], + "examples": [ + "今日有什么活动", + "明天有什么活动" + ] + } + ], + "iconUrl": "https://t.gzitvs.cn/media/agent_icon.png", + "category": "生活服务", + "type": "APP", + "appInfo": { + "deviceTypes": ["tablet", "phone"], + "minAppVersion": "1.0.0" + } + } + ] +} diff --git a/entry/src/main/resources/base/profile/backup_config.json b/entry/src/main/resources/base/profile/backup_config.json new file mode 100644 index 0000000..78f40ae --- /dev/null +++ b/entry/src/main/resources/base/profile/backup_config.json @@ -0,0 +1,3 @@ +{ + "allowToBackupRestore": true +} \ No newline at end of file diff --git a/entry/src/main/resources/base/profile/form_config.json b/entry/src/main/resources/base/profile/form_config.json new file mode 100644 index 0000000..663905e --- /dev/null +++ b/entry/src/main/resources/base/profile/form_config.json @@ -0,0 +1,92 @@ +{ + "forms": [ + { + "name": "elderly_meal_card", + "displayName": "$string:card_name", + "description": "$string:card_desc", + "src": "./ets/widget/entry_card/EntryCard.ets", + "uiSyntax": "arkts", + "window": { + "designWidth": 720, + "autoDesignWidth": true + }, + "colorMode": "auto", + "isDefault": true, + "updateEnabled": true, + "updateDuration": 1, + "conditionUpdate": [ + "network" + ], + "defaultDimension": "2*2", + "supportDimensions": [ + "2*2" + ] + }, + { + "name": "elderly_order_card", + "displayName": "$string:card_order_name", + "description": "$string:card_order_desc", + "src": "./ets/widget/entry_card/EntryOrderCard.ets", + "uiSyntax": "arkts", + "window": { + "designWidth": 720, + "autoDesignWidth": true + }, + "colorMode": "auto", + "isDefault": false, + "updateEnabled": true, + "updateDuration": 1, + "conditionUpdate": [ + "network" + ], + "defaultDimension": "2*4", + "supportDimensions": [ + "2*4" + ] + }, + { + "name": "elderly_rec_card", + "displayName": "$string:card_rec_name", + "description": "$string:card_rec_desc", + "src": "./ets/widget/entry_card/EntryRecCard.ets", + "uiSyntax": "arkts", + "window": { + "designWidth": 720, + "autoDesignWidth": true + }, + "colorMode": "auto", + "isDefault": false, + "updateEnabled": true, + "updateDuration": 1, + "conditionUpdate": [ + "network" + ], + "defaultDimension": "2*4", + "supportDimensions": [ + "2*4" + ] + }, + { + "name": "elderly_act_card", + "displayName": "$string:card_act_name", + "description": "$string:card_act_desc", + "src": "./ets/widget/entry_card/EntryActCard.ets", + "uiSyntax": "arkts", + "window": { + "designWidth": 720, + "autoDesignWidth": true + }, + "colorMode": "auto", + "isDefault": false, + "updateEnabled": true, + "updateDuration": 1, + "conditionUpdate": [ + "network" + ], + "defaultDimension": "2*4", + "supportDimensions": [ + "2*4" + ] + } + ] +} diff --git a/entry/src/main/resources/base/profile/main_pages.json b/entry/src/main/resources/base/profile/main_pages.json new file mode 100644 index 0000000..4719f21 --- /dev/null +++ b/entry/src/main/resources/base/profile/main_pages.json @@ -0,0 +1,60 @@ +{ + "src": [ + "pages/Index", + "pages/Login", + "pages/nurse/NurseHome", + "pages/nurse/RecordEntry", + "pages/nurse/History", + "pages/nurse/RecordDetail", + "pages/nurse/Alerts", + "pages/nurse/MedicationToDo", + "pages/nurse/RiskPatrol", + "pages/nurse/WorkCenter", + "pages/family/FamilyHome", + "pages/family/Records", + "pages/family/FamilyBind", + "pages/family/FamilyAlerts", + "pages/family/FamilyAlertDetail", + "pages/family/FamilyElderly", + "pages/family/FamilyElderlyDetail", + "pages/family/FamilyElderlyHistory", + "pages/family/FamilyReportCard", + "pages/family/FamilyHealthReport", + "pages/family/FamilyAssistant", + "pages/family/FamilyVerify", + "pages/elderly/ElderlyHome", + "pages/elderly/Assistant", + "pages/elderly/ElderlyMealDetail", + "pages/elderly/ElderlyHistory", + "pages/elderly/ElderlyMealOrder", + "pages/elderly/ElderlyRecommendations", + "pages/elderly/ElderlyList", + "pages/elderly/ElderlyDetail", + "pages/elderly/ElderlyAdd", + "pages/elderly/Activities", + "pages/elderly/ElderlyWeather", + "pages/elderly/ElderlyDial", + "pages/profile/Feedback", + "pages/profile/About", + "pages/meals/MealOrders", + "pages/notifications/Notifications", + "pages/admin/AdminOverview", + "pages/admin/AdminSchedule", + "pages/admin/AdminQuery", + "pages/admin/AdminSalesDetail", + "pages/admin/AdminNurseList", + "pages/admin/AdminNurseDetail", + "pages/admin/DeviceMonitoring", + "pages/admin/AdminNursingRecords", + "pages/admin/AdminRecordDetail", + "pages/admin/AdminFeedback", + "pages/fire-control/FirePatrolHome", + "pages/fire-control/FireRecordDetail", + "pages/sales/SalesHome", + "pages/sales/SalesLeadDetail", + "pages/nurse/NurseAlertProcess", + "pages/nurse/NurseCarePlan", + "pages/nurse/SkinCheck", + "pages/nurse/CancelMeal" + ] +} \ No newline at end of file diff --git a/entry/src/main/resources/base/profile/shortcuts_config.json b/entry/src/main/resources/base/profile/shortcuts_config.json new file mode 100644 index 0000000..9492678 --- /dev/null +++ b/entry/src/main/resources/base/profile/shortcuts_config.json @@ -0,0 +1,49 @@ +{ + "shortcuts": [ + { + "shortcutId": "id_shortcut_meal", + "label": "$string:shortcut_meal", + "icon": "$media:startIcon", + "wants": [ + { + "bundleName": "com.captain1275.smartseniorhmos", + "moduleName": "entry", + "abilityName": "EntryAbility", + "parameters": { + "targetPage": "pages/elderly/ElderlyMealOrder" + } + } + ] + }, + { + "shortcutId": "id_shortcut_weather", + "label": "$string:shortcut_weather", + "icon": "$media:startIcon", + "wants": [ + { + "bundleName": "com.captain1275.smartseniorhmos", + "moduleName": "entry", + "abilityName": "EntryAbility", + "parameters": { + "targetPage": "pages/elderly/ElderlyWeather" + } + } + ] + }, + { + "shortcutId": "id_shortcut_dial", + "label": "$string:shortcut_dial", + "icon": "$media:startIcon", + "wants": [ + { + "bundleName": "com.captain1275.smartseniorhmos", + "moduleName": "entry", + "abilityName": "EntryAbility", + "parameters": { + "targetPage": "pages/elderly/ElderlyDial" + } + } + ] + } + ] +} diff --git a/entry/src/main/resources/dark/element/color.json b/entry/src/main/resources/dark/element/color.json new file mode 100644 index 0000000..4dffa79 --- /dev/null +++ b/entry/src/main/resources/dark/element/color.json @@ -0,0 +1,60 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#0A0A0A" + }, + { + "name": "background", + "value": "#0A0A0A" + }, + { + "name": "background_card", + "value": "#1E1E1E" + }, + { + "name": "text_primary", + "value": "#FFFFFF" + }, + { + "name": "text_secondary", + "value": "#F0F0F0" + }, + { + "name": "inactive_tab", + "value": "#888888" + }, + { + "name": "accent_blue", + "value": "#0A84FF" + }, + { + "name": "accent_red", + "value": "#FF4D4D" + }, + { + "name": "accent_green", + "value": "#00FF99" + }, + { + "name": "ohos_id_color_blue", + "value": "#007AFF" + }, + { + "name": "ohos_id_color_red", + "value": "#FF4D4D" + }, + { + "name": "ohos_id_color_green", + "value": "#00FF99" + }, + { + "name": "hds_light", + "value": "#00A6FF" + }, + { + "name": "info", + "value": "#1C1C1E" + } + ] +} \ No newline at end of file diff --git a/entry/src/mock/mock-config.json5 b/entry/src/mock/mock-config.json5 new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/entry/src/mock/mock-config.json5 @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/entry/src/ohosTest/ets/test/Ability.test.ets b/entry/src/ohosTest/ets/test/Ability.test.ets new file mode 100644 index 0000000..85c78f6 --- /dev/null +++ b/entry/src/ohosTest/ets/test/Ability.test.ets @@ -0,0 +1,35 @@ +import { hilog } from '@kit.PerformanceAnalysisKit'; +import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium'; + +export default function abilityTest() { + describe('ActsAbilityTest', () => { + // Defines a test suite. Two parameters are supported: test suite name and test suite function. + beforeAll(() => { + // Presets an action, which is performed only once before all test cases of the test suite start. + // This API supports only one parameter: preset action function. + }) + beforeEach(() => { + // Presets an action, which is performed before each unit test case starts. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: preset action function. + }) + afterEach(() => { + // Presets a clear action, which is performed after each unit test case ends. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: clear action function. + }) + afterAll(() => { + // Presets a clear action, which is performed after all test cases of the test suite end. + // This API supports only one parameter: clear action function. + }) + it('assertContain', 0, () => { + // Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function. + hilog.info(0x0000, 'testTag', '%{public}s', 'it begin'); + let a = 'abc'; + let b = 'b'; + // Defines a variety of assertion methods, which are used to declare expected boolean conditions. + expect(a).assertContain(b); + expect(a).assertEqual(a); + }) + }) +} \ No newline at end of file diff --git a/entry/src/ohosTest/ets/test/List.test.ets b/entry/src/ohosTest/ets/test/List.test.ets new file mode 100644 index 0000000..794c7dc --- /dev/null +++ b/entry/src/ohosTest/ets/test/List.test.ets @@ -0,0 +1,5 @@ +import abilityTest from './Ability.test'; + +export default function testsuite() { + abilityTest(); +} \ No newline at end of file diff --git a/entry/src/ohosTest/module.json5 b/entry/src/ohosTest/module.json5 new file mode 100644 index 0000000..55725a9 --- /dev/null +++ b/entry/src/ohosTest/module.json5 @@ -0,0 +1,13 @@ +{ + "module": { + "name": "entry_test", + "type": "feature", + "deviceTypes": [ + "phone", + "tablet", + "2in1" + ], + "deliveryWithInstall": true, + "installationFree": false + } +} diff --git a/entry/src/test/List.test.ets b/entry/src/test/List.test.ets new file mode 100644 index 0000000..bb5b5c3 --- /dev/null +++ b/entry/src/test/List.test.ets @@ -0,0 +1,5 @@ +import localUnitTest from './LocalUnit.test'; + +export default function testsuite() { + localUnitTest(); +} \ No newline at end of file diff --git a/entry/src/test/LocalUnit.test.ets b/entry/src/test/LocalUnit.test.ets new file mode 100644 index 0000000..165fc16 --- /dev/null +++ b/entry/src/test/LocalUnit.test.ets @@ -0,0 +1,33 @@ +import { describe, beforeAll, beforeEach, afterEach, afterAll, it, expect } from '@ohos/hypium'; + +export default function localUnitTest() { + describe('localUnitTest', () => { + // Defines a test suite. Two parameters are supported: test suite name and test suite function. + beforeAll(() => { + // Presets an action, which is performed only once before all test cases of the test suite start. + // This API supports only one parameter: preset action function. + }); + beforeEach(() => { + // Presets an action, which is performed before each unit test case starts. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: preset action function. + }); + afterEach(() => { + // Presets a clear action, which is performed after each unit test case ends. + // The number of execution times is the same as the number of test cases defined by **it**. + // This API supports only one parameter: clear action function. + }); + afterAll(() => { + // Presets a clear action, which is performed after all test cases of the test suite end. + // This API supports only one parameter: clear action function. + }); + it('assertContain', 0, () => { + // Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function. + let a = 'abc'; + let b = 'b'; + // Defines a variety of assertion methods, which are used to declare expected boolean conditions. + expect(a).assertContain(b); + expect(a).assertEqual(a); + }); + }); +} \ No newline at end of file diff --git a/hvigor/hvigor-config.json5 b/hvigor/hvigor-config.json5 new file mode 100644 index 0000000..2140b7a --- /dev/null +++ b/hvigor/hvigor-config.json5 @@ -0,0 +1,23 @@ +{ + "modelVersion": "26.0.0", + "dependencies": { + }, + "execution": { + // "analyze": "normal", /* Define the build analyze mode. Value: [ "normal" | "advanced" | "ultrafine" | false ]. Default: "normal" */ + // "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */ + // "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */ + // "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */ + // "typeCheck": false, /* Enable typeCheck. Value: [ true | false ]. Default: false */ + // "optimizationStrategy": "memory" /* Define the optimization strategy. Value: [ "memory" | "performance" ]. Default: "memory" */ + }, + "logging": { + // "level": "info" /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */ + }, + "debugging": { + // "stacktrace": false /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */ + }, + "nodeOptions": { + // "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/ + // "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/ + } +} diff --git a/hvigorfile.ts b/hvigorfile.ts new file mode 100644 index 0000000..47113e2 --- /dev/null +++ b/hvigorfile.ts @@ -0,0 +1,6 @@ +import { appTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: appTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ +} \ No newline at end of file diff --git a/oh-package-lock.json5 b/oh-package-lock.json5 new file mode 100644 index 0000000..c5a91ec --- /dev/null +++ b/oh-package-lock.json5 @@ -0,0 +1,28 @@ +{ + "meta": { + "stableOrder": true, + "enableUnifiedLockfile": false + }, + "lockfileVersion": 3, + "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", + "specifiers": { + "@ohos/hamock@1.0.0": "@ohos/hamock@1.0.0", + "@ohos/hypium@1.0.25": "@ohos/hypium@1.0.25" + }, + "packages": { + "@ohos/hamock@1.0.0": { + "name": "@ohos/hamock", + "version": "1.0.0", + "integrity": "sha512-K6lDPYc6VkKe6ZBNQa9aoG+ZZMiwqfcR/7yAVFSUGIuOAhPvCJAo9+t1fZnpe0dBRBPxj2bxPPbKh69VuyAtDg==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hamock/-/hamock-1.0.0.har", + "registryType": "ohpm" + }, + "@ohos/hypium@1.0.25": { + "name": "@ohos/hypium", + "version": "1.0.25", + "integrity": "sha512-l6uO2pjl8HyEKdekLqQt7tUpWbDqX/42zoAzkagtUVZAW9jT6lMvbe54MVjoLxq/RwQGygRvi6j4GpypSMFSHw==", + "resolved": "https://ohpm.openharmony.cn/ohpm/@ohos/hypium/-/hypium-1.0.25.har", + "registryType": "ohpm" + } + } +} \ No newline at end of file diff --git a/oh-package.json5 b/oh-package.json5 new file mode 100644 index 0000000..e459758 --- /dev/null +++ b/oh-package.json5 @@ -0,0 +1,10 @@ +{ + "modelVersion": "26.0.0", + "description": "Please describe the basic information.", + "dependencies": { + }, + "devDependencies": { + "@ohos/hypium": "1.0.25", + "@ohos/hamock": "1.0.0" + } +}