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

- 以 D:\SmartSenior 生产工程为源,纳入 AppScope、entry、cjagent、hvigor 及根配置
- 移除 build-profile.json5 的 signingConfigs(p12/p7b/cer 路径与加密口令)及 products.signingConfig 引用
- 不纳入本地与开发产物:local.properties、.idea、.hvigor、.codegenie、demo、backup_a2a_native、SESSION_LOG.md、build_err.txt、build/、oh_modules
- .gitignore 追加 .codegenie 与密钥文件通配(*.p12/*.p7b/*.cer/*.jks/*.keystore/*.pem)
This commit is contained in:
CAPTAIN1275
2026-09-29 18:44:37 +08:00
parent d7f0e2f8a8
commit baf307e9d5
184 changed files with 31334 additions and 0 deletions
+29
View File
@@ -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
+15
View File
@@ -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"
}
}
+59
View File
@@ -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"
}
]
}
+8
View File
@@ -0,0 +1,8 @@
/node_modules
/oh_modules
/.preview
/build
/.cxx
/.test
**/IDL_Dependencies_List~
**/build-script-cache
+17
View File
@@ -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;
}
View File
+21
View File
@@ -0,0 +1,21 @@
{
"apiType": "stageMode",
"buildOption": {
"cangjieOptions": {
"path": "./cjpm.toml",
"abiFilters": ["arm64-v8a","x86_64"]
},
"nativeLib": {
"filter": {
"enableOverride": true
}
}
},
"buildOptionSet": [
],
"targets": [
{
"name": "default"
}
]
}
+3
View File
@@ -0,0 +1,3 @@
version = 0
[requires]
+37
View File
@@ -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 = { }
+8
View File
@@ -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
+6
View File
@@ -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. */
}
+11
View File
@@ -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": ""
}
+7
View File
@@ -0,0 +1,7 @@
package ohos_app_cangjie_cjagent
import kit.PerformanceAnalysisKit.Hilog
public func sayHello(): Unit {
Hilog.info(0, "Cangjie", "Hello Cangjie!")
}
+294
View File
@@ -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": ""
}
+10
View File
@@ -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.")
}
}
+44
View File
@@ -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 = { }
+14
View File
@@ -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()
}
}
+36
View File
@@ -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()}
+27
View File
@@ -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 @@
{
}
+22
View File
@@ -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]
+14
View File
@@ -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)
}
+17
View File
@@ -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()
}
}
+33
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
/node_modules
/oh_modules
/.preview
/build
/.cxx
/.test
+33
View File
@@ -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",
}
]
}
+6
View File
@@ -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. */
}
+23
View File
@@ -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
+19
View File
@@ -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"
}
}
}
+12
View File
@@ -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');
}
}
+188
View File
@@ -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 '请求失败,请稍后重试';
}
+171
View File
@@ -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];
}
+26
View File
@@ -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 };
}
}
+244
View File
@@ -0,0 +1,244 @@
// 智慧养老(数智护老)客户端主页面
// 结构:HdsNavigation + HdsTabs,五个页签(工作台/预警/业务/更多/我的)
// 业务内容分别在 pages/workbench、pages/alerts、pages/business、pages/profile 中维护
import { curves } from '@kit.ArkUI';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { HdsTabs, HdsTabsAttribute, HdsTabsController, HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { router } from '@kit.ArkUI';
import { Workbench } from './workbench/Workbench';
import { AlertsTab } from './alerts/AlertsTab';
import { BusinessTab } from './business/BusinessTab';
import { ProfileTab } from './profile/ProfileTab';
import { AuthStore } from '../utils/AuthStore';
import { parseRouteTarget } from '../common/RouteTarget';
@Entry
@Component
struct Index {
@State message: string = '数智护老';
@State subTitle: string = 'SmartSenior';
@State currentIndex: number = 0;
private hdsTabsController: HdsTabsController = new HdsTabsController();
aboutToAppear(): void {
// 老人角色使用独立的老人端首页
AuthStore.getState().then((state) => {
if (state.role === 'elderly') {
router.replaceUrl({ url: 'pages/elderly/ElderlyHome' });
}
// 冷启动带目标页(卡片/快捷方式/小艺/通知点击跳转):进首页后再跳目标页
const pending = AppStorage.get<string>('pendingCardTarget') ?? '';
if (pending.length > 0) {
AppStorage.setOrCreate<string>('pendingCardTarget', '');
const rt = parseRouteTarget(pending);
setTimeout(() => {
router.pushUrl({ url: rt.url, params: rt.params }).catch(() => {});
}, 500);
}
});
}
@Builder
TabBarItem(icon: string, text: string, index: number) {
Column({ space: 4 }) {
// 处理"更多"页签的特殊图标
if (index === 3) {
// 使用自定义的SymbolSpan图标
Stack() {
// 未选中状态的图标
Text() {
SymbolSpan($r("sys.symbol.list_bullet"))
}
.fontSize(24)
.fontColor('#888888')
.opacity((this.currentIndex !== index) ? 1 : 0)
.animation({
duration: 200,
curve: curves.springMotion(0.4, 0.8)
})
// 选中状态的图标
Text() {
SymbolSpan($r("sys.symbol.list_bullet"))
}
.fontSize(24)
.fontColor('#007AFF')
.opacity((this.currentIndex === index) ? 1 : 0)
.animation({
duration: 200,
curve: curves.springMotion(0.4, 0.8)
})
}
} else {
// 其他页签保持原有逻辑
Stack() {
// 未选中状态的图标
Text(icon)
.fontSize(24)
.fontColor('#888888')
.opacity((this.currentIndex !== index) ? 1 : 0)
.animation({
duration: 200,
curve: curves.springMotion(0.4, 0.8)
})
// 选中状态的图标
Text(icon)
.fontSize(24)
.fontColor('#007AFF')
.opacity((this.currentIndex === index) ? 1 : 0)
.animation({
duration: 200,
curve: curves.springMotion(0.4, 0.8)
})
}
}
// 文字部分
Stack() {
// 未选中状态的文字
Text(text)
.fontSize(12)
.fontColor('#888888')
.opacity((this.currentIndex !== index) ? 1 : 0)
.animation({
duration: 200,
curve: curves.springMotion(0.4, 0.8)
})
// 选中状态的文字
Text(text)
.fontSize(12)
.fontColor('#007AFF')
.opacity((this.currentIndex === index) ? 1 : 0)
.animation({
duration: 200,
curve: curves.springMotion(0.4, 0.8)
})
}
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
// 直接更新当前选中的索引
this.currentIndex = index;
// 更新HdsTabs控制器
this.hdsTabsController.changeIndex(index);
})
}
@Builder
TabContentBuilder(index: number) {
TabContent() {
Stack() {
if (index === 0) {
Workbench()
}
if (index === 1) {
AlertsTab()
}
if (index === 2) {
BusinessTab()
}
if (index === 3) {
Column() {
Text('更多功能开发中')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
if (index === 4) {
ProfileTab()
}
}
.width('100%')
.height('100%')
// 避让顶部 Hds 标题栏(MINI 模式 + 状态栏)
.padding({ top: 92 })
}
.tabBar(this.TabBarItem(
index === 0 ? '󰁋' :
index === 1 ? '󰑷' :
index === 2 ? '󰆝' :
index === 3 ? '' : '󰗳',
index === 0 ? '工作台' :
index === 1 ? '预警' :
index === 2 ? '业务' :
index === 3 ? '更多' : '我的',
index
))
}
build() {
Column() {
// 主应用界面
Stack() {
HdsNavigation() {
HdsTabs({ controller: this.hdsTabsController }) {
ForEach([0, 1, 2, 3, 4], (index: number) => {
this.TabContentBuilder(index)
})
}
.barOverlap(true)
.barPosition(BarPosition.End)
.vertical(false)
.scrollable(false)
.animationDuration(200)
.onChange((index: number) => {
this.currentIndex = index;
})
.barFloatingStyle({
barWidth: { smallWidth: 300, mediumWidth: 440, largeWidth: 560 },
barBottomMargin: 28,
gradientMask: { maskColor: '#00000000', maskHeight: 92 },
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.IMMERSIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE
}
})
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: this.message,
subTitle: this.subTitle,
},
// 右上角菜单按钮暂时隐藏
// menu: this.menus,
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE
}
}
})
.hideBackButton(true)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.background'))
.alignContent(Alignment.Top)
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.background'))
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
}
}
+348
View File
@@ -0,0 +1,348 @@
// 登录页(ArkTS 严格类型,无 any)—— 账号密码 + 邮箱验证码 + 家属注册
import { router, promptAction, uiMaterial } from '@kit.ArkUI';
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { BusinessError, deviceInfo } from '@kit.BasicServicesKit';
import {
LoginResponse,
PREFS_NAME,
PREFS_AGREED,
ROLE_NAME_MAP,
normalizeError,
} from '../common/ApiConstants';
import { api, RequestError } from '../utils/Request';
import { AuthStore } from '../utils/AuthStore';
import { Constants } from '../common/Constants';
import { PushManager } from '../utils/PushManager';
import { AlertSocket } from '../utils/AlertSocket';
import { requestLocationPermission } from '../utils/PermissionUtil';
import { loadFontScale, resetFontScaleRuntime } from '../utils/FontScale';
import { parseRouteTarget } from '../common/RouteTarget';
interface HomeRoute {
route: string;
name: string;
}
// 登录请求体
interface LoginPayload {
username: string;
password: string;
}
function getHomeRoute(role: string, familyVerified: boolean): HomeRoute {
if (role === 'elderly') {
return { route: 'pages/Index', name: '老人端' };
}
if (role === 'family' && !familyVerified) {
return { route: 'pages/Index', name: '家属端' };
}
return { route: 'pages/Index', name: ROLE_NAME_MAP[role] ?? '工作台' };
}
@Entry
@Component
struct LoginPage {
@State username: string = '';
@State password: string = '';
@State loading: boolean = false;
@State showPassword: boolean = false;
@State showAgreement: boolean = false;
@StorageProp('needPrivacyCheck') @Watch('onNeedPrivacyChanged') needPrivacyCheck: boolean = false;
// 应用初始化(EntryAbility)判定未同意 → 登录页一渲染就弹
onNeedPrivacyChanged(): void {
if (this.needPrivacyCheck) {
this.showAgreement = true;
}
}
async aboutToAppear(): Promise<void> {
// 兜底:AppStorage 标志未就绪时直接读偏好,确保首次启动必弹
const context = getContext(this) as common.UIAbilityContext;
const prefs: preferences.Preferences = await preferences.getPreferences(context, PREFS_NAME);
const agreed = await prefs.get(PREFS_AGREED, 0);
if (agreed !== 1) {
this.showAgreement = true;
}
// 登录持久化:已登录用户直接进入主页
const loggedIn = await AuthStore.isLoggedIn();
if (loggedIn) {
router.replaceUrl({ url: 'pages/Index' });
}
}
private async agree(): Promise<void> {
const context = getContext(this) as common.UIAbilityContext;
const prefs: preferences.Preferences = await preferences.getPreferences(context, PREFS_NAME);
await prefs.put(PREFS_AGREED, 1);
await prefs.flush();
AppStorage.setOrCreate<boolean>('needPrivacyCheck', false);
this.showAgreement = false;
// 隐私政策同意后一次性完成授权:先通知,再定位(系统授权弹窗依次排队)
try {
await PushManager.init(context);
} catch (err) {
console.error('[Login] PushManager init failed:', normalizeError(err as BusinessError | Error));
}
await requestLocationPermission(context);
}
private async handleLogin(): Promise<void> {
if (this.username.trim().length === 0) {
promptAction.showToast({ message: '请输入账号' });
return;
}
if (this.password.length === 0) {
promptAction.showToast({ message: '请输入密码' });
return;
}
if (this.loading) {
return;
}
this.loading = true;
try {
const payload: LoginPayload = {
username: this.username.trim(),
password: this.password,
};
const res = await api.post<LoginResponse>('/auth/login/', payload, { anonymous: true });
await AuthStore.setAuth({
token: res.access,
refreshToken: res.refresh,
role: res.user.role,
userId: res.user.id,
userName: res.user.name,
familyVerified: res.user.family_verified,
});
// Push Kit:把推送令牌绑定到当前登录用户
PushManager.uploadTokenAfterLogin().catch(() => {});
// 字体设置:老人/家属登录恢复持久化档位,其他角色强制跟随系统
if (res.user.role === 'elderly' || res.user.role === 'family') {
loadFontScale(getContext(this) as common.UIAbilityContext).catch(() => {});
} else {
resetFontScaleRuntime();
}
// 预警实时推送:建立 WebSocket 连接
AlertSocket.connect().catch(() => {});
const home = getHomeRoute(res.user.role, res.user.family_verified);
router.replaceUrl({ url: home.route });
// 冷启动带目标页(卡片/快捷方式/小艺/通知点击跳转):进入首页后再跳目标页
const pending = AppStorage.get<string>('pendingCardTarget') ?? '';
if (pending.length > 0) {
AppStorage.setOrCreate<string>('pendingCardTarget', '');
const rt = parseRouteTarget(pending);
setTimeout(() => {
router.pushUrl({ url: rt.url, params: rt.params }).catch(() => {});
}, 500);
}
} catch (err) {
// 后端不可用时,写死测试账号作为离线兜底(正常联调不会走到这里)
const username = this.username.trim();
if (username === '0001' && this.password === '0001') {
await AuthStore.setAuth({
token: 'local-0001',
refreshToken: '',
role: 'admin',
userId: 1,
userName: '测试用户',
familyVerified: false,
});
router.replaceUrl({ url: 'pages/Index' });
return;
}
if (username === '0002' && this.password === '0002') {
await AuthStore.setAuth({
token: 'local-0002',
refreshToken: '',
role: 'elderly',
userId: 2,
userName: '测试老人',
familyVerified: false,
});
router.replaceUrl({ url: 'pages/Index' });
return;
}
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
// 隐私政策弹窗的沉浸式系统材质:uiMaterial 从 API 26 起才提供,
// 低版本设备上该命名空间不存在,直接访问 uiMaterial.ImmersiveStyle 会抛 TypeError 导致应用退出
private buildAgreementMaterial(): SystemUiMaterial | undefined {
if (deviceInfo.sdkApiVersion < 26) {
return undefined;
}
try {
return new uiMaterial.ImmersiveMaterial({
style: uiMaterial.ImmersiveStyle.ULTRA_THIN,
});
} catch (err) {
console.warn('[Login] immersive material unavailable:', normalizeError(err as BusinessError | Error));
return undefined;
}
}
build() {
Stack() {
// 背景(延伸到状态栏与导航栏,沉浸式)
Column()
.width('100%')
.height('100%')
.backgroundColor($r('app.color.background'))
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
Column({ space: 20 }) {
Blank()
.height(56) // 状态栏安全间距
// Logo
Image($r('app.media.logo1'))
.width(88)
.height(88)
.borderRadius(20)
.shadow({ radius: 10, color: '#33007AFF', offsetX: 0, offsetY: 5 })
Text('数智护老')
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text('请输入账号信息以继续使用智慧照护端')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
// 账号输入
Column({ space: 14 }) {
TextInput({ placeholder: '请输入账号 / 手机号 / 邮箱', text: this.username })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.height(50)
.padding({ left: 16, right: 16 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.onChange((value: string) => {
this.username = value;
})
// 密码输入
Row() {
TextInput({ placeholder: '请输入登录密码', text: this.password })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.type(InputType.Password)
.height(50)
.layoutWeight(1)
.padding({ left: 16, right: 16 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.onChange((value: string) => {
this.password = value;
})
}
.width('100%')
// 登录按钮
Button(this.loading ? '登录中...' : '登录')
.width('100%')
.height(50)
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
.enabled(!this.loading)
.onClick(() => {
this.handleLogin();
})
}
.width('100%')
.padding({ left: 24, right: 24 })
}
.width('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.padding({ left: 24, right: 24, bottom: 60 })
}
.width('100%')
.height('100%')
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
// 隐私政策弹窗(与 Index 一致的透明沉浸式样式,应用打开时弹出)
.bindSheet($$this.showAgreement, this.AgreementSheetBuilder(), {
preferType: SheetType.CENTER,
detents: [SheetSize.MEDIUM],
dragBar: true,
showClose: false,
title: { title: '用户协议与隐私政策' },
backgroundColor: Color.Transparent,
systemMaterial: this.buildAgreementMaterial(),
onWillDismiss: (action: DismissSheetAction) => {
// 未同意前禁止下拉/点遮罩关闭
if (this.showAgreement) {
promptAction.showToast({ message: '请先阅读并同意隐私政策' });
}
},
})
}
// 隐私政策半模态内容
@Builder
AgreementSheetBuilder() {
Column() {
Scroll() {
Column() {
Text('欢迎使用前请仔细阅读以下政策全文:')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.margin({ bottom: 12 })
ForEach(Constants.PRIVACY_DETAIL, (item: string) => {
Text(item)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.textAlign(TextAlign.Start)
.margin({ bottom: 8 })
}, (item: string) => item.substring(0, 12))
}
.width('100%')
.padding({ left: 24, right: 24 })
}
.layoutWeight(1)
.scrollBar(BarState.Auto)
.align(Alignment.Top)
Row({ space: 12 }) {
Button('不同意并退出')
.layoutWeight(1)
.height(44)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_red'))
.borderRadius(10)
.onClick(() => {
this.handleDisagree();
})
Button('同意并继续')
.layoutWeight(1)
.height(44)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(10)
.onClick(() => {
this.agree();
})
}
.width('100%')
.padding({ left: 24, right: 24, top: 8, bottom: 24 })
}
.width('100%')
.height('100%')
}
private async handleDisagree(): Promise<void> {
this.showAgreement = false;
// 按原交互:不同意则退出应用
const context = getContext(this) as common.UIAbilityContext;
context.terminateSelf();
}
}
@@ -0,0 +1,323 @@
// 管理员 · 意见反馈工单管理(ArkTS 严格类型,无 any)
// 接口:GET /feedback/records/、PATCH /feedback/records/{id}/
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { Page, normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface FeedbackItem {
id: number;
content: string;
contact: string;
status: string;
status_label: string;
user_name: string;
user_alias: string;
user_role: string;
created_at: string;
resolved_at: string;
}
function roleLabel(role: string): string {
if (role === 'family') {
return '家属';
}
if (role === 'elderly') {
return '长者';
}
if (role === 'nurse') {
return '护理员';
}
if (role === 'staff_nurse') {
return '护士';
}
if (role === 'admin') {
return '管理员';
}
return role || '--';
}
function formatTime(iso: string): string {
if (!iso || iso.length === 0) {
return '';
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return iso;
}
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${mm}/${dd} ${hh}:${mi}`;
}
@Entry
@Component
struct AdminFeedback {
@State items: Array<FeedbackItem> = [];
@State loading: boolean = true;
@State page: number = 1;
@State count: number = 0;
@State statusFilter: string = 'all';
@State processingId: number = 0;
private pageSize: number = 10;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.reload();
}
private reload(): void {
this.page = 1;
this.items = [];
this.fetch(true);
}
private async fetch(isReset: boolean): Promise<void> {
this.loading = true;
try {
let res: Page<FeedbackItem>;
if (this.statusFilter !== 'all') {
res = await api.get<Page<FeedbackItem>>('/feedback/records/', {
page: this.page,
page_size: this.pageSize,
status: this.statusFilter,
});
} else {
res = await api.get<Page<FeedbackItem>>('/feedback/records/', {
page: this.page,
page_size: this.pageSize,
});
}
this.count = res.count;
if (isReset) {
this.items = res.results;
} else {
this.items = this.items.concat(res.results);
}
if (res.results.length > 0) {
this.page += 1;
}
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
private switchStatus(mode: string): void {
if (this.statusFilter === mode) {
return;
}
this.statusFilter = mode;
this.reload();
}
private async toggleStatus(item: FeedbackItem): Promise<void> {
if (this.processingId === item.id) {
return;
}
this.processingId = item.id;
try {
const next = item.status === 'resolved' ? 'pending' : 'resolved';
const updated = await api.patch<FeedbackItem>(`/feedback/records/${item.id}/`, { status: next });
this.items = this.items.map((it: FeedbackItem): FeedbackItem => {
if (it.id === item.id) {
return {
id: it.id,
content: it.content,
contact: it.contact,
status: updated.status,
status_label: updated.status === 'resolved' ? '已处理' : '待处理',
user_name: it.user_name,
user_alias: it.user_alias,
user_role: it.user_role,
created_at: it.created_at,
resolved_at: it.resolved_at,
};
}
return it;
});
promptAction.showToast({ message: updated.status === 'resolved' ? '已标记为已处理' : '已设为待处理' });
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.processingId = 0;
}
}
@Builder
ItemCard(item: FeedbackItem) {
Column({ space: 8 }) {
Row({ space: 8 }) {
Text(item.user_name || item.user_alias || '用户')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(roleLabel(item.user_role))
.fontSize(11)
.fontColor('#007AFF')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor('#1A007AFF')
.borderRadius(6)
Blank()
Text(item.status === 'resolved' ? '已处理' : '待处理')
.fontSize(12)
.fontColor(item.status === 'resolved' ? $r('app.color.accent_green') : '#FF9500')
if (item.status !== 'resolved') {
Text('处理')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('#007AFF')
.borderRadius(8)
.onClick(() => {
this.toggleStatus(item);
})
}
}
.width('100%')
.alignItems(VerticalAlign.Center)
Text(item.content)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.width('100%')
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
Text(formatTime(item.created_at))
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
if (item.contact && item.contact.length > 0) {
Text(`联系方式:${item.contact}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Column() {
Text('正在加载')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding({ top: 60 })
} else if (this.items.length === 0) {
Column({ space: 8 }) {
Text('暂无反馈')
.fontSize(16)
.fontColor($r('app.color.text_primary'))
Text('还没有意见反馈工单')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding({ top: 160 })
.alignItems(HorizontalAlign.Center)
} else {
Scroll(this.scroller) {
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('全部')
.fontSize(14)
.fontColor(this.statusFilter === 'all' ? '#FFFFFF' : $r('app.color.text_secondary'))
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(this.statusFilter === 'all' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.switchStatus('all');
})
Text('待处理')
.fontSize(14)
.fontColor(this.statusFilter === 'pending' ? '#FFFFFF' : $r('app.color.text_secondary'))
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(this.statusFilter === 'pending' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.switchStatus('pending');
})
Text('已处理')
.fontSize(14)
.fontColor(this.statusFilter === 'resolved' ? '#FFFFFF' : $r('app.color.text_secondary'))
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(this.statusFilter === 'resolved' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.switchStatus('resolved');
})
Blank()
Text(`共 ${this.count} 条`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.alignItems(VerticalAlign.Center)
ForEach(this.items, (item: FeedbackItem) => {
this.ItemCard(item)
}, (item: FeedbackItem) => String(item.id))
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.onReachEnd(() => {
if (!this.loading && this.items.length < this.count) {
this.fetch(false);
}
})
}
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '意见反馈',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(true)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,334 @@
// 管理员 · 护理员详情(ArkTS 严格类型,无 any)
// 接口:GET /users/{id}/、GET /stats/supervisor/nurse-detail/{id}/
import { router, promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface NurseProfile {
id: number;
name: string;
phone: string;
group: string;
role: string;
duty_status: string;
account_status: string;
date_joined: string;
}
interface Summary {
record_count: number;
elderly_served: number;
}
interface WindowProgress {
status: string; // completed / current / upcoming / overdue
}
interface ServiceItem {
service_name: string;
category_name: string;
status: string;
remaining: number;
window_progress?: Array<WindowProgress>;
}
interface ServiceProgress {
elderly_id: number;
elderly_name: string;
room: string;
bed: string;
items: Array<ServiceItem>;
}
interface NurseDetail {
date: string;
nurse_id: number;
nurse_name: string;
duty_status: string;
group: string;
summary: Summary;
overall_status: string;
overall_completion_rate: number;
service_progress: Array<ServiceProgress>;
}
function statusLabel(item: ServiceItem): string {
if (item.status === 'completed') {
return '完成';
}
if (item.status === 'partial') {
return '部分';
}
if (item.status === 'missed') {
// 后端把"完成数=0"都标 missed,需按时间窗区分:
// 还有进行中/未到的窗口 → 未开始;全部窗口已过 → 遗漏
const wp: Array<WindowProgress> = item.window_progress ?? [];
const hasOpen = wp.some((w: WindowProgress): boolean => w.status === 'current' || w.status === 'upcoming');
return hasOpen ? '未开始' : '遗漏';
}
return '遗漏';
}
function statusColor(item: ServiceItem): string {
if (item.status === 'completed') {
return '#34C759';
}
if (item.status === 'partial') {
return '#FF9500';
}
if (item.status === 'missed') {
const wp: Array<WindowProgress> = item.window_progress ?? [];
const hasOpen = wp.some((w: WindowProgress): boolean => w.status === 'current' || w.status === 'upcoming');
return hasOpen ? '#8E8E93' : '#FF3B30';
}
return '#FF3B30';
}
function dutyLabel(status: string): string {
if (status === 'on_duty') {
return '在岗';
}
if (status === 'rest') {
return '休息';
}
if (status === 'offboard') {
return '离职';
}
return '--';
}
function percentText(rate: number): string {
if (rate === undefined || rate === null) {
return '--';
}
return `${Math.round(rate * 100)}%`;
}
@Entry
@Component
struct AdminNurseDetail {
@State nurseId: number = 0;
@State profile: NurseProfile | null = null;
@State detail: NurseDetail | null = null;
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
const params = router.getParams() as Record<string, string | number>;
if (params) {
const id = params['id'];
if (id !== undefined) {
this.nurseId = Number(id);
}
}
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const results: Array<NurseProfile | NurseDetail | null> = await Promise.all([
api.get<NurseProfile>(`/auth/users/${this.nurseId}/`).catch((): NurseProfile | null => null),
api.get<NurseDetail>(`/stats/supervisor/nurse-detail/${this.nurseId}/`).catch((): NurseDetail | null => null),
]);
this.profile = results[0] as NurseProfile | null;
this.detail = results[1] as NurseDetail | null;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
InfoRow(label: string, value: string) {
Row({ space: 12 }) {
Text(label)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(72)
Text(value)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
}
.width('100%')
}
@Builder
StatTile(value: string, label: string, color: string) {
Column({ space: 4 }) {
Text(value)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(color)
Text(label)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
@Builder
ElderlyProgress(p: ServiceProgress) {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(p.elderly_name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
if (p.room && p.room.length > 0) {
Text(`${p.room}${p.bed ? '-' + p.bed : ''}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
Blank()
Text(`${p.items.length} 项服务`)
.fontSize(12)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
ForEach(p.items, (item: ServiceItem) => {
Row({ space: 8 }) {
Text(item.service_name)
.fontSize(13)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(statusLabel(item))
.fontSize(12)
.fontColor(statusColor(item))
}
.width('100%')
}, (item: ServiceItem) => `${p.elderly_id}-${item.service_name}`)
}
.width('100%')
.padding(12)
.backgroundColor($r('app.color.background'))
.borderRadius(10)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.profile) {
Column({ space: 12 }) {
this.InfoRow('姓名', this.profile.name || '--')
this.InfoRow('手机', this.profile.phone || '--')
this.InfoRow('所属组', this.profile.group || '--')
this.InfoRow('值班状态', dutyLabel(this.profile.duty_status))
this.InfoRow('账号状态', this.profile.account_status || '--')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
}
if (this.detail) {
// 今日统计
Column({ space: 12 }) {
Row() {
Text('今日工作')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(`完成率 ${percentText(this.detail.overall_completion_rate)}`)
.fontSize(13)
.fontColor($r('app.color.accent_green'))
}
.width('100%')
Row() {
this.StatTile(String(this.detail.summary.record_count), '完成记录', '#007AFF')
this.StatTile(String(this.detail.summary.elderly_served), '服务长者', '#34C759')
this.StatTile(percentText(this.detail.overall_completion_rate), '整体完成率', '#007AFF')
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
// 服务进度
if (this.detail.service_progress.length > 0) {
Column({ space: 8 }) {
Text('今日服务进度')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
ForEach(this.detail.service_progress, (p: ServiceProgress) => {
Column() {
this.ElderlyProgress(p)
}
.width('100%')
}, (p: ServiceProgress) => String(p.elderly_id))
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
}
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 24 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '护理员详情',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,190 @@
// 管理员 · 护理员列表(ArkTS 严格类型,无 any)
// 接口:GET /users/?role=nurse
import { router, promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface NurseUser {
id: number;
username: string;
name: string;
phone: string;
group: string;
duty_status: string;
account_status: string;
}
function dutyLabel(status: string): string {
if (status === 'on_duty') {
return '在岗';
}
if (status === 'rest') {
return '休息';
}
if (status === 'offboard') {
return '离职';
}
return '--';
}
function dutyColor(status: string): string {
if (status === 'on_duty') {
return '#34C759';
}
if (status === 'rest') {
return '#FF9500';
}
return '#8E8E93';
}
@Entry
@Component
struct AdminNurseList {
@State nurses: Array<NurseUser> = [];
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const res = await api.get<Array<NurseUser>>('/auth/users/', { role: 'nurse', page_size: 50 });
this.nurses = res;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
private openDetail(nurse: NurseUser): void {
router.pushUrl({
url: 'pages/admin/AdminNurseDetail',
params: { id: nurse.id, name: nurse.name },
}).catch((err: BusinessError) => {
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
});
}
@Builder
NurseCard(nurse: NurseUser) {
Row({ space: 12 }) {
Column() {
Text(nurse.name.substring(0, 1))
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(48)
.height(48)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(nurse.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(dutyLabel(nurse.duty_status))
.fontSize(11)
.fontColor(dutyColor(nurse.duty_status))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor('#14000000')
.borderRadius(6)
}
.width('100%')
Text(`${nurse.phone || '--'}${nurse.group ? ' · ' + nurse.group : ''}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('›')
.fontSize(20)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.openDetail(nurse);
})
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else if (this.nurses.length === 0) {
Text('暂无护理员')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.margin({ top: 160 })
} else {
Scroll(this.scroller) {
Column({ space: 10 }) {
ForEach(this.nurses, (nurse: NurseUser) => {
Column() {
this.NurseCard(nurse)
}
.width('100%')
}, (nurse: NurseUser) => String(nurse.id))
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '护理员',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,346 @@
// 管理员 · 护理记录列表(ArkTS 严格类型,无 any)
// 接口:GET /tasks/nursing-records/,支持 全部/今日 筛选 + 滚动分页
import { router, promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { Page, normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface VitalPart {
text?: string;
numeric?: string;
trend?: string;
systolic?: string;
diastolic?: string;
}
interface TextPart {
text?: string;
trend?: string;
}
interface ExtractedJson {
temperature?: VitalPart;
blood_pressure?: VitalPart;
weight?: VitalPart;
medication?: TextPart;
diet?: TextPart;
mood?: TextPart;
}
interface NursingRecordItem {
id: number;
elderly: number | null;
nurse: number | null;
elderly_name: string;
nurse_name: string;
images: Array<string>;
raw_text: string;
extracted_json: object;
work_item: string;
created_at: string;
}
function todayYMD(): string {
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
return `${now.getFullYear()}-${mm}-${dd}`;
}
function formatRecordTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return iso;
}
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${mm}/${dd} ${hh}:${mi}`;
}
function recordSummary(record: NursingRecordItem): string {
const e = record.extracted_json as ExtractedJson;
const parts: Array<string> = [];
if (e.temperature && e.temperature.text && e.temperature.text.length > 0) {
parts.push(`体温 ${e.temperature.text}`);
}
if (e.blood_pressure && e.blood_pressure.text && e.blood_pressure.text.length > 0) {
parts.push(`血压 ${e.blood_pressure.text}`);
}
if (e.weight && e.weight.text && e.weight.text.length > 0) {
parts.push(`体重 ${e.weight.text}`);
}
if (e.medication && e.medication.text && e.medication.text.length > 0) {
parts.push(`服药 ${e.medication.text}`);
}
if (e.diet && e.diet.text && e.diet.text.length > 0) {
parts.push(`进食 ${e.diet.text}`);
}
if (e.mood && e.mood.text && e.mood.text.length > 0) {
parts.push(`心理 ${e.mood.text}`);
}
return parts.join(' · ');
}
@Entry
@Component
struct AdminNursingRecords {
@State records: Array<NursingRecordItem> = [];
@State loading: boolean = true;
@State loadingMore: boolean = false;
@State page: number = 1;
@State count: number = 0;
@State dateFilter: string = 'all';
private pageSize: number = 10;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.reload();
}
private reload(): void {
this.page = 1;
this.records = [];
this.fetch(true);
}
private async fetch(isReset: boolean): Promise<void> {
if (isReset) {
this.loading = true;
} else {
this.loadingMore = true;
}
try {
let res: Page<NursingRecordItem>;
if (this.dateFilter === 'today') {
res = await api.get<Page<NursingRecordItem>>('/tasks/nursing-records/', {
page: this.page,
page_size: this.pageSize,
ordering: '-created_at',
date: todayYMD(),
});
} else {
res = await api.get<Page<NursingRecordItem>>('/tasks/nursing-records/', {
page: this.page,
page_size: this.pageSize,
ordering: '-created_at',
});
}
this.count = res.count;
if (isReset) {
this.records = res.results;
} else {
this.records = this.records.concat(res.results);
}
if (res.results.length > 0) {
this.page += 1;
}
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
this.loadingMore = false;
}
}
private switchFilter(mode: string): void {
if (this.dateFilter === mode) {
return;
}
this.dateFilter = mode;
this.reload();
}
private openDetail(record: NursingRecordItem): void {
router.pushUrl({
url: 'pages/admin/AdminRecordDetail',
params: { id: record.id },
}).catch((err: BusinessError) => {
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
});
}
@Builder
RecordCard(record: NursingRecordItem) {
Row({ space: 12 }) {
Column() {
Text(record.elderly_name.length > 0 ? record.elderly_name.substring(0, 1) : '护')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(48)
.height(48)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
Column({ space: 5 }) {
Row({ space: 8 }) {
Text(record.elderly_name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(formatRecordTime(record.created_at))
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
if (record.raw_text && record.raw_text.length > 0) {
Text(record.raw_text)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
if (recordSummary(record).length > 0) {
Text(recordSummary(record))
.fontSize(12)
.fontColor($r('app.color.accent_blue'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
Row({ space: 8 }) {
Text(record.nurse_name || '--')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
if (record.images.length > 0) {
Text(`图片 ${record.images.length}`)
.fontSize(12)
.fontColor($r('app.color.accent_green'))
}
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.openDetail(record);
})
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Column() {
Text('正在加载')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding({ top: 60 })
} else if (this.records.length === 0) {
Column({ space: 8 }) {
Text('暂无护理记录')
.fontSize(16)
.fontColor($r('app.color.text_primary'))
Text(this.dateFilter === 'today' ? '今日还没有护理记录' : '还没有护理记录')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding({ top: 160 })
.alignItems(HorizontalAlign.Center)
} else {
Scroll(this.scroller) {
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('全部')
.fontSize(14)
.fontColor(this.dateFilter === 'all' ? '#FFFFFF' : $r('app.color.text_secondary'))
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(this.dateFilter === 'all' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.switchFilter('all');
})
Text('今日')
.fontSize(14)
.fontColor(this.dateFilter === 'today' ? '#FFFFFF' : $r('app.color.text_secondary'))
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(this.dateFilter === 'today' ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.switchFilter('today');
})
Blank()
Text(`共 ${this.count} 条`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.alignItems(VerticalAlign.Center)
ForEach(this.records, (record: NursingRecordItem) => {
this.RecordCard(record)
}, (record: NursingRecordItem) => String(record.id))
if (this.loadingMore) {
Text('加载中...')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.padding(12)
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.onReachEnd(() => {
if (!this.loading && !this.loadingMore && this.records.length < this.count) {
this.fetch(false);
}
})
}
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '护理记录',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(true)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,603 @@
// 管理员 · 信息概览(ArkTS 严格类型,无 any)
// 接口:GET /dashboard/summary/
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface HealthDistribution {
total: number;
good: number;
attention: number;
bedridden: number;
good_percent: string;
attention_percent: string;
bedridden_percent: string;
}
interface UnresolvedAlert {
id: number;
title: string;
description: string;
level: string;
elderly_name: string;
created_at: string;
}
interface RecentRecord {
id: number;
elderly_name: string;
nurse_name: string;
raw_text: string;
created_at: string;
}
interface DailyTrend {
date: string;
count: number;
}
interface MealSummary {
category: string;
reserved_count: number;
menu_items: Array<string>;
}
interface MealsOverview {
total_reserved_count: number;
meals: Array<MealSummary>;
}
interface CareOverview {
on_duty_count: number;
today_record_count: number;
daily_trend: Array<DailyTrend>;
}
interface OperationOverview {
care: CareOverview;
meals: MealsOverview;
}
interface DashboardSummary {
summary_date: string;
overview_elderly: number;
focused_elderly: number;
on_duty_nurses: number;
active_accounts: number;
today_alert_count: number;
pending_alert_count: number;
today_record_count: number;
health_distribution: HealthDistribution;
unresolved_alerts: Array<UnresolvedAlert>;
recent_records: Array<RecentRecord>;
operation_overview: OperationOverview;
}
function levelColor(level: string): string {
if (level === 'emergency') {
return '#FF3B30';
}
if (level === 'warning') {
return '#FF9500';
}
return '#007AFF';
}
function levelLabel(level: string): string {
if (level === 'emergency') {
return '紧急';
}
if (level === 'warning') {
return '注意';
}
return '正常';
}
function mealLabel(category: string): string {
if (category === 'breakfast') {
return '早餐';
}
if (category === 'lunch') {
return '午餐';
}
return '晚餐';
}
function formatDateTime(iso: string): string {
if (!iso || iso.length === 0) {
return '--';
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return '--';
}
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${mm}/${dd} ${hh}:${mi}`;
}
@Entry
@Component
struct AdminOverview {
@State data: DashboardSummary | null = null;
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const res = await api.get<DashboardSummary>('/dashboard/summary/');
this.data = res;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
StatTile(value: number, unit: string, label: string, color: string) {
Column({ space: 4 }) {
Row({ space: 2 }) {
Text(String(value))
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor(color)
Text(unit)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.margin({ top: 6 })
}
Text(label)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
@Builder
BarRow(label: string, count: number, percent: string, color: string) {
Row({ space: 10 }) {
Text(label)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.width(56)
Progress({ value: count, total: Math.max(this.totalSafe(), 1) })
.color(color)
.style({ strokeWidth: 8 })
.layoutWeight(1)
Text(percent)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width(44)
.textAlign(TextAlign.End)
}
.width('100%')
}
private totalSafe(): number {
const d = this.data;
if (d !== null && d.health_distribution !== null) {
return d.health_distribution.total;
}
return 1;
}
@Builder
AlertCard(alert: UnresolvedAlert) {
Row({ space: 8 }) {
Text(levelLabel(alert.level))
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(levelColor(alert.level))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(alert.level === 'emergency' ? '#14FF3B30' : (alert.level === 'warning' ? '#14FF9500' : '#14007AFF'))
.borderRadius(4)
Column({ space: 4 }) {
Text(alert.title)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
Text(`${alert.elderly_name} · ${formatDateTime(alert.created_at)}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
}
@Builder
RecordCard(record: RecentRecord) {
Column({ space: 4 }) {
Row() {
Text(record.elderly_name)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Blank()
Text(formatDateTime(record.created_at))
.fontSize(11)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
Text(record.raw_text)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
private healthValues(): Array<number> {
const d = this.data;
if (d !== null && d.health_distribution !== null) {
return [d.health_distribution.good, d.health_distribution.attention, d.health_distribution.bedridden];
}
return [0, 0, 0];
}
private trendMax(): number {
const d = this.data;
if (d !== null && d.operation_overview !== null && d.operation_overview.care !== null) {
let max = 1;
d.operation_overview.care.daily_trend.forEach((t: DailyTrend) => {
if (t.count > max) {
max = t.count;
}
});
return max;
}
return 1;
}
private barHeight(count: number): number {
return Math.round(60 * count / this.trendMax());
}
private shortDate(dateStr: string): string {
if (!dateStr || dateStr.length < 5) {
return dateStr;
}
return dateStr.substring(5);
}
@Builder
HealthRingCard() {
Column({ space: 12 }) {
Row() {
Text('健康分布')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(`${this.data?.health_distribution.total ?? 0} 人`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
Stack() {
DataPanel({
values: this.healthValues(),
max: Math.max(this.data?.health_distribution.total ?? 1, 1),
type: DataPanelType.Circle,
})
.width(168)
.height(168)
.strokeWidth(24)
.valueColors(['#34C759', '#FF9500', '#FF3B30'])
Column({ space: 2 }) {
Text(String(this.data?.health_distribution.total ?? 0))
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text('在院对象')
.fontSize(11)
.fontColor($r('app.color.text_secondary'))
}
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.height(180)
.alignContent(Alignment.Center)
Row({ space: 8 }) {
Circle().width(10).height(10).fill('#34C759')
Text('良好')
.fontSize(13)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
Text(`${this.data?.health_distribution.good ?? 0} 人 · ${this.data?.health_distribution.good_percent ?? '0%'}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
Row({ space: 8 }) {
Circle().width(10).height(10).fill('#FF9500')
Text('需关注')
.fontSize(13)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
Text(`${this.data?.health_distribution.attention ?? 0} 人 · ${this.data?.health_distribution.attention_percent ?? '0%'}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
Row({ space: 8 }) {
Circle().width(10).height(10).fill('#FF3B30')
Text('卧床')
.fontSize(13)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
Text(`${this.data?.health_distribution.bedridden ?? 0} 人 · ${this.data?.health_distribution.bedridden_percent ?? '0%'}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
@Builder
TrendChartCard() {
Column({ space: 10 }) {
Row() {
Text('近7日护理记录')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text('每日条数')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
Row({ space: 8 }) {
ForEach(this.data?.operation_overview.care.daily_trend ?? [], (t: DailyTrend) => {
Column({ space: 4 }) {
Text(String(t.count))
.fontSize(9)
.fontColor($r('app.color.text_secondary'))
Column()
.width(22)
.height(this.barHeight(t.count))
.backgroundColor('#007AFF')
.borderRadius(4)
Text(this.shortDate(t.date))
.fontSize(9)
.fontColor($r('app.color.text_secondary'))
}
.justifyContent(FlexAlign.End)
}, (t: DailyTrend) => t.date)
}
.width('100%')
.height(120)
.alignItems(VerticalAlign.Bottom)
.justifyContent(FlexAlign.SpaceAround)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else if (this.data) {
// 顶部统计
Row() {
this.StatTile(this.data.overview_elderly, '位', '在院对象', '#007AFF')
this.StatTile(this.data.on_duty_nurses, '名', '在岗护理', '#34C759')
this.StatTile(this.data.today_record_count, '条', '今日记录', '#FF9500')
}
.width('100%')
.height(96)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.padding(8)
Row() {
this.StatTile(this.data.today_alert_count, '条', '今日预警', '#FF3B30')
this.StatTile(this.data.pending_alert_count, '条', '待处理', '#FF3B30')
this.StatTile(this.data.focused_elderly, '位', '重点关注', '#AF52DE')
}
.width('100%')
.height(96)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.padding(8)
// 健康分布(环形图)
this.HealthRingCard()
// 运营概览 · 护理
Column({ space: 10 }) {
Text('运营概览')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Row({ space: 12 }) {
Text(`在岗护理 ${this.data.operation_overview.care.on_duty_count} 名`)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
Text(`今日记录 ${this.data.operation_overview.care.today_record_count} 条`)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 近7日护理记录趋势
this.TrendChartCard()
// 订餐
Column({ space: 10 }) {
Text(`今日订餐 ${this.data.operation_overview.meals.total_reserved_count} 份`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
ForEach(this.data.operation_overview.meals.meals, (m: MealSummary) => {
Row({ space: 8 }) {
Text(mealLabel(m.category))
.fontSize(14)
.fontColor($r('app.color.accent_blue'))
Text(`${m.reserved_count} 份`)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
Text(m.menu_items.join('、'))
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
}, (m: MealSummary) => m.category)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 未处理预警
Column({ space: 8 }) {
Text(`未处理预警 (${this.data.unresolved_alerts.length})`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.data.unresolved_alerts.length === 0) {
Text('暂无未处理预警')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
} else {
ForEach(this.data.unresolved_alerts, (alert: UnresolvedAlert) => {
Column() {
this.AlertCard(alert)
}
.width('100%')
}, (alert: UnresolvedAlert) => String(alert.id))
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 最近护理记录
Column({ space: 8 }) {
Text('最近护理记录')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.data.recent_records.length === 0) {
Text('暂无护理记录')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
} else {
ForEach(this.data.recent_records, (record: RecentRecord) => {
Column() {
this.RecordCard(record)
}
.width('100%')
}, (record: RecentRecord) => String(record.id))
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 10 })
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '信息概览',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,213 @@
// 管理员 · 智能查询(ArkTS 严格类型,无 any)
// 接口:POST /tasks/admin-query/ {query} → {answer, summaries, matched_record}
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface ChatMessage {
id: number;
role: string;
content: string;
}
interface SummaryItem {
label: string;
value: string;
}
interface AdminQueryResult {
answer: string;
summaries: Array<SummaryItem>;
}
interface QueryPayload {
query: string;
}
let messageSeq = 1;
@Entry
@Component
struct AdminQuery {
@State inputText: string = '';
@State messages: Array<ChatMessage> = [];
@State summaries: Array<SummaryItem> = [];
@State sending: boolean = false;
private scroller: Scroller = new Scroller();
private async send(): Promise<void> {
const text = this.inputText.trim();
if (text.length === 0 || this.sending) {
return;
}
const userMsg: ChatMessage = { id: messageSeq++, role: 'user', content: text };
this.messages = this.messages.concat([userMsg]);
this.inputText = '';
this.sending = true;
setTimeout(() => {
this.scroller.scrollEdge(Edge.Bottom);
}, 50);
try {
const payload: QueryPayload = { query: text };
const res = await api.post<AdminQueryResult>('/tasks/admin-query/', payload);
this.summaries = res.summaries;
const answer = res.answer && res.answer.length > 0 ? res.answer : '未查询到相关信息。';
this.messages = this.messages.concat([{ id: messageSeq++, role: 'assistant', content: answer }]);
this.scroller.scrollEdge(Edge.Bottom);
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
this.messages = this.messages.concat([{ id: messageSeq++, role: 'assistant', content: message }]);
this.scroller.scrollEdge(Edge.Bottom);
} finally {
this.sending = false;
}
}
@Builder
SummaryRow(item: SummaryItem) {
Row({ space: 10 }) {
Text(item.label)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(60)
Text(item.value)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
}
.width('100%')
}
@Builder
MessageView(msg: ChatMessage) {
Row() {
if (msg.role === 'user') {
Blank()
Text(msg.content)
.fontSize(16)
.fontColor('#FFFFFF')
.constraintSize({ maxWidth: '72%' })
.padding(14)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
} else {
Text(msg.content)
.fontSize(16)
.fontColor($r('app.color.text_primary'))
.constraintSize({ maxWidth: '88%' })
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
Blank()
}
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 12 })
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 6 }) {
if (this.messages.length === 0) {
Text('您好,我是管理员智能查询助手 🌟\n可以用文字或语音查询护理记录与健康数据,例如:\n“查询张三今天的体温”')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
.textAlign(TextAlign.Center)
.width('100%')
.padding({ top: 60 })
}
ForEach(this.messages, (msg: ChatMessage) => {
this.MessageView(msg)
}, (msg: ChatMessage) => String(msg.id))
if (this.summaries.length > 0) {
Column({ space: 8 }) {
ForEach(this.summaries, (item: SummaryItem) => {
this.SummaryRow(item)
}, (item: SummaryItem) => item.label)
}
.width('92%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
}
}
.width('100%')
.padding({ top: 92, bottom: 20 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.align(Alignment.Top)
Row({ space: 10 }) {
TextInput({ placeholder: '输入查询内容...', text: this.inputText })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.fontSize(16)
.height(52)
.layoutWeight(1)
.padding({ left: 16, right: 16 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.onChange((value: string) => {
this.inputText = value;
})
.onSubmit((enterKey: EnterKeyType) => {
this.send();
})
Button(this.sending ? '…' : '查询')
.height(52)
.padding({ left: 18, right: 18 })
.constraintSize({ minWidth: 80 })
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
.enabled(!this.sending)
.onClick(() => {
this.send();
})
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 20 })
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '智能查询',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,256 @@
// 管理员 · 护理记录详情(ArkTS 严格类型,无 any)
// 接口:GET /tasks/nursing-records/{id}/
import { router, promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface VitalPart {
text?: string;
numeric?: string;
trend?: string;
systolic?: string;
diastolic?: string;
}
interface TextPart {
text?: string;
trend?: string;
}
interface ExtractedJson {
record_time?: string;
elderly_name?: string;
elderly_id_card?: string;
temperature?: VitalPart;
blood_pressure?: VitalPart;
weight?: VitalPart;
medication?: TextPart;
diet?: TextPart;
mood?: TextPart;
other?: TextPart;
}
interface NursingRecordDetail {
id: number;
elderly: number | null;
elderly_name: string;
nurse: number | null;
nurse_name: string;
raw_text: string;
images: Array<string>;
extracted_json: object;
work_item: string;
created_at: string;
}
function formatFullTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return iso;
}
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd} ${hh}:${mi}`;
}
interface VitalRow {
label: string;
value: string;
}
function buildVitalRows(record: NursingRecordDetail): Array<VitalRow> {
const e = record.extracted_json as ExtractedJson;
const rows: Array<VitalRow> = [];
const add = (label: string, value: string): void => {
if (value && value.length > 0) {
rows.push({ label: label, value: value });
}
};
add('记录时间', e.record_time || '');
add('体温', e.temperature ? e.temperature.text || e.temperature.numeric || '' : '');
add('血压', e.blood_pressure ? e.blood_pressure.text || '' : '');
add('体重', e.weight ? e.weight.text || e.weight.numeric || '' : '');
add('服药', e.medication ? e.medication.text || '' : '');
add('进食', e.diet ? e.diet.text || '' : '');
add('情绪', e.mood ? e.mood.text || '' : '');
add('其他', e.other ? e.other.text || '' : '');
return rows;
}
@Entry
@Component
struct AdminRecordDetail {
@State detail: NursingRecordDetail | null = null;
@State loading: boolean = true;
private recordId: number = 0;
aboutToAppear(): void {
const params = router.getParams() as Record<string, Object>;
this.recordId = Number(params['id'] ?? 0);
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const res = await api.get<NursingRecordDetail>(`/tasks/nursing-records/${this.recordId}/`);
this.detail = res;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
FieldRow(row: VitalRow) {
Row({ space: 12 }) {
Text(row.label)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width(72)
Text(row.value)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(3)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.padding({ top: 6, bottom: 6 })
}
build() {
HdsNavigation() {
if (this.loading) {
Column() {
Text('正在加载')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
} else if (this.detail === null) {
Column() {
Text('记录不存在或已删除')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
} else {
Scroll() {
Column({ space: 12 }) {
// 老人头卡
Row({ space: 12 }) {
Column() {
Text(this.detail.elderly_name.length > 0 ? this.detail.elderly_name.substring(0, 1) : '护')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(52)
.height(52)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(16)
Column({ space: 5 }) {
Text(this.detail.elderly_name)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(`${this.detail.nurse_name || '--'} · ${formatFullTime(this.detail.created_at)}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(VerticalAlign.Center)
// 体征与评估
if (buildVitalRows(this.detail).length > 0) {
Column({ space: 2 }) {
Text('体征与评估')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
ForEach(buildVitalRows(this.detail), (row: VitalRow) => {
this.FieldRow(row)
}, (row: VitalRow) => `${row.label}-${row.value}`)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
// 护理描述
Column({ space: 8 }) {
Text('护理描述')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Text(this.detail.raw_text || '无')
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 24 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Auto)
}
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '护理记录详情',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,452 @@
// 管理员 · 销售详情(ArkTS 严格类型,无 any)
// 接口:GET /market/cycles/overview/
// GET /market/leads/
// GET /market/contracts/
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { Page, normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { router } from '@kit.ArkUI';
interface CycleOverview {
month: string;
total_count: number;
assigned_count: number;
unassigned_count: number;
pending_count: number;
in_progress_count: number;
won_count: number;
lost_count: number;
active_sales_count: number;
sales_breakdown: Array<SalesBreakdown>;
}
interface SalesBreakdown {
sales_id: number;
sales_name: string;
assigned_count: number;
pending_count: number;
in_progress_count: number;
won_count: number;
lost_count: number;
won_amount: number;
}
interface SalesLead {
id: number;
name: string;
gender: string;
age: number | null;
contact_method: string;
status: string;
deal_amount: number | null;
current_sales_name: string;
current_cycle_month: string;
latest_note: string;
last_follow_up_at: string;
created_at: string;
}
interface Contract {
id: number;
lead_name: string;
customer_name: string;
sales_name: string;
contract_amount: number | null;
contract_code: string;
contract_date_range: string;
room_number: string;
signed_date: string;
occupancy_status: string;
care_level_label: string;
admitted_elderly_name: string;
created_at: string;
}
function statusLabel(status: string): string {
if (status === 'won') {
return '成交';
}
if (status === 'in_progress') {
return '跟进中';
}
if (status === 'lost') {
return '失败';
}
return '待跟进';
}
function statusColor(status: string): string {
if (status === 'won') {
return '#34C759';
}
if (status === 'in_progress') {
return '#007AFF';
}
if (status === 'lost') {
return '#8E8E93';
}
return '#FF9500';
}
function formatDate(iso: string): string {
if (!iso || iso.length === 0) {
return '--';
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return '--';
}
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${mm}/${dd}`;
}
function monthLabel(month: string): string {
if (!month || month.length === 0) {
return '';
}
const parts = month.split('-');
if (parts.length !== 2) {
return month;
}
return `${parts[0]}年${parts[1]}月`;
}
function amountText(amount: number | null): string {
if (amount === null || amount === undefined) {
return '--';
}
return `¥${amount}`;
}
@Entry
@Component
struct AdminSalesDetail {
@State cycle: CycleOverview | null = null;
@State leads: Array<SalesLead> = [];
@State contracts: Array<Contract> = [];
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const results: Array<CycleOverview | Page<SalesLead> | Page<Contract> | null> = await Promise.all([
api.get<CycleOverview>('/market/cycles/overview/').catch((): CycleOverview | null => null),
api.get<Page<SalesLead>>('/market/leads/', { page_size: 30, ordering: '-created_at' }).catch((): Page<SalesLead> | null => null),
api.get<Page<Contract>>('/market/contracts/', { page_size: 30 }).catch((): Page<Contract> | null => null),
]);
this.cycle = results[0] as CycleOverview | null;
const leadRes = results[1] as Page<SalesLead> | null;
const contractRes = results[2] as Page<Contract> | null;
this.leads = leadRes ? leadRes.results : [];
this.contracts = contractRes ? contractRes.results : [];
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
StatTile(value: number, label: string, color: string) {
Column({ space: 4 }) {
Text(String(value))
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(color)
Text(label)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
@Builder
LeadCard(lead: SalesLead) {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(lead.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
if (lead.age !== null && lead.age !== undefined) {
Text(`${lead.age}岁`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
Blank()
Text(statusLabel(lead.status))
.fontSize(12)
.fontColor(statusColor(lead.status))
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.backgroundColor('#14000000')
.borderRadius(6)
}
.width('100%')
if (lead.contact_method && lead.contact_method.length > 0) {
Text(`📞 ${lead.contact_method}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
Row({ space: 12 }) {
if (lead.current_sales_name && lead.current_sales_name.length > 0) {
Text(`负责:${lead.current_sales_name}`)
.fontSize(12)
.fontColor($r('app.color.accent_blue'))
}
Text(amountText(lead.deal_amount))
.fontSize(13)
.fontColor('#FF9500')
Blank()
Text(formatDate(lead.created_at))
.fontSize(12)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
}
@Builder
ContractCard(c: Contract) {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(c.customer_name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(amountText(c.contract_amount))
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FF9500')
}
.width('100%')
Row({ space: 12 }) {
if (c.contract_code && c.contract_code.length > 0) {
Text(c.contract_code)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
if (c.contract_date_range && c.contract_date_range.length > 0) {
Text(c.contract_date_range)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
Blank()
}
.width('100%')
Row({ space: 12 }) {
if (c.sales_name && c.sales_name.length > 0) {
Text(`销售:${c.sales_name}`)
.fontSize(12)
.fontColor($r('app.color.accent_blue'))
}
if (c.room_number && c.room_number.length > 0) {
Text(`房号:${c.room_number}`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.cycle) {
// 周期概览
Column({ space: 12 }) {
Row() {
Text(monthLabel(this.cycle.month))
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(`活跃销售 ${this.cycle.active_sales_count} 名`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
Row() {
this.StatTile(this.cycle.total_count, '总线索', '#007AFF')
this.StatTile(this.cycle.won_count, '成交', '#34C759')
this.StatTile(this.cycle.in_progress_count, '跟进中', '#FF9500')
this.StatTile(this.cycle.lost_count, '失败', '#8E8E93')
}
.width('100%')
if (this.cycle.sales_breakdown.length > 0) {
Column({ space: 8 }) {
ForEach(this.cycle.sales_breakdown, (s: SalesBreakdown) => {
Row({ space: 10 }) {
Text(s.sales_name)
.fontSize(13)
.fontColor($r('app.color.text_primary'))
.width(72)
Text(`${s.assigned_count}条`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Text(`成交 ${s.won_count}`)
.fontSize(12)
.fontColor($r('app.color.accent_green'))
Text(`¥${s.won_amount}`)
.fontSize(12)
.fontColor('#FF9500')
Blank()
}
.width('100%')
}, (s: SalesBreakdown) => String(s.sales_id))
}
.width('100%')
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
// 线索列表
Column({ space: 8 }) {
Row() {
Text('销售线索')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(`共 ${this.leads.length} 条`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
if (this.leads.length === 0) {
Text('暂无销售线索')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.leads, (lead: SalesLead) => {
Column() {
this.LeadCard(lead)
}
.width('100%')
}, (lead: SalesLead) => String(lead.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 合同列表
Column({ space: 8 }) {
Row() {
Text('签约合同')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(`共 ${this.contracts.length} 份`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
if (this.contracts.length === 0) {
Text('暂无合同')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.contracts, (c: Contract) => {
Column() {
this.ContractCard(c)
}
.width('100%')
}, (c: Contract) => String(c.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 20 })
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 24 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '销售详情',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,285 @@
// 管理员 · 排班管理(ArkTS 严格类型,无 any)
// 接口:GET /shifts/floor-duty-assignments/?duty_date=
// GET /shifts/caregiver-schedules/?month=
// GET /shifts/caregiver-duty-assignments/?schedule_version=
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { Page, normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface FloorDuty {
id: number;
duty_date: string;
floor: number;
shift_name: string;
shift_time: string;
staff_nurse_name: string;
}
interface CaregiverAssignment {
id: number;
duty_date: string;
caregiver_name: string;
source_name: string;
target_floor: number | null;
target_room: string;
scope_type: string;
shift_code: string;
is_day_duty: boolean;
}
interface ScheduleVersion {
id: number;
target_month: string;
version: number;
status: string;
assignments: Array<CaregiverAssignment>;
}
function todayStr(): string {
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
return `${now.getFullYear()}-${mm}-${dd}`;
}
function monthStr(): string {
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
return `${now.getFullYear()}-${mm}`;
}
function weekdayLabel(): string {
const weekdays: Array<string> = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
return weekdays[new Date().getDay()];
}
function scopeLabel(scope: string): string {
if (scope === 'floor') {
return '楼层';
}
if (scope === 'private_room') {
return '专护';
}
return '辅助';
}
@Entry
@Component
struct AdminSchedule {
@State floorDuties: Array<FloorDuty> = [];
@State caregiverAssignments: Array<CaregiverAssignment> = [];
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const responses: Array<Page<FloorDuty> | Page<ScheduleVersion> | null> = await Promise.all([
api.get<Page<FloorDuty>>('/shifts/floor-duty-assignments/', { duty_date: todayStr(), page_size: 100 })
.catch((): Page<FloorDuty> | null => null),
api.get<Page<ScheduleVersion>>('/shifts/caregiver-schedules/', { month: monthStr(), page_size: 20 })
.catch((): Page<ScheduleVersion> | null => null),
]);
const floorRes = responses[0] as Page<FloorDuty> | null;
this.floorDuties = floorRes ? floorRes.results : [];
const versionRes = responses[1] as Page<ScheduleVersion> | null;
if (versionRes) {
const versions: Array<ScheduleVersion> = versionRes.results;
// 取已发布的版本;如无发布取第一个
const published = versions.find((v: ScheduleVersion) => v.status === 'published');
const target = published ?? (versions.length > 0 ? versions[0] : null);
if (target && target.assignments) {
this.caregiverAssignments = target.assignments.filter(
(a: CaregiverAssignment) => a.duty_date === todayStr());
}
}
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
FloorCard(duty: FloorDuty) {
Row({ space: 12 }) {
Column() {
Text(String(duty.floor))
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(48)
.height(48)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
Column({ space: 4 }) {
Text(`${duty.floor}楼 · ${duty.shift_name}`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(duty.shift_time)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(duty.staff_nurse_name || '--')
.fontSize(15)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.accent_green'))
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
}
@Builder
CaregiverCard(a: CaregiverAssignment) {
Row({ space: 12 }) {
Text(a.caregiver_name || a.source_name || '未校对')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(a.is_day_duty ? '日值' : scopeLabel(a.scope_type))
.fontSize(11)
.fontColor('#FFFFFF')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(a.is_day_duty ? '#AF52DE' : '#FF9500')
.borderRadius(4)
Blank()
Text(a.target_floor ? `${a.target_floor}楼` : (a.target_room ? a.target_room : '--'))
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
Text(a.shift_code)
.fontSize(13)
.fontColor($r('app.color.accent_blue'))
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else {
// 楼层护士值班
Column({ space: 8 }) {
Text('今日楼层护士值班')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.floorDuties.length === 0) {
Text('今日暂无楼层值班排班')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.floorDuties, (duty: FloorDuty) => {
Column() {
this.FloorCard(duty)
}
.width('100%')
}, (duty: FloorDuty) => String(duty.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 护理员今日值班
Column({ space: 8 }) {
Text('护理员今日值班')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.caregiverAssignments.length === 0) {
Text('当前月份暂无护理员排班数据')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.caregiverAssignments, (a: CaregiverAssignment) => {
Column() {
this.CaregiverCard(a)
}
.width('100%')
}, (a: CaregiverAssignment) => String(a.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 20 })
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '排班管理',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,419 @@
// 管理员 · 设备监测/睡眠(ArkTS 严格类型,无 any)
// 接口:GET /devices/types/sleep_monitor/overview/、/devices/types/body_sensing/overview/
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface TypeInfo {
code: string;
name: string;
device_count: number;
bound_elderly_count: number;
unresolved_alert_count: number;
}
interface ElderlyBrief {
name: string;
gender: string;
age: number;
care_level: string;
}
interface DeviceAlert {
key: string;
severity: string;
description: string;
}
interface SleepInfo {
status: string;
deep_minutes: number;
light_minutes: number;
awake_minutes: number;
start_at: string;
wake_at: string;
report_date: string;
}
interface Vitals {
heart_rate: number | null;
respiratory_rate: number | null;
body_temperature: number | null;
}
interface SleepResident {
id: number;
device_code: string;
floor: string;
room: string;
bed: string;
elderly: ElderlyBrief;
presence: boolean;
body_movement: boolean;
sleep: SleepInfo;
vitals: Vitals;
alerts: Array<DeviceAlert>;
}
interface SleepOverview {
type: TypeInfo;
pending_alert_count: number;
monitoring_residents: Array<SleepResident>;
}
interface BodySensing {
online: boolean;
motion_label: string;
fall_status: boolean;
long_unattended: boolean;
people_count: number;
}
interface BodyResident {
id: number;
device_code: string;
floor: string;
room: string;
bed: string;
elderly: ElderlyBrief;
presence: boolean;
body_sensing: BodySensing;
alerts: Array<DeviceAlert>;
}
interface BodyOverview {
type: TypeInfo;
pending_alert_count: number;
monitoring_residents: Array<BodyResident>;
}
function roomLabel(r: SleepResident | BodyResident): string {
if (r.floor && r.floor.length > 0) {
return `${r.floor}${r.room ? '/' + r.room : ''}${r.bed ? '/' + r.bed : ''}`;
}
return r.room || r.bed || '--';
}
function vitalText(v: number | null): string {
return v === null || v === undefined ? '--' : String(v);
}
@Entry
@Component
struct DeviceMonitoring {
@State sleep: SleepOverview | null = null;
@State body: BodyOverview | null = null;
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const results: Array<SleepOverview | BodyOverview | null> = await Promise.all([
api.get<SleepOverview>('/devices/types/sleep_monitor/overview/').catch((): SleepOverview | null => null),
api.get<BodyOverview>('/devices/types/body_sensing/overview/').catch((): BodyOverview | null => null),
]);
this.sleep = results[0] as SleepOverview | null;
this.body = results[1] as BodyOverview | null;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
SleepCard(r: SleepResident) {
Column({ space: 8 }) {
Row({ space: 10 }) {
Column() {
Text((r.elderly.name || '?').substring(0, 1))
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(44)
.height(44)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
Column({ space: 4 }) {
Row({ space: 8 }) {
Text(r.elderly.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(roomLabel(r))
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Blank()
}
.width('100%')
Text(r.sleep.status || '--')
.fontSize(13)
.fontColor(r.presence ? $r('app.color.accent_blue') : $r('app.color.accent_green'))
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
if (r.sleep) {
Row({ space: 10 }) {
Text(`深睡 ${r.sleep.deep_minutes}分`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Text(`浅睡 ${r.sleep.light_minutes}分`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Text(`清醒 ${r.sleep.awake_minutes}分`)
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Blank()
Text(`${r.sleep.start_at}~${r.sleep.wake_at}`)
.fontSize(12)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
}
if (r.vitals) {
Row({ space: 10 }) {
Text(`心率 ${vitalText(r.vitals.heart_rate)}`)
.fontSize(12)
.fontColor($r('app.color.accent_red'))
Text(`呼吸 ${vitalText(r.vitals.respiratory_rate)}`)
.fontSize(12)
.fontColor($r('app.color.accent_blue'))
Text(`体温 ${vitalText(r.vitals.body_temperature)}`)
.fontSize(12)
.fontColor('#FF9500')
Blank()
}
.width('100%')
}
if (r.alerts && r.alerts.length > 0) {
ForEach(r.alerts, (a: DeviceAlert) => {
Text(`⚠ ${a.description || a.key}`)
.fontSize(12)
.fontColor('#FF3B30')
.width('100%')
}, (a: DeviceAlert) => `${r.id}-${a.key}`)
}
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
@Builder
BodyCard(r: BodyResident) {
Column({ space: 8 }) {
Row({ space: 10 }) {
Column() {
Text((r.elderly.name || '?').substring(0, 1))
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(44)
.height(44)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor('#7C3AED')
.borderRadius(12)
Column({ space: 4 }) {
Row({ space: 8 }) {
Text(r.elderly.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(roomLabel(r))
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Blank()
}
.width('100%')
Text(r.body_sensing && r.body_sensing.motion_label ? r.body_sensing.motion_label : '--')
.fontSize(13)
.fontColor(r.presence ? $r('app.color.accent_blue') : $r('app.color.inactive_tab'))
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
if (r.body_sensing) {
Row({ space: 10 }) {
Text(r.body_sensing.online ? '在线' : '离线')
.fontSize(12)
.fontColor(r.body_sensing.online ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
if (r.body_sensing.fall_status) {
Text('跌倒!')
.fontSize(12)
.fontColor('#FF3B30')
}
if (r.body_sensing.long_unattended) {
Text('长期无人')
.fontSize(12)
.fontColor('#FF9500')
}
Blank()
}
.width('100%')
}
if (r.alerts && r.alerts.length > 0) {
ForEach(r.alerts, (a: DeviceAlert) => {
Text(`⚠ ${a.description || a.key}`)
.fontSize(12)
.fontColor('#FF3B30')
.width('100%')
}, (a: DeviceAlert) => `${r.id}-${a.key}`)
}
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else {
Scroll(this.scroller) {
Column({ space: 14 }) {
// 睡眠监测
Column({ space: 10 }) {
Row() {
Text('睡眠监测')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
if (this.sleep) {
Text(`设备 ${this.sleep.type.device_count} · 绑定 ${this.sleep.type.bound_elderly_count}`)
.fontSize(12)
.fontColor($r('app.color.inactive_tab'))
}
}
.width('100%')
if (!this.sleep) {
Text('暂无睡眠设备数据')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.sleep.monitoring_residents, (r: SleepResident) => {
Column() {
this.SleepCard(r)
}
.width('100%')
}, (r: SleepResident) => String(r.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
// 体感监测
Column({ space: 10 }) {
Row() {
Text('体感监测')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
if (this.body) {
Text(`设备 ${this.body.type.device_count} · 绑定 ${this.body.type.bound_elderly_count}`)
.fontSize(12)
.fontColor($r('app.color.inactive_tab'))
}
}
.width('100%')
if (!this.body) {
Text('暂无体感设备数据')
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
} else {
ForEach(this.body.monitoring_residents, (r: BodyResident) => {
Column() {
this.BodyCard(r)
}
.width('100%')
}, (r: BodyResident) => String(r.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 20 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '设备监测',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,172 @@
// 预警页签(ArkTS 严格类型,无 any)
// 数据来源:/alerts/
import { BusinessError } from '@kit.BasicServicesKit';
import { Page, normalizeError } from '../../common/ApiConstants';
import { api } from '../../utils/Request';
import { AlertSocket } from '../../utils/AlertSocket';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface AlertItem {
id: number;
title: string;
description: string;
level: string;
created_at: string;
}
function formatDateTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return '--';
}
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${mm}/${dd} ${hh}:${mi}`;
}
function levelColor(level: string): string {
const lv = (level || '').toLowerCase();
if (lv.indexOf('critical') >= 0 || lv.indexOf('emergency') >= 0 || lv.indexOf('严重') >= 0) {
return ('app.color.accent_red');
}
if (lv.indexOf('warning') >= 0 || lv.indexOf('警告') >= 0) {
return '#FF9500';
}
return ('app.color.accent_blue');
}
@Component
export struct AlertsTab {
@State alerts: Array<AlertItem> = [];
@State loading: boolean = true;
@State refreshing: boolean = false;
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
aboutToAppear(): void {
this.loadAlerts();
// 注册预警实时推送:收到推送刷新列表
AlertSocket.setListener(() => {
this.loadAlerts();
});
}
aboutToDisappear(): void {
AlertSocket.setListener(null);
}
private async loadAlerts(): Promise<void> {
this.loading = true;
try {
const res = await api.get<Page<AlertItem>>('/alerts/', {
page_size: 50,
ordering: '-created_at',
});
this.alerts = res.results;
} catch (err) {
// 静默降级:显示空态
console.error('[AlertsTab] load failed:', normalizeError(err as BusinessError | Error));
} finally {
this.loading = false;
this.refreshing = false;
}
}
@Builder
AlertCard(item: AlertItem) {
Column({ space: 8 }) {
Row({ space: 8 }) {
Circle()
.width(8)
.height(8)
.fill(levelColor(item.level))
Text(item.title)
.fontSize(Math.round(15 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(formatDateTime(item.created_at))
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
Text(item.description && item.description.length > 0 ? item.description : '(无详情)')
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
build() {
Refresh({ refreshing: $$this.refreshing }) {
Scroll() {
Column({ space: 10 }) {
Row() {
Text('预警消息')
.fontSize(Math.round(18 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text('刷新')
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.accent_blue'))
.onClick(() => {
this.loadAlerts();
})
}
.width('92%')
.margin({ top: 20, bottom: 4 })
if (this.loading) {
Text('加载中...')
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else if (this.alerts.length === 0) {
Column({ space: 8 }) {
Text('🔔')
.fontSize(Math.round(32 * this.scaleFor()))
Text('暂无预警消息')
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.margin({ top: 80 })
.alignItems(HorizontalAlign.Center)
} else {
ForEach(this.alerts, (item: AlertItem) => {
this.AlertCard(item)
}, (item: AlertItem) => String(item.id))
}
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ bottom: 120 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
.onRefreshing(() => {
this.loadAlerts();
})
}
}
@@ -0,0 +1,187 @@
// 业务中心页签(按角色自适应入口列表,ArkTS 严格类型,无 any)
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { uiMaterial } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { UnifiedRole, normalizeError } from '../../common/ApiConstants';
import { AuthStore } from '../../utils/AuthStore';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface BusinessEntry {
key: string;
title: string;
desc: string;
route: string; // 空字符串表示未实现
}
interface BusinessSection {
title: string;
entries: Array<BusinessEntry>;
}
@Component
export struct BusinessTab {
@State role: UnifiedRole | null = null;
@State sections: Array<BusinessSection> = [];
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
aboutToAppear(): void {
this.loadRole();
}
private async loadRole(): Promise<void> {
const state = await AuthStore.getState();
this.role = state.role;
this.applyRoleConfig();
}
private applyRoleConfig(): void {
const role = this.role;
if (role === 'nurse' || role === 'staff_nurse') {
this.sections = [
{
title: '护理工作',
entries: [
{ key: 'record', title: '护理记录录入', desc: '提交文字/图片护理记录', route: 'pages/nurse/RecordEntry' },
{ key: 'history', title: '护理记录列表', desc: '查看与搜索历史记录', route: 'pages/nurse/History' },
{ key: 'work-center', title: '护理中心', desc: '当前楼层与护理要务', route: 'pages/nurse/WorkCenter' },
],
},
{
title: '老人服务',
entries: [
{ key: 'elderly', title: '老人列表', desc: '负责长者的档案信息', route: 'pages/elderly/ElderlyList' },
{ key: 'skin', title: '皮肤检查', desc: '压疮风险巡检记录', route: 'pages/nurse/RecordEntry' },
{ key: 'medications', title: '用药管理', desc: '长者用药提醒与记录', route: 'pages/nurse/MedicationToDo' },
],
},
];
} else if (role === 'family') {
this.sections = [
{
title: '看护服务',
entries: [
{ key: 'records', title: '护理记录', desc: '查看长者护理记录', route: 'pages/family/Records' },
{ key: 'elderly', title: '长者档案', desc: '查看长者档案信息', route: 'pages/family/FamilyElderly' },
{ key: 'history', title: '长者历史', desc: '健康与护理历史', route: 'pages/family/FamilyElderlyHistory' },
{ key: 'assistant', title: '智能助手', desc: '随时咨询家人状况', route: 'pages/family/FamilyAssistant' },
{ key: 'verify', title: '安全验证', desc: '验证家属身份', route: 'pages/family/FamilyVerify' },
],
},
];
} else if (role === 'fire_control') {
this.sections = [
{
title: '消防工作',
entries: [
{ key: 'patrol', title: '消防巡更', desc: '扫码打卡与巡更记录', route: 'pages/fire-control/FirePatrolHome' },
],
},
];
} else if (role === 'sales') {
this.sections = [
{
title: '销售工作',
entries: [
{ key: 'sales', title: '销售工作台', desc: '线索、跟进与合同', route: 'pages/sales/SalesHome' },
],
},
];
} else {
// 管理员/默认
this.sections = [
{
title: '记录管理',
entries: [
{ key: 'today', title: '今日记录', desc: '查看今日全部护理记录', route: 'pages/nurse/History' },
{ key: 'records', title: '记录概览', desc: '搜索与筛选护理记录', route: 'pages/nurse/History' },
{ key: 'alerts', title: '预警概览', desc: '查看预警处理情况', route: 'pages/nurse/Alerts' },
],
},
{
title: '运营管理',
entries: [
{ key: 'meal', title: '订餐查询', desc: '今日菜单与订餐记录', route: 'pages/meals/MealOrders' },
{ key: 'schedule', title: '排班管理', desc: '查看和调整护理排班', route: 'pages/admin/AdminSchedule' },
{ key: 'feedback', title: '意见反馈', desc: '收集家属及员工建议', route: 'pages/profile/Feedback' },
],
},
];
}
}
private onEntryClick(entry: BusinessEntry): void {
if (entry.route.length === 0) {
promptAction.showToast({ message: '功能开发中' });
return;
}
router.pushUrl({ url: entry.route }).catch((err: BusinessError) => {
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
});
}
@Builder
EntryRow(entry: BusinessEntry) {
Row({ space: 12 }) {
Column({ space: 4 }) {
Text(entry.title)
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text(entry.desc)
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('›')
.fontSize(Math.round(22 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
.padding(16)
.backgroundColor(Color.Transparent)
.backgroundBlurStyle(BlurStyle.Thin)
.borderRadius(14)
.onClick(() => {
this.onEntryClick(entry);
})
}
build() {
Scroll() {
Column({ space: 18 }) {
ForEach(this.sections, (section: BusinessSection) => {
Column({ space: 10 }) {
Text(section.title)
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Column({ space: 8 }) {
ForEach(section.entries, (entry: BusinessEntry) => {
this.EntryRow(entry)
}, (entry: BusinessEntry) => entry.key)
}
.width('100%')
}
.width('92%')
.alignItems(HorizontalAlign.Start)
}, (section: BusinessSection) => section.title)
}
.width('100%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 20, bottom: 120 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.width('100%')
.height('100%')
}
}
@@ -0,0 +1,258 @@
// 老人端 · 活动安排(ArkTS 严格类型,无 any)
// 接口:GET /activities/weeks/<week_start>/
import { router, promptAction } from '@kit.ArkUI';
import { InsightIntentPage, common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { normalizeError } from '../../common/ApiConstants';
import { api } from '../../utils/Request';
import { addCalendarEvent } from '../../utils/CalendarHelper';
interface ActivityItem {
id: number;
activity_name: string;
activity_description: string;
activity_date: string;
start_time: string;
end_time: string;
location: string;
participants: number;
}
interface ActivityWeek {
week_start: string;
status: string;
items: Array<ActivityItem>;
}
function weekStart(date: Date): string {
const d = new Date(date);
const day = d.getDay() === 0 ? 7 : d.getDay();
d.setDate(d.getDate() - day + 1);
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}
function formatDateLabel(iso: string): string {
const d = new Date(iso + 'T00:00:00');
if (Number.isNaN(d.getTime())) {
return iso;
}
const weekdays: Array<string> = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
return `${d.getMonth() + 1}/${d.getDate()} ${weekdays[d.getDay()]}`;
}
function formatTime(t: string): string {
return t && t.length > 0 ? t : '--';
}
function formatYMD(d: Date): string {
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}
@InsightIntentPage({
intentName: 'ViewActivities',
domain: 'ElderlyCare',
intentVersion: '1.0.0',
displayName: '查看院内活动',
displayDescription: '打开智慧护老的活动页,查看本周和下周的院内活动安排',
uiAbility: 'EntryAbility',
pagePath: 'ets/pages/elderly/Activities',
keywords: ['活动', '健身', '八段锦', '娱乐', '课程'],
llmDescription: '查看养老院本周及未来的院内活动安排与报名信息',
})
@Entry
@Component
struct Activities {
@State items: Array<ActivityItem> = [];
@State loading: boolean = true;
@State weekStartText: string = weekStart(new Date());
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.loadActivities();
}
// 从本周起向后查找最近一个已发布且有活动的周,避免本周未发布时误显示空周
private async loadActivities(): Promise<void> {
this.loading = true;
try {
const base = new Date(this.weekStartText + 'T00:00:00');
for (let i = 0; i < 5; i++) {
const ws = new Date(base);
ws.setDate(ws.getDate() + i * 7);
const week = formatYMD(ws);
const res = await api.get<ActivityWeek>(`/activities/weeks/${week}/`);
if (res.items && res.items.length > 0) {
this.items = res.items;
this.weekStartText = week;
break;
}
this.items = [];
}
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
@Builder
ActivityCard(item: ActivityItem) {
Column({ space: 8 }) {
Row() {
Text(item.activity_name)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(formatDateLabel(item.activity_date))
.fontSize(13)
.fontColor($r('app.color.accent_blue'))
}
.width('100%')
if (item.activity_description && item.activity_description.length > 0) {
Text(item.activity_description)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
Row({ space: 12 }) {
Text(`🕐 ${formatTime(item.start_time)} - ${formatTime(item.end_time)}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
if (item.location && item.location.length > 0) {
Text(`📍 ${item.location}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
Blank()
Text('+ 日历')
.fontSize(13)
.fontColor($r('app.color.accent_blue'))
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor($r('app.color.background'))
.onClick(() => {
this.addEventToCalendar(item);
})
.accessibilityLevel('yes')
.accessibilityText(`将${item.activity_name}加入系统日历`)
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
// 解析活动日期与时间 → 写入系统日历
private async addEventToCalendar(item: ActivityItem): Promise<void> {
try {
// 后端时间为 HH:mm:ss,兼容 HH:mm;避免拼出非法 ISO 串
const startT = item.start_time.length >= 8 ? item.start_time.substring(0, 5) : item.start_time;
const endT = item.end_time.length >= 8 ? item.end_time.substring(0, 5) : item.end_time;
const startTimeStr = `${item.activity_date}T${startT.length > 0 ? startT : '09:00'}:00`;
const endTimeStr = `${item.activity_date}T${endT.length > 0 ? endT : '10:00'}:00`;
const start = new Date(startTimeStr).getTime();
const end = new Date(endTimeStr).getTime();
if (Number.isNaN(start) || Number.isNaN(end)) {
promptAction.showToast({ message: '活动时间无法解析' });
return;
}
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
const errText = await addCalendarEvent(context, `活动:${item.activity_name}`, start, end,
item.location ?? '', item.activity_description ?? '');
if (errText.length === 0) {
promptAction.showToast({ message: '已加入系统日历' });
} else {
promptAction.showToast({ message: errText });
}
} catch (err) {
promptAction.showToast({ message: `写入日历失败:${normalizeError(err as BusinessError | Error)}` });
}
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 80 })
} else if (this.items.length === 0) {
Column({ space: 8 }) {
Text('🎉')
.fontSize(40)
Text('本周暂无活动安排')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.margin({ top: 100 })
.alignItems(HorizontalAlign.Center)
} else {
Scroll(this.scroller) {
Column({ space: 10 }) {
Text(`活动安排(${this.weekStartText} 起)`)
.fontSize(13)
.fontColor($r('app.color.inactive_tab'))
.width('100%')
ForEach(this.items, (item: ActivityItem) => {
this.ActivityCard(item)
}, (item: ActivityItem) => String(item.id))
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 20 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.width('100%')
}
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '活动安排',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,146 @@
// 老人助手(ArkTS 严格类型,无 any)
// 数据来源:/elderly-portal/assistant/reply/
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
interface ReplyResult {
reply: string;
auto_reserved: boolean;
reservations: Array<Record<string, string | number | boolean>>;
}
// 助手消息请求体
interface AssistantPayload {
message: string;
}
interface ReservationItem {
id: string;
meal_name: string;
time: string;
status: string;
}
@Entry
@Component
struct Assistant {
@State inputText: string = '';
@State messages: ChatMessage[] = [];
@State sending: boolean = false;
private scroller: Scroller = new Scroller();
private async send(): Promise<void> {
const text = this.inputText.trim();
if (text.length === 0 || this.sending) {
return;
}
const nextMessages = this.messages.concat([{ role: 'user', content: text }]);
this.messages = nextMessages;
this.inputText = '';
this.sending = true;
try {
const payload: AssistantPayload = { message: text };
const res = await api.post<ReplyResult>('/elderly-portal/assistant/reply/', payload);
const reply = res.reply && res.reply.length > 0 ? res.reply : '暂时无法回答,请稍后再试。';
this.messages = this.messages.concat([{ role: 'assistant', content: reply }]);
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
this.messages = this.messages.concat([{ role: 'assistant', content: message }]);
} finally {
this.sending = false;
}
}
@Builder
MessageView(msg: ChatMessage) {
Row() {
if (msg.role === 'user') {
Blank()
Column() {
Text(msg.content)
.fontSize(15)
.fontColor($r('app.color.text_primary'))
}
.width('75%')
.padding(12)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
} else {
Column() {
Text(msg.content)
.fontSize(15)
.fontColor($r('app.color.text_primary'))
}
.width('75%')
.padding(12)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
Blank()
}
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 10 })
}
build() {
Column() {
Text('老人助手')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
.padding({ left: 20, right: 20, top: 20, bottom: 8 })
Scroll(this.scroller) {
Column({ space: 6 }) {
ForEach(this.messages, (msg: ChatMessage) => {
this.MessageView(msg)
}, (msg: ChatMessage) => `${msg.role}-${this.messages.indexOf(msg)}`)
}
.width('100%')
.padding({ bottom: 20 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.align(Alignment.Top)
Row({ space: 10 }) {
TextArea({ placeholder: '输入想说的话,例如:我想吃清淡点的晚餐', text: this.inputText })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.height(46)
.layoutWeight(1)
.padding({ left: 14, right: 14 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.onChange((value: string) => {
this.inputText = value;
})
Button(this.sending ? '…' : '发送')
.width(64)
.height(46)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
.enabled(!this.sending)
.onClick(() => {
this.send();
})
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 20 })
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,383 @@
// 新增对象(录入老人档案)(ArkTS 严格类型,无 any)
// 接口:POST /elderly/
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { common } from '@kit.AbilityKit';
import { takePhoto, pickOneImage, recognizeTextFromUri, extractIdCardFields } from '../../utils/OcrHelper';
// 新增老人请求体
interface ElderlyCreatePayload {
name: string;
gender: string;
birth_date: string;
id_card: string;
phone: string;
room: string;
bed: string;
floor: string;
care_level: string;
medical_history: string;
allergies: string;
medications: string;
}
const CARE_LEVELS: Array<string> = ['self_care', 'semi_care', 'full_care'];
const GENDERS: Array<string> = ['male', 'female'];
function careLevelLabel(value: string): string {
if (value === 'self_care') {
return '自理';
}
if (value === 'semi_care') {
return '半护理';
}
if (value === 'full_care') {
return '全护理';
}
return '自理';
}
@Entry
@Component
struct ElderlyAdd {
@State name: string = '';
@State gender: string = 'male';
@State birthDate: string = '';
@State idCard: string = '';
@State phone: string = '';
@State room: string = '';
@State bed: string = '';
@State floor: string = '';
@State careLevel: string = 'self_care';
@State medicalHistory: string = '';
@State allergies: string = '';
@State medications: string = '';
@State submitting: boolean = false;
@State ocrRunning: boolean = false;
private scroller: Scroller = new Scroller();
// 拍身份证识别:端侧 OCR 提取证件号/性别/出生日期/姓名,自动填表(可核对修改)
private async runIdCardOcr(useCamera: boolean): Promise<void> {
if (this.ocrRunning) {
return;
}
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
const uri = useCamera ? await takePhoto(context) : await pickOneImage(context);
if (uri === null) {
return;
}
this.ocrRunning = true;
promptAction.showToast({ message: '识别中...' });
try {
const text = await recognizeTextFromUri(uri);
const fields = extractIdCardFields(text);
if (fields.idNumber.length === 0) {
promptAction.showToast({ message: '未识别到身份证号,请光线充足后重拍' });
return;
}
this.idCard = fields.idNumber;
if (fields.gender.length > 0) {
this.gender = fields.gender;
}
if (fields.birthDate.length > 0) {
this.birthDate = fields.birthDate;
}
if (this.name.trim().length === 0 && fields.name.length > 0) {
this.name = fields.name;
}
promptAction.showToast({ message: '识别完成,请核对后提交' });
} catch (err) {
console.error('[ElderlyAdd] id card ocr failed:', normalizeError(err as BusinessError | Error));
promptAction.showToast({ message: '识别失败,请重试或手动输入' });
} finally {
this.ocrRunning = false;
}
}
// 跨端 OCR 菜单(bindMenu 自定义项)
@Builder
OcrMenu() {
Column() {
Text('📷 拍照识别')
.width('100%')
.padding(12)
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.onClick(() => {
this.runIdCardOcr(true);
})
Divider().color($r('app.color.background'))
Text('🖼️ 从相册识别')
.width('100%')
.padding(12)
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.onClick(() => {
this.runIdCardOcr(false);
})
}
.width(170)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
}
private async submit(): Promise<void> {
if (this.name.trim().length === 0) {
promptAction.showToast({ message: '请输入老人姓名' });
return;
}
if (this.idCard.trim().length === 0) {
promptAction.showToast({ message: '请输入身份证号' });
return;
}
if (this.submitting) {
return;
}
this.submitting = true;
try {
const body: ElderlyCreatePayload = {
name: this.name.trim(),
gender: this.gender,
birth_date: this.birthDate.trim(),
id_card: this.idCard.trim(),
phone: this.phone.trim(),
room: this.room.trim(),
bed: this.bed.trim(),
floor: this.floor.trim(),
care_level: this.careLevel,
medical_history: this.medicalHistory.trim(),
allergies: this.allergies.trim(),
medications: this.medications.trim(),
};
await api.post('/elderly/', body);
promptAction.showToast({ message: '老人档案已创建' });
router.back();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.submitting = false;
}
}
@Builder
Field(label: string, placeholder: string, value: string, onChange: (v: string) => void) {
Column({ space: 6 }) {
Text(label)
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.width('100%')
TextInput({ placeholder: placeholder, text: value })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.height(44)
.padding({ left: 12, right: 12 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(10)
.onChange((v: string) => {
onChange(v);
})
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 14 }) {
// 基本信息
Column({ space: 12 }) {
Text('基本信息')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
this.Field('姓名 *', '请输入老人姓名', this.name, (v: string) => {
this.name = v;
})
// 性别
Column({ space: 6 }) {
Text('性别')
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.width('100%')
Row({ space: 10 }) {
ForEach(GENDERS, (g: string) => {
Text(g === 'male' ? '男' : '女')
.fontSize(14)
.fontColor(this.gender === g ? '#FFFFFF' : $r('app.color.text_primary'))
.padding({ left: 20, right: 20, top: 10, bottom: 10 })
.backgroundColor(this.gender === g ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(10)
.onClick(() => {
this.gender = g;
})
}, (g: string) => g)
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
this.Field('出生日期', '如 1950-01-01', this.birthDate, (v: string) => {
this.birthDate = v;
})
this.Field('身份证号 *', '请输入身份证号', this.idCard, (v: string) => {
this.idCard = v;
})
// 身份证拍照识别(端侧 OCR,可选)
Button(this.ocrRunning ? '识别中...' : '📷 拍身份证自动填入')
.fontSize(14)
.height(40)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(10)
.enabled(!this.ocrRunning)
.bindMenu(this.OcrMenu)
.accessibilityLevel('yes')
.accessibilityText('拍身份证自动填入')
.accessibilityDescription('拍照或从相册选择身份证照片,自动识别证件号、性别、出生日期')
this.Field('联系电话', '选填', this.phone, (v: string) => {
this.phone = v;
})
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
// 位置与护理
Column({ space: 12 }) {
Text('位置与护理')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
this.Field('房间', '如 801', this.room, (v: string) => {
this.room = v;
})
this.Field('床位', '如 12', this.bed, (v: string) => {
this.bed = v;
})
this.Field('楼层', '如 8F', this.floor, (v: string) => {
this.floor = v;
})
// 护理等级
Column({ space: 6 }) {
Text('护理等级')
.fontSize(14)
.fontColor($r('app.color.text_primary'))
.width('100%')
Row({ space: 10 }) {
ForEach(CARE_LEVELS, (level: string) => {
Text(careLevelLabel(level))
.fontSize(14)
.fontColor(this.careLevel === level ? '#FFFFFF' : $r('app.color.text_primary'))
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor(this.careLevel === level ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(10)
.onClick(() => {
this.careLevel = level;
})
}, (level: string) => level)
}
.width('100%')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
// 健康信息
Column({ space: 12 }) {
Text('健康信息')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
this.Field('病史', '选填', this.medicalHistory, (v: string) => {
this.medicalHistory = v;
})
this.Field('过敏史', '选填', this.allergies, (v: string) => {
this.allergies = v;
})
this.Field('用药情况', '选填', this.medications, (v: string) => {
this.medications = v;
})
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
Button(this.submitting ? '提交中...' : '保存老人档案')
.width('100%')
.height(50)
.fontSize(16)
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(12)
.enabled(!this.submitting)
.onClick(() => {
this.submit();
})
}
.width('92%')
.padding({ top: 120, bottom: 30 })
.alignItems(HorizontalAlign.Center)
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.width('100%')
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '新增对象',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,299 @@
// 老人端 · 聊天助手面板(ArkTS 严格类型,无 any)
// 数据来源:/elderly-portal/assistant/reply/
// 历史对话记录本地持久化(preferences)
import { promptAction } from '@kit.ArkUI';
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
const CHAT_PREFS_NAME = 'ElderlyChat';
const CHAT_MESSAGES_KEY = 'messages';
interface ChatMessage {
id: number; // 唯一消息 id
role: string;
content: string;
displayLen: number; // 已显示字符数(打字机效果),-1 表示全文
}
// 自增 id 计数器
let chatMessageSeq = 1;
interface AssistantReservation {
slot_id: string;
slot_name: string;
ordered: boolean;
service_date: string;
}
interface ReplyResult {
reply: string;
auto_reserved: boolean;
reservations: Array<AssistantReservation>;
}
// 助手消息请求体
interface AssistantPayload {
message: string;
}
@Component
export struct ElderlyAssistantPanel {
@State inputText: string = '';
@State messages: Array<ChatMessage> = [];
@State sending: boolean = false;
@State typingKey: string = '';
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
// 缩放倍数(读 @StorageProp,变化触发重渲)
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
private scroller: Scroller = new Scroller();
private typingTimer: number = -1;
aboutToAppear(): void {
this.loadHistory().then(() => {
// 打开默认滚动到最新消息
setTimeout(() => {
this.scrollToBottom();
}, 100);
});
}
// 读取本地历史对话
private async loadHistory(): Promise<void> {
try {
const context = getContext(this) as common.UIAbilityContext;
const prefs = await preferences.getPreferences(context, CHAT_PREFS_NAME);
const value = await prefs.get(CHAT_MESSAGES_KEY, '');
if (typeof value === 'string' && value.length > 0) {
const parsed = JSON.parse(value) as Array<ChatMessage>;
if (parsed && parsed.length > 0) {
// 历史消息补 displayLen(全文显示)
const restored = parsed.map((m: ChatMessage): ChatMessage => ({
id: chatMessageSeq++,
role: m.role,
content: m.content,
displayLen: m.displayLen !== undefined ? m.displayLen : -1,
}));
this.messages = restored;
}
}
} catch (err) {
console.warn('[ElderlyChat] load history failed:', normalizeError(err as BusinessError | Error));
}
}
// 保存历史对话(限制最近 100 条;保存时全部以全文 displayLen=-1 存储)
private async saveHistory(): Promise<void> {
try {
const context = getContext(this) as common.UIAbilityContext;
const prefs = await preferences.getPreferences(context, CHAT_PREFS_NAME);
const toSave = this.messages.length > 100 ? this.messages.slice(this.messages.length - 100) : this.messages;
const fullText = toSave.map((m: ChatMessage): ChatMessage => ({
id: m.id,
role: m.role,
content: m.content,
displayLen: -1,
}));
await prefs.put(CHAT_MESSAGES_KEY, JSON.stringify(fullText));
await prefs.flush();
} catch (err) {
console.warn('[ElderlyChat] save history failed:', normalizeError(err as BusinessError | Error));
}
}
// 滚动到底部聚焦最新消息
private scrollToBottom(): void {
this.scroller.scrollEdge(Edge.Bottom);
}
// 启动打字机效果:逐字显示回复
private startTyping(id: number, fullText: string): void {
this.clearTyping();
this.typingKey = String(id);
this.messages = this.messages.map((m: ChatMessage): ChatMessage => {
if (m.id === id) {
return { id: m.id, role: m.role, content: m.content, displayLen: 0 };
}
return m;
});
let count = 0;
this.typingTimer = setInterval(() => {
count += 2;
if (count >= fullText.length) {
this.clearTyping();
}
this.messages = this.messages.map((m: ChatMessage): ChatMessage => {
if (m.id === id) {
const len = Math.min(count, fullText.length);
return { id: m.id, role: m.role, content: m.content, displayLen: len };
}
return m;
});
this.scrollToBottom();
}, 30);
}
private clearTyping(): void {
if (this.typingTimer !== -1) {
clearInterval(this.typingTimer);
this.typingTimer = -1;
}
this.typingKey = '';
}
private async send(): Promise<void> {
const text = this.inputText.trim();
if (text.length === 0 || this.sending) {
return;
}
const userMsg: ChatMessage = { id: chatMessageSeq++, role: 'user', content: text, displayLen: -1 };
this.messages = this.messages.concat([userMsg]);
this.inputText = '';
this.sending = true;
// 发送后聚焦新消息
setTimeout(() => {
this.scrollToBottom();
}, 50);
try {
const payload: AssistantPayload = { message: text };
const res = await api.post<ReplyResult>('/elderly-portal/assistant/reply/', payload);
let reply = res.reply && res.reply.length > 0 ? res.reply : '我会继续陪您聊天。';
if (res.auto_reserved && res.reservations.length > 0) {
reply += '\n已为您自动预订:' + res.reservations.map((r: AssistantReservation) => r.slot_name).join('、');
}
const botMsg: ChatMessage = { id: chatMessageSeq++, role: 'assistant', content: reply, displayLen: 0 };
this.messages = this.messages.concat([botMsg]);
// 打字机逐字显示回复
this.startTyping(botMsg.id, reply);
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
const botMsg: ChatMessage = { id: chatMessageSeq++, role: 'assistant', content: message, displayLen: -1 };
this.messages = this.messages.concat([botMsg]);
this.scrollToBottom();
} finally {
this.sending = false;
// 保存历史对话
this.saveHistory();
}
}
// 按打字机进度截取显示文本
private displayText(msg: ChatMessage): string {
if (msg.displayLen < 0) {
return msg.content;
}
return msg.content.substring(0, msg.displayLen);
}
// 按 id 从 @State messages 读取(保证打字机/更新重渲)
private messageById(id: number): ChatMessage {
const found = this.messages.find((m: ChatMessage) => m.id === id);
if (found) {
return found;
}
return { id: id, role: 'assistant', content: '', displayLen: -1 };
}
@Builder
MessageView(id: number) {
Row() {
if (this.messageById(id).role === 'user') {
Blank()
Text(this.displayText(this.messageById(id)))
.fontSize(Math.round(18 * this.scaleFor()))
.fontColor('#FFFFFF')
.constraintSize({ maxWidth: '70%' })
.padding(14)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
} else {
Text(this.displayText(this.messageById(id)))
.fontSize(Math.round(18 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.constraintSize({ maxWidth: '70%' })
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
Blank()
}
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 12 })
}
build() {
Column() {
Scroll(this.scroller) {
Column({ space: 6 }) {
if (this.messages.length === 0) {
Text('您好,我是您的智能助手 🌟\n可以直接对我说,例如:\n“帮我预订明天的午餐”')
.fontSize(Math.round(18 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.textAlign(TextAlign.Center)
.width('100%')
.padding({ top: 60 })
}
ForEach(this.messages, (msg: ChatMessage) => {
this.MessageView(msg.id)
}, (msg: ChatMessage) => String(msg.id))
}
.width('100%')
.padding({ bottom: 20 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.align(Alignment.Top)
Row({ space: 10 }) {
TextInput({ placeholder: '输入想说的话...', text: this.inputText })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.fontSize(Math.round(18 * this.scaleFor()))
.height(52)
.layoutWeight(1)
.padding({ left: 16, right: 16 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.onChange((value: string) => {
this.inputText = value;
})
.onSubmit((enterKey: EnterKeyType) => {
// 回车直接发送
this.send();
})
Button(this.sending ? '…' : '发送')
.height(52)
.padding({ left: 18, right: 18 })
.constraintSize({ minWidth: 80 })
.fontSize(Math.round(18 * this.scaleFor()))
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
.enabled(!this.sending)
.onClick(() => {
this.send();
})
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 12 })
// AI 声明
Text('内容由 AI 生成,仅供参考,如有疑问请咨询护理人员')
.fontSize(11)
.fontColor($r('app.color.inactive_tab'))
.textAlign(TextAlign.Center)
.width('100%')
.padding({ bottom: 8 })
}
.width('100%')
.height('100%')
}
}
@@ -0,0 +1,353 @@
// 老人详情页(ArkTS 严格类型,无 any)
// 数据来源:GET /elderly/{id}/
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { normalizeError } from '../../common/ApiConstants';
import { api } from '../../utils/Request';
interface ElderlyDetailData {
id: number;
name: string | null;
gender: string | null;
age: number | null;
birth_date: string | null;
plain_id_card: string | null;
room_bed: string | null;
floor_room_bed: string | null;
care_level: string | null;
care_level_label: string | null;
care_status: string | null;
overview_status: string | null;
risks: Array<string> | null;
medications: string | null;
medical_history: string | null;
allergies: string | null;
current_caregiver_name: string | null;
primary_family_name: string | null;
primary_family_relation: string | null;
primary_family_phone: string | null;
emergency_contact: string | null;
emergency_phone: string | null;
phone: string | null;
}
function genderLabel(gender: string | null): string {
if (gender === 'male') {
return '男';
}
if (gender === 'female') {
return '女';
}
return '未录入';
}
function displayEmpty(value: string | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
const text = String(value).trim();
return text.length > 0 ? text : '—';
}
@Entry
@Component
struct ElderlyDetail {
@State detail: ElderlyDetailData | null = null;
@State loading: boolean = true;
private elderlyId: string = '';
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
const params = router.getParams() as Record<string, string> | undefined;
if (params) {
this.elderlyId = params['id'] ?? '';
}
this.loadDetail();
}
private async loadDetail(): Promise<void> {
if (this.elderlyId.length === 0) {
this.loading = false;
return;
}
try {
this.detail = await api.get<ElderlyDetailData>(`/elderly/${this.elderlyId}/`);
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
@Builder
InfoRow(label: string, value: string) {
Row({ space: 12 }) {
Text(label)
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.width(84)
Text(displayEmpty(value))
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.textAlign(TextAlign.Start)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
}
@Builder
RiskTag(tag: string) {
Text(tag)
.fontSize(13)
.fontColor($r('app.color.accent_red'))
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor('#22FF4D4D')
.borderRadius(8)
.margin({ right: 8, bottom: 8 })
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else if (!this.detail) {
Text('未找到该老人信息')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.margin({ top: 60 })
} else {
Scroll(this.scroller) {
Column({ space: 14 }) {
// 基本信息卡
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(this.detail.name ?? '')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
if (this.detail.overview_status && this.detail.overview_status.length > 0) {
Text(this.detail.overview_status)
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor($r('app.color.accent_green'))
.borderRadius(8)
}
}
Text(`${genderLabel(this.detail.gender)} | 年龄:${this.detail.age ?? '未录入'} | ID: ${this.detail.id}`)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
if (this.detail.plain_id_card && this.detail.plain_id_card.length > 0) {
Text(this.detail.plain_id_card)
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
}
}
.width('100%')
.padding(18)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 护理等级 + 房间
Column({ space: 6 }) {
Row() {
Text('护理等级')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
Blank()
Text(displayEmpty(this.detail.care_level_label))
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.accent_blue'))
}
.width('100%')
Divider().color($r('app.color.inactive_tab'))
Text(displayEmpty(this.detail.floor_room_bed))
.fontSize(15)
.fontColor($r('app.color.text_primary'))
.width('100%')
}
.width('100%')
.padding(18)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 风险标签
if (this.detail.risks && this.detail.risks.length > 0) {
Column({ space: 8 }) {
Text('风险关注')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.detail.risks, (risk: string) => {
if (risk && risk.length > 0) {
this.RiskTag(risk)
}
}, (risk: string) => risk)
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
// 状态卡
Column({ space: 6 }) {
Text('护理状态')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Row() {
Text('状态')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
Blank()
Text(displayEmpty(this.detail.care_status))
.fontSize(15)
.fontColor($r('app.color.accent_green'))
}
.width('100%')
Row() {
Text('用药')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
Blank()
Text(displayEmpty(this.detail.medications))
.fontSize(15)
.fontColor($r('app.color.text_primary'))
}
.width('100%')
Row() {
Text('备注')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
Blank()
Text(displayEmpty(this.detail.medical_history))
.fontSize(15)
.fontColor($r('app.color.text_primary'))
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 护理员
Column({ space: 6 }) {
Text('护理员')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Text(displayEmpty(this.detail.current_caregiver_name))
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
// 联系人
Column({ space: 10 }) {
Text('联系人')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.detail.primary_family_name && this.detail.primary_family_name.trim().length > 0) {
Row() {
Text(this.detail.primary_family_name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
if (this.detail.primary_family_relation && this.detail.primary_family_relation.trim().length > 0) {
Text(this.detail.primary_family_relation)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.margin({ left: 8 })
}
Blank()
if (this.detail.primary_family_phone && this.detail.primary_family_phone.trim().length > 0) {
Text(this.detail.primary_family_phone)
.fontSize(14)
.fontColor($r('app.color.accent_blue'))
}
}
.width('100%')
} else {
Text('暂无配置')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
.margin({ bottom: 30 })
}
.width('92%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 60 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.width('100%')
}
}
}
.width('100%')
.height('100%')
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '关注对象详情',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,202 @@
// 长者端 · 快速拨号(ArkTS 严格类型,无 any)
// 拨号:call.makeCall 跳转拨号界面,由用户确认拨打
// 家属列表:GET /elderly/bindings/(长者角色只能看到绑定自己的记录,含家属姓名/电话)
import { promptAction } from '@kit.ArkUI';
import { InsightIntentPage } from '@kit.AbilityKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { call } from '@kit.TelephonyKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { Page } from '../../common/ApiConstants';
import { api } from '../../utils/Request';
interface DialContact {
name: string;
phone: string;
desc: string;
color: string;
}
interface ApiFamilyBinding {
id: number;
user_name: string;
user_phone: string;
}
const CONTACTS: Array<DialContact> = [
{ name: '服务中心', phone: '020-12345678', desc: '院内服务台,任何需求都可以找', color: '#007AFF' },
{ name: '急救电话', phone: '120', desc: '突发身体不适时拨打', color: '#FF3B30' },
{ name: '火警电话', phone: '119', desc: '发现火情时拨打', color: '#FF9500' },
{ name: '报警电话', phone: '110', desc: '需要警察帮助时拨打', color: '#8E8E93' },
];
@InsightIntentPage({
intentName: 'CallFamily',
domain: 'ElderlyCare',
intentVersion: '1.0.0',
displayName: '呼叫家人',
displayDescription: '打开智慧护老的拨号页,一键拨打家人或护理员电话',
uiAbility: 'EntryAbility',
pagePath: 'ets/pages/elderly/ElderlyDial',
keywords: ['打电话', '呼叫', '家人', '护理员', '电话', '求助'],
llmDescription: '打开一键拨号页,拨打家人、护理员或养老院前台电话',
})
@Entry
@Component
struct ElderlyDial {
@State familyContacts: Array<DialContact> = [];
@State familyLoading: boolean = true;
aboutToAppear(): void {
this.loadFamily();
}
// 拉取绑定自己的家属(无电话的不展示)
private async loadFamily(): Promise<void> {
this.familyLoading = true;
try {
const res = await api.get<Page<ApiFamilyBinding>>('/elderly/bindings/', { page_size: 50 });
const contacts: Array<DialContact> = [];
res.results.forEach((b: ApiFamilyBinding): void => {
const phone = typeof b.user_phone === 'string' ? b.user_phone.trim() : '';
const name = typeof b.user_name === 'string' ? b.user_name.trim() : '';
if (phone.length > 0) {
contacts.push({
name: name.length > 0 ? name : '家属',
phone: phone,
desc: '您的家属,有事随时联系',
color: '#34C759',
});
}
});
this.familyContacts = contacts;
} catch (err) {
console.error('[ElderlyDial] load family bindings failed:', (err as Error).message);
} finally {
this.familyLoading = false;
}
}
private dial(phone: string): void {
call.makeCall(phone).catch(() => {
promptAction.showToast({ message: '拨号失败,请手动拨打' });
});
}
@Builder
ContactCard(contact: DialContact) {
Row({ space: 14 }) {
Column() {
Text(contact.name.substring(0, 1))
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(56)
.height(56)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor(contact.color)
.borderRadius(28)
Column({ space: 4 }) {
Text(contact.name)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(contact.desc)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
Text(contact.phone)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#007AFF')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('拨打')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
.backgroundColor('#34C759')
.borderRadius(22)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(18)
.alignItems(VerticalAlign.Center)
.onClick(() => {
this.dial(contact.phone);
})
}
build() {
HdsNavigation() {
Scroll() {
Column({ space: 14 }) {
Text('点击卡片即可拨打电话')
.fontSize(14)
.fontColor($r('app.color.text_secondary'))
.width('100%')
// 家属(动态:绑定我的家属账号)
if (this.familyContacts.length > 0) {
Text('我的家属')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
}
ForEach(this.familyContacts, (contact: DialContact) => {
this.ContactCard(contact)
}, (contact: DialContact) => contact.phone)
// 常用电话
Text('常用电话')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
ForEach(CONTACTS, (contact: DialContact) => {
this.ContactCard(contact)
}, (contact: DialContact) => contact.phone)
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 30 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '快速拨号',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,167 @@
// 老人端 · 历史订餐(ArkTS 严格类型,无 any)
// 数据来源:/elderly-portal/reservations/history/
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface MealHistoryItem {
slot_id: string;
service_date: string;
category: string;
menu: string;
ordered: boolean;
updated_at: string;
}
interface MealHistoryResponse {
reservations: Array<MealHistoryItem>;
}
@Component
export struct ElderlyHistoryPanel {
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
@State items: Array<MealHistoryItem> = [];
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
aboutToAppear(): void {
this.loadHistory();
}
private async loadHistory(): Promise<void> {
this.loading = true;
try {
const res = await api.get<MealHistoryResponse>('/elderly-portal/reservations/history/');
this.items = res.reservations;
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
@Builder
HistoryCard(item: MealHistoryItem) {
Column({ space: 8 }) {
Row() {
Text(item.category)
.fontSize(Math.round(18 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.accent_blue'))
Blank()
Text(item.service_date)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
if (item.menu && item.menu.length > 0) {
Text(item.menu)
.fontSize(Math.round(16 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.width('100%')
}
Text(item.ordered ? '已预订' : '已取消')
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor('#FFFFFF')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(item.ordered ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
.borderRadius(8)
}
.width('100%')
.padding(18)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 80 })
} else if (this.items.length === 0) {
Column({ space: 8 }) {
Text('🍽️')
.fontSize(40)
Text('暂无订餐历史')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.margin({ top: 100 })
.alignItems(HorizontalAlign.Center)
} else {
Scroll(this.scroller) {
Column({ space: 10 }) {
ForEach(this.items, (item: MealHistoryItem) => {
this.HistoryCard(item)
}, (item: MealHistoryItem) => `${item.slot_id}-${item.service_date}`)
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 24 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '历史订餐',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@Entry
@Component
struct ElderlyHistory {
build() {
Column() {
ElderlyHistoryPanel()
}
.width('100%')
.height('100%')
.backgroundColor($r('app.color.background'))
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,205 @@
// 关注对象(老人列表)页(ArkTS 严格类型,无 any)
// 数据来源:GET /elderly/?page_size=
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { Page, normalizeError } from '../../common/ApiConstants';
import { api } from '../../utils/Request';
interface ElderlyItem {
id: number;
name: string;
gender: string;
age: number | null;
room_bed: string;
floor_room_bed: string;
care_level_label: string;
overview_status: string;
}
// 详情页路由参数
interface DetailParams {
id: string;
}
function genderLabel(gender: string): string {
if (gender === 'male') {
return '男';
}
if (gender === 'female') {
return '女';
}
return '未录入';
}
@Entry
@Component
struct ElderlyList {
@State elderly: Array<ElderlyItem> = [];
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.loadList();
}
private async loadList(): Promise<void> {
this.loading = true;
try {
const res = await api.get<Page<ElderlyItem>>('/elderly/', { page_size: 100 });
this.elderly = res.results;
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
private openDetail(item: ElderlyItem): void {
const params: DetailParams = { id: String(item.id) };
router.pushUrl({
url: 'pages/elderly/ElderlyDetail',
params: params,
}).catch((err: BusinessError) => {
promptAction.showToast({ message: `页面打开失败:${normalizeError(err)}` });
});
}
@Builder
ElderlyCard(item: ElderlyItem) {
Row({ space: 12 }) {
// 首字头像
Column() {
Text(item.name.length > 0 ? item.name.substring(0, 1) : '老')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(52)
.height(52)
.justifyContent(FlexAlign.Center)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(26)
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(item.name)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
if (item.overview_status && item.overview_status.length > 0) {
Text(item.overview_status)
.fontSize(12)
.fontColor('#FFFFFF')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor($r('app.color.accent_green'))
.borderRadius(6)
}
Blank()
Text('›')
.fontSize(20)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
Row({ space: 8 }) {
Text(`${genderLabel(item.gender)}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
Text(`年龄:${item.age ?? '未录入'}`)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
Text(item.care_level_label && item.care_level_label.length > 0 ? item.care_level_label : '')
.fontSize(13)
.fontColor($r('app.color.accent_blue'))
}
.width('100%')
if (item.floor_room_bed && item.floor_room_bed.length > 0) {
Text(item.floor_room_bed)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(VerticalAlign.Center)
.onClick(() => {
this.openDetail(item);
})
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else if (this.elderly.length === 0) {
Column({ space: 8 }) {
Text('👴')
.fontSize(36)
Text('暂无关注对象')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.margin({ top: 80 })
.alignItems(HorizontalAlign.Center)
} else {
Scroll(this.scroller) {
Column({ space: 12 }) {
ForEach(this.elderly, (item: ElderlyItem) => {
this.ElderlyCard(item)
}, (item: ElderlyItem) => String(item.id))
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 20 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.width('100%')
}
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '关注对象',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,240 @@
// 老人端 · 餐食详情(ArkTS 严格类型,无 any)
// 数据来源:GET /elderly-portal/meals/{id}/?service_date=
// 操作:POST /elderly-portal/meals/{id}/reservation/
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { BusinessError } from '@kit.BasicServicesKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
interface ElderlyMeal {
id: string;
name: string;
time: string;
menu: string;
description: string;
ordered: boolean;
reservable?: boolean;
reservation_hint?: string;
type: string;
service_date?: string;
}
interface ReservationPayload {
ordered: boolean;
service_date?: string;
}
function todayText(): string {
const d = new Date();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}
// 校验日期是否为 YYYY-MM-DD 格式
function isValidDate(date: string): boolean {
const pattern = /^\d{4}-\d{2}-\d{2}$/;
return pattern.test(date);
}
@Entry
@Component
struct ElderlyMealDetail {
@State meal: ElderlyMeal | null = null;
@State loading: boolean = true;
@State toggling: boolean = false;
private mealId: string = '';
private serviceDate: string = '';
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
const params = router.getParams() as Record<string, string> | undefined;
if (params) {
this.mealId = params['mealId'] ?? '';
this.serviceDate = params['serviceDate'] ?? '';
}
this.loadMeal();
}
private async loadMeal(): Promise<void> {
if (this.mealId.length === 0) {
this.loading = false;
return;
}
// 标准化 service_date 为 YYYY-MM-DD(截取前 10 位,避免带时间/空格)
const normalized = this.serviceDate.length >= 10 ? this.serviceDate.substring(0, 10) : this.serviceDate;
const finalDate = isValidDate(normalized) ? normalized : todayText();
try {
const url = `/elderly-portal/meals/${this.mealId}/?service_date=${finalDate}`;
console.info(`[MealDetail] request url=${url} rawServiceDate=${this.serviceDate} normalized=${normalized}`);
this.meal = await api.get<ElderlyMeal>(url);
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
private async toggleOrder(): Promise<void> {
const meal = this.meal;
if (!meal || this.toggling) {
return;
}
if (meal.reservable === false) {
promptAction.showToast({ message: meal.reservation_hint || '当前餐次不可操作' });
return;
}
this.toggling = true;
try {
const payload: ReservationPayload = { ordered: !meal.ordered };
if (this.serviceDate.length > 0) {
payload.service_date = this.serviceDate;
}
const updated = await api.post<ElderlyMeal>(`/elderly-portal/meals/${meal.id}/reservation/`, payload);
this.meal = updated;
promptAction.showToast({ message: updated.ordered ? '预订成功' : '已取消预订' });
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.toggling = false;
}
}
private statusLabel(): string {
const meal = this.meal;
if (!meal) {
return '';
}
if (meal.ordered) {
return '已预订';
}
if (meal.reservable === false) {
return '不可预订';
}
return '未预订';
}
@Builder
InfoRow(label: string, value: string) {
Row({ space: 12 }) {
Text(label)
.fontSize(18)
.fontColor($r('app.color.text_secondary'))
.width(90)
Text(value && value.length > 0 ? value : '--')
.fontSize(18)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
build() {
HdsNavigation() {
Column() {
if (this.loading) {
Text('加载中...')
.fontSize(16)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 60 })
} else if (!this.meal) {
Text('未找到该餐次信息')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
.margin({ top: 60 })
} else {
Scroll(this.scroller) {
Column({ space: 16 }) {
Text(this.meal.name)
.fontSize(30)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Column({ space: 14 }) {
this.InfoRow('用餐时间', this.meal.time)
this.InfoRow('服务日期', this.meal.service_date ?? this.serviceDate)
this.InfoRow('菜单', this.meal.menu)
this.InfoRow('说明', this.meal.description)
}
.width('100%')
.padding(20)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Start)
if (this.meal.reservation_hint && this.meal.reservation_hint.length > 0) {
Text(this.meal.reservation_hint)
.fontSize(15)
.fontColor('#FF9500')
.width('100%')
}
Row({ space: 10 }) {
Text(this.statusLabel())
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor(this.meal.ordered ? $r('app.color.accent_green') : $r('app.color.text_secondary'))
Blank()
Button(this.toggling ? '处理中...' : (this.meal.ordered ? '取消预订' : '立即预订'))
.height(52)
.fontSize(20)
.fontColor('#FFFFFF')
.backgroundColor(this.meal.ordered ? $r('app.color.accent_red') : $r('app.color.accent_green'))
.borderRadius(14)
.enabled(!this.toggling)
.onClick(() => {
this.toggleOrder();
})
}
.width('100%')
.padding(20)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
}
.width('92%')
.padding({ top: 60, bottom: 30 })
.alignItems(HorizontalAlign.Center)
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.width('100%')
}
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '餐食详情',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,546 @@
// 老人端 · 餐饮服务(定制餐点单)(ArkTS 严格类型,无 any)
// 接口(与 Web 管理端/小程序一致):
// GET /meals/custom-menu/?service_date=&category=
// GET /meals/custom-orders/
// POST /meals/custom-orders/
// POST /meals/custom-orders/{id}/cancel/
import { router } from '@kit.ArkUI';
import { InsightIntentPage } from '@kit.AbilityKit';
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, EmptyBody, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
type MealCategory = string; // '早餐' | '午餐' | '晚餐'
interface StandardMenuItem {
id: number;
name: string;
dish_type: string;
}
interface CustomMenuItem {
id: number;
source: string; // 'menu' | 'recommendation'
name: string;
price: string;
dish_type: string;
reason: string;
}
interface CustomMenuResponse {
standard_menu: Array<StandardMenuItem>;
custom_menu: Array<CustomMenuItem>;
}
interface OrderItemBrief {
dish_name: string;
quantity: number;
}
interface CustomOrder {
id: number;
service_date: string;
category: string;
status: string;
items: Array<OrderItemBrief>;
}
interface CustomOrdersResponse {
orders: Array<CustomOrder>;
}
function customOrderStatusLabel(status: string): string {
if (status === 'active') {
return '有效';
}
if (status === 'cancelled') {
return '已取消';
}
return status;
}
function customOrderStatusColor(status: string): string {
if (status === 'active') {
return '#34C759';
}
if (status === 'cancelled') {
return '#8E8E93';
}
return '#8E8E93';
}
interface OrderItemPayload {
menu_item_id?: number;
recommendation_id?: number;
quantity: number;
}
interface CreateOrderPayload {
service_date: string;
category: string;
items: Array<OrderItemPayload>;
}
interface DishKeyItem {
item: CustomMenuItem;
key: string;
}
const CATEGORIES: Array<string> = ['早餐', '午餐', '晚餐'];
function tomorrowText(): string {
const d = new Date();
d.setDate(d.getDate() + 1);
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}
function dishKey(item: CustomMenuItem): string {
return `${item.source}:${item.id}`;
}
function categoryIcon(category: string): string {
if (category === '早餐') {
return '🌅';
}
if (category === '午餐') {
return '🍚';
}
return '🌙';
}
@InsightIntentPage({
intentName: 'ViewMealOrder',
domain: 'ElderlyCare',
intentVersion: '1.0.0',
displayName: '查看我的订餐',
displayDescription: '打开智慧护老的订餐页,查看今日和明日的菜单并预订',
uiAbility: 'EntryAbility',
pagePath: 'ets/pages/elderly/ElderlyMealOrder',
keywords: ['订餐', '点餐', '吃饭', '菜单', '午餐', '晚餐', '早餐'],
llmDescription: '查看养老院长者的今日订餐与菜单,并可预订或取消餐食',
})
@Entry
@Component
struct ElderlyMealOrder {
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
@State serviceDate: string = tomorrowText();
@State category: string = '午餐';
@State customMenu: Array<CustomMenuItem> = [];
@State standardMenu: Array<StandardMenuItem> = [];
@State selectedKeys: Array<string> = [];
@State orders: Array<CustomOrder> = [];
@State loading: boolean = true;
@State submitting: boolean = false;
private scroller: Scroller = new Scroller();
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const responses: Array<CustomMenuResponse | CustomOrdersResponse | null> = await Promise.all([
api.get<CustomMenuResponse>('/meals/custom-menu/', {
service_date: this.serviceDate,
category: this.category,
}).catch((): CustomMenuResponse | null => null),
api.get<CustomOrdersResponse>('/meals/custom-orders/').catch((): CustomOrdersResponse | null => null),
]);
const menuRes = responses[0] as CustomMenuResponse | null;
const orderRes = responses[1] as CustomOrdersResponse | null;
this.standardMenu = menuRes ? menuRes.standard_menu : [];
this.customMenu = menuRes ? menuRes.custom_menu : [];
this.orders = orderRes ? orderRes.orders : [];
} catch (err) {
promptAction.showToast({ message: normalizeError(err as BusinessError | Error) });
} finally {
this.loading = false;
}
}
private personalRecommendations(): Array<CustomMenuItem> {
return this.customMenu.filter((item: CustomMenuItem) => item.source === 'recommendation');
}
private selectableDishes(): Array<CustomMenuItem> {
return this.customMenu.filter((item: CustomMenuItem) => item.source === 'menu');
}
private isSelected(item: CustomMenuItem): boolean {
return this.selectedKeys.indexOf(dishKey(item)) >= 0;
}
private toggleDish(item: CustomMenuItem): void {
const key = dishKey(item);
if (this.selectedKeys.indexOf(key) >= 0) {
this.selectedKeys = this.selectedKeys.filter((value: string) => value !== key);
} else {
this.selectedKeys = this.selectedKeys.concat([key]);
}
}
private submitBtnText(): string {
if (this.submitting) {
return '提交中...';
}
if (this.selectedKeys.length === 0) {
return '请先选择菜品';
}
if (this.scaleFor() >= 1.5) {
return `提交定制餐(${this.selectedKeys.length})`;
}
return `提交定制餐(已选${this.selectedKeys.length} 项)`;
}
private selectedDishes(): Array<CustomMenuItem> {
return this.customMenu.filter((item: CustomMenuItem) => this.selectedKeys.indexOf(dishKey(item)) >= 0);
}
private async createOrder(): Promise<void> {
const selected = this.selectedDishes();
if (selected.length === 0 || this.submitting) {
return;
}
this.submitting = true;
try {
const items: Array<OrderItemPayload> = selected.map((item: CustomMenuItem): OrderItemPayload => {
const payload: OrderItemPayload = { quantity: 1 };
if (item.source === 'recommendation') {
payload.recommendation_id = item.id;
} else {
payload.menu_item_id = item.id;
}
return payload;
});
const body: CreateOrderPayload = {
service_date: this.serviceDate,
category: this.category,
items: items,
};
await api.post('/meals/custom-orders/', body);
this.selectedKeys = [];
promptAction.showToast({ message: '已提交定制餐' });
await this.load();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.submitting = false;
}
}
private async cancelOrder(orderId: number): Promise<void> {
if (this.submitting) {
return;
}
this.submitting = true;
try {
await api.post(`/meals/custom-orders/${orderId}/cancel/`, new EmptyBody());
promptAction.showToast({ message: '已取消定制餐' });
await this.load();
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.submitting = false;
}
}
@Builder
CategoryTab(category: string) {
Column({ space: 4 }) {
Text(categoryIcon(category))
.fontSize(Math.round(22 * this.scaleFor()))
Text(category)
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(this.category === category ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.category === category ? $r('app.color.accent_blue') : $r('app.color.text_secondary'))
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.backgroundColor(this.category === category ? '#14007AFF' : Color.Transparent)
.borderRadius(12)
.onClick(() => {
if (this.category !== category) {
this.category = category;
this.selectedKeys = [];
this.load();
}
})
}
@Builder
DishCard(item: CustomMenuItem) {
Row({ space: 10 }) {
Column({ space: 4 }) {
Text(item.name)
.fontSize(Math.round(17 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 6 }) {
if (item.price && item.price.length > 0) {
Text(`¥${item.price}`)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor('#FF9500')
}
if (item.source === 'recommendation' && item.reason && item.reason.length > 0) {
Text(item.reason)
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.accent_blue'))
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1)
}
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(this.isSelected(item) ? '✓' : '+')
.fontSize(Math.round(18 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.width(32)
.height(32)
.textAlign(TextAlign.Center)
.backgroundColor(this.isSelected(item) ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
.borderRadius(16)
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.border({
width: 2,
color: this.isSelected(item) ? $r('app.color.accent_green') : Color.Transparent
})
.onClick(() => {
this.toggleDish(item);
})
}
@Builder
OrderCard(order: CustomOrder) {
Column({ space: 8 }) {
Row() {
Text(`${order.category} · ${order.service_date}`)
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Blank()
Text(customOrderStatusLabel(order.status))
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor(customOrderStatusColor(order.status))
}
.width('100%')
Text(order.items.map((item: OrderItemBrief) => `${item.dish_name}×${item.quantity}`).join('、'))
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width('100%')
if (order.status === 'active') {
Button('取消定制餐')
.height(32)
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_red'))
.borderRadius(10)
.onClick(() => {
this.cancelOrder(order.id);
})
}
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 10 }) {
// 日期 + 餐次切换
Column({ space: 10 }) {
Text(`服务日期${this.serviceDate}(明日)`)
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width('100%')
Row({ space: 8 }) {
ForEach(CATEGORIES, (category: string) => {
this.CategoryTab(category)
}, (category: string) => category)
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
if (this.loading) {
Text('加载中...')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
.width('100%')
.padding({ top: 40, bottom: 40 })
.textAlign(TextAlign.Center)
} else {
// 个人推荐
if (this.personalRecommendations().length > 0) {
Text('为您推荐')
.fontSize(Math.round(19 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
ForEach(this.personalRecommendations(), (item: CustomMenuItem) => {
this.DishCard(item)
}, (item: CustomMenuItem) => dishKey(item))
}
// 标准菜单(只读)
if (this.standardMenu.length > 0) {
Text('标准菜单')
.fontSize(Math.round(19 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
ForEach(this.standardMenu, (item: StandardMenuItem) => {
Row({ space: 10 }) {
Text(item.name)
.fontSize(Math.round(16 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (item.dish_type && item.dish_type.length > 0) {
Text(item.dish_type)
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
}, (item: StandardMenuItem) => String(item.id))
}
// 可选菜品(可定制加菜)
Text('可选定制菜')
.fontSize(Math.round(19 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.selectableDishes().length === 0) {
Text('暂无可选定制菜')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.textAlign(TextAlign.Center)
} else {
ForEach(this.selectableDishes(), (item: CustomMenuItem) => {
this.DishCard(item)
}, (item: CustomMenuItem) => dishKey(item))
}
// 提交
Button(this.submitBtnText())
.width('100%')
.height(52)
.fontSize(Math.round(18 * this.scaleFor()))
.fontColor('#FFFFFF')
.backgroundColor(this.selectedKeys.length > 0 ? $r('app.color.accent_green') : $r('app.color.inactive_tab'))
.borderRadius(14)
.enabled(this.selectedKeys.length > 0 && !this.submitting)
.onClick(() => {
this.createOrder();
})
// 我的定制餐
Text('我的定制餐')
.fontSize(Math.round(19 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.orders.length === 0) {
Text('暂无定制餐订单')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.textAlign(TextAlign.Center)
} else {
ForEach(this.orders, (order: CustomOrder) => {
this.OrderCard(order)
}, (order: CustomOrder) => String(order.id))
}
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 24 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '餐饮服务',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,225 @@
// 老人端 · 我的面板(ArkTS 严格类型,无 any)
// 对齐小程序 ElderlyProfilePanel:账号卡 + 菜单 + 退出登录
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { AuthStore } from '../../utils/AuthStore';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { FontScaleLevel, FontScaleOption, FONT_SCALE_LABELS, FONT_SCALE_MAP, loadFontScale, setFontScale } from '../../utils/FontScale';
interface ProfileDashboard {
elderly_id: number | null;
elderly_name: string;
service_date: string;
}
interface ProfileMenu {
key: string;
title: string;
desc: string;
route: string; // 空字符串表示未实现
}
@Component
export struct ElderlyProfilePanel {
@State userName: string = '';
@State userId: number = 0;
@State elderlyName: string = '';
@State fontScale: FontScaleLevel = 'normal';
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
menus: Array<ProfileMenu> = [
{ key: 'meal', title: '餐饮服务', desc: '查看并选择定制餐', route: 'pages/elderly/ElderlyMealOrder' },
{ key: 'activities', title: '活动安排', desc: '查看今天和本周的社工活动', route: 'pages/elderly/Activities' },
{ key: 'notifications', title: '消息通知', desc: '查看护理记录和订餐状态消息', route: 'pages/notifications/Notifications' },
{ key: 'history', title: '历史订餐', desc: '查看过往和近期的订餐记录', route: 'pages/elderly/ElderlyHistory' },
];
aboutToAppear(): void {
this.loadAuth();
this.loadDashboard();
loadFontScale(getContext(this) as common.UIAbilityContext).then((level: FontScaleLevel) => {
this.fontScale = level;
});
}
private async loadDashboard(): Promise<void> {
try {
const res = await api.get<ProfileDashboard>('/elderly-portal/dashboard/');
this.elderlyName = res.elderly_name || '';
} catch (err) {
console.warn('[ElderlyProfile] load dashboard failed:', normalizeError(err as BusinessError | Error));
}
}
private changeFontScale(level: FontScaleLevel): void {
this.fontScale = level;
setFontScale(getContext(this) as common.UIAbilityContext, level);
promptAction.showToast({ message: `字体已调整为${FONT_SCALE_LABELS.find((item) => item.level === level)?.label ?? ''}` });
}
private async loadAuth(): Promise<void> {
const state = await AuthStore.getState();
this.userName = state.userName;
this.userId = state.userId;
}
private onMenuClick(menu: ProfileMenu): void {
if (menu.route.length === 0) {
promptAction.showToast({ message: '功能开发中' });
return;
}
router.pushUrl({ url: menu.route }).catch(() => {});
}
private async logout(): Promise<void> {
await AuthStore.logout();
promptAction.showToast({ message: '已退出登录' });
router.replaceUrl({ url: 'pages/Login' }).catch(() => {});
}
@Builder
MenuRow(menu: ProfileMenu) {
Row({ space: 12 }) {
Column({ space: 6 }) {
Text(menu.title)
.fontSize(Math.round(17 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Text(menu.desc)
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('›')
.fontSize(Math.round(22 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.onMenuClick(menu);
})
}
build() {
Scroll() {
Column({ space: 12 }) {
// 用户信息卡
Column({ space: 8 }) {
Stack() {
Circle()
.width(64)
.height(64)
.fill($r('app.color.accent_blue'))
Text((this.elderlyName || this.userName || '客').substring(0, 1))
.fontSize(Math.round(26 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
}
.width(64)
.height(64)
Text(this.elderlyName || this.userName || '用户')
.fontSize(Math.round(22 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text(this.elderlyName.length > 0 ? '长者账号' : '账号')
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.width('92%')
.padding({ top: 16, bottom: 14 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.margin({ top: 16 })
// 账号编号
Row() {
Text('账号编号')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
Blank()
Text(this.userId > 0 ? String(this.userId) : '--')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
}
.width('92%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
// 字体大小
Column({ space: 8 }) {
Text('字体大小')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.width('100%')
Flex({ wrap: FlexWrap.Wrap, alignItems: ItemAlign.Center }) {
ForEach(FONT_SCALE_LABELS, (item: FontScaleOption) => {
Text(item.label)
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor(this.fontScale === item.level ? '#FFFFFF' : $r('app.color.text_primary'))
.padding({ left: 18, right: 18, top: 10, bottom: 10 })
.backgroundColor(this.fontScale === item.level ? $r('app.color.accent_blue') : $r('app.color.background_card'))
.borderRadius(10)
.margin({ right: 10, bottom: 10 })
.onClick(() => {
this.changeFontScale(item.level);
})
}, (item: FontScaleOption) => item.level)
}
.width('100%')
}
.width('92%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
// 菜单
Column({ space: 8 }) {
ForEach(this.menus, (menu: ProfileMenu) => {
this.MenuRow(menu)
}, (menu: ProfileMenu) => menu.key)
}
.width('92%')
// 退出登录
Text('退出登录')
.fontSize(Math.round(17 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.accent_red'))
.textAlign(TextAlign.Center)
.width('92%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
// 避让底部悬浮 Tab
.margin({ bottom: 130 })
.onClick(() => {
this.logout();
})
}
.width('100%')
.alignItems(HorizontalAlign.Center)
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.width('100%')
.height('100%')
}
}
@@ -0,0 +1,211 @@
// 老人端 · 膳食推荐(ArkTS 严格类型,无 any)
// 接口:GET /elderly-portal/recommendations/
import { router } from '@kit.ArkUI';
import { promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface Recommendation {
id: number;
service_date: string;
meal_period: string;
dish_name: string;
price: string;
reason: string;
}
interface RecommendationsResponse {
elderly_id: number | null;
recommendations: Array<Recommendation>;
}
function periodIcon(period: string): string {
if (period === '早餐') {
return '🌅';
}
if (period === '午餐') {
return '🍚';
}
return '🌙';
}
function periodColor(period: string): string {
if (period === '早餐') {
return '#FF9500';
}
if (period === '午餐') {
return '#007AFF';
}
return '#34C759';
}
function formatDateLabel(dateStr: string): string {
if (!dateStr || dateStr.length === 0) {
return '';
}
const parts: Array<string> = dateStr.split('-');
if (parts.length !== 3) {
return dateStr;
}
const month = parseInt(parts[1], 10);
const day = parseInt(parts[2], 10);
return `${month}月${day}日`;
}
function isToday(dateStr: string): boolean {
if (!dateStr || dateStr.length === 0) {
return false;
}
const now = new Date();
const mm = String(now.getMonth() + 1).padStart(2, '0');
const dd = String(now.getDate()).padStart(2, '0');
return dateStr === `${now.getFullYear()}-${mm}-${dd}`;
}
@Entry
@Component
struct ElderlyRecommendations {
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
@State recommendations: Array<Recommendation> = [];
@State loading: boolean = true;
private scroller: Scroller = new Scroller();
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const res = await api.get<RecommendationsResponse>('/elderly-portal/recommendations/');
this.recommendations = res.recommendations;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
RecommendCard(item: Recommendation) {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text(periodIcon(item.meal_period))
.fontSize(Math.round(24 * this.scaleFor()))
Text(`${item.meal_period} · ${isToday(item.service_date) ? '今日' : formatDateLabel(item.service_date)}`)
.fontSize(Math.round(17 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Blank()
Text(`¥${item.price}`)
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor('#FF9500')
}
.width('100%')
Text(item.dish_name)
.fontSize(Math.round(20 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor(periodColor(item.meal_period))
.width('100%')
if (item.reason && item.reason.length > 0) {
Text(item.reason)
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.lineHeight(22)
.width('100%')
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(18)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.loading) {
Text('加载中...')
.fontSize(Math.round(16 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else if (this.recommendations.length === 0) {
Column({ space: 8 }) {
Text('暂无膳食推荐')
.fontSize(Math.round(20 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
Text('护理团队会根据您的健康状况推送个性化餐食建议')
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.textAlign(TextAlign.Center)
}
.width('100%')
.padding(24)
.backgroundColor($r('app.color.background_card'))
.borderRadius(16)
.alignItems(HorizontalAlign.Center)
} else {
ForEach(this.recommendations, (item: Recommendation) => {
Column() {
this.RecommendCard(item)
}
.width('100%')
}, (item: Recommendation) => String(item.id))
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '膳食推荐',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,329 @@
// 长者端 · 天气(ArkTS 严格类型,无 any)
// 数据:Open-Meteo(utils/OpenMeteo.ts),广州 7 天预报
import { router } from '@kit.ArkUI';
import { InsightIntentPage } from '@kit.AbilityKit';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { fetchWeather, weatherCodeLabel, todayYMD, WeatherData, WeatherDay } from '../../utils/OpenMeteo';
function dateLabel(dateStr: string): string {
const today = todayYMD();
if (dateStr === today) {
return '今天';
}
if (dateStr.length < 5) {
return dateStr;
}
const md = dateStr.substring(5);
return md.replace('-', '/');
}
// 天气图标(与 weatherCodeLabel 同一套 code 区间)
function weatherEmoji(code: number): string {
if (code === 0 || code === 1) {
return '☀️';
}
if (code === 2) {
return '⛅';
}
if (code === 3) {
return '☁️';
}
if (code === 45 || code === 48) {
return '🌫️';
}
if (code >= 51 && code <= 57) {
return '🌦️';
}
if (code >= 71 && code <= 77 || code === 85 || code === 86) {
return '❄️';
}
if (code >= 95) {
return '⛈️';
}
return '🌧️';
}
// 温度图表常量:0~40° 映射到 160px
const CHART_MIN_T = 0;
const CHART_MAX_T = 40;
const CHART_HEIGHT = 160;
const CHART_PX_PER_DEGREE = CHART_HEIGHT / (CHART_MAX_T - CHART_MIN_T);
@InsightIntentPage({
intentName: 'ViewWeather',
domain: 'ElderlyCare',
intentVersion: '1.0.0',
displayName: '查看天气',
displayDescription: '打开智慧护老的天气页,查看养老院所在地七天天气',
uiAbility: 'EntryAbility',
pagePath: 'ets/pages/elderly/ElderlyWeather',
keywords: ['天气', '下雨', '气温', '出门', '降温'],
llmDescription: '查看养老院所在地的今日与未来七天天气预报',
})
@Entry
@Component
struct ElderlyWeather {
@State weather: WeatherData | null = null;
@State loading: boolean = true;
@State errorMsg: string = '';
aboutToAppear(): void {
// 与首页同源:优先用首页传入的定位坐标(-1 表示未定位,回退广州);无参数直接进入也回退
const params = router.getParams() as Record<string, Object> | null | undefined;
let lat = -1;
let lon = -1;
if (params !== null && params !== undefined) {
lat = Number(params['lat'] ?? -1);
lon = Number(params['lon'] ?? -1);
}
if (lat > 0 && lon > 0) {
this.load(lat, lon);
} else {
this.load(undefined, undefined);
}
}
private async load(lat?: number, lon?: number): Promise<void> {
this.loading = true;
this.errorMsg = '';
try {
const res = await fetchWeather(lat, lon);
this.weather = res;
} catch (err) {
this.errorMsg = (err as Error).message;
} finally {
this.loading = false;
}
}
// 当前温度映射到 0-40° 仪表区间
private gaugeValue(): number {
if (this.weather === null) {
return 0;
}
const t = Math.round(this.weather.current.temperature);
return Math.max(0, Math.min(40, t));
}
private todayRainText(): string {
const list = this.weather?.daily ?? [];
if (list.length > 0) {
return `今日降雨概率 ${list[0].rainPct}%`;
}
return '';
}
build() {
HdsNavigation() {
Scroll() {
Column({ space: 24 }) {
if (this.loading) {
Text('正在获取天气...')
.fontSize(16)
.fontColor($r('app.color.text_secondary'))
.padding({ top: 60 })
} else if (this.errorMsg.length > 0) {
Column({ space: 10 }) {
Text('天气获取失败')
.fontSize(18)
.fontColor($r('app.color.text_primary'))
Text(this.errorMsg)
.fontSize(13)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.padding({ top: 60 })
.alignItems(HorizontalAlign.Center)
} else {
// 今日温度仪表(官方 Gauge 样式)
Column({ space: 8 }) {
Row() {
Gauge({ value: this.gaugeValue(), min: 0, max: 40 }) {
Column() {
Text(this.weather !== null ? `${this.weather.current.temperature.toFixed(1)}°C` : '--')
.fontSize(34)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
.textAlign(TextAlign.Center)
.margin({ top: '35%' })
.maxLines(1)
Text(this.weather !== null ? `${weatherCodeLabel(this.weather.current.code)} · 广州今天` : '--')
.fontSize(14)
.fontColor($r('sys.color.ohos_id_color_text_secondary'))
.width('100%')
.textAlign(TextAlign.Center)
Text(this.todayRainText())
.fontSize(13)
.fontColor($r('sys.color.ohos_id_color_text_secondary'))
.width('100%')
.textAlign(TextAlign.Center)
}
.width('100%')
.height('100%')
.alignItems(HorizontalAlign.Center)
}
.startAngle(210)
.endAngle(150)
.colors([
[new LinearGradient([{ color: '#61cfbe', offset: 0 }, { color: '#46b1e3', offset: 1 }]), 1],
[new LinearGradient([{ color: '#c1e4be', offset: 0 }, { color: '#64bb5c', offset: 1 }]), 1],
[new LinearGradient([{ color: '#fceb99', offset: 0 }, { color: '#f7ce00', offset: 1 }]), 1],
[new LinearGradient([{ color: '#f8c5a6', offset: 0 }, { color: '#ed6f21', offset: 1 }]), 1]
])
.width(240)
.height(240)
.strokeWidth(18)
.trackShadow({ radius: 7, offsetX: 7, offsetY: 7 })
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
// 未来 7 天温度范围图表(竖向柱状:柱底=最低温,柱顶=最高温)
Text('未来 7 天')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
Column({ space: 12 }) {
Row({ space: 8 }) {
// 纵轴刻度
Column() {
Text('40°')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Text('30°')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Text('20°')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Text('10°')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Text('0°')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.height(CHART_HEIGHT)
.justifyContent(FlexAlign.SpaceBetween)
.alignItems(HorizontalAlign.End)
// 每日一列:图标 + 双柱(蓝=最低温/橙=最高温,柱高=温度) + 日期 + 降雨
ForEach(this.weather?.daily ?? [], (day: WeatherDay) => {
Column({ space: 4 }) {
Text(weatherEmoji(day.code))
.fontSize(20)
Row({ space: 6 }) {
Column({ space: 3 }) {
Text(`${Math.round(Math.max(day.minT, CHART_MIN_T))}°`)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor('#46b1e3')
Column()
.width(14)
.height(Math.min(Math.max(12, Math.round((Math.max(day.minT, CHART_MIN_T)) * CHART_PX_PER_DEGREE)), CHART_HEIGHT - 24))
.backgroundColor('#46b1e3')
.borderRadius(7)
}
.alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text(`${Math.round(Math.min(day.maxT, CHART_MAX_T))}°`)
.fontSize(11)
.fontWeight(FontWeight.Medium)
.fontColor('#ed6f21')
Column()
.width(14)
.height(Math.min(Math.max(12, Math.round((Math.min(day.maxT, CHART_MAX_T)) * CHART_PX_PER_DEGREE)), CHART_HEIGHT - 24))
.backgroundColor('#ed6f21')
.borderRadius(7)
}
.alignItems(HorizontalAlign.Center)
}
.height(CHART_HEIGHT)
.width('100%')
.alignItems(VerticalAlign.Bottom)
.justifyContent(FlexAlign.Center)
Text(dateLabel(day.date))
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.maxLines(1)
Text(`雨${day.rainPct}%`)
.fontSize(11)
.fontColor(day.rainPct >= 60 ? '#007AFF' : $r('app.color.text_secondary'))
.maxLines(1)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.accessibilityLevel('yes')
.accessibilityText(`${dateLabel(day.date)},${weatherCodeLabel(day.code)},最低${Math.round(day.minT)}度,最高${Math.round(day.maxT)}度,降雨概率${day.rainPct}%`)
}, (day: WeatherDay) => day.date)
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
Row({ space: 6 }) {
Column().width(14).height(14).borderRadius(7).backgroundColor('#46b1e3')
Text('最低温')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
Column().width(14).height(14).borderRadius(7).backgroundColor('#ed6f21')
Text('最高温')
.fontSize(12)
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
.justifyContent(FlexAlign.Center)
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(20)
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 120, bottom: 30 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '天气',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,266 @@
// 家属 · 预警详情(ArkTS 严格类型,无 any)
// 接口:GET /alerts/{id}/
import { router, promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface AlertProcess {
id: number;
nurse_name: string;
description: string;
created_at: string;
}
interface AlertDetail {
id: number;
elderly_name: string;
level: string;
metric: string;
actual_value: string;
title: string;
description: string;
status_display: string;
processing_by_name: string;
resolved_at: string;
created_at: string;
processes: Array<AlertProcess>;
}
function levelLabel(level: string): string {
if (level === 'emergency') {
return '紧急';
}
if (level === 'warning') {
return '注意';
}
return '正常';
}
function levelColor(level: string): string {
if (level === 'emergency') {
return '#FF3B30';
}
if (level === 'warning') {
return '#FF9500';
}
return '#007AFF';
}
function formatDateTime(iso: string): string {
if (!iso || iso.length === 0) {
return '--';
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return '--';
}
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${mm}/${dd} ${hh}:${mi}`;
}
@Entry
@Component
struct FamilyAlertDetail {
@State alert: AlertDetail | null = null;
@State loading: boolean = true;
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
const params = router.getParams() as Record<string, string | number>;
const id = params ? params['id'] : null;
if (id === null || id === undefined) {
router.back();
return;
}
this.load(Number(id));
}
private async load(id: number): Promise<void> {
this.loading = true;
try {
const res = await api.get<AlertDetail>(`/alerts/${id}/`);
this.alert = res;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
}
}
@Builder
InfoRow(label: string, value: string) {
Row({ space: 12 }) {
Text(label)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width(72)
Text(value)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
}
.width('100%')
}
@Builder
ProcessCard(process: AlertProcess) {
Column({ space: 6 }) {
Row() {
Text(process.nurse_name)
.fontSize(Math.round(14 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
Blank()
Text(formatDateTime(process.created_at))
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
Text(process.description)
.fontSize(Math.round(14 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else if (this.alert) {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text(levelLabel(this.alert.level))
.fontSize(Math.round(13 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor(levelColor(this.alert.level))
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.backgroundColor(this.alert.level === 'emergency' ? '#14FF3B30' : (this.alert.level === 'warning' ? '#14FF9500' : '#14007AFF'))
.borderRadius(6)
Text(this.alert.status_display)
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
}
.width('100%')
Text(this.alert.title)
.fontSize(Math.round(20 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
if (this.alert.description && this.alert.description.length > 0) {
Text(this.alert.description)
.fontSize(Math.round(15 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.lineHeight(Math.round(22 * this.scaleFor()))
.width('100%')
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
Column({ space: 12 }) {
this.InfoRow('长者', this.alert.elderly_name)
if (this.alert.metric && this.alert.metric.length > 0) {
this.InfoRow('指标', this.alert.metric)
}
if (this.alert.actual_value && this.alert.actual_value.length > 0) {
this.InfoRow('实际值', this.alert.actual_value)
}
this.InfoRow('触发时间', formatDateTime(this.alert.created_at))
if (this.alert.resolved_at && this.alert.resolved_at.length > 0) {
this.InfoRow('处理时间', formatDateTime(this.alert.resolved_at))
}
}
.width('100%')
.padding(16)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
if (this.alert.processes && this.alert.processes.length > 0) {
Column({ space: 8 }) {
Text('处理记录')
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_primary'))
.width('100%')
ForEach(this.alert.processes, (process: AlertProcess) => {
Column() {
this.ProcessCard(process)
}
.width('100%')
}, (process: AlertProcess) => String(process.id))
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.layoutWeight(1)
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '预警详情',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,230 @@
// 家属 · 健康预警列表(ArkTS 严格类型,无 any)
// 接口:GET /alerts/
import { router, promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { Page, normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface FamilyAlert {
id: number;
elderly_name: string;
level: string;
metric: string;
actual_value: string;
title: string;
description: string;
status_display: string;
created_at: string;
}
function levelColor(level: string): string {
if (level === 'emergency') {
return '#FF3B30';
}
if (level === 'warning') {
return '#FF9500';
}
return '#007AFF';
}
function levelLabel(level: string): string {
if (level === 'emergency') {
return '紧急';
}
if (level === 'warning') {
return '注意';
}
return '正常';
}
function formatDateTime(iso: string): string {
if (!iso || iso.length === 0) {
return '--';
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return '--';
}
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${mm}/${dd} ${hh}:${mi}`;
}
@Entry
@Component
struct FamilyAlerts {
@State alerts: Array<FamilyAlert> = [];
@State loading: boolean = true;
@State refreshing: boolean = false;
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
private scroller: Scroller = new Scroller();
aboutToAppear(): void {
this.load();
}
private async load(): Promise<void> {
this.loading = true;
try {
const res = await api.get<Page<FamilyAlert>>('/alerts/', {
page_size: 50,
ordering: '-created_at',
});
this.alerts = res.results;
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
promptAction.showToast({ message: message });
} finally {
this.loading = false;
this.refreshing = false;
}
}
private openDetail(alert: FamilyAlert): void {
router.pushUrl({ url: 'pages/family/FamilyAlertDetail', params: { id: alert.id } })
.catch((err: BusinessError) => {
promptAction.showToast({ message: `打开失败:${normalizeError(err)}` });
});
}
@Builder
AlertCard(alert: FamilyAlert) {
Row({ space: 10 }) {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(levelLabel(alert.level))
.fontSize(Math.round(12 * this.scaleFor()))
.fontWeight(FontWeight.Bold)
.fontColor(levelColor(alert.level))
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.backgroundColor(alert.level === 'emergency' ? '#14FF3B30' : (alert.level === 'warning' ? '#14FF9500' : '#14007AFF'))
.borderRadius(6)
Text(alert.title)
.fontSize(Math.round(16 * this.scaleFor()))
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.text_primary'))
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
if (alert.description && alert.description.length > 0) {
Text(alert.description)
.fontSize(Math.round(13 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
}
Row({ space: 8 }) {
Text(alert.elderly_name)
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.accent_blue'))
Blank()
Text(formatDateTime(alert.created_at))
.fontSize(Math.round(12 * this.scaleFor()))
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('›')
.fontSize(20)
.fontColor($r('app.color.inactive_tab'))
}
.width('100%')
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.onClick(() => {
this.openDetail(alert);
})
}
build() {
HdsNavigation() {
Column() {
Refresh({ refreshing: $$this.refreshing }) {
Scroll(this.scroller) {
Column({ space: 12 }) {
if (this.loading) {
Text('加载中...')
.fontSize(15)
.fontColor($r('app.color.inactive_tab'))
.margin({ top: 40 })
} else if (this.alerts.length === 0) {
Text('暂无预警')
.fontSize(15)
.fontColor($r('app.color.text_secondary'))
.width('100%')
.padding(24)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.textAlign(TextAlign.Center)
} else {
ForEach(this.alerts, (alert: FamilyAlert) => {
Column() {
this.AlertCard(alert)
}
.width('100%')
}, (alert: FamilyAlert) => String(alert.id))
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 92, bottom: 30 })
}
.width('100%')
.height('100%')
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
}
.width('100%')
.layoutWeight(1)
.onRefreshing(() => {
this.load();
})
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '健康预警',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}
@@ -0,0 +1,177 @@
// 家属 · 智能助手(ArkTS 严格类型,无 any)
// 接口:POST /robots/family-assistant/query/ {message} → {reply}
import { router, promptAction } from '@kit.ArkUI';
import { HdsNavigation, HdsNavigationTitleMode, ScrollEffectType } from '@kit.UIDesignKit';
import { hdsMaterial } from '@hms.hds.hdsMaterial';
import { BusinessError } from '@kit.BasicServicesKit';
import { normalizeError } from '../../common/ApiConstants';
import { api, RequestError } from '../../utils/Request';
import { FontScaleLevel, FONT_SCALE_MAP } from '../../utils/FontScale';
interface ChatMessage {
id: number;
role: string;
content: string;
}
interface AssistantReply {
reply: string;
}
let messageSeq = 1;
@Entry
@Component
struct FamilyAssistant {
@State inputText: string = '';
@State messages: Array<ChatMessage> = [];
@State sending: boolean = false;
@StorageProp('fontScale') fontScaleLevel: FontScaleLevel = 'system';
private scaleFor(): number {
return FONT_SCALE_MAP[this.fontScaleLevel] ?? 1.0;
}
private scroller: Scroller = new Scroller();
private async send(): Promise<void> {
const text = this.inputText.trim();
if (text.length === 0 || this.sending) {
return;
}
const userMsg: ChatMessage = { id: messageSeq++, role: 'user', content: text };
this.messages = this.messages.concat([userMsg]);
this.inputText = '';
this.sending = true;
setTimeout(() => {
this.scroller.scrollEdge(Edge.Bottom);
}, 50);
try {
const res = await api.post<AssistantReply>('/robots/family-assistant/query/', { message: text });
const reply = res.reply && res.reply.length > 0 ? res.reply : '好的,我会继续为您查询。';
this.messages = this.messages.concat([{ id: messageSeq++, role: 'assistant', content: reply }]);
this.scroller.scrollEdge(Edge.Bottom);
} catch (err) {
const message = err instanceof RequestError ? err.message : normalizeError(err as BusinessError | Error);
this.messages = this.messages.concat([{ id: messageSeq++, role: 'assistant', content: message }]);
this.scroller.scrollEdge(Edge.Bottom);
} finally {
this.sending = false;
}
}
@Builder
MessageView(msg: ChatMessage) {
Row() {
if (msg.role === 'user') {
Blank()
Text(msg.content)
.fontSize(Math.round(17 * this.scaleFor()))
.fontColor('#FFFFFF')
.constraintSize({ maxWidth: '72%' })
.padding(14)
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
} else {
Text(msg.content)
.fontSize(Math.round(17 * this.scaleFor()))
.fontColor($r('app.color.text_primary'))
.constraintSize({ maxWidth: '72%' })
.padding(14)
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
Blank()
}
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 12 })
}
build() {
HdsNavigation() {
Column() {
Scroll(this.scroller) {
Column({ space: 6 }) {
if (this.messages.length === 0) {
Text('您好,我是家属智能助手 🌟\n可以问询您家人的健康与照护情况,例如:\n“我妈今天状态怎么样?”')
.fontSize(Math.round(17 * this.scaleFor()))
.fontColor($r('app.color.text_secondary'))
.textAlign(TextAlign.Center)
.width('100%')
.padding({ top: 60 })
}
ForEach(this.messages, (msg: ChatMessage) => {
this.MessageView(msg)
}, (msg: ChatMessage) => String(msg.id))
}
.width('100%')
.padding({ top: 92, bottom: 20 })
}
.scrollBar(BarState.Auto)
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.align(Alignment.Top)
Row({ space: 10 }) {
TextInput({ placeholder: '输入您的问题...', text: this.inputText })
.placeholderColor($r('app.color.inactive_tab'))
.fontColor($r('app.color.text_primary'))
.fontSize(Math.round(17 * this.scaleFor()))
.height(52)
.layoutWeight(1)
.padding({ left: 16, right: 16 })
.backgroundColor($r('app.color.background_card'))
.borderRadius(14)
.onChange((value: string) => {
this.inputText = value;
})
.onSubmit((enterKey: EnterKeyType) => {
this.send();
})
Button(this.sending ? '…' : '发送')
.height(52)
.padding({ left: 18, right: 18 })
.constraintSize({ minWidth: 80 })
.fontSize(Math.round(17 * this.scaleFor()))
.fontColor('#FFFFFF')
.backgroundColor($r('app.color.accent_blue'))
.borderRadius(14)
.enabled(!this.sending)
.onClick(() => {
this.send();
})
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 20 })
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Stack)
.titleBar({
content: {
title: {
mainTitle: '智能助手',
},
},
style: {
scrollEffectOpts: {
enableScrollEffect: false,
scrollEffectType: ScrollEffectType.GRADIENT_BLUR,
},
systemMaterialEffect: {
materialType: hdsMaterial.MaterialType.ADAPTIVE,
materialLevel: hdsMaterial.MaterialLevel.EXQUISITE,
},
},
avoidLayoutSafeArea: false,
enableComponentSafeArea: false,
})
.bindToScrollable([this.scroller])
.hideBackButton(false)
.titleMode(HdsNavigationTitleMode.MINI)
.ignoreLayoutSafeArea([LayoutSafeAreaType.SYSTEM], [LayoutSafeAreaEdge.TOP, LayoutSafeAreaEdge.BOTTOM])
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.background'))
}
}

Some files were not shown because too many files have changed in this diff Show More