技术背景:互联网家装场景下的暖白工程橙设计体系与多形态列表工程
在移动互联网深度渗透传统行业的浪潮中,互联网家装是近年来变化最剧烈的垂直领域之一。家装业务天然横跨"线上决策—线下施工—长期交付"三个物理时空,涉及户型估价、设计师匹配、工地进度追踪、建材进场溯源、阶段付款管控、电子合同签署、装修知识科普等十余个业务子域。这些子域之间既有数据关联(如设计师风格影响案例标签、工地阶段决定付款节点),又各自具有迥异的信息形态——案例适合瀑布流图文卡、工地适合纵向时间轴、建材适合清单进度条、预算适合大卡加柱状图。如何在同一个移动端页面中以统一的视觉语言容纳七种完全不同的列表布局形态,是互联网家装前端工程最具挑战的设计命题。
鸿蒙操作系统(HarmonyOS)的 ArkTS 声明式 UI 框架为这类"多形态列表共存"的复杂业务场景提供了坚实的工程底座。ArkTS 在 TypeScript 基础上扩展了 @Entry、@Component、@State、@Builder、@Observed 等装饰器语法,使开发者能够以接近自然语言的声明方式同时描述"界面结构树"与"状态驱动关系"。相较于传统命令式 UI 中"先查找节点、再修改属性"的两步式操作,声明式 UI 通过"状态即视图"的响应式模型,让界面随数据变化而自动更新。在本应用中,七个 Tab 的内容由 tabIdx 一个状态变量驱动切换,工地阶段的呼吸动画由 breath 布尔状态驱动,合同列表的增删改由 @State 数组的 unshift/splice/属性赋值触发自动渲染。
@Observed 装饰器是 ArkUI 细粒度响应式渲染的关键机制。当一个被 @Observed 修饰的类实例作为 @State 数组的元素参与 ForEach 渲染时,框架会在渲染过程中建立"属性—UI 节点"的依赖追踪关系。此后,对该实例某个属性的赋值操作会被框架捕获,并仅触发依赖该属性的那一处 UI 节点的局部刷新,而非整个列表的重建。这种"属性级"的观察粒度,在工地阶段状态切换、建材进场进度更新、合同金额修改等高频数据变更场景下,能够有效避免不必要的重绘开销。
本应用采用"暖白底 + 工程橙 + 石墨灰"的三色方案精准命中家装行业的视觉调性——暖白底色 #F5F4F0 传递居家的柔和,工程橙 #E8722A 传递施工的活力与专业感,石墨灰 #2E2C28 作为主文字色传递工程的沉稳。本文将以一个完整的互联网家装界面为载体,逐段深入分析 ArkTS 在家装场景下的七种列表布局形态、色彩语义映射体系、呼吸式状态提醒动画、表单弹窗 CRUD 交互与多模块协同等核心技术场景的实现方式。

项目概述:美家帮平台功能全景
美家帮是一个面向城市家庭的互联网家装平台,围绕"估价—设计—施工—建材—预算—合同—攻略"全链路提供一站式服务。该平台涵盖了以下七大核心模块:
装修案例模块以双列瀑布流布局展示真实工地实拍案例。每张案例卡显示户型图标、装修风格标签、小区名称、面积、设计师署名、整装报价与"热门"标记,其中热门标记配合呼吸式透明度动画进行视觉强调。案例列表支持通过"免费量房"弹窗新增案例。
设计师模块以人物卡纵向列表形式展示签约设计师。每张人物卡包含头像、姓名、职级、擅长风格、作品套数、口碑评分与每平米设计费,并提供"看作品集"与"在线沟通"两个操作入口,底部标注回复率以增强信任感。
工地模块以纵向时间轴形式展示装修全流程的八个施工阶段。每个阶段节点显示名称、日期、状态与施工说明,节点圆点颜色按状态映射为绿/橙/灰,时间轴连线按已完成段数显示绿色。进行中阶段配合呼吸式透明度动画,整行以橙色芯片背景高亮区分。
建材模块以清单加进度条形式展示建材进场情况。每项建材显示图标、名称、品牌、数量与进场百分比,底部以十段格点式进度条可视化呈现进场比例,未进场项以红色百分比警示。
预算模块以大卡加柱状图形式展示装修预算与付款进度。顶部大卡使用橙色线性渐变背景展示合同总价、已支付金额与付款进度条,中部列出各阶段付款明细,底部以柱状图展示近六个月各阶段花费趋势,当前月份以橙色高亮区分历史月份。
合同模块以订单状态卡列表形式展示电子合同。每份合同显示编号、套餐名称、金额、签约状态与签约日期,待签字状态配合呼吸闪烁动画提醒。合同列表支持编辑金额与删除操作,通过两个独立的弹窗实现完整的 CRUD 交互链路。
攻略模块以左侧色条列表形式展示装修经验文章。每条攻略显示色条、图标、标题、分类标签与阅读量,色条颜色按分类映射为红/金/蓝/绿等语义色。攻略内容覆盖水电改造避坑、预算优化、量房科普、卫生间工艺、板材选材、合同条款、乳胶漆配色等高频装修痛点。
架构设计与整体技术选型
从架构层面来看,页面采用"头部估价卡 + 内容可滚动 + 底部七 Tab 两行导航"的三段式布局。头部包含品牌标识行、户型 Chips 选择行、面积估价卡与免费量房横条四层信息。底部 Tab 栏采用两行 4+3 的网格布局——第一行承载案例、设计师、工地、建材四个施工过程模块,第二行承载预算、合同、攻略三个管理模块,这种"过程在前、管理在后"的排列符合家装业务的时间线推进逻辑。
在状态管理方面,组件维护了 Tab 索引、呼吸动画状态、三个弹窗显隐状态、编辑/删除索引、三个表单输入状态与两个可变数组。其中 caseList 和 contractList 两个 @State 数组分别支持 unshift(新增案例)、splice(删除合同)和属性赋值(修改合同金额)操作。breath 布尔值由 setInterval 每 700 毫秒翻转一次,同时驱动案例热门标记、工地进行中状态、合同待签字状态三处呼吸式透明度动画,实现了"单定时器驱动多场景动画"的复用设计。
在色彩系统方面,"暖白底 + 工程橙 + 石墨灰"的三色方案构成了家装品牌的视觉基调。辅助色体系覆盖蓝(信息)、绿(完成)、红(警示/删除)、金(评分/省钱)四种语义,配合六个辅助函数实现"业务语义到颜色"的集中映射。在数据模型体系方面,六个 @Observed export class 分别对应案例、设计师、工地阶段、建材、合同、攻略六类业务实体,配套的六个常量数组作为模拟数据集提供初始数据,其中 caseList 与 contractList 被赋给 @State 实现可变。
逐段代码深度分析
一、暖白工程橙主题色彩系统

interface ColorPalette {
bg: string;
card: string;
dark: string;
darkL: string;
title: string;
sub: string;
text3: string;
orange: string;
orangeD: string;
blue: string;
green: string;
red: string;
gold: string;
line: string;
tabOn: string;
mask: string;
transparent: string;
chip: string;
}
const COLORS: ColorPalette = {
bg: '#F5F4F0',
card: '#FFFFFF',
dark: '#EFEDE6',
darkL: '#E5E1D6',
title: '#2E2C28',
sub: '#77726A',
text3: '#ADA79C',
orange: '#E8722A',
orangeD: '#C05A18',
blue: '#3E7BC4',
green: '#4E9E6A',
red: '#D44E4E',
gold: '#C9922F',
line: '#E8E4DA',
tabOn: '#E8722A',
mask: 'rgba(46,44,40,0.55)',
transparent: 'rgba(0,0,0,0)',
chip: 'rgba(232,114,42,0.10)'
};
这一段是整个应用的视觉基石。作者没有采用散落在各处的硬编码色值,而是先用 interface ColorPalette 定义一个包含 18 个字符串字段的色彩契约接口,再用一个 const COLORS 常量集中实现全部色值。这种"接口先行、常量后置"的设计模式,使得色彩系统具备了类型安全——任何拼写错误或遗漏字段都会在编译期被 TypeScript 检测出,而非在运行时表现为不可预期的颜色缺失。
从色彩语义上看,这套调色板可以归纳为四个层次。第一层是背景与容器色,bg 暖白底 #F5F4F0 呈现微暖的米白调;card 纯白卡用于卡片容器形成层次提升;dark 与 darkL 两档浅米色分别用于次级容器与表格条纹。第二层是文字色,title 石墨灰 #2E2C28 是接近纯黑但带暖调的深色,sub 棕灰 #77726A 用于副文本,text3 浅灰 #ADA79C 用于三级辅助文本,形成清晰的三级文字层级。
第三层是品牌主色与语义色。主色橙 #E8722A 呈现出饱和度较高的工程橙色,深橙 #C05A18 用于渐变终点和按钮装饰。语义色体系覆盖四种——蓝 #3E7BC4 用于信息标识与柱状图历史月份、绿 #4E9E6A 用于完成状态与已付标记、红 #D44E4E 用于警示与删除操作、金 #C9922F 用于评分与省钱标签。第四层是功能性色值,line 浅米线用于卡片边框,mask 使用半透明石墨色作为弹窗遮罩,transparent 全透明用于条件背景的"空状态",chip 使用主色橙 10% 透明度的芯片背景。
二、Tab 导航元数据与两行布局

interface TabMeta {
icon: string;
label: string;
}
const TAB_ROW1: TabMeta[] = [
{ icon: '🏡', label: '案例' },
{ icon: '✏️', label: '设计师' },
{ icon: '🚧', label: '工地' },
{ icon: '🧱', label: '建材' }
];
const TAB_ROW2: TabMeta[] = [
{ icon: '💰', label: '预算' },
{ icon: '📄', label: '合同' },
{ icon: '📚', label: '攻略' }
];
这段定义了底部导航栏的元数据结构。TabMeta 接口仅含 icon 与 label 两个字段,本应用将七个 Tab 拆分为 TAB_ROW1(四项)和 TAB_ROW2(三项)两个独立数组,直接对应底部两行 4+3 的网格布局——第一行承载案例、设计师、工地、建材四个"施工过程"模块,第二行承载预算、合同、攻略三个"管理与知识"模块。
选择 Emoji 作为图标而非图片资源,是一个权衡了包体积、加载性能与视觉表达力的决策。Emoji 无需额外资源文件,渲染零成本,且天然具备跨平台一致性。每个 Emoji 都经过精心挑选以传达业务语义——🏡 代表案例、✏️ 代表设计、🚧 代表工地、🧱 代表建材、💰 代表预算、📄 代表合同、📚 代表攻略。这种"图标即语义"的设计让用户无需阅读文字标签即可快速识别功能。
七个 Tab 的排列顺序遵循了家装业务的时间推进逻辑。案例是决策起点,设计师是方案确定环节,工地与建材是施工执行环节,预算与合同是费用管控环节,攻略是贯穿全程的知识辅助。两行布局在可用性与可读性之间取得了平衡——单行 7 个 Tab 会导致文字标签被挤压,两行布局使触控区域更大。
三、图表与付款数据常量

const MONTH_NAME: string[] = ['3月', '4月', '5月', '6月', '7月', '8月'];
const COST_CNT: number[] = [12, 18, 32, 56, 74, 46];
const PAY_ROWS: string[][] = [['水电阶段款', '¥5.8 万', '已付'], ['泥瓦阶段款', '¥6.2 万', '已付'], ['木工阶段款', '¥4.2 万', '待付'], ['软装尾款', '¥10.6 万', '待付']];
这里定义了预算模块所需的图表数据与付款明细常量。MONTH_NAME 与 COST_CNT 是一组配对数据,分别作为预算柱状图的 X 轴标签与 Y 轴数值,6 个月份对应 6 个各阶段花费金额(单位为千元)。数据范围从 3 月的 12 千元到 7 月的 74 千元,呈现装修过程中花费逐步攀升至高峰后回落的真实趋势——前期水电泥瓦花费较低,7 月达到软装与家具采购高峰 74 千元,8 月进入收尾阶段花费回落。
PAY_ROWS 是一个二维字符串数组,每一行是一个三元素元组:[阶段款名称, 金额, 状态]。四行数据对应装修的四个主要付款节点——水电阶段款 5.8 万(已付)、泥瓦阶段款 6.2 万(已付)、木工阶段款 4.2 万(待付)、软装尾款 10.6 万(待付)。使用 string[][] 二维数组而非定义一个 PayRow 类来建模付款明细,是一个在"类型严谨性"与"代码简洁性"之间的权衡选择——用二维数组直接以内联字面量声明,数据一目了然,代价是失去了类型检查,但在这个数据量小且结构稳定的场景下,这种妥协是合理的。
四、建材进场百分比映射函数

function matPercent(p: string): number {
if (p === '已进场 100%') {
return 10;
}
if (p === '已进场 80%') {
return 8;
}
if (p === '已进场 60%') {
return 6;
}
if (p === '已进场 40%') {
return 4;
}
if (p === '已进场 20%') {
return 2;
}
return 0;
}
function matPercentText(p: string): string {
if (p === '已进场 100%') {
return '100%';
}
if (p === '已进场 80%') {
return '80%';
}
if (p === '已进场 60%') {
return '60%';
}
if (p === '已进场 40%') {
return '40%';
}
if (p === '已进场 20%') {
return '20%';
}
return '0%';
}
这两个函数共同处理建材进场百分比的可视化映射。matPercent 函数将中文状态字符串(如"已进场 80%“)映射为 0-10 的整数值,用于驱动十段格点式进度条的高亮格数;matPercentText 函数将同一状态字符串映射为简洁的百分比文本(如"80%”),用于在进度条右侧显示。这种将状态字符串通过独立函数映射为数值与文本的设计,体现了"关注点分离"的工程意识——抽取为函数后,构建器只需调用 matPercent(item.percent) 和 matPercentText(item.percent) 即可获得所需数据,模板保持简洁。两个函数都采用早返回的防御式编程风格,最后一行兜底返回默认值,确保任何未预期的输入都不会导致空值。
值得注意的是,进度条采用十段格点式设计而非连续渐变进度条,matPercent 返回值映射为 0、2、4、6、8、10 六档。这种离散的格点式进度比连续进度条更适合"建材进场"这种按批次交付的场景——每批建材进场对应 20% 的跳跃,十段格点能精确呈现这种阶梯式进度。
五、攻略标签与风格状态色映射函数

function guideTagColor(t: string): string {
if (t === '避坑') {
return COLORS.red;
}
if (t === '省钱') {
return COLORS.gold;
}
if (t === '工艺') {
return COLORS.blue;
}
if (t === '选材') {
return COLORS.green;
}
return COLORS.orange;
}
function styleColor(s: string): string {
if (s === '奶油风') {
return COLORS.gold;
}
if (s === '极简风') {
return COLORS.blue;
}
if (s === '法式复古') {
return COLORS.red;
}
if (s === '日式原木') {
return COLORS.green;
}
return COLORS.orange;
}
这两个函数分别实现攻略分类与装修风格的语义化色彩映射。guideTagColor 函数将攻略标签映射为五色方案——避坑映射红色(警示)、省钱映射金色(利益)、工艺映射蓝色(技术)、选材映射绿色(材料),其他兜底映射橙色。这种"按内容语义分配颜色"的设计,使用户在浏览攻略列表时能通过左侧色条快速识别文章类别,形成"色彩即分类"的视觉记忆。
styleColor 函数将四种主流装修风格映射为四种语义色——奶油风映射金色(温暖)、极简风映射蓝色(理性)、法式复古映射红色(热烈)、日式原木映射绿色(自然)。这种映射不仅用于案例卡的风格标签背景,也用于设计师擅长风格的色彩标识,使整个应用中同一风格始终对应同一颜色,形成跨模块的视觉一致性。两个函数都采用早返回的编程范式,将映射逻辑集中到函数中而非分散在各构建器内联,使得未来新增风格或标签时只需修改函数体。
六、工地阶段与合同状态色映射函数

function stageColor(s: string): string {
if (s === '已完成') {
return COLORS.green;
}
if (s === '进行中') {
return COLORS.orange;
}
return COLORS.text3;
}
function contractColor(s: string): string {
if (s === '履约中') {
return COLORS.green;
}
if (s === '待签字') {
return COLORS.red;
}
return COLORS.sub;
}
这两个函数分别实现工地阶段状态与合同签约状态的颜色映射。stageColor 函数将工地阶段映射为三色方案——已完成映射绿色(完成)、进行中映射橙色(进行)、未开始兜底映射浅灰 text3(未启动)。这种"绿-橙-灰"的三态色彩体系是进度类应用中最经典的状态语义。contractColor 函数将合同状态映射为三色方案——履约中映射绿色、待签字映射红色(需关注)、已完成兜底映射棕灰 sub(归档)。待签字使用红色而非橙色,是因为待签字意味着合同尚未生效,属于需要用户立即行动的高优先级状态。
两个函数的兜底返回值设计值得注意。stageColor 的兜底返回 text3(浅灰),因为未开始阶段在视觉上应当弱化。contractColor 的兜底返回 sub(棕灰),因为已完成合同属于归档状态。这种"按状态优先级分配色彩强度"的设计,使视觉层次自然呈现"进行中突出、已完成次之、未开始最弱"的渐进效果。
七、案例与设计师数据模型
@Observed export class CaseItem {
icon: string;
name: string;
style: string;
area: string;
money: string;
designer: string;
hot: boolean;
constructor(icon: string, name: string, style: string, area: string, money: string, designer: string, hot: boolean) {
this.icon = icon;
this.name = name;
this.style = style;
this.area = area;
this.money = money;
this.designer = designer;
this.hot = hot;
}
}
@Observed export class Designer {
avatar: string;
name: string;
level: string;
style: string;
cases: string;
score: string;
fee: string;
constructor(avatar: string, name: string, level: string, style: string, cases: string, score: string, fee: string) {
this.avatar = avatar;
this.name = name;
this.level = level;
this.style = style;
this.cases = cases;
this.score = score;
this.fee = fee;
}
}
这两个类是案例与设计师模块的核心数据模型,均使用 @Observed 装饰并 export 导出。@Observed 是 ArkUI 框架提供的关键装饰器,它使类实例具备"可观察"能力——当该实例被赋值给 @State 装饰的数组并用于 ForEach 渲染时,框架能够追踪其属性级别的变更,从而实现"修改单个属性即触发对应 UI 局部刷新"的细粒度更新。
CaseItem 类建模单条装修案例,包含 icon、name、style、area、money、designer 和 hot 七个字段。其中 hot 是唯一的布尔字段,用于驱动热门标记的呼吸式透明度动画。money 字段存储的是格式化字符串(如"¥31.8 万")而非数值,这是因为 UI 层仅需展示文本而无需进行金额运算。
Designer 类建模单位设计师,包含 avatar、name、level、style、cases、score 和 fee 七个字段。fee 字段以"¥260 /㎡"格式存储单价信息,同时包含货币符号、数值与单位。两个类的构造函数均采用"全参构造"模式,要求实例化时必须提供全部字段,避免了"部分初始化导致渲染空值"的潜在缺陷。
八、工地阶段与建材数据模型
@Observed export class Stage {
name: string;
date: string;
status: string;
note: string;
constructor(name: string, date: string, status: string, note: string) {
this.name = name;
this.date = date;
this.status = status;
this.note = note;
}
}
@Observed export class Material {
icon: string;
name: string;
brand: string;
amount: string;
percent: string;
constructor(icon: string, name: string, brand: string, amount: string, percent: string) {
this.icon = icon;
this.name = name;
this.brand = brand;
this.amount = amount;
this.percent = percent;
}
}
这两个类分别建模工地施工阶段与建材进场记录。Stage 类包含 name、date、status 和 note 四个字段。date 字段同时承载单日(如"07-08")与日期范围(如"07-09 ~ 07-18")两种格式,这是因为不同施工阶段的工期长短不一,这种灵活的字符串设计避免了为短工期与长工期分别设计字段的冗余。
Material 类包含 icon、name、brand、amount 和 percent 五个字段。percent 字段以"已进场 80%"这种中文描述格式存储,而非简单的数值 80。这种设计将业务语义直接嵌入数据,但需要通过 matPercent 和 matPercentText 函数进行解析转换。两个类继续沿用 @Observed export class 的统一建模范式,例如工地阶段从"进行中"变为"已完成"时,只需 this.stageList[i].status = '已完成' 一行赋值,框架自动定位到该阶段的节点圆点、状态标签与时间轴连线三处 UI 进行局部刷新。
九、合同与攻略数据模型
@Observed export class Contract {
code: string;
home: string;
money: string;
status: string;
date: string;
constructor(code: string, home: string, money: string, status: string, date: string) {
this.code = code;
this.home = home;
this.money = money;
this.status = status;
this.date = date;
}
}
@Observed export class Guide {
icon: string;
title: string;
tag: string;
read: string;
constructor(icon: string, title: string, tag: string, read: string) {
this.icon = icon;
this.title = title;
this.tag = tag;
this.read = read;
}
}
这两个类分别建模电子合同与装修攻略。Contract 类包含 code(合同编号)、home(套餐名称)、money(金额)、status(签约状态)和 date(签约日期)五个字段。合同编号采用"HT-2024-0806"格式,即"HT-年份-月日"的结构化编码,既有人类可读的日期信息,又有机器可解析的结构化前缀。Guide 类包含 icon、title、tag 和 read 四个字段。tag 字段承载"避坑""省钱"等分类标签,是驱动攻略列表色条颜色的关键字段。read 字段以"12.8 万阅读"格式存储,将数值与单位合并在一个字符串中。
至此,六个 @Observed 数据类构成了应用完整的数据模型体系,覆盖了全部六个业务实体。每个类都采用扁平化的字段设计,所有属性均为公开字符串或布尔类型,便于直接在 ArkTS 模板中通过 item.字段名 插值渲染。这种扁平化建模虽然牺牲了面向对象的封装性,但在声明式 UI 场景下换取了最高的模板表达效率。
十、案例与设计师模拟数据集
const CASE_LIST: CaseItem[] = [
new CaseItem('🛋️', '中铁·城南之光 4 室 2 厅', '奶油风', '128㎡', '¥31.8 万', '设计师 林知夏', true),
new CaseItem('🪴', '万科·翡翠公园 3 室', '日式原木', '96㎡', '¥24.6 万', '设计师 顾之川', false),
new CaseItem('🖼️', '保利·天悦 大平层', '极简风', '156㎡', '¥42.2 万', '设计师 沈墨白', true),
new CaseItem('🪞', '龙湖·梵城 复式', '法式复古', '142㎡', '¥38.8 万', '设计师 温叙', false),
new CaseItem('🛁', '中海·锦江城 2 室', '奶油风', '78㎡', '¥19.8 万', '设计师 林知夏', false),
new CaseItem('🍳', '华润·二十四城 3 室', '极简风', '108㎡', '¥27.4 万', '设计师 沈墨白', false),
new CaseItem('📚', '绿地·锦官城 4 室', '日式原木', '132㎡', '¥33.6 万', '设计师 顾之川', false),
new CaseItem('🛏️', '融创·璟凌溪 叠拼', '法式复古', '186㎡', '¥52.8 万', '设计师 温叙', true)
];
const DESIGNER_LIST: Designer[] = [
new Designer('👩🎨', '林知夏', '首席设计师', '奶油风 / 日式', '168 套', '9.6', '¥260 /㎡'),
new Designer('🧑🎨', '沈墨白', '总监设计师', '极简 / 现代', '212 套', '9.8', '¥320 /㎡'),
new Designer('👨🎨', '顾之川', '资深设计师', '日式原木 / 侘寂', '134 套', '9.4', '¥220 /㎡'),
new Designer('🧑🎨', '温叙', '首席设计师', '法式 / 复古', '156 套', '9.5', '¥280 /㎡'),
new Designer('👩🎨', '程亦安', '资深设计师', '现代轻奢', '98 套', '9.3', '¥200 /㎡'),
new Designer('🧔', '老白', '明星设计师', '工业风 / 混搭', '286 套', '9.9', '¥380 /㎡')
];
这两段是案例与设计师模块的初始模拟数据集。CASE_LIST 包含八条案例数据,覆盖成都主流楼盘,面积从 78㎡ 到 186㎡,报价从 19.8 万到 52.8 万覆盖不同预算区间。八条案例中有三条标记为 hot: true(热门),分散在列表的首、中、尾位置,使热门标记的呼吸动画在滚动浏览时持续出现。
案例数据中的设计师署名与 DESIGNER_LIST 中的设计师姓名存在交叉引用——案例"林知夏"对应设计师列表中的"林知夏",这种跨数据集的关联体现了真实业务中"案例—设计师"的归属关系。如果未来需要扩展"查看设计师全部案例"功能,这种关联可直接复用。
DESIGNER_LIST 包含六位设计师,覆盖首席、总监、资深、明星四种职级,擅长风格涵盖奶油风、极简风、日式原木、法式复古、现代轻奢、工业风六种主流风格。作品套数从 98 套到 286 套,口碑评分从 9.3 到 9.9,设计费从 ¥200/㎡ 到 ¥380/㎡。明星设计师"老白"以 286 套作品和 9.9 评分置于末位作为压轴。
十一、工地阶段与建材模拟数据集
const STAGE_LIST: Stage[] = [
new Stage('开工交底', '07-08', '已完成', '设计师+工长+监理三方到场交底'),
new Stage('水电改造', '07-09 ~ 07-18', '已完成', '横平竖直布管,打压测试通过'),
new Stage('防水工程', '07-19 ~ 07-22', '已完成', '厨卫刷了两遍,闭水试验 48 小时'),
new Stage('泥瓦阶段', '07-23 ~ 08-10', '已完成', '墙地砖铺贴完成,空鼓率达标'),
new Stage('木工阶段', '08-11 ~ 08-20', '进行中', '吊顶龙骨安装中,进度 70%'),
new Stage('油漆阶段', '08-21 ~ 08-30', '未开始', '三底两面工艺,预计 10 天'),
new Stage('安装收尾', '09-01 ~ 09-10', '未开始', '地板、木门、洁具安装'),
new Stage('开荒保洁', '09-12', '未开始', '交付前深度保洁一次')
];
const MATERIAL_LIST: Material[] = [
new Material('🧱', '600×1200 抛釉砖', '马可波罗', '86 ㎡', '已进场 100%'),
new Material('🪵', '强化复合地板', '圣象', '54 ㎡', '已进场 80%'),
new Material('🎨', '净味乳胶漆', '多乐士', '18 桶', '已进场 60%'),
new Material('🚪', '实木复合门', 'TATA', '5 樘', '已进场 40%'),
new Material('🚽', '智能马桶', '九牧', '2 台', '已进场 100%'),
new Material('🧱', '石膏板吊顶', '龙牌', '38 ㎡', '已进场 100%'),
new Material('🚰', '全屋定制柜', '索菲亚', '12 ㎡', '已进场 20%'),
new Material('💡', '无主灯套装', '欧普', '26 盏', '已进场 0%')
];
STAGE_LIST 包含八个施工阶段,完整覆盖了从开工交底到开荒保洁的装修全流程。阶段状态呈现"四已完成一进行中三未开始"的分布,恰好对应工地时间轴的"绿-橙-灰"三色渐变效果。每条阶段数据的 note 字段承载了丰富的施工细节——"横平竖直布管,打压测试通过"描述水电工艺标准,"厨卫刷了两遍,闭水试验 48 小时"描述防水验收标准,这些细节文字传递了专业施工的严谨性。
MATERIAL_LIST 包含八项建材,覆盖瓷砖、地板、乳胶漆、木门、马桶、吊顶、定制柜、灯具八大主材品类。品牌均为真实知名建材品牌(马可波罗、圣象、多乐士、TATA、九牧、龙牌、索菲亚、欧普),进场百分比从 0% 到 100% 跨越六个档位,其中一项 0% 以红色百分比警示呈现未到位状态。
十二、合同与攻略模拟数据集
const CONTRACT_LIST: Contract[] = [
new Contract('HT-2024-0806', '美家帮·半包套餐 B1', '¥18.6 万', '履约中', '08-06 签约'),
new Contract('HT-2024-0712', '全屋定制·橱柜衣柜', '¥6.8 万', '履约中', '07-12 签约'),
new Contract('HT-2024-0701', '美家帮·设计合同', '¥1.2 万', '已完成', '07-01 签约'),
new Contract('HT-2024-0620', '中央空调加装', '¥3.4 万', '已完成', '06-20 签约'),
new Contract('HT-2024-0901', '软装整装服务', '¥12.8 万', '待签字', '09-01 待签'),
new Contract('HT-2024-0910', '开荒保洁+甲醛检测', '¥0.6 万', '待签字', '09-10 待签')
];
const GUIDE_LIST: Guide[] = [
new Guide('⚠️', '水电改造最容易踩的 8 个坑', '避坑', '12.8 万阅读'),
new Guide('💰', '同样 20 万预算,怎么装出 30 万效果', '省钱', '9.6 万阅读'),
new Guide('📏', '免费量房到底在量什么?', '科普', '6.2 万阅读'),
new Guide('🚿', '卫生间做下沉式还是墙排?', '工艺', '5.4 万阅读'),
new Guide('🪵', '全屋定制板材等级一篇讲透', '选材', '8.8 万阅读'),
new Guide('📝', '装修合同里必须写清的 12 条', '合同', '10.2 万阅读'),
new Guide('🎨', '乳胶漆色号怎么选不翻车', '配色', '7.6 万阅读')
];
CONTRACT_LIST 包含六份合同,状态呈现"两履约中两已完成两待签字"的分布。合同类型覆盖半包套餐、全屋定制、设计合同、中央空调加装、软装整装、保洁检测六种,金额从 ¥0.6 万到 ¥18.6 万。待签字的两份合同排在列表末尾,配合呼吸闪烁动画提醒用户尽快签署。date 字段承载"08-06 签约"或"09-01 待签"两种格式,已签约合同标注签约动作,待签合同标注待签状态。
GUIDE_LIST 包含七条攻略,覆盖避坑、省钱、科普、工艺、选材、合同、配色七种分类标签。攻略标题采用痛点驱动的文案风格——“最容易踩的 8 个坑”“怎么装出 30 万效果”“一篇讲透”“必须写清的 12 条”,这些标题直击装修新手的焦虑点。七条攻略的色条颜色通过 guideTagColor 函数映射为红、金、橙、蓝、绿等七色,使列表呈现出丰富的色彩节奏。
十三、页面状态管理与生命周期
@Entry
@Component
struct Page809 {
@State tabIdx: number = 0;
@State breath: boolean = false;
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = 0;
@State delIdx: number = 0;
@State addName: string = '';
@State addArea: string = '';
@State addStyle: string = '';
@State editMoney: string = '';
@State caseList: CaseItem[] = CASE_LIST;
@State contractList: Contract[] = CONTRACT_LIST;
private timer: number = -1;
aboutToAppear(): void {
this.timer = setInterval(() => {
this.breath = !this.breath;
}, 700);
}
aboutToDisappear(): void {
if (this.timer > 0) {
clearInterval(this.timer);
}
}
这段定义了页面组件 Page809 的状态管理与生命周期。@Entry 装饰器声明该组件为页面入口,@State 装饰器修饰的每个变量都纳入 ArkUI 的响应式渲染体系——任何变量的变更都会自动触发依赖该变量的 UI 区域重新渲染。状态变量可分为四组:tabIdx 控制 Tab 视图切换、breath 驱动呼吸动画、三个布尔值控制弹窗显隐、editIdx/delIdx 记录操作索引、四个表单输入状态承载弹窗输入值。
两个可变数组 caseList 和 contractList 分别支持增删改操作。caseList 支持 unshift(新增案例插入头部),contractList 支持 splice(删除合同)和属性赋值(修改金额)。这种"属性级赋值触发局部刷新"正是 @Observed 装饰器的核心价值——修改合同金额后,框架仅刷新该合同的金额文本节点,而非重建整个合同列表。
生命周期管理方面,aboutToAppear 在组件创建后启动 700 毫秒间隔的呼吸动画定时器;aboutToDisappear 在组件销毁前清除定时器避免内存泄漏。timer 字段初始值为 -1 作为"未启动"哨兵值,clearInterval 前通过 if (this.timer > 0) 判断确保仅在有有效定时器时才清除,这种防御式编程避免了运行时警告。
十四、头部构建器:估价卡与量房入口
@Builder
header() {
Column({ space: 12 }) {
Row({ space: 10 }) {
Text('🧡').fontSize(16)
.padding(6).borderRadius(12).backgroundColor(COLORS.chip)
Text('美家帮 · 装修就找管家').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('已服务 26 万家庭').fontSize(9).fontColor(COLORS.sub)
}
.width('100%')
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('户型').fontSize(9).fontColor(COLORS.text3)
ForEach(['2 室', '3 室', '4 室', '复式'], (h: string, i: number) => {
Text(h).fontSize(10).fontColor(i === 1 ? COLORS.orange : COLORS.sub)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(10)
.backgroundColor(i === 1 ? COLORS.chip : COLORS.dark)
}, (h: string) => h)
Column().layoutWeight(1)
Text('108㎡').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
}
.width('100%')
Row({ space: 12 }) {
Column({ space: 3 }) {
Text('预估整装报价').fontSize(8).fontColor(COLORS.text3)
Text('¥26.8 万').fontSize(18).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text('📋 免费量房').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.padding({ left: 18, right: 18, top: 8, bottom: 8 })
.borderRadius(12).backgroundColor(COLORS.orange)
.onClick(() => { this.addName = ''; this.addModal = true; })
}
.width('100%')
}
.width('100%').padding(14).borderRadius(16)
.backgroundColor(COLORS.card).borderWidth(1).borderColor(COLORS.line)
Scroll() {
Row({ space: 8 }) {
ForEach(['先施工后付款', '延期必赔', '环保不达标全额退', '金牌工长直管'], (t: string, i: number) => {
Column({ space: 2 }) {
Text('✓ ' + t).fontSize(10).fontColor(i === 0 ? COLORS.orange : COLORS.sub)
}
.padding({ left: 10, right: 10, top: 7, bottom: 7 })
.borderRadius(12).backgroundColor(i === 0 ? COLORS.chip : COLORS.dark)
}, (t: string) => t)
}
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).width('100%')
}
.width('100%').padding({ left: 14, right: 14, top: 12, bottom: 12 })
}
header 构建器是整个头部估价区域的实现,由品牌标识行、户型估价卡和服务承诺横滑行三层结构组成。品牌标识行使用 Column().layoutWeight(1) 作为弹性占位符将品牌名推向左侧、服务统计推向右侧。品牌图标 🧡 使用 COLORS.chip 半透明橙色背景包裹,与品牌主色形成呼应。
户型估价卡是头部的核心信息区域,包含户型 Chips 选择行与报价+量房按钮行两部分。户型 Chips 通过 ForEach 遍历 ['2 室', '3 室', '4 室', '复式'] 四个选项,以 i === 1(即"3 室")作为选中态,选中项使用橙色文字配 COLORS.chip 背景,未选中项使用灰色文字配 COLORS.dark 背景。这种 Chips 选择组件是移动端表单中常见的单选交互模式。报价行采用"左信息右按钮"的布局,左侧显示"预估整装报价"标签与"¥26.8 万"大号橙色加粗金额,右侧"免费量房"按钮使用实心橙色背景配白色加粗文字,onClick 回调清空 addName 并打开 addModal 弹窗。整个估价卡通过 borderWidth(1) 和 borderColor(COLORS.line) 形成有边框的卡片外观,区别于其他无框卡片的视觉风格。
服务承诺横滑行使用 Scroll 容器配 ScrollDirection.Horizontal 实现水平滚动,scrollBar(BarState.Off) 隐藏滚动条。四个承诺标签中第一个以 COLORS.chip 橙色背景突出,其余以 COLORS.dark 灰色背景呈现。每个标签以"✓ "前缀加文字的形式表达"已承诺"的语义,增强了用户对平台服务的信任感。
十五、案例 Tab 构建器:双列瀑布流
@Builder
caseTab() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🏡 装修案例 · 真实工地实拍').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('成都 · 2680 套').fontSize(9).fontColor(COLORS.sub)
}
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.caseList, (item: CaseItem) => {
Column({ space: 6 }) {
Text(item.icon).fontSize(36)
.width('100%').height(92).borderRadius(12)
.backgroundColor(COLORS.dark).textAlign(TextAlign.Center)
Row({ space: 6 }) {
Text(item.style).fontSize(8).fontColor(styleColor(item.style))
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(COLORS.darkL)
if (item.hot) {
Text('🔥 热门').fontSize(8).fontColor(COLORS.orange)
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(COLORS.chip)
.opacity(this.breath ? 0.6 : 1)
.animation({ duration: 600, iterations: 1 })
}
}
.width('100%')
Text(item.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold).maxLines(1)
Text(item.area + ' · ' + item.designer).fontSize(8).fontColor(COLORS.text3).maxLines(1)
Text(item.money).fontSize(13).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
}
.width('47.5%').padding(10).borderRadius(14)
.backgroundColor(COLORS.card).margin(5).alignItems(HorizontalAlign.Start)
}, (item: CaseItem) => item.name + item.money)
}
.width('100%')
}
.width('100%')
}
caseTab 构建器实现了案例模块的双列瀑布流布局。顶部标题行使用"标题+弹性占位+统计"的三段式布局,左侧标题"装修案例 · 真实工地实拍"配 Emoji 图标,右侧统计"成都 · 2680 套"显示地域与案例总量。这种"标题行+统计"的设计模式在七个 Tab 中反复出现,形成了统一的视觉节奏。
瀑布流主体使用 Flex({ wrap: FlexWrap.Wrap }) 容器实现自动换行。每张案例卡宽度设为 47.5%,配合 margin(5) 的外边距形成双列布局。Flex 的 Wrap 模式比 Grid 组件更灵活——它不需要预先定义列数,卡片宽度由百分比自适应,在不同屏幕宽度下都能保持双列效果。每张案例卡包含五层信息:Emoji 图标占位区、风格标签与热门标记行、小区名称、面积与设计师署名、橙色加粗报价。
热门标记的呼吸动画是案例卡的核心视觉动效。opacity(this.breath ? 0.6 : 1) 根据 breath 布尔值在 0.6 和 1 之间切换透明度,animation({ duration: 600, iterations: 1 }) 定义 600 毫秒单次动画。由于 breath 每 700 毫秒翻转一次而动画持续 600 毫秒,形成了一种"渐隐-渐显"的呼吸节奏。ForEach 的键值函数使用 item.name + item.money 组合键,确保案例的唯一性识别。
十六、设计师 Tab 构建器:人物卡列表
@Builder
designerTab() {
Column({ space: 10 }) {
Text('✏️ 签约设计师 · 3 套方案任你选').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
ForEach(DESIGNER_LIST, (item: Designer) => {
Column({ space: 10 }) {
Row({ space: 12 }) {
Text(item.avatar).fontSize(32)
.width(60).height(60).borderRadius(30).backgroundColor(COLORS.dark).textAlign(TextAlign.Center)
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(item.name).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(item.level).fontSize(8).fontColor(COLORS.orange)
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(COLORS.chip)
}
Text('擅长 ' + item.style).fontSize(9).fontColor(COLORS.sub)
Row({ space: 8 }) {
Text('作品 ' + item.cases).fontSize(8).fontColor(COLORS.text3)
Text('口碑 ' + item.score).fontSize(8).fontColor(COLORS.gold)
}
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column({ space: 3 }) {
Text(item.fee).fontSize(11).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text('/㎡').fontSize(8).fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
Row({ space: 8 }) {
Column() { Text('看作品集').fontSize(9).fontColor(COLORS.orange) }
.padding({ left: 14, right: 14, top: 6, bottom: 6 }).borderRadius(10).backgroundColor(COLORS.chip)
Column() { Text('在线沟通').fontSize(9).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.padding({ left: 14, right: 14, top: 6, bottom: 6 }).borderRadius(10).backgroundColor(COLORS.orange)
Column().layoutWeight(1)
Text('回复率 98%').fontSize(8).fontColor(COLORS.text3)
}
.width('100%')
}
.width('100%').padding(12).borderRadius(14).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Start)
}, (item: Designer) => item.name + item.cases)
}
.width('100%')
}
designerTab 构建器实现了设计师模块的人物卡纵向列表。每张人物卡分为上下两部分:上部是"头像+信息+设计费"的三列水平布局,下部是"看作品集+在线沟通+回复率"的操作行。
上部布局中,头像使用 60×60 圆形容器配 COLORS.dark 背景与 32 号 Emoji。中间信息列使用 layoutWeight(1) 占据剩余宽度,包含姓名+职级行、擅长风格行、作品+口碑行三层信息。姓名以 13 号加粗主色呈现,职级以 8 号橙色文字配 COLORS.chip 芯片背景标注。作品套数与口碑评分并排展示——作品数使用 COLORS.text3 浅灰弱化,口碑评分使用 COLORS.gold 金色突出,这种"弱化数量、强化评分"的呈现策略使口碑评分成为用户决策的核心参考。
右侧设计费列使用 HorizontalAlign.End 右对齐,展示设计费单价(如"¥260 /㎡")。设计费以橙色加粗突出,单位以浅灰弱化,形成"主数值+辅单位"的视觉层次,使用户在浏览列表时能快速横向比较不同设计师的价位。
下部操作行中,"看作品集"按钮使用 COLORS.chip 半透明橙色背景配橙色文字,是次要操作;“在线沟通"按钮使用 COLORS.orange 实心橙色背景配白色加粗文字,是主要操作。两个按钮通过背景色深浅区分优先级,引导用户优先点击"在线沟通”。右侧"回复率 98%"以浅灰小字呈现,作为信任背书。
十七、工地 Tab 构建器:进度时间轴
@Builder
stageTab() {
Column({ space: 0 }) {
Row({ space: 8 }) {
Text('🚧 我的工地 · 天府新区 108㎡').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('第 42 天 / 65 天').fontSize(9).fontColor(COLORS.orange)
}
.width('100%').padding({ bottom: 10 })
ForEach(STAGE_LIST, (item: Stage, i: number) => {
Row({ space: 12 }) {
Column({ space: 6 }) {
Column().width(12).height(12).borderRadius(6)
.backgroundColor(item.status === '已完成' ? COLORS.green : (item.status === '进行中' ? COLORS.orange : COLORS.darkL))
if (i < STAGE_LIST.length - 1) {
Column().width(2).height(48)
.backgroundColor(i < 4 ? COLORS.green : COLORS.line)
}
}
.alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Row({ space: 8 }) {
Text(item.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(item.status).fontSize(8).fontColor(stageColor(item.status))
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(COLORS.dark)
.opacity(item.status === '进行中' && this.breath ? 0.55 : 1)
.animation({ duration: 600, iterations: 1 })
}
Text(item.date).fontSize(9).fontColor(COLORS.text3)
Text(item.note).fontSize(9).fontColor(COLORS.sub).maxLines(1)
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
}
.width('100%').padding({ bottom: 8 }).borderRadius(12)
.backgroundColor(i === 4 ? COLORS.chip : COLORS.transparent)
}, (item: Stage, i: number) => item.name + i.toString())
}
.width('100%')
}
stageTab 构建器实现了工地模块的纵向时间轴布局。顶部标题行使用"工地地址+弹性占位+进度天数"的三段式布局,右侧"第 42 天 / 65 天"以橙色文字突出当前进度,使用户一眼即可了解施工的总体进展。
时间轴主体通过 ForEach 遍历 STAGE_LIST,每个阶段采用"左侧节点+连线+右侧信息"的横向布局。左侧 Column 包含节点圆点和连线两部分——节点圆点是 12×12 的圆角方块(borderRadius(6)),背景色通过三元条件映射:已完成映射 COLORS.green 绿色、进行中映射 COLORS.orange 橙色、未开始兜底映射 COLORS.darkL 浅灰色。连线是 2×48 的竖条,通过 if (i < STAGE_LIST.length - 1) 条件渲染确保最后一个阶段不显示连线。连线颜色通过 i < 4 ? COLORS.green : COLORS.line 判断——前四个阶段(已完成)的连线为绿色,后续阶段的连线为浅灰色,形成"绿色连线段+灰色连线段"的进度可视化效果。
右侧信息列包含阶段名称+状态标签行、日期行、说明行三层信息。阶段名称以 12 号加粗主色呈现,状态标签以 8 号小字配 COLORS.dark 背景呈现,颜色通过 stageColor 函数映射。进行中阶段的状态标签额外绑定呼吸动画——opacity(item.status === '进行中' && this.breath ? 0.55 : 1) 配合 600 毫秒单次动画,形成"进行中状态闪烁提醒"的视觉效果。
值得注意的设计细节是当前进行中阶段(索引 4,即"木工阶段")的整行高亮——backgroundColor(i === 4 ? COLORS.chip : COLORS.transparent) 使该行获得 COLORS.chip 半透明橙色背景,其余行使用全透明背景。这种"当前阶段高亮"的设计使用户在浏览时间轴时能快速定位当前施工进度,配合状态标签的呼吸闪烁,形成"背景高亮+标签闪烁"的双重视觉强调。ForEach 的键值函数使用 item.name + i.toString() 组合键,确保阶段的唯一性识别。
十八、建材 Tab 构建器:清单进度条
@Builder
materialTab() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🧱 建材进场 · 一物一码可溯源').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('8 项 · 3 项在路上').fontSize(9).fontColor(COLORS.sub)
}
.width('100%')
ForEach(MATERIAL_LIST, (item: Material) => {
Column({ space: 8 }) {
Row({ space: 12 }) {
Text(item.icon).fontSize(22)
.width(44).height(44).borderRadius(12).backgroundColor(COLORS.dark).textAlign(TextAlign.Center)
Column({ space: 3 }) {
Text(item.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(item.brand + ' · ' + item.amount).fontSize(9).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(matPercentText(item.percent)).fontSize(9).fontColor(item.percent === '已进场 0%' ? COLORS.red : COLORS.green)
}
.width('100%')
Row({ space: 3 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], (d: number) => {
Column()
.layoutWeight(1)
.height(5)
.borderRadius(3)
.backgroundColor(d < matPercent(item.percent) ? COLORS.orange : COLORS.darkL)
}, (d: number) => 'm' + d.toString())
}
.width('100%')
}
.width('100%')
.padding(12)
.borderRadius(14)
.backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Start)
}, (item: Material) => item.name + item.percent)
}
.width('100%')
}
materialTab 构建器实现了建材模块的清单加进度条布局。顶部标题行使用"标题+弹性占位+统计"的三段式布局,右侧"8 项 · 3 项在路上"同时展示总项数与未完成项数,使用户快速掌握建材进度的总体状况。
每项建材卡分为上下两部分。上部是"图标+名称品牌+百分比"的三列水平布局——左侧 44×44 圆角方块容器配 COLORS.dark 背景承载 Emoji 图标,中间 layoutWeight(1) 列展示材料名称与品牌+数量,右侧展示进场百分比。百分比颜色通过 item.percent === '已进场 0%' ? COLORS.red : COLORS.green 条件映射——未进场项以红色警示,已进场项以绿色表示正常。这种"红绿二态"的百分比色彩方案,使用户能通过颜色快速识别需要关注的建材项。
下部是十段格点式进度条,通过 ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) 遍历生成十个等宽格点。背景色通过 d < matPercent(item.percent) ? COLORS.orange : COLORS.darkL 条件映射——格点索引小于进场格数的显示橙色,其余显示浅灰色。这种格点式进度条比连续渐变进度条更适合建材进场这种按批次交付的场景。整个进度条的渲染完全由布局组件实现,无需任何图表库依赖,体现了 ArkUI 声明式布局在数据可视化方面的轻量能力。
十九、预算 Tab 构建器:大卡与柱状图
@Builder
budgetTab() {
Column({ space: 12 }) {
Text('💰 装修预算 · 每一笔都清楚').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column({ space: 10 }) {
Row({ space: 12 }) {
Column({ space: 3 }) {
Text('合同总价').fontSize(9).fontColor('#F3E0D0')
Text('¥26.8 万').fontSize(20).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column({ space: 3 }) {
Text('已支付').fontSize(9).fontColor('#F3E0D0')
Text('¥16.2 万').fontSize(16).fontColor('#FFD9C0').fontWeight(FontWeight.Bold)
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
Column({ space: 5 }) {
Row({ space: 6 }) {
Text('付款进度').fontSize(9).fontColor('#F3E0D0')
Column().layoutWeight(1)
Text('4 / 6 期').fontSize(9).fontColor('#FFD9C0')
}
.width('100%')
Row({ space: 3 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], (d: number) => {
Column().layoutWeight(1).height(6).borderRadius(3)
.backgroundColor(d < 7 ? '#FFD9C0' : 'rgba(255,217,192,0.25)')
}, (d: number) => 'b' + d.toString())
}
.width('100%')
}
.width('100%')
}
.width('100%').padding(16).borderRadius(16)
.linearGradient({ angle: 135, colors: [['#C05A18', 0.0], ['#E8722A', 1.0]] })
ForEach(PAY_ROWS, (r: string[]) => {
Row({ space: 12 }) {
Text(r[0]).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(r[1]).fontSize(11).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text(r[2]).fontSize(8).fontColor(r[2] === '已付' ? COLORS.green : COLORS.text3)
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(COLORS.dark)
}
.width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
}, (r: string[]) => r[0])
Text('📊 近 6 个月各阶段花费(千元)').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Row({ space: 8 }) {
ForEach(COST_CNT, (v: number, i: number) => {
Column({ space: 4 }) {
Column().width('100%').height(v).borderRadius(5)
.backgroundColor(i === 4 ? COLORS.orange : COLORS.blue)
Text(MONTH_NAME[i]).fontSize(8).fontColor(COLORS.text3)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
}, (v: number, i: number) => 'c' + i.toString())
}
.width('100%').padding(12).borderRadius(14).backgroundColor(COLORS.card)
.alignItems(VerticalAlign.Bottom).height(125)
}
.width('100%')
}
budgetTab 构建器实现了预算模块的大卡+付款明细+柱状图三层信息结构。顶部大卡是整个预算模块的视觉焦点,使用 linearGradient({ angle: 135, colors: [['#C05A18', 0.0], ['#E8722A', 1.0]] }) 实现 135 度从深橙到浅橙的线性渐变背景。这种渐变背景是整个应用中唯一使用渐变的卡片,使其在暖白底色的页面中脱颖而出,形成强烈的视觉焦点。
大卡内部包含合同总价、已支付金额与付款进度三组数据。合同总价以 20 号白色加粗大字呈现,是整个卡片中字号最大的文本,强化其"核心数据"地位。已支付金额以 16 号浅橙色(#FFD9C0)呈现,字号小于合同总价形成主次层次。两组数据的标签均使用 #F3E0D0 浅橙色,这种"深色背景+浅色文字"的配色方案与卡片外的"浅色背景+深色文字"形成反转对比。
付款进度条使用十段格点式设计,通过 ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) 生成十个格点。前七格使用 #FFD9C0 浅橙色,后三格使用 rgba(255,217,192,0.25) 即浅橙色 25% 透明度,形成"已付 7 格实色+未付 3 格虚色"的进度可视化。这与建材模块的十段进度条设计一致,但颜色方案适配了渐变卡片的暖色调。
付款明细列表通过 ForEach(PAY_ROWS) 渲染四行阶段付款信息,每行采用"阶段款名称+弹性占位+金额+状态"的布局。状态标签颜色通过 r[2] === '已付' ? COLORS.green : COLORS.text3 条件映射——已付映射绿色、待付映射浅灰色。柱状图区域通过 ForEach(COST_CNT) 渲染六根柱子,每根柱子高度直接等于数据值(如 74 像素),backgroundColor(i === 4 ? COLORS.orange : COLORS.blue) 使当前月份(7 月,索引 4)以橙色高亮,历史月份以蓝色呈现。柱状图容器使用 alignItems(VerticalAlign.Bottom) 使柱子底部对齐,height(125) 固定容器高度为最高柱子(74)加上标签空间。
二十、合同 Tab 构建器:订单状态卡
@Builder
contractTab() {
Column({ space: 10 }) {
Text('📄 我的合同 (' + this.contractList.length.toString() + ') · 电子签随时查').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
ForEach(this.contractList, (item: Contract, i: number) => {
Column({ space: 10 }) {
Row({ space: 10 }) {
Text('📄').fontSize(24)
.width(44).height(44).borderRadius(12).backgroundColor(COLORS.dark).textAlign(TextAlign.Center)
Column({ space: 3 }) {
Text(item.code).fontSize(10).fontColor(COLORS.text3)
Text(item.home).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold).maxLines(1)
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column({ space: 3 }) {
Text(item.money).fontSize(12).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text(item.status).fontSize(8).fontColor(contractColor(item.status))
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(COLORS.dark)
.opacity(item.status === '待签字' && this.breath ? 0.55 : 1)
.animation({ duration: 600, iterations: 1 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
Row({ space: 8 }) {
Text(item.date).fontSize(8).fontColor(COLORS.text3)
Column().layoutWeight(1)
Text('✏️').fontSize(11)
.onClick(() => { this.editIdx = i; this.editMoney = item.money; this.editModal = true; })
Text('🗑️').fontSize(11)
.onClick(() => { this.delIdx = i; this.delModal = true; })
}
.width('100%')
}
.width('100%').padding(12).borderRadius(14).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Start)
}, (item: Contract) => item.code + item.home)
}
.width('100%')
}
contractTab 构建器实现了合同模块的订单状态卡列表。顶部标题行动态显示合同数量——'📄 我的合同 (' + this.contractList.length.toString() + ') · 电子签随时查',当列表增删后数量自动更新,这是 @State 响应式渲染的直接体现。
每份合同卡分为上下两部分。上部是"图标+合同信息+金额状态"的三列水平布局——左侧 44×44 圆角方块容器配 COLORS.dark 背景承载合同图标,中间 layoutWeight(1) 列展示合同编号(浅灰小字)与套餐名称(加粗主色),右侧展示金额(橙色加粗)与状态标签。状态标签颜色通过 contractColor 函数映射,待签字状态额外绑定呼吸动画——opacity(item.status === '待签字' && this.breath ? 0.55 : 1) 配合 600 毫秒单次动画,形成"待签字状态闪烁提醒"的视觉效果,促使用户尽快签署合同。
下部操作行包含签约日期、编辑按钮与删除按钮。编辑按钮 ✏️ 的 onClick 回调将当前索引 i 赋给 editIdx,将当前金额 item.money 赋给 editMoney 作为输入框初始值,然后打开 editModal 弹窗。删除按钮 🗑️ 的 onClick 回调将当前索引 i 赋给 delIdx,然后打开 delModal 弹窗。这种"先记录索引再打开弹窗"的两步式交互模式,确保弹窗内的操作能够精确定位到目标合同。
ForEach 的键值函数使用 item.code + item.home 组合键,以合同编号与套餐名称的组合确保唯一性。值得注意的是,合同列表是两个可变 @State 数组之一——contractList 支持 splice(删除)和属性赋值(修改金额)操作。当用户在编辑弹窗中修改金额并确认后,this.contractList[this.editIdx].money = this.editMoney 这一属性赋值会触发 @Observed 的细粒度更新,框架仅刷新该合同的金额文本节点,而非重建整个合同列表。当用户在删除弹窗中确认后,this.contractList.splice(this.delIdx, 1) 触发列表项移除,框架自动移除对应的 UI 节点并更新顶部合同数量。
二十一、攻略 Tab 与底部导航构建器
@Builder
guideTab() {
Column({ space: 10 }) {
Text('📚 装修攻略 · 过来人用钱换的经验').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
ForEach(GUIDE_LIST, (item: Guide) => {
Row({ space: 12 }) {
Column().width(4).height(52).borderRadius(2)
.backgroundColor(guideTagColor(item.tag))
Text(item.icon).fontSize(24)
.width(46).height(46).borderRadius(12).backgroundColor(COLORS.dark).textAlign(TextAlign.Center)
Column({ space: 4 }) {
Text(item.title).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold).maxLines(2)
Row({ space: 6 }) {
Text(item.tag).fontSize(8).fontColor(guideTagColor(item.tag))
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(COLORS.darkL)
Text(item.read).fontSize(8).fontColor(COLORS.text3)
}
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('›').fontSize(16).fontColor(COLORS.text3)
}
.width('100%').padding(10).borderRadius(14).backgroundColor(COLORS.card)
}, (item: Guide) => item.title)
}
.width('100%')
}
@Builder
tabBar() {
Column({ space: 0 }) {
Row({ space: 0 }) {
ForEach(TAB_ROW1, (t: TabMeta, i: number) => {
Column({ space: 3 }) {
Text(t.icon).fontSize(18)
Text(t.label).fontSize(9)
.fontColor(this.tabIdx === i ? COLORS.tabOn : COLORS.text3)
.fontWeight(this.tabIdx === i ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1).padding({ top: 7, bottom: 4 })
.onClick(() => { this.tabIdx = i; })
}, (t: TabMeta, i: number) => t.label + i.toString())
}
.width('100%')
Row({ space: 0 }) {
ForEach(TAB_ROW2, (t: TabMeta, i: number) => {
Column({ space: 3 }) {
Text(t.icon).fontSize(18)
Text(t.label).fontSize(9)
.fontColor(this.tabIdx === i + 4 ? COLORS.tabOn : COLORS.text3)
.fontWeight(this.tabIdx === i + 4 ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1).padding({ top: 2, bottom: 5 })
.onClick(() => { this.tabIdx = i + 4; })
}, (t: TabMeta, i: number) => t.label + (i + 4).toString())
}
.width('100%')
}
.width('100%').backgroundColor(COLORS.card)
.borderRadius({ topLeft: 16, topRight: 16 })
}
guideTab 构建器实现了攻略模块的左侧色条列表布局。每条攻略采用"色条+图标+标题信息+箭头"的四列水平布局。最左侧是 4×52 的色条(borderRadius(2)),颜色通过 guideTagColor(item.tag) 函数映射——避坑红色、省钱金色、工艺蓝色、选材绿色,使每条攻略的分类通过色条颜色一目了然。这种"左色条"设计是列表设计中经典的信息分类可视化方案,色条不占额外空间却能有效传达分类语义。
色条右侧是 46×46 圆角方块图标容器,配 COLORS.dark 背景承载 24 号 Emoji。再右侧是 layoutWeight(1) 的标题信息列,包含标题(加粗主色,maxLines(2) 限制两行)与标签+阅读量行。标签以 COLORS.darkL 背景小芯片呈现,颜色与色条一致(通过 guideTagColor 函数),形成"色条色与标签色一致"的视觉呼应。阅读量以浅灰小字呈现。最右侧是"›"箭头,暗示可点击进入详情。
tabBar 构建器实现了底部两行 4+3 的 Tab 导航。第一行通过 ForEach(TAB_ROW1) 渲染案例、设计师、工地、建材四个 Tab,第二行通过 ForEach(TAB_ROW2) 渲染预算、合同、攻略三个 Tab。每个 Tab 包含 18 号 Emoji 图标与 9 号文字标签,文字颜色和粗细通过 this.tabIdx === i ? COLORS.tabOn : COLORS.text3 和 this.tabIdx === i ? FontWeight.Bold : FontWeight.Normal 双条件判断——选中 Tab 的文字为橙色加粗,未选中 Tab 的文字为浅灰色常规。
第二行 Tab 的索引判断使用 this.tabIdx === i + 4,因为第二行的三个 Tab 在 tabIdx 体系中对应索引 4、5、6。onClick 回调将 this.tabIdx 设为 i(第一行)或 i + 4(第二行),驱动 build 方法中的条件分支切换对应模块。整个 tabBar 使用 COLORS.card 白色背景配 { topLeft: 16, topRight: 16 } 顶部圆角,形成"浮于内容之上"的视觉层次。
二十二、弹窗遮罩与主布局编排
@Builder
modalOverlay(onClose: () => void) {
Column().width('100%').height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
build() {
Stack({ alignContent: Alignment.Center }) {
Column() {
this.header()
Scroll() {
Column({ space: 10 }) {
if (this.tabIdx === 0) {
this.caseTab()
} else if (this.tabIdx === 1) {
this.designerTab()
} else if (this.tabIdx === 2) {
this.stageTab()
} else if (this.tabIdx === 3) {
this.materialTab()
} else if (this.tabIdx === 4) {
this.budgetTab()
} else if (this.tabIdx === 5) {
this.contractTab()
} else {
this.guideTab()
}
}
.width('100%').padding({ left: 14, right: 14, top: 12, bottom: 16 })
}
.layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top)
this.tabBar()
}
.width('100%').height('100%')
if (this.addModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.addModal = false; })
// 新增案例弹窗内容...
}
.width('100%').height('100%').zIndex(999)
}
if (this.editModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.editModal = false; })
// 编辑合同金额弹窗内容...
}
.width('100%').height('100%').zIndex(999)
}
if (this.delModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.delModal = false; })
// 删除合同确认弹窗内容...
}
.width('100%').height('100%').zIndex(999)
}
}
.width('100%').height('100%').backgroundColor(COLORS.bg)
}
modalOverlay 是一个可复用的弹窗遮罩构建器,接受一个 onClose 回调函数作为参数。遮罩使用 COLORS.mask(rgba(46,44,40,0.55) 半透明石墨色)覆盖全屏,onClick 回调调用 onClose() 关闭弹窗。这种"点击遮罩关闭弹窗"的交互模式是移动端模态弹窗的标准范式。将遮罩抽取为独立构建器并通过参数传递回调函数,是一个良好的复用设计——三个弹窗共用同一套遮罩逻辑,避免了代码重复。
build 方法是整个页面的编排核心,采用 Stack 根容器嵌套 Column 的纵向编排模式。Stack 居于 Alignment.Center,其内分为两层:底层是占满全屏的 Column 主容器,承载头部 header()、可滚动内容区 Scroll 与底部 tabBar();上层是三个条件渲染的弹窗 Stack(新增、编辑、删除),分别由 addModal、editModal、delModal 三个布尔状态控制显隐,并以 zIndex(999) 浮于主内容之上。
可滚动内容区 Scroll 内部的 Column 通过 if-else if-else 条件分支根据 tabIdx 切换七个 Tab 构建器——0 调用 caseTab()、1 调用 designerTab()、2 调用 stageTab()、3 调用 materialTab()、4 调用 budgetTab()、5 调用 contractTab()、其余调用 guideTab()。Scroll 使用 layoutWeight(1) 占据头部与底部 Tab 栏之间的全部剩余高度,scrollBar(BarState.Off) 隐藏滚动条保持视觉整洁,align(Alignment.Top) 使内容从顶部开始排列。
这种"主内容 + 浮层弹窗"的 Stack 叠加模式,是 ArkUI 实现模态交互的经典范式。当用户点击"免费量房"按钮时,addModal 被设为 true,if (this.addModal) 条件成立,新增弹窗的 Stack 被渲染并浮于主内容之上;用户填写表单并提交后,unshift 新案例到 caseList,addModal 被设为 false,弹窗消失,案例列表头部出现新增项。这种"状态驱动弹窗显隐"的机制,使弹窗的管理完全声明式,无需命令式的 show/hide 调用。
二十三、新增案例与编辑合同弹窗
if (this.addModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.addModal = false; })
Column({ space: 12 }) {
Text('📋 预约免费量房').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
TextInput({ placeholder: '小区名称(如 中铁·城南之光)', text: this.addName })
.onChange((v: string) => { this.addName = v; })
.fontSize(12).padding(10).borderRadius(10).backgroundColor(COLORS.dark)
TextInput({ placeholder: '建筑面积(如 98㎡)', text: this.addArea })
.onChange((v: string) => { this.addArea = v; })
.fontSize(12).padding(10).borderRadius(10).backgroundColor(COLORS.dark)
TextInput({ placeholder: '偏好风格(如 奶油风)', text: this.addStyle })
.onChange((v: string) => { this.addStyle = v; })
.fontSize(12).padding(10).borderRadius(10).backgroundColor(COLORS.dark)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(12).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 })
.borderRadius(10).backgroundColor(COLORS.darkL)
.onClick(() => { this.addModal = false; })
Column() { Text('提交预约').fontSize(12).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 })
.borderRadius(10).backgroundColor(COLORS.orange)
.onClick(() => {
this.caseList.unshift(new CaseItem(
'🏠',
this.addName.length > 0 ? this.addName : '我的新家',
this.addStyle.length > 0 ? this.addStyle : '极简风',
this.addArea.length > 0 ? this.addArea : '100㎡',
'¥25.0 万', '待分配设计师', false
));
this.addModal = false;
})
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '80%' })
.padding(16).borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
新增案例弹窗是三个弹窗中最复杂的一个,包含三个 TextInput 输入框与"取消/提交预约"两个操作按钮。三个输入框分别接收小区名称、建筑面积与偏好风格,每个 TextInput 通过 onChange 回调将输入值实时绑定到 addName、addArea、addStyle 三个状态变量。
"提交预约"按钮的 onClick 回调通过 new CaseItem(...) 构造一个新的案例实例,其中 icon 固定为 🏠,name、style、area 三个字段通过三元表达式提供默认值兜底——this.addName.length > 0 ? this.addName : '我的新家'。这种"空值兜底"设计确保即使用户直接提交空表单,也能生成有效的案例数据。新案例通过 this.caseList.unshift(...) 插入列表头部,触发 @State 数组的响应式更新,框架自动在列表头部插入新案例卡。
弹窗的 constraintSize({ maxHeight: '80%' }) 限制了弹窗最大高度为屏幕的 80%,当输入框内容较多时弹窗可内部滚动而不会超出屏幕。这种"最大高度约束"在表单弹窗中是必要的防御措施。
编辑合同金额弹窗与删除合同确认弹窗的结构与新增弹窗类似。编辑弹窗包含一个 TextInput 接收新金额,确认时通过 this.contractList[this.editIdx].money = this.editMoney 修改合同金额——这是 @Observed 细粒度更新的典型应用,仅修改一个属性即触发该合同金额文本的局部刷新。删除弹窗包含"再想想/确认删除"两个按钮,确认时通过 this.contractList.splice(this.delIdx, 1) 移除合同,"确认删除"按钮使用 COLORS.red 红色背景以视觉警示区分于普通操作。
整体渲染流程图
核心技术特性对比
| 技术维度 | 实现方式 | 设计特点 | 性能考量 |
|---|---|---|---|
| 暖白工程橙主题 | 暖白底+工程橙+石墨灰三色方案 | 四层背景体系+橙色主色营造家装专业感 | 暖白底减轻长时间浏览疲劳 |
| 头部估价卡 | 有边框卡片+户型Chips+报价+量房按钮 | layoutWeight差异化分配信息宽度 | 边框引导关注核心估价区域 |
| 双列瀑布流 | Flex Wrap+47.5%宽度卡片 | styleColor函数动态映射风格色 | ForEach键值含name+money防重复 |
| 人物卡列表 | 头像+信息列+设计费三列布局 | 职级橙色芯片+评分金色突出 | 静态列表无需状态驱动 |
| 进度时间轴 | 节点圆点+竖向连线+信息列 | 三态色映射+进行中行橙色高亮 | 连线条件渲染减少末尾节点冗余 |
| 十段格点进度条 | ForEach十段+条件backgroundColor | matPercent函数映射0-10整数值 | 轻量布局组件实现进度可视化 |
| 渐变大卡预算 | linearGradient 135度橙到橙 | 深橙到浅橙渐变+白色文字反转 | 纯CSS渐变实现视觉焦点 |
| 柱状图趋势 | Column height=v+条件backgroundColor | 当前月橙色高亮历史月蓝色 | 零依赖纯布局柱状图 |
| 呼吸式动画 | breath布尔+条件opacity+animation | 700ms翻转驱动三处闪烁 | 单定时器驱动多场景动画复用 |
| 左色条攻略列表 | 4px色条+图标+标题+箭头 | guideTagColor函数映射五色分类 | 色条不占额外空间传达分类语义 |
| 弹窗CRUD | unshift/splice/属性赋值 | 三弹窗共用modalOverlay遮罩 | @Observed属性级赋值触发局部刷新 |
| 两行Tab导航 | TAB_ROW1四项+TAB_ROW2三项 | 4+3两行布局过程在前管理在后 | layoutWeight等分Tab宽度 |
| 空值兜底 | 三元表达式提供默认值 | 未输入时使用默认案例数据 | 防御式编程避免空值渲染 |
| 合同数量动态 | contractList.length.toString() | 标题动态显示合同总数 | @State数组变化自动更新标题 |
| 渐变遮罩 | modalOverlay复用构建器 | 55%透明石墨色遮罩+点击关闭 | 参数回调实现遮罩复用 |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

初始化项目,自动下载相关依赖:

完整代码:
// ============================================================
// 809 美家帮 · 互联网家装平台(现代行业:互联网家装)
// 头部样式:家装估价头部(户型chips + 面积估价卡 + 免费量房横条)无动画
// 布局风格:7 个 tab 每个布局完全不同
// 案例=双列瀑布 / 设计师=人物卡 / 工地=进度时间轴
// 建材=清单进度条 / 预算=大卡+柱状图 / 合同=订单状态 / 攻略=左色条
// 底部 7 tab 两行(4 + 3)
// 主题:暖白底 + 工程橙 + 石墨灰
// ============================================================
interface ColorPalette {
bg: string;
card: string;
dark: string;
darkL: string;
title: string;
sub: string;
text3: string;
orange: string;
orangeD: string;
blue: string;
green: string;
red: string;
gold: string;
line: string;
tabOn: string;
mask: string;
transparent: string;
chip: string;
}
const COLORS: ColorPalette = {
bg: '#F5F4F0',
card: '#FFFFFF',
dark: '#EFEDE6',
darkL: '#E5E1D6',
title: '#2E2C28',
sub: '#77726A',
text3: '#ADA79C',
orange: '#E8722A',
orangeD: '#C05A18',
blue: '#3E7BC4',
green: '#4E9E6A',
red: '#D44E4E',
gold: '#C9922F',
line: '#E8E4DA',
tabOn: '#E8722A',
mask: 'rgba(46,44,40,0.55)',
transparent: 'rgba(0,0,0,0)',
chip: 'rgba(232,114,42,0.10)'
};
interface TabMeta {
icon: string;
label: string;
}
const TAB_ROW1: TabMeta[] = [
{ icon: '🏡', label: '案例' },
{ icon: '✏️', label: '设计师' },
{ icon: '🚧', label: '工地' },
{ icon: '🧱', label: '建材' }
];
const TAB_ROW2: TabMeta[] = [
{ icon: '💰', label: '预算' },
{ icon: '📄', label: '合同' },
{ icon: '📚', label: '攻略' }
];
const MONTH_NAME: string[] = ['3月', '4月', '5月', '6月', '7月', '8月'];
const COST_CNT: number[] = [12, 18, 32, 56, 74, 46];
const PAY_ROWS: string[][] = [['水电阶段款', '¥5.8 万', '已付'], ['泥瓦阶段款', '¥6.2 万', '已付'], ['木工阶段款', '¥4.2 万', '待付'], ['软装尾款', '¥10.6 万', '待付']];
function matPercent(p: string): number {
if (p === '已进场 100%') {
return 10;
}
if (p === '已进场 80%') {
return 8;
}
if (p === '已进场 60%') {
return 6;
}
if (p === '已进场 40%') {
return 4;
}
if (p === '已进场 20%') {
return 2;
}
return 0;
}
function matPercentText(p: string): string {
if (p === '已进场 100%') {
return '100%';
}
if (p === '已进场 80%') {
return '80%';
}
if (p === '已进场 60%') {
return '60%';
}
if (p === '已进场 40%') {
return '40%';
}
if (p === '已进场 20%') {
return '20%';
}
return '0%';
}
function guideTagColor(t: string): string {
if (t === '避坑') {
return COLORS.red;
}
if (t === '省钱') {
return COLORS.gold;
}
if (t === '工艺') {
return COLORS.blue;
}
if (t === '选材') {
return COLORS.green;
}
return COLORS.orange;
}
function styleColor(s: string): string {
if (s === '奶油风') {
return COLORS.gold;
}
if (s === '极简风') {
return COLORS.blue;
}
if (s === '法式复古') {
return COLORS.red;
}
if (s === '日式原木') {
return COLORS.green;
}
return COLORS.orange;
}
function stageColor(s: string): string {
if (s === '已完成') {
return COLORS.green;
}
if (s === '进行中') {
return COLORS.orange;
}
return COLORS.text3;
}
function contractColor(s: string): string {
if (s === '履约中') {
return COLORS.green;
}
if (s === '待签字') {
return COLORS.red;
}
return COLORS.sub;
}
@Observed export class CaseItem {
icon: string;
name: string;
style: string;
area: string;
money: string;
designer: string;
hot: boolean;
constructor(icon: string, name: string, style: string, area: string, money: string, designer: string, hot: boolean) {
this.icon = icon;
this.name = name;
this.style = style;
this.area = area;
this.money = money;
this.designer = designer;
this.hot = hot;
}
}
@Observed export class Designer {
avatar: string;
name: string;
level: string;
style: string;
cases: string;
score: string;
fee: string;
constructor(avatar: string, name: string, level: string, style: string, cases: string, score: string, fee: string) {
this.avatar = avatar;
this.name = name;
this.level = level;
this.style = style;
this.cases = cases;
this.score = score;
this.fee = fee;
}
}
@Observed export class Stage {
name: string;
date: string;
status: string;
note: string;
constructor(name: string, date: string, status: string, note: string) {
this.name = name;
this.date = date;
this.status = status;
this.note = note;
}
}
@Observed export class Material {
icon: string;
name: string;
brand: string;
amount: string;
percent: string;
constructor(icon: string, name: string, brand: string, amount: string, percent: string) {
this.icon = icon;
this.name = name;
this.brand = brand;
this.amount = amount;
this.percent = percent;
}
}
@Observed export class Contract {
code: string;
home: string;
money: string;
status: string;
date: string;
constructor(code: string, home: string, money: string, status: string, date: string) {
this.code = code;
this.home = home;
this.money = money;
this.status = status;
this.date = date;
}
}
@Observed export class Guide {
icon: string;
title: string;
tag: string;
read: string;
constructor(icon: string, title: string, tag: string, read: string) {
this.icon = icon;
this.title = title;
this.tag = tag;
this.read = read;
}
}
const CASE_LIST: CaseItem[] = [
new CaseItem('🛋️', '中铁·城南之光 4 室 2 厅', '奶油风', '128㎡', '¥31.8 万', '设计师 林知夏', true),
new CaseItem('🪴', '万科·翡翠公园 3 室', '日式原木', '96㎡', '¥24.6 万', '设计师 顾之川', false),
new CaseItem('🖼️', '保利·天悦 大平层', '极简风', '156㎡', '¥42.2 万', '设计师 沈墨白', true),
new CaseItem('🪞', '龙湖·梵城 复式', '法式复古', '142㎡', '¥38.8 万', '设计师 温叙', false),
new CaseItem('🛁', '中海·锦江城 2 室', '奶油风', '78㎡', '¥19.8 万', '设计师 林知夏', false),
new CaseItem('🍳', '华润·二十四城 3 室', '极简风', '108㎡', '¥27.4 万', '设计师 沈墨白', false),
new CaseItem('📚', '绿地·锦官城 4 室', '日式原木', '132㎡', '¥33.6 万', '设计师 顾之川', false),
new CaseItem('🛏️', '融创·璟凌溪 叠拼', '法式复古', '186㎡', '¥52.8 万', '设计师 温叙', true)
];
const DESIGNER_LIST: Designer[] = [
new Designer('👩🎨', '林知夏', '首席设计师', '奶油风 / 日式', '168 套', '9.6', '¥260 /㎡'),
new Designer('🧑🎨', '沈墨白', '总监设计师', '极简 / 现代', '212 套', '9.8', '¥320 /㎡'),
new Designer('👨🎨', '顾之川', '资深设计师', '日式原木 / 侘寂', '134 套', '9.4', '¥220 /㎡'),
new Designer('🧑🎨', '温叙', '首席设计师', '法式 / 复古', '156 套', '9.5', '¥280 /㎡'),
new Designer('👩🎨', '程亦安', '资深设计师', '现代轻奢', '98 套', '9.3', '¥200 /㎡'),
new Designer('🧔', '老白', '明星设计师', '工业风 / 混搭', '286 套', '9.9', '¥380 /㎡')
];
const STAGE_LIST: Stage[] = [
new Stage('开工交底', '07-08', '已完成', '设计师+工长+监理三方到场交底'),
new Stage('水电改造', '07-09 ~ 07-18', '已完成', '横平竖直布管,打压测试通过'),
new Stage('防水工程', '07-19 ~ 07-22', '已完成', '厨卫刷了两遍,闭水试验 48 小时'),
new Stage('泥瓦阶段', '07-23 ~ 08-10', '已完成', '墙地砖铺贴完成,空鼓率达标'),
new Stage('木工阶段', '08-11 ~ 08-20', '进行中', '吊顶龙骨安装中,进度 70%'),
new Stage('油漆阶段', '08-21 ~ 08-30', '未开始', '三底两面工艺,预计 10 天'),
new Stage('安装收尾', '09-01 ~ 09-10', '未开始', '地板、木门、洁具安装'),
new Stage('开荒保洁', '09-12', '未开始', '交付前深度保洁一次')
];
const MATERIAL_LIST: Material[] = [
new Material('🧱', '600×1200 抛釉砖', '马可波罗', '86 ㎡', '已进场 100%'),
new Material('🪵', '强化复合地板', '圣象', '54 ㎡', '已进场 80%'),
new Material('🎨', '净味乳胶漆', '多乐士', '18 桶', '已进场 60%'),
new Material('🚪', '实木复合门', 'TATA', '5 樘', '已进场 40%'),
new Material('🚽', '智能马桶', '九牧', '2 台', '已进场 100%'),
new Material('🧱', '石膏板吊顶', '龙牌', '38 ㎡', '已进场 100%'),
new Material('🚰', '全屋定制柜', '索菲亚', '12 ㎡', '已进场 20%'),
new Material('💡', '无主灯套装', '欧普', '26 盏', '已进场 0%')
];
const CONTRACT_LIST: Contract[] = [
new Contract('HT-2024-0806', '美家帮·半包套餐 B1', '¥18.6 万', '履约中', '08-06 签约'),
new Contract('HT-2024-0712', '全屋定制·橱柜衣柜', '¥6.8 万', '履约中', '07-12 签约'),
new Contract('HT-2024-0701', '美家帮·设计合同', '¥1.2 万', '已完成', '07-01 签约'),
new Contract('HT-2024-0620', '中央空调加装', '¥3.4 万', '已完成', '06-20 签约'),
new Contract('HT-2024-0901', '软装整装服务', '¥12.8 万', '待签字', '09-01 待签'),
new Contract('HT-2024-0910', '开荒保洁+甲醛检测', '¥0.6 万', '待签字', '09-10 待签')
];
const GUIDE_LIST: Guide[] = [
new Guide('⚠️', '水电改造最容易踩的 8 个坑', '避坑', '12.8 万阅读'),
new Guide('💰', '同样 20 万预算,怎么装出 30 万效果', '省钱', '9.6 万阅读'),
new Guide('📏', '免费量房到底在量什么?', '科普', '6.2 万阅读'),
new Guide('🚿', '卫生间做下沉式还是墙排?', '工艺', '5.4 万阅读'),
new Guide('🪵', '全屋定制板材等级一篇讲透', '选材', '8.8 万阅读'),
new Guide('📝', '装修合同里必须写清的 12 条', '合同', '10.2 万阅读'),
new Guide('🎨', '乳胶漆色号怎么选不翻车', '配色', '7.6 万阅读')
];
@Entry
@Component
struct Page809 {
@State tabIdx: number = 0;
@State breath: boolean = false;
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = 0;
@State delIdx: number = 0;
@State addName: string = '';
@State addArea: string = '';
@State addStyle: string = '';
@State editMoney: string = '';
@State caseList: CaseItem[] = CASE_LIST;
@State contractList: Contract[] = CONTRACT_LIST;
private timer: number = -1;
aboutToAppear(): void {
this.timer = setInterval(() => {
this.breath = !this.breath;
}, 700);
}
aboutToDisappear(): void {
if (this.timer > 0) {
clearInterval(this.timer);
}
}
@Builder
header() {
Column({ space: 12 }) {
Row({ space: 10 }) {
Text('🧡').fontSize(16)
.padding(6)
.borderRadius(12)
.backgroundColor(COLORS.chip)
Text('美家帮 · 装修就找管家').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('已服务 26 万家庭').fontSize(9).fontColor(COLORS.sub)
}
.width('100%')
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('户型').fontSize(9).fontColor(COLORS.text3)
ForEach(['2 室', '3 室', '4 室', '复式'], (h: string, i: number) => {
Text(h).fontSize(10).fontColor(i === 1 ? COLORS.orange : COLORS.sub)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(10)
.backgroundColor(i === 1 ? COLORS.chip : COLORS.dark)
}, (h: string) => h)
Column().layoutWeight(1)
Text('108㎡').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
}
.width('100%')
Row({ space: 12 }) {
Column({ space: 3 }) {
Text('预估整装报价').fontSize(8).fontColor(COLORS.text3)
Text('¥26.8 万').fontSize(18).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column() {
Text('📋 免费量房').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.padding({ left: 18, right: 18, top: 8, bottom: 8 })
.borderRadius(12)
.backgroundColor(COLORS.orange)
.onClick(() => {
this.addName = '';
this.addModal = true;
})
}
.width('100%')
}
.width('100%')
.padding(14)
.borderRadius(16)
.backgroundColor(COLORS.card)
.borderWidth(1)
.borderColor(COLORS.line)
Scroll() {
Row({ space: 8 }) {
ForEach(['先施工后付款', '延期必赔', '环保不达标全额退', '金牌工长直管'], (t: string, i: number) => {
Column({ space: 2 }) {
Text('✓ ' + t).fontSize(10).fontColor(i === 0 ? COLORS.orange : COLORS.sub)
}
.padding({ left: 10, right: 10, top: 7, bottom: 7 })
.borderRadius(12)
.backgroundColor(i === 0 ? COLORS.chip : COLORS.dark)
}, (t: string) => t)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
}
@Builder
caseTab() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🏡 装修案例 · 真实工地实拍').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('成都 · 2680 套').fontSize(9).fontColor(COLORS.sub)
}
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(this.caseList, (item: CaseItem) => {
Column({ space: 6 }) {
Text(item.icon).fontSize(36)
.width('100%')
.height(92)
.borderRadius(12)
.backgroundColor(COLORS.dark)
.textAlign(TextAlign.Center)
Row({ space: 6 }) {
Text(item.style).fontSize(8).fontColor(styleColor(item.style))
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(COLORS.darkL)
if (item.hot) {
Text('🔥 热门').fontSize(8).fontColor(COLORS.orange)
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(COLORS.chip)
.opacity(this.breath ? 0.6 : 1)
.animation({ duration: 600, iterations: 1 })
}
}
.width('100%')
Text(item.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.maxLines(1)
Text(item.area + ' · ' + item.designer).fontSize(8).fontColor(COLORS.text3)
.maxLines(1)
Text(item.money).fontSize(13).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
}
.width('47.5%')
.padding(10)
.borderRadius(14)
.backgroundColor(COLORS.card)
.margin(5)
.alignItems(HorizontalAlign.Start)
}, (item: CaseItem) => item.name + item.money)
}
.width('100%')
}
.width('100%')
}
@Builder
designerTab() {
Column({ space: 10 }) {
Text('✏️ 签约设计师 · 3 套方案任你选').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
ForEach(DESIGNER_LIST, (item: Designer) => {
Column({ space: 10 }) {
Row({ space: 12 }) {
Text(item.avatar).fontSize(32)
.width(60).height(60).borderRadius(30).backgroundColor(COLORS.dark).textAlign(TextAlign.Center)
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(item.name).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(item.level).fontSize(8).fontColor(COLORS.orange)
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(COLORS.chip)
}
Text('擅长 ' + item.style).fontSize(9).fontColor(COLORS.sub)
Row({ space: 8 }) {
Text('作品 ' + item.cases).fontSize(8).fontColor(COLORS.text3)
Text('口碑 ' + item.score).fontSize(8).fontColor(COLORS.gold)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 3 }) {
Text(item.fee).fontSize(11).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text('/㎡').fontSize(8).fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
Row({ space: 8 }) {
Column() {
Text('看作品集').fontSize(9).fontColor(COLORS.orange)
}
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(10)
.backgroundColor(COLORS.chip)
Column() {
Text('在线沟通').fontSize(9).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(10)
.backgroundColor(COLORS.orange)
Column().layoutWeight(1)
Text('回复率 98%').fontSize(8).fontColor(COLORS.text3)
}
.width('100%')
}
.width('100%')
.padding(12)
.borderRadius(14)
.backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Start)
}, (item: Designer) => item.name + item.cases)
}
.width('100%')
}
@Builder
stageTab() {
Column({ space: 0 }) {
Row({ space: 8 }) {
Text('🚧 我的工地 · 天府新区 108㎡').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('第 42 天 / 65 天').fontSize(9).fontColor(COLORS.orange)
}
.width('100%')
.padding({ bottom: 10 })
ForEach(STAGE_LIST, (item: Stage, i: number) => {
Row({ space: 12 }) {
Column({ space: 6 }) {
Column()
.width(12).height(12).borderRadius(6)
.backgroundColor(item.status === '已完成' ? COLORS.green : (item.status === '进行中' ? COLORS.orange : COLORS.darkL))
if (i < STAGE_LIST.length - 1) {
Column()
.width(2).height(48)
.backgroundColor(i < 4 ? COLORS.green : COLORS.line)
}
}
.alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Row({ space: 8 }) {
Text(item.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(item.status).fontSize(8).fontColor(stageColor(item.status))
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(COLORS.dark)
.opacity(item.status === '进行中' && this.breath ? 0.55 : 1)
.animation({ duration: 600, iterations: 1 })
}
Text(item.date).fontSize(9).fontColor(COLORS.text3)
Text(item.note).fontSize(9).fontColor(COLORS.sub)
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding({ bottom: 8 })
.borderRadius(12)
.backgroundColor(i === 4 ? COLORS.chip : COLORS.transparent)
}, (item: Stage, i: number) => item.name + i.toString())
}
.width('100%')
}
@Builder
materialTab() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🧱 建材进场 · 一物一码可溯源').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('8 项 · 3 项在路上').fontSize(9).fontColor(COLORS.sub)
}
.width('100%')
ForEach(MATERIAL_LIST, (item: Material) => {
Column({ space: 8 }) {
Row({ space: 12 }) {
Text(item.icon).fontSize(22)
.width(44).height(44).borderRadius(12).backgroundColor(COLORS.dark).textAlign(TextAlign.Center)
Column({ space: 3 }) {
Text(item.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(item.brand + ' · ' + item.amount).fontSize(9).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(matPercentText(item.percent)).fontSize(9).fontColor(item.percent === '已进场 0%' ? COLORS.red : COLORS.green)
}
.width('100%')
Row({ space: 3 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], (d: number) => {
Column()
.layoutWeight(1)
.height(5)
.borderRadius(3)
.backgroundColor(d < matPercent(item.percent) ? COLORS.orange : COLORS.darkL)
}, (d: number) => 'm' + d.toString())
}
.width('100%')
}
.width('100%')
.padding(12)
.borderRadius(14)
.backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Start)
}, (item: Material) => item.name + item.percent)
}
.width('100%')
}
@Builder
budgetTab() {
Column({ space: 12 }) {
Text('💰 装修预算 · 每一笔都清楚').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column({ space: 10 }) {
Row({ space: 12 }) {
Column({ space: 3 }) {
Text('合同总价').fontSize(9).fontColor('#F3E0D0')
Text('¥26.8 万').fontSize(20).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 3 }) {
Text('已支付').fontSize(9).fontColor('#F3E0D0')
Text('¥16.2 万').fontSize(16).fontColor('#FFD9C0').fontWeight(FontWeight.Bold)
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
Column({ space: 5 }) {
Row({ space: 6 }) {
Text('付款进度').fontSize(9).fontColor('#F3E0D0')
Column().layoutWeight(1)
Text('4 / 6 期').fontSize(9).fontColor('#FFD9C0')
}
.width('100%')
Row({ space: 3 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], (d: number) => {
Column()
.layoutWeight(1)
.height(6)
.borderRadius(3)
.backgroundColor(d < 7 ? '#FFD9C0' : 'rgba(255,217,192,0.25)')
}, (d: number) => 'b' + d.toString())
}
.width('100%')
}
.width('100%')
}
.width('100%')
.padding(16)
.borderRadius(16)
.linearGradient({ angle: 135, colors: [['#C05A18', 0.0], ['#E8722A', 1.0]] })
ForEach(PAY_ROWS, (r: string[]) => {
Row({ space: 12 }) {
Text(r[0]).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text(r[1]).fontSize(11).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text(r[2]).fontSize(8).fontColor(r[2] === '已付' ? COLORS.green : COLORS.text3)
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(COLORS.dark)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(COLORS.card)
}, (r: string[]) => r[0])
Text('📊 近 6 个月各阶段花费(千元)').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Row({ space: 8 }) {
ForEach(COST_CNT, (v: number, i: number) => {
Column({ space: 4 }) {
Column()
.width('100%')
.height(v)
.borderRadius(5)
.backgroundColor(i === 4 ? COLORS.orange : COLORS.blue)
Text(MONTH_NAME[i]).fontSize(8).fontColor(COLORS.text3)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (v: number, i: number) => 'c' + i.toString())
}
.width('100%')
.padding(12)
.borderRadius(14)
.backgroundColor(COLORS.card)
.alignItems(VerticalAlign.Bottom)
.height(125)
}
.width('100%')
}
@Builder
contractTab() {
Column({ space: 10 }) {
Text('📄 我的合同 (' + this.contractList.length.toString() + ') · 电子签随时查').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
ForEach(this.contractList, (item: Contract, i: number) => {
Column({ space: 10 }) {
Row({ space: 10 }) {
Text('📄').fontSize(24)
.width(44).height(44).borderRadius(12).backgroundColor(COLORS.dark).textAlign(TextAlign.Center)
Column({ space: 3 }) {
Text(item.code).fontSize(10).fontColor(COLORS.text3)
Text(item.home).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 3 }) {
Text(item.money).fontSize(12).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
Text(item.status).fontSize(8).fontColor(contractColor(item.status))
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(COLORS.dark)
.opacity(item.status === '待签字' && this.breath ? 0.55 : 1)
.animation({ duration: 600, iterations: 1 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
Row({ space: 8 }) {
Text(item.date).fontSize(8).fontColor(COLORS.text3)
Column().layoutWeight(1)
Text('✏️').fontSize(11)
.onClick(() => {
this.editIdx = i;
this.editMoney = item.money;
this.editModal = true;
})
Text('🗑️').fontSize(11)
.onClick(() => {
this.delIdx = i;
this.delModal = true;
})
}
.width('100%')
}
.width('100%')
.padding(12)
.borderRadius(14)
.backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Start)
}, (item: Contract) => item.code + item.home)
}
.width('100%')
}
@Builder
guideTab() {
Column({ space: 10 }) {
Text('📚 装修攻略 · 过来人用钱换的经验').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
ForEach(GUIDE_LIST, (item: Guide) => {
Row({ space: 12 }) {
Column()
.width(4).height(52).borderRadius(2)
.backgroundColor(guideTagColor(item.tag))
Text(item.icon).fontSize(24)
.width(46).height(46).borderRadius(12).backgroundColor(COLORS.dark).textAlign(TextAlign.Center)
Column({ space: 4 }) {
Text(item.title).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.maxLines(2)
Row({ space: 6 }) {
Text(item.tag).fontSize(8).fontColor(guideTagColor(item.tag))
.padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(6).backgroundColor(COLORS.darkL)
Text(item.read).fontSize(8).fontColor(COLORS.text3)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('›').fontSize(16).fontColor(COLORS.text3)
}
.width('100%')
.padding(10)
.borderRadius(14)
.backgroundColor(COLORS.card)
}, (item: Guide) => item.title)
}
.width('100%')
}
@Builder
tabBar() {
Column({ space: 0 }) {
Row({ space: 0 }) {
ForEach(TAB_ROW1, (t: TabMeta, i: number) => {
Column({ space: 3 }) {
Text(t.icon).fontSize(18)
Text(t.label).fontSize(9)
.fontColor(this.tabIdx === i ? COLORS.tabOn : COLORS.text3)
.fontWeight(this.tabIdx === i ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1)
.padding({ top: 7, bottom: 4 })
.onClick(() => {
this.tabIdx = i;
})
}, (t: TabMeta, i: number) => t.label + i.toString())
}
.width('100%')
Row({ space: 0 }) {
ForEach(TAB_ROW2, (t: TabMeta, i: number) => {
Column({ space: 3 }) {
Text(t.icon).fontSize(18)
Text(t.label).fontSize(9)
.fontColor(this.tabIdx === i + 4 ? COLORS.tabOn : COLORS.text3)
.fontWeight(this.tabIdx === i + 4 ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1)
.padding({ top: 2, bottom: 5 })
.onClick(() => {
this.tabIdx = i + 4;
})
}, (t: TabMeta, i: number) => t.label + (i + 4).toString())
}
.width('100%')
}
.width('100%')
.backgroundColor(COLORS.card)
.borderRadius({ topLeft: 16, topRight: 16 })
}
@Builder
modalOverlay(onClose: () => void) {
Column().width('100%').height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => {
onClose();
})
}
build() {
Stack({ alignContent: Alignment.Center }) {
Column() {
this.header()
Scroll() {
Column({ space: 10 }) {
if (this.tabIdx === 0) {
this.caseTab()
} else if (this.tabIdx === 1) {
this.designerTab()
} else if (this.tabIdx === 2) {
this.stageTab()
} else if (this.tabIdx === 3) {
this.materialTab()
} else if (this.tabIdx === 4) {
this.budgetTab()
} else if (this.tabIdx === 5) {
this.contractTab()
} else {
this.guideTab()
}
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 16 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.align(Alignment.Top)
this.tabBar()
}
.width('100%')
.height('100%')
if (this.addModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => {
this.addModal = false;
})
Column({ space: 12 }) {
Text('📋 预约免费量房').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
TextInput({ placeholder: '小区名称(如 中铁·城南之光)', text: this.addName })
.onChange((v: string) => {
this.addName = v;
})
.fontSize(12)
.padding(10)
.borderRadius(10)
.backgroundColor(COLORS.dark)
TextInput({ placeholder: '建筑面积(如 98㎡)', text: this.addArea })
.onChange((v: string) => {
this.addArea = v;
})
.fontSize(12)
.padding(10)
.borderRadius(10)
.backgroundColor(COLORS.dark)
TextInput({ placeholder: '偏好风格(如 奶油风)', text: this.addStyle })
.onChange((v: string) => {
this.addStyle = v;
})
.fontSize(12)
.padding(10)
.borderRadius(10)
.backgroundColor(COLORS.dark)
Row({ space: 10 }) {
Column() {
Text('取消').fontSize(12).fontColor(COLORS.sub)
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.borderRadius(10)
.backgroundColor(COLORS.darkL)
.onClick(() => {
this.addModal = false;
})
Column() {
Text('提交预约').fontSize(12).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.borderRadius(10)
.backgroundColor(COLORS.orange)
.onClick(() => {
this.caseList.unshift(new CaseItem(
'🏠',
this.addName.length > 0 ? this.addName : '我的新家',
this.addStyle.length > 0 ? this.addStyle : '极简风',
this.addArea.length > 0 ? this.addArea : '100㎡',
'¥25.0 万',
'待分配设计师',
false
));
this.addModal = false;
})
}
.width('100%')
}
.width('82%')
.constraintSize({ maxHeight: '80%' })
.padding(16)
.borderRadius(16)
.backgroundColor(COLORS.card)
}
.width('100%')
.height('100%')
.zIndex(999)
}
if (this.editModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => {
this.editModal = false;
})
Column({ space: 12 }) {
Text('✏️ 修改合同金额').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(this.contractList[this.editIdx].code + ' · ' + this.contractList[this.editIdx].home).fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '新的合同金额(如 ¥12.0 万)', text: this.editMoney })
.onChange((v: string) => {
this.editMoney = v;
})
.fontSize(12)
.padding(10)
.borderRadius(10)
.backgroundColor(COLORS.dark)
Row({ space: 10 }) {
Column() {
Text('取消').fontSize(12).fontColor(COLORS.sub)
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.borderRadius(10)
.backgroundColor(COLORS.darkL)
.onClick(() => {
this.editModal = false;
})
Column() {
Text('保存').fontSize(12).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.borderRadius(10)
.backgroundColor(COLORS.orange)
.onClick(() => {
if (this.editMoney.length > 0) {
this.contractList[this.editIdx].money = this.editMoney;
}
this.editModal = false;
})
}
.width('100%')
}
.width('82%')
.constraintSize({ maxHeight: '80%' })
.padding(16)
.borderRadius(16)
.backgroundColor(COLORS.card)
}
.width('100%')
.height('100%')
.zIndex(999)
}
if (this.delModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => {
this.delModal = false;
})
Column({ space: 14 }) {
Text('🗑️ 删除这份合同?').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('「' + this.contractList[this.delIdx].code + '」将从列表移除。').fontSize(10).fontColor(COLORS.sub)
Row({ space: 10 }) {
Column() {
Text('再想想').fontSize(12).fontColor(COLORS.sub)
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.borderRadius(10)
.backgroundColor(COLORS.darkL)
.onClick(() => {
this.delModal = false;
})
Column() {
Text('确认删除').fontSize(12).fontColor('#FFFFFF')
}
.layoutWeight(1)
.padding({ top: 10, bottom: 10 })
.borderRadius(10)
.backgroundColor(COLORS.red)
.onClick(() => {
this.contractList.splice(this.delIdx, 1);
this.delModal = false;
})
}
.width('100%')
}
.width('78%')
.constraintSize({ maxHeight: '80%' })
.padding(16)
.borderRadius(16)
.backgroundColor(COLORS.card)
}
.width('100%')
.height('100%')
.zIndex(999)
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
}
总结

通过对这个互联网家装界面的逐段深度剖析,我们可以看到 ArkTS 声明式 UI 范式在传统行业数字化转型中的完整工程实践。从主题设计维度来看,"暖白底 + 工程橙 + 石墨灰"的三色方案精准命中了家装"家的温暖感与工程的严谨感并存"的视觉调性。四层背景体系在卡片内部形成了丰富的层次差异,半透明橙色的芯片背景色在白色卡片上实现了品牌色彩的一致性表达。六个辅助函数将风格、阶段、合同、攻略等业务语义集中映射为颜色,使色彩与语义形成稳定关联。
从布局形态维度来看,七个 Tab 的七种完全不同列表布局是本应用最具技术价值的部分。案例模块的 Flex Wrap 双列瀑布流、设计师模块的人物卡三列布局、工地模块的节点+连线时间轴、建材模块的十段格点进度条、预算模块的渐变大卡+柱状图、合同模块的订单状态卡、攻略模块的左色条列表——七种布局形态在同一个声明式 UI 框架下流畅切换。每个 Tab 的布局都经过精心设计以匹配其业务特性——时间轴适合线性推进的工地进度,格点进度条适合按批次交付的建材进场,柱状图适合月度花费趋势,这种"布局形态服务于业务语义"的设计意识是优秀前端工程的核心素养。
从交互设计维度来看,呼吸式透明度动画是整个应用最核心的视觉动效。breath 布尔值由单个 setInterval 每 700 毫秒翻转一次,同时驱动案例热门标记、工地进行中状态、合同待签字状态三处动画。这种"单定时器驱动多场景动画"的复用设计,既减少了定时器数量降低了资源开销,又确保了三处动画的节奏一致性。进行中阶段的整行橙色芯片高亮配合状态标签闪烁,形成"背景高亮+标签闪烁"的双重视觉强调,待签字合同的闪烁则有效促使用户尽快签署合同。
从数据管理维度来看,@Observed 装饰器的细粒度响应式更新能力是整个应用数据流转的基石。六个 @Observed 数据类覆盖了全部业务实体,两个 @State 数组支持完整的增删改操作。新增案例通过 unshift 插入列表头部,删除合同通过 splice 移除列表项,修改合同金额通过属性赋值实现。这种"属性级赋值触发局部刷新"是 @Observed 的核心价值——框架仅刷新该合同的金额文本节点,而非重建整个合同列表,在高频数据变更场景下能够有效避免不必要的重绘开销。
从表单交互维度来看,三个弹窗通过 modalOverlay 可复用遮罩构建器实现了统一的遮罩逻辑。新增案例弹窗通过三个 TextInput + onChange + 状态变量的组合实现了完整的表单输入流程,确认时通过三元表达式提供默认值兜底。编辑弹窗通过属性赋值实现金额修改,删除弹窗通过 splice 实现列表项移除,"确认删除"按钮使用红色背景以视觉警示区分于普通操作。这种"状态驱动弹窗显隐+回调驱动数据操作"的两步式交互模式,使弹窗的管理完全声明式。
整体而言,这个实现展现了 ArkTS 在互联网家装应用中的工程价值:通过暖白工程橙主题营造专业又温暖的品牌视觉,通过 @Observed 细粒度观察实现工地阶段和合同状态的实时更新,通过七种不同的列表布局形态精准匹配各模块的业务语义,通过单定时器驱动多场景呼吸动画,通过表单双向绑定实现案例新增与合同编辑交互。这些技术实践为鸿蒙生态下的传统行业数字化转型应用开发提供了有价值的参考范式,尤其是"同一框架下七种布局形态共存"的工程实践,充分证明了声明式 UI 范式在复杂业务场景下的适应能力与表达效率。


被折叠的 条评论
为什么被折叠?



