基于HarmonyOS API 24考研备考助手应用:从数据建模到可视化呈现的关键词非空,则任务名或科目名中必须包含该关键词(用 indexOf 子串匹配),“状态优先、关键词其次“的短路求值顺序
一、引言:为什么我们需要一款"考研专属"的学习管理工具
考研,作为每年数百万学子都要面对的一场持久战,其本质并不是一场"智力竞赛",而是一场时间管理、精力分配与心理调节的综合较量。从决定考研的那一刻起,考生就需要同时应对政治、英语、数学以及一门或多门专业课的复习,部分同学还要为复试提前做准备。科目多、战线长、知识点繁杂、阶段性目标交错,这些特点决定了"凭感觉复习"几乎注定会失败。

在真实的备考场景中,考生常常面临以下几类痛点。
第一类是**"今天到底该学什么"的决策疲劳**。每天清晨醒来,面对六七门科目、上百个待办知识点,大脑还没进入学习状态就要先做一堆选择题,这种"启动摩擦力"会显著降低一整天的学习效率。
第二类是**"学了多久、学得怎么样"的感知失真**。人脑对时间的感知是非常不准确的,尤其是进入心流状态后,“感觉才学了一小时"往往意味着三个小时已经过去;反过来,焦虑状态下又会觉得时间过得飞快而自己什么都没做。如果没有客观的记录与可视化呈现,考生很容易陷入"自我感动式学习”。
第三类是**“周计划与日执行的脱节”**。很多同学喜欢在周日晚上列一份漂亮的周计划,但执行两三天后就形同虚设,原因是计划缺乏动态追踪与状态反馈机制,无法在执行过程中及时调整。
第四类是**“番茄钟等专注工具与学习内容割裂”**。市面上的番茄钟应用通常只管计时,不管你在学什么;而学习类应用又往往只管内容,不管你专注了多久。两者割裂导致数据无法形成闭环。
正是在这样的背景下,一款专为考研场景量身定制的学习管理应用显得尤为必要。它不应只是一个简单的待办清单,而应该是一个"备考驾驶舱"——把今日任务、番茄钟专注、周计划追踪、科目进度、知识掌握度雷达、个人备考档案等信息整合在一个统一的界面里,让考生一眼就能看清自己的位置和方向。
本文将深入剖析这样一个考研备考助手应用的完整源码实现。该应用基于 HarmonyOS 的 ArkTS 声明式 UI 框架开发,采用了观察者模式的数据建模、设计令牌驱动的视觉系统、纯函数化的业务逻辑、声明式构建的组件化 UI 等一系列工程化实践。通过逐段拆解,你将看到一个从数据层到表现层都经过精心设计的完整移动应用是如何被构造出来的。
无论你是 HarmonyOS 开发者、跨端 UI 框架的学习者,还是单纯对"如何用代码构建一个产品级学习工具"感兴趣的读者,都能从这次源码走读中获得有价值的参考。接下来,让我们正式进入代码的世界。
二、整体架构概览:分层而治的工程化设计
在深入每一个细节之前,先从宏观上把握整个应用的架构脉络。这份源码虽然集中在一个文件里,但其内部分层非常清晰,可以划分为以下几个层次。
最底层是类型定义层,使用一系列 interface 描述了应用中涉及的所有配置结构,包括科目配置、任务状态配置、柱状图数据、雷达图数据等。这些接口本身不产生任何运行时开销,却为整个应用提供了类型安全的契约。
往上一层是数据模型层,通过带 @Observed 装饰器的 class 定义了三个核心模型:复习任务、周计划项、科目。@Observed 是 ArkUI 框架提供的响应式能力,使得这些类的实例在被组件引用时,其属性变化能够触发视图更新。
再往上是配置与数据层,包含设计令牌(颜色、图标、背景的统一映射表)、图表配置(柱状图与雷达图的数据点)、以及模拟数据(18 条今日任务、15 条周计划、6 个科目)。这一层是"假数据驱动 UI"开发模式的典型体现,方便在真实后端接入前先把界面跑通。
接着是纯函数工具层,把所有不依赖组件状态的业务逻辑抽离成独立函数,例如番茄钟时间格式化、任务匹配过滤、倒计时天数计算、雷达图坐标换算等。这种做法让逻辑可测试、可复用,也便于阅读理解。
最后是组件 UI 层,由一个入口组件和五个子页面组件构成。入口组件负责底部 Tab 切换与内容区路由;五个子页面分别对应"今日复习"“周计划”“科目”“进度”"我的"五个核心场景。每个子页面内部又通过 @Builder 把可复用的视图片段封装成方法,实现视图级别的复用。
这种分层方式的好处是显而易见的:类型在前、数据在中、逻辑与视图在后,阅读时由抽象到具体,修改时各层互不干扰。下面我们就按照这个层次顺序,自顶向下逐段拆解。
三、类型定义层:用接口为数据立约
3.1 科目配置接口
应用首先定义了一组用于描述"科目外观属性"的接口。
interface SubjectConfig {
icon: string
color: string
bg: string
}

这段代码定义了一个名为 SubjectConfig 的接口,它包含三个字符串字段。icon 用于存放该科目的 Emoji 图标,例如政治用书、英语用字母符号;color 是该科目的主题色,将贯穿于标签背景、进度条、图表柱体等所有出现该科目的视觉元素中;bg 则是该科目对应的浅色背景,通常用于标签底色,与 color 形成同色系的浅深搭配。
之所以把这种"外观映射"抽成接口而不是写死在组件里,是因为同一个科目的颜色需要在任务列表、周计划、科目卡片、进度图表等十余处地方保持一致。一旦集中定义,后续要调整配色方案只需要改一个地方,整个应用的颜色就会同步更新。这正是设计令牌思想的最朴素体现。
3.2 任务状态配置接口
interface TaskStatusConfig {
label: string
color: string
bg: string
icon: string
}

TaskStatusConfig 接口用于描述任务的状态外观,比科目配置多了一个 label 字段。任务的三个状态——已完成、进行中、待开始——各自对应一组标签文案、主题色、背景色和图标。label 字段的存在意味着状态在界面上的展示文案可以和内部存储的状态字符串解耦,例如内部存 '已完成',展示时也可以灵活替换成其他文案。
3.3 图表数据接口
interface ChartBarData {
label: string
value: number
color: string
}
interface RadarPointData {
label: string
pct: number
angle: number
}

这里定义了两种图表所需的数据结构。ChartBarData 描述柱状图中的一根柱子:标签、数值(0 到 100 的百分比)、颜色。RadarPointData 描述雷达图上的一个数据点:标签、掌握度比例(0 到 1 的小数)、以及该点在雷达图上对应的角度(以度为单位)。把角度直接写进数据是一个很巧妙的设计——它把"六边形雷达图的六个顶点等分 60 度"这种几何信息与数据本身绑定,后续绘制时无需再额外计算角度分配逻辑。
值得注意的是,这两个接口都没有包含"位置"字段,位置完全由渲染时的纯函数根据 value 或 angle 动态计算得出。这种"数据与布局分离"的设计让数据更加纯粹,也让布局逻辑可以独立测试和调整。
四、数据模型层:响应式类的设计
4.1 复习任务模型
@Observed
export class StudyTaskModel {
id: number = 0
name: string = ''
subject: string = ''
timeRange: string = ''
duration: number = 0
importance: string = ''
status: string = ''
goal: string = ''
constructor(id: number, name: string, subject: string, timeRange: string,
duration: number, importance: string, status: string, goal: string) {
this.id = id; this.name = name; this.subject = subject
this.timeRange = timeRange; this.duration = duration
this.importance = importance; this.status = status; this.goal = goal
}
}

StudyTaskModel 是整个应用最核心的数据模型,描述了一条"今日复习任务"。注意类声明上方的 @Observed 装饰器,这是 ArkUI 响应式系统的关键。被它装饰的类,其实例在被 @State、@Prop、@Link 等状态装饰器引用时,属性的变化会被框架捕获并触发依赖该属性的 UI 重新渲染。
类内部为每个字段都赋予了默认值(如 0、''),这是一个良好的防御性编程习惯,避免在 new 出对象后忘记赋值时出现 undefined。字段的含义依次是:id 唯一标识、name 任务名称、subject 所属科目、timeRange 时间段字符串、duration 预计时长(分钟)、importance 重要程度、status 当前状态、goal 本任务的复习目标。
构造函数采用位置参数的方式一次性接收所有字段并在函数体内逐行赋值。这里用分号把多条赋值语句写在一行,是一种在数据模型类中常见的紧凑写法,既保证了可读性,又避免了文件过度膨胀。值得留意的是,subject、importance、status 这些字段都是字符串而不是枚举,这是为了在前端的 ForEach 渲染和配置表查找时更加灵活——字符串可以直接作为 Record 的键去取配置,省去了枚举值到字符串的转换。
4.2 周计划模型
@Observed
export class PlanItemModel {
id: number = 0
date: string = ''
weekDay: string = ''
timeRange: string = ''
subject: string = ''
task: string = ''
isDone: boolean = false
constructor(id: number, date: string, weekDay: string, timeRange: string,
subject: string, task: string, isDone: boolean) {
this.id = id; this.date = date; this.weekDay = weekDay
this.timeRange = timeRange; this.subject = subject
this.task = task; this.isDone = isDone
}
}

PlanItemModel 描述周计划中的一项。与任务模型相比,它去掉了 importance、status、goal、duration,增加了 date(日期)、weekDay(星期)、task(计划内容描述)、isDone(布尔型完成标记)。可以看到,周计划的粒度比今日任务更粗——它关注的是"某天某时段复习某科目的某块内容并标记是否完成",而不需要精细到时长和目标。这种不同视图采用不同粒度模型的做法,体现了对真实使用场景的深刻理解:周计划是战略层,今日任务是战术层。
isDone 使用布尔类型而非字符串状态,是因为周计划只有"完成/未完成"两种状态,没有"进行中"这种中间态,用布尔值更语义化、也更节省内存。
4.3 科目模型
@Observed
export class SubjectModel {
id: number = 0
name: string = ''
icon: string = ''
progress: number = 0
targetHours: number = 0
doneHours: number = 0
description: string = ''
constructor(id: number, name: string, icon: string, progress: number,
targetHours: number, doneHours: number, description: string) {
this.id = id; this.name = name; this.icon = icon
this.progress = progress; this.targetHours = targetHours
this.doneHours = doneHours; this.description = description
}
}

SubjectModel 描述一个科目的整体复习情况。progress 是 0 到 100 的整数百分比,targetHours 和 doneHours 分别是目标学时和已完成学时,description 是该科目的复习策略简述(例如"精讲精练+1000题+肖四肖八")。这里同时保留了 progress 百分比和 doneHours/targetHours 时长比,是因为两者在语义上并不完全等价:进度百分比可能综合了知识点覆盖率、习题正确率等多个维度,而学时比只反映时间投入。把它们分开存储,让 UI 可以分别呈现"完成度"和"投入度"两个维度的信息。
icon 字段在 SubjectModel 里存了一份,同时在前面的 SUBJECT_CONFIG 设计令牌里也有,这种看似冗余的设计实际上是为了让模型在脱离配置表时也能自描述——例如导出数据、跨页面传递时,模型自身就携带了展示所需的全部信息。
五、设计令牌:让视觉系统可维护、可演进
5.1 科目配色表
const SUBJECT_CONFIG: Record<string, SubjectConfig> = {
'政治': { icon: '📖', color: '#E53935', bg: '#FDECEA' },
'英语': { icon: '🔤', color: '#3949AB', bg: '#E8EAF6' },
'数学': { icon: '📐', color: '#00897B', bg: '#E0F2F1' },
'专业课1': { icon: '💻', color: '#6A1B9A', bg: '#F3E5F5' },
'专业课2': { icon: '🧠', color: '#00838F', bg: '#E0F7FA' },
'复试': { icon: '🎓', color: '#F4511E', bg: '#FBE9E7' }
}

这是整个应用视觉系统的核心配置表。Record<string, SubjectConfig> 表示一个以科目名字符串为键、以 SubjectConfig 为值的映射对象。六个科目各自分配了一组精心搭配的色卡:政治用红色系(#E53935 深红配 #FDECEA 极浅红),英语用靛蓝系,数学用青绿系,专业课1用紫色系,专业课2用青蓝系,复试用橙红系。
这些颜色都来自 Material Design 的调色板,每一种主色都配了一个同色系但明度极高的背景色,使得"标签"这种 UI 元素既能用主色做文字、又能用背景色做底,形成柔和而不刺眼的视觉对比。更重要的是,这套配色贯穿了整个应用——任务列表的科目标签、周计划的时间轴圆点、科目卡片的进度条、柱状图的柱体颜色、雷达图的数据点,全都从这里取色。这意味着如果你想让"政治"从红色变成棕色,只需要改这一行,整个应用所有涉及政治的地方都会同步变化。
5.2 任务状态配色表
const TASK_STATUS_CONFIG: Record<string, TaskStatusConfig> = {
'已完成': { label: '已完成', color: '#4CAF50', bg: '#E8F5E9', icon: '✅' },
'进行中': { label: '进行中', color: '#3F51B5', bg: '#E8EAF6', icon: '⏳' },
'待开始': { label: '待开始', color: '#FF9800', bg: '#FFF3E0', icon: '📝' }
}

与科目配色同理,任务的三种状态也各自对应一组视觉属性。已完成用绿色(传递"成功、完成"的正面情绪),进行中用主品牌色靛蓝(表示"正在进行、需要关注"),待开始用橙色(表示"待办、提醒")。图标的选择也很有讲究:已完成用对勾、进行中用沙漏、待开始用备忘录,每个图标都能在第一时间传达状态含义,即使色盲用户也能通过图标区分。
5.3 枚举常量
const TASK_STATUS_FILTER: string[] = ['全部', '已完成', '进行中', '待开始']
const SUBJECT_NAMES: string[] = ['政治', '英语', '数学', '专业课1', '专业课2', '复试']
const IMPORTANCE_OPTIONS: string[] = ['普通', '重要', '紧急']
这三个数组定义了应用中出现的几组"枚举值"集合。TASK_STATUS_FILTER 比状态配置多了一个 '全部' 项,用于筛选器中代表"不筛选"。SUBJECT_NAMES 把六个科目名按固定顺序列出,用于新增任务时的科目选择器。IMPORTANCE_OPTIONS 定义了三个重要程度等级。把这些常量集中定义而不是散落在各组件里,保证了"选项的唯一来源",避免出现某个组件里漏了一项、多了一项的不一致问题。
六、图表配置:数据驱动的可视化
6.1 科目进度柱状图配置
const SUBJECT_PROGRESS_CONFIG: ChartBarData[] = [
{ label: '政治', value: 82, color: '#E53935' },
{ label: '英语', value: 65, color: '#3949AB' },
{ label: '数学', value: 58, color: '#00897B' },
{ label: '专业课1', value: 72, color: '#6A1B9A' },
{ label: '专业课2', value: 68, color: '#00838F' },
{ label: '复试', value: 45, color: '#F4511E' }
]
这组数据定义了进度页柱状图的六根柱子。每根柱子的颜色与前面 SUBJECT_CONFIG 中对应科目的主色完全一致,这种跨配置的颜色一致性是设计令牌思想的延伸——即使柱状图数据是独立配置的,颜色仍然从同一套色卡中取值,保证视觉统一。数值上可以看到政治进度最高(82%),复试最低(45%),这种数据分布也符合真实备考节奏:复试通常在初试后才重点准备,所以前期进度偏低是合理的。
6.2 知识掌握度雷达配置
const KNOWLEDGE_RADAR_CONFIG: RadarPointData[] = [
{ label: '政治', pct: 0.82, angle: 0 },
{ label: '英语', pct: 0.65, angle: 60 },
{ label: '数学', pct: 0.58, angle: 120 },
{ label: '专业课1', pct: 0.72, angle: 180 },
{ label: '专业课2', pct: 0.68, angle: 240 },
{ label: '复试', pct: 0.45, angle: 300 }
]
雷达图配置定义了六个数据点,pct 用 0 到 1 的小数表示掌握度(与柱状图的百分比数值一一对应),angle 从 0 度开始每 60 度递增,正好把一个圆周等分成六份,对应六边形的六个顶点。0 度指向正上方(数学上以 cos 计算 Y 轴),60 度、120 度顺时针分布。把角度写死在数据里而不是动态计算,让渲染逻辑变得极其简单——遍历数组、按角度摆点即可,无需额外的"第几个点对应多少度"的换算。
七、模拟数据:让界面先行
7.1 今日复习任务数据
应用预置了 18 条今日复习任务,覆盖了从早上 7 点到深夜 11 点 40 的完整一天。这里摘取几条典型示例。
const mockTasks: StudyTaskModel[] = [
new StudyTaskModel(1, '政治1000题单选精练', '政治', '07:00-08:00', 60, '重要', '已完成', '完成40题并订正'),
new StudyTaskModel(4, '数学导数应用专题', '数学', '09:50-11:50', 120, '紧急', '进行中', '中值定理证明题6道'),
new StudyTaskModel(8, '数据结构树与图复习', '专业课1', '14:50-16:20', 90, '紧急', '待开始', '遍历+最小生成树'),
new StudyTaskModel(18, '概率论分布复习', '数学', '08:00-09:30', 90, '重要', '待开始', '常见分布公式总结')
]
可以看到每条任务都携带了非常丰富的信息:不仅有"做什么"(任务名),还有"属于哪科"(科目)、“什么时候做”(时间段)、“做多久”(时长)、“有多急”(重要程度)、“现在什么状态”(状态)、“具体目标是什么”(目标)。这种"一条任务即一个完整的复习单元"的设计,让任务卡片能够展示足够多的信息,帮助考生在扫一眼列表时就对今天的工作量有清晰认知。
数据中刻意混入了不同状态(已完成、进行中、待开始)、不同重要程度(普通、重要、紧急)、不同时长(20 分钟到 120 分钟)的任务,目的是让 UI 在各种数据组合下都能被充分测试。还包括了"午休时间""晚餐与散步"这类非学习任务,体现了对真实作息的尊重——备考不是只有刷题,休息也是计划的一部分。
7.2 周计划数据
const mockPlans: PlanItemModel[] = [
new PlanItemModel(1, '2026-08-10', '周一', '08:00-10:00', '数学', '高数第一章总复习+习题', true),
new PlanItemModel(7, '2026-08-12', '周三', '08:00-10:00', '政治', '毛中特核心考点梳理', false),
new PlanItemModel(15, '2026-08-14', '周五', '19:00-21:00', '专业课2', '计算机网络重要考点', false)
]
15 条周计划覆盖了周一到周五,每天上午、下午、晚上各一个 2 小时的复习时段,科目在五天内轮转分布。前 6 条(周一、周二)的 isDone 为 true,后 9 条为 false,这样在 UI 上就会呈现"本周前几天已完成、后几天待执行"的真实进度感。日期从 2026-08-10 到 2026-08-14,恰好是应用"今日"所在的那一周,与今日任务的时间线对齐。
7.3 科目数据
const mockSubjects: SubjectModel[] = [
new SubjectModel(1, '政治', '📖', 82, 200, 164, '精讲精练+1000题+肖四肖八'),
new SubjectModel(3, '数学', '📐', 58, 400, 232, '高数+线代+概率全程强化'),
new SubjectModel(6, '复试', '🎓', 45, 150, 68, '英语口语+专业面试准备')
]
六个科目的数据中,数学的目标学时最高(400 小时),因为数学是考研中最拉分、也最需要时间堆砌的科目;复试目标学时最低(150 小时),因为复试准备通常集中在初试之后。每个科目的 doneHours 与 progress 之间并不严格成比例——例如政治已完成 164 小时占目标 200 小时的 82%,进度恰好也是 82%,说明政治的进度主要按时间投入衡量;而数学已完成 232 小时占目标 400 小时的 58%,进度也是 58%,同样吻合。这种数据一致性让 UI 上"进度条"和"学时比"两个数字能够互相印证,增强了数据的可信度。
八、全局纯函数:业务逻辑的原子化封装
这一层是整个应用工程化程度最高的部分。所有不依赖组件状态的逻辑都被抽离成独立函数,放在全局作用域中。
8.1 番茄钟时间格式化
function formatPomodoroTime(seconds: number): string {
let min: number = Math.floor(seconds / 60)
let sec: number = seconds % 60
return (min < 10 ? '0' + min.toString() : min.toString())
+ ':' + (sec < 10 ? '0' + sec.toString() : sec.toString())
}
这个函数接收一个总秒数,返回 MM:SS 格式的字符串。Math.floor(seconds / 60) 算出分钟部分,seconds % 60 取余得到秒部分。两个三元运算符分别判断分钟和秒是否小于 10,若是则前面补零。例如传入 1500(25 分钟)返回 '25:00',传入 59 返回 '00:59'。把这种格式化逻辑抽成纯函数,使得番茄钟显示逻辑可以在任何地方复用,也方便单独测试。
8.2 任务匹配过滤
function isTaskMatched(item: StudyTaskModel, keyword: string, status: string): boolean {
if (status !== '全部' && item.status !== status) {
return false
}
if (keyword !== '') {
return item.name.indexOf(keyword) >= 0 || item.subject.indexOf(keyword) >= 0
}
return true
}
这个函数是今日任务列表筛选的核心逻辑,接收一个任务、一个搜索关键词、一个状态筛选值,返回布尔值表示该任务是否应该显示。逻辑分两步:先看状态——如果选了"全部"则放行,否则任务状态必须等于所选状态;再看关键词——如果关键词非空,则任务名或科目名中必须包含该关键词(用 indexOf 子串匹配)。这种"状态优先、关键词其次"的短路求值顺序,在数据量大时能尽早过滤掉不匹配项,提升筛选效率。
8.3 统计数据获取函数
function getExamCountdownDays(): number {
return 126
}
function getStudyDays(): number {
return 98
}
function getTotalStudyHours(): number {
return 1115
}
function getTodayDoneCount(): number {
return 6
}
function getTodayTaskCount(): number {
return 18
}
这五个函数各自返回一个硬编码的统计数字:距考研还有 126 天、已备考 98 天、累计学时 1115 小时、今日已完成 6 个任务、今日共 18 个任务。把它们封装成函数而不是直接用常量,是一个面向未来的设计——目前返回的是写死的假数据,未来接入真实后端时,只需要把函数体改成异步请求或本地数据库查询,所有调用方代码完全不用动。这就是"把数据来源抽象成函数"带来的可替换性收益。
8.4 柱状图高度计算
function getBarHeight(v: number): number {
return Math.round(v / 100 * 80)
}
把 0 到 100 的百分比值映射为 0 到 80 的像素高度。Math.round 保证结果是整数,避免渲染时出现亚像素模糊。最大高度 80 是一个经验值,既保证柱子足够高能直观比较,又不会撑爆卡片。如果未来柱状图容器尺寸变化,只需要改这一个数字。
8.5 雷达图坐标计算
function getRadarDotX(deg: number, pct: number): string {
let rad: number = deg * Math.PI / 180
let r: number = 12 + pct * 30
return (50 + r * Math.sin(rad)) + '%'
}
function getRadarDotY(deg: number, pct: number): string {
let rad: number = deg * Math.PI / 180
let r: number = 12 + pct * 30
return (50 - r * Math.cos(rad)) + '%'
}
这两个函数是雷达图绘制的数学核心。getRadarDotX 计算数据点的横坐标,getRadarDotY 计算纵坐标。先把角度从度转换为弧度(deg * Math.PI / 180),然后计算半径 r = 12 + pct * 30——这里 12 是最小半径(即使掌握度为 0,点也不会跑到圆心,保持视觉可辨),30 是半径的变化范围,所以掌握度从 0 到 1 时,半径从 12 变到 42。
坐标计算用了经典的极坐标转直角公式:x = 50 + r * sin(rad)、y = 50 - r * cos(rad)。注意 Y 用的是减号,因为屏幕坐标系 Y 轴向下,而数学坐标系 Y 轴向上,减号实现了翻转。50 是圆心位置(百分比表示,相对于 260x250 的容器,50% 大致居中)。返回值是带 % 的字符串,可以直接用于 ArkUI 的 position 属性。
function getRadarLabelX(deg: number): string {
let rad: number = deg * Math.PI / 180
return (50 + 45 * Math.sin(rad)) + '%'
}
function getRadarLabelY(deg: number): string {
let rad: number = deg * Math.PI / 180
return (50 - 45 * Math.cos(rad)) + '%'
}
标签位置的计算与数据点类似,只是半径固定为 45(比最大数据点半径 42 略大),让标签始终位于雷达图外侧,不会与数据点重叠。这种"数据点半径随掌握度变化、标签半径固定"的设计,让雷达图既能反映数据大小,又能保证标签可读。
九、底部导航与入口组件:应用骨架的搭建
9.1 Tab 枚举
enum StudyTab {
TODAY = 0,
PLAN = 1,
SUBJECT = 2,
PROGRESS = 3,
PROFILE = 4
}
用枚举定义五个底部 Tab 的索引。使用枚举而非魔法数字(如直接写 0、1、2)的好处是:在切换逻辑中写 StudyTab.TODAY 比写 0 的可读性高得多,而且未来如果要调整 Tab 顺序或新增 Tab,只需要改枚举定义,所有引用处自动更新。枚举值从 0 开始连续递增,与数组索引天然契合。
9.2 入口组件
@Entry
@Component
struct StudyPlanApp {
@State activeTab: StudyTab = StudyTab.TODAY
...
}
@Entry 标记这是应用的入口组件,@Component 声明这是一个自定义组件。@State activeTab 是组件的内部状态,初始值为 StudyTab.TODAY,表示应用启动后默认展示"今日复习"页。当 activeTab 变化时,依赖它的 UI 会自动重新渲染。
@Builder contentArea() {
Column() {
if (this.activeTab === StudyTab.TODAY) {
StudyTodayContent()
} else if (this.activeTab === StudyTab.PLAN) {
StudyPlanContent()
} else if (this.activeTab === StudyTab.SUBJECT) {
StudySubjectContent()
} else if (this.activeTab === StudyTab.PROGRESS) {
StudyProgressContent()
} else {
StudyProfileContent()
}
}
.layoutWeight(1)
}
@Builder 装饰的方法是一个可复用的视图构建器,这里用于封装"内容区"的渲染逻辑。通过一串 if/else if 根据 activeTab 的值渲染对应的子页面组件。外层 Column 加 .layoutWeight(1) 让内容区占据除底部 Tab 栏之外的全部剩余高度。这种用 @Builder 封装条件渲染的做法,让 build() 方法本身保持简洁。
@Builder bottomTabItem(icon: string, label: string, tab: StudyTab) {
Column() {
Text(icon).fontSize(20).opacity(this.activeTab === tab ? 1.0 : 0.45)
Text(label).fontSize(9)
.fontColor(this.activeTab === tab ? '#3F51B5' : '#999999')
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 1 })
if (this.activeTab === tab) {
Column().width(18).height(3)
.backgroundColor('#3F51B5').borderRadius(2).margin({ top: 2 })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 5, bottom: 5 })
.onClick(() => { this.activeTab = tab })
}
这是底部 Tab 单项的构建器,接收图标、文案、Tab 枚举三个参数。每个 Tab 项是一个垂直排列的图标加文字。关键在于"选中态"的视觉区分:选中时图标完全不透明、文字用主品牌色且加粗,并在文字下方多渲染一条 18 宽 3 高的圆角指示条;未选中时图标半透明、文字灰色且常规字重。.onClick 中把 activeTab 设为当前 Tab,触发整个内容区切换。这种"一个 Builder 渲染所有 Tab"的写法避免了重复代码,五个 Tab 只需要调用五次 this.bottomTabItem(...) 即可。
build() {
Column() {
this.contentArea()
Row() {
this.bottomTabItem('📋', '今日', StudyTab.TODAY)
this.bottomTabItem('📅', '计划', StudyTab.PLAN)
this.bottomTabItem('📚', '科目', StudyTab.SUBJECT)
this.bottomTabItem('📈', '进度', StudyTab.PROGRESS)
this.bottomTabItem('👤', '我的', StudyTab.PROFILE)
}
.width('100%')
.backgroundColor('#FFFFFF')
.padding({ top: 4, bottom: 6 })
.shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
}
.width('100%').height('100%')
.backgroundColor('#F5F6FA')
}
入口组件的 build() 方法结构非常清晰:一个全屏的 Column,上方是内容区(layoutWeight(1) 撑满),下方是一行底部 Tab 栏。Tab 栏用白色背景加向上的阴影(offsetY: -2 让阴影投向上方),形成与内容区的视觉分隔。整个应用背景色 #F5F6FA 是一个极浅的蓝灰,既不刺眼又能让白色卡片浮起来,是移动端常见的"卡片化"背景配色。
十、今日复习页:番茄钟、统计与任务列表的整合
这是整个应用信息密度最高、交互最复杂的页面。
10.1 状态声明
@State searchKeyword: string = ''
@State selectedStatus: string = '全部'
@State pomodoroRunning: boolean = false
@State pomodoroSeconds: number = 1500
@State showAddModal: boolean = false
@State showEditModal: boolean = false
@State showDeleteConfirm: boolean = false
@State selectedTask: StudyTaskModel | null = null
@State editingTask: StudyTaskModel | null = null
@State formTaskName: string = ''
@State formSubject: string = '政治'
@State formTimeRange: string = '08:00-10:00'
@State formImportance: string = '重要'
timerId: number = -1
这一长串状态变量揭示了页面要管理的所有动态信息:搜索关键词、状态筛选值、番茄钟运行状态与剩余秒数、三个弹框的显隐开关、当前选中与编辑的任务、表单各字段的值。值得注意的是 selectedTask 和 editingTask 使用了联合类型 StudyTaskModel | null,允许为空,表示"当前没有选中任何任务"。timerId 没有加 @State,因为它只用于内部定时器管理,不需要触发视图更新。
10.2 番茄钟逻辑
aboutToDisappear() {
if (this.timerId >= 0) {
clearInterval(this.timerId)
this.timerId = -1
}
}
aboutToDisappear 是组件的生命周期钩子,在组件从视图树移除时调用。这里用它来清理定时器——如果番茄钟还在运行(timerId >= 0),就调用 clearInterval 清除并重置 id。这一步非常关键,否则组件销毁后定时器仍在后台执行,会造成内存泄漏和无效的状态更新。
startPomodoro(): void {
if (this.pomodoroRunning) {
if (this.timerId >= 0) {
clearInterval(this.timerId)
}
this.timerId = -1
this.pomodoroRunning = false
} else {
this.pomodoroRunning = true
this.timerId = setInterval(() => {
this.pomodoroSeconds = this.pomodoroSeconds - 1
if (this.pomodoroSeconds <= 0) {
if (this.timerId >= 0) {
clearInterval(this.timerId)
}
this.timerId = -1
this.pomodoroRunning = false
this.pomodoroSeconds = 1500
}
}, 1000)
}
}
startPomodoro 是一个"开关键"——如果番茄钟正在运行,则暂停(清除定时器、设运行状态为 false);如果未运行,则启动(设运行状态为 true,用 setInterval 每 1000 毫秒减一秒)。当倒计时归零时,自动清除定时器、停止运行、把秒数重置为 1500(即 25 分钟,标准番茄钟时长)。这个函数把"开始"和"暂停"两种语义合并到一个方法里,通过 pomodoroRunning 状态判断当前应该执行哪个动作,减少了 UI 上按钮的数量。
10.3 弹框遮罩复用
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(onClose)
}
这是一个高度复用的弹框遮罩构建器,接收一个回调函数 onClose。它渲染一个全屏半透明黑色(rgba(0,0,0,0.5))的 Column,点击时触发传入的关闭回调。所有弹框(新增、编辑、删除)都会在底层先铺一层这个遮罩,实现"点击空白处关闭弹框"的常见交互。把遮罩抽成独立 Builder,避免了在三个弹框里重复写同样的遮罩代码。
10.4 新增任务弹框
新增任务弹框是这个页面最复杂的视图片段之一。
@Builder addTaskModal() {
Column() {
this.modalOverlay(() => { this.showAddModal = false })
Column() {
Row() {
Text('➕ 新增复习任务').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showAddModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#F0F0F0')
...
}
.width('90%').height('70%').backgroundColor('#FFFFFF').borderRadius(16)
.alignItems(HorizontalAlign.Center)
.constraintSize({ maxHeight: '75%' })
.position({ x: '5%', y: '12%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
弹框结构是"遮罩层 + 内容卡片"的经典模式。最外层 Column 全屏覆盖(position 从 0,0 开始,zIndex: 999 确保浮在最上层),内部先放遮罩,再放白色内容卡片。内容卡片宽 90%、高 70%,圆角 16,通过 position 定位在屏幕上方 12% 处,constraintSize({ maxHeight: '75%' }) 防止内容过多时撑出屏幕。卡片头部是标题加关闭按钮的横向布局,中间用 Divider 分隔。
表单部分包含任务名称输入框、科目选择器、时间范围输入框、重要程度选择器。科目选择器用 ForEach 遍历 SUBJECT_NAMES,为每个科目渲染一个胶囊形按钮,选中态用主品牌色填充白字,未选中态用浅靛蓝底深靛蓝字。这种"标签组单选"是移动端常见的选项选择交互,比下拉框更直观、点击成本更低。
Row() {
ForEach(SUBJECT_NAMES, (s: string) => {
if (this.formSubject === s) {
Text((SUBJECT_CONFIG[s]?.icon ?? '📖') + ' ' + s)
.fontSize(11).fontColor('#FFFFFF').backgroundColor('#3F51B5')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
} else {
Text((SUBJECT_CONFIG[s]?.icon ?? '📖') + ' ' + s)
.fontSize(11).fontColor('#3F51B5').backgroundColor('#E8EAF6')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formSubject = s })
}
})
}
这段科目选择器代码值得细看。ForEach 遍历科目名数组,对每个科目判断是否是当前选中项。(SUBJECT_CONFIG[s]?.icon ?? '📖') 用可选链加空值合并运算符从配置表取图标,如果配置表里没有该科目则兜底用书本图标——这是一种防御性编程,避免数据缺失时界面报错。选中与未选中两种状态分别渲染两个样式不同的 Text,未选中项绑定 onClick 切换选中值。选中项不绑定点击事件,因为点击已选中项无需切换。
底部是"取消"和"添加任务"两个按钮,取消按钮浅灰底,添加按钮主品牌色底,形成主次分明的操作引导。
10.5 编辑任务弹框
编辑弹框与新增弹框结构类似,但表单字段用 TextInput 的 placeholder 显示当前任务的原值。
Text('任务名称').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: this.editingTask?.name ?? '任务名称' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F6FA').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
this.editingTask?.name ?? '任务名称' 同样用了可选链和空值合并——如果 editingTask 为 null(没有选中任务),则 placeholder 显示默认文案。这种写法让编辑弹框在"没有选中任务"的异常情况下也不会崩溃,而是优雅降级。复习目标字段用了 TextArea 而非 TextInput,因为目标描述通常较长,需要多行输入。
10.6 删除确认弹框
@Builder deleteTaskModal() {
Column() {
this.modalOverlay(() => { this.showDeleteConfirm = false })
Column() {
Text('⚠️').fontSize(48).margin({ top: 24 })
Text('确认删除此任务?').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Text('删除后今日安排将更新').fontSize(13).fontColor('#3F51B5').margin({ top: 4 })
Row() {
Text(this.selectedTask?.name ?? '任务').fontSize(14).fontColor('#333333')
.fontWeight(FontWeight.Bold).margin({ left: 8 })
Text(this.selectedTask?.timeRange ?? '').fontSize(12).fontColor('#7986CB')
.margin({ left: 8 })
}
.backgroundColor('#E8EAF6').borderRadius(10)
.padding({ left: 16, right: 16, top: 10, bottom: 10 }).margin({ top: 16 })
...
}
.width('80%').backgroundColor('#FFFFFF').borderRadius(16)
.position({ x: '10%', y: '38%' })
}
}
删除确认弹框采用了"警告式"设计:顶部一个大大的警告图标,中间是确认问句和影响说明,下方用浅靛蓝底色块展示即将删除的任务名称和时间段,让用户在确认前能再次核对。底部"取消"是灰色按钮,"确认删除"是红色按钮(#F44336),红色传递"危险操作"的视觉信号。这种"二次确认 + 危险色"的组合是删除类操作的标准交互范式,能有效防止误删。
10.7 任务列表项构建器
@Builder taskItemBuilder(t: StudyTaskModel) {
Column() {
Row() {
Text(t.status === '已完成' ? '✅' : '⬜').fontSize(15)
Column() {
Text(t.name).fontSize(14).fontWeight(FontWeight.Medium)
.fontColor(t.status === '已完成' ? '#999999' : '#212121')
.decoration({
type: t.status === '已完成' ? TextDecorationType.LineThrough : TextDecorationType.None
})
Row() {
Text((SUBJECT_CONFIG[t.subject]?.icon ?? '📖') + ' ' + t.subject).fontSize(9)
.fontColor(SUBJECT_CONFIG[t.subject]?.color ?? '#888888')
.backgroundColor(SUBJECT_CONFIG[t.subject]?.bg ?? '#F5F5F5')
.padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(6)
.margin({ right: 4 })
Text('⏱️ ' + t.duration + '分钟').fontSize(9).fontColor('#999999')
}
.margin({ top: 4 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 8 })
Column() {
Text(t.timeRange).fontSize(9).fontColor('#7986CB')
.backgroundColor('#E8EAF6').padding({ left: 5, right: 5, top: 2, bottom: 2 })
.borderRadius(6)
Text(TASK_STATUS_CONFIG[t.status]?.label ?? t.status).fontSize(9)
.fontColor(TASK_STATUS_CONFIG[t.status]?.color ?? '#999999')
.margin({ top: 4 })
}.alignItems(HorizontalAlign.End)
}
.width('100%')
Row() {
Text('✏️').fontSize(12).fontColor('#3F51B5')
.onClick(() => { this.editingTask = t; this.showEditModal = true })
Text('🗑️').fontSize(12).fontColor('#F44336').margin({ left: 10 })
.onClick(() => { this.selectedTask = t; this.showDeleteConfirm = true })
Column().layoutWeight(1)
Text('🎯 ' + t.goal).fontSize(9).fontColor('#BBBBBB')
}
.width('100%').margin({ top: 6 })
}
.width('100%').padding(12).backgroundColor('#FFFFFF')
.borderRadius(12).margin({ left: 12, right: 12, top: 6 })
}
这是单个任务卡片的完整渲染逻辑,信息密度很高。卡片分上下两部分。
上半部分是任务主信息:左侧根据完成状态显示对勾或空方框图标;中间是任务名(已完成项变灰并加删除线,这是待办类应用的标准视觉语言)、科目标签(从配置表取图标和颜色)、时长;右侧是时间段和状态标签。
下半部分是操作行:编辑图标(点击打开编辑弹框,把当前任务赋给 editingTask)、删除图标(点击打开删除确认弹框,把当前任务赋给 selectedTask)、中间用 Column().layoutWeight(1) 撑开、最右是复习目标。
整个卡片用白底圆角加左右上边距,形成悬浮在浅灰背景上的"卡片"效果。每处从配置表取值都用了 ?? 兜底默认值,确保即使数据异常也能正常渲染。
10.8 今日页面主体 build
build() {
Stack() {
Column() {
Row() {
Column() {
Text('📋 今日复习').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Text('2026-08-11 周二 · 距离考研' + getExamCountdownDays() + '天').fontSize(11)
.fontColor('#7986CB').margin({ top: 3 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('+').fontSize(22).fontColor('#FFFFFF')
.backgroundColor('#3F51B5').width(32).height(32).borderRadius(16)
.textAlign(TextAlign.Center)
.onClick(() => { this.showAddModal = true })
}
...
}
if (this.showAddModal) { this.addTaskModal() }
if (this.showEditModal) { this.editTaskModal() }
if (this.showDeleteConfirm) { this.deleteTaskModal() }
}
}
整个页面用 Stack 作为根容器,这是为了实现"主内容层 + 弹框层"的叠加。Stack 内先放主内容 Column,再根据三个弹框显隐状态条件渲染弹框。由于 Stack 默认子元素居中堆叠,而弹框自身已经用 position 精确定位,所以能正确覆盖在主内容之上。
页面头部是标题加副标题(今日日期与考研倒计时)加一个圆形"+"按钮(点击打开新增弹框)。接着是番茄钟卡片,展示倒计时大数字、开始/暂停与重置按钮、今日番茄钟完成数。再往下是三宫格今日统计(已完成、待完成、今日时长),然后是横向滚动的状态筛选条,最后是占据剩余空间的可滚动任务列表。
任务列表部分用 18 个 if (isTaskMatched(...)) 逐条判断是否显示——这种"手动展开 18 次"而非用 ForEach 的写法,在某些版本的 ArkUI 中是为了确保条件渲染的稳定性,虽然代码冗长但行为可预测。每个 if 调用 isTaskMatched 函数根据当前搜索关键词和状态筛选值决定是否渲染该任务卡片。
十一、周计划页:时间轴式的计划呈现
11.1 计划项构建器
@Builder planItemBuilder(p: PlanItemModel) {
Row() {
Column() {
Text(p.date.substring(5)).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3F51B5')
Text(p.weekDay).fontSize(9).fontColor('#999999').margin({ top: 2 })
Column().width(10).height(10).backgroundColor(p.isDone ? '#4CAF50' : '#3F51B5')
.borderRadius(5).margin({ top: 6 })
Column().width(2).layoutWeight(1).backgroundColor('#DCE1F5').margin({ top: 2 })
}
.width(56).alignItems(HorizontalAlign.Center)
Column() {
Row() {
Text(p.timeRange).fontSize(10).fontColor('#3F51B5')
.backgroundColor('#E8EAF6').padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6)
Column().layoutWeight(1)
Text(p.isDone ? '✅ 已完成' : '⏳ 未开始').fontSize(9)
.fontColor(p.isDone ? '#4CAF50' : '#FF9800')
}
.width('100%')
Text(p.task).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121').margin({ top: 5 })
Text((SUBJECT_CONFIG[p.subject]?.icon ?? '📖') + ' ' + p.subject).fontSize(10)
.fontColor(SUBJECT_CONFIG[p.subject]?.color ?? '#888888')
.backgroundColor(SUBJECT_CONFIG[p.subject]?.bg ?? '#F5F5F5')
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
.margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
.backgroundColor('#FFFFFF').borderRadius(12).padding(12)
.margin({ left: 4, right: 12 })
}
.width('100%').alignItems(VerticalAlign.Top)
.padding({ left: 8, right: 0, top: 0 })
}
每个计划项采用经典的"左侧时间轴 + 右侧内容卡片"双栏布局。左侧 56 宽的窄列展示日期(p.date.substring(5) 截取从第 5 个字符开始的部分,即把 '2026-08-10' 变成 '08-10',去掉年份让显示更紧凑)、星期、一个圆点(完成时绿色、未完成时主品牌色),以及一条向下延伸的灰色连接线(Column().width(2).layoutWeight(1),layoutWeight(1) 让它撑满剩余高度,形成时间轴的纵线)。
右侧是白色圆角卡片,顶部是时间段标签和完成状态,中间是计划任务名(加粗),底部是科目标签。圆点颜色与卡片状态文案联动,完成则绿、未完成则橙,形成一致的视觉语义。
11.2 周计划主体
build() {
Column() {
Row() {
Column() {
Text('📅 本周计划').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Text('2026-08-10 至 08-16 · 冲刺阶段').fontSize(11).fontColor('#7986CB').margin({ top: 3 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('📌').fontSize(22)
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 8 })
Row() {
Column() {
Text('6').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4CAF50')
Text('已完成').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('9').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FF9800')
Text('待完成').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('15').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#3F51B5')
Text('总计划').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('40%').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#7986CB')
Text('完成率').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.padding({ top: 12, bottom: 12 }).margin({ left: 12, right: 12, top: 4 })
Scroll() {
Column() {
this.planItemBuilder(mockPlans[0])
...
this.planItemBuilder(mockPlans[14])
}
.padding({ top: 6, bottom: 20 })
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
}
页面顶部是标题行(含本周日期范围与"冲刺阶段"标签),接着是一个四宫格周汇总卡片(已完成 6、待完成 9、总计划 15、完成率 40%),四列等宽用 layoutWeight(1) 实现。汇总卡片下方是可滚动的时间轴列表,依次渲染 15 条计划项。scrollBar(BarState.Off) 隐藏滚动条,保持视觉干净。
十二、科目页:进度可视化与策略展示
12.1 科目卡片构建器
@Builder subjectCardBuilder(s: SubjectModel) {
Column() {
Row() {
Column() {
Text(s.icon).fontSize(26)
}.width(48).height(48).backgroundColor(SUBJECT_CONFIG[s.name]?.bg ?? '#E8EAF6')
.borderRadius(12)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column() {
Row() {
Text(s.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#212121')
Column().layoutWeight(1)
Text(s.progress + '%').fontSize(15).fontWeight(FontWeight.Bold)
.fontColor(SUBJECT_CONFIG[s.name]?.color ?? '#3F51B5')
}
.width('100%')
Text(s.description).fontSize(10).fontColor('#999999').margin({ top: 3 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 12 })
}
.width('100%')
Row() {
Column()
.width(s.progress + '%')
.height(7).backgroundColor(SUBJECT_CONFIG[s.name]?.color ?? '#3F51B5').borderRadius(4)
Column().layoutWeight(1)
}
.width('100%').height(7).backgroundColor('#EEF0F8').borderRadius(4).margin({ top: 10 })
Row() {
Text('已完成 ' + s.doneHours + 'h / ' + s.targetHours + 'h').fontSize(9).fontColor('#888888')
Column().layoutWeight(1)
Text('距目标 ' + (s.targetHours - s.doneHours) + 'h').fontSize(9).fontColor('#7986CB')
}
.width('100%').margin({ top: 4 })
}
.width('100%').padding(14).backgroundColor('#FFFFFF')
.borderRadius(14).margin({ left: 12, right: 12, top: 6 })
}
科目卡片分为三部分。顶部是一个图标方块(48x48,背景取科目浅色,圆角 12)加科目名、进度百分比、复习策略描述。中间是进度条——外层灰色凹槽(#EEF0F8,高 7,圆角 4),内层用科目主色填充,宽度直接用 s.progress + '%' 拼接百分比字符串。这里巧妙地用 Column().width(s.progress + '%') 让进度条宽度跟随进度值变化,无需额外的宽度计算。底部是学时统计行,左侧显示"已完成 X h / Y h",右侧显示"距目标 Z h",让考生既看到已投入的时长,也看到剩余的差距。
12.2 科目页主体
build() {
Column() {
Row() {
Column() {
Text('📚 科目复习').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Text('6 个科目 · 总进度 ' + getTotalStudyHours() + 'h').fontSize(11)
.fontColor('#7986CB').margin({ top: 3 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('🏆').fontSize(22)
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 8 })
Column() {
Row() {
Text('总复习进度').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#3F51B5')
Column().layoutWeight(1)
Text('65%').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#3F51B5')
}
.width('100%')
Row() {
Column().width('65%').height(8).backgroundColor('#3F51B5').borderRadius(4)
Column().layoutWeight(1)
}
.width('100%').height(8).backgroundColor('#EEF0F8').borderRadius(4).margin({ top: 6 })
}
.width('100%').padding(14).backgroundColor('#FFFFFF')
.borderRadius(14).margin({ left: 12, right: 12, top: 4 })
Scroll() {
Column() {
this.subjectCardBuilder(mockSubjects[0])
...
this.subjectCardBuilder(mockSubjects[5])
}
.padding({ bottom: 20 })
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
}
页面顶部是标题行,下方先放一个"总复习进度"概览卡片(显示 65% 的整体进度与对应进度条),再滚动展示六个科目卡片。总进度卡片用统一的靛蓝色,与下方各科目的彩色进度条形成"整体 vs 分项"的视觉层次。
十三、进度页:数据可视化的集中呈现
进度页是整个应用可视化能力最集中的展示,包含倒计时大卡片、柱状图、雷达图、时长分布四类图表。
13.1 倒计时大卡片
Column() {
Text('⏰ 距离考研还有').fontSize(12).fontColor('#FFFFFF').opacity(0.85)
Text(getExamCountdownDays().toString()).fontSize(52)
.fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 4 })
Text('天').fontSize(14).fontColor('#FFFFFF').opacity(0.85)
Row() {
Column() {
Text(getStudyDays().toString()).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('已备考天数').fontSize(9).fontColor('#FFFFFF').opacity(0.75)
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text(getTotalStudyHours().toString()).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('累计学时').fontSize(9).fontColor('#FFFFFF').opacity(0.75)
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('11.4').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('日均学时').fontSize(9).fontColor('#FFFFFF').opacity(0.75)
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').margin({ top: 14 })
}
.width('100%').padding({ top: 18, bottom: 18 })
.linearGradient({ angle: 135, colors: [['#3F51B5', 0], ['#7986CB', 1]] })
.borderRadius(16).margin({ left: 12, right: 12, top: 4 })
.alignItems(HorizontalAlign.Center)
这张卡片是进度页的视觉焦点。linearGradient({ angle: 135, colors: [['#3F51B5', 0], ['#7986CB', 1]] }) 创建了一个 135 度角的线性渐变背景,从主品牌色 #3F51B5 渐变到浅靛蓝 #7986CB,营造出有层次感的渐变质感。卡片正中是一个 52 字号的巨大倒计时数字,下方三宫格分别展示已备考天数、累计学时、日均学时。所有文字白色,副文案用 opacity 降低不透明度形成层次。这种"渐变背景 + 大数字 + 辅助指标"是数据看板类应用的经典设计语言。
13.2 柱状图
Column() {
Text('📊 各科目复习进度').fontSize(13).fontWeight(FontWeight.Bold)
.width('100%').padding({ left: 16, top: 12, bottom: 8 })
Row() {
ForEach(SUBJECT_PROGRESS_CONFIG, (bar: ChartBarData) => {
Column() {
Text(bar.value + '%')
.fontSize(9).fontColor('#3F51B5').margin({ bottom: 3 })
Column()
.width(26)
.height(getBarHeight(bar.value) + 'vp')
.backgroundColor(bar.color)
.borderRadius({ topLeft: 4, topRight: 4 })
Text(bar.label).fontSize(9).fontColor('#999999').margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
.padding({ left: 12, right: 12, bottom: 12 })
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(14)
.margin({ left: 12, right: 12, top: 8 })
柱状图用 ForEach 遍历配置数组渲染六根柱子。每根柱子是一个垂直排列的 Column:顶部是百分比数值,中间是柱体(宽 26,高度由 getBarHeight 算出并拼上 'vp' 单位,背景色取配置中的颜色,顶部圆角),底部是科目标签。六个 Column 用 layoutWeight(1) 等分宽度,形成等间距柱状图。这种"纯布局拼柱状图"的方式无需引入图表库,用原生组件即可实现,适合数据点较少的场景。
13.3 雷达图
雷达图是这个应用最巧妙的可视化实现,完全用原生组件加数学计算拼出来。
Stack() {
Column().width(170).height(170)
.border({ width: 1, color: '#DCE1F5' }).borderRadius(85)
Column().width(110).height(110)
.border({ width: 1, color: '#E8EAF6' }).borderRadius(55)
Column().width(1.5).height(170).backgroundColor('#DCE1F5').rotate({ angle: 0 })
Column().width(1.5).height(170).backgroundColor('#DCE1F5').rotate({ angle: 60 })
Column().width(1.5).height(170).backgroundColor('#DCE1F5').rotate({ angle: 120 })
Column().width(1.5).height(170).backgroundColor('#DCE1F5').rotate({ angle: 180 })
Column().width(1.5).height(170).backgroundColor('#DCE1F5').rotate({ angle: 240 })
Column().width(1.5).height(170).backgroundColor('#DCE1F5').rotate({ angle: 300 })
ForEach(KNOWLEDGE_RADAR_CONFIG, (point: RadarPointData) => {
Column() {
Text(Math.round(point.pct * 100).toString()).fontSize(8).fontColor('#FFFFFF')
}
.width(22).height(22).backgroundColor('#3F51B5').borderRadius(11)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
.position({ x: getRadarDotX(point.angle, point.pct), y: getRadarDotY(point.angle, point.pct) })
})
ForEach(KNOWLEDGE_RADAR_CONFIG, (point: RadarPointData) => {
Text(point.label).fontSize(10).fontColor('#3F51B5').fontWeight(FontWeight.Medium)
.position({ x: getRadarLabelX(point.angle), y: getRadarLabelY(point.angle) })
})
}
.width(260).height(250).margin({ top: 6 })
整个雷达图在一个 Stack 内堆叠了多层元素。最底下是两个同心圆(用带 border 和 borderRadius 的 Column 模拟,外圆 170 宽半径 85,内圆 110 宽半径 55),形成雷达图的网格背景。接着是六根从中心向外辐射的轴线——每根是一个 1.5 宽、170 高的 Column,用 rotate({ angle: N }) 分别旋转 0、60、120、180、240、300 度,正好把六边形雷达的六条对称轴画出来。
然后是数据点:ForEach 遍历配置数组,每个点用一个 22x22 的圆形 Column(背景主品牌色,白字显示掌握度百分比),通过 position 用前面定义的 getRadarDotX/getRadarDotY 计算坐标定位。最后再 ForEach 一遍,用 getRadarLabelX/getRadarLabelY 把科目标签摆放在雷达图外围。
这种"圆+线+点+标签"四层堆叠的实现方式,把一个看似复杂的雷达图拆解成了最基本的几何元素,充分展现了声明式 UI 的表达能力——只要数学算得对,任何图表都能用原生组件拼出来。
13.4 复习时长分布
Column() {
Row() {
Text('📖 政治').fontSize(11).fontColor('#E53935').layoutWeight(1)
Text('164h / 21%').fontSize(11).fontColor('#888888')
}
.width('100%').margin({ top: 4 })
Row() {
Column().width('21%').height(6).backgroundColor('#E53935').borderRadius(3)
Column().layoutWeight(1)
}
.width('100%').height(6).backgroundColor('#EEF0F8').borderRadius(3).margin({ top: 4 })
}
时长分布用一组横向进度条展示各科目的学时占比。每条由一个标签行(科目名 + “已学时 / 占比”)和一个进度条(宽度直接用百分比字符串)组成。与科目页的进度条不同,这里的进度条宽度代表的是"该科目占总学时的比例"而非"该科目的完成度",所以政治 21%、英语 24%、数学 28% 三条加起来约 73%,剩余比例对应其他三个科目。这种"占比条"让考生能直观看到时间在不同科目间的分配是否均衡。
十四、我的页面:个人档案与设置中心
14.1 渐变头部
Column() {
Row() {
Column() {
Text('🧑🎓').fontSize(36)
}.width(60).height(60).backgroundColor('#FFFFFF').opacity(0.9).borderRadius(30)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column() {
Text('陈晓峰').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('2027届考研 · 上岸中').fontSize(11).fontColor('#FFFFFF').opacity(0.85).margin({ top: 3 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 14 })
Text('⚙️').fontSize(18).fontColor('#FFFFFF').opacity(0.85)
}
.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 16 })
}
.width('100%')
.linearGradient({ angle: 135, colors: [['#3F51B5', 0], ['#7986CB', 1]] })
.borderRadius({ bottomLeft: 22, bottomRight: 22 })
头部用与倒计时卡片相同的渐变背景,但只对底部两个角做圆角(borderRadius({ bottomLeft: 22, bottomRight: 22 })),形成"顶部贴边、底部圆弧"的沉浸式头部效果。头部内是头像(半透明白底圆形)、姓名、考研届别与状态、设置图标。这种"渐变头部 + 圆角底"是个人中心页面的常见设计,能让页面顶部与状态栏自然融合。
14.2 考研信息卡片
Column() {
Row() {
Text('🎯 目标院校').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#3F51B5')
Column().layoutWeight(1)
Text('985 · 双一流').fontSize(10).fontColor('#7986CB')
}
.width('100%')
Text('浙江大学 · 计算机科学与技术').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor('#212121').margin({ top: 6 })
Divider().color('#F0F0F0').margin({ top: 10 })
Row() {
Column() {
Text(getExamCountdownDays().toString()).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#F44336')
Text('距初试').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('12月').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#3F51B5')
Text('考试月份').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('4门').fontSize(10).fontWeight(FontWeight.Bold).fontColor('#FF9800')
Text('考试科目').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').margin({ top: 10 })
}
这张卡片集中展示了考生的目标信息:目标院校层级(985/双一流)、具体院校与专业、距初试天数、考试月份、考试科目数。距初试天数用红色突出(传递紧迫感),考试月份用主品牌色,科目数用橙色,三种颜色与前面任务状态配色体系一致,形成跨页面的色彩语义统一。Divider 把院校信息与统计数据在视觉上分开,层次清晰。
14.3 学习统计与设置列表
Row() {
Column() {
Text(getStudyDays().toString()).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#3F51B5')
Text('连续备考').fontSize(10).fontColor('#888888').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
...
}
Column() {
Text('⚙️ 学习设置').fontSize(13).fontWeight(FontWeight.Bold)
.width('100%').padding({ left: 16, top: 12, bottom: 6 })
Column() {
Row() {
Text('🍅').fontSize(18)
Text('番茄钟时长').fontSize(13).fontColor('#333333').layoutWeight(1).margin({ left: 10 })
Text('25分钟').fontSize(11).fontColor('#3F51B5')
Text('>').fontColor('#CCCCCC').margin({ left: 6 })
}
.width('100%').padding({ top: 10, bottom: 10, left: 4 })
Divider().color('#F5F5F5')
...
}
}
三宫格统计展示连续备考天数、连续打卡天数、已刷题量。设置列表用"图标 + 标题 + 当前值 + 箭头"的典型列表项布局,每项之间用浅色 Divider 分隔。番茄钟时长、每日提醒时间、目标院校这三项有当前值显示,学习报告和考研经验贴只有箭头表示可进入。这种"设置项 + 当前值 + 进入箭头"是 iOS 和 Android 设置页的通用范式,用户无需学习即可上手。
页面最底部是一行版本号文案 v1.0 · 考研上岸助手 · 2026,用极浅灰色居中显示,作为应用的"签名"。
十五、关键特性对比总结
为了让读者快速把握整个应用各页面的功能定位与技术特点,下面用一张表格进行横向对比。
| 页面 | 核心功能 | 关键状态 | 主要交互 | 可视化元素 | 工程化亮点 |
|---|---|---|---|---|---|
| 今日复习 | 18 条任务的展示与增删改查 | 12 个 @State 变量管理弹框、表单、番茄钟 | 番茄钟开始/暂停/重置、状态筛选、新增/编辑/删除弹框 | 番茄钟倒计时、三宫格统计 | 纯函数 isTaskMatched 过滤、modalOverlay 复用、aboutToDisappear 清理定时器 |
| 周计划 | 15 条计划的时间轴呈现 | 无额外状态(纯展示) | 横向滚动浏览 | 左侧时间轴纵线、四宫格周汇总 | planItemBuilder 双栏布局、substring 日期裁剪、圆点颜色联动状态 |
| 科目 | 6 个科目的进度与策略 | 无额外状态(纯展示) | 纵向滚动浏览 | 进度条、总进度概览卡片 | 设计令牌驱动配色、width 百分比直驱进度条、图标方块+描述卡片 |
| 进度 | 备考数据的综合可视化 | 无额外状态(纯展示) | 纵向滚动浏览 | 渐变倒计时大卡片、柱状图、雷达图、占比条 | 纯函数算雷达坐标、Stack 多层堆叠拼图表、linearGradient 渐变 |
| 我的 | 个人档案与设置入口 | 无额外状态(纯展示) | 设置项点击进入 | 渐变头部、信息卡片、三宫格统计、设置列表 | borderRadius 部分圆角、Divider 分组、列表项统一范式 |
| 入口骨架 | 五个 Tab 的切换路由 | activeTab 单一状态 | 底部 Tab 点击切换 | 选中指示条、阴影分隔 | @Builder 封装内容区与 Tab 项、枚举驱动路由、layoutWeight 布局 |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:
初始化项目,自动下载相关依赖:

完整代码:
// ============ 类型定义 ============
interface SubjectConfig {
icon: string
color: string
bg: string
}
interface TaskStatusConfig {
label: string
color: string
bg: string
icon: string
}
interface ChartBarData {
label: string
value: number
color: string
}
interface RadarPointData {
label: string
pct: number
angle: number
}
// ============ 复习任务数据模型 ============
@Observed
export class StudyTaskModel {
id: number = 0
name: string = ''
subject: string = ''
timeRange: string = ''
duration: number = 0
importance: string = ''
status: string = ''
goal: string = ''
constructor(id: number, name: string, subject: string, timeRange: string, duration: number, importance: string, status: string, goal: string) {
this.id = id; this.name = name; this.subject = subject
this.timeRange = timeRange; this.duration = duration
this.importance = importance; this.status = status; this.goal = goal
}
}
// ============ 周计划数据模型 ============
@Observed
export class PlanItemModel {
id: number = 0
date: string = ''
weekDay: string = ''
timeRange: string = ''
subject: string = ''
task: string = ''
isDone: boolean = false
constructor(id: number, date: string, weekDay: string, timeRange: string, subject: string, task: string, isDone: boolean) {
this.id = id; this.date = date; this.weekDay = weekDay
this.timeRange = timeRange; this.subject = subject
this.task = task; this.isDone = isDone
}
}
// ============ 科目数据模型 ============
@Observed
export class SubjectModel {
id: number = 0
name: string = ''
icon: string = ''
progress: number = 0
targetHours: number = 0
doneHours: number = 0
description: string = ''
constructor(id: number, name: string, icon: string, progress: number, targetHours: number, doneHours: number, description: string) {
this.id = id; this.name = name; this.icon = icon
this.progress = progress; this.targetHours = targetHours
this.doneHours = doneHours; this.description = description
}
}
// ============ 设计令牌 ============
const SUBJECT_CONFIG: Record<string, SubjectConfig> = {
'政治': { icon: '📖', color: '#E53935', bg: '#FDECEA' },
'英语': { icon: '🔤', color: '#3949AB', bg: '#E8EAF6' },
'数学': { icon: '📐', color: '#00897B', bg: '#E0F2F1' },
'专业课1': { icon: '💻', color: '#6A1B9A', bg: '#F3E5F5' },
'专业课2': { icon: '🧠', color: '#00838F', bg: '#E0F7FA' },
'复试': { icon: '🎓', color: '#F4511E', bg: '#FBE9E7' }
}
const TASK_STATUS_CONFIG: Record<string, TaskStatusConfig> = {
'已完成': { label: '已完成', color: '#4CAF50', bg: '#E8F5E9', icon: '✅' },
'进行中': { label: '进行中', color: '#3F51B5', bg: '#E8EAF6', icon: '⏳' },
'待开始': { label: '待开始', color: '#FF9800', bg: '#FFF3E0', icon: '📝' }
}
const TASK_STATUS_FILTER: string[] = ['全部', '已完成', '进行中', '待开始']
const SUBJECT_NAMES: string[] = ['政治', '英语', '数学', '专业课1', '专业课2', '复试']
const IMPORTANCE_OPTIONS: string[] = ['普通', '重要', '紧急']
// ============ 科目进度柱状图配置 ============
const SUBJECT_PROGRESS_CONFIG: ChartBarData[] = [
{ label: '政治', value: 82, color: '#E53935' },
{ label: '英语', value: 65, color: '#3949AB' },
{ label: '数学', value: 58, color: '#00897B' },
{ label: '专业课1', value: 72, color: '#6A1B9A' },
{ label: '专业课2', value: 68, color: '#00838F' },
{ label: '复试', value: 45, color: '#F4511E' }
]
// ============ 知识掌握度雷达配置 ============
const KNOWLEDGE_RADAR_CONFIG: RadarPointData[] = [
{ label: '政治', pct: 0.82, angle: 0 },
{ label: '英语', pct: 0.65, angle: 60 },
{ label: '数学', pct: 0.58, angle: 120 },
{ label: '专业课1', pct: 0.72, angle: 180 },
{ label: '专业课2', pct: 0.68, angle: 240 },
{ label: '复试', pct: 0.45, angle: 300 }
]
// ============ 18 条今日复习任务 ============
const mockTasks: StudyTaskModel[] = [
new StudyTaskModel(1, '政治1000题单选精练', '政治', '07:00-08:00', 60, '重要', '已完成', '完成40题并订正'),
new StudyTaskModel(2, '英语真题阅读1篇精析', '英语', '08:10-09:00', 50, '重要', '已完成', '2005年Text1逐句翻译'),
new StudyTaskModel(3, '英语单词背诵Unit12', '英语', '09:10-09:40', 30, '普通', '已完成', '新词50+复习100'),
new StudyTaskModel(4, '数学导数应用专题', '数学', '09:50-11:50', 120, '紧急', '进行中', '中值定理证明题6道'),
new StudyTaskModel(5, '数学错题本整理', '数学', '12:00-12:30', 30, '普通', '待开始', '整理上周错题15道'),
new StudyTaskModel(6, '午休时间', '复试', '12:30-13:30', 60, '普通', '已完成', '保证30分钟午睡'),
new StudyTaskModel(7, '政治马原知识点回顾', '政治', '13:40-14:40', 60, '重要', '待开始', '辩证唯物主义框架梳理'),
new StudyTaskModel(8, '数据结构树与图复习', '专业课1', '14:50-16:20', 90, '紧急', '待开始', '遍历+最小生成树'),
new StudyTaskModel(9, '算法题每日一练', '专业课1', '16:30-17:30', 60, '重要', '待开始', 'LeetCode每日一题'),
new StudyTaskModel(10, '英语作文范文背诵', '英语', '17:40-18:20', 40, '普通', '待开始', '小作文模板2篇'),
new StudyTaskModel(11, '晚餐与散步', '复试', '18:20-19:00', 40, '普通', '已完成', '放松休息'),
new StudyTaskModel(12, '线性代数矩阵复习', '数学', '19:10-20:40', 90, '重要', '待开始', '矩阵运算+初等变换'),
new StudyTaskModel(13, '政治时政热点学习', '政治', '20:50-21:30', 40, '普通', '待开始', '本月时政要点整理'),
new StudyTaskModel(14, '操作系统进程管理', '专业课2', '21:40-22:40', 60, '重要', '待开始', '进程调度算法对比'),
new StudyTaskModel(15, '英语长难句训练', '英语', '22:50-23:20', 30, '普通', '待开始', '拆分3个长难句'),
new StudyTaskModel(16, '睡前单词快速回顾', '英语', '23:20-23:40', 20, '普通', '待开始', '扫读今日新词'),
new StudyTaskModel(17, '计算机网络分层模型', '专业课2', '14:00-15:30', 90, '紧急', '待开始', 'TCP/IP各层协议图'),
new StudyTaskModel(18, '概率论分布复习', '数学', '08:00-09:30', 90, '重要', '待开始', '常见分布公式总结')
]
// ============ 15 条周计划 ============
const mockPlans: PlanItemModel[] = [
new PlanItemModel(1, '2026-08-10', '周一', '08:00-10:00', '数学', '高数第一章总复习+习题', true),
new PlanItemModel(2, '2026-08-10', '周一', '14:00-16:00', '英语', '2005年真题阅读精析', true),
new PlanItemModel(3, '2026-08-10', '周一', '19:00-21:00', '政治', '马原第二章精讲笔记', true),
new PlanItemModel(4, '2026-08-11', '周二', '08:00-10:00', '专业课1', '数据结构树专题练习', true),
new PlanItemModel(5, '2026-08-11', '周二', '14:00-16:00', '数学', '线性代数矩阵变换', true),
new PlanItemModel(6, '2026-08-11', '周二', '19:00-21:00', '英语', '完形填空专项训练', true),
new PlanItemModel(7, '2026-08-12', '周三', '08:00-10:00', '政治', '毛中特核心考点梳理', false),
new PlanItemModel(8, '2026-08-12', '周三', '14:00-16:00', '专业课1', '图算法专题(最短路径)', false),
new PlanItemModel(9, '2026-08-12', '周三', '19:00-21:00', '数学', '概率论与数理统计', false),
new PlanItemModel(10, '2026-08-13', '周四', '08:00-10:00', '英语', '翻译专项强化训练', false),
new PlanItemModel(11, '2026-08-13', '周四', '14:00-16:00', '专业课2', '操作系统进程与线程', false),
new PlanItemModel(12, '2026-08-13', '周四', '19:00-21:00', '政治', '思修法基选择题练习', false),
new PlanItemModel(13, '2026-08-14', '周五', '08:00-10:00', '数学', '历年真题模拟卷1', false),
new PlanItemModel(14, '2026-08-14', '周五', '14:00-16:00', '英语', '大作文模板整理背诵', false),
new PlanItemModel(15, '2026-08-14', '周五', '19:00-21:00', '专业课2', '计算机网络重要考点', false)
]
// ============ 6 个科目数据 ============
const mockSubjects: SubjectModel[] = [
new SubjectModel(1, '政治', '📖', 82, 200, 164, '精讲精练+1000题+肖四肖八'),
new SubjectModel(2, '英语', '🔤', 65, 300, 195, '真题阅读+单词+作文模板'),
new SubjectModel(3, '数学', '📐', 58, 400, 232, '高数+线代+概率全程强化'),
new SubjectModel(4, '专业课1', '💻', 72, 350, 252, '数据结构与算法核心题库'),
new SubjectModel(5, '专业课2', '🧠', 68, 300, 204, '操作系统+计算机网络'),
new SubjectModel(6, '复试', '🎓', 45, 150, 68, '英语口语+专业面试准备')
]
// ============ 全局纯函数 ============
function formatPomodoroTime(seconds: number): string {
let min: number = Math.floor(seconds / 60)
let sec: number = seconds % 60
return (min < 10 ? '0' + min.toString() : min.toString())
+ ':' + (sec < 10 ? '0' + sec.toString() : sec.toString())
}
function isTaskMatched(item: StudyTaskModel, keyword: string, status: string): boolean {
if (status !== '全部' && item.status !== status) {
return false
}
if (keyword !== '') {
return item.name.indexOf(keyword) >= 0 || item.subject.indexOf(keyword) >= 0
}
return true
}
function getExamCountdownDays(): number {
return 126
}
function getStudyDays(): number {
return 98
}
function getTotalStudyHours(): number {
return 1115
}
function getTodayDoneCount(): number {
return 6
}
function getTodayTaskCount(): number {
return 18
}
function getBarHeight(v: number): number {
return Math.round(v / 100 * 80)
}
function getRadarDotX(deg: number, pct: number): string {
let rad: number = deg * Math.PI / 180
let r: number = 12 + pct * 30
return (50 + r * Math.sin(rad)) + '%'
}
function getRadarDotY(deg: number, pct: number): string {
let rad: number = deg * Math.PI / 180
let r: number = 12 + pct * 30
return (50 - r * Math.cos(rad)) + '%'
}
function getRadarLabelX(deg: number): string {
let rad: number = deg * Math.PI / 180
return (50 + 45 * Math.sin(rad)) + '%'
}
function getRadarLabelY(deg: number): string {
let rad: number = deg * Math.PI / 180
return (50 - 45 * Math.cos(rad)) + '%'
}
// ============ 底部 Tab 枚举 ============
enum StudyTab {
TODAY = 0,
PLAN = 1,
SUBJECT = 2,
PROGRESS = 3,
PROFILE = 4
}
// ============ 入口页面 ============
@Entry
@Component
struct StudyPlanApp {
@State activeTab: StudyTab = StudyTab.TODAY
@Builder contentArea() {
Column() {
if (this.activeTab === StudyTab.TODAY) {
StudyTodayContent()
} else if (this.activeTab === StudyTab.PLAN) {
StudyPlanContent()
} else if (this.activeTab === StudyTab.SUBJECT) {
StudySubjectContent()
} else if (this.activeTab === StudyTab.PROGRESS) {
StudyProgressContent()
} else {
StudyProfileContent()
}
}
.layoutWeight(1)
}
@Builder bottomTabItem(icon: string, label: string, tab: StudyTab) {
Column() {
Text(icon).fontSize(20).opacity(this.activeTab === tab ? 1.0 : 0.45)
Text(label).fontSize(9)
.fontColor(this.activeTab === tab ? '#3F51B5' : '#999999')
.fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 1 })
if (this.activeTab === tab) {
Column().width(18).height(3)
.backgroundColor('#3F51B5').borderRadius(2).margin({ top: 2 })
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 5, bottom: 5 })
.onClick(() => { this.activeTab = tab })
}
build() {
Column() {
this.contentArea()
Row() {
this.bottomTabItem('📋', '今日', StudyTab.TODAY)
this.bottomTabItem('📅', '计划', StudyTab.PLAN)
this.bottomTabItem('📚', '科目', StudyTab.SUBJECT)
this.bottomTabItem('📈', '进度', StudyTab.PROGRESS)
this.bottomTabItem('👤', '我的', StudyTab.PROFILE)
}
.width('100%')
.backgroundColor('#FFFFFF')
.padding({ top: 4, bottom: 6 })
.shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
}
.width('100%').height('100%')
.backgroundColor('#F5F6FA')
}
}
// ============ 今日复习页 ============
@Component
struct StudyTodayContent {
@State searchKeyword: string = ''
@State selectedStatus: string = '全部'
@State pomodoroRunning: boolean = false
@State pomodoroSeconds: number = 1500
@State showAddModal: boolean = false
@State showEditModal: boolean = false
@State showDeleteConfirm: boolean = false
@State selectedTask: StudyTaskModel | null = null
@State editingTask: StudyTaskModel | null = null
@State formTaskName: string = ''
@State formSubject: string = '政治'
@State formTimeRange: string = '08:00-10:00'
@State formImportance: string = '重要'
timerId: number = -1
aboutToDisappear() {
if (this.timerId >= 0) {
clearInterval(this.timerId)
this.timerId = -1
}
}
startPomodoro(): void {
if (this.pomodoroRunning) {
if (this.timerId >= 0) {
clearInterval(this.timerId)
}
this.timerId = -1
this.pomodoroRunning = false
} else {
this.pomodoroRunning = true
this.timerId = setInterval(() => {
this.pomodoroSeconds = this.pomodoroSeconds - 1
if (this.pomodoroSeconds <= 0) {
if (this.timerId >= 0) {
clearInterval(this.timerId)
}
this.timerId = -1
this.pomodoroRunning = false
this.pomodoroSeconds = 1500
}
}, 1000)
}
}
// ========== 弹框遮罩 ==========
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(onClose)
}
// ========== 弹框1:新增任务 ==========
@Builder addTaskModal() {
Column() {
this.modalOverlay(() => { this.showAddModal = false })
Column() {
Row() {
Text('➕ 新增复习任务').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showAddModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#F0F0F0')
Scroll() {
Column() {
Text('任务名称').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: '如:数学导数专题' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F6FA').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.formTaskName = v })
Text('所属科目').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
Row() {
ForEach(SUBJECT_NAMES, (s: string) => {
if (this.formSubject === s) {
Text((SUBJECT_CONFIG[s]?.icon ?? '📖') + ' ' + s)
.fontSize(11).fontColor('#FFFFFF').backgroundColor('#3F51B5')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
} else {
Text((SUBJECT_CONFIG[s]?.icon ?? '📖') + ' ' + s)
.fontSize(11).fontColor('#3F51B5').backgroundColor('#E8EAF6')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formSubject = s })
}
})
}
.margin({ left: 16, right: 16, top: 4 })
Text('时间与重要程度').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: '时间范围 08:00-10:00' })
.placeholderColor('#BBBBBB').fontSize(12).width('100%')
.backgroundColor('#F5F6FA').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
.onChange((v: string) => { this.formTimeRange = v })
Row() {
ForEach(IMPORTANCE_OPTIONS, (i: string) => {
if (this.formImportance === i) {
Text(i).fontSize(11).fontColor('#FFFFFF').backgroundColor('#3F51B5')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
} else {
Text(i).fontSize(11).fontColor('#3F51B5').backgroundColor('#E8EAF6')
.padding({ left: 8, right: 8, top: 5, bottom: 5 }).borderRadius(12)
.margin({ left: 3, right: 3 })
.onClick(() => { this.formImportance = i })
}
})
}
.margin({ left: 16, right: 16, top: 4 })
}
.padding({ 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('#3F51B5').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%').height('70%').backgroundColor('#FFFFFF').borderRadius(16)
.alignItems(HorizontalAlign.Center)
.constraintSize({ maxHeight: '75%' })
.position({ x: '5%', y: '12%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
// ========== 弹框2:编辑任务 ==========
@Builder editTaskModal() {
Column() {
this.modalOverlay(() => { this.showEditModal = false })
Column() {
Row() {
Text('✏️ 编辑任务').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Column().layoutWeight(1)
Text('✕').fontSize(18).fontColor('#999999')
.onClick(() => { this.showEditModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#F0F0F0')
Column() {
Text('任务名称').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: this.editingTask?.name ?? '任务名称' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F6FA').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('所属科目').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: this.editingTask?.subject ?? '科目' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F6FA').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('时间范围').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextInput({ placeholder: this.editingTask?.timeRange ?? '08:00-10:00' })
.placeholderColor('#BBBBBB').fontSize(14).width('100%')
.backgroundColor('#F5F6FA').borderRadius(8)
.margin({ left: 20, right: 20, top: 4 })
Text('复习目标').fontSize(12).fontColor('#888888').margin({ top: 12, left: 20 })
TextArea({ placeholder: this.editingTask?.goal ?? '完成目标...' })
.placeholderColor('#BBBBBB').fontSize(12).width('100%').height(60)
.backgroundColor('#F5F6FA').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.showEditModal = false })
Text('保存修改').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#7986CB').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => { this.showEditModal = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 16, bottom: 16 })
}
.width('90%').height('60%').backgroundColor('#FFFFFF').borderRadius(16)
.alignItems(HorizontalAlign.Center)
.position({ x: '5%', y: '18%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
// ========== 弹框3:删除确认 ==========
@Builder deleteTaskModal() {
Column() {
this.modalOverlay(() => { this.showDeleteConfirm = false })
Column() {
Text('⚠️').fontSize(48).margin({ top: 24 })
Text('确认删除此任务?').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Text('删除后今日安排将更新').fontSize(13).fontColor('#3F51B5').margin({ top: 4 })
Row() {
Text(this.selectedTask?.name ?? '任务').fontSize(14).fontColor('#333333')
.fontWeight(FontWeight.Bold).margin({ left: 8 })
Text(this.selectedTask?.timeRange ?? '').fontSize(12).fontColor('#7986CB')
.margin({ left: 8 })
}
.backgroundColor('#E8EAF6').borderRadius(10)
.padding({ left: 16, right: 16, top: 10, bottom: 10 }).margin({ top: 16 })
Row() {
Text('取消').fontSize(14).fontColor('#888888')
.backgroundColor('#F5F5F5').borderRadius(20)
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.onClick(() => { this.showDeleteConfirm = false })
Text('确认删除').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#F44336').borderRadius(20)
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => { this.showDeleteConfirm = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 20, bottom: 20 })
}
.width('80%').backgroundColor('#FFFFFF').borderRadius(16)
.alignItems(HorizontalAlign.Center)
.position({ x: '10%', y: '38%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
// ========== 今日任务列表项 Builder ==========
@Builder taskItemBuilder(t: StudyTaskModel) {
Column() {
Row() {
Text(t.status === '已完成' ? '✅' : '⬜').fontSize(15)
Column() {
Text(t.name).fontSize(14).fontWeight(FontWeight.Medium)
.fontColor(t.status === '已完成' ? '#999999' : '#212121')
.decoration({
type: t.status === '已完成' ? TextDecorationType.LineThrough : TextDecorationType.None
})
Row() {
Text((SUBJECT_CONFIG[t.subject]?.icon ?? '📖') + ' ' + t.subject).fontSize(9)
.fontColor(SUBJECT_CONFIG[t.subject]?.color ?? '#888888')
.backgroundColor(SUBJECT_CONFIG[t.subject]?.bg ?? '#F5F5F5')
.padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(6)
.margin({ right: 4 })
Text('⏱️ ' + t.duration + '分钟').fontSize(9).fontColor('#999999')
}
.margin({ top: 4 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 8 })
Column() {
Text(t.timeRange).fontSize(9).fontColor('#7986CB')
.backgroundColor('#E8EAF6').padding({ left: 5, right: 5, top: 2, bottom: 2 })
.borderRadius(6)
Text(TASK_STATUS_CONFIG[t.status]?.label ?? t.status).fontSize(9)
.fontColor(TASK_STATUS_CONFIG[t.status]?.color ?? '#999999')
.margin({ top: 4 })
}.alignItems(HorizontalAlign.End)
}
.width('100%')
Row() {
Text('✏️').fontSize(12).fontColor('#3F51B5')
.onClick(() => { this.editingTask = t; this.showEditModal = true })
Text('🗑️').fontSize(12).fontColor('#F44336').margin({ left: 10 })
.onClick(() => { this.selectedTask = t; this.showDeleteConfirm = true })
Column().layoutWeight(1)
Text('🎯 ' + t.goal).fontSize(9).fontColor('#BBBBBB')
}
.width('100%').margin({ top: 6 })
}
.width('100%').padding(12).backgroundColor('#FFFFFF')
.borderRadius(12).margin({ left: 12, right: 12, top: 6 })
}
build() {
Stack() {
// 主内容层
Column() {
Row() {
Column() {
Text('📋 今日复习').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Text('2026-08-11 周二 · 距离考研' + getExamCountdownDays() + '天').fontSize(11)
.fontColor('#7986CB').margin({ top: 3 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('+').fontSize(22).fontColor('#FFFFFF')
.backgroundColor('#3F51B5').width(32).height(32).borderRadius(16)
.textAlign(TextAlign.Center)
.onClick(() => { this.showAddModal = true })
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 8 })
// 番茄钟卡片
Column() {
Row() {
Text('🍅 番茄钟').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3F51B5')
Column().layoutWeight(1)
Text(this.pomodoroRunning ? '🔔 专注中' : '⏸️ 休息中').fontSize(10)
.fontColor(this.pomodoroRunning ? '#F44336' : '#999999')
}
.width('100%')
Text(formatPomodoroTime(this.pomodoroSeconds)).fontSize(34)
.fontWeight(FontWeight.Bold).fontColor('#3F51B5').margin({ top: 10 })
Row() {
Text(this.pomodoroRunning ? '暂停' : '开始').fontSize(13).fontColor('#FFFFFF')
.backgroundColor(this.pomodoroRunning ? '#FF9800' : '#3F51B5')
.borderRadius(18).padding({ left: 24, right: 24, top: 8, bottom: 8 })
.onClick(() => { this.startPomodoro() })
Text('重置').fontSize(13).fontColor('#3F51B5')
.backgroundColor('#E8EAF6').borderRadius(18)
.padding({ left: 24, right: 24, top: 8, bottom: 8 })
.margin({ left: 8 })
.onClick(() => { this.pomodoroSeconds = 1500 })
}
.margin({ top: 10 })
Text('今日已完成 3 个番茄钟').fontSize(10).fontColor('#999999').margin({ top: 8 })
}
.width('100%').padding(14).backgroundColor('#FFFFFF')
.borderRadius(14).margin({ left: 12, right: 12, top: 4 })
.alignItems(HorizontalAlign.Center)
// 今日统计
Row() {
Column() {
Text(getTodayDoneCount().toString()).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4CAF50')
Text('已完成').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
.backgroundColor('#FFFFFF').borderRadius(12).padding({ top: 10, bottom: 10 })
.margin({ left: 12, right: 3, top: 6 })
Column() {
Text((getTodayTaskCount() - getTodayDoneCount()).toString())
.fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FF9800')
Text('待完成').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
.backgroundColor('#FFFFFF').borderRadius(12).padding({ top: 10, bottom: 10 })
.margin({ left: 3, right: 3, top: 6 })
Column() {
Text('6.5').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#3F51B5')
Text('今日时长h').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
.backgroundColor('#FFFFFF').borderRadius(12).padding({ top: 10, bottom: 10 })
.margin({ left: 3, right: 12, top: 6 })
}
.width('100%')
// 状态筛选
Scroll() {
Row() {
ForEach(TASK_STATUS_FILTER, (s: string) => {
if (this.selectedStatus === s) {
Text(s).fontSize(11).fontColor('#FFFFFF').backgroundColor('#3F51B5')
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(14)
.margin({ left: 3, right: 3 })
} else {
Text(s).fontSize(11).fontColor('#3F51B5').backgroundColor('#E8EAF6')
.padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(14)
.margin({ left: 3, right: 3 })
.onClick(() => { this.selectedStatus = s })
}
})
}
.padding({ left: 8, right: 8 })
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(38)
// 18 条任务列表
Scroll() {
Column() {
if (isTaskMatched(mockTasks[0], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[0])
}
if (isTaskMatched(mockTasks[1], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[1])
}
if (isTaskMatched(mockTasks[2], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[2])
}
if (isTaskMatched(mockTasks[3], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[3])
}
if (isTaskMatched(mockTasks[4], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[4])
}
if (isTaskMatched(mockTasks[5], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[5])
}
if (isTaskMatched(mockTasks[6], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[6])
}
if (isTaskMatched(mockTasks[7], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[7])
}
if (isTaskMatched(mockTasks[8], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[8])
}
if (isTaskMatched(mockTasks[9], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[9])
}
if (isTaskMatched(mockTasks[10], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[10])
}
if (isTaskMatched(mockTasks[11], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[11])
}
if (isTaskMatched(mockTasks[12], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[12])
}
if (isTaskMatched(mockTasks[13], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[13])
}
if (isTaskMatched(mockTasks[14], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[14])
}
if (isTaskMatched(mockTasks[15], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[15])
}
if (isTaskMatched(mockTasks[16], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[16])
}
if (isTaskMatched(mockTasks[17], this.searchKeyword, this.selectedStatus)) {
this.taskItemBuilder(mockTasks[17])
}
}
.padding({ bottom: 20 })
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
// ===== 弹框层 =====
if (this.showAddModal) { this.addTaskModal() }
if (this.showEditModal) { this.editTaskModal() }
if (this.showDeleteConfirm) { this.deleteTaskModal() }
}
}
}
// ============ 周计划页(时间轴) ============
@Component
struct StudyPlanContent {
@Builder planItemBuilder(p: PlanItemModel) {
Row() {
// 左侧时间轴
Column() {
Text(p.date.substring(5)).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#3F51B5')
Text(p.weekDay).fontSize(9).fontColor('#999999').margin({ top: 2 })
Column().width(10).height(10).backgroundColor(p.isDone ? '#4CAF50' : '#3F51B5')
.borderRadius(5).margin({ top: 6 })
Column().width(2).layoutWeight(1).backgroundColor('#DCE1F5').margin({ top: 2 })
}
.width(56).alignItems(HorizontalAlign.Center)
// 右侧内容卡片
Column() {
Row() {
Text(p.timeRange).fontSize(10).fontColor('#3F51B5')
.backgroundColor('#E8EAF6').padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6)
Column().layoutWeight(1)
Text(p.isDone ? '✅ 已完成' : '⏳ 未开始').fontSize(9)
.fontColor(p.isDone ? '#4CAF50' : '#FF9800')
}
.width('100%')
Text(p.task).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#212121').margin({ top: 5 })
Text((SUBJECT_CONFIG[p.subject]?.icon ?? '📖') + ' ' + p.subject).fontSize(10)
.fontColor(SUBJECT_CONFIG[p.subject]?.color ?? '#888888')
.backgroundColor(SUBJECT_CONFIG[p.subject]?.bg ?? '#F5F5F5')
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
.margin({ top: 4 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
.backgroundColor('#FFFFFF').borderRadius(12).padding(12)
.margin({ left: 4, right: 12 })
}
.width('100%').alignItems(VerticalAlign.Top)
.padding({ left: 8, right: 0, top: 0 })
}
build() {
Column() {
Row() {
Column() {
Text('📅 本周计划').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Text('2026-08-10 至 08-16 · 冲刺阶段').fontSize(11).fontColor('#7986CB').margin({ top: 3 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('📌').fontSize(22)
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 8 })
// 周汇总卡片
Row() {
Column() {
Text('6').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4CAF50')
Text('已完成').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('9').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FF9800')
Text('待完成').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('15').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#3F51B5')
Text('总计划').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('40%').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#7986CB')
Text('完成率').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(12)
.padding({ top: 12, bottom: 12 }).margin({ left: 12, right: 12, top: 4 })
// 时间轴列表
Scroll() {
Column() {
this.planItemBuilder(mockPlans[0])
this.planItemBuilder(mockPlans[1])
this.planItemBuilder(mockPlans[2])
this.planItemBuilder(mockPlans[3])
this.planItemBuilder(mockPlans[4])
this.planItemBuilder(mockPlans[5])
this.planItemBuilder(mockPlans[6])
this.planItemBuilder(mockPlans[7])
this.planItemBuilder(mockPlans[8])
this.planItemBuilder(mockPlans[9])
this.planItemBuilder(mockPlans[10])
this.planItemBuilder(mockPlans[11])
this.planItemBuilder(mockPlans[12])
this.planItemBuilder(mockPlans[13])
this.planItemBuilder(mockPlans[14])
}
.padding({ top: 6, bottom: 20 })
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
}
}
// ============ 科目页 ============
@Component
struct StudySubjectContent {
@Builder subjectCardBuilder(s: SubjectModel) {
Column() {
Row() {
Column() {
Text(s.icon).fontSize(26)
}.width(48).height(48).backgroundColor(SUBJECT_CONFIG[s.name]?.bg ?? '#E8EAF6')
.borderRadius(12)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column() {
Row() {
Text(s.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#212121')
Column().layoutWeight(1)
Text(s.progress + '%').fontSize(15).fontWeight(FontWeight.Bold)
.fontColor(SUBJECT_CONFIG[s.name]?.color ?? '#3F51B5')
}
.width('100%')
Text(s.description).fontSize(10).fontColor('#999999').margin({ top: 3 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 12 })
}
.width('100%')
Row() {
Column()
.width(s.progress + '%')
.height(7).backgroundColor(SUBJECT_CONFIG[s.name]?.color ?? '#3F51B5').borderRadius(4)
Column().layoutWeight(1)
}
.width('100%').height(7).backgroundColor('#EEF0F8').borderRadius(4).margin({ top: 10 })
Row() {
Text('已完成 ' + s.doneHours + 'h / ' + s.targetHours + 'h').fontSize(9).fontColor('#888888')
Column().layoutWeight(1)
Text('距目标 ' + (s.targetHours - s.doneHours) + 'h').fontSize(9).fontColor('#7986CB')
}
.width('100%').margin({ top: 4 })
}
.width('100%').padding(14).backgroundColor('#FFFFFF')
.borderRadius(14).margin({ left: 12, right: 12, top: 6 })
}
build() {
Column() {
Row() {
Column() {
Text('📚 科目复习').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Text('6 个科目 · 总进度 ' + getTotalStudyHours() + 'h').fontSize(11).fontColor('#7986CB').margin({ top: 3 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('🏆').fontSize(22)
}
.width('100%').padding({ left: 16, right: 16, top: 14, bottom: 8 })
// 平均进度总览
Column() {
Row() {
Text('总复习进度').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#3F51B5')
Column().layoutWeight(1)
Text('65%').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#3F51B5')
}
.width('100%')
Row() {
Column().width('65%').height(8).backgroundColor('#3F51B5').borderRadius(4)
Column().layoutWeight(1)
}
.width('100%').height(8).backgroundColor('#EEF0F8').borderRadius(4).margin({ top: 6 })
}
.width('100%').padding(14).backgroundColor('#FFFFFF')
.borderRadius(14).margin({ left: 12, right: 12, top: 4 })
Scroll() {
Column() {
this.subjectCardBuilder(mockSubjects[0])
this.subjectCardBuilder(mockSubjects[1])
this.subjectCardBuilder(mockSubjects[2])
this.subjectCardBuilder(mockSubjects[3])
this.subjectCardBuilder(mockSubjects[4])
this.subjectCardBuilder(mockSubjects[5])
}
.padding({ bottom: 20 })
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
}
}
// ============ 进度页 ============
@Component
struct StudyProgressContent {
build() {
Column() {
Text('📈 复习进度').fontSize(18).fontWeight(FontWeight.Bold)
.width('100%').padding({ left: 16, top: 14, bottom: 8 })
Scroll() {
Column() {
// 倒计时大数字卡片
Column() {
Text('⏰ 距离考研还有').fontSize(12).fontColor('#FFFFFF').opacity(0.85)
Text(getExamCountdownDays().toString()).fontSize(52)
.fontWeight(FontWeight.Bold).fontColor('#FFFFFF').margin({ top: 4 })
Text('天').fontSize(14).fontColor('#FFFFFF').opacity(0.85)
Row() {
Column() {
Text(getStudyDays().toString()).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('已备考天数').fontSize(9).fontColor('#FFFFFF').opacity(0.75)
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text(getTotalStudyHours().toString()).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('累计学时').fontSize(9).fontColor('#FFFFFF').opacity(0.75)
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('11.4').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('日均学时').fontSize(9).fontColor('#FFFFFF').opacity(0.75)
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').margin({ top: 14 })
}
.width('100%').padding({ top: 18, bottom: 18 })
.linearGradient({ angle: 135, colors: [['#3F51B5', 0], ['#7986CB', 1]] })
.borderRadius(16).margin({ left: 12, right: 12, top: 4 })
.alignItems(HorizontalAlign.Center)
// 各科目进度柱状图
Column() {
Text('📊 各科目复习进度').fontSize(13).fontWeight(FontWeight.Bold)
.width('100%').padding({ left: 16, top: 12, bottom: 8 })
Row() {
ForEach(SUBJECT_PROGRESS_CONFIG, (bar: ChartBarData) => {
Column() {
Text(bar.value + '%')
.fontSize(9).fontColor('#3F51B5').margin({ bottom: 3 })
Column()
.width(26)
.height(getBarHeight(bar.value) + 'vp')
.backgroundColor(bar.color)
.borderRadius({ topLeft: 4, topRight: 4 })
Text(bar.label).fontSize(9).fontColor('#999999').margin({ top: 3 })
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
.padding({ left: 12, right: 12, bottom: 12 })
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(14)
.margin({ left: 12, right: 12, top: 8 })
// 知识掌握度雷达图
Column() {
Text('🧭 知识点掌握度雷达').fontSize(13).fontWeight(FontWeight.Bold)
.width('100%').padding({ left: 16, top: 12, bottom: 4 })
Stack() {
Column().width(170).height(170)
.border({ width: 1, color: '#DCE1F5' }).borderRadius(85)
Column().width(110).height(110)
.border({ width: 1, color: '#E8EAF6' }).borderRadius(55)
Column().width(1.5).height(170).backgroundColor('#DCE1F5').rotate({ angle: 0 })
Column().width(1.5).height(170).backgroundColor('#DCE1F5').rotate({ angle: 60 })
Column().width(1.5).height(170).backgroundColor('#DCE1F5').rotate({ angle: 120 })
Column().width(1.5).height(170).backgroundColor('#DCE1F5').rotate({ angle: 180 })
Column().width(1.5).height(170).backgroundColor('#DCE1F5').rotate({ angle: 240 })
Column().width(1.5).height(170).backgroundColor('#DCE1F5').rotate({ angle: 300 })
ForEach(KNOWLEDGE_RADAR_CONFIG, (point: RadarPointData) => {
Column() {
Text(Math.round(point.pct * 100).toString()).fontSize(8).fontColor('#FFFFFF')
}
.width(22).height(22).backgroundColor('#3F51B5').borderRadius(11)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
.position({ x: getRadarDotX(point.angle, point.pct), y: getRadarDotY(point.angle, point.pct) })
})
ForEach(KNOWLEDGE_RADAR_CONFIG, (point: RadarPointData) => {
Text(point.label).fontSize(10).fontColor('#3F51B5').fontWeight(FontWeight.Medium)
.position({ x: getRadarLabelX(point.angle), y: getRadarLabelY(point.angle) })
})
}
.width(260).height(250).margin({ top: 6 })
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(14)
.margin({ left: 12, right: 12, top: 8 })
.alignItems(HorizontalAlign.Center)
// 复习时长分布
Column() {
Text('🕰️ 复习时长分布').fontSize(13).fontWeight(FontWeight.Bold)
.width('100%').padding({ left: 16, top: 12, bottom: 8 })
Column() {
Column() {
Row() {
Text('📖 政治').fontSize(11).fontColor('#E53935').layoutWeight(1)
Text('164h / 21%').fontSize(11).fontColor('#888888')
}
.width('100%').margin({ top: 4 })
Row() {
Column().width('21%').height(6).backgroundColor('#E53935').borderRadius(3)
Column().layoutWeight(1)
}
.width('100%').height(6).backgroundColor('#EEF0F8').borderRadius(3).margin({ top: 4 })
Column() {
Row() {
Text('🔤 英语').fontSize(11).fontColor('#3949AB').layoutWeight(1)
Text('195h / 24%').fontSize(11).fontColor('#888888')
}
.width('100%').margin({ top: 4 })
Row() {
Column().width('24%').height(6).backgroundColor('#3949AB').borderRadius(3)
Column().layoutWeight(1)
}
.width('100%').height(6).backgroundColor('#EEF0F8').borderRadius(3).margin({ top: 4 })
}
.width('100%')
Column() {
Row() {
Text('📐 数学').fontSize(11).fontColor('#00897B').layoutWeight(1)
Text('232h / 28%').fontSize(11).fontColor('#888888')
}
.width('100%').margin({ top: 4 })
Row() {
Column().width('28%').height(6).backgroundColor('#00897B').borderRadius(3)
Column().layoutWeight(1)
}
.width('100%').height(6).backgroundColor('#EEF0F8').borderRadius(3).margin({ top: 4 })
}
.width('100%')
}
.padding({ left: 16, right: 16, bottom: 12 })
}
.width('100%')
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(14)
.margin({ left: 12, right: 12, top: 8 })
}
.padding({ bottom: 20 })
}
.layoutWeight(1).scrollBar(BarState.Off)
}
.width('100%').height('100%')
}
}
// ============ 我的页面 ============
@Component
struct StudyProfileContent {
build() {
Column() {
// 渐变背景头部
Column() {
Row() {
Column() {
Text('🧑🎓').fontSize(36)
}.width(60).height(60).backgroundColor('#FFFFFF').opacity(0.9).borderRadius(30)
.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
Column() {
Text('陈晓峰').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
Text('2027届考研 · 上岸中').fontSize(11).fontColor('#FFFFFF').opacity(0.85).margin({ top: 3 })
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 14 })
Text('⚙️').fontSize(18).fontColor('#FFFFFF').opacity(0.85)
}
.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 16 })
}
.width('100%')
.linearGradient({ angle: 135, colors: [['#3F51B5', 0], ['#7986CB', 1]] })
.borderRadius({ bottomLeft: 22, bottomRight: 22 })
// 考研信息卡片
Column() {
Row() {
Text('🎯 目标院校').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#3F51B5')
Column().layoutWeight(1)
Text('985 · 双一流').fontSize(10).fontColor('#7986CB')
}
.width('100%')
Text('浙江大学 · 计算机科学与技术').fontSize(14).fontWeight(FontWeight.Bold)
.fontColor('#212121').margin({ top: 6 })
Divider().color('#F0F0F0').margin({ top: 10 })
Row() {
Column() {
Text(getExamCountdownDays().toString()).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#F44336')
Text('距初试').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('12月').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#3F51B5')
Text('考试月份').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
Column() {
Text('4门').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FF9800')
Text('考试科目').fontSize(10).fontColor('#888888')
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').margin({ top: 10 })
}
.width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(14)
.margin({ left: 12, right: 12, top: 8 })
// 学习统计
Row() {
Column() {
Text(getStudyDays().toString()).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#3F51B5')
Text('连续备考').fontSize(10).fontColor('#888888').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
.backgroundColor('#FFFFFF').borderRadius(12).padding({ top: 12, bottom: 12 })
.margin({ left: 12, right: 3, top: 8 })
Column() {
Text('42').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#4CAF50')
Text('连续打卡').fontSize(10).fontColor('#888888').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
.backgroundColor('#FFFFFF').borderRadius(12).padding({ top: 12, bottom: 12 })
.margin({ left: 3, right: 3, top: 8 })
Column() {
Text('3250').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#7986CB')
Text('已刷题量').fontSize(10).fontColor('#888888').margin({ top: 2 })
}.layoutWeight(1).alignItems(HorizontalAlign.Center)
.backgroundColor('#FFFFFF').borderRadius(12).padding({ top: 12, bottom: 12 })
.margin({ left: 3, right: 12, top: 8 })
}
.width('100%')
// 快捷设置
Column() {
Text('⚙️ 学习设置').fontSize(13).fontWeight(FontWeight.Bold)
.width('100%').padding({ left: 16, top: 12, bottom: 6 })
Column() {
Row() {
Text('🍅').fontSize(18)
Text('番茄钟时长').fontSize(13).fontColor('#333333').layoutWeight(1).margin({ left: 10 })
Text('25分钟').fontSize(11).fontColor('#3F51B5')
Text('>').fontColor('#CCCCCC').margin({ left: 6 })
}
.width('100%').padding({ top: 10, bottom: 10, left: 4 })
Divider().color('#F5F5F5')
Row() {
Text('🔔').fontSize(18)
Text('每日提醒').fontSize(13).fontColor('#333333').layoutWeight(1).margin({ left: 10 })
Text('07:00').fontSize(11).fontColor('#3F51B5')
Text('>').fontColor('#CCCCCC').margin({ left: 6 })
}
.width('100%').padding({ top: 10, bottom: 10, left: 4 })
Divider().color('#F5F5F5')
Row() {
Text('🎯').fontSize(18)
Text('目标院校').fontSize(13).fontColor('#333333').layoutWeight(1).margin({ left: 10 })
Text('浙大').fontSize(11).fontColor('#3F51B5')
Text('>').fontColor('#CCCCCC').margin({ left: 6 })
}
.width('100%').padding({ top: 10, bottom: 10, left: 4 })
Divider().color('#F5F5F5')
Row() {
Text('📊').fontSize(18)
Text('学习报告').fontSize(13).fontColor('#333333').layoutWeight(1).margin({ left: 10 })
Text('>').fontColor('#CCCCCC')
}
.width('100%').padding({ top: 10, bottom: 10, left: 4 })
Divider().color('#F5F5F5')
Row() {
Text('💡').fontSize(18)
Text('考研经验贴').fontSize(13).fontColor('#333333').layoutWeight(1).margin({ left: 10 })
Text('>').fontColor('#CCCCCC')
}
.width('100%').padding({ top: 10, bottom: 10, left: 4 })
}
.padding({ left: 16, right: 16 })
}
.width('100%').backgroundColor('#FFFFFF').borderRadius(14)
.margin({ left: 12, right: 12, top: 8 })
Column() {
Text('v1.0 · 考研上岸助手 · 2026').fontSize(10).fontColor('#CCCCCC')
.alignSelf(ItemAlign.Center).margin({ top: 16, bottom: 16 })
}
}
.width('100%').height('100%')
}
}
十六、总结:从一个学习工具看移动应用的工程化思维
通过对这份源码的逐段拆解,我们看到的不仅仅是一个考研备考助手的界面实现,更是一套完整的移动应用工程化方法论在具体场景中的落地。

在数据建模层面,应用采用了"接口描述配置结构 + 装饰器类描述业务模型"的双层设计。接口提供了类型契约,让配置表的形状有据可依;@Observed 类则让业务数据天然具备响应式能力,属性变化自动驱动视图更新。三个模型(任务、计划、科目)各自对应不同的业务粒度,既不冗余也不缺失,体现了对真实使用场景的精准建模。
在视觉系统层面,设计令牌思想贯穿始终。两套配色表(科目配色、状态配色)集中定义了所有颜色与图标,从任务标签到进度条、从柱状图到雷达图,全应用取色同源。这意味着任何视觉调整都只需要改动一处配置,维护成本极低。同时,颜色的语义化使用(绿色=完成、橙色=待办、红色=危险/紧迫)在所有页面保持一致,形成了跨页面的视觉语言体系。
在逻辑组织层面,所有不依赖组件状态的业务逻辑都被抽离成全局纯函数。时间格式化、任务过滤、统计获取、坐标计算——这些函数无副作用、可独立测试、可跨组件复用。特别是雷达图坐标计算,把极坐标转直角的数学逻辑封装得干净利落,让视图层只需调用 getRadarDotX/Y 即可拿到定位坐标,无需关心三角函数细节。这种"逻辑与视图分离"的做法,是大型应用保持可维护性的关键。
在 UI 构建层面,@Builder 被大量用于封装可复用的视图片段。底部 Tab 项、弹框遮罩、任务卡片、计划项、科目卡片——每个重复出现的 UI 单元都被抽成 Builder,既减少了代码重复,又保证了视觉一致性。三个弹框(新增、编辑、删除)采用"遮罩 + 内容卡片 + zIndex 浮层"的统一模式,交互体验一致。条件渲染配合 Stack 堆叠,实现了弹框的优雅叠加。
在可视化层面,应用展示了"用原生组件拼图表"的极致能力。柱状图用 ForEach 加 layoutWeight 等分宽度;雷达图用同心圆 + 旋转轴线 + 数学定位的数据点与标签四层堆叠;进度条直接用百分比字符串驱动宽度。没有引入任何第三方图表库,全部用 ArkUI 原生组件实现,既控制了包体积,又保证了渲染性能与定制灵活性。
在交互细节层面,应用处处体现对用户体验的打磨。番茄钟的暂停/启动合并到一个按钮、删除操作的二次确认与危险色提示、已完成任务的删除线与灰色处理、科目选择器的标签组单选、设置列表的"当前值 + 箭头"范式——这些细节单独看都不复杂,但组合起来就构成了一个"用着舒服"的产品。
当然,作为一份以界面演示为目的的源码,它也有一些可以进一步演进的方向。例如任务列表目前是手动展开 18 个 if 判断,未来可以改用 ForEach 配合数组方法实现更简洁的过滤渲染;统计函数目前返回硬编码值,后续接入真实数据源时可以替换为异步请求;数据模型目前没有持久化,可以结合 HarmonyOS 的关系型数据库或首选项实现本地存储。但这些都是"从演示到生产"的增量工作,应用的整体架构已经为这些演进预留了充分的空间。
更多推荐

所有评论(0)