家庭健康管理应用基于HarmonyOS API 24构造函数接收十二个参数并逐一赋值,参数列表虽然较长,每条药品记录都是通过 new MedicineItem(...) 的方式创建的
引言:为什么我们需要一个家庭药品管理工具
在现代都市生活中,每个家庭或多或少都会在家里储备一些常用药品。从普通的感冒药、退烧药,到需要长期服用的降压药、降糖药,再到外伤应急用的创可贴和消毒用品,家庭药箱已经成为每个家庭不可或缺的"微型药房"。然而,随着药品数量逐渐增多,一系列管理难题也随之而来。

我们经常会遇到这样的场景:孩子半夜突然发烧,翻箱倒柜却找不到退烧药;老人需要按时服用慢性病药物,却总是忘记该几点吃、吃多少;清理药箱时才发现,好几盒药品早已过期半年之久;去医院就诊时,医生询问之前的用药史和就诊记录,却一时想不起来具体细节。这些问题看似琐碎,却实实在在地影响着每个家庭的健康管理效率,甚至在关键时刻可能延误治疗时机。
正是在这样的背景下,一个能够统一管理家庭药品、智能提醒用药、记录就诊历史、追踪健康指标的综合健康管理应用显得尤为重要。本文将深入剖析这样一款家庭药品管理应用的完整实现,从类型定义、数据建模、配置系统,到五大功能模块的页面构建,逐行解读每一处关键代码的设计意图与实现技巧。
这款应用采用了声明式 UI 开发范式,通过状态驱动视图更新的方式构建界面,整体采用清新的薄荷绿色调,传递出健康、安全的视觉感受。应用包含药品清单、用药提醒、就医记录、健康指标和个人中心五大核心模块,覆盖了家庭健康管理的全流程需求。接下来,让我们从代码层面逐一展开分析。
一、应用整体架构概览
在深入细节之前,我们首先从宏观层面理解这个应用的整体架构。整个应用采用分层设计思想,从下到上依次为:类型定义层、数据模型层、配置令牌层、模拟数据层、工具函数层,以及最上方的页面组件层。这种分层设计让每一部分的职责都清晰明确,便于维护和扩展。
应用的核心是一个底部带五个 Tab 标签的主框架,分别对应五个独立的功能页面。每个页面都是一个独立的自定义组件,拥有自己的状态管理和视图构建逻辑。页面之间通过主框架的 activeTab 状态进行切换,实现了单页面应用式的导航体验。
整体配色围绕一个主色调展开:#00897B(青绿色)作为主色,#004D40(深青色)用于标题文字,#4DB6AC(浅青色)用于辅助文字,#E0F2F1(极浅青色)作为背景色。这套配色方案在医疗健康类应用中非常常见,因为绿色系能够给人带来安全、可靠、健康的心理暗示。
二、类型定义层:构建数据契约
2.1 元数据接口家族
在应用的起始部分,首先定义了一系列接口,它们构成了整个应用的数据契约基础。这些接口虽然不包含具体逻辑,但它们明确了每个数据实体的结构,让后续的代码编写有据可依。
interface MedicineCategoryMeta {
label: string
icon: string
color: string
bg: string
desc: string
}
interface MedicineStatusMeta {
label: string
color: string
icon: string
bg: string
}
interface StorageMeta {
label: string
icon: string
desc: string
}

MedicineCategoryMeta 接口定义了药品分类的元数据结构。label 字段存储分类的显示名称,比如"感冒药"、"消炎药"等;icon 字段存储一个 emoji 表情符号,用于在界面上做可视化区分;color 字段是该分类对应的主色调,用于标签背景或图标颜色;bg 字段是浅色背景色,用于卡片底色;desc 字段是一句简短的描述文字,说明该分类的用途。
MedicineStatusMeta 接口用于描述药品的状态信息,与分类元数据类似,它包含标签、颜色、图标和背景色四个字段,但少了描述字段。药品状态包括"正常"、“库存不足”、“即将过期”、“已过期”、"已停用"等,每种状态都对应不同的颜色方案,让用户一眼就能识别出需要关注的药品。
StorageMeta 接口定义了药品存储方式的元数据。它只有三个字段:label 是存储方式名称(如"常温"、“冷藏”),icon 是对应的 emoji 图标,desc 是详细的存储温度说明。这种设计把存储条件可视化,帮助用户正确保存药品。
2.2 频率与提醒接口
interface FrequencyMeta {
label: string
times: number
interval: string
}
interface ReminderMeta {
id: number
time: string
medicine: string
dosage: string
frequency: string
isTaken: boolean
icon: string
}

FrequencyMeta 接口定义了服药频率的元数据。label 是频率的显示文本,如"每日3次";times 是每日服药的次数,数值类型,用于后续计算和统计;interval 是两次服药之间的时间间隔说明,如"8小时",帮助用户理解服药节奏。
ReminderMeta 接口是用药提醒的数据结构,它是提醒模块的核心数据实体。id 是唯一标识符;time 是提醒时间,格式为"HH:MM"字符串;medicine 是药品名称;dosage 是单次服用剂量;frequency 是服药频率文本;isTaken 是一个布尔值,标记该次服药是否已完成;icon 是用于界面展示的 emoji 图标。这个接口的设计非常贴近实际用药场景,涵盖了用户需要知道的所有关键信息。
2.3 就诊与健康指标接口
interface VisitMeta {
id: number
date: string
hospital: string
department: string
doctor: string
diagnosis: string
prescription: string
cost: number
notes: string
}
interface HealthMetricMeta {
id: number
date: string
type: string
value: number
unit: string
status: string
reference: string
icon: string
}

VisitMeta 接口定义了就医记录的完整数据结构。它记录了就诊日期、医院名称、就诊科室、主治医生、诊断结果、处方用药、就诊费用和医嘱备注等九个字段。这个结构设计得非常完整,基本上覆盖了一次就诊活动中所有有价值的信息,方便日后回顾和与医生沟通。
HealthMetricMeta 接口用于健康指标的记录。type 标识指标类型(如血压、血糖、体温等);value 是测量数值;unit 是计量单位;status 是该指标的状态评估(正常、偏高等);reference 是参考范围文本,让用户了解正常区间;icon 是可视化图标。通过这个结构,应用能够完整地呈现用户的健康数据趋势。
2.4 图表数据接口
interface MonthBarMeta {
label: string
value: number
color: string
}
interface CategoryRatioMeta {
label: string
value: number
color: string
icon: string
}
interface StockWarnMeta {
label: string
current: number
threshold: number
color: string
icon: string
}

这三个接口专门服务于图表展示。MonthBarMeta 用于月度用药次数柱状图,包含标签、数值和颜色;CategoryRatioMeta 用于药品分类占比展示,额外包含图标字段;StockWarnMeta 用于库存预警进度条,包含当前库存量和阈值,通过 current 和 threshold 的对比来直观呈现库存状态。这些接口的设计体现了"数据驱动视图"的理念,图表渲染完全由数据决定。
三、数据模型与可观察对象
3.1 MedicineItem 可观察类
在类型定义之后,应用定义了一个核心的数据模型类——MedicineItem,这是整个药品管理功能的基础数据载体。
@Observed
export class MedicineItem {
id: number = 0
name: string = ''
category: string = ''
quantity: number = 0
expiryDate: string = ''
dosage: string = ''
frequency: string = ''
notes: string = ''
storage: string = ''
location: string = ''
manufacturer: string = ''
status: string = ''
constructor(id: number, name: string, category: string, quantity: number,
expiryDate: string, dosage: string, frequency: string, notes: string,
storage: string, location: string, manufacturer: string, status: string) {
this.id = id; this.name = name; this.category = category; this.quantity = quantity
this.expiryDate = expiryDate; this.dosage = dosage; this.frequency = frequency
this.notes = notes; this.storage = storage; this.location = location
this.manufacturer = manufacturer; this.status = status
}
}

这段代码有几个关键设计点值得深入分析。
首先,类声明上方的 @Observed 装饰器是声明式 UI 框架提供的一个关键装饰器。它的作用是将这个类标记为"可观察对象",意味着当该类的实例属性发生变化时,框架能够自动感知并触发与之绑定的视图更新。这对于实现数据驱动的响应式界面至关重要。在药品管理场景中,当用户编辑了某药品的库存数量后,界面上的相应显示会自动刷新,无需手动调用更新方法。
其次,类中定义了十二个属性,每个属性都赋予了默认初始值。这种做法虽然看似简单,却是一个良好的编程习惯——它确保了即使构造函数没有被正确调用,对象也始终处于一个有效的状态,避免了 undefined 导致的潜在问题。
最后,构造函数接收十二个参数并逐一赋值。参数列表虽然较长,但每个参数都对应药品的一个真实属性,这种"全量构造"的方式让对象的创建一目了然。在实际的模拟数据中,我们可以看到每条药品记录都是通过 new MedicineItem(...) 的方式创建的,代码可读性很强。
四、配置令牌系统:统一管理视觉与语义
4.1 药品分类配置
配置系统是这个应用的一大亮点。它将所有与分类、状态、存储方式、频率相关的视觉和语义信息集中管理,形成了一套"设计令牌"体系。
const MED_CATEGORY_CONFIG: Record<string, MedicineCategoryMeta> = {
'感冒药': { label: '感冒药', icon: '🤧', color: '#00897B', bg: '#E0F2F1', desc: '缓解感冒症状' },
'消炎药': { label: '消炎药', icon: '💊', color: '#4DB6AC', bg: '#E0F2F1', desc: '抗菌消炎' },
'维生素': { label: '维生素', icon: '🍊', color: '#004D40', bg: '#E0F2F1', desc: '补充营养素' },
'外用药': { label: '外用药', icon: '🩹', color: '#00695C', bg: '#E0F2F1', desc: '涂抹外敷' },
'慢性病药': { label: '慢性病药', icon: '⏰', color: '#B2DFDB', bg: '#E0F2F1', desc: '长期服用' },
'肠胃药': { label: '肠胃药', icon: '🌿', color: '#4DB6AC', bg: '#E0F2F1', desc: '调理肠胃' },
'止痛药': { label: '止痛药', icon: '💉', color: '#00897B', bg: '#E0F2F1', desc: '镇痛止痛' },
'眼耳鼻喉': { label: '眼耳鼻喉', icon: '👁️', color: '#004D40', bg: '#E0F2F1', desc: '专科用药' }
}

MED_CATEGORY_CONFIG 是一个 Record<string, MedicineCategoryMeta> 类型的常量对象,它以分类名称为键,以分类元数据为值。这种设计的优势在于:当界面需要渲染某个分类的标签时,只需通过 MED_CATEGORY_CONFIG['感冒药'] 即可获取到该分类的图标、颜色、背景色和描述,无需在多处硬编码这些信息。
观察颜色配置可以发现,所有分类的背景色统一使用 #E0F2F1,保持了视觉一致性;而主色调则在青绿色系的不同深浅之间变化(#00897B、#4DB6AC、#004D40、#00695C、#B2DFDB),既统一又有层次感。这种配色策略让界面看起来和谐而不单调。
4.2 药品状态配置
const MED_STATUS_CONFIG: Record<string, MedicineStatusMeta> = {
'正常': { label: '正常', color: '#00897B', icon: '✅', bg: '#E0F2F1' },
'库存不足': { label: '库存不足', color: '#FF9800', icon: '⚠️', bg: '#FFF3E0' },
'即将过期': { label: '即将过期', color: '#FF9800', icon: '⏰', bg: '#FFF3E0' },
'已过期': { label: '已过期', color: '#F44336', icon: '❌', bg: '#FFEBEE' },
'已停用': { label: '已停用', color: '#9E9E9E', icon: '⏹️', bg: '#F5F5F5' }
}

MED_STATUS_CONFIG 定义了五种药品状态的视觉表现。这里的设计非常讲究状态语义与颜色的对应关系:正常状态使用绿色系(#00897B),传达安全信号;库存不足和即将过期使用橙色系(#FF9800),传达警示信号;已过期使用红色系(#F44336),传达危险信号;已停用使用灰色系(#9E9E9E),传达中性信息。这种颜色编码符合用户对交通信号灯的认知习惯,大大降低了理解成本。
4.3 存储方式与服药频率配置
const STORAGE_CONFIG: Record<string, StorageMeta> = {
'常温': { label: '常温', icon: '🌡️', desc: '室温保存10-30℃' },
'冷藏': { label: '冷藏', icon: '❄️', desc: '冰箱2-8℃保存' },
'冷冻': { label: '冷冻', icon: '🧊', desc: '冷冻-18℃以下' },
'避光': { label: '避光', icon: '🌑', desc: '避光密闭保存' }
}
const FREQUENCY_CONFIG: Record<string, FrequencyMeta> = {
'每日1次': { label: '每日1次', times: 1, interval: '24小时' },
'每日2次': { label: '每日2次', times: 2, interval: '12小时' },
'每日3次': { label: '每日3次', times: 3, interval: '8小时' },
'每周1次': { label: '每周1次', times: 1, interval: '7天' },
'必要时': { label: '必要时', times: 0, interval: '按需服用' }
}

STORAGE_CONFIG 为四种存储方式定义了图标和温度说明。desc 字段特别有价值,它把抽象的存储方式转化为具体的温度范围,比如"冷藏"对应"冰箱2-8℃保存",让用户清楚地知道该如何保存药品。这种细节体现了应用对用户体验的用心。
FREQUENCY_CONFIG 则定义了五种服药频率。times 字段记录每日服药次数,interval 字段用通俗易懂的方式表达间隔时间。值得注意的是"必要时"的 times 设为 0,interval 设为"按需服用",巧妙地区分了规律性服药和按需服药两种场景。
4.4 枚举数组
const MED_CATEGORIES: string[] = ['感冒药', '消炎药', '维生素', '外用药', '慢性病药', '肠胃药', '止痛药', '眼耳鼻喉']
const MED_FILTERS: string[] = ['全部', '感冒药', '消炎药', '维生素', '外用药', '慢性病药', '肠胃药', '止痛药', '眼耳鼻喉']
const STORAGES: string[] = ['常温', '冷藏', '冷冻', '避光']
const FREQUENCIES: string[] = ['每日1次', '每日2次', '每日3次', '每周1次', '必要时']

这四个数组是配置系统的补充,它们将配置键以数组形式组织,方便在界面中做遍历渲染。MED_FILTERS 比 MED_CATEGORIES 多了一个"全部"选项,用于筛选条的第一个选项。这种将枚举值单独提取为数组的做法,让界面渲染时无需额外处理对象的键,代码更加简洁。
五、模拟数据层:贴近真实的数据样本
5.1 药品数据集
应用预置了二十条药品记录作为模拟数据,覆盖了八大分类和各种状态场景。
const mockMedicines: MedicineItem[] = [
new MedicineItem(1, '连花清瘟胶囊', '感冒药', 3, '2027-03-15', '4粒/次', '每日3次', '风热感冒常备', '常温', '家庭药箱A层', '以岭药业', '正常'),
new MedicineItem(2, '布洛芬缓释胶囊', '止痛药', 2, '2026-09-20', '1粒/次', '每日2次', '发热头痛止痛', '常温', '家庭药箱A层', '中美史克', '库存不足'),
new MedicineItem(3, '阿莫西林胶囊', '消炎药', 12, '2026-11-05', '2粒/次', '每日3次', '细菌感染消炎', '常温', '家庭药箱B层', '珠海联邦', '正常'),
new MedicineItem(4, '维生素C片', '维生素', 1, '2026-08-10', '1片/次', '每日1次', '增强免疫补充维C', '避光', '家庭药箱B层', '华北制药', '库存不足'),
new MedicineItem(5, '复方丹参滴丸', '慢性病药', 6, '2027-01-25', '10丸/次', '每日3次', '心血管保健', '常温', '床头柜抽屉', '天士力', '正常')
]
这些数据并非随意编造,而是经过精心设计的真实药品样本。以第一条为例:连花清瘟胶囊是真实存在的感冒药,由以岭药业生产,每日3次、每次4粒的用法用量也符合实际说明书。库存3盒、存放在家庭药箱A层、常温保存等细节,都与真实家庭药箱的场景高度吻合。
数据中还刻意包含了各种状态:库存不足(布洛芬仅剩2盒、维生素C仅剩1盒)、即将过期(红霉素软膏有效期2025年12月)、已过期(板蓝根冲剂有效期2025年8月、对乙酰氨基酚片有效期2025年11月)。这种设计确保了应用的各个状态分支都能在模拟数据中被触发和展示。
5.2 用药提醒数据集
const mockReminders: ReminderMeta[] = [
{ id: 1, time: '06:30', medicine: '硝苯地平控释片', dosage: '1片', frequency: '每日1次', isTaken: true, icon: '⏰' },
{ id: 2, time: '07:00', medicine: '盐酸二甲双胍片', dosage: '1片', frequency: '每日2次', isTaken: true, icon: '⏰' },
{ id: 3, time: '07:30', medicine: '连花清瘟胶囊', dosage: '4粒', frequency: '每日3次', isTaken: true, icon: '💊' },
{ id: 4, time: '08:00', medicine: '维生素片', dosage: '1片', frequency: '每日1次', isTaken: false, icon: '🍊' }
]
用药提醒数据按照时间顺序排列,从早上6:30开始到晚上20:30结束,覆盖了一天的完整用药时间线。前三个提醒的 isTaken 为 true(已服),后面五个为 false(待服),这种分布让界面能够同时展示"已服"和"待服"两种状态的视觉效果。提醒时间的设计也很有讲究:慢性病药安排在清晨空腹服用(6:30硝苯地平、7:00二甲双胍),饭后用药安排在餐后半小时,符合真实的用药习惯。
5.3 就医记录与健康指标数据
const mockVisits: VisitMeta[] = [
{ id: 1, date: '2026-07-10', hospital: '市第一人民医院', department: '心内科', doctor: '王主任医师', diagnosis: '高血压2级', prescription: '硝苯地平控释片', cost: 328, notes: '建议低盐饮食定期监测血压' },
{ id: 2, date: '2026-06-22', hospital: '市中医院', department: '内科', doctor: '李医师', diagnosis: '风寒感冒', prescription: '连花清瘟胶囊+板蓝根', cost: 86, notes: '多饮温水注意休息' }
]
const mockMetrics: HealthMetricMeta[] = [
{ id: 1, date: '2026-07-19', type: '血压', value: 128, unit: 'mmHg', status: '偏高', reference: '90-120', icon: '💓' },
{ id: 2, date: '2026-07-19', type: '血糖', value: 6.8, unit: 'mmol/L', status: '偏高', reference: '3.9-6.1', icon: '🩸' },
{ id: 3, date: '2026-07-18', type: '血压', value: 118, unit: 'mmHg', status: '正常', reference: '90-120', icon: '💓' }
]
就医记录数据按时间倒序排列,最近一次就诊在7月10日(心内科高血压),最早在4月10日(呼吸内科上呼吸道感染)。每条记录都包含完整的就诊信息,包括医院、科室、医生、诊断、处方、费用和医嘱。健康指标数据则涵盖了血压、血糖、体温、心率、体重五种类型,并刻意混入了"偏高"状态的数据(血压128、血糖6.8),让用户能够看到异常指标的警示效果。
5.4 图表数据集
const monthBarData: MonthBarMeta[] = [
{ label: '6月上', value: 42, color: '#B2DFDB' },
{ label: '6月中', value: 56, color: '#4DB6AC' },
{ label: '6月下', value: 68, color: '#00897B' },
{ label: '7月上', value: 78, color: '#4DB6AC' },
{ label: '7月中', value: 92, color: '#00897B' },
{ label: '7月下', value: 65, color: '#004D40' }
]
const categoryRatioData: CategoryRatioMeta[] = [
{ label: '感冒药', value: 4, color: '#00897B', icon: '🤧' },
{ label: '消炎药', value: 3, color: '#4DB6AC', icon: '💊' },
{ label: '维生素', value: 2, color: '#004D40', icon: '🍊' }
]
const stockWarnData: StockWarnMeta[] = [
{ label: '维生素片', current: 1, threshold: 5, color: '#FF9800', icon: '⚠️' },
{ label: '布洛芬胶囊', current: 2, threshold: 5, color: '#FF9800', icon: '⚠️' },
{ label: '金霉素眼膏', current: 1, threshold: 3, color: '#F44336', icon: '❌' }
]
月度用药数据呈现了从6月到7月的用药次数变化趋势,数值从42逐步上升到92后又回落到65,形成了一条自然的波动曲线。颜色也随着数值变化在浅色到深色之间过渡,增强了视觉层次感。分类占比数据展示了七大类药品的数量分布。库存预警数据则列出了五款库存不足的药品,用橙色和红色区分了"库存偏低"和"严重不足"两个级别。
六、统计函数与枚举定义
6.1 统计函数
function getMedicineCount(): number { return 20 }
function getExpiringCount(): number { return 3 }
function getExpiredCount(): number { return 2 }
function getLowStockCount(): number { return 5 }
function getReminderCount(): number { return 8 }
function getTodayTakenCount(): number { return 3 }
function getVisitCount(): number { return 7 }
function getNormalCount(): number { return 13 }
这八个统计函数各自返回一个固定的数值,分别对应药品总数、即将过期数、已过期数、库存不足数、今日提醒数、今日已服数、就医次数和正常药品数。在实际应用中,这些函数应该从数据源动态计算得出,但在此处作为演示版本,返回的是与模拟数据匹配的固定值。
这种封装方式的好处是:当未来接入真实数据源时,只需修改这些函数的内部实现,而不需要改动任何界面代码。函数名清晰地表达了其语义,调用处如 getMedicineCount().toString() 的可读性非常好。
6.2 Tab 枚举
enum MedicineTab {
LIST = 0,
REMINDER = 1,
VISIT = 2,
HEALTH = 3,
PROFILE = 4
}
MedicineTab 枚举定义了底部导航栏的五个标签项,每个标签对应一个数字索引。使用枚举而非魔法数字(magic number)的好处显而易见:代码中 this.activeTab === MedicineTab.LIST 比 this.activeTab === 0 的可读性强得多,也避免了因索引变化导致的难以排查的 bug。
七、入口组件与底部导航
7.1 主框架结构
入口组件 MedicineApp 是整个应用的根组件,负责管理当前激活的标签页和渲染底部导航栏。
@Entry
@Component
struct MedicineApp {
@State activeTab: MedicineTab = MedicineTab.LIST
@Builder contentArea() {
Column() {
if (this.activeTab === MedicineTab.LIST) {
MedicineListContent()
} else if (this.activeTab === MedicineTab.REMINDER) {
ReminderContent()
} else if (this.activeTab === MedicineTab.VISIT) {
VisitContent()
} else if (this.activeTab === MedicineTab.HEALTH) {
HealthContent()
} else {
ProfileContent()
}
}
.layoutWeight(1)
}
@Entry 装饰器标记此组件为应用的入口页面;@Component 装饰器声明这是一个自定义组件。@State activeTab 是组件的内部状态,初始值为 MedicineTab.LIST,表示应用启动时默认显示药品清单页。
contentArea 是一个 @Builder 方法,它根据 activeTab 的值条件性地渲染对应的页面组件。if-else if-else 的链式判断虽然简单,但对于五个固定标签来说已经足够清晰。.layoutWeight(1) 让内容区域占据除底部导航外的所有剩余空间。
7.2 底部导航项构建器
@Builder bottomTabItem(icon: string, label: string, tab: MedicineTab) {
Column() {
Text(icon).fontSize(20).opacity(this.activeTab === tab ? 1.0 : 0.45)
Text(label).fontSize(9)
.fontColor(this.activeTab === tab ? '#00897B' : '#999999')
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 1 })
if (this.activeTab === tab) {
Column().width(18).height(3)
.backgroundColor('#00897B').borderRadius(2).margin({ top: 2 })
}
}
.layoutWeight(1)
.padding({ top: 5, bottom: 5 })
.onClick(() => { this.activeTab = tab })
}
bottomTabItem 是一个参数化的构建器,接收图标、标签文字和对应的枚举值三个参数。它的设计巧妙之处在于通过 this.activeTab === tab 的条件判断,实现了选中态和未选中态的视觉差异:
选中状态下,图标透明度为 1.0(完全不透明),标签文字颜色为主题色 #00897B,字体加粗,并且在下方显示一个 18x3 像素的青绿色小圆角条作为选中指示器。未选中状态下,图标透明度降为 0.45(半透明),标签文字变为灰色 #999999,字体恢复正常粗细,不显示指示器。
onClick 事件处理器将 activeTab 设置为当前点击的标签值,触发状态更新后,框架自动重新渲染界面,切换到对应的页面组件。
7.3 主构建函数
build() {
Column() {
this.contentArea()
Row() {
this.bottomTabItem('💊', '药品清单', MedicineTab.LIST)
this.bottomTabItem('⏰', '用药提醒', MedicineTab.REMINDER)
this.bottomTabItem('🏥', '就医记录', MedicineTab.VISIT)
this.bottomTabItem('📊', '健康指标', MedicineTab.HEALTH)
this.bottomTabItem('👤', '我的', MedicineTab.PROFILE)
}
.width('100%')
.backgroundColor('#FFFFFF')
.padding({ top: 4, bottom: 6 })
.shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
}
.width('100%').height('100%')
.backgroundColor('#E0F2F1')
}
主 build 函数将内容区和底部导航栏组合在一起。底部导航栏使用 Row 水平排列五个标签项,每个项通过 layoutWeight(1) 等分宽度。导航栏背景为白色,顶部有一个向上的阴影效果(offsetY: -2),营造出导航栏悬浮于内容之上的视觉层次感。整个页面的背景色为 #E0F2F1,即极浅青色,与整体配色方案保持一致。
八、药品清单页深度解析
药品清单页是应用最核心、最复杂的页面,包含了搜索、统计、图表、筛选、列表和四个弹框等丰富功能。
8.1 状态管理
@State searchKeyword: string = ''
@State selectedCategory: string = '全部'
@State showAddModal: boolean = false
@State showEditModal: boolean = false
@State showDeleteConfirm: boolean = false
@State showDetailModal: boolean = false
@State selectedMedicine: MedicineItem | null = null
@State editingMedicine: MedicineItem | null = null
@State formName: string = ''
@State formCategory: string = '感冒药'
@State formQuantity: string = '10'
@State formExpiry: string = '2027-06-30'
@State formDosage: string = ''
@State formFrequency: string = '每日3次'
@State formStorage: string = '常温'
@State formLocation: string = ''
@State formManufacturer: string = ''
@State formNotes: string = ''
药品清单页的状态管理非常丰富,包含三大类状态:
第一类是交互状态,包括搜索关键词 searchKeyword、选中的分类筛选 selectedCategory,以及四个弹框的显示控制布尔值(showAddModal、showEditModal、showDeleteConfirm、showDetailModal)。这四个布尔值各自控制一个弹框的显示与隐藏,互不干扰。
第二类是选中数据状态,selectedMedicine 和 editingMedicine 分别存储当前查看详情的药品和当前编辑的药品,类型为 MedicineItem | null,初始值为 null,表示未选中任何药品。
第三类是表单状态,formName 到 formNotes 共九个字段,用于"新增药品"弹框中的表单数据。每个字段都有合理的默认值,比如分类默认为"感冒药"、频率默认为"每日3次"、存储方式默认为"常温",这些默认值减少了用户填表的工作量。
8.2 弹框遮罩构建器
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(onClose)
}
modalOverlay 是一个通用的弹框遮罩构建器,它接收一个回调函数 onClose 作为参数。遮罩是一个铺满全屏的 Column,背景色为半透明黑色(rgba(0,0,0,0.5)),点击遮罩区域时触发 onClose 回调关闭弹框。这种将遮罩逻辑抽取为独立构建器的做法,避免了在每个弹框中重复编写遮罩代码,体现了 DRY(Don’t Repeat Yourself)原则。
8.3 新增药品弹框
新增药品弹框是所有弹框中最复杂的一个,因为它包含了一个完整的药品信息表单。
@Builder addMedicineModal() {
Column() {
this.modalOverlay(() => { this.showAddModal = false })
Column() {
Row() {
Text('💊 新增药品').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#004D40')
Row().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showAddModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#E0F2F1')
弹框的最外层是一个全屏 Column,包含两部分:首先是遮罩层(调用 modalOverlay),然后是实际的弹框内容卡片。内容卡片的头部是一个 Row,左侧是标题"💊 新增药品",右侧是关闭按钮"✕",中间用一个 Row().layoutWeight(1) 撑开空间,实现左右两端对齐的效果。标题下方有一个浅青色的分割线 Divider,将头部与表单内容区分隔开来。
Scroll() {
Column() {
Text('药品名称').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: '如:连花清瘟胶囊' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.formName = v })
表单内容被包裹在 Scroll 组件中,确保当表单内容超出弹框高度时可以滚动查看。每个表单项由一个标签 Text 和一个输入框 TextInput 组成。标签使用 12 号灰色字体,输入框使用浅灰色背景(#F5F5F5)和 8 像素圆角,onChange 回调将输入值同步到对应的表单状态变量。
Text('药品分类').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
Scroll() {
Row() {
ForEach(MED_CATEGORIES, (c: string) => {
if (this.formCategory === c) {
Text(MED_CATEGORY_CONFIG[c]?.icon + ' ' + c)
.fontSize(11).fontColor('#FFFFFF')
.backgroundColor(MED_CATEGORY_CONFIG[c]?.color ?? '#00897B')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
} else {
Text(MED_CATEGORY_CONFIG[c]?.icon + ' ' + c)
.fontSize(11).fontColor(MED_CATEGORY_CONFIG[c]?.color ?? '#00897B')
.backgroundColor(MED_CATEGORY_CONFIG[c]?.bg ?? '#E0F2F1')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formCategory = c })
}
})
}
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
药品分类的选择器是一个水平滚动的标签条。通过 ForEach 遍历 MED_CATEGORIES 数组,为每个分类生成一个标签按钮。这里使用了 if-else 条件渲染来区分选中态和未选中态:选中时标签为白字彩底(使用分类配置中的 color 作为背景色),未选中时为彩字浅底(使用 bg 作为背景色)。点击未选中标签时,将 formCategory 设置为该分类。
这种"标签选择器"模式在表单设计中非常实用,比下拉选择框更加直观,用户可以一眼看到所有可选分类。使用 ?. 可选链操作符(如 MED_CATEGORY_CONFIG[c]?.icon)是一种防御性编程,即使配置中缺失某个分类也不会导致程序崩溃。
Row() {
Column() {
Text('库存数量').fontSize(12).fontColor('#888888')
TextInput({ placeholder: '10' })
.placeholderColor('#BBBBBB').fontSize(12).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8).margin({ top: 4 })
.onChange((v: string) => { this.formQuantity = v })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Text('过期日期').fontSize(12).fontColor('#888888')
TextInput({ placeholder: '2027-06-30' })
.placeholderColor('#BBBBBB').fontSize(12).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8).margin({ top: 4 })
.onChange((v: string) => { this.formExpiry = v })
}.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })
}
.margin({ left: 20, right: 20, top: 12 })
库存数量和过期日期两个字段被放在同一行中,通过两个 Column 各自 layoutWeight(1) 实现等分布局,中间用 12 的左间距隔开。这种双列布局在表单设计中很常见,能够在有限的宽度内展示更多字段,减少表单的整体高度。
Row() {
Text('取消').fontSize(14).fontColor('#888888')
.backgroundColor('#F5F5F5').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showAddModal = false })
Text('保存药品').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#00897B').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => { this.showAddModal = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 16, bottom: 16 })
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(16)
.constraintSize({ maxHeight: '80%' })
.position({ x: '5%', y: '10%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
弹框底部是操作按钮区,包含"取消"和"保存药品"两个按钮,通过 justifyContent(FlexAlign.Center) 居中排列。取消按钮为灰色背景,保存按钮为主题色背景。弹框卡片宽度为屏幕的 90%,最大高度限制为 80%,通过 position 定位在屏幕上方 10% 的位置。最外层 Column 设置 zIndex(999),确保弹框浮于所有内容之上。
8.4 编辑药品弹框
编辑弹框与新增弹框结构类似,但表单字段更少,专注于核心可编辑信息。
@Builder editMedicineModal() {
Column() {
this.modalOverlay(() => { this.showEditModal = false })
Column() {
Row() {
Text('✏️ 编辑药品').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#004D40')
Row().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showEditModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#E0F2F1')
Column() {
Text('药品名称').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: this.editingMedicine?.name ?? '' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
编辑弹框的关键特点在于,输入框的 placeholder 不再是固定文本,而是从 editingMedicine 对象中读取已有值。使用 this.editingMedicine?.name ?? '' 这种写法,当 editingMedicine 为 null 时显示空字符串,避免空指针异常。这种设计让用户在编辑时能够看到当前值作为参考。
编辑弹框的保存按钮使用了橙色背景(#FF9800),与新增弹框的青绿色保存按钮形成区分,视觉上提示用户这是一个"修改"操作而非"新增"操作。
8.5 删除确认弹框
@Builder deleteMedicineModal() {
Column() {
this.modalOverlay(() => { this.showDeleteConfirm = false })
Column() {
Row() {
Text('🗑️ 删除药品').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#F44336')
Row().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showDeleteConfirm = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#FFEBEE')
Column() {
Text('❓').fontSize(48).margin({ top: 20 })
Text('确定要删除该药品记录吗?')
.fontSize(15).fontColor('#333333').margin({ top: 12 })
Text('药品名称:' + (this.selectedMedicine?.name ?? ''))
.fontSize(13).fontColor('#00897B').margin({ top: 6 })
Text('此操作不可恢复,请谨慎确认')
.fontSize(12).fontColor('#999999').margin({ top: 8 })
}
.width('100%').padding({ bottom: 16 })
删除确认弹框采用了警示性的设计风格。标题颜色为红色(#F44336),分割线也使用了浅红色(#FFEBEE)。弹框中央是一个大号问号 emoji(48号字体),下方是确认提示文字,并显示即将删除的药品名称,最后用灰色小字提醒"此操作不可恢复"。这种二次确认机制在涉及不可逆操作时非常重要,能够有效防止用户误删数据。
确认删除按钮同样使用红色背景,与弹框整体警示风格一致。弹框宽度为屏幕的 80%(比其他弹框窄),定位在屏幕 25% 高度的位置,营造出居中偏上的对话框视觉效果。
8.6 药品详情弹框
详情弹框是信息量最大的弹框,展示了药品的完整信息。
@Builder detailMedicineModal() {
Column() {
this.modalOverlay(() => { this.showDetailModal = false })
Column() {
Row() {
Text('📋 药品详情').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#004D40')
Row().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showDetailModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#E0F2F1')
Scroll() {
Column() {
Row() {
Text(MED_CATEGORY_CONFIG[this.selectedMedicine?.category ?? '感冒药']?.icon ?? '💊')
.fontSize(40)
Column() {
Text(this.selectedMedicine?.name ?? '')
.fontSize(18).fontWeight(FontWeight.Bold).fontColor('#004D40')
Text(this.selectedMedicine?.manufacturer ?? '')
.fontSize(12).fontColor('#888888').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).margin({ left: 12 })
Row().layoutWeight(1)
Text(MED_STATUS_CONFIG[this.selectedMedicine?.status ?? '正常']?.icon ?? '✅')
.fontSize(28)
}
详情弹框的头部区域展示了药品的核心标识信息:左侧是 40 号字体的分类图标(大尺寸,非常醒目),中间是药品名称和生产厂家,右侧是状态图标。通过配置系统获取图标和颜色,确保了视觉表现与列表页的一致性。
Row() {
Text(MED_STATUS_CONFIG[this.selectedMedicine?.status ?? '正常']?.label ?? '正常')
.fontSize(11).fontColor('#FFFFFF')
.backgroundColor(MED_STATUS_CONFIG[this.selectedMedicine?.status ?? '正常']?.color ?? '#00897B')
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(10)
Text(MED_CATEGORY_CONFIG[this.selectedMedicine?.category ?? '感冒药']?.label ?? '')
.fontSize(11).fontColor('#00897B')
.backgroundColor(MED_CATEGORY_CONFIG[this.selectedMedicine?.category ?? '感冒药']?.bg ?? '#E0F2F1')
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(10)
.margin({ left: 6 })
}
状态标签和分类标签以彩色胶囊的形式展示。状态标签为白字彩底(使用状态配置的 color),分类标签为彩字浅底(使用分类配置的 bg),两种标签并排显示,让用户快速了解药品的状态和归属。
this.detailRow('用法用量', this.selectedMedicine?.dosage ?? '')
this.detailRow('服用频率', this.selectedMedicine?.frequency ?? '')
this.detailRow('存储方式', STORAGE_CONFIG[this.selectedMedicine?.storage ?? '常温']?.label ?? '')
this.detailRow('存放位置', this.selectedMedicine?.location ?? '')
this.detailRow('生产厂家', this.selectedMedicine?.manufacturer ?? '')
this.detailRow('备注信息', this.selectedMedicine?.notes ?? '')
详情信息以键值对的形式逐行展示,通过调用 detailRow 构建器统一渲染。值得注意的是"存储方式"这一行,它不是直接显示原始值(如"常温"),而是通过 STORAGE_CONFIG 获取对应的 label,虽然在本例中两者相同,但这种设计预留了未来对存储方式进行更友好展示的空间。
Row() {
Text('编辑').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#00897B').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => {
this.showDetailModal = false
this.editingMedicine = this.selectedMedicine
this.showEditModal = true
})
Text('删除').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#F44336').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => {
this.showDetailModal = false
this.showDeleteConfirm = true
})
}
详情弹框底部提供了"编辑"和"删除"两个操作按钮。点击"编辑"时,先关闭详情弹框,然后将 editingMedicine 设置为当前选中的药品,再打开编辑弹框——这种"弹框串联"的设计让用户可以在查看详情后无缝进入编辑流程。点击"删除"时,同样先关闭详情弹框,再打开删除确认弹框,形成安全的操作链路。
8.7 详情行构建器
@Builder detailRow(label: string, value: string) {
Row() {
Text(label).fontSize(12).fontColor('#999999').width(80)
Text(value).fontSize(13).fontColor('#333333').layoutWeight(1)
}
.width('100%').padding({ left: 20, right: 20, top: 8, bottom: 8 })
}
detailRow 是一个简单的键值对行构建器,左侧标签固定宽度 80,右侧值占据剩余空间。这种固定宽度+弹性宽度的组合在信息展示中非常常见,确保了多行信息的对齐效果。
8.8 统计卡片构建器
@Builder statCard(icon: string, value: string, label: string, color: string) {
Column() {
Text(icon).fontSize(22)
Text(value).fontSize(20).fontWeight(FontWeight.Bold).fontColor(color).margin({ top: 2 })
Text(label).fontSize(10).fontColor('#888888').margin({ top: 1 })
}
.layoutWeight(1)
.backgroundColor('#FFFFFF')
.borderRadius(10)
.padding({ top: 10, bottom: 10 })
.alignItems(HorizontalAlign.Start)
}
statCard 构建器生成一个统计卡片,自上而下依次是图标、数值和标签。数值使用 20 号粗体字,颜色由参数 color 决定,这样每个卡片可以有不同的强调色。卡片使用白色背景和 10 像素圆角,通过 layoutWeight(1) 在水平方向等分排列。在主构建函数中,四个统计卡片分别展示药品总数(青绿)、库存不足(橙色)、即将过期(橙色)、已过期(红色),颜色编码与状态配置保持一致。
8.9 月度用药柱状图
@Builder monthBarChart() {
Column() {
Row() {
Text('📈 月度用药次数').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#004D40')
Row().layoutWeight(1)
Text('单位:次').fontSize(10).fontColor('#999999')
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 8 })
Row() {
ForEach(monthBarData, (item: MonthBarMeta) => {
Column() {
Column() {
Text(item.value.toString())
.fontSize(9).fontColor('#00897B').margin({ bottom: 2 })
}
.width(20).height(item.value * 1.2)
.backgroundColor(item.color).borderRadius({ topLeft: 4, topRight: 4 })
Text(item.label).fontSize(9).fontColor('#888888').margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
})
}
.width('100%').padding({ left: 12, right: 12, bottom: 12 })
.alignItems(VerticalAlign.Bottom)
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.margin({ left: 12, right: 12, top: 8 })
}
这是一个纯 CSS 实现的柱状图,无需引入任何图表库。图表标题区使用 Row 实现左标题右单位的两端对齐。柱状图主体通过 ForEach 遍历 monthBarData 数组,每个数据项渲染为一个 Column,其中包含一个数值标签和一个彩色柱体。
柱体的高度通过 item.value * 1.2 计算得出,这里的 1.2 是一个缩放系数,将数据值映射为合适的像素高度。柱体宽度固定为 20 像素,背景色使用数据项自带的 color 字段,顶部圆角(topLeft: 4, topRight: 4)让柱子看起来更加精致。整个 Row 设置 alignItems(VerticalAlign.Bottom),确保所有柱子底部对齐,形成标准的柱状图效果。
8.10 分类占比条
@Builder categoryRatioBar() {
Column() {
Text('📊 药品分类占比').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#004D40')
.margin({ left: 16, top: 14, bottom: 8 })
ForEach(categoryRatioData, (item: CategoryRatioMeta) => {
Row() {
Text(item.icon + ' ' + item.label).fontSize(11).fontColor('#333333').width(80)
Column() {
Row() {
Column()
.layoutWeight(item.value)
.height(14)
.backgroundColor(item.color)
.borderRadius({ topLeft: 7, bottomLeft: 7 })
Column().layoutWeight(20 - item.value).height(14)
.backgroundColor('#F5F5F5')
.borderRadius({ topRight: 7, bottomRight: 7 })
}
}
.layoutWeight(1)
.margin({ left: 8, right: 8 })
Text(item.value + '种').fontSize(11).fontColor(item.color).fontWeight(FontWeight.Bold)
}
.width('100%').padding({ left: 16, right: 16, bottom: 8 })
})
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.margin({ left: 12, right: 12, top: 8 })
}
分类占比条使用了一种巧妙的"双段进度条"实现方式。对于每个分类,进度条由两段 Column 组成:第一段使用 layoutWeight(item.value) 设置权重,背景为分类颜色;第二段使用 layoutWeight(20 - item.value) 设置权重,背景为浅灰色。两段合在一起的总权重为 20,因此第一段的占比就是 value / 20。
这种利用 layoutWeight 实现比例条的方式非常优雅,无需计算具体像素宽度,框架会自动根据权重分配空间。左侧圆角和右侧圆角分别设置在两段上,形成了一个完整的圆角进度条。右侧显示数值"X种",使用分类颜色加粗显示。
8.11 库存预警进度条
@Builder stockWarnChart() {
Column() {
Text('⚠️ 库存预警').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#FF9800')
.margin({ left: 16, top: 14, bottom: 8 })
ForEach(stockWarnData, (item: StockWarnMeta) => {
Row() {
Text(item.icon).fontSize(16).width(24)
Text(item.label).fontSize(11).fontColor('#333333').width(110)
Column() {
Row() {
Column()
.layoutWeight(item.current)
.height(10)
.backgroundColor(item.color)
.borderRadius(5)
Column().layoutWeight(Math.max(item.threshold - item.current, 1)).height(10)
.backgroundColor('#F5F5F5')
.borderRadius(5)
}
}
.layoutWeight(1)
.margin({ left: 8, right: 8 })
Text(item.current + '/' + item.threshold)
.fontSize(10).fontColor(item.color).fontWeight(FontWeight.Bold)
}
.width('100%').padding({ left: 16, right: 16, bottom: 8 })
})
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.margin({ left: 12, right: 12, top: 8, bottom: 8 })
}
库存预警进度条与分类占比条原理类似,但有一个重要的区别:进度条的总权重是 threshold(阈值)而非固定值。第一段权重为 current(当前库存),第二段权重为 threshold - current(缺口)。这样,进度条的填充比例就是 current / threshold,直观地反映了库存与阈值的差距。
一个细节值得注意:第二段使用了 Math.max(item.threshold - item.current, 1),确保当 current 等于 threshold 时,第二段权重至少为 1,避免出现权重为 0 导致的布局异常。右侧显示"当前/阈值"的文本,如"1/5",让用户精确了解库存状况。
8.12 筛选条与药品卡片
@Builder filterChip(label: string, category: string) {
Text(label)
.fontSize(11)
.fontColor(this.selectedCategory === category ? '#FFFFFF' : '#00897B')
.backgroundColor(this.selectedCategory === category ? '#00897B' : '#E0F2F1')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
.margin({ left: 4, right: 4 })
.onClick(() => { this.selectedCategory = category })
}
filterChip 是筛选标签构建器,通过 selectedCategory 与 category 参数的对比来决定选中态样式。选中时为白字青绿底,未选中时为青绿字浅底。点击时更新 selectedCategory 状态,触发界面重新渲染。
@Builder medicineCard(med: MedicineItem, index: number) {
Column() {
Row() {
Column() {
Text(MED_CATEGORY_CONFIG[med.category]?.icon ?? '💊').fontSize(28)
}
.width(48).height(48)
.backgroundColor(MED_CATEGORY_CONFIG[med.category]?.bg ?? '#E0F2F1')
.borderRadius(24)
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Center)
Column() {
Text(med.name)
.fontSize(15).fontWeight(FontWeight.Bold).fontColor('#333333')
Row() {
Text(MED_CATEGORY_CONFIG[med.category]?.label ?? med.category)
.fontSize(10).fontColor('#FFFFFF')
.backgroundColor(MED_CATEGORY_CONFIG[med.category]?.color ?? '#00897B')
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
Text(STORAGE_CONFIG[med.storage]?.icon + ' ' + med.storage)
.fontSize(10).fontColor('#004D40')
.backgroundColor('#E0F2F1')
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
.margin({ left: 6 })
if (this.isExpiringSoon(med.expiryDate)) {
Text('⏰ 即将过期')
.fontSize(10).fontColor('#FFFFFF')
.backgroundColor('#FF9800')
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
.margin({ left: 6 })
}
if (med.status === '已过期') {
Text('❌ 已过期')
.fontSize(10).fontColor('#FFFFFF')
.backgroundColor('#F44336')
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
.margin({ left: 6 })
}
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 10 })
Column() {
Text(med.quantity + '盒')
.fontSize(15).fontWeight(FontWeight.Bold)
.fontColor(med.quantity <= 2 ? '#F44336' : '#00897B')
Text('库存').fontSize(9).fontColor('#999999').margin({ top: 1 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding({ left: 14, right: 14, top: 12, bottom: 12 })
Divider().color('#F5F5F5').margin({ left: 14, right: 14 })
Row() {
Text('📅 ' + med.expiryDate).fontSize(10).fontColor('#888888')
Row().layoutWeight(1)
Text('💊 ' + med.dosage).fontSize(10).fontColor('#888888')
Row().layoutWeight(1)
Text('📍 ' + med.location).fontSize(10).fontColor('#888888')
}
.width('100%').padding({ left: 14, right: 14, bottom: 10 })
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(12)
.margin({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick(() => {
this.selectedMedicine = med
this.showDetailModal = true
})
}
medicineCard 是药品列表中每一条药品的卡片构建器,是整个应用中最复杂的卡片组件之一。卡片分为三个区域:
顶部主区域是一个 Row,从左到右依次是:圆形分类图标(48x48,圆角24,背景为分类浅色)、药品名称与标签组(分类标签、存储方式标签、条件性显示的"即将过期"和"已过期"警示标签)、右侧库存数量。库存数量的颜色是动态的:当数量小于等于 2 时显示红色,否则显示青绿色,这是通过三元运算符 med.quantity <= 2 ? '#F44336' : '#00897B' 实现的。
中间是一条浅灰色分割线。底部是一个三列信息行,通过两个 Row().layoutWeight(1) 实现三等分布局,分别显示过期日期、用法用量和存放位置,每项都带有 emoji 前缀增强可读性。
整个卡片绑定了 onClick 事件,点击后设置 selectedMedicine 并打开详情弹框。卡片整体使用白色背景、12 像素圆角,左右各有 12 的外边距,形成卡片间的间距。
8.13 过期判断方法
isExpiringSoon(date: string): boolean {
return date.startsWith('2026-0') || date.startsWith('2025-')
}
isExpiringSoon 是一个简单的日期判断方法,通过字符串前缀匹配来判断药品是否即将过期。如果日期以"2026-0"(即2026年1-9月)或"2025-"(即2025年任意月份)开头,则判定为即将过期。这种实现方式虽然简单,但在演示场景下已经足够。在实际应用中,应该使用日期对象进行更精确的过期判断,比如判断是否在三个月内过期。
8.14 药品清单页主构建函数
build() {
Column() {
Row() {
Column() {
Text('家庭药品管理').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#004D40')
Text('守护家人健康').fontSize(11).fontColor('#4DB6AC').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('➕').fontSize(22).fontColor('#00897B')
.onClick(() => { this.showAddModal = true })
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 10 })
页面顶部是标题栏,左侧是主标题"家庭药品管理"和副标题"守护家人健康",右侧是新增按钮"➕"。主标题使用 20 号深青色粗体字,副标题使用 11 号浅青色字,形成了清晰的标题层级。点击新增按钮设置 showAddModal 为 true,打开新增药品弹框。
Row() {
Text('🔍').fontSize(14).margin({ left: 12 })
TextInput({ placeholder: '搜索药品名称...' })
.placeholderColor('#BBBBBB').fontSize(12).layoutWeight(1)
.backgroundColor('transparent').margin({ left: 6, right: 12 })
.onChange((v: string) => { this.searchKeyword = v })
}
.width('100%').height(36)
.backgroundColor('#FFFFFF').borderRadius(18)
.margin({ left: 12, right: 12, bottom: 8 })
搜索框是一个圆角白色容器(高度36,圆角18),内含一个搜索图标和输入框。输入框背景设为透明(transparent),与容器融为一体。onChange 回调将输入值同步到 searchKeyword 状态。
Row() {
this.statCard('💊', getMedicineCount().toString(), '药品总数', '#00897B')
this.statCard('⚠️', getLowStockCount().toString(), '库存不足', '#FF9800')
this.statCard('⏰', getExpiringCount().toString(), '即将过期', '#FF9800')
this.statCard('❌', getExpiredCount().toString(), '已过期', '#F44336')
}
.width('100%').padding({ left: 8, right: 8 })
四个统计卡片水平排列,分别展示药品总数、库存不足数、即将过期数和已过期数。每个卡片调用前面定义的 statCard 构建器,传入不同的图标、数值、标签和颜色。
Scroll() {
Column() {
Scroll() {
Row() {
this.filterChip('全部', '全部')
this.filterChip('🤧 感冒', '感冒药')
this.filterChip('💊 消炎', '消炎药')
this.filterChip('🍊 维生素', '维生素')
this.filterChip('🩹 外用', '外用药')
this.filterChip('⏰ 慢病', '慢性病药')
this.filterChip('🌿 肠胃', '肠胃药')
this.filterChip('💉 止痛', '止痛药')
this.filterChip('👁️ 专科', '眼耳鼻喉')
}
.padding({ left: 8, right: 8 })
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
.margin({ left: 4, right: 4, bottom: 8 })
this.monthBarChart()
this.categoryRatioBar()
this.stockWarnChart()
主体内容区域是一个垂直滚动的 Scroll 容器,内部依次包含:水平滚动的分类筛选条、月度柱状图、分类占比条、库存预警进度条。筛选条使用 scrollable(ScrollDirection.Horizontal) 实现水平滚动,并通过 scrollBar(BarState.Off) 隐藏滚动条,保持界面整洁。
Row() {
Text('📋 药品列表').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#004D40')
Row().layoutWeight(1)
Text('共' + getMedicineCount() + '种').fontSize(11).fontColor('#999999')
}
.width('100%').padding({ left: 16, top: 12, bottom: 4 })
this.medicineCard(mockMedicines[0], 0)
this.medicineCard(mockMedicines[1], 1)
// ... 共20条药品卡片
this.medicineCard(mockMedicines[19], 19)
Column().height(20)
}
}
.layoutWeight(1)
if (this.showAddModal) { this.addMedicineModal() }
if (this.showEditModal) { this.editMedicineModal() }
if (this.showDeleteConfirm) { this.deleteMedicineModal() }
if (this.showDetailModal) { this.detailMedicineModal() }
}
.width('100%').height('100%')
.backgroundColor('#E0F2F1')
}
药品列表标题行使用两端对齐布局,左侧是"📋 药品列表"标题,右侧是"共20种"的计数。下方依次渲染了20个药品卡片,每个卡片通过 this.medicineCard(mockMedicines[index], index) 调用。列表末尾添加了一个高度为20的空白 Column 作为底部间距。
最后,四个弹框通过条件渲染的方式放在 build 函数末尾。只有当对应的 show*Modal 状态为 true 时,弹框才会被渲染出来。这种条件渲染的方式确保了弹框不影响正常界面的性能,只有在需要时才创建。
九、用药提醒页深度解析
用药提醒页以时间轴的形式展示一天的用药计划,让用户清晰地了解每个时间点需要服用什么药。
9.1 时间轴项构建器
@Builder reminderTimelineItem(r: ReminderMeta, index: number) {
Row() {
Column() {
Text(r.time).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#00897B')
Column().width(2).height(40).backgroundColor('#B2DFDB').margin({ top: 4 })
}
.width(60)
Column() {
Column()
.width(r.isTaken ? 12 : 10).height(r.isTaken ? 12 : 10)
.backgroundColor(r.isTaken ? '#00897B' : '#FFFFFF')
.border({ width: 2, color: '#00897B' })
.borderRadius(6)
}
.width(20)
Column() {
Row() {
Text(r.icon).fontSize(20)
Column() {
Text(r.medicine).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#333333')
Text(r.dosage + ' · ' + r.frequency)
.fontSize(11).fontColor('#888888').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 8 })
if (r.isTaken) {
Text('✅ 已服').fontSize(10).fontColor('#FFFFFF')
.backgroundColor('#00897B')
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(10)
} else {
Text('⏳ 待服').fontSize(10).fontColor('#FFFFFF')
.backgroundColor('#FF9800')
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(10)
}
}
.width('100%')
}
.layoutWeight(1)
.backgroundColor('#FFFFFF')
.borderRadius(10)
.padding({ left: 12, right: 12, top: 12, bottom: 12 })
.margin({ left: 8 })
}
.width('100%')
.padding({ left: 12, right: 12, top: 4, bottom: 4 })
}
时间轴项是用药提醒页的核心组件,它由三部分组成:
左侧时间列(宽度60)显示提醒时间和一条竖向连接线。竖线宽度为2、高度为40、颜色为浅青色,将相邻的时间节点串联起来,形成时间轴的视觉效果。
中间圆点列(宽度20)显示状态圆点。已服状态的圆点为实心(12x12,青绿色填充),未服状态为空心(10x10,白色填充加2像素青绿色边框)。这种大小和填充状态的差异让用户能够快速区分已服和待服项。
右侧内容卡片是一个白色圆角卡片,包含药品图标、药品名称、剂量频率信息和状态标签。状态标签通过条件渲染显示"✅ 已服"(青绿底)或"⏳ 待服"(橙色底),颜色编码与状态语义一致。
9.2 今日统计与进度条
Row() {
Column() {
Text('今日提醒').fontSize(11).fontColor('#888888')
Text(getReminderCount().toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#00897B').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column().width(1).height(36).backgroundColor('#E0F2F1')
Column() {
Text('已服用').fontSize(11).fontColor('#888888')
Text(getTodayTakenCount().toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#4DB6AC').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column().width(1).height(36).backgroundColor('#E0F2F1')
Column() {
Text('待服用').fontSize(11).fontColor('#888888')
Text((getReminderCount() - getTodayTakenCount()).toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FF9800').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.padding({ top: 14, bottom: 14 })
.margin({ left: 12, right: 12, bottom: 8 })
今日统计区分为三列:今日提醒总数(青绿色)、已服用数(浅青色)、待服用数(橙色)。三列之间用1像素宽、36像素高的浅色竖线分隔,形成清晰的视觉分区。"待服用"的数值通过 getReminderCount() - getTodayTakenCount() 动态计算得出。
Column() {
Row() {
Text('今日服药进度').fontSize(11).fontColor('#888888')
Row().layoutWeight(1)
Text(getTodayTakenCount() + '/' + getReminderCount()).fontSize(11).fontColor('#00897B').fontWeight(FontWeight.Bold)
}
.width('100%').margin({ bottom: 6 })
Row() {
Column()
.layoutWeight(getTodayTakenCount())
.height(8).backgroundColor('#00897B').borderRadius({ topLeft: 4, bottomLeft: 4 })
Column()
.layoutWeight(getReminderCount() - getTodayTakenCount())
.height(8).backgroundColor('#E0F2F1').borderRadius({ topRight: 4, bottomRight: 4 })
}
.width('100%')
}
服药进度条使用与分类占比条相同的 layoutWeight 技术实现。已服部分权重为 getTodayTakenCount()(值为3),待服部分权重为 getReminderCount() - getTodayTakenCount()(值为5),因此进度条填充比例为 3/8 = 37.5%。进度条上方左侧显示"今日服药进度"标签,右侧显示"3/8"的进度数值。
9.3 筛选与列表
Row() {
this.filterChip('今日', '今日')
this.filterChip('本周', '本周')
this.filterChip('全部', '全部')
this.filterChip('已服', '已服')
this.filterChip('待服', '待服')
}
.padding({ left: 8, right: 8, bottom: 8 })
Scroll() {
Column() {
this.reminderTimelineItem(mockReminders[0], 0)
this.reminderTimelineItem(mockReminders[1], 1)
// ... 共8条提醒
this.reminderTimelineItem(mockReminders[7], 7)
Column().height(20)
}
}
.layoutWeight(1)
筛选条提供五个选项:今日、本周、全部、已服、待服。通过 selectedFilter 状态控制选中态,与药品清单页的分类筛选逻辑一致。时间轴列表通过 Scroll 包裹,支持垂直滚动查看所有提醒项。
十、就医记录页深度解析
就医记录页以卡片形式展示历次就诊信息,帮助用户回顾完整的就医历史。
10.1 就诊卡片构建器
@Builder visitCard(v: VisitMeta) {
Column() {
Row() {
Column() {
Text('🏥').fontSize(24)
}
.width(44).height(44)
.backgroundColor('#E0F2F1').borderRadius(22)
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Center)
Column() {
Text(v.hospital).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#333333')
Text(v.department + ' · ' + v.doctor)
.fontSize(11).fontColor('#888888').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 10 })
Text('📅' + v.date).fontSize(10).fontColor('#00897B')
}
.width('100%')
Divider().color('#F5F5F5').margin({ top: 10, bottom: 10 })
Row() {
Text('诊断:').fontSize(11).fontColor('#999999')
Text(v.diagnosis).fontSize(11).fontColor('#004D40').fontWeight(FontWeight.Bold)
}
Row() {
Text('处方:').fontSize(11).fontColor('#999999')
Text(v.prescription).fontSize(11).fontColor('#333333').layoutWeight(1)
}
.margin({ top: 4 })
Row() {
Text('💰 ' + v.cost + '元').fontSize(11).fontColor('#FF9800').fontWeight(FontWeight.Bold)
Row().layoutWeight(1)
Text('查看详情 >').fontSize(10).fontColor('#00897B')
}
.margin({ top: 6 })
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(12)
.padding(14)
.margin({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick(() => {
this.selectedVisit = v
this.showDetailModal = true
})
}
就诊卡片的设计层次分明。顶部区域左侧是医院图标(圆形浅青色背景),中间是医院名称和"科室 · 医生"信息,右侧是就诊日期。中间分割线下方是诊断和处方信息,诊断结果使用深青色加粗显示以突出重点。底部左侧是就诊费用(橙色加粗),右侧是"查看详情 >"链接。整个卡片可点击,点击后打开详情弹框。
10.2 就医统计
Row() {
Column() {
Text('就医次数').fontSize(11).fontColor('#888888')
Text(getVisitCount().toString()).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#00897B').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column().width(1).height(32).backgroundColor('#E0F2F1')
Column() {
Text('总花费').fontSize(11).fontColor('#888888')
Text('1246元').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FF9800').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column().width(1).height(32).backgroundColor('#E0F2F1')
Column() {
Text('本年就医').fontSize(11).fontColor('#888888')
Text('7次').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#4DB6AC').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
}
就医统计区展示三个指标:就医次数(7次,青绿色)、总花费(1246元,橙色)、本年就医(7次,浅青色)。总花费使用橙色强调,因为费用是用户比较关注的信息。三列之间同样使用竖线分隔,与用药提醒页的统计区设计风格保持一致。
十一、健康指标页深度解析
健康指标页展示用户的各项健康数据,包括血压趋势图和指标卡片列表。
11.1 血压趋势柱状图
Column() {
Row() {
Text('💓 血压趋势').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#004D40')
Row().layoutWeight(1)
Text('mmHg').fontSize(10).fontColor('#999999')
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 8 })
Row() {
Column() {
Column()
.width(24).height(128)
.backgroundColor('#F44336').borderRadius({ topLeft: 4, topRight: 4 })
Text('7/19').fontSize(9).fontColor('#888888').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Column()
.width(24).height(118)
.backgroundColor('#00897B').borderRadius({ topLeft: 4, topRight: 4 })
Text('7/18').fontSize(9).fontColor('#888888').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Column()
.width(24).height(122)
.backgroundColor('#FF9800').borderRadius({ topLeft: 4, topRight: 4 })
Text('7/15').fontSize(9).fontColor('#888888').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%').padding({ left: 12, right: 12, bottom: 12 })
.alignItems(VerticalAlign.Bottom)
}
血压趋势图展示了三天的血压数据。每根柱子的颜色根据血压值动态选择:128 mmHg 使用红色(#F44336,偏高),118 mmHg 使用青绿色(#00897B,正常),122 mmHg 使用橙色(#FF9800,略高)。这种根据数值范围动态着色的方式,让用户一眼就能识别出异常数据点。柱子高度直接使用血压值作为像素高度(128像素对应128 mmHg),简洁直观。
11.2 健康指标卡片
@Builder metricCard(m: HealthMetricMeta) {
Column() {
Row() {
Text(m.icon).fontSize(20)
Column() {
Text(m.type).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#333333')
Text(m.date).fontSize(10).fontColor('#999999').margin({ top: 1 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 8 })
Column() {
Text(m.value.toString() + ' ' + m.unit)
.fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(m.status === '正常' ? '#00897B' : (m.status === '偏高' ? '#FF9800' : '#F44336'))
Text(m.status).fontSize(10).fontColor(m.status === '正常' ? '#00897B' : '#FF9800')
.margin({ top: 1 })
}
.alignItems(HorizontalAlign.End)
}
Divider().color('#F5F5F5').margin({ top: 8, bottom: 6 })
Text('参考范围:' + m.reference).fontSize(10).fontColor('#999999')
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(10)
.padding(12)
.margin({ left: 6, right: 6, bottom: 6 })
}
健康指标卡片采用双列布局,在主构建函数中以两列网格的形式排列。卡片左侧是指标图标和类型名称、日期,右侧是测量数值和状态。数值颜色通过嵌套三元运算符动态决定:正常为青绿色,偏高为橙色,其他为红色。卡片底部分割线下方显示参考范围,帮助用户理解数值含义。
Scroll() {
Column() {
Row() {
this.metricCard(mockMetrics[0])
this.metricCard(mockMetrics[1])
}
Row() {
this.metricCard(mockMetrics[2])
this.metricCard(mockMetrics[3])
}
Row() {
this.metricCard(mockMetrics[4])
this.metricCard(mockMetrics[5])
}
Row() {
this.metricCard(mockMetrics[6])
this.metricCard(mockMetrics[7])
}
Column().height(20)
}
}
.layoutWeight(1)
八条指标数据以两列四行的网格形式排列。每行包含两个 metricCard,通过卡片自身的 margin 设置左右间距。整个网格被包裹在 Scroll 中,支持垂直滚动。这种两列网格布局在移动端展示数据卡片时非常高效,能够在有限的屏幕宽度内展示更多信息。
十二、个人中心页深度解析
个人中心页是一个纯展示页面,没有状态管理和交互逻辑,但它提供了应用的全局概览和功能入口。
12.1 用户头部
Row() {
Column() {
Text('👤').fontSize(40)
}
.width(64).height(64)
.backgroundColor('#FFFFFF').borderRadius(32)
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Center)
Column() {
Text('健康守护者').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#004D40')
Text('家庭健康管理 · 365天').fontSize(11).fontColor('#4DB6AC').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 12 })
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(12)
.padding(16)
.margin({ left: 12, right: 12, top: 14, bottom: 8 })
用户头部展示一个 64x64 的圆形头像区域(白色背景,32 圆角)和用户信息。"健康守护者"是用户的显示名称,"家庭健康管理 · 365天"是用户的使用标语,暗示用户已经持续使用该应用一年。整个头部使用白色圆角卡片包裹。
12.2 数据概览
Row() {
Column() {
Text('20').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#00897B')
Text('药品总数').fontSize(10).fontColor('#888888').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column().width(1).height(32).backgroundColor('#E0F2F1')
Column() {
Text('8').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#4DB6AC')
Text('用药提醒').fontSize(10).fontColor('#888888').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column().width(1).height(32).backgroundColor('#E0F2F1')
Column() {
Text('7').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FF9800')
Text('就医记录').fontSize(10).fontColor('#888888').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column().width(1).height(32).backgroundColor('#E0F2F1')
Column() {
Text('13').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#004D40')
Text('正常药品').fontSize(10).fontColor('#888888').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
}
数据概览区展示四项关键指标:药品总数(20)、用药提醒(8)、就医记录(7)、正常药品(13)。每项使用不同的颜色(青绿、浅青、橙色、深青),四项之间用竖线分隔。这种四列统计布局让用户一眼就能掌握应用的整体数据状况。
12.3 功能菜单列表
Column() {
Row() {
Text('⚙️').fontSize(18)
Text('药品管理设置').fontSize(14).fontColor('#333333').margin({ left: 12 })
Row().layoutWeight(1)
Text('›').fontSize(18).fontColor('#CCCCCC')
}
.width('100%').padding(14)
Divider().color('#F5F5F5').margin({ left: 14, right: 14 })
Row() {
Text('🔔').fontSize(18)
Text('提醒通知设置').fontSize(14).fontColor('#333333').margin({ left: 12 })
Row().layoutWeight(1)
Text('›').fontSize(18).fontColor('#CCCCCC')
}
.width('100%').padding(14)
// ... 更多菜单项
Row() {
Text('ℹ️').fontSize(18)
Text('关于').fontSize(14).fontColor('#333333').margin({ left: 12 })
Row().layoutWeight(1)
Text('v1.0.0').fontSize(11).fontColor('#999999').margin({ right: 8 })
Text('›').fontSize(18).fontColor('#CCCCCC')
}
.width('100%').padding(14)
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.margin({ left: 12, right: 12 })
功能菜单列表包含七项:药品管理设置、提醒通知设置、导出健康报告、家庭成员管理、用药历史记录、帮助与反馈、关于。每项由图标、标题、弹性间距和右箭头组成,项与项之间用浅灰色分割线分隔。最后一项"关于"额外显示了版本号"v1.0.0",这是应用信息展示的常见做法。整个菜单列表使用白色圆角卡片包裹,视觉风格统一。
十三、关键特性对比总结
下面通过一个表格,对应用的五大核心模块进行横向对比,帮助读者快速了解各模块的定位与特点。
| 特性维度 | 药品清单页 | 用药提醒页 | 就医记录页 | 健康指标页 | 个人中心页 |
|---|---|---|---|---|---|
| 核心功能 | 药品信息管理与展示 | 服药时间规划与追踪 | 就诊历史归档与查阅 | 健康数据记录与趋势 | 全局数据概览与设置 |
| 数据条目 | 20条药品记录 | 8条提醒记录 | 7条就诊记录 | 8条指标记录 | 7项功能菜单 |
| 状态变量数 | 18个 | 2个 | 3个 | 1个 | 0个 |
| 弹框数量 | 4个(新增/编辑/删除/详情) | 1个(新增提醒) | 1个(新增记录) | 1个(新增指标) | 无 |
| 图表组件 | 柱状图/占比条/预警条 | 进度条 | 无 | 血压趋势柱状图 | 无 |
| 交互复杂度 | 高(搜索/筛选/CRUD) | 中(筛选/时间轴) | 中(查看/新增) | 低(查看/新增) | 低(纯展示) |
| 主色调 | 青绿色 #00897B | 青绿色 #00897B | 青绿色 #00897B | 青绿色 #00897B | 青绿色 #00897B |
| 状态编码 | 5种状态色 | 2种状态色(已服/待服) | 无状态编码 | 3种状态色 | 无状态编码 |
| 数据模型 | MedicineItem类 | ReminderMeta接口 | VisitMeta接口 | HealthMetricMeta接口 | 无独立模型 |
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:

初始化项目,自动下载相关依赖:

完整代码:
// ============ 类型定义 ============
interface MedicineCategoryMeta {
label: string
icon: string
color: string
bg: string
desc: string
}
interface MedicineStatusMeta {
label: string
color: string
icon: string
bg: string
}
interface StorageMeta {
label: string
icon: string
desc: string
}
interface FrequencyMeta {
label: string
times: number
interval: string
}
interface ReminderMeta {
id: number
time: string
medicine: string
dosage: string
frequency: string
isTaken: boolean
icon: string
}
interface VisitMeta {
id: number
date: string
hospital: string
department: string
doctor: string
diagnosis: string
prescription: string
cost: number
notes: string
}
interface HealthMetricMeta {
id: number
date: string
type: string
value: number
unit: string
status: string
reference: string
icon: string
}
interface MonthBarMeta {
label: string
value: number
color: string
}
interface CategoryRatioMeta {
label: string
value: number
color: string
icon: string
}
interface StockWarnMeta {
label: string
current: number
threshold: number
color: string
icon: string
}
// ============ 药品数据模型 ============
@Observed
export class MedicineItem {
id: number = 0
name: string = ''
category: string = ''
quantity: number = 0
expiryDate: string = ''
dosage: string = ''
frequency: string = ''
notes: string = ''
storage: string = ''
location: string = ''
manufacturer: string = ''
status: string = ''
constructor(id: number, name: string, category: string, quantity: number, expiryDate: string, dosage: string, frequency: string, notes: string, storage: string, location: string, manufacturer: string, status: string) {
this.id = id; this.name = name; this.category = category; this.quantity = quantity
this.expiryDate = expiryDate; this.dosage = dosage; this.frequency = frequency
this.notes = notes; this.storage = storage; this.location = location
this.manufacturer = manufacturer; this.status = status
}
}
// ============ 配置令牌 ============
const MED_CATEGORY_CONFIG: Record<string, MedicineCategoryMeta> = {
'感冒药': { label: '感冒药', icon: '🤧', color: '#00897B', bg: '#E0F2F1', desc: '缓解感冒症状' },
'消炎药': { label: '消炎药', icon: '💊', color: '#4DB6AC', bg: '#E0F2F1', desc: '抗菌消炎' },
'维生素': { label: '维生素', icon: '🍊', color: '#004D40', bg: '#E0F2F1', desc: '补充营养素' },
'外用药': { label: '外用药', icon: '🩹', color: '#00695C', bg: '#E0F2F1', desc: '涂抹外敷' },
'慢性病药': { label: '慢性病药', icon: '⏰', color: '#B2DFDB', bg: '#E0F2F1', desc: '长期服用' },
'肠胃药': { label: '肠胃药', icon: '🌿', color: '#4DB6AC', bg: '#E0F2F1', desc: '调理肠胃' },
'止痛药': { label: '止痛药', icon: '💉', color: '#00897B', bg: '#E0F2F1', desc: '镇痛止痛' },
'眼耳鼻喉': { label: '眼耳鼻喉', icon: '👁️', color: '#004D40', bg: '#E0F2F1', desc: '专科用药' }
}
const MED_STATUS_CONFIG: Record<string, MedicineStatusMeta> = {
'正常': { label: '正常', color: '#00897B', icon: '✅', bg: '#E0F2F1' },
'库存不足': { label: '库存不足', color: '#FF9800', icon: '⚠️', bg: '#FFF3E0' },
'即将过期': { label: '即将过期', color: '#FF9800', icon: '⏰', bg: '#FFF3E0' },
'已过期': { label: '已过期', color: '#F44336', icon: '❌', bg: '#FFEBEE' },
'已停用': { label: '已停用', color: '#9E9E9E', icon: '⏹️', bg: '#F5F5F5' }
}
const STORAGE_CONFIG: Record<string, StorageMeta> = {
'常温': { label: '常温', icon: '🌡️', desc: '室温保存10-30℃' },
'冷藏': { label: '冷藏', icon: '❄️', desc: '冰箱2-8℃保存' },
'冷冻': { label: '冷冻', icon: '🧊', desc: '冷冻-18℃以下' },
'避光': { label: '避光', icon: '🌑', desc: '避光密闭保存' }
}
const FREQUENCY_CONFIG: Record<string, FrequencyMeta> = {
'每日1次': { label: '每日1次', times: 1, interval: '24小时' },
'每日2次': { label: '每日2次', times: 2, interval: '12小时' },
'每日3次': { label: '每日3次', times: 3, interval: '8小时' },
'每周1次': { label: '每周1次', times: 1, interval: '7天' },
'必要时': { label: '必要时', times: 0, interval: '按需服用' }
}
const MED_CATEGORIES: string[] = ['感冒药', '消炎药', '维生素', '外用药', '慢性病药', '肠胃药', '止痛药', '眼耳鼻喉']
const MED_FILTERS: string[] = ['全部', '感冒药', '消炎药', '维生素', '外用药', '慢性病药', '肠胃药', '止痛药', '眼耳鼻喉']
const STORAGES: string[] = ['常温', '冷藏', '冷冻', '避光']
const FREQUENCIES: string[] = ['每日1次', '每日2次', '每日3次', '每周1次', '必要时']
// ============ 全局写死数据 - 20条药品记录 ============
const mockMedicines: MedicineItem[] = [
new MedicineItem(1, '连花清瘟胶囊', '感冒药', 3, '2027-03-15', '4粒/次', '每日3次', '风热感冒常备', '常温', '家庭药箱A层', '以岭药业', '正常'),
new MedicineItem(2, '布洛芬缓释胶囊', '止痛药', 2, '2026-09-20', '1粒/次', '每日2次', '发热头痛止痛', '常温', '家庭药箱A层', '中美史克', '库存不足'),
new MedicineItem(3, '阿莫西林胶囊', '消炎药', 12, '2026-11-05', '2粒/次', '每日3次', '细菌感染消炎', '常温', '家庭药箱B层', '珠海联邦', '正常'),
new MedicineItem(4, '维生素C片', '维生素', 1, '2026-08-10', '1片/次', '每日1次', '增强免疫补充维C', '避光', '家庭药箱B层', '华北制药', '库存不足'),
new MedicineItem(5, '复方丹参滴丸', '慢性病药', 6, '2027-01-25', '10丸/次', '每日3次', '心血管保健', '常温', '床头柜抽屉', '天士力', '正常'),
new MedicineItem(6, '红霉素软膏', '外用药', 1, '2025-12-30', '适量外涂', '每日2次', '皮肤感染外伤', '常温', '家庭药箱C层', '利君制药', '即将过期'),
new MedicineItem(7, '健胃消食片', '肠胃药', 8, '2027-06-18', '3片/次', '每日3次', '消化不良常备', '常温', '客厅药盒', '江中制药', '正常'),
new MedicineItem(8, '氯雷他定片', '消炎药', 15, '2027-02-28', '1片/次', '每日1次', '抗过敏', '常温', '家庭药箱B层', '拜耳医药', '正常'),
new MedicineItem(9, '硝苯地平控释片', '慢性病药', 4, '2026-10-12', '1片/次', '每日1次', '高血压降压', '避光', '床头柜抽屉', '拜耳医药', '正常'),
new MedicineItem(10, '创可贴(套装)', '外用药', 20, '2028-01-01', '1片/次', '必要时', '小伤口止血', '常温', '家庭药箱C层', '云南白药', '正常'),
new MedicineItem(11, '板蓝根冲剂', '感冒药', 0, '2025-08-15', '1袋/次', '每日3次', '风寒感冒预防', '常温', '家庭药箱A层', '白云山制药', '已过期'),
new MedicineItem(12, '金霉素眼膏', '眼耳鼻喉', 1, '2026-07-28', '适量涂抹', '每日2次', '眼部感染炎症', '常温', '家庭药箱D层', '辰欣药业', '即将过期'),
new MedicineItem(13, '盐酸二甲双胍片', '慢性病药', 10, '2027-04-30', '1片/次', '每日2次', '二型糖尿病控制血糖', '常温', '床头柜抽屉', '中美上海施贵宝', '正常'),
new MedicineItem(14, '蒙脱石散', '肠胃药', 5, '2027-05-22', '1袋/次', '每日3次', '腹泻止泻', '常温', '客厅药盒', '博福-益普生', '正常'),
new MedicineItem(15, '云南白药喷雾', '外用药', 2, '2026-12-15', '适量喷洒', '每日3次', '跌打损伤淤青', '常温', '玄关鞋柜', '云南白药', '正常'),
new MedicineItem(16, '对乙酰氨基酚片', '止痛药', 6, '2025-11-08', '1片/次', '每日3次', '退烧镇痛', '常温', '家庭药箱A层', '上海强生', '已过期'),
new MedicineItem(17, '复合维生素B', '维生素', 20, '2027-08-30', '2片/次', '每日3次', 'B族维生素补充', '避光', '家庭药箱B层', '华北制药', '正常'),
new MedicineItem(18, '头孢克肟分散片', '消炎药', 7, '2026-09-05', '2片/次', '每日2次', '呼吸道感染', '常温', '家庭药箱B层', '广药集团', '正常'),
new MedicineItem(19, '开塞露', '肠胃药', 3, '2028-03-20', '1支/次', '必要时', '便秘通便', '常温', '家庭药箱C层', '新乡东海', '正常'),
new MedicineItem(20, '左氧氟沙星滴眼液', '眼耳鼻喉', 1, '2026-08-20', '2滴/次', '每日3次', '细菌性结膜炎', '冷藏', '冰箱冷藏层', '参天制药', '即将过期')
]
// ============ 用药提醒数据 ============
const mockReminders: ReminderMeta[] = [
{ id: 1, time: '06:30', medicine: '硝苯地平控释片', dosage: '1片', frequency: '每日1次', isTaken: true, icon: '⏰' },
{ id: 2, time: '07:00', medicine: '盐酸二甲双胍片', dosage: '1片', frequency: '每日2次', isTaken: true, icon: '⏰' },
{ id: 3, time: '07:30', medicine: '连花清瘟胶囊', dosage: '4粒', frequency: '每日3次', isTaken: true, icon: '💊' },
{ id: 4, time: '08:00', medicine: '维生素片', dosage: '1片', frequency: '每日1次', isTaken: false, icon: '🍊' },
{ id: 5, time: '12:30', medicine: '盐酸二甲双胍片', dosage: '1片', frequency: '每日2次', isTaken: false, icon: '⏰' },
{ id: 6, time: '13:00', medicine: '复方丹参滴丸', dosage: '10丸', frequency: '每日3次', isTaken: false, icon: '💊' },
{ id: 7, time: '19:00', medicine: '复方丹参滴丸', dosage: '10丸', frequency: '每日3次', isTaken: false, icon: '💊' },
{ id: 8, time: '20:30', medicine: '健胃消食片', dosage: '3片', frequency: '每日3次', isTaken: false, icon: '🌿' }
]
// ============ 就医记录 ============
const mockVisits: VisitMeta[] = [
{ id: 1, date: '2026-07-10', hospital: '市第一人民医院', department: '心内科', doctor: '王主任医师', diagnosis: '高血压2级', prescription: '硝苯地平控释片', cost: 328, notes: '建议低盐饮食定期监测血压' },
{ id: 2, date: '2026-06-22', hospital: '市中医院', department: '内科', doctor: '李医师', diagnosis: '风寒感冒', prescription: '连花清瘟胶囊+板蓝根', cost: 86, notes: '多饮温水注意休息' },
{ id: 3, date: '2026-06-15', hospital: '市第二人民医院', department: '内分泌科', doctor: '张副主任医师', diagnosis: '二型糖尿病', prescription: '盐酸二甲双胍片', cost: 245, notes: '控制糖分摄入适当运动' },
{ id: 4, date: '2026-05-30', hospital: '社区卫生服务中心', department: '全科', doctor: '陈医生', diagnosis: '急性肠胃炎', prescription: '蒙脱石散+健胃消食片', cost: 68, notes: '清淡饮食避免辛辣' },
{ id: 5, date: '2026-05-12', hospital: '市第一人民医院', department: '眼科', doctor: '刘主治医师', diagnosis: '细菌性结膜炎', prescription: '左氧氟沙星滴眼液', cost: 128, notes: '注意用眼卫生避免揉眼' },
{ id: 6, date: '2026-04-28', hospital: '市骨科医院', department: '骨科', doctor: '赵主任医师', diagnosis: '踝关节扭伤', prescription: '云南白药喷雾+布洛芬', cost: 215, notes: '减少走动抬高患肢' },
{ id: 7, date: '2026-04-10', hospital: '市第一人民医院', department: '呼吸内科', doctor: '孙副主任医师', diagnosis: '上呼吸道感染', prescription: '阿莫西林胶囊+复方甘草片', cost: 156, notes: '注意保暖多喝热水' }
]
// ============ 健康指标 ============
const mockMetrics: HealthMetricMeta[] = [
{ id: 1, date: '2026-07-19', type: '血压', value: 128, unit: 'mmHg', status: '偏高', reference: '90-120', icon: '💓' },
{ id: 2, date: '2026-07-19', type: '血糖', value: 6.8, unit: 'mmol/L', status: '偏高', reference: '3.9-6.1', icon: '🩸' },
{ id: 3, date: '2026-07-18', type: '血压', value: 118, unit: 'mmHg', status: '正常', reference: '90-120', icon: '💓' },
{ id: 4, date: '2026-07-18', type: '体温', value: 36.5, unit: '℃', status: '正常', reference: '36-37.2', icon: '🌡️' },
{ id: 5, date: '2026-07-17', type: '心率', value: 72, unit: '次/分', status: '正常', reference: '60-100', icon: '❤️' },
{ id: 6, date: '2026-07-17', type: '血糖', value: 5.9, unit: 'mmol/L', status: '正常', reference: '3.9-6.1', icon: '🩸' },
{ id: 7, date: '2026-07-16', type: '体重', value: 65.2, unit: 'kg', status: '正常', reference: 'BMI标准', icon: '⚖️' },
{ id: 8, date: '2026-07-15', type: '血压', value: 122, unit: 'mmHg', status: '正常', reference: '90-120', icon: '💓' }
]
// ============ 月度用药次数 ============
const monthBarData: MonthBarMeta[] = [
{ label: '6月上', value: 42, color: '#B2DFDB' },
{ label: '6月中', value: 56, color: '#4DB6AC' },
{ label: '6月下', value: 68, color: '#00897B' },
{ label: '7月上', value: 78, color: '#4DB6AC' },
{ label: '7月中', value: 92, color: '#00897B' },
{ label: '7月下', value: 65, color: '#004D40' }
]
// ============ 各药品分类占比 ============
const categoryRatioData: CategoryRatioMeta[] = [
{ label: '感冒药', value: 4, color: '#00897B', icon: '🤧' },
{ label: '消炎药', value: 3, color: '#4DB6AC', icon: '💊' },
{ label: '维生素', value: 2, color: '#004D40', icon: '🍊' },
{ label: '外用药', value: 4, color: '#00695C', icon: '🩹' },
{ label: '慢性病药', value: 3, color: '#B2DFDB', icon: '⏰' },
{ label: '肠胃药', value: 2, color: '#4DB6AC', icon: '🌿' },
{ label: '止痛药', value: 2, color: '#00897B', icon: '💉' }
]
// ============ 库存预警 ============
const stockWarnData: StockWarnMeta[] = [
{ label: '维生素片', current: 1, threshold: 5, color: '#FF9800', icon: '⚠️' },
{ label: '布洛芬胶囊', current: 2, threshold: 5, color: '#FF9800', icon: '⚠️' },
{ label: '金霉素眼膏', current: 1, threshold: 3, color: '#F44336', icon: '❌' },
{ label: '左氧氟沙星滴眼液', current: 1, threshold: 3, color: '#F44336', icon: '❌' },
{ label: '板蓝根冲剂', current: 0, threshold: 2, color: '#F44336', icon: '❌' }
]
// ============ 统计函数 ============
function getMedicineCount(): number { return 20 }
function getExpiringCount(): number { return 3 }
function getExpiredCount(): number { return 2 }
function getLowStockCount(): number { return 5 }
function getReminderCount(): number { return 8 }
function getTodayTakenCount(): number { return 3 }
function getVisitCount(): number { return 7 }
function getNormalCount(): number { return 13 }
// ============ 底部 Tab 枚举 ============
enum MedicineTab {
LIST = 0,
REMINDER = 1,
VISIT = 2,
HEALTH = 3,
PROFILE = 4
}
// ============ 入口页面 ============
@Entry
@Component
struct MedicineApp {
@State activeTab: MedicineTab = MedicineTab.LIST
@Builder contentArea() {
Column() {
if (this.activeTab === MedicineTab.LIST) {
MedicineListContent()
} else if (this.activeTab === MedicineTab.REMINDER) {
ReminderContent()
} else if (this.activeTab === MedicineTab.VISIT) {
VisitContent()
} else if (this.activeTab === MedicineTab.HEALTH) {
HealthContent()
} else {
ProfileContent()
}
}
.layoutWeight(1)
}
@Builder bottomTabItem(icon: string, label: string, tab: MedicineTab) {
Column() {
Text(icon).fontSize(20).opacity(this.activeTab === tab ? 1.0 : 0.45)
Text(label).fontSize(9)
.fontColor(this.activeTab === tab ? '#00897B' : '#999999')
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 1 })
if (this.activeTab === tab) {
Column().width(18).height(3)
.backgroundColor('#00897B').borderRadius(2).margin({ top: 2 })
}
}
.layoutWeight(1)
.padding({ top: 5, bottom: 5 })
.onClick(() => { this.activeTab = tab })
}
build() {
Column() {
this.contentArea()
Row() {
this.bottomTabItem('💊', '药品清单', MedicineTab.LIST)
this.bottomTabItem('⏰', '用药提醒', MedicineTab.REMINDER)
this.bottomTabItem('🏥', '就医记录', MedicineTab.VISIT)
this.bottomTabItem('📊', '健康指标', MedicineTab.HEALTH)
this.bottomTabItem('👤', '我的', MedicineTab.PROFILE)
}
.width('100%')
.backgroundColor('#FFFFFF')
.padding({ top: 4, bottom: 6 })
.shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
}
.width('100%').height('100%')
.backgroundColor('#E0F2F1')
}
}
// ============ 药品清单页 ============
@Component
struct MedicineListContent {
@State searchKeyword: string = ''
@State selectedCategory: string = '全部'
@State showAddModal: boolean = false
@State showEditModal: boolean = false
@State showDeleteConfirm: boolean = false
@State showDetailModal: boolean = false
@State selectedMedicine: MedicineItem | null = null
@State editingMedicine: MedicineItem | null = null
@State formName: string = ''
@State formCategory: string = '感冒药'
@State formQuantity: string = '10'
@State formExpiry: string = '2027-06-30'
@State formDosage: string = ''
@State formFrequency: string = '每日3次'
@State formStorage: string = '常温'
@State formLocation: string = ''
@State formManufacturer: string = ''
@State formNotes: string = ''
// ========== 弹框遮罩 ==========
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(onClose)
}
// ========== 弹框1:新增药品 ==========
@Builder addMedicineModal() {
Column() {
this.modalOverlay(() => { this.showAddModal = false })
Column() {
Row() {
Text('💊 新增药品').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#004D40')
Row().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showAddModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#E0F2F1')
Scroll() {
Column() {
Text('药品名称').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: '如:连花清瘟胶囊' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.formName = v })
Text('药品分类').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
Scroll() {
Row() {
ForEach(MED_CATEGORIES, (c: string) => {
if (this.formCategory === c) {
Text(MED_CATEGORY_CONFIG[c]?.icon + ' ' + c)
.fontSize(11).fontColor('#FFFFFF')
.backgroundColor(MED_CATEGORY_CONFIG[c]?.color ?? '#00897B')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
} else {
Text(MED_CATEGORY_CONFIG[c]?.icon + ' ' + c)
.fontSize(11).fontColor(MED_CATEGORY_CONFIG[c]?.color ?? '#00897B')
.backgroundColor(MED_CATEGORY_CONFIG[c]?.bg ?? '#E0F2F1')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formCategory = c })
}
})
}
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
.margin({ left: 16, right: 16, top: 4 })
Row() {
Column() {
Text('库存数量').fontSize(12).fontColor('#888888')
TextInput({ placeholder: '10' })
.placeholderColor('#BBBBBB').fontSize(12).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8).margin({ top: 4 })
.onChange((v: string) => { this.formQuantity = v })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Text('过期日期').fontSize(12).fontColor('#888888')
TextInput({ placeholder: '2027-06-30' })
.placeholderColor('#BBBBBB').fontSize(12).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8).margin({ top: 4 })
.onChange((v: string) => { this.formExpiry = v })
}.layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })
}
.margin({ left: 20, right: 20, top: 12 })
Text('用法用量').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: '如:2粒/次' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.formDosage = v })
Text('服用频率').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
Row() {
ForEach(FREQUENCIES, (f: string) => {
if (this.formFrequency === f) {
Text(FREQUENCY_CONFIG[f]?.label ?? f)
.fontSize(11).fontColor('#FFFFFF')
.backgroundColor('#00897B')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
} else {
Text(FREQUENCY_CONFIG[f]?.label ?? f)
.fontSize(11).fontColor('#00897B')
.backgroundColor('#E0F2F1')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formFrequency = f })
}
})
}
.margin({ left: 16, right: 16, top: 4 })
Text('存储方式').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
Row() {
ForEach(STORAGES, (s: string) => {
if (this.formStorage === s) {
Text(STORAGE_CONFIG[s]?.icon + ' ' + s)
.fontSize(11).fontColor('#FFFFFF')
.backgroundColor('#004D40')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
} else {
Text(STORAGE_CONFIG[s]?.icon + ' ' + s)
.fontSize(11).fontColor('#004D40')
.backgroundColor('#E0F2F1')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formStorage = s })
}
})
}
.margin({ left: 16, right: 16, top: 4 })
Text('存放位置').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: '如:家庭药箱A层' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.formLocation = v })
Text('生产厂家').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: '如:以岭药业' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.formManufacturer = v })
Text('备注信息').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextArea({ placeholder: '药品服用注意事项...' })
.placeholderColor('#BBBBBB').fontSize(12).width('100%').height(60)
.backgroundColor('#F5F5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4, bottom: 12 })
.onChange((v: string) => { this.formNotes = v })
}
}
.layoutWeight(1)
Row() {
Text('取消').fontSize(14).fontColor('#888888')
.backgroundColor('#F5F5F5').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showAddModal = false })
Text('保存药品').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#00897B').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => { this.showAddModal = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 16, bottom: 16 })
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(16)
.constraintSize({ maxHeight: '80%' })
.position({ x: '5%', y: '10%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
@Builder detailRow(label: string, value: string) {
Row() {
Text(label).fontSize(12).fontColor('#999999').width(80)
Text(value).fontSize(13).fontColor('#333333').layoutWeight(1)
}
.width('100%').padding({ left: 20, right: 20, top: 8, bottom: 8 })
}
// ========== 统计卡片 ==========
@Builder statCard(icon: string, value: string, label: string, color: string) {
Column() {
Text(icon).fontSize(22)
Text(value).fontSize(20).fontWeight(FontWeight.Bold).fontColor(color).margin({ top: 2 })
Text(label).fontSize(10).fontColor('#888888').margin({ top: 1 })
}
.layoutWeight(1)
.backgroundColor('#FFFFFF')
.borderRadius(10)
.padding({ top: 10, bottom: 10 })
.alignItems(HorizontalAlign.Start)
}
// ========== 月度用药柱状图 ==========
@Builder monthBarChart() {
Column() {
Row() {
Text('📈 月度用药次数').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#004D40')
Row().layoutWeight(1)
Text('单位:次').fontSize(10).fontColor('#999999')
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 8 })
Row() {
ForEach(monthBarData, (item: MonthBarMeta) => {
Column() {
Column() {
Text(item.value.toString())
.fontSize(9).fontColor('#00897B').margin({ bottom: 2 })
}
.width(20).height(item.value * 1.2)
.backgroundColor(item.color).borderRadius({ topLeft: 4, topRight: 4 })
Text(item.label).fontSize(9).fontColor('#888888').margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
})
}
.width('100%').padding({ left: 12, right: 12, bottom: 12 })
.alignItems(VerticalAlign.Bottom)
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.margin({ left: 12, right: 12, top: 8 })
}
// ========== 分类占比条 ==========
@Builder categoryRatioBar() {
Column() {
Text('📊 药品分类占比').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#004D40')
.margin({ left: 16, top: 14, bottom: 8 })
ForEach(categoryRatioData, (item: CategoryRatioMeta) => {
Row() {
Text(item.icon + ' ' + item.label).fontSize(11).fontColor('#333333').width(80)
Column() {
Row() {
Column()
.layoutWeight(item.value)
.height(14)
.backgroundColor(item.color)
.borderRadius({ topLeft: 7, bottomLeft: 7 })
Column().layoutWeight(20 - item.value).height(14)
.backgroundColor('#F5F5F5')
.borderRadius({ topRight: 7, bottomRight: 7 })
}
}
.layoutWeight(1)
.margin({ left: 8, right: 8 })
Text(item.value + '种').fontSize(11).fontColor(item.color).fontWeight(FontWeight.Bold)
}
.width('100%').padding({ left: 16, right: 16, bottom: 8 })
})
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.margin({ left: 12, right: 12, top: 8 })
}
// ========== 库存预警进度条 ==========
@Builder stockWarnChart() {
Column() {
Text('⚠️ 库存预警').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#FF9800')
.margin({ left: 16, top: 14, bottom: 8 })
ForEach(stockWarnData, (item: StockWarnMeta) => {
Row() {
Text(item.icon).fontSize(16).width(24)
Text(item.label).fontSize(11).fontColor('#333333').width(110)
Column() {
Row() {
Column()
.layoutWeight(item.current)
.height(10)
.backgroundColor(item.color)
.borderRadius(5)
Column().layoutWeight(Math.max(item.threshold - item.current, 1)).height(10)
.backgroundColor('#F5F5F5')
.borderRadius(5)
}
}
.layoutWeight(1)
.margin({ left: 8, right: 8 })
Text(item.current + '/' + item.threshold)
.fontSize(10).fontColor(item.color).fontWeight(FontWeight.Bold)
}
.width('100%').padding({ left: 16, right: 16, bottom: 8 })
})
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.margin({ left: 12, right: 12, top: 8, bottom: 8 })
}
// ========== 分类筛选条 ==========
@Builder filterChip(label: string, category: string) {
Text(label)
.fontSize(11)
.fontColor(this.selectedCategory === category ? '#FFFFFF' : '#00897B')
.backgroundColor(this.selectedCategory === category ? '#00897B' : '#E0F2F1')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
.margin({ left: 4, right: 4 })
.onClick(() => { this.selectedCategory = category })
}
// ========== 药品列表项 ==========
@Builder medicineCard(med: MedicineItem, index: number) {
Column() {
Row() {
Column() {
Text(MED_CATEGORY_CONFIG[med.category]?.icon ?? '💊').fontSize(28)
}
.width(48).height(48)
.backgroundColor(MED_CATEGORY_CONFIG[med.category]?.bg ?? '#E0F2F1')
.borderRadius(24)
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Center)
Column() {
Text(med.name)
.fontSize(15).fontWeight(FontWeight.Bold).fontColor('#333333')
Row() {
Text(MED_CATEGORY_CONFIG[med.category]?.label ?? med.category)
.fontSize(10).fontColor('#FFFFFF')
.backgroundColor(MED_CATEGORY_CONFIG[med.category]?.color ?? '#00897B')
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
Text(STORAGE_CONFIG[med.storage]?.icon + ' ' + med.storage)
.fontSize(10).fontColor('#004D40')
.backgroundColor('#E0F2F1')
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
.margin({ left: 6 })
if (this.isExpiringSoon(med.expiryDate)) {
Text('⏰ 即将过期')
.fontSize(10).fontColor('#FFFFFF')
.backgroundColor('#FF9800')
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
.margin({ left: 6 })
}
if (med.status === '已过期') {
Text('❌ 已过期')
.fontSize(10).fontColor('#FFFFFF')
.backgroundColor('#F44336')
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
.margin({ left: 6 })
}
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 10 })
Column() {
Text(med.quantity + '盒')
.fontSize(15).fontWeight(FontWeight.Bold)
.fontColor(med.quantity <= 2 ? '#F44336' : '#00897B')
Text('库存').fontSize(9).fontColor('#999999').margin({ top: 1 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding({ left: 14, right: 14, top: 12, bottom: 12 })
Divider().color('#F5F5F5').margin({ left: 14, right: 14 })
Row() {
Text('📅 ' + med.expiryDate).fontSize(10).fontColor('#888888')
Row().layoutWeight(1)
Text('💊 ' + med.dosage).fontSize(10).fontColor('#888888')
Row().layoutWeight(1)
Text('📍 ' + med.location).fontSize(10).fontColor('#888888')
}
.width('100%').padding({ left: 14, right: 14, bottom: 10 })
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(12)
.margin({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick(() => {
this.selectedMedicine = med
this.showDetailModal = true
})
}
isExpiringSoon(date: string): boolean {
return date.startsWith('2026-0') || date.startsWith('2025-')
}
build() {
Column() {
// 顶部标题栏
Row() {
Column() {
Text('家庭药品管理').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#004D40')
Text('守护家人健康').fontSize(11).fontColor('#4DB6AC').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('➕').fontSize(22).fontColor('#00897B')
.onClick(() => { this.showAddModal = true })
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 10 })
// 搜索框
Row() {
Text('🔍').fontSize(14).margin({ left: 12 })
TextInput({ placeholder: '搜索药品名称...' })
.placeholderColor('#BBBBBB').fontSize(12).layoutWeight(1)
.backgroundColor('transparent').margin({ left: 6, right: 12 })
.onChange((v: string) => { this.searchKeyword = v })
}
.width('100%').height(36)
.backgroundColor('#FFFFFF').borderRadius(18)
.margin({ left: 12, right: 12, bottom: 8 })
// 统计卡片
Row() {
this.statCard('💊', getMedicineCount().toString(), '药品总数', '#00897B')
this.statCard('⚠️', getLowStockCount().toString(), '库存不足', '#FF9800')
this.statCard('⏰', getExpiringCount().toString(), '即将过期', '#FF9800')
this.statCard('❌', getExpiredCount().toString(), '已过期', '#F44336')
}
.width('100%').padding({ left: 8, right: 8 })
// 图表区
Scroll() {
Column() {
// 分类筛选
Scroll() {
Row() {
this.filterChip('全部', '全部')
this.filterChip('🤧 感冒', '感冒药')
this.filterChip('💊 消炎', '消炎药')
this.filterChip('🍊 维生素', '维生素')
this.filterChip('🩹 外用', '外用药')
this.filterChip('⏰ 慢病', '慢性病药')
this.filterChip('🌿 肠胃', '肠胃药')
this.filterChip('💉 止痛', '止痛药')
this.filterChip('👁️ 专科', '眼耳鼻喉')
}
.padding({ left: 8, right: 8 })
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
.margin({ left: 4, right: 4, bottom: 8 })
// 月度柱状图
this.monthBarChart()
// 分类占比
this.categoryRatioBar()
// 库存预警
this.stockWarnChart()
// 药品列表
Row() {
Text('📋 药品列表').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#004D40')
Row().layoutWeight(1)
Text('共' + getMedicineCount() + '种').fontSize(11).fontColor('#999999')
}
.width('100%').padding({ left: 16, top: 12, bottom: 4 })
// 逐条渲染药品
this.medicineCard(mockMedicines[0], 0)
this.medicineCard(mockMedicines[1], 1)
this.medicineCard(mockMedicines[2], 2)
this.medicineCard(mockMedicines[3], 3)
this.medicineCard(mockMedicines[4], 4)
this.medicineCard(mockMedicines[5], 5)
this.medicineCard(mockMedicines[6], 6)
this.medicineCard(mockMedicines[7], 7)
this.medicineCard(mockMedicines[8], 8)
this.medicineCard(mockMedicines[9], 9)
this.medicineCard(mockMedicines[10], 10)
this.medicineCard(mockMedicines[11], 11)
this.medicineCard(mockMedicines[12], 12)
this.medicineCard(mockMedicines[13], 13)
this.medicineCard(mockMedicines[14], 14)
this.medicineCard(mockMedicines[15], 15)
this.medicineCard(mockMedicines[16], 16)
this.medicineCard(mockMedicines[17], 17)
this.medicineCard(mockMedicines[18], 18)
this.medicineCard(mockMedicines[19], 19)
Column().height(20)
}
}
.layoutWeight(1)
// 弹框
if (this.showAddModal) { this.addMedicineModal() }
if (this.showEditModal) { this.editMedicineModal() }
if (this.showDeleteConfirm) { this.deleteMedicineModal() }
if (this.showDetailModal) { this.detailMedicineModal() }
}
.width('100%').height('100%')
.backgroundColor('#E0F2F1')
}
}
// ============ 用药提醒页 ============
@Component
struct ReminderContent {
@State showAddModal: boolean = false
@State selectedFilter: string = '今日'
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(onClose)
}
@Builder addReminderModal() {
Column() {
this.modalOverlay(() => { this.showAddModal = false })
Column() {
Row() {
Text('⏰ 新增提醒').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#004D40')
Row().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showAddModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#E0F2F1')
Column() {
Text('提醒时间').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: '08:00' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('药品名称').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: '如:硝苯地平控释片' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('服用剂量').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: '如:1片' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
}
.layoutWeight(1)
Row() {
Text('取消').fontSize(14).fontColor('#888888')
.backgroundColor('#F5F5F5').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showAddModal = false })
Text('保存提醒').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#00897B').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => { this.showAddModal = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 16, bottom: 16 })
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(16)
.constraintSize({ maxHeight: '70%' })
.position({ x: '5%', y: '15%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
@Builder reminderTimelineItem(r: ReminderMeta, index: number) {
Row() {
// 时间轴左侧
Column() {
Text(r.time).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#00897B')
Column().width(2).height(40).backgroundColor('#B2DFDB').margin({ top: 4 })
}
.width(60)
// 圆点
Column() {
Column()
.width(r.isTaken ? 12 : 10).height(r.isTaken ? 12 : 10)
.backgroundColor(r.isTaken ? '#00897B' : '#FFFFFF')
.border({ width: 2, color: '#00897B' })
.borderRadius(6)
}
.width(20)
// 内容卡片
Column() {
Row() {
Text(r.icon).fontSize(20)
Column() {
Text(r.medicine).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#333333')
Text(r.dosage + ' · ' + r.frequency)
.fontSize(11).fontColor('#888888').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 8 })
if (r.isTaken) {
Text('✅ 已服').fontSize(10).fontColor('#FFFFFF')
.backgroundColor('#00897B')
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(10)
} else {
Text('⏳ 待服').fontSize(10).fontColor('#FFFFFF')
.backgroundColor('#FF9800')
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(10)
}
}
.width('100%')
}
.layoutWeight(1)
.backgroundColor('#FFFFFF')
.borderRadius(10)
.padding({ left: 12, right: 12, top: 12, bottom: 12 })
.margin({ left: 8 })
}
.width('100%')
.padding({ left: 12, right: 12, top: 4, bottom: 4 })
}
@Builder filterChip(label: string, filter: string) {
Text(label)
.fontSize(11)
.fontColor(this.selectedFilter === filter ? '#FFFFFF' : '#00897B')
.backgroundColor(this.selectedFilter === filter ? '#00897B' : '#FFFFFF')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(16)
.margin({ left: 4, right: 4 })
.onClick(() => { this.selectedFilter = filter })
}
build() {
Column() {
Row() {
Text('⏰ 用药提醒').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#004D40')
Row().layoutWeight(1)
Text('➕').fontSize(22).fontColor('#00897B')
.onClick(() => { this.showAddModal = true })
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 10 })
// 今日统计
Row() {
Column() {
Text('今日提醒').fontSize(11).fontColor('#888888')
Text(getReminderCount().toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#00897B').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column().width(1).height(36).backgroundColor('#E0F2F1')
Column() {
Text('已服用').fontSize(11).fontColor('#888888')
Text(getTodayTakenCount().toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#4DB6AC').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column().width(1).height(36).backgroundColor('#E0F2F1')
Column() {
Text('待服用').fontSize(11).fontColor('#888888')
Text((getReminderCount() - getTodayTakenCount()).toString()).fontSize(24).fontWeight(FontWeight.Bold).fontColor('#FF9800').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.padding({ top: 14, bottom: 14 })
.margin({ left: 12, right: 12, bottom: 8 })
// 进度条
Column() {
Row() {
Text('今日服药进度').fontSize(11).fontColor('#888888')
Row().layoutWeight(1)
Text(getTodayTakenCount() + '/' + getReminderCount()).fontSize(11).fontColor('#00897B').fontWeight(FontWeight.Bold)
}
.width('100%').margin({ bottom: 6 })
Row() {
Column()
.layoutWeight(getTodayTakenCount())
.height(8).backgroundColor('#00897B').borderRadius({ topLeft: 4, bottomLeft: 4 })
Column()
.layoutWeight(getReminderCount() - getTodayTakenCount())
.height(8).backgroundColor('#E0F2F1').borderRadius({ topRight: 4, bottomRight: 4 })
}
.width('100%')
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.padding(14)
.margin({ left: 12, right: 12, bottom: 8 })
// 筛选
Row() {
this.filterChip('今日', '今日')
this.filterChip('本周', '本周')
this.filterChip('全部', '全部')
this.filterChip('已服', '已服')
this.filterChip('待服', '待服')
}
.padding({ left: 8, right: 8, bottom: 8 })
// 时间轴列表
Scroll() {
Column() {
this.reminderTimelineItem(mockReminders[0], 0)
this.reminderTimelineItem(mockReminders[1], 1)
this.reminderTimelineItem(mockReminders[2], 2)
this.reminderTimelineItem(mockReminders[3], 3)
this.reminderTimelineItem(mockReminders[4], 4)
this.reminderTimelineItem(mockReminders[5], 5)
this.reminderTimelineItem(mockReminders[6], 6)
this.reminderTimelineItem(mockReminders[7], 7)
Column().height(20)
}
}
.layoutWeight(1)
if (this.showAddModal) { this.addReminderModal() }
}
.width('100%').height('100%')
.backgroundColor('#E0F2F1')
}
}
// ============ 就医记录页 ============
@Component
struct VisitContent {
@State showAddModal: boolean = false
@State showDetailModal: boolean = false
@State selectedVisit: VisitMeta | null = null
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(onClose)
}
@Builder addVisitModal() {
Column() {
this.modalOverlay(() => { this.showAddModal = false })
Column() {
Row() {
Text('🏥 新增就医记录').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#004D40')
Row().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showAddModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#E0F2F1')
Column() {
Text('就医日期').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: '2026-07-19' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('医院名称').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: '如:市第一人民医院' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('就诊科室').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: '如:心内科' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('诊断结果').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: '如:高血压2级' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('医嘱备注').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextArea({ placeholder: '医生叮嘱...' })
.placeholderColor('#BBBBBB').fontSize(12).width('100%').height(60)
.backgroundColor('#F5F5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4, bottom: 12 })
}
.layoutWeight(1)
Row() {
Text('取消').fontSize(14).fontColor('#888888')
.backgroundColor('#F5F5F5').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showAddModal = false })
Text('保存记录').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#00897B').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => { this.showAddModal = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 16, bottom: 16 })
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(16)
.constraintSize({ maxHeight: '80%' })
.position({ x: '5%', y: '10%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
@Builder visitCard(v: VisitMeta) {
Column() {
Row() {
Column() {
Text('🏥').fontSize(24)
}
.width(44).height(44)
.backgroundColor('#E0F2F1').borderRadius(22)
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Center)
Column() {
Text(v.hospital).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#333333')
Text(v.department + ' · ' + v.doctor)
.fontSize(11).fontColor('#888888').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 10 })
Text('📅' + v.date).fontSize(10).fontColor('#00897B')
}
.width('100%')
Divider().color('#F5F5F5').margin({ top: 10, bottom: 10 })
Row() {
Text('诊断:').fontSize(11).fontColor('#999999')
Text(v.diagnosis).fontSize(11).fontColor('#004D40').fontWeight(FontWeight.Bold)
}
Row() {
Text('处方:').fontSize(11).fontColor('#999999')
Text(v.prescription).fontSize(11).fontColor('#333333').layoutWeight(1)
}
.margin({ top: 4 })
Row() {
Text('💰 ' + v.cost + '元').fontSize(11).fontColor('#FF9800').fontWeight(FontWeight.Bold)
Row().layoutWeight(1)
Text('查看详情 >').fontSize(10).fontColor('#00897B')
}
.margin({ top: 6 })
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(12)
.padding(14)
.margin({ left: 12, right: 12, top: 6, bottom: 6 })
.onClick(() => {
this.selectedVisit = v
this.showDetailModal = true
})
}
build() {
Column() {
Row() {
Text('🏥 就医记录').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#004D40')
Row().layoutWeight(1)
Text('➕').fontSize(22).fontColor('#00897B')
.onClick(() => { this.showAddModal = true })
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 10 })
// 统计
Row() {
Column() {
Text('就医次数').fontSize(11).fontColor('#888888')
Text(getVisitCount().toString()).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#00897B').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column().width(1).height(32).backgroundColor('#E0F2F1')
Column() {
Text('总花费').fontSize(11).fontColor('#888888')
Text('1246元').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FF9800').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column().width(1).height(32).backgroundColor('#E0F2F1')
Column() {
Text('本年就医').fontSize(11).fontColor('#888888')
Text('7次').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#4DB6AC').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.padding({ top: 12, bottom: 12 })
.margin({ left: 12, right: 12, bottom: 8 })
// 记录列表
Scroll() {
Column() {
this.visitCard(mockVisits[0])
this.visitCard(mockVisits[1])
this.visitCard(mockVisits[2])
this.visitCard(mockVisits[3])
this.visitCard(mockVisits[4])
this.visitCard(mockVisits[5])
this.visitCard(mockVisits[6])
Column().height(20)
}
}
.layoutWeight(1)
if (this.showAddModal) { this.addVisitModal() }
}
.width('100%').height('100%')
.backgroundColor('#E0F2F1')
}
}
// ============ 健康指标页 ============
@Component
struct HealthContent {
@State showAddModal: boolean = false
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(onClose)
}
@Builder addMetricModal() {
Column() {
this.modalOverlay(() => { this.showAddModal = false })
Column() {
Row() {
Text('📊 记录健康指标').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#004D40')
Row().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showAddModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#E0F2F1')
Column() {
Text('指标类型').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: '如:血压' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('测量数值').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: '如:120' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F5F5').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
}
.layoutWeight(1)
Row() {
Text('取消').fontSize(14).fontColor('#888888')
.backgroundColor('#F5F5F5').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showAddModal = false })
Text('保存').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#00897B').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => { this.showAddModal = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 16, bottom: 16 })
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(16)
.constraintSize({ maxHeight: '60%' })
.position({ x: '5%', y: '20%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
@Builder metricCard(m: HealthMetricMeta) {
Column() {
Row() {
Text(m.icon).fontSize(20)
Column() {
Text(m.type).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#333333')
Text(m.date).fontSize(10).fontColor('#999999').margin({ top: 1 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 8 })
Column() {
Text(m.value.toString() + ' ' + m.unit)
.fontSize(16).fontWeight(FontWeight.Bold)
.fontColor(m.status === '正常' ? '#00897B' : (m.status === '偏高' ? '#FF9800' : '#F44336'))
Text(m.status).fontSize(10).fontColor(m.status === '正常' ? '#00897B' : '#FF9800')
.margin({ top: 1 })
}
.alignItems(HorizontalAlign.End)
}
Divider().color('#F5F5F5').margin({ top: 8, bottom: 6 })
Text('参考范围:' + m.reference).fontSize(10).fontColor('#999999')
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(10)
.padding(12)
.margin({ left: 6, right: 6, bottom: 6 })
}
build() {
Column() {
Row() {
Text('📊 健康指标').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#004D40')
Row().layoutWeight(1)
Text('➕').fontSize(22).fontColor('#00897B')
.onClick(() => { this.showAddModal = true })
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 10 })
// 血压趋势柱状图
Column() {
Row() {
Text('💓 血压趋势').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#004D40')
Row().layoutWeight(1)
Text('mmHg').fontSize(10).fontColor('#999999')
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 8 })
Row() {
Column() {
Column()
.width(24).height(128)
.backgroundColor('#F44336').borderRadius({ topLeft: 4, topRight: 4 })
Text('7/19').fontSize(9).fontColor('#888888').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Column()
.width(24).height(118)
.backgroundColor('#00897B').borderRadius({ topLeft: 4, topRight: 4 })
Text('7/18').fontSize(9).fontColor('#888888').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Column()
.width(24).height(122)
.backgroundColor('#FF9800').borderRadius({ topLeft: 4, topRight: 4 })
Text('7/15').fontSize(9).fontColor('#888888').margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%').padding({ left: 12, right: 12, bottom: 12 })
.alignItems(VerticalAlign.Bottom)
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.margin({ left: 12, right: 12, bottom: 8 })
// 指标列表
Row() {
Text('📋 最新指标').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#004D40')
Row().layoutWeight(1)
Text('8条记录').fontSize(11).fontColor('#999999')
}
.width('100%').padding({ left: 16, top: 8, bottom: 4 })
Scroll() {
Column() {
Row() {
this.metricCard(mockMetrics[0])
this.metricCard(mockMetrics[1])
}
Row() {
this.metricCard(mockMetrics[2])
this.metricCard(mockMetrics[3])
}
Row() {
this.metricCard(mockMetrics[4])
this.metricCard(mockMetrics[5])
}
Row() {
this.metricCard(mockMetrics[6])
this.metricCard(mockMetrics[7])
}
Column().height(20)
}
}
.layoutWeight(1)
if (this.showAddModal) { this.addMetricModal() }
}
.width('100%').height('100%')
.backgroundColor('#E0F2F1')
}
}
// ============ 个人中心页 ============
@Component
struct ProfileContent {
build() {
Column() {
// 头部
Row() {
Column() {
Text('👤').fontSize(40)
}
.width(64).height(64)
.backgroundColor('#FFFFFF').borderRadius(32)
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Center)
Column() {
Text('健康守护者').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#004D40')
Text('家庭健康管理 · 365天').fontSize(11).fontColor('#4DB6AC').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 12 })
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(12)
.padding(16)
.margin({ left: 12, right: 12, top: 14, bottom: 8 })
// 数据统计
Row() {
Column() {
Text('20').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#00897B')
Text('药品总数').fontSize(10).fontColor('#888888').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column().width(1).height(32).backgroundColor('#E0F2F1')
Column() {
Text('8').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#4DB6AC')
Text('用药提醒').fontSize(10).fontColor('#888888').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column().width(1).height(32).backgroundColor('#E0F2F1')
Column() {
Text('7').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FF9800')
Text('就医记录').fontSize(10).fontColor('#888888').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column().width(1).height(32).backgroundColor('#E0F2F1')
Column() {
Text('13').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#004D40')
Text('正常药品').fontSize(10).fontColor('#888888').margin({ top: 2 })
}.alignItems(HorizontalAlign.Start).layoutWeight(1)
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.padding({ top: 14, bottom: 14 })
.margin({ left: 12, right: 12, bottom: 8 })
// 菜单列表
Column() {
Row() {
Text('⚙️').fontSize(18)
Text('药品管理设置').fontSize(14).fontColor('#333333').margin({ left: 12 })
Row().layoutWeight(1)
Text('›').fontSize(18).fontColor('#CCCCCC')
}
.width('100%').padding(14)
Divider().color('#F5F5F5').margin({ left: 14, right: 14 })
Row() {
Text('🔔').fontSize(18)
Text('提醒通知设置').fontSize(14).fontColor('#333333').margin({ left: 12 })
Row().layoutWeight(1)
Text('›').fontSize(18).fontColor('#CCCCCC')
}
.width('100%').padding(14)
Divider().color('#F5F5F5').margin({ left: 14, right: 14 })
Row() {
Text('📤').fontSize(18)
Text('导出健康报告').fontSize(14).fontColor('#333333').margin({ left: 12 })
Row().layoutWeight(1)
Text('›').fontSize(18).fontColor('#CCCCCC')
}
.width('100%').padding(14)
Divider().color('#F5F5F5').margin({ left: 14, right: 14 })
Row() {
Text('👨👩👧').fontSize(18)
Text('家庭成员管理').fontSize(14).fontColor('#333333').margin({ left: 12 })
Row().layoutWeight(1)
Text('›').fontSize(18).fontColor('#CCCCCC')
}
.width('100%').padding(14)
Divider().color('#F5F5F5').margin({ left: 14, right: 14 })
Row() {
Text('📋').fontSize(18)
Text('用药历史记录').fontSize(14).fontColor('#333333').margin({ left: 12 })
Row().layoutWeight(1)
Text('›').fontSize(18).fontColor('#CCCCCC')
}
.width('100%').padding(14)
Divider().color('#F5F5F5').margin({ left: 14, right: 14 })
Row() {
Text('❓').fontSize(18)
Text('帮助与反馈').fontSize(14).fontColor('#333333').margin({ left: 12 })
Row().layoutWeight(1)
Text('›').fontSize(18).fontColor('#CCCCCC')
}
.width('100%').padding(14)
Divider().color('#F5F5F5').margin({ left: 14, right: 14 })
Row() {
Text('ℹ️').fontSize(18)
Text('关于').fontSize(14).fontColor('#333333').margin({ left: 12 })
Row().layoutWeight(1)
Text('v1.0.0').fontSize(11).fontColor('#999999').margin({ right: 8 })
Text('›').fontSize(18).fontColor('#CCCCCC')
}
.width('100%').padding(14)
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.margin({ left: 12, right: 12 })
}
.width('100%').height('100%')
.backgroundColor('#E0F2F1')
}
}
十四、总结
通过对这款家庭健康管理应用的完整代码解析,我们可以看到一个结构清晰、功能完备的移动端健康应用是如何从零构建的。整个应用涵盖了家庭药品管理的全生命周期:从药品的入库登记、分类存储、库存监控、过期预警,到用药提醒的时间轴规划、服药状态追踪,再到就医记录的历史归档和健康指标的趋势分析,形成了一个闭环的健康管理生态。

在技术架构层面,应用采用了分层设计模式。最底层的类型定义和接口为整个应用建立了严格的数据契约,确保了数据流的类型安全。@Observed 装饰器修饰的数据模型类实现了响应式数据绑定,让视图能够自动跟随数据变化而更新。配置令牌系统将所有视觉元素(颜色、图标、描述)集中管理,既保证了设计一致性,又为未来的主题切换预留了扩展空间。模拟数据层提供了贴近真实的数据样本,让应用在演示时就能呈现出完整的业务场景。
在 UI 实现层面,应用展示了多种声明式 UI 的实用技巧。@Builder 构建器的广泛使用实现了视图逻辑的复用——从通用的弹框遮罩、统计卡片,到具体的药品卡片、时间轴项,每个构建器都职责单一、参数清晰。纯 CSS 图表的实现尤为亮眼:柱状图通过 height 属性映射数据值,进度条通过 layoutWeight 权重分配实现比例展示,完全无需引入第三方图表库,既减小了包体积,又保证了渲染性能。条件渲染在状态标签、选中态样式、弹框显隐等场景中被大量使用,让界面能够根据数据动态变化。
在交互设计层面,应用注重用户体验的每一个细节。颜色编码贯穿始终——绿色表示正常、橙色表示警示、红色表示危险、灰色表示中性,这种语义化的颜色体系让用户无需阅读文字就能快速理解信息。弹框系统采用了遮罩层加内容卡片的标准模式,支持点击遮罩关闭、关闭按钮关闭和操作按钮关闭三种方式。删除操作设置了二次确认机制,防止用户误删数据。表单设计提供了合理的默认值,减少了用户输入成本。
在数据层面,虽然当前版本使用的是模拟数据,但应用已经为接入真实数据源做好了准备。统计函数封装了数据计算的接口,未来只需修改函数内部实现即可对接后端 API。MedicineItem 类的可观察特性确保了数据变更后视图的自动更新。数据模型与视图组件之间的解耦,使得数据源的替换不会影响界面逻辑。
当然,这款应用还有一些可以进一步优化的方向。在数据持久化方面,可以引入本地存储或数据库来保存用户数据,实现数据的跨会话保留。在搜索和筛选方面,当前的搜索功能仅更新了状态变量但未实际过滤列表,后续可以将 ForEach 与过滤函数结合实现真正的搜索效果。在过期判断方面,isExpiringSoon 方法使用字符串前缀匹配,可以改为基于日期对象的精确计算。在图表方面,可以增加折线图、饼图等更多可视化形式。在通知方面,可以接入系统通知 API,实现真正的定时用药提醒推送。
更多推荐

所有评论(0)