一、技术背景与开发概述

鸿蒙HarmonyOS与ArkTS语言

在这里插入图片描述

鸿蒙HarmonyOS是华为推出的面向全场景的分布式操作系统,其设计理念是"一次开发,多端部署"。在鸿蒙生态中,应用开发框架经历了从Java UI到ArkUI声明式范式的演进。ArkTS是在TypeScript基础上扩展而来的编程语言,专为鸿蒙应用开发设计。它在TypeScript的类型系统之上增加了鸿蒙特有装饰器系统,包括@Component@Entry@State@Builder@Prop@Link等,使开发者能够以声明式的方式构建用户界面。

ArkTS语言的核心优势在于其强类型系统与声明式UI范式的结合。强类型系统在编译期即可捕获大量潜在错误,如类型不匹配、空引用等问题。声明式UI则将界面的描述与逻辑分离,开发者只需声明"界面应该是什么样子",框架负责在状态变化时自动更新视图。这种范式大幅减少了传统命令式UI开发中大量的findViewByIdsetTextsetVisibility等样板代码。

ArkUI声明式UI范式

在这里插入图片描述

ArkUI是鸿蒙的声明式UI开发框架,其核心思想借鉴了现代前端框架如SwiftUI和Jetpack Compose的设计理念。在ArkUI中,界面由一系列嵌套的组件构成,每个组件都是一个struct结构体,通过build()方法描述其UI结构。组件之间的数据流动通过装饰器实现:@State管理组件内部状态,@Prop接收父组件传递的只读数据,@Link建立父子组件间的双向绑定,@Builder定义可复用的UI片段。

ArkUI的布局系统提供了丰富的容器组件:Column用于垂直线性布局,Row用于水平线性布局,Stack用于层叠布局,Flex用于弹性布局,Scroll用于可滚动区域。这些容器可以任意嵌套组合,构建出复杂的界面结构。样式方法采用链式调用风格,如.width().height().padding().borderRadius().linearGradient()等,使得UI代码既直观又紧凑。

本文分析的应用场景

本文将深入解析一个人事管理App中的"入职橙"模块。该模块是一个完整的入职离职全流程管理系统,面向新员工从入职报到到试用期转正的全生命周期管理。应用采用底部五Tab导航架构,分别是"入职"(任务清单管理)、“材料”(入职材料提交与审核)、“流程”(入职流程跟踪与离职服务)、“伙伴”(入职伙伴网络与互动打卡)、“我的”(个人信息与成长计划)。

该模块的技术亮点包括:六组静态数据模型定义了入职任务、材料清单、流程步骤、伙伴信息、打卡记录等业务实体;五个子组件各自独立管理状态和弹窗逻辑;六种弹窗交互覆盖了任务确认、材料上传、离职申请、撤回警示、联系伙伴、信息编辑等核心业务场景;多种动画特效包括进度条动画、任务勾选缩放、柱状图动画、环形进度等。整体采用青蓝色清新视觉风格,通过linearGradient渐变和精心设计的配色方案营造出专业的企业应用氛围。

二、类型定义与数据模型分析

在这里插入图片描述

接口定义解析

本应用在文件顶部定义了五个interface接口,构建了完整的数据模型层。

interface OnboardTask {
  id: number
  title: string
  desc: string
  icon: string
  points: number
  done: boolean
  urgent: boolean
}

interface MaterialItem {
  id: number
  name: string
  icon: string
  status: string
  note: string
}

interface FlowStep {
  id: number
  title: string
  time: string
  operator: string
  state: string
}

interface BuddyItem {
  id: number
  name: string
  avatar: string
  dept: string
  duty: string
  intro: string
  contact: boolean
}

interface CheckinLog {
  id: number
  day: string
  times: number
}

在ArkTS中,interface用于定义对象的类型结构,这与TypeScript的接口语法一致。第一个接口OnboardTask定义了入职任务的数据结构,包含id唯一标识、title任务标题、desc任务描述、icon图标emoji、points完成积分、done完成状态标记、urgent紧急标记共七个字段。这七个字段覆盖了一个入职任务所需的全部信息维度。

MaterialItem定义了入职材料项的结构,status字段用字符串表示材料状态(如"已通过"、“审核中”、“未提交”),这种设计使得状态展示具有灵活性。FlowStep定义了流程步骤,包含time时间戳和operator操作人,用于构建入职流程的时间轴展示。BuddyItem定义了入职伙伴信息,contact布尔值标记是否可联系。CheckinLog是最简洁的接口,仅包含星期几和打卡次数两个字段。

这种将数据模型与UI组件分离的设计模式是ArkTS开发的最佳实践之一。通过在文件顶部集中定义所有接口,开发者可以在编写组件代码之前就明确业务数据的结构,避免在编码过程中频繁回头修改类型定义。

静态数据常量解析

在这里插入图片描述

定义接口之后,应用声明了五组const常量数组,作为模拟数据源。

const TASK_LIST: OnboardTask[] = [
  { id: 1, title: '完成入职信息登记', desc: '填写个人基本信息、银行卡、紧急联系人', icon: '📝', points: 10, done: true, urgent: false },
  { id: 2, title: '上传入职材料', desc: '身份证、学历证书、离职证明、体检报告', icon: '📄', points: 20, done: true, urgent: false },
  { id: 3, title: '签署电子劳动合同', desc: '在线阅读并签署三年期劳动合同', icon: '🖊️', points: 15, done: true, urgent: false },
  { id: 4, title: '领取办公设备', desc: '到 IT 服务台领取笔记本与外设套装', icon: '💻', points: 10, done: true, urgent: false },
  { id: 5, title: '开通账号权限', desc: '邮箱、内网、代码仓库、工单系统', icon: '🔑', points: 10, done: true, urgent: false },
  { id: 6, title: '参加入职培训', desc: '企业文化、信息安全、职业素养三堂课', icon: '🎓', points: 20, done: false, urgent: true },
  { id: 7, title: '完成导师破冰', desc: '与 Mentor 一对一沟通,制定 30 天成长计划', icon: '🤝', points: 10, done: false, urgent: false },
  { id: 8, title: '转正自评提交', desc: '三个月试用期满后填写自评表', icon: '📊', points: 5, done: false, urgent: false }
]

TASK_LIST数组包含八项入职任务,从信息登记到转正自评,覆盖了新员工入职的全流程。每个任务对象严格遵循OnboardTask接口定义,字段类型完全匹配。注意前五项的donetrue(已完成),第六项urgenttrue(今日必办),这种数据设计模拟了一位入职第七天的新员工状态,使应用展示更加真实可信。const关键字确保这些数组引用不可变,虽然在ArkTS中数组内容仍可修改,但使用const表达了这些数据作为静态数据源的意图。

类似地,MATERIAL_LIST定义了六项入职材料,FLOW_STEPS定义了六个流程步骤,BUDDY_LIST定义了六位入职伙伴,CHECKIN_LOG定义了七天打卡记录。这些数据在应用中被多个组件通过ForEach渲染,构成了整个应用的"数据骨架"。

Tab枚举与辅助函数

enum OnboardTab { TASK, MATERIAL, FLOW, BUDDY, MINE }

interface OnboardTabMeta {
  icon: string
  label: string
}

const ONBOARD_TABS: OnboardTabMeta[] = [
  { icon: '🧭', label: '入职' },
  { icon: '📄', label: '材料' },
  { icon: '🔀', label: '流程' },
  { icon: '🤝', label: '伙伴' },
  { icon: '👤', label: '我的' }
]

enum是ArkTS中的枚举类型,OnboardTab定义了五个Tab页的枚举值。枚举的成员从0开始自动递增(TASK=0, MATERIAL=1, FLOW=2, BUDDY=3, MINE=4),这种数值枚举在Tab切换逻辑中用于比较当前选中页。OnboardTabMeta接口定义了Tab的元数据结构,ONBOARD_TABS数组将枚举值与显示信息关联。这种设计将Tab的定义与渲染分离,使得添加或修改Tab只需修改数据数组,而无需改动渲染逻辑。

function getMatStatusColor(s: string): string {
  if (s === '已通过') {
    return '#00897B'
  } else if (s === '审核中') {
    return '#F9A825'
  } else {
    return '#EF6C00'
  }
}

function getFlowStateColor(s: string): string {
  if (s === '已完成') {
    return '#00838F'
  } else if (s === '进行中') {
    return '#F9A825'
  } else {
    return '#B0BEC5'
  }
}

两个全局纯函数getMatStatusColorgetFlowStateColor分别根据材料状态字符串和流程状态字符串返回对应的颜色值。纯函数是指输入相同参数必定返回相同结果且无副作用的函数,在ArkUI中,将颜色映射逻辑提取为纯函数有几个好处:避免在UI代码中嵌入大量条件判断、提高代码可读性、便于统一修改配色方案。这两个函数在ForEach渲染中被调用,实现了状态到颜色的动态映射。

三、核心组件逐段分析

在这里插入图片描述

主入口组件 OnboardApp

@Entry装饰器标记OnboardApp为应用的根组件,即页面入口。@Component装饰器声明这是一个自定义组件。每个ArkTS页面有且仅有一个@Entry组件。

@Entry
@Component
struct OnboardApp {
  @State currentTab: OnboardTab = OnboardTab.TASK

  build() {
    Column() {
      // ===== 头部(电商风渐变:进度概览) =====
      Column() {
        Row() {
          Column() {
            Text('🚀 欢迎加入,第 7 天').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text('试用期剩余 83 天 · 入职完成度 75%').fontSize(9).fontColor('#B2EBF2').margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start).layoutWeight(1)

          Column() {
            Text('850').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text('入职积分').fontSize(8).fontColor('#B2EBF2').margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%').padding({ left: 16, right: 16, top: 12 })
        ...
      }
      .width('100%')
      .linearGradient({ angle: 120, colors: [['#00838F', 0], ['#26C6DA', 1]] }

@State装饰器声明了组件内部状态变量currentTab,初始值为OnboardTab.TASK@State是ArkUI状态管理的核心,当被装饰的变量发生变化时,框架会自动触发build()方法的重新执行,更新依赖该状态的UI部分。这里currentTab控制着五个Tab页的切换,其值的改变会直接驱动内容区域的组件替换。

build()方法是每个组件必须实现的方法,返回该组件的UI结构。最外层的Column是垂直线性布局容器,将页面分为头部、内容区、底部Tab三个垂直区域。

头部区域是一个嵌套的Column,内部先放一个Row水平排列欢迎信息和积分信息。Row是ArkUI的水平布局容器,子元素默认从左到右排列。左侧Column使用alignItems(HorizontalAlign.Start)设置子元素左对齐,通过layoutWeight(1)占据剩余空间。右侧Column使用alignItems(HorizontalAlign.End)设置右对齐。Text组件的fontSize设置字体大小,fontWeight设置字重,fontColor设置文字颜色,margin设置外边距。

linearGradient是ArkUI的线性渐变样式方法,接受一个对象参数:angle指定渐变角度(120度),colors数组定义渐变颜色断点,['#00838F', 0]表示起点颜色为深青色,['#26C6DA', 1]表示终点颜色为亮青色。这种双色渐变营造了清新专业的视觉效果,符合企业人事应用的调性。

        // 入职进度条(动画特效)
        Column() {
          Column()
            .width(this.currentTab === OnboardTab.TASK ? '75%' : '0%')
            .height(6).backgroundColor('#FFFFFF').borderRadius(3)
            .animation({ duration: 1000, curve: Curve.EaseOut })
        }
        .width('92%').height(6).backgroundColor('rgba(255,255,255,0.3)').borderRadius(3)
        .margin({ top: 12, bottom: 14 })

这段代码实现了入职进度条动画。外层Column是进度条容器,宽度92%,高度6px,背景使用rgba半透明白色。内层Column是进度条填充部分,其宽度根据currentTab的值动态变化:当处于TASK页时显示75%宽度,其他页面显示0%宽度。animation方法为这个宽度变化添加了1000毫秒的缓出动画,Curve.EaseOut是缓出曲线,使得动画开始快、结束慢,给用户以柔和的视觉感受。

animation是ArkUI的属性动画方法,它作用于前一个属性变化。当width'75%'变为'0%'或反向变化时,框架会以指定的时间和曲线自动插值,产生平滑的过渡效果。这种声明式动画的方式非常简洁,开发者只需声明起始和终止状态,动画过程由框架自动处理。

      // ===== 内容区 =====
      Column() {
        if (this.currentTab === OnboardTab.TASK) {
          TaskListContent()
        } else if (this.currentTab === OnboardTab.MATERIAL) {
          MaterialCheckContent()
        } else if (this.currentTab === OnboardTab.FLOW) {
          FlowTrackContent()
        } else if (this.currentTab === OnboardTab.BUDDY) {
          BuddyNetworkContent()
        } else {
          MyOnboardContent()
        }
      }
      .layoutWeight(1).width('100%')

内容区使用if-else条件渲染,根据currentTab的值决定渲染哪个子组件。这是ArkUI条件渲染的基本模式:在build()方法中使用if语句控制组件的创建和销毁。layoutWeight(1)使内容区占据头部和底部Tab之间的全部剩余空间。当currentTab变化时,框架会自动销毁旧组件、创建新组件,实现页面切换效果。

      // ===== 底部 Tab(5个单排) =====
      Divider().color('#E0F7FA').strokeWidth(1)

      Row() {
        ForEach(ONBOARD_TABS, (t: OnboardTabMeta, idx: number) => {
          Column() {
            Text(t.icon).fontSize(19)
              .animation({ duration: 200, curve: Curve.EaseOut })
            Text(t.label).fontSize(9)
              .fontColor(this.currentTab === idx ? '#00838F' : '#9E9E9E')
              .margin({ top: 2 })
              .fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)
          .padding({ top: 6, bottom: 6 })
          .onClick(() => {
            this.currentTab = idx as OnboardTab
          })
        }, (t: OnboardTabMeta) => t.label)
      }
      .width('100%').backgroundColor('#FFFFFF')

底部Tab栏使用Divider分隔线与内容区分隔。Divider是ArkUI的分割线组件,color设置颜色,strokeWidth设置线宽。

ForEach是ArkUI的循环渲染组件,其签名类似ForEach(arr, itemGenerator, keyGenerator)。第一个参数ONBOARD_TABS是数据源数组,第二个参数是项生成函数,接收每一项数据和索引idx,第三个参数是键值生成函数,用于框架的差分更新。这里使用t.label作为键值,确保Tab项的唯一标识。

每个Tab项是一个Column,包含图标Text和文字Text。图标添加了200毫秒的缓出动画,当Tab切换时图标会有轻微的过渡效果。文字颜色通过三元表达式this.currentTab === idx ? '#00838F' : '#9E9E9E'实现选中态和未选中态的颜色切换,选中时为深青色加粗,未选中时为灰色常规。onClick设置点击事件,将idx转换为OnboardTab枚举赋值给currentTab,触发页面切换。layoutWeight(1)使五个Tab等宽分布。

OnboardApp 入口组件

头部渐变区

内容区 layoutWeight=1

底部Tab栏

欢迎信息 Row

入职积分 Row

进度条动画

TaskListContent

MaterialCheckContent

FlowTrackContent

BuddyNetworkContent

MyOnboardContent

ForEach 渲染5个Tab

入职

材料

流程

伙伴

我的

入职任务组件 TaskListContent

在这里插入图片描述

TaskListContent是入职Tab页的内容组件,负责展示入职任务清单、任务完成度、今日必办提示等功能。

@Component
struct TaskListContent {
  @State showConfirm: boolean = false
  @State taskTitle: string = '参加入职培训'
  @State taskDesc: string = '企业文化、信息安全、职业素养三堂课'
  @State taskIcon: string = '🎓'
  @State taskPoints: number = 20
  @State doneIds: number[] = [1, 2, 3, 4, 5]
  @State checkScale: number = 1.0
  @State barGrow: boolean = false

该组件声明了七个@State状态变量。showConfirm控制任务确认弹窗的显示与隐藏。taskTitletaskDesctaskIcontaskPoints四个变量存储当前选中任务的信息,这些变量会在弹窗中动态显示。doneIds是一个数字数组,存储已完成任务的ID列表,初始值为[1, 2, 3, 4, 5],表示前五项任务已完成。checkScale用于任务勾选时的缩放动画。barGrow预留用于进度条动画触发。

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column().width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)')
        .onClick(() => {
          onClose()
        })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(998)
  }

@Builder装饰器定义了一个可复用的UI构建方法。modalOverlay是弹窗遮罩层的通用模板,接收一个onClose回调函数参数。遮罩层是一个全屏Column,背景使用半透明黑色rgba(0,0,0,0.5)position设置为绝对定位覆盖全屏,zIndex(998)确保遮罩层位于内容之上但弹窗之下。点击遮罩层会触发onClose回调关闭弹窗。这种通过@Builder封装通用遮罩层的模式在应用的每个子组件中重复使用,保证了弹窗交互的一致性。

  // 弹框1:任务完成确认(清单勾选式)
  @Builder confirmTaskModal() {
    Column() {
      Column() {
        Row() {
          Text(this.taskIcon).fontSize(30).margin({ left: 16 })
          Column() {
            Text(this.taskTitle).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#006064')
            Text('+' + this.taskPoints.toString() + ' 积分').fontSize(10).fontColor('#F9A825').margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start).margin({ left: 10 })
        }
        .width('100%').padding({ top: 18 })

        Text(this.taskDesc).fontSize(11).fontColor('#757575')
          .width('100%').padding({ left: 16, top: 8 })

任务确认弹窗是一个居中卡片式弹窗。顶部Row水平排列任务图标和任务标题信息,图标使用fontSize(30)放大显示,标题下方显示可获得的积分值。this.taskPoints.toString()将数字转为字符串用于拼接显示,这是ArkTS中数字到字符串的常见转换方式。弹窗使用#006064(深青色)作为标题颜色,#F9A825(琥珀色)作为积分颜色,与整体青蓝配色方案保持一致。

        Column() {
          ForEach(['我已经完成该任务的全部要求', '相关证明材料已上传(如需)', '确认信息真实有效'], (r: string) => {
            Row() {
              Radio({ value: r, group: 'task' })
                .width(16).height(16)
              Text(r).fontSize(11).fontColor('#424242').margin({ left: 8 })
            }
            .width('100%').padding({ left: 16, top: 10 })
          }, (r: string) => r)
        }
        .width('100%')

弹窗中部使用ForEach渲染三个声明项,每项包含一个Radio单选按钮和对应的Text说明。Radio是ArkUI的单选框组件,value设置选项值,group设置单选组名,同一组内只能选中一个。这里将三个声明项放入同一个task组中,用户需要逐项确认。Radio的尺寸设置为16x16,与文字之间通过margin({ left: 8 })保持间距。

        Row() {
          Button() {
            Text('稍后再说').fontSize(13).fontColor('#616161')
          }
          .layoutWeight(1).height(38).backgroundColor('#ECEFF1').borderRadius(19)
          .onClick(() => {
            this.showConfirm = false
          })

          Button() {
            Text('✓ 确认完成').fontSize(13).fontColor('#FFFFFF')
          }
          .layoutWeight(1).height(38).backgroundColor('#00838F').borderRadius(19)
          .margin({ left: 12 })
          .onClick(() => {
            if (this.taskTitle === '参加入职培训') {
              this.doneIds = this.doneIds.concat([6])
            }
            this.checkScale = 1.4
            setTimeout(() => {
              this.checkScale = 1.0
            }, 200)
            this.showConfirm = false
          })
        }
        .width('88%').margin({ top: 20, bottom: 20 })
      }
      .width('90%').backgroundColor('#FFFFFF').borderRadius(16)
    }
    .width('100%').alignItems(HorizontalAlign.Center)
    .position({ y: '30%' }).zIndex(999)

弹窗底部是两个按钮的Row,使用layoutWeight(1)等宽分布。Button是ArkUI的按钮容器组件,内部包裹Text作为按钮文字。borderRadius(19)配合height(38)使按钮呈现胶囊形状。取消按钮使用灰色背景,确认按钮使用主题色#00838F背景。

确认按钮的onClick逻辑包含三个操作:首先检查当前任务标题是否为"参加入职培训",如果是则将任务ID 6添加到doneIds数组(使用concat方法返回新数组而非原地修改,这是ArkTS状态更新的推荐方式,确保框架能检测到变化);然后设置checkScale为1.4触发缩放动画,200毫秒后通过setTimeout恢复为1.0;最后关闭弹窗。

setTimeout在ArkTS中可用于延迟执行,配合animation属性可以实现"先放大再缩回"的弹性效果。整个弹窗使用position({ y: '30%' })垂直定位在屏幕30%处,zIndex(999)确保位于遮罩层之上。

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 今日必办提示卡
          Row() {
            Text('⏰').fontSize(22)
            Column() {
              Text('今日必办').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              Text('「参加入职培训」 今天 16:00 · 3 号多功能厅').fontSize(9).fontColor('#B2EBF2').margin({ top: 3 })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 10 })

            Text('去完成').fontSize(10).fontColor('#00838F').backgroundColor('#FFFFFF')
              .borderRadius(12).padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .onClick(() => {
                this.taskTitle = '参加入职培训'
                this.taskDesc = '企业文化、信息安全、职业素养三堂课,完成全部课程并通过随堂测验'
                this.taskIcon = '🎓'
                this.taskPoints = 20
                this.showConfirm = true
              })
          }
          .width('96%').padding(14)
          .linearGradient({ angle: 135, colors: [['#006064', 0], ['#00ACC1', 1]] })
          .borderRadius(14).margin({ top: 10 })

build()方法使用Stack作为根容器。Stack是ArkUI的层叠布局容器,子元素可以重叠放置,后声明的子元素位于上层。这里使用Stack的目的是将弹窗层叠加在内容层之上。

Scroll是可滚动容器组件,当内容超出可视区域时用户可以滚动查看。scrollBar(BarState.Off)隐藏滚动条,使界面更简洁。

今日必办提示卡是一个Row,使用linearGradient渐变背景,从#006064(深青色)到#00ACC1(亮青色),135度渐变方向。左侧是闹钟emoji和任务信息,右侧是"去完成"按钮,使用白色背景和青色文字形成反差按钮效果。点击按钮时,将任务信息赋值到状态变量并打开确认弹窗,这种模式实现了任务信息的动态传递。

          // 任务完成度环
          Row() {
            Stack() {
              Progress({ value: this.doneIds.length, total: 8, type: ProgressType.Ring })
                .width(86).height(86)
                .color('#00838F').backgroundColor('#E0F7FA')
                .style({ strokeWidth: 7 })
                .animation({ duration: 900, curve: Curve.EaseOut })

              Column() {
                Text((this.doneIds.length / 8 * 100).toFixed(0) + '%').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#006064')
                Text(this.doneIds.length + '/8 项').fontSize(8).fontColor('#90A4AE').margin({ top: 2 })
              }
            }

任务完成度环使用了Stack层叠布局,将Progress环形进度条和中心文字Column叠加在一起。Progress是ArkUI的进度组件,type: ProgressType.Ring指定为环形进度条,value为当前值(已完成任务数),total为总值(8项)。style({ strokeWidth: 7 })设置环形线条宽度。animation为进度变化添加900毫秒的缓出动画。

中心文字使用(this.doneIds.length / 8 * 100).toFixed(0)计算完成百分比,toFixed(0)将浮点数四舍五入为整数字符串。Stack的层叠特性使得中心文字完美居中在环形进度条内部,这是Stack组件最典型的使用场景——当需要将一个元素居中叠加在另一个元素之上时,Stack是最佳选择。

          // 任务列表
          Column() {
            Row() {
              Text('📋 任务清单').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#006064')
              Text('已完成 ' + this.doneIds.length.toString() + ' 项').fontSize(9).fontColor('#90A4AE').margin({ left: 8 })
            }
            .width('100%').padding({ left: 14, right: 14, top: 12 })

            ForEach(TASK_LIST, (t: OnboardTask) => {
              Row() {
                Stack() {
                  Text(t.icon).fontSize(22)
                    .width(44).height(44).backgroundColor('#E0F7FA').borderRadius(22)
                    .textAlign(TextAlign.Center)
                  if (this.doneIds.indexOf(t.id) >= 0) {
                    Text('✓').fontSize(11).fontColor('#FFFFFF').backgroundColor('#00838F')
                      .width(16).height(16).borderRadius(8).textAlign(TextAlign.Center)
                      .animation({ duration: 150, curve: Curve.EaseOut })
                      .position({ x: 30, y: 30 })
                  }
                }

任务列表使用ForEach遍历TASK_LIST数组渲染每个任务项。每个任务项的左侧图标区域使用Stack层叠布局:底层是44x44的圆形背景Text,上层是已完成状态时的勾选标记。this.doneIds.indexOf(t.id)检查当前任务ID是否在已完成数组中,>= 0表示已找到(indexOf返回-1表示未找到)。勾选标记使用position({ x: 30, y: 30 })绝对定位在图标右下角,形成角标效果,并添加150毫秒的动画。

                Column() {
                  Row() {
                    Text(t.title).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#424242')
                    if (t.urgent && this.doneIds.indexOf(t.id) < 0) {
                      Text('今日必办').fontSize(7).fontColor('#FFFFFF').backgroundColor('#EF5350')
                        .borderRadius(4).padding({ left: 4, right: 4, top: 1, bottom: 1 }).margin({ left: 6 })
                    }
                  }
                  Text(t.desc).fontSize(9).fontColor('#90A4AE').margin({ top: 3 })
                  Text('+' + t.points.toString() + ' 积分').fontSize(9).fontColor('#F9A825').margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })

任务信息区域是一个Column,使用条件渲染if (t.urgent && this.doneIds.indexOf(t.id) < 0)判断是否显示"今日必办"红色标签。条件渲染中的两个条件通过&&逻辑与连接:任务本身标记为紧急且尚未完成时才显示标签。#EF5350是红色背景,fontSize(7)使标签文字小于标题文字,borderRadius(4)padding使标签呈现小圆角矩形形态。

                if (this.doneIds.indexOf(t.id) >= 0) {
                  Text('已完成').fontSize(10).fontColor('#00838F').backgroundColor('#E0F7FA')
                    .borderRadius(10).padding({ left: 10, right: 10, top: 4, bottom: 4 })
                } else {
                  Text('去完成').fontSize(10).fontColor('#FFFFFF').backgroundColor('#00838F')
                    .borderRadius(10).padding({ left: 10, right: 10, top: 4, bottom: 4 })
                    .onClick(() => {
                      this.taskTitle = t.title
                      this.taskDesc = t.desc
                      this.taskIcon = t.icon
                      this.taskPoints = t.points
                      this.showConfirm = true
                    })
                }

任务右侧的操作按钮使用if-else条件渲染:已完成时显示灰底"已完成"标签,未完成时显示主题色"去完成"按钮。点击"去完成"按钮时,将当前任务的信息赋值到状态变量并打开确认弹窗。这种动态赋值的方式实现了弹窗内容的动态化——同一个弹窗模板可以根据不同的任务数据展示不同内容。

TaskListContent 组件

已找到

未找到

Scroll 可滚动区域

今日必办提示卡

任务完成度环 Stack+Progress

任务清单 ForEach

doneIds.indexOf

显示已完成标签

显示去完成按钮

点击: 赋值任务信息

showConfirm = true

点击去完成

modalOverlay 遮罩层

confirmTaskModal 确认弹窗

Radio 声明项勾选

确认完成按钮

doneIds.concat 新增完成ID

checkScale 缩放动画

showConfirm = false

材料清单组件 MaterialCheckContent

在这里插入图片描述

MaterialCheckContent负责入职材料的提交、审核状态展示和材料上传功能。

@Component
struct MaterialCheckContent {
  @State showUpload: boolean = false
  @State matName: string = '银行卡复印件'
  @State uploadNote: string = ''
  @State uploadType: string = '原件拍照'

该组件管理四个状态变量。showUpload控制上传弹窗显示。matName存储当前操作的材料名称。uploadNote存储上传备注文本,初始为空字符串。uploadType存储上传方式选择,默认为"原件拍照"。这些状态变量在弹窗交互中被读取和修改,实现了表单数据的双向绑定。

  // 弹框2:材料上传(类型单选+备注+重新上传)
  @Builder uploadModal() {
    Column() {
      Column() {
        Text('📤 上传材料').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#006064')
          .width('100%').padding({ top: 16, left: 16 })

        Row() {
          Text('材料名称').fontSize(11).fontColor('#757575').width(64)
          Text(this.matName).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#424242')
        }
        .width('88%').margin({ top: 14 })

        Text('上传方式').fontSize(11).fontColor('#757575').width('88%').margin({ top: 14 })

        Row() {
          ForEach(['原件拍照', '扫描件', '电子档'], (t: string) => {
            Text(t).fontSize(11)
              .fontColor(this.uploadType === t ? '#FFFFFF' : '#00838F')
              .backgroundColor(this.uploadType === t ? '#00838F' : '#E0F7FA')
              .borderRadius(13).padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .margin({ right: 10 })
              .onClick(() => {
                this.uploadType = t
              })
          }, (t: string) => t)
        }
        .width('88%').margin({ top: 8 })

上传弹窗中"上传方式"的选择使用了ForEach渲染三个选项的标签式选择器。每个选项的选中态通过三元表达式控制颜色和背景:选中时为白字青底,未选中时为青字浅底。点击选项时将值赋给uploadType状态变量,框架自动更新UI。这种标签式单选是移动端常见的选择交互模式,比传统的Radio单选按钮更直观美观。

        TextArea({ placeholder: '补充说明材料情况,如照片模糊原因等...', text: this.uploadNote })
          .fontSize(12).height(60)
          .width('88%').backgroundColor('#F0FAFB').borderRadius(8)
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.uploadNote = v
          })

TextArea是ArkUI的多行文本输入组件,placeholder设置占位提示文字,text参数绑定初始文本内容。onChange是文本变化回调,参数v为当前文本值,在回调中将新值赋给uploadNote状态变量,实现数据的双向绑定。backgroundColorborderRadius设置了输入区域的视觉样式,使其与弹窗整体风格协调。

        Row() {
          Text('📷').fontSize(30)
            .width(64).height(64).backgroundColor('#E0F7FA').borderRadius(10)
            .textAlign(TextAlign.Center)
          Text('🖼️').fontSize(30)
            .width(64).height(64).backgroundColor('#E0F7FA').borderRadius(10)
            .textAlign(TextAlign.Center).margin({ left: 10 })
          Text('+').fontSize(24).fontColor('#00838F')
            .width(64).height(64).backgroundColor('#F0FAFB').borderRadius(10)
            .border({ width: 1, color: '#B2EBF2' })
            .textAlign(TextAlign.Center).margin({ left: 10 })
        }
        .width('88%').margin({ top: 14 })

材料图片上传区域使用Row水平排列三个64x64的图标方块:已上传的两张图片以emoji形式展示,第三个是添加按钮,使用border设置虚线边框效果。border方法接受一个对象参数,width设置边框宽度,color设置边框颜色。这种设计虽然在真实应用中需要对接文件选择器,但在视觉原型层面完整地表达了交互意图。

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 材料进度概览
          Row() {
            Column() {
              Text('4').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              Text('已通过').fontSize(8).fontColor('#B2EBF2').margin({ top: 2 })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Center)
            ...
          }
          .width('96%').padding({ top: 14, bottom: 14 })
          .linearGradient({ angle: 135, colors: [['#006064', 0], ['#00ACC1', 1]] })
          .borderRadius(14).margin({ top: 10 })

材料进度概览卡使用四个等宽Column(通过layoutWeight(1))分别展示已通过、审核中、未提交、完成率四项数据。渐变背景与头部一致,形成视觉统一性。alignItems(HorizontalAlign.Center)使每个数据块内部居中对齐。

          // 材料列表
          Column() {
            ForEach(MATERIAL_LIST, (m: MaterialItem) => {
              Row() {
                Text(m.icon).fontSize(24)
                  .width(46).height(46).backgroundColor('#E0F7FA').borderRadius(12)
                  .textAlign(TextAlign.Center)

                Column() {
                  Text(m.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#424242')
                  Text(m.note).fontSize(9).fontColor('#90A4AE').margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })

                Column() {
                  Text(m.status).fontSize(9).fontColor('#FFFFFF')
                    .backgroundColor(getMatStatusColor(m.status))
                    .borderRadius(9).padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  if (m.status !== '已通过') {
                    Text(m.status === '审核中' ? '查看' : '上传').fontSize(10).fontColor('#00838F')
                      .margin({ top: 6 })
                      .onClick(() => {
                        this.matName = m.name
                        this.showUpload = true
                      })
                  } else {
                    Text('已归档').fontSize(9).fontColor('#B0BEC5').margin({ top: 6 })
                  }
                }
                .alignItems(HorizontalAlign.End)
              }
              .width('100%').padding({ left: 14, right: 14, top: 12, bottom: 12 })

              Divider().color('#E0F7FA')
            }, (m: MaterialItem) => m.id.toString())
          }

材料列表使用ForEach遍历MATERIAL_LIST渲染。每个材料项右侧使用getMatStatusColor(m.status)函数动态获取状态颜色,这是一种将逻辑与UI分离的良好实践。操作按钮根据状态动态变化:已通过显示"已归档"、审核中显示"查看"、未提交显示"上传"。每项之间使用Divider分割线分隔,Divider是ArkUI的分割线组件,color设置颜色。

      // 悬浮上传按钮
      Text('📤').fontSize(22)
        .width(52).height(52).backgroundColor('#00838F').borderRadius(26)
        .textAlign(TextAlign.Center)
        .shadow({ radius: 12, color: 'rgba(0,131,143,0.35)', offsetX: 0, offsetY: 4 })
        .position({ x: '78%', y: '82%' })
        .onClick(() => {
          this.matName = '银行卡复印件'
          this.showUpload = true
        })

悬浮上传按钮是一个52x52的圆形Text组件,使用shadow方法添加阴影效果实现浮动感。shadow方法接收对象参数:radius设置模糊半径,color设置阴影颜色(使用半透明青色),offsetXoffsetY设置阴影偏移量。position将按钮绝对定位到屏幕右下角区域(78%, 82%),这是Material Design中FAB(Floating Action Button)的经典位置。由于按钮声明在Stack内部,它可以叠加在Scroll内容之上,不随内容滚动而移动。

流程跟踪组件 FlowTrackContent

FlowTrackContent负责入职流程的跟踪展示和离职服务管理,是整个应用中弹窗逻辑最复杂的组件。

@Component
struct FlowTrackContent {
  @State showOffboard: boolean = false
  @State showCancel: boolean = false
  @State lastDay: string = '2026-11-20'
  @State offReason: string = '个人职业规划'
  @State handover: string = '王莉'
  @State showDeleteWarn: boolean = false

该组件管理六个状态变量,涉及两个弹窗和离职表单数据。showOffboard控制离职申请弹窗,showDeleteWarn控制撤回离职警示弹窗。lastDayoffReasonhandover分别存储离职表单中的最后工作日、离职原因、交接人信息。

  // 弹框3:离职申请(底部抽屉:原因+最后工作日+交接人)
  @Builder offboardModal() {
    Column() {
      Column() {
        Row() {
          Text('🚪 离职申请').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#B71C1C')
          Text('✕').fontSize(15).fontColor('#9E9E9E').margin({ left: 10 })
            .onClick(() => {
              this.showOffboard = false
            })
        }
        .width('100%').padding({ left: 16, right: 16, top: 16 })

离职申请弹窗采用底部抽屉式设计,使用borderRadius({ topLeft: 20, topRight: 20 })只设置上方圆角,模拟从底部滑入的抽屉效果。标题使用#B71C1C红色,与入职主题的青色形成对比,暗示这是一个严肃的操作。关闭按钮使用emoji"✕"实现,简洁有效。

        Column() {
          ForEach(['个人职业规划', '家庭因素', '健康原因', '继续深造', '其他'], (r: string) => {
            Row() {
              Radio({ value: r, group: 'offreason' })
                .checked(this.offReason === r)
                .width(15).height(15)
                .onChange((c: boolean) => {
                  if (c) {
                    this.offReason = r
                  }
                })
              Text(r).fontSize(11).fontColor('#424242').margin({ left: 8 })
            }
            .width('88%').padding({ top: 8 })
          }, (r: string) => r)
        }

离职原因使用Radio单选按钮组,五个选项通过ForEach渲染。checked方法设置选中状态,通过比较this.offReason === r判断当前选项是否被选中。onChange回调在选中状态变化时触发,参数c为布尔值表示是否被选中,当ctrue时将选项值赋给offReason。这种模式比标签式选择器更传统,适合选项较多或文字较长的场景。

        Row() {
          Text('交接人').fontSize(11).fontColor('#757575').width(80)
          ForEach(['王莉', '陈默', '刘芳'], (h: string) => {
            Text(h).fontSize(10)
              .fontColor(this.handover === h ? '#FFFFFF' : '#00838F')
              .backgroundColor(this.handover === h ? '#00838F' : '#E0F7FA')
              .borderRadius(11).padding({ left: 12, right: 12, top: 4, bottom: 4 })
              .margin({ right: 8 })
              .onClick(() => {
                this.handover = h
              })
          }, (h: string) => h)
        }
        .width('88%').margin({ top: 14 })

交接人选择使用了与上传方式相同的标签式单选模式,三个候选人通过ForEach渲染。这种标签式选择器在移动端交互中体验优于传统的下拉列表,因为减少了操作层级(不需要展开列表)。

  // 弹框4:撤回离职申请(警示卡)
  @Builder cancelOffboardModal() {
    Column() {
      Column() {
        Text('⚠️').fontSize(34).margin({ top: 16 })
        Text('确认提交离职申请?').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#B71C1C').margin({ top: 8 })
        Text('提交后将通知直属主管进行离职面谈,试用期离职需提前 3 天,正式员工需提前 30 天。').fontSize(10).fontColor('#757575')
          .textAlign(TextAlign.Center).margin({ top: 8 }).padding({ left: 24, right: 24 })

        Text('离职原因:' + this.offReason).fontSize(10).fontColor('#424242').margin({ top: 8 })
        Text('最后工作日:' + this.lastDay).fontSize(10).fontColor('#424242').margin({ top: 4 })
        Text('工作交接人:' + this.handover).fontSize(10).fontColor('#424242').margin({ top: 4 })

撤回离职警示弹窗是一个居中卡片式弹窗,使用警告emoji和红色文字营造严肃氛围。弹窗中回显了用户在离职申请表单中填写的信息(原因、最后工作日、交接人),让用户在最终确认前再次核对。这种"二次确认"模式在涉及不可逆操作的场景中是必要的安全措施。textAlign(TextAlign.Center)设置文字居中对齐,配合padding使文字两侧留出适当间距。

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 横向步骤条
          Column() {
            Text('📍 当前流程位置').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#006064')
              .width('100%').padding({ left: 14, top: 12 })

            Scroll() {
              Row() {
                ForEach(FLOW_STEPS, (s: FlowStep) => {
                  Column() {
                    Stack() {
                      Column()
                        .width(30).height(30).borderRadius(15)
                        .backgroundColor(s.state === '已完成' ? '#00838F' : (s.state === '进行中' ? '#F9A825' : '#ECEFF1'))
                      Text(s.state === '已完成' ? '✓' : s.id.toString())
                        .fontSize(12).fontWeight(FontWeight.Bold)
                        .fontColor(s.state === '已完成' ? '#FFFFFF' : (s.state === '进行中' ? '#FFFFFF' : '#90A4AE'))
                    }

                    Text(s.title).fontSize(8)
                      .fontColor(s.state === '进行中' ? '#F9A825' : '#90A4AE')
                      .margin({ top: 5 })

                    Column()
                      .width(s.id === 6 ? 0 : 24).height(2)
                      .backgroundColor(s.state === '已完成' ? '#00838F' : '#E0E0E0')
                      .margin({ top: 5 })
                  }
                  .alignItems(HorizontalAlign.Center)
                  .margin({ left: 8, right: 8 })
                }, (s: FlowStep) => s.id.toString())
              }
              .padding({ left: 10, right: 10 })
            }
            .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
            .width('100%').margin({ top: 12, bottom: 14 })
          }

横向步骤条使用了嵌套Scroll(水平方向ScrollDirection.Horizontal)来展示六个流程步骤,解决步骤过多时屏幕宽度不足的问题。每个步骤由Stack(圆形节点+文字/勾选标记)、步骤标题Text、连接线Column三部分组成。Stack内的圆形节点使用三元嵌套表达式根据状态设置颜色:已完成为青色、进行中为琥珀色、未开始为浅灰色。最后一个步骤(id=6)不显示连接线,通过s.id === 6 ? 0 : 24将宽度设为0来隐藏。scrollable(ScrollDirection.Horizontal)设置水平滚动方向。

          // 垂直时间轴
          Column() {
            ForEach(FLOW_STEPS, (s: FlowStep) => {
              Row() {
                Column() {
                  Column()
                    .width(12).height(12).borderRadius(6)
                    .backgroundColor(getFlowStateColor(s.state))
                  if (s.id !== 6) {
                    Column()
                      .width(2).height(44)
                      .backgroundColor(s.state === '已完成' ? '#00838F' : '#E0E0E0')
                  }
                }
                .alignItems(HorizontalAlign.Center).width(30)

                Column() {
                  Row() {
                    Text(s.title).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#424242')
                    Text(s.state).fontSize(8).fontColor('#FFFFFF')
                      .backgroundColor(getFlowStateColor(s.state))
                      .borderRadius(4).padding({ left: 5, right: 5, top: 1, bottom: 1 }).margin({ left: 8 })
                  }
                  Text(s.operator + ' · ' + s.time).fontSize(9).fontColor('#90A4AE').margin({ top: 4 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 8 })

                if (s.state === '进行中') {
                  Text('催办').fontSize(9).fontColor('#F9A825').backgroundColor('#FFF8E1')
                    .borderRadius(9).padding({ left: 10, right: 10, top: 4, bottom: 4 })
                }
              }
              .width('100%').alignItems(VerticalAlign.Top)
              .padding({ left: 14, right: 14, top: 8 })
            }, (s: FlowStep) => s.id.toString())
          }

垂直时间轴是横向步骤条的详细展开版本,使用ForEach遍历FLOW_STEPS渲染。每行左侧是一个Column包含圆点和连接线(类似时间轴的纵线),右侧是步骤详细信息。连接线高度44px,颜色根据状态动态变化。"进行中"状态的步骤右侧额外显示"催办"按钮,使用浅黄背景#FFF8E1和琥珀色文字。alignItems(VerticalAlign.Top)确保行内元素顶部对齐,使圆点与标题在同一水平线上。

          // 离职服务入口
          Column() {
            Row() {
              Text('🚪 离职服务台').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#006064')
              Text('保密通道').fontSize(8).fontColor('#FFFFFF').backgroundColor('#B71C1C')
                .borderRadius(4).padding({ left: 5, right: 5, top: 1, bottom: 1 }).margin({ left: 8 })
            }

            Row() {
              ForEach([['📋', '离职申请', '提交离职意向'], ['📦', '交接清单', '工作交接模板'], ['💼', '社保转移', '公积金提取指引'], ['📄', '证明开具', '离职证明下载']], (e: string[]) => {
                Column() {
                  Text(e[0]).fontSize(24)
                    .width(48).height(48).backgroundColor('#FFF8F0').borderRadius(12)
                    .textAlign(TextAlign.Center)
                  Text(e[1]).fontSize(9).fontWeight(FontWeight.Bold).fontColor('#424242').margin({ top: 6 })
                  Text(e[2]).fontSize(7).fontColor('#90A4AE').margin({ top: 2 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Center)
                .onClick(() => {
                  if (e[1] === '离职申请') {
                    this.showOffboard = true
                  }
                })
              }, (e: string[]) => e[1])
            }
          }

离职服务入口使用四列等宽网格(通过layoutWeight(1)),每个入口是emoji图标加标题加说明的三层结构。onClick中通过条件判断e[1] === '离职申请'决定点击行为,只有"离职申请"入口会触发离职弹窗,其他入口为视觉展示。这种设计将业务逻辑与数据驱动结合,ForEach的数据源是字符串数组的数组,每个子数组包含三个元素(emoji、标题、说明)。

入职伙伴组件 BuddyNetworkContent

@Component
struct BuddyNetworkContent {
  @State showCall: boolean = false
  @State buddyName: string = '王莉'
  @State buddyDuty: string = '入职 Buddy'
  @State askedIds: number[] = [1, 3, 6]

该组件管理四个状态:showCall控制联系弹窗,buddyNamebuddyDuty存储当前选中的伙伴信息,askedIds数组记录已求助过的伙伴ID列表。

  // 弹框5:联系伙伴(底部抽屉:联系方式+留言)
  @Builder callBuddyModal() {
    Column() {
      Column() {
        Row() {
          Text('🤝 联系 ' + this.buddyName).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#006064')
        }
        .width('100%').padding({ left: 16, top: 16 })

        Text('TA 的角色:' + this.buddyDuty).fontSize(10).fontColor('#90A4AE')
          .width('100%').padding({ left: 16, top: 6 })

        Row() {
          Text('📞').fontSize(20).width(40).height(40).backgroundColor('#E0F7FA').borderRadius(20).textAlign(TextAlign.Center)
          Text('电话 138****6688').fontSize(11).fontColor('#424242').margin({ left: 10 })
        }
        .width('88%').padding({ top: 14 })

联系伙伴弹窗是底部抽屉式设计,展示三种联系方式(电话、消息、邮箱)和留言区域。每种联系方式使用Row水平排列圆形emoji图标和说明文字,图标背景使用#E0F7FA浅青色保持一致性。电话号码使用脱敏格式138****6688,这在企业应用中是常见的隐私保护做法。

          // 伙伴打卡周报柱状图
          Column() {
            Text('📊 本周伙伴互动打卡').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#006064')
              .width('100%').padding({ left: 14, top: 12 })

            Row() {
              ForEach(CHECKIN_LOG, (c: CheckinLog) => {
                Column() {
                  Column()
                    .width(14).height(c.times * 9)
                    .backgroundColor(c.times >= 6 ? '#00838F' : '#B2EBF2')
                    .borderRadius({ topLeft: 3, topRight: 3 })
                    .animation({ duration: 700, curve: Curve.EaseOut })
                  Text('周' + c.day).fontSize(8).fontColor('#90A4AE').margin({ top: 4 })
                }
                .alignItems(HorizontalAlign.Center).margin({ left: 10, right: 10 })
              }, (c: CheckinLog) => c.id.toString())
            }
            .width('100%').padding({ top: 14, bottom: 12 })
            .alignItems(VerticalAlign.Bottom).height(104)

伙伴打卡周报柱状图是纯ArkUI实现的简易图表,使用ForEach遍历CHECKIN_LOG(七天数据)渲染七个柱子。每个柱子是一个Column,高度通过c.times * 9计算(打卡次数乘以9像素),颜色根据打卡次数动态选择:大于等于6次为深色#00838F,小于6次为浅色#B2EBF2borderRadius({ topLeft: 3, topRight: 3 })只设置顶部圆角,模拟柱状图的视觉特征。animation为高度变化添加700毫秒的缓出动画,使柱子有"生长"效果。

RowalignItems(VerticalAlign.Bottom)使所有柱子底部对齐,这是柱状图的基本要求——所有柱子从同一基线向上延伸。height(104)固定图表区域高度,确保布局稳定。

            // 伙伴卡片列表
            ForEach(BUDDY_LIST, (b: BuddyItem) => {
              Row() {
                Stack() {
                  Text(b.avatar).fontSize(24)
                    .width(46).height(46).backgroundColor('#E0F7FA').borderRadius(23)
                    .textAlign(TextAlign.Center)
                  if (b.contact) {
                    Text('●').fontSize(8).fontColor('#00C853')
                      .position({ x: 34, y: 34 })
                  }
                }

                Column() {
                  Row() {
                    Text(b.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#424242')
                    Text(b.duty).fontSize(8).fontColor('#FFFFFF').backgroundColor('#00838F')
                      .borderRadius(4).padding({ left: 5, right: 5, top: 1, bottom: 1 }).margin({ left: 6 })
                  }
                  Text(b.dept).fontSize(9).fontColor('#90A4AE').margin({ top: 3 })
                  Text('「' + b.intro + '」').fontSize(9).fontColor('#757575').margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })

                Column() {
                  Text('联系').fontSize(10).fontColor('#FFFFFF').backgroundColor('#00838F')
                    .borderRadius(11).padding({ left: 14, right: 14, top: 5, bottom: 5 })
                  Text(this.askedIds.indexOf(b.id) >= 0 ? '已求助过' : '未求助')
                    .fontSize(7).fontColor('#B0BEC5').margin({ top: 4 })
                }
                .alignItems(HorizontalAlign.End)
                .onClick(() => {
                  this.buddyName = b.name
                  this.buddyDuty = b.duty
                  if (this.askedIds.indexOf(b.id) < 0) {
                    this.askedIds = this.askedIds.concat([b.id])
                  }
                  this.showCall = true
                })
              }

伙伴卡片列表使用ForEach遍历BUDDY_LIST渲染。每个伙伴的左侧头像使用Stack层叠布局,头像背景上叠加一个绿色圆点表示在线状态(b.contacttrue时显示)。右侧操作区域显示"联系"按钮和求助状态标签。点击联系按钮时,将伙伴信息赋值到状态变量,如果该伙伴尚未被求助过则将其ID添加到askedIds数组(使用concat返回新数组),然后打开联系弹窗。indexOf(b.id) < 0检查表示尚未求助,concat([b.id])将新ID追加到数组末尾。

我的组件 MyOnboardContent

@Component
struct MyOnboardContent {
  @State showEdit: boolean = false
  @State phone: string = '138****6688'
  @State emergency: string = '母亲 139****2233'
  @State tshirt: string = 'L'
  @State ringAnim: boolean = false

该组件管理五个状态:编辑弹窗控制、手机号、紧急联系人、工服尺码、预留动画触发。

  // 弹框6:编辑个人信息(手机+紧急联系人+尺码)
  @Builder editInfoModal() {
    Column() {
      Column() {
        Text('✏️ 编辑个人信息').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#006064')
          .width('100%').padding({ top: 16, left: 16 })

        Row() {
          Text('手机号码').fontSize(11).fontColor('#757575').width(80)
          TextInput({ text: this.phone })
            .fontSize(12).layoutWeight(1).height(38)
            .backgroundColor('#F0FAFB').borderRadius(8)
            .onChange((v: string) => {
              this.phone = v
            })
        }
        .width('88%').margin({ top: 14 })

编辑信息弹窗使用TextInput组件作为单行文本输入框,text参数绑定状态变量作为初始值,onChange回调实时更新状态。layoutWeight(1)使输入框占据标签之后的剩余宽度。height(38)borderRadius(8)设置输入框尺寸和圆角。

        Row() {
          ForEach(['S', 'M', 'L', 'XL', 'XXL'], (s: string) => {
            Text(s).fontSize(12)
              .fontColor(this.tshirt === s ? '#FFFFFF' : '#00838F')
              .backgroundColor(this.tshirt === s ? '#00838F' : '#E0F7FA')
              .width(38).height(32).borderRadius(16)
              .textAlign(TextAlign.Center).margin({ right: 10 })
              .onClick(() => {
                this.tshirt = s
              })
          }, (s: string) => s)
        }

工服尺码选择器使用标签式单选,五个尺码通过ForEach渲染,选中态通过颜色对比体现。width(38).height(32).borderRadius(16)使每个标签呈圆角矩形,textAlign(TextAlign.Center)使文字居中。

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 个人信息卡
          Column() {
            Row() {
              Stack() {
                Text('🧑‍🎓').fontSize(32)
                  .width(64).height(64).backgroundColor('#E0F7FA').borderRadius(32)
                  .textAlign(TextAlign.Center)
                Text('新人').fontSize(8).fontColor('#FFFFFF').backgroundColor('#F9A825')
                  .borderRadius(7).padding({ left: 4, right: 4, top: 1, bottom: 1 })
                  .position({ x: 42, y: 46 })
              }

              Column() {
                Text('周子轩').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                Text('研发部 · 前端工程师 · 试用期').fontSize(9).fontColor('#B2EBF2').margin({ top: 4 })
                Text('工号 EMP-2026-08826 · 入职 2026-08-20').fontSize(8).fontColor('#B2EBF2').margin({ top: 3 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 14 })

              Text('✏️').fontSize(16)
                .onClick(() => {
                  this.showEdit = true
                })
            }
            .width('100%').padding({ left: 16, right: 16, top: 16 })

个人信息卡使用渐变背景卡片,左侧头像使用Stack层叠一个64x64的圆形背景和一个"新人"角标。角标使用position({ x: 42, y: 46 })绝对定位在头像右下角,#F9A825琥珀色背景使其在青色渐变背景上醒目可见。右侧编辑按钮点击后打开编辑弹窗。

          // 试用期成长曲线
          Column() {
            ForEach([['第 1 周', '环境搭建与团队破冰', '100', '#00838F'], ['第 2 周', '熟悉业务与代码规范', '80', '#00838F'], ['第 3 周', '承接第一个迭代任务', '45', '#F9A825'], ['第 4 周', '独立负责功能模块', '10', '#B0BEC5'], ['第 12 周', '转正答辩', '0', '#ECEFF1']], (g: string[]) => {
              Column() {
                Row() {
                  Text(g[0]).fontSize(11).fontWeight(FontWeight.Bold).fontColor('#424242').width(52)
                  Text(g[1]).fontSize(10).fontColor('#757575').layoutWeight(1)
                  Text(g[2] + '%').fontSize(10).fontColor(g[3]).fontWeight(FontWeight.Bold)
                }
                .width('100%')

                Column() {
                  Column()
                    .width(g[2] + '%').height(5)
                    .backgroundColor(g[3]).borderRadius(3)
                    .animation({ duration: 700, curve: Curve.EaseOut })
                }
                .width('100%').height(5).backgroundColor('#F0FAFB').borderRadius(3)
                .margin({ top: 7 })
              }
              .width('100%').padding({ left: 14, right: 14, top: 10, bottom: 10 })
            }, (g: string[]) => g[0])
          }

试用期成长曲线使用字符串数组数据,每个子数组包含四个元素:周次、任务描述、进度百分比、进度条颜色。进度条使用嵌套Column实现:外层是灰色背景轨道,内层是彩色填充部分,宽度通过g[2] + '%'动态设置(字符串拼接生成如'100%')。animation为宽度变化添加动画效果。这种将多个属性值打包在数组中传递给ForEach的模式在ArkTS中很常见,适合简单的静态数据展示场景。

弹窗触发链

页面导航流程

TASK

MATERIAL

FLOW

BUDDY

MINE

底部Tab点击

currentTab 判断

TaskListContent

MaterialCheckContent

FlowTrackContent

BuddyNetworkContent

MyOnboardContent

任务确认弹窗

材料上传弹窗

离职申请弹窗

离职警示弹窗

联系伙伴弹窗

信息编辑弹窗

四、组件级技术要点总结

Stack层叠布局的运用

在本应用中,Stack组件被大量使用于以下场景:进度环与中心文字的叠加(任务完成度环)、头像与在线状态点的叠加(伙伴列表)、任务图标与勾选标记的叠加(任务列表)、头像与"新人"角标的叠加(个人信息卡)、悬浮按钮与角标的叠加(购物车)。Stack的特点是子元素默认居中叠放,可以通过position方法精确定位叠加元素的位置,是实现角标、徽章、浮动按钮等UI模式的理想容器。

@Builder方法的复用模式

每个子组件都定义了modalOverlay方法作为通用遮罩层,这是@Builder装饰器实现UI片段复用的典型应用。@Builder方法可以接收参数(如回调函数),使得同一构建方法在不同上下文中产生不同行为。在弹窗实现中,遮罩层+弹窗内容的组合模式统一了交互体验。

状态更新与不可变模式

在更新数组类型状态时,应用一致使用this.doneIds.concat([6])而非this.doneIds.push(6)concat方法返回新数组而不修改原数组,这是ArkUI状态更新的推荐模式。由于@State的状态变化检测基于引用比较,直接修改数组内容(如push)可能不会被框架检测到,而创建新数组引用则能确保触发重新渲染。

五、组件功能对比表格

组件名功能定位状态变量数弹窗数核心UI技术动画特效
OnboardApp主入口/Tab导航10条件渲染+ForEach进度条动画
TaskListContent任务清单管理71Stack角标+Progress环勾选缩放动画
MaterialCheckContent材料提交审核41FAB悬浮按钮+TextArea
FlowTrackContent流程跟踪与离职62横向Scroll步骤条+时间轴
BuddyNetworkContent伙伴网络互动41柱状图+在线状态点柱状图生长动画
MyOnboardContent个人信息与成长51渐变卡片+进度条进度条动画
弹窗名称所在组件弹窗类型交互模式按钮配置
任务完成确认TaskListContent居中卡片Radio单选声明取消+确认
材料上传MaterialCheckContent居中卡片标签单选+TextArea取消+提交
离职申请FlowTrackContent底部抽屉Radio+标签+TextArea取消+提交
撤回离职警示FlowTrackContent居中警示信息回显只读再想想+确认
联系伙伴BuddyNetworkContent底部抽屉联系方式+留言发送消息
编辑个人信息MyOnboardContent居中卡片TextInput+标签选择取消+保存

安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:

// ============================================================
// 底部 5 Tab:入职/材料/流程/伙伴/我的(青蓝清新风)
// 弹框:材料上传 / 编辑个人信息 / 离职申请 / 撤回离职警示 / 任务完成确认
// 图表:伙伴打卡周报柱状图 | 特效:任务勾选缩放 / 入职进度条动画 / 步骤条
// ============================================================

// ============ 类型定义 ============
interface OnboardTask {
  id: number
  title: string
  desc: string
  icon: string
  points: number
  done: boolean
  urgent: boolean
}

interface MaterialItem {
  id: number
  name: string
  icon: string
  status: string
  note: string
}

interface FlowStep {
  id: number
  title: string
  time: string
  operator: string
  state: string
}

interface BuddyItem {
  id: number
  name: string
  avatar: string
  dept: string
  duty: string
  intro: string
  contact: boolean
}

interface CheckinLog {
  id: number
  day: string
  times: number
}

// ============ 入职任务清单(8项) ============
const TASK_LIST: OnboardTask[] = [
  { id: 1, title: '完成入职信息登记', desc: '填写个人基本信息、银行卡、紧急联系人', icon: '📝', points: 10, done: true, urgent: false },
  { id: 2, title: '上传入职材料', desc: '身份证、学历证书、离职证明、体检报告', icon: '📄', points: 20, done: true, urgent: false },
  { id: 3, title: '签署电子劳动合同', desc: '在线阅读并签署三年期劳动合同', icon: '🖊️', points: 15, done: true, urgent: false },
  { id: 4, title: '领取办公设备', desc: '到 IT 服务台领取笔记本与外设套装', icon: '💻', points: 10, done: true, urgent: false },
  { id: 5, title: '开通账号权限', desc: '邮箱、内网、代码仓库、工单系统', icon: '🔑', points: 10, done: true, urgent: false },
  { id: 6, title: '参加入职培训', desc: '企业文化、信息安全、职业素养三堂课', icon: '🎓', points: 20, done: false, urgent: true },
  { id: 7, title: '完成导师破冰', desc: '与 Mentor 一对一沟通,制定 30 天成长计划', icon: '🤝', points: 10, done: false, urgent: false },
  { id: 8, title: '转正自评提交', desc: '三个月试用期满后填写自评表', icon: '📊', points: 5, done: false, urgent: false }
]

// ============ 材料清单(6项) ============
const MATERIAL_LIST: MaterialItem[] = [
  { id: 1, name: '身份证正反面', icon: '🪪', status: '已通过', note: '2026-08-20 提交' },
  { id: 2, name: '学历学位证书', icon: '🎓', status: '已通过', note: '学信网核验通过' },
  { id: 3, name: '上家单位离职证明', icon: '📤', status: '已通过', note: '2026-08-21 提交' },
  { id: 4, name: '入职体检报告', icon: '🏥', status: '已通过', note: '三甲医院 8月报告' },
  { id: 5, name: '一寸免冠照片', icon: '📸', status: '审核中', note: '预计 1 个工作日' },
  { id: 6, name: '银行卡复印件', icon: '💳', status: '未提交', note: '用于工资发放' }
]

// ============ 流程步骤(6步) ============
const FLOW_STEPS: FlowStep[] = [
  { id: 1, title: 'Offer 发放', time: '07-28 10:00', operator: '人事部 · 刘芳', state: '已完成' },
  { id: 2, title: '背景调查', time: '07-29 15:30', operator: '第三方机构', state: '已完成' },
  { id: 3, title: 'Offer 回签', time: '08-01 09:12', operator: '本人', state: '已完成' },
  { id: 4, title: '入职报到', time: '08-20 09:00', operator: '人事部 · 刘芳', state: '已完成' },
  { id: 5, title: '试用期待遇确认', time: '08-20 10:00', operator: '薪酬组', state: '进行中' },
  { id: 6, title: '试用期转正评估', time: '11-20 预计', operator: '直属主管', state: '未开始' }
]

// ============ 入职伙伴(6位) ============
const BUDDY_LIST: BuddyItem[] = [
  { id: 1, name: '王莉', avatar: '👩‍💼', dept: '产品部', duty: '入职 Buddy', intro: '带你熟悉园区与各部门', contact: true },
  { id: 2, name: '李慕白', avatar: '🧑‍🏫', dept: '培训部', duty: '培训讲师', intro: '企业文化课程主讲', contact: true },
  { id: 3, name: '陈默', avatar: '🧑‍🔬', dept: '算法组', duty: '技术导师', intro: '30 天成长计划制定者', contact: true },
  { id: 4, name: '孙浩宇', avatar: '👨‍🔧', dept: '运维部', duty: 'IT 支持', intro: '设备与账号开通', contact: true },
  { id: 5, name: '周雨薇', avatar: '👩‍🎨', dept: '设计部', duty: '同批新人', intro: '同期入职设计师', contact: false },
  { id: 6, name: '刘芳', avatar: '👩‍⚕️', dept: '人事部', duty: 'HRBP', intro: '入职全流程对接人', contact: true }
]

// ============ 伙伴打卡周报(7天) ============
const CHECKIN_LOG: CheckinLog[] = [
  { id: 1, day: '一', times: 3 },
  { id: 2, day: '二', times: 5 },
  { id: 3, day: '三', times: 4 },
  { id: 4, day: '四', times: 6 },
  { id: 5, day: '五', times: 8 },
  { id: 6, day: '六', times: 2 },
  { id: 7, day: '日', times: 1 }
]

// ============ Tab 定义 ============
enum OnboardTab { TASK, MATERIAL, FLOW, BUDDY, MINE }

interface OnboardTabMeta {
  icon: string
  label: string
}

const ONBOARD_TABS: OnboardTabMeta[] = [
  { icon: '🧭', label: '入职' },
  { icon: '📄', label: '材料' },
  { icon: '🔀', label: '流程' },
  { icon: '🤝', label: '伙伴' },
  { icon: '👤', label: '我的' }
]

// 全局纯函数:材料状态颜色
function getMatStatusColor(s: string): string {
  if (s === '已通过') {
    return '#00897B'
  } else if (s === '审核中') {
    return '#F9A825'
  } else {
    return '#EF6C00'
  }
}

// 全局纯函数:流程状态颜色
function getFlowStateColor(s: string): string {
  if (s === '已完成') {
    return '#00838F'
  } else if (s === '进行中') {
    return '#F9A825'
  } else {
    return '#B0BEC5'
  }
}

@Entry
@Component
struct OnboardApp {
  @State currentTab: OnboardTab = OnboardTab.TASK

  build() {
    Column() {
      // ===== 头部(电商风渐变:进度概览) =====
      Column() {
        Row() {
          Column() {
            Text('🚀 欢迎加入,第 7 天').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text('试用期剩余 83 天 · 入职完成度 75%').fontSize(9).fontColor('#B2EBF2').margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start).layoutWeight(1)

          Column() {
            Text('850').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text('入职积分').fontSize(8).fontColor('#B2EBF2').margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%').padding({ left: 16, right: 16, top: 12 })

        // 入职进度条(动画特效)
        Column() {
          Column()
            .width(this.currentTab === OnboardTab.TASK ? '75%' : '0%')
            .height(6).backgroundColor('#FFFFFF').borderRadius(3)
            .animation({ duration: 1000, curve: Curve.EaseOut })
        }
        .width('92%').height(6).backgroundColor('rgba(255,255,255,0.3)').borderRadius(3)
        .margin({ top: 12, bottom: 14 })
      }
      .width('100%')
      .linearGradient({ angle: 120, colors: [['#00838F', 0], ['#26C6DA', 1]] })

      // ===== 内容区 =====
      Column() {
        if (this.currentTab === OnboardTab.TASK) {
          TaskListContent()
        } else if (this.currentTab === OnboardTab.MATERIAL) {
          MaterialCheckContent()
        } else if (this.currentTab === OnboardTab.FLOW) {
          FlowTrackContent()
        } else if (this.currentTab === OnboardTab.BUDDY) {
          BuddyNetworkContent()
        } else {
          MyOnboardContent()
        }
      }
      .layoutWeight(1).width('100%')

      // ===== 底部 Tab(5个单排) =====
      Divider().color('#E0F7FA').strokeWidth(1)

      Row() {
        ForEach(ONBOARD_TABS, (t: OnboardTabMeta, idx: number) => {
          Column() {
            Text(t.icon).fontSize(19)
              .animation({ duration: 200, curve: Curve.EaseOut })
            Text(t.label).fontSize(9)
              .fontColor(this.currentTab === idx ? '#00838F' : '#9E9E9E')
              .margin({ top: 2 })
              .fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)
          .padding({ top: 6, bottom: 6 })
          .onClick(() => {
            this.currentTab = idx as OnboardTab
          })
        }, (t: OnboardTabMeta) => t.label)
      }
      .width('100%').backgroundColor('#FFFFFF')
    }
    .width('100%').height('100%').backgroundColor('#F0FAFB')
  }
}

// ============ 入职任务 Tab ============
@Component
struct TaskListContent {
  @State showConfirm: boolean = false
  @State taskTitle: string = '参加入职培训'
  @State taskDesc: string = '企业文化、信息安全、职业素养三堂课'
  @State taskIcon: string = '🎓'
  @State taskPoints: number = 20
  @State doneIds: number[] = [1, 2, 3, 4, 5]
  @State checkScale: number = 1.0
  @State barGrow: boolean = false

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column().width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)')
        .onClick(() => {
          onClose()
        })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(998)
  }

  // 弹框1:任务完成确认(清单勾选式)
  @Builder confirmTaskModal() {
    Column() {
      Column() {
        Row() {
          Text(this.taskIcon).fontSize(30).margin({ left: 16 })
          Column() {
            Text(this.taskTitle).fontSize(15).fontWeight(FontWeight.Bold).fontColor('#006064')
            Text('+' + this.taskPoints.toString() + ' 积分').fontSize(10).fontColor('#F9A825').margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start).margin({ left: 10 })
        }
        .width('100%').padding({ top: 18 })

        Text(this.taskDesc).fontSize(11).fontColor('#757575')
          .width('100%').padding({ left: 16, top: 8 })

        Column() {
          ForEach(['我已经完成该任务的全部要求', '相关证明材料已上传(如需)', '确认信息真实有效'], (r: string) => {
            Row() {
              Radio({ value: r, group: 'task' })
                .width(16).height(16)
              Text(r).fontSize(11).fontColor('#424242').margin({ left: 8 })
            }
            .width('100%').padding({ left: 16, top: 10 })
          }, (r: string) => r)
        }
        .width('100%')

        Row() {
          Button() {
            Text('稍后再说').fontSize(13).fontColor('#616161')
          }
          .layoutWeight(1).height(38).backgroundColor('#ECEFF1').borderRadius(19)
          .onClick(() => {
            this.showConfirm = false
          })

          Button() {
            Text('✓ 确认完成').fontSize(13).fontColor('#FFFFFF')
          }
          .layoutWeight(1).height(38).backgroundColor('#00838F').borderRadius(19)
          .margin({ left: 12 })
          .onClick(() => {
            if (this.taskTitle === '参加入职培训') {
              this.doneIds = this.doneIds.concat([6])
            }
            this.checkScale = 1.4
            setTimeout(() => {
              this.checkScale = 1.0
            }, 200)
            this.showConfirm = false
          })
        }
        .width('88%').margin({ top: 20, bottom: 20 })
      }
      .width('90%').backgroundColor('#FFFFFF').borderRadius(16)
    }
    .width('100%').alignItems(HorizontalAlign.Center)
    .position({ y: '30%' }).zIndex(999)
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 今日必办提示卡
          Row() {
            Text('⏰').fontSize(22)
            Column() {
              Text('今日必办').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              Text('「参加入职培训」 今天 16:00 · 3 号多功能厅').fontSize(9).fontColor('#B2EBF2').margin({ top: 3 })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 10 })

            Text('去完成').fontSize(10).fontColor('#00838F').backgroundColor('#FFFFFF')
              .borderRadius(12).padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .onClick(() => {
                this.taskTitle = '参加入职培训'
                this.taskDesc = '企业文化、信息安全、职业素养三堂课,完成全部课程并通过随堂测验'
                this.taskIcon = '🎓'
                this.taskPoints = 20
                this.showConfirm = true
              })
          }
          .width('96%').padding(14)
          .linearGradient({ angle: 135, colors: [['#006064', 0], ['#00ACC1', 1]] })
          .borderRadius(14).margin({ top: 10 })

          // 任务完成度环
          Row() {
            Stack() {
              Progress({ value: this.doneIds.length, total: 8, type: ProgressType.Ring })
                .width(86).height(86)
                .color('#00838F').backgroundColor('#E0F7FA')
                .style({ strokeWidth: 7 })
                .animation({ duration: 900, curve: Curve.EaseOut })

              Column() {
                Text((this.doneIds.length / 8 * 100).toFixed(0) + '%').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#006064')
                Text(this.doneIds.length + '/8 项').fontSize(8).fontColor('#90A4AE').margin({ top: 2 })
              }
            }

            Column() {
              Text('入职任务清单').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#006064')
              Text('完成全部任务可参与新人抽奖,100% 中奖率').fontSize(9).fontColor('#90A4AE').margin({ top: 5 })
              Text('🎁 满 100 积分解锁新人礼盒').fontSize(9).fontColor('#F9A825').margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.Start).layoutWeight(1).margin({ left: 16 })
          }
          .width('96%').backgroundColor('#FFFFFF').borderRadius(14)
          .padding(16).margin({ top: 12 }).alignItems(VerticalAlign.Center)

          // 任务列表
          Column() {
            Row() {
              Text('📋 任务清单').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#006064')
              Text('已完成 ' + this.doneIds.length.toString() + ' 项').fontSize(9).fontColor('#90A4AE').margin({ left: 8 })
            }
            .width('100%').padding({ left: 14, right: 14, top: 12 })

            ForEach(TASK_LIST, (t: OnboardTask) => {
              Row() {
                Stack() {
                  Text(t.icon).fontSize(22)
                    .width(44).height(44).backgroundColor('#E0F7FA').borderRadius(22)
                    .textAlign(TextAlign.Center)
                  if (this.doneIds.indexOf(t.id) >= 0) {
                    Text('✓').fontSize(11).fontColor('#FFFFFF').backgroundColor('#00838F')
                      .width(16).height(16).borderRadius(8).textAlign(TextAlign.Center)
                      .animation({ duration: 150, curve: Curve.EaseOut })
                      .position({ x: 30, y: 30 })
                  }
                }

                Column() {
                  Row() {
                    Text(t.title).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#424242')
                    if (t.urgent && this.doneIds.indexOf(t.id) < 0) {
                      Text('今日必办').fontSize(7).fontColor('#FFFFFF').backgroundColor('#EF5350')
                        .borderRadius(4).padding({ left: 4, right: 4, top: 1, bottom: 1 }).margin({ left: 6 })
                    }
                  }
                  Text(t.desc).fontSize(9).fontColor('#90A4AE').margin({ top: 3 })
                  Text('+' + t.points.toString() + ' 积分').fontSize(9).fontColor('#F9A825').margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })

                if (this.doneIds.indexOf(t.id) >= 0) {
                  Text('已完成').fontSize(10).fontColor('#00838F').backgroundColor('#E0F7FA')
                    .borderRadius(10).padding({ left: 10, right: 10, top: 4, bottom: 4 })
                } else {
                  Text('去完成').fontSize(10).fontColor('#FFFFFF').backgroundColor('#00838F')
                    .borderRadius(10).padding({ left: 10, right: 10, top: 4, bottom: 4 })
                    .onClick(() => {
                      this.taskTitle = t.title
                      this.taskDesc = t.desc
                      this.taskIcon = t.icon
                      this.taskPoints = t.points
                      this.showConfirm = true
                    })
                }
              }
              .width('100%').padding({ left: 14, right: 14, top: 12, bottom: 12 })

              Divider().color('#E0F7FA')
            }, (t: OnboardTask) => t.id.toString())
          }
          .width('96%').backgroundColor('#FFFFFF').borderRadius(14)
          .margin({ top: 12, left: 8, right: 8, bottom: 20 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .width('100%').height('100%')

      if (this.showConfirm) {
        this.modalOverlay(() => {
          this.showConfirm = false
        })
        this.confirmTaskModal()
      }
    }
    .width('100%').height('100%')
  }
}

// ============ 材料清单 Tab ============
@Component
struct MaterialCheckContent {
  @State showUpload: boolean = false
  @State matName: string = '银行卡复印件'
  @State uploadNote: string = ''
  @State uploadType: string = '原件拍照'

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column().width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)')
        .onClick(() => {
          onClose()
        })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(998)
  }

  // 弹框2:材料上传(类型单选+备注+重新上传)
  @Builder uploadModal() {
    Column() {
      Column() {
        Text('📤 上传材料').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#006064')
          .width('100%').padding({ top: 16, left: 16 })

        Row() {
          Text('材料名称').fontSize(11).fontColor('#757575').width(64)
          Text(this.matName).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#424242')
        }
        .width('88%').margin({ top: 14 })

        Text('上传方式').fontSize(11).fontColor('#757575').width('88%').margin({ top: 14 })

        Row() {
          ForEach(['原件拍照', '扫描件', '电子档'], (t: string) => {
            Text(t).fontSize(11)
              .fontColor(this.uploadType === t ? '#FFFFFF' : '#00838F')
              .backgroundColor(this.uploadType === t ? '#00838F' : '#E0F7FA')
              .borderRadius(13).padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .margin({ right: 10 })
              .onClick(() => {
                this.uploadType = t
              })
          }, (t: string) => t)
        }
        .width('88%').margin({ top: 8 })

        Text('备注说明(选填)').fontSize(11).fontColor('#757575').width('88%').margin({ top: 14 })

        TextArea({ placeholder: '补充说明材料情况,如照片模糊原因等...', text: this.uploadNote })
          .fontSize(12).height(60)
          .width('88%').backgroundColor('#F0FAFB').borderRadius(8)
          .margin({ top: 6 })
          .onChange((v: string) => {
            this.uploadNote = v
          })

        Row() {
          Text('📷').fontSize(30)
            .width(64).height(64).backgroundColor('#E0F7FA').borderRadius(10)
            .textAlign(TextAlign.Center)
          Text('🖼️').fontSize(30)
            .width(64).height(64).backgroundColor('#E0F7FA').borderRadius(10)
            .textAlign(TextAlign.Center).margin({ left: 10 })
          Text('+').fontSize(24).fontColor('#00838F')
            .width(64).height(64).backgroundColor('#F0FAFB').borderRadius(10)
            .border({ width: 1, color: '#B2EBF2' })
            .textAlign(TextAlign.Center).margin({ left: 10 })
        }
        .width('88%').margin({ top: 14 })

        Text('支持 JPG/PNG/PDF,单文件不超过 10MB').fontSize(8).fontColor('#B0BEC5')
          .width('88%').margin({ top: 8 })

        Row() {
          Button() {
            Text('取消').fontSize(13).fontColor('#616161')
          }
          .layoutWeight(1).height(38).backgroundColor('#ECEFF1').borderRadius(19)
          .onClick(() => {
            this.showUpload = false
          })

          Button() {
            Text('提交审核').fontSize(13).fontColor('#FFFFFF')
          }
          .layoutWeight(1).height(38).backgroundColor('#00838F').borderRadius(19)
          .margin({ left: 12 })
          .onClick(() => {
            this.showUpload = false
          })
        }
        .width('88%').margin({ top: 18, bottom: 20 })
      }
      .width('90%').backgroundColor('#FFFFFF').borderRadius(16)
    }
    .width('100%').alignItems(HorizontalAlign.Center)
    .position({ y: '8%' }).zIndex(999)
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 材料进度概览
          Row() {
            Column() {
              Text('4').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              Text('已通过').fontSize(8).fontColor('#B2EBF2').margin({ top: 2 })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Center)

            Column() {
              Text('1').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              Text('审核中').fontSize(8).fontColor('#B2EBF2').margin({ top: 2 })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Center)

            Column() {
              Text('1').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              Text('未提交').fontSize(8).fontColor('#B2EBF2').margin({ top: 2 })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Center)

            Column() {
              Text('96%').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              Text('材料完成率').fontSize(8).fontColor('#B2EBF2').margin({ top: 2 })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Center)
          }
          .width('96%').padding({ top: 14, bottom: 14 })
          .linearGradient({ angle: 135, colors: [['#006064', 0], ['#00ACC1', 1]] })
          .borderRadius(14).margin({ top: 10 })

          // 温馨提示
          Row() {
            Text('💡').fontSize(13)
            Text('未在 09-05 前提交全部材料将影响试用期薪资核算').fontSize(9).fontColor('#EF6C00')
              .layoutWeight(1).margin({ left: 8 })
          }
          .width('96%').backgroundColor('#FFF8E1').borderRadius(10)
          .padding({ left: 12, right: 12, top: 9, bottom: 9 }).margin({ top: 10 })

          // 材料列表
          Column() {
            Text('📂 材料清单').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#006064')
              .width('100%').padding({ left: 14, top: 12 })

            ForEach(MATERIAL_LIST, (m: MaterialItem) => {
              Row() {
                Text(m.icon).fontSize(24)
                  .width(46).height(46).backgroundColor('#E0F7FA').borderRadius(12)
                  .textAlign(TextAlign.Center)

                Column() {
                  Text(m.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#424242')
                  Text(m.note).fontSize(9).fontColor('#90A4AE').margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })

                Column() {
                  Text(m.status).fontSize(9).fontColor('#FFFFFF')
                    .backgroundColor(getMatStatusColor(m.status))
                    .borderRadius(9).padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  if (m.status !== '已通过') {
                    Text(m.status === '审核中' ? '查看' : '上传').fontSize(10).fontColor('#00838F')
                      .margin({ top: 6 })
                      .onClick(() => {
                        this.matName = m.name
                        this.showUpload = true
                      })
                  } else {
                    Text('已归档').fontSize(9).fontColor('#B0BEC5').margin({ top: 6 })
                  }
                }
                .alignItems(HorizontalAlign.End)
              }
              .width('100%').padding({ left: 14, right: 14, top: 12, bottom: 12 })

              Divider().color('#E0F7FA')
            }, (m: MaterialItem) => m.id.toString())
          }
          .width('96%').backgroundColor('#FFFFFF').borderRadius(14)
          .margin({ top: 12, left: 8, right: 8 })

          // 常见问题
          Column() {
            Text('❓ 常见问题').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#006064')
              .width('100%').padding({ left: 14, top: 12 })

            ForEach([['照片模糊被退回怎么办?', '重新拍照上传即可,无需联系人工'], ['学历证书丢失?', '可提交学信网电子备案表替代'], ['材料审核要多久?', '一般 1 个工作日,高峰期不超过 3 天']], (q: string[]) => {
              Column() {
                Text('Q:' + q[0]).fontSize(11).fontWeight(FontWeight.Bold).fontColor('#424242')
                Text('A:' + q[1]).fontSize(10).fontColor('#757575').margin({ top: 5 })
              }
              .width('100%').padding({ left: 14, right: 14, top: 10, bottom: 10 })
              .alignItems(HorizontalAlign.Start)

              Divider().color('#E0F7FA')
            }, (q: string[]) => q[0])
          }
          .width('96%').backgroundColor('#FFFFFF').borderRadius(14)
          .margin({ top: 12, left: 8, right: 8, bottom: 20 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .width('100%').height('100%')

      // 悬浮上传按钮
      Text('📤').fontSize(22)
        .width(52).height(52).backgroundColor('#00838F').borderRadius(26)
        .textAlign(TextAlign.Center)
        .shadow({ radius: 12, color: 'rgba(0,131,143,0.35)', offsetX: 0, offsetY: 4 })
        .position({ x: '78%', y: '82%' })
        .onClick(() => {
          this.matName = '银行卡复印件'
          this.showUpload = true
        })

      if (this.showUpload) {
        this.modalOverlay(() => {
          this.showUpload = false
        })
        this.uploadModal()
      }
    }
    .width('100%').height('100%')
  }
}

// ============ 流程 Tab ============
@Component
struct FlowTrackContent {
  @State showOffboard: boolean = false
  @State showCancel: boolean = false
  @State lastDay: string = '2026-11-20'
  @State offReason: string = '个人职业规划'
  @State handover: string = '王莉'
  @State showDeleteWarn: boolean = false

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column().width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)')
        .onClick(() => {
          onClose()
        })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(998)
  }

  // 弹框3:离职申请(底部抽屉:原因+最后工作日+交接人)
  @Builder offboardModal() {
    Column() {
      Column() {
        Row() {
          Text('🚪 离职申请').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#B71C1C')
          Text('✕').fontSize(15).fontColor('#9E9E9E').margin({ left: 10 })
            .onClick(() => {
              this.showOffboard = false
            })
        }
        .width('100%').padding({ left: 16, right: 16, top: 16 })

        Text('请如实填写以下信息,提交后进入直属主管审批').fontSize(9).fontColor('#9E9E9E')
          .width('100%').padding({ left: 16, top: 6 })

        Text('离职原因').fontSize(11).fontColor('#757575').width('88%').margin({ top: 14 })

        Column() {
          ForEach(['个人职业规划', '家庭因素', '健康原因', '继续深造', '其他'], (r: string) => {
            Row() {
              Radio({ value: r, group: 'offreason' })
                .checked(this.offReason === r)
                .width(15).height(15)
                .onChange((c: boolean) => {
                  if (c) {
                    this.offReason = r
                  }
                })
              Text(r).fontSize(11).fontColor('#424242').margin({ left: 8 })
            }
            .width('88%').padding({ top: 8 })
          }, (r: string) => r)
        }
        .width('88%')

        Row() {
          Text('最后工作日').fontSize(11).fontColor('#757575').width(80)
          Text(this.lastDay).fontSize(12).fontColor('#B71C1C').fontWeight(FontWeight.Bold)
          Text('修改').fontSize(10).fontColor('#00838F').margin({ left: 10 })
        }
        .width('88%').margin({ top: 14 })

        Row() {
          Text('交接人').fontSize(11).fontColor('#757575').width(80)
          ForEach(['王莉', '陈默', '刘芳'], (h: string) => {
            Text(h).fontSize(10)
              .fontColor(this.handover === h ? '#FFFFFF' : '#00838F')
              .backgroundColor(this.handover === h ? '#00838F' : '#E0F7FA')
              .borderRadius(11).padding({ left: 12, right: 12, top: 4, bottom: 4 })
              .margin({ right: 8 })
              .onClick(() => {
                this.handover = h
              })
          }, (h: string) => h)
        }
        .width('88%').margin({ top: 14 })

        TextArea({ placeholder: '补充说明(交接安排、未尽事宜...)' })
          .fontSize(12).height(64)
          .width('88%').backgroundColor('#FFF8F0').borderRadius(8)
          .margin({ top: 14 })

        Row() {
          Button() {
            Text('取消').fontSize(13).fontColor('#616161')
          }
          .layoutWeight(1).height(38).backgroundColor('#ECEFF1').borderRadius(19)
          .onClick(() => {
            this.showOffboard = false
          })

          Button() {
            Text('提交申请').fontSize(13).fontColor('#FFFFFF')
          }
          .layoutWeight(1).height(38).backgroundColor('#B71C1C').borderRadius(19)
          .margin({ left: 12 })
          .onClick(() => {
            this.showOffboard = false
            this.showDeleteWarn = true
          })
        }
        .width('88%').margin({ top: 16, bottom: 24 })
      }
      .width('100%').backgroundColor('#FFFFFF').borderRadius({ topLeft: 20, topRight: 20 })
    }
    .width('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  // 弹框4:撤回离职申请(警示卡)
  @Builder cancelOffboardModal() {
    Column() {
      Column() {
        Text('⚠️').fontSize(34).margin({ top: 16 })
        Text('确认提交离职申请?').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#B71C1C').margin({ top: 8 })
        Text('提交后将通知直属主管进行离职面谈,试用期离职需提前 3 天,正式员工需提前 30 天。').fontSize(10).fontColor('#757575')
          .textAlign(TextAlign.Center).margin({ top: 8 }).padding({ left: 24, right: 24 })

        Text('离职原因:' + this.offReason).fontSize(10).fontColor('#424242').margin({ top: 8 })
        Text('最后工作日:' + this.lastDay).fontSize(10).fontColor('#424242').margin({ top: 4 })
        Text('工作交接人:' + this.handover).fontSize(10).fontColor('#424242').margin({ top: 4 })

        Row() {
          Button() {
            Text('再想想').fontSize(13).fontColor('#616161')
          }
          .layoutWeight(1).height(36).backgroundColor('#ECEFF1').borderRadius(18)
          .onClick(() => {
            this.showDeleteWarn = false
          })

          Button() {
            Text('确认提交').fontSize(13).fontColor('#FFFFFF')
          }
          .layoutWeight(1).height(36).backgroundColor('#B71C1C').borderRadius(18)
          .margin({ left: 12 })
          .onClick(() => {
            this.showDeleteWarn = false
          })
        }
        .width('86%').margin({ top: 18, bottom: 18 })
      }
      .width('80%').backgroundColor('#FFFFFF').borderRadius(16)
    }
    .width('100%').alignItems(HorizontalAlign.Center)
    .position({ y: '32%' }).zIndex(999)
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 横向步骤条
          Column() {
            Text('📍 当前流程位置').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#006064')
              .width('100%').padding({ left: 14, top: 12 })

            Scroll() {
              Row() {
                ForEach(FLOW_STEPS, (s: FlowStep) => {
                  Column() {
                    Stack() {
                      Column()
                        .width(30).height(30).borderRadius(15)
                        .backgroundColor(s.state === '已完成' ? '#00838F' : (s.state === '进行中' ? '#F9A825' : '#ECEFF1'))
                      Text(s.state === '已完成' ? '✓' : s.id.toString())
                        .fontSize(12).fontWeight(FontWeight.Bold)
                        .fontColor(s.state === '已完成' ? '#FFFFFF' : (s.state === '进行中' ? '#FFFFFF' : '#90A4AE'))
                    }

                    Text(s.title).fontSize(8)
                      .fontColor(s.state === '进行中' ? '#F9A825' : '#90A4AE')
                      .margin({ top: 5 })

                    Column()
                      .width(s.id === 6 ? 0 : 24).height(2)
                      .backgroundColor(s.state === '已完成' ? '#00838F' : '#E0E0E0')
                      .margin({ top: 5 })
                  }
                  .alignItems(HorizontalAlign.Center)
                  .margin({ left: 8, right: 8 })
                }, (s: FlowStep) => s.id.toString())
              }
              .padding({ left: 10, right: 10 })
            }
            .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
            .width('100%').margin({ top: 12, bottom: 14 })
          }
          .width('96%').backgroundColor('#FFFFFF').borderRadius(14)
          .margin({ top: 10 })

          // 垂直时间轴
          Column() {
            Text('🕐 流程办理记录').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#006064')
              .width('100%').padding({ left: 14, top: 12 })

            ForEach(FLOW_STEPS, (s: FlowStep) => {
              Row() {
                Column() {
                  Column()
                    .width(12).height(12).borderRadius(6)
                    .backgroundColor(getFlowStateColor(s.state))
                  if (s.id !== 6) {
                    Column()
                      .width(2).height(44)
                      .backgroundColor(s.state === '已完成' ? '#00838F' : '#E0E0E0')
                  }
                }
                .alignItems(HorizontalAlign.Center).width(30)

                Column() {
                  Row() {
                    Text(s.title).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#424242')
                    Text(s.state).fontSize(8).fontColor('#FFFFFF')
                      .backgroundColor(getFlowStateColor(s.state))
                      .borderRadius(4).padding({ left: 5, right: 5, top: 1, bottom: 1 }).margin({ left: 8 })
                  }
                  Text(s.operator + ' · ' + s.time).fontSize(9).fontColor('#90A4AE').margin({ top: 4 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 8 })

                if (s.state === '进行中') {
                  Text('催办').fontSize(9).fontColor('#F9A825').backgroundColor('#FFF8E1')
                    .borderRadius(9).padding({ left: 10, right: 10, top: 4, bottom: 4 })
                }
              }
              .width('100%').alignItems(VerticalAlign.Top)
              .padding({ left: 14, right: 14, top: 8 })
            }, (s: FlowStep) => s.id.toString())
          }
          .width('96%').backgroundColor('#FFFFFF').borderRadius(14)
          .margin({ top: 12, left: 8, right: 8 })

          // 离职服务入口
          Column() {
            Row() {
              Text('🚪 离职服务台').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#006064')
              Text('保密通道').fontSize(8).fontColor('#FFFFFF').backgroundColor('#B71C1C')
                .borderRadius(4).padding({ left: 5, right: 5, top: 1, bottom: 1 }).margin({ left: 8 })
            }
            .width('100%').padding({ left: 14, right: 14, top: 12 })

            Row() {
              ForEach([['📋', '离职申请', '提交离职意向'], ['📦', '交接清单', '工作交接模板'], ['💼', '社保转移', '公积金提取指引'], ['📄', '证明开具', '离职证明下载']], (e: string[]) => {
                Column() {
                  Text(e[0]).fontSize(24)
                    .width(48).height(48).backgroundColor('#FFF8F0').borderRadius(12)
                    .textAlign(TextAlign.Center)
                  Text(e[1]).fontSize(9).fontWeight(FontWeight.Bold).fontColor('#424242').margin({ top: 6 })
                  Text(e[2]).fontSize(7).fontColor('#90A4AE').margin({ top: 2 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Center)
                .onClick(() => {
                  if (e[1] === '离职申请') {
                    this.showOffboard = true
                  }
                })
              }, (e: string[]) => e[1])
            }
            .width('100%').padding({ top: 14, bottom: 16 })
          }
          .width('96%').backgroundColor('#FFFFFF').borderRadius(14)
          .margin({ top: 12, left: 8, right: 8, bottom: 20 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .width('100%').height('100%')

      if (this.showOffboard) {
        this.modalOverlay(() => {
          this.showOffboard = false
        })
        this.offboardModal()
      }

      if (this.showDeleteWarn) {
        this.modalOverlay(() => {
          this.showDeleteWarn = false
        })
        this.cancelOffboardModal()
      }
    }
    .width('100%').height('100%')
  }
}

// ============ 入职伙伴 Tab ============
@Component
struct BuddyNetworkContent {
  @State showCall: boolean = false
  @State buddyName: string = '王莉'
  @State buddyDuty: string = '入职 Buddy'
  @State askedIds: number[] = [1, 3, 6]

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column().width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)')
        .onClick(() => {
          onClose()
        })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(998)
  }

  // 弹框5:联系伙伴(底部抽屉:联系方式+留言)
  @Builder callBuddyModal() {
    Column() {
      Column() {
        Row() {
          Text('🤝 联系 ' + this.buddyName).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#006064')
        }
        .width('100%').padding({ left: 16, top: 16 })

        Text('TA 的角色:' + this.buddyDuty).fontSize(10).fontColor('#90A4AE')
          .width('100%').padding({ left: 16, top: 6 })

        Row() {
          Text('📞').fontSize(20).width(40).height(40).backgroundColor('#E0F7FA').borderRadius(20).textAlign(TextAlign.Center)
          Text('电话 138****6688').fontSize(11).fontColor('#424242').margin({ left: 10 })
        }
        .width('88%').padding({ top: 14 })

        Row() {
          Text('💬').fontSize(20).width(40).height(40).backgroundColor('#E0F7FA').borderRadius(20).textAlign(TextAlign.Center)
          Text('内发消息(推荐)').fontSize(11).fontColor('#424242').margin({ left: 10 })
        }
        .width('88%').padding({ top: 10 })

        Row() {
          Text('📧').fontSize(20).width(40).height(40).backgroundColor('#E0F7FA').borderRadius(20).textAlign(TextAlign.Center)
          Text('wangli@company.com').fontSize(11).fontColor('#424242').margin({ left: 10 })
        }
        .width('88%').padding({ top: 10 })

        TextArea({ placeholder: '给 TA 留个言,说明你的问题...' })
          .fontSize(12).height(70)
          .width('88%').backgroundColor('#F0FAFB').borderRadius(8)
          .margin({ top: 16 })

        Button() {
          Text('发送消息').fontSize(13).fontColor('#FFFFFF')
        }
        .width('88%').height(40).backgroundColor('#00838F').borderRadius(20)
        .margin({ top: 16, bottom: 22 })
        .onClick(() => {
          this.showCall = false
        })
      }
      .width('100%').backgroundColor('#FFFFFF').borderRadius({ topLeft: 20, topRight: 20 })
    }
    .width('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 伙伴网络说明卡
          Column() {
            Text('🌟 你的入职守护网络').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text('遇到任何问题,随时找他们,没有笨问题').fontSize(9).fontColor('#B2EBF2').margin({ top: 4 })
          }
          .width('96%').padding(16)
          .linearGradient({ angle: 135, colors: [['#006064', 0], ['#26C6DA', 1]] })
          .borderRadius(14).margin({ top: 10 }).alignItems(HorizontalAlign.Start)

          // 伙伴打卡周报柱状图
          Column() {
            Text('📊 本周伙伴互动打卡').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#006064')
              .width('100%').padding({ left: 14, top: 12 })

            Row() {
              ForEach(CHECKIN_LOG, (c: CheckinLog) => {
                Column() {
                  Column()
                    .width(14).height(c.times * 9)
                    .backgroundColor(c.times >= 6 ? '#00838F' : '#B2EBF2')
                    .borderRadius({ topLeft: 3, topRight: 3 })
                    .animation({ duration: 700, curve: Curve.EaseOut })
                  Text('周' + c.day).fontSize(8).fontColor('#90A4AE').margin({ top: 4 })
                }
                .alignItems(HorizontalAlign.Center).margin({ left: 10, right: 10 })
              }, (c: CheckinLog) => c.id.toString())
            }
            .width('100%').padding({ top: 14, bottom: 12 })
            .alignItems(VerticalAlign.Bottom).height(104)

            Text('本周累计互动 29 次,超过 78% 的新人').fontSize(9).fontColor('#90A4AE')
              .margin({ bottom: 14 })
          }
          .width('96%').backgroundColor('#FFFFFF').borderRadius(14)
          .margin({ top: 12, left: 8, right: 8 })

          // 伙伴卡片列表
          Column() {
            Text('👥 伙伴列表').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#006064')
              .width('100%').padding({ left: 14, top: 12 })

            ForEach(BUDDY_LIST, (b: BuddyItem) => {
              Row() {
                Stack() {
                  Text(b.avatar).fontSize(24)
                    .width(46).height(46).backgroundColor('#E0F7FA').borderRadius(23)
                    .textAlign(TextAlign.Center)
                  if (b.contact) {
                    Text('●').fontSize(8).fontColor('#00C853')
                      .position({ x: 34, y: 34 })
                  }
                }

                Column() {
                  Row() {
                    Text(b.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#424242')
                    Text(b.duty).fontSize(8).fontColor('#FFFFFF').backgroundColor('#00838F')
                      .borderRadius(4).padding({ left: 5, right: 5, top: 1, bottom: 1 }).margin({ left: 6 })
                  }
                  Text(b.dept).fontSize(9).fontColor('#90A4AE').margin({ top: 3 })
                  Text('「' + b.intro + '」').fontSize(9).fontColor('#757575').margin({ top: 3 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 12 })

                Column() {
                  Text('联系').fontSize(10).fontColor('#FFFFFF').backgroundColor('#00838F')
                    .borderRadius(11).padding({ left: 14, right: 14, top: 5, bottom: 5 })
                  Text(this.askedIds.indexOf(b.id) >= 0 ? '已求助过' : '未求助')
                    .fontSize(7).fontColor('#B0BEC5').margin({ top: 4 })
                }
                .alignItems(HorizontalAlign.End)
                .onClick(() => {
                  this.buddyName = b.name
                  this.buddyDuty = b.duty
                  if (this.askedIds.indexOf(b.id) < 0) {
                    this.askedIds = this.askedIds.concat([b.id])
                  }
                  this.showCall = true
                })
              }
              .width('100%').padding({ left: 14, right: 14, top: 12, bottom: 12 })

              Divider().color('#E0F7FA')
            }, (b: BuddyItem) => b.id.toString())
          }
          .width('96%').backgroundColor('#FFFFFF').borderRadius(14)
          .margin({ top: 12, left: 8, right: 8, bottom: 20 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .width('100%').height('100%')

      if (this.showCall) {
        this.modalOverlay(() => {
          this.showCall = false
        })
        this.callBuddyModal()
      }
    }
    .width('100%').height('100%')
  }
}

// ============ 我的 Tab ============
@Component
struct MyOnboardContent {
  @State showEdit: boolean = false
  @State phone: string = '138****6688'
  @State emergency: string = '母亲 139****2233'
  @State tshirt: string = 'L'
  @State ringAnim: boolean = false

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Column().width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)')
        .onClick(() => {
          onClose()
        })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(998)
  }

  // 弹框6:编辑个人信息(手机+紧急联系人+尺码)
  @Builder editInfoModal() {
    Column() {
      Column() {
        Text('✏️ 编辑个人信息').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#006064')
          .width('100%').padding({ top: 16, left: 16 })

        Row() {
          Text('手机号码').fontSize(11).fontColor('#757575').width(80)
          TextInput({ text: this.phone })
            .fontSize(12).layoutWeight(1).height(38)
            .backgroundColor('#F0FAFB').borderRadius(8)
            .onChange((v: string) => {
              this.phone = v
            })
        }
        .width('88%').margin({ top: 14 })

        Row() {
          Text('紧急联系人').fontSize(11).fontColor('#757575').width(80)
          TextInput({ text: this.emergency })
            .fontSize(12).layoutWeight(1).height(38)
            .backgroundColor('#F0FAFB').borderRadius(8)
            .onChange((v: string) => {
              this.emergency = v
            })
        }
        .width('88%').margin({ top: 12 })

        Text('工服尺码(入职礼包)').fontSize(11).fontColor('#757575').width('88%').margin({ top: 14 })

        Row() {
          ForEach(['S', 'M', 'L', 'XL', 'XXL'], (s: string) => {
            Text(s).fontSize(12)
              .fontColor(this.tshirt === s ? '#FFFFFF' : '#00838F')
              .backgroundColor(this.tshirt === s ? '#00838F' : '#E0F7FA')
              .width(38).height(32).borderRadius(16)
              .textAlign(TextAlign.Center).margin({ right: 10 })
              .onClick(() => {
                this.tshirt = s
              })
          }, (s: string) => s)
        }
        .width('88%').margin({ top: 8 })

        Row() {
          Button() {
            Text('取消').fontSize(13).fontColor('#616161')
          }
          .layoutWeight(1).height(36).backgroundColor('#ECEFF1').borderRadius(18)
          .onClick(() => {
            this.showEdit = false
          })

          Button() {
            Text('保存').fontSize(13).fontColor('#FFFFFF')
          }
          .layoutWeight(1).height(36).backgroundColor('#00838F').borderRadius(18)
          .margin({ left: 12 })
          .onClick(() => {
            this.showEdit = false
          })
        }
        .width('88%').margin({ top: 18, bottom: 20 })
      }
      .width('90%').backgroundColor('#FFFFFF').borderRadius(16)
    }
    .width('100%').alignItems(HorizontalAlign.Center)
    .position({ y: '20%' }).zIndex(999)
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 个人信息卡
          Column() {
            Row() {
              Stack() {
                Text('🧑‍🎓').fontSize(32)
                  .width(64).height(64).backgroundColor('#E0F7FA').borderRadius(32)
                  .textAlign(TextAlign.Center)
                Text('新人').fontSize(8).fontColor('#FFFFFF').backgroundColor('#F9A825')
                  .borderRadius(7).padding({ left: 4, right: 4, top: 1, bottom: 1 })
                  .position({ x: 42, y: 46 })
              }

              Column() {
                Text('周子轩').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                Text('研发部 · 前端工程师 · 试用期').fontSize(9).fontColor('#B2EBF2').margin({ top: 4 })
                Text('工号 EMP-2026-08826 · 入职 2026-08-20').fontSize(8).fontColor('#B2EBF2').margin({ top: 3 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 14 })

              Text('✏️').fontSize(16)
                .onClick(() => {
                  this.showEdit = true
                })
            }
            .width('100%').padding({ left: 16, right: 16, top: 16 })

            Row() {
              Column() {
                Text('850').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                Text('入职积分').fontSize(8).fontColor('#B2EBF2').margin({ top: 2 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Center)

              Column() {
                Text('75%').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                Text('完成度').fontSize(8).fontColor('#B2EBF2').margin({ top: 2 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Center)

              Column() {
                Text('7天').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                Text('入职天数').fontSize(8).fontColor('#B2EBF2').margin({ top: 2 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Center)

              Column() {
                Text('5位').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                Text('已结识伙伴').fontSize(8).fontColor('#B2EBF2').margin({ top: 2 })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Center)
            }
            .width('100%').padding({ top: 16, bottom: 14 })
          }
          .width('96%')
          .linearGradient({ angle: 135, colors: [['#006064', 0], ['#26C6DA', 1]] })
          .borderRadius(16).margin({ top: 10 })

          // 试用期成长曲线
          Column() {
            Text('📈 试用期成长计划').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#006064')
              .width('100%').padding({ left: 14, top: 12 })

            ForEach([['第 1 周', '环境搭建与团队破冰', '100', '#00838F'], ['第 2 周', '熟悉业务与代码规范', '80', '#00838F'], ['第 3 周', '承接第一个迭代任务', '45', '#F9A825'], ['第 4 周', '独立负责功能模块', '10', '#B0BEC5'], ['第 12 周', '转正答辩', '0', '#ECEFF1']], (g: string[]) => {
              Column() {
                Row() {
                  Text(g[0]).fontSize(11).fontWeight(FontWeight.Bold).fontColor('#424242').width(52)
                  Text(g[1]).fontSize(10).fontColor('#757575').layoutWeight(1)
                  Text(g[2] + '%').fontSize(10).fontColor(g[3]).fontWeight(FontWeight.Bold)
                }
                .width('100%')

                Column() {
                  Column()
                    .width(g[2] + '%').height(5)
                    .backgroundColor(g[3]).borderRadius(3)
                    .animation({ duration: 700, curve: Curve.EaseOut })
                }
                .width('100%').height(5).backgroundColor('#F0FAFB').borderRadius(3)
                .margin({ top: 7 })
              }
              .width('100%').padding({ left: 14, right: 14, top: 10, bottom: 10 })
            }, (g: string[]) => g[0])
          }
          .width('96%').backgroundColor('#FFFFFF').borderRadius(14)
          .margin({ top: 12, left: 8, right: 8 })

          // 我的入职档案
          Column() {
            Text('🗄️ 我的入职档案').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#006064')
              .width('100%').padding({ left: 14, top: 12 })

            ForEach([['邮箱', 'zhouzixuan@company.com'], ['工位', 'A 座 12 楼 · F 区 · 088'], ['饭卡', '已开通 · 余额 ¥126.5'], ['门禁', '已开通 · 全楼栋权限'], ['社保', '9 月起缴纳 · 基数已确认'], ['公积金', '12% 比例 · 9 月起缴存']], (d: string[]) => {
              Row() {
                Text(d[0]).fontSize(11).fontColor('#90A4AE').width(56)
                Text(d[1]).fontSize(11).fontColor('#424242').layoutWeight(1)
                Text('›').fontSize(14).fontColor('#B0BEC5')
              }
              .width('100%').padding({ left: 14, right: 14, top: 11, bottom: 11 })

              Divider().color('#E0F7FA')
            }, (d: string[]) => d[0])
          }
          .width('96%').backgroundColor('#FFFFFF').borderRadius(14)
          .margin({ top: 12, left: 8, right: 8, bottom: 20 })
        }
        .width('100%')
      }
      .scrollBar(BarState.Off)
      .width('100%').height('100%')

      if (this.showEdit) {
        this.modalOverlay(() => {
          this.showEdit = false
        })
        this.editInfoModal()
      }
    }
    .width('100%').height('100%')
  }
}


六、总结

在这里插入图片描述

本文对一个人事管理App入职离职全流程模块进行了完整的代码级深度解析。从整体架构来看,该应用采用了清晰的分层设计:数据模型层由五个interface接口和五组const静态数组构成,定义了入职任务、材料清单、流程步骤、伙伴信息、打卡记录等业务实体的类型结构和初始数据。这些数据在多个组件间共享,构成了应用的"数据骨架"。导航层通过OnboardTab枚举和ONBOARD_TABS元数据数组实现了五个Tab页的定义和渲染,使用if-else条件渲染根据currentTab状态值动态切换内容组件。

在组件层面,@Entry装饰的OnboardApp作为根组件管理全局Tab状态,五个@Component子组件各自独立管理内部状态和弹窗逻辑。每个组件都遵循Stack(根容器)+ Scroll(可滚动内容)+ 条件弹窗的结构模式,保证了UI层级的清晰和交互的一致性。@State装饰器在七个组件中总共管理了27个状态变量,覆盖了弹窗开关控制、表单数据绑定、列表选中状态、动画参数控制等多种状态管理场景。状态更新采用不可变模式,使用concat方法返回新数组而非原地修改,确保框架能正确检测变化并触发重渲染。

在UI实现层面,应用大量运用了ArkUI的核心布局容器:Column用于垂直排列(如弹窗内容、卡片内部信息)、Row用于水平排列(如列表项、按钮组)、Stack用于层叠叠加(如进度环中心文字、头像角标、勾选标记)、Scroll用于可滚动区域(如内容列表、横向步骤条)、Flex用于弹性布局。ForEach作为循环渲染组件在所有列表场景中使用,其三个参数(数据源、项生成函数、键值生成函数)的设计兼顾了灵活性和差分更新效率。linearGradient渐变背景在头部、卡片、按钮等元素上广泛使用,通过双色渐变营造层次感。

在交互动画层面,animation方法配合Curve.EaseOut缓出曲线在进度条、柱状图、勾选标记、标签切换等场景中实现了平滑过渡效果。setTimeout用于实现"先放大再缩回"的弹性动画模式。Progress组件的ProgressType.Ring环形进度通过Stack叠加文字实现了完成度展示。shadow方法为悬浮按钮添加了阴影效果。position方法实现了绝对定位,用于角标、悬浮按钮、遮罩层等需要精确定位的元素。

在弹窗交互层面,应用通过@Builder装饰器定义了六种弹窗,涵盖居中卡片式(任务确认、材料上传、信息编辑、离职警示)和底部抽屉式(离职申请、联系伙伴)两种弹窗形态。每个弹窗都配合modalOverlay遮罩层使用,遮罩层使用半透明黑色背景和zIndex(998)确保层级关系正确。弹窗内容通过状态变量动态填充,实现了"一个模板多种内容"的复用模式。表单交互使用了Radio单选按钮、TextInput文本输入、TextArea多行文本输入、标签式选择器等多种输入组件,覆盖了企业应用中常见的表单场景。

辅助函数getMatStatusColorgetFlowStateColor作为全局纯函数,将业务状态到颜色的映射逻辑从UI代码中分离,提高了代码的可维护性和一致性。这种将展示逻辑提取为纯函数的做法在大型应用中尤为重要,使得配色方案的调整只需修改函数实现而无需遍历所有UI代码。整体应用通过青蓝色系(#00838F#26C6DA#E0F7FA等)的统一运用,辅以琥珀色(#F9A825)和红色(#B71C1C)的对比点缀,营造了清新专业又不失活力的视觉风格,符合企业人事管理应用的设计调性。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐