【HarmonyOS 6】“人物详情“页面的UI布局拆解

【HarmonyOS 6】"人物详情"页面的UI布局拆解

上一篇我们拆了"人物"列表页。这篇来看点击列表项之后进入的------人物详情页

列表页解决的是"我关心的人有哪些",详情页解决的是"这个人到底怎么样"。信息密度骤然上升:头像、姓名、关系、职称、城市、亲密度、生日、喜好忌讳、关键印象、往来记录......这么多信息怎么排,才能不乱?


一、页面全貌

详情页的结构从上到下分为六个区块:

复制代码
Stack
 ├─ Column
 │   ├─ 顶栏(返回 + 编辑/删除)
 │   └─ Scroll
 │       └─ Column({ space: 24 })
 │           ├─ 头部区(头像 + 姓名/关系/标签)
 │           ├─ 三指标卡(城市 / 亲密度 / 生日)
 │           ├─ 喜好与忌讳
 │           ├─ 关键印象
 │           └─ 往来记录
 └─ FAB(+ 记录往来)

对应代码骨架:

typescript 复制代码
build() {
  Stack({ alignContent: Alignment.BottomEnd }) {
    Column() {
      Row() { 返回  编辑  删除 }       // 顶栏
      Scroll() {
        Column({ space: 24 }) {
          头像 + 姓名 + 标签
          Flex({ wrap: FlexWrap.Wrap }) { 城市卡  亲密度卡  生日卡 }
          喜好与忌讳
          关键印象
          往来记录
        }
      }
    }
    Button('+ 记录往来')                // FAB
    if (this.showAddRecordPanel) { 新增面板 }
  }
}

整体用 Stack 包裹,主内容在底层,FAB 悬浮在右下角,新增往来记录面板是覆盖层。三层叠加,互不干扰。


二、顶栏:返回 + 编辑/删除

typescript 复制代码
Row() {
  Stack() {
    Path()
      .commands('M16 8 L6 18 L16 28')
      .strokeWidth(3)
      .stroke(Theme.getTextPrimary(this.currentMode))
      .fill(Color.Transparent)
      .strokeLineCap(LineCapStyle.Round)
      .strokeLineJoin(LineJoinStyle.Round)
  }
  .width(32)
  .height(32)
  .onClick(() => {
    if (this.isEditMode) {
      this.showSaveConfirmDialog();
    } else {
      if (this.onBack) this.onBack();
    }
  })

  Blank()

  Row({ space: 12 }) {
    Text(this.isEditMode ? '完成' : '编辑')
      .fontSize(15)
      .fontWeight(FontWeight.Medium)
      .fontColor(Theme.primary)
      .padding({ left: 16, right: 16, top: 6, bottom: 6 })
      .backgroundColor(Theme.getPrimarySoft(this.currentMode))
      .borderRadius(14)
      .onClick(...)

    if (!this.isEditMode) {
      Text('删除')
        .fontSize(15)
        .fontWeight(FontWeight.Medium)
        .fontColor('#FA5151')
        .padding({ left: 16, right: 16, top: 6, bottom: 6 })
        .backgroundColor(this.currentMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK ? '#33FA5151' : '#1AFA5151')
        .borderRadius(14)
        .onClick(...)
    }
  }
}
.width('100%')
.padding({ left: 12, right: 20, top: 12, bottom: 12 })
.backgroundColor(Theme.getBackground(this.currentMode))

2.1 返回箭头:用 Path 绘制

typescript 复制代码
Path()
  .commands('M16 8 L6 18 L16 28')
  .strokeWidth(3)
  .stroke(Theme.getTextPrimary(this.currentMode))
  .fill(Color.Transparent)
  .strokeLineCap(LineCapStyle.Round)
  .strokeLineJoin(LineJoinStyle.Round)

返回按钮没有用图片或 Text('<'),而是用 Path 画了一个 < 形状的箭头。SVG 路径 M16 8 L6 18 L16 28 表示:移动到 (16,8),画线到 (6,18),再画线到 (16,28)------就是一个标准的左箭头。

strokeLineCap(LineCapStyle.Round)strokeLineJoin(LineJoinStyle.Round) 让线条两端和拐角都是圆的,比直角更柔和。

2.2 编辑态切换

typescript 复制代码
Text(this.isEditMode ? '完成' : '编辑')

正常模式显示"编辑",编辑模式显示"完成"。同一块区域,两种状态,省空间。

编辑模式下,"删除"按钮会隐藏------防止用户在编辑时误删。

2.3 删除按钮:鸿蒙警告红

typescript 复制代码
.fontColor('#FA5151')
.backgroundColor(this.currentMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK ? '#33FA5151' : '#1AFA5151')

#FA5151 是鸿蒙设计规范中的警告红。背景色是警告红的低透明度版本:亮色模式 #1AFA5151(约 10% 不透明度),暗色模式 #33FA5151(约 20% 不透明度)。深底浅字 + 浅底,和"编辑"按钮的浅底深字风格呼应,但颜色不同(蓝 vs 红),用户一眼就能区分"安全操作"和"危险操作"。

2.4 返回时的未保存提醒

typescript 复制代码
.onClick(() => {
  if (this.isEditMode) {
    this.showSaveConfirmDialog();
  } else {
    if (this.onBack) this.onBack();
  }
})

如果在编辑模式下点返回,不会直接退出,而是弹出确认对话框。这是防止用户丢失修改的标准做法。


三、头部区:80×80 头像 + 姓名/关系/标签

typescript 复制代码
Column({ space: 16 }) {
  Row({ space: 20 }) {
    // 头像
    Column() {
      Text(this.contact.name.substring(0, 1))
        .fontSize(32)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
    }
    .width(80)
    .height(80)
    .backgroundColor(Theme.primary)
    .borderRadius(40)
    .justifyContent(FlexAlign.Center)

    // 右侧信息
    Column({ space: 8 }) { ... }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
  }
  .width('100%')

  // 标签行
  Row({ space: 12 }) { ... }
  .width('100%')
}
.width('100%')
.padding({ left: 20, right: 20 })

3.1 头像:80×80 正圆

typescript 复制代码
.width(80)
.height(80)
.borderRadius(40)

这是整个 App 里最大的头像。列表页是 44×44 圆角方块,首页优先人物是 52×52 正圆,详情页直接拉到 80×80。因为详情页的头像是页面的视觉锚点,用户进入详情页的第一眼就应该看到"这个人是谁"。

32 号字的首字名,在 80vp 的蓝色圆形中非常醒目。

3.2 编辑模式下的姓名输入

正常模式和编辑模式下,右侧信息区的布局完全不同:

正常模式:

typescript 复制代码
Text(this.contact.name)
  .fontSize(28)
  .fontWeight(FontWeight.Bold)
  .fontColor(Theme.getTextPrimary(this.currentMode))
Text(this.contact.relation + (this.contact.title ? ' · ' + this.contact.title : ''))
  .fontSize(15)
  .fontColor(Theme.getTextSecondary(this.currentMode))

姓名 28 号加粗,关系和职称用中圆点 · 拼接,15 号灰色。

编辑模式:

typescript 复制代码
Column({ space: 4 }) {
  Text('姓名').fontSize(11).fontColor(Theme.getTextSecondary(this.currentMode))
  TextInput({ text: $$this.contact.name, placeholder: '姓名' })
    .fontSize(16).fontWeight(FontWeight.Bold).height(36).padding({ left: 10, right: 10 })
    .backgroundColor(Theme.getSurface(this.currentMode))
    .border({ width: 1, color: Theme.getBorder(this.currentMode), radius: 8 })
}
Row({ space: 8 }) {
  Column({ space: 4 }) {
    Text('关系').fontSize(11)...
    TextInput({ text: $$this.contact.relation, placeholder: '如:朋友、客户' })...
  }.layoutWeight(1)
  Column({ space: 4 }) {
    Text('头衔').fontSize(11)...
    TextInput({ text: $$this.contact.title, placeholder: '如:总监、行业专家' })...
  }.layoutWeight(1)
}

编辑模式下,每个字段上方都有 11 号的灰色标签("姓名"、"关系"、"头衔"),下方是带描边的输入框。关系和头衔用 Row({ space: 8 }) 并排,各占 layoutWeight(1)

3.3 $$ 双向绑定

typescript 复制代码
TextInput({ text: $$this.contact.name, placeholder: '姓名' })

$$ 是鸿蒙的双向绑定语法。输入框的值和 this.contact.name 实时同步,不需要在 onChange 里手动赋值。这比写 onChange((val) => { this.contact.name = val }) 更简洁。

3.4 标签行:编辑 vs 展示

正常模式------药丸标签:

typescript 复制代码
if (this.contact.tags && this.contact.tags.length > 0) {
  ForEach(this.contact.tags, (tag: string) => {
    Text(tag)
      .fontSize(12)
      .fontColor(Theme.primary)
      .padding({ left: 12, right: 12, top: 6, bottom: 6 })
      .backgroundColor(Theme.getPrimarySoft(this.currentMode))
      .borderRadius(14)
  })
}

标签用浅底深字药丸,和"首页"优先人物卡片的描边药丸不同。浅底药丸更轻,适合标签数量较多的场景。

编辑模式------空格分隔输入:

typescript 复制代码
TextInput({ text: $$this.draftTags, placeholder: '如:重要客户 健身达人' })

编辑时所有标签合并成一个输入框,用空格分隔。比给每个标签单独做输入框简单得多,也避免了"怎么删除标签"的交互问题。


四、三指标卡:城市 / 亲密度 / 生日

三个卡片用 Flex 排列:

typescript 复制代码
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
  this.buildCityCard()       // 全宽
  this.buildIntimacyCard()   // 半宽
  this.buildBirthdayCard()   // 半宽
}
.width('100%')
.padding({ left: 20, right: 20 })

4.1 布局:1 + 2 排列

复制代码
┌──────────────────────────────────┐
│  常驻城市:深圳                    │
└──────────────────────────────────┘
┌────────────────┐ ┌────────────────┐
│  亲密度:★★★★☆  │ │  生日:05-12   │
└────────────────┘ └────────────────┘

城市卡是全宽,margin({ bottom: 12 }) 和下方两张卡分开。亲密度卡和生日卡各占 calc(50% - 6px),中间留 12vp 的间距。

4.2 为什么城市卡用全宽

typescript 复制代码
// 城市卡
.width('100%')

// 亲密度卡、生日卡
.width('calc(50% - 6px)')

城市名可能比较长("哈尔滨"、"乌鲁木齐"),全宽避免文字截断。而亲密度和生日的内容都很短,半宽就够了。

4.3 每张卡都有彩色圆点标识

typescript 复制代码
// 城市卡
Column().width(6).height(6).borderRadius(3).backgroundColor(Theme.accent)     // 橙

// 亲密度卡
Column().width(6).height(6).borderRadius(3).backgroundColor(Theme.warning)   // 黄

// 生日卡
Column().width(6).height(6).borderRadius(3).backgroundColor(Theme.success)   // 绿

6×6 的圆形小点,放在标签文字左侧,用不同颜色区分:

卡片 圆点色 色值
常驻城市 Theme.accent #FF8C66
亲密度 Theme.warning #FFAA00
生日 Theme.success #27B38A 绿

和"首页"三指标行的语义色一脉相承------不同颜色代表不同含义。

4.4 描边卡片

typescript 复制代码
.border({ width: 1, color: Theme.getBorder(this.currentMode) })

三张指标卡用的是描边风格,不是阴影风格。因为这三张卡紧挨在一起,如果都用阴影,相邻的阴影会"打架"------描边更轻薄,适合密集排列的场景。

4.5 亲密度卡:星标 vs Rating

正常模式:

typescript 复制代码
Text(this.contact.intimacy > 0 ? '★'.repeat(this.contact.intimacy) : '+评定星级')
  .fontSize(15).fontWeight(FontWeight.Bold)
  .fontColor(this.contact.intimacy > 0 ? Theme.getTextPrimary(this.currentMode) : Theme.primary)

字符重复显示星级,简单直接。如果还没评定(intimacy === 0),显示"+评定星级"作为引导操作,颜色用 Theme.primary 蓝色暗示可点击。

编辑模式:

typescript 复制代码
Rating({ rating: this.contact.intimacy, indicator: false })
  .stars(5).stepSize(1).height(24).width(120)
  .onChange((v) => { this.contact.intimacy = v; })

编辑时切换为鸿蒙官方 Rating 组件,支持滑动评分,indicator: false 表示可交互。

4.6 空值引导:点击自动进入编辑

typescript 复制代码
.onClick(() => {
  if (!this.isEditMode && !this.contact.city) {
    this.isEditMode = true;
  }
})

城市为空时,文字显示"+添加城市",颜色用蓝色。点击后自动进入编辑模式,用户不需要先点顶栏的"编辑"再找城市输入框------一个点击就能开始编辑。


五、喜好与忌讳:双行偏好卡片

typescript 复制代码
Column({ space: 16 }) {
  this.buildDetailSectionTitle('喜好与忌讳')
  Column({ space: 12 }) {
    if (this.isEditMode) {
      // 编辑模式:两个 TextInput
    } else {
      if (this.contact.likes && this.contact.likes.length > 0) {
        this.buildPreferenceRow('喜好', this.getPreferenceText(this.contact.likes), Theme.success)
      }
      if (this.contact.dislikes && this.contact.dislikes.length > 0) {
        this.buildPreferenceRow('忌讳', this.getPreferenceText(this.contact.dislikes), Theme.accent)
      }
      if (都没有) {
        Text('暂无记录,建议在互动中观察补充')
      }
    }
  }
  .width('100%')
  .padding(18)
  .backgroundColor(Theme.getSurface(this.currentMode))
  .borderRadius(20)
}

5.1 buildPreferenceRow:标签 + 文字

typescript 复制代码
@Builder
private buildPreferenceRow(label: string, content: string, color: string) {
  Row({ space: 10 }) {
    Text(label)
      .fontSize(12)
      .fontColor('#FFFFFF')
      .padding({ left: 8, right: 8, top: 4, bottom: 4 })
      .backgroundColor(color)
      .borderRadius(6)
    Text(content)
      .fontSize(14)
      .fontColor(Theme.getTextPrimary(this.currentMode))
      .layoutWeight(1)
      .lineHeight(20)
  }
  .width('100%')
  .alignItems(VerticalAlign.Top)
}
复制代码
[喜好]  精品手冲咖啡极简主义设计黑胶唱片
[忌讳]  过度包装的礼盒迟到非工作时间谈公事

"喜好"和"忌讳"的标签是深底白字------和头部区的浅底深字药丸正好相反。因为这里的标签是行内标识,需要比内容文字更醒目,深底才能跳出来。

喜好用 Theme.success(绿),忌讳用 Theme.accent(橙)。绿色=正向,橙色=注意,语义清晰。

5.2 alignItems(VerticalAlign.Top)

typescript 复制代码
.alignItems(VerticalAlign.Top)

标签和内容文字顶部对齐。如果内容是多行文字,标签会贴着第一行顶部,而不是垂直居中------这样阅读时视线更自然。


六、关键印象:浅底卡片 + 内联新增

typescript 复制代码
Column({ space: 16 }) {
  this.buildDetailSectionTitle('关键印象')
  Column({ space: 12 }) {
    // 印象列表
    ForEach(this.contact.memories, (memory: string, index: number) => {
      Row({ space: 12 }) {
        Text('•')
          .fontSize(18)
          .fontColor(Theme.primary)
        Text(memory)
          .fontSize(15)
          .fontColor(Theme.getTextPrimary(this.currentMode))
          .layoutWeight(1)
          .lineHeight(22)
        Text('删除')
          .fontSize(13)
          .fontColor(Theme.getTextMuted(this.currentMode))
          .onClick(() => { this.deleteMemory(index); })
      }
      .width('100%')
      .alignItems(VerticalAlign.Top)
    })

    Divider().color(Theme.getBorder(this.currentMode)).strokeWidth(0.5)

    // 新增区域
    if (this.isAddingMemory) {
      Row({ space: 10 }) {
        TextInput({ placeholder: '输入新的关键印象', text: this.newMemoryText })
          .layoutWeight(1).height(40).fontSize(14)
          .backgroundColor(Theme.getBackground(this.currentMode))
          .onChange((val) => this.newMemoryText = val)
        Button('保存').height(40).fontSize(14).backgroundColor(Theme.primary)
        Button('取消').height(40).fontSize(14)
          .backgroundColor(Color.Transparent)
          .fontColor(Theme.getTextSecondary(this.currentMode))
      }
    } else {
      Row() {
        Text('+ 添加印象')
          .fontSize(14)
          .fontColor(Theme.primary)
          .padding({ top: 8, bottom: 8 })
          .onClick(() => { this.isAddingMemory = true; })
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
    }
  }
  .width('100%')
  .padding(20)
  .backgroundColor(Theme.getPrimarySoft(this.currentMode))
  .borderRadius(24)
}

6.1 浅底背景:PrimarySoft

typescript 复制代码
.backgroundColor(Theme.getPrimarySoft(this.currentMode))

关键印象区块用的是 PrimarySoft(亮色模式 #EAF2FD,淡蓝色),而不是其他区块的 Surface(白色)。这个微妙的色差让"关键印象"从页面中"浮"出来------因为这是用户最需要关注的信息。

6.2 圆点列表

typescript 复制代码
Text('•')
  .fontSize(18)
  .fontColor(Theme.primary)

每条印象前面有一个蓝色圆点 ,18 号字。比用数字序号更轻量,也比用图标更简洁。

6.3 "删除"用灰色而非红色

typescript 复制代码
Text('删除')
  .fontSize(13)
  .fontColor(Theme.getTextMuted(this.currentMode))

印象的删除用的是灰色 textMuted,而不是顶栏删除按钮的警告红 #FA5151。因为删除印象是"小操作"(删了一条还能再加),而删除人物是"大操作"(不可逆)。颜色深浅暗示了操作的危险程度。

6.4 内联新增:不弹面板,就地展开

typescript 复制代码
if (this.isAddingMemory) {
  Row({ space: 10 }) {
    TextInput(...)     // 输入框
    Button('保存')     // 保存按钮
    Button('取消')     // 取消按钮
  }
} else {
  Text('+ 添加印象')  // 收起状态
}

点击"+ 添加印象"后,输入框就地展开------不需要弹窗、不需要跳转。这种"内联新增"的模式,在印象这种轻量级内容上比弹面板更高效。用户写完一条,保存后继续写下一条,手不需要离开这个区域。

6.5 取消按钮:透明背景

typescript 复制代码
Button('取消')
  .height(40)
  .fontSize(14)
  .backgroundColor(Color.Transparent)
  .fontColor(Theme.getTextSecondary(this.currentMode))

取消按钮的背景是透明的,只有文字,视觉上退到"保存"按钮后面。这也是一种暗示------"保存"是主操作,"取消"是次要操作。


七、往来记录:时间轴式列表

typescript 复制代码
Column({ space: 16 }) {
  Row() {
    Text('往来记录')
      .fontSize(18)
      .fontWeight(FontWeight.Bold)
      .fontColor(Theme.getTextPrimary(this.currentMode))
      .layoutWeight(1)
    Text('新增')
      .fontSize(13)
      .fontColor(Theme.primary)
      .padding({ left: 10, right: 10, top: 5, bottom: 5 })
      .backgroundColor(Theme.getPrimarySoft(this.currentMode))
      .borderRadius(12)
      .onClick(() => { this.showAddRecordPanel = true; })
  }

  Column({ space: 0 }) {
    ForEach(this.contact.interactions, (record: InteractionRecord, index: number) => {
      this.buildInteractionItem(record, index === this.contact.interactions.length - 1)
    })
  }
  .width('100%')
  .backgroundColor(Theme.getSurface(this.currentMode))
  .borderRadius(24)
}
.width('100%')
.padding({ left: 20, right: 20, bottom: 110 })

7.1 "新增"做成药丸按钮

typescript 复制代码
Text('新增')
  .fontSize(13)
  .fontColor(Theme.primary)
  .padding({ left: 10, right: 10, top: 5, bottom: 5 })
  .backgroundColor(Theme.getPrimarySoft(this.currentMode))
  .borderRadius(12)

和顶栏的"编辑"按钮风格一致------浅底深字药丸。在标题行里放操作按钮,是详情页常见的设计模式。用户不需要滚到页面底部,在标题行就能新增记录。


八、往来记录项:时间轴布局

这是详情页信息密度最高的组件:

复制代码
┌──────────────────────────────────────────┐
│  03/03    [深度交流]              ¥260   │
│  2026     升职祝贺                       │
│           恭喜她升任 brand manager...     │
│           反馈:很喜欢那款冷萃壶          │
├──────────────────────────────────────────┤
│  02/15    [聚餐/宴请]                    │
│  2026     春节后午餐                     │
│           在万象天地吃了简餐...           │
└──────────────────────────────────────────┘
typescript 复制代码
@Builder
private buildInteractionItem(record: InteractionRecord, isLast: boolean) {
  Column() {
    Row({ space: 14 }) {
      // 左侧:日期
      Column({ space: 4 }) {
        Text(record.date.split('-')[1] + '/' + record.date.split('-')[2])
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(Theme.getTextPrimary(this.currentMode))
        Text(record.date.split('-')[0])
          .fontSize(10)
          .fontColor(Theme.getTextMuted(this.currentMode))
      }
      .width(40)

      // 右侧:内容
      Column({ space: 8 }) {
        Row() {
          Text(this.getInteractionTypeText(record.type))
            .fontSize(11)
            .fontColor(Theme.primary)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .border({ width: 1, color: Theme.primary })
            .borderRadius(6)
          Blank()
          if (record.amount) {
            Text('¥' + record.amount)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(Theme.accent)
          }
        }
        .width('100%')

        Text(record.title)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(Theme.getTextPrimary(this.currentMode))
        Text(record.content)
          .fontSize(14)
          .fontColor(Theme.getTextSecondary(this.currentMode))
          .lineHeight(20)

        if (record.feedback) {
          Text('反馈:' + record.feedback)
            .fontSize(13)
            .fontColor(Theme.success)
            .fontStyle(FontStyle.Italic)
        }
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .padding(20)

    if (!isLast) {
      Divider().color(Theme.getBorder(this.currentMode)).strokeWidth(0.5).margin({ left: 74, right: 20 })
    }
  }
}

8.1 左侧日期:月/日 + 年

typescript 复制代码
Text(record.date.split('-')[1] + '/' + record.date.split('-')[2])  // "03/03"
  .fontSize(13)
  .fontWeight(FontWeight.Bold)
Text(record.date.split('-')[0])                                     // "2026"
  .fontSize(10)

日期格式从 "2026-03-03" 拆成两行:月/日是 13 号加粗(用户最关心的),年是 10 号灰色(辅助信息)。40vp 的宽度刚好够放下 "03/03"。

8.2 互动类型:描边标签

typescript 复制代码
Text(this.getInteractionTypeText(record.type))
  .fontSize(11)
  .fontColor(Theme.primary)
  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
  .border({ width: 1, color: Theme.primary })
  .borderRadius(6)

互动类型用描边标签,和"首页"优先人物卡片的里程碑标签风格一致。五种类型对应五种文字:

typescript 复制代码
private getInteractionTypeText(type: string): string {
  const map: Record<string, string> = {
    'gift': '送礼/收礼',
    'meal': '聚餐/宴请',
    'help': '帮忙/托付',
    'visit': '拜访/见面',
    'chat': '深度交流'
  };
  return map[type] || '其他';
}

8.3 金额:橙色加粗

typescript 复制代码
Text('¥' + record.amount)
  .fontSize(14)
  .fontWeight(FontWeight.Bold)
  .fontColor(Theme.accent)

金额用 Theme.accent#FF8C66 橙色),和"首页"三指标行的"待问候"同色。橙色在视觉上暗示"需要注意的数字"。

8.4 反馈:绿色斜体

typescript 复制代码
Text('反馈:' + record.feedback)
  .fontSize(13)
  .fontColor(Theme.success)
  .fontStyle(FontStyle.Italic)

反馈用 Theme.success#27B38A 绿色)+ 斜体。绿色=正向反馈,斜体=引用语气------"对方说了这样的话"。这是整页唯一使用斜体的地方,视觉上很容易识别。

8.5 Divider 左侧缩进 74vp

typescript 复制代码
Divider().color(Theme.getBorder(this.currentMode)).strokeWidth(0.5).margin({ left: 74, right: 20 })

和"人物"列表页的缩进逻辑一样:左侧 padding 20 + 日期宽度 40 + 间距 14 = 74。分隔线和内容左对齐,不和日期左对齐。


九、FAB:右下角悬浮按钮

typescript 复制代码
Button('+ 记录往来')
  .height(50)
  .fontSize(16)
  .fontWeight(FontWeight.Medium)
  .fontColor('#FFFFFF')
  .backgroundColor(Theme.primary)
  .borderRadius(25)
  .margin({ right: 20, bottom: 24 })
  .shadow({ radius: 12, color: Theme.getShadow(this.currentMode), offsetX: 0, offsetY: 6 })
  .onClick(() => { this.showAddRecordPanel = true; })

和"人物"列表页的 FAB 不同,详情页的 FAB 是带文字的------+ 记录往来,50vp 高的胶囊形。因为详情页的操作更明确("记录往来" vs "新增"),文字按钮比纯图标更清晰。

Stack({ alignContent: Alignment.BottomEnd }) 让 FAB 定位在右下角,不会随内容滚动。


十、新增往来记录面板:底部抽屉

typescript 复制代码
@Builder
private buildAddRecordPanel() {
  Column() {
    Blank()
    Column({ space: 16 }) {
      // 标题行
      Row() {
        Text('新增往来记录').fontSize(22).fontWeight(FontWeight.Bold)
        Blank()
        Text('关闭').fontSize(14).fontColor(Theme.getTextSecondary(this.currentMode))
      }

      // 互动类型选择
      Column({ space: 8 }) {
        Text('互动类型').fontSize(13)
        Scroll() {
          Row({ space: 8 }) {
            this.buildTypeOption('chat')
            this.buildTypeOption('visit')
            this.buildTypeOption('meal')
            this.buildTypeOption('gift')
            this.buildTypeOption('help')
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
      }

      // 输入字段
      this.buildRecordInput('标题', '例如:周末聚餐', ...)
      this.buildRecordInput('内容', '记录这次互动的关键细节', ...)
      Row({ space: 12 }) {
        this.buildRecordInput('金额(可选)', '例如:188', ...)
        this.buildRecordInput('反馈(可选)', '对方反馈', ...)
      }

      // 按钮
      Row({ space: 12 }) {
        Button('取消').layoutWeight(1).height(48)...
        Button('保存记录').layoutWeight(1).height(48)...
      }
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 22, bottom: 28 })
    .backgroundColor(Theme.getSurface(this.currentMode))
    .borderRadius({ topLeft: 28, topRight: 28 })
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.End)
  .backgroundColor(Theme.getOverlay(this.currentMode))
}

10.1 底部抽屉形态

typescript 复制代码
.borderRadius({ topLeft: 28, topRight: 28 })

只有顶部两个角是圆角,底部是直角------这是典型的底部抽屉(Bottom Sheet)形态。28vp 的圆角比普通卡片的 16-20vp 都大,因为抽屉是从底部滑出来的大面板,大圆角更柔和。

10.2 遮罩层

typescript 复制代码
.backgroundColor(Theme.getOverlay(this.currentMode))

抽屉背后是半透明遮罩,Theme.overlay 在亮色模式下是 #660C1320(约 40% 不透明度的深色),让用户知道"当前在弹层中,背景内容暂时不可操作"。

10.3 互动类型选择:横向滑动药丸

typescript 复制代码
@Builder
private buildTypeOption(type: InteractionType) {
  Text(this.getInteractionTypeText(type))
    .fontSize(12)
    .fontColor(this.draftType === type ? '#FFFFFF' : Theme.getTextSecondary(this.currentMode))
    .padding({ left: 12, right: 12, top: 7, bottom: 7 })
    .backgroundColor(this.draftType === type ? Theme.primary : Theme.getSurfaceAlt(this.currentMode))
    .borderRadius(14)
    .onClick(() => { this.draftType = type; })
}

选中态是深底白字(Theme.primary 蓝 + 白),未选中态是浅底深字(Theme.surfaceAlt + 灰)。5 个药丸水平排列,用 Scroll 包裹防止窄屏溢出。

10.4 金额和反馈并排

typescript 复制代码
Row({ space: 12 }) {
  this.buildRecordInput('金额(可选)', ...)
  this.buildRecordInput('反馈(可选)', ...)
}

金额和反馈是可选字段,各占一半宽度。必填字段(标题、内容)独占一行,可选字段缩成半行------这种布局暗示了"哪些是必须填的,哪些可以跳过"。

10.5 双按钮:取消 + 保存

typescript 复制代码
Button('取消')
  .layoutWeight(1).height(48)
  .fontColor(Theme.getTextSecondary(this.currentMode))
  .backgroundColor(Theme.getSurfaceAlt(this.currentMode))
  .borderRadius(18)

Button('保存记录')
  .layoutWeight(1).height(48)
  .fontColor('#FFFFFF')
  .backgroundColor(Theme.primary)
  .borderRadius(18)

两个按钮等宽(layoutWeight(1)),48vp 高,18vp 圆角。取消是浅底灰字,保存是深底白字------和顶栏的"编辑"/"删除"按钮一样,用颜色区分主次操作。


十一、编辑模式的统一设计

整个详情页有一个贯穿始终的设计:正常模式和编辑模式的切换

区域 正常模式 编辑模式
姓名 28 号加粗文字 TextInput + 11 号标签
关系/职称 中圆点拼接文字 两个并排 TextInput
标签 ForEach 药丸 空格分隔 TextInput
城市 纯文字 TextInput
亲密度 ★ 文字 Rating 组件
喜好/忌讳 buildPreferenceRow 两个 TextInput

切换逻辑集中在顶栏的一个按钮上:

typescript 复制代码
Text(this.isEditMode ? '完成' : '编辑')
  .onClick(async () => {
    if (this.isEditMode) {
      // 保存修改
      this.contact.tags = this.draftTags.split(' ').filter(v => v.trim() !== '');
      await this.persistContact();
      this.isEditMode = false;
    } else {
      // 进入编辑
      this.draftTags = (this.contact.tags || []).join(' ');
      this.isEditMode = true;
    }
  })

进入编辑时,把标签数组转成空格分隔的字符串;保存时,再把字符串拆回数组。这种"展示态 ↔ 编辑态"的双模式设计,在详情页中非常实用------不需要单独做一个编辑页面,同一个页面就能完成查看和修改。


十二、页面区块的间距体系

区块 外层 space 内层 space 区块标题字号
头部区 16 8 / 12
三指标卡 --- 8 / 12 12
喜好与忌讳 16 12 18
关键印象 16 12 18
往来记录 16 0 / 8 18

整个 Scroll 内的 Column({ space: 24 }) 给了 24vp 的大间距,让六个区块之间有明确的视觉分隔。区块内部用 12-16vp 的间距,信息更紧凑。

三个区块标题用了统一的 buildDetailSectionTitle

typescript 复制代码
@Builder
private buildDetailSectionTitle(title: string) {
  Text(title)
    .fontSize(18)
    .fontWeight(FontWeight.Bold)
    .fontColor(Theme.getTextPrimary(this.currentMode))
    .width('100%')
}

18 号加粗,和"首页"区块标题的 17 号接近,保持系列文章的视觉一致性。


十三、总结

这篇我们拆解了"人物详情"页面的 UI 布局,核心要点:

  1. Stack 三层叠加:主内容 + FAB + 新增面板,互不干扰。
  2. 80×80 头像:全 App 最大的头像,详情页的视觉锚点。
  3. **双向绑定∗∗:编辑态下的输入框用' 双向绑定**:编辑态下的输入框用 `双向绑定∗∗:编辑态下的输入框用'` 实时同步,省去手动 onChange。
  4. 三指标卡:1+2 排列,城市全宽、亲密度和生日半宽,描边风格适合密集排列。
  5. 空值引导:"+添加城市"、"+评定星级"用蓝色暗示可点击,点击自动进入编辑模式。
  6. 喜好忌讳:深底白字标签 + 浅底内容文字,绿色=正向、橙色=注意。
  7. 关键印象:PrimarySoft 浅蓝背景让它"浮"出来,内联新增比弹面板更高效。
  8. 往来记录:时间轴式布局,日期分两行(月/日 + 年),描边类型标签,金额橙色、反馈绿色斜体。
  9. Divider 缩进 74vp:和列表页同一逻辑,分隔线和内容对齐。
  10. 底部抽屉:28vp 顶部圆角 + 遮罩层,类型选择用横向药丸,必填独占一行、可选并排半行。
  11. 编辑模式统一:一个按钮切换,标签用空格分隔输入,保存时拆回数组。