chore(hmos): 导入生产环境鸿蒙工程
- 以 D:\SmartSenior 生产工程为源,纳入 AppScope、entry、cjagent、hvigor 及根配置 - 移除 build-profile.json5 的 signingConfigs(p12/p7b/cer 路径与加密口令)及 products.signingConfig 引用 - 不纳入本地与开发产物:local.properties、.idea、.hvigor、.codegenie、demo、backup_a2a_native、SESSION_LOG.md、build_err.txt、build/、oh_modules - .gitignore 追加 .codegenie 与密钥文件通配(*.p12/*.p7b/*.cer/*.jks/*.keystore/*.pem)
This commit is contained in:
+29
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"string": [
|
||||||
|
{
|
||||||
|
"name": "app_name",
|
||||||
|
"value": "数智护老"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"layered-image":
|
||||||
|
{
|
||||||
|
"background" : "$media:background",
|
||||||
|
"foreground" : "$media:foreground"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/node_modules
|
||||||
|
/oh_modules
|
||||||
|
/.preview
|
||||||
|
/build
|
||||||
|
/.cxx
|
||||||
|
/.test
|
||||||
|
**/IDL_Dependencies_List~
|
||||||
|
**/build-script-cache
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"apiType": "stageMode",
|
||||||
|
"buildOption": {
|
||||||
|
"cangjieOptions": {
|
||||||
|
"path": "./cjpm.toml",
|
||||||
|
"abiFilters": ["arm64-v8a","x86_64"]
|
||||||
|
},
|
||||||
|
"nativeLib": {
|
||||||
|
"filter": {
|
||||||
|
"enableOverride": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"buildOptionSet": [
|
||||||
|
],
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"name": "default"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version = 0
|
||||||
|
|
||||||
|
[requires]
|
||||||
@@ -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 = { }
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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. */
|
||||||
|
}
|
||||||
@@ -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": {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "libark_interop_api.so",
|
||||||
|
"types": "./ark_interop_api.d.ts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package ohos_app_cangjie_cjagent
|
||||||
|
|
||||||
|
import kit.PerformanceAnalysisKit.Hilog
|
||||||
|
|
||||||
|
public func sayHello(): Unit {
|
||||||
|
Hilog.info(0, "Cangjie", "Hello Cangjie!")
|
||||||
|
}
|
||||||
@@ -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<Array<String>> = [
|
||||||
|
["定餐", "订餐"], ["定饭", "订饭"], ["定个", "订个"], ["定明", "订明"],
|
||||||
|
["订叁", "订餐"], ["订叄", "订餐"], ["订散", "订餐"],
|
||||||
|
["午叁", "午餐"], ["晚叁", "晚餐"], ["早叁", "早餐"],
|
||||||
|
["天汽", "天气"], ["查寻", "查询"], ["枢查", "查询"],
|
||||||
|
["抱告", "报告"], ["健慷", "健康"]
|
||||||
|
]
|
||||||
|
|
||||||
|
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<Rune> {
|
||||||
|
let out = ArrayList<Rune>()
|
||||||
|
for (r in s.runes()) {
|
||||||
|
out.add(r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func runeEqAt(hay: ArrayList<Rune>, needle: ArrayList<Rune>, 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<ArrayList<Rune>> = [
|
||||||
|
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<String>()
|
||||||
|
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<String>()
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "libohos_app_cangjie_cjagent.so",
|
||||||
|
"types": "./Index.d.ts",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"module": {
|
||||||
|
"name": "cjagent",
|
||||||
|
"type": "har",
|
||||||
|
"deviceTypes": [
|
||||||
|
"default",
|
||||||
|
"tablet"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"string": [
|
||||||
|
{
|
||||||
|
"name": "page_show",
|
||||||
|
"value": "page from npm package"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"string": [
|
||||||
|
{
|
||||||
|
"name": "page_show",
|
||||||
|
"value": "page from npm package"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"string": [
|
||||||
|
{
|
||||||
|
"name": "page_show",
|
||||||
|
"value": "page from npm package"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = { }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String, TestSuite>()
|
||||||
|
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<String>, 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<String>()
|
||||||
|
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<Int64>
|
||||||
|
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()}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"color": [
|
||||||
|
{
|
||||||
|
"name": "start_window_background",
|
||||||
|
"value": "#FFFFFF"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"string": [
|
||||||
|
{
|
||||||
|
"name": "module_desc",
|
||||||
|
"value": "module description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EntryAbility_desc",
|
||||||
|
"value": "description"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "EntryAbility_label",
|
||||||
|
"value": "label"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"layered-image":
|
||||||
|
{
|
||||||
|
"background" : "$media:background",
|
||||||
|
"foreground" : "$media:foreground"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,2 @@
|
|||||||
|
{
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/node_modules
|
||||||
|
/oh_modules
|
||||||
|
/.preview
|
||||||
|
/build
|
||||||
|
/.cxx
|
||||||
|
/.test
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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. */
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "entry",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Please describe the basic information.",
|
||||||
|
"main": "",
|
||||||
|
"author": "",
|
||||||
|
"license": "",
|
||||||
|
"dependencies": {
|
||||||
|
"cjagent": "file:../cjagent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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<RpcSuggestion>;
|
||||||
|
suggestions?: Array<RpcSuggestion>;
|
||||||
|
jump?: RpcJump;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcSuggestion {
|
||||||
|
text: string;
|
||||||
|
reply?: string;
|
||||||
|
jump?: RpcJump;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcOpeningTexts {
|
||||||
|
openingText: string;
|
||||||
|
openingQuestions: Array<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcPart {
|
||||||
|
text?: string;
|
||||||
|
mediaType?: string;
|
||||||
|
data?: RpcPartData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcArtifact {
|
||||||
|
artifactId: string;
|
||||||
|
parts: Array<RpcPart>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcStatus {
|
||||||
|
state: string;
|
||||||
|
timestamp?: string;
|
||||||
|
message?: RpcAgentMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcAgentMessage {
|
||||||
|
role?: string;
|
||||||
|
messageId?: string;
|
||||||
|
parts?: Array<RpcPart>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcJump {
|
||||||
|
overlayMode?: string;
|
||||||
|
deepLink?: RpcDeepLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcDeepLink {
|
||||||
|
url: string;
|
||||||
|
appName?: string;
|
||||||
|
appPackage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RpcTask {
|
||||||
|
id: string;
|
||||||
|
contextId: string;
|
||||||
|
status: RpcStatus;
|
||||||
|
artifacts?: Array<RpcArtifact>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RpcPart>;
|
||||||
|
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<boolean> => {
|
||||||
|
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<RpcSuggestion>): RpcFrame {
|
||||||
|
const parts: Array<RpcPart> = [
|
||||||
|
{ 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<RpcFrame> {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
let greeting = '您好';
|
||||||
|
let questions: Array<string> = ['我要订午餐', '帮我叫护士过来一下', '今日有什么活动'];
|
||||||
|
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<RpcPart> = [{ 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<RpcFrame> {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
const candidates: Array<RpcSuggestion> = [];
|
||||||
|
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<RpcPart> = [{ 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<string> = [];
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T> {
|
||||||
|
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<UnifiedRole, string> = {
|
||||||
|
admin: '工作台',
|
||||||
|
sales: '销售工作台',
|
||||||
|
fire_control: '消防工作台',
|
||||||
|
nurse: '护理工作台',
|
||||||
|
staff_nurse: '护士工作台',
|
||||||
|
family: '看护',
|
||||||
|
elderly: '老人端',
|
||||||
|
employee: '工作台',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 角色中文名
|
||||||
|
export const ROLE_NAME_MAP: Record<UnifiedRole, string> = {
|
||||||
|
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 '请求失败,请稍后重试';
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRouteTarget(target: string): RouteTarget {
|
||||||
|
const q = target.indexOf('?');
|
||||||
|
if (q < 0) {
|
||||||
|
const empty: Record<string, string> = {};
|
||||||
|
const plain: RouteTarget = { url: target, params: empty };
|
||||||
|
return plain;
|
||||||
|
}
|
||||||
|
const url = target.substring(0, q);
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string, image.PixelMap> = new Map();
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<void> {
|
||||||
|
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%')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<boolean>('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<FontScaleLevel>('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<boolean>('needPrivacyCheck', true);
|
||||||
|
});
|
||||||
|
// 服务卡片点击跳转:冷启动时记录目标页,登录进入主页后由 ElderlyHome 兜底跳转
|
||||||
|
const target = want.parameters ? String(want.parameters['targetPage'] ?? '') : '';
|
||||||
|
if (target.length > 0) {
|
||||||
|
AppStorage.setOrCreate<string>('pendingCardTarget', target);
|
||||||
|
}
|
||||||
|
// 小艺意图(页面意图):系统传入 @normalized 页面路径,由 App 自行路由
|
||||||
|
const intentPage = this.insightTargetPage(want);
|
||||||
|
if (intentPage.length > 0) {
|
||||||
|
AppStorage.setOrCreate<string>('pendingCardTarget', intentPage);
|
||||||
|
}
|
||||||
|
// 小艺原生操控 deepLink:smartsenior://page?target=<页面路径>
|
||||||
|
const linkPage = this.deepLinkTarget(want);
|
||||||
|
if (linkPage.length > 0) {
|
||||||
|
AppStorage.setOrCreate<string>('pendingCardTarget', linkPage);
|
||||||
|
}
|
||||||
|
// 碰一碰分享的健康报告:smartsenior://report?data=<JSON>
|
||||||
|
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<boolean>('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<string>('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<boolean>('pendingGohomeRide', true);
|
||||||
|
}
|
||||||
|
if (reportPage.length > 0) {
|
||||||
|
target = reportPage;
|
||||||
|
}
|
||||||
|
if (target.length > 0) {
|
||||||
|
AppStorage.setOrCreate<string>('pendingCardTarget', target);
|
||||||
|
}
|
||||||
|
// 热启动:小艺意图跳转目标页(走窗口 UIContext 的路由,Ability 级 router 解析不到页面)
|
||||||
|
const intentPage = this.insightTargetPage(want);
|
||||||
|
if (intentPage.length > 0 && target.length === 0) {
|
||||||
|
target = intentPage;
|
||||||
|
AppStorage.setOrCreate<string>('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=<encodeURIComponent(JSON)>,存 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<string>('pendingReportData', data);
|
||||||
|
AppStorage.setOrCreate<string>('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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<insightIntent.IntentResult<string>> {
|
||||||
|
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<string> {
|
||||||
|
executeMode: insightIntent.ExecuteMode = insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND;
|
||||||
|
|
||||||
|
async onExecute(): Promise<insightIntent.IntentResult<string>> {
|
||||||
|
return runCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<insightIntent.IntentResult<string>> {
|
||||||
|
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<string> {
|
||||||
|
executeMode: insightIntent.ExecuteMode = insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND;
|
||||||
|
|
||||||
|
async onExecute(): Promise<insightIntent.IntentResult<string>> {
|
||||||
|
return runCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<insightIntent.IntentResult<string>> {
|
||||||
|
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<string> {
|
||||||
|
executeMode: insightIntent.ExecuteMode = insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND;
|
||||||
|
|
||||||
|
async onExecute(): Promise<insightIntent.IntentResult<string>> {
|
||||||
|
return runCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string> {
|
||||||
|
executeMode: insightIntent.ExecuteMode = insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND;
|
||||||
|
|
||||||
|
async onExecute(): Promise<insightIntent.IntentResult<string>> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<insightIntent.IntentResult<string>> {
|
||||||
|
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<string> {
|
||||||
|
executeMode: insightIntent.ExecuteMode = insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND;
|
||||||
|
|
||||||
|
async onExecute(): Promise<insightIntent.IntentResult<string>> {
|
||||||
|
return runMeal();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<insightIntent.IntentResult<string>> {
|
||||||
|
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<string> {
|
||||||
|
executeMode: insightIntent.ExecuteMode = insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND;
|
||||||
|
|
||||||
|
async onExecute(): Promise<insightIntent.IntentResult<string>> {
|
||||||
|
return runMeal();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<insightIntent.IntentResult<string>> {
|
||||||
|
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<string> {
|
||||||
|
executeMode: insightIntent.ExecuteMode = insightIntent.ExecuteMode.UI_ABILITY_BACKGROUND;
|
||||||
|
|
||||||
|
async onExecute(): Promise<insightIntent.IntentResult<string>> {
|
||||||
|
return runMeal();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string> {
|
||||||
|
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<insightIntent.IntentResult<string>> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
// 智慧养老(数智护老)客户端主页面
|
||||||
|
// 结构:HdsNavigation + HdsTabs,五个页签(工作台/预警/业务/更多/我的)
|
||||||
|
// 业务内容分别在 pages/workbench、pages/alerts、pages/business、pages/profile 中维护
|
||||||
|
import { curves } from '@kit.ArkUI';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { HdsTabs, HdsTabsAttribute, HdsTabsController, HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { router } from '@kit.ArkUI';
|
||||||
|
import { Workbench } from './workbench/Workbench';
|
||||||
|
import { AlertsTab } from './alerts/AlertsTab';
|
||||||
|
import { BusinessTab } from './business/BusinessTab';
|
||||||
|
import { ProfileTab } from './profile/ProfileTab';
|
||||||
|
import { AuthStore } from '../utils/AuthStore';
|
||||||
|
import { parseRouteTarget } from '../common/RouteTarget';
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct Index {
|
||||||
|
@State message: string = '数智护老';
|
||||||
|
@State subTitle: string = 'SmartSenior';
|
||||||
|
@State currentIndex: number = 0;
|
||||||
|
|
||||||
|
private hdsTabsController: HdsTabsController = new HdsTabsController();
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
// 老人角色使用独立的老人端首页
|
||||||
|
AuthStore.getState().then((state) => {
|
||||||
|
if (state.role === 'elderly') {
|
||||||
|
router.replaceUrl({ url: 'pages/elderly/ElderlyHome' });
|
||||||
|
}
|
||||||
|
// 冷启动带目标页(卡片/快捷方式/小艺/通知点击跳转):进首页后再跳目标页
|
||||||
|
const pending = AppStorage.get<string>('pendingCardTarget') ?? '';
|
||||||
|
if (pending.length > 0) {
|
||||||
|
AppStorage.setOrCreate<string>('pendingCardTarget', '');
|
||||||
|
const rt = parseRouteTarget(pending);
|
||||||
|
setTimeout(() => {
|
||||||
|
router.pushUrl({ url: rt.url, params: rt.params }).catch(() => {});
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
TabBarItem(icon: string, text: string, index: number) {
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
// 处理"更多"页签的特殊图标
|
||||||
|
if (index === 3) {
|
||||||
|
// 使用自定义的SymbolSpan图标
|
||||||
|
Stack() {
|
||||||
|
// 未选中状态的图标
|
||||||
|
Text() {
|
||||||
|
SymbolSpan($r("sys.symbol.list_bullet"))
|
||||||
|
}
|
||||||
|
.fontSize(24)
|
||||||
|
.fontColor('#888888')
|
||||||
|
.opacity((this.currentIndex !== index) ? 1 : 0)
|
||||||
|
.animation({
|
||||||
|
duration: 200,
|
||||||
|
curve: curves.springMotion(0.4, 0.8)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 选中状态的图标
|
||||||
|
Text() {
|
||||||
|
SymbolSpan($r("sys.symbol.list_bullet"))
|
||||||
|
}
|
||||||
|
.fontSize(24)
|
||||||
|
.fontColor('#007AFF')
|
||||||
|
.opacity((this.currentIndex === index) ? 1 : 0)
|
||||||
|
.animation({
|
||||||
|
duration: 200,
|
||||||
|
curve: curves.springMotion(0.4, 0.8)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 其他页签保持原有逻辑
|
||||||
|
Stack() {
|
||||||
|
// 未选中状态的图标
|
||||||
|
Text(icon)
|
||||||
|
.fontSize(24)
|
||||||
|
.fontColor('#888888')
|
||||||
|
.opacity((this.currentIndex !== index) ? 1 : 0)
|
||||||
|
.animation({
|
||||||
|
duration: 200,
|
||||||
|
curve: curves.springMotion(0.4, 0.8)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 选中状态的图标
|
||||||
|
Text(icon)
|
||||||
|
.fontSize(24)
|
||||||
|
.fontColor('#007AFF')
|
||||||
|
.opacity((this.currentIndex === index) ? 1 : 0)
|
||||||
|
.animation({
|
||||||
|
duration: 200,
|
||||||
|
curve: curves.springMotion(0.4, 0.8)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文字部分
|
||||||
|
Stack() {
|
||||||
|
// 未选中状态的文字
|
||||||
|
Text(text)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor('#888888')
|
||||||
|
.opacity((this.currentIndex !== index) ? 1 : 0)
|
||||||
|
.animation({
|
||||||
|
duration: 200,
|
||||||
|
curve: curves.springMotion(0.4, 0.8)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 选中状态的文字
|
||||||
|
Text(text)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor('#007AFF')
|
||||||
|
.opacity((this.currentIndex === index) ? 1 : 0)
|
||||||
|
.animation({
|
||||||
|
duration: 200,
|
||||||
|
curve: curves.springMotion(0.4, 0.8)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
.onClick(() => {
|
||||||
|
// 直接更新当前选中的索引
|
||||||
|
this.currentIndex = index;
|
||||||
|
// 更新HdsTabs控制器
|
||||||
|
this.hdsTabsController.changeIndex(index);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
TabContentBuilder(index: number) {
|
||||||
|
TabContent() {
|
||||||
|
Stack() {
|
||||||
|
if (index === 0) {
|
||||||
|
Workbench()
|
||||||
|
}
|
||||||
|
if (index === 1) {
|
||||||
|
AlertsTab()
|
||||||
|
}
|
||||||
|
if (index === 2) {
|
||||||
|
BusinessTab()
|
||||||
|
}
|
||||||
|
if (index === 3) {
|
||||||
|
Column() {
|
||||||
|
Text('更多功能开发中')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
}
|
||||||
|
if (index === 4) {
|
||||||
|
ProfileTab()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
// 避让顶部 Hds 标题栏(MINI 模式 + 状态栏)
|
||||||
|
.padding({ top: 92 })
|
||||||
|
}
|
||||||
|
.tabBar(this.TabBarItem(
|
||||||
|
index === 0 ? '' :
|
||||||
|
index === 1 ? '' :
|
||||||
|
index === 2 ? '' :
|
||||||
|
index === 3 ? '' : '',
|
||||||
|
index === 0 ? '工作台' :
|
||||||
|
index === 1 ? '预警' :
|
||||||
|
index === 2 ? '业务' :
|
||||||
|
index === 3 ? '更多' : '我的',
|
||||||
|
index
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column() {
|
||||||
|
// 主应用界面
|
||||||
|
Stack() {
|
||||||
|
HdsNavigation() {
|
||||||
|
HdsTabs({ controller: this.hdsTabsController }) {
|
||||||
|
ForEach([0, 1, 2, 3, 4], (index: number) => {
|
||||||
|
this.TabContentBuilder(index)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.barOverlap(true)
|
||||||
|
.barPosition(BarPosition.End)
|
||||||
|
.vertical(false)
|
||||||
|
.scrollable(false)
|
||||||
|
.animationDuration(200)
|
||||||
|
.onChange((index: number) => {
|
||||||
|
this.currentIndex = index;
|
||||||
|
})
|
||||||
|
.barFloatingStyle({
|
||||||
|
barWidth: { smallWidth: 300, mediumWidth: 440, largeWidth: 560 },
|
||||||
|
barBottomMargin: 28,
|
||||||
|
gradientMask: { maskColor: '#00000000', maskHeight: 92 },
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.IMMERSIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: this.message,
|
||||||
|
subTitle: this.subTitle,
|
||||||
|
},
|
||||||
|
// 右上角菜单按钮暂时隐藏
|
||||||
|
// menu: this.menus,
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.hideBackButton(true)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
.alignContent(Alignment.Top)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
// 登录页(ArkTS 严格类型,无 any)—— 账号密码 + 邮箱验证码 + 家属注册
|
||||||
|
import { router, promptAction, uiMaterial } from '@kit.ArkUI';
|
||||||
|
import { preferences } from '@kit.ArkData';
|
||||||
|
import { common } from '@kit.AbilityKit';
|
||||||
|
import { BusinessError, deviceInfo } from '@kit.BasicServicesKit';
|
||||||
|
import {
|
||||||
|
LoginResponse,
|
||||||
|
PREFS_NAME,
|
||||||
|
PREFS_AGREED,
|
||||||
|
ROLE_NAME_MAP,
|
||||||
|
normalizeError,
|
||||||
|
} from '../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../utils/Request';
|
||||||
|
import { AuthStore } from '../utils/AuthStore';
|
||||||
|
import { Constants } from '../common/Constants';
|
||||||
|
import { PushManager } from '../utils/PushManager';
|
||||||
|
import { AlertSocket } from '../utils/AlertSocket';
|
||||||
|
import { requestLocationPermission } from '../utils/PermissionUtil';
|
||||||
|
import { loadFontScale, resetFontScaleRuntime } from '../utils/FontScale';
|
||||||
|
import { parseRouteTarget } from '../common/RouteTarget';
|
||||||
|
|
||||||
|
interface HomeRoute {
|
||||||
|
route: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录请求体
|
||||||
|
interface LoginPayload {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHomeRoute(role: string, familyVerified: boolean): HomeRoute {
|
||||||
|
if (role === 'elderly') {
|
||||||
|
return { route: 'pages/Index', name: '老人端' };
|
||||||
|
}
|
||||||
|
if (role === 'family' && !familyVerified) {
|
||||||
|
return { route: 'pages/Index', name: '家属端' };
|
||||||
|
}
|
||||||
|
return { route: 'pages/Index', name: ROLE_NAME_MAP[role] ?? '工作台' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct LoginPage {
|
||||||
|
@State username: string = '';
|
||||||
|
@State password: string = '';
|
||||||
|
@State loading: boolean = false;
|
||||||
|
@State showPassword: boolean = false;
|
||||||
|
@State showAgreement: boolean = false;
|
||||||
|
@StorageProp('needPrivacyCheck') @Watch('onNeedPrivacyChanged') needPrivacyCheck: boolean = false;
|
||||||
|
|
||||||
|
// 应用初始化(EntryAbility)判定未同意 → 登录页一渲染就弹
|
||||||
|
onNeedPrivacyChanged(): void {
|
||||||
|
if (this.needPrivacyCheck) {
|
||||||
|
this.showAgreement = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async aboutToAppear(): Promise<void> {
|
||||||
|
// 兜底:AppStorage 标志未就绪时直接读偏好,确保首次启动必弹
|
||||||
|
const context = getContext(this) as common.UIAbilityContext;
|
||||||
|
const prefs: preferences.Preferences = await preferences.getPreferences(context, PREFS_NAME);
|
||||||
|
const agreed = await prefs.get(PREFS_AGREED, 0);
|
||||||
|
if (agreed !== 1) {
|
||||||
|
this.showAgreement = true;
|
||||||
|
}
|
||||||
|
// 登录持久化:已登录用户直接进入主页
|
||||||
|
const loggedIn = await AuthStore.isLoggedIn();
|
||||||
|
if (loggedIn) {
|
||||||
|
router.replaceUrl({ url: 'pages/Index' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async agree(): Promise<void> {
|
||||||
|
const context = getContext(this) as common.UIAbilityContext;
|
||||||
|
const prefs: preferences.Preferences = await preferences.getPreferences(context, PREFS_NAME);
|
||||||
|
await prefs.put(PREFS_AGREED, 1);
|
||||||
|
await prefs.flush();
|
||||||
|
AppStorage.setOrCreate<boolean>('needPrivacyCheck', false);
|
||||||
|
this.showAgreement = false;
|
||||||
|
// 隐私政策同意后一次性完成授权:先通知,再定位(系统授权弹窗依次排队)
|
||||||
|
try {
|
||||||
|
await PushManager.init(context);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Login] PushManager init failed:', normalizeError(err as BusinessError | Error));
|
||||||
|
}
|
||||||
|
await requestLocationPermission(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleLogin(): Promise<void> {
|
||||||
|
if (this.username.trim().length === 0) {
|
||||||
|
promptAction.showToast({ message: '请输入账号' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.password.length === 0) {
|
||||||
|
promptAction.showToast({ message: '请输入密码' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.loading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const payload: LoginPayload = {
|
||||||
|
username: this.username.trim(),
|
||||||
|
password: this.password,
|
||||||
|
};
|
||||||
|
const res = await api.post<LoginResponse>('/auth/login/', payload, { anonymous: true });
|
||||||
|
await AuthStore.setAuth({
|
||||||
|
token: res.access,
|
||||||
|
refreshToken: res.refresh,
|
||||||
|
role: res.user.role,
|
||||||
|
userId: res.user.id,
|
||||||
|
userName: res.user.name,
|
||||||
|
familyVerified: res.user.family_verified,
|
||||||
|
});
|
||||||
|
// Push Kit:把推送令牌绑定到当前登录用户
|
||||||
|
PushManager.uploadTokenAfterLogin().catch(() => {});
|
||||||
|
// 字体设置:老人/家属登录恢复持久化档位,其他角色强制跟随系统
|
||||||
|
if (res.user.role === 'elderly' || res.user.role === 'family') {
|
||||||
|
loadFontScale(getContext(this) as common.UIAbilityContext).catch(() => {});
|
||||||
|
} else {
|
||||||
|
resetFontScaleRuntime();
|
||||||
|
}
|
||||||
|
// 预警实时推送:建立 WebSocket 连接
|
||||||
|
AlertSocket.connect().catch(() => {});
|
||||||
|
const home = getHomeRoute(res.user.role, res.user.family_verified);
|
||||||
|
router.replaceUrl({ url: home.route });
|
||||||
|
// 冷启动带目标页(卡片/快捷方式/小艺/通知点击跳转):进入首页后再跳目标页
|
||||||
|
const pending = AppStorage.get<string>('pendingCardTarget') ?? '';
|
||||||
|
if (pending.length > 0) {
|
||||||
|
AppStorage.setOrCreate<string>('pendingCardTarget', '');
|
||||||
|
const rt = parseRouteTarget(pending);
|
||||||
|
setTimeout(() => {
|
||||||
|
router.pushUrl({ url: rt.url, params: rt.params }).catch(() => {});
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// 后端不可用时,写死测试账号作为离线兜底(正常联调不会走到这里)
|
||||||
|
const username = this.username.trim();
|
||||||
|
if (username === '0001' && this.password === '0001') {
|
||||||
|
await AuthStore.setAuth({
|
||||||
|
token: 'local-0001',
|
||||||
|
refreshToken: '',
|
||||||
|
role: 'admin',
|
||||||
|
userId: 1,
|
||||||
|
userName: '测试用户',
|
||||||
|
familyVerified: false,
|
||||||
|
});
|
||||||
|
router.replaceUrl({ url: 'pages/Index' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (username === '0002' && this.password === '0002') {
|
||||||
|
await AuthStore.setAuth({
|
||||||
|
token: 'local-0002',
|
||||||
|
refreshToken: '',
|
||||||
|
role: 'elderly',
|
||||||
|
userId: 2,
|
||||||
|
userName: '测试老人',
|
||||||
|
familyVerified: false,
|
||||||
|
});
|
||||||
|
router.replaceUrl({ url: 'pages/Index' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 隐私政策弹窗的沉浸式系统材质:uiMaterial 从 API 26 起才提供,
|
||||||
|
// 低版本设备上该命名空间不存在,直接访问 uiMaterial.ImmersiveStyle 会抛 TypeError 导致应用退出
|
||||||
|
private buildAgreementMaterial(): SystemUiMaterial | undefined {
|
||||||
|
if (deviceInfo.sdkApiVersion < 26) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new uiMaterial.ImmersiveMaterial({
|
||||||
|
style: uiMaterial.ImmersiveStyle.ULTRA_THIN,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Login] immersive material unavailable:', normalizeError(err as BusinessError | Error));
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Stack() {
|
||||||
|
// 背景(延伸到状态栏与导航栏,沉浸式)
|
||||||
|
Column()
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
|
||||||
|
Column({ space: 20 }) {
|
||||||
|
Blank()
|
||||||
|
.height(56) // 状态栏安全间距
|
||||||
|
|
||||||
|
// Logo
|
||||||
|
Image($r('app.media.logo1'))
|
||||||
|
.width(88)
|
||||||
|
.height(88)
|
||||||
|
.borderRadius(20)
|
||||||
|
.shadow({ radius: 10, color: '#33007AFF', offsetX: 0, offsetY: 5 })
|
||||||
|
|
||||||
|
Text('数智护老')
|
||||||
|
.fontSize(28)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
|
||||||
|
Text('请输入账号信息以继续使用智慧照护端')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
|
||||||
|
// 账号输入
|
||||||
|
Column({ space: 14 }) {
|
||||||
|
TextInput({ placeholder: '请输入账号 / 手机号 / 邮箱', text: this.username })
|
||||||
|
.placeholderColor($r('app.color.inactive_tab'))
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.height(50)
|
||||||
|
.padding({ left: 16, right: 16 })
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(12)
|
||||||
|
.onChange((value: string) => {
|
||||||
|
this.username = value;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 密码输入
|
||||||
|
Row() {
|
||||||
|
TextInput({ placeholder: '请输入登录密码', text: this.password })
|
||||||
|
.placeholderColor($r('app.color.inactive_tab'))
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.type(InputType.Password)
|
||||||
|
.height(50)
|
||||||
|
.layoutWeight(1)
|
||||||
|
.padding({ left: 16, right: 16 })
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(12)
|
||||||
|
.onChange((value: string) => {
|
||||||
|
this.password = value;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
// 登录按钮
|
||||||
|
Button(this.loading ? '登录中...' : '登录')
|
||||||
|
.width('100%')
|
||||||
|
.height(50)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(12)
|
||||||
|
.enabled(!this.loading)
|
||||||
|
.onClick(() => {
|
||||||
|
this.handleLogin();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 24, right: 24 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
.padding({ left: 24, right: 24, bottom: 60 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
// 隐私政策弹窗(与 Index 一致的透明沉浸式样式,应用打开时弹出)
|
||||||
|
.bindSheet($$this.showAgreement, this.AgreementSheetBuilder(), {
|
||||||
|
preferType: SheetType.CENTER,
|
||||||
|
detents: [SheetSize.MEDIUM],
|
||||||
|
dragBar: true,
|
||||||
|
showClose: false,
|
||||||
|
title: { title: '用户协议与隐私政策' },
|
||||||
|
backgroundColor: Color.Transparent,
|
||||||
|
systemMaterial: this.buildAgreementMaterial(),
|
||||||
|
onWillDismiss: (action: DismissSheetAction) => {
|
||||||
|
// 未同意前禁止下拉/点遮罩关闭
|
||||||
|
if (this.showAgreement) {
|
||||||
|
promptAction.showToast({ message: '请先阅读并同意隐私政策' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 隐私政策半模态内容
|
||||||
|
@Builder
|
||||||
|
AgreementSheetBuilder() {
|
||||||
|
Column() {
|
||||||
|
Scroll() {
|
||||||
|
Column() {
|
||||||
|
Text('欢迎使用前请仔细阅读以下政策全文:')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.margin({ bottom: 12 })
|
||||||
|
ForEach(Constants.PRIVACY_DETAIL, (item: string) => {
|
||||||
|
Text(item)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.textAlign(TextAlign.Start)
|
||||||
|
.margin({ bottom: 8 })
|
||||||
|
}, (item: string) => item.substring(0, 12))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 24, right: 24 })
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.align(Alignment.Top)
|
||||||
|
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
Button('不同意并退出')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.height(44)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.backgroundColor($r('app.color.accent_red'))
|
||||||
|
.borderRadius(10)
|
||||||
|
.onClick(() => {
|
||||||
|
this.handleDisagree();
|
||||||
|
})
|
||||||
|
Button('同意并继续')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.height(44)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(10)
|
||||||
|
.onClick(() => {
|
||||||
|
this.agree();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 24, right: 24, top: 8, bottom: 24 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDisagree(): Promise<void> {
|
||||||
|
this.showAgreement = false;
|
||||||
|
// 按原交互:不同意则退出应用
|
||||||
|
const context = getContext(this) as common.UIAbilityContext;
|
||||||
|
context.terminateSelf();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
// 管理员 · 意见反馈工单管理(ArkTS 严格类型,无 any)
|
||||||
|
// 接口:GET /feedback/records/、PATCH /feedback/records/{id}/
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
|
||||||
|
interface FeedbackItem {
|
||||||
|
id: number;
|
||||||
|
content: string;
|
||||||
|
contact: string;
|
||||||
|
status: string;
|
||||||
|
status_label: string;
|
||||||
|
user_name: string;
|
||||||
|
user_alias: string;
|
||||||
|
user_role: string;
|
||||||
|
created_at: string;
|
||||||
|
resolved_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleLabel(role: string): string {
|
||||||
|
if (role === 'family') {
|
||||||
|
return '家属';
|
||||||
|
}
|
||||||
|
if (role === 'elderly') {
|
||||||
|
return '长者';
|
||||||
|
}
|
||||||
|
if (role === 'nurse') {
|
||||||
|
return '护理员';
|
||||||
|
}
|
||||||
|
if (role === 'staff_nurse') {
|
||||||
|
return '护士';
|
||||||
|
}
|
||||||
|
if (role === 'admin') {
|
||||||
|
return '管理员';
|
||||||
|
}
|
||||||
|
return role || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
if (!iso || iso.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
return `${mm}/${dd} ${hh}:${mi}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct AdminFeedback {
|
||||||
|
@State items: Array<FeedbackItem> = [];
|
||||||
|
@State loading: boolean = true;
|
||||||
|
@State page: number = 1;
|
||||||
|
@State count: number = 0;
|
||||||
|
@State statusFilter: string = 'all';
|
||||||
|
@State processingId: number = 0;
|
||||||
|
private pageSize: number = 10;
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private reload(): void {
|
||||||
|
this.page = 1;
|
||||||
|
this.items = [];
|
||||||
|
this.fetch(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetch(isReset: boolean): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
let res: Page<FeedbackItem>;
|
||||||
|
if (this.statusFilter !== 'all') {
|
||||||
|
res = await api.get<Page<FeedbackItem>>('/feedback/records/', {
|
||||||
|
page: this.page,
|
||||||
|
page_size: this.pageSize,
|
||||||
|
status: this.statusFilter,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res = await api.get<Page<FeedbackItem>>('/feedback/records/', {
|
||||||
|
page: this.page,
|
||||||
|
page_size: this.pageSize,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.count = res.count;
|
||||||
|
if (isReset) {
|
||||||
|
this.items = res.results;
|
||||||
|
} else {
|
||||||
|
this.items = this.items.concat(res.results);
|
||||||
|
}
|
||||||
|
if (res.results.length > 0) {
|
||||||
|
this.page += 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private switchStatus(mode: string): void {
|
||||||
|
if (this.statusFilter === mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.statusFilter = mode;
|
||||||
|
this.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toggleStatus(item: FeedbackItem): Promise<void> {
|
||||||
|
if (this.processingId === item.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.processingId = item.id;
|
||||||
|
try {
|
||||||
|
const next = item.status === 'resolved' ? 'pending' : 'resolved';
|
||||||
|
const updated = await api.patch<FeedbackItem>(`/feedback/records/${item.id}/`, { status: next });
|
||||||
|
this.items = this.items.map((it: FeedbackItem): FeedbackItem => {
|
||||||
|
if (it.id === item.id) {
|
||||||
|
return {
|
||||||
|
id: it.id,
|
||||||
|
content: it.content,
|
||||||
|
contact: it.contact,
|
||||||
|
status: updated.status,
|
||||||
|
status_label: updated.status === 'resolved' ? '已处理' : '待处理',
|
||||||
|
user_name: it.user_name,
|
||||||
|
user_alias: it.user_alias,
|
||||||
|
user_role: it.user_role,
|
||||||
|
created_at: it.created_at,
|
||||||
|
resolved_at: it.resolved_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return it;
|
||||||
|
});
|
||||||
|
promptAction.showToast({ message: updated.status === 'resolved' ? '已标记为已处理' : '已设为待处理' });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.processingId = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
ItemCard(item: FeedbackItem) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(item.user_name || item.user_alias || '用户')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text(roleLabel(item.user_role))
|
||||||
|
.fontSize(11)
|
||||||
|
.fontColor('#007AFF')
|
||||||
|
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||||
|
.backgroundColor('#1A007AFF')
|
||||||
|
.borderRadius(6)
|
||||||
|
Blank()
|
||||||
|
Text(item.status === 'resolved' ? '已处理' : '待处理')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor(item.status === 'resolved' ? $r('app.color.accent_green') : '#FF9500')
|
||||||
|
if (item.status !== 'resolved') {
|
||||||
|
Text('处理')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||||
|
.backgroundColor('#007AFF')
|
||||||
|
.borderRadius(8)
|
||||||
|
.onClick(() => {
|
||||||
|
this.toggleStatus(item);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(VerticalAlign.Center)
|
||||||
|
|
||||||
|
Text(item.content)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
.maxLines(3)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(formatTime(item.created_at))
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
if (item.contact && item.contact.length > 0) {
|
||||||
|
Text(`联系方式:${item.contact}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
if (this.loading) {
|
||||||
|
Column() {
|
||||||
|
Text('正在加载')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 60 })
|
||||||
|
} else if (this.items.length === 0) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('暂无反馈')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text('还没有意见反馈工单')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 160 })
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
} else {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text('全部')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor(this.statusFilter === 'all' ? '#FFFFFF' : $r('app.color.text_secondary'))
|
||||||
|
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
|
||||||
|
.backgroundColor(this.statusFilter === 'all' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.onClick(() => {
|
||||||
|
this.switchStatus('all');
|
||||||
|
})
|
||||||
|
Text('待处理')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor(this.statusFilter === 'pending' ? '#FFFFFF' : $r('app.color.text_secondary'))
|
||||||
|
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
|
||||||
|
.backgroundColor(this.statusFilter === 'pending' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.onClick(() => {
|
||||||
|
this.switchStatus('pending');
|
||||||
|
})
|
||||||
|
Text('已处理')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor(this.statusFilter === 'resolved' ? '#FFFFFF' : $r('app.color.text_secondary'))
|
||||||
|
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
|
||||||
|
.backgroundColor(this.statusFilter === 'resolved' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.onClick(() => {
|
||||||
|
this.switchStatus('resolved');
|
||||||
|
})
|
||||||
|
Blank()
|
||||||
|
Text(`共 ${this.count} 条`)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(VerticalAlign.Center)
|
||||||
|
|
||||||
|
ForEach(this.items, (item: FeedbackItem) => {
|
||||||
|
this.ItemCard(item)
|
||||||
|
}, (item: FeedbackItem) => String(item.id))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
.onReachEnd(() => {
|
||||||
|
if (!this.loading && this.items.length < this.count) {
|
||||||
|
this.fetch(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '意见反馈',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(true)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
// 管理员 · 护理员详情(ArkTS 严格类型,无 any)
|
||||||
|
// 接口:GET /users/{id}/、GET /stats/supervisor/nurse-detail/{id}/
|
||||||
|
import { router, promptAction } from '@kit.ArkUI';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
|
||||||
|
interface NurseProfile {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
group: string;
|
||||||
|
role: string;
|
||||||
|
duty_status: string;
|
||||||
|
account_status: string;
|
||||||
|
date_joined: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Summary {
|
||||||
|
record_count: number;
|
||||||
|
elderly_served: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindowProgress {
|
||||||
|
status: string; // completed / current / upcoming / overdue
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceItem {
|
||||||
|
service_name: string;
|
||||||
|
category_name: string;
|
||||||
|
status: string;
|
||||||
|
remaining: number;
|
||||||
|
window_progress?: Array<WindowProgress>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceProgress {
|
||||||
|
elderly_id: number;
|
||||||
|
elderly_name: string;
|
||||||
|
room: string;
|
||||||
|
bed: string;
|
||||||
|
items: Array<ServiceItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NurseDetail {
|
||||||
|
date: string;
|
||||||
|
nurse_id: number;
|
||||||
|
nurse_name: string;
|
||||||
|
duty_status: string;
|
||||||
|
group: string;
|
||||||
|
summary: Summary;
|
||||||
|
overall_status: string;
|
||||||
|
overall_completion_rate: number;
|
||||||
|
service_progress: Array<ServiceProgress>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(item: ServiceItem): string {
|
||||||
|
if (item.status === 'completed') {
|
||||||
|
return '完成';
|
||||||
|
}
|
||||||
|
if (item.status === 'partial') {
|
||||||
|
return '部分';
|
||||||
|
}
|
||||||
|
if (item.status === 'missed') {
|
||||||
|
// 后端把"完成数=0"都标 missed,需按时间窗区分:
|
||||||
|
// 还有进行中/未到的窗口 → 未开始;全部窗口已过 → 遗漏
|
||||||
|
const wp: Array<WindowProgress> = item.window_progress ?? [];
|
||||||
|
const hasOpen = wp.some((w: WindowProgress): boolean => w.status === 'current' || w.status === 'upcoming');
|
||||||
|
return hasOpen ? '未开始' : '遗漏';
|
||||||
|
}
|
||||||
|
return '遗漏';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(item: ServiceItem): string {
|
||||||
|
if (item.status === 'completed') {
|
||||||
|
return '#34C759';
|
||||||
|
}
|
||||||
|
if (item.status === 'partial') {
|
||||||
|
return '#FF9500';
|
||||||
|
}
|
||||||
|
if (item.status === 'missed') {
|
||||||
|
const wp: Array<WindowProgress> = item.window_progress ?? [];
|
||||||
|
const hasOpen = wp.some((w: WindowProgress): boolean => w.status === 'current' || w.status === 'upcoming');
|
||||||
|
return hasOpen ? '#8E8E93' : '#FF3B30';
|
||||||
|
}
|
||||||
|
return '#FF3B30';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dutyLabel(status: string): string {
|
||||||
|
if (status === 'on_duty') {
|
||||||
|
return '在岗';
|
||||||
|
}
|
||||||
|
if (status === 'rest') {
|
||||||
|
return '休息';
|
||||||
|
}
|
||||||
|
if (status === 'offboard') {
|
||||||
|
return '离职';
|
||||||
|
}
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function percentText(rate: number): string {
|
||||||
|
if (rate === undefined || rate === null) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
return `${Math.round(rate * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct AdminNurseDetail {
|
||||||
|
@State nurseId: number = 0;
|
||||||
|
@State profile: NurseProfile | null = null;
|
||||||
|
@State detail: NurseDetail | null = null;
|
||||||
|
@State loading: boolean = true;
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
const params = router.getParams() as Record<string, string | number>;
|
||||||
|
if (params) {
|
||||||
|
const id = params['id'];
|
||||||
|
if (id !== undefined) {
|
||||||
|
this.nurseId = Number(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const results: Array<NurseProfile | NurseDetail | null> = await Promise.all([
|
||||||
|
api.get<NurseProfile>(`/auth/users/${this.nurseId}/`).catch((): NurseProfile | null => null),
|
||||||
|
api.get<NurseDetail>(`/stats/supervisor/nurse-detail/${this.nurseId}/`).catch((): NurseDetail | null => null),
|
||||||
|
]);
|
||||||
|
this.profile = results[0] as NurseProfile | null;
|
||||||
|
this.detail = results[1] as NurseDetail | null;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
InfoRow(label: string, value: string) {
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
Text(label)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width(72)
|
||||||
|
Text(value)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.layoutWeight(1)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
StatTile(value: string, label: string, color: string) {
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Text(value)
|
||||||
|
.fontSize(22)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(color)
|
||||||
|
Text(label)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
ElderlyProgress(p: ServiceProgress) {
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(p.elderly_name)
|
||||||
|
.fontSize(15)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
if (p.room && p.room.length > 0) {
|
||||||
|
Text(`${p.room}${p.bed ? '-' + p.bed : ''}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
Blank()
|
||||||
|
Text(`${p.items.length} 项服务`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
ForEach(p.items, (item: ServiceItem) => {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(item.service_name)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.layoutWeight(1)
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
Text(statusLabel(item))
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor(statusColor(item))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}, (item: ServiceItem) => `${p.elderly_id}-${item.service_name}`)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(12)
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
.borderRadius(10)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
if (this.loading) {
|
||||||
|
Text('加载中...')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.margin({ top: 60 })
|
||||||
|
} else {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
if (this.profile) {
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
this.InfoRow('姓名', this.profile.name || '--')
|
||||||
|
this.InfoRow('手机', this.profile.phone || '--')
|
||||||
|
this.InfoRow('所属组', this.profile.group || '--')
|
||||||
|
this.InfoRow('值班状态', dutyLabel(this.profile.duty_status))
|
||||||
|
this.InfoRow('账号状态', this.profile.account_status || '--')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.detail) {
|
||||||
|
// 今日统计
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
Row() {
|
||||||
|
Text('今日工作')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Blank()
|
||||||
|
Text(`完成率 ${percentText(this.detail.overall_completion_rate)}`)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.accent_green'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Row() {
|
||||||
|
this.StatTile(String(this.detail.summary.record_count), '完成记录', '#007AFF')
|
||||||
|
this.StatTile(String(this.detail.summary.elderly_served), '服务长者', '#34C759')
|
||||||
|
this.StatTile(percentText(this.detail.overall_completion_rate), '整体完成率', '#007AFF')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
|
||||||
|
// 服务进度
|
||||||
|
if (this.detail.service_progress.length > 0) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('今日服务进度')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
ForEach(this.detail.service_progress, (p: ServiceProgress) => {
|
||||||
|
Column() {
|
||||||
|
this.ElderlyProgress(p)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}, (p: ServiceProgress) => String(p.elderly_id))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, bottom: 24 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '护理员详情',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
// 管理员 · 护理员列表(ArkTS 严格类型,无 any)
|
||||||
|
// 接口:GET /users/?role=nurse
|
||||||
|
import { router, promptAction } from '@kit.ArkUI';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
|
||||||
|
interface NurseUser {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
group: string;
|
||||||
|
duty_status: string;
|
||||||
|
account_status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dutyLabel(status: string): string {
|
||||||
|
if (status === 'on_duty') {
|
||||||
|
return '在岗';
|
||||||
|
}
|
||||||
|
if (status === 'rest') {
|
||||||
|
return '休息';
|
||||||
|
}
|
||||||
|
if (status === 'offboard') {
|
||||||
|
return '离职';
|
||||||
|
}
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dutyColor(status: string): string {
|
||||||
|
if (status === 'on_duty') {
|
||||||
|
return '#34C759';
|
||||||
|
}
|
||||||
|
if (status === 'rest') {
|
||||||
|
return '#FF9500';
|
||||||
|
}
|
||||||
|
return '#8E8E93';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct AdminNurseList {
|
||||||
|
@State nurses: Array<NurseUser> = [];
|
||||||
|
@State loading: boolean = true;
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await api.get<Array<NurseUser>>('/auth/users/', { role: 'nurse', page_size: 50 });
|
||||||
|
this.nurses = res;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private openDetail(nurse: NurseUser): void {
|
||||||
|
router.pushUrl({
|
||||||
|
url: 'pages/admin/AdminNurseDetail',
|
||||||
|
params: { id: nurse.id, name: nurse.name },
|
||||||
|
}).catch((err: BusinessError) => {
|
||||||
|
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
NurseCard(nurse: NurseUser) {
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
Column() {
|
||||||
|
Text(nurse.name.substring(0, 1))
|
||||||
|
.fontSize(18)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
}
|
||||||
|
.width(48)
|
||||||
|
.height(48)
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(14)
|
||||||
|
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(nurse.name)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text(dutyLabel(nurse.duty_status))
|
||||||
|
.fontSize(11)
|
||||||
|
.fontColor(dutyColor(nurse.duty_status))
|
||||||
|
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||||
|
.backgroundColor('#14000000')
|
||||||
|
.borderRadius(6)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Text(`${nurse.phone || '--'}${nurse.group ? ' · ' + nurse.group : ''}`)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
Text('›')
|
||||||
|
.fontSize(20)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.onClick(() => {
|
||||||
|
this.openDetail(nurse);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
if (this.loading) {
|
||||||
|
Text('加载中...')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.margin({ top: 60 })
|
||||||
|
} else if (this.nurses.length === 0) {
|
||||||
|
Text('暂无护理员')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.margin({ top: 160 })
|
||||||
|
} else {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 10 }) {
|
||||||
|
ForEach(this.nurses, (nurse: NurseUser) => {
|
||||||
|
Column() {
|
||||||
|
this.NurseCard(nurse)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}, (nurse: NurseUser) => String(nurse.id))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '护理员',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
// 管理员 · 护理记录列表(ArkTS 严格类型,无 any)
|
||||||
|
// 接口:GET /tasks/nursing-records/,支持 全部/今日 筛选 + 滚动分页
|
||||||
|
import { router, promptAction } from '@kit.ArkUI';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
|
||||||
|
interface VitalPart {
|
||||||
|
text?: string;
|
||||||
|
numeric?: string;
|
||||||
|
trend?: string;
|
||||||
|
systolic?: string;
|
||||||
|
diastolic?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextPart {
|
||||||
|
text?: string;
|
||||||
|
trend?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtractedJson {
|
||||||
|
temperature?: VitalPart;
|
||||||
|
blood_pressure?: VitalPart;
|
||||||
|
weight?: VitalPart;
|
||||||
|
medication?: TextPart;
|
||||||
|
diet?: TextPart;
|
||||||
|
mood?: TextPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NursingRecordItem {
|
||||||
|
id: number;
|
||||||
|
elderly: number | null;
|
||||||
|
nurse: number | null;
|
||||||
|
elderly_name: string;
|
||||||
|
nurse_name: string;
|
||||||
|
images: Array<string>;
|
||||||
|
raw_text: string;
|
||||||
|
extracted_json: object;
|
||||||
|
work_item: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayYMD(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(now.getDate()).padStart(2, '0');
|
||||||
|
return `${now.getFullYear()}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRecordTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
return `${mm}/${dd} ${hh}:${mi}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordSummary(record: NursingRecordItem): string {
|
||||||
|
const e = record.extracted_json as ExtractedJson;
|
||||||
|
const parts: Array<string> = [];
|
||||||
|
if (e.temperature && e.temperature.text && e.temperature.text.length > 0) {
|
||||||
|
parts.push(`体温 ${e.temperature.text}`);
|
||||||
|
}
|
||||||
|
if (e.blood_pressure && e.blood_pressure.text && e.blood_pressure.text.length > 0) {
|
||||||
|
parts.push(`血压 ${e.blood_pressure.text}`);
|
||||||
|
}
|
||||||
|
if (e.weight && e.weight.text && e.weight.text.length > 0) {
|
||||||
|
parts.push(`体重 ${e.weight.text}`);
|
||||||
|
}
|
||||||
|
if (e.medication && e.medication.text && e.medication.text.length > 0) {
|
||||||
|
parts.push(`服药 ${e.medication.text}`);
|
||||||
|
}
|
||||||
|
if (e.diet && e.diet.text && e.diet.text.length > 0) {
|
||||||
|
parts.push(`进食 ${e.diet.text}`);
|
||||||
|
}
|
||||||
|
if (e.mood && e.mood.text && e.mood.text.length > 0) {
|
||||||
|
parts.push(`心理 ${e.mood.text}`);
|
||||||
|
}
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct AdminNursingRecords {
|
||||||
|
@State records: Array<NursingRecordItem> = [];
|
||||||
|
@State loading: boolean = true;
|
||||||
|
@State loadingMore: boolean = false;
|
||||||
|
@State page: number = 1;
|
||||||
|
@State count: number = 0;
|
||||||
|
@State dateFilter: string = 'all';
|
||||||
|
private pageSize: number = 10;
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private reload(): void {
|
||||||
|
this.page = 1;
|
||||||
|
this.records = [];
|
||||||
|
this.fetch(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetch(isReset: boolean): Promise<void> {
|
||||||
|
if (isReset) {
|
||||||
|
this.loading = true;
|
||||||
|
} else {
|
||||||
|
this.loadingMore = true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let res: Page<NursingRecordItem>;
|
||||||
|
if (this.dateFilter === 'today') {
|
||||||
|
res = await api.get<Page<NursingRecordItem>>('/tasks/nursing-records/', {
|
||||||
|
page: this.page,
|
||||||
|
page_size: this.pageSize,
|
||||||
|
ordering: '-created_at',
|
||||||
|
date: todayYMD(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res = await api.get<Page<NursingRecordItem>>('/tasks/nursing-records/', {
|
||||||
|
page: this.page,
|
||||||
|
page_size: this.pageSize,
|
||||||
|
ordering: '-created_at',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.count = res.count;
|
||||||
|
if (isReset) {
|
||||||
|
this.records = res.results;
|
||||||
|
} else {
|
||||||
|
this.records = this.records.concat(res.results);
|
||||||
|
}
|
||||||
|
if (res.results.length > 0) {
|
||||||
|
this.page += 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
this.loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private switchFilter(mode: string): void {
|
||||||
|
if (this.dateFilter === mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.dateFilter = mode;
|
||||||
|
this.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private openDetail(record: NursingRecordItem): void {
|
||||||
|
router.pushUrl({
|
||||||
|
url: 'pages/admin/AdminRecordDetail',
|
||||||
|
params: { id: record.id },
|
||||||
|
}).catch((err: BusinessError) => {
|
||||||
|
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
RecordCard(record: NursingRecordItem) {
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
Column() {
|
||||||
|
Text(record.elderly_name.length > 0 ? record.elderly_name.substring(0, 1) : '护')
|
||||||
|
.fontSize(18)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
}
|
||||||
|
.width(48)
|
||||||
|
.height(48)
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(14)
|
||||||
|
|
||||||
|
Column({ space: 5 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(record.elderly_name)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Blank()
|
||||||
|
Text(formatRecordTime(record.created_at))
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
if (record.raw_text && record.raw_text.length > 0) {
|
||||||
|
Text(record.raw_text)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.maxLines(2)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordSummary(record).length > 0) {
|
||||||
|
Text(recordSummary(record))
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.accent_blue'))
|
||||||
|
.maxLines(2)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(record.nurse_name || '--')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
if (record.images.length > 0) {
|
||||||
|
Text(`图片 ${record.images.length}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.accent_green'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.onClick(() => {
|
||||||
|
this.openDetail(record);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
if (this.loading) {
|
||||||
|
Column() {
|
||||||
|
Text('正在加载')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 60 })
|
||||||
|
} else if (this.records.length === 0) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('暂无护理记录')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text(this.dateFilter === 'today' ? '今日还没有护理记录' : '还没有护理记录')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 160 })
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
} else {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text('全部')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor(this.dateFilter === 'all' ? '#FFFFFF' : $r('app.color.text_secondary'))
|
||||||
|
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
|
||||||
|
.backgroundColor(this.dateFilter === 'all' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.onClick(() => {
|
||||||
|
this.switchFilter('all');
|
||||||
|
})
|
||||||
|
Text('今日')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor(this.dateFilter === 'today' ? '#FFFFFF' : $r('app.color.text_secondary'))
|
||||||
|
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
|
||||||
|
.backgroundColor(this.dateFilter === 'today' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.onClick(() => {
|
||||||
|
this.switchFilter('today');
|
||||||
|
})
|
||||||
|
Blank()
|
||||||
|
Text(`共 ${this.count} 条`)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(VerticalAlign.Center)
|
||||||
|
|
||||||
|
ForEach(this.records, (record: NursingRecordItem) => {
|
||||||
|
this.RecordCard(record)
|
||||||
|
}, (record: NursingRecordItem) => String(record.id))
|
||||||
|
|
||||||
|
if (this.loadingMore) {
|
||||||
|
Text('加载中...')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.padding(12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
.onReachEnd(() => {
|
||||||
|
if (!this.loading && !this.loadingMore && this.records.length < this.count) {
|
||||||
|
this.fetch(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '护理记录',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(true)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
// 管理员 · 信息概览(ArkTS 严格类型,无 any)
|
||||||
|
// 接口:GET /dashboard/summary/
|
||||||
|
import { router } from '@kit.ArkUI';
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
|
||||||
|
interface HealthDistribution {
|
||||||
|
total: number;
|
||||||
|
good: number;
|
||||||
|
attention: number;
|
||||||
|
bedridden: number;
|
||||||
|
good_percent: string;
|
||||||
|
attention_percent: string;
|
||||||
|
bedridden_percent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UnresolvedAlert {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
level: string;
|
||||||
|
elderly_name: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentRecord {
|
||||||
|
id: number;
|
||||||
|
elderly_name: string;
|
||||||
|
nurse_name: string;
|
||||||
|
raw_text: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailyTrend {
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MealSummary {
|
||||||
|
category: string;
|
||||||
|
reserved_count: number;
|
||||||
|
menu_items: Array<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MealsOverview {
|
||||||
|
total_reserved_count: number;
|
||||||
|
meals: Array<MealSummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CareOverview {
|
||||||
|
on_duty_count: number;
|
||||||
|
today_record_count: number;
|
||||||
|
daily_trend: Array<DailyTrend>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OperationOverview {
|
||||||
|
care: CareOverview;
|
||||||
|
meals: MealsOverview;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardSummary {
|
||||||
|
summary_date: string;
|
||||||
|
overview_elderly: number;
|
||||||
|
focused_elderly: number;
|
||||||
|
on_duty_nurses: number;
|
||||||
|
active_accounts: number;
|
||||||
|
today_alert_count: number;
|
||||||
|
pending_alert_count: number;
|
||||||
|
today_record_count: number;
|
||||||
|
health_distribution: HealthDistribution;
|
||||||
|
unresolved_alerts: Array<UnresolvedAlert>;
|
||||||
|
recent_records: Array<RecentRecord>;
|
||||||
|
operation_overview: OperationOverview;
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelColor(level: string): string {
|
||||||
|
if (level === 'emergency') {
|
||||||
|
return '#FF3B30';
|
||||||
|
}
|
||||||
|
if (level === 'warning') {
|
||||||
|
return '#FF9500';
|
||||||
|
}
|
||||||
|
return '#007AFF';
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelLabel(level: string): string {
|
||||||
|
if (level === 'emergency') {
|
||||||
|
return '紧急';
|
||||||
|
}
|
||||||
|
if (level === 'warning') {
|
||||||
|
return '注意';
|
||||||
|
}
|
||||||
|
return '正常';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mealLabel(category: string): string {
|
||||||
|
if (category === 'breakfast') {
|
||||||
|
return '早餐';
|
||||||
|
}
|
||||||
|
if (category === 'lunch') {
|
||||||
|
return '午餐';
|
||||||
|
}
|
||||||
|
return '晚餐';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
if (!iso || iso.length === 0) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
return `${mm}/${dd} ${hh}:${mi}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct AdminOverview {
|
||||||
|
@State data: DashboardSummary | null = null;
|
||||||
|
@State loading: boolean = true;
|
||||||
|
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await api.get<DashboardSummary>('/dashboard/summary/');
|
||||||
|
this.data = res;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
StatTile(value: number, unit: string, label: string, color: string) {
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Row({ space: 2 }) {
|
||||||
|
Text(String(value))
|
||||||
|
.fontSize(24)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(color)
|
||||||
|
Text(unit)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.margin({ top: 6 })
|
||||||
|
}
|
||||||
|
Text(label)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
BarRow(label: string, count: number, percent: string, color: string) {
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
Text(label)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width(56)
|
||||||
|
Progress({ value: count, total: Math.max(this.totalSafe(), 1) })
|
||||||
|
.color(color)
|
||||||
|
.style({ strokeWidth: 8 })
|
||||||
|
.layoutWeight(1)
|
||||||
|
Text(percent)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width(44)
|
||||||
|
.textAlign(TextAlign.End)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
private totalSafe(): number {
|
||||||
|
const d = this.data;
|
||||||
|
if (d !== null && d.health_distribution !== null) {
|
||||||
|
return d.health_distribution.total;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
AlertCard(alert: UnresolvedAlert) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(levelLabel(alert.level))
|
||||||
|
.fontSize(11)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(levelColor(alert.level))
|
||||||
|
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||||
|
.backgroundColor(alert.level === 'emergency' ? '#14FF3B30' : (alert.level === 'warning' ? '#14FF9500' : '#14007AFF'))
|
||||||
|
.borderRadius(4)
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Text(alert.title)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
.width('100%')
|
||||||
|
Text(`${alert.elderly_name} · ${formatDateTime(alert.created_at)}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
RecordCard(record: RecentRecord) {
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Row() {
|
||||||
|
Text(record.elderly_name)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Blank()
|
||||||
|
Text(formatDateTime(record.created_at))
|
||||||
|
.fontSize(11)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
Text(record.raw_text)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.maxLines(2)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
private healthValues(): Array<number> {
|
||||||
|
const d = this.data;
|
||||||
|
if (d !== null && d.health_distribution !== null) {
|
||||||
|
return [d.health_distribution.good, d.health_distribution.attention, d.health_distribution.bedridden];
|
||||||
|
}
|
||||||
|
return [0, 0, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private trendMax(): number {
|
||||||
|
const d = this.data;
|
||||||
|
if (d !== null && d.operation_overview !== null && d.operation_overview.care !== null) {
|
||||||
|
let max = 1;
|
||||||
|
d.operation_overview.care.daily_trend.forEach((t: DailyTrend) => {
|
||||||
|
if (t.count > max) {
|
||||||
|
max = t.count;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private barHeight(count: number): number {
|
||||||
|
return Math.round(60 * count / this.trendMax());
|
||||||
|
}
|
||||||
|
|
||||||
|
private shortDate(dateStr: string): string {
|
||||||
|
if (!dateStr || dateStr.length < 5) {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
return dateStr.substring(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
HealthRingCard() {
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
Row() {
|
||||||
|
Text('健康分布')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Blank()
|
||||||
|
Text(`${this.data?.health_distribution.total ?? 0} 人`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Stack() {
|
||||||
|
DataPanel({
|
||||||
|
values: this.healthValues(),
|
||||||
|
max: Math.max(this.data?.health_distribution.total ?? 1, 1),
|
||||||
|
type: DataPanelType.Circle,
|
||||||
|
})
|
||||||
|
.width(168)
|
||||||
|
.height(168)
|
||||||
|
.strokeWidth(24)
|
||||||
|
.valueColors(['#34C759', '#FF9500', '#FF3B30'])
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Text(String(this.data?.health_distribution.total ?? 0))
|
||||||
|
.fontSize(28)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text('在院对象')
|
||||||
|
.fontSize(11)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height(180)
|
||||||
|
.alignContent(Alignment.Center)
|
||||||
|
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Circle().width(10).height(10).fill('#34C759')
|
||||||
|
Text('良好')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.layoutWeight(1)
|
||||||
|
Text(`${this.data?.health_distribution.good ?? 0} 人 · ${this.data?.health_distribution.good_percent ?? '0%'}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Circle().width(10).height(10).fill('#FF9500')
|
||||||
|
Text('需关注')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.layoutWeight(1)
|
||||||
|
Text(`${this.data?.health_distribution.attention ?? 0} 人 · ${this.data?.health_distribution.attention_percent ?? '0%'}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Circle().width(10).height(10).fill('#FF3B30')
|
||||||
|
Text('卧床')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.layoutWeight(1)
|
||||||
|
Text(`${this.data?.health_distribution.bedridden ?? 0} 人 · ${this.data?.health_distribution.bedridden_percent ?? '0%'}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
TrendChartCard() {
|
||||||
|
Column({ space: 10 }) {
|
||||||
|
Row() {
|
||||||
|
Text('近7日护理记录')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Blank()
|
||||||
|
Text('每日条数')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
ForEach(this.data?.operation_overview.care.daily_trend ?? [], (t: DailyTrend) => {
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Text(String(t.count))
|
||||||
|
.fontSize(9)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Column()
|
||||||
|
.width(22)
|
||||||
|
.height(this.barHeight(t.count))
|
||||||
|
.backgroundColor('#007AFF')
|
||||||
|
.borderRadius(4)
|
||||||
|
Text(this.shortDate(t.date))
|
||||||
|
.fontSize(9)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.justifyContent(FlexAlign.End)
|
||||||
|
}, (t: DailyTrend) => t.date)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height(120)
|
||||||
|
.alignItems(VerticalAlign.Bottom)
|
||||||
|
.justifyContent(FlexAlign.SpaceAround)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
if (this.loading) {
|
||||||
|
Text('加载中...')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.margin({ top: 40 })
|
||||||
|
} else if (this.data) {
|
||||||
|
// 顶部统计
|
||||||
|
Row() {
|
||||||
|
this.StatTile(this.data.overview_elderly, '位', '在院对象', '#007AFF')
|
||||||
|
this.StatTile(this.data.on_duty_nurses, '名', '在岗护理', '#34C759')
|
||||||
|
this.StatTile(this.data.today_record_count, '条', '今日记录', '#FF9500')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height(96)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.padding(8)
|
||||||
|
|
||||||
|
Row() {
|
||||||
|
this.StatTile(this.data.today_alert_count, '条', '今日预警', '#FF3B30')
|
||||||
|
this.StatTile(this.data.pending_alert_count, '条', '待处理', '#FF3B30')
|
||||||
|
this.StatTile(this.data.focused_elderly, '位', '重点关注', '#AF52DE')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height(96)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.padding(8)
|
||||||
|
|
||||||
|
// 健康分布(环形图)
|
||||||
|
this.HealthRingCard()
|
||||||
|
|
||||||
|
// 运营概览 · 护理
|
||||||
|
Column({ space: 10 }) {
|
||||||
|
Text('运营概览')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
Text(`在岗护理 ${this.data.operation_overview.care.on_duty_count} 名`)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text(`今日记录 ${this.data.operation_overview.care.today_record_count} 条`)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
// 近7日护理记录趋势
|
||||||
|
this.TrendChartCard()
|
||||||
|
|
||||||
|
// 订餐
|
||||||
|
Column({ space: 10 }) {
|
||||||
|
Text(`今日订餐 ${this.data.operation_overview.meals.total_reserved_count} 份`)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
ForEach(this.data.operation_overview.meals.meals, (m: MealSummary) => {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(mealLabel(m.category))
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.accent_blue'))
|
||||||
|
Text(`${m.reserved_count} 份`)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text(m.menu_items.join('、'))
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.layoutWeight(1)
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}, (m: MealSummary) => m.category)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
// 未处理预警
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text(`未处理预警 (${this.data.unresolved_alerts.length})`)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
if (this.data.unresolved_alerts.length === 0) {
|
||||||
|
Text('暂无未处理预警')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
} else {
|
||||||
|
ForEach(this.data.unresolved_alerts, (alert: UnresolvedAlert) => {
|
||||||
|
Column() {
|
||||||
|
this.AlertCard(alert)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}, (alert: UnresolvedAlert) => String(alert.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
// 最近护理记录
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('最近护理记录')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
if (this.data.recent_records.length === 0) {
|
||||||
|
Text('暂无护理记录')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
} else {
|
||||||
|
ForEach(this.data.recent_records, (record: RecentRecord) => {
|
||||||
|
Column() {
|
||||||
|
this.RecordCard(record)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}, (record: RecentRecord) => String(record.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
.margin({ bottom: 10 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '信息概览',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// 管理员 · 智能查询(ArkTS 严格类型,无 any)
|
||||||
|
// 接口:POST /tasks/admin-query/ {query} → {answer, summaries, matched_record}
|
||||||
|
import { router } from '@kit.ArkUI';
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: number;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SummaryItem {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminQueryResult {
|
||||||
|
answer: string;
|
||||||
|
summaries: Array<SummaryItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueryPayload {
|
||||||
|
query: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageSeq = 1;
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct AdminQuery {
|
||||||
|
@State inputText: string = '';
|
||||||
|
@State messages: Array<ChatMessage> = [];
|
||||||
|
@State summaries: Array<SummaryItem> = [];
|
||||||
|
@State sending: boolean = false;
|
||||||
|
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
private async send(): Promise<void> {
|
||||||
|
const text = this.inputText.trim();
|
||||||
|
if (text.length === 0 || this.sending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userMsg: ChatMessage = { id: messageSeq++, role: 'user', content: text };
|
||||||
|
this.messages = this.messages.concat([userMsg]);
|
||||||
|
this.inputText = '';
|
||||||
|
this.sending = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.scroller.scrollEdge(Edge.Bottom);
|
||||||
|
}, 50);
|
||||||
|
try {
|
||||||
|
const payload: QueryPayload = { query: text };
|
||||||
|
const res = await api.post<AdminQueryResult>('/tasks/admin-query/', payload);
|
||||||
|
this.summaries = res.summaries;
|
||||||
|
const answer = res.answer && res.answer.length > 0 ? res.answer : '未查询到相关信息。';
|
||||||
|
this.messages = this.messages.concat([{ id: messageSeq++, role: 'assistant', content: answer }]);
|
||||||
|
this.scroller.scrollEdge(Edge.Bottom);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
this.messages = this.messages.concat([{ id: messageSeq++, role: 'assistant', content: message }]);
|
||||||
|
this.scroller.scrollEdge(Edge.Bottom);
|
||||||
|
} finally {
|
||||||
|
this.sending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
SummaryRow(item: SummaryItem) {
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
Text(item.label)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width(60)
|
||||||
|
Text(item.value)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.layoutWeight(1)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
MessageView(msg: ChatMessage) {
|
||||||
|
Row() {
|
||||||
|
if (msg.role === 'user') {
|
||||||
|
Blank()
|
||||||
|
Text(msg.content)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.constraintSize({ maxWidth: '72%' })
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(14)
|
||||||
|
} else {
|
||||||
|
Text(msg.content)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.constraintSize({ maxWidth: '88%' })
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
Blank()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, bottom: 12 })
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
if (this.messages.length === 0) {
|
||||||
|
Text('您好,我是管理员智能查询助手 🌟\n可以用文字或语音查询护理记录与健康数据,例如:\n“查询张三今天的体温”')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 60 })
|
||||||
|
}
|
||||||
|
ForEach(this.messages, (msg: ChatMessage) => {
|
||||||
|
this.MessageView(msg)
|
||||||
|
}, (msg: ChatMessage) => String(msg.id))
|
||||||
|
|
||||||
|
if (this.summaries.length > 0) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
ForEach(this.summaries, (item: SummaryItem) => {
|
||||||
|
this.SummaryRow(item)
|
||||||
|
}, (item: SummaryItem) => item.label)
|
||||||
|
}
|
||||||
|
.width('92%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(12)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 92, bottom: 20 })
|
||||||
|
}
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
.layoutWeight(1)
|
||||||
|
.align(Alignment.Top)
|
||||||
|
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
TextInput({ placeholder: '输入查询内容...', text: this.inputText })
|
||||||
|
.placeholderColor($r('app.color.inactive_tab'))
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.fontSize(16)
|
||||||
|
.height(52)
|
||||||
|
.layoutWeight(1)
|
||||||
|
.padding({ left: 16, right: 16 })
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.onChange((value: string) => {
|
||||||
|
this.inputText = value;
|
||||||
|
})
|
||||||
|
.onSubmit((enterKey: EnterKeyType) => {
|
||||||
|
this.send();
|
||||||
|
})
|
||||||
|
Button(this.sending ? '…' : '查询')
|
||||||
|
.height(52)
|
||||||
|
.padding({ left: 18, right: 18 })
|
||||||
|
.constraintSize({ minWidth: 80 })
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.enabled(!this.sending)
|
||||||
|
.onClick(() => {
|
||||||
|
this.send();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, bottom: 20 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '智能查询',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// 管理员 · 护理记录详情(ArkTS 严格类型,无 any)
|
||||||
|
// 接口:GET /tasks/nursing-records/{id}/
|
||||||
|
import { router, promptAction } from '@kit.ArkUI';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
|
||||||
|
interface VitalPart {
|
||||||
|
text?: string;
|
||||||
|
numeric?: string;
|
||||||
|
trend?: string;
|
||||||
|
systolic?: string;
|
||||||
|
diastolic?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextPart {
|
||||||
|
text?: string;
|
||||||
|
trend?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExtractedJson {
|
||||||
|
record_time?: string;
|
||||||
|
elderly_name?: string;
|
||||||
|
elderly_id_card?: string;
|
||||||
|
temperature?: VitalPart;
|
||||||
|
blood_pressure?: VitalPart;
|
||||||
|
weight?: VitalPart;
|
||||||
|
medication?: TextPart;
|
||||||
|
diet?: TextPart;
|
||||||
|
mood?: TextPart;
|
||||||
|
other?: TextPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NursingRecordDetail {
|
||||||
|
id: number;
|
||||||
|
elderly: number | null;
|
||||||
|
elderly_name: string;
|
||||||
|
nurse: number | null;
|
||||||
|
nurse_name: string;
|
||||||
|
raw_text: string;
|
||||||
|
images: Array<string>;
|
||||||
|
extracted_json: object;
|
||||||
|
work_item: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFullTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${mm}-${dd} ${hh}:${mi}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VitalRow {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVitalRows(record: NursingRecordDetail): Array<VitalRow> {
|
||||||
|
const e = record.extracted_json as ExtractedJson;
|
||||||
|
const rows: Array<VitalRow> = [];
|
||||||
|
const add = (label: string, value: string): void => {
|
||||||
|
if (value && value.length > 0) {
|
||||||
|
rows.push({ label: label, value: value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
add('记录时间', e.record_time || '');
|
||||||
|
add('体温', e.temperature ? e.temperature.text || e.temperature.numeric || '' : '');
|
||||||
|
add('血压', e.blood_pressure ? e.blood_pressure.text || '' : '');
|
||||||
|
add('体重', e.weight ? e.weight.text || e.weight.numeric || '' : '');
|
||||||
|
add('服药', e.medication ? e.medication.text || '' : '');
|
||||||
|
add('进食', e.diet ? e.diet.text || '' : '');
|
||||||
|
add('情绪', e.mood ? e.mood.text || '' : '');
|
||||||
|
add('其他', e.other ? e.other.text || '' : '');
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct AdminRecordDetail {
|
||||||
|
@State detail: NursingRecordDetail | null = null;
|
||||||
|
@State loading: boolean = true;
|
||||||
|
private recordId: number = 0;
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
const params = router.getParams() as Record<string, Object>;
|
||||||
|
this.recordId = Number(params['id'] ?? 0);
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await api.get<NursingRecordDetail>(`/tasks/nursing-records/${this.recordId}/`);
|
||||||
|
this.detail = res;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
FieldRow(row: VitalRow) {
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
Text(row.label)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width(72)
|
||||||
|
Text(row.value)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.layoutWeight(1)
|
||||||
|
.maxLines(3)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 6, bottom: 6 })
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
if (this.loading) {
|
||||||
|
Column() {
|
||||||
|
Text('正在加载')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
} else if (this.detail === null) {
|
||||||
|
Column() {
|
||||||
|
Text('记录不存在或已删除')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
} else {
|
||||||
|
Scroll() {
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
// 老人头卡
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
Column() {
|
||||||
|
Text(this.detail.elderly_name.length > 0 ? this.detail.elderly_name.substring(0, 1) : '护')
|
||||||
|
.fontSize(20)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
}
|
||||||
|
.width(52)
|
||||||
|
.height(52)
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(16)
|
||||||
|
|
||||||
|
Column({ space: 5 }) {
|
||||||
|
Text(this.detail.elderly_name)
|
||||||
|
.fontSize(17)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text(`${this.detail.nurse_name || '--'} · ${formatFullTime(this.detail.created_at)}`)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(VerticalAlign.Center)
|
||||||
|
|
||||||
|
// 体征与评估
|
||||||
|
if (buildVitalRows(this.detail).length > 0) {
|
||||||
|
Column({ space: 2 }) {
|
||||||
|
Text('体征与评估')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
ForEach(buildVitalRows(this.detail), (row: VitalRow) => {
|
||||||
|
this.FieldRow(row)
|
||||||
|
}, (row: VitalRow) => `${row.label}-${row.value}`)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 护理描述
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('护理描述')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
Text(this.detail.raw_text || '无')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '护理记录详情',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
// 管理员 · 销售详情(ArkTS 严格类型,无 any)
|
||||||
|
// 接口:GET /market/cycles/overview/
|
||||||
|
// GET /market/leads/
|
||||||
|
// GET /market/contracts/
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
import { router } from '@kit.ArkUI';
|
||||||
|
|
||||||
|
interface CycleOverview {
|
||||||
|
month: string;
|
||||||
|
total_count: number;
|
||||||
|
assigned_count: number;
|
||||||
|
unassigned_count: number;
|
||||||
|
pending_count: number;
|
||||||
|
in_progress_count: number;
|
||||||
|
won_count: number;
|
||||||
|
lost_count: number;
|
||||||
|
active_sales_count: number;
|
||||||
|
sales_breakdown: Array<SalesBreakdown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SalesBreakdown {
|
||||||
|
sales_id: number;
|
||||||
|
sales_name: string;
|
||||||
|
assigned_count: number;
|
||||||
|
pending_count: number;
|
||||||
|
in_progress_count: number;
|
||||||
|
won_count: number;
|
||||||
|
lost_count: number;
|
||||||
|
won_amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SalesLead {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
gender: string;
|
||||||
|
age: number | null;
|
||||||
|
contact_method: string;
|
||||||
|
status: string;
|
||||||
|
deal_amount: number | null;
|
||||||
|
current_sales_name: string;
|
||||||
|
current_cycle_month: string;
|
||||||
|
latest_note: string;
|
||||||
|
last_follow_up_at: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Contract {
|
||||||
|
id: number;
|
||||||
|
lead_name: string;
|
||||||
|
customer_name: string;
|
||||||
|
sales_name: string;
|
||||||
|
contract_amount: number | null;
|
||||||
|
contract_code: string;
|
||||||
|
contract_date_range: string;
|
||||||
|
room_number: string;
|
||||||
|
signed_date: string;
|
||||||
|
occupancy_status: string;
|
||||||
|
care_level_label: string;
|
||||||
|
admitted_elderly_name: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
if (status === 'won') {
|
||||||
|
return '成交';
|
||||||
|
}
|
||||||
|
if (status === 'in_progress') {
|
||||||
|
return '跟进中';
|
||||||
|
}
|
||||||
|
if (status === 'lost') {
|
||||||
|
return '失败';
|
||||||
|
}
|
||||||
|
return '待跟进';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(status: string): string {
|
||||||
|
if (status === 'won') {
|
||||||
|
return '#34C759';
|
||||||
|
}
|
||||||
|
if (status === 'in_progress') {
|
||||||
|
return '#007AFF';
|
||||||
|
}
|
||||||
|
if (status === 'lost') {
|
||||||
|
return '#8E8E93';
|
||||||
|
}
|
||||||
|
return '#FF9500';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
if (!iso || iso.length === 0) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${mm}/${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthLabel(month: string): string {
|
||||||
|
if (!month || month.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const parts = month.split('-');
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return month;
|
||||||
|
}
|
||||||
|
return `${parts[0]}年${parts[1]}月`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function amountText(amount: number | null): string {
|
||||||
|
if (amount === null || amount === undefined) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
return `¥${amount}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct AdminSalesDetail {
|
||||||
|
@State cycle: CycleOverview | null = null;
|
||||||
|
@State leads: Array<SalesLead> = [];
|
||||||
|
@State contracts: Array<Contract> = [];
|
||||||
|
@State loading: boolean = true;
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const results: Array<CycleOverview | Page<SalesLead> | Page<Contract> | null> = await Promise.all([
|
||||||
|
api.get<CycleOverview>('/market/cycles/overview/').catch((): CycleOverview | null => null),
|
||||||
|
api.get<Page<SalesLead>>('/market/leads/', { page_size: 30, ordering: '-created_at' }).catch((): Page<SalesLead> | null => null),
|
||||||
|
api.get<Page<Contract>>('/market/contracts/', { page_size: 30 }).catch((): Page<Contract> | null => null),
|
||||||
|
]);
|
||||||
|
this.cycle = results[0] as CycleOverview | null;
|
||||||
|
const leadRes = results[1] as Page<SalesLead> | null;
|
||||||
|
const contractRes = results[2] as Page<Contract> | null;
|
||||||
|
this.leads = leadRes ? leadRes.results : [];
|
||||||
|
this.contracts = contractRes ? contractRes.results : [];
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
StatTile(value: number, label: string, color: string) {
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Text(String(value))
|
||||||
|
.fontSize(22)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(color)
|
||||||
|
Text(label)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
LeadCard(lead: SalesLead) {
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(lead.name)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
if (lead.age !== null && lead.age !== undefined) {
|
||||||
|
Text(`${lead.age}岁`)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
Blank()
|
||||||
|
Text(statusLabel(lead.status))
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor(statusColor(lead.status))
|
||||||
|
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
||||||
|
.backgroundColor('#14000000')
|
||||||
|
.borderRadius(6)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
if (lead.contact_method && lead.contact_method.length > 0) {
|
||||||
|
Text(`📞 ${lead.contact_method}`)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
if (lead.current_sales_name && lead.current_sales_name.length > 0) {
|
||||||
|
Text(`负责:${lead.current_sales_name}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.accent_blue'))
|
||||||
|
}
|
||||||
|
Text(amountText(lead.deal_amount))
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor('#FF9500')
|
||||||
|
Blank()
|
||||||
|
Text(formatDate(lead.created_at))
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(12)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
ContractCard(c: Contract) {
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(c.customer_name)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Blank()
|
||||||
|
Text(amountText(c.contract_amount))
|
||||||
|
.fontSize(14)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#FF9500')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
if (c.contract_code && c.contract_code.length > 0) {
|
||||||
|
Text(c.contract_code)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
if (c.contract_date_range && c.contract_date_range.length > 0) {
|
||||||
|
Text(c.contract_date_range)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
Blank()
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
if (c.sales_name && c.sales_name.length > 0) {
|
||||||
|
Text(`销售:${c.sales_name}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.accent_blue'))
|
||||||
|
}
|
||||||
|
if (c.room_number && c.room_number.length > 0) {
|
||||||
|
Text(`房号:${c.room_number}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(12)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
if (this.loading) {
|
||||||
|
Text('加载中...')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.margin({ top: 60 })
|
||||||
|
} else {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
if (this.cycle) {
|
||||||
|
// 周期概览
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
Row() {
|
||||||
|
Text(monthLabel(this.cycle.month))
|
||||||
|
.fontSize(17)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Blank()
|
||||||
|
Text(`活跃销售 ${this.cycle.active_sales_count} 名`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Row() {
|
||||||
|
this.StatTile(this.cycle.total_count, '总线索', '#007AFF')
|
||||||
|
this.StatTile(this.cycle.won_count, '成交', '#34C759')
|
||||||
|
this.StatTile(this.cycle.in_progress_count, '跟进中', '#FF9500')
|
||||||
|
this.StatTile(this.cycle.lost_count, '失败', '#8E8E93')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
if (this.cycle.sales_breakdown.length > 0) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
ForEach(this.cycle.sales_breakdown, (s: SalesBreakdown) => {
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
Text(s.sales_name)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width(72)
|
||||||
|
Text(`${s.assigned_count}条`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Text(`成交 ${s.won_count}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.accent_green'))
|
||||||
|
Text(`¥${s.won_amount}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor('#FF9500')
|
||||||
|
Blank()
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}, (s: SalesBreakdown) => String(s.sales_id))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 线索列表
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Row() {
|
||||||
|
Text('销售线索')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Blank()
|
||||||
|
Text(`共 ${this.leads.length} 条`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
if (this.leads.length === 0) {
|
||||||
|
Text('暂无销售线索')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(12)
|
||||||
|
} else {
|
||||||
|
ForEach(this.leads, (lead: SalesLead) => {
|
||||||
|
Column() {
|
||||||
|
this.LeadCard(lead)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}, (lead: SalesLead) => String(lead.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
// 合同列表
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Row() {
|
||||||
|
Text('签约合同')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Blank()
|
||||||
|
Text(`共 ${this.contracts.length} 份`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
if (this.contracts.length === 0) {
|
||||||
|
Text('暂无合同')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(12)
|
||||||
|
} else {
|
||||||
|
ForEach(this.contracts, (c: Contract) => {
|
||||||
|
Column() {
|
||||||
|
this.ContractCard(c)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}, (c: Contract) => String(c.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
.margin({ bottom: 20 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, bottom: 24 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '销售详情',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
// 管理员 · 排班管理(ArkTS 严格类型,无 any)
|
||||||
|
// 接口:GET /shifts/floor-duty-assignments/?duty_date=
|
||||||
|
// GET /shifts/caregiver-schedules/?month=
|
||||||
|
// GET /shifts/caregiver-duty-assignments/?schedule_version=
|
||||||
|
import { router } from '@kit.ArkUI';
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
|
||||||
|
interface FloorDuty {
|
||||||
|
id: number;
|
||||||
|
duty_date: string;
|
||||||
|
floor: number;
|
||||||
|
shift_name: string;
|
||||||
|
shift_time: string;
|
||||||
|
staff_nurse_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CaregiverAssignment {
|
||||||
|
id: number;
|
||||||
|
duty_date: string;
|
||||||
|
caregiver_name: string;
|
||||||
|
source_name: string;
|
||||||
|
target_floor: number | null;
|
||||||
|
target_room: string;
|
||||||
|
scope_type: string;
|
||||||
|
shift_code: string;
|
||||||
|
is_day_duty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScheduleVersion {
|
||||||
|
id: number;
|
||||||
|
target_month: string;
|
||||||
|
version: number;
|
||||||
|
status: string;
|
||||||
|
assignments: Array<CaregiverAssignment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayStr(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(now.getDate()).padStart(2, '0');
|
||||||
|
return `${now.getFullYear()}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthStr(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
return `${now.getFullYear()}-${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekdayLabel(): string {
|
||||||
|
const weekdays: Array<string> = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||||
|
return weekdays[new Date().getDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeLabel(scope: string): string {
|
||||||
|
if (scope === 'floor') {
|
||||||
|
return '楼层';
|
||||||
|
}
|
||||||
|
if (scope === 'private_room') {
|
||||||
|
return '专护';
|
||||||
|
}
|
||||||
|
return '辅助';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct AdminSchedule {
|
||||||
|
@State floorDuties: Array<FloorDuty> = [];
|
||||||
|
@State caregiverAssignments: Array<CaregiverAssignment> = [];
|
||||||
|
@State loading: boolean = true;
|
||||||
|
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const responses: Array<Page<FloorDuty> | Page<ScheduleVersion> | null> = await Promise.all([
|
||||||
|
api.get<Page<FloorDuty>>('/shifts/floor-duty-assignments/', { duty_date: todayStr(), page_size: 100 })
|
||||||
|
.catch((): Page<FloorDuty> | null => null),
|
||||||
|
api.get<Page<ScheduleVersion>>('/shifts/caregiver-schedules/', { month: monthStr(), page_size: 20 })
|
||||||
|
.catch((): Page<ScheduleVersion> | null => null),
|
||||||
|
]);
|
||||||
|
const floorRes = responses[0] as Page<FloorDuty> | null;
|
||||||
|
this.floorDuties = floorRes ? floorRes.results : [];
|
||||||
|
|
||||||
|
const versionRes = responses[1] as Page<ScheduleVersion> | null;
|
||||||
|
if (versionRes) {
|
||||||
|
const versions: Array<ScheduleVersion> = versionRes.results;
|
||||||
|
// 取已发布的版本;如无发布取第一个
|
||||||
|
const published = versions.find((v: ScheduleVersion) => v.status === 'published');
|
||||||
|
const target = published ?? (versions.length > 0 ? versions[0] : null);
|
||||||
|
if (target && target.assignments) {
|
||||||
|
this.caregiverAssignments = target.assignments.filter(
|
||||||
|
(a: CaregiverAssignment) => a.duty_date === todayStr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
FloorCard(duty: FloorDuty) {
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
Column() {
|
||||||
|
Text(String(duty.floor))
|
||||||
|
.fontSize(20)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
}
|
||||||
|
.width(48)
|
||||||
|
.height(48)
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(14)
|
||||||
|
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Text(`${duty.floor}楼 · ${duty.shift_name}`)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text(duty.shift_time)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
Text(duty.staff_nurse_name || '--')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor($r('app.color.accent_green'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
CaregiverCard(a: CaregiverAssignment) {
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
Text(a.caregiver_name || a.source_name || '未校对')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text(a.is_day_duty ? '日值' : scopeLabel(a.scope_type))
|
||||||
|
.fontSize(11)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||||
|
.backgroundColor(a.is_day_duty ? '#AF52DE' : '#FF9500')
|
||||||
|
.borderRadius(4)
|
||||||
|
Blank()
|
||||||
|
Text(a.target_floor ? `${a.target_floor}楼` : (a.target_room ? a.target_room : '--'))
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Text(a.shift_code)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.accent_blue'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
if (this.loading) {
|
||||||
|
Text('加载中...')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.margin({ top: 40 })
|
||||||
|
} else {
|
||||||
|
// 楼层护士值班
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('今日楼层护士值班')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
if (this.floorDuties.length === 0) {
|
||||||
|
Text('今日暂无楼层值班排班')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(12)
|
||||||
|
} else {
|
||||||
|
ForEach(this.floorDuties, (duty: FloorDuty) => {
|
||||||
|
Column() {
|
||||||
|
this.FloorCard(duty)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}, (duty: FloorDuty) => String(duty.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
// 护理员今日值班
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('护理员今日值班')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
if (this.caregiverAssignments.length === 0) {
|
||||||
|
Text('当前月份暂无护理员排班数据')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(12)
|
||||||
|
} else {
|
||||||
|
ForEach(this.caregiverAssignments, (a: CaregiverAssignment) => {
|
||||||
|
Column() {
|
||||||
|
this.CaregiverCard(a)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}, (a: CaregiverAssignment) => String(a.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
.margin({ bottom: 20 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 120, bottom: 30 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '排班管理',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
// 管理员 · 设备监测/睡眠(ArkTS 严格类型,无 any)
|
||||||
|
// 接口:GET /devices/types/sleep_monitor/overview/、/devices/types/body_sensing/overview/
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
|
||||||
|
interface TypeInfo {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
device_count: number;
|
||||||
|
bound_elderly_count: number;
|
||||||
|
unresolved_alert_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ElderlyBrief {
|
||||||
|
name: string;
|
||||||
|
gender: string;
|
||||||
|
age: number;
|
||||||
|
care_level: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeviceAlert {
|
||||||
|
key: string;
|
||||||
|
severity: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SleepInfo {
|
||||||
|
status: string;
|
||||||
|
deep_minutes: number;
|
||||||
|
light_minutes: number;
|
||||||
|
awake_minutes: number;
|
||||||
|
start_at: string;
|
||||||
|
wake_at: string;
|
||||||
|
report_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Vitals {
|
||||||
|
heart_rate: number | null;
|
||||||
|
respiratory_rate: number | null;
|
||||||
|
body_temperature: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SleepResident {
|
||||||
|
id: number;
|
||||||
|
device_code: string;
|
||||||
|
floor: string;
|
||||||
|
room: string;
|
||||||
|
bed: string;
|
||||||
|
elderly: ElderlyBrief;
|
||||||
|
presence: boolean;
|
||||||
|
body_movement: boolean;
|
||||||
|
sleep: SleepInfo;
|
||||||
|
vitals: Vitals;
|
||||||
|
alerts: Array<DeviceAlert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SleepOverview {
|
||||||
|
type: TypeInfo;
|
||||||
|
pending_alert_count: number;
|
||||||
|
monitoring_residents: Array<SleepResident>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BodySensing {
|
||||||
|
online: boolean;
|
||||||
|
motion_label: string;
|
||||||
|
fall_status: boolean;
|
||||||
|
long_unattended: boolean;
|
||||||
|
people_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BodyResident {
|
||||||
|
id: number;
|
||||||
|
device_code: string;
|
||||||
|
floor: string;
|
||||||
|
room: string;
|
||||||
|
bed: string;
|
||||||
|
elderly: ElderlyBrief;
|
||||||
|
presence: boolean;
|
||||||
|
body_sensing: BodySensing;
|
||||||
|
alerts: Array<DeviceAlert>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BodyOverview {
|
||||||
|
type: TypeInfo;
|
||||||
|
pending_alert_count: number;
|
||||||
|
monitoring_residents: Array<BodyResident>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roomLabel(r: SleepResident | BodyResident): string {
|
||||||
|
if (r.floor && r.floor.length > 0) {
|
||||||
|
return `${r.floor}${r.room ? '/' + r.room : ''}${r.bed ? '/' + r.bed : ''}`;
|
||||||
|
}
|
||||||
|
return r.room || r.bed || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function vitalText(v: number | null): string {
|
||||||
|
return v === null || v === undefined ? '--' : String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct DeviceMonitoring {
|
||||||
|
@State sleep: SleepOverview | null = null;
|
||||||
|
@State body: BodyOverview | null = null;
|
||||||
|
@State loading: boolean = true;
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const results: Array<SleepOverview | BodyOverview | null> = await Promise.all([
|
||||||
|
api.get<SleepOverview>('/devices/types/sleep_monitor/overview/').catch((): SleepOverview | null => null),
|
||||||
|
api.get<BodyOverview>('/devices/types/body_sensing/overview/').catch((): BodyOverview | null => null),
|
||||||
|
]);
|
||||||
|
this.sleep = results[0] as SleepOverview | null;
|
||||||
|
this.body = results[1] as BodyOverview | null;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
SleepCard(r: SleepResident) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
Column() {
|
||||||
|
Text((r.elderly.name || '?').substring(0, 1))
|
||||||
|
.fontSize(18)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
}
|
||||||
|
.width(44)
|
||||||
|
.height(44)
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(12)
|
||||||
|
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(r.elderly.name)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text(roomLabel(r))
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Blank()
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
Text(r.sleep.status || '--')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor(r.presence ? $r('app.color.accent_blue') : $r('app.color.accent_green'))
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
if (r.sleep) {
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
Text(`深睡 ${r.sleep.deep_minutes}分`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Text(`浅睡 ${r.sleep.light_minutes}分`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Text(`清醒 ${r.sleep.awake_minutes}分`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Blank()
|
||||||
|
Text(`${r.sleep.start_at}~${r.sleep.wake_at}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.vitals) {
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
Text(`心率 ${vitalText(r.vitals.heart_rate)}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.accent_red'))
|
||||||
|
Text(`呼吸 ${vitalText(r.vitals.respiratory_rate)}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.accent_blue'))
|
||||||
|
Text(`体温 ${vitalText(r.vitals.body_temperature)}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor('#FF9500')
|
||||||
|
Blank()
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.alerts && r.alerts.length > 0) {
|
||||||
|
ForEach(r.alerts, (a: DeviceAlert) => {
|
||||||
|
Text(`⚠ ${a.description || a.key}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor('#FF3B30')
|
||||||
|
.width('100%')
|
||||||
|
}, (a: DeviceAlert) => `${r.id}-${a.key}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
BodyCard(r: BodyResident) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
Column() {
|
||||||
|
Text((r.elderly.name || '?').substring(0, 1))
|
||||||
|
.fontSize(18)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
}
|
||||||
|
.width(44)
|
||||||
|
.height(44)
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
.backgroundColor('#7C3AED')
|
||||||
|
.borderRadius(12)
|
||||||
|
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(r.elderly.name)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text(roomLabel(r))
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Blank()
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
Text(r.body_sensing && r.body_sensing.motion_label ? r.body_sensing.motion_label : '--')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor(r.presence ? $r('app.color.accent_blue') : $r('app.color.inactive_tab'))
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
if (r.body_sensing) {
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
Text(r.body_sensing.online ? '在线' : '离线')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor(r.body_sensing.online ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
|
||||||
|
if (r.body_sensing.fall_status) {
|
||||||
|
Text('跌倒!')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor('#FF3B30')
|
||||||
|
}
|
||||||
|
if (r.body_sensing.long_unattended) {
|
||||||
|
Text('长期无人')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor('#FF9500')
|
||||||
|
}
|
||||||
|
Blank()
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (r.alerts && r.alerts.length > 0) {
|
||||||
|
ForEach(r.alerts, (a: DeviceAlert) => {
|
||||||
|
Text(`⚠ ${a.description || a.key}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor('#FF3B30')
|
||||||
|
.width('100%')
|
||||||
|
}, (a: DeviceAlert) => `${r.id}-${a.key}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
if (this.loading) {
|
||||||
|
Text('加载中...')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.margin({ top: 60 })
|
||||||
|
} else {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 14 }) {
|
||||||
|
// 睡眠监测
|
||||||
|
Column({ space: 10 }) {
|
||||||
|
Row() {
|
||||||
|
Text('睡眠监测')
|
||||||
|
.fontSize(17)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Blank()
|
||||||
|
if (this.sleep) {
|
||||||
|
Text(`设备 ${this.sleep.type.device_count} · 绑定 ${this.sleep.type.bound_elderly_count}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
if (!this.sleep) {
|
||||||
|
Text('暂无睡眠设备数据')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(12)
|
||||||
|
} else {
|
||||||
|
ForEach(this.sleep.monitoring_residents, (r: SleepResident) => {
|
||||||
|
Column() {
|
||||||
|
this.SleepCard(r)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}, (r: SleepResident) => String(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
// 体感监测
|
||||||
|
Column({ space: 10 }) {
|
||||||
|
Row() {
|
||||||
|
Text('体感监测')
|
||||||
|
.fontSize(17)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Blank()
|
||||||
|
if (this.body) {
|
||||||
|
Text(`设备 ${this.body.type.device_count} · 绑定 ${this.body.type.bound_elderly_count}`)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
if (!this.body) {
|
||||||
|
Text('暂无体感设备数据')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(12)
|
||||||
|
} else {
|
||||||
|
ForEach(this.body.monitoring_residents, (r: BodyResident) => {
|
||||||
|
Column() {
|
||||||
|
this.BodyCard(r)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}, (r: BodyResident) => String(r.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
.margin({ bottom: 20 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '设备监测',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
// 预警页签(ArkTS 严格类型,无 any)
|
||||||
|
// 数据来源:/alerts/
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api } from '../../utils/Request';
|
||||||
|
import { AlertSocket } from '../../utils/AlertSocket';
|
||||||
|
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||||
|
|
||||||
|
interface AlertItem {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
level: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
return `${mm}/${dd} ${hh}:${mi}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelColor(level: string): string {
|
||||||
|
const lv = (level || '').toLowerCase();
|
||||||
|
if (lv.indexOf('critical') >= 0 || lv.indexOf('emergency') >= 0 || lv.indexOf('严重') >= 0) {
|
||||||
|
return ('app.color.accent_red');
|
||||||
|
}
|
||||||
|
if (lv.indexOf('warning') >= 0 || lv.indexOf('警告') >= 0) {
|
||||||
|
return '#FF9500';
|
||||||
|
}
|
||||||
|
return ('app.color.accent_blue');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export struct AlertsTab {
|
||||||
|
@State alerts: Array<AlertItem> = [];
|
||||||
|
@State loading: boolean = true;
|
||||||
|
@State refreshing: boolean = false;
|
||||||
|
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||||
|
|
||||||
|
private scaleFor(): number {
|
||||||
|
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.loadAlerts();
|
||||||
|
// 注册预警实时推送:收到推送刷新列表
|
||||||
|
AlertSocket.setListener(() => {
|
||||||
|
this.loadAlerts();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
aboutToDisappear(): void {
|
||||||
|
AlertSocket.setListener(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadAlerts(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await api.get<Page<AlertItem>>('/alerts/', {
|
||||||
|
page_size: 50,
|
||||||
|
ordering: '-created_at',
|
||||||
|
});
|
||||||
|
this.alerts = res.results;
|
||||||
|
} catch (err) {
|
||||||
|
// 静默降级:显示空态
|
||||||
|
console.error('[AlertsTab] load failed:', normalizeError(err as BusinessError | Error));
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
this.refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
AlertCard(item: AlertItem) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Circle()
|
||||||
|
.width(8)
|
||||||
|
.height(8)
|
||||||
|
.fill(levelColor(item.level))
|
||||||
|
Text(item.title)
|
||||||
|
.fontSize(Math.round(15 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.layoutWeight(1)
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
Text(formatDateTime(item.created_at))
|
||||||
|
.fontSize(Math.round(12 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Text(item.description && item.description.length > 0 ? item.description : '(无详情)')
|
||||||
|
.fontSize(Math.round(13 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.maxLines(2)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Refresh({ refreshing: $$this.refreshing }) {
|
||||||
|
Scroll() {
|
||||||
|
Column({ space: 10 }) {
|
||||||
|
Row() {
|
||||||
|
Text('预警消息')
|
||||||
|
.fontSize(Math.round(18 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Blank()
|
||||||
|
Text('刷新')
|
||||||
|
.fontSize(Math.round(13 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.accent_blue'))
|
||||||
|
.onClick(() => {
|
||||||
|
this.loadAlerts();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('92%')
|
||||||
|
.margin({ top: 20, bottom: 4 })
|
||||||
|
|
||||||
|
if (this.loading) {
|
||||||
|
Text('加载中...')
|
||||||
|
.fontSize(Math.round(13 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.margin({ top: 40 })
|
||||||
|
} else if (this.alerts.length === 0) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('🔔')
|
||||||
|
.fontSize(Math.round(32 * this.scaleFor()))
|
||||||
|
Text('暂无预警消息')
|
||||||
|
.fontSize(Math.round(14 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.margin({ top: 80 })
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
} else {
|
||||||
|
ForEach(this.alerts, (item: AlertItem) => {
|
||||||
|
this.AlertCard(item)
|
||||||
|
}, (item: AlertItem) => String(item.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
.padding({ bottom: 120 })
|
||||||
|
}
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.onRefreshing(() => {
|
||||||
|
this.loadAlerts();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
// 业务中心页签(按角色自适应入口列表,ArkTS 严格类型,无 any)
|
||||||
|
import { router } from '@kit.ArkUI';
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { uiMaterial } from '@kit.ArkUI';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { UnifiedRole, normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { AuthStore } from '../../utils/AuthStore';
|
||||||
|
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||||
|
|
||||||
|
interface BusinessEntry {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
route: string; // 空字符串表示未实现
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BusinessSection {
|
||||||
|
title: string;
|
||||||
|
entries: Array<BusinessEntry>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export struct BusinessTab {
|
||||||
|
@State role: UnifiedRole | null = null;
|
||||||
|
@State sections: Array<BusinessSection> = [];
|
||||||
|
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||||
|
|
||||||
|
private scaleFor(): number {
|
||||||
|
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.loadRole();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadRole(): Promise<void> {
|
||||||
|
const state = await AuthStore.getState();
|
||||||
|
this.role = state.role;
|
||||||
|
this.applyRoleConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyRoleConfig(): void {
|
||||||
|
const role = this.role;
|
||||||
|
if (role === 'nurse' || role === 'staff_nurse') {
|
||||||
|
this.sections = [
|
||||||
|
{
|
||||||
|
title: '护理工作',
|
||||||
|
entries: [
|
||||||
|
{ key: 'record', title: '护理记录录入', desc: '提交文字/图片护理记录', route: 'pages/nurse/RecordEntry' },
|
||||||
|
{ key: 'history', title: '护理记录列表', desc: '查看与搜索历史记录', route: 'pages/nurse/History' },
|
||||||
|
{ key: 'work-center', title: '护理中心', desc: '当前楼层与护理要务', route: 'pages/nurse/WorkCenter' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '老人服务',
|
||||||
|
entries: [
|
||||||
|
{ key: 'elderly', title: '老人列表', desc: '负责长者的档案信息', route: 'pages/elderly/ElderlyList' },
|
||||||
|
{ key: 'skin', title: '皮肤检查', desc: '压疮风险巡检记录', route: 'pages/nurse/RecordEntry' },
|
||||||
|
{ key: 'medications', title: '用药管理', desc: '长者用药提醒与记录', route: 'pages/nurse/MedicationToDo' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else if (role === 'family') {
|
||||||
|
this.sections = [
|
||||||
|
{
|
||||||
|
title: '看护服务',
|
||||||
|
entries: [
|
||||||
|
{ key: 'records', title: '护理记录', desc: '查看长者护理记录', route: 'pages/family/Records' },
|
||||||
|
{ key: 'elderly', title: '长者档案', desc: '查看长者档案信息', route: 'pages/family/FamilyElderly' },
|
||||||
|
{ key: 'history', title: '长者历史', desc: '健康与护理历史', route: 'pages/family/FamilyElderlyHistory' },
|
||||||
|
{ key: 'assistant', title: '智能助手', desc: '随时咨询家人状况', route: 'pages/family/FamilyAssistant' },
|
||||||
|
{ key: 'verify', title: '安全验证', desc: '验证家属身份', route: 'pages/family/FamilyVerify' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else if (role === 'fire_control') {
|
||||||
|
this.sections = [
|
||||||
|
{
|
||||||
|
title: '消防工作',
|
||||||
|
entries: [
|
||||||
|
{ key: 'patrol', title: '消防巡更', desc: '扫码打卡与巡更记录', route: 'pages/fire-control/FirePatrolHome' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else if (role === 'sales') {
|
||||||
|
this.sections = [
|
||||||
|
{
|
||||||
|
title: '销售工作',
|
||||||
|
entries: [
|
||||||
|
{ key: 'sales', title: '销售工作台', desc: '线索、跟进与合同', route: 'pages/sales/SalesHome' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// 管理员/默认
|
||||||
|
this.sections = [
|
||||||
|
{
|
||||||
|
title: '记录管理',
|
||||||
|
entries: [
|
||||||
|
{ key: 'today', title: '今日记录', desc: '查看今日全部护理记录', route: 'pages/nurse/History' },
|
||||||
|
{ key: 'records', title: '记录概览', desc: '搜索与筛选护理记录', route: 'pages/nurse/History' },
|
||||||
|
{ key: 'alerts', title: '预警概览', desc: '查看预警处理情况', route: 'pages/nurse/Alerts' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '运营管理',
|
||||||
|
entries: [
|
||||||
|
{ key: 'meal', title: '订餐查询', desc: '今日菜单与订餐记录', route: 'pages/meals/MealOrders' },
|
||||||
|
{ key: 'schedule', title: '排班管理', desc: '查看和调整护理排班', route: 'pages/admin/AdminSchedule' },
|
||||||
|
{ key: 'feedback', title: '意见反馈', desc: '收集家属及员工建议', route: 'pages/profile/Feedback' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onEntryClick(entry: BusinessEntry): void {
|
||||||
|
if (entry.route.length === 0) {
|
||||||
|
promptAction.showToast({ message: '功能开发中' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.pushUrl({ url: entry.route }).catch((err: BusinessError) => {
|
||||||
|
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
EntryRow(entry: BusinessEntry) {
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Text(entry.title)
|
||||||
|
.fontSize(Math.round(16 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text(entry.desc)
|
||||||
|
.fontSize(Math.round(12 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
Text('›')
|
||||||
|
.fontSize(Math.round(22 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor(Color.Transparent)
|
||||||
|
.backgroundBlurStyle(BlurStyle.Thin)
|
||||||
|
.borderRadius(14)
|
||||||
|
.onClick(() => {
|
||||||
|
this.onEntryClick(entry);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Scroll() {
|
||||||
|
Column({ space: 18 }) {
|
||||||
|
ForEach(this.sections, (section: BusinessSection) => {
|
||||||
|
Column({ space: 10 }) {
|
||||||
|
Text(section.title)
|
||||||
|
.fontSize(Math.round(16 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
ForEach(section.entries, (entry: BusinessEntry) => {
|
||||||
|
this.EntryRow(entry)
|
||||||
|
}, (entry: BusinessEntry) => entry.key)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('92%')
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}, (section: BusinessSection) => section.title)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
.padding({ top: 20, bottom: 120 })
|
||||||
|
}
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
// 老人端 · 活动安排(ArkTS 严格类型,无 any)
|
||||||
|
// 接口:GET /activities/weeks/<week_start>/
|
||||||
|
import { router, promptAction } from '@kit.ArkUI';
|
||||||
|
import { InsightIntentPage, common } from '@kit.AbilityKit';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api } from '../../utils/Request';
|
||||||
|
import { addCalendarEvent } from '../../utils/CalendarHelper';
|
||||||
|
|
||||||
|
interface ActivityItem {
|
||||||
|
id: number;
|
||||||
|
activity_name: string;
|
||||||
|
activity_description: string;
|
||||||
|
activity_date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
location: string;
|
||||||
|
participants: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivityWeek {
|
||||||
|
week_start: string;
|
||||||
|
status: string;
|
||||||
|
items: Array<ActivityItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function weekStart(date: Date): string {
|
||||||
|
const d = new Date(date);
|
||||||
|
const day = d.getDay() === 0 ? 7 : d.getDay();
|
||||||
|
d.setDate(d.getDate() - day + 1);
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLabel(iso: string): string {
|
||||||
|
const d = new Date(iso + 'T00:00:00');
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
const weekdays: Array<string> = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()} ${weekdays[d.getDay()]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(t: string): string {
|
||||||
|
return t && t.length > 0 ? t : '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatYMD(d: Date): string {
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@InsightIntentPage({
|
||||||
|
intentName: 'ViewActivities',
|
||||||
|
domain: 'ElderlyCare',
|
||||||
|
intentVersion: '1.0.0',
|
||||||
|
displayName: '查看院内活动',
|
||||||
|
displayDescription: '打开智慧护老的活动页,查看本周和下周的院内活动安排',
|
||||||
|
uiAbility: 'EntryAbility',
|
||||||
|
pagePath: 'ets/pages/elderly/Activities',
|
||||||
|
keywords: ['活动', '健身', '八段锦', '娱乐', '课程'],
|
||||||
|
llmDescription: '查看养老院本周及未来的院内活动安排与报名信息',
|
||||||
|
})
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct Activities {
|
||||||
|
@State items: Array<ActivityItem> = [];
|
||||||
|
@State loading: boolean = true;
|
||||||
|
@State weekStartText: string = weekStart(new Date());
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.loadActivities();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从本周起向后查找最近一个已发布且有活动的周,避免本周未发布时误显示空周
|
||||||
|
private async loadActivities(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const base = new Date(this.weekStartText + 'T00:00:00');
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const ws = new Date(base);
|
||||||
|
ws.setDate(ws.getDate() + i * 7);
|
||||||
|
const week = formatYMD(ws);
|
||||||
|
const res = await api.get<ActivityWeek>(`/activities/weeks/${week}/`);
|
||||||
|
if (res.items && res.items.length > 0) {
|
||||||
|
this.items = res.items;
|
||||||
|
this.weekStartText = week;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.items = [];
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
ActivityCard(item: ActivityItem) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Row() {
|
||||||
|
Text(item.activity_name)
|
||||||
|
.fontSize(18)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.layoutWeight(1)
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
Text(formatDateLabel(item.activity_date))
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.accent_blue'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
if (item.activity_description && item.activity_description.length > 0) {
|
||||||
|
Text(item.activity_description)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.maxLines(2)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
Text(`🕐 ${formatTime(item.start_time)} - ${formatTime(item.end_time)}`)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
if (item.location && item.location.length > 0) {
|
||||||
|
Text(`📍 ${item.location}`)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
Blank()
|
||||||
|
Text('+ 日历')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.accent_blue'))
|
||||||
|
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||||
|
.borderRadius(14)
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
.onClick(() => {
|
||||||
|
this.addEventToCalendar(item);
|
||||||
|
})
|
||||||
|
.accessibilityLevel('yes')
|
||||||
|
.accessibilityText(`将${item.activity_name}加入系统日历`)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析活动日期与时间 → 写入系统日历
|
||||||
|
private async addEventToCalendar(item: ActivityItem): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 后端时间为 HH:mm:ss,兼容 HH:mm;避免拼出非法 ISO 串
|
||||||
|
const startT = item.start_time.length >= 8 ? item.start_time.substring(0, 5) : item.start_time;
|
||||||
|
const endT = item.end_time.length >= 8 ? item.end_time.substring(0, 5) : item.end_time;
|
||||||
|
const startTimeStr = `${item.activity_date}T${startT.length > 0 ? startT : '09:00'}:00`;
|
||||||
|
const endTimeStr = `${item.activity_date}T${endT.length > 0 ? endT : '10:00'}:00`;
|
||||||
|
const start = new Date(startTimeStr).getTime();
|
||||||
|
const end = new Date(endTimeStr).getTime();
|
||||||
|
if (Number.isNaN(start) || Number.isNaN(end)) {
|
||||||
|
promptAction.showToast({ message: '活动时间无法解析' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
|
||||||
|
const errText = await addCalendarEvent(context, `活动:${item.activity_name}`, start, end,
|
||||||
|
item.location ?? '', item.activity_description ?? '');
|
||||||
|
if (errText.length === 0) {
|
||||||
|
promptAction.showToast({ message: '已加入系统日历' });
|
||||||
|
} else {
|
||||||
|
promptAction.showToast({ message: errText });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
promptAction.showToast({ message: `写入日历失败:${normalizeError(err as BusinessError | Error)}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
if (this.loading) {
|
||||||
|
Text('加载中...')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.margin({ top: 80 })
|
||||||
|
} else if (this.items.length === 0) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('🎉')
|
||||||
|
.fontSize(40)
|
||||||
|
Text('本周暂无活动安排')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.margin({ top: 100 })
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
} else {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 10 }) {
|
||||||
|
Text(`活动安排(${this.weekStartText} 起)`)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.width('100%')
|
||||||
|
ForEach(this.items, (item: ActivityItem) => {
|
||||||
|
this.ActivityCard(item)
|
||||||
|
}, (item: ActivityItem) => String(item.id))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 92, bottom: 20 })
|
||||||
|
}
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
.layoutWeight(1)
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '活动安排',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// 老人助手(ArkTS 严格类型,无 any)
|
||||||
|
// 数据来源:/elderly-portal/assistant/reply/
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReplyResult {
|
||||||
|
reply: string;
|
||||||
|
auto_reserved: boolean;
|
||||||
|
reservations: Array<Record<string, string | number | boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 助手消息请求体
|
||||||
|
interface AssistantPayload {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReservationItem {
|
||||||
|
id: string;
|
||||||
|
meal_name: string;
|
||||||
|
time: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct Assistant {
|
||||||
|
@State inputText: string = '';
|
||||||
|
@State messages: ChatMessage[] = [];
|
||||||
|
@State sending: boolean = false;
|
||||||
|
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
private async send(): Promise<void> {
|
||||||
|
const text = this.inputText.trim();
|
||||||
|
if (text.length === 0 || this.sending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextMessages = this.messages.concat([{ role: 'user', content: text }]);
|
||||||
|
this.messages = nextMessages;
|
||||||
|
this.inputText = '';
|
||||||
|
this.sending = true;
|
||||||
|
try {
|
||||||
|
const payload: AssistantPayload = { message: text };
|
||||||
|
const res = await api.post<ReplyResult>('/elderly-portal/assistant/reply/', payload);
|
||||||
|
const reply = res.reply && res.reply.length > 0 ? res.reply : '暂时无法回答,请稍后再试。';
|
||||||
|
this.messages = this.messages.concat([{ role: 'assistant', content: reply }]);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
this.messages = this.messages.concat([{ role: 'assistant', content: message }]);
|
||||||
|
} finally {
|
||||||
|
this.sending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
MessageView(msg: ChatMessage) {
|
||||||
|
Row() {
|
||||||
|
if (msg.role === 'user') {
|
||||||
|
Blank()
|
||||||
|
Column() {
|
||||||
|
Text(msg.content)
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
}
|
||||||
|
.width('75%')
|
||||||
|
.padding(12)
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(12)
|
||||||
|
} else {
|
||||||
|
Column() {
|
||||||
|
Text(msg.content)
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
}
|
||||||
|
.width('75%')
|
||||||
|
.padding(12)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(12)
|
||||||
|
Blank()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, bottom: 10 })
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column() {
|
||||||
|
Text('老人助手')
|
||||||
|
.fontSize(20)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 20, right: 20, top: 20, bottom: 8 })
|
||||||
|
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
ForEach(this.messages, (msg: ChatMessage) => {
|
||||||
|
this.MessageView(msg)
|
||||||
|
}, (msg: ChatMessage) => `${msg.role}-${this.messages.indexOf(msg)}`)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ bottom: 20 })
|
||||||
|
}
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
.layoutWeight(1)
|
||||||
|
.align(Alignment.Top)
|
||||||
|
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
TextArea({ placeholder: '输入想说的话,例如:我想吃清淡点的晚餐', text: this.inputText })
|
||||||
|
.placeholderColor($r('app.color.inactive_tab'))
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.height(46)
|
||||||
|
.layoutWeight(1)
|
||||||
|
.padding({ left: 14, right: 14 })
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(12)
|
||||||
|
.onChange((value: string) => {
|
||||||
|
this.inputText = value;
|
||||||
|
})
|
||||||
|
Button(this.sending ? '…' : '发送')
|
||||||
|
.width(64)
|
||||||
|
.height(46)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(12)
|
||||||
|
.enabled(!this.sending)
|
||||||
|
.onClick(() => {
|
||||||
|
this.send();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, bottom: 20 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
// 新增对象(录入老人档案)(ArkTS 严格类型,无 any)
|
||||||
|
// 接口:POST /elderly/
|
||||||
|
import { router } from '@kit.ArkUI';
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
import { common } from '@kit.AbilityKit';
|
||||||
|
import { takePhoto, pickOneImage, recognizeTextFromUri, extractIdCardFields } from '../../utils/OcrHelper';
|
||||||
|
|
||||||
|
// 新增老人请求体
|
||||||
|
interface ElderlyCreatePayload {
|
||||||
|
name: string;
|
||||||
|
gender: string;
|
||||||
|
birth_date: string;
|
||||||
|
id_card: string;
|
||||||
|
phone: string;
|
||||||
|
room: string;
|
||||||
|
bed: string;
|
||||||
|
floor: string;
|
||||||
|
care_level: string;
|
||||||
|
medical_history: string;
|
||||||
|
allergies: string;
|
||||||
|
medications: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CARE_LEVELS: Array<string> = ['self_care', 'semi_care', 'full_care'];
|
||||||
|
const GENDERS: Array<string> = ['male', 'female'];
|
||||||
|
|
||||||
|
function careLevelLabel(value: string): string {
|
||||||
|
if (value === 'self_care') {
|
||||||
|
return '自理';
|
||||||
|
}
|
||||||
|
if (value === 'semi_care') {
|
||||||
|
return '半护理';
|
||||||
|
}
|
||||||
|
if (value === 'full_care') {
|
||||||
|
return '全护理';
|
||||||
|
}
|
||||||
|
return '自理';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct ElderlyAdd {
|
||||||
|
@State name: string = '';
|
||||||
|
@State gender: string = 'male';
|
||||||
|
@State birthDate: string = '';
|
||||||
|
@State idCard: string = '';
|
||||||
|
@State phone: string = '';
|
||||||
|
@State room: string = '';
|
||||||
|
@State bed: string = '';
|
||||||
|
@State floor: string = '';
|
||||||
|
@State careLevel: string = 'self_care';
|
||||||
|
@State medicalHistory: string = '';
|
||||||
|
@State allergies: string = '';
|
||||||
|
@State medications: string = '';
|
||||||
|
@State submitting: boolean = false;
|
||||||
|
@State ocrRunning: boolean = false;
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
// 拍身份证识别:端侧 OCR 提取证件号/性别/出生日期/姓名,自动填表(可核对修改)
|
||||||
|
private async runIdCardOcr(useCamera: boolean): Promise<void> {
|
||||||
|
if (this.ocrRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
|
||||||
|
const uri = useCamera ? await takePhoto(context) : await pickOneImage(context);
|
||||||
|
if (uri === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ocrRunning = true;
|
||||||
|
promptAction.showToast({ message: '识别中...' });
|
||||||
|
try {
|
||||||
|
const text = await recognizeTextFromUri(uri);
|
||||||
|
const fields = extractIdCardFields(text);
|
||||||
|
if (fields.idNumber.length === 0) {
|
||||||
|
promptAction.showToast({ message: '未识别到身份证号,请光线充足后重拍' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.idCard = fields.idNumber;
|
||||||
|
if (fields.gender.length > 0) {
|
||||||
|
this.gender = fields.gender;
|
||||||
|
}
|
||||||
|
if (fields.birthDate.length > 0) {
|
||||||
|
this.birthDate = fields.birthDate;
|
||||||
|
}
|
||||||
|
if (this.name.trim().length === 0 && fields.name.length > 0) {
|
||||||
|
this.name = fields.name;
|
||||||
|
}
|
||||||
|
promptAction.showToast({ message: '识别完成,请核对后提交' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ElderlyAdd] id card ocr failed:', normalizeError(err as BusinessError | Error));
|
||||||
|
promptAction.showToast({ message: '识别失败,请重试或手动输入' });
|
||||||
|
} finally {
|
||||||
|
this.ocrRunning = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跨端 OCR 菜单(bindMenu 自定义项)
|
||||||
|
@Builder
|
||||||
|
OcrMenu() {
|
||||||
|
Column() {
|
||||||
|
Text('📷 拍照识别')
|
||||||
|
.width('100%')
|
||||||
|
.padding(12)
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.onClick(() => {
|
||||||
|
this.runIdCardOcr(true);
|
||||||
|
})
|
||||||
|
Divider().color($r('app.color.background'))
|
||||||
|
Text('🖼️ 从相册识别')
|
||||||
|
.width('100%')
|
||||||
|
.padding(12)
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.onClick(() => {
|
||||||
|
this.runIdCardOcr(false);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width(170)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async submit(): Promise<void> {
|
||||||
|
if (this.name.trim().length === 0) {
|
||||||
|
promptAction.showToast({ message: '请输入老人姓名' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.idCard.trim().length === 0) {
|
||||||
|
promptAction.showToast({ message: '请输入身份证号' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.submitting = true;
|
||||||
|
try {
|
||||||
|
const body: ElderlyCreatePayload = {
|
||||||
|
name: this.name.trim(),
|
||||||
|
gender: this.gender,
|
||||||
|
birth_date: this.birthDate.trim(),
|
||||||
|
id_card: this.idCard.trim(),
|
||||||
|
phone: this.phone.trim(),
|
||||||
|
room: this.room.trim(),
|
||||||
|
bed: this.bed.trim(),
|
||||||
|
floor: this.floor.trim(),
|
||||||
|
care_level: this.careLevel,
|
||||||
|
medical_history: this.medicalHistory.trim(),
|
||||||
|
allergies: this.allergies.trim(),
|
||||||
|
medications: this.medications.trim(),
|
||||||
|
};
|
||||||
|
await api.post('/elderly/', body);
|
||||||
|
promptAction.showToast({ message: '老人档案已创建' });
|
||||||
|
router.back();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
Field(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
Text(label)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
TextInput({ placeholder: placeholder, text: value })
|
||||||
|
.placeholderColor($r('app.color.inactive_tab'))
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.height(44)
|
||||||
|
.padding({ left: 12, right: 12 })
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(10)
|
||||||
|
.onChange((v: string) => {
|
||||||
|
onChange(v);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 14 }) {
|
||||||
|
// 基本信息
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
Text('基本信息')
|
||||||
|
.fontSize(17)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
this.Field('姓名 *', '请输入老人姓名', this.name, (v: string) => {
|
||||||
|
this.name = v;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 性别
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
Text('性别')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
ForEach(GENDERS, (g: string) => {
|
||||||
|
Text(g === 'male' ? '男' : '女')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor(this.gender === g ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||||
|
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
|
||||||
|
.backgroundColor(this.gender === g ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||||
|
.borderRadius(10)
|
||||||
|
.onClick(() => {
|
||||||
|
this.gender = g;
|
||||||
|
})
|
||||||
|
}, (g: string) => g)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
this.Field('出生日期', '如 1950-01-01', this.birthDate, (v: string) => {
|
||||||
|
this.birthDate = v;
|
||||||
|
})
|
||||||
|
this.Field('身份证号 *', '请输入身份证号', this.idCard, (v: string) => {
|
||||||
|
this.idCard = v;
|
||||||
|
})
|
||||||
|
// 身份证拍照识别(端侧 OCR,可选)
|
||||||
|
Button(this.ocrRunning ? '识别中...' : '📷 拍身份证自动填入')
|
||||||
|
.fontSize(14)
|
||||||
|
.height(40)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(10)
|
||||||
|
.enabled(!this.ocrRunning)
|
||||||
|
.bindMenu(this.OcrMenu)
|
||||||
|
.accessibilityLevel('yes')
|
||||||
|
.accessibilityText('拍身份证自动填入')
|
||||||
|
.accessibilityDescription('拍照或从相册选择身份证照片,自动识别证件号、性别、出生日期')
|
||||||
|
this.Field('联系电话', '选填', this.phone, (v: string) => {
|
||||||
|
this.phone = v;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
// 位置与护理
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
Text('位置与护理')
|
||||||
|
.fontSize(17)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
this.Field('房间', '如 801', this.room, (v: string) => {
|
||||||
|
this.room = v;
|
||||||
|
})
|
||||||
|
this.Field('床位', '如 12', this.bed, (v: string) => {
|
||||||
|
this.bed = v;
|
||||||
|
})
|
||||||
|
this.Field('楼层', '如 8F', this.floor, (v: string) => {
|
||||||
|
this.floor = v;
|
||||||
|
})
|
||||||
|
|
||||||
|
// 护理等级
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
Text('护理等级')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
ForEach(CARE_LEVELS, (level: string) => {
|
||||||
|
Text(careLevelLabel(level))
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor(this.careLevel === level ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||||
|
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
|
||||||
|
.backgroundColor(this.careLevel === level ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||||
|
.borderRadius(10)
|
||||||
|
.onClick(() => {
|
||||||
|
this.careLevel = level;
|
||||||
|
})
|
||||||
|
}, (level: string) => level)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
// 健康信息
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
Text('健康信息')
|
||||||
|
.fontSize(17)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
this.Field('病史', '选填', this.medicalHistory, (v: string) => {
|
||||||
|
this.medicalHistory = v;
|
||||||
|
})
|
||||||
|
this.Field('过敏史', '选填', this.allergies, (v: string) => {
|
||||||
|
this.allergies = v;
|
||||||
|
})
|
||||||
|
this.Field('用药情况', '选填', this.medications, (v: string) => {
|
||||||
|
this.medications = v;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
Button(this.submitting ? '提交中...' : '保存老人档案')
|
||||||
|
.width('100%')
|
||||||
|
.height(50)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(12)
|
||||||
|
.enabled(!this.submitting)
|
||||||
|
.onClick(() => {
|
||||||
|
this.submit();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('92%')
|
||||||
|
.padding({ top: 120, bottom: 30 })
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
}
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
.layoutWeight(1)
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '新增对象',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
// 老人端 · 聊天助手面板(ArkTS 严格类型,无 any)
|
||||||
|
// 数据来源:/elderly-portal/assistant/reply/
|
||||||
|
// 历史对话记录本地持久化(preferences)
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { preferences } from '@kit.ArkData';
|
||||||
|
import { common } from '@kit.AbilityKit';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||||
|
|
||||||
|
const CHAT_PREFS_NAME = 'ElderlyChat';
|
||||||
|
const CHAT_MESSAGES_KEY = 'messages';
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: number; // 唯一消息 id
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
displayLen: number; // 已显示字符数(打字机效果),-1 表示全文
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自增 id 计数器
|
||||||
|
let chatMessageSeq = 1;
|
||||||
|
|
||||||
|
interface AssistantReservation {
|
||||||
|
slot_id: string;
|
||||||
|
slot_name: string;
|
||||||
|
ordered: boolean;
|
||||||
|
service_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReplyResult {
|
||||||
|
reply: string;
|
||||||
|
auto_reserved: boolean;
|
||||||
|
reservations: Array<AssistantReservation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 助手消息请求体
|
||||||
|
interface AssistantPayload {
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export struct ElderlyAssistantPanel {
|
||||||
|
@State inputText: string = '';
|
||||||
|
@State messages: Array<ChatMessage> = [];
|
||||||
|
@State sending: boolean = false;
|
||||||
|
@State typingKey: string = '';
|
||||||
|
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||||
|
|
||||||
|
// 缩放倍数(读 @StorageProp,变化触发重渲)
|
||||||
|
private scaleFor(): number {
|
||||||
|
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
private typingTimer: number = -1;
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.loadHistory().then(() => {
|
||||||
|
// 打开默认滚动到最新消息
|
||||||
|
setTimeout(() => {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取本地历史对话
|
||||||
|
private async loadHistory(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const context = getContext(this) as common.UIAbilityContext;
|
||||||
|
const prefs = await preferences.getPreferences(context, CHAT_PREFS_NAME);
|
||||||
|
const value = await prefs.get(CHAT_MESSAGES_KEY, '');
|
||||||
|
if (typeof value === 'string' && value.length > 0) {
|
||||||
|
const parsed = JSON.parse(value) as Array<ChatMessage>;
|
||||||
|
if (parsed && parsed.length > 0) {
|
||||||
|
// 历史消息补 displayLen(全文显示)
|
||||||
|
const restored = parsed.map((m: ChatMessage): ChatMessage => ({
|
||||||
|
id: chatMessageSeq++,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
displayLen: m.displayLen !== undefined ? m.displayLen : -1,
|
||||||
|
}));
|
||||||
|
this.messages = restored;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[ElderlyChat] load history failed:', normalizeError(err as BusinessError | Error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存历史对话(限制最近 100 条;保存时全部以全文 displayLen=-1 存储)
|
||||||
|
private async saveHistory(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const context = getContext(this) as common.UIAbilityContext;
|
||||||
|
const prefs = await preferences.getPreferences(context, CHAT_PREFS_NAME);
|
||||||
|
const toSave = this.messages.length > 100 ? this.messages.slice(this.messages.length - 100) : this.messages;
|
||||||
|
const fullText = toSave.map((m: ChatMessage): ChatMessage => ({
|
||||||
|
id: m.id,
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
displayLen: -1,
|
||||||
|
}));
|
||||||
|
await prefs.put(CHAT_MESSAGES_KEY, JSON.stringify(fullText));
|
||||||
|
await prefs.flush();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[ElderlyChat] save history failed:', normalizeError(err as BusinessError | Error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动到底部聚焦最新消息
|
||||||
|
private scrollToBottom(): void {
|
||||||
|
this.scroller.scrollEdge(Edge.Bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动打字机效果:逐字显示回复
|
||||||
|
private startTyping(id: number, fullText: string): void {
|
||||||
|
this.clearTyping();
|
||||||
|
this.typingKey = String(id);
|
||||||
|
this.messages = this.messages.map((m: ChatMessage): ChatMessage => {
|
||||||
|
if (m.id === id) {
|
||||||
|
return { id: m.id, role: m.role, content: m.content, displayLen: 0 };
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
let count = 0;
|
||||||
|
this.typingTimer = setInterval(() => {
|
||||||
|
count += 2;
|
||||||
|
if (count >= fullText.length) {
|
||||||
|
this.clearTyping();
|
||||||
|
}
|
||||||
|
this.messages = this.messages.map((m: ChatMessage): ChatMessage => {
|
||||||
|
if (m.id === id) {
|
||||||
|
const len = Math.min(count, fullText.length);
|
||||||
|
return { id: m.id, role: m.role, content: m.content, displayLen: len };
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
this.scrollToBottom();
|
||||||
|
}, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTyping(): void {
|
||||||
|
if (this.typingTimer !== -1) {
|
||||||
|
clearInterval(this.typingTimer);
|
||||||
|
this.typingTimer = -1;
|
||||||
|
}
|
||||||
|
this.typingKey = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async send(): Promise<void> {
|
||||||
|
const text = this.inputText.trim();
|
||||||
|
if (text.length === 0 || this.sending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userMsg: ChatMessage = { id: chatMessageSeq++, role: 'user', content: text, displayLen: -1 };
|
||||||
|
this.messages = this.messages.concat([userMsg]);
|
||||||
|
this.inputText = '';
|
||||||
|
this.sending = true;
|
||||||
|
// 发送后聚焦新消息
|
||||||
|
setTimeout(() => {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}, 50);
|
||||||
|
try {
|
||||||
|
const payload: AssistantPayload = { message: text };
|
||||||
|
const res = await api.post<ReplyResult>('/elderly-portal/assistant/reply/', payload);
|
||||||
|
let reply = res.reply && res.reply.length > 0 ? res.reply : '我会继续陪您聊天。';
|
||||||
|
if (res.auto_reserved && res.reservations.length > 0) {
|
||||||
|
reply += '\n已为您自动预订:' + res.reservations.map((r: AssistantReservation) => r.slot_name).join('、');
|
||||||
|
}
|
||||||
|
const botMsg: ChatMessage = { id: chatMessageSeq++, role: 'assistant', content: reply, displayLen: 0 };
|
||||||
|
this.messages = this.messages.concat([botMsg]);
|
||||||
|
// 打字机逐字显示回复
|
||||||
|
this.startTyping(botMsg.id, reply);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
const botMsg: ChatMessage = { id: chatMessageSeq++, role: 'assistant', content: message, displayLen: -1 };
|
||||||
|
this.messages = this.messages.concat([botMsg]);
|
||||||
|
this.scrollToBottom();
|
||||||
|
} finally {
|
||||||
|
this.sending = false;
|
||||||
|
// 保存历史对话
|
||||||
|
this.saveHistory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按打字机进度截取显示文本
|
||||||
|
private displayText(msg: ChatMessage): string {
|
||||||
|
if (msg.displayLen < 0) {
|
||||||
|
return msg.content;
|
||||||
|
}
|
||||||
|
return msg.content.substring(0, msg.displayLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 id 从 @State messages 读取(保证打字机/更新重渲)
|
||||||
|
private messageById(id: number): ChatMessage {
|
||||||
|
const found = this.messages.find((m: ChatMessage) => m.id === id);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
return { id: id, role: 'assistant', content: '', displayLen: -1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
MessageView(id: number) {
|
||||||
|
Row() {
|
||||||
|
if (this.messageById(id).role === 'user') {
|
||||||
|
Blank()
|
||||||
|
Text(this.displayText(this.messageById(id)))
|
||||||
|
.fontSize(Math.round(18 * this.scaleFor()))
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.constraintSize({ maxWidth: '70%' })
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(14)
|
||||||
|
} else {
|
||||||
|
Text(this.displayText(this.messageById(id)))
|
||||||
|
.fontSize(Math.round(18 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.constraintSize({ maxWidth: '70%' })
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
Blank()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, bottom: 12 })
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Column() {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
if (this.messages.length === 0) {
|
||||||
|
Text('您好,我是您的智能助手 🌟\n可以直接对我说,例如:\n“帮我预订明天的午餐”')
|
||||||
|
.fontSize(Math.round(18 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 60 })
|
||||||
|
}
|
||||||
|
ForEach(this.messages, (msg: ChatMessage) => {
|
||||||
|
this.MessageView(msg.id)
|
||||||
|
}, (msg: ChatMessage) => String(msg.id))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ bottom: 20 })
|
||||||
|
}
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
.layoutWeight(1)
|
||||||
|
.align(Alignment.Top)
|
||||||
|
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
TextInput({ placeholder: '输入想说的话...', text: this.inputText })
|
||||||
|
.placeholderColor($r('app.color.inactive_tab'))
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.fontSize(Math.round(18 * this.scaleFor()))
|
||||||
|
.height(52)
|
||||||
|
.layoutWeight(1)
|
||||||
|
.padding({ left: 16, right: 16 })
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.onChange((value: string) => {
|
||||||
|
this.inputText = value;
|
||||||
|
})
|
||||||
|
.onSubmit((enterKey: EnterKeyType) => {
|
||||||
|
// 回车直接发送
|
||||||
|
this.send();
|
||||||
|
})
|
||||||
|
Button(this.sending ? '…' : '发送')
|
||||||
|
.height(52)
|
||||||
|
.padding({ left: 18, right: 18 })
|
||||||
|
.constraintSize({ minWidth: 80 })
|
||||||
|
.fontSize(Math.round(18 * this.scaleFor()))
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.enabled(!this.sending)
|
||||||
|
.onClick(() => {
|
||||||
|
this.send();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, bottom: 12 })
|
||||||
|
|
||||||
|
// AI 声明
|
||||||
|
Text('内容由 AI 生成,仅供参考,如有疑问请咨询护理人员')
|
||||||
|
.fontSize(11)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
.width('100%')
|
||||||
|
.padding({ bottom: 8 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
// 老人详情页(ArkTS 严格类型,无 any)
|
||||||
|
// 数据来源:GET /elderly/{id}/
|
||||||
|
import { router } from '@kit.ArkUI';
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api } from '../../utils/Request';
|
||||||
|
|
||||||
|
interface ElderlyDetailData {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
gender: string | null;
|
||||||
|
age: number | null;
|
||||||
|
birth_date: string | null;
|
||||||
|
plain_id_card: string | null;
|
||||||
|
room_bed: string | null;
|
||||||
|
floor_room_bed: string | null;
|
||||||
|
care_level: string | null;
|
||||||
|
care_level_label: string | null;
|
||||||
|
care_status: string | null;
|
||||||
|
overview_status: string | null;
|
||||||
|
risks: Array<string> | null;
|
||||||
|
medications: string | null;
|
||||||
|
medical_history: string | null;
|
||||||
|
allergies: string | null;
|
||||||
|
current_caregiver_name: string | null;
|
||||||
|
primary_family_name: string | null;
|
||||||
|
primary_family_relation: string | null;
|
||||||
|
primary_family_phone: string | null;
|
||||||
|
emergency_contact: string | null;
|
||||||
|
emergency_phone: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function genderLabel(gender: string | null): string {
|
||||||
|
if (gender === 'male') {
|
||||||
|
return '男';
|
||||||
|
}
|
||||||
|
if (gender === 'female') {
|
||||||
|
return '女';
|
||||||
|
}
|
||||||
|
return '未录入';
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayEmpty(value: string | null | undefined): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const text = String(value).trim();
|
||||||
|
return text.length > 0 ? text : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct ElderlyDetail {
|
||||||
|
@State detail: ElderlyDetailData | null = null;
|
||||||
|
@State loading: boolean = true;
|
||||||
|
private elderlyId: string = '';
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
const params = router.getParams() as Record<string, string> | undefined;
|
||||||
|
if (params) {
|
||||||
|
this.elderlyId = params['id'] ?? '';
|
||||||
|
}
|
||||||
|
this.loadDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadDetail(): Promise<void> {
|
||||||
|
if (this.elderlyId.length === 0) {
|
||||||
|
this.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.detail = await api.get<ElderlyDetailData>(`/elderly/${this.elderlyId}/`);
|
||||||
|
} catch (err) {
|
||||||
|
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
InfoRow(label: string, value: string) {
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
Text(label)
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width(84)
|
||||||
|
Text(displayEmpty(value))
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.layoutWeight(1)
|
||||||
|
.textAlign(TextAlign.Start)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 10, bottom: 10 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
RiskTag(tag: string) {
|
||||||
|
Text(tag)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.accent_red'))
|
||||||
|
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
|
||||||
|
.backgroundColor('#22FF4D4D')
|
||||||
|
.borderRadius(8)
|
||||||
|
.margin({ right: 8, bottom: 8 })
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
if (this.loading) {
|
||||||
|
Text('加载中...')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.margin({ top: 60 })
|
||||||
|
} else if (!this.detail) {
|
||||||
|
Text('未找到该老人信息')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.margin({ top: 60 })
|
||||||
|
} else {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 14 }) {
|
||||||
|
// 基本信息卡
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(this.detail.name ?? '')
|
||||||
|
.fontSize(24)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
if (this.detail.overview_status && this.detail.overview_status.length > 0) {
|
||||||
|
Text(this.detail.overview_status)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
|
||||||
|
.backgroundColor($r('app.color.accent_green'))
|
||||||
|
.borderRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(`${genderLabel(this.detail.gender)} | 年龄:${this.detail.age ?? '未录入'} | ID: ${this.detail.id}`)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
if (this.detail.plain_id_card && this.detail.plain_id_card.length > 0) {
|
||||||
|
Text(this.detail.plain_id_card)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(18)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
// 护理等级 + 房间
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
Row() {
|
||||||
|
Text('护理等级')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Blank()
|
||||||
|
Text(displayEmpty(this.detail.care_level_label))
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.accent_blue'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
Divider().color($r('app.color.inactive_tab'))
|
||||||
|
Text(displayEmpty(this.detail.floor_room_bed))
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(18)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
// 风险标签
|
||||||
|
if (this.detail.risks && this.detail.risks.length > 0) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('风险关注')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
Flex({ wrap: FlexWrap.Wrap }) {
|
||||||
|
ForEach(this.detail.risks, (risk: string) => {
|
||||||
|
if (risk && risk.length > 0) {
|
||||||
|
this.RiskTag(risk)
|
||||||
|
}
|
||||||
|
}, (risk: string) => risk)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状态卡
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
Text('护理状态')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
Row() {
|
||||||
|
Text('状态')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Blank()
|
||||||
|
Text(displayEmpty(this.detail.care_status))
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.accent_green'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
Row() {
|
||||||
|
Text('用药')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Blank()
|
||||||
|
Text(displayEmpty(this.detail.medications))
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
Row() {
|
||||||
|
Text('备注')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Blank()
|
||||||
|
Text(displayEmpty(this.detail.medical_history))
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
// 护理员
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
Text('护理员')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
Text(displayEmpty(this.detail.current_caregiver_name))
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
// 联系人
|
||||||
|
Column({ space: 10 }) {
|
||||||
|
Text('联系人')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
if (this.detail.primary_family_name && this.detail.primary_family_name.trim().length > 0) {
|
||||||
|
Row() {
|
||||||
|
Text(this.detail.primary_family_name)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
if (this.detail.primary_family_relation && this.detail.primary_family_relation.trim().length > 0) {
|
||||||
|
Text(this.detail.primary_family_relation)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.margin({ left: 8 })
|
||||||
|
}
|
||||||
|
Blank()
|
||||||
|
if (this.detail.primary_family_phone && this.detail.primary_family_phone.trim().length > 0) {
|
||||||
|
Text(this.detail.primary_family_phone)
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.accent_blue'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
} else {
|
||||||
|
Text('暂无配置')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
.margin({ bottom: 30 })
|
||||||
|
}
|
||||||
|
.width('92%')
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
.padding({ top: 60 })
|
||||||
|
}
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
.layoutWeight(1)
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '关注对象详情',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// 长者端 · 快速拨号(ArkTS 严格类型,无 any)
|
||||||
|
// 拨号:call.makeCall 跳转拨号界面,由用户确认拨打
|
||||||
|
// 家属列表:GET /elderly/bindings/(长者角色只能看到绑定自己的记录,含家属姓名/电话)
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { InsightIntentPage } from '@kit.AbilityKit';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { call } from '@kit.TelephonyKit';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { Page } from '../../common/ApiConstants';
|
||||||
|
import { api } from '../../utils/Request';
|
||||||
|
|
||||||
|
interface DialContact {
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
desc: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiFamilyBinding {
|
||||||
|
id: number;
|
||||||
|
user_name: string;
|
||||||
|
user_phone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTACTS: Array<DialContact> = [
|
||||||
|
{ name: '服务中心', phone: '020-12345678', desc: '院内服务台,任何需求都可以找', color: '#007AFF' },
|
||||||
|
{ name: '急救电话', phone: '120', desc: '突发身体不适时拨打', color: '#FF3B30' },
|
||||||
|
{ name: '火警电话', phone: '119', desc: '发现火情时拨打', color: '#FF9500' },
|
||||||
|
{ name: '报警电话', phone: '110', desc: '需要警察帮助时拨打', color: '#8E8E93' },
|
||||||
|
];
|
||||||
|
|
||||||
|
@InsightIntentPage({
|
||||||
|
intentName: 'CallFamily',
|
||||||
|
domain: 'ElderlyCare',
|
||||||
|
intentVersion: '1.0.0',
|
||||||
|
displayName: '呼叫家人',
|
||||||
|
displayDescription: '打开智慧护老的拨号页,一键拨打家人或护理员电话',
|
||||||
|
uiAbility: 'EntryAbility',
|
||||||
|
pagePath: 'ets/pages/elderly/ElderlyDial',
|
||||||
|
keywords: ['打电话', '呼叫', '家人', '护理员', '电话', '求助'],
|
||||||
|
llmDescription: '打开一键拨号页,拨打家人、护理员或养老院前台电话',
|
||||||
|
})
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct ElderlyDial {
|
||||||
|
@State familyContacts: Array<DialContact> = [];
|
||||||
|
@State familyLoading: boolean = true;
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.loadFamily();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拉取绑定自己的家属(无电话的不展示)
|
||||||
|
private async loadFamily(): Promise<void> {
|
||||||
|
this.familyLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await api.get<Page<ApiFamilyBinding>>('/elderly/bindings/', { page_size: 50 });
|
||||||
|
const contacts: Array<DialContact> = [];
|
||||||
|
res.results.forEach((b: ApiFamilyBinding): void => {
|
||||||
|
const phone = typeof b.user_phone === 'string' ? b.user_phone.trim() : '';
|
||||||
|
const name = typeof b.user_name === 'string' ? b.user_name.trim() : '';
|
||||||
|
if (phone.length > 0) {
|
||||||
|
contacts.push({
|
||||||
|
name: name.length > 0 ? name : '家属',
|
||||||
|
phone: phone,
|
||||||
|
desc: '您的家属,有事随时联系',
|
||||||
|
color: '#34C759',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.familyContacts = contacts;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ElderlyDial] load family bindings failed:', (err as Error).message);
|
||||||
|
} finally {
|
||||||
|
this.familyLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dial(phone: string): void {
|
||||||
|
call.makeCall(phone).catch(() => {
|
||||||
|
promptAction.showToast({ message: '拨号失败,请手动拨打' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
ContactCard(contact: DialContact) {
|
||||||
|
Row({ space: 14 }) {
|
||||||
|
Column() {
|
||||||
|
Text(contact.name.substring(0, 1))
|
||||||
|
.fontSize(24)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
}
|
||||||
|
.width(56)
|
||||||
|
.height(56)
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
.backgroundColor(contact.color)
|
||||||
|
.borderRadius(28)
|
||||||
|
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Text(contact.name)
|
||||||
|
.fontSize(20)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text(contact.desc)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Text(contact.phone)
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor('#007AFF')
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
Text('拨打')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
|
||||||
|
.backgroundColor('#34C759')
|
||||||
|
.borderRadius(22)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(18)
|
||||||
|
.alignItems(VerticalAlign.Center)
|
||||||
|
.onClick(() => {
|
||||||
|
this.dial(contact.phone);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Scroll() {
|
||||||
|
Column({ space: 14 }) {
|
||||||
|
Text('点击卡片即可拨打电话')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
// 家属(动态:绑定我的家属账号)
|
||||||
|
if (this.familyContacts.length > 0) {
|
||||||
|
Text('我的家属')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
ForEach(this.familyContacts, (contact: DialContact) => {
|
||||||
|
this.ContactCard(contact)
|
||||||
|
}, (contact: DialContact) => contact.phone)
|
||||||
|
|
||||||
|
// 常用电话
|
||||||
|
Text('常用电话')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
ForEach(CONTACTS, (contact: DialContact) => {
|
||||||
|
this.ContactCard(contact)
|
||||||
|
}, (contact: DialContact) => contact.phone)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 120, bottom: 30 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '快速拨号',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// 老人端 · 历史订餐(ArkTS 严格类型,无 any)
|
||||||
|
// 数据来源:/elderly-portal/reservations/history/
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api } from '../../utils/Request';
|
||||||
|
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||||
|
|
||||||
|
interface MealHistoryItem {
|
||||||
|
slot_id: string;
|
||||||
|
service_date: string;
|
||||||
|
category: string;
|
||||||
|
menu: string;
|
||||||
|
ordered: boolean;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MealHistoryResponse {
|
||||||
|
reservations: Array<MealHistoryItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export struct ElderlyHistoryPanel {
|
||||||
|
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||||
|
@State items: Array<MealHistoryItem> = [];
|
||||||
|
@State loading: boolean = true;
|
||||||
|
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
private scaleFor(): number {
|
||||||
|
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadHistory(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await api.get<MealHistoryResponse>('/elderly-portal/reservations/history/');
|
||||||
|
this.items = res.reservations;
|
||||||
|
} catch (err) {
|
||||||
|
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
HistoryCard(item: MealHistoryItem) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Row() {
|
||||||
|
Text(item.category)
|
||||||
|
.fontSize(Math.round(18 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor($r('app.color.accent_blue'))
|
||||||
|
Blank()
|
||||||
|
Text(item.service_date)
|
||||||
|
.fontSize(Math.round(14 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
if (item.menu && item.menu.length > 0) {
|
||||||
|
Text(item.menu)
|
||||||
|
.fontSize(Math.round(16 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(item.ordered ? '已预订' : '已取消')
|
||||||
|
.fontSize(Math.round(14 * this.scaleFor()))
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
|
||||||
|
.backgroundColor(item.ordered ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
|
||||||
|
.borderRadius(8)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(18)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
if (this.loading) {
|
||||||
|
Text('加载中...')
|
||||||
|
.fontSize(Math.round(15 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.margin({ top: 80 })
|
||||||
|
} else if (this.items.length === 0) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('🍽️')
|
||||||
|
.fontSize(40)
|
||||||
|
Text('暂无订餐历史')
|
||||||
|
.fontSize(Math.round(15 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.margin({ top: 100 })
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
} else {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 10 }) {
|
||||||
|
ForEach(this.items, (item: MealHistoryItem) => {
|
||||||
|
this.HistoryCard(item)
|
||||||
|
}, (item: MealHistoryItem) => `${item.slot_id}-${item.service_date}`)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 92, bottom: 24 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '历史订餐',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct ElderlyHistory {
|
||||||
|
build() {
|
||||||
|
Column() {
|
||||||
|
ElderlyHistoryPanel()
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,205 @@
|
|||||||
|
// 关注对象(老人列表)页(ArkTS 严格类型,无 any)
|
||||||
|
// 数据来源:GET /elderly/?page_size=
|
||||||
|
import { router } from '@kit.ArkUI';
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api } from '../../utils/Request';
|
||||||
|
|
||||||
|
interface ElderlyItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
gender: string;
|
||||||
|
age: number | null;
|
||||||
|
room_bed: string;
|
||||||
|
floor_room_bed: string;
|
||||||
|
care_level_label: string;
|
||||||
|
overview_status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 详情页路由参数
|
||||||
|
interface DetailParams {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function genderLabel(gender: string): string {
|
||||||
|
if (gender === 'male') {
|
||||||
|
return '男';
|
||||||
|
}
|
||||||
|
if (gender === 'female') {
|
||||||
|
return '女';
|
||||||
|
}
|
||||||
|
return '未录入';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct ElderlyList {
|
||||||
|
@State elderly: Array<ElderlyItem> = [];
|
||||||
|
@State loading: boolean = true;
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.loadList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadList(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await api.get<Page<ElderlyItem>>('/elderly/', { page_size: 100 });
|
||||||
|
this.elderly = res.results;
|
||||||
|
} catch (err) {
|
||||||
|
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private openDetail(item: ElderlyItem): void {
|
||||||
|
const params: DetailParams = { id: String(item.id) };
|
||||||
|
router.pushUrl({
|
||||||
|
url: 'pages/elderly/ElderlyDetail',
|
||||||
|
params: params,
|
||||||
|
}).catch((err: BusinessError) => {
|
||||||
|
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
ElderlyCard(item: ElderlyItem) {
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
// 首字头像
|
||||||
|
Column() {
|
||||||
|
Text(item.name.length > 0 ? item.name.substring(0, 1) : '老')
|
||||||
|
.fontSize(20)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
}
|
||||||
|
.width(52)
|
||||||
|
.height(52)
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(26)
|
||||||
|
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(item.name)
|
||||||
|
.fontSize(18)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
if (item.overview_status && item.overview_status.length > 0) {
|
||||||
|
Text(item.overview_status)
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
|
||||||
|
.backgroundColor($r('app.color.accent_green'))
|
||||||
|
.borderRadius(6)
|
||||||
|
}
|
||||||
|
Blank()
|
||||||
|
Text('›')
|
||||||
|
.fontSize(20)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(`${genderLabel(item.gender)}`)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Text(`年龄:${item.age ?? '未录入'}`)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Text(item.care_level_label && item.care_level_label.length > 0 ? item.care_level_label : '')
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.accent_blue'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
if (item.floor_room_bed && item.floor_room_bed.length > 0) {
|
||||||
|
Text(item.floor_room_bed)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(VerticalAlign.Center)
|
||||||
|
.onClick(() => {
|
||||||
|
this.openDetail(item);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
if (this.loading) {
|
||||||
|
Text('加载中...')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.margin({ top: 60 })
|
||||||
|
} else if (this.elderly.length === 0) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('👴')
|
||||||
|
.fontSize(36)
|
||||||
|
Text('暂无关注对象')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.margin({ top: 80 })
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
} else {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
ForEach(this.elderly, (item: ElderlyItem) => {
|
||||||
|
this.ElderlyCard(item)
|
||||||
|
}, (item: ElderlyItem) => String(item.id))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, bottom: 20 })
|
||||||
|
}
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
.layoutWeight(1)
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '关注对象',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// 老人端 · 餐食详情(ArkTS 严格类型,无 any)
|
||||||
|
// 数据来源:GET /elderly-portal/meals/{id}/?service_date=
|
||||||
|
// 操作:POST /elderly-portal/meals/{id}/reservation/
|
||||||
|
import { router } from '@kit.ArkUI';
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
|
||||||
|
interface ElderlyMeal {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
time: string;
|
||||||
|
menu: string;
|
||||||
|
description: string;
|
||||||
|
ordered: boolean;
|
||||||
|
reservable?: boolean;
|
||||||
|
reservation_hint?: string;
|
||||||
|
type: string;
|
||||||
|
service_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReservationPayload {
|
||||||
|
ordered: boolean;
|
||||||
|
service_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayText(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验日期是否为 YYYY-MM-DD 格式
|
||||||
|
function isValidDate(date: string): boolean {
|
||||||
|
const pattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
return pattern.test(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct ElderlyMealDetail {
|
||||||
|
@State meal: ElderlyMeal | null = null;
|
||||||
|
@State loading: boolean = true;
|
||||||
|
@State toggling: boolean = false;
|
||||||
|
private mealId: string = '';
|
||||||
|
private serviceDate: string = '';
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
const params = router.getParams() as Record<string, string> | undefined;
|
||||||
|
if (params) {
|
||||||
|
this.mealId = params['mealId'] ?? '';
|
||||||
|
this.serviceDate = params['serviceDate'] ?? '';
|
||||||
|
}
|
||||||
|
this.loadMeal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadMeal(): Promise<void> {
|
||||||
|
if (this.mealId.length === 0) {
|
||||||
|
this.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 标准化 service_date 为 YYYY-MM-DD(截取前 10 位,避免带时间/空格)
|
||||||
|
const normalized = this.serviceDate.length >= 10 ? this.serviceDate.substring(0, 10) : this.serviceDate;
|
||||||
|
const finalDate = isValidDate(normalized) ? normalized : todayText();
|
||||||
|
try {
|
||||||
|
const url = `/elderly-portal/meals/${this.mealId}/?service_date=${finalDate}`;
|
||||||
|
console.info(`[MealDetail] request url=${url} rawServiceDate=${this.serviceDate} normalized=${normalized}`);
|
||||||
|
this.meal = await api.get<ElderlyMeal>(url);
|
||||||
|
} catch (err) {
|
||||||
|
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async toggleOrder(): Promise<void> {
|
||||||
|
const meal = this.meal;
|
||||||
|
if (!meal || this.toggling) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (meal.reservable === false) {
|
||||||
|
promptAction.showToast({ message: meal.reservation_hint || '当前餐次不可操作' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.toggling = true;
|
||||||
|
try {
|
||||||
|
const payload: ReservationPayload = { ordered: !meal.ordered };
|
||||||
|
if (this.serviceDate.length > 0) {
|
||||||
|
payload.service_date = this.serviceDate;
|
||||||
|
}
|
||||||
|
const updated = await api.post<ElderlyMeal>(`/elderly-portal/meals/${meal.id}/reservation/`, payload);
|
||||||
|
this.meal = updated;
|
||||||
|
promptAction.showToast({ message: updated.ordered ? '预订成功' : '已取消预订' });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.toggling = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private statusLabel(): string {
|
||||||
|
const meal = this.meal;
|
||||||
|
if (!meal) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (meal.ordered) {
|
||||||
|
return '已预订';
|
||||||
|
}
|
||||||
|
if (meal.reservable === false) {
|
||||||
|
return '不可预订';
|
||||||
|
}
|
||||||
|
return '未预订';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
InfoRow(label: string, value: string) {
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
Text(label)
|
||||||
|
.fontSize(18)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width(90)
|
||||||
|
Text(value && value.length > 0 ? value : '--')
|
||||||
|
.fontSize(18)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.layoutWeight(1)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(VerticalAlign.Top)
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
if (this.loading) {
|
||||||
|
Text('加载中...')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.margin({ top: 60 })
|
||||||
|
} else if (!this.meal) {
|
||||||
|
Text('未找到该餐次信息')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.margin({ top: 60 })
|
||||||
|
} else {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 16 }) {
|
||||||
|
Text(this.meal.name)
|
||||||
|
.fontSize(30)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Column({ space: 14 }) {
|
||||||
|
this.InfoRow('用餐时间', this.meal.time)
|
||||||
|
this.InfoRow('服务日期', this.meal.service_date ?? this.serviceDate)
|
||||||
|
this.InfoRow('菜单', this.meal.menu)
|
||||||
|
this.InfoRow('说明', this.meal.description)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(20)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
if (this.meal.reservation_hint && this.meal.reservation_hint.length > 0) {
|
||||||
|
Text(this.meal.reservation_hint)
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor('#FF9500')
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
Text(this.statusLabel())
|
||||||
|
.fontSize(18)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor(this.meal.ordered ? $r('app.color.accent_green') : $r('app.color.text_secondary'))
|
||||||
|
Blank()
|
||||||
|
Button(this.toggling ? '处理中...' : (this.meal.ordered ? '取消预订' : '立即预订'))
|
||||||
|
.height(52)
|
||||||
|
.fontSize(20)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.backgroundColor(this.meal.ordered ? $r('app.color.accent_red') : $r('app.color.accent_green'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.enabled(!this.toggling)
|
||||||
|
.onClick(() => {
|
||||||
|
this.toggleOrder();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(20)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
}
|
||||||
|
.width('92%')
|
||||||
|
.padding({ top: 60, bottom: 30 })
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
}
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
.layoutWeight(1)
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '餐食详情',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
// 老人端 · 餐饮服务(定制餐点单)(ArkTS 严格类型,无 any)
|
||||||
|
// 接口(与 Web 管理端/小程序一致):
|
||||||
|
// GET /meals/custom-menu/?service_date=&category=
|
||||||
|
// GET /meals/custom-orders/
|
||||||
|
// POST /meals/custom-orders/
|
||||||
|
// POST /meals/custom-orders/{id}/cancel/
|
||||||
|
import { router } from '@kit.ArkUI';
|
||||||
|
import { InsightIntentPage } from '@kit.AbilityKit';
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, EmptyBody, RequestError } from '../../utils/Request';
|
||||||
|
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||||
|
|
||||||
|
type MealCategory = string; // '早餐' | '午餐' | '晚餐'
|
||||||
|
|
||||||
|
interface StandardMenuItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
dish_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomMenuItem {
|
||||||
|
id: number;
|
||||||
|
source: string; // 'menu' | 'recommendation'
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
dish_type: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomMenuResponse {
|
||||||
|
standard_menu: Array<StandardMenuItem>;
|
||||||
|
custom_menu: Array<CustomMenuItem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderItemBrief {
|
||||||
|
dish_name: string;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomOrder {
|
||||||
|
id: number;
|
||||||
|
service_date: string;
|
||||||
|
category: string;
|
||||||
|
status: string;
|
||||||
|
items: Array<OrderItemBrief>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomOrdersResponse {
|
||||||
|
orders: Array<CustomOrder>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function customOrderStatusLabel(status: string): string {
|
||||||
|
if (status === 'active') {
|
||||||
|
return '有效';
|
||||||
|
}
|
||||||
|
if (status === 'cancelled') {
|
||||||
|
return '已取消';
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function customOrderStatusColor(status: string): string {
|
||||||
|
if (status === 'active') {
|
||||||
|
return '#34C759';
|
||||||
|
}
|
||||||
|
if (status === 'cancelled') {
|
||||||
|
return '#8E8E93';
|
||||||
|
}
|
||||||
|
return '#8E8E93';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface OrderItemPayload {
|
||||||
|
menu_item_id?: number;
|
||||||
|
recommendation_id?: number;
|
||||||
|
quantity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateOrderPayload {
|
||||||
|
service_date: string;
|
||||||
|
category: string;
|
||||||
|
items: Array<OrderItemPayload>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DishKeyItem {
|
||||||
|
item: CustomMenuItem;
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORIES: Array<string> = ['早餐', '午餐', '晚餐'];
|
||||||
|
|
||||||
|
function tomorrowText(): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dishKey(item: CustomMenuItem): string {
|
||||||
|
return `${item.source}:${item.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function categoryIcon(category: string): string {
|
||||||
|
if (category === '早餐') {
|
||||||
|
return '🌅';
|
||||||
|
}
|
||||||
|
if (category === '午餐') {
|
||||||
|
return '🍚';
|
||||||
|
}
|
||||||
|
return '🌙';
|
||||||
|
}
|
||||||
|
|
||||||
|
@InsightIntentPage({
|
||||||
|
intentName: 'ViewMealOrder',
|
||||||
|
domain: 'ElderlyCare',
|
||||||
|
intentVersion: '1.0.0',
|
||||||
|
displayName: '查看我的订餐',
|
||||||
|
displayDescription: '打开智慧护老的订餐页,查看今日和明日的菜单并预订',
|
||||||
|
uiAbility: 'EntryAbility',
|
||||||
|
pagePath: 'ets/pages/elderly/ElderlyMealOrder',
|
||||||
|
keywords: ['订餐', '点餐', '吃饭', '菜单', '午餐', '晚餐', '早餐'],
|
||||||
|
llmDescription: '查看养老院长者的今日订餐与菜单,并可预订或取消餐食',
|
||||||
|
})
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct ElderlyMealOrder {
|
||||||
|
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||||
|
@State serviceDate: string = tomorrowText();
|
||||||
|
@State category: string = '午餐';
|
||||||
|
@State customMenu: Array<CustomMenuItem> = [];
|
||||||
|
@State standardMenu: Array<StandardMenuItem> = [];
|
||||||
|
@State selectedKeys: Array<string> = [];
|
||||||
|
@State orders: Array<CustomOrder> = [];
|
||||||
|
@State loading: boolean = true;
|
||||||
|
@State submitting: boolean = false;
|
||||||
|
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
private scaleFor(): number {
|
||||||
|
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const responses: Array<CustomMenuResponse | CustomOrdersResponse | null> = await Promise.all([
|
||||||
|
api.get<CustomMenuResponse>('/meals/custom-menu/', {
|
||||||
|
service_date: this.serviceDate,
|
||||||
|
category: this.category,
|
||||||
|
}).catch((): CustomMenuResponse | null => null),
|
||||||
|
api.get<CustomOrdersResponse>('/meals/custom-orders/').catch((): CustomOrdersResponse | null => null),
|
||||||
|
]);
|
||||||
|
const menuRes = responses[0] as CustomMenuResponse | null;
|
||||||
|
const orderRes = responses[1] as CustomOrdersResponse | null;
|
||||||
|
this.standardMenu = menuRes ? menuRes.standard_menu : [];
|
||||||
|
this.customMenu = menuRes ? menuRes.custom_menu : [];
|
||||||
|
this.orders = orderRes ? orderRes.orders : [];
|
||||||
|
} catch (err) {
|
||||||
|
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private personalRecommendations(): Array<CustomMenuItem> {
|
||||||
|
return this.customMenu.filter((item: CustomMenuItem) => item.source === 'recommendation');
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectableDishes(): Array<CustomMenuItem> {
|
||||||
|
return this.customMenu.filter((item: CustomMenuItem) => item.source === 'menu');
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSelected(item: CustomMenuItem): boolean {
|
||||||
|
return this.selectedKeys.indexOf(dishKey(item)) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleDish(item: CustomMenuItem): void {
|
||||||
|
const key = dishKey(item);
|
||||||
|
if (this.selectedKeys.indexOf(key) >= 0) {
|
||||||
|
this.selectedKeys = this.selectedKeys.filter((value: string) => value !== key);
|
||||||
|
} else {
|
||||||
|
this.selectedKeys = this.selectedKeys.concat([key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private submitBtnText(): string {
|
||||||
|
if (this.submitting) {
|
||||||
|
return '提交中...';
|
||||||
|
}
|
||||||
|
if (this.selectedKeys.length === 0) {
|
||||||
|
return '请先选择菜品';
|
||||||
|
}
|
||||||
|
if (this.scaleFor() >= 1.5) {
|
||||||
|
return `提交定制餐(${this.selectedKeys.length})`;
|
||||||
|
}
|
||||||
|
return `提交定制餐(已选${this.selectedKeys.length} 项)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectedDishes(): Array<CustomMenuItem> {
|
||||||
|
return this.customMenu.filter((item: CustomMenuItem) => this.selectedKeys.indexOf(dishKey(item)) >= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createOrder(): Promise<void> {
|
||||||
|
const selected = this.selectedDishes();
|
||||||
|
if (selected.length === 0 || this.submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.submitting = true;
|
||||||
|
try {
|
||||||
|
const items: Array<OrderItemPayload> = selected.map((item: CustomMenuItem): OrderItemPayload => {
|
||||||
|
const payload: OrderItemPayload = { quantity: 1 };
|
||||||
|
if (item.source === 'recommendation') {
|
||||||
|
payload.recommendation_id = item.id;
|
||||||
|
} else {
|
||||||
|
payload.menu_item_id = item.id;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
});
|
||||||
|
const body: CreateOrderPayload = {
|
||||||
|
service_date: this.serviceDate,
|
||||||
|
category: this.category,
|
||||||
|
items: items,
|
||||||
|
};
|
||||||
|
await api.post('/meals/custom-orders/', body);
|
||||||
|
this.selectedKeys = [];
|
||||||
|
promptAction.showToast({ message: '已提交定制餐' });
|
||||||
|
await this.load();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cancelOrder(orderId: number): Promise<void> {
|
||||||
|
if (this.submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.submitting = true;
|
||||||
|
try {
|
||||||
|
await api.post(`/meals/custom-orders/${orderId}/cancel/`, new EmptyBody());
|
||||||
|
promptAction.showToast({ message: '已取消定制餐' });
|
||||||
|
await this.load();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
CategoryTab(category: string) {
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Text(categoryIcon(category))
|
||||||
|
.fontSize(Math.round(22 * this.scaleFor()))
|
||||||
|
Text(category)
|
||||||
|
.fontSize(Math.round(16 * this.scaleFor()))
|
||||||
|
.fontWeight(this.category === category ? FontWeight.Bold : FontWeight.Normal)
|
||||||
|
.fontColor(this.category === category ? $r('app.color.accent_blue') : $r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.padding({ top: 10, bottom: 10 })
|
||||||
|
.backgroundColor(this.category === category ? '#14007AFF' : Color.Transparent)
|
||||||
|
.borderRadius(12)
|
||||||
|
.onClick(() => {
|
||||||
|
if (this.category !== category) {
|
||||||
|
this.category = category;
|
||||||
|
this.selectedKeys = [];
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
DishCard(item: CustomMenuItem) {
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Text(item.name)
|
||||||
|
.fontSize(Math.round(17 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
Row({ space: 6 }) {
|
||||||
|
if (item.price && item.price.length > 0) {
|
||||||
|
Text(`¥${item.price}`)
|
||||||
|
.fontSize(Math.round(14 * this.scaleFor()))
|
||||||
|
.fontColor('#FF9500')
|
||||||
|
}
|
||||||
|
if (item.source === 'recommendation' && item.reason && item.reason.length > 0) {
|
||||||
|
Text(item.reason)
|
||||||
|
.fontSize(Math.round(12 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.accent_blue'))
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
.layoutWeight(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
Text(this.isSelected(item) ? '✓' : '+')
|
||||||
|
.fontSize(Math.round(18 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.width(32)
|
||||||
|
.height(32)
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
.backgroundColor(this.isSelected(item) ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
|
||||||
|
.borderRadius(16)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.border({
|
||||||
|
width: 2,
|
||||||
|
color: this.isSelected(item) ? $r('app.color.accent_green') : Color.Transparent
|
||||||
|
})
|
||||||
|
.onClick(() => {
|
||||||
|
this.toggleDish(item);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
OrderCard(order: CustomOrder) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Row() {
|
||||||
|
Text(`${order.category} · ${order.service_date}`)
|
||||||
|
.fontSize(Math.round(16 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Blank()
|
||||||
|
Text(customOrderStatusLabel(order.status))
|
||||||
|
.fontSize(Math.round(13 * this.scaleFor()))
|
||||||
|
.fontColor(customOrderStatusColor(order.status))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Text(order.items.map((item: OrderItemBrief) => `${item.dish_name}×${item.quantity}`).join('、'))
|
||||||
|
.fontSize(Math.round(14 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
if (order.status === 'active') {
|
||||||
|
Button('取消定制餐')
|
||||||
|
.height(32)
|
||||||
|
.fontSize(Math.round(13 * this.scaleFor()))
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.backgroundColor($r('app.color.accent_red'))
|
||||||
|
.borderRadius(10)
|
||||||
|
.onClick(() => {
|
||||||
|
this.cancelOrder(order.id);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 10 }) {
|
||||||
|
// 日期 + 餐次切换
|
||||||
|
Column({ space: 10 }) {
|
||||||
|
Text(`服务日期${this.serviceDate}(明日)`)
|
||||||
|
.fontSize(Math.round(15 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
ForEach(CATEGORIES, (category: string) => {
|
||||||
|
this.CategoryTab(category)
|
||||||
|
}, (category: string) => category)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
|
||||||
|
if (this.loading) {
|
||||||
|
Text('加载中...')
|
||||||
|
.fontSize(Math.round(15 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 40, bottom: 40 })
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
} else {
|
||||||
|
// 个人推荐
|
||||||
|
if (this.personalRecommendations().length > 0) {
|
||||||
|
Text('为您推荐')
|
||||||
|
.fontSize(Math.round(19 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
ForEach(this.personalRecommendations(), (item: CustomMenuItem) => {
|
||||||
|
this.DishCard(item)
|
||||||
|
}, (item: CustomMenuItem) => dishKey(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标准菜单(只读)
|
||||||
|
if (this.standardMenu.length > 0) {
|
||||||
|
Text('标准菜单')
|
||||||
|
.fontSize(Math.round(19 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
ForEach(this.standardMenu, (item: StandardMenuItem) => {
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
Text(item.name)
|
||||||
|
.fontSize(Math.round(16 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.layoutWeight(1)
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
if (item.dish_type && item.dish_type.length > 0) {
|
||||||
|
Text(item.dish_type)
|
||||||
|
.fontSize(Math.round(12 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(12)
|
||||||
|
}, (item: StandardMenuItem) => String(item.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可选菜品(可定制加菜)
|
||||||
|
Text('可选定制菜')
|
||||||
|
.fontSize(Math.round(19 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
if (this.selectableDishes().length === 0) {
|
||||||
|
Text('暂无可选定制菜')
|
||||||
|
.fontSize(Math.round(15 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
} else {
|
||||||
|
ForEach(this.selectableDishes(), (item: CustomMenuItem) => {
|
||||||
|
this.DishCard(item)
|
||||||
|
}, (item: CustomMenuItem) => dishKey(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交
|
||||||
|
Button(this.submitBtnText())
|
||||||
|
.width('100%')
|
||||||
|
.height(52)
|
||||||
|
.fontSize(Math.round(18 * this.scaleFor()))
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.backgroundColor(this.selectedKeys.length > 0 ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.enabled(this.selectedKeys.length > 0 && !this.submitting)
|
||||||
|
.onClick(() => {
|
||||||
|
this.createOrder();
|
||||||
|
})
|
||||||
|
|
||||||
|
// 我的定制餐
|
||||||
|
Text('我的定制餐')
|
||||||
|
.fontSize(Math.round(19 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
if (this.orders.length === 0) {
|
||||||
|
Text('暂无定制餐订单')
|
||||||
|
.fontSize(Math.round(15 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
} else {
|
||||||
|
ForEach(this.orders, (order: CustomOrder) => {
|
||||||
|
this.OrderCard(order)
|
||||||
|
}, (order: CustomOrder) => String(order.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 92, bottom: 24 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '餐饮服务',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
// 老人端 · 我的面板(ArkTS 严格类型,无 any)
|
||||||
|
// 对齐小程序 ElderlyProfilePanel:账号卡 + 菜单 + 退出登录
|
||||||
|
import { router } from '@kit.ArkUI';
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { common } from '@kit.AbilityKit';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { AuthStore } from '../../utils/AuthStore';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
import { FontScaleLevel, FontScaleOption, FONT_SCALE_LABELS, FONT_SCALE_MAP, loadFontScale, setFontScale } from '../../utils/FontScale';
|
||||||
|
|
||||||
|
interface ProfileDashboard {
|
||||||
|
elderly_id: number | null;
|
||||||
|
elderly_name: string;
|
||||||
|
service_date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileMenu {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
route: string; // 空字符串表示未实现
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component
|
||||||
|
export struct ElderlyProfilePanel {
|
||||||
|
@State userName: string = '';
|
||||||
|
@State userId: number = 0;
|
||||||
|
@State elderlyName: string = '';
|
||||||
|
@State fontScale: FontScaleLevel = 'normal';
|
||||||
|
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||||
|
|
||||||
|
private scaleFor(): number {
|
||||||
|
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
menus: Array<ProfileMenu> = [
|
||||||
|
{ key: 'meal', title: '餐饮服务', desc: '查看并选择定制餐', route: 'pages/elderly/ElderlyMealOrder' },
|
||||||
|
{ key: 'activities', title: '活动安排', desc: '查看今天和本周的社工活动', route: 'pages/elderly/Activities' },
|
||||||
|
{ key: 'notifications', title: '消息通知', desc: '查看护理记录和订餐状态消息', route: 'pages/notifications/Notifications' },
|
||||||
|
{ key: 'history', title: '历史订餐', desc: '查看过往和近期的订餐记录', route: 'pages/elderly/ElderlyHistory' },
|
||||||
|
|
||||||
|
];
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.loadAuth();
|
||||||
|
this.loadDashboard();
|
||||||
|
loadFontScale(getContext(this) as common.UIAbilityContext).then((level: FontScaleLevel) => {
|
||||||
|
this.fontScale = level;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadDashboard(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await api.get<ProfileDashboard>('/elderly-portal/dashboard/');
|
||||||
|
this.elderlyName = res.elderly_name || '';
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[ElderlyProfile] load dashboard failed:', normalizeError(err as BusinessError | Error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private changeFontScale(level: FontScaleLevel): void {
|
||||||
|
this.fontScale = level;
|
||||||
|
setFontScale(getContext(this) as common.UIAbilityContext, level);
|
||||||
|
promptAction.showToast({ message: `字体已调整为${FONT_SCALE_LABELS.find((item) => item.level === level)?.label ?? ''}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadAuth(): Promise<void> {
|
||||||
|
const state = await AuthStore.getState();
|
||||||
|
this.userName = state.userName;
|
||||||
|
this.userId = state.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMenuClick(menu: ProfileMenu): void {
|
||||||
|
if (menu.route.length === 0) {
|
||||||
|
promptAction.showToast({ message: '功能开发中' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.pushUrl({ url: menu.route }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logout(): Promise<void> {
|
||||||
|
await AuthStore.logout();
|
||||||
|
promptAction.showToast({ message: '已退出登录' });
|
||||||
|
router.replaceUrl({ url: 'pages/Login' }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
MenuRow(menu: ProfileMenu) {
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
Text(menu.title)
|
||||||
|
.fontSize(Math.round(17 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text(menu.desc)
|
||||||
|
.fontSize(Math.round(15 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
Text('›')
|
||||||
|
.fontSize(Math.round(22 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.onClick(() => {
|
||||||
|
this.onMenuClick(menu);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
Scroll() {
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
// 用户信息卡
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Stack() {
|
||||||
|
Circle()
|
||||||
|
.width(64)
|
||||||
|
.height(64)
|
||||||
|
.fill($r('app.color.accent_blue'))
|
||||||
|
Text((this.elderlyName || this.userName || '客').substring(0, 1))
|
||||||
|
.fontSize(Math.round(26 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
}
|
||||||
|
.width(64)
|
||||||
|
.height(64)
|
||||||
|
|
||||||
|
Text(this.elderlyName || this.userName || '用户')
|
||||||
|
.fontSize(Math.round(22 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
|
||||||
|
Text(this.elderlyName.length > 0 ? '长者账号' : '账号')
|
||||||
|
.fontSize(Math.round(13 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('92%')
|
||||||
|
.padding({ top: 16, bottom: 14 })
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.margin({ top: 16 })
|
||||||
|
|
||||||
|
// 账号编号
|
||||||
|
Row() {
|
||||||
|
Text('账号编号')
|
||||||
|
.fontSize(Math.round(15 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Blank()
|
||||||
|
Text(this.userId > 0 ? String(this.userId) : '--')
|
||||||
|
.fontSize(Math.round(15 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
}
|
||||||
|
.width('92%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
|
||||||
|
// 字体大小
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('字体大小')
|
||||||
|
.fontSize(Math.round(15 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
Flex({ wrap: FlexWrap.Wrap, alignItems: ItemAlign.Center }) {
|
||||||
|
ForEach(FONT_SCALE_LABELS, (item: FontScaleOption) => {
|
||||||
|
Text(item.label)
|
||||||
|
.fontSize(Math.round(15 * this.scaleFor()))
|
||||||
|
.fontColor(this.fontScale === item.level ? '#FFFFFF' : $r('app.color.text_primary'))
|
||||||
|
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
|
||||||
|
.backgroundColor(this.fontScale === item.level ? $r('app.color.accent_blue') : $r('app.color.background_card'))
|
||||||
|
.borderRadius(10)
|
||||||
|
.margin({ right: 10, bottom: 10 })
|
||||||
|
.onClick(() => {
|
||||||
|
this.changeFontScale(item.level);
|
||||||
|
})
|
||||||
|
}, (item: FontScaleOption) => item.level)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('92%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
// 菜单
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
ForEach(this.menus, (menu: ProfileMenu) => {
|
||||||
|
this.MenuRow(menu)
|
||||||
|
}, (menu: ProfileMenu) => menu.key)
|
||||||
|
}
|
||||||
|
.width('92%')
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
Text('退出登录')
|
||||||
|
.fontSize(Math.round(17 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor($r('app.color.accent_red'))
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
.width('92%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
// 避让底部悬浮 Tab
|
||||||
|
.margin({ bottom: 130 })
|
||||||
|
|
||||||
|
.onClick(() => {
|
||||||
|
this.logout();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
}
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// 老人端 · 膳食推荐(ArkTS 严格类型,无 any)
|
||||||
|
// 接口:GET /elderly-portal/recommendations/
|
||||||
|
import { router } from '@kit.ArkUI';
|
||||||
|
import { promptAction } from '@kit.ArkUI';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||||
|
|
||||||
|
interface Recommendation {
|
||||||
|
id: number;
|
||||||
|
service_date: string;
|
||||||
|
meal_period: string;
|
||||||
|
dish_name: string;
|
||||||
|
price: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecommendationsResponse {
|
||||||
|
elderly_id: number | null;
|
||||||
|
recommendations: Array<Recommendation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodIcon(period: string): string {
|
||||||
|
if (period === '早餐') {
|
||||||
|
return '🌅';
|
||||||
|
}
|
||||||
|
if (period === '午餐') {
|
||||||
|
return '🍚';
|
||||||
|
}
|
||||||
|
return '🌙';
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodColor(period: string): string {
|
||||||
|
if (period === '早餐') {
|
||||||
|
return '#FF9500';
|
||||||
|
}
|
||||||
|
if (period === '午餐') {
|
||||||
|
return '#007AFF';
|
||||||
|
}
|
||||||
|
return '#34C759';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLabel(dateStr: string): string {
|
||||||
|
if (!dateStr || dateStr.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const parts: Array<string> = dateStr.split('-');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
const month = parseInt(parts[1], 10);
|
||||||
|
const day = parseInt(parts[2], 10);
|
||||||
|
return `${month}月${day}日`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToday(dateStr: string): boolean {
|
||||||
|
if (!dateStr || dateStr.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const mm = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(now.getDate()).padStart(2, '0');
|
||||||
|
return dateStr === `${now.getFullYear()}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct ElderlyRecommendations {
|
||||||
|
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||||
|
@State recommendations: Array<Recommendation> = [];
|
||||||
|
@State loading: boolean = true;
|
||||||
|
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
private scaleFor(): number {
|
||||||
|
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await api.get<RecommendationsResponse>('/elderly-portal/recommendations/');
|
||||||
|
this.recommendations = res.recommendations;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
RecommendCard(item: Recommendation) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
Text(periodIcon(item.meal_period))
|
||||||
|
.fontSize(Math.round(24 * this.scaleFor()))
|
||||||
|
Text(`${item.meal_period} · ${isToday(item.service_date) ? '今日' : formatDateLabel(item.service_date)}`)
|
||||||
|
.fontSize(Math.round(17 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Blank()
|
||||||
|
Text(`¥${item.price}`)
|
||||||
|
.fontSize(Math.round(16 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor('#FF9500')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Text(item.dish_name)
|
||||||
|
.fontSize(Math.round(20 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor(periodColor(item.meal_period))
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
if (item.reason && item.reason.length > 0) {
|
||||||
|
Text(item.reason)
|
||||||
|
.fontSize(Math.round(15 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.lineHeight(22)
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(18)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
if (this.loading) {
|
||||||
|
Text('加载中...')
|
||||||
|
.fontSize(Math.round(16 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.margin({ top: 40 })
|
||||||
|
} else if (this.recommendations.length === 0) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('暂无膳食推荐')
|
||||||
|
.fontSize(Math.round(20 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text('护理团队会根据您的健康状况推送个性化餐食建议')
|
||||||
|
.fontSize(Math.round(15 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(24)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(16)
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
} else {
|
||||||
|
ForEach(this.recommendations, (item: Recommendation) => {
|
||||||
|
Column() {
|
||||||
|
this.RecommendCard(item)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}, (item: Recommendation) => String(item.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '膳食推荐',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
// 长者端 · 天气(ArkTS 严格类型,无 any)
|
||||||
|
// 数据:Open-Meteo(utils/OpenMeteo.ts),广州 7 天预报
|
||||||
|
import { router } from '@kit.ArkUI';
|
||||||
|
import { InsightIntentPage } from '@kit.AbilityKit';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { fetchWeather, weatherCodeLabel, todayYMD, WeatherData, WeatherDay } from '../../utils/OpenMeteo';
|
||||||
|
|
||||||
|
function dateLabel(dateStr: string): string {
|
||||||
|
const today = todayYMD();
|
||||||
|
if (dateStr === today) {
|
||||||
|
return '今天';
|
||||||
|
}
|
||||||
|
if (dateStr.length < 5) {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
const md = dateStr.substring(5);
|
||||||
|
return md.replace('-', '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 天气图标(与 weatherCodeLabel 同一套 code 区间)
|
||||||
|
function weatherEmoji(code: number): string {
|
||||||
|
if (code === 0 || code === 1) {
|
||||||
|
return '☀️';
|
||||||
|
}
|
||||||
|
if (code === 2) {
|
||||||
|
return '⛅';
|
||||||
|
}
|
||||||
|
if (code === 3) {
|
||||||
|
return '☁️';
|
||||||
|
}
|
||||||
|
if (code === 45 || code === 48) {
|
||||||
|
return '🌫️';
|
||||||
|
}
|
||||||
|
if (code >= 51 && code <= 57) {
|
||||||
|
return '🌦️';
|
||||||
|
}
|
||||||
|
if (code >= 71 && code <= 77 || code === 85 || code === 86) {
|
||||||
|
return '❄️';
|
||||||
|
}
|
||||||
|
if (code >= 95) {
|
||||||
|
return '⛈️';
|
||||||
|
}
|
||||||
|
return '🌧️';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 温度图表常量:0~40° 映射到 160px
|
||||||
|
const CHART_MIN_T = 0;
|
||||||
|
const CHART_MAX_T = 40;
|
||||||
|
const CHART_HEIGHT = 160;
|
||||||
|
const CHART_PX_PER_DEGREE = CHART_HEIGHT / (CHART_MAX_T - CHART_MIN_T);
|
||||||
|
|
||||||
|
@InsightIntentPage({
|
||||||
|
intentName: 'ViewWeather',
|
||||||
|
domain: 'ElderlyCare',
|
||||||
|
intentVersion: '1.0.0',
|
||||||
|
displayName: '查看天气',
|
||||||
|
displayDescription: '打开智慧护老的天气页,查看养老院所在地七天天气',
|
||||||
|
uiAbility: 'EntryAbility',
|
||||||
|
pagePath: 'ets/pages/elderly/ElderlyWeather',
|
||||||
|
keywords: ['天气', '下雨', '气温', '出门', '降温'],
|
||||||
|
llmDescription: '查看养老院所在地的今日与未来七天天气预报',
|
||||||
|
})
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct ElderlyWeather {
|
||||||
|
@State weather: WeatherData | null = null;
|
||||||
|
@State loading: boolean = true;
|
||||||
|
@State errorMsg: string = '';
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
// 与首页同源:优先用首页传入的定位坐标(-1 表示未定位,回退广州);无参数直接进入也回退
|
||||||
|
const params = router.getParams() as Record<string, Object> | null | undefined;
|
||||||
|
let lat = -1;
|
||||||
|
let lon = -1;
|
||||||
|
if (params !== null && params !== undefined) {
|
||||||
|
lat = Number(params['lat'] ?? -1);
|
||||||
|
lon = Number(params['lon'] ?? -1);
|
||||||
|
}
|
||||||
|
if (lat > 0 && lon > 0) {
|
||||||
|
this.load(lat, lon);
|
||||||
|
} else {
|
||||||
|
this.load(undefined, undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(lat?: number, lon?: number): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
this.errorMsg = '';
|
||||||
|
try {
|
||||||
|
const res = await fetchWeather(lat, lon);
|
||||||
|
this.weather = res;
|
||||||
|
} catch (err) {
|
||||||
|
this.errorMsg = (err as Error).message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前温度映射到 0-40° 仪表区间
|
||||||
|
private gaugeValue(): number {
|
||||||
|
if (this.weather === null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const t = Math.round(this.weather.current.temperature);
|
||||||
|
return Math.max(0, Math.min(40, t));
|
||||||
|
}
|
||||||
|
|
||||||
|
private todayRainText(): string {
|
||||||
|
const list = this.weather?.daily ?? [];
|
||||||
|
if (list.length > 0) {
|
||||||
|
return `今日降雨概率 ${list[0].rainPct}%`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Scroll() {
|
||||||
|
Column({ space: 24 }) {
|
||||||
|
if (this.loading) {
|
||||||
|
Text('正在获取天气...')
|
||||||
|
.fontSize(16)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.padding({ top: 60 })
|
||||||
|
} else if (this.errorMsg.length > 0) {
|
||||||
|
Column({ space: 10 }) {
|
||||||
|
Text('天气获取失败')
|
||||||
|
.fontSize(18)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Text(this.errorMsg)
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 60 })
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
} else {
|
||||||
|
// 今日温度仪表(官方 Gauge 样式)
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Row() {
|
||||||
|
Gauge({ value: this.gaugeValue(), min: 0, max: 40 }) {
|
||||||
|
Column() {
|
||||||
|
Text(this.weather !== null ? `${this.weather.current.temperature.toFixed(1)}°C` : '--')
|
||||||
|
.fontSize(34)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
.margin({ top: '35%' })
|
||||||
|
.maxLines(1)
|
||||||
|
Text(this.weather !== null ? `${weatherCodeLabel(this.weather.current.code)} · 广州今天` : '--')
|
||||||
|
.fontSize(14)
|
||||||
|
.fontColor($r('sys.color.ohos_id_color_text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
Text(this.todayRainText())
|
||||||
|
.fontSize(13)
|
||||||
|
.fontColor($r('sys.color.ohos_id_color_text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
}
|
||||||
|
.startAngle(210)
|
||||||
|
.endAngle(150)
|
||||||
|
.colors([
|
||||||
|
[new LinearGradient([{ color: '#61cfbe', offset: 0 }, { color: '#46b1e3', offset: 1 }]), 1],
|
||||||
|
[new LinearGradient([{ color: '#c1e4be', offset: 0 }, { color: '#64bb5c', offset: 1 }]), 1],
|
||||||
|
[new LinearGradient([{ color: '#fceb99', offset: 0 }, { color: '#f7ce00', offset: 1 }]), 1],
|
||||||
|
[new LinearGradient([{ color: '#f8c5a6', offset: 0 }, { color: '#ed6f21', offset: 1 }]), 1]
|
||||||
|
])
|
||||||
|
.width(240)
|
||||||
|
.height(240)
|
||||||
|
.strokeWidth(18)
|
||||||
|
.trackShadow({ radius: 7, offsetX: 7, offsetY: 7 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(20)
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
|
||||||
|
// 未来 7 天温度范围图表(竖向柱状:柱底=最低温,柱顶=最高温)
|
||||||
|
Text('未来 7 天')
|
||||||
|
.fontSize(18)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
// 纵轴刻度
|
||||||
|
Column() {
|
||||||
|
Text('40°')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Text('30°')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Text('20°')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Text('10°')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Text('0°')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.height(CHART_HEIGHT)
|
||||||
|
.justifyContent(FlexAlign.SpaceBetween)
|
||||||
|
.alignItems(HorizontalAlign.End)
|
||||||
|
|
||||||
|
// 每日一列:图标 + 双柱(蓝=最低温/橙=最高温,柱高=温度) + 日期 + 降雨
|
||||||
|
ForEach(this.weather?.daily ?? [], (day: WeatherDay) => {
|
||||||
|
Column({ space: 4 }) {
|
||||||
|
Text(weatherEmoji(day.code))
|
||||||
|
.fontSize(20)
|
||||||
|
Row({ space: 6 }) {
|
||||||
|
Column({ space: 3 }) {
|
||||||
|
Text(`${Math.round(Math.max(day.minT, CHART_MIN_T))}°`)
|
||||||
|
.fontSize(11)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor('#46b1e3')
|
||||||
|
Column()
|
||||||
|
.width(14)
|
||||||
|
.height(Math.min(Math.max(12, Math.round((Math.max(day.minT, CHART_MIN_T)) * CHART_PX_PER_DEGREE)), CHART_HEIGHT - 24))
|
||||||
|
.backgroundColor('#46b1e3')
|
||||||
|
.borderRadius(7)
|
||||||
|
}
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
Column({ space: 3 }) {
|
||||||
|
Text(`${Math.round(Math.min(day.maxT, CHART_MAX_T))}°`)
|
||||||
|
.fontSize(11)
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor('#ed6f21')
|
||||||
|
Column()
|
||||||
|
.width(14)
|
||||||
|
.height(Math.min(Math.max(12, Math.round((Math.min(day.maxT, CHART_MAX_T)) * CHART_PX_PER_DEGREE)), CHART_HEIGHT - 24))
|
||||||
|
.backgroundColor('#ed6f21')
|
||||||
|
.borderRadius(7)
|
||||||
|
}
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
}
|
||||||
|
.height(CHART_HEIGHT)
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(VerticalAlign.Bottom)
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
|
||||||
|
Text(dateLabel(day.date))
|
||||||
|
.fontSize(13)
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.maxLines(1)
|
||||||
|
Text(`雨${day.rainPct}%`)
|
||||||
|
.fontSize(11)
|
||||||
|
.fontColor(day.rainPct >= 60 ? '#007AFF' : $r('app.color.text_secondary'))
|
||||||
|
.maxLines(1)
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Center)
|
||||||
|
.accessibilityLevel('yes')
|
||||||
|
.accessibilityText(`${dateLabel(day.date)},${weatherCodeLabel(day.code)},最低${Math.round(day.minT)}度,最高${Math.round(day.maxT)}度,降雨概率${day.rainPct}%`)
|
||||||
|
}, (day: WeatherDay) => day.date)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(VerticalAlign.Bottom)
|
||||||
|
|
||||||
|
Row({ space: 6 }) {
|
||||||
|
Column().width(14).height(14).borderRadius(7).backgroundColor('#46b1e3')
|
||||||
|
Text('最低温')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
Column().width(14).height(14).borderRadius(7).backgroundColor('#ed6f21')
|
||||||
|
Text('最高温')
|
||||||
|
.fontSize(12)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.justifyContent(FlexAlign.Center)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 120, bottom: 30 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '天气',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
// 家属 · 预警详情(ArkTS 严格类型,无 any)
|
||||||
|
// 接口:GET /alerts/{id}/
|
||||||
|
import { router, promptAction } from '@kit.ArkUI';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||||
|
|
||||||
|
interface AlertProcess {
|
||||||
|
id: number;
|
||||||
|
nurse_name: string;
|
||||||
|
description: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertDetail {
|
||||||
|
id: number;
|
||||||
|
elderly_name: string;
|
||||||
|
level: string;
|
||||||
|
metric: string;
|
||||||
|
actual_value: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
status_display: string;
|
||||||
|
processing_by_name: string;
|
||||||
|
resolved_at: string;
|
||||||
|
created_at: string;
|
||||||
|
processes: Array<AlertProcess>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelLabel(level: string): string {
|
||||||
|
if (level === 'emergency') {
|
||||||
|
return '紧急';
|
||||||
|
}
|
||||||
|
if (level === 'warning') {
|
||||||
|
return '注意';
|
||||||
|
}
|
||||||
|
return '正常';
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelColor(level: string): string {
|
||||||
|
if (level === 'emergency') {
|
||||||
|
return '#FF3B30';
|
||||||
|
}
|
||||||
|
if (level === 'warning') {
|
||||||
|
return '#FF9500';
|
||||||
|
}
|
||||||
|
return '#007AFF';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
if (!iso || iso.length === 0) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
return `${mm}/${dd} ${hh}:${mi}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct FamilyAlertDetail {
|
||||||
|
@State alert: AlertDetail | null = null;
|
||||||
|
@State loading: boolean = true;
|
||||||
|
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||||
|
|
||||||
|
private scaleFor(): number {
|
||||||
|
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
const params = router.getParams() as Record<string, string | number>;
|
||||||
|
const id = params ? params['id'] : null;
|
||||||
|
if (id === null || id === undefined) {
|
||||||
|
router.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.load(Number(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(id: number): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await api.get<AlertDetail>(`/alerts/${id}/`);
|
||||||
|
this.alert = res;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
InfoRow(label: string, value: string) {
|
||||||
|
Row({ space: 12 }) {
|
||||||
|
Text(label)
|
||||||
|
.fontSize(Math.round(14 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width(72)
|
||||||
|
Text(value)
|
||||||
|
.fontSize(Math.round(14 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.layoutWeight(1)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
ProcessCard(process: AlertProcess) {
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
Row() {
|
||||||
|
Text(process.nurse_name)
|
||||||
|
.fontSize(Math.round(14 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
Blank()
|
||||||
|
Text(formatDateTime(process.created_at))
|
||||||
|
.fontSize(Math.round(12 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
Text(process.description)
|
||||||
|
.fontSize(Math.round(14 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(12)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
if (this.loading) {
|
||||||
|
Text('加载中...')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.margin({ top: 40 })
|
||||||
|
} else if (this.alert) {
|
||||||
|
Column({ space: 10 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(levelLabel(this.alert.level))
|
||||||
|
.fontSize(Math.round(13 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(levelColor(this.alert.level))
|
||||||
|
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
|
||||||
|
.backgroundColor(this.alert.level === 'emergency' ? '#14FF3B30' : (this.alert.level === 'warning' ? '#14FF9500' : '#14007AFF'))
|
||||||
|
.borderRadius(6)
|
||||||
|
Text(this.alert.status_display)
|
||||||
|
.fontSize(Math.round(13 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
Text(this.alert.title)
|
||||||
|
.fontSize(Math.round(20 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
if (this.alert.description && this.alert.description.length > 0) {
|
||||||
|
Text(this.alert.description)
|
||||||
|
.fontSize(Math.round(15 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.lineHeight(Math.round(22 * this.scaleFor()))
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
this.InfoRow('长者', this.alert.elderly_name)
|
||||||
|
if (this.alert.metric && this.alert.metric.length > 0) {
|
||||||
|
this.InfoRow('指标', this.alert.metric)
|
||||||
|
}
|
||||||
|
if (this.alert.actual_value && this.alert.actual_value.length > 0) {
|
||||||
|
this.InfoRow('实际值', this.alert.actual_value)
|
||||||
|
}
|
||||||
|
this.InfoRow('触发时间', formatDateTime(this.alert.created_at))
|
||||||
|
if (this.alert.resolved_at && this.alert.resolved_at.length > 0) {
|
||||||
|
this.InfoRow('处理时间', formatDateTime(this.alert.resolved_at))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(16)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
|
||||||
|
if (this.alert.processes && this.alert.processes.length > 0) {
|
||||||
|
Column({ space: 8 }) {
|
||||||
|
Text('处理记录')
|
||||||
|
.fontSize(Math.round(16 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.width('100%')
|
||||||
|
ForEach(this.alert.processes, (process: AlertProcess) => {
|
||||||
|
Column() {
|
||||||
|
this.ProcessCard(process)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}, (process: AlertProcess) => String(process.id))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '预警详情',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
// 家属 · 健康预警列表(ArkTS 严格类型,无 any)
|
||||||
|
// 接口:GET /alerts/
|
||||||
|
import { router, promptAction } from '@kit.ArkUI';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { Page, normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||||
|
|
||||||
|
interface FamilyAlert {
|
||||||
|
id: number;
|
||||||
|
elderly_name: string;
|
||||||
|
level: string;
|
||||||
|
metric: string;
|
||||||
|
actual_value: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
status_display: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelColor(level: string): string {
|
||||||
|
if (level === 'emergency') {
|
||||||
|
return '#FF3B30';
|
||||||
|
}
|
||||||
|
if (level === 'warning') {
|
||||||
|
return '#FF9500';
|
||||||
|
}
|
||||||
|
return '#007AFF';
|
||||||
|
}
|
||||||
|
|
||||||
|
function levelLabel(level: string): string {
|
||||||
|
if (level === 'emergency') {
|
||||||
|
return '紧急';
|
||||||
|
}
|
||||||
|
if (level === 'warning') {
|
||||||
|
return '注意';
|
||||||
|
}
|
||||||
|
return '正常';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string): string {
|
||||||
|
if (!iso || iso.length === 0) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
return `${mm}/${dd} ${hh}:${mi}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct FamilyAlerts {
|
||||||
|
@State alerts: Array<FamilyAlert> = [];
|
||||||
|
@State loading: boolean = true;
|
||||||
|
@State refreshing: boolean = false;
|
||||||
|
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||||
|
|
||||||
|
private scaleFor(): number {
|
||||||
|
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
aboutToAppear(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(): Promise<void> {
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await api.get<Page<FamilyAlert>>('/alerts/', {
|
||||||
|
page_size: 50,
|
||||||
|
ordering: '-created_at',
|
||||||
|
});
|
||||||
|
this.alerts = res.results;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
promptAction.showToast({ message: message });
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
this.refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private openDetail(alert: FamilyAlert): void {
|
||||||
|
router.pushUrl({ url: 'pages/family/FamilyAlertDetail', params: { id: alert.id } })
|
||||||
|
.catch((err: BusinessError) => {
|
||||||
|
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
AlertCard(alert: FamilyAlert) {
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(levelLabel(alert.level))
|
||||||
|
.fontSize(Math.round(12 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Bold)
|
||||||
|
.fontColor(levelColor(alert.level))
|
||||||
|
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
|
||||||
|
.backgroundColor(alert.level === 'emergency' ? '#14FF3B30' : (alert.level === 'warning' ? '#14FF9500' : '#14007AFF'))
|
||||||
|
.borderRadius(6)
|
||||||
|
Text(alert.title)
|
||||||
|
.fontSize(Math.round(16 * this.scaleFor()))
|
||||||
|
.fontWeight(FontWeight.Medium)
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.layoutWeight(1)
|
||||||
|
.maxLines(1)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
|
||||||
|
if (alert.description && alert.description.length > 0) {
|
||||||
|
Text(alert.description)
|
||||||
|
.fontSize(Math.round(13 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.maxLines(2)
|
||||||
|
.textOverflow({ overflow: TextOverflow.Ellipsis })
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
|
||||||
|
Row({ space: 8 }) {
|
||||||
|
Text(alert.elderly_name)
|
||||||
|
.fontSize(Math.round(12 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.accent_blue'))
|
||||||
|
Blank()
|
||||||
|
Text(formatDateTime(alert.created_at))
|
||||||
|
.fontSize(Math.round(12 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}
|
||||||
|
.layoutWeight(1)
|
||||||
|
.alignItems(HorizontalAlign.Start)
|
||||||
|
Text('›')
|
||||||
|
.fontSize(20)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.onClick(() => {
|
||||||
|
this.openDetail(alert);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
Refresh({ refreshing: $$this.refreshing }) {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 12 }) {
|
||||||
|
if (this.loading) {
|
||||||
|
Text('加载中...')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.inactive_tab'))
|
||||||
|
.margin({ top: 40 })
|
||||||
|
} else if (this.alerts.length === 0) {
|
||||||
|
Text('暂无预警')
|
||||||
|
.fontSize(15)
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.width('100%')
|
||||||
|
.padding(24)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
} else {
|
||||||
|
ForEach(this.alerts, (alert: FamilyAlert) => {
|
||||||
|
Column() {
|
||||||
|
this.AlertCard(alert)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
}, (alert: FamilyAlert) => String(alert.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.layoutWeight(1)
|
||||||
|
.onRefreshing(() => {
|
||||||
|
this.load();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '健康预警',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// 家属 · 智能助手(ArkTS 严格类型,无 any)
|
||||||
|
// 接口:POST /robots/family-assistant/query/ {message} → {reply}
|
||||||
|
import { router, promptAction } from '@kit.ArkUI';
|
||||||
|
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
|
||||||
|
import { hdsMaterial } from '@hms.hds.hdsMaterial';
|
||||||
|
import { BusinessError } from '@kit.BasicServicesKit';
|
||||||
|
import { normalizeError } from '../../common/ApiConstants';
|
||||||
|
import { api, RequestError } from '../../utils/Request';
|
||||||
|
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
|
||||||
|
|
||||||
|
interface ChatMessage {
|
||||||
|
id: number;
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssistantReply {
|
||||||
|
reply: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageSeq = 1;
|
||||||
|
|
||||||
|
@Entry
|
||||||
|
@Component
|
||||||
|
struct FamilyAssistant {
|
||||||
|
@State inputText: string = '';
|
||||||
|
@State messages: Array<ChatMessage> = [];
|
||||||
|
@State sending: boolean = false;
|
||||||
|
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
|
||||||
|
|
||||||
|
private scaleFor(): number {
|
||||||
|
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scroller: Scroller = new Scroller();
|
||||||
|
|
||||||
|
private async send(): Promise<void> {
|
||||||
|
const text = this.inputText.trim();
|
||||||
|
if (text.length === 0 || this.sending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userMsg: ChatMessage = { id: messageSeq++, role: 'user', content: text };
|
||||||
|
this.messages = this.messages.concat([userMsg]);
|
||||||
|
this.inputText = '';
|
||||||
|
this.sending = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.scroller.scrollEdge(Edge.Bottom);
|
||||||
|
}, 50);
|
||||||
|
try {
|
||||||
|
const res = await api.post<AssistantReply>('/robots/family-assistant/query/', { message: text });
|
||||||
|
const reply = res.reply && res.reply.length > 0 ? res.reply : '好的,我会继续为您查询。';
|
||||||
|
this.messages = this.messages.concat([{ id: messageSeq++, role: 'assistant', content: reply }]);
|
||||||
|
this.scroller.scrollEdge(Edge.Bottom);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
|
||||||
|
this.messages = this.messages.concat([{ id: messageSeq++, role: 'assistant', content: message }]);
|
||||||
|
this.scroller.scrollEdge(Edge.Bottom);
|
||||||
|
} finally {
|
||||||
|
this.sending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
MessageView(msg: ChatMessage) {
|
||||||
|
Row() {
|
||||||
|
if (msg.role === 'user') {
|
||||||
|
Blank()
|
||||||
|
Text(msg.content)
|
||||||
|
.fontSize(Math.round(17 * this.scaleFor()))
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.constraintSize({ maxWidth: '72%' })
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(14)
|
||||||
|
} else {
|
||||||
|
Text(msg.content)
|
||||||
|
.fontSize(Math.round(17 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.constraintSize({ maxWidth: '72%' })
|
||||||
|
.padding(14)
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
Blank()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, bottom: 12 })
|
||||||
|
}
|
||||||
|
|
||||||
|
build() {
|
||||||
|
HdsNavigation() {
|
||||||
|
Column() {
|
||||||
|
Scroll(this.scroller) {
|
||||||
|
Column({ space: 6 }) {
|
||||||
|
if (this.messages.length === 0) {
|
||||||
|
Text('您好,我是家属智能助手 🌟\n可以问询您家人的健康与照护情况,例如:\n“我妈今天状态怎么样?”')
|
||||||
|
.fontSize(Math.round(17 * this.scaleFor()))
|
||||||
|
.fontColor($r('app.color.text_secondary'))
|
||||||
|
.textAlign(TextAlign.Center)
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 60 })
|
||||||
|
}
|
||||||
|
ForEach(this.messages, (msg: ChatMessage) => {
|
||||||
|
this.MessageView(msg)
|
||||||
|
}, (msg: ChatMessage) => String(msg.id))
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ top: 92, bottom: 20 })
|
||||||
|
}
|
||||||
|
.scrollBar(BarState.Auto)
|
||||||
|
.edgeEffect(EdgeEffect.Spring)
|
||||||
|
.layoutWeight(1)
|
||||||
|
.align(Alignment.Top)
|
||||||
|
|
||||||
|
Row({ space: 10 }) {
|
||||||
|
TextInput({ placeholder: '输入您的问题...', text: this.inputText })
|
||||||
|
.placeholderColor($r('app.color.inactive_tab'))
|
||||||
|
.fontColor($r('app.color.text_primary'))
|
||||||
|
.fontSize(Math.round(17 * this.scaleFor()))
|
||||||
|
.height(52)
|
||||||
|
.layoutWeight(1)
|
||||||
|
.padding({ left: 16, right: 16 })
|
||||||
|
.backgroundColor($r('app.color.background_card'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.onChange((value: string) => {
|
||||||
|
this.inputText = value;
|
||||||
|
})
|
||||||
|
.onSubmit((enterKey: EnterKeyType) => {
|
||||||
|
this.send();
|
||||||
|
})
|
||||||
|
Button(this.sending ? '…' : '发送')
|
||||||
|
.height(52)
|
||||||
|
.padding({ left: 18, right: 18 })
|
||||||
|
.constraintSize({ minWidth: 80 })
|
||||||
|
.fontSize(Math.round(17 * this.scaleFor()))
|
||||||
|
.fontColor('#FFFFFF')
|
||||||
|
.backgroundColor($r('app.color.accent_blue'))
|
||||||
|
.borderRadius(14)
|
||||||
|
.enabled(!this.sending)
|
||||||
|
.onClick(() => {
|
||||||
|
this.send();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.padding({ left: 16, right: 16, bottom: 20 })
|
||||||
|
}
|
||||||
|
.width('100%')
|
||||||
|
.height('100%')
|
||||||
|
}
|
||||||
|
.mode(NavigationMode.Stack)
|
||||||
|
.titleBar({
|
||||||
|
content: {
|
||||||
|
title: {
|
||||||
|
mainTitle: '智能助手',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
scrollEffectOpts: {
|
||||||
|
enableScrollEffect: false,
|
||||||
|
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
|
||||||
|
},
|
||||||
|
systemMaterialEffect: {
|
||||||
|
materialType: hdsMaterial.MaterialType.ADAPTIVE,
|
||||||
|
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
avoidLayoutSafeArea: false,
|
||||||
|
enableComponentSafeArea: false,
|
||||||
|
})
|
||||||
|
.bindToScrollable([this.scroller])
|
||||||
|
.hideBackButton(false)
|
||||||
|
.titleMode(HdsNavigationTitleMode.MINI)
|
||||||
|
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
|
||||||
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
|
||||||
|
.backgroundColor($r('app.color.background'))
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user