深色赛博风算法竞技场的技术突围:HarmonyOS ArkUI 声明式范式下排序柱跳动与光标闪烁双特效驱动的刷题训练营全链路工程解析
一、技术前言
1.1 行业背景:算法教育平台从工具到生态的范式跃迁
在编程教育和求职准备领域,算法刷题平台已经从简单的在线评测系统演变为集题库管理、训练计划、竞赛上分、面试模拟、社区互动于一体的综合型学习生态。LeetCode、Codeforces、牛客网等平台的核心竞争力不仅在于题目质量,更在于能否通过数据可视化和游戏化机制激发用户的持续学习动力。这个赛道的用户画像极其清晰——计算机专业学生、转码求职者、竞赛选手、大厂面试候选人——他们有一个共同特征:长时间在屏幕前与算法博弈,通常在夜间或深夜进行深度刷题。这个用户画像决定了算法平台必须采用深色主题以减少视觉疲劳,同时需要通过动态特效保持用户的专注度和兴奋感。
从产品形态来看,现代算法平台已经形成了"刷题核心+训练计划+竞赛上分+面试模拟+社区互动+插件工具"的六维产品矩阵。单纯的在线做题已经无法满足用户需求——用户需要训练计划来规划学习路径,需要竞赛系统来检验实战能力,需要面试模拟来准备求职,需要社区互动来获取学习动力,需要插件工具来提升刷题效率。本文将以一款名为"算语"的算法刷题与技术面试训练营应用为例,基于 HarmonyOS ArkUI 框架,从深色主题设计、排序柱动画特效、双阶进度指示器、导师预约系统等多个维度,深入剖析其完整的技术实现方案。
1.2 ArkUI 框架深度技术分析:声明式 UI 范式的底层哲学

ArkUI 是 HarmonyOS 的官方 UI 开发框架,采用了声明式 UI 范式(Declarative UI Paradigm)。与传统的命令式 UI(Imperative UI)不同,声明式 UI 的核心思想是"描述 what 而非 how"——开发者只需要声明界面在某一状态下的样子,框架负责在状态变化时自动计算并应用最小化的 DOM diff,完成界面更新。
这种范式的核心载体是一组装饰器(Decorators):
@Entry:标记一个组件为页面入口组件。一个页面有且只有一个@Entry组件,它是整个页面组件树的根节点。@Entry装饰器会为组件生成唯一的页面实例,并管理页面的生命周期(aboutToAppear、aboutToDisappear、onPageShow、onPageHide、onBackPress)。@Component:标记一个 struct 为自定义组件。@Component装饰器会为 struct 生成组件元数据,包括属性观察器、渲染函数和更新调度器。被@Component装饰的 struct 必须实现build()方法,该方法返回组件的 UI 结构。@State:组件内部的状态变量装饰器。被@State装饰的变量会被框架代理——每次赋值操作都会触发依赖追踪和差异计算,只有当新值与旧值不同时才会触发重渲染。@State变量的观察范围限于当前组件,子组件无法直接感知父组件的@State变化。@Observed:类装饰器,标记一个类为可观察对象。被@Observed装饰的类,其实例在作为@State变量使用时,属性的变化会被框架捕获。@Observed通常配合@ObjectLink使用实现跨组件的深度观察。@Builder:构建器装饰器,用于定义可复用的 UI 片段。@Builder方法可以被多次调用,每次调用都会生成独立的 UI 节点。@Builder与普通方法的区别在于:普通方法返回值,@Builder方法返回 UI 结构。@Builder是 ArkUI 中实现组件复用的重要手段。
这些装饰器的工作原理与 React 的 Hooks 有相似之处但也有本质区别。React 的 useState 返回一个值和一个 setter 函数,setter 函数触发重渲染;ArkUI 的 @State 变量直接赋值即触发重渲染,无需调用 setter。React 的组件是函数,ArkUI 的组件是 struct——struct 是值类型,函数是引用类型,这导致了两者在内存管理和性能特征上的差异。
1.3 ArkUI 与 React、Flutter 的横向对比

从跨框架对比的角度来看,ArkUI、React 和 Flutter 代表了三种不同的技术路线:
React 采用虚拟 DOM(Virtual DOM)+ 差异算法(Reconciliation)的方案。React 通过 JSX 描述 UI,每次状态变化后重新构建虚拟 DOM 树,通过 diff 算法计算最小变更集,然后批量提交到真实 DOM。React 的优势在于生态丰富和跨平台能力强(React Native),劣势在于虚拟 DOM 的内存开销和 diff 算法的计算成本。
Flutter 采用直接编译为原生代码的方案。Flutter 的 Skia 引擎直接绘制 UI,不经过平台原生组件层。Flutter 的 Widget 是不可变的(Immutable),状态变化时创建新的 Widget 树,框架通过 diff 算法计算 Element 树的变更。Flutter 的优势在于高性能和一致性(所有平台渲染结果完全一致),劣势在于包体积大和与原生平台集成的摩擦。
ArkUI 采用了介于 React 和 Flutter 之间的方案。ArkUI 的组件树直接映射到 C++ 层的原生组件,不经过虚拟 DOM 的中间层。ArkUI 的 @State 变量通过 ES6 Proxy 或编译时插桩实现变化追踪,只追踪实际被使用的属性,避免不必要的重渲染。ArkUI 的优势在于与 HarmonyOS 深度集成和原生性能,劣势在于生态尚在建设和跨平台能力有限。
对于算法训练平台这类信息密集型应用,ArkUI 的声明式范式有天然优势——复杂的状态管理(多 Tab 切换、多弹框管理、响应式列表更新)可以通过简洁的 @State 声明完成,无需手动管理 DOM 操作或 Widget 重建。
1.4 深色主题的技术挑战

深色主题(Dark Mode)的实现看似简单——把背景色改深、文字色改浅即可——但实际上涉及一系列技术挑战:
对比度管理:深色背景上的文字对比度比浅色背景更难管理。浅色背景上,黑色文字的对比度天然就高(约 15:1);深色背景上,白色文字与深色背景的对比度虽然也高,但过亮的文字会产生"光晕效应"(Halation Effect),导致文字边缘模糊。因此深色主题的文本色通常不是纯白,而是带轻微蓝调的浅色(如本应用的 #E8EEF7),以减轻光晕效应。
层级表达:浅色主题中,层级通过阴影和明度差表达——上层元素更亮、有阴影。深色主题中,阴影不可见(深色背景上的深色阴影等于没有阴影),层级只能通过明度差表达——上层元素比背景略亮。本应用的背景色 #0E1524 和卡片色 #172036 之间的明度差约 10%,这是深色主题层级表达的经典手法。
颜色反转:浅色主题中的"Light"变体(变亮版本)在深色主题中需要反转为"Dark"变体(变暗版本),反之亦然。本应用的 primaryLight 值为 #123241(比主色 #3AB8DB 更暗),primaryDark 值为 #7FD4EA(比主色更亮)——这种"反直觉"的命名是深色主题设计的常见现象,色彩变量沿用浅色主题的命名约定,但实际色值逻辑完全反转。
状态色饱和度:深色背景上的状态色(成功/警告/危险)需要更高的饱和度才能达到与浅色背景相同的视觉冲击力。本应用的成功色 #5BD98A、警告色 #E8B45A、危险色 #E5655B 都是高饱和度的荧光色,在深色背景上如同发光的指示灯。
1.5 关键技术决策

从技术选型的角度来审视,这款算法训练平台应用采用了以下几个关键技术决策:
第一,深色赛博风色彩体系。 与常见的浅色系应用不同,这款应用采用了以深蓝黑(#0E1524)为背景的深色主题。主色选择了青蓝色(#3AB8DB),这是一种在深色背景上具有高对比度且不刺眼的冷色调。强调色选择了荧光绿(#5BD98A),在深色背景上具有类似终端代码高亮的视觉效果。整个色彩体系营造出一种"代码编辑器"的氛围,与算法编程的场景高度契合。

第二,排序柱跳动动画特效。 特效层没有使用简单的粒子飘散,而是模拟了排序算法中的柱状图——6 根不同高度的柱子在背景中随时间跳动变化,高度通过 sortBarH(tick, i) 函数根据 tick 值动态计算。这种设计不仅提供了动态视觉氛围,更在概念上与"算法排序"的应用主题形成了呼应。同时配合光标闪烁点(模拟代码编辑器中的光标),让整个界面充满了"代码竞技"的紧张感。

第三,双阶进度式 Tab 指示器。 子导航栏的选中态指示器采用了双层设计——上层是 20px 宽的主色青蓝条,下层是 20px 宽的强调色绿色条,两层高度不同(4px 和 2px),形成阶梯式的视觉层次。这种"双阶"设计比普通的单条下划线更加醒目,也更符合算法平台"步步进阶"的学习理念。

第四,导师陪跑预约系统。 应用在四套弹框之外,增加了一套独特的导师预约弹框——带有横幅式头卡的预约表单,包含辅导方向和期望时段两个输入字段,以及"首次免费"“限时陪练”"不过重学"三个服务标签。这套弹框将教育服务从纯工具型产品升级为服务型产品。
第五,难度三级颜色编码。 算法题目按难度分为简单(绿色)、中等(金色)、困难(红色)三档,通过 diffColor 函数实现统一的颜色映射。这种颜色编码让用户在浏览题库列表时能通过颜色快速判断题目难度分布,比纯文字标签更高效。
下面,我们将从代码的第一行开始,逐段深入分析这个算法训练平台的完整实现。
二、整体架构流程图
2.1 架构全景图
2.2 架构解读
从上述架构流程图可以清晰地看到,整个应用以 PageAlgoDojo 组件为核心枢纽,向下连接了状态管理、数据层、特效层、头部区域、内容区域、弹框层和底部导航七个子系统。
入口组件层 是整个应用的根节点,由 @Entry 和 @Component 装饰的 PageAlgoDojo struct 承担。它既是页面的唯一入口(@Entry 的职责),也是一个可复用的自定义组件(@Component 的职责)。入口组件内部通过 build() 方法构建了完整的 UI 树——从底层的背景色层,到特效层,再到内容层,最后到弹框层,通过 Stack 容器实现了 Z 轴方向的精确层叠控制。
状态管理层 包含了 19 个 @State 变量,分为四组:Tab 导航状态(mainTab、subTab)、特效动画状态(tick、glow)、弹框开关状态(addOpen、editOpen、delOpen、coachOpen 及其关联的表单字段)、响应式列表数据(postList、planList)。这四组状态各自独立运作,通过条件渲染和响应式更新驱动界面变化。
数据层 是整个应用的数据基座,由色彩体系(ColorPalette)、数据模型(4 个 @Observed 类)、静态数据(8 组常量数组)和工具函数(11 个纯函数)组成。数据层的设计遵循了"数据驱动 UI"的声明式理念——UI 的最终呈现完全由数据层的状态决定。
特效层 位于 UI 树的底层,通过 hitTestBehavior(HitTestMode.None) 实现触摸穿透,既提供了视觉氛围又不干扰用户交互。
内容区域 包含 7 个 @Builder 方法构建的内容页面,通过 subTab 索引进行条件渲染。这种设计实现了"一个组件、多套视图"的页面切换模式,避免了页面跳转的转场动画开销。
弹框层 包含 4 套独立的弹框组件,通过 4 个布尔型 @State 变量控制显隐,每套弹框都有独特的头卡设计风格。
2.3 特效层动画流程图
这张特效层动画流程图揭示了三套独立但共享同一时钟源的动画系统。定时器每 90 毫秒触发一次,同时更新 tick 和 glow 两个状态变量。tick 驱动排序柱的高度变化和光标的位置/透明度变化,glow 驱动发题解弹框标题发光点的呼吸闪烁。三套动画共享一个定时器而非各自独立计时,既节省了系统资源,又确保了三套动画的节奏完全同步。
2.4 弹框系统状态转换图
弹框系统状态转换图展示了从空闲状态出发,通过不同的用户操作进入五种弹框状态,再通过确认或取消操作回到空闲状态的完整生命周期。值得注意的是,删除弹框根据触发来源不同(delTarget 为 task 或 course)会执行不同的删除逻辑——放弃计划时删除 planList 首项,退课时删除 postList 首项。所有弹框都可以通过点击遮罩层关闭,遮罩层的点击事件统一将四个布尔状态变量设为 false。
2.5 数据流向图
数据流向图揭示了应用中"静态数据"与"响应式数据"的分流设计。静态数据(POST_LIST、PROBLEM_LIST 等)在应用启动时初始化,在整个生命周期中保持不变,直接被各页面消费。响应式数据(postList、planList)是从静态数据初始化的 @State 变量,可以被弹框操作函数(doAdd、doEdit、doDel、doCoach)修改,修改后自动触发依赖该数据的页面重渲染。这种设计将"只读数据"与"可变数据"分离,既保证了静态数据的稳定性,又实现了响应式数据的实时更新。
三、色彩体系设计
3.1 ColorPalette 接口定义
interface ColorPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
bit: string;
}
这个 ColorPalette 接口定义了应用使用的全部 16 个颜色字段。从接口设计的角度来看,ColorPalette 是一个纯数据接口(只有属性、没有方法),它的作用是为色彩常量提供类型约束——确保 COLORS 常量实现了所有声明的颜色字段,在编译期捕获遗漏。
字段分为五组:主色系(primary、primaryLight、primaryDark)控制品牌色调和选中态视觉;强调色系(accent、accentLight)控制次要强调和成就态视觉;背景与层次系(bg、cardBg、border)控制空间层次和分隔线;文本色阶系(textPrimary、textSecondary、textHint)控制信息层次;状态色系(success、warning、danger、white、bit)控制状态反馈和特殊装饰。
值得注意的是最后一个字段 bit。这个命名暗示了二进制位(bit)的含义,与算法编程的位运算主题相呼应。在深色主题中,这个颜色用于特效层的排序柱和辅助视觉元素,在深色背景上提供低对比度的装饰性色彩。这种"色彩字段名与应用主题概念呼应"的设计体现了产品层面的巧思。
3.2 COLORS 常量与深色主题逐色分析
const COLORS: ColorPalette = {
primary: '#3AB8DB',
primaryLight: '#123241',
primaryDark: '#7FD4EA',
accent: '#5BD98A',
accentLight: '#123324',
bg: '#0E1524',
cardBg: '#172036',
textPrimary: '#E8EEF7',
textSecondary: '#9DAEC5',
textHint: '#5E6F88',
border: '#243050',
success: '#5BD98A',
warning: '#E8B45A',
danger: '#E5655B',
white: '#FFFFFF',
bit: '#7A8BA8'
};
这套深色色彩体系的设计逻辑与浅色系截然不同,值得逐色深入分析。
主色 #3AB8DB 是一种明亮的青蓝色。在 RGB 空间中,R=58、G=184、B=219,蓝绿分量远大于红色分量,形成冷色调。在 HSL 空间中,色相约 194°(青色区域),饱和度约 72%,明度约 54%。这种颜色让人联想到终端控制台中的蓝色高亮文字,或是 IDE 编辑器中的函数名颜色。在深色背景 #0E1524 上,主色青蓝的对比度约为 7.5:1,超过了 WCAG AAA 标准的 7:1,适合作为正文级强调色使用。
primaryLight 值为 #123241——这是一个关键的设计决策点。注意,在深色主题中,primaryLight 不是主色的浅化版本,而是主色的暗化版本。RGB 值为 R=18、G=50、B=65,比主色暗得多。这个颜色用作选中态标签的背景色——在深色背景上创建一个更深的选中区域,形成"凹下"的视觉效果。这与浅色主题的逻辑完全相反:浅色主题中,选中态背景比卡片背景更亮("凸起"效果);深色主题中,选中态背景比卡片背景更暗("凹下"效果)。这种反转逻辑是深色主题设计中最容易出错的地方。
primaryDark 值为 #7FD4EA,是主色的亮化版本,RGB 值为 R=127、G=212、B=234。用于选中态文字颜色——在深色背景上,选中态需要更亮的文字来形成对比,因此 primaryDark 反而比 primary 更亮。这种"反直觉"的命名是深色主题设计中的常见现象——色彩变量的命名沿用浅色主题的约定(“Light” 变体在浅色主题中更亮),但实际色值逻辑完全反转。开发者需要理解这种命名约定与实际色值的背离,避免在阅读代码时产生误解。
强调色 #5BD98A 是一种明亮的荧光绿。RGB 值为 R=91、G=217、B=138,绿色分量远大于红色和蓝色分量。在深色背景上,这种荧光绿如同终端中编译成功后的绿色输出(如 npm run build 成功时的绿色提示),或如 VS Code 编辑器中字符串字面量的绿色高亮。强调色在应用中用于连胜卡背景、成功状态、成就标签等场景,是应用中最具情感感染力的颜色。
accentLight 值为 #123324,同样是强调色的暗化版本,RGB 值为 R=18、G=51、B=36。用作强调色标签的背景——如训练计划卡片中状态标签的深绿色背景。在深绿背景上渲染荧光绿文字,形成了类似终端中"高亮标签"的视觉效果。
背景色 #0E1524 是一种极深的蓝黑色。RGB 值为 R=14、G=21、B=36,蓝色分量略高于红色和绿色,形成"带蓝色调的黑"。比纯黑(#000000)多了一丝蓝色调,避免了纯黑背景的压抑感和"洞穴感"。这种"近黑带蓝"的背景色是深色赛博风应用的标准选择——VS Code 的 Dark+ 主题、Dracula 主题都采用类似的背景色调。
卡片背景 #172036 是背景色的微亮版本,RGB 值为 R=23、G=32、B=54。两者之间的明度差约为 10%,但足以让卡片在背景上浮起。这种微妙的明度差是深色主题设计的核心技巧——太大的明度差会让卡片显得突兀,产生"贴纸"感;太小的明度差又无法区分层次,导致内容区域混作一片。10% 是一个经验性的最佳值,既保证了层次分离,又维持了整体的沉稳基调。
文本主色 #E8EEF7 是一种极浅的蓝白色。RGB 值为 R=232、G=238、B=247,三个分量都很高,但蓝色分量略高,与背景色的蓝色调形成呼应。比纯白(#FFFFFF)更柔和,避免了纯白文字在深色背景上的"光晕效应"。与背景色的对比度约为 13.5:1,远超 WCAG AAA 标准的 7:1。
辅助文本色 #9DAEC5 是中等明度的蓝灰色,RGB 值为 R=157、G=174、B=197。用于次要文本信息,与背景的对比度约为 6.2:1,达到 WCAG AA 标准的 4.5:1。
提示文本色 #5E6F88 是较暗的蓝灰色,RGB 值为 R=94、G=111、B=136。用于最次要的提示文字、标签、单位等,与背景的对比度约为 3.5:1,达到 WCAG AA 标准的 3:1(大文本最低要求)。
三级文本色阶(主文本 13.5:1、辅助文本 6.2:1、提示文本 3.5:1)在深色背景上形成了清晰的信息层次。这种三级色阶设计是信息密集型应用的标配——不同重要程度的信息使用不同对比度的文本色,让用户在扫描时能自然过滤次要信息,聚焦核心内容。
边框色 #243050 是一种深蓝灰色,RGB 值为 R=36、G=48、B=80。用作分隔线和未选中态指示器。在深色背景上,边框色需要比背景略亮才能可见,但又不能太亮以免喧宾夺主。
状态色方面,成功色与强调色相同(#5BD98A),因为"AC(Accepted)"在算法平台中既是成功也是强调。警告色 #E8B45A 是琥珀金色,RGB 值为 R=232、G=180、B=90,让人联想到警示牌或交通灯的黄灯。危险色 #E5655B 是偏暖的珊瑚红,RGB 值为 R=229、G=101、B=91,比纯红(#FF0000)更柔和,避免了纯红在深色背景上的刺眼感。
bit 色值 #7A8BA8 是一种中度的蓝灰色,RGB 值为 R=122、G=139、B=168。用于排序柱和辅助元素,在深色背景上提供低调的视觉装饰。这个颜色与背景的对比度约为 2.8:1,低于 WCAG 3:1 的最低标准,因此只用于装饰性元素,不用于承载文字信息。
四、数据模型层
4.1 码友动态数据模型
@Observed
export class PostItem {
id: number = 0
nick: string = ''
avatar: string = ''
text: string = ''
time: string = ''
likes: number = 0
constructor(id: number, nick: string, avatar: string, text: string, time: string, likes: number) {
this.id = id; this.nick = nick; this.avatar = avatar; this.text = text
this.time = time; this.likes = likes
}
}
PostItem 描述了码友圈中的一条用户动态。@Observed 装饰器将这个类标记为可观察对象——当 PostItem 的实例作为 @State 变量使用时,其属性的变化会被框架捕获并触发依赖组件的重渲染。export 关键字使这个类可以在其他文件中被导入使用(虽然在当前单文件结构中并非必需,但保留了模块化的可能性)。
六个字段各自承担不同的 UI 职责:id 是唯一标识,用作 ForEach 的键值(key generator),确保列表项在增删时的正确 diff;nick 是用户昵称,如"双指针之神"、“DP 恐惧症患者"等,这些昵称本身就是算法社区的身份标识;avatar 是头像,这里使用 Emoji 字符而非图片 URL,既节省了网络请求,又与算法平台的"代码感"风格一致;text 是动态正文,承载刷题心得;time 是时间描述,如"3分钟前”、“昨天”;likes 是点赞数,控制社交反馈的数字显示。
构造函数采用了简洁的单行赋值风格——每个赋值语句用分号分隔,写在同一行内。这种写法虽然牺牲了一些可读性,但在数据模型类中是常见的做法,因为构造函数的逻辑非常机械(只是参数到属性的映射),不需要逐行展开。
PostItem 在应用中有两个响应式消费者:pageFeatured 中的"码友圈动态"模块(展示前 3 条)和 pageCircle 中的"码友圈"页面(展示全部)。此外,pageMine 中的"我的动态"模块也消费 postList 数据。当用户通过 doAdd() 或 doCoach() 函数向 postList 插入新动态时,这三个页面都会自动更新——这就是 @Observed + @State 组合实现的响应式数据绑定。
4.2 算法题目数据模型
@Observed
export class ProblemItem {
id: number = 0
name: string = ''
diff: string = ''
tag: string = ''
pass: number = 0
hot: number = 0
constructor(id: number, name: string, diff: string, tag: string, pass: number, hot: number) {
this.id = id; this.name = name; this.diff = diff; this.tag = tag
this.pass = pass; this.hot = hot
}
}
ProblemItem 是算法平台的核心数据模型,描述了一道算法题目。@Observed 装饰器使其属性变化可被框架观察。
六个字段的 UI 职责各不相同:id 是题目唯一标识;name 是题目标题,如"最长递增子序列 II"、“滑动窗口最大值"等,这些题目名称都是 LeetCode 风格的经典算法题;diff 是难度等级字符串,取值为"简单”、“中等"或"困难”,通过 diffColor 函数映射为绿色、金色或红色;tag 是算法标签字符串,如"DP · 树状数组"、“单调队列”,通过 problemTagColor 函数根据关键词映射为不同颜色;pass 是通过率百分比(0-100),以文字形式展示在题目卡片上;hot 是热度值(0-100),以进度条形式可视化。
这 6 个字段共同定义了一道题目在 UI 中的完整呈现——难度通过颜色编码(diffColor),标签通过颜色编码(problemTagColor),热度通过进度条可视化,通过率通过文字直接展示。这种"一字段一视觉维度"的设计让每张题目卡片都承载了丰富的信息密度。
ProblemItem 是静态数据 PROBLEM_LIST 的元素类型,不作为响应式 @State 变量使用(@State 变量是 postList 和 planList,不包含 problemList)。这意味着题目数据是只读的——用户无法通过弹框操作修改题目,题库数据在整个应用生命周期中保持不变。这种设计符合实际产品逻辑:题库由平台维护,用户只读消费。
4.3 训练计划数据模型
@Observed
export class PlanItem {
id: number = 0
name: string = ''
item: string = ''
status: string = ''
progress: number = 0
constructor(id: number, name: string, item: string, status: string, progress: number) {
this.id = id; this.name = name; this.item = item; this.status = status
this.progress = progress
}
}
PlanItem 描述了用户的训练计划。五个字段中,name 是计划名称(如"秋招冲刺计划"),item 是计划详情描述(如"进行中 · 每日 3 题 · 困难题占比 40%"),status 是当前状态(已通过/做题中/卡壳中/复盘错题中/待开始),progress 是进度百分比。
这里有一个重要的设计细节:虽然 progress 字段存在于模型中,但实际 UI 渲染时使用的是 statusProgress(status) 函数的返回值而非模型自身的 progress 字段。这意味着进度条的宽度完全由状态字符串决定,与模型中存储的 progress 值无关。这种设计确保了状态与进度的始终一致——当状态变化时,进度会自动跟随,避免了状态与进度不匹配的数据不一致问题。
这种"模型字段冗余但 UI 派生"的设计是一种有趣的取舍。从数据规范化的角度看,progress 字段是冗余的(因为它可以从 status 推导),但保留它有两个好处:一是保留了数据的原始记录(静态数据中每个计划的 progress 值是各自独立的),二是为未来可能的状态-进度解耦预留了扩展空间。
PlanItem 作为 @State 变量 planList 的元素类型,是响应式数据。当用户通过 doEdit() 修改计划或通过 doDel() 删除计划时,依赖 planList 的页面(pageCrafts、pageWorkshop 的错题本模块、pageMine 的训练营档案模块)都会自动更新。
4.4 插件数据模型
@Observed
export class GearItem {
id: number = 0
name: string = ''
desc: string = ''
icon: string = ''
constructor(id: number, name: string, desc: string, icon: string) {
this.id = id; this.name = name; this.desc = desc; this.icon = icon
}
}
GearItem 描述了算法平台的插件工具。四个字段:id 唯一标识,name 插件名称(如"复杂度分析器"、“对拍机”),desc 插件描述(如"实时估算 O() 复杂度"),icon Emoji 图标。
这些插件从分析和效率两个维度增强了刷题体验:复杂度分析器帮助用户评估算法效率,对拍机自动验证代码正确性,刷题番茄钟管理专注时间,错题收割机管理错题本,代码骨架铺提供算法模板,可视化调试台辅助理解算法过程,竞赛倒计时管理赛程,面经情报站提供面试情报。这些插件让平台从单纯的"做题工具"升级为"刷题工作站",体现了产品从工具型到平台型的升级思路。
GearItem 是静态数据 GEAR_LIST 的元素类型,不作为响应式 @State 变量使用。用户无法通过弹框操作修改插件数据,插件列表在整个应用生命周期中保持不变。
4.5 图表与课程接口
interface WeekChartItem {
label: string;
value: number;
}
interface MatChartItem {
label: string;
value: number;
color: string;
}
interface CourseItem {
title: string;
week: string;
progress: number;
color: string;
}
interface PlayTypeItem {
name: string;
desc: string;
icon: string;
hot: boolean;
}
这四个接口定义了图表和课程相关的数据结构。它们与 @Observed 类的区别在于:接口只是类型声明,没有构造函数,不能被实例化——它们的实例通过对象字面量直接创建。这种设计用于不需要方法行为、只需要承载数据的简单数据结构。
WeekChartItem 只有两个字段(label 和 value),用于柱状图数据。MatChartItem 多了一个 color 字段,用于水平进度条的颜色。CourseItem 有四个字段,包含课程标题、周次、进度和颜色。PlayTypeItem 有四个字段,包含刷法名称、描述、图标和是否热门的布尔值。
这四个接口都不需要 @Observed 装饰,因为它们对应的数据(WEEK_CHART、MAT_CHART、COURSE_LIST、TYPE_LIST)都是静态常量,不作为响应式 @State 变量使用,不需要被框架观察属性变化。
五、静态数据与模拟数据
5.1 码友动态列表
const POST_LIST: PostItem[] = [
new PostItem(1, '双指针之神', '🥷', '滑动窗口一口气过了 5 道困难,手都热了。', '3分钟前', 97),
new PostItem(2, 'DP 恐惧症患者', '🧠', '区间 DP 终于开窍,原来是枚举分割点。', '15分钟前', 90),
new PostItem(3, '大厂收割机', '💼', '五面算法全 A,靠的就是每天三题不中断。', '26分钟前', 85),
new PostItem(4, '竞赛蓝名选手', '🏅', '上分 84,图论构造题赌对了方向。', '1小时前', 78),
new PostItem(5, '错题本狂魔', '📒', '复盘第 200 道错题,同类错误再没犯过。', '2小时前', 72),
new PostItem(6, '手撕链表侠', '⛓️', '现场手写反转链表,面试官点了头。', '3小时前', 66),
new PostItem(7, '二分练气期', '🎯', '浮点二分精度坑,调试了两小时才过。', '5小时前', 58),
new PostItem(8, '零基础转码', '🌱', '第 60 天打卡,从循环都不会到过了 120 题。', '昨天', 51)
];
POST_LIST 包含 8 条用户动态,每条的昵称都与算法领域相关。这些昵称不仅是用户标识,更是算法社区的"身份勋章"——"双指针之神"暗示擅长双指针技巧,"DP 恐惧症患者"自嘲动态规划薄弱但正在突破,"大厂收割机"暗示已获多个大厂 offer,"竞赛蓝名选手"表示 Codeforces 蓝名等级,"错题本狂魔"以复盘错题为荣,"手撕链表侠"擅长现场手写代码,"二分练气期"用修仙小说的等级体系自比,"零基础转码"代表转行学习者的群体。
动态正文的内容也都是算法学习的真实场景——"滑动窗口一口气过了 5 道困难"展示了连续 AC 的兴奋,"区间 DP 终于开窍"表达了突破瓶颈的喜悦,"五面算法全 A"是面试成功的捷报,"上分 84"是竞赛上分的战绩,"复盘第 200 道错题"体现了错题复盘的坚持,"现场手写反转链表"还原了面试手撕场景,"浮点二分精度坑"记录了调试的艰辛,"第 60 天打卡"展示了从零基础到通过 120 题的成长曲线。
点赞数从 97 递减到 51,呈递减分布——这模拟了社交媒体中"新动态获赞多、旧动态获赞少"的时间衰减规律。时间标注从"3分钟前"到"昨天",覆盖了从分钟到小时到天的时间尺度。
5.2 算法题目列表
const PROBLEM_LIST: ProblemItem[] = [
new ProblemItem(1, '最长递增子序列 II', '困难', 'DP · 树状数组', 38, 95),
new ProblemItem(2, '滑动窗口最大值', '中等', '单调队列', 52, 92),
new ProblemItem(3, '二叉树的直径', '简单', '树 · DFS', 68, 88),
new ProblemItem(4, '最小覆盖子串', '困难', '滑动窗口', 35, 86),
new ProblemItem(5, '岛屿数量进阶版', '中等', '并查集', 47, 82),
new ProblemItem(6, '编辑距离', '中等', '二维 DP', 44, 79),
new ProblemItem(7, '前缀树实现', '中等', '字典树', 56, 74),
new ProblemItem(8, '两数之和', '简单', '哈希表', 88, 70)
];
PROBLEM_LIST 包含 8 道经典算法题,覆盖了动态规划(最长递增子序列、编辑距离)、滑动窗口(滑动窗口最大值、最小覆盖子串)、树遍历(二叉树的直径)、并查集(岛屿数量)、字典树(前缀树)、哈希表(两数之和)等多种算法类型。
难度分布为:困难 2 道、中等 4 道、简单 2 道,呈"中间多、两头少"的分布——这与真实题库的难度分布一致,中等难度题目占比最高。通过率从 35% 到 88%,简单题通过率高(两数之和 88%)、困难题通过率低(最小覆盖子串 35%),符合难度与通过率反相关的直觉。热度从 70 到 95,呈递减排列——排在前面的题目热度更高,暗示这些是本周最热门的挑战题目。
这些题目都是 LeetCode 上的真实题目名称(或变体),让算法背景的用户一眼就能识别,增强了应用的真实感和专业度。
5.3 训练计划列表
const PLAN_LIST: PlanItem[] = [
new PlanItem(1, '秋招冲刺计划', '进行中 · 每日 3 题 · 困难题占比 40%', '做题中', 46),
new PlanItem(2, '图论专题周', '进行中 · 最短路 6/12 · 连通分量 2/5', '卡壳中', 33),
new PlanItem(3, '字符串专题', '进行中 · KMP 已过 · 后缀数组学习中', '复盘错题中', 61),
new PlanItem(4, '面试模拟轮', '已过 4 场 · 系统设计还剩 2 场', '已通过', 100),
new PlanItem(5, '竞赛上分季', 'Rating +84 · 目标蓝名还差 96 分', '做题中', 55),
new PlanItem(6, '零基础百日筑基', '第 60 天 · 基础语法全部完成', '做题中', 60),
new PlanItem(7, 'DP 地狱特训', '待开启 · 区间 DP 20 题清单', '待开始', 4),
new PlanItem(8, '错题清零行动', '积压 37 题 · 已清 21 题', '复盘错题中', 57)
];
PLAN_LIST 包含 8 个训练计划,状态覆盖了"做题中"(3 个)、“卡壳中”(1 个)、“复盘错题中”(2 个)、“已通过”(1 个)、“待开始”(1 个)等完整的学习生命周期。这种状态分布模拟了一个真实用户的多计划并行场景——有的计划在正常推进,有的遇到了瓶颈,有的在复盘阶段,有的已经完成,有的还未开始。
其中"卡壳中"是一个很有算法特色的状态——它表示用户在某个专题遇到了瓶颈,需要额外帮助才能突破。“图论专题周"计划就处于卡壳状态,详情是"最短路 6/12 · 连通分量 2/5”——已完成最短路 6 题但还差 6 题,连通分量只完成了 2/5,暗示用户在连通分量这一知识点上遇到了困难。
计划名称的设计也很有层次——"秋招冲刺计划"面向求职季,"图论专题周"面向知识点突破,"面试模拟轮"面向面试准备,"竞赛上分季"面向竞赛选手,"零基础百日筑基"面向初学者,"DP 地狱特训"用夸张的命名表达难度,"错题清零行动"面向错题管理。这些计划名称覆盖了不同水平、不同目标、不同阶段用户的需求。
5.4 插件列表
const GEAR_LIST: GearItem[] = [
new GearItem(1, '复杂度分析器', '实时估算 O() 复杂度', '📈'),
new GearItem(2, '对拍机', '随机数据 · 自动验题', '⚔️'),
new GearItem(3, '刷题番茄钟', '45 分钟深度专注', '⏱️'),
new GearItem(4, '错题收割机', '失败用例一键收藏', '📒'),
new GearItem(5, '代码骨架铺', '常用算法模板速插', '🦴'),
new GearItem(6, '可视化调试台', '单步看 DP 表填充', '🔬'),
new GearItem(7, '竞赛倒计时', 'Div.2 赛程提醒', '⏳'),
new GearItem(8, '面经情报站', '高频题 · 公司标签', '🕵️')
];
GEAR_LIST 包含 8 个插件工具,分为三大类别:分析类(复杂度分析器、对拍机、可视化调试台)、效率类(刷题番茄钟、错题收割机、代码骨架铺)、情报类(竞赛倒计时、面经情报站)。这三大类别覆盖了算法学习的全流程——分析类帮助理解算法效率,效率类提升刷题效率,情报类提供竞赛和面试的外部信息。
插件描述使用了简洁的短语风格——“实时估算 O() 复杂度”、“随机数据 · 自动验题”、"45 分钟深度专注"等,每条描述不超过 10 个字,符合移动端 UI 对文本长度的限制。Emoji 图标的选择也很有讲究——📈 代表分析趋势,⚔️ 代表对战的竞技感,⏱️ 代表时间管理,📒 代表记录收藏,🦴 代表代码骨架的"骨头"意象,🔬 代表微观调试,⏳ 代表倒计时,🕵️ 代表情报侦探。
5.5 图表数据
const WEEK_CHART: WeekChartItem[] = [
{ label: '周一', value: 2 },
{ label: '周二', value: 5 },
{ label: '周三', value: 4 },
{ label: '周四', value: 7 },
{ label: '周五', value: 9 },
{ label: '周六', value: 12 },
{ label: '周日', value: 8 }
];
const MAT_CHART: MatChartItem[] = [
{ label: '动态规划', value: 31, color: '#3AB8DB' },
{ label: '数组双指针', value: 24, color: '#5BD98A' },
{ label: '图论搜索', value: 18, color: '#7A8BA8' },
{ label: '字符串', value: 15, color: '#E8B45A' },
{ label: '数据结构', value: 12, color: '#E5655B' }
];
const TYPE_LIST: PlayTypeItem[] = [
{ name: '每日一题', desc: '打卡 · 连胜奖励', icon: '📅', hot: true },
{ name: '专题突破', desc: 'DP · 图论 · 字符串', icon: '🎯', hot: true },
{ name: '面试模拟', desc: '计时手撕 · 点评', icon: '💼', hot: false },
{ name: '竞赛上分', desc: 'Div.2 · 周赛', icon: '🏅', hot: false }
];
const COURSE_LIST: CourseItem[] = [
{ title: '复杂度与基础数据结构', week: '第 1 周', progress: 100, color: '#3AB8DB' },
{ title: '双指针与滑动窗口', week: '第 2 周', progress: 92, color: '#5BD98A' },
{ title: '动态规划入门到精通', week: '第 3 周', progress: 64, color: '#7A8BA8' },
{ title: '图论与搜索剪枝', week: '第 4 周', progress: 30, color: '#E8B45A' },
{ title: '高频面试题串讲', week: '第 5 周', progress: 7, color: '#E5655B' }
];
WEEK_CHART 展示了一周的刷题数量——周一到周五从 2 题递增到 9 题,周末达到峰值(周六 12 题),周日略有回落(8 题)。这个分布模拟了上班族/学生的刷题节奏——工作日忙但逐渐加量,周末时间充裕刷题最多。底部展示"周末刷题占比 56%"(周六+周日=20题,总计47题,占比约 42.5%,但文案写 56% 是模拟数据,不需要精确)。
MAT_CHART 展示了已过题目的类型分布——动态规划占比最高(31%),其次是数组双指针(24%),图论搜索(18%),字符串(15%),数据结构(12%)。每个类型使用了不同的颜色,从主色青蓝到强调色绿到 bit 蓝灰到警告色金到危险色红,形成了一条完整的色相光谱。这种"每个类别一个颜色"的设计在深色背景上形成了彩虹般的视觉效果。
TYPE_LIST 展示了四种刷法模式——每日一题(打卡连胜)和专题突破(DP、图论、字符串)标记为"热门"(红色标签),面试模拟(计时手撕、点评)和竞赛上分(Div.2、周赛)不标记热门。这种"热门"标记引导新用户从每日一题和专题突破开始,降低入门门槛。
COURSE_LIST 展示了五周课程计划——从第 1 周的基础数据结构到第 5 周的高频面试题串讲,进度从 100% 递减到 7%,模拟了一个正在进行中的课程序列。每周使用不同的颜色,与题型分布的颜色方案保持一致,形成了统一的色彩语言。
5.6 导航配置
interface NavItem {
icon: string;
label: string;
}
const NAV_LIST: NavItem[] = [
{ icon: '🧠', label: '首页' },
{ icon: '📖', label: '题库' },
{ icon: '📚', label: '课堂' },
{ icon: '👤', label: '我的' }
];
const SUB_NAV_LIST: string[] = ['精选', '题库', '我的训练营', '刷题工坊', '算法课堂', '码友圈', '插件库'];
NAV_LIST 定义了底部导航的四个 Tab——首页(🧠 大脑图标,暗示算法训练)、题库(📖 书本图标)、课堂(📚 书堆图标)、我的(👤 人像图标)。四个 Tab 的命名简洁明了,覆盖了应用的核心功能区域。
SUB_NAV_LIST 定义了首页内的七个内容 Tab——精选、题库、我的训练营、刷题工坊、算法课堂、码友圈、插件库。这七个 Tab 的排列顺序有讲究:精选(综合首页)排在第一,题库(核心功能)排第二,我的训练营(个性化)排第三,刷题工坊(效率工具)排第四,算法课堂(学习内容)排第五,码友圈(社交互动)排第六,插件库(扩展功能)排第七。从核心到辅助、从内容到社交到工具的排列逻辑清晰。
六、工具函数体系
6.1 柱状图高度计算函数
function barH(v: number, max: number): number {
return Math.round(118 * v / max);
}
barH 函数计算柱状图中单根柱子的高度(像素)。参数 v 是当前数据值,max 是数据集最大值。返回值是 118 * v / max 的四舍五入值——当 v 等于 max 时返回 118(满高),当 v 为 0 时返回 0。118 是柱状图区域的最大像素高度,这个值经过精心计算,确保柱状图在 140 像素高的容器内(包含柱子、数值文字和标签文字)不会溢出。
Math.round 的使用确保了返回值是整数——ArkUI 的高度属性虽然接受小数,但整数高度能避免亚像素渲染带来的模糊。这种对渲染精度的关注是高质量 UI 实现的标志。
在应用中,barH 被 pageFeatured 的"本周每日刷题数量"柱状图调用,参数为 (it.value, 12)——12 是 WEEK_CHART 中的最大值(周六 12 题)。每根柱子的高度为 barH(it.value, 12),从周一的 barH(2, 12) = 20 像素到周六的 barH(12, 12) = 118 像素,形成了视觉上的"刷题量递增"趋势。
6.2 柱状图颜色计算函数
function ccColor(v: number): string {
if (v > 7) {
return '#3AB8DB';
} else if (v > 4) {
return '#5BD98A';
}
return '#E8B45A';
}
ccColor 函数根据柱状图的数值返回对应颜色——大于 7 返回主色青蓝(#3AB8DB),4 到 7 返回强调色荧光绿(#5BD98A),4 以下返回警告色琥珀金(#E8B45A)。
这种三段式颜色映射在深色背景上的视觉效果比浅色背景更加鲜明——青蓝和荧光绿在深色背景上都有"发光"般的亮度感,而琥珀金则像是"中等亮度"的指示。三段颜色从冷(青蓝)到暖(琥珀金)的渐变也暗示了刷题量从"优秀"到"一般"的评估——青蓝是"优秀"(刷题量高),绿色是"良好"(刷题量中等),金色是"需努力"(刷题量低)。
注意阈值的选择——7 和 4 是针对 0-12 范围的值设计的。如果数据范围变化,这两个阈值也需要相应调整。在当前数据集中,周一(2题)和周二(5题)使用金色,周三(4题)使用金色,周四(7题)使用绿色,周五(9题)、周六(12题)、周日(8题)使用青蓝。
6.3 排序柱高度计算函数
function sortBarH(tick: number, i: number): number {
return 10 + ((tick * 3 + i * 29) % 54);
}
sortBarH 是排序柱特效的核心计算函数。参数 tick 是定时器驱动的递增计数器(每 90ms 加 1),i 是柱子序号(0 到 5)。返回值是 10 + ((tick * 3 + i * 29) % 54)——基础高度 10 像素,加上一个 0 到 53 之间的值(通过取模 54 实现),最终高度范围是 10 到 63 像素。
这个函数的设计精妙之处在于三个方面:
第一,tick * 3 让高度随时间变化。 每 90ms,tick 加 1,tick * 3 加 3,取模 54 后的值变化 3(大多数情况下),所以柱子高度每 90ms 变化 3 像素。这种变化速度肉眼可见但不过快,形成了"缓慢跳动"的视觉效果。
第二,i * 29 让不同柱子在同一时刻有不同的高度值。 29 是一个质数,与 54 的公约数只有 1,确保了 6 根柱子(i 从 0 到 5)的 i * 29 % 54 值充分分散——分别是 0、29、4、33、8、37。配合 tick * 3 后,6 根柱子在同一时刻的高度各不相同,形成了类似排序算法中"柱子高度不一"的视觉效果。
第三,取模 54 确保了高度有界。 无论 tick 增长到多大,取模运算确保返回值始终在 0-53 范围内,加上基础高度 10 后始终在 10-63 范围内。这种"有界"设计避免了柱子高度无限增长导致超出屏幕的问题。
sortBarH 模拟了排序算法中柱子高度不断交换变化的视觉效果——随着 tick 的增加,每根柱子的高度都在不断变化,就像快速排序或冒泡排序过程中柱子的交换动画。这种"特效与应用主题概念呼应"的设计是高级 UI 的体现。
6.4 排序柱位置计算函数
function sortBarX(i: number): number {
return 70 + i * 92;
}
function sortBarY(i: number): number {
return 620 - (10 + ((i * 29) % 54));
}
sortBarX 计算柱子的横向位置——从 70 像素开始,每根柱子间隔 92 像素。6 根柱子的 X 坐标分别是 70、162、254、346、438、530。92 像素的间距确保了柱子之间有足够的空隙(柱宽 18 像素,间距 74 像素),不会挤在一起。
sortBarY 计算柱子的纵向基准位置——620 是一个基准线,减去柱子高度(10 + (i * 29) % 54)得到柱子顶部的 Y 坐标。注意 sortBarY 使用的是 i * 29 而非 tick * 3 + i * 29,这意味着柱子的 Y 轴基准位置是固定的(不随时间变化),只有高度在变化。
在实际渲染时,柱子的 translate Y 值是 sortBarY(i) - sortBarH(tick, i)——基准位置减去当前高度。这个计算确保了柱子从基准线(620 像素处)向上生长:高度越大,柱子顶部越靠上(Y 值越小),柱子从底部向上延伸。
620 像素这个基准线的选择考虑了屏幕高度和底部导航栏的位置——底部导航栏高度 56 像素,主内容区域从顶部到导航栏上方。620 像素大致位于屏幕中下部,确保排序柱的底部不会与底部导航栏重叠。
6.5 光标位置计算函数
function cursorX(tick: number, i: number): number {
return 40 + ((tick * 8 + i * 181) % 600);
}
function cursorY(tick: number, i: number): number {
return 80 + ((tick * 5 + i * 149) % 560);
}
cursorX 和 cursorY 计算光标闪烁点的动态位置。与排序柱不同,光标的位置是随时间变化的——tick * 8 和 tick * 5 的不同系数让光标在横向和纵向以不同速度移动。
横向移动:cursorX 返回 40 + ((tick * 8 + i * 181) % 600)。tick * 8 意味着每 90ms 光标横向移动 8 像素(取模前),取模 600 确保横向位置在 40-640 范围内。i * 181 的大质数系数确保不同光标的横向位置充分散开——181 是质数,与 600 的公约数只有 1,6 个光标的 i * 181 % 600 值分别是 0、181、362、543、124、305,分布均匀。
纵向移动:cursorY 返回 80 + ((tick * 5 + i * 149) % 560)。tick * 5 意味着每 90ms 光标纵向移动 5 像素,取模 560 确保纵向位置在 80-640 范围内。i * 149 同样是大质数系数,确保纵向位置分散。
由于横向移动速度(8 像素/帧)大于纵向移动速度(5 像素/帧),光标的移动轨迹是斜向的——偏向横向。这种"横向快、纵向慢"的速度差让光标看起来像是在"扫描"屏幕,类似代码编辑器中光标在代码行间移动的效果。
6.6 光标透明度计算函数
function cursorA(tick: number, i: number): number {
if ((tick + i * 2) % 4 < 2) {
return 0.8;
}
return 0.15;
}
cursorA 函数实现光标的闪烁效果——每 4 个 tick 的前 2 个 tick 透明度为 0.8(高亮),后 2 个 tick 为 0.15(暗淡)。这种"亮 2 暗 2"的节奏模拟了代码编辑器中光标的闪烁频率。
i * 2 的偏移让不同光标的闪烁相位不同——光标 0 的闪烁周期从 tick=0 开始,光标 1 从 tick=2 开始(相位偏移 2),光标 2 从 tick=4=0 开始(相位偏移 0,与光标 0 同步),光标 3 从 tick=6=2 开始(与光标 1 同步)。这种"两两同步、整体异步"的闪烁模式比全部同步闪烁更有层次感。
由于定时器间隔是 90ms,一个完整的闪烁周期是 4 * 90 = 360ms,闪烁频率约为 2.8 次/秒。真实代码编辑器的光标闪烁频率通常是 1-2 次/秒(如 VS Code 默认约 1.6 次/秒),所以这个特效比真实光标更快,更像是"代码雨"或"数据流"的效果。
0.8 和 0.15 的透明度选择也很有讲究——0.8 是"明亮但不完全可见"(不完全遮挡背景),0.15 是"暗淡但隐约可见"(不完全消失)。这种"不完全亮、不完全暗"的透明度范围让光标在闪烁时始终保持了"存在感",避免了完全消失后的突兀重现。
6.7 状态颜色映射函数
function statusColor(s: string): string {
if (s === '已通过') {
return '#5BD98A';
} else if (s === '做题中') {
return '#3AB8DB';
} else if (s === '卡壳中') {
return '#E8B45A';
} else if (s === '复盘错题中') {
return '#E5655B';
}
return '#7A8BA8';
}
statusColor 将训练计划的状态字符串映射为颜色。"已通过"对应荧光绿(#5BD98A,强调色),"做题中"对应青蓝(#3AB8DB,主色),"卡壳中"对应琥珀金(#E8B45A,警告色),"复盘错题中"对应珊瑚红(#E5655B,危险色),默认(“待开始”)对应蓝灰(#7A8BA8,bit 色)。
这种颜色映射的设计逻辑是:绿色表示"完成"(安全状态),青蓝表示"进行中"(正常状态),金色表示"遇到困难"(警示状态),红色表示"深度复盘"(注意状态),蓝灰表示"未开始"(冷淡状态)。
值得注意的是"复盘错题中"使用红色——复盘错题意味着用户在做错的基础上进行深入分析,这是一个"从错误中学习"的过程。用红色表示既是对错误的提醒,也是对复盘重要性的强调。这种颜色语义的设计体现了算法学习中对"错题"的重视——在算法领域,错题复盘比盲目刷题更重要。
6.8 状态进度映射函数
function statusProgress(s: string): number {
if (s === '已通过') {
return 100;
} else if (s === '复盘错题中') {
return 58;
} else if (s === '做题中') {
return 42;
} else if (s === '卡壳中') {
return 26;
}
return 4;
}
statusProgress 将状态字符串映射为进度百分比。"已通过"100%,"复盘错题中"58%,"做题中"42%,"卡壳中"26%,"待开始"4%。
注意"复盘错题中"的进度(58%)高于"做题中"(42%),这看似不合理——但如果从学习深度的角度理解就通了:复盘错题意味着已经做题并进入分析阶段,学习深度高于刚开始的"做题中"状态。这种映射逻辑体现了算法学习的认知层次——不是简单的时间进度,而是学习深度的进度。
“卡壳中"的进度(26%)高于"待开始”(4%)但低于"做题中"(42%),因为卡壳意味着已经开始了但遇到了困难,进度介于"未开始"和"正常推进"之间。
这个函数与 statusColor 函数配合使用,确保了状态标签颜色、进度条颜色和进度百分数三者的一致性。在 pageCrafts 中,进度条的宽度为 statusProgress(it.status)%,颜色为 statusColor(it.status)——两个函数对同一状态的返回值在视觉上协调一致。
6.9 题目标签颜色函数
function problemTagColor(g: string): string {
if (g.indexOf('DP') >= 0 || g.indexOf('距离') >= 0) {
return '#3AB8DB';
} else if (g.indexOf('窗口') >= 0 || g.indexOf('二叉') >= 0) {
return '#5BD98A';
} else if (g.indexOf('树') >= 0 || g.indexOf('哈希') >= 0) {
return '#E8B45A';
}
return '#7A8BA8';
}
problemTagColor 根据题目标签中的关键词返回对应颜色。DP 类(包含"DP"或"距离")用青蓝,窗口/二叉类(包含"窗口"或"二叉")用荧光绿,树/哈希类(包含"树"或"哈希")用琥珀金,其他用蓝灰。
indexOf 的使用实现了模糊匹配——只要标签字符串中包含关键词就返回对应颜色。这种设计允许"DP · 树状数组"这种复合标签匹配到"DP"关键词,返回青蓝色。但"树状数组"也包含"树"关键词——由于 indexOf('DP') 先于 indexOf('树') 检查,最终返回青蓝色。这种"先匹配先返回"的优先级设计确保了更具体的关键词(DP)优先于更泛的关键词(树)。
颜色映射的逻辑是:DP 类用主色青蓝(最醒目),窗口/二叉类用强调色绿(次醒目),树/哈希类用警告色金(再次),其他用 bit 色蓝灰(最低调)。这种颜色优先级与算法类型的重要性无直接关系,而是为了在视觉上形成颜色多样性——如果所有题目都用同一颜色,题库列表会显得单调。
6.10 难度颜色函数
function diffColor(s: string): string {
if (s === '简单') {
return '#5BD98A';
} else if (s === '中等') {
return '#E8B45A';
} else if (s === '困难') {
return '#E5655B';
}
return '#3AB8DB';
}
diffColor 是难度颜色映射——简单用绿色(#5BD98A,安全/成功色),中等用金色(#E8B45A,警示色),困难用红色(#E5655B,危险色),默认用青蓝(#3AB8DB,主色)。
这种"红绿黄"三色编码是算法平台的通用视觉语言——LeetCode、牛客等平台都采用类似的颜色方案。绿色表示"轻松"(可以快速通过),金色表示"需要思考"(可能需要多次尝试),红色表示"困难"(可能需要深入分析或参考题解)。这种颜色编码让用户在浏览题库列表时能通过颜色快速判断题目难度分布,比纯文字标签更高效。
在深色背景上,这三种颜色如同交通信号灯——绿灯行(简单题可以挑战),黄灯注意(中等题需要思考),红灯停(困难题可能需要准备)。这种"交通灯"隐喻在深色背景上比浅色背景更加鲜明,因为深色背景如同夜间的交通灯背景,颜色对比更加突出。
七、组件主体结构
7.1 状态变量声明
@Entry
@Component
struct PageAlgoDojo {
@State mainTab: number = 0
@State subTab: number = 0
@State tick: number = 0
@State glow: number = 0
@State addOpen: boolean = false
@State editOpen: boolean = false
@State delOpen: boolean = false
@State coachOpen: boolean = false
@State editIdx: number = 0
@State delTarget: string = 'task'
@State editName: string = ''
@State editStyle: string = ''
@State editNote: string = ''
@State addTitle: string = ''
@State addContent: string = ''
@State coachField: string = ''
@State coachTime: string = ''
@State postList: PostItem[] = POST_LIST
@State planList: PlanItem[] = PLAN_LIST
@State fxTimer: number = -1
状态变量声明区包含 19 个 @State 变量,分为五组:
Tab 导航组(2 个):mainTab 控制底部导航的选中 Tab(0-首页、1-题库、2-课堂、3-我的),subTab 控制首页内的内容 Tab(0-6 对应七个内容页面)。两个变量协同工作——只有当 mainTab 为 0(首页)时,subTab 才有作用;当 mainTab 切换到其他 Tab 时,subTab 的值保留但不再驱动渲染。
特效动画组(2 个):tick 是定时器驱动的递增计数器,每 90ms 加 1,驱动排序柱高度变化和光标位置/透明度变化。glow 是 0-1 交替的布尔型计数器,驱动发题解弹框标题发光点的呼吸闪烁。
弹框开关组(4 个布尔 + 7 个表单字段):addOpen、editOpen、delOpen、coachOpen 分别控制四套弹框的显隐。editIdx 存储当前编辑的计划索引,delTarget 存储删除操作的目标类型(‘task’ 或 ‘course’)。editName、editStyle、editNote 是编辑弹框的三个输入字段,addTitle、addContent 是发题解弹框的两个输入字段,coachField、coachTime 是导师预约弹框的两个输入字段。
响应式列表组(2 个):postList 和 planList 分别从 POST_LIST 和 PLAN_LIST 初始化,是可被弹框操作函数修改的响应式数组。当用户通过弹框新增/编辑/删除数据时,这两个数组的修改会自动触发依赖它们的页面重渲染。
定时器管理组(1 个):fxTimer 存储定时器 ID,初始值为 -1(表示无定时器),在 aboutToAppear 中被赋值为 setInterval 的返回值,在 aboutToDisappear 中被 clearInterval 清除。
7.2 生命周期函数
aboutToAppear(): void {
this.fxTimer = setInterval(() => {
this.tick = this.tick + 1;
this.glow = (this.glow + 1) % 2;
}, 90);
}
aboutToDisappear(): void {
if (this.fxTimer > 0) {
clearInterval(this.fxTimer);
this.fxTimer = -1;
}
}
aboutToAppear 是 ArkUI 组件的生命周期回调,在组件实例创建后、build 方法执行前被调用。在这个函数中,通过 setInterval 创建了一个 90 毫秒间隔的定时器,定时器回调中做两件事:this.tick = this.tick + 1(递增 tick 计数器,驱动排序柱和光标动画),this.glow = (this.glow + 1) % 2(在 0 和 1 之间交替,驱动发光点呼吸效果)。
定时器 ID 被存储在 this.fxTimer 中,用于后续清理。setInterval 返回一个 number 类型的 ID,通过这个 ID 可以在需要时停止定时器。
aboutToDisappear 是组件销毁前的生命周期回调。在这个函数中,检查 this.fxTimer 是否大于 0(表示有活跃的定时器),如果有则调用 clearInterval 清除定时器,并将 fxTimer 重置为 -1。
这种"启动-清理"的成对模式是 ArkUI 中资源管理的最佳实践——任何在 aboutToAppear 中获取的资源(定时器、事件监听器、网络请求等)都应该在 aboutToDisappear 中释放。如果不清理定时器,即使组件已销毁,定时器回调仍会继续执行,尝试访问已销毁组件的 this 引用,导致内存泄漏或运行时错误。
90 毫秒的间隔选择是性能与流畅度的平衡——更短的间隔(如 16ms)可以实现 60fps 的丝滑动画,但会增加 CPU 占用和电量消耗;更长的间隔(如 200ms)可以节省资源,但动画会显得卡顿。90ms 约等于 11fps,对于背景特效(不需要丝滑流畅)来说足够了,同时 CPU 占用相对较低。
7.3 弹框打开函数
openAdd(): void {
this.addTitle = '';
this.addContent = '';
this.addOpen = true;
}
openEdit(index: number): void {
if (index >= 0 && index < this.planList.length) {
this.editIdx = index;
this.editName = this.planList[index].name;
this.editStyle = this.planList[index].status;
this.editNote = this.planList[index].item;
}
this.editOpen = true;
}
openDelA(): void {
this.delTarget = 'task';
this.delOpen = true;
}
openDelB(): void {
this.delTarget = 'course';
this.delOpen = true;
}
openCoach(): void {
this.coachField = '';
this.coachTime = '';
this.coachOpen = true;
}
五个 open* 函数分别打开五套弹框(删除弹框有两套入口 A 和 B,但共用同一个弹框组件)。
openAdd 在打开发题解弹框前清空 addTitle 和 addContent 两个输入字段——确保每次打开弹框时表单是空的,不会残留上次的输入。
openEdit 接收计划索引 index,先做边界检查(index >= 0 && index < this.planList.length),然后将对应计划的数据(name、status、item)填充到编辑表单字段中——这样编辑弹框打开时就预填了当前计划的数据,用户可以在现有数据基础上修改。这种"预填充"设计比"打开空表单重新输入"更友好。
openDelA 和 openDelB 通过设置 delTarget 的值(‘task’ 或 ‘course’)来区分删除操作的来源——放弃计划还是退课。同一个删除弹框根据 delTarget 的值显示不同的标题和文案。
openCoach 在打开导师预约弹框前清空 coachField 和 coachTime 两个输入字段。
7.4 弹框确认函数
doAdd(): void {
if (this.addTitle.length > 0) {
this.postList.unshift(new PostItem(999, '我的动态', '🧠', this.addTitle, '刚刚', 0));
}
this.addOpen = false;
}
doEdit(): void {
if (this.editIdx >= 0 && this.editIdx < this.planList.length) {
this.planList.splice(this.editIdx, 1, new PlanItem(this.planList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
}
this.editOpen = false;
}
doDel(): void {
if (this.delTarget === 'task' && this.planList.length > 0) {
this.planList.splice(0, 1);
} else if (this.delTarget === 'course' && this.postList.length > 0) {
this.postList.splice(0, 1);
}
this.delOpen = false;
}
doCoach(): void {
if (this.coachField.length > 0) {
this.postList.unshift(new PostItem(998, '我的动态', '🥋', `已预约导师陪跑:${this.coachField}`, '刚刚', 0));
}
this.coachOpen = false;
}
四个 do* 函数分别处理四套弹框的确认操作。
doAdd 检查 addTitle 非空后,创建一个新的 PostItem(id=999,昵称"我的动态",头像 🧠,正文为用户输入的标题,时间"刚刚",点赞 0),通过 unshift 插入到 postList 头部。unshift 而非 push 确保新动态显示在列表最前面。插入后关闭弹框。注意只检查了 addTitle 而非 addContent——标题是必填的,内容是选填的。
doEdit 检查 editIdx 有效后,通过 splice 替换 planList 中指定索引的计划。splice(editIdx, 1, newItem) 的语义是"从 editIdx 开始删除 1 个元素,然后插入 newItem"——即用新计划替换旧计划。新计划的 progress 固定为 40,不使用用户输入的值——因为进度由 statusProgress(status) 函数决定,模型中的 progress 字段在 UI 中不被使用。
doDel 根据 delTarget 的值执行不同的删除逻辑——‘task’ 时删除 planList 的首项(放弃计划),‘course’ 时删除 postList 的首项(退课,虽然语义是退课但代码删除的是 postList 首项,这是一个模拟逻辑)。删除后关闭弹框。
doCoach 检查 coachField 非空后,创建新动态(id=998,头像 🥋 武道馆,正文为"已预约导师陪跑:“+ 辅导方向),插入到 postList 头部。这种设计将预约行为社交化——用户的预约动作在码友圈中可见,既是一种行为记录,也是一种隐性的推广机制,让其他用户看到"有人预约了导师陪跑”。
八、特效层详解
8.1 特效层完整代码
@Builder
fxLayer() {
Stack({ alignContent: Alignment.TopStart }) {
ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
Column()
.width(18)
.height(sortBarH(this.tick, i))
.borderRadius(4)
.backgroundColor(i % 3 === 0 ? COLORS.primary : (i % 3 === 1 ? COLORS.accent : COLORS.bit))
.opacity(0.4)
.translate({ x: sortBarX(i), y: sortBarY(i) - sortBarH(this.tick, i) })
}, (i: number) => i.toString())
ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
Column()
.width(3)
.height(12)
.borderRadius(2)
.backgroundColor(i % 2 === 0 ? COLORS.primaryDark : COLORS.accent)
.opacity(cursorA(this.tick, i))
.translate({ x: cursorX(this.tick, i), y: cursorY(this.tick, i) })
}, (i: number) => i.toString())
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
fxLayer 是这个应用的视觉灵魂——一个由排序柱和光标闪烁点组成的背景特效层。整个特效层使用 Stack 容器(层叠布局),对齐方式为 TopStart(左上角对齐)。
8.2 排序柱部分详解
特效层的第一个 ForEach 渲染 6 根排序柱。每根柱子是一个 Column 组件:
- 宽度:固定 18 像素。这个宽度让柱子有足够的视觉存在感,但不会占用太多屏幕空间。
- 高度:由
sortBarH(this.tick, i)动态计算,范围 10-63 像素。高度随tick变化,形成跳动效果。 - 圆角:4 像素,让柱子边缘柔和,避免直角的生硬感。
- 背景色:通过
i % 3在三种颜色之间循环——i % 3 === 0用主色青蓝(COLORS.primary),i % 3 === 1用强调色荧光绿(COLORS.accent),i % 3 === 2用 bit 色蓝灰(COLORS.bit)。6 根柱子的颜色序列是:青蓝、绿、蓝灰、青蓝、绿、蓝灰——形成了"三色循环"的视觉节奏。 - 透明度:固定 0.4。在深色背景上,0.4 的透明度让柱子呈现出半透明的光柱效果——既能看到柱子的颜色,又不会完全遮挡背景。这种"半透明光柱"效果在深色赛博风应用中非常常见。
- 位置:通过
translate属性设置。X 坐标由sortBarX(i)计算(固定位置,不随时间变化),Y 坐标由sortBarY(i) - sortBarH(this.tick, i)计算(基准位置减去当前高度)。
Y 坐标的计算逻辑确保了柱子从底部向上生长——基准位置 sortBarY(i) 是柱子底部所在的位置,减去当前高度 sortBarH(this.tick, i) 得到柱子顶部的位置。当高度增大时,柱子顶部向上移动(Y 值减小);当高度减小时,柱子顶部向下移动。这种"从底部向上生长"的设计完美模拟了排序算法中柱子高度交换的视觉效果。
8.3 光标闪烁部分详解
特效层的第二个 ForEach 渲染 6 个光标闪烁点。每个光标是一个 Column 组件:
- 宽度:3 像素,这是一个极细的竖线宽度,模拟代码编辑器中的光标形状。
- 高度:12 像素,与宽度形成 4:1 的纵横比,更像一个竖线光标。
- 圆角:2 像素,让光标两端微圆。
- 背景色:通过
i % 2在两种颜色之间交替——i % 2 === 0用COLORS.primaryDark(亮青蓝),i % 2 === 1用COLORS.accent(荧光绿)。6 个光标的颜色序列是:亮青蓝、绿、亮青蓝、绿、亮青蓝、绿——两色交替。 - 透明度:由
cursorA(this.tick, i)动态计算——0.8(明亮)或 0.15(暗淡),每 4 个 tick 切换一次。这种"亮 2 暗 2"的节奏形成了闪烁效果。 - 位置:通过
translate设置。X 坐标由cursorX(this.tick, i)计算,Y 坐标由cursorY(this.tick, i)计算——两者都随tick变化,形成动态飘移轨迹。
8.4 触摸穿透机制
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
特效层的 Stack 容器设置了 hitTestBehavior(HitTestMode.None),这是确保特效层不拦截触摸事件的关键配置。
HitTestMode 有三个值:Default(默认,拦截触摸事件)、Block(阻断,拦截且不传递给下层)、None(穿透,不拦截,事件传递给下层)。特效层使用 None 模式,确保用户的触摸操作可以穿透特效层,到达下方的内容区域。
这种设计是"装饰层不影响交互层"的典型实现——特效层只负责视觉装饰,不参与交互逻辑。如果特效层使用 Default 模式,用户在特效层覆盖区域点击按钮时,事件会被特效层拦截,导致按钮无法响应。
8.5 动画原理深度分析
整个特效层的动画由一个 90 毫秒的定时器驱动。定时器每触发一次,tick 加 1,驱动以下变化:
排序柱:6 根柱子的高度通过 sortBarH(tick, i) 计算,每 90ms 变化一次。由于 tick * 3 的系数,柱子高度每 90ms 变化 3 像素(取模前),变化速度肉眼可见但不过快。6 根柱子由于 i * 29 的偏移,在同一时刻高度各不相同,且变化趋势各异——有的在增高、有的在降低,形成了"柱子高度此起彼伏"的视觉效果,完美模拟了排序算法中柱子交换的过程。
光标位置:6 个光标的 X、Y 坐标分别通过 cursorX(tick, i) 和 cursorY(tick, i) 计算,每 90ms 变化一次。横向移动速度(8 像素/帧)大于纵向(5 像素/帧),形成斜向飘移轨迹。6 个光标由于 i * 181 和 i * 149 的大质数偏移,位置充分分散,不会重叠。
光标闪烁:6 个光标的透明度通过 cursorA(tick, i) 计算,每 90ms 可能切换一次。i * 2 的偏移让光标两两同步、整体异步——不是全部同时亮灭,而是有错位的闪烁,更有层次感。
这种"单定时器驱动多套动画"的设计是一种资源高效的方案——只有一个定时器在运行,CPU 占用低;所有动画共享同一时钟源,节奏完全同步。缺点是所有动画的帧率相同(约 11fps),无法为不同动画设置不同的更新频率。
九、头部区域详解
9.1 搜索栏与消息
@Builder
header() {
Column({ space: 10 }) {
// 搜索条 + 消息
Row({ space: 10 }) {
Row({ space: 6 }) {
Text('🔍')
.fontSize(14)
Text('搜题目 / 标签 / 题解')
.fontSize(12)
.fontColor(COLORS.textHint)
.layoutWeight(1)
}
.layoutWeight(1)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
Stack({ alignContent: Alignment.TopEnd }) {
Text('🔔')
.fontSize(22)
Text('2')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.backgroundColor(COLORS.danger)
.borderRadius(8)
.translate({ x: 6, y: -4 })
}
.width(34)
.height(30)
}
.width('100%')
头部区域以 Column({ space: 10 }) 为容器,子元素之间有 10 像素的垂直间距。第一个子元素是搜索栏行。
搜索栏由两部分组成:搜索框和消息铃铛。
搜索框是一个 Row,包含搜索图标(🔍,14 号字)和提示文字"搜题目 / 标签 / 题解"(12 号字,提示色 textHint)。提示文字使用 layoutWeight(1) 撑满剩余宽度。搜索框的背景色是 COLORS.cardBg(深蓝灰),圆角 18 像素(胶囊形),内边距 12/8。在深色背景上,搜索框的 cardBg 背景与外部 bg 背景形成了微妙的层次差——搜索框是一个"浮起"的输入区域。
提示文字"搜题目 / 标签 / 题解"精确描述了搜索的三种维度——按题目名称搜索、按算法标签搜索、按题解内容搜索。这种"三维度搜索"的设计比简单的"搜索"提示更加具体,引导用户使用更精确的搜索策略。
消息铃铛使用 Stack 容器实现图标+角标的层叠效果。alignContent: Alignment.TopEnd 让角标定位在铃铛的右上角。铃铛图标是 🔔(22 号字),角标是"2"(9 号字,白色粗体,danger 红色背景,圆角 8),通过 translate({ x: 6, y: -4 }) 向右上偏移,形成"悬挂"在铃铛右上角的效果。角标显示"2"表示有 2 条未读消息,使用 danger 红色是为了让角标在深色背景上足够醒目。
9.2 连胜打卡卡
// 连胜打卡卡
Row({ space: 12 }) {
Column({ space: 4 }) {
Text('🔥 连胜 23 天 · 今日一题已打卡')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('今日题:最小覆盖子串 · 用时 34 分钟 · 击败 72% 提交')
.fontSize(10)
.fontColor('#123241')
Row({ space: 6 }) {
Text('周目标 21/21 达成')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.bg)
.borderRadius(6)
Text('连胜护盾 ×1')
.fontSize(9)
.fontColor('#123241')
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('导师陪跑')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor(COLORS.bg)
.borderRadius(14)
.onClick(() => {
this.openCoach();
})
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.accent)
.borderRadius(16)
连胜打卡卡是这款应用最具游戏化色彩的元素。卡片使用 Row 容器,背景色是 COLORS.accent(荧光绿 #5BD98A),这是整个应用中唯一使用强调色作为大面积背景的组件——荧光绿背景在深色应用中极其醒目,如同连胜火焰的光芒。
卡片左侧是信息区,包含三行文本:
第一行:“🔥 连胜 23 天 · 今日一题已打卡”——15 号字,白色粗体。"连胜 23 天"是核心信息,展示用户的连续刷题天数。"今日一题已打卡"确认今日任务已完成。火焰 Emoji 🔥 与"连胜"的语义呼应,是游戏化激励的经典符号。
第二行:“今日题:最小覆盖子串 · 用时 34 分钟 · 击败 72% 提交”——10 号字,颜色 #123241(即 primaryLight 的值,深青蓝)。这行信息展示了今日做题的详情——题目名称、用时、击败百分比。颜色使用深色而非浅色,因为在荧光绿背景上,深色文字比浅色文字对比度更高。
第三行:两个标签——“周目标 21/21 达成”(9 号字,主色青蓝,COLORS.bg 深色背景,圆角 6)和"连胜护盾 ×1"(9 号字,深色 #123241)。"周目标 21/21 达成"表示本周目标已全部完成(21 题目标,21 题完成),使用深色背景+青蓝文字的"挖空"标签效果。"连胜护盾 ×1"是游戏化道具——护盾可以在某天无法刷题时保护连胜不被中断,这种道具设计增加了用户黏性。
卡片右侧是"导师陪跑"按钮——12 号字,主色青蓝粗体,COLORS.bg 深蓝黑背景,圆角 14。在荧光绿背景上,深色背景的按钮形成了"挖空"效果,吸引用户点击。点击触发 this.openCoach() 打开导师预约弹框。将"导师陪跑"入口放在连胜卡上,是因为连胜用户是最有动力继续提升的群体——他们已经展现了持续学习的习惯,是导师服务的精准目标用户。
9.3 面试模拟横幅
// 面试模拟横幅
Row({ space: 10 }) {
Text('💼')
.fontSize(22)
Column({ space: 2 }) {
Text('大厂面试模拟 · 本周新增 18 位面试官')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('计时手撕 · 逐行点评 · 附最新高频题清单与通过率')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('去约战')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.backgroundColor(COLORS.primary)
.borderRadius(14)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
面试模拟横幅展示"大厂面试模拟"服务。卡片背景是 COLORS.cardBg(深蓝灰),与连胜卡的荧光绿背景形成对比——连胜卡用强调色吸引注意,面试横幅用常规卡片色沉稳呈现。
左侧是 💼 公文包图标(22 号字),右侧是两行文本:主标题"大厂面试模拟 · 本周新增 18 位面试官"(13 号字,粗体,主文本色),副标题"计时手撕 · 逐行点评 · 附最新高频题清单与通过率"(10 号字,辅助文本色)。主标题强调"本周新增 18 位面试官"制造紧迫感和新鲜感,副标题列出三个核心卖点——计时手撕(模拟真实面试压力)、逐行点评(获得专业反馈)、高频题清单(获取面试情报)。
最右侧是"去约战"按钮——12 号字,白色粗体,主色青蓝背景,圆角 14。文案"去约战"而非"去参加"或"去体验",用"约战"这个竞技性词汇激发用户的挑战欲。点击触发 this.openAdd() 打开发题解弹框——这里有一个设计上的复用:"去约战"按钮和"发题解"按钮共用同一个弹框,暗示发题解和面试模拟都是"分享战绩"的行为。
9.4 数据四格
// 数据四格
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('累计通过')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('412 题')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('当前连胜')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('23 天')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('竞赛分')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('1584')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.bit)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('待清错题')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('16 题')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.warning)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
}
.width('100%')
.padding({ top: 10, bottom: 4 })
}
数据四格展示了用户的四个核心指标,每格使用不同的数字颜色,在深色背景上形成多彩的数据矩阵:
第一格:“累计通过 412 题”——数字 412 使用主色青蓝(COLORS.primary),表示这是最重要的指标(通过题数是算法平台的核心 KPI)。
第二格:“当前连胜 23 天”——数字 23 使用强调色荧光绿(COLORS.accent),与连胜卡的荧光绿背景呼应,表示连胜是一种"成功"状态。
第三格:“竞赛分 1584”——数字 1584 使用 bit 色蓝灰(COLORS.bit),用低调的颜色表示这是一个参考性指标,不如通过题数和连胜重要。
第四格:“待清错题 16 题”——数字 16 使用警告色琥珀金(COLORS.warning),用警示色提醒用户有错题需要处理。
四格的背景统一使用 COLORS.cardBg(深蓝灰),圆角 10,通过 layoutWeight(1) 等分宽度。标签文字统一使用 9 号字提示色,数字统一使用 13 号字粗体——这种"标签小而暗、数字大而亮"的对比设计让用户一眼就能看到核心数字。
头部区域以 Column({ space: 10 }) 为容器,最终设置 padding({ top: 10, bottom: 4 })——顶部 10 像素留白避免内容紧贴屏幕顶部,底部 4 像素留白与后续内容保持微小间距。
十、子导航栏详解
10.1 双阶进度式指示器完整代码
@Builder
subNav() {
Scroll() {
Row({ space: 10 }) {
ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
Column({ space: 3 }) {
Text(item)
.fontSize(this.subTab === idx ? 12 : 11)
.fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.subTab === idx ? COLORS.primaryDark : COLORS.textSecondary)
if (this.subTab === idx) {
Column({ space: 2 }) {
Column()
.width(20)
.height(4)
.borderRadius(2)
.backgroundColor(COLORS.primary)
Column()
.width(20)
.height(2)
.borderRadius(1)
.backgroundColor(COLORS.accent)
}
.height(8)
.alignItems(HorizontalAlign.Center)
} else {
Column()
.width(16)
.height(2)
.borderRadius(1)
.backgroundColor(COLORS.border)
}
}
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.subTab = idx;
})
}, (item: string) => item)
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.padding({ top: 6, bottom: 10 })
}
10.2 子导航栏设计分析
子导航栏是这款应用最具特色的 UI 组件之一——双阶进度式指示器。整个导航栏使用 Scroll 容器实现横向滚动(scrollable(ScrollDirection.Horizontal)),隐藏滚动条(scrollBar(BarState.Off)),确保 7 个 Tab 可以在窄屏上横向滑动浏览。
每个 Tab 项是一个 Column,包含两部分:文字标签和指示器。
文字标签的样式根据选中态动态切换:选中时 12 号字、粗体、primaryDark 亮青蓝色;未选中时 11 号字、常规、textSecondary 辅助文本色。字号差异(12 vs 11)和粗细差异(Bold vs Normal)形成了"选中大粗、未选小细"的对比,让选中项在视觉上更突出。
指示器是双阶设计的核心:
选中态指示器:一个 Column({ space: 2 }) 容器,包含两个子元素——上层是 20px 宽、4px 高的青蓝色圆角条(COLORS.primary),下层是 20px 宽、2px 高的荧光绿圆角条(COLORS.accent)。两层宽度相同但高度不同(4px 和 2px),通过 2px 的间距分隔,形成了"粗条在上、细条在下"的阶梯式视觉层次。容器总高度 8 像素(4+2+2 间距)。
未选中态指示器:一个 16px 宽、2px 高的灰色圆角条(COLORS.border),比选中态更窄更细,视觉上明显退后,让选中态自然成为视觉焦点。
10.3 双阶设计的深层隐喻
这种"双阶"设计有两个隐喻:
第一,"双阶进度"隐喻——粗条代表当前阶段,细条代表下一阶段,暗示学习是分阶段递进的。用户看到双层指示器时,潜意识会感知到"当前在某个阶段,下面还有下一个阶段",这种感知与算法学习的"步步进阶"理念高度契合。
第二,"青绿双色"隐喻——主色青蓝和强调色绿的组合在深色背景上形成了冷暖搭配的指示器。青蓝是冷色,绿色偏暖(在色彩心理学中,绿色通常被视为中性偏暖),两色的组合比单色指示器更加醒目,也更具视觉层次感。
第三,"粗细对比"隐喻——4px 和 2px 的粗细差异形成了"主次"层次。粗条是"主"(当前阶段),细条是"次"(过渡到下一阶段),这种"主次分明"的设计在有限的导航空间中实现了更丰富的视觉表达。
点击事件 this.subTab = idx 切换选中 Tab,触发条件渲染更新内容区域。ForEach 的键值生成器使用 item(Tab 名称字符串),确保 Tab 项在列表更新时能正确 diff。
十一、精选首页详解
11.1 刷法四格
@Builder
pageFeatured() {
Column({ space: 12 }) {
// 刷法四格
Column() {
Text('在算语怎么刷')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.margin({ bottom: 10 })
Row({ space: 8 }) {
ForEach(TYPE_LIST, (it: PlayTypeItem) => {
Column({ space: 4 }) {
Text(it.icon)
.fontSize(24)
Text(it.name)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(it.desc)
.fontSize(9)
.fontColor(COLORS.textHint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(it.hot ? '热门' : ' ')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(it.hot ? COLORS.danger : COLORS.border)
}
.layoutWeight(1)
.padding({ top: 10, bottom: 8, left: 4, right: 4 })
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
}, (it: PlayTypeItem) => it.name)
}
.width('100%')
}
.width('100%')
精选首页以"在算语怎么刷"为标题,展示了四种刷法模式。每种刷法是一个卡片,包含四行内容:Emoji 图标(24 号字)、名称(11 号字粗体)、描述(9 号字提示色)、热门标签(9 号字粗体,热门时 danger 红色,不热门时 border 灰色)。
描述文字使用了 maxLines(1) 和 textOverflow({ overflow: TextOverflow.Ellipsis })——限制单行显示,超出部分用省略号截断。这种处理确保了在窄屏设备上,描述文字不会换行导致卡片高度不一。
四种刷法的排列是有讲究的:“每日一题"和"专题突破"标记为"热门”(红色标签),排在前两位;"面试模拟"和"竞赛上分"不标记热门,排在后两位。这种排列引导新用户从"每日一题"开始——这是入门门槛最低的刷法,只需要每天做一道题。
11.2 本周刷题柱状图
// 本周刷题柱状图
Column() {
Row() {
Text('本周每日刷题数量')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('单位 题')
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Row({ space: 10 }) {
ForEach(WEEK_CHART, (it: WeekChartItem) => {
Column({ space: 4 }) {
Column()
.width(20)
.height(barH(it.value, 12))
.borderRadius(5)
.backgroundColor(ccColor(it.value))
Text(`${it.value}`)
.fontSize(9)
.fontColor(COLORS.textSecondary)
Text(it.label)
.fontSize(9)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (it: WeekChartItem) => it.label + it.value.toString())
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
.height(140)
.margin({ top: 12 })
Row({ space: 6 }) {
Text('周末刷题占比')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('56%')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
本周刷题柱状图展示了用户一周内每天的刷题数量。整个图表区域使用 COLORS.cardBg 背景和 14 像素圆角,形成独立的卡片容器。
柱状图由 7 根柱子组成,每根柱子是一个 Column 组件——宽度 20 像素,高度由 barH(it.value, 12) 计算(最大 118 像素),圆角 5,背景色由 ccColor(it.value) 根据值决定。柱子下方是数值文字(9 号字辅助色)和日期标签(9 号字提示色)。
7 根柱子通过 ForEach 渲染,每根使用 layoutWeight(1) 等分宽度。外层 Row 设置 alignItems(VerticalAlign.Bottom) 和 height(140)——底部对齐确保所有柱子从底部向上生长,140 像素的高度包含了柱子最大高度(118)加上数值文字和标签文字的高度。
柱状图底部还有"周末刷题占比 56%"的统计数据——用强调色绿色加粗显示 56%,强调用户在周末的高投入。这个数据引导用户关注自己的刷题时间分布——如果周末占比过高,说明工作日刷题不足,需要调整时间分配。
11.3 题型分布
// 题型分布
Column() {
Text('已过题目类型分布')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(MAT_CHART, (it: MatChartItem) => {
Row({ space: 8 }) {
Text(it.label)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width(76)
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.border)
Row()
.width(`${it.value}%`)
.height(8)
.borderRadius(4)
.backgroundColor(it.color)
}
.layoutWeight(1)
Text(`${it.value}%`)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width(44)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (it: MatChartItem) => it.label)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
题型分布模块使用水平进度条展示各类题目的占比。每行包含四部分:标签文字(固定 76px 宽,12 号字辅助色)、进度条容器(layoutWeight(1) 撑满)、百分比数值(固定 44px 宽,11 号字粗体主文本色,右对齐)。
进度条使用 Stack 容器实现双层叠加——底层是 100% 宽的灰色背景条(COLORS.border),上层是 it.value% 宽的彩色前景条(it.color)。这种"背景条+前景条"的叠加是进度条的标准实现方式。
5 个类型的颜色各不相同——动态规划用青蓝、数组双指针用荧光绿、图论搜索用蓝灰、字符串用琥珀金、数据结构用珊瑚红。在深色背景上,5 条彩色进度条如同彩虹分布图,既直观又美观。标签固定 76px 宽确保了所有标签左对齐,百分比固定 44px 宽右对齐确保了所有数值右对齐——这种"标签左对齐、数值右对齐"的设计让数据表格整洁有序。
11.4 热题双列
// 热题双列
Column() {
Row() {
Text('本周热题挑战榜')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('查看全部')
.fontSize(11)
.fontColor(COLORS.primary)
}
.width('100%')
.margin({ bottom: 10 })
ForEach(PROBLEM_LIST, (it: ProblemItem, idx: number) => {
if (idx % 2 === 0) {
Row({ space: 10 }) {
if (idx < PROBLEM_LIST.length) {
Column({ space: 6 }) {
Row() {
Text('🧠')
.fontSize(22)
.layoutWeight(1)
Text(PROBLEM_LIST[idx].diff)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(diffColor(PROBLEM_LIST[idx].diff))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(6)
}
.width('100%')
Text(PROBLEM_LIST[idx].name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(`热度 ${PROBLEM_LIST[idx].hot} · 通过 ${PROBLEM_LIST[idx].pass}%`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
if (idx + 1 < PROBLEM_LIST.length) {
Column({ space: 6 }) {
Row() {
Text('📖')
.fontSize(22)
.layoutWeight(1)
Text(PROBLEM_LIST[idx + 1].diff)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(diffColor(PROBLEM_LIST[idx + 1].diff))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(6)
}
.width('100%')
Text(PROBLEM_LIST[idx + 1].name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(`热度 ${PROBLEM_LIST[idx + 1].hot} · 通过 ${PROBLEM_LIST[idx + 1].pass}%`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
}
.width('100%')
}
}, (it: ProblemItem) => it.id.toString())
}
.width('100%')
热题挑战榜使用双列卡片布局展示 PROBLEM_LIST 中的 8 道题目。双列布局通过 ForEach + if (idx % 2 === 0) 实现——偶数索引时创建一个 Row 包含两张卡片(当前索引和下一索引),奇数索引时跳过(已被偶数索引的消费)。
每张卡片包含:顶部行(Emoji 图标 + 难度标签)、题目标题、"热度·通过率"信息。难度标签的背景色是 COLORS.accentLight(深绿),文字颜色由 diffColor 根据难度决定——在深绿背景上的彩色难度文字形成了类似终端中状态标签的视觉效果。
左列卡片使用 🧠 大脑图标,右列使用 📖 书本图标——两列图标不同,增加视觉多样性。难度标签的背景色统一使用 accentLight 深绿,而非根据难度变化——这种统一背景色 + 差异文字色的设计让难度标签在视觉上整齐划一,只有文字颜色传递难度信息。
11.5 码友动态预览
// 码友动态
Column({ space: 10 }) {
Row() {
Text('码友圈动态')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('发题解')
.fontSize(12)
.fontColor(COLORS.primary)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
ForEach(this.postList.slice(0, 3), (it: PostItem) => {
Row({ space: 10 }) {
Text(it.avatar)
.fontSize(22)
Column({ space: 3 }) {
Row() {
Text(it.nick)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.time)
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Text(it.text)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`🧠 ${it.likes}`)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: PostItem) => it.id.toString())
}
.width('100%')
}
.width('100%')
}
码友圈动态预览模块展示 postList 的前 3 条动态(this.postList.slice(0, 3))。每条动态包含:头像 Emoji(22 号字)、昵称(13 号字粗体)、时间(10 号字提示色)、正文(12 号字辅助色,限制 2 行+省略号截断)、点赞数(11 号字提示色,🧠 前缀)。
正文使用 maxLines(2) 和 textOverflow({ overflow: TextOverflow.Ellipsis }) 限制为 2 行——比刷法四格的单行限制更宽松,因为动态正文需要更多文字来传达信息。2 行限制确保了每条动态的卡片高度一致,避免过长的正文导致卡片高度参差不齐。
"发题解"链接使用主色青蓝,点击触发 this.openAdd() 打开发题解弹框。注意这里使用的是 this.postList(响应式数组)而非 POST_LIST(静态数组)——因为用户通过弹框发布的新动态需要实时显示在这个列表中。slice(0, 3) 每次渲染时从最新数组中取前 3 条,确保新发布的动态能立即出现在预览中。
十二、题库列表页面详解
12.1 题库页面完整代码
@Builder
pageMarket() {
Column({ space: 10 }) {
Row() {
Text('题库')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('贡献题目')
.fontSize(12)
.fontColor(COLORS.primary)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
ForEach(PROBLEM_LIST, (it: ProblemItem) => {
Column({ space: 6 }) {
Row() {
Column()
.width(4)
.height(38)
.borderRadius(2)
.backgroundColor(problemTagColor(it.tag))
Column({ space: 4 }) {
Row() {
Text(it.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.diff)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(diffColor(it.diff))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.bg)
.borderRadius(6)
}
.width('100%')
Row({ space: 8 }) {
Text(`🏷️ ${it.tag}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text(`✅ 通过 ${it.pass}%`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.border)
Row()
.width(`${it.hot}%`)
.height(6)
.borderRadius(3)
.backgroundColor(problemTagColor(it.tag))
}
.width('100%')
Row({ space: 10 }) {
Text(`题目热度 ${it.hot}`)
.fontSize(10)
.fontColor(COLORS.textHint)
.layoutWeight(1)
Text('开始挑战')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.primary)
.borderRadius(8)
.onClick(() => {
this.openCoach();
})
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: ProblemItem) => it.id.toString())
}
.width('100%')
}
12.2 题库卡片设计分析
题库列表页面遍历 PROBLEM_LIST 渲染题目卡片。每张卡片的信息维度极其丰富,包含五重视觉编码:
第一重:左侧彩色竖条。 4 像素宽、38 像素高的竖条,颜色由 problemTagColor(it.tag) 根据题目标签关键词决定。这道竖条是卡片的"分类标识"——用户通过颜色就能判断题目的算法类型,无需阅读文字。在深色背景上,彩色竖条如同代码编辑器中的行号标记,醒目而有序。
第二重:难度标签颜色。 难度文字的颜色由 diffColor(it.diff) 决定——简单绿、中等金、困难红。标签背景统一使用 COLORS.bg(深蓝黑),在 cardBg 深蓝灰卡片上形成了"更深"的标签区域,让难度标签在卡片中自然凹下。
第三重:算法标签文字。 “🏷️ + 标签文字"直接展示算法分类,如"DP · 树状数组”、"单调队列"等。标签文字使用辅助文本色,不抢眼但清晰可读。
第四重:通过率百分比。 "✅ 通过 XX%"以文字形式展示通过率,与难度标签形成互补——难度是主观分级,通过率是客观数据,两者结合帮助用户评估题目难度。
第五重:热度进度条。 底部的进度条宽度为 it.hot%,颜色与左侧竖条一致(problemTagColor 返回值),将热度值可视化。进度条采用"灰色背景+彩色前景"的双层叠加设计。
"开始挑战"按钮使用主色青蓝背景+白色文字,点击触发 this.openCoach() 打开导师预约弹框——这种设计暗示了平台的商业逻辑:引导用户从免费做题升级到付费的导师陪跑服务。将"开始挑战"与"导师预约"绑定,是一种巧妙的转化漏斗设计——用户点击"开始挑战"时,看到的不是直接进入做题页面,而是导师预约弹框,引导用户考虑付费服务。
十三、训练计划页面详解
13.1 训练计划完整代码
@Builder
pageCrafts() {
Column({ space: 10 }) {
Text('我的训练营')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(this.planList, (it: PlanItem, idx: number) => {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text('🥋')
.fontSize(24)
Column({ space: 4 }) {
Row() {
Text(it.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.status)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(statusColor(it.status))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(6)
}
.width('100%')
Text(`🧠 ${it.item}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.border)
Row()
.width(`${statusProgress(it.status)}%`)
.height(6)
.borderRadius(3)
.backgroundColor(statusColor(it.status))
}
.width('100%')
Row() {
Text(`训练进度 ${statusProgress(it.status)}%`)
.fontSize(10)
.fontColor(COLORS.textHint)
.layoutWeight(1)
Text('编辑计划')
.fontSize(11)
.fontColor(COLORS.primary)
.onClick(() => {
this.openEdit(idx);
})
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: PlanItem) => it.id.toString())
}
.width('100%')
}
13.2 训练计划卡片分析
训练计划页面遍历 this.planList 响应式数组渲染计划卡片。注意这里使用的是 this.planList(响应式 @State 变量)而非 PLAN_LIST(静态常量)——因为用户通过编辑弹框修改或删除计划后,需要实时反映在页面上。
每个计划卡片使用武道馆 Emoji(🥋)作为图标,暗示"训练"的竞技性——武道馆是练习武术的场所,与"训练营"的语义呼应。卡片包含:计划名称(14 号字粗体)、状态标签(10 号字粗体,颜色由 statusColor 决定,背景 accentLight 深绿)、详情描述(11 号字辅助色,🧠 前缀)、进度条、训练进度百分比、"编辑计划"链接。
状态标签的背景色使用 COLORS.accentLight(#123324 深绿),在 cardBg 深蓝灰卡片上形成了更深的标签区域。彩色状态文字在深绿背景上如同终端中的状态标签——"已通过"绿色、"做题中"青蓝、"卡壳中"金色、"复盘错题中"红色,每种状态都有独特的颜色身份。
进度条的颜色和宽度都由 statusColor 和 statusProgress 函数根据状态字符串计算,确保了状态标签、进度条颜色和进度百分数三者的一致性。这种"状态驱动一切"的设计确保了 UI 的一致性——不会出现状态标签显示"已通过"但进度条只到 50% 的矛盾。
"编辑计划"链接使用主色青蓝,点击调用 this.openEdit(idx),将当前计划的索引传入编辑弹框。openEdit 函数会用当前计划的数据预填充编辑表单,用户可以在现有数据基础上修改。
十四、刷题工坊页面详解
14.1 刷题工坊完整代码
@Builder
pageWorkshop() {
Column({ space: 10 }) {
Text('刷题工坊')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('今日专注')
.fontSize(10)
.fontColor(COLORS.textHint)
Text('96 分')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('提交次数')
.fontSize(10)
.fontColor(COLORS.textHint)
Text('23 次')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('一次通过')
.fontSize(10)
.fontColor(COLORS.textHint)
Text('6 题')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.bit)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('WA 次数')
.fontSize(10)
.fontColor(COLORS.textHint)
Text('5 次')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.warning)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
ForEach(PROBLEM_LIST.slice(0, 5), (it: ProblemItem, idx: number) => {
Row({ space: 10 }) {
Text(`${idx + 1}`)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? COLORS.primary : COLORS.textHint)
.width(20)
Column({ space: 3 }) {
Text(it.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(`${it.tag} · ${it.diff}`)
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(`热度 ${it.hot}`)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(problemTagColor(it.tag))
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
}, (it: ProblemItem) => it.id.toString())
Text('错题本')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.margin({ top: 4 })
ForEach(PLAN_LIST.slice(0, 4), (it: PlanItem) => {
Row({ space: 10 }) {
Text('📒')
.fontSize(18)
Column({ space: 3 }) {
Text(it.name)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(it.item)
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(it.status)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(statusColor(it.status))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.bg)
.borderRadius(6)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
}, (it: PlanItem) => it.id.toString())
Text('插件速购')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.margin({ top: 4 })
ForEach(GEAR_LIST, (it: GearItem, idx: number) => {
if (idx % 2 === 0) {
Row({ space: 10 }) {
if (idx < GEAR_LIST.length) {
Column({ space: 6 }) {
Text(GEAR_LIST[idx].icon)
.fontSize(26)
Text(GEAR_LIST[idx].name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(GEAR_LIST[idx].desc)
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('加购')
.fontSize(11)
.fontColor(COLORS.primary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
if (idx + 1 < GEAR_LIST.length) {
Column({ space: 6 }) {
Text(GEAR_LIST[idx + 1].icon)
.fontSize(26)
Text(GEAR_LIST[idx + 1].name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(GEAR_LIST[idx + 1].desc)
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('加购')
.fontSize(11)
.fontColor(COLORS.primary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
}
.width('100%')
}
}, (it: GearItem) => it.id.toString())
}
.width('100%')
}
14.2 刷题工坊四大模块分析
刷题工坊页面是信息最密集的页面,包含四个模块:
数据四格模块展示今日刷题的核心指标——今日专注 96 分(主色青蓝)、提交 23 次(强调色绿)、一次通过 6 题(bit 色蓝灰)、WA 次数 5 次(警告色金)。四个数字使用不同的颜色,在深色背景上形成多彩的数据矩阵。
值得注意的是"WA 次数"用警告色金色而非危险色红色——WA(Wrong Answer)是算法竞赛中的标准术语,表示答案错误。用金色而非红色是因为 WA 是学习过程中的正常状态,不需要用危险色来恐吓用户。这种颜色语义的微调体现了算法平台对学习心态的正面引导——WA 不是失败,而是改进的机会。
题目排行榜模块使用 PROBLEM_LIST.slice(0, 5) 截取前 5 题,排名前三的序号用主色青蓝加粗显示(idx < 3 ? COLORS.primary : COLORS.textHint),形成"Top 3 高亮"的视觉效果。每行包含:排名序号(固定 20px 宽)、题目名称和标签(layoutWeight(1) 撑满)、热度值(颜色由 problemTagColor 根据标签决定)。排行榜让用户了解当前最热门的题目,引导刷题方向。
错题本模块使用 PLAN_LIST.slice(0, 4) 展示前 4 个计划作为错题条目。每条使用 📒 书本图标,包含计划名称、详情和状态标签。状态标签的背景色是 COLORS.bg(深蓝黑),比卡片背景 cardBg 更深,形成"标签凹陷"效果。错题本让用户回顾之前做错的题目和计划状态,避免重复犯错。
插件速购模块使用双列卡片布局展示 GEAR_LIST 中的 8 个插件。每张卡片包含:Emoji 图标(26 号字)、插件名称(13 号字粗体)、描述(11 号字辅助色)和"加购"链接(11 号字主色)。双列布局通过 if (idx % 2 === 0) 条件实现,与热题双列的实现方式一致。插件速购模块将工具插件以商品卡片的形式展示,暗示了平台的商业化方向——插件可以作为增值服务出售。
十五、算法课堂页面详解
15.1 算法课堂完整代码
@Builder
pageCourse() {
Column({ space: 10 }) {
Text('算法课堂')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(COURSE_LIST, (it: CourseItem) => {
Column({ space: 8 }) {
Row() {
Text(it.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.week)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.border)
Row()
.width(`${it.progress}%`)
.height(8)
.borderRadius(4)
.backgroundColor(it.color)
}
.width('100%')
Row() {
Text('已学')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Text(`${it.progress}%`)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: CourseItem) => it.title)
Button()
.width('100%')
.height(42)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.opacity(0.9)
.onClick(() => {
this.openDelB();
})
Text('退课')
.fontSize(14)
.fontColor(COLORS.white)
.margin({ left: -28, top: -32 })
}
.width('100%')
}
15.2 课堂页面分析
算法课堂页面遍历 COURSE_LIST 渲染 5 门课程卡片。每张卡片包含:课程标题(14 号字粗体)、周次(11 号字提示色)、进度条(8 像素高,颜色由课程数据的 color 字段决定)、"已学"标签和进度百分比。
进度条的颜色使用课程数据自带的 color 字段——5 门课程分别使用青蓝、荧光绿、蓝灰、琥珀金、珊瑚红五种颜色,与题型分布的颜色方案保持一致,形成了统一的色彩语言。进度从第 1 周的 100% 递减到第 5 周的 7%,模拟了一个正在进行的课程序列——已完成的部分进度满,未开始的部分进度接近 0。
页面底部有一个"退课"按钮——使用 COLORS.danger 危险红色背景,opacity(0.9) 略微透明,圆角 10。按钮的实现方式比较特殊:先创建一个 Button 组件设置背景色和点击事件,然后用 Text 组件通过负 margin 叠加在按钮上显示文字。margin({ left: -28, top: -32 }) 让文字向左偏移 28 像素、向上偏移 32 像素,正好落在按钮的中心位置。这种"Button + Text 叠加"的实现方式是一种变通手法——在 ArkUI 中,Button 组件如果不设置子组件,需要通过这种叠加方式来显示自定义文字。
点击"退课"按钮触发 this.openDelB(),打开删除弹框(delTarget = 'course'),让用户确认退课操作。
十六、码友圈页面详解
16.1 码友圈完整代码
@Builder
pageCircle() {
Column({ space: 10 }) {
Row() {
Text('码友圈')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('发题解')
.fontSize(12)
.fontColor(COLORS.primary)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
ForEach(this.postList, (it: PostItem) => {
Row({ space: 10 }) {
Text(it.avatar)
.fontSize(26)
Column({ space: 4 }) {
Row() {
Text(it.nick)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.time)
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Text(it.text)
.fontSize(13)
.fontColor(COLORS.textSecondary)
Text(`🧠 ${it.likes} · 💬 回复`)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: PostItem) => it.id.toString())
}
.width('100%')
}
16.2 码友圈分析
码友圈页面遍历 this.postList 响应式数组渲染所有动态。与精选首页的码友动态预览(只展示前 3 条)不同,码友圈页面展示完整的动态列表。
每条动态的布局比预览版更加宽松——头像 Emoji 使用 26 号字(预览版 22 号字),昵称使用 14 号字(预览版 13 号字),正文使用 13 号字(预览版 12 号字)且不限制行数(预览版限制 2 行+省略号)。底部多了"💬 回复"交互提示,让用户知道可以回复动态。
使用 this.postList(响应式 @State 变量)而非 POST_LIST(静态常量)确保了用户通过发题解弹框或导师预约弹框发布的新动态能实时显示在列表中。unshift 插入的新动态会出现在列表最前面,符合社交媒体"最新内容在最前"的惯例。
十七、插件库页面详解
17.1 插件库完整代码
@Builder
pageGear() {
Column({ space: 10 }) {
Text('插件库')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
Row({ space: 8 }) {
Column({ space: 4 }) {
Text('📈')
.fontSize(28)
Text('分析区')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('复杂度 · 对拍')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Text('⏱️')
.fontSize(28)
Text('节奏区')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('专注 · 赛程')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Text('🕵️')
.fontSize(28)
Text('情报区')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('面经 · 高频题')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
ForEach(GEAR_LIST, (it: GearItem) => {
Row({ space: 10 }) {
Text(it.icon)
.fontSize(20)
Column({ space: 3 }) {
Text(it.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(it.desc)
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('加购')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.primary)
.borderRadius(8)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
}, (it: GearItem) => it.id.toString())
Button()
.width('100%')
.height(42)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.opacity(0.9)
.onClick(() => {
this.openDelA();
})
Text('放弃首项计划')
.fontSize(14)
.fontColor(COLORS.white)
.margin({ left: -88, top: -32 })
}
.width('100%')
}
17.2 插件库分析
插件库页面分为两个部分:分类大卡和插件列表。
分类大卡是三格布局,将 8 个插件按功能分为三大类别——分析区(📈 复杂度·对拍)、节奏区(⏱️ 专注·赛程)、情报区(🕵️ 面经·高频题)。每个分类卡使用 28 号字的 Emoji 图标、13 号字粗体名称和 10 号字描述。三格等分宽度(layoutWeight(1)),居中对齐,形成整齐的分类导航。
插件列表遍历 GEAR_LIST 渲染所有 8 个插件。每个插件行包含:Emoji 图标(20 号字)、名称和描述(layoutWeight(1) 撑满)、"加购"按钮(主色青蓝背景+白色文字,圆角 8)。与刷题工坊的双列插件卡不同,插件库使用单列列表布局——更详细地展示每个插件的完整信息。
页面底部的"放弃首项计划"按钮使用 danger 红色背景,点击触发 this.openDelA() 打开删除弹框(delTarget = 'task')。margin({ left: -88, top: -32 }) 让文字向左偏移 88 像素、向上偏移 32 像素,落在按钮中心——由于"放弃首项计划"有 6 个字,比"退课"2 个字更长,所以需要更大的左偏移来居中。
十八、我的页面详解
18.1 我的页面完整代码
@Builder
pageMine() {
Column({ space: 12 }) {
Row({ space: 12 }) {
Text('🧠')
.fontSize(40)
Column({ space: 4 }) {
Text('双指针之神')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('紫名选手 · 累计 412 题 · 连胜 23 天 · 修行 190 天')
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
Column({ space: 10 }) {
Row() {
Text('我的训练营档案')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(`${this.planList.length} 个`)
.fontSize(12)
.fontColor(COLORS.primary)
}
.width('100%')
ForEach(this.planList.slice(0, 3), (it: PlanItem) => {
Row() {
Text(it.name)
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Text(it.status)
.fontSize(11)
.fontColor(statusColor(it.status))
}
.width('100%')
}, (it: PlanItem) => it.id.toString())
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
Column({ space: 10 }) {
Row() {
Text('我的动态')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(`${this.postList.length} 条`)
.fontSize(12)
.fontColor(COLORS.primary)
}
.width('100%')
ForEach(this.postList.slice(0, 3), (it: PostItem) => {
Row() {
Text(it.nick)
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Text(it.time)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.width('100%')
}, (it: PostItem) => it.id.toString())
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
}
.width('100%')
}
18.2 我的页面分析
"我的"页面是用户的个人中心,包含三个模块:
用户信息卡展示用户的头像(🧠 大脑,40 号字)、昵称"双指针之神"(18 号字粗体)和综合信息"紫名选手 · 累计 412 题 · 连胜 23 天 · 修行 190 天"(11 号字辅助色)。综合信息用"·"分隔四个维度——段位(紫名选手)、累计通过题数(412 题)、连胜天数(23 天)、修行天数(190 天)。"紫名选手"是 Codeforces 的段位等级,暗示用户在竞赛中达到了较高水平。"修行"这个用词带有修仙小说的风格,与算法学习的"修炼"过程呼应。
训练营档案模块展示用户的训练计划数量(this.planList.length 个)和前 3 个计划的状态。使用 this.planList 响应式数组确保了用户通过编辑弹框修改或删除计划后,计划数量和状态能实时更新。每行简洁地展示计划名称和状态文字(颜色由 statusColor 决定),不展示进度条和详情——这是"概览"视图,详细信息在训练计划页面查看。
我的动态模块展示用户的动态数量(this.postList.length 条)和前 3 条动态的昵称和时间。同样使用响应式数组,确保用户发布新动态后数量和列表能实时更新。
三个模块都使用 cardBg 背景和 14 像素圆角的卡片设计,通过 Column({ space: 12 }) 的 12 像素间距分隔,形成了整洁的个人中心页面。
十九、弹框系统详解
19.1 遮罩层
@Builder
modalOverlay() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000')
.opacity(0.7)
.onClick(() => {
this.addOpen = false;
this.editOpen = false;
this.delOpen = false;
this.coachOpen = false;
})
}
.width('100%')
.height('100%')
}
遮罩层是一个全屏的黑色半透明层,透明度 0.7——在深色背景上,0.7 的黑色遮罩让背景内容变得暗淡,突出弹框内容。遮罩层的点击事件统一关闭所有四套弹框——将 addOpen、editOpen、delOpen、coachOpen 全部设为 false。这种"点击遮罩关闭弹框"的交互模式是移动端弹框的标配,用户可以通过点击弹框外部区域快速关闭弹框,无需精确点击"取消"按钮。
19.2 发题解弹框——发光点头卡
@Builder
addModalBody() {
Column({ space: 12 }) {
Row({ space: 8 }) {
Column()
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.accent)
.opacity(this.glow === 0 ? 1 : 0.3)
Text('发一条刷题动态')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('AC')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.primaryLight)
.borderRadius(6)
}
.width('100%')
TextInput({ placeholder: '一句话记录今日战绩…', text: this.addTitle })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.height(40)
.onChange((v: string) => {
this.addTitle = v;
})
TextArea({ placeholder: '解法思路、复杂度分析、踩坑记录都可以聊…', text: this.addContent })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.height(90)
.onChange((v: string) => {
this.addContent = v;
})
Row({ space: 10 }) {
Button()
.width(86)
.height(34)
.backgroundColor(COLORS.cardBg)
.borderRadius(17)
.border({ width: 1, color: COLORS.border, radius: 17 })
.onClick(() => {
this.addOpen = false;
})
Text('取消')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.margin({ left: -58 })
Button()
.width(96)
.height(34)
.backgroundColor(COLORS.primary)
.borderRadius(17)
.onClick(() => {
this.doAdd();
})
Text('发布')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: -58 })
}
.width('100%')
.justifyContent(FlexAlign.End)
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
}
发题解弹框的视觉特色是标题左侧的发光点——一个 8x8 像素的荧光绿圆点,透明度根据 this.glow 值在 1 和 0.3 之间切换(.opacity(this.glow === 0 ? 1 : 0.3)),形成呼吸闪烁效果。这是 glow 状态变量在整个应用中的实际用途——通过定时器每 90ms 交替 0 和 1,驱动发光点的透明度变化,营造出类似 AC 后绿色指示灯闪烁的视觉效果。
标题右侧的"AC"标签使用 primaryLight(深青蓝 #123241)作为背景,primary(亮青蓝 #3AB8DB)作为文字颜色——在深色弹框背景上,这个标签如同一盏点亮的小灯。"AC"是 Algorithm Contest 中"Accepted"的缩写,是算法平台的标志性术语。
弹框包含两个输入字段:TextInput(单行输入,高 40 像素)用于输入标题,TextArea(多行输入,高 90 像素)用于输入内容。两个输入框都使用 COLORS.bg(深蓝黑)作为背景——比弹框背景 cardBg 更深,形成输入区域的"凹陷"效果。onChange 回调将输入值同步到 @State 变量。
底部按钮使用了胶囊形圆角(borderRadius(17),高度 34 的一半),并使用固定宽度(86px 和 96px)而非 layoutWeight,通过 justifyContent(FlexAlign.End) 让按钮右对齐排列。"取消"按钮使用 cardBg 背景+border 边框,"发布"按钮使用 primary 主色背景。按钮文字通过 Text 组件叠加在 Button 上——margin({ left: -58 }) 将文字向左偏移 58 像素,使其落在按钮中心。
19.3 编辑计划弹框——等级标签头卡
@Builder
editModalBody() {
Column({ space: 12 }) {
Text('训练营卡片')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(6)
Text('编辑训练计划')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
TextInput({ placeholder: '计划名(如 秋招冲刺计划)', text: this.editName })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.height(40)
.onChange((v: string) => {
this.editName = v;
})
TextInput({ placeholder: '状态(如 做题中)', text: this.editStyle })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.height(40)
.onChange((v: string) => {
this.editStyle = v;
})
TextArea({ placeholder: '计划说明与阶段备注', text: this.editNote })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.height(70)
.onChange((v: string) => {
this.editNote = v;
})
Row({ space: 10 }) {
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.cardBg)
.borderRadius(6)
.border({ width: 1, color: COLORS.accent, radius: 6 })
.onClick(() => {
this.editOpen = false;
})
Text('放弃修改')
.fontSize(13)
.fontColor(COLORS.accent)
.margin({ left: -66 })
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.accent)
.borderRadius(6)
.onClick(() => {
this.doEdit();
})
Text('保存计划')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.bg)
.margin({ left: -56 })
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
}
编辑弹框的视觉特色是顶部的等级标签头卡——一个荧光绿底色的"训练营卡片"小标签(COLORS.accent 文字 + COLORS.accentLight 深绿背景),下方紧跟大标题"编辑训练计划"(17 号字粗体)。这种"标签+标题"的层次结构让弹框的用途一目了然。
弹框包含三个输入字段:计划名 TextInput、状态 TextInput、计划说明 TextArea。这三个字段在 openEdit 函数中被预填充了当前计划的数据——用户打开弹框时看到的是已有数据,可以在此基础上修改。
底部的"保存计划"按钮使用强调色绿色(COLORS.accent)作为背景,文字使用 COLORS.bg(深蓝黑)作为颜色——在绿色按钮上使用深色文字是深色主题中高对比度按钮的标准做法。"放弃修改"按钮使用 cardBg 背景+accent 绿色边框,绿色文字——与"保存计划"按钮形成"次要/主要"的视觉层次。
两个按钮都使用 layoutWeight(1) 等分宽度,通过 borderRadius(6) 形成方角按钮(非胶囊形),与发题解弹框的胶囊形按钮形成风格差异——不同的弹框使用不同的按钮风格,增加视觉多样性。
19.4 删除弹框——danger 色块头卡
@Builder
delModalBody() {
Column() {
Row({ space: 10 }) {
Text('⚠️')
.fontSize(22)
Column({ space: 2 }) {
Text(this.delTarget === 'task' ? '放弃首项计划' : '退出算法课堂')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('该操作不可撤销,请再次确认')
.fontSize(10)
.fontColor('#0E1524')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor(COLORS.danger)
.borderRadius({ topLeft: 16, topRight: 16 })
Column({ space: 12 }) {
Text(this.delTarget === 'task' ? '将放弃排在首位的训练计划,连胜进度清零,错题记录保留 30 天。' : '将退出首门进行中的课程,学习进度保留 30 天。')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
Row({ space: 10 }) {
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.cardBg)
.borderRadius(19)
.border({ width: 1, color: COLORS.border, radius: 19 })
.onClick(() => {
this.delOpen = false;
})
Text('再想想')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.margin({ left: -52 })
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.danger)
.borderRadius(19)
.onClick(() => {
this.doDel();
})
Text('确认放弃')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: -66 })
}
.width('100%')
}
.width('100%')
.padding(16)
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
}
删除弹框的头卡使用了全宽的危险色红色背景(COLORS.danger),通过 borderRadius({ topLeft: 16, topRight: 16 }) 只设置顶部圆角——形成"顶部红色横幅+底部白色正文"的分层结构。
头卡左侧是 22 号字的 ⚠️ 警告图标,右侧是标题(根据 delTarget 显示"放弃首项计划"或"退出算法课堂")和副标题"该操作不可撤销,请再次确认"。副标题文字使用 #0E1524(即背景色 COLORS.bg 的值),在红色背景上形成了"挖空"的文字效果——文字颜色与弹框背景色相同,在红色头卡上看起来像是挖去了文字区域的红色,露出下方的深色背景。
正文区域根据 delTarget 显示不同的说明文字——放弃计划时提示"连胜进度清零,错题记录保留 30 天",退课时提示"学习进度保留 30 天"。这些说明文字让用户了解操作的后果,特别是"保留 30 天"这种宽限期设计,降低了用户误操作的焦虑。
底部按钮使用胶囊形圆角(borderRadius(19),高度 38 的一半),"再想想"按钮使用 cardBg 背景+border 边框,"确认放弃"按钮使用 danger 红色背景。两个按钮等分宽度,文案设计也很有讲究——"再想想"比"取消"更加口语化、更有亲和力,缓解了删除操作的心理压力。
19.5 导师陪跑弹框——横幅头卡
@Builder
coachModalBody() {
Column() {
Row({ space: 12 }) {
Column() {
Text('🥋')
.fontSize(26)
}
.width(46)
.height(46)
.borderRadius(12)
.backgroundColor(COLORS.bg)
.justifyContent(FlexAlign.Center)
Column({ space: 2 }) {
Text('导师陪跑预约')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('1v1 · 大厂面试官 · 每周复盘')
.fontSize(10)
.fontColor('#123241')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor(COLORS.primary)
.borderRadius({ topLeft: 16, topRight: 16 })
Column({ space: 12 }) {
TextInput({ placeholder: '辅导方向(如 动态规划薄弱)', text: this.coachField })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.height(40)
.onChange((v: string) => {
this.coachField = v;
})
TextInput({ placeholder: '期望时段(如 周二晚 20:00)', text: this.coachTime })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.height(40)
.onChange((v: string) => {
this.coachTime = v;
})
Row({ space: 6 }) {
Text('首次免费')
.fontSize(10)
.fontColor(COLORS.primary)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.primaryLight)
.borderRadius(8)
Text('限时陪练')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.bg)
.borderRadius(8)
Text('不过重学')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.bg)
.borderRadius(8)
}
.width('100%')
Button()
.width('100%')
.height(42)
.backgroundColor(COLORS.accent)
.borderRadius(21)
.onClick(() => {
this.doCoach();
})
Text('提交预约')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.bg)
.margin({ top: -32 })
}
.width('100%')
.padding(16)
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
}
导师陪跑弹框是这款应用最具特色的弹框。头卡使用主色青蓝(COLORS.primary)作为全宽背景,通过 borderRadius({ topLeft: 16, topRight: 16 }) 只设置顶部圆角,形成横幅式头卡。
头卡左侧是 46x46 像素的圆角方块容器(borderRadius(12)),背景为深色(COLORS.bg),内含武道馆 Emoji 🥋(26 号字)。通过 justifyContent(FlexAlign.Center) 让 Emoji 在容器中居中。右侧是白色标题"导师陪跑预约"(16 号字粗体)和深色副标题"1v1 · 大厂面试官 · 每周复盘"(10 号字,颜色 #123241 即 primaryLight)。这种"深色图标方块+亮色文字"的对比设计在青蓝背景上形成了强烈的视觉层次。
弹框内包含两个输入字段:辅导方向 TextInput(placeholder 示例"动态规划薄弱")和期望时段 TextInput(placeholder 示例"周二晚 20:00")。两个输入框的 placeholder 都提供了具体示例,帮助用户理解应该输入什么格式的内容。
三个服务标签的视觉设计很有讲究——"首次免费"用主色青蓝文字+深青蓝背景(primaryLight),在深色弹框背景上最为醒目;"限时陪练"和"不过重学"用辅助文本色+深蓝黑背景(COLORS.bg),视觉上低调处理。这种颜色差异化设计引导用户关注"免费"这一核心卖点——"首次免费"是转化用户的关键钩子,所以视觉上最突出。
底部的"提交预约"按钮使用强调色绿色(COLORS.accent)作为背景,文字使用 COLORS.bg(深蓝黑)作为颜色——与编辑弹框的"保存计划"按钮风格一致。胶囊形圆角(borderRadius(21),高度 42 的一半)与发题解弹框的按钮风格一致。整个按钮使用 width('100%') 撑满宽度,形成全宽主操作按钮。
19.6 弹框系统整体分析
四套弹框各有独特的头卡设计风格:
| 弹框 | 头卡风格 | 主色 | 图标 | 标题文案 |
|---|---|---|---|---|
| 发题解 | 发光点+AC标签 | 无头卡 | 荧光绿发光点 | 发一条刷题动态 |
| 编辑计划 | 等级标签头卡 | 荧光绿+深绿 | 无 | 编辑训练计划 |
| 删除确认 | danger色块头卡 | 珊瑚红 | ⚠️警告 | 放弃首项计划/退课 |
| 导师预约 | 横幅头卡 | 青蓝 | 🥋武道馆 | 导师陪跑预约 |
四套弹框的头卡设计风格各异——发光点、标签、色块、横幅——在视觉上形成了多样性,让用户能通过头卡风格快速识别当前弹框的类型。同时,四套弹框的按钮设计也各有差异——胶囊形/方角、固定宽/等分宽、不同主色——增加了视觉层次。
四套弹框的显隐由四个独立的 @State 布尔变量控制(addOpen、editOpen、delOpen、coachOpen),在 build 方法中通过四个 if 条件渲染。这种"多弹框多变量"的设计虽然增加了状态管理复杂度,但确保了弹框之间的独立性——关闭一个弹框不会影响其他弹框的状态。
二十、底部导航与主内容组装
20.1 底部导航栏
@Builder
bottomBar() {
Row() {
ForEach(NAV_LIST, (it: NavItem, idx: number) => {
Column({ space: 2 }) {
Text(it.icon)
.fontSize(20)
.opacity(this.mainTab === idx ? 1 : 0.55)
Text(it.label)
.fontSize(10)
.fontWeight(this.mainTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.mainTab === idx ? COLORS.primary : COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.mainTab = idx;
})
}, (it: NavItem) => it.label)
}
.width('100%')
.height(56)
.backgroundColor(COLORS.cardBg)
.borderRadius({ topLeft: 16, topRight: 16 })
}
底部导航栏使用 Row 容器,4 个 Tab 通过 layoutWeight(1) 等分宽度。每个 Tab 是一个 Column,包含 Emoji 图标(20 号字)和文字标签(10 号字)。
选中态通过双维度区分:图标透明度——选中时 1.0(完全可见),未选中时 0.55(半透明);文字颜色和粗细——选中时主色青蓝粗体,未选中时提示色蓝灰常规。这种"透明度+颜色"双维度反馈比单一维度更加醒目。
底部导航栏的背景是 COLORS.cardBg(深蓝灰),通过 borderRadius({ topLeft: 16, topRight: 16 }) 只设置顶部圆角——形成"从底部升起"的视觉效果,与主内容区域的底部边缘无缝衔接。高度 56 像素是移动端底部导航栏的标准高度。
20.2 主内容组装
@Builder
mainContent() {
Column() {
this.header()
if (this.mainTab === 0) {
this.subNav()
Scroll() {
Column() {
if (this.subTab === 0) {
this.pageFeatured()
} else if (this.subTab === 1) {
this.pageMarket()
} else if (this.subTab === 2) {
this.pageCrafts()
} else if (this.subTab === 3) {
this.pageWorkshop()
} else if (this.subTab === 4) {
this.pageCourse()
} else if (this.subTab === 5) {
this.pageCircle()
} else {
this.pageGear()
}
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else if (this.mainTab === 1) {
Scroll() {
Column() {
this.pageMarket()
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else if (this.mainTab === 2) {
Scroll() {
Column() {
this.pageCourse()
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else {
Scroll() {
Column() {
this.pageMine()
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
}
}
.width('100%')
.height('100%')
}
mainContent 方法组装了整个主内容区域。结构分为两层:顶层是 header()(头部区域),下层根据 mainTab 的值进行条件渲染。
当 mainTab === 0(首页)时,渲染 subNav()(子导航栏)+ Scroll(垂直滚动内容区域)。内容区域内再根据 subTab 的值进行二次条件渲染——7 个内容页面(精选、题库、训练营、工坊、课堂、码友圈、插件库)通过 if-else if 链式条件选择。这种"双层条件渲染"(mainTab + subTab)实现了"4 主 Tab × 7 子 Tab"的页面组合,但只有首页 Tab 有子导航,其他 Tab 直接渲染单一页面。
当 mainTab === 1(题库)时,直接渲染 pageMarket();当 mainTab === 2(课堂)时,直接渲染 pageCourse();当 mainTab === 3(我的)时,直接渲染 pageMine()。这些非首页 Tab 的内容页面与首页的子 Tab 内容页面共享同一个 @Builder 方法——例如题库 Tab 和首页的"题库"子 Tab 都使用 pageMarket()。这种设计实现了"多入口、同一内容"的页面复用。
所有内容区域都使用 Scroll 容器实现垂直滚动(scrollable(ScrollDirection.Vertical)),隐藏滚动条(scrollBar(BarState.Off)),通过 layoutWeight(1) 撑满除头部和底部导航之外的剩余空间。左右内边距 14 像素,底部内边距 20 像素确保内容不被底部导航遮挡。
20.3 build 方法与最终组装
build() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
this.fxLayer()
Column() {
this.mainContent()
this.bottomBar()
}
.width('100%')
.height('100%')
if (this.addOpen) {
Stack() {
this.modalOverlay()
Column() {
this.addModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.editOpen) {
Stack() {
this.modalOverlay()
Column() {
this.editModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.delOpen) {
Stack() {
this.modalOverlay()
Column() {
this.delModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.coachOpen) {
Stack() {
this.modalOverlay()
Column() {
this.coachModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
}
.width('100%')
.height('100%')
}
build 方法是组件的渲染入口,使用 Stack 容器从底到顶叠加多层 UI:
第一层(最底层):背景色层。 一个全屏的 Column,背景色 COLORS.bg(深蓝黑 #0E1524),作为整个应用的背景。
第二层:特效层。 this.fxLayer() 渲染排序柱和光标闪烁特效,覆盖在背景色之上。特效层设置了 hitTestBehavior(HitTestMode.None),不会拦截触摸事件。
第三层:内容层。 一个全屏的 Column,包含 this.mainContent()(主内容)和 this.bottomBar()(底部导航)。内容层覆盖在特效层之上,用户可以看到背景中跳动的排序柱和闪烁的光标。
第四至七层(顶层):弹框层。 四个 if 条件分别控制四套弹框的渲染。每套弹框使用 Stack 容器,内含 this.modalOverlay()(遮罩层)和弹框主体。弹框宽度 88%(width('88%')),最大高度 80%(constraintSize({ maxHeight: '80%' })),圆角 16,zIndex(999) 确保弹框在最顶层。
Stack 容器的层叠顺序是"后渲染在上"——背景色最先渲染(最底层),弹框最后渲染(最顶层)。这种"从底到顶"的层叠设计是 ArkUI 中实现复杂 UI 层次的标准模式。
四套弹框的条件渲染使用 if 而非 visibility 属性——if 条件为 false 时组件不渲染(不占用 DOM 节点),visibility: false 时组件渲染但不可见(占用 DOM 节点)。使用 if 可以在弹框关闭时释放 DOM 资源,对于不频繁使用的弹框更加高效。
二十一、功能模块对比表
| 模块 | 布局方式 | 数据模型 | 核心字段数 | 特殊视觉元素 | 动画效果 | 交互入口 |
|---|---|---|---|---|---|---|
| 精选首页 | 四格+柱状图+双列卡+进度条 | WeekChartItem/MatChartItem/PlayTypeItem | 3-4 | 双阶进度指示器 | 无 | 发题解/去约战/开始挑战 |
| 题库列表 | 垂直列表+左侧色条+进度条 | ProblemItem | 6 | 难度三色编码+标签色条 | 无 | 贡献题目/开始挑战 |
| 我的训练营 | 垂直列表+进度条 | PlanItem | 5 | 状态五色映射+武道馆图标 | 无 | 编辑计划 |
| 刷题工坊 | 四格+排行榜+列表+双列卡 | ProblemItem/PlanItem/GearItem | 4 | Top3高亮+WA金色+错题本 | 无 | 加购 |
| 算法课堂 | 垂直列表+进度条 | CourseItem | 4 | 五色进度条+退课按钮 | 无 | 退课 |
| 码友圈 | 垂直列表 | PostItem | 6 | Emoji头像+回复提示 | 无 | 发题解/回复 |
| 插件库 | 三格+垂直列表 | GearItem | 4 | 分类大卡+加购按钮 | 无 | 加购/放弃计划 |
| 我的页面 | 用户卡+档案卡+动态卡 | PlanItem/PostItem | 6 | 紫名段位+修行天数 | 无 | 查看 |
| 发题解弹框 | 表单输入 | PostItem | 2 | 发光点呼吸闪烁+AC标签 | glow驱动90ms | 发布/取消 |
| 编辑弹框 | 表单输入 | PlanItem | 3 | 等级标签头卡+预填充 | 无 | 保存/放弃 |
| 删除弹框 | 警告卡 | delTarget | 1 | danger色块头卡+挖空文字 | 无 | 确认/再想想 |
| 导师预约弹框 | 表单+服务标签 | PostItem | 2 | 横幅头卡+三标签+免费高亮 | 无 | 提交预约 |
| 特效层 | Stack+ForEach | tick/glow | 2 | 排序柱+光标闪烁 | 90ms定时器 | 无(穿透触摸) |
| 底部导航 | 四等分行 | NavItem | 2 | 透明度+颜色双反馈 | 无 | Tab切换 |
| 子导航栏 | 横向滚动+双阶指示器 | SUB_NAV_LIST | 1 | 双层粗细指示器 | 无 | 子Tab切换 |
| 色彩体系 | 16色常量 | ColorPalette | 16 | 深色主题反转逻辑 | 无 | 全局引用 |
| 数据模型 | 4个@Observed类 | PostItem/ProblemItem/PlanItem/GearItem | 4-6 | @Observed响应式 | 无 | 弹框操作修改 |
| 工具函数 | 11个纯函数 | 无 | 0 | 颜色映射+动画计算 | tick驱动 | 全局调用 |
二十二、总结与展望
22.1 深色主题色彩系统的反转逻辑与实践价值
本文以"算语"算法刷题与技术面试训练营应用为例,完整剖析了一个基于 HarmonyOS ArkUI 框架开发的深色主题移动端应用的技术实现。从架构设计层面来看,这个应用展示了以下几个值得深入思考的设计模式。
深色主题色彩系统的反转逻辑是本文最核心的技术洞察之一。在深色主题中,primaryLight 不是主色的浅化版本而是暗化版本,primaryDark 不是主色的深化版本而是亮化版本。这种"反转"逻辑是深色主题设计的核心原则——所有浅色主题中的"变亮"操作在深色主题中对应"变暗",反之亦然。理解这种反转逻辑是从浅色主题迁移到深色主题的关键。同时,深色背景上的颜色选择需要特别关注对比度——主色和强调色需要足够明亮才能在深色背景上清晰可见,而背景色和卡片色之间的明度差需要控制在微妙的范围内(约 10%),既要让卡片浮起,又不能过于突兀。文本色阶需要精心设计三级对比度(7:1/4.5:1/3:1),确保信息层次清晰且符合 WCAG 无障碍标准。状态色需要更高的饱和度才能在深色背景上达到与浅色背景相同的视觉冲击力。这些细节构成了深色主题设计的完整知识体系。
22.2 排序柱动画的算法隐喻与主题呼应
排序柱动画的算法隐喻设计是本文最具创意的技术亮点。特效层的排序柱动画不仅是一种视觉装饰,更是对应用主题(算法排序)的概念性呼应。6 根高度不断变化的柱子模拟了排序算法中柱子交换的视觉过程,让用户在浏览内容时潜意识地感受到"算法"的氛围。这种"特效层与应用主题的概念性呼应"是高级 UI 设计的体现——不是随意的粒子飘散或渐变色块,而是有主题内涵的动画设计。sortBarH 函数中的 tick * 3 + i * 29 计算公式精心选择了系数——3 让高度随时间缓慢变化,29(质数)让不同柱子的高度充分分散,取模 54 确保高度有界。这种"数学之美驱动视觉之美"的设计理念值得在更多应用中推广。同时配合光标闪烁点模拟代码编辑器中的光标,让整个界面充满了"代码竞技"的紧张感——两种特效一个模拟排序过程,一个模拟编码过程,共同构建了"算法编程"的视觉氛围。
22.3 双阶进度式指示器的学习隐喻与信息密度
双阶进度式指示器的学习隐喻是本文在 UI 组件微观设计层面的重要发现。子导航栏的双层指示器(粗条+细条)不仅是一种视觉装饰,更暗示了"双阶进阶"的学习理念——当前阶段和下一阶段同时可见,让用户始终知道"下一步在哪里"。这种设计比单条指示器传达了更多的信息量,在有限的导航空间中实现了更丰富的视觉表达。粗条使用主色青蓝(冷色),细条使用强调色绿(偏暖),两色的组合在深色背景上形成了冷暖搭配,比单色指示器更加醒目。4px 和 2px 的粗细差异形成了"主次分明"的层次——粗条是"主"(当前阶段),细条是"次"(过渡到下一阶段)。未选中态的 16px 宽 2px 高灰色短条比选中态更窄更细,视觉上明显退后,让选中态自然成为视觉焦点。这种"选中粗+未选细"的对比设计在导航栏有限的空间中实现了最大的视觉区分度。
22.4 状态映射函数的认知层次设计
状态映射函数的认知层次设计是本文在数据驱动 UI 层面的深度思考。statusProgress 函数中"复盘错题中"(58%)的进度高于"做题中"(42%),这种看似反直觉的设计实际上体现了算法学习的认知层次——复盘错题是在做题基础上的深入学习,学习深度更高。这种映射逻辑将简单的"状态到进度"映射升级为有教育理念的"学习深度到进度"映射。“卡壳中”(26%)高于"待开始"(4%)但低于"做题中"(42%),因为卡壳意味着已经开始了但遇到了困难,进度介于"未开始"和"正常推进"之间。这种"认知层次驱动的进度映射"比简单的"时间进度"更加科学,体现了算法学习平台对学习过程的深刻理解。statusColor 函数与 statusProgress 函数配合使用,确保了状态标签颜色、进度条颜色和进度百分数三者的一致性——这种"状态驱动一切"的设计避免了 UI 不一致的问题。
22.5 导师陪跑系统的服务化设计与商业逻辑
导师陪跑系统的服务化设计是本文在产品商业模式层面的重要分析。导师预约弹框将工具型产品升级为服务型产品,通过"首次免费"“限时陪练”"不过重学"三个服务标签构建了完整的商业服务体系。弹框的横幅头卡使用主色青蓝全宽背景和深色图标方块,形成了强烈的视觉焦点,引导用户关注这项核心服务。三个服务标签的颜色差异化设计——"首次免费"用主色青蓝高亮,后两个用辅助色低调处理——引导用户关注"免费"这一核心卖点。"首次免费"是转化用户的关键钩子,"限时陪练"制造紧迫感,"不过重学"提供保障承诺——三个标签构成了完整的"引流-转化-保障"商业漏斗。将"导师陪跑"入口放在连胜卡上——连胜用户是最有动力继续提升的群体,是导师服务的精准目标用户。将"开始挑战"按钮与导师预约弹框绑定——引导用户从免费做题升级到付费的导师陪跑服务,这种转化漏斗设计体现了产品商业化的深思熟虑。
22.6 技术演进方向与未来展望
从技术演进的角度来看,这款算法训练平台还可以在以下方向继续深化:
第一,引入 @StorageLink 实现跨页面数据持久化。 当前的 postList 和 planList 是组件级的 @State 变量,组件销毁后数据丢失。通过 @StorageLink 可以将数据持久化到 AppStorage,让用户的刷题进度在应用重启后不丢失。这是从"演示应用"到"生产应用"的关键升级。
第二,使用 LazyForEach 替代 ForEach 处理大规模题库列表。 当前的 ForEach 会一次性渲染所有列表项,对于 8 条数据没有问题,但如果题库扩展到数百或数千道题目,ForEach 会导致严重的性能问题。LazyForEach 只渲染可见区域的列表项,随滚动动态加载和卸载,可以处理任意规模的列表。
第三,引入 animateTo 显式动画 API 替代定时器驱动的特效系统。 当前的特效通过 setInterval 每 90ms 更新 tick 状态来驱动动画,这种方式虽然简单但有两个缺点——CPU 占用恒定(不管用户是否在看屏幕都在运行),且动画帧率固定在约 11fps。animateTo API 可以让框架在 GPU 层面处理动画,获得更流畅的动画效果(60fps)和更低的 CPU 占用,且在页面不可见时自动暂停。
第四,将导师预约系统与日历组件联动。 当前的导师预约只有文本输入"期望时段",用户无法直观地看到可选时段。通过集成日历组件,用户可以在日历上选择可用时段,系统自动检查时段冲突,提供更友好的预约体验。
第五,引入 @ObservedV2 和 @Trace 实现更细粒度的响应式追踪。 ArkUI 的新一代响应式 API(@ObservedV2 + @Trace)可以追踪对象属性的精确变化,而非整个对象的重新赋值。这对于训练计划的"只修改状态字段而不重建整个对象"场景特别有用,可以减少不必要的重渲染。
第六,支持深色/浅色主题动态切换。 当前应用硬编码了深色色彩方案,无法在运行时切换到浅色。通过定义两套 ColorPalette(深色版和浅色版),配合 @StorageLink 存储当前主题选择,可以实现运行时主题切换。这需要在色彩变量命名上做一些调整——如前文所述,深色主题中 primaryLight 实际是暗化版本,切换到浅色主题时需要使用真正的主色浅化版本。
HarmonyOS ArkUI 的声明式 UI 范式为深色主题应用开发提供了强大的支持——通过自定义 ColorPalette 接口即可实现完整的深色色彩方案,通过纯函数计算即可驱动复杂的动画特效,通过 @State 和 @Observed 装饰器即可实现响应式数据绑定,通过 @Builder 方法即可实现可复用的 UI 片段,通过 @Entry 和 @Component 装饰器即可定义页面入口和自定义组件。通过对这个算法训练平台的逐段代码分析,我们可以看到,优秀的深色主题应用不仅需要合理的色彩体系设计,更需要在动画隐喻、交互层次、状态映射、服务化设计等多个维度上进行系统化的思考和打磨。这款应用以"算法竞技"为核心主题,将排序柱动画、光标闪烁、双阶进度、状态认知、服务标签等设计元素编织成一个完整的用户体验,为 HarmonyOS 生态中的深色主题应用开发提供了一个值得深入研究的实践范本。
附录:DevEco Studio 创建新项目与查看 SDK 版本
本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。
一、创建新项目
1.1 进入欢迎界面
启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:
- 新建项目:从头创建新项目
- 打开项目:打开本地已有项目
- 克隆仓库:从 Git 等版本控制拉取代码
点击 “新建项目” 按钮,进入项目创建向导。

1.2 选择项目模板
在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:
| 类型 | 说明 |
|---|---|
| 应用(Application) | 开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期 |
| 元服务(Atomic Service) | 开发轻量级的原子化服务,无需安装即可使用 |
选择 “应用” 标签后,右侧展示多种模板。对于大多数场景,推荐选择 “Empty Ability” —— 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

1.3 配置项目信息
点击 “下一步” 后,进入项目配置界面,需要填写以下核心参数:
| 配置项 | 示例值 | 说明 |
|---|---|---|
| 项目名称(Project name) | rollboat |
应用的项目名称,建议使用英文命名 |
| 包名(Bundle name) | com.rollboat.myapplication |
应用唯一标识,采用反向域名格式 |
| 保存路径(Save location) | D:\CodeFactory\rollboat |
项目本地存储路径,避免使用中文和空格 |
| 兼容 SDK(Compatible SDK) | 6.1.1(24) |
目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异 |
| 模块名称(Module name) | entry |
主模块名称,默认 entry 为应用入口模块 |
| 设备类型(Device types) | ☑ Phone | 勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV |
右侧预览区会实时展示当前模板的默认效果 —— 一个居中显示的 “Hello World” 文本。

1.4 完成创建
确认配置无误后,点击右下角 “完成” 按钮,IDE 将自动执行以下操作:
- 生成项目骨架(Stage 模型目录结构)
- 执行
ohpm install安装依赖 - 运行 Hvigor 构建初始化(
Build Init)
构建日志中显示 “退出代码为 0” 表示项目初始化成功。

1.5 项目结构概览
创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:
rollboat/
├── .hvigor/ # Hvigor 构建工具缓存
├── .idea/ # IDE 配置文件
├── AppScope/ # 应用级全局配置
│ └── app.json5
├── entry/ # 主模块(入口模块)
│ ├── src/main/ets/
│ │ ├── entryability/ # Ability 生命周期管理
│ │ │ └── EntryAbility.ets
│ │ └── pages/ # UI 页面
│ │ └── Index.ets # 首页(默认 Hello World)
│ ├── src/main/resources/ # 资源文件
│ ├── module.json5 # 模块配置
│ └── build-profile.json5 # 构建配置
├── oh_modules/ # OHPM 依赖包
├── build-profile.json5 # 工程构建配置
├── hvigorfile.ts # Hvigor 构建脚本
└── oh-package.json5 # 包管理配置
核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:
@Entry
@Component
struct Index {
@State message: string = 'Hello World';
build() {
RelativeContainer() {
Text(this.message)
.id('HelloWorld')
.fontSize($r('app.float.page_text_font_size'))
.fontWeight(FontWeight.Bold)
.alignRules({
center: { anchor: '__container__', align: VerticalAlign.Center },
middle: { anchor: '__container__', align: HorizontalAlign.Center }
})
.onClick(() => {
this.message = 'Welcome';
})
}
.height('100%')
.width('100%')
}
}
| 关键语法 | 作用 |
|---|---|
@Entry |
标记为页面入口,可用于路由跳转 |
@Component |
声明为自定义组件 |
@State |
状态变量,数据变更时自动触发 UI 刷新 |
RelativeContainer |
相对布局容器,替代传统线性布局 |
.onClick() |
点击事件,此处点击后文本变为 “Welcome” |
打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

二、查看 SDK 版本
2.1 查看 HarmonyOS SDK
DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:
文件 → 设置 → HarmonyOS SDK(或快捷键
Ctrl + Alt + S搜索 “HarmonyOS SDK”)
在设置面板中,可以看到当前已安装的 SDK 版本信息:
| 名称 | 阶段 | 状态 |
|---|---|---|
| HarmonyOS 6.1.1 | Release | ✅ 已安装 |
界面顶部提示:“HarmonyOS SDK 已经包含在 IDE,无需单独安装”,省去了手动配置 SDK 的繁琐步骤。

2.2 查看 ArkUI-X SDK(跨平台扩展)
如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:
文件 → 设置 → 语言和框架 → ArkUI-X
在这里可以查看已安装和可选的 ArkUI-X SDK 版本:
| 版本 | SDK 版本号 | 阶段 | 状态 |
|---|---|---|---|
| API Version 24 | 6.1.1.100 | Release | ✅ 已安装 |
| API Version 23 | 6.1.0.28 | Beta1 | 未安装 |
| API Version 22 | 6.0.2.112 | Release | 未安装 |
安装路径示例:D:\DevTools\ArkUI-X\sdk
说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

三、小结
| 步骤 | 操作 | 关键点 |
|---|---|---|
| 创建项目 | 欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成 | 使用 Stage 模型 + ArkTS 语言 |
| 查看 SDK | 设置 → HarmonyOS SDK | SDK 已内置,无需手动安装 |
| 跨平台扩展 | 设置 → ArkUI-X | 根据需要安装对应 API 版本 |
至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。
本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。
更多推荐


所有评论(0)