HarmonyOS 6.1.1下基于HarmonyOS API 24的ArkTS折扇坊应用开发深度解析:HarmonyOS ArkTS API 24声明式范式、@Observed可观察数据模型与扇形动
HarmonyOS 6.1.1下基于HarmonyOS API 24的ArkTS折扇坊应用开发深度解析:HarmonyOS ArkTS API 24声明式范式、@Observed可观察数据模型与扇形动画系统全流程剖析
导言
HarmonyOS 6.1.1所带来的声明式UI范式,标志着鸿蒙生态在前端开发体验上完成了一次质的飞跃。在HarmonyOS ArkTS API 24体系下,ArkTS语言以TypeScript为超集,引入了@Entry、@Component、@State、@Builder、@Observed等一系列装饰器,使得开发者能够以接近声明式描述的方式构建界面,而无需再手写繁琐的命令式布局代码。声明式范式的核心思想是"状态即界面"——开发者只需描述界面在某一状态下的样子,框架会自动追踪状态的改变并完成最小化的差异更新,这种数据驱动的渲染模型大幅降低了复杂界面的维护成本。
在ArkTS的可观察对象机制中,@Observed装饰器用于标记一个类为可观察类,当该类的实例被@State或@ObjectLink等状态变量引用时,对其属性的修改会被框架自动监听,并触发依赖该属性的UI组件重新渲染。这一机制是构建MVVM架构应用的基础,它让数据模型与视图层彻底解耦,业务逻辑只需专注于数据本身的变更,视图层则会"自动跟上"。本应用"折扇坊"正是基于这一理念,将扇品、扇骨、扇面、名家、工艺、题字、藏家、订单等八类业务实体都声明为@Observed类,并通过一个集中的FanShopData数据仓进行统一管理。
此外,HarmonyOS的动画系统为声明式UI赋予了生命力。ArkTS提供了animation属性、animateTo函数以及PlayMode.Alternate等播放模式,开发者可以通过对translate、rotate、opacity、scale等变换属性的细粒度控制,实现扇骨辐线开合、扇坠闪烁、进度条呼吸、列表项轻浮等富有韵律的动效。本应用巧妙地将这些动画能力与"折扇"这一东方传统器物的视觉意象相结合——扇骨辐线以rotate角度递增模拟扇面开合,扇坠以opacity交替模拟流苏摇曳,工艺难度以横条宽度映射数值,藏家珍藏数以柱状图高低呈现数据,整体形成了"竹青米白雅色"的国风视觉基调。本文将从配色体系、数据模型、工具函数、主组件生命周期、各Builder构建器、八大Tab内容渲染、模态弹窗交互、底部导航装配等多个维度,对代码进行逐段拆解与深度剖析。
一、配色体系与界面基调

折扇坊的整体视觉基调建立在"竹青米白雅色"之上,这一配色通过一个interface与一个const常量共同定义,既保证了类型安全,又便于全局统一引用。
interface FanPalette {
bg: string;
cardBg: string;
bamboo: string;
bambooDeep: string;
gold: string;
ink: string;
textMain: string;
textSub: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
}
这里定义了一个FanPalette接口,它声明了应用所需的全部颜色字段。使用interface而非直接使用对象字面量,是ArkTS推荐的类型优先实践——它让编译器能够在编译期校验后续COLORS常量是否完整提供了每一个字段,避免运行时因颜色缺失导致的UI异常。从语义命名可以看出,颜色被划分为背景(bg/cardBg)、主题色(bamboo/bambooDeep竹青)、点缀色(gold金/ink墨)、文本色三级(textMain/textSub/textHint)、边框色(border)、语义色(success/warning/danger)以及白色(white),这种分层命名法使整套配色具备清晰的语义层级。
const COLORS: FanPalette = {
bg: '#F4F0E6',
cardBg: '#FFFFFF',
bamboo: '#7A8B5A',
bambooDeep: '#4E5C36',
gold: '#C9A227',
ink: '#3A3228',
textMain: '#3A3228',
textSub: '#7A7260',
textHint: '#B0A890',
border: '#E5DFCE',
success: '#5E7B4A',
warning: '#B57B2E',
danger: '#A83A2E',
white: '#FFFFFF'
};
COLORS常量是配色体系的实际载体,它以FanPalette类型被实例化。竹青色#7A8B5A与深竹青#4E5C36构成主色调,米白背景#F4F0E6营造宣纸般的温润质感,金色#C9A227用于扇坠、徽章等点缀,墨色#3A3228用于头部背景。文本色采用三级灰度(textMain/textSub/textHint)以区分主、次、提示信息层次,这种渐进式灰度在长列表中尤为重要,能让用户视觉快速聚焦关键信息。success/warning/danger三色则承担状态语义,分别对应已交付、裱面中、移除操作等业务状态。
二、扇形头部构建器headerArea

headerArea是应用最具视觉特色的区域,通过Stack叠层布局将标题、扇骨辐线、扇坠组合成扇面意象。
@Builder
headerArea() {
Stack() {
Column() {
Text('折扇坊').fontSize(22).fontColor(COLORS.bamboo)
.fontWeight(FontWeight.Bold).letterSpacing(4)
Text('扇开清凉 · 袖中风雅').fontSize(11).fontColor(COLORS.textSub)
.margin({ top: 4 }).letterSpacing(2)
}
.width('100%')
.position({ x: 0, y: 20 })
.alignItems(HorizontalAlign.Center)
headerArea以Stack为容器,第一层是标题列——"折扇坊"主标题22号字加粗竹青色带4字距,"扇开清凉 · 袖中风雅"副标题11号字灰色带2字距。letterSpacing增加字距营造书法般的疏朗感。position将标题列固定在距顶20像素处居中显示。Stack的叠层特性使后续的扇骨辐线与扇坠能覆盖在标题之上或之下,形成层次丰富的视觉构图。
Row() {
ForEach(RIB_IDX, (i: number) => {
Column()
.width(4).height(64)
.backgroundColor(i % 2 === 0 ? COLORS.bamboo : COLORS.gold)
.borderRadius(2)
.rotate({ angle: (i - 2.5) * 10 })
.translate({ y: this.amb ? -6 : 0 })
.animation({ duration: 1200 + i * 150, iterations: -1, playMode: PlayMode.Alternate })
}, (i: number) => i.toString())
}
.width('60%')
.height(70)
.justifyContent(FlexAlign.SpaceAround)
.alignItems(VerticalAlign.Bottom)
.position({ x: '20%', y: 70 })
第二层是扇骨辐线Row,通过ForEach遍历RIB_IDX(0-5)生成六根辐条。每根辐条是4x64的Column,颜色按奇偶交替竹青与金色,borderRadius圆角。关键在rotate({ angle: (i - 2.5) * 10 })——以索引减2.5再乘10得到角度,使六根辐条以中心为轴呈扇形放射展开(角度从-25到25度),模拟折扇扇骨的辐线排列。translate({ y: this.amb ? -6 : 0 })配合animation实现辐线上下浮动:amb为true时上移6像素,animation的duration为1200+i*150毫秒(每根辐条时长递增形成错落感)、iterations为-1(无限循环)、playMode为Alternate(往返交替),于是六根辐条以不同节奏上下浮动,形成扇面微微开合的呼吸动效。
Column()
.width(10).height(10).borderRadius(5)
.backgroundColor(COLORS.gold)
.position({ x: '50%', y: 128 })
.opacity(this.amb ? 0.35 : 0.9)
.animation({ duration: 900, iterations: -1, playMode: PlayMode.Alternate })
}
.width('100%')
.height(180)
.backgroundColor(COLORS.ink)
.borderRadius({ bottomLeft: 28, bottomRight: 28 })
}
第三层是扇坠——一个10x10的金色圆点(Column带borderRadius 5),position固定在50%水平、128垂直处,模拟扇子底端的流苏坠饰。opacity在amb为true时为0.35、false时为0.9,配合900毫秒Alternate动画形成明暗交替的闪烁效果,模拟流苏在风中摇曳的光影变化。整个header以墨色#3A3228为背景、底部两角28像素圆角,与米白主体形成深浅对比,使扇形头部如同悬浮于纸面上的扇面剪影。
三、新增订单模态弹窗addOrderModal

新增订单弹窗是一个包含四个TextInput表单的模态对话框,是应用中最复杂的交互组件之一。
@Builder
addOrderModal() {
Column() {
Row() {
Text('新增订单').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.textHint)
.onClick(() => { this.showAddOrder = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 8 })
弹窗以Column为容器,顶部是标题行——“新增订单"标题加粗、右侧用layoutWeight(1)的空Column撑开空间、最右是关闭按钮"✕”。这种"标题+弹性占位+操作按钮"的行布局是弹窗头部的标准模式。点击✕将showAddOrder置false关闭弹窗。padding提供了合理的内边距使内容不贴边。
Scroll() {
Column() {
Text('订单编号').fontSize(12).fontColor(COLORS.textSub).width('100%').margin({ top: 8 })
TextInput({ text: this.formNo, placeholder: '如 P20260822' })
.placeholderColor(COLORS.textHint).fontSize(14).width('100%')
.backgroundColor(COLORS.bg).borderRadius(8)
.margin({ top: 4 }).onChange((v: string) => { this.formNo = v })
Text('客户姓名').fontSize(12).fontColor(COLORS.textSub).width('100%').margin({ top: 12 })
TextInput({ text: this.formBuyer, placeholder: '客户姓名' })
.placeholderColor(COLORS.textHint).fontSize(14).width('100%')
.backgroundColor(COLORS.bg).borderRadius(8)
.margin({ top: 4 }).onChange((v: string) => { this.formBuyer = v })
表单主体包裹在Scroll中(配合后续constraintSize maxHeight限制高度),内含四个字段:订单编号、客户姓名、定制扇品、定金金额。每个字段由一个标签Text和一个TextInput组成。TextInput通过{ text: this.formXxx }绑定状态变量实现受控输入,onChange回调将输入值回写状态,形成双向绑定。placeholder提供输入提示,placeholderColor用hint色弱化提示文字。backgroundColor与borderRadius使输入框呈现米白圆角卡片样式,margin({ top: 4 })让标签与输入框之间有呼吸空间。
Text('定制扇品').fontSize(12).fontColor(COLORS.textSub).width('100%').margin({ top: 12 })
TextInput({ text: this.formPiece, placeholder: '如 玉竹扇' })
.placeholderColor(COLORS.textHint).fontSize(14).width('100%')
.backgroundColor(COLORS.bg).borderRadius(8)
.margin({ top: 4 }).onChange((v: string) => { this.formPiece = v })
Text('定金金额').fontSize(12).fontColor(COLORS.textSub).width('100%').margin({ top: 12 })
TextInput({ text: this.formAmount, placeholder: '元' })
.placeholderColor(COLORS.textHint).fontSize(14).width('100%')
.backgroundColor(COLORS.bg).borderRadius(8)
.margin({ top: 4 }).onChange((v: string) => { this.formAmount = v })
}
.width('100%').padding({ left: 20, right: 20 })
}
.width('100%')
.constraintSize({ maxHeight: '52%' })
定制扇品与定金金额两个字段延续了相同的标签+输入框模式。定金金额的placeholder为"元",提示用户输入数值。Scroll外层用constraintSize({ maxHeight: ‘52%’ })限制了表单区最大高度为屏幕的52%,当字段较多或键盘弹起时表单可滚动查看,避免内容溢出弹窗边界。这种"可滚动表单+高度约束"是处理长表单的标准方案。
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSub)
.backgroundColor(COLORS.border).borderRadius(20)
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.onClick(() => { this.showAddOrder = false })
Text('提交订单').fontSize(14).fontColor(COLORS.white)
.backgroundColor(COLORS.bambooDeep).borderRadius(20)
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => {
this.data.addOrder(
this.formNo === '' ? 'P20260822' : this.formNo,
this.formBuyer === '' ? '匿名客' : this.formBuyer,
this.formPiece === '' ? '玉竹扇' : this.formPiece,
this.formAmount === '' ? 3000 : Number(this.formAmount));
this.showAddOrder = false;
})
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 16, bottom: 16 })
}
.width('90%')
.constraintSize({ maxHeight: '80%' })
.backgroundColor(COLORS.cardBg).borderRadius(16)
.alignItems(HorizontalAlign.Center)
.position({ x: '5%', y: '8%' })
}
底部是操作按钮行——"取消"按钮为灰底圆角,"提交订单"按钮为深竹青底白字圆角,两按钮居中排列。提交按钮的onClick调用了data.addOrder,并对四个字段做了空值兜底:若用户未填写则使用默认值(订单号P20260822、客户匿名客、扇品玉竹扇、金额3000),Number(this.formAmount)将字符串金额转为数值。提交后showAddOrder置false关闭弹窗。整个弹窗宽90%、最大高80%、白底圆角16、绝对定位在5%/8%处,形成居中悬浮的卡片式对话框。
四、编辑扇骨模态弹窗editBoneModal
编辑扇骨弹窗用于修改扇骨部件的名称与价格,结构比新增订单弹窗精简,但同样体现了表单与数据仓的交互模式。
@Builder
editBoneModal() {
Column() {
Row() {
Text('编辑扇骨').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.textHint)
.onClick(() => { this.showEditBone = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 8 })
Text('部件名称').fontSize(12).fontColor(COLORS.textSub).width('100%').margin({ top: 8, left: 20 })
TextInput({ text: this.boneName, placeholder: '部件名称' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.bg).borderRadius(8)
.margin({ top: 4 }).onChange((v: string) => { this.boneName = v })
弹窗头部同样是"标题+弹性占位+关闭按钮"模式。表单只有两个字段:部件名称与单价。boneName通过{text: this.boneName}绑定,在扇骨Tab点击"编辑"时已被预填为当前部件名(b.name),用户可在此基础上修改。onChange回写boneName状态。注意TextInput宽度为90%而标签margin left为20,使输入框与标签左对齐又保持一定留白。
Text('单价(元)').fontSize(12).fontColor(COLORS.textSub).width('100%').margin({ top: 12, left: 20 })
TextInput({ text: this.bonePrice, placeholder: '单价' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.bg).borderRadius(8)
.margin({ top: 4 }).onChange((v: string) => { this.bonePrice = v })
单价字段绑定bonePrice状态,预填当前部件价格(b.price.toString())。同样以字符串形式存储输入,提交时转换。这种"预填当前值、接收修改"的模式让编辑弹窗具备良好的用户体验——用户能看到原值并在此基础上调整,而非从零开始。
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSub)
.backgroundColor(COLORS.border).borderRadius(20)
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.onClick(() => { this.showEditBone = false })
Text('保存修改').fontSize(14).fontColor(COLORS.white)
.backgroundColor(COLORS.bambooDeep).borderRadius(20)
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => {
this.data.editBone(
this.boneIdx,
this.boneName === '' ? this.data.bones[this.boneIdx].name : this.boneName,
this.bonePrice === '' ? this.data.bones[this.boneIdx].price : Number(this.bonePrice));
this.showEditBone = false;
})
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 20, bottom: 16 })
}
.width('90%')
.constraintSize({ maxHeight: '80%' })
.backgroundColor(COLORS.cardBg).borderRadius(16)
.alignItems(HorizontalAlign.Center)
.position({ x: '5%', y: '8%' })
}
保存按钮调用data.editBone,传入boneIdx索引,并对名称与价格做空值兜底——若用户清空了输入框,则回退到原值(this.data.bones[this.boneIdx].name/price),避免提交空数据。Number(this.bonePrice)将字符串价格转数值。由于BoneItem是@Observed类,editBone内部对this.bones[idx].name与price的赋值会自动触发扇骨Tab对应卡片的重新渲染,用户能立即看到修改效果。这种"模态表单+数据仓方法+可观察自动刷新"的链路,是ArkTS实现编辑功能的完整闭环。
五、移除藏家确认弹窗removeCollectorModal
移除藏家弹窗是一个确认型对话框,用于在执行破坏性操作前征求用户确认,体现了谨慎的交互设计。
@Builder
removeCollectorModal() {
Column() {
Text('🪭').fontSize(34).margin({ top: 24 })
Text('移除藏家').fontSize(17).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textMain).margin({ top: 10 })
Text('确认将 ' + this.data.collectors[this.collectorIdx].name + ' 移出藏家名录?')
.fontSize(13).fontColor(COLORS.textSub).margin({ top: 10 })
.padding({ left: 28, right: 28 }).textAlign(TextAlign.Center)
弹窗顶部是一个34号的扇子Emoji作为视觉图标,下方是"移除藏家"标题,再下方是确认提示文案。提示文案动态拼接了待移除藏家的名字——通过this.data.collectors[this.collectorIdx].name读取,collectorIdx在藏家Tab点击"移除"时已被赋值。textAlign居中与左右padding 28使多行提示文案居中显示,符合确认对话框的视觉惯例。
Row() {
Text('再想想').fontSize(14).fontColor(COLORS.textSub)
.backgroundColor(COLORS.border).borderRadius(20)
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.onClick(() => { this.showRemoveCollector = false })
Text('确认移除').fontSize(14).fontColor(COLORS.white)
.backgroundColor(COLORS.danger).borderRadius(20)
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => {
this.data.removeCollector(this.collectorIdx);
this.showRemoveCollector = false;
})
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 22, bottom: 20 })
}
.width('86%')
.constraintSize({ maxHeight: '80%' })
.backgroundColor(COLORS.cardBg).borderRadius(16)
.alignItems(HorizontalAlign.Center)
.position({ x: '7%', y: '28%' })
}
底部按钮行中,"再想想"为灰底(取消操作)、"确认移除"为danger红色底白字(确认破坏性操作)。红色按钮明确警示了操作的不可逆性,符合移动端确认对话框的色彩语义规范。点击确认后调用data.removeCollector(this.collectorIdx),该方法用splice删除数组对应项,由于collectors数组是@Observed且被ForEach渲染,删除后列表自动收缩。弹窗宽86%、定位在7%/28%处,比前两个弹窗略小且位置偏下,符合确认对话框"小巧居中偏下"的视觉定位。
六、内容区构建器contentArea总览

contentArea是应用内容渲染的核心,通过if-else分支根据activeTab渲染八个Tab的内容,每个Tab都有独特的布局与数据可视化方式。
@Builder
contentArea() {
if (this.activeTab === 0) {
Scroll() {
Column() {
Row() {
Column() {
Text('12').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.bambooDeep)
Text('坊藏折扇').fontSize(10).fontColor(COLORS.textHint).margin({ top: 2 })
}.layoutWeight(1)
Column() {
Text('湘妃竹扇').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.gold)
Text('最热款').fontSize(10).fontColor(COLORS.textHint).margin({ top: 2 })
}.layoutWeight(1)
Column() {
Text('¥13.6w').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.bambooDeep)
Text('本季成交').fontSize(10).fontColor(COLORS.textHint).margin({ top: 2 })
}.layoutWeight(1)
}
.width('100%')
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.cardBg).borderRadius(12)
.margin({ top: 12, bottom: 12 })
.border({ width: 1, color: COLORS.border })
contentArea以if(this.activeTab===0)开始折扇Tab分支。顶部是一个三列统计卡片Row——“12坊藏折扇”“湘妃竹扇最热款”“¥13.6w本季成交”,三列均分宽度(layoutWeight 1),每列上下排列数值与标签,数值加粗深竹青或金色,标签灰色小字。这种"数值+标签"的统计卡片是仪表盘式界面的常见元素,让用户一眼获取关键指标。卡片白底圆角带边框,与米白背景形成层次。
ForEach(this.data.fans, (f: FanItem) => {
Column() {
Row() {
Column() {
Text(f.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Text(f.bone + ' · ' + f.face).fontSize(11)
.fontColor(COLORS.textSub).margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('¥' + f.price).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.bambooDeep)
Column()
.width(4).height(34).borderRadius(2)
.backgroundColor(COLORS.bamboo).margin({ left: 10 })
.opacity(this.amb ? 0.4 : 0.9)
.animation({ duration: 1100, iterations: -1, playMode: PlayMode.Alternate })
}
.width('100%')
}
.width('100%')
.backgroundColor(COLORS.cardBg).borderRadius(12)
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.margin({ bottom: 10 })
.border({ width: 1, color: COLORS.border })
}, (f: FanItem) => f.name)
}
.width('100%').padding({ left: 14, right: 14, bottom: 12 })
}
.width('100%')
.layoutWeight(1)
折扇列表通过ForEach遍历data.fans渲染12款折扇卡片。每张卡片左侧是扇名(加粗)与"扇骨·扇面"描述(灰色),右侧是价格(深竹青加粗)与一个4x34的竹青竖条。这个竖条的opacity在amb为true时为0.4、false时为0.9,配合1100毫秒Alternate动画形成明暗呼吸,模拟折扇骨身的律动。ForEach的键值生成器(f: FanItem) => f.name以扇名为唯一键,保证列表高效diff更新。整个列表包裹在Scroll中并layoutWeight(1)占满剩余空间,超出时可滚动。
七、扇骨Tab内容(activeTab===1)
扇骨Tab展示了扇骨部件列表,并支持点击编辑触发editBoneModal。
} else if (this.activeTab === 1) {
Scroll() {
Column() {
Row() {
Text('🥢').fontSize(14).margin({ right: 6 })
Text('扇骨部件').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('点击可编辑').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').padding({ top: 14, bottom: 8 })
扇骨Tab顶部是标题行——扇子骨Emoji、"扇骨部件"标题、弹性占位、"点击可编辑"提示。这种"标题+操作提示"的行布局告知用户列表项可交互,引导用户发现编辑功能。padding提供上下间距使标题行不紧贴内容。
ForEach(this.data.bones, (b: BoneItem, i: number) => {
Column() {
Row() {
Column() {
Text(b.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Text(b.desc + ' · ' + b.material).fontSize(11)
.fontColor(COLORS.textSub).margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('¥' + b.price).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.bambooDeep)
Text('编辑').fontSize(10).fontColor(COLORS.bamboo).margin({ left: 10 })
.onClick(() => {
this.boneIdx = i;
this.boneName = b.name;
this.bonePrice = b.price.toString();
this.showEditBone = true;
})
}
.width('100%')
ForEach遍历data.bones渲染8个部件卡片,注意第二个参数是索引i: number。每张卡片左侧是部件名与"描述·材质",右侧是价格与"编辑"文字按钮。点击编辑时,先将boneIdx赋值为当前索引i、boneName预填b.name、bonePrice预填b.price.toString(),然后将showEditBone置true弹出编辑弹窗。这种"点击即预填、预填即弹窗"的链路使用户能快速定位并修改目标部件,体验流畅。
Row() {
Column().layoutWeight(1)
.height(1).backgroundColor(COLORS.border).margin({ top: 10 })
Text('⿻').fontSize(10).fontColor(COLORS.gold).margin({ top: 6, left: 8, right: 8 })
Column().layoutWeight(1)
.height(1).backgroundColor(COLORS.border).margin({ top: 10 })
}
.width('100%')
}
.width('100%')
.backgroundColor(COLORS.cardBg).borderRadius(12)
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.margin({ bottom: 10 })
.border({ width: 1, color: COLORS.border })
}, (b: BoneItem) => b.name)
}
.width('100%').padding({ left: 14, right: 14, bottom: 12 })
}
.width('100%')
.layoutWeight(1)
每张部件卡片底部还有一个分隔行——两段1像素高的border色线条中间夹一个金色"⿻"符号(部件连接符),这种装饰性分隔线模拟了扇骨部件之间的穿钉连接关系,将物理结构隐喻为视觉元素。ForEach键值以b.name生成,保证编辑后列表正确diff。
八、扇面Tab内容(activeTab===2)

扇面Tab以库存进度条为特色,将stock数值映射为横条宽度并配合呼吸动画。
} else if (this.activeTab === 2) {
Scroll() {
Column() {
Row() {
Text('📜').fontSize(14).margin({ right: 6 })
Text('扇面材质').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('共 8 款').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').padding({ top: 14, bottom: 8 })
ForEach(this.data.faces, (fa: FaceItem) => {
Column() {
Row() {
Column() {
Text(fa.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Text(fa.desc).fontSize(11).fontColor(COLORS.textSub).margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('¥' + fa.price).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.bambooDeep)
}
.width('100%')
扇面Tab标题行显示"共 8 款"总数提示。每张卡片上半部分是材质名、描述与价格的标准布局。描述字段如"素白细润""金箔洒金"用简练四字概括材质特征,兼具信息量与文学性。
Row() {
Text('库存').fontSize(10).fontColor(COLORS.textHint).margin({ right: 8 })
Column()
.width(Math.min(160, fa.stock * 2)).height(6).borderRadius(3)
.backgroundColor(COLORS.bamboo)
.opacity(this.amb ? 0.5 : 1)
.animation({ duration: 1000, iterations: -1, playMode: PlayMode.Alternate })
Column().layoutWeight(1)
Text(fa.stock + ' 张').fontSize(10).fontColor(COLORS.textSub).margin({ left: 8 })
}
.width('100%').margin({ top: 8 })
}
.width('100%')
.backgroundColor(COLORS.cardBg).borderRadius(12)
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.margin({ bottom: 10 })
.border({ width: 1, color: COLORS.border })
}, (fa: FaceItem) => fa.name)
}
.width('100%').padding({ left: 14, right: 14, bottom: 12 })
}
.width('100%')
.layoutWeight(1)
卡片下半部分是库存进度条——"库存"标签后跟一个宽度为Math.min(160, fa.stock*2)、高6、圆角3的竹青色横条,横条宽度直接由库存数值决定(库存120则宽160像素上限,库存30则宽60像素)。横条opacity在amb为true时为0.5、false时为1,配合1000毫秒Alternate动画形成呼吸闪烁,使库存进度条如脉搏般律动。右侧"X张"显示具体数值。这种"数值映射宽度+呼吸动画"的组合让库存数据获得了直观且富有生命力的可视化呈现。
九、名家Tab内容(activeTab===3)
名家Tab以时代徽章与作品数为特色,并加入列表项轻浮动效。
} else if (this.activeTab === 3) {
Scroll() {
Column() {
Row() {
Text('🎨').fontSize(14).margin({ right: 6 })
Text('扇上名家').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('文脉传承').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').padding({ top: 14, bottom: 8 })
ForEach(this.data.masters, (m: MasterItem) => {
Column() {
Row() {
Column() {
Text(m.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Text(m.style).fontSize(11).fontColor(COLORS.textSub).margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text(m.era).fontSize(10).fontColor(COLORS.white)
.backgroundColor(COLORS.bamboo).borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
Text(m.works + '件').fontSize(11).fontWeight(FontWeight.Bold)
.fontColor(COLORS.bambooDeep).margin({ left: 8 })
}
.width('100%')
}
.width('100%')
.backgroundColor(COLORS.cardBg).borderRadius(12)
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.margin({ bottom: 10 })
.border({ width: 1, color: COLORS.border })
.translate({ x: this.amb ? 4 : 0 })
.animation({ duration: 1300, iterations: -1, playMode: PlayMode.Alternate })
}, (m: MasterItem) => m.name)
}
.width('100%').padding({ left: 14, right: 14, bottom: 12 })
}
.width('100%')
.layoutWeight(1)
名家卡片左侧是姓名与风格(如"山水"“花鸟人物”),右侧是时代徽章(竹青底白字圆角标签,如"明代")与作品数("X件"深竹青加粗)。时代徽章用色彩标签区分时代,是信息分类的视觉编码。卡片整体加了translate({ x: this.amb ? 4 : 0 })与1300毫秒Alternate动画,使每张名家卡片在水平方向轻微左右浮动4像素,形成列表整体的"轻浮"动效,模拟扇面在风中微微摆动的意象。这种为整个列表项添加translate动画的手法,比单元素动画更具整体律动感。
十、工艺Tab内容(activeTab===4)
工艺Tab以难度横条为核心可视化,配合rankColor色彩标签呈现工艺难度。
} else if (this.activeTab === 4) {
Scroll() {
Column() {
Row() {
Text('🔧').fontSize(14).margin({ right: 6 })
Text('制扇工艺').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('难度指数').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').padding({ top: 14, bottom: 8 })
ForEach(this.data.crafts, (c: CraftItem) => {
Column() {
Row() {
Text(c.name).fontSize(13).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textMain)
Text(c.rank).fontSize(10).fontColor(COLORS.white)
.backgroundColor(rankColor(c.rank)).borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 }).margin({ left: 8 })
Column().layoutWeight(1)
Text(c.diff + '').fontSize(10).fontColor(COLORS.textSub)
}
.width('100%')
Text(c.desc).fontSize(10).fontColor(COLORS.textHint).width('100%').margin({ top: 4 })
工艺卡片首行是工艺名、rank等级标签(背景色由rankColor函数返回——高难竹青、中等橙、基础灰)、弹性占位、难度数值。第二行是工艺描述灰色小字。rankColor函数的调用使等级标签的颜色随等级变化,用户能通过颜色快速识别工艺难度层级。
Row() {
Column()
.width(craftBarW(c.diff)).height(8).borderRadius(4)
.backgroundColor(COLORS.bamboo)
.opacity(this.amb ? 0.55 : 1)
.animation({ duration: 1000, iterations: -1, playMode: PlayMode.Alternate })
Column().layoutWeight(1)
Text('难度 ' + c.diff).fontSize(10).fontColor(COLORS.textSub).margin({ left: 8 })
}
.width('100%').margin({ top: 8 })
}
.width('100%')
.backgroundColor(COLORS.cardBg).borderRadius(12)
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.margin({ bottom: 10 })
.border({ width: 1, color: COLORS.border })
}, (c: CraftItem) => c.name)
}
.width('100%').padding({ left: 14, right: 14, bottom: 12 })
}
.width('100%')
.layoutWeight(1)
卡片底部是难度横条——宽度由craftBarW(c.diff)计算(难度85则宽136像素、难度40则宽64像素),高8、圆角4、竹青色。横条opacity在amb为true时0.55、false时1,配合1000毫秒Alternate动画呼吸闪烁。右侧"难度 X"显示具体数值。这里横条宽度与标签颜色双重编码了难度信息——宽度量化、颜色分级,用户无论偏好数值还是色彩都能快速获取难度认知,是数据可视化的优秀实践。
十一、题字Tab内容(activeTab===5)
题字Tab以诗书画印的文化意象呈现,结构最为简洁但文学性最强。
} else if (this.activeTab === 5) {
Scroll() {
Column() {
Row() {
Text('✍️').fontSize(14).margin({ right: 6 })
Text('扇面题字').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('诗书画印').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').padding({ top: 14, bottom: 8 })
ForEach(this.data.inscriptions, (ins: InscriptionItem) => {
Column() {
Text(ins.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Row() {
Text(ins.author).fontSize(11).fontColor(COLORS.ink).margin({ top: 6 })
Column().layoutWeight(1)
Text(ins.seal).fontSize(10).fontColor(COLORS.gold)
.margin({ top: 6 })
}
.width('100%')
}
.width('100%')
.backgroundColor(COLORS.cardBg).borderRadius(12)
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.margin({ bottom: 10 })
.border({ width: 1, color: COLORS.border })
}, (ins: InscriptionItem) => ins.name)
}
.width('100%').padding({ left: 14, right: 14, bottom: 12 })
}
.width('100%')
.layoutWeight(1)
题字卡片结构极简——顶部是题字内容(加粗主色),下方一行左侧是作者(墨色)、右侧是印章字号(金色)。印章字号用金色呈现,模拟印章的朱金意象。整个题字Tab没有动画装饰,以静态的书法式排版凸显文字本身的文化韵味,与其他Tab的动效形成动静对比,体现了"内容驱动设计"的理念——当内容本身具有足够表现力时,无需过多视觉干扰。
十二、藏家Tab内容(activeTab===6)
藏家Tab展示藏家名录,并支持点击移除触发removeCollectorModal。
} else if (this.activeTab === 6) {
Scroll() {
Column() {
Row() {
Text('🧓').fontSize(14).margin({ right: 6 })
Text('藏家雅集').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('点击可移除').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').padding({ top: 14, bottom: 8 })
ForEach(this.data.collectors, (co: CollectorItem, i: number) => {
Column() {
Row() {
Column() {
Text(co.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Text('镇宅:' + co.treasure).fontSize(11)
.fontColor(COLORS.textSub).margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text(co.count + '把').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.bambooDeep)
Text('珍藏').fontSize(10).fontColor(COLORS.textHint).margin({ top: 2 })
}
Text('移除').fontSize(10).fontColor(COLORS.danger).margin({ left: 12 })
.onClick(() => {
this.collectorIdx = i;
this.showRemoveCollector = true;
})
}
.width('100%')
}
.width('100%')
.backgroundColor(COLORS.cardBg).borderRadius(12)
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.margin({ bottom: 10 })
.border({ width: 1, color: COLORS.border })
}, (co: CollectorItem) => co.name)
}
.width('100%').padding({ left: 14, right: 14, bottom: 12 })
}
.width('100%')
.layoutWeight(1)
藏家卡片左侧是藏家名与"镇宅:XXX"镇宅之宝描述,中间是"X把珍藏"的数量列,右侧是"移除"文字按钮(danger红色)。点击移除时先记录collectorIdx为当前索引i,再置showRemoveCollector为true弹出确认弹窗。移除按钮用红色明确标识其破坏性,配合确认弹窗的双重防护,避免了误操作导致的数据丢失。ForEach键值以co.name生成,移除后数组收缩,列表自动更新。
十三、订单Tab内容(activeTab===7)

订单Tab是数据可视化最丰富的Tab,顶部有近六单金额柱状图,下方是订单台账列表与新增入口。
} else {
Scroll() {
Column() {
Row() {
Text('📦').fontSize(14).margin({ right: 6 })
Text('订单台账').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('近六单金额').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').padding({ top: 14, bottom: 8 })
Row() {
ForEach(TOP_FAN_IDX, (i: number) => {
Column() {
Text(this.data.orders[i].piece.length > 2
? this.data.orders[i].piece.substring(0, 2) : this.data.orders[i].piece)
.fontSize(9).fontColor(COLORS.textHint)
Column()
.width(26).height(countBarH(this.data.orders[i].amount / 1000)).borderRadius(4)
.backgroundColor(i % 2 === 0 ? COLORS.bambooDeep : COLORS.bamboo)
.margin({ top: 6 })
.opacity(this.amb ? 0.55 : 1)
.animation({ duration: 900 + i * 150, iterations: -1, playMode: PlayMode.Alternate })
Text((this.data.orders[i].amount / 1000).toFixed(0) + 'k')
.fontSize(9).fontColor(COLORS.textSub).margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (i: number) => i.toString())
}
.width('100%')
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.cardBg).borderRadius(12)
.margin({ top: 4, bottom: 12 })
.alignItems(VerticalAlign.Bottom)
.border({ width: 1, color: COLORS.border })
订单Tab顶部是柱状图Row,遍历TOP_FAN_IDX(0-5)取前六单数据。每根柱子是一个Column——顶部是扇品名前两字(piece.length>2则substring截取)、中间是柱体(宽26、高countBarH(amount/1000)即金额千元数乘2上限110)、底部是"Xk"金额标签。柱体背景色按奇偶交替深竹青与竹青,opacity随amb呼吸,animation时长900+i*150毫秒递增形成错落脉动。整行alignItems为Bottom使六根柱子底部对齐,模拟柱状图的基线效果。这是应用中最复杂的可视化组件,将金额数据通过高度映射为柱状图,配合色彩交替与错落动画形成生动的数据展示。
Row() {
Text('新增订单').fontSize(12).fontColor(COLORS.white)
.backgroundColor(COLORS.bambooDeep).borderRadius(16)
.padding({ left: 22, right: 22, top: 9, bottom: 9 })
.onClick(() => {
this.formNo = '';
this.formBuyer = '';
this.formPiece = '';
this.formAmount = '';
this.showAddOrder = true;
})
Column().layoutWeight(1)
Text('共 ' + this.data.orders.length + ' 笔').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').margin({ bottom: 12 })
柱状图下方是操作行——"新增订单"按钮(深竹青底白字圆角)与右侧"共X笔"总数提示。点击新增按钮先清空四个表单状态(formNo/formBuyer/formPiece/formAmount均置空),再置showAddOrder为true弹出新增订单弹窗。清空表单是必要的,避免上一次填写的内容残留到本次新增。总数提示动态显示data.orders.length,新增订单后该数字自动更新,体现了数据驱动的实时性。
ForEach(this.data.orders, (o: OrderItem) => {
Column() {
Row() {
Column() {
Text(o.piece).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Text(o.no + ' · ' + o.buyer).fontSize(10)
.fontColor(COLORS.textHint).margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text(o.status).fontSize(10).fontColor(COLORS.white)
.backgroundColor(statusColor(o.status)).borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
Text('¥' + o.amount).fontSize(13).fontWeight(FontWeight.Bold)
.fontColor(COLORS.bambooDeep).margin({ left: 8 })
}
.width('100%')
}
.width('100%')
.backgroundColor(COLORS.cardBg).borderRadius(12)
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.margin({ bottom: 10 })
.border({ width: 1, color: COLORS.border })
}, (o: OrderItem) => o.no)
}
.width('100%').padding({ left: 14, right: 14, bottom: 12 })
}
.width('100%')
.layoutWeight(1)
}
}
订单列表ForEach遍历data.orders渲染每笔订单。卡片左侧是扇品名与"订单号·买家"描述,右侧是状态标签(背景色由statusColor函数返回)与金额。状态标签的色彩编码使用户能通过颜色快速识别订单进度——绿色已交付、紫色定制中、橙色裱面中、竹青色制骨/题字/折裥中。由于addOrder用unshift插入头部,新增订单会立即出现在列表顶部,配合柱状图的实时更新,形成完整的数据闭环。ForEach键值以o.no订单号生成,保证新增后列表正确更新。
十四、底部导航栏构建器bottomBar
底部导航栏将八个Tab分为双行排列,复用bottomItem构建器实现统一渲染。
@Builder
bottomBar() {
Column() {
Row() {
ForEach(ROW1_IDX, (i: number) => {
this.bottomItem(i)
}, (i: number) => i.toString())
}
.width('100%')
Row() {
ForEach(ROW2_IDX, (i: number) => {
this.bottomItem(i)
}, (i: number) => i.toString())
}
.width('100%')
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius({ topLeft: 20, topRight: 20 })
.border({ width: 1, color: COLORS.border })
}
bottomBar以Column为容器,内含两个Row——上行ForEach遍历ROW1_IDX(0-3)渲染前四个Tab项,下行遍历ROW2_IDX(4-7)渲染后四个Tab项。每个Tab项通过this.bottomItem(i)调用构建器,传入索引生成对应图标、标签与指示条。这种"构建器复用+索引分组"的模式使八个导航项的代码极为精简,且行为完全一致。整个导航栏白底、顶部两角20像素圆角、带顶部边框,与上方内容区形成视觉分隔,是移动端底部导航的标准视觉规范。
十五、build主装配与模态层叠

build方法是组件的渲染入口,将头部、内容区、底部导航垂直排列,并通过Stack叠层管理三个模态弹窗的显隐。
build() {
Stack() {
Column() {
this.headerArea()
this.contentArea()
this.bottomBar()
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
build以Stack为根容器,第一层是一个Column,垂直排列headerArea(头部扇面)、contentArea(内容区)、bottomBar(底部导航)三大区域。Column宽高100%、背景米白,构成应用的主框架。Stack的叠层特性使模态弹窗能覆盖在主框架之上。
if (this.showAddOrder) {
Stack() {
this.addOrderModal()
this.modalOverlay(() => { this.showAddOrder = false })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
if (this.showEditBone) {
Stack() {
this.editBoneModal()
this.modalOverlay(() => { this.showEditBone = false })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
if (this.showRemoveCollector) {
Stack() {
this.removeCollectorModal()
this.modalOverlay(() => { this.showRemoveCollector = false })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
}
.width('100%')
.height('100%')
}
}
三个模态弹窗通过if条件渲染——当对应的show*状态为true时,对应的Stack(内含弹窗内容+遮罩)才会进入渲染树。每个模态Stack宽高100%、position绝对定位在0/0、zIndex为999,确保覆盖在主框架之上且层级最高。注意弹窗内容(addOrderModal等)在前、遮罩(modalOverlay)在后,由于Stack后渲染的元素层级更高,遮罩会覆盖在弹窗之上——但实际行为是点击遮罩区域触发onClose,点击弹窗内容则不触发(因为弹窗内容拦截了点击),这种顺序保证了"点击弹窗外关闭、点击弹窗内不关闭"的标准模态交互。zIndex 999确保模态始终在最顶层,不受其他元素干扰。
十六、数据流与状态管理总览

整个应用的数据流遵循单向数据流原则——状态驱动视图,用户操作修改状态,状态变更触发视图自动更新。
从流程图可以看出,状态层是单向数据流的源头:data数据仓驱动内容区渲染、activeTab决定渲染分支、amb驱动全局动画、show控制弹窗显隐、form绑定表单输入。用户操作通过onClick/onChange反向修改状态,状态变更后框架自动diff并更新依赖视图,形成闭环。这种单向数据流使应用状态可预测、可追踪、可调试,是ArkTS声明式范式的核心优势。
十七、Tab导航元数据与索引常量
应用采用八Tab导航结构,每个Tab的标签与图标通过元数据数组统一定义,配合索引常量实现底部双行导航的分组渲染。
interface TabMeta {
label: string;
icon: string;
}
const TAB_LIST: TabMeta[] = [
{ label: '折扇', icon: '🪭' },
{ label: '扇骨', icon: '🥢' },
{ label: '扇面', icon: '📜' },
{ label: '名家', icon: '🎨' },
{ label: '工艺', icon: '🔧' },
{ label: '题字', icon: '✍️' },
{ label: '藏家', icon: '🧓' },
{ label: '订单', icon: '📦' }
];
TabMeta接口定义了label(标签文字)与icon(Emoji图标)两个字段,TAB_LIST数组按业务逻辑顺序排列了八个Tab。使用Emoji作为图标无需引入图片资源,既减小了应用体积,又能在不同设备上保持一致的显示效果。将Tab元数据抽离为常量数组而非散落在各组件内,是数据驱动设计的体现——当需要增删Tab时只需修改这一处,所有依赖TAB_LIST的导航项、内容区分支都会自动同步,降低了维护成本与出错概率。
const ROW1_IDX: number[] = [0, 1, 2, 3];
const ROW2_IDX: number[] = [4, 5, 6, 7];
const RIB_IDX: number[] = [0, 1, 2, 3, 4, 5];
const TOP_FAN_IDX: number[] = [0, 1, 2, 3, 4, 5];
这四个索引常量分别服务于不同的渲染场景。ROW1_IDX与ROW2_IDX将八个Tab平分为上下两行,用于底部双行导航的ForEach循环;RIB_IDX定义了头部扇骨辐线的六根索引,配合rotate动画形成扇面开合效果;TOP_FAN_IDX则用于订单Tab顶部柱状图取前六单数据。将这些"魔术数字"提取为具名常量,使代码意图清晰可读,也便于后续调整——例如若要把扇骨辐线增加到八根,只需修改RIB_IDX一处。
十八、@Observed可观察数据模型
折扇坊的核心业务数据由八个@Observed类承载,每个类对应一个业务实体,字段语义清晰且都通过constructor完成初始化。
@Observed
export class FanItem {
name: string = '';
bone: string = '';
face: string = '';
price: number = 0;
constructor(name: string, bone: string, face: string, price: number) {
this.name = name;
this.bone = bone;
this.face = face;
this.price = price;
}
}
FanItem是折扇实体类,包含扇名(name)、扇骨材质(bone)、扇面材质(face)、价格(price)四个字段。@Observed装饰器使该类成为可观察类——当其实例被状态变量引用时,任何对这四个属性的赋值都会被框架捕获并触发依赖UI的刷新。每个字段都给了默认值(空字符串或0),这是ArkTS的良好实践,能避免对象在未完全初始化时被访问导致的undefined问题。constructor以位置参数接收四个值并赋给this,使得new FanItem(…)的调用简洁直观。
@Observed
export class BoneItem {
name: string = '';
desc: string = '';
material: string = '';
price: number = 0;
constructor(name: string, desc: string, material: string, price: number) {
this.name = name;
this.desc = desc;
this.material = material;
this.price = price;
}
}
BoneItem是扇骨部件实体类,相较于FanItem多了描述(desc)与材质(material)两个字段,用于在扇骨Tab中展示部件的详细信息。desc字段如"扇骨外两片主骨"用于解释部件作用,material字段如"湘妃竹"标识具体材质。由于扇骨部件支持编辑操作(后续editBoneModal会修改name与price),这两个字段的可观察性保证了编辑后列表能即时刷新。
@Observed
export class FaceItem {
name: string = '';
desc: string = '';
price: number = 0;
stock: number = 0;
constructor(name: string, desc: string, price: number, stock: number) {
this.name = name;
this.desc = desc;
this.price = price;
this.stock = stock;
}
}
FaceItem是扇面材质实体类,其特色在于拥有stock(库存)字段,这一数值会在扇面Tab中被映射为进度条的宽度——库存越多进度条越长,配合呼吸动画形成动态的库存可视化。price与stock共同构成了扇面的商业属性,使该实体既展示材质信息又反映库存状态,体现了数据模型对业务场景的精准建模。
@Observed
export class MasterItem {
name: string = '';
style: string = '';
era: string = '';
works: number = 0;
constructor(name: string, style: string, era: string, works: number) {
this.name = name;
this.style = style;
this.era = works;
}
}
MasterItem是名家实体类,记录扇上名家的姓名(name)、风格(style)、时代(era)与存世作品数(works)。era字段(如"明代"“清代”“近现代”)在UI中被渲染为带竹青背景的徽章标签,works字段以"件"为单位展示创作丰度。这些字段共同构建了扇艺文脉的传承谱系,让应用不只是商品台账,更承载了文化属性。
@Observed
export class CraftItem {
name: string = '';
desc: string = '';
diff: number = 0;
rank: string = '';
constructor(name: string, desc: string, diff: number, rank: string) {
this.name = name;
this.desc = desc;
this.diff = diff;
this.rank = rank;
}
}
CraftItem是制扇工艺实体类,diff字段(难度数值,如40/55/80)与rank字段(难度等级,如"基础"“中等”“高难”)是两个关键属性。diff会被传入craftBarW工具函数计算横条宽度,rank会被传入rankColor工具函数返回对应颜色——这种"数据驱动样式"的设计让工艺难度既以数值横条量化呈现,又以色彩标签语义分级,双重表达使信息更易被用户快速感知。
@Observed
export class InscriptionItem {
name: string = '';
author: string = '';
seal: string = '';
constructor(name: string, author: string, seal: string) {
this.name = name;
this.author = author;
this.seal = seal;
}
}
InscriptionItem是题字实体类,记录题字内容(name)、作者(author)、印章(seal)三项。seal字段如"六如居士"“板桥”"白石"是名家的字号或别号,在UI中以金色呈现,模拟印章的视觉意象。这类实体字数少但文化意涵深厚,是折扇"诗书画印"四艺中"印"的数字化体现。
@Observed
export class CollectorItem {
name: string = '';
count: number = 0;
treasure: string = '';
constructor(name: number, count: number, treasure: string) {
this.name = name;
this.count = count;
this.treasure = treasure;
}
}
CollectorItem是藏家实体类,count字段(珍藏数量)是核心数据,它既以"把"为单位在列表中展示,又会在订单Tab的柱状图中作为高度参考被间接使用。treasure字段记录镇宅之宝,让藏家条目更具故事性。该实体支持移除操作(removeCollector),count与treasure的可观察性保证移除后列表立即收缩更新。
@Observed
export class OrderItem {
no: string = '';
buyer: string = '';
piece: string = '';
amount: number = 0;
status: string = '';
constructor(no: string, buyer: string, piece: string, amount: number, status: string) {
this.no = no;
this.buyer = buyer;
this.piece = piece;
this.amount = amount;
this.status = status;
}
}
OrderItem是订单实体类,包含订单号(no)、买家(buyer)、扇品(piece)、金额(amount)、状态(status)五个字段。status字段是关键,它会被statusColor工具函数映射为不同颜色——"已交付"为success绿、"定制中"为紫色、"裱面中"为warning橙、其他为bamboo竹青。amount字段则在订单Tab顶部柱状图中通过countBarH映射为柱高,以"k"为单位展示近六单成交金额。
十九、中央数据仓FanShopData
八个实体类分散管理会导致状态难以统一追踪,因此应用设计了一个FanShopData中央数据仓,将所有业务数组聚合,并封装增删改方法。
@Observed
export class FanShopData {
fans: FanItem[] = [
new FanItem('湘妃竹扇', '湘妃竹', '泥金面', 12800),
new FanItem('玉竹扇', '玉竹', '宣纸面', 3200),
new FanItem('棕竹扇', '棕竹', '洒金面', 4800),
new FanItem('梅鹿竹扇', '梅鹿竹', '绢面', 9800),
new FanItem('紫檀扇', '紫檀木', '蜡笺面', 26800),
new FanItem('黄花梨扇', '黄花梨', '泥金面', 32800),
new FanItem('乌木扇', '乌木', '瓷青面', 18800),
new FanItem('鸡翅木扇', '鸡翅木', '洒金面', 15800),
new FanItem('斑竹扇', '斑竹', '宣纸面', 6800),
new FanItem('油竹扇', '油竹', '笺纸面', 2800),
new FanItem('罗汉竹扇', '罗汉竹', '粉笺面', 5600),
new FanItem('芝麻竹扇', '芝麻竹', '泥金面', 8800)
];
FanShopData本身也被@Observed标记,这意味着它内部的数组引用变化以及其引用的@Observed子对象属性变化,都能被框架追踪。fans数组预置了12款折扇,涵盖湘妃竹、玉竹、棕竹、梅鹿竹等名贵竹种以及紫檀、黄花梨、乌木等硬木,价格从2800元到32800元跨度极大,体现了折扇品类的丰富层次。数组元素直接以new FanItem(…)构造,数据与逻辑同处一仓,便于初始化与维护。
bones: BoneItem[] = [
new BoneItem('大骨', '扇骨外两片主骨', '湘妃竹', 1800),
new BoneItem('小骨', '扇骨内细骨', '玉竹', 600),
new BoneItem('扇钉', '枢轴铆钉', '牛角', 800),
new BoneItem('排口', '大骨顶端尺寸', '竹', 200),
new BoneItem('头型', '扇头造型', '竹木', 400),
new BoneItem('扇肩', '骨身曲线肩部', '竹', 300),
new BoneItem('扇芯', '内芯夹层', '竹', 500),
new BoneItem('贴梁', '小骨贴片', '竹木', 700)
];
bones数组预置了8类扇骨部件,从大骨、小骨到扇钉、排口、头型、扇肩、扇芯、贴梁,完整覆盖了一把折扇的结构组成。每个部件都有描述与材质,价格从200元到1800元,这些数据为扇骨Tab的列表渲染与编辑功能提供了完整的数据基础。
faces: FaceItem[] = [
new FaceItem('宣纸面', '素白细润', 320, 120),
new FaceItem('泥金面', '金箔洒金', 980, 40),
new FaceItem('洒金面', '片金点缀', 680, 55),
new FaceItem('绢面', '丝绢细密', 880, 35),
new FaceItem('瓷青面', '青蓝古雅', 760, 30),
new FaceItem('蜡笺面', '砑光蜡笺', 580, 60),
new FaceItem('笺纸面', '印花诗笺', 420, 80),
new FaceItem('粉笺面', '粉彩底笺', 520, 70)
];
faces数组预置了8种扇面材质,库存(stock)从30到120不等,这些库存数值会通过Math.min(160, fa.stock * 2)映射为进度条宽度,使扇面Tab呈现出一排长度不一的库存进度条,配合呼吸动画形成生动的库存可视化效果。
masters: MasterItem[] = [
new MasterItem('沈石田', '山水', '明代', 86),
new MasterItem('唐伯虎', '花鸟人物', '明代', 120),
new MasterItem('文徵明', '行书小楷', '明代', 150),
new MasterItem('仇英', '工笔仕女', '明代', 98),
new MasterItem('董其昌', '山水行草', '明代', 130),
new MasterItem('郑板桥', '兰竹', '清代', 76),
new MasterItem('金农', '漆书梅影', '清代', 64),
new MasterItem('齐白石', '虫草虾趣', '近现代', 210)
];
masters数组收录了八位扇上名家,跨越明代、清代、近现代三个时代,works字段从64到210件不等,呈现了不同名家的创作丰度。这些数据让名家Tab不仅是名录展示,更是一幅扇艺文脉的传承图谱。
crafts: CraftItem[] = [
new CraftItem('刮竹', '竹材刮青', 40, '基础'),
new CraftItem('煮料', '沸煮防裂', 55, '中等'),
new CraftItem('打磨', '骨身磨光', 60, '中等'),
new CraftItem('钻眼', '定位钻孔', 50, '基础'),
new CraftItem('串骨', '穿钉成扇', 80, '高难'),
new CraftItem('裱面', '扇面裱糊', 85, '高难'),
new CraftItem('折裥', '折纹定型', 75, '高难'),
new CraftItem('封边', '包边收口', 65, '中等')
];
crafts数组预置了8道制扇工艺,diff数值从40到85,rank涵盖基础、中等、高难三级。这些数据驱动了工艺Tab中难度横条的宽度与颜色,让制扇工艺的复杂度以可视化的方式直观呈现。
inscriptions: InscriptionItem[] = [
new InscriptionItem('扇面绘松风', '唐伯虎', '六如居士'),
new InscriptionItem('山色空蒙雨亦奇', '董其昌', '玄宰'),
new InscriptionItem('兰有香兮', '郑板桥', '板桥'),
new InscriptionItem('虾戏清波', '齐白石', '白石'),
new InscriptionItem('荷风送香气', '文徵明', '衡山'),
new InscriptionItem('江天暮雪', '沈石田', '石田'),
new InscriptionItem('花开富贵', '金农', '冬心'),
new InscriptionItem('仕女倚栏图', '仇英', '实父')
];
inscriptions数组收录了八条扇面题字,每条都关联一位名家与其印章字号。题字内容多取自古诗句或意境,如"山色空蒙雨亦奇"化用苏轼诗意,"荷风送香气"出自孟浩然诗句,这些文化元素使题字Tab充满诗书雅趣。
collectors: CollectorItem[] = [
new CollectorItem('张怀扇', 36, '唐寅山水扇'),
new CollectorItem('李藏扇', 52, '齐白石虾扇'),
new CollectorItem('王扇痴', 28, '湘妃竹泥金扇'),
new CollectorItem('赵清玩', 41, '郑板桥兰竹扇'),
new CollectorItem('钱雅集', 33, '文徵明行书扇'),
new CollectorItem('孙扇庐', 24, '金农漆书扇'),
new CollectorItem('周扇缘', 46, '董其昌行草扇'),
new CollectorItem('吴扇斋', 19, '仇英仕女扇')
];
collectors数组预置了八位藏家,count珍藏数从19到52把,treasure镇宅之宝关联到前述名家作品。藏家数据支持移除操作,移除后数组收缩,体现了数据仓的动态可变性。
orders: OrderItem[] = [
new OrderItem('P20260801', '苏先生', '湘妃竹扇', 12800, '制骨中'),
new OrderItem('P20260803', '林女士', '紫檀扇', 26800, '裱面中'),
new OrderItem('P20260805', '许老师', '玉竹扇', 3200, '已交付'),
new OrderItem('P20260808', '蔡先生', '黄花梨扇', 32800, '制骨中'),
new OrderItem('P20260810', '郑女士', '斑竹扇', 6800, '题字中'),
new OrderItem('P20260812', '马先生', '乌木扇', 18800, '裱面中'),
new OrderItem('P20260814', '黄先生', '罗汉竹扇', 5600, '定制中'),
new OrderItem('P20260816', '吴女士', '梅鹿竹扇', 9800, '折裥中'),
new OrderItem('P20260818', '邓先生', '油竹扇', 2800, '已交付'),
new OrderItem('P20260820', '梁先生', '芝麻竹扇', 8800, '制骨中')
];
orders数组预置了十笔订单,状态涵盖制骨中、裱面中、已交付、题字中、定制中、折裥中等制扇全流程节点。前六单(TOP_FAN_IDX)的amount会通过countBarH映射为订单Tab顶部柱状图的高度,形成近六单成交金额的可视化对比。
addOrder(no: string, buyer: string, piece: string, amount: number): void {
this.orders.unshift(new OrderItem(no, buyer, piece, amount, '定制中'));
}
editBone(idx: number, name: string, price: number): void {
this.bones[idx].name = name;
this.bones[idx].price = price;
}
removeCollector(idx: number): void {
this.collectors.splice(idx, 1);
}
}
数据仓封装了三个业务方法:addOrder用unshift将新订单插入数组头部,使最新订单显示在列表顶部;editBone通过索引直接修改bones数组中某项的name与price,由于BoneItem是@Observed类,属性变更会自动触发扇骨Tab对应卡片的重新渲染;removeCollector用splice删除指定索引的藏家,数组收缩后藏家Tab的ForEach会自动减少一项。这三个方法体现了数据仓作为"单一数据源"的设计——所有状态变更都收敛到数据仓内部,UI层只负责调用与展示,职责清晰。
二十、工具函数与样式映射
数据到样式的映射通过四个工具函数完成,它们将业务数值转化为视觉属性,实现了数据与表现的解耦。
function craftBarW(d: number): number {
return Math.min(160, d * 1.6);
}
function countBarH(v: number): number {
return Math.min(110, v * 2);
}
craftBarW将工艺难度数值(diff,范围40-85)映射为横条宽度,乘以1.6后用Math.min限制最大160像素,防止数值过大撑破布局。countBarH将订单金额(以千元为单位传入)映射为柱状图高度,乘以2后限制最大110像素。这两个函数都用Math.min做了上限钳制,是防御性编程的体现——即便数据异常偏大,UI也不会溢出。这种"数值到尺寸"的映射是数据可视化的常见手法,让抽象数值获得了直观的视觉表达。
function rankColor(r: string): string {
if (r === '高难') {
return COLORS.bamboo;
}
if (r === '中等') {
return COLORS.warning;
}
return COLORS.textSub;
}
function statusColor(s: string): string {
if (s === '已交付') {
return COLORS.success;
}
if (s === '定制中') {
return '#8E6FB0';
}
if (s === '裱面中') {
return COLORS.warning;
}
return COLORS.bamboo;
}
rankColor将工艺等级映射为颜色:高难为竹青色、中等为warning橙、基础为textSub灰。statusColor将订单状态映射为颜色:已交付为success绿、定制中为紫色#8E6FB0(自定义色未纳入COLORS体系)、裱面中为warning橙、其他(制骨中/题字中/折裥中等)为bamboo竹青。这两个函数通过条件分支实现了"语义到色彩"的映射,让用户能通过颜色快速识别状态类别,这是信息可视化的色彩编码实践。注意statusColor中定制中的紫色是内联硬编码,这是一个可优化点——理想情况下应纳入COLORS体系统一管理。
二十一、主组件FanShopApp的状态声明
主组件FanShopApp承载了应用的全部状态与构建逻辑,其状态变量声明是理解整个应用行为的关键。
@Entry
@Component
struct FanShopApp {
@State data: FanShopData = new FanShopData();
@State activeTab: number = 0;
@State amb: boolean = false;
@State showAddOrder: boolean = false;
@State showEditBone: boolean = false;
@State showRemoveCollector: boolean = false;
@Entry装饰器标记FanShopApp为应用入口组件,@Component声明它为一个自定义组件。@State装饰的状态变量是驱动UI更新的核心:data持有FanShopData数据仓实例,其内部@Observed数组的变更会触发依赖UI刷新;activeTab记录当前激活的Tab索引(0-7),决定了contentArea渲染哪个分支;amb是一个全局动画开关布尔值,在aboutToAppear中被置为true后,所有依赖this.amb的animation属性会开始播放,形成应用启动时的"扇面展开"动效。三个show*布尔变量分别控制三个模态弹窗的显隐,这是声明式UI实现模态的典型方式——通过状态切换决定组件是否进入渲染树。
@State formNo: string = '';
@State formBuyer: string = '';
@State formPiece: string = '';
@State formAmount: string = '';
@State boneIdx: number = 0;
@State boneName: string = '';
@State bonePrice: string = '';
@State collectorIdx: number = 0;
这一组状态变量服务于表单交互。formNo/formBuyer/formPiece/formAmount四个字符串变量绑定到新增订单弹窗的TextInput,用户输入时通过onChange回写,形成双向数据流。boneIdx/boneName/bonePrice服务于编辑扇骨弹窗——boneIdx记录被编辑扇骨的索引,boneName与bonePrice预填当前值并接收修改。collectorIdx记录待移除藏家的索引,供移除确认弹窗读取。注意金额与价格在表单中均以字符串存储,提交时再用Number()转换,这是因为TextInput的onChange回调值类型为string,这种"输入即字符串、提交即转换"的模式简化了表单状态管理。
aboutToAppear(): void {
setTimeout(() => {
this.amb = true;
}, 100);
}
aboutToAppear是组件的生命周期回调,在组件创建后、build执行前被调用。这里用setTimeout延迟100毫秒后将amb置为true,制造了一个"先渲染静止状态、再触发动画"的时序——初始amb为false时扇骨辐线静止、扇坠高亮,100毫秒后amb变true,animation属性感知到状态变化开始播放,扇骨辐线上下浮动、扇坠opacity交替闪烁,形成扇面徐徐展开的开场动效。这种利用生命周期+延时状态切换触发入场动画的技巧,是ArkTS动画控制的常见模式。
二十二、底部导航项构建器bottomItem
底部导航的每一项通过@Builder构建器复用,实现了"一次定义、多处调用"的组件化复用。
@Builder
bottomItem(i: number) {
Column() {
Text(TAB_LIST[i].icon).fontSize(18)
.opacity(this.activeTab === i ? 1 : 0.55)
Text(TAB_LIST[i].label).fontSize(11)
.fontColor(this.activeTab === i ? COLORS.bambooDeep : COLORS.textSub)
.fontWeight(this.activeTab === i ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 3 })
Column()
.width(16).height(3).borderRadius(2)
.backgroundColor(this.activeTab === i ? COLORS.bamboo : '#00000000')
.margin({ top: 4 })
}
.layoutWeight(1)
.padding({ top: 8, bottom: 8 })
.onClick(() => {
this.activeTab = i;
})
}
bottomItem是一个带参数i的@Builder,根据索引渲染对应的图标、标签与指示条。激活态(activeTab===i)时图标opacity为1、标签字体加粗深竹青色、底部出现竹青色指示条;非激活态时图标半透明、标签常规灰色、指示条透明(#00000000)。这种"三元表达式驱动样式"的手法让激活与非激活两种状态在同一套构建逻辑中切换,避免了重复代码。onClick将activeTab赋值为i,由于activeTab是@State,赋值后contentArea会根据新值重新渲染对应Tab内容。layoutWeight(1)使每一项在Row中均分宽度,padding提供上下内边距保证点击区域足够大。
二十三、模态遮罩构建器modalOverlay
三个模态弹窗都需要半透明遮罩,通过一个通用的modalOverlay构建器统一生成,体现了DRY(不要重复自己)原则。
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('#55000000')
.onClick(() => {
onClose();
})
}
modalOverlay接收一个onClose回调函数作为参数,生成一个全屏半透明黑色(带55透明度)的Column。点击遮罩区域会调用onClose关闭弹窗,这是模态交互的标准模式——点击遮罩取消操作。将遮罩抽离为带回调参数的构建器,使三个弹窗(addOrderModal/editBoneModal/removeCollectorModal)都能复用同一套遮罩逻辑,只需传入各自的关闭回调(如() => { this.showAddOrder = false })。这种"构建器+回调参数"的模式,是ArkTS实现参数化复用的核心手段。
技术点对比表
| 技术维度 | 具体实现 | 作用与特点 | 适用场景 |
|---|---|---|---|
| @Observed (FanItem) | 折扇实体类(name/bone/face/price) | 扇品数据可观察,属性变更触发列表刷新 | 折扇Tab列表渲染 |
| @Observed (BoneItem) | 扇骨部件类(name/desc/material/price) | 部件数据可观察,支持editBone后即时刷新 | 扇骨Tab列表+编辑 |
| @Observed (FaceItem) | 扇面材质类(name/desc/price/stock) | stock映射进度条宽度,库存可视化 | 扇面Tab库存进度条 |
| @Observed (MasterItem) | 名家类(name/style/era/works) | era映射徽章颜色,works展示创作丰度 | 名家Tab名录 |
| @Observed (CraftItem) | 工艺类(name/desc/diff/rank) | diff映射横条宽度,rank映射标签颜色 | 工艺Tab难度可视化 |
| @Observed (InscriptionItem) | 题字类(name/author/seal) | seal金色呈现印章意象 | 题字Tab文化展示 |
| @Observed (CollectorItem) | 藏家类(name/count/treasure) | count展示珍藏数,支持splice移除 | 藏家Tab名录+移除 |
| @Observed (OrderItem) | 订单类(no/buyer/piece/amount/status) | amount映射柱状图高度,status映射标签色 | 订单Tab台账+柱状图 |
| @Observed (FanShopData) | 中央数据仓聚合八类数组 | 统一管理数据,封装addOrder/editBone/removeCollector | 全应用单一数据源 |
| @State (data) | 持有FanShopData实例 | 驱动全部内容区渲染 | 主组件状态根 |
| @State (activeTab) | 当前Tab索引(0-7) | 决定contentArea渲染分支 | Tab切换控制 |
| @State (amb) | 全局动画布尔开关 | 触发扇骨辐线/扇坠/进度条等呼吸动画 | 入场动效与持续律动 |
| @State (show*) | 三个弹窗显隐布尔 | 控制模态弹窗条件渲染 | 模态交互控制 |
| @State (form*) | 四个表单字符串 | 绑定TextInput受控输入 | 新增订单表单 |
| @State (boneIdx/Name/Price) | 编辑扇骨索引与预填值 | 记录编辑目标并预填当前值 | 编辑扇骨弹窗 |
| @State (collectorIdx) | 待移除藏家索引 | 供移除确认弹窗读取目标 | 移除藏家确认 |
| @Entry | 标记FanShopApp为入口 | 应用渲染起点 | 主组件入口声明 |
| @Component | 声明自定义组件 | 组件化封装 | 所有struct组件 |
| @Builder (bottomItem) | 带参数构建器 | 复用导航项渲染逻辑 | 底部导航八项 |
| @Builder (modalOverlay) | 带回调构建器 | 复用模态遮罩,参数化关闭逻辑 | 三个弹窗遮罩 |
| @Builder (headerArea) | 扇形头部构建器 | Stack叠层组合标题/辐线/扇坠 | 应用头部 |
| @Builder (addOrderModal) | 新增订单弹窗 | 四字段TextInput表单+提交 | 订单新增交互 |
| @Builder (editBoneModal) | 编辑扇骨弹窗 | 两字段表单+预填+保存 | 扇骨编辑交互 |
| @Builder (removeCollectorModal) | 移除藏家确认 | 确认文案+双按钮 | 破坏性操作确认 |
| @Builder (contentArea) | 八Tab内容分支 | if-else渲染八个Tab | 内容区核心 |
| @Builder (bottomBar) | 双行导航构建器 | ForEach分组渲染八项 | 底部导航 |
| aboutToAppear | 生命周期回调 | setTimeout延时置amb触发入场动画 | 应用启动动效 |
| rotate动画 | (i-2.5)*10角度 | 扇骨辐线扇形放射排列 | headerArea扇面意象 |
| translate动画 | y/x方向位移 | 辐线上浮、名家卡轻浮 | 律动效果 |
| opacity动画 | 明暗交替 | 扇坠闪烁、进度条呼吸 | 呼吸律动 |
| animation属性 | duration/iterations/playMode | 控制动画时长循环模式 | 所有动效 |
| PlayMode.Alternate | 往返交替播放 | 形成往返呼吸而非单向 | 所有循环动画 |
| craftBarW | Math.min(160,d*1.6) | 难度数值映射横条宽度 | 工艺难度可视化 |
| countBarH | Math.min(110,v*2) | 金额映射柱状图高度 | 订单金额柱状图 |
| rankColor | 条件返回颜色 | 工艺等级语义色彩编码 | 工艺标签 |
| statusColor | 条件返回颜色 | 订单状态语义色彩编码 | 订单状态标签 |
| ForEach | 键值生成器遍历 | 列表渲染与diff更新 | 所有列表渲染 |
| layoutWeight | 权重分配 | 列均分宽度、占位撑开 | 统计卡片、按钮行 |
| constraintSize | maxHeight限制 | 防止弹窗/表单溢出 | 模态弹窗与Scroll |
| zIndex(999) | 层级置顶 | 模态弹窗覆盖主框架 | 三个模态Stack |
| unshift | 数组头部插入 | 新订单置顶显示 | addOrder方法 |
| splice | 数组索引删除 | 移除藏家收缩列表 | removeCollector方法 |
完整代码
// 694.ets 折扇坊 · 扇开清凉
// 布局特色:竹青米白雅色 + 扇形扇面 header(半圆扇面 + 扇骨辐线开合 + 扇坠流苏)+ 工艺难度横条 + 藏家珍藏数柱状图
interface FanPalette {
bg: string;
cardBg: string;
bamboo: string;
bambooDeep: string;
gold: string;
ink: string;
textMain: string;
textSub: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
}
const COLORS: FanPalette = {
bg: '#F4F0E6',
cardBg: '#FFFFFF',
bamboo: '#7A8B5A',
bambooDeep: '#4E5C36',
gold: '#C9A227',
ink: '#3A3228',
textMain: '#3A3228',
textSub: '#7A7260',
textHint: '#B0A890',
border: '#E5DFCE',
success: '#5E7B4A',
warning: '#B57B2E',
danger: '#A83A2E',
white: '#FFFFFF'
};
interface TabMeta {
label: string;
icon: string;
}
const TAB_LIST: TabMeta[] = [
{ label: '折扇', icon: '🪭' },
{ label: '扇骨', icon: '🥢' },
{ label: '扇面', icon: '📜' },
{ label: '名家', icon: '🎨' },
{ label: '工艺', icon: '🔧' },
{ label: '题字', icon: '✍️' },
{ label: '藏家', icon: '🧓' },
{ label: '订单', icon: '📦' }
];
const ROW1_IDX: number[] = [0, 1, 2, 3];
const ROW2_IDX: number[] = [4, 5, 6, 7];
const RIB_IDX: number[] = [0, 1, 2, 3, 4, 5];
const TOP_FAN_IDX: number[] = [0, 1, 2, 3, 4, 5];
@Observed
export class FanItem {
name: string = '';
bone: string = '';
face: string = '';
price: number = 0;
constructor(name: string, bone: string, face: string, price: number) {
this.name = name;
this.bone = bone;
this.face = face;
this.price = price;
}
}
@Observed
export class BoneItem {
name: string = '';
desc: string = '';
material: string = '';
price: number = 0;
constructor(name: string, desc: string, material: string, price: number) {
this.name = name;
this.desc = desc;
this.material = material;
this.price = price;
}
}
@Observed
export class FaceItem {
name: string = '';
desc: string = '';
price: number = 0;
stock: number = 0;
constructor(name: string, desc: string, price: number, stock: number) {
this.name = name;
this.desc = desc;
this.price = price;
this.stock = stock;
}
}
@Observed
export class MasterItem {
name: string = '';
style: string = '';
era: string = '';
works: number = 0;
constructor(name: string, style: string, era: string, works: number) {
this.name = name;
this.style = style;
this.era = era;
this.works = works;
}
}
@Observed
export class CraftItem {
name: string = '';
desc: string = '';
diff: number = 0;
rank: string = '';
constructor(name: string, desc: string, diff: number, rank: string) {
this.name = name;
this.desc = desc;
this.diff = diff;
this.rank = rank;
}
}
@Observed
export class InscriptionItem {
name: string = '';
author: string = '';
seal: string = '';
constructor(name: string, author: string, seal: string) {
this.name = name;
this.author = author;
this.seal = seal;
}
}
@Observed
export class CollectorItem {
name: string = '';
count: number = 0;
treasure: string = '';
constructor(name: string, count: number, treasure: string) {
this.name = name;
this.count = count;
this.treasure = treasure;
}
}
@Observed
export class OrderItem {
no: string = '';
buyer: string = '';
piece: string = '';
amount: number = 0;
status: string = '';
constructor(no: string, buyer: string, piece: string, amount: number, status: string) {
this.no = no;
this.buyer = buyer;
this.piece = piece;
this.amount = amount;
this.status = status;
}
}
@Observed
export class FanShopData {
fans: FanItem[] = [
new FanItem('湘妃竹扇', '湘妃竹', '泥金面', 12800),
new FanItem('玉竹扇', '玉竹', '宣纸面', 3200),
new FanItem('棕竹扇', '棕竹', '洒金面', 4800),
new FanItem('梅鹿竹扇', '梅鹿竹', '绢面', 9800),
new FanItem('紫檀扇', '紫檀木', '蜡笺面', 26800),
new FanItem('黄花梨扇', '黄花梨', '泥金面', 32800),
new FanItem('乌木扇', '乌木', '瓷青面', 18800),
new FanItem('鸡翅木扇', '鸡翅木', '洒金面', 15800),
new FanItem('斑竹扇', '斑竹', '宣纸面', 6800),
new FanItem('油竹扇', '油竹', '笺纸面', 2800),
new FanItem('罗汉竹扇', '罗汉竹', '粉笺面', 5600),
new FanItem('芝麻竹扇', '芝麻竹', '泥金面', 8800)
];
bones: BoneItem[] = [
new BoneItem('大骨', '扇骨外两片主骨', '湘妃竹', 1800),
new BoneItem('小骨', '扇骨内细骨', '玉竹', 600),
new BoneItem('扇钉', '枢轴铆钉', '牛角', 800),
new BoneItem('排口', '大骨顶端尺寸', '竹', 200),
new BoneItem('头型', '扇头造型', '竹木', 400),
new BoneItem('扇肩', '骨身曲线肩部', '竹', 300),
new BoneItem('扇芯', '内芯夹层', '竹', 500),
new BoneItem('贴梁', '小骨贴片', '竹木', 700)
];
faces: FaceItem[] = [
new FaceItem('宣纸面', '素白细润', 320, 120),
new FaceItem('泥金面', '金箔洒金', 980, 40),
new FaceItem('洒金面', '片金点缀', 680, 55),
new FaceItem('绢面', '丝绢细密', 880, 35),
new FaceItem('瓷青面', '青蓝古雅', 760, 30),
new FaceItem('蜡笺面', '砑光蜡笺', 580, 60),
new FaceItem('笺纸面', '印花诗笺', 420, 80),
new FaceItem('粉笺面', '粉彩底笺', 520, 70)
];
masters: MasterItem[] = [
new MasterItem('沈石田', '山水', '明代', 86),
new MasterItem('唐伯虎', '花鸟人物', '明代', 120),
new MasterItem('文徵明', '行书小楷', '明代', 150),
new MasterItem('仇英', '工笔仕女', '明代', 98),
new MasterItem('董其昌', '山水行草', '明代', 130),
new MasterItem('郑板桥', '兰竹', '清代', 76),
new MasterItem('金农', '漆书梅影', '清代', 64),
new MasterItem('齐白石', '虫草虾趣', '近现代', 210)
];
crafts: CraftItem[] = [
new CraftItem('刮竹', '竹材刮青', 40, '基础'),
new CraftItem('煮料', '沸煮防裂', 55, '中等'),
new CraftItem('打磨', '骨身磨光', 60, '中等'),
new CraftItem('钻眼', '定位钻孔', 50, '基础'),
new CraftItem('串骨', '穿钉成扇', 80, '高难'),
new CraftItem('裱面', '扇面裱糊', 85, '高难'),
new CraftItem('折裥', '折纹定型', 75, '高难'),
new CraftItem('封边', '包边收口', 65, '中等')
];
inscriptions: InscriptionItem[] = [
new InscriptionItem('扇面绘松风', '唐伯虎', '六如居士'),
new InscriptionItem('山色空蒙雨亦奇', '董其昌', '玄宰'),
new InscriptionItem('兰有香兮', '郑板桥', '板桥'),
new InscriptionItem('虾戏清波', '齐白石', '白石'),
new InscriptionItem('荷风送香气', '文徵明', '衡山'),
new InscriptionItem('江天暮雪', '沈石田', '石田'),
new InscriptionItem('花开富贵', '金农', '冬心'),
new InscriptionItem('仕女倚栏图', '仇英', '实父')
];
collectors: CollectorItem[] = [
new CollectorItem('张怀扇', 36, '唐寅山水扇'),
new CollectorItem('李藏扇', 52, '齐白石虾扇'),
new CollectorItem('王扇痴', 28, '湘妃竹泥金扇'),
new CollectorItem('赵清玩', 41, '郑板桥兰竹扇'),
new CollectorItem('钱雅集', 33, '文徵明行书扇'),
new CollectorItem('孙扇庐', 24, '金农漆书扇'),
new CollectorItem('周扇缘', 46, '董其昌行草扇'),
new CollectorItem('吴扇斋', 19, '仇英仕女扇')
];
orders: OrderItem[] = [
new OrderItem('P20260801', '苏先生', '湘妃竹扇', 12800, '制骨中'),
new OrderItem('P20260803', '林女士', '紫檀扇', 26800, '裱面中'),
new OrderItem('P20260805', '许老师', '玉竹扇', 3200, '已交付'),
new OrderItem('P20260808', '蔡先生', '黄花梨扇', 32800, '制骨中'),
new OrderItem('P20260810', '郑女士', '斑竹扇', 6800, '题字中'),
new OrderItem('P20260812', '马先生', '乌木扇', 18800, '裱面中'),
new OrderItem('P20260814', '黄先生', '罗汉竹扇', 5600, '定制中'),
new OrderItem('P20260816', '吴女士', '梅鹿竹扇', 9800, '折裥中'),
new OrderItem('P20260818', '邓先生', '油竹扇', 2800, '已交付'),
new OrderItem('P20260820', '梁先生', '芝麻竹扇', 8800, '制骨中')
];
addOrder(no: string, buyer: string, piece: string, amount: number): void {
this.orders.unshift(new OrderItem(no, buyer, piece, amount, '定制中'));
}
editBone(idx: number, name: string, price: number): void {
this.bones[idx].name = name;
this.bones[idx].price = price;
}
removeCollector(idx: number): void {
this.collectors.splice(idx, 1);
}
}
function craftBarW(d: number): number {
return Math.min(160, d * 1.6);
}
function countBarH(v: number): number {
return Math.min(110, v * 2);
}
function rankColor(r: string): string {
if (r === '高难') {
return COLORS.bamboo;
}
if (r === '中等') {
return COLORS.warning;
}
return COLORS.textSub;
}
function statusColor(s: string): string {
if (s === '已交付') {
return COLORS.success;
}
if (s === '定制中') {
return '#8E6FB0';
}
if (s === '裱面中') {
return COLORS.warning;
}
return COLORS.bamboo;
}
@Entry
@Component
struct FanShopApp {
@State data: FanShopData = new FanShopData();
@State activeTab: number = 0;
@State amb: boolean = false;
@State showAddOrder: boolean = false;
@State showEditBone: boolean = false;
@State showRemoveCollector: boolean = false;
@State formNo: string = '';
@State formBuyer: string = '';
@State formPiece: string = '';
@State formAmount: string = '';
@State boneIdx: number = 0;
@State boneName: string = '';
@State bonePrice: string = '';
@State collectorIdx: number = 0;
aboutToAppear(): void {
setTimeout(() => {
this.amb = true;
}, 100);
}
@Builder
bottomItem(i: number) {
Column() {
Text(TAB_LIST[i].icon).fontSize(18)
.opacity(this.activeTab === i ? 1 : 0.55)
Text(TAB_LIST[i].label).fontSize(11)
.fontColor(this.activeTab === i ? COLORS.bambooDeep : COLORS.textSub)
.fontWeight(this.activeTab === i ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 3 })
Column()
.width(16).height(3).borderRadius(2)
.backgroundColor(this.activeTab === i ? COLORS.bamboo : '#00000000')
.margin({ top: 4 })
}
.layoutWeight(1)
.padding({ top: 8, bottom: 8 })
.onClick(() => {
this.activeTab = i;
})
}
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('#55000000')
.onClick(() => {
onClose();
})
}
@Builder
headerArea() {
Stack() {
Column() {
Text('折扇坊').fontSize(22).fontColor(COLORS.bamboo)
.fontWeight(FontWeight.Bold).letterSpacing(4)
Text('扇开清凉 · 袖中风雅').fontSize(11).fontColor(COLORS.textSub)
.margin({ top: 4 }).letterSpacing(2)
}
.width('100%')
.position({ x: 0, y: 20 })
.alignItems(HorizontalAlign.Center)
Row() {
ForEach(RIB_IDX, (i: number) => {
Column()
.width(4).height(64)
.backgroundColor(i % 2 === 0 ? COLORS.bamboo : COLORS.gold)
.borderRadius(2)
.rotate({ angle: (i - 2.5) * 10 })
.translate({ y: this.amb ? -6 : 0 })
.animation({ duration: 1200 + i * 150, iterations: -1, playMode: PlayMode.Alternate })
}, (i: number) => i.toString())
}
.width('60%')
.height(70)
.justifyContent(FlexAlign.SpaceAround)
.alignItems(VerticalAlign.Bottom)
.position({ x: '20%', y: 70 })
Column()
.width(10).height(10).borderRadius(5)
.backgroundColor(COLORS.gold)
.position({ x: '50%', y: 128 })
.opacity(this.amb ? 0.35 : 0.9)
.animation({ duration: 900, iterations: -1, playMode: PlayMode.Alternate })
}
.width('100%')
.height(180)
.backgroundColor(COLORS.ink)
.borderRadius({ bottomLeft: 28, bottomRight: 28 })
}
@Builder
addOrderModal() {
Column() {
Row() {
Text('新增订单').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.textHint)
.onClick(() => { this.showAddOrder = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 8 })
Scroll() {
Column() {
Text('订单编号').fontSize(12).fontColor(COLORS.textSub).width('100%').margin({ top: 8 })
TextInput({ text: this.formNo, placeholder: '如 P20260822' })
.placeholderColor(COLORS.textHint).fontSize(14).width('100%')
.backgroundColor(COLORS.bg).borderRadius(8)
.margin({ top: 4 }).onChange((v: string) => { this.formNo = v })
Text('客户姓名').fontSize(12).fontColor(COLORS.textSub).width('100%').margin({ top: 12 })
TextInput({ text: this.formBuyer, placeholder: '客户姓名' })
.placeholderColor(COLORS.textHint).fontSize(14).width('100%')
.backgroundColor(COLORS.bg).borderRadius(8)
.margin({ top: 4 }).onChange((v: string) => { this.formBuyer = v })
Text('定制扇品').fontSize(12).fontColor(COLORS.textSub).width('100%').margin({ top: 12 })
TextInput({ text: this.formPiece, placeholder: '如 玉竹扇' })
.placeholderColor(COLORS.textHint).fontSize(14).width('100%')
.backgroundColor(COLORS.bg).borderRadius(8)
.margin({ top: 4 }).onChange((v: string) => { this.formPiece = v })
Text('定金金额').fontSize(12).fontColor(COLORS.textSub).width('100%').margin({ top: 12 })
TextInput({ text: this.formAmount, placeholder: '元' })
.placeholderColor(COLORS.textHint).fontSize(14).width('100%')
.backgroundColor(COLORS.bg).borderRadius(8)
.margin({ top: 4 }).onChange((v: string) => { this.formAmount = v })
}
.width('100%').padding({ left: 20, right: 20 })
}
.width('100%')
.constraintSize({ maxHeight: '52%' })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSub)
.backgroundColor(COLORS.border).borderRadius(20)
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.onClick(() => { this.showAddOrder = false })
Text('提交订单').fontSize(14).fontColor(COLORS.white)
.backgroundColor(COLORS.bambooDeep).borderRadius(20)
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => {
this.data.addOrder(
this.formNo === '' ? 'P20260822' : this.formNo,
this.formBuyer === '' ? '匿名客' : this.formBuyer,
this.formPiece === '' ? '玉竹扇' : this.formPiece,
this.formAmount === '' ? 3000 : Number(this.formAmount));
this.showAddOrder = false;
})
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 16, bottom: 16 })
}
.width('90%')
.constraintSize({ maxHeight: '80%' })
.backgroundColor(COLORS.cardBg).borderRadius(16)
.alignItems(HorizontalAlign.Center)
.position({ x: '5%', y: '8%' })
}
@Builder
editBoneModal() {
Column() {
Row() {
Text('编辑扇骨').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.textHint)
.onClick(() => { this.showEditBone = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 8 })
Text('部件名称').fontSize(12).fontColor(COLORS.textSub).width('100%').margin({ top: 8, left: 20 })
TextInput({ text: this.boneName, placeholder: '部件名称' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.bg).borderRadius(8)
.margin({ top: 4 }).onChange((v: string) => { this.boneName = v })
Text('单价(元)').fontSize(12).fontColor(COLORS.textSub).width('100%').margin({ top: 12, left: 20 })
TextInput({ text: this.bonePrice, placeholder: '单价' })
.placeholderColor(COLORS.textHint).fontSize(14).width('90%')
.backgroundColor(COLORS.bg).borderRadius(8)
.margin({ top: 4 }).onChange((v: string) => { this.bonePrice = v })
Row() {
Text('取消').fontSize(14).fontColor(COLORS.textSub)
.backgroundColor(COLORS.border).borderRadius(20)
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.onClick(() => { this.showEditBone = false })
Text('保存修改').fontSize(14).fontColor(COLORS.white)
.backgroundColor(COLORS.bambooDeep).borderRadius(20)
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => {
this.data.editBone(
this.boneIdx,
this.boneName === '' ? this.data.bones[this.boneIdx].name : this.boneName,
this.bonePrice === '' ? this.data.bones[this.boneIdx].price : Number(this.bonePrice));
this.showEditBone = false;
})
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 20, bottom: 16 })
}
.width('90%')
.constraintSize({ maxHeight: '80%' })
.backgroundColor(COLORS.cardBg).borderRadius(16)
.alignItems(HorizontalAlign.Center)
.position({ x: '5%', y: '8%' })
}
@Builder
removeCollectorModal() {
Column() {
Text('🪭').fontSize(34).margin({ top: 24 })
Text('移除藏家').fontSize(17).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textMain).margin({ top: 10 })
Text('确认将 ' + this.data.collectors[this.collectorIdx].name + ' 移出藏家名录?')
.fontSize(13).fontColor(COLORS.textSub).margin({ top: 10 })
.padding({ left: 28, right: 28 }).textAlign(TextAlign.Center)
Row() {
Text('再想想').fontSize(14).fontColor(COLORS.textSub)
.backgroundColor(COLORS.border).borderRadius(20)
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.onClick(() => { this.showRemoveCollector = false })
Text('确认移除').fontSize(14).fontColor(COLORS.white)
.backgroundColor(COLORS.danger).borderRadius(20)
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => {
this.data.removeCollector(this.collectorIdx);
this.showRemoveCollector = false;
})
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 22, bottom: 20 })
}
.width('86%')
.constraintSize({ maxHeight: '80%' })
.backgroundColor(COLORS.cardBg).borderRadius(16)
.alignItems(HorizontalAlign.Center)
.position({ x: '7%', y: '28%' })
}
@Builder
contentArea() {
if (this.activeTab === 0) {
Scroll() {
Column() {
Row() {
Column() {
Text('12').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.bambooDeep)
Text('坊藏折扇').fontSize(10).fontColor(COLORS.textHint).margin({ top: 2 })
}.layoutWeight(1)
Column() {
Text('湘妃竹扇').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.gold)
Text('最热款').fontSize(10).fontColor(COLORS.textHint).margin({ top: 2 })
}.layoutWeight(1)
Column() {
Text('¥13.6w').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.bambooDeep)
Text('本季成交').fontSize(10).fontColor(COLORS.textHint).margin({ top: 2 })
}.layoutWeight(1)
}
.width('100%')
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.cardBg).borderRadius(12)
.margin({ top: 12, bottom: 12 })
.border({ width: 1, color: COLORS.border })
ForEach(this.data.fans, (f: FanItem) => {
Column() {
Row() {
Column() {
Text(f.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Text(f.bone + ' · ' + f.face).fontSize(11)
.fontColor(COLORS.textSub).margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('¥' + f.price).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.bambooDeep)
Column()
.width(4).height(34).borderRadius(2)
.backgroundColor(COLORS.bamboo).margin({ left: 10 })
.opacity(this.amb ? 0.4 : 0.9)
.animation({ duration: 1100, iterations: -1, playMode: PlayMode.Alternate })
}
.width('100%')
}
.width('100%')
.backgroundColor(COLORS.cardBg).borderRadius(12)
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.margin({ bottom: 10 })
.border({ width: 1, color: COLORS.border })
}, (f: FanItem) => f.name)
}
.width('100%').padding({ left: 14, right: 14, bottom: 12 })
}
.width('100%')
.layoutWeight(1)
} else if (this.activeTab === 1) {
Scroll() {
Column() {
Row() {
Text('🥢').fontSize(14).margin({ right: 6 })
Text('扇骨部件').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('点击可编辑').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').padding({ top: 14, bottom: 8 })
ForEach(this.data.bones, (b: BoneItem, i: number) => {
Column() {
Row() {
Column() {
Text(b.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Text(b.desc + ' · ' + b.material).fontSize(11)
.fontColor(COLORS.textSub).margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('¥' + b.price).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.bambooDeep)
Text('编辑').fontSize(10).fontColor(COLORS.bamboo).margin({ left: 10 })
.onClick(() => {
this.boneIdx = i;
this.boneName = b.name;
this.bonePrice = b.price.toString();
this.showEditBone = true;
})
}
.width('100%')
Row() {
Column().layoutWeight(1)
.height(1).backgroundColor(COLORS.border).margin({ top: 10 })
Text('⿻').fontSize(10).fontColor(COLORS.gold).margin({ top: 6, left: 8, right: 8 })
Column().layoutWeight(1)
.height(1).backgroundColor(COLORS.border).margin({ top: 10 })
}
.width('100%')
}
.width('100%')
.backgroundColor(COLORS.cardBg).borderRadius(12)
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.margin({ bottom: 10 })
.border({ width: 1, color: COLORS.border })
}, (b: BoneItem) => b.name)
}
.width('100%').padding({ left: 14, right: 14, bottom: 12 })
}
.width('100%')
.layoutWeight(1)
} else if (this.activeTab === 2) {
Scroll() {
Column() {
Row() {
Text('📜').fontSize(14).margin({ right: 6 })
Text('扇面材质').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('共 8 款').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').padding({ top: 14, bottom: 8 })
ForEach(this.data.faces, (fa: FaceItem) => {
Column() {
Row() {
Column() {
Text(fa.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Text(fa.desc).fontSize(11).fontColor(COLORS.textSub).margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text('¥' + fa.price).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.bambooDeep)
}
.width('100%')
Row() {
Text('库存').fontSize(10).fontColor(COLORS.textHint).margin({ right: 8 })
Column()
.width(Math.min(160, fa.stock * 2)).height(6).borderRadius(3)
.backgroundColor(COLORS.bamboo)
.opacity(this.amb ? 0.5 : 1)
.animation({ duration: 1000, iterations: -1, playMode: PlayMode.Alternate })
Column().layoutWeight(1)
Text(fa.stock + ' 张').fontSize(10).fontColor(COLORS.textSub).margin({ left: 8 })
}
.width('100%').margin({ top: 8 })
}
.width('100%')
.backgroundColor(COLORS.cardBg).borderRadius(12)
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.margin({ bottom: 10 })
.border({ width: 1, color: COLORS.border })
}, (fa: FaceItem) => fa.name)
}
.width('100%').padding({ left: 14, right: 14, bottom: 12 })
}
.width('100%')
.layoutWeight(1)
} else if (this.activeTab === 3) {
Scroll() {
Column() {
Row() {
Text('🎨').fontSize(14).margin({ right: 6 })
Text('扇上名家').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('文脉传承').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').padding({ top: 14, bottom: 8 })
ForEach(this.data.masters, (m: MasterItem) => {
Column() {
Row() {
Column() {
Text(m.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Text(m.style).fontSize(11).fontColor(COLORS.textSub).margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text(m.era).fontSize(10).fontColor(COLORS.white)
.backgroundColor(COLORS.bamboo).borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
Text(m.works + '件').fontSize(11).fontWeight(FontWeight.Bold)
.fontColor(COLORS.bambooDeep).margin({ left: 8 })
}
.width('100%')
}
.width('100%')
.backgroundColor(COLORS.cardBg).borderRadius(12)
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.margin({ bottom: 10 })
.border({ width: 1, color: COLORS.border })
.translate({ x: this.amb ? 4 : 0 })
.animation({ duration: 1300, iterations: -1, playMode: PlayMode.Alternate })
}, (m: MasterItem) => m.name)
}
.width('100%').padding({ left: 14, right: 14, bottom: 12 })
}
.width('100%')
.layoutWeight(1)
} else if (this.activeTab === 4) {
Scroll() {
Column() {
Row() {
Text('🔧').fontSize(14).margin({ right: 6 })
Text('制扇工艺').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('难度指数').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').padding({ top: 14, bottom: 8 })
ForEach(this.data.crafts, (c: CraftItem) => {
Column() {
Row() {
Text(c.name).fontSize(13).fontWeight(FontWeight.Bold)
.fontColor(COLORS.textMain)
Text(c.rank).fontSize(10).fontColor(COLORS.white)
.backgroundColor(rankColor(c.rank)).borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 }).margin({ left: 8 })
Column().layoutWeight(1)
Text(c.diff + '').fontSize(10).fontColor(COLORS.textSub)
}
.width('100%')
Text(c.desc).fontSize(10).fontColor(COLORS.textHint).width('100%').margin({ top: 4 })
Row() {
Column()
.width(craftBarW(c.diff)).height(8).borderRadius(4)
.backgroundColor(COLORS.bamboo)
.opacity(this.amb ? 0.55 : 1)
.animation({ duration: 1000, iterations: -1, playMode: PlayMode.Alternate })
Column().layoutWeight(1)
Text('难度 ' + c.diff).fontSize(10).fontColor(COLORS.textSub).margin({ left: 8 })
}
.width('100%').margin({ top: 8 })
}
.width('100%')
.backgroundColor(COLORS.cardBg).borderRadius(12)
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.margin({ bottom: 10 })
.border({ width: 1, color: COLORS.border })
}, (c: CraftItem) => c.name)
}
.width('100%').padding({ left: 14, right: 14, bottom: 12 })
}
.width('100%')
.layoutWeight(1)
} else if (this.activeTab === 5) {
Scroll() {
Column() {
Row() {
Text('✍️').fontSize(14).margin({ right: 6 })
Text('扇面题字').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('诗书画印').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').padding({ top: 14, bottom: 8 })
ForEach(this.data.inscriptions, (ins: InscriptionItem) => {
Column() {
Text(ins.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Row() {
Text(ins.author).fontSize(11).fontColor(COLORS.ink).margin({ top: 6 })
Column().layoutWeight(1)
Text(ins.seal).fontSize(10).fontColor(COLORS.gold)
.margin({ top: 6 })
}
.width('100%')
}
.width('100%')
.backgroundColor(COLORS.cardBg).borderRadius(12)
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.margin({ bottom: 10 })
.border({ width: 1, color: COLORS.border })
}, (ins: InscriptionItem) => ins.name)
}
.width('100%').padding({ left: 14, right: 14, bottom: 12 })
}
.width('100%')
.layoutWeight(1)
} else if (this.activeTab === 6) {
Scroll() {
Column() {
Row() {
Text('🧓').fontSize(14).margin({ right: 6 })
Text('藏家雅集').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('点击可移除').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').padding({ top: 14, bottom: 8 })
ForEach(this.data.collectors, (co: CollectorItem, i: number) => {
Column() {
Row() {
Column() {
Text(co.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Text('镇宅:' + co.treasure).fontSize(11)
.fontColor(COLORS.textSub).margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Column() {
Text(co.count + '把').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.bambooDeep)
Text('珍藏').fontSize(10).fontColor(COLORS.textHint).margin({ top: 2 })
}
Text('移除').fontSize(10).fontColor(COLORS.danger).margin({ left: 12 })
.onClick(() => {
this.collectorIdx = i;
this.showRemoveCollector = true;
})
}
.width('100%')
}
.width('100%')
.backgroundColor(COLORS.cardBg).borderRadius(12)
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.margin({ bottom: 10 })
.border({ width: 1, color: COLORS.border })
}, (co: CollectorItem) => co.name)
}
.width('100%').padding({ left: 14, right: 14, bottom: 12 })
}
.width('100%')
.layoutWeight(1)
} else {
Scroll() {
Column() {
Row() {
Text('📦').fontSize(14).margin({ right: 6 })
Text('订单台账').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Column().layoutWeight(1)
Text('近六单金额').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').padding({ top: 14, bottom: 8 })
Row() {
ForEach(TOP_FAN_IDX, (i: number) => {
Column() {
Text(this.data.orders[i].piece.length > 2
? this.data.orders[i].piece.substring(0, 2) : this.data.orders[i].piece)
.fontSize(9).fontColor(COLORS.textHint)
Column()
.width(26).height(countBarH(this.data.orders[i].amount / 1000)).borderRadius(4)
.backgroundColor(i % 2 === 0 ? COLORS.bambooDeep : COLORS.bamboo)
.margin({ top: 6 })
.opacity(this.amb ? 0.55 : 1)
.animation({ duration: 900 + i * 150, iterations: -1, playMode: PlayMode.Alternate })
Text((this.data.orders[i].amount / 1000).toFixed(0) + 'k')
.fontSize(9).fontColor(COLORS.textSub).margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (i: number) => i.toString())
}
.width('100%')
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.cardBg).borderRadius(12)
.margin({ top: 4, bottom: 12 })
.alignItems(VerticalAlign.Bottom)
.border({ width: 1, color: COLORS.border })
Row() {
Text('新增订单').fontSize(12).fontColor(COLORS.white)
.backgroundColor(COLORS.bambooDeep).borderRadius(16)
.padding({ left: 22, right: 22, top: 9, bottom: 9 })
.onClick(() => {
this.formNo = '';
this.formBuyer = '';
this.formPiece = '';
this.formAmount = '';
this.showAddOrder = true;
})
Column().layoutWeight(1)
Text('共 ' + this.data.orders.length + ' 笔').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').margin({ bottom: 12 })
ForEach(this.data.orders, (o: OrderItem) => {
Column() {
Row() {
Column() {
Text(o.piece).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
Text(o.no + ' · ' + o.buyer).fontSize(10)
.fontColor(COLORS.textHint).margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text(o.status).fontSize(10).fontColor(COLORS.white)
.backgroundColor(statusColor(o.status)).borderRadius(8)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
Text('¥' + o.amount).fontSize(13).fontWeight(FontWeight.Bold)
.fontColor(COLORS.bambooDeep).margin({ left: 8 })
}
.width('100%')
}
.width('100%')
.backgroundColor(COLORS.cardBg).borderRadius(12)
.padding({ left: 14, right: 14, top: 12, bottom: 12 })
.margin({ bottom: 10 })
.border({ width: 1, color: COLORS.border })
}, (o: OrderItem) => o.no)
}
.width('100%').padding({ left: 14, right: 14, bottom: 12 })
}
.width('100%')
.layoutWeight(1)
}
}
@Builder
bottomBar() {
Column() {
Row() {
ForEach(ROW1_IDX, (i: number) => {
this.bottomItem(i)
}, (i: number) => i.toString())
}
.width('100%')
Row() {
ForEach(ROW2_IDX, (i: number) => {
this.bottomItem(i)
}, (i: number) => i.toString())
}
.width('100%')
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius({ topLeft: 20, topRight: 20 })
.border({ width: 1, color: COLORS.border })
}
build() {
Stack() {
Column() {
this.headerArea()
this.contentArea()
this.bottomBar()
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
if (this.showAddOrder) {
Stack() {
this.addOrderModal()
this.modalOverlay(() => { this.showAddOrder = false })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
if (this.showEditBone) {
Stack() {
this.editBoneModal()
this.modalOverlay(() => { this.showEditBone = false })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
if (this.showRemoveCollector) {
Stack() {
this.removeCollectorModal()
this.modalOverlay(() => { this.showRemoveCollector = false })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
}
.width('100%')
.height('100%')
}
}
总结
综上所述,这款"折扇坊"应用是HarmonyOS 6.1.1下基于HarmonyOS API 24的ArkTS声明式范式的一次完整而精彩的实践。从架构层面看,它采用了"中央数据仓+状态驱动视图"的MVVM模式——FanShopData作为@Observed单一数据源聚合了八类业务实体数组,主组件通过@State持有其实例,所有内容渲染与交互都围绕这一数据仓展开。addOrder/editBone/removeCollector三个业务方法收敛了全部状态变更逻辑,UI层只负责调用与展示,职责边界清晰,这种设计使应用的状态可预测、可追踪、易于维护与扩展。
在数据建模上,应用将折扇、扇骨、扇面、名家、工艺、题字、藏家、订单八类业务实体均声明为@Observed类,每类实体的字段都精准对应业务场景的需求,且通过@Observed获得了属性级变更监听能力。配合四个工具函数(craftBarW/countBarH/rankColor/statusColor)实现"数据到样式"的映射,将抽象的业务数值转化为直观的横条宽度、柱状图高度与语义色彩,是数据可视化在声明式UI中的典范应用。
在交互设计上,应用通过三个模态弹窗(新增订单/编辑扇骨/移除藏家)覆盖了增删改三类操作,每个弹窗都遵循"标题+表单/确认+操作按钮"的统一模式,并复用modalOverlay构建器生成遮罩,体现了组件化复用与DRY原则。模态通过show*布尔状态条件渲染,配合zIndex(999)层叠覆盖,是ArkTS实现模态交互的标准范式。表单采用受控输入模式——TextInput绑定@State字符串,onChange回写,提交时转换与兜底,保证了输入数据的可控性。
在动效设计上,应用巧妙地将ArkTS的animation/rotate/translate/opacity能力与"折扇"这一东方器物的视觉意象融合——扇骨辐线以rotate角度递增呈扇形放射、以translate上下浮动模拟开合、扇坠以opacity交替模拟流苏摇曳、库存进度条与工艺难度横条以opacity呼吸呈现数据脉动、名家卡片以translate轻浮模拟扇面摆动、订单柱状图以错落时长形成数据律动。aboutToAppear生命周期配合setTimeout延时触发amb开关,制造了应用启动时扇面徐徐展开的开场动效。这些动效并非装饰性堆砌,而是与内容语义紧密关联——扇骨辐线对应折扇结构、进度条对应库存状态、柱状图对应金额数据,实现了"动效即数据表达"的高级境界。
更多推荐



所有评论(0)