引言:鸿蒙声明式 UI 与剧本杀业务的深度碰撞

在当今移动应用开发领域,华为鸿蒙操作系统凭借其独有的分布式能力和声明式 UI 框架 ArkUI,正在重新定义跨设备应用的开发范式。ArkUI 框架以 ArkTS 语言为核心,融合了 TypeScript 的类型安全特性与声明式编程的直观表达力,为开发者提供了一套从数据建模到界面渲染的完整解决方案。本文将以一个名为"迷雾剧本·员工剧本杀馆"的应用为案例,深入剖析其数据层设计、多页面组件架构、弹窗交互体系以及状态驱动渲染机制。

技术提示:ArkTS 是鸿蒙生态的官方开发语言,它在 TypeScript 的基础上扩展了 @Component@State@Builder@Entry 等装饰器语法,将面向对象的数据模型与声明式 UI 描述有机结合。理解这些装饰器的语义边界,是掌握 ArkUI 开发的关键一步。

这个剧本杀馆应用覆盖了完整的业务闭环:从剧本库浏览、组局大厅、角色档案馆,到活动中心、迷雾商城、个人场次记录。它采用典型的"底部多 Tab 导航 + 弹窗详情"的交互架构,通过 @State 状态变量驱动十四个独立弹窗的显示与隐藏,每个弹窗对应一类业务实体的详情或操作表单。整个应用没有使用页面路由跳转,而是通过 Stack 容器叠加遮罩层与弹窗内容,实现轻量级的模态交互,这种设计在 ArkUI 中是一种高效的模式。

从架构层面来看,整个应用遵循"数据层 — 组件层 — 入口层"的三段式结构。数据层通过 interface 定义六类业务实体的类型契约,再通过 const 数组固化模拟数据,辅以一批纯函数完成数据切片与样式映射。组件层由七个独立的 @Component 构成,每个组件负责一个 Tab 页面的 UI 渲染,并通过回调函数向父组件传递用户交互事件。入口层是一个 @Entry 装饰的主组件,它持有全部应用状态,通过条件渲染切换页面内容,并通过 @Builder 方法管理弹窗 UI 的复用。

数据层 Data Layer

接口与常量定义

辅助与工具函数

组件层 Component Layer

首页 MystHomeTab

剧本 ScriptTab

组局 GroupTab

角色 RoleTab

活动 MystEventTab

商城 MystShopTab

我的 MystMineTab

入口层 Entry Layer

MystApp 主组件

状态管理 @State

内容区 contentArea

底部导航 bottomTabItem

14个弹窗 Builder

在这里插入图片描述

上图展示了应用从数据到界面再到交互的完整数据流转链路。数据层提供的类型契约和模拟数据,经由工具函数加工后流入组件层;组件层渲染出七大业务页面,再通过回调将用户意图上抛到入口层;入口层根据状态变化决定渲染哪个页面、弹出哪个弹窗。这种单向数据流的设计使得状态可预测、调试可追溯,是 ArkUI 推荐的架构模式。

在技术栈方面,本案例大量运用了 ArkUI 的核心能力:ColumnRow 作为线性布局容器承载绝大多数排版需求;Stack 作为层叠容器实现弹窗的浮层叠加;Scroll 提供可滚动的内容区域;ForEach 实现列表的声明式渲染与 key 机制;FlexAlign 控制子元素的主轴对齐方式;linearGradient 实现渐变背景营造沉浸氛围;shadowborderRadius 构建卡片质感的视觉层级。此外,@Builder 方法被广泛用于抽取可复用的 UI 片段,十四个弹窗各自封装为一个 Builder 方法,实现了"一处定义、多处调用"的复用策略。


一、数据层:类型契约与模拟数据的基石

1.1 业务实体接口定义

interface MystScript {
  id: number;
  emoji: string;
  name: string;
  type: string;
  difficulty: number;
  mins: number;
  players: string;
  score: number;
  plays: number;
  price: number;
  color: string;
  desc: string;
}

interface MystGroup {
  id: number;
  emoji: string;
  name: string;
  script: string;
  time: string;
  dm: string;
  quota: number;
  joined: number;
  status: string;
  color: string;
  desc: string;
}

interface MystRole {
  id: number;
  emoji: string;
  name: string;
  script: string;
  job: string;
  age: number;
  trait: string;
  importance: string;
  color: string;
  desc: string;
}

interface MystEvent {
  id: number;
  emoji: string;
  name: string;
  type: string;
  time: string;
  quota: number;
  joined: number;
  reward: number;
  status: string;
  color: string;
  desc: string;
}

interface MystGoods {
  id: number;
  emoji: string;
  name: string;
  price: number;
  oldPrice: number;
  tag: string;
  stock: number;
  color: string;
  desc: string;
}

interface PlayRecord {
  id: number;
  emoji: string;
  name: string;
  script: string;
  role: string;
  result: string;
  date: string;
  status: string;
  color: string;
}

在这里插入图片描述

这里是数据层的根基——六个 interface 接口定义了六类业务实体的结构契约。在 ArkTS 中,interface 的作用与 TypeScript 一脉相承,它以纯声明的方式描述对象的数据形状,不包含任何方法实现,也不参与运行时的实例化。每个接口都遵循"标识唯一、字段命名清晰、类型精确"的原则,为后续的数据数组、组件参数传递、状态变量声明提供统一的类型依据。

逐个来看这六个实体的业务含义。MystScript 代表"剧本"这一核心资源,它包含名称、类型、难度系数、游戏时长、适合人数、评分、已开局数、人均价格等维度,是整个应用最复杂的数据结构。MystGroup 代表"组局"——也就是一次具体的开局活动,记录了关联剧本、开本时间、主持 DM、名额与已加入人数、组局状态等信息。MystRole 是角色档案,关联到具体剧本,描述角色的职业、年龄、性格特质和剧本中的重要性等级。

MystEvent 代表馆内举办的各类活动,如竞技赛、表演赛、内测招募等,包含名额、报名人数、奖励和状态。MystGoods 是商城商品,记录价格、原价、标签分类、库存量。PlayRecord 是玩家的场次记录,记录扮演的角色、结局、日期和本场评价。这六个实体共同构成了剧本杀馆的完整业务模型,覆盖了从剧本资源管理到玩家体验追踪的全链路。

值得注意的是,每个接口都包含一个 color 字段。这是一个设计上的巧思——将视觉色彩直接固化在数据层,使得每条记录都自带主题色,组件在渲染时可以直接读取这个字段作为卡片背景或标识色。这种"数据即样式"的做法在原型快速开发阶段非常高效,让 UI 层的逻辑得以简化,无需根据类型再做颜色映射推断。

架构思考:将 interface 放在文件顶部,意味着数据模型在整个文件范围内可见。ArkTS 的 interface 是编译期类型约束,运行时不产生任何开销,这与 class 不同。选择 interface 而非 class,是因为这里的数据是静态的、只读的,不需要实例方法或继承层级,interface 恰好满足"轻量类型契约"的需求。

1.2 模拟数据常量数组

const MYST_SCRIPTS: MystScript[] = [
  { id: 1, emoji: '🔍', name: '雾都夜行', type: '推理', difficulty: 4, mins: 180, players: '5-7人', score: 9.2, plays: 128, price: 68, color: '#4527A0', desc: '民国大都会连环失踪案,线索藏在雨夜的每一个角落' },
  { id: 2, emoji: '🕯️', name: '古宅烛影', type: '恐怖', difficulty: 3, mins: 150, players: '6-8人', score: 8.8, plays: 96, price: 58, color: '#1A237E', desc: '百年古宅闹鬼传闻背后,究竟是人祸还是鬼魅' },
  { id: 3, emoji: '💔', name: '月光邮局', type: '情感', difficulty: 2, mins: 120, players: '4-6人', score: 9.5, plays: 156, price: 48, color: '#AD1457', desc: '一封迟到十年的信,牵起三段尘封的青春往事' },
  { id: 4, emoji: '🎭', name: '十二时辰', type: '机制', difficulty: 4, mins: 200, players: '7-9人', score: 8.6, plays: 88, price: 78, color: '#E65100', desc: '长安城十二时辰内找出下毒真凶,阵营对决步步惊心' },
  { id: 5, emoji: '😂', name: '欢乐动物园', type: '欢乐', difficulty: 1, mins: 90, players: '5-8人', score: 8.4, plays: 142, price: 38, color: '#00838F', desc: '全员动物身份,笑料不断的合家欢本' },
  { id: 6, emoji: '🚢', name: '远洋号谜案', type: '推理', difficulty: 5, mins: 240, players: '6-8人', score: 9.0, plays: 76, price: 88, color: '#37474F', desc: '豪华游轮海上密室,五重不在场证明烧脑对决' }
];

在这里插入图片描述

这里展示的是 MYST_SCRIPTS 数组的前六条数据(完整数组有十二条)。使用 const 关键字声明,配合类型标注 MystScript[],确保数组元素的类型在编译期即被严格约束。每条数据是一个对象字面量,字段顺序与 interface 定义保持一致,便于阅读和维护。这种"用 const 数组充当本地数据源"的方式,在原型开发和功能验证阶段非常实用,无需依赖后端接口或数据库即可驱动完整 UI。

数据内容的设计也值得一提。剧本类型涵盖了推理、恐怖、情感、机制、欢乐、科幻六大流派,难度系数从 1 到 5 梯度分布,时长从 90 分钟到 240 分钟不等,适合人数从 4 人到 9 人各有差异。这种丰富的数据多样性,使得 UI 渲染时能充分展示各种边界情况——比如高难度的红色标识、长时长的提示、满员状态的灰色化处理等,让界面信息层次饱满。

每个剧本的 color 字段采用 Material Design 的深色调色板,如 #4527A0(深紫)、#1A237E(靛蓝)、#AD1457(深粉)、#E65100(深橙)、#00838F(深青)、#37474F(蓝灰)。这些颜色饱和度高、明度低,适合作为深色背景或强调色,与白色文字形成良好的对比度。在弹窗的渐变背景中,这些颜色作为起始色,配合 #1A1038(极深紫)作为终止色,营造出剧本杀特有的悬疑暗黑氛围。

数据设计哲学:在真实项目中,这些数据应来自后端 API。但本案例刻意将数据内联,体现了"前端先行"的开发理念——先以 mock 数据完成 UI 与交互的全部逻辑,再对接真实接口。这种模式让前后端可以并行开发,是敏捷团队常用的工程实践。

1.3 数据切片与工具函数

function getScriptRows(): MystScript[] {
  return [MYST_SCRIPTS[0], MYST_SCRIPTS[2], MYST_SCRIPTS[4], MYST_SCRIPTS[6], MYST_SCRIPTS[8], MYST_SCRIPTS[10]];
}

function getScriptRows2(): MystScript[] {
  return [MYST_SCRIPTS[1], MYST_SCRIPTS[3], MYST_SCRIPTS[5], MYST_SCRIPTS[7], MYST_SCRIPTS[9], MYST_SCRIPTS[11]];
}

function getHomeScripts(): MystScript[] {
  return [MYST_SCRIPTS[0], MYST_SCRIPTS[1], MYST_SCRIPTS[2]];
}

function getTypeTags(): string[] {
  return ['🔍 推理', '🕯️ 恐怖', '💔 情感', '🎭 机制', '😂 欢乐'];
}

function getPayLevels(): number[] {
  return [100, 300, 500, 1000];
}

function getPayGifts(): string[] {
  return ['送 20 币', '送 80 币', '送 150 币', '送 400 币'];
}

function getQuotaList(): number[] {
  return [4, 5, 6, 7, 8];
}

function getScoreList(): number[] {
  return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
}

function getDmList(): string[] {
  return ['老K', '小满', '墨白', '豆豆', '夜枭'];
}

在这里插入图片描述

这批函数是数据层与组件层之间的"适配层"。它们的核心职责是对原始数据数组进行切片、重组和筛选,以适配不同页面或弹窗的渲染需求。例如 getScriptRowsgetScriptRows2 将十二条剧本数据按奇偶索引拆成两组,分别用于剧本页的"主列表"和"更多剧本"区段;getHomeScripts 只取前三条,用于首页的横向滚动卡片。

这种将数据切片逻辑封装为独立函数的做法,带来了三重好处。第一,组件代码无需关心数据来源和筛选逻辑,只需调用函数获取结果数组,降低了组件的复杂度。第二,如果未来数据结构变化或筛选规则调整,只需修改这些函数,组件代码不受影响,符合开闭原则。第三,函数返回的是新的数组引用,而非对原数组的引用,避免了组件对全局数据的意外修改,保障了数据的不可变性。

另外几组函数返回的是配置性的常量数组,如充值档位、赠送奖励、可组人数、评分选项、DM 列表等。这些数据本质上是应用的配置参数,用函数封装而非直接声明 const,保持了调用风格的统一,也方便未来替换为动态配置接口。值得注意的是 getScoreList 返回 1 到 10 的完整评分序列,这在评分弹窗中会作为 ForEach 的数据源,渲染出十个可选的分值按钮。

设计启示:纯函数是 ArkUI 数据流中最安全的一环。它们无副作用、可预测、易测试。在数据层大量使用纯函数做转换和筛选,能让组件保持"只管渲染不管数据来源"的纯粹性,这是声明式 UI 得以高效运作的底层支撑。

1.4 样式映射工具函数

function getBarHeight(v: number): number {
  return 26 + v * 0.55;
}

function getDifficultyColor(d: number): string {
  if (d >= 5) {
    return '#B71C1C';
  }
  if (d >= 4) {
    return '#E65100';
  }
  if (d >= 3) {
    return '#F57C00';
  }
  return '#2E7D32';
}

function getStatusColor(s: string): string {
  if (s === '组局中' || s === '报名中') {
    return '#4527A0';
  }
  if (s === '已满员' || s === '报名截止') {
    return '#BDBDBD';
  }
  return '#F57C00';
}

function getRecordColor(s: string): string {
  if (s === 'MVP') {
    return '#FFB300';
  }
  if (s === '跳车') {
    return '#BDBDBD';
  }
  return '#00897B';
}

function formatScore(sc: number): string {
  return String(sc);
}

在这里插入图片描述

这一组函数负责将业务语义映射为视觉样式,是数据层向 UI 层提供"色彩建议"的桥梁。getDifficultyColor 根据难度系数返回对应的警示色——难度 5 返回深红 #B71C1C,难度 4 返回深橙,难度 3 返回中橙,难度 1-2 返回深绿,形成从绿到红的风险递进色谱。这种"数值到颜色"的映射在仪表盘、状态指示等场景中非常常见,让用户一眼就能感知风险等级。

getStatusColor 将组局和活动的状态文本映射为颜色。进行中的状态返回品牌紫色 #4527A0,表示可操作;已满员或已截止返回灰色 #BDBDBD,表示不可操作;其他中间状态返回橙色 #F57C00,表示需关注。这套色彩语义在组件的标签渲染中被反复调用——状态标签的背景色直接取自这个函数的返回值,确保了全应用范围内状态色彩的一致性。

getRecordColor 专门用于场次记录的评价映射,MVP 返回金色 #FFB300 体现荣誉感,跳车返回灰色体现消极,正常完本返回青绿 #00897B 体现积极。formatScore 虽然只是简单的 String() 转换,但将其封装为独立函数,意味着未来如果需要格式化为"9.2 分"这样的带单位字符串,只需修改这一处即可,体现了对扩展性的预留。

这些函数在组件的 build 方法中被高频调用,体现了 ArkUI 声明式渲染的一个特点:可以在属性链中直接调用函数。例如 .backgroundColor(getDifficultyColor(s.difficulty)),函数的返回值直接作为属性参数参与渲染。这种"函数驱动样式"的模式让 UI 与数据之间建立了动态绑定关系——只要数据变化,样式就会随之更新。

样式输出

映射函数

数据输入

difficulty 数值

status 字符串

record status 字符串

getDifficultyColor

getStatusColor

getRecordColor

绿/橙/红 色谱

紫/灰/橙 状态色

金/灰/青 评价色

上图展示了三种映射函数的输入输出对应关系。数值型和字符串型数据分别经过各自的映射逻辑,输出为语义化的色彩值,最终注入到 UI 组件的背景色属性中,形成"数据驱动视觉"的完整闭环。


二、首页组件:应用门面的沉浸式设计

2.1 组件声明与回调接口

@Component
struct MystHomeTab {
  onScriptClick: (s: MystScript) => void = () => {};
  onGroupClick: (g: MystGroup) => void = () => {};
  onMoreGroup: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        // ... 首页内容
      }
      .width('100%')
      .padding({ left: 14, right: 14, bottom: 20 })
    }
    .width('100%')
    .height('100%')
    .scrollBar(BarState.Off)
  }
}

在这里插入图片描述

首页组件 MystHomeTab@Component 装饰器声明,这是 ArkUI 中定义自定义组件的标准方式。@Component 装饰器告诉编译器:这个 struct 是一个可复用的 UI 组件,拥有独立的 build 方法描述其视觉结构。每个自定义组件都是一个 struct,而非 class——这是 ArkTS 的设计选择,struct 在内存上更轻量,更契合"数据 + 渲染描述"的组件本质。

组件声明了三个成员属性:onScriptClickonGroupClickonMoreGroup,它们的类型都是箭头函数,初始值为空函数 () => {}。这种"以函数属性作为事件回调"的设计,是 ArkUI 父子组件通信的标准模式。父组件在实例化子组件时,通过参数传入具体的回调实现(例如 MystHomeTab({ onScriptClick: (s) => { ... } })),子组件在用户点击时调用这些回调,将用户意图传递给父组件处理。

这种回调模式的精妙之处在于"控制反转"。子组件不知道点击后会发生什么——是打开弹窗、跳转页面还是触发网络请求,它只负责在点击事件中调用回调函数。具体的响应逻辑由父组件决定,子组件与业务逻辑彻底解耦。这使得 MystHomeTab 成为一个纯粹的"展示 + 事件上报"组件,可以在不同上下文中复用而无需修改。

ArkUI 知识点·@Component:被 @Component 装饰的 struct 不能独立存在于页面中,它必须被其他组件(包括 @Entry 主组件)引用才会渲染。@Component 组件每次被引用都会创建独立的状态实例,这意味着同一个组件可以在不同位置多次使用且互不干扰。

build 方法的根容器是 Scroll,这是 ArkUI 提供的滚动容器组件。Scroll 包裹一个 Column,Column 内部纵向排列所有首页内容。scrollBar(BarState.Off) 隐藏了滚动条,使界面更干净——这在卡片式布局中是常见的做法,因为卡片本身的阴影和圆角已经提供了足够的视觉边界,额外的滚动条反而显得冗余。

2.2 顶部 Banner 卡片

Column() {
  Row() {
    Text('🕵️')
      .fontSize(36)
    Column() {
      Text('迷雾之夜 · 开场')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
      Text('本季已解锁 12 个剧本 · 侦探等级 Lv.6')
        .fontSize(10)
        .fontColor('#C5CAE9')
        .margin({ top: 4 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })

    Column() {
      Text('1280')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFD700')
      Text('迷雾币')
        .fontSize(9)
        .fontColor('#C5CAE9')
    }
    .padding({ left: 14, right: 14, top: 8, bottom: 8 })
    .backgroundColor('#33FFFFFF')
    .borderRadius(12)
  }
  .width('100%')

  Row() {
    Text('🎭 今晚 19:00 雾都夜行首车 · 已满员')
      .fontSize(10)
      .fontColor('#C5CAE9')
    Text('')
      .layoutWeight(1)
    Text('去组局 ›')
      .fontSize(10)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFD700')
      .onClick(() => {
        this.onMoreGroup();
      })
  }
  .width('100%')
  .padding({ top: 8, bottom: 8, left: 12, right: 12 })
  .backgroundColor('#33FFFFFF')
  .borderRadius(10)
  .margin({ top: 12 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 16 })
.linearGradient({
  angle: 135,
  colorList: ['#1A1038', '#2D1B4E']
})
.borderRadius(16)
.shadow({ radius: 12, color: '#334527A0', offsetY: 4 })

在这里插入图片描述

这段代码构建了首页顶部的 Banner 卡片,它是整个应用视觉风格的第一印象。卡片由两层 Column 嵌套构成:外层 Column 定义整体卡片容器,内层先放一行(Row)展示侦探形象和迷雾币余额,再放一行展示今晚的组局提示。这种"Row 内嵌 Column"的嵌套模式是 ArkUI 布局的核心手法——Row 负责水平排布,Column 负责在水平方向上某一区域的纵向堆叠。

Banner 的视觉质感来自三个属性的协同:linearGradient 设置 135 度对角渐变,从极深紫 #1A1038 过渡到稍亮的 #2D1B4E,营造出夜幕低垂的悬疑感;borderRadius(16) 赋予卡片圆润的四角;shadow 添加带紫色色调的投影 #334527A0(30% 透明度的品牌紫),offsetY 为 4 让卡片产生悬浮于背景之上的层次感。这种"渐变 + 圆角 + 阴影"的三件套是 ArkUI 卡片设计的经典配方。

迷雾币的展示用了 backgroundColor('#33FFFFFF')——这是 20% 透明度的白色,在深紫背景上呈现为半透明的浅色区块,既不喧宾夺主,又清晰地隔离出余额信息。这种半透明叠层技巧在深色 UI 中极为常用,能在不引入新色彩的前提下创建信息区块的视觉边界。#FFD700 金色用于余额数字,呼应了"货币=黄金"的通用认知隐喻。

底部的组局提示行使用了 layoutWeight(1) 的空 Text 作为弹性占位符,将"去组局 ›“推向右侧。layoutWeight 是 ArkUI 中分配剩余空间的关键属性——当容器中某元素设置 layoutWeight 后,它会占据父容器在主轴方向上的剩余空间。用一个空的 Text 配合 layoutWeight 实现"两端对齐”,是 ArkUI 布局中的惯用技巧,类似于 CSS 的 flex: 1justify-content: space-between 效果。

ArkUI 知识点·Row 与 Column:Row 是水平线性布局容器,子元素从左到右排列;Column 是垂直线性布局容器,子元素从上到下排列。两者可以无限嵌套,构建出任意复杂的二维布局。alignItems 控制交叉轴对齐(如 Row 中的垂直对齐),justifyContent 控制主轴对齐(如 Row 中的水平分布)。

2.3 功能入口与高分剧本横向滚动

Row() {
  Column() {
    Text('📖')
      .fontSize(24)
    Text('剧本库')
      .fontSize(10)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 4 })
  }
  .layoutWeight(1)
  .padding({ top: 12, bottom: 12 })
  .justifyContent(FlexAlign.Center)
  .backgroundColor('#FFFFFF')
  .borderRadius(14)
  .margin({ right: 8 })
  .shadow({ radius: 6, color: '#14000000', offsetY: 2 })
  .onClick(() => {
    this.onMoreGroup();
  })

  Column() {
    Text('🎲')
      .fontSize(24)
    Text('快速组局')
      .fontSize(10)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 4 })
  }
  .layoutWeight(1)
  .padding({ top: 12, bottom: 12 })
  .justifyContent(FlexAlign.Center)
  .backgroundColor('#FFFFFF')
  .borderRadius(14)
  .margin({ right: 8 })
  .shadow({ radius: 6, color: '#14000000', offsetY: 2 })
  .onClick(() => {
    this.onMoreGroup();
  })

  Column() {
    Text('🎭')
      .fontSize(24)
    Text('角色库')
      .fontSize(10)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 4 })
  }
  .layoutWeight(1)
  .padding({ top: 12, bottom: 12 })
  .justifyContent(FlexAlign.Center)
  .backgroundColor('#FFFFFF')
  .borderRadius(14)
  .shadow({ radius: 6, color: '#14000000', offsetY: 2 })
  .onClick(() => {
    this.onMoreGroup();
  })
}
.width('100%')
.margin({ top: 12 })

在这里插入图片描述

功能入口区由三个等宽的白色卡片组成,分别对应"剧本库"“快速组局”"角色库"三大功能。三个卡片放在同一个 Row 中,每个 Column 都设置 layoutWeight(1),使它们平分 Row 的水平空间。这是 ArkUI 实现等分布局的最简方式——无需计算具体宽度,layoutWeight 会自动将剩余空间均分给设置了该属性的元素。

每个功能卡片内部是纵向排列的 emoji 图标和文字标签,通过 justifyContent(FlexAlign.Center) 实现内容在垂直方向的居中。FlexAlign 是 ArkUI 中控制 Flex 布局主轴对齐的枚举类型,FlexAlign.Center 表示子元素在主轴居中排列。对于 Column 而言,主轴是垂直方向,因此 Center 让内容垂直居中,使图标和文字在卡片中视觉平衡。

阴影属性 shadow({ radius: 6, color: '#14000000', offsetY: 2 }) 中的 color 用了 8% 透明度的黑色(#14 前缀 = 十六进制 0x14 = 十进制 20,约 8%),这种轻微的阴影让白色卡片在浅灰背景上产生微妙的悬浮感。三个卡片的阴影参数完全一致,体现了设计规范的一致性——同一层级的元素应具有相同的视觉权重。

点击事件统一调用 this.onMoreGroup(),这是为了在原型阶段简化交互——所有功能入口暂时都跳转到组局页。在真实项目中,每个入口应有各自的回调函数(如 onScriptLibonQuickGrouponRoleLib),但这里通过统一的 onMoreGroup 演示了回调传递的基本模式。

Scroll() {
  Row() {
    ForEach(getHomeScripts(), (s: MystScript) => {
      Column() {
        Text(s.emoji)
          .fontSize(36)
        Text(s.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .margin({ top: 8 })
        Text(s.type + ' · ' + s.players)
          .fontSize(9)
          .fontColor('#8E8E8E')
          .margin({ top: 3 })
        Text('⭐ ' + formatScore(s.score))
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFB300')
          .margin({ top: 4 })
        Text(s.difficulty >= 4 ? '高难度' : (s.difficulty >= 3 ? '中难度' : '新手友好'))
          .fontSize(9)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .backgroundColor(getDifficultyColor(s.difficulty))
          .borderRadius(9)
          .margin({ top: 6 })
      }
      .width(118)
      .padding({ top: 14, bottom: 14, left: 8, right: 8 })
      .backgroundColor('#FFFFFF')
      .borderRadius(14)
      .margin({ right: 10 })
      .shadow({ radius: 6, color: '#14000000', offsetY: 2 })
      .onClick(() => {
        this.onScriptClick(s);
      })
    }, (s: MystScript) => String(s.id))
  }
}
.width('100%')
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)

高分剧本区采用横向滚动列表,外层 Scroll 设置 scrollable(ScrollDirection.Horizontal) 将滚动方向切换为水平。ScrollDirection 枚举提供 Horizontal(水平)、Vertical(垂直)和 Free(自由)三种模式,默认是垂直滚动。水平滚动是展示推荐内容、精选内容的经典交互模式,用户可以通过左右滑动浏览更多卡片,节省垂直空间。

ForEach 是 ArkUI 中渲染列表数据的核心组件。它接收三个参数:数据数组、item 渲染函数、key 生成函数。这里数据源是 getHomeScripts() 返回的三条剧本数据,item 函数为每条数据渲染一个固定宽度 118 的卡片,key 函数 (s: MystScript) => String(s.id) 以剧本 id 作为唯一键。key 的作用是帮助 ArkUI 的 diff 算法高效识别列表项的变化——当数据增删或重排时,框架通过 key 匹配旧节点与新节点,最小化重新渲染的范围。

每张卡片内部是一个 Column,纵向堆叠 emoji、剧本名、类型与人数、评分、难度标签五个信息层。难度标签的实现尤其值得注意:通过三元表达式 s.difficulty >= 4 ? '高难度' : (s.difficulty >= 3 ? '中难度' : '新手友好') 将数值映射为文案,再通过 getDifficultyColor(s.difficulty) 映射为背景色,形成"数值 → 文案 + 色彩"的双重转换。这种在渲染时即时计算的写法,体现了声明式 UI 的灵活性。

点击卡片调用 this.onScriptClick(s),将当前剧本数据传递给父组件。父组件收到后会设置 selScript 状态并打开剧本详情弹窗。这种"点击列表项 → 打开详情弹窗"的交互模式贯穿整个应用,是用户体验的核心路径之一。

ArkUI 知识点·ForEachForEach(arr, itemGenerator, keyGenerator) 是 ArkUI 的列表渲染原语。itemGenerator 定义每个列表项的 UI 结构,keyGenerator 为每项生成唯一标识。key 的质量直接影响渲染性能——使用稳定且唯一的 key(如业务 id)能让 diff 算法高效运作,避免不必要的全量重建。


三、剧本页与组局页:列表展示的两种范式

3.1 剧本页:主次分明的双层列表

@Component
struct ScriptTab {
  onScriptClick: (s: MystScript) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('📖')
              .fontSize(30)
            Column() {
              Text('剧本库')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('12 个剧本 · 覆盖 5 大类型')
                .fontSize(10)
                .fontColor('#C5CAE9')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })
          }
          .width('100%')
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14, bottom: 14 })
        .linearGradient({
          angle: 90,
          colorList: ['#2D1B4E', '#4527A0']
        })
        .borderRadius(14)
        .margin({ bottom: 12 })

        Row() {
          ForEach(getTypeTags(), (tag: string) => {
            Text(tag)
              .fontSize(11)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .backgroundColor('#EDE7F6')
              .fontColor('#4527A0')
              .borderRadius(12)
            }, (tag: string) => tag)
          }
          .width('100%')
          .margin({ bottom: 12 })
      }
      .width('100%')
      .padding({ left: 14, right: 14, bottom: 20 })
    }
    .width('100%')
    .height('100%')
    .scrollBar(BarState.Off)
  }
}

剧本页 ScriptTab 的顶部是一个标题卡片,使用 90 度水平渐变 ['#2D1B4E', '#4527A0'],从左到右由深紫过渡到亮紫。这种水平渐变与首页 Banner 的 135 度对角渐变形成视觉区分,让用户在 Tab 切换时能感受到不同页面的"色彩个性"。标题区下方紧跟一行类型标签,用 ForEach(getTypeTags(), ...) 渲染五个流派标签,每个标签是浅紫底深紫字的圆角小方块,构成一个水平排列的筛选条。

类型标签的样式设计遵循"品牌色弱化版"原则:背景 #EDE7F6 是品牌紫 #4527A0 的极浅版本(约 10% 饱和度),文字用 #4527A0 原色,形成"同色系浅底深字"的协调配色。这种标签在 ArkUI 中通常用 Text 配合 padding 和 borderRadius 实现,无需引入 Badge 或 Chip 等额外组件,保持了实现的轻量。

剧本页的核心是两层列表结构。第一层用 getScriptRows() 返回六条数据,渲染为详细的卡片列表——每张卡片展示 emoji、名称、评分、类型与人数与时长、开局数与难度、难度标签和价格,信息密度高。第二层用 getScriptRows2() 返回另外六条数据,渲染为精简的列表项——只展示 emoji、名称、类型摘要和价格,信息密度低。中间用一条 Text('— 更多剧本 —') 作为视觉分隔,引导用户理解列表的层次。

这种"主列表 + 分隔符 + 次列表"的双层结构,是处理长列表的常见策略。主列表提供核心信息的充分展示,满足用户的深度浏览需求;次列表以紧凑形式呈现剩余内容,满足快速扫读需求。两层之间通过视觉密度的差异建立信息层级,避免了"一刀切"的等密度列表带来的阅读疲劳。

ArkUI 知识点·FlexAlignFlexAlign 是 Flex 布局的主轴对齐枚举,包含 Start(首端对齐)、Center(居中)、End(尾端对齐)、SpaceBetween(两端对齐无间距)、SpaceAround(等间距含两端)、SpaceEvenly(完全均布)等值。在 Row 和 Column 中通过 justifyContent 属性设置,决定子元素在主轴方向上的排布方式。

3.2 组局页:功能操作型列表

@Component
struct GroupTab {
  onGroupClick: (g: MystGroup) => void = () => {};
  onAddGroup: () => void = () => {};
  onEditGroup: (g: MystGroup) => void = () => {};
  onDeleteGroup: (g: MystGroup) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🎲')
              .fontSize(30)
            Column() {
              Text('组局大厅')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('10 个车在组 · 6 车还差人')
                .fontSize(10)
                .fontColor('#C5CAE9')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })

            Text('+ 发起组局')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor('#212121')
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .backgroundColor('#FFD700')
              .borderRadius(12)
              .onClick(() => {
                this.onAddGroup();
              })
          }
          .width('100%')
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14, bottom: 14 })
        .linearGradient({
          angle: 180,
          colorList: ['#1A1038', '#2D1B4E']
        })
        .borderRadius(14)
        .margin({ bottom: 12 })

组局页 GroupTab 的声明相比剧本页多了三个回调:onAddGrouponEditGrouponDeleteGroup,分别对应发起组局、编辑组局、解散组局三个操作。这说明组局页是一个"功能操作型"页面,除了浏览,还承担创建和管理的职责。回调数量直接反映了页面的功能复杂度。

顶部标题卡的右上角嵌入了"+ 发起组局"按钮,使用金色 #FFD700 背景、深色文字 #212121,与深紫标题背景形成强对比,吸引用户注意。按钮的 padding 和 borderRadius 构造了一个胶囊形状的可点击区域,onClick 调用 this.onAddGroup() 将意图上报给父组件。这种在标题栏内嵌操作按钮的设计,节省了页面空间,让标题与操作在同一视觉层内完成。

标题卡片使用了 180 度垂直渐变 ['#1A1038', '#2D1B4E'],从上到下由更深过渡到稍浅。垂直渐变在标题栏中能营造"从天而降"的纵深感,配合顶部到下方的圆角裁切,让标题栏看起来像一块从顶部垂落的帷幕。

ForEach(getGroupRows(), (g: MystGroup, gi: number) => {
  Row() {
    Column() {
      Text(String(gi + 1))
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
    }
    .width(30)
    .height(30)
    .justifyContent(FlexAlign.Center)
    .backgroundColor(g.color)
    .borderRadius(15)

    Column() {
      Row() {
        Text(g.emoji)
          .fontSize(16)
        Text(g.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .margin({ left: 4 })
        Text('')
          .layoutWeight(1)
        Text(g.status)
          .fontSize(9)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .backgroundColor(getStatusColor(g.status))
          .borderRadius(9)
      }
      .width('100%')

      Text(g.script + ' · ' + g.time + ' · DM ' + g.dm)
        .fontSize(10)
        .fontColor('#8E8E8E')
        .margin({ top: 4 })

      Row() {
        Text('席位 ' + String(g.joined) + '/' + String(g.quota))
          .fontSize(9)
          .fontColor('#4527A0')
        Text('')
          .layoutWeight(1)
        Text(g.status === '组局中' ? '加入 ›' : '已满员')
          .fontSize(10)
          .fontWeight(FontWeight.Bold)
          .fontColor(g.status === '组局中' ? '#4527A0' : '#BDBDBD')
      }
      .width('100%')
      .margin({ top: 4 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 12, bottom: 12 })
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ bottom: 10 })
  .shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
  .onClick(() => {
    this.onGroupClick(g);
  })
}, (g: MystGroup) => String(g.id))

组局列表项的渲染展示了一个"序号圆 + 信息区"的经典列表布局。ForEach 的第二个参数是 (g: MystGroup, gi: number),这里额外取了索引 gi,用于显示序号。序号通过一个 30x30 的圆形 Column(width 和 height 各 30,borderRadius 15 即为圆)展示,背景色取自数据自身的 g.color 字段,文字为白色。这种"以数据自带颜色作为序号背景"的做法让每条列表项都有独特的色彩标识,在长列表中帮助用户快速定位。

信息区是一个 Column,内部嵌套两层 Row。第一层 Row 展示组局名称、emoji 和状态标签,状态标签通过 getStatusColor(g.status) 获取背景色——组局中显示品牌紫,已满员显示灰色。第二层 Row 展示席位进度和操作提示,操作提示文字"加入 ›"或"已满员"通过三元表达式根据状态动态切换文案和颜色,这种"状态驱动文案与样式"的声明式写法让 UI 始终与数据保持同步。

ArkUI 知识点·ForEach 索引:ForEach 的 item 生成函数可以接收第二个参数 index(索引),语法为 (item: T, index: number) => { ... }。index 从 0 开始递增,常用于序号显示、奇偶行差异化样式等场景。但需注意,index 在列表重排后会变化,不应将其作为 key 使用。

组局页的第二层列表"我组的局"使用了不同的背景色 #EDE7F6(浅紫底),与第一层的白色卡片形成区分,让用户一眼看出这是"我创建的"而非"所有的"。每个列表项内嵌"编辑"和"删除"两个文字按钮,分别用橙色和红色文字,直接调用 onEditGrouponDeleteGroup 回调。这种内联操作按钮的设计避免了长按菜单或滑动删除的复杂交互,在信息密度和操作便捷性之间取得了平衡。


四、角色页、活动页、商城页:列表模式的变体

4.1 角色档案馆

@Component
struct RoleTab {
  onRoleClick: (r: MystRole) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🎭')
              .fontSize(30)
            Column() {
              Text('角色档案馆')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('12 位经典角色 · 等你来演绎')
                .fontSize(10)
                .fontColor('#C5CAE9')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })
          }
          .width('100%')
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14, bottom: 14 })
        .linearGradient({
          angle: 135,
          colorList: ['#4A148C', '#2D1B4E']
        })
        .borderRadius(14)
        .margin({ bottom: 12 })

        ForEach(getRoleRows(), (r: MystRole) => {
          Row() {
            Text(r.emoji)
              .fontSize(34)
            Column() {
              Row() {
                Text(r.name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#333333')
                Text(' ' + r.importance)
                  .fontSize(9)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(r.importance === '主角' ? '#FFB300' : '#8E8E8E')
                  .margin({ left: 4 })
              }
              Text(r.job + ' · ' + String(r.age) + ' 岁 · ' + r.trait)
                .fontSize(9)
                .fontColor('#8E8E8E')
                .margin({ top: 3 })
              Text('出自《' + r.script + '》')
                .fontSize(9)
                .fontColor('#4527A0')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })
          }
          .width('100%')
          .padding({ left: 12, right: 12, top: 12, bottom: 12 })
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .margin({ bottom: 10 })
          .shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
          .onClick(() => {
            this.onRoleClick(r);
          })
        }, (r: MystRole) => String(r.id))
      }
    }
  }
}

角色页 RoleTab 的标题渐变使用了 ['#4A148C', '#2D1B4E']——从深品紫到深紫蓝,135 度对角方向。这与首页 Banner 的 ['#1A1038', '#2D1B4E'] 和剧本页的 ['#2D1B4E', '#4527A0'] 色调不同,为角色页赋予了独特的视觉签名。在整个应用中,七个 Tab 页面各有一个独特的标题渐变配色,形成"一页一色"的设计规范,帮助用户通过色彩快速识别当前所在页面。

角色列表项的核心信息包括:名称、重要性(主角/重要)、职业、年龄、性格特质、出自剧本。重要性标签的色彩处理值得注意——主角显示金色 #FFB300,其他显示灰色 #8E8E8E,这种"主角金色、配角灰色"的对比让用户一眼识别核心角色。色彩语义在此被用于传达业务层级,而非纯粹的装饰。

"出自《剧本名》"这一行用品牌紫 #4527A0 文字,与上方灰色信息行形成色彩跳变,引导用户关注角色的来源关联。这种在信息流中通过色彩突显关键字段的手法,是信息层级设计的常见技巧——并非所有信息都同等重要,通过颜色为重要信息赋予更高的视觉权重。

角色页同样采用双层列表结构:getRoleRows() 返回六个主角色渲染为详细卡片,getRoleRows2() 返回六个次角色渲染为精简列表项。次列表项的背景使用 #F9F9FB(极浅灰),与主列表的白色卡片形成微妙的层次差异。这种"白卡 + 浅灰项"的双层配色模式在剧本页、角色页、商城页中重复出现,构成了应用列表设计的一致规范。

4.2 活动中心

@Component
struct MystEventTab {
  onEventClick: (e: MystEvent) => void = () => {};
  onDeleteEvent: (e: MystEvent) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🏆')
              .fontSize(30)
            Column() {
              Text('活动中心')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('本月 8 场活动 · 6 场可报名')
                .fontSize(10)
                .fontColor('#C5CAE9')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })
          }
          .width('100%')
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14, bottom: 14 })
        .linearGradient({
          angle: 90,
          colorList: ['#4527A0', '#6A1B9A']
        })
        .borderRadius(14)
        .margin({ bottom: 12 })

        ForEach(getEventRows(), (e: MystEvent, ei: number) => {
          Row() {
            Column() {
              Text(String(ei + 1))
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
            }
            .width(30)
            .height(30)
            .justifyContent(FlexAlign.Center)
            .backgroundColor(e.color)
            .borderRadius(15)

            Column() {
              Row() {
                Text(e.emoji)
                  .fontSize(16)
                Text(e.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#333333')
                  .margin({ left: 4 })
                Text('')
                  .layoutWeight(1)
                Text(e.status)
                  .fontSize(9)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#FFFFFF')
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .backgroundColor(getStatusColor(e.status))
                  .borderRadius(9)
              }
              .width('100%')

              Text(e.type + ' · ' + e.time)
                .fontSize(10)
                .fontColor('#8E8E8E')
                .margin({ top: 4 })

              Row() {
                Text('报名 ' + String(e.joined) + '/' + String(e.quota))
                  .fontSize(9)
                  .fontColor('#4527A0')
                Text('')
                  .layoutWeight(1)
                Text('奖励 ' + String(e.reward) + ' 币')
                  .fontSize(9)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#F57C00')
              }
              .width('100%')
              .margin({ top: 4 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })
          }
          .width('100%')
          .padding({ left: 12, right: 12, top: 12, bottom: 12 })
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .margin({ bottom: 10 })
          .shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
          .onClick(() => {
            this.onEventClick(e);
          })
        }, (e: MystEvent) => String(e.id))
      }
    }
  }
}

活动页 MystEventTab 的列表结构与组局页高度相似——都采用"序号圆 + 信息区"的布局,序号圆的背景色取自数据自身的 color 字段。这种结构复用体现了 ArkUI 组件设计的一个理念:相似的数据结构可以复用相似的 UI 模式,减少用户的学习成本。组局和活动都是"有时间、有名额、有状态"的实体,自然适合相同的列表样式。

活动列表项的信息行展示了类型与时间、报名进度与奖励。奖励金额用橙色 #F57C00 加粗显示,与报名进度的紫色形成色彩对比,让"利益点"在视觉上突出。在运营型应用中,将奖励、价格等利益相关字段用暖色(橙/红)突出,是引导用户行为的有效手段。

第二层列表"已结束活动"的每个列表项内嵌"删除"文字按钮,红色文字 #D32F2F,调用 onDeleteEvent 回调。这种对"过期内容"提供清理入口的设计,体现了应用对内容生命周期管理的考量——已结束的活动不应永久占用列表空间,用户应能主动清理。

4.3 迷雾商城

ForEach(getGoodsRows(), (g: MystGoods) => {
  Column() {
    Row() {
      Text(g.emoji)
        .fontSize(32)
      Column() {
        Text(g.name)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text(g.tag + ' · 库存 ' + String(g.stock) + ' 件')
          .fontSize(9)
          .fontColor('#8E8E8E')
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })

      Text(g.tag)
        .fontSize(9)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .backgroundColor(g.color)
        .borderRadius(9)
    }
    .width('100%')

    Row() {
      Text('¥' + String(g.price))
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#D32F2F')
      Text(' ¥' + String(g.oldPrice))
        .fontSize(10)
        .fontColor('#BDBDBD')
        .decoration({ type: TextDecorationType.LineThrough })
        .margin({ left: 6 })
      Text('')
        .layoutWeight(1)
      Text('兑换 ›')
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor('#4527A0')
    }
    .width('100%')
    .margin({ top: 8 })
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 12, bottom: 12 })
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ bottom: 10 })
  .shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
  .onClick(() => {
    this.onGoodsClick(g);
  })
}, (g: MystGoods) => String(g.id))

商城页的商品列表项展示了一个电商场景的经典布局:商品图标 + 名称 + 标签信息 + 分类标签 + 现价 + 原价(划线)+ 操作入口。最值得关注的是原价的划线效果,通过 decoration({ type: TextDecorationType.LineThrough }) 实现。TextDecorationType 是 ArkUI 提供的文本装饰枚举,LineThrough 表示删除线,另有 Underline(下划线)和 None(无装饰)。划线原价配合灰色文字 #BDBDBD,与红色的现价 #D32F2F 形成对比,是电商 UI 中传递"降价感"的通用手法。

商城页的标题栏右侧嵌入了迷雾币余额展示,点击触发 onRecharge 回调打开充值弹窗。这与组局页标题栏嵌入"发起组局"按钮的模式一致——将全局性的高频操作入口嵌入标题栏,让用户在任何时候都能快速触达核心功能。余额数字用金色 #FFD700 显示,呼应了 Banner 中的余额配色,保持了跨页面的色彩一致性。

ArkUI 知识点·decorationText 组件的 decoration 属性用于添加文本装饰线,参数为 { type: TextDecorationType, color?: ResourceColor }。除了删除线,还可以添加下划线并自定义颜色。这是 ArkUI 对文本视觉细节控制的体现,让开发者能在不引入额外组件的情况下实现丰富的文本样式。


五、我的页面:个人数据档案的呈现

5.1 个人信息卡片与统计区

@Component
struct MystMineTab {
  onRecordClick: (r: PlayRecord) => void = () => {};
  onDeleteRecord: (r: PlayRecord) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🕵️')
              .fontSize(40)
            Column() {
              Text('推理玩家 · 阿雾')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('侦探等级 Lv.6 · 累计 12 本')
                .fontSize(10)
                .fontColor('#C5CAE9')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 12 })

            Text('🎖️')
              .fontSize(28)
          }
          .width('100%')

          Row() {
            Column() {
              Text('8')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFD700')
              Text('剧本数')
                .fontSize(9)
                .fontColor('#C5CAE9')
                .margin({ top: 2 })
            }
            .layoutWeight(1)
            Column() {
              Text('3')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFD700')
              Text('MVP 场次')
                .fontSize(9)
                .fontColor('#C5CAE9')
                .margin({ top: 2 })
            }
            .layoutWeight(1)
            Column() {
              Text('1280')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFD700')
              Text('迷雾币')
                .fontSize(9)
                .fontColor('#C5CAE9')
                .margin({ top: 2 })
            }
            .layoutWeight(1)
          }
          .width('100%')
          .padding({ top: 14, bottom: 4 })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 18, bottom: 16 })
        .linearGradient({
          angle: 135,
          colorList: ['#1A1038', '#4527A0']
        })
        .borderRadius(16)
        .shadow({ radius: 12, color: '#334527A0', offsetY: 4 })

“我的"页 MystMineTab 的顶部是一个用户信息卡片,采用了与首页 Banner 完全一致的渐变配色 ['#1A1038', '#4527A0'] 和 135 度对角方向。这种视觉呼应是有意为之的——首页是应用的"门面”,我的页是用户的"门面",两者使用相同的视觉语言传达了"同等重要"的设计意图。卡片内部的 shadow 参数也与首页 Banner 一致(radius 12, color #334527A0, offsetY 4),确保了悬浮感的一致性。

用户头像使用了一个大号 emoji 🕵️(fontSize 40),右侧紧跟用户名和等级信息。最右侧的 🎖️ 勋章图标作为装饰,暗示用户的成就身份。这种"大头像 + 信息 + 装饰"的三段式头部布局在个人中心页极为常见,ArkUI 用一个 Row 即可完成,无需复杂的自定义组件。

统计区是三个等宽的 Column,通过 layoutWeight(1) 平分宽度。每个 Column 展示一个数字(金色加粗)和一行标签(浅色小字)。数字使用 #FFD700 金色,与卡片整体的深紫背景形成强对比,让核心数据成为视觉焦点。三个数据——剧本数、MVP 场次、迷雾币——分别代表了用户的"广度(玩了多少)"“深度(玩得多好)”"资产(积累了多少)"三个维度,构成了用户画像的完整描述。

5.2 场次记录列表

ForEach(getRecordRows(), (r: PlayRecord) => {
  Column() {
    Row() {
      Text(r.emoji)
        .fontSize(26)
      Column() {
        Text(r.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text(r.script + ' · ' + r.date)
          .fontSize(9)
          .fontColor('#8E8E8E')
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })

      Text(r.status)
        .fontSize(10)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .backgroundColor(getRecordColor(r.status))
        .borderRadius(10)
    }
    .width('100%')

    Row() {
      Text('扮演 ' + r.role + ' · ' + r.result)
        .fontSize(10)
        .fontColor('#4527A0')
      Text('')
        .layoutWeight(1)
      Text('删除')
        .fontSize(10)
        .fontColor('#D32F2F')
        .onClick(() => {
          this.onDeleteRecord(r);
        })
    }
    .width('100%')
    .margin({ top: 8 })
    .padding({ top: 8 })
  }
  .width('100%')
  .padding({ left: 12, right: 12, top: 12, bottom: 12 })
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .margin({ bottom: 10 })
  .shadow({ radius: 4, color: '#0F000000', offsetY: 1 })
  .onClick(() => {
    this.onRecordClick(r);
  })
}, (r: PlayRecord) => String(r.id))

场次记录列表项采用 Column 嵌套两层 Row 的结构。第一层 Row 展示剧本 emoji、名称、类型与日期、以及状态标签。状态标签的背景色通过 getRecordColor(r.status) 获取——MVP 金色、完本青绿、跳车灰色,这三种色彩分别传达了"荣誉"“完成”"遗憾"三种情感语义。在游戏类应用中,将玩家的体验结果用色彩编码,能增强成就感和反馈感。

第二层 Row 展示扮演的角色和结局,用品牌紫色文字,与上方的灰色信息行形成色彩跳变。"删除"按钮红色文字,点击调用 onDeleteRecord 回调。这个删除按钮与外层卡片的 onClick(打开记录详情)是两个独立的点击区域——ArkUI 的事件处理会正确处理嵌套点击:内层 Text 的 onClick 会触发自身回调,同时事件不会冒泡到外层(因为在 ArkUI 中,消费了事件的组件会阻止冒泡)。

历史场次列表 getRecordRows2() 渲染为精简列表项,只展示 emoji、名称、日期与类型、以及状态文字(不是标签,而是彩色文字)。状态文字的颜色通过 getRecordColor(r.status) 获取,但直接作为 fontColor 而非 backgroundColor,形成"深色背景标签 vs 彩色文字"的两种状态展示风格,在视觉上区分了"近期场次"和"历史场次"的信息密度。

用户进入我的页面

渲染用户信息卡片

展示统计: 剧本数/MVP/迷雾币

渲染近期场次列表 getRecordRows

用户点击记录项

调用 onRecordClick 回调

父组件打开记录详情弹窗

用户点击删除按钮

调用 onDeleteRecord 回调

父组件打开删除确认弹窗

渲染历史场次列表 getRecordRows2

页面渲染完成

上图展示了用户在"我的"页的完整交互路径。从页面渲染到点击记录项或删除按钮,再到父组件响应并打开对应弹窗,整条链路通过回调函数串联,体现了 ArkUI"事件上抛、状态下沉"的数据流模式。


六、主入口组件:状态枢纽与渲染调度

6.1 Tab 枚举与状态声明

enum MystTab {
  Home = 0,
  Script = 1,
  Group = 2,
  Role = 3,
  Event = 4,
  Shop = 5,
  Mine = 6
}

@Entry
@Component
struct MystApp {
  @State curTab: number = MystTab.Home;
  @State showScript: boolean = false;
  @State selScript: MystScript | null = null;
  @State showVote: boolean = false;
  @State voteScore: number = 9;
  @State showGroup: boolean = false;
  @State selGroup: MystGroup | null = null;
  @State showAddGroup: boolean = false;
  @State showEditGroup: boolean = false;
  @State showDelGroup: boolean = false;
  @State delGroup: MystGroup | null = null;
  @State showAssign: boolean = false;
  @State assignScript: MystScript | null = null;
  @State assignIdx: number = 0;
  @State showRole: boolean = false;
  @State selRole: MystRole | null = null;
  @State showEvent: boolean = false;
  @State selEvent: MystEvent | null = null;
  @State showDelEvent: boolean = false;
  @State delEvent: MystEvent | null = null;
  @State showGoods: boolean = false;
  @State selGoods: MystGoods | null = null;
  @State showPay: boolean = false;
  @State showRecord: boolean = false;
  @State selRecord: PlayRecord | null = null;
  @State showDelRecord: boolean = false;
  @State delRecord: PlayRecord | null = null;
  @State toast: string = '';
  @State fSlot: string = '10:00-13:00';
  @State fPayIdx: number = 0;
  @State fName: string = '';
  @State fTime: string = '';
  @State fQuota: number = 5;

MystTab 枚举定义了七个 Tab 的索引值,从 0 到 6 分别对应首页、剧本、组局、角色、活动、商城、我的。使用枚举而非魔法数字(如直接写 0、1、2)能大幅提升代码可读性——MystTab.Home0 更具语义。枚举值还用于条件渲染的判断,如 if (this.curTab === MystTab.Home),让分支逻辑一目了然。

MystApp 是整个应用的入口组件,以 @Entry 装饰。@Entry 的作用是标记该组件为页面入口——每个页面有且仅有一个 @Entry 组件,它是渲染树的根。@Entry 组件会被框架自动实例化并挂载到窗口上,无需在其他地方引用。这与 @Component 不同——@Component 组件需要被显式引用才会渲染。

@State 是 ArkUI 状态管理的核心装饰器。被 @State 装饰的变量会成为"响应式状态"——当其值变化时,框架会自动重新渲染依赖该状态的 UI 部分。这里声明了超过三十个 @State 变量,它们可以分为四类:当前 Tab(curTab)、各类弹窗的显示开关(showScriptshowVoteshowGroup 等,均为 boolean)、各类弹窗的选中数据(selScriptselGroupselRole 等,为对象或 null)、表单临时状态(fNamefTimefQuotafPayIdxvoteScoreassignIdx 等)。

ArkUI 知识点·@State@State 装饰的变量是组件内部的私有状态,变化会触发组件的局部重新渲染。对于基本类型(number、string、boolean),值的变化即可触发更新;对于引用类型(对象、数组),需要赋值整个新引用才能触发——修改对象内部属性不会自动触发渲染,除非使用 @Observed/@ObjectLink 或重新赋值。本案例中 selScript: MystScript | null 在选中时会被整体赋值新对象,因此能正确触发更新。

selScript: MystScript | null 这种"联合类型"声明是 ArkTS 的类型安全设计。初始值为 null,表示未选中任何剧本;当用户点击剧本卡片时,赋值为对应的 MystScript 对象。在弹窗渲染前会检查 this.selScript !== null,确保不会在空值上访问属性。这种"可空类型 + 空值检查"的模式贯穿整个应用的状态管理,是避免运行时空指针异常的有效策略。

表单状态变量以 f 前缀命名(form 的缩写),如 fNamefTimefQuotafPayIdx,用于在创建组局、编辑组局、充值等表单弹窗中临时存储用户输入。这些状态在父组件上声明而非在弹窗组件内部,是因为弹窗 UI 通过 @Builder 方法定义而非独立组件——Builder 方法没有自己的状态,只能依赖宿主组件的状态。

6.2 底部导航数据与 @Builder 方法

private tabs1: string[] = ['首页', '剧本', '组局', '角色'];
private tabs1Icons: string[] = ['🏠', '📖', '🎲', '🎭'];
private tabs2: string[] = ['活动', '商城', '我的'];
private tabs2Icons: string[] = ['🏆', '🛍️', '🕵️'];

@Builder
modalOverlay(onClose: () => void) {
  Column()
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(0,0,0,0.55)')
    .onClick(onClose)
}

@Builder
contentArea() {
  if (this.curTab === MystTab.Home) {
    MystHomeTab({
      onScriptClick: (s: MystScript) => {
        this.selScript = s;
        this.showScript = true;
      },
      onGroupClick: (g: MystGroup) => {
        this.selGroup = g;
        this.showGroup = true;
      },
      onMoreGroup: () => {
        this.curTab = MystTab.Group;
      }
    })
  } else if (this.curTab === MystTab.Script) {
    ScriptTab({
      onScriptClick: (s: MystScript) => {
        this.selScript = s;
        this.showScript = true;
      }
    })
  } else if (this.curTab === MystTab.Role) {
    RoleTab({
      onRoleClick: (r: MystRole) => {
        this.selRole = r;
        this.showRole = true;
      }
    })
  }
}

底部导航被拆成两组:tabs1(首页、剧本、组局、角色)和 tabs2(活动、商城、我的),每组配有对应的 emoji 图标数组。这种拆分是为了在底部渲染两行 Tab——第一行四个,第二行三个,共七个。每个 Tab 的索引通过 ti(tabs1)和 ti + 4(tabs2,偏移 4)计算,与 MystTab 枚举值对应。

@Builder 是 ArkUI 中定义可复用 UI 片段的装饰器。与 @Component 不同,@Builder 方法不创建独立组件实例,它只是将一段 UI 描述封装为可调用的方法。Builder 方法可以直接访问宿主组件的 this(包括状态变量和方法),因此适合在同一个组件内抽取重复的 UI 结构。

modalOverlay 是一个极简的 Builder——它只渲染一个全屏的半透明黑色 Column,接收一个 onClose 回调作为点击事件。这个遮罩层是所有弹窗的底座,点击遮罩(即点击弹窗外部区域)关闭弹窗是模态交互的标准行为。rgba(0,0,0,0.55) 表示 55% 不透明度的黑色,既能遮挡背景内容又不完全遮蔽,保持了视觉上下文的连续性。

contentArea Builder 是页面内容的调度中心。它通过一串 if-else if 条件判断 this.curTab 的值,渲染对应的 Tab 组件。每个分支在实例化子组件时传入回调函数,这些回调函数在内部修改 @State 变量——例如 onScriptClickselScript 赋值为点击的剧本对象,并将 showScript 设为 true。状态变化后,框架会自动重新渲染依赖这些状态的弹窗区域。

ArkUI 知识点·@Builder@Builder 方法用于封装可复用的 UI 描述片段,语法为 @Builder methodName(params) { UI内容 }。它与 @Component 的区别在于:Builder 不产生独立组件实例,不拥有独立状态,它直接引用宿主组件的 this。适合用于抽取同一组件内重复的 UI 结构,如弹窗、卡片模板等。Builder 方法可以接收参数,参数类型在方法签名中声明。

6.3 底部 Tab 项 Builder

@Builder
bottomTabItem(icon: string, label: string, idx: number) {
  Column() {
    Text(icon)
      .fontSize(19)
      .opacity(this.curTab === idx ? 1 : 0.55)
    Text(label)
      .fontSize(9)
      .fontColor(this.curTab === idx ? '#4527A0' : '#9E9E9E')
      .fontWeight(this.curTab === idx ? FontWeight.Bold : FontWeight.Normal)
      .margin({ top: 2 })
  }
  .layoutWeight(1)
  .padding({ top: 5, bottom: 5 })
  .justifyContent(FlexAlign.Center)
  .onClick(() => {
    this.curTab = idx;
  })
}

bottomTabItem Builder 接收三个参数:emoji 图标、文字标签、Tab 索引。它渲染一个 Column,内部纵向排列图标和文字。核心在于条件样式——this.curTab === idx 判断当前是否为激活的 Tab,是则用全不透明度(opacity 1)、品牌紫色文字 #4527A0、加粗字重;否则用 55% 不透明度、灰色文字 #9E9E9E、常规字重。

这种"状态驱动样式"的写法是声明式 UI 的精髓。开发者无需手动在状态变化时调用 setStyle 之类的命令式 API,只需声明"样式取决于状态值",框架会在状态变化时自动重新计算样式并更新 UI。opacity 属性用于控制元素的透明度,0.55 让非激活 Tab 呈现"退后"的视觉效果,与激活 Tab 的全不透明形成对比,引导用户聚焦当前页面。

点击事件 this.curTab = idx 是整个应用导航的核心——修改 curTab 状态后,contentArea Builder 会重新执行条件判断,渲染新的 Tab 组件,页面内容随之切换。这种"状态即导航"的模式避免了命令式的页面跳转 API 调用,让导航逻辑可预测、可调试。

layoutWeight(1) 让每个 Tab 项平分底部导航的宽度,FlexAlign.Center 让图标和文字在垂直方向居中。两行 Tab 通过两个 Row 容器分别承载 ForEach 渲染,每个 Row 中的 bottomTabItem 都会设置 layoutWeight(1),确保七个 Tab 项在两行中各自等宽分布。


七、弹窗体系:十四个 Builder 的设计与实现

7.1 剧本详情弹窗

@Builder
scriptModal() {
  Column() {
    Column() {
      Row() {
        Text(this.selScript!.emoji)
          .fontSize(40)
        Column() {
          Text(this.selScript!.name)
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text(this.selScript!.type + ' · ' + this.selScript!.players)
            .fontSize(10)
            .fontColor('#C5CAE9')
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 12 })

        Text('✕')
          .fontSize(16)
          .fontColor('#FFFFFF')
          .padding(8)
          .onClick(() => {
            this.showScript = false;
          })
      }
      .width('100%')
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 16 })
    .linearGradient({
      angle: 135,
      colorList: ['#1A1038', '#4527A0']
    })

    Column() {
      Row() {
        Column() {
          Text('⭐ ' + formatScore(this.selScript!.score))
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFB300')
          Text('玩家评分')
            .fontSize(9)
            .fontColor('#8E8E8E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        Column() {
          Text(String(this.selScript!.mins) + 'min')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4527A0')
          Text('游戏时长')
            .fontSize(9)
            .fontColor('#8E8E8E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        Column() {
          Text('难度 ' + String(this.selScript!.difficulty))
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(getDifficultyColor(this.selScript!.difficulty))
          Text('上手难度')
            .fontColor('#8E8E8E')
            .fontSize(9)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
      }
      .width('100%')
      .padding({ top: 14, bottom: 14 })
      .backgroundColor('#EDE7F6')
      .borderRadius(12)

      Text(this.selScript!.desc)
        .fontSize(11)
        .fontColor('#616161')
        .lineHeight(18)
        .margin({ top: 12 })

      Row() {
        Text('我要组局')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .width('48%')
          .textAlign(TextAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .backgroundColor('#4527A0')
          .borderRadius(24)
          .onClick(() => {
            this.showScript = false;
            this.toast = '组局已发起:' + this.selScript!.name;
          })

        Text('评分')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .width('48%')
          .textAlign(TextAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .backgroundColor('#FFB300')
          .borderRadius(24)
          .onClick(() => {
            this.voteScore = 9;
            this.showScript = false;
            this.showVote = true;
          })
      }
      .width('100%')
      .margin({ top: 14 })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 18 })
    .backgroundColor('#FFFFFF')
  }
  .width('82%')
  .borderRadius(18)
  .clip(true)
  .margin({ bottom: 40 })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

剧本详情弹窗 scriptModal 是十四个弹窗中最具代表性的一个,它展示了弹窗设计的完整范式。弹窗整体是一个 Column,宽度设为 82%(不占满全屏),通过 borderRadius(18)clip(true) 实现圆角裁切——clip(true) 确保内部子元素的渐变背景不会溢出圆角边界,这是 ArkUI 中实现圆角容器内嵌渐变的关键属性。

弹窗分为上下两段:上半段是深紫渐变的标题区(linearGradient 135 度,与首页 Banner 同色),展示剧本 emoji、名称、类型与人数,右上角有关闭按钮"✕";下半段是白色背景的信息区,展示三个核心指标(评分、时长、难度)、剧本描述、以及两个操作按钮。这种"深色头 + 浅色身"的拼接设计是弹窗的经典布局,深色头部承载身份信息,浅色身体承载详情和操作。

三指标区放在一个浅紫底 #EDE7F6 的圆角区块内,三个 Column 通过 layoutWeight(1) 等分宽度。每个指标由大号数值和小号标签组成,数值的颜色因指标而异——评分金色、时长紫色、难度根据函数返回色。这种"每指标独立配色"的设计让信息层次丰富,但又不至于杂乱,因为底部有统一的浅紫底色作为调和。

transition(TransitionEffect.OPACITY.animation({ duration: 200 })) 为弹窗添加了淡入淡出动画。TransitionEffect.OPACITY 是 ArkUI 提供的过渡效果之一(透明度动画),animation({ duration: 200 }) 指定动画时长 200 毫秒。当弹窗通过条件渲染出现或消失时,框架会自动播放这段过渡动画,使弹窗的显隐更平滑、更有质感。这是 ArkUI 声明式动画的体现——无需手动控制动画的 start 和 stop,只需声明过渡效果,框架自动管理。

ArkUI 知识点·transitiontransition 属性用于为组件的插入和删除添加过渡动画。参数为 TransitionEffect 对象,可链式调用 .animation() 指定动画参数。常见的 TransitionEffect 包括 OPACITY(淡入淡出)、SLIDE(滑动)、EXPAND(展开)等。transition 只在组件通过条件渲染(if 判断)出现或消失时触发,是弹窗、抽屉等模态交互的标准动画方案。

两个操作按钮"我要组局"和"评分"各占 48% 宽度,放在一个 Row 中,中间留有间距。按钮使用 Text 组件配合 textAlign(TextAlign.Center) 实现文字居中,borderRadius(24) 形成胶囊形状。"我要组局"用品牌紫背景,"评分"用金色背景,两种颜色对应两种不同性质的操作——组局是主流程操作(品牌色),评分是辅助操作(强调色)。

点击"我要组局"后,回调先设置 this.showScript = false 关闭当前弹窗,再设置 this.toast = '组局已发起:' + this.selScript!.name 显示 Toast 提示。点击"评分"则先关闭剧本弹窗,再设置 this.showVote = true 打开评分弹窗——这种"关闭一个弹窗同时打开另一个弹窗"的链式交互,通过修改多个状态变量实现,体现了状态驱动渲染的灵活性。

7.2 评分弹窗

@Builder
voteModal() {
  Column() {
    Column() {
      Text('⭐')
        .fontSize(44)
      Text('为《' + this.selScript!.name + '》评分')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .margin({ top: 8 })
      Text('你的评价将帮助更多玩家')
        .fontSize(10)
        .fontColor('#C5CAE9')
        .margin({ top: 4 })
    }
    .width('100%')
    .padding({ top: 22, bottom: 20 })
    .justifyContent(FlexAlign.Center)
    .linearGradient({
      angle: 135,
      colorList: ['#4527A0', '#1A1038']
    })

    Column() {
      Text(this.voteScore + ' 分')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFB300')
        .margin({ top: 16 })

      Row() {
        ForEach(getScoreList(), (sc: number) => {
          Text(String(sc))
            .fontSize(12)
            .width(36)
            .height(32)
            .textAlign(TextAlign.Center)
            .backgroundColor(this.voteScore === sc ? '#FFB300' : '#F5F5F5')
            .fontColor(this.voteScore === sc ? '#FFFFFF' : '#616161')
            .borderRadius(8)
            .margin({ right: 6 })
            .onClick(() => {
              this.voteScore = sc;
            })
        }, (sc: number) => String(sc))
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .margin({ top: 8 })

      Text('提交评分')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#4527A0')
        .borderRadius(24)
        .margin({ top: 16, bottom: 18 })
        .onClick(() => {
          this.showVote = false;
          this.toast = '评分成功:' + this.selScript!.name + ' ' + String(this.voteScore) + ' 分';
        })
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
  }
  .width('78%')
  .borderRadius(18)
  .clip(true)
  .margin({ bottom: 40 })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

评分弹窗 voteModal 展示了一个交互表单弹窗的设计。弹窗宽度为 78%,比剧本弹窗的 82% 略窄,因为评分弹窗内容更少,窄一些能让弹窗在屏幕中更显聚焦。弹窗顶部是一个居中的大号星星 emoji 和标题文字,标题中动态插入了 this.selScript!.name,让用户明确知道在为哪个剧本评分——这种"上下文带入"的文案设计能减少用户的认知负担。

评分的核心交互是十个分值按钮,通过 ForEach(getScoreList(), ...) 渲染 1 到 10 的分值。每个按钮是一个固定尺寸(36x32)的 Text,背景色根据 this.voteScore === sc 判断——选中的分值用金色背景 #FFB300 白字,未选中的用浅灰背景 #F5F5F5 深字。点击任意按钮将 this.voteScore 设为对应分值,状态变化后所有按钮的样式会重新计算——选中的变金,未选中的变灰,实现"单选"效果。

这种"状态驱动的单选交互"是 ArkUI 中实现选择器的标准模式。无需维护 selectedIndex 或使用 Radio 组件,只需一个状态变量记录当前选中值,在渲染时用三元表达式判断每个选项的样式即可。这种模式简洁、直观、易维护,适用于分值选择、档位选择、分类选择等各种场景。

ArkUI 知识点·条件样式:在 ArkUI 声明式语法中,属性值可以是任意表达式——包括三元表达式、函数调用、变量引用。例如 .backgroundColor(this.voteScore === sc ? '#FFB300' : '#F5F5F5'),框架会在每次渲染时重新求值这个表达式,确保样式始终与状态同步。这种"属性即表达式"的设计是声明式 UI 的核心特征,让 UI 与状态之间建立自动的响应式绑定。

弹窗顶部的当前分值展示 Text(this.voteScore + ' 分') 是一个实时反馈——用户点击分值按钮后,voteScore 变化,这行文字立即更新为新的分值,配合按钮的选中状态变化,形成"点击 → 按钮变色 + 数字更新"的双重即时反馈。这种即时反馈是优秀交互体验的基础,让用户确信操作已生效。

7.3 组局详情弹窗

@Builder
groupModal() {
  Column() {
    Column() {
      Row() {
        Text(this.selGroup!.emoji)
          .fontSize(34)
        Column() {
          Text(this.selGroup!.name)
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text(this.selGroup!.script + ' · ' + this.selGroup!.status)
            .fontSize(10)
            .fontColor('#C5CAE9')
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 10 })

        Text('✕')
          .fontSize(16)
          .fontColor('#FFFFFF')
          .padding(8)
          .onClick(() => {
            this.showGroup = false;
          })
      }
      .width('100%')
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 16 })
    .linearGradient({
      angle: 90,
      colorList: ['#1A1038', '#2D1B4E']
    })

    Column() {
      Row() {
        Column() {
          Text(this.selGroup!.time)
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333333')
          Text('开本时间')
            .fontSize(9)
            .fontColor('#8E8E8E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        Column() {
          Text('DM ' + this.selGroup!.dm)
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4527A0')
          Text('主持 DM')
            .fontSize(9)
            .fontColor('#8E8E8E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        Column() {
          Text(String(this.selGroup!.joined) + '/' + String(this.selGroup!.quota))
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#00897B')
          Text('当前席位')
            .fontSize(9)
            .fontColor('#8E8E8E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
      }
      .width('100%')
      .padding({ top: 14, bottom: 14 })
      .backgroundColor('#EDE7F6')
      .borderRadius(12)

      Text(this.selGroup!.desc)
        .fontSize(11)
        .fontColor('#616161')
        .lineHeight(18)
        .margin({ top: 12 })

      Text(this.selGroup!.status === '组局中' ? '加入此局' : '查看详情')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor(this.selGroup!.status === '组局中' ? '#4527A0' : '#9E9E9E')
        .borderRadius(24)
        .margin({ top: 14 })
        .onClick(() => {
          this.showGroup = false;
          if (this.selGroup!.status === '组局中') {
            this.toast = '已加入:' + this.selGroup!.name;
          }
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 18 })
    .backgroundColor('#FFFFFF')
  }
  .width('82%')
  .borderRadius(18)
  .clip(true)
  .margin({ bottom: 40 })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

组局详情弹窗 groupModal 的结构与剧本弹窗高度相似——深色头部 + 白色身体 + 三指标区 + 描述 + 操作按钮。但有三处细节差异值得注意。首先,头部的渐变方向改为 90 度水平渐变 ['#1A1038', '#2D1B4E'],与组局页标题卡的渐变方向一致,保持页面与弹窗的视觉统一。其次,三指标分别是时间、DM、席位,每个指标用不同颜色——时间用深灰 #333333、DM 用品牌紫 #4527A0、席位用青绿 #00897B,三种颜色分别传达"中性"“品牌”"正向"的语义。

最值得分析的是操作按钮的动态文案和样式。this.selGroup!.status === '组局中' ? '加入此局' : '查看详情' 根据组局状态动态切换按钮文案——组局中显示"加入此局"(可操作),已满员显示"查看详情"(只读)。背景色也同步变化——组局中用品牌紫 #4527A0(可操作的主色),已满员用灰色 #9E9E9E(不可操作的弱化色)。这种"状态驱动文案 + 状态驱动样式"的双重声明,让按钮始终与业务状态保持一致,避免了"按钮文案与实际行为不符"的体验问题。

点击按钮后,回调先关闭弹窗,再根据状态决定是否显示 Toast——只有 组局中 状态才显示"已加入"提示,已满员状态点击后不显示任何提示(因为只是查看详情,无需反馈)。这种"条件反馈"的设计避免了不必要的打扰,只在用户真正执行了操作时才给予反馈。

7.4 创建组局表单弹窗

@Builder
addGroupModal() {
  Column() {
    Row() {
      Text('+ 发起组局')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#212121')
      Text('')
        .layoutWeight(1)
      Text('✕')
        .fontSize(16)
        .fontColor('#9E9E9E')
        .padding(8)
        .onClick(() => {
          this.showAddGroup = false;
        })
    }
    .width('100%')

    Text('组局名称')
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 14, bottom: 6 })
    Text(this.fName.length > 0 ? this.fName : '例如:雾都夜行·下班局')
      .fontSize(12)
      .fontColor(this.fName.length > 0 ? '#333333' : '#BDBDBD')
      .width('100%')
      .padding({ top: 10, bottom: 10, left: 12, right: 12 })
      .backgroundColor('#F5F5F5')
      .borderRadius(10)

    Text('可组人数')
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 12, bottom: 6 })
    Row() {
      ForEach(getQuotaList(), (qo: number) => {
        Text(String(qo) + ' 人')
          .fontSize(11)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor(this.fQuota === qo ? '#4527A0' : '#F5F5F5')
          .fontColor(this.fQuota === qo ? '#FFFFFF' : '#616161')
          .borderRadius(10)
          .margin({ right: 8 })
          .onClick(() => {
            this.fQuota = qo;
          })
      }, (qo: number) => String(qo))
    }
    .width('100%')
    .margin({ top: 2 })

    Text('确认发起')
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .width('100%')
      .textAlign(TextAlign.Center)
      .padding({ top: 12, bottom: 12 })
      .backgroundColor('#4527A0')
      .borderRadius(24)
      .margin({ top: 16 })
      .onClick(() => {
        this.showAddGroup = false;
        this.toast = '组局发起成功,等待玩家加入';
      })
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 16, bottom: 20 })
  .backgroundColor('#FFFFFF')
  .borderRadius({ topLeft: 18, topRight: 18 })
  .constraintSize({ maxHeight: '80%' })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

创建组局弹窗 addGroupModal 展示了一种不同于居中弹窗的布局——底部弹出式表单。弹窗宽度为 100%(占满全宽),borderRadius 只设置 topLefttopRight(顶部两角圆角),constraintSize({ maxHeight: '80%' }) 限制最大高度为屏幕的 80%。这种"底部弹出、顶部圆角、高度受限"的样式是底部抽屉(Bottom Sheet)的标准设计,在移动端用于表单输入场景。

表单的"组局名称"字段展示了一种简化版的输入框实现。本案例没有使用 TextInput 组件,而是用 Text 配合 placeholder 文案模拟——当 fName.length > 0 时显示用户输入的内容(深色字),否则显示占位提示文案(灰色字)。这种实现虽然不接收真实输入(因为原型阶段 fName 未被赋值),但完整展示了输入框的视觉结构和样式规范。

"可组人数"使用了与评分弹窗相同的单选交互模式——ForEach(getQuotaList(), ...) 渲染 4 到 8 人五个选项,每个选项通过 this.fQuota === qo 判断选中状态,点击修改 fQuota 状态。这种模式在应用中被反复使用:评分弹窗的分值选择、组局弹窗的人数选择、编辑弹窗的 DM 选择、充值弹窗的档位选择,全部采用同一套"状态驱动单选"的实现范式。这种一致性降低了维护成本——修改一处范式即可影响所有同类交互。

设计原则·一致性:在同一个应用中,相同的交互模式应使用相同的视觉表现和操作逻辑。本案例中所有"单选选择器"都采用"圆角小方块 + 选中高亮色 + 未选中浅灰底"的统一样式,让用户形成"这种样式就是让我选一个"的认知习惯,降低了学习成本。这是尼尔森可用性原则中"一致性"的体现。

7.5 编辑组局与删除确认弹窗

@Builder
editGroupModal() {
  Column() {
    Row() {
      Text('✏️ 编辑组局')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#212121')
      Text('')
        .layoutWeight(1)
      Text('✕')
        .fontSize(16)
        .fontColor('#9E9E9E')
        .padding(8)
        .onClick(() => {
          this.showEditGroup = false;
        })
    }
    .width('100%')

    Text(this.selGroup!.emoji + ' ' + this.selGroup!.name)
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .width('100%')
      .padding({ top: 10, bottom: 10, left: 12, right: 12 })
      .backgroundColor('#EDE7F6')
      .borderRadius(10)
      .margin({ top: 12 })

    Text('调整开本时间')
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 12, bottom: 6 })
    Text(this.fTime.length > 0 ? this.fTime : this.selGroup!.time)
      .fontSize(12)
      .fontColor('#333333')
      .width('100%')
      .padding({ top: 10, bottom: 10, left: 12, right: 12 })
      .backgroundColor('#F5F5F5')
      .borderRadius(10)

    Text('换一位 DM')
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 12, bottom: 6 })
    Row() {
      ForEach(getDmList(), (dm: string, di: number) => {
        Text(dm)
          .fontSize(11)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor(this.fPayIdx === di ? '#4527A0' : '#F5F5F5')
          .fontColor(this.fPayIdx === di ? '#FFFFFF' : '#616161')
          .borderRadius(10)
          .margin({ right: 8 })
          .onClick(() => {
            this.fPayIdx = di;
          })
      }, (dm: string) => dm)
    }
    .width('100%')
    .margin({ top: 2 })

    Text('保存修改')
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .width('100%')
      .textAlign(TextAlign.Center)
      .padding({ top: 12, bottom: 12 })
      .backgroundColor('#F57C00')
      .borderRadius(24)
      .margin({ top: 16 })
      .onClick(() => {
        this.showEditGroup = false;
        this.toast = '组局信息已更新';
      })
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 16, bottom: 20 })
  .backgroundColor('#FFFFFF')
  .borderRadius({ topLeft: 18, topRight: 18 })
  .constraintSize({ maxHeight: '80%' })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

编辑弹窗 editGroupModal 也是底部弹出式表单,结构与创建弹窗类似,但有一个重要差异——时间字段使用了回退值 this.fTime.length > 0 ? this.fTime : this.selGroup!.time。当用户尚未修改时间时,显示原始组局的时间(selGroup.time)而非占位提示文案。这种"有用户输入则显示输入值,无输入则回退到原始数据"的模式,让编辑弹窗天然携带了被编辑对象的当前值,是编辑表单的标准做法。

DM 选择器复用了 fPayIdx 状态变量(注意这个名字,它本意是"充值档位索引",但在编辑弹窗中被复用为 DM 索引)。这是原型开发中的权宜做法——在真实项目中应为每个表单独立维护各自的选中索引状态,避免跨弹窗的状态冲突。但从模式角度看,DM 选择器与人数选择器、档位选择器使用完全相同的实现范式,再次印证了"状态驱动单选"模式的普适性。

“保存修改"按钮使用橙色 #F57C00 背景,与创建弹窗的紫色按钮形成区分。这种"创建用主色、编辑用强调色"的配色策略,帮助用户通过按钮颜色快速识别操作类型——紫色意味着"新建”,橙色意味着"修改"。

@Builder
delGroupModal() {
  Column() {
    Column() {
      Text('🗑️')
        .fontSize(40)
      Text('解散组局')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .margin({ top: 8 })
      Text('解散后已加入玩家将收到通知')
        .fontSize(10)
        .fontColor('#C5CAE9')
        .margin({ top: 4 })
    }
    .width('100%')
    .padding({ top: 20, bottom: 20 })
    .justifyContent(FlexAlign.Center)
    .linearGradient({
      angle: 135,
      colorList: ['#4527A0', '#1A1038']
    })

    Column() {
      Text(this.delGroup!.emoji + ' ' + this.delGroup!.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .margin({ top: 14 })

      Row() {
        Text('取消')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#616161')
          .width('48%')
          .textAlign(TextAlign.Center)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor('#F5F5F5')
          .borderRadius(22)
          .onClick(() => {
            this.showDelGroup = false;
          })

        Text('确认解散')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .width('48%')
          .textAlign(TextAlign.Center)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor('#D32F2F')
          .borderRadius(22)
          .onClick(() => {
            this.showDelGroup = false;
            this.toast = '组局已解散';
          })
      }
      .width('100%')
      .margin({ top: 14, bottom: 16 })
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
  }
  .width('80%')
  .borderRadius(18)
  .clip(true)
  .margin({ bottom: 40 })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

删除确认弹窗 delGroupModal 展示了"居中确认弹窗"的设计范式。弹窗宽度 80%,居中显示(在 Stack 中通过默认对齐实现居中),顶部深紫渐变区展示删除图标和标题,底部白色区展示被删除对象的名称和两个操作按钮。这种"居中 + 警示图标 + 双按钮"的结构是危险操作确认弹窗的通用模板。

双按钮"取消"和"确认解散"各占 48% 宽度。"取消"用浅灰底深灰字,“确认解散"用红色 #D32F2F 底白字。红色作为危险操作的警示色,与"取消"的中性灰形成强对比,引导用户慎重操作。按钮文案特意使用"确认解散"而非简单的"确认”——更具体的动词让用户明确操作的后果,这是防误操作设计的文案策略。

这个删除确认弹窗的模式在应用中被复用了三次——删除组局、删除活动、删除记录,三个确认弹窗的结构和样式完全一致,只更换了图标、标题文案和被删除对象的展示。这种模板化复用确保了危险操作确认体验的一致性,用户在任何删除场景下都能识别相同的交互模式。

ArkUI 知识点·StackStack 是层叠布局容器,子元素按声明顺序从底到顶层叠,后声明的子元素覆盖在前面的子元素之上。本案例的主 build 方法用 Stack 作为根容器,第一层是页面内容,后续的弹窗和遮罩依次叠加在上面。Stack 默认让所有子元素居中对齐,因此居中弹窗无需额外的定位属性即可居中显示。底部弹出弹窗则通过 positionalign 属性调整对齐位置。

7.6 角色分配弹窗与角色详情弹窗

@Builder
assignModal() {
  Column() {
    Row() {
      Text('🎭 角色分配')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#212121')
      Text('')
        .layoutWeight(1)
      Text('✕')
        .fontSize(16)
        .fontColor('#9E9E9E')
        .padding(8)
        .onClick(() => {
          this.showAssign = false;
        })
    }
    .width('100%')

    Text('为《' + this.assignScript!.name + '》挑选你的角色')
      .fontSize(11)
      .fontColor('#8E8E8E')
      .margin({ top: 6, bottom: 10 })

    ForEach(getAssignRoles(), (r: MystRole, ri: number) => {
      Row() {
        Text(r.emoji)
          .fontSize(26)
        Column() {
          Text(r.name)
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333333')
          Text(r.job + ' · ' + r.trait)
            .fontSize(9)
            .fontColor('#8E8E8E')
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 8 })

        Column() {
          Text(this.assignIdx === ri ? '已选' : '选择')
            .fontSize(10)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.assignIdx === ri ? '#FFFFFF' : '#4527A0')
            .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            .backgroundColor(this.assignIdx === ri ? '#4527A0' : '#EDE7F6')
            .borderRadius(10)
        }
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 10, bottom: 10 })
      .backgroundColor(this.assignIdx === ri ? '#EDE7F6' : '#FFFFFF')
      .borderRadius(10)
      .margin({ bottom: 8 })
      .onClick(() => {
        this.assignIdx = ri;
      })
    }, (r: MystRole) => String(r.id))

    Text('确认分配')
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .width('100%')
      .textAlign(TextAlign.Center)
      .padding({ top: 12, bottom: 12 })
      .backgroundColor('#4527A0')
      .borderRadius(24)
      .margin({ top: 6 })
      .onClick(() => {
        this.showAssign = false;
        this.toast = '角色已分配:' + getAssignRoles()[this.assignIdx].name;
      })
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 16, bottom: 20 })
  .backgroundColor('#FFFFFF')
  .borderRadius({ topLeft: 18, topRight: 18 })
  .constraintSize({ maxHeight: '80%' })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

角色分配弹窗 assignModal 是一个"列表式单选"弹窗——用户从三个角色卡片中选择一个。与评分弹窗的"按钮式单选"不同,列表式单选的每个选项是一个卡片 Row,包含更丰富的信息(emoji、名称、职业、特质)和选中状态标识。选中状态通过两重表现:右侧的"已选/选择"标签变色变底,以及整个卡片行背景从白色变为浅紫 #EDE7F6

这种"整行高亮 + 状态标签"的双重选中反馈,比单纯的标签变色更直观——用户能从整体卡片背景一眼看出哪个被选中,而无需逐个查看标签。this.assignIdx === ri 的判断用索引而非 id,因为这里的选择是"列表中的第几个"而非"哪个角色"——索引与渲染顺序直接对应。

确认分配后,回调通过 getAssignRoles()[this.assignIdx].name 获取选中角色的名称,组装进 Toast 文案。这种"从数据源重新查询"而非"缓存选中对象"的做法,确保了显示的数据始终来自单一数据源,避免了缓存与源不同步的风险。

角色详情弹窗 roleModal 的结构与剧本详情弹窗高度一致——深色头部 + 三指标 + 描述 + 操作按钮。差异在于头部渐变用了 ['#1A1038', '#4A148C'](极深紫到深品紫),与角色页标题的渐变色调呼应。两个指标分别是"性格特质"和"剧本地位",用紫色和金色区分。"预约此角色"按钮调用 onRoleClick 类的回调,显示预约成功 Toast。

7.7 活动详情、商品详情、充值弹窗

@Builder
goodsModal() {
  Column() {
    Column() {
      Text(this.selGoods!.emoji)
        .fontSize(56)
      Text(this.selGoods!.name)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .margin({ top: 8 })
      Text(this.selGoods!.tag + ' · 库存 ' + String(this.selGoods!.stock) + ' 件')
        .fontSize(10)
        .fontColor('#C5CAE9')
        .margin({ top: 4 })
    }
    .width('100%')
    .padding({ top: 22, bottom: 22 })
    .justifyContent(FlexAlign.Center)
    .linearGradient({
      angle: 135,
      colorList: [this.selGoods!.color, '#1A1038']
    })

    Column() {
      Row() {
        Text('¥' + String(this.selGoods!.price))
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .fontColor('#D32F2F')
        Text(' ¥' + String(this.selGoods!.oldPrice))
          .fontSize(12)
          .fontColor('#BDBDBD')
          .decoration({ type: TextDecorationType.LineThrough })
          .margin({ left: 6 })
        Text('')
          .layoutWeight(1)
        Text('可抵 ' + String(this.selGoods!.price) + ' 迷雾币')
          .fontSize(10)
          .fontWeight(FontWeight.Bold)
          .fontColor('#4527A0')
      }
      .width('100%')

      Text(this.selGoods!.desc)
        .fontSize(11)
        .fontColor('#616161')
        .lineHeight(18)
        .margin({ top: 10 })

      Text('立即兑换')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor(this.selGoods!.color)
        .borderRadius(24)
        .margin({ top: 14 })
        .onClick(() => {
          this.showGoods = false;
          this.toast = '兑换成功,请到前台领取';
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 18 })
    .backgroundColor('#FFFFFF')
  }
  .width('80%')
  .borderRadius(18)
  .clip(true)
  .margin({ bottom: 40 })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

商品详情弹窗 goodsModal 有一个独特的设计——头部渐变的起始色使用商品自身的颜色 this.selGoods!.color,而非固定的品牌色。这意味着不同商品的弹窗会有不同的头部色调——骰子套装的弹窗头部是深紫,蜡烛礼盒是靛蓝,骰子是深青…这种"数据驱动弹窗配色"的设计让每个商品都有独特的视觉个性,增强了浏览的丰富感。

头部的商品 emoji 用了极大的字号(fontSize 56),这是所有弹窗中最大的 emoji 尺寸,目的是让商品图标成为视觉焦点,模拟电商应用中"商品主图"的展示效果。下方的价格区展示了现价(红色大字 22px)和原价(灰色小字 12px 带删除线),以及"可抵 X 迷雾币"的换算提示,为用户提供了价格的多维参考。

"立即兑换"按钮的背景色也使用 this.selGoods!.color(商品自身颜色),与头部渐变起始色呼应,让按钮与商品视觉绑定。这种"按钮颜色随商品变化"的设计在电商弹窗中并不常见,但在本案例中增强了"每个商品都有独特身份"的体验感。

@Builder
payModal() {
  Column() {
    Row() {
      Text('💎 迷雾币充值')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#212121')
      Text('')
        .layoutWeight(1)
      Text('✕')
        .fontSize(16)
        .fontColor('#9E9E9E')
        .padding(8)
        .onClick(() => {
          this.showPay = false;
        })
    }
    .width('100%')

    Text('选择充值档位')
      .fontSize(13)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .margin({ top: 14, bottom: 10 })

    Row() {
      ForEach(getPayLevels(), (lv: number, li: number) => {
        Column() {
          Text(String(lv))
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(this.fPayIdx === li ? '#4527A0' : '#333333')
          Text(getPayGifts()[li])
            .fontSize(9)
            .fontColor(this.fPayIdx === li ? '#4527A0' : '#8E8E8E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .padding({ top: 12, bottom: 12 })
        .justifyContent(FlexAlign.Center)
        .backgroundColor(this.fPayIdx === li ? '#EDE7F6' : '#F5F5F5')
        .borderRadius(12)
        .margin({ right: 8 })
        .onClick(() => {
          this.fPayIdx = li;
        })
      }, (lv: number) => String(lv))
    }
    .width('100%')

    Text('会员充值可获双倍迷雾币')
      .fontSize(11)
      .fontColor('#F57C00')
      .margin({ top: 12 })
      .alignSelf(ItemAlign.Start)

    Text('确认充值')
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .width('100%')
      .textAlign(TextAlign.Center)
      .padding({ top: 12, bottom: 12 })
      .backgroundColor('#4527A0')
      .borderRadius(24)
      .margin({ top: 14 })
      .onClick(() => {
        this.showPay = false;
        this.toast = '充值成功:+' + String(getPayLevels()[this.fPayIdx] * 2) + ' 迷雾币';
      })
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 16, bottom: 20 })
  .backgroundColor('#FFFFFF')
  .borderRadius({ topLeft: 18, topRight: 18 })
  .constraintSize({ maxHeight: '80%' })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

充值弹窗 payModal 是底部弹出式表单,核心交互是四档充值选项的选择。每档选项是一个 Column,通过 layoutWeight(1) 等分宽度,展示档位金额(大字)和赠送奖励(小字)。选中态用浅紫底 #EDE7F6 + 品牌紫字,未选中用浅灰底 #F5F5F5 + 深灰字。这种"等宽卡片单选"的布局在充值、套餐选择等场景中极为常见。

"会员充值可获双倍迷雾币"这行提示用橙色 #F57C00 文字,通过 alignSelf(ItemAlign.Start) 让文字在 Column 中左对齐。ItemAlign 是交叉轴对齐枚举,Start 表示起始端对齐(在 Column 中即左对齐)。这行提示告知用户充值后会翻倍,并在确认按钮的回调中兑现——getPayLevels()[this.fPayIdx] * 2 计算双倍后的迷雾币数量,显示在 Toast 中。这种"提示承诺 → 操作兑现"的闭环让用户的预期与结果一致。

7.8 场次记录详情弹窗

@Builder
recordModal() {
  Column() {
    Column() {
      Row() {
        Text(this.selRecord!.emoji)
          .fontSize(30)
        Column() {
          Text(this.selRecord!.name)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text(this.selRecord!.date + ' · ' + this.selRecord!.script)
            .fontSize(10)
            .fontColor('#C5CAE9')
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 10 })

        Text('✕')
          .fontSize(16)
          .fontColor('#FFFFFF')
          .padding(8)
          .onClick(() => {
            this.showRecord = false;
          })
      }
      .width('100%')
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 16 })
    .linearGradient({
      angle: 90,
      colorList: ['#1A1038', '#4527A0']
    })

    Column() {
      Row() {
        Column() {
          Text(this.selRecord!.role)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#4527A0')
          Text('扮演角色')
            .fontSize(9)
            .fontColor('#8E8E8E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        Column() {
          Text(this.selRecord!.result)
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#00897B')
          Text('结局')
            .fontSize(9)
            .fontColor('#8E8E8E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        Column() {
          Text(this.selRecord!.status)
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(getRecordColor(this.selRecord!.status))
          Text('本场评价')
            .fontSize(9)
            .fontColor('#8E8E8E')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
      }
      .width('100%')
      .padding({ top: 14, bottom: 14 })
      .backgroundColor('#EDE7F6')
      .borderRadius(12)

      Row() {
        Column() {
          Text('+150 币')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor('#F57C00')
          Text('场次奖励')
            .fontSize(9)
            .fontColor('#8E8E8E')
            .margin({ top: 3 })
        }
        .layoutWeight(1)
        Column() {
          Text('已归档')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor('#00897B')
          Text('战报状态')
            .fontSize(9)
            .fontColor('#8E8E8E')
            .margin({ top: 3 })
        }
        .layoutWeight(1)
      }
      .width('100%')
      .padding({ top: 12, bottom: 12 })
      .backgroundColor('#F9F9FB')
      .borderRadius(12)
      .margin({ top: 10 })

      Text('查看完整战报')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width('100%')
        .textAlign(TextAlign.Center)
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#4527A0')
        .borderRadius(24)
        .margin({ top: 14 })
        .onClick(() => {
          this.showRecord = false;
          this.toast = '战报已发送到邮箱';
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 16, bottom: 18 })
    .backgroundColor('#FFFFFF')
  }
  .width('82%')
  .borderRadius(18)
  .clip(true)
  .margin({ bottom: 40 })
  .transition(TransitionEffect.OPACITY.animation({ duration: 200 }))
}

场次记录详情弹窗 recordModal 被设计成"票根样式"——通过两层指标区(浅紫底 + 浅灰底)模拟纸质票据的多段结构。第一层三指标展示扮演角色(紫)、结局(青绿)、本场评价(动态色),第二层两指标展示场次奖励(橙)和战报状态(青绿)。两层指标区使用不同的背景色(#EDE7F6#F9F9FB)建立视觉分段,模拟票据上的信息区块划分。

"本场评价"指标的颜色通过 getRecordColor(this.selRecord!.status) 动态获取——MVP 记录显示金色,完本显示青绿,跳车显示灰色。这种"数据驱动色彩"让同一个弹窗根据记录类型呈现不同的视觉氛围,MVP 记录的金色评价字带来荣誉感,跳车记录的灰色则带有一丝遗憾。

"查看完整战报"按钮点击后显示"战报已发送到邮箱"的 Toast,模拟了战报邮件发送的功能闭环。这种"操作 → 即时反馈 → 后续处理"的交互链路,虽然在原型中只是 Toast 提示,但完整地展示了功能的设计意图。

设计洞察:十四个弹窗虽然各有差异,但共享一套设计语言——深紫渐变头部、白色信息身体、指标三等分布局、胶囊形操作按钮、200ms 淡入淡出过渡。这种"统一框架 + 局部变化"的设计策略,既保证了应用整体的视觉一致性,又通过头部渐变色、按钮颜色、指标内容的变化赋予了每个弹窗独特的业务个性。


八、主构建方法:Stack 层叠与条件渲染调度

8.1 页面骨架与底部导航

build() {
  Stack() {
    Column() {
      Column() {
        Text('🕵️ 迷雾剧本 · 员工剧本杀馆')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
        Row() {
          Text('🎲')
            .fontSize(12)
          Text(' 今晚 3 车开本 · 快去组队')
            .fontSize(9)
            .fontColor('#C5CAE9')
            .margin({ left: 2 })
        }
        .margin({ top: 3 })
      }
      .width('100%')
      .padding({ top: 14, bottom: 12 })
      .justifyContent(FlexAlign.Center)
      .linearGradient({
        angle: 90,
        colorList: ['#1A1038', '#4527A0']
      })

      this.contentArea()

      Column() {
        Row() {
          ForEach(this.tabs1, (t: string, ti: number) => {
            this.bottomTabItem(this.tabs1Icons[ti], t, ti)
          }, (t: string) => t)
        }
        .width('100%')

        Row() {
          ForEach(this.tabs2, (t: string, ti: number) => {
            this.bottomTabItem(this.tabs2Icons[ti], t, ti + 4)
          }, (t: string) => t)
        }
        .width('100%')
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .shadow({ radius: 8, color: '#1A000000', offsetY: -2 })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F3FA')

主 build 方法是整个应用的渲染入口,以 Stack 作为根容器。Stack 内的第一层是一个全屏 Column,它构成了应用的页面骨架——顶部标题栏、中间内容区、底部导航栏三段式布局。这三段通过 Column 的垂直排列自然堆叠,无需额外的定位属性。

顶部标题栏是一个水平渐变的 Column,展示应用名称和一条动态提示"今晚 3 车开本"。justifyContent(FlexAlign.Center) 让文字在水平方向居中。这个标题栏是固定不变的——无论用户在哪个 Tab,顶部都显示应用名,提供了持续的品牌标识。

中间内容区通过 this.contentArea() 调用 Builder 方法渲染,如前所述,它根据 curTab 状态条件渲染对应的 Tab 组件。这是整个页面中唯一会随 Tab 切换而变化的区域,上下两端(标题栏和导航栏)保持固定。

底部导航栏是一个白色背景的 Column,内含两行 Row,每行通过 ForEach 渲染一组 Tab 项。shadow({ radius: 8, color: '#1A000000', offsetY: -2 }) 的 offsetY 为 -2,意味着阴影投射方向是向上(负 Y 方向),这让导航栏与上方内容之间产生微妙的分割感——阴影从导航栏顶部向上扩散,模拟了导航栏"浮在内容之上"的视觉效果。

ArkUI 知识点·StackStack 是 ArkUI 的层叠布局容器,所有子元素默认在容器中居中对齐,后声明的子元素覆盖在先声明的子元素之上。Stack 适合实现"基础内容 + 浮层叠加"的场景,如本案例的"页面内容 + 遮罩 + 弹窗 + Toast"。Stack 的 alignContent 属性可以调整子元素的整体对齐方式,但本案例使用默认居中,配合弹窗自身的宽度百分比实现居中弹窗效果。

8.2 弹窗条件渲染调度

if (this.showScript && this.selScript !== null) {
  this.modalOverlay(() => {
    this.showScript = false;
  })
  this.scriptModal()
}
if (this.showVote && this.selScript !== null) {
  this.modalOverlay(() => {
    this.showVote = false;
  })
  this.voteModal()
}
if (this.showGroup && this.selGroup !== null) {
  this.modalOverlay(() => {
    this.showGroup = false;
  })
  this.groupModal()
}
if (this.showAddGroup) {
  this.modalOverlay(() => {
    this.showAddGroup = false;
  })
  this.addGroupModal()
}
if (this.showEditGroup && this.selGroup !== null) {
  this.modalOverlay(() => {
    this.showEditGroup = false;
  })
  this.editGroupModal()
}
if (this.showDelGroup && this.delGroup !== null) {
  this.modalOverlay(() => {
    this.showDelGroup = false;
  })
  this.delGroupModal()
}

这段代码是弹窗渲染的核心调度逻辑。每个弹窗的渲染由一个 if 条件控制,条件通常是"显示开关为 true 且选中数据不为 null"。例如剧本弹窗的渲染条件是 this.showScript && this.selScript !== null——只有当用户点击了某个剧本(设置了 selScript)并且主动打开了弹窗(showScript 为 true)时,才渲染弹窗。

每个弹窗的渲染包含两个 Builder 调用:先调用 this.modalOverlay(onClose) 渲染遮罩层,再调用具体的弹窗 Builder(如 this.scriptModal())。遮罩层在前、弹窗在后,由于 Stack 的层叠特性,后声明的弹窗会覆盖在遮罩之上。遮罩的 onClick 回调关闭弹窗——点击遮罩区域(弹窗外部)即可关闭弹窗,这是模态交互的标准行为。

十四个弹窗的渲染条件按声明顺序排列在 Stack 中。由于同一时刻通常只有一个弹窗显示,因此实际上只有一个条件为 true。但代码结构允许理论上多个弹窗同时显示——例如先显示剧本弹窗,用户在弹窗内点击"评分"后,剧本弹窗关闭(showScript=false)、评分弹窗打开(showVote=true),同一时刻只有一个弹窗存在。这种设计通过状态互斥确保了单一弹窗的显示。

8.3 Toast 提示的渲染

if (this.toast.length > 0) {
  Column() {
    Text('✅ ' + this.toast)
      .fontSize(13)
      .fontColor('#FFFFFF')
      .padding({ left: 18, right: 18, top: 10, bottom: 10 })
      .backgroundColor('#333333')
      .borderRadius(18)
  }
  .width('100%')
  .justifyContent(FlexAlign.Center)
  .position({ x: 0, y: '72%' })
}

Toast 提示是整个 Stack 中的最后一层,覆盖在所有内容之上。渲染条件是 this.toast.length > 0——只要 toast 字符串非空就显示。Toast 是一个居中的深色圆角文本条,通过 position({ x: 0, y: '72%' }) 定位在屏幕 72% 高度的位置(偏下方),这是移动端 Toast 的常见位置——不遮挡顶部内容,又在拇指可达区域内。

Toast 的背景色 #333333 是深灰而非纯黑,文字白色,前缀"✅"暗示操作成功。这种"深灰底 + 白字 + 勾号"的样式是轻量提示信息的通用设计。Toast 没有关闭按钮,在真实应用中通常会配合定时器在几秒后自动清空 toast 状态使其消失。本案例中 toast 的清空需要用户进行下一次操作时触发(某些操作的回调中没有清空 toast,因此它会持续显示直到下一次状态变化)。

点击列表项

提交/确认

打开子弹窗

点击遮罩/关闭

用户操作触发状态变化

判断状态类型

设置 selXxx = 数据对象

设置 showXxx = true

Stack 重新渲染

条件渲染弹窗 + 遮罩

弹窗淡入动画 200ms

用户在弹窗内操作

操作类型

设置 showXxx = false

设置 toast = 反馈文案

弹窗淡出 + Toast 显示

设置当前 show = false

设置子弹窗 show = true

子弹窗渲染

设置 showXxx = false

弹窗淡出

上图完整描绘了从用户操作到弹窗渲染再到反馈提示的全流程。状态变化是整个流程的驱动力——每一次 setState 都会触发 Stack 的重新渲染,条件判断决定渲染哪些弹窗,过渡动画让显隐更自然。这种"状态 → 渲染 → 动画"的链路是 ArkUI 声明式 UI 的核心运行机制。


九、核心特性对比与技术总结

技术维度 实现方式 作用与价值 适用场景
@Component 装饰 struct 声明自定义组件 封装可复用的 UI 单元,拥有独立 build 方法 页面级组件、卡片组件、列表项组件
@Entry 装饰入口组件 标记页面根组件,框架自动挂载 应用主入口、路由页面
@State 装饰组件内部状态变量 值变化触发 UI 局部重新渲染 当前 Tab、弹窗开关、表单输入、选中数据
@Builder 装饰 UI 描述方法 封装可复用 UI 片段,访问宿主 this 弹窗模板、遮罩层、导航项、内容区
Stack 层叠布局容器 子元素从底到顶叠加,后声明者在上 弹窗叠加、遮罩层、Toast 浮层
Column 垂直线性布局 子元素从上到下排列 卡片内部纵向堆叠、页面整体结构
Row 水平线性布局 子元素从左到右排列 按钮并排、信息行、标签条
ForEach 列表渲染原语 根据数据数组渲染列表项,支持 key 列表、标签组、单选选项组
Scroll 滚动容器 包裹可滚动内容,支持横纵向 长列表页面、横向推荐卡片
FlexAlign 主轴对齐枚举 控制子元素在主轴方向的排布 内容居中、两端对齐、等间距分布
linearGradient 渐变背景 营造色彩层次和氛围感 标题栏、Banner 卡片、弹窗头部
transition 过渡动画 组件显隐时播放淡入淡出等动画 弹窗、抽屉、模态交互
layoutWeight 弹性空间分配 占据父容器主轴剩余空间 等分布局、弹性占位、两端对齐
clip 裁切属性 防止子元素溢出圆角边界 圆角容器内嵌渐变背景
interface 类型契约声明 定义数据形状,编译期类型约束,运行时零开销 业务实体建模、组件参数类型、回调签名
const 数组 静态数据源 固化模拟数据,驱动 UI 原型 Mock 数据、配置参数、枚举值列表
纯函数 数据加工与样式映射 无副作用的数据切片和颜色转换 数据筛选、难度映射、状态着色
回调属性 父子通信桥梁 子组件上报事件意图,父组件决定响应 列表点击、按钮操作、表单提交
条件渲染 if 判断控制组件显隐 根据状态动态决定渲染内容 Tab 切换、弹窗显隐、Toast 显示
shadow 阴影属性 营造卡片悬浮感和层级深度 卡片容器、标题栏、导航栏
borderRadius 圆角属性 赋予元素柔和边缘,提升视觉品质 卡片、按钮、标签、弹窗
opacity 透明度属性 控制元素视觉权重,弱化非焦点内容 非激活 Tab、禁用状态、背景层

安装DevEco Studio程序

    .position({ x: 0, y: '72%' })
  }
}
.width('100%')
.height('100%')

}
}


---
![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/7355abec4be14363a9f314c2f63c070b.png#pic_center)

## 十、总结:

通过对这个迷雾剧本杀馆应用的逐段剖析,我们可以清晰地看到鸿蒙 ArkUI 声明式开发范式的完整面貌。整个应用以"数据层—组件层—入口层"三层架构组织代码,数据层用 interface 和 const 数组构建类型安全的模拟数据源,组件层用七个独立的 @Component 构建七大业务页面,入口层用一个 @Entry 主组件统管全局状态和弹窗调度。这种分层设计让职责边界清晰,数据流向单向可追踪,是 ArkUI 推荐的工程化结构。

从状态管理的角度看,本应用展示了 @State 的典型用法。超过三十个状态变量集中声明在入口组件上,分为四类:Tab 导航状态、弹窗开关状态(boolean)、选中数据状态(对象或 null)、表单临时状态。每个状态的变化都会触发 Stack 的重新渲染,通过条件判断决定哪些弹窗或页面内容应该显示。这种"状态即 UI"的模式让界面的当前状态完全由状态变量决定,无需命令式地调用 show/hide API,使得 UI 始终与数据保持同步,调试和预测都更加容易。

从交互设计的角度看,应用采用了"底部多 Tab + 居中弹窗/底部表单 + Toast 反馈"的经典三段式交互架构。七个 Tab 覆盖了浏览、组局、角色、活动、商城、个人中心全部业务场景;十四个弹窗通过 @Builder 方法模板化定义,分为详情展示型(深色头+白底身)、表单操作型(底部弹出)、确认型(居中双按钮)三大类;Toast 作为轻量反馈在操作完成后即时提示。这套交互体系完整地覆盖了从浏览到操作到反馈的用户旅程,每个环节都有对应的 ArkUI 技术实现支撑。

从视觉设计的角度看,应用建立了一套完整的色彩规范——以深紫 #1A1038 和品牌紫 #4527A0 为主色,金色 #FFD700 为强调色,搭配 Material Design 深色调色板作为数据标识色。七个 Tab 页面各有独特的标题渐变配色,十四个弹窗共享"深紫渐变头+白底身"的统一框架但在头部色调上各有变化。阴影、圆角、透明度三个属性被系统性地用于构建卡片的悬浮感、柔和感和层级深度。这种"统一框架+局部变化"的设计策略,既保证了整体一致性,又赋予了每个页面和弹窗独特的视觉个性。

从代码复用的角度看,@Builder 方法是本应用复用策略的核心。modalOverlay 作为通用遮罩被十四个弹窗共享;bottomTabItem 作为通用导航项被七个 Tab 共享;contentArea 作为页面调度器统一管理七个 Tab 的渲染切换。在交互模式层面,"状态驱动单选"的范式被评分弹窗、组局弹窗、编辑弹窗、充值弹窗反复使用,形成了高度一致的交互体验。这种在框架层面和模式层面的双重复用,大幅降低了代码量和维护成本。

从鸿蒙技术生态的角度看,本案例集中运用了 ArkUI 的核心能力:Column 和 Row 的线性布局、Stack 的层叠布局、Scroll 的滚动容器、ForEach 的列表渲染、linearGradient 的渐变背景、transition 的过渡动画、layoutWeight 的弹性分配、FlexAlign 的对齐控制、clip 的裁切约束。这些能力覆盖了声明式 UI 开发中布局、渲染、动画、交互的全部维度,构成了一个完整的技术闭环。掌握这些原语的语义和用法,是进行鸿蒙应用开发的基础功力。


Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐