HarmonyOS 6效果实现:整行卡片应用 .rotate({ angle: tiltAngle(idx) }) 实现了±1.5度的交替倾斜模拟扇骨
引言:当折扇遇上代码
折扇,作为中国传统文人雅士手中的标志性器物,承载着数百年的文化积淀与工艺传承。从扇骨的选材到扇面的铺就,从书画的绘制到装裱的成型,每一个环节都凝聚着匠人的心血与智慧。在数字化时代的浪潮中,如何将这一古老工艺以现代技术手段进行系统性管理,成为了一个既具文化意义又具技术挑战的课题。
本文将深入剖析一个基于 ArkTS 声明式 UI 框架构建的折扇工艺数字化管理系统。该系统以暖棕米白为主色调,通过扇骨开合动画、扇形倾斜排列布局、月度产量统计图表等视觉元素,将传统折扇的六大工艺环节——扇骨、扇面、书画、装裱、匠人、成扇——完整地呈现在移动端界面上。系统不仅实现了数据的列表展示与分类管理,还通过精心设计的动画效果与交互反馈,让用户在操作过程中感受到折扇"开合清风"的独特韵味。
从技术架构的角度来看,该系统采用了 HarmonyOS 声明式 UI 范式,运用了 @Entry、@Component、@Builder、@State、@Observed 等核心装饰器,构建了一套高度模块化、可复用的组件体系。系统内部通过 ForEach 实现列表渲染,通过 linearGradient 实现渐变头部,通过 rotate 属性实现扇形倾斜布局,通过 setInterval 驱动呼吸动画,通过 Stack 叠层实现模态弹窗。每一处技术选型都紧密围绕着"折扇"这一主题展开,使得代码本身就如同一把精心制作的折扇——结构清晰、层次分明、开合有度。

一、色彩体系设计:暖棕米白的视觉基调
1.1 色彩接口定义
系统首先通过 interface ColorPalette 定义了一套完整的色彩类型约束体系。这个接口涵盖了背景色、卡片色、纸张色、标题色、副标题色、三级文字色、墨色、木色系列、竹色、米色、扇面色系列、金色、奶油色、红色、绿色、分割线色、标签选中色、遮罩色、透明色以及扇面背景色等二十余种语义化颜色字段。
interface ColorPalette {
bg: string;
card: string;
paper: string;
title: string;
sub: string;
text3: string;
ink: string;
wood: string;
woodL: string;
woodD: string;
bamboo: string;
rice: string;
fanA: string;
fanB: string;
fanC: string;
gold: string;
cream: string;
red: string;
green: string;
line: string;
tabOn: string;
mask: string;
transparent: string;
fanBg: string;
}

这段接口定义的意义远不止于简单的类型声明。它实际上构建了一套"色彩语义化"的设计哲学:每个颜色字段都对应着一个明确的使用场景。例如,wood 代表折扇扇骨的木质本色,woodL 代表浅木色用于辅助装饰,woodD 代表深木色用于强调与价格数字;fanA、fanB、fanC 则构成了扇面色彩的三色变体系列,分别用于不同的视觉层级。通过接口约束,开发者无法随意更改颜色值的语义,从而保证了整个系统视觉风格的高度一致性。
1.2 色彩常量实例化
在接口定义之后,系统通过 const COLORS: ColorPalette 将具体的十六进制颜色值赋给各个语义化字段。这一过程是将抽象的设计理念转化为具体的视觉呈现的关键一步。
const COLORS: ColorPalette = {
bg: '#F7F2EC',
card: '#FFFFFF',
paper: '#EDE4D6',
title: '#3E2A1A',
sub: '#6B5440',
text3: '#9B8A78',
ink: '#2A1A0A',
wood: '#8B6B4A',
woodL: '#B89878',
woodD: '#5A3E28',
bamboo: '#A08060',
rice: '#FFF8E8',
fanA: '#D4A574',
fanB: '#E8C89A',
fanC: '#C4A06A',
gold: '#B8860B',
cream: '#FFF8E1',
red: '#D32F2F',
green: '#388E3C',
line: '#DCC8B0',
tabOn: '#3E2A1A',
mask: 'rgba(0,0,0,0.5)',
transparent: 'rgba(0,0,0,0)',
fanBg: 'rgba(212,165,116,0.1)'
};

从色彩搭配的角度来看,这套配色方案精心营造出了一种"暖棕米白"的古典氛围。背景色 #F7F2EC 是一种极淡的暖米色,模拟宣纸或旧绢的质感;标题色 #3E2A1A 是深棕色,让人联想到老木与浓墨;扇面色系 fanA(#D4A574)、fanB(#E8C89A)、fanC(#C4A06A)则呈现出从浅金到深棕的渐变层次,恰如一把展开的折扇上不同区域的色彩变化。值得注意的是,fanBg 使用了 rgba(212,165,116,0.1) 的半透明形式,这种极低不透明度的暖色调被用于标签选中态和卡片背景,既保持了视觉区分度,又不会过于突兀地打断整体的柔和基调。
二、标签元数据与索引数组:系统导航的数据骨架
2.1 标签元数据接口与常量
系统通过 interface TabMeta 定义了底部导航标签的元数据结构,包含 icon(图标文字)和 label(标签文字)两个字段。随后通过 TAB_LIST 常量实例化了六个标签项,分别对应折扇制作的六大环节。
interface TabMeta {
icon: string;
label: string;
}
const TAB_LIST: TabMeta[] = [
{ icon: '骨', label: '扇骨' },
{ icon: '面', label: '扇面' },
{ icon: '画', label: '书画' },
{ icon: '裱', label: '装裱' },
{ icon: '匠', label: '匠人' },
{ icon: '扇', label: '成扇' }
];

这里的标签设计颇为巧妙。图标使用了单字汉字——“骨、面、画、裱、匠、扇”——每一个字都精准地概括了对应模块的核心内容,既简洁有力,又与折扇的传统文化语境高度契合。六个标签按照折扇制作工艺的实际流程排列:先选扇骨、再裁扇面、然后书画、接着装裱、匠人贯穿其中、最终成扇。这种排列顺序不仅是工艺逻辑的体现,也引导用户按照"从原材料到成品"的认知路径浏览系统。
2.2 索引数组体系
系统定义了多组索引数组,用于在不同的渲染场景中控制循环范围和数据映射。
const ROW1_IDX: number[] = [0, 1, 2];
const ROW2_IDX: number[] = [3, 4, 5];
const TAG_IDX: number[] = [0, 1, 2, 3];
const TAG_NAME_IDX: string[] = ['扇骨', '扇面', '书画', '成扇'];
const RIB_IDX: number[] = [0, 1, 2, 3, 4, 5, 6];
const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
const MONTH_NAME_IDX: string[] = ['正月', '二月', '三月', '四月', '五月', '六月'];
const SALES_IDX: number[] = [30, 45, 55, 68, 80, 65];

ROW1_IDX 和 ROW2_IDX 将六个标签分为两行三列,这是底部导航栏"两行布局"的数据基础。TAG_IDX 和 TAG_NAME_IDX 用于头部区域的四个快捷筛选标签。RIB_IDX 是一个七元素的索引数组,专门用于头部扇骨开合动画——七根扇骨模拟折扇的骨架结构。MONTH_IDX 和 MONTH_NAME_IDX 构成了月度产量图表的横轴标签,使用了传统农历月份名称"正月至六月",与折扇的传统文化主题相呼应。SALES_IDX 则直接存储了各月的产量数值,通过 SALES_IDX[m] 的方式在图表中映射为柱状图的高度。
三、数据模型层:六大可观察实体类
3.1 扇骨数据模型
系统使用 @Observed 装饰器定义了六个可观察的数据实体类。第一个是 RibItem(扇骨项),包含名称、种类、材质和价格四个字段。
@Observed
export class RibItem {
name: string;
kind: string;
material: string;
price: number;
constructor(name: string, kind: string, material: string, price: number) {
this.name = name; this.kind = kind; this.material = material; this.price = price;
}
}
@Observed 装饰器是 ArkTS 状态管理体系中的关键一环。被 @Observed 标记的类,其实例在赋值给 @State 变量后,属性的变更能够自动触发关联的 UI 组件重新渲染。这意味着当用户在编辑弹窗中修改了某根扇骨的价格后,列表中对应项的价格显示会自动更新,无需手动调用刷新方法。构造函数采用简洁的赋值式写法,四个参数在构造时一次性传入,保证了对象创建时数据的完整性。
3.2 扇面数据模型
@Observed
export class FanFaceItem {
name: string;
kind: string;
size: string;
price: number;
constructor(name: string, kind: string, size: string, price: number) {
this.name = name; this.kind = kind; this.size = size; this.price = price;
}
}

FanFaceItem 模型用于描述扇面信息。与扇骨模型不同的是,它使用 size(尺寸)字段替代了 material(材质)字段。在折扇工艺中,扇面的尺寸通常是标准化的"九寸",因此该字段在模拟数据中几乎全部填入"九寸",但这并不影响模型的可扩展性——如果后续需要支持不同尺寸的扇面管理,只需在数据中填入不同值即可。
3.3 书画数据模型
@Observed
export class PaintingItem {
name: string;
kind: string;
steps: number;
price: number;
desc: string;
constructor(name: string, kind: string, steps: number, price: number, desc: string) {
this.name = name; this.kind = kind; this.steps = steps; this.price = price; this.desc = desc;
}
}
PaintingItem 是所有数据模型中字段最多的一个,额外包含了 steps(绘制步骤数)和 desc(描述文本)。steps 字段用于在列表项中以圆形数字标签的形式直观展示绘制一幅扇面所需的工序数量——写意画通常只需1步,而工笔画则需要3步。desc 字段则在列表项下方以较小字号展示,为用户提供关于该书画风格的文字说明,增强信息密度。
3.4 装裱与匠人数据模型
@Observed
export class MountItem {
name: string;
kind: string;
days: number;
price: number;
constructor(name: string, kind: string, days: number, price: number) {
this.name = name; this.kind = kind; this.days = days; this.price = price;
}
}
@Observed
export class CraftsmanItem {
name: string;
kind: string;
years: number;
price: number;
constructor(name: string, kind: string, years: number, price: number) {
this.name = name; this.kind = kind; this.years = years; this.price = price;
}
}

MountItem 使用 days(天数)字段表示装裱工序所需的时间,CraftsmanItem 使用 years(年限)字段表示匠人的从业年限。这两个字段虽然都是数值型,但在 UI 呈现上有着截然不同的处理方式:天数以"天"为后缀以暖色调显示,年限以"年"为后缀以灰色辅助文字显示。这种差异化展示体现了系统在数据可视化层面对不同业务场景的精心考量。
3.5 成扇数据模型
@Observed
export class FanItem {
name: string;
kind: string;
size: string;
price: number;
constructor(name: string, kind: string, size: string, price: number) {
this.name = name; this.kind = kind; this.size = string; this.price = price;
}
}
FanItem 描述的是最终成品折扇的信息,字段结构与 FanFaceItem 类似,但其数据内容更丰富——涵盖从入门级到高端级别的各种折扇成品,价格从120到880不等,体现了成扇作为最终产品在价值上的跨度。
四、模拟数据集:十二类扇骨与十种扇面
4.1 扇骨模拟数据
系统为每个数据模型准备了充足的模拟数据。扇骨数据 mockRibList 包含了十二条记录,覆盖了竹扇骨、木扇骨、香木骨、珍稀骨、镶嵌骨、漆艺骨等多种类型。
const mockRibList: RibItem[] = [
new RibItem('白竹骨', '竹扇骨', '三年湘妃竹', 80),
new RibItem('乌木骨', '木扇骨', '非洲乌木', 120),
new RibItem('紫檀骨', '木扇骨', '印度小叶紫檀', 280),
new RibItem('黄花梨骨', '木扇骨', '海南黄花梨', 380),
new RibItem('湘妃竹骨', '竹扇骨', '湖南斑竹', 150),
new RibItem('梅鹿竹骨', '竹扇骨', '浙江梅鹿竹', 130),
new RibItem('棕竹骨', '竹扇骨', '云南棕竹', 90),
new RibItem('鸡翅木骨', '木扇骨', '缅甸鸡翅木', 100),
new RibItem('沉香骨', '香木骨', '印尼沉香木', 350),
new RibItem('象牙骨', '珍稀骨', '猛犸象牙', 500),
new RibItem('螺钿骨', '镶嵌骨', '鲍鱼贝壳', 280),
new RibItem('大漆骨', '漆艺骨', '推光大漆', 180)
];

这十二条数据的排列并非随意。从白竹骨的80元到象牙骨的500元,价格逐步递增,中间穿插了湘妃竹、梅鹿竹等中等价位选项,最后以螺钿骨和大漆骨等工艺复杂的类型收尾。这种排列方式在列表展示时形成了一种"从朴素到华丽"的视觉递进感,让用户在滑动浏览过程中自然感受到折扇扇骨材质的丰富多样性。
4.2 其他模拟数据集概览
系统还为扇面、书画、装裱、匠人、成扇分别定义了十条左右的模拟数据。扇面数据涵盖了矾宣、洒金宣、冷金笺等十种不同材质的扇面类型;书画数据包含写意花鸟、工笔仕女、青绿山水等十种书画风格;装裱数据列出了穿面、糊面、折面等十道装裱工序;匠人数据记录了十位不同工种的折扇制作师傅;成扇数据则展示了十二种从入门到高端的成品折扇。每条数据都包含了真实的材质名称、产地信息和合理的价格区间,使得整个系统在演示状态下就具备了接近真实业务场景的数据密度。
五、辅助函数:扇骨角度与倾斜计算
5.1 扇骨角度计算函数
系统定义了三个辅助函数,用于在头部动画和列表布局中计算扇骨的旋转角度和位置坐标。
function ribAngle(i: number): number {
return (i - 3) * 8;
}
ribAngle 函数接收一个索引参数 i(取值范围为0到6),返回该扇骨相对于中心轴的旋转角度。公式 (i - 3) * 8 的设计巧妙地以索引3为中心点(角度为0),向两侧对称扩展——索引0的角度为-24度,索引6的角度为+24度。这种以中心为基准的对称角度分配方式,精确地模拟了折扇展开时扇骨呈扇形排列的物理形态。
5.2 扇骨水平位置函数
function ribX(i: number): number {
return 120 + i * 35;
}
ribX 函数计算每根扇骨在水平方向上的起始位置。基准位置为120像素,每根扇骨向右偏移35像素。七根扇骨从x=120到x=330,总宽度约210像素,恰好覆盖了屏幕的中右部区域。这个位置计算与 ribAngle 的角度计算配合使用,使得每根扇骨既在水平方向上均匀分布,又在旋转角度上呈扇形展开,两者共同构成了折扇的视觉形态。
5.3 倾斜角度函数
function tiltAngle(idx: number): number {
return idx % 2 === 0 ? -1.5 : 1.5;
}
tiltAngle 函数是整个系统"扇形倾斜布局"的核心算法。它接收列表项的索引 idx,返回-1.5或+1.5度的微小倾斜角度。偶数索引项向左倾斜1.5度,奇数索引项向右倾斜1.5度。这种极其微小的交替倾斜,在视觉上产生了一种"扇面展开"的暗示效果——列表不再是死板的水平排列,而是如同折扇扇面般微微波动,赋予了整个界面一种灵动的节奏感。这个函数在后续所有六个 Tab 内容构建器中被反复调用,是系统布局风格统一性的关键保障。
六、页面组件主体:状态管理与生命周期
6.1 组件声明与状态变量
@Entry
@Component
struct Page768 {
@State selTab: number = 0;
@State selTag: number = 0;
@State breath: boolean = false;
@State showAdd: boolean = false;
@State showEdit: boolean = false;
@State showDel: boolean = false;
@State editIdx: number = 0;
@State delIdx: number = 0;
@State ribList: RibItem[] = mockRibList;
@State faceList: FanFaceItem[] = mockFaceList;
@State paintList: PaintingItem[] = mockPaintingList;
@State mountList: MountItem[] = mockMountList;
@State craftsmanList: CraftsmanItem[] = mockCraftsmanList;
@State fanList: FanItem[] = mockFanList;
@Entry 标记该组件为页面入口,@Component 声明其为一个自定义组件。组件内部定义了十三个 @State 状态变量,可以分为三组:
交互状态组:selTab(当前选中标签页,默认0)、selTag(当前选中筛选标签,默认0)、breath(呼吸动画布尔值,默认false)。这三个变量控制着用户与系统的核心交互行为——切换标签页时改变 selTab 会触发内容区重新渲染对应 Tab 的列表;点击筛选标签时同步改变 selTag 和 selTab,实现头部标签与底部标签的联动。
弹窗状态组:showAdd、showEdit、showDel 三个布尔变量分别控制新增、编辑、删除三个模态弹窗的显示与隐藏。editIdx 和 delIdx 记录当前操作的数据索引,确保弹窗中显示和操作的是正确的那条记录。
数据列表组:六个 @State 数组分别绑定六个 Tab 的模拟数据集。由于数据模型类使用了 @Observed 装饰器,当数组中某条记录的属性发生变化时,对应的列表项会自动更新。
6.2 生命周期函数
aboutToAppear() {
setInterval(() => { this.breath = !this.breath; }, 700);
}
aboutToAppear 是 ArkTS 组件生命周期的关键回调,在组件实例创建后、UI 渲染前被调用。系统在此处通过 setInterval 注册了一个每700毫秒执行一次的定时器,定时器回调将 breath 布尔值在 true 和 false 之间切换。这个简单的定时器是整个系统所有"呼吸"动画的驱动源——头部扇骨的高度变化和角度旋转、列表项中某些元素的缩放和透明度变化,都依赖于 breath 状态的周期性翻转。700毫秒的间隔经过精心调校,既不会快到让人眼花缭乱,也不会慢到让用户觉得画面静止。
七、构建函数 build():页面结构的骨架
build() {
Column() {
this.headerType()
Divider().strokeWidth(1).color(COLORS.line)
Scroll() {
Column() {
if (this.selTab === 0) {
this.ribTab()
} else if (this.selTab === 1) {
this.faceTab()
} else if (this.selTab === 2) {
this.paintTab()
} else if (this.selTab === 3) {
this.mountTab()
} else if (this.selTab === 4) {
this.craftsmanTab()
} else {
this.fanTab()
}
this.salesChart()
}.width('100%').padding({ left: 10, right: 10, top: 10, bottom: 100 })
}.constraintSize({ maxHeight: '70%' }).scrollBar(BarState.Off)
this.tabBar()
if (this.showAdd) {
this.addModal()
}
if (this.showEdit) {
this.editModal()
}
if (this.showDel) {
this.delModal()
}
}.width('100%').height('100%').backgroundColor(COLORS.bg)
}
build() 函数是整个页面的结构骨架。最外层是一个填满屏幕的 Column,背景色为暖米色 COLORS.bg。从上到下依次包含以下层次:
头部区域:调用 this.headerType() 构建器渲染渐变头部,包含动画、搜索框、标签条和新增按钮。头部下方有一条 Divider 分割线,宽度1像素,颜色为 COLORS.line(浅棕灰色),在视觉上将头部与内容区柔和地分隔开。
滚动内容区:使用 Scroll 组件包裹一个 Column,通过 constraintSize({ maxHeight: '70%' }) 限制最大高度为屏幕的70%,并关闭滚动条(scrollBar(BarState.Off))。内容区内部通过 if-else 链根据 selTab 的值条件渲染对应的 Tab 内容构建器,末尾始终附加 this.salesChart() 月度产量图表。内边距设置 bottom: 100 为底部标签栏留出了足够的空间。
底部标签栏:调用 this.tabBar() 渲染两行三列的导航标签。
模态弹窗组:三个 if 条件块分别判断 showAdd、showEdit、showDel 的状态,当对应状态为 true 时渲染相应的模态弹窗。这种条件渲染方式确保了弹窗在不使用时不占用渲染资源。
八、头部构建器 headerType():渐变Banner与扇骨动画
8.1 渐变背景与标题
@Builder
headerType() {
Stack({ alignContent: Alignment.TopStart }) {
Column()
.width('100%')
.height(170)
.linearGradient({
angle: 135,
colors: [[COLORS.woodD, 0.0], [COLORS.wood, 0.5], [COLORS.bg, 1.0]]
})
Row() {
Text('折扇')
.fontSize(24)
.fontColor(COLORS.cream)
.fontWeight(FontWeight.Bold)
Text('·开合清风')
.fontSize(14)
.fontColor(COLORS.woodL)
.margin({ left: 6 })
}
.position({ x: 14, y: 12 })
头部使用 Stack 作为容器,内容对齐方式为左上角(Alignment.TopStart)。最底层是一个高度170像素的 Column,通过 linearGradient 属性设置了135度角的线性渐变——从顶部深木色 COLORS.woodD(#5A3E28)开始,到中间木色 COLORS.wood(#8B6B4A),再到底部背景色 COLORS.bg(#F7F2EC)。这种三色渐变模拟了折扇从深色扇骨到浅色扇面的色彩过渡,顶部深色区域承载标题文字,底部浅色区域自然融入页面背景。
标题由两部分组成:主标题"折扇"以24号粗体奶油色字体显示,副标题"·开合清风"以14号浅木色字体紧随其后,两者之间有6像素的左边距间隔。标题定位于 (14, 12),紧贴头部左上角。
8.2 搜索框与标签筛选条
TextInput({ placeholder: '搜索扇骨/扇面/成扇' })
.fontSize(13).fontColor(COLORS.title)
.placeholderColor(COLORS.text3)
.backgroundColor(COLORS.card).borderRadius(8)
.margin({ left: 12, right: 12, top: 44 })
Row({ space: 6 }) {
ForEach(TAG_IDX, (t: number) => {
Column() {
Text(TAG_NAME_IDX[t]).fontSize(11)
.fontColor(this.selTag === t ? COLORS.cream : COLORS.wood)
}
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(this.selTag === t ? COLORS.woodD : COLORS.fanBg)
.borderRadius(12)
.onClick(() => { this.selTag = t; this.selTab = t; })
}, (t: number) => 'tg' + t)
}
.width('100%').padding({ left: 12, right: 12 })
.position({ x: 0, y: 88 })
搜索框使用 TextInput 组件,占位符文字为"搜索扇骨/扇面/成扇",提示了系统的核心搜索范围。搜索框背景为白色卡片色,圆角8像素,通过 margin({ top: 44 }) 定位于标题下方。
标签筛选条是一个 Row,通过 ForEach 遍历 TAG_IDX 数组渲染四个筛选标签。每个标签都是一个 Column 容器,内含文字。标签的选中态通过三元运算符动态控制:选中时文字颜色为奶油色、背景为深木色;未选中时文字颜色为木色、背景为极淡的扇面色。点击标签时同时更新 selTag 和 selTab,实现了"点击头部标签即切换底部 Tab 内容"的联动效果。标签使用 borderRadius(12) 形成药丸形状,内边距 (10, 4) 保证了文字周围的舒适留白。
8.3 新增按钮与扇骨开合动画
Row() {
Text('+').fontSize(14).fontColor(COLORS.cream)
Text('新增').fontSize(12).fontColor(COLORS.cream).margin({ left: 4 })
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(COLORS.fanC).borderRadius(8)
.position({ x: 270, y: 88 })
.onClick(() => { this.showAdd = true; })
ForEach(RIB_IDX, (r: number) => {
Column()
.width(2)
.height(this.breath ? 35 : 15)
.backgroundColor(COLORS.fanA)
.rotate({ angle: ribAngle(r) + (this.breath ? 10 : 0) })
.position({ x: ribX(r), y: 130 })
.animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })
}, (r: number) => 'rb' + r)
Text('扇')
.fontSize(48)
.fontColor('rgba(255,255,255,0.1)')
.fontWeight(FontWeight.Bold)
.position({ x: 260, y: 100 })
}
.width('100%').height(170)
}
新增按钮定位于头部右侧 (270, 88),由加号和"新增"文字组成,背景为扇面色C(#C4A06A),点击后设置 showAdd = true 弹出新增弹窗。
头部的核心动画效果——扇骨开合——通过 ForEach 遍历 RIB_IDX(0到6共七根)实现。每根扇骨是一个宽度2像素的 Column,高度随 breath 状态在15和35像素之间切换,背景色为 COLORS.fanA。每根扇骨的旋转角度由 ribAngle(r) 计算基础角度,再加上 breath 为真时的10度增量,模拟扇骨"呼吸"时的微微张合。位置由 ribX(r) 计算水平坐标,垂直坐标固定在130像素。动画属性设置为 duration: 700(与定时器间隔一致)、iterations: -1(无限循环)、playMode: PlayMode.Alternate(往返模式),使得扇骨在展开和收拢之间持续交替运动。
最后一层是一个巨大的"扇"字水印,字号48,颜色为极低透明度的白色 rgba(255,255,255,0.1),定位于 (260, 100),为头部增添了一层若有若无的文化气息。
九、六大 Tab 内容构建器:扇形倾斜布局的六种变体
9.1 扇骨列表 ribTab()
@Builder
ribTab() {
Column({ space: 6 }) {
Text('扇骨 · 扇形倾斜排列').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%').padding({ left: 4, top: 4, bottom: 4 })
ForEach(this.ribList, (item: RibItem, idx: number) => {
Row() {
Column() {
Column()
.width(3).height(30)
.backgroundColor(idx % 3 === 0 ? COLORS.woodD : idx % 3 === 1 ? COLORS.fanA : COLORS.bamboo)
.borderRadius(2)
.rotate({ angle: tiltAngle(idx) * 3 })
}.width(30)
Column({ space: 3 }) {
Text(item.name).fontSize(13).fontColor(COLORS.title)
Row() {
Text(item.kind).fontSize(10).fontColor(COLORS.sub)
Text(' · ' + item.material).fontSize(10).fontColor(COLORS.text3)
}.width('100%')
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 6 })
Text('Y' + item.price).fontSize(13).fontColor(COLORS.woodD).fontWeight(FontWeight.Bold)
}
.width('100%').padding({ left: 10, right: 10, top: 8, bottom: 8 })
.backgroundColor(COLORS.card).borderRadius(8)
.margin({ top: 4 })
.rotate({ angle: tiltAngle(idx) })
.onClick(() => { this.editIdx = idx; this.showEdit = true; })
}, (item: RibItem) => item.name)
}.width('100%')
}
扇骨列表是六个 Tab 中视觉最丰富的一个。标题"扇骨 · 扇形倾斜排列"以13号粗体字明确标识了当前内容区和布局风格。列表通过 ForEach 遍历 this.ribList,每项的键值生成器为 (item: RibItem) => item.name,确保列表项的唯一性。
每行列表项的左侧装饰元素是一根模拟扇骨——宽度3像素、高度30像素的 Column,通过 idx % 3 三元运算在三种颜色间循环切换:深木色、扇面色A和竹色。这根装饰扇骨自身也应用了 tiltAngle(idx) * 3 的旋转角度(即±4.5度),比整行的倾斜幅度更大,形成了"扇骨中的扇骨"的嵌套视觉趣味。
整行卡片应用 .rotate({ angle: tiltAngle(idx) }) 实现了±1.5度的交替倾斜。点击列表项时设置 editIdx 为当前索引并弹出编辑弹窗。价格显示在右侧,以深木色粗体呈现,前缀"Y"代替了货币符号。
9.2 扇面列表 faceTab()
@Builder
faceTab() {
Column({ space: 6 }) {
Row() {
Text('+').fontSize(16).fontColor(COLORS.tabOn)
Text('新增扇面').fontSize(13).fontColor(COLORS.tabOn).margin({ left: 4 })
}
.width('100%').padding({ left: 12, top: 8, bottom: 8 })
.backgroundColor(COLORS.card).borderRadius(8)
.onClick(() => { this.showAdd = true; })
ForEach(this.faceList, (item: FanFaceItem, idx: number) => {
Row() {
Column()
.width(30).height(30)
.backgroundColor(idx % 2 === 0 ? COLORS.fanBg : COLORS.rice)
.borderRadius(4)
.rotate({ angle: tiltAngle(idx) * 2 })
Column({ space: 3 }) {
Text(item.name).fontSize(13).fontColor(COLORS.title)
Row() {
Text(item.kind).fontSize(10).fontColor(COLORS.sub)
Text(' · ' + item.size).fontSize(10).fontColor(COLORS.text3)
}.width('100%')
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 8 })
Text('Y' + item.price).fontSize(13).fontColor(COLORS.woodD).fontWeight(FontWeight.Bold)
}
.width('100%').padding(10)
.backgroundColor(idx % 2 === 0 ? COLORS.card : COLORS.paper)
.borderRadius(8).margin({ top: 4 })
.rotate({ angle: tiltAngle(idx) })
}, (item: FanFaceItem) => item.name)
}.width('100%')
}
扇面列表的独特之处在于:列表顶部增加了一个"新增扇面"按钮行,以加号和文字组合呈现,背景为白色卡片色,点击触发新增弹窗。列表项左侧的装饰色块是一个30×30的正方形,通过 idx % 2 在扇面背景色和米色之间交替。整行卡片背景同样通过 idx % 2 在白色卡片色和纸张色之间交替,形成了双色斑马的列表效果。整行同样应用了 tiltAngle(idx) 的±1.5度倾斜。
9.3 书画列表 paintTab()
书画列表在所有 Tab 中信息密度最高。每条记录不仅包含名称、种类和价格,还额外展示了绘制步骤数和描述文本,并且在底部提供了"编辑"和"删除"两个操作按钮。
@Builder
paintTab() {
Column({ space: 6 }) {
ForEach(this.paintList, (item: PaintingItem, idx: number) => {
Column({ space: 6 }) {
Row() {
Column() {
Text(item.steps.toString())
.fontSize(14).fontColor(COLORS.cream).fontWeight(FontWeight.Bold)
Text('步').fontSize(7).fontColor(COLORS.cream)
}
.width(28).height(28)
.backgroundColor(idx % 3 === 0 ? COLORS.woodD : idx % 3 === 1 ? COLORS.fanC : COLORS.fanA)
.borderRadius(6)
.justifyContent(FlexAlign.Center)
Column({ space: 2 }) {
Text(item.name).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(item.kind).fontSize(10).fontColor(COLORS.sub)
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 8 })
Text('Y' + item.price).fontSize(13).fontColor(COLORS.woodD).fontWeight(FontWeight.Bold)
}.width('100%')
Text(item.desc).fontSize(11).fontColor(COLORS.text3)
Row({ space: 8 }) {
Text('编辑').fontSize(10).fontColor(COLORS.tabOn)
.onClick(() => { this.editIdx = idx; this.showEdit = true; })
Text('删除').fontSize(10).fontColor(COLORS.red)
.onClick(() => { this.delIdx = idx; this.showDel = true; })
}.width('100%')
}
.width('100%').padding(10)
.backgroundColor(COLORS.card).borderRadius(8)
.margin({ top: 4 })
.rotate({ angle: tiltAngle(idx) })
}, (item: PaintingItem) => item.name)
}.width('100%')
}
列表项的左侧装饰是一个28×28的圆角方块,内含步骤数字和"步"字。步骤数字以14号奶油色粗体显示,“步"字以7号超小字号位于下方,通过 justifyContent(FlexAlign.Center) 居中对齐。方块背景色通过 idx % 3 在三色间循环。名称行下方是 item.desc 描述文本,以11号灰色字显示,增加了每条记录的信息量。最底部的操作行包含"编辑”(点击触发编辑弹窗)和"删除"(红色文字,点击触发删除弹窗)两个文字按钮,提供了直接的操作入口。整行卡片同样应用了倾斜布局。
9.4 装裱列表 mountTab()
@Builder
mountTab() {
Column({ space: 6 }) {
ForEach(this.mountList, (item: MountItem, idx: number) => {
Row() {
Column() {
Text(item.days.toString())
.fontSize(14).fontColor(COLORS.fanA).fontWeight(FontWeight.Bold)
Text('天').fontSize(7).fontColor(COLORS.fanA)
}.width(36)
Column({ space: 3 }) {
Text(item.name).fontSize(13).fontColor(COLORS.title)
Text(item.kind).fontSize(10).fontColor(COLORS.sub)
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 6 })
Text('Y' + item.price).fontSize(13).fontColor(COLORS.woodD).fontWeight(FontWeight.Bold)
}
.width('100%').padding(10)
.backgroundColor(idx % 2 === 0 ? COLORS.card : COLORS.paper)
.borderRadius(8).margin({ top: 4 })
.rotate({ angle: tiltAngle(idx) })
.onClick(() => { this.editIdx = idx; this.showEdit = true; })
}, (item: MountItem) => item.name)
}.width('100%')
}
装裱列表的结构较为简洁,左侧是装裱天数显示——14号扇面色A粗体数字加上7号"天"字,宽度36像素。整行点击触发编辑弹窗,背景色通过 idx % 2 交替变化。
9.5 匠人列表 craftsmanTab()
@Builder
craftsmanTab() {
Column({ space: 6 }) {
ForEach(this.craftsmanList, (item: CraftsmanItem, idx: number) => {
Row() {
Column()
.width(34).height(34)
.backgroundColor(COLORS.fanBg).borderRadius(6)
.justifyContent(FlexAlign.Center)
.rotate({ angle: tiltAngle(idx) * 2 })
Column({ space: 2 }) {
Text(item.name).fontSize(13).fontColor(COLORS.title)
Text(item.kind).fontSize(10).fontColor(COLORS.sub)
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Text(item.years + '年').fontSize(11).fontColor(COLORS.text3)
Row({ space: 8 }) {
Text('编').fontSize(9).fontColor(COLORS.tabOn)
.onClick(() => { this.editIdx = idx; this.showEdit = true; })
Text('删').fontSize(9).fontColor(COLORS.red)
.onClick(() => { this.delIdx = idx; this.showDel = true; })
}.margin({ left: 6 })
}
.width('100%').padding(10)
.backgroundColor(COLORS.card).borderRadius(8)
.margin({ top: 4 })
.rotate({ angle: tiltAngle(idx) })
}, (item: CraftsmanItem) => item.name)
}.width('100%')
}
匠人列表的左侧是一个34×34的圆角方块头像占位符,背景为扇面背景色,自身也应用了 tiltAngle(idx) * 2 的旋转。中间区域显示匠人姓名和工种类别。右侧依次是从业年限(以"年"为后缀的灰色文字)和"编""删"两个操作按钮(9号超小字号),分别触发编辑和删除弹窗。
9.6 成扇列表 fanTab()
@Builder
fanTab() {
Column({ space: 6 }) {
Row() {
Text('+').fontSize(16).fontColor(COLORS.tabOn)
Text('新增成扇').fontSize(13).fontColor(COLORS.tabOn).margin({ left: 4 })
}
.width('100%').padding({ left: 12, top: 8, bottom: 8 })
.backgroundColor(COLORS.card).borderRadius(8)
.onClick(() => { this.showAdd = true; })
ForEach(this.fanList, (item: FanItem, idx: number) => {
Row() {
Column({ space: 3 }) {
Text(item.name).fontSize(13).fontColor(COLORS.title)
Row() {
Text(item.kind).fontSize(10).fontColor(COLORS.sub)
Text(' · ' + item.size).fontSize(10).fontColor(COLORS.text3)
}.width('100%')
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('Y' + item.price).fontSize(13).fontColor(COLORS.fanC).fontWeight(FontWeight.Bold)
}
.width('100%').padding(10)
.backgroundColor(idx % 2 === 0 ? COLORS.card : COLORS.paper)
.borderRadius(8).margin({ top: 4 })
.rotate({ angle: tiltAngle(idx) })
}, (item: FanItem) => item.name)
}.width('100%')
}
成扇列表的布局最为简洁——没有左侧装饰色块,名称和种类信息直接占满左侧区域,价格以扇面色C显示。列表顶部同样有"新增成扇"按钮行,背景色通过 idx % 2 交替。这种简洁化的设计是因为成扇作为最终产品,用户关注的是名称和价格,不需要额外的装饰元素分散注意力。
十、月度产量图表 salesChart()
@Builder
salesChart() {
Column({ space: 8 }) {
Row() {
Text('月度成扇产量').fontSize(14).fontColor(COLORS.title).layoutWeight(1)
Text('把').fontSize(11).fontColor(COLORS.text3)
}.width('100%')
Row({ space: 4 }) {
ForEach(MONTH_IDX, (m: number) => {
Column({ space: 4 }) {
Text(SALES_IDX[m].toString())
.fontSize(9).fontColor(COLORS.sub)
Column()
.width(24)
.height(SALES_IDX[m] * 0.15)
.backgroundColor(m % 2 === 0 ? COLORS.woodD : COLORS.fanA)
.borderRadius(4)
Text(MONTH_NAME_IDX[m])
.fontSize(9)
.fontColor(COLORS.text3)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (m: number) => 'ch' + m)
}
.width('100%').height(60)
.alignItems(VerticalAlign.Bottom)
}
.width('100%').padding(12)
.backgroundColor(COLORS.card).borderRadius(10)
.margin({ top: 10 })
}
月度产量图表是一个纯 CSS 实现的柱状图组件,无需引入任何图表库。图表标题行包含"月度成扇产量"和单位"把",分别用14号和11号字显示。图表主体是一个 Row,高度60像素,通过 alignItems(VerticalAlign.Bottom) 使所有柱子底部对齐。
ForEach 遍历 MONTH_IDX(0到5),每个月份渲染一个 Column:顶部是产量数值(9号字),中间是柱子(宽度24像素,高度为 SALES_IDX[m] * 0.15),底部是月份名称。柱子颜色通过 m % 2 在深木色和扇面色A之间交替,形成了双色的视觉节奏。高度计算的系数0.15经过精确调校——最高值80 × 0.15 = 12像素,最低值30 × 0.15 = 4.5像素,在60像素的图表区域内呈现出合理的视觉比例。
十一、底部标签栏 tabBar()
@Builder
tabBar() {
Column({ space: 6 }) {
Row() {
ForEach(ROW1_IDX, (i: number) => {
Column({ space: 2 }) {
Text(TAB_LIST[i].icon).fontSize(16).fontColor(this.selTab === i ? COLORS.tabOn : COLORS.sub)
Text(TAB_LIST[i].label).fontSize(9).fontColor(this.selTab === i ? COLORS.tabOn : COLORS.sub)
}
.layoutWeight(1).height(48)
.backgroundColor(this.selTab === i ? COLORS.fanBg : COLORS.card)
.borderRadius(8)
.justifyContent(FlexAlign.Center)
.onClick(() => { this.selTab = i; })
}, (i: number) => 't1' + i)
}.width('100%')
Row() {
ForEach(ROW2_IDX, (i: number) => {
Column({ space: 2 }) {
Text(TAB_LIST[i].icon).fontSize(16).fontColor(this.selTab === i ? COLORS.tabOn : COLORS.sub)
Text(TAB_LIST[i].label).fontSize(9).fontColor(this.selTab === i ? COLORS.tabOn : COLORS.sub)
}
.layoutWeight(1).height(48)
.backgroundColor(this.selTab === i ? COLORS.fanBg : COLORS.card)
.borderRadius(8)
.justifyContent(FlexAlign.Center)
.onClick(() => { this.selTab = i; })
}, (i: number) => 't2' + i)
}.width('100%')
}
.width('100%').padding({ left: 8, right: 8, top: 6, bottom: 6 })
.backgroundColor(COLORS.card)
}
底部标签栏分为两行,分别由 ROW1_IDX(0,1,2)和 ROW2_IDX(3,4,5)驱动。每个标签项是一个 Column,内含图标(16号字)和标签文字(9号字),高度48像素,通过 layoutWeight(1) 等分宽度。选中态的视觉反馈包括:文字颜色从副标题色变为深色 tabOn,背景色从白色变为极淡的扇面背景色。点击标签时更新 selTab,触发内容区的条件渲染切换。
十二、模态弹窗系统
12.1 遮罩层 modalOverlay()
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
遮罩层构建器接收一个 onClose 回调函数,渲染一个填满全屏的半透明黑色 Column(rgba(0,0,0,0.5))。点击遮罩层任意区域触发 onClose 回调关闭弹窗,这是模态弹窗的标准交互模式。
12.2 新增弹窗 addModal()
@Builder
addModal() {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.showAdd = false; })
Column({ space: 12 }) {
Text('新增记录').fontSize(16).fontColor(COLORS.title)
TextInput({ placeholder: '名称' })
.fontSize(13).fontColor(COLORS.title)
.backgroundColor(COLORS.paper).borderRadius(8)
TextInput({ placeholder: '种类' })
.fontSize(13).fontColor(COLORS.title)
.backgroundColor(COLORS.paper).borderRadius(8)
TextInput({ placeholder: '数值' })
.fontSize(13).fontColor(COLORS.title)
.backgroundColor(COLORS.paper).borderRadius(8)
TextInput({ placeholder: '单价' })
.fontSize(13).fontColor(COLORS.title)
.backgroundColor(COLORS.paper).borderRadius(8)
Row({ space: 10 }) {
Column() {
Text('确定').fontSize(13).fontColor(COLORS.cream)
}
.layoutWeight(1).height(38)
.backgroundColor(COLORS.woodD).borderRadius(8)
.justifyContent(FlexAlign.Center)
.onClick(() => { this.showAdd = false; })
Column() {
Text('取消').fontSize(13).fontColor(COLORS.sub)
}
.layoutWeight(1).height(38)
.backgroundColor(COLORS.line).borderRadius(8)
.justifyContent(FlexAlign.Center)
.onClick(() => { this.showAdd = false; })
}.width('100%')
}
.width('80%').padding(20)
.backgroundColor(COLORS.card).borderRadius(12)
.constraintSize({ maxHeight: '80%' })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
.backgroundColor(COLORS.transparent)
}
新增弹窗使用 Stack 叠层,先渲染遮罩层,再在中心位置渲染弹窗主体。弹窗主体是一个 Column,宽度80%,内含标题、四个输入框(名称、种类、数值、单价)和确定/取消按钮行。输入框背景使用纸张色 COLORS.paper,与白色弹窗背景形成微妙的层次差异。确定按钮背景为深木色,文字为奶油色;取消按钮背景为分割线色,文字为副标题色。zIndex(999) 确保弹窗浮于所有内容之上。constraintSize({ maxHeight: '80%' }) 防止弹窗内容过多时溢出屏幕。
12.3 编辑弹窗 editModal() 与删除弹窗 delModal()
编辑弹窗的结构与新增弹窗基本一致,差异在于标题动态显示"编辑第 X 条记录"(this.editIdx + 1),按钮文字为"保存"而非"确定"。删除弹窗则更为紧凑——宽度70%,标题"确认删除",下方有删除项说明和警示文字"删除后不可恢复,请谨慎操作",确认按钮使用红色背景 COLORS.red 强调危险操作的视觉警示。
十三、系统架构总览流程图
十四、技术对比总结表
| 技术维度 | 实现方式 | 核心特点 | 适用场景 |
|---|---|---|---|
| 色彩体系 | interface + const 常量 | 24种语义化颜色字段,暖棕米白基调 | 全局视觉一致性保障 |
| 状态管理 | @State + @Observed | 13个状态变量,6个可观察实体类 | 数据驱动的UI自动更新 |
| 动画驱动 | setInterval 700ms + breath 布尔 | 单一定时器驱动所有呼吸动画 | 轻量级周期性动画 |
| 渐变头部 | linearGradient 135° 三色 | 深木→木色→背景色过渡 | 模拟扇骨到扇面的色彩过渡 |
| 扇骨动画 | ForEach + rotate + position | 7根扇骨,角度对称分布 | 折扇开合的物理模拟 |
| 倾斜布局 | tiltAngle(idx) 函数 | ±1.5度交替倾斜 | 六大Tab统一的扇形风格 |
| 列表渲染 | ForEach + key生成器 | 以name属性为键 | 高效的差异化渲染 |
| 条件渲染 | if-else 链 + selTab | 6个Tab条件分支 | 内容区动态切换 |
| 模态弹窗 | Stack叠层 + zIndex(999) | 遮罩层+弹窗主体 | 新增/编辑/删除操作 |
| 柱状图表 | 纯CSS Column高度 | 数值×0.15系数计算高度 | 无依赖的轻量级图表 |
| 底部导航 | 两行三列 ForEach | 选中态双色反馈 | 六模块快捷切换 |
| 辅助函数 | ribAngle/ribX/tiltAngle | 索引驱动的角度与位置计算 | 头部动画与列表布局 |
安装DevEco Studio程序

第一次打开需要点击同意一下:

新建一个空白模板:

设置API为24的模板项目:

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

完整代码:
// ============================================================
// 768 折扇坊 · 开合清风
// 头部样式:暖棕米白渐变Banner + 扇骨开合动画 + 扇面渐变 + 巨字水印
// 布局风格:扇形展开式布局(卡片以rotate旋转弧度排列,偶数行反向倾斜)
// 底部 6 tab 两行
// ============================================================
interface ColorPalette {
bg: string;
card: string;
paper: string;
title: string;
sub: string;
text3: string;
ink: string;
wood: string;
woodL: string;
woodD: string;
bamboo: string;
rice: string;
fanA: string;
fanB: string;
fanC: string;
gold: string;
cream: string;
red: string;
green: string;
line: string;
tabOn: string;
mask: string;
transparent: string;
fanBg: string;
}
const COLORS: ColorPalette = {
bg: '#F7F2EC',
card: '#FFFFFF',
paper: '#EDE4D6',
title: '#3E2A1A',
sub: '#6B5440',
text3: '#9B8A78',
ink: '#2A1A0A',
wood: '#8B6B4A',
woodL: '#B89878',
woodD: '#5A3E28',
bamboo: '#A08060',
rice: '#FFF8E8',
fanA: '#D4A574',
fanB: '#E8C89A',
fanC: '#C4A06A',
gold: '#B8860B',
cream: '#FFF8E1',
red: '#D32F2F',
green: '#388E3C',
line: '#DCC8B0',
tabOn: '#3E2A1A',
mask: 'rgba(0,0,0,0.5)',
transparent: 'rgba(0,0,0,0)',
fanBg: 'rgba(212,165,116,0.1)'
};
interface TabMeta {
icon: string;
label: string;
}
const TAB_LIST: TabMeta[] = [
{ icon: '骨', label: '扇骨' },
{ icon: '面', label: '扇面' },
{ icon: '画', label: '书画' },
{ icon: '裱', label: '装裱' },
{ icon: '匠', label: '匠人' },
{ icon: '扇', label: '成扇' }
];
const ROW1_IDX: number[] = [0, 1, 2];
const ROW2_IDX: number[] = [3, 4, 5];
const TAG_IDX: number[] = [0, 1, 2, 3];
const TAG_NAME_IDX: string[] = ['扇骨', '扇面', '书画', '成扇'];
const RIB_IDX: number[] = [0, 1, 2, 3, 4, 5, 6];
const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
const MONTH_NAME_IDX: string[] = ['正月', '二月', '三月', '四月', '五月', '六月'];
const SALES_IDX: number[] = [30, 45, 55, 68, 80, 65];
@Observed
export class RibItem {
name: string;
kind: string;
material: string;
price: number;
constructor(name: string, kind: string, material: string, price: number) {
this.name = name; this.kind = kind; this.material = material; this.price = price;
}
}
@Observed
export class FanFaceItem {
name: string;
kind: string;
size: string;
price: number;
constructor(name: string, kind: string, size: string, price: number) {
this.name = name; this.kind = kind; this.size = size; this.price = price;
}
}
@Observed
export class PaintingItem {
name: string;
kind: string;
steps: number;
price: number;
desc: string;
constructor(name: string, kind: string, steps: number, price: number, desc: string) {
this.name = name; this.kind = kind; this.steps = steps; this.price = price; this.desc = desc;
}
}
@Observed
export class MountItem {
name: string;
kind: string;
days: number;
price: number;
constructor(name: string, kind: string, days: number, price: number) {
this.name = name; this.kind = kind; this.days = days; this.price = price;
}
}
@Observed
export class CraftsmanItem {
name: string;
kind: string;
years: number;
price: number;
constructor(name: string, kind: string, years: number, price: number) {
this.name = name; this.kind = kind; this.years = years; this.price = price;
}
}
@Observed
export class FanItem {
name: string;
kind: string;
size: string;
price: number;
constructor(name: string, kind: string, size: string, price: number) {
this.name = name; this.kind = kind; this.size = size; this.price = price;
}
}
const mockRibList: RibItem[] = [
new RibItem('白竹骨', '竹扇骨', '三年湘妃竹', 80),
new RibItem('乌木骨', '木扇骨', '非洲乌木', 120),
new RibItem('紫檀骨', '木扇骨', '印度小叶紫檀', 280),
new RibItem('黄花梨骨', '木扇骨', '海南黄花梨', 380),
new RibItem('湘妃竹骨', '竹扇骨', '湖南斑竹', 150),
new RibItem('梅鹿竹骨', '竹扇骨', '浙江梅鹿竹', 130),
new RibItem('棕竹骨', '竹扇骨', '云南棕竹', 90),
new RibItem('鸡翅木骨', '木扇骨', '缅甸鸡翅木', 100),
new RibItem('沉香骨', '香木骨', '印尼沉香木', 350),
new RibItem('象牙骨', '珍稀骨', '猛犸象牙', 500),
new RibItem('螺钿骨', '镶嵌骨', '鲍鱼贝壳', 280),
new RibItem('大漆骨', '漆艺骨', '推光大漆', 180)
];
const mockFaceList: FanFaceItem[] = [
new FanFaceItem('矾宣', '宣纸扇面', '九寸', 30),
new FanFaceItem('洒金宣', '金宣扇面', '九寸', 50),
new FanFaceItem('冷金笺', '金笺扇面', '九寸', 60),
new FanFaceItem('虎皮宣', '花宣扇面', '九寸', 45),
new FanFaceItem('色宣', '色宣扇面', '九寸', 35),
new FanFaceItem('绢本', '绢丝扇面', '九寸', 80),
new FanFaceItem('泥金笺', '泥金扇面', '九寸', 120),
new FanFaceItem('发笺', '特种宣面', '九寸', 90),
new FanFaceItem('蝉翼宣', '薄宣扇面', '九寸', 40),
new FanFaceItem('罗纹宣', '纹宣扇面', '九寸', 42)
];
const mockPaintingList: PaintingItem[] = [
new PaintingItem('写意花鸟', '水墨画', 1, 80, '以写意手法绘花鸟于扇面。'),
new PaintingItem('工笔仕女', '工笔画', 3, 120, '工笔细描仕女人物。'),
new PaintingItem('青绿山水', '设色画', 2, 100, '青绿设色山水风景。'),
new PaintingItem('墨竹', '水墨画', 1, 60, '写意墨竹,风骨清雅。'),
new PaintingItem('书法行楷', '书法', 1, 50, '行楷书写诗词。'),
new PaintingItem('书法隶书', '书法', 1, 55, '隶书书写扇面。'),
new PaintingItem('泼墨山水', '写意画', 2, 90, '泼墨大写意山水。'),
new PaintingItem('工笔花卉', '工笔画', 3, 110, '工笔设色花卉。'),
new PaintingItem('写意鱼藻', '水墨画', 1, 70, '写意游鱼水藻。'),
new PaintingItem('朱砂钟馗', '人物画', 2, 95, '朱砂绘钟馗像。')
];
const mockMountList: MountItem[] = [
new MountItem('穿面', '基础工序', 1, 20),
new MountItem('糊面', '基础工序', 1, 15),
new MountItem('折面', '成型工序', 1, 25),
new MountItem('裁边', '修整工序', 1, 18),
new MountItem('上骨', '组装工序', 1, 22),
new MountItem('钉铆', '组装工序', 1, 30),
new MountItem('包角', '装饰工序', 2, 35),
new MountItem('题款', '书画工序', 1, 40),
new MountItem('钤印', '收尾工序', 1, 28),
new MountItem('装盒', '包装工序', 1, 12)
];
const mockCraftsmanList: CraftsmanItem[] = [
new CraftsmanItem('王扇骨', '制骨大师', 40, 0),
new CraftsmanItem('李糊面', '糊面师傅', 25, 0),
new CraftsmanItem('张书画', '扇面书画', 35, 0),
new CraftsmanItem('赵装裱', '装裱师傅', 30, 0),
new CraftsmanItem('陈钉铆', '钉铆师傅', 22, 0),
new CraftsmanItem('刘刻骨', '刻骨师傅', 28, 0),
new CraftsmanItem('孙打磨', '打磨师傅', 20, 0),
new CraftsmanItem('周题款', '书法题款', 32, 0),
new CraftsmanItem('吴传承', '非遗传承', 42, 0),
new CraftsmanItem('冯学徒', '学徒', 3, 0)
];
const mockFanList: FanItem[] = [
new FanItem('水墨花鸟扇', '书画折扇', '九寸', 280),
new FanItem('工笔仕女扇', '书画折扇', '九寸', 380),
new FanItem('青绿山水扇', '书画折扇', '九寸', 320),
new FanItem('泥金书法扇', '书法折扇', '九寸', 450),
new FanItem('紫檀水墨扇', '高端折扇', '九寸', 680),
new FanItem('黄花梨工笔扇', '高端折扇', '九寸', 880),
new FanItem('乌木写意扇', '中档折扇', '九寸', 350),
new FanItem('湘妃竹山水扇', '中档折扇', '九寸', 420),
new FanItem('大漆书法扇', '工艺折扇', '九寸', 380),
new FanItem('螺钿花鸟扇', '工艺折扇', '九寸', 550),
new FanItem('素面白竹扇', '入门折扇', '九寸', 120),
new FanItem('洒金行楷扇', '中档折扇', '九寸', 320)
];
function ribAngle(i: number): number {
return (i - 3) * 8;
}
function ribX(i: number): number {
return 120 + i * 35;
}
function tiltAngle(idx: number): number {
return idx % 2 === 0 ? -1.5 : 1.5;
}
@Entry
@Component
struct Page768 {
@State selTab: number = 0;
@State selTag: number = 0;
@State breath: boolean = false;
@State showAdd: boolean = false;
@State showEdit: boolean = false;
@State showDel: boolean = false;
@State editIdx: number = 0;
@State delIdx: number = 0;
@State ribList: RibItem[] = mockRibList;
@State faceList: FanFaceItem[] = mockFaceList;
@State paintList: PaintingItem[] = mockPaintingList;
@State mountList: MountItem[] = mockMountList;
@State craftsmanList: CraftsmanItem[] = mockCraftsmanList;
@State fanList: FanItem[] = mockFanList;
aboutToAppear() {
setInterval(() => { this.breath = !this.breath; }, 700);
}
build() {
Column() {
this.headerType()
Divider().strokeWidth(1).color(COLORS.line)
Scroll() {
Column() {
if (this.selTab === 0) {
this.ribTab()
} else if (this.selTab === 1) {
this.faceTab()
} else if (this.selTab === 2) {
this.paintTab()
} else if (this.selTab === 3) {
this.mountTab()
} else if (this.selTab === 4) {
this.craftsmanTab()
} else {
this.fanTab()
}
this.salesChart()
}.width('100%').padding({ left: 10, right: 10, top: 10, bottom: 100 })
}.constraintSize({ maxHeight: '70%' }).scrollBar(BarState.Off)
this.tabBar()
if (this.showAdd) {
this.addModal()
}
if (this.showEdit) {
this.editModal()
}
if (this.showDel) {
this.delModal()
}
}.width('100%').height('100%').backgroundColor(COLORS.bg)
}
@Builder
headerType() {
Stack({ alignContent: Alignment.TopStart }) {
Column()
.width('100%')
.height(170)
.linearGradient({
angle: 135,
colors: [[COLORS.woodD, 0.0], [COLORS.wood, 0.5], [COLORS.bg, 1.0]]
})
Row() {
Text('折扇')
.fontSize(24)
.fontColor(COLORS.cream)
.fontWeight(FontWeight.Bold)
Text('·开合清风')
.fontSize(14)
.fontColor(COLORS.woodL)
.margin({ left: 6 })
}
.position({ x: 14, y: 12 })
TextInput({ placeholder: '搜索扇骨/扇面/成扇' })
.fontSize(13).fontColor(COLORS.title)
.placeholderColor(COLORS.text3)
.backgroundColor(COLORS.card).borderRadius(8)
.margin({ left: 12, right: 12, top: 44 })
Row({ space: 6 }) {
ForEach(TAG_IDX, (t: number) => {
Column() {
Text(TAG_NAME_IDX[t]).fontSize(11)
.fontColor(this.selTag === t ? COLORS.cream : COLORS.wood)
}
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(this.selTag === t ? COLORS.woodD : COLORS.fanBg)
.borderRadius(12)
.onClick(() => { this.selTag = t; this.selTab = t; })
}, (t: number) => 'tg' + t)
}
.width('100%').padding({ left: 12, right: 12 })
.position({ x: 0, y: 88 })
Row() {
Text('+').fontSize(14).fontColor(COLORS.cream)
Text('新增').fontSize(12).fontColor(COLORS.cream).margin({ left: 4 })
}
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(COLORS.fanC).borderRadius(8)
.position({ x: 270, y: 88 })
.onClick(() => { this.showAdd = true; })
ForEach(RIB_IDX, (r: number) => {
Column()
.width(2)
.height(this.breath ? 35 : 15)
.backgroundColor(COLORS.fanA)
.rotate({ angle: ribAngle(r) + (this.breath ? 10 : 0) })
.position({ x: ribX(r), y: 130 })
.animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })
}, (r: number) => 'rb' + r)
Text('扇')
.fontSize(48)
.fontColor('rgba(255,255,255,0.1)')
.fontWeight(FontWeight.Bold)
.position({ x: 260, y: 100 })
}
.width('100%').height(170)
}
@Builder
ribTab() {
Column({ space: 6 }) {
Text('扇骨 · 扇形倾斜排列').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%').padding({ left: 4, top: 4, bottom: 4 })
ForEach(this.ribList, (item: RibItem, idx: number) => {
Row() {
Column() {
Column()
.width(3).height(30)
.backgroundColor(idx % 3 === 0 ? COLORS.woodD : idx % 3 === 1 ? COLORS.fanA : COLORS.bamboo)
.borderRadius(2)
.rotate({ angle: tiltAngle(idx) * 3 })
}.width(30)
Column({ space: 3 }) {
Text(item.name).fontSize(13).fontColor(COLORS.title)
Row() {
Text(item.kind).fontSize(10).fontColor(COLORS.sub)
Text(' · ' + item.material).fontSize(10).fontColor(COLORS.text3)
}.width('100%')
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 6 })
Text('Y' + item.price).fontSize(13).fontColor(COLORS.woodD).fontWeight(FontWeight.Bold)
}
.width('100%').padding({ left: 10, right: 10, top: 8, bottom: 8 })
.backgroundColor(COLORS.card).borderRadius(8)
.margin({ top: 4 })
.rotate({ angle: tiltAngle(idx) })
.onClick(() => { this.editIdx = idx; this.showEdit = true; })
}, (item: RibItem) => item.name)
}.width('100%')
}
@Builder
faceTab() {
Column({ space: 6 }) {
Row() {
Text('+').fontSize(16).fontColor(COLORS.tabOn)
Text('新增扇面').fontSize(13).fontColor(COLORS.tabOn).margin({ left: 4 })
}
.width('100%').padding({ left: 12, top: 8, bottom: 8 })
.backgroundColor(COLORS.card).borderRadius(8)
.onClick(() => { this.showAdd = true; })
ForEach(this.faceList, (item: FanFaceItem, idx: number) => {
Row() {
Column()
.width(30).height(30)
.backgroundColor(idx % 2 === 0 ? COLORS.fanBg : COLORS.rice)
.borderRadius(4)
.rotate({ angle: tiltAngle(idx) * 2 })
Column({ space: 3 }) {
Text(item.name).fontSize(13).fontColor(COLORS.title)
Row() {
Text(item.kind).fontSize(10).fontColor(COLORS.sub)
Text(' · ' + item.size).fontSize(10).fontColor(COLORS.text3)
}.width('100%')
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 8 })
Text('Y' + item.price).fontSize(13).fontColor(COLORS.woodD).fontWeight(FontWeight.Bold)
}
.width('100%').padding(10)
.backgroundColor(idx % 2 === 0 ? COLORS.card : COLORS.paper)
.borderRadius(8).margin({ top: 4 })
.rotate({ angle: tiltAngle(idx) })
}, (item: FanFaceItem) => item.name)
}.width('100%')
}
@Builder
paintTab() {
Column({ space: 6 }) {
ForEach(this.paintList, (item: PaintingItem, idx: number) => {
Column({ space: 6 }) {
Row() {
Column() {
Text(item.steps.toString())
.fontSize(14).fontColor(COLORS.cream).fontWeight(FontWeight.Bold)
Text('步').fontSize(7).fontColor(COLORS.cream)
}
.width(28).height(28)
.backgroundColor(idx % 3 === 0 ? COLORS.woodD : idx % 3 === 1 ? COLORS.fanC : COLORS.fanA)
.borderRadius(6)
.justifyContent(FlexAlign.Center)
Column({ space: 2 }) {
Text(item.name).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(item.kind).fontSize(10).fontColor(COLORS.sub)
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 8 })
Text('Y' + item.price).fontSize(13).fontColor(COLORS.woodD).fontWeight(FontWeight.Bold)
}.width('100%')
Text(item.desc).fontSize(11).fontColor(COLORS.text3)
Row({ space: 8 }) {
Text('编辑').fontSize(10).fontColor(COLORS.tabOn)
.onClick(() => { this.editIdx = idx; this.showEdit = true; })
Text('删除').fontSize(10).fontColor(COLORS.red)
.onClick(() => { this.delIdx = idx; this.showDel = true; })
}.width('100%')
}
.width('100%').padding(10)
.backgroundColor(COLORS.card).borderRadius(8)
.margin({ top: 4 })
.rotate({ angle: tiltAngle(idx) })
}, (item: PaintingItem) => item.name)
}.width('100%')
}
@Builder
mountTab() {
Column({ space: 6 }) {
ForEach(this.mountList, (item: MountItem, idx: number) => {
Row() {
Column() {
Text(item.days.toString())
.fontSize(14).fontColor(COLORS.fanA).fontWeight(FontWeight.Bold)
Text('天').fontSize(7).fontColor(COLORS.fanA)
}.width(36)
Column({ space: 3 }) {
Text(item.name).fontSize(13).fontColor(COLORS.title)
Text(item.kind).fontSize(10).fontColor(COLORS.sub)
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 6 })
Text('Y' + item.price).fontSize(13).fontColor(COLORS.woodD).fontWeight(FontWeight.Bold)
}
.width('100%').padding(10)
.backgroundColor(idx % 2 === 0 ? COLORS.card : COLORS.paper)
.borderRadius(8).margin({ top: 4 })
.rotate({ angle: tiltAngle(idx) })
.onClick(() => { this.editIdx = idx; this.showEdit = true; })
}, (item: MountItem) => item.name)
}.width('100%')
}
@Builder
craftsmanTab() {
Column({ space: 6 }) {
ForEach(this.craftsmanList, (item: CraftsmanItem, idx: number) => {
Row() {
Column()
.width(34).height(34)
.backgroundColor(COLORS.fanBg).borderRadius(6)
.justifyContent(FlexAlign.Center)
.rotate({ angle: tiltAngle(idx) * 2 })
Column({ space: 2 }) {
Text(item.name).fontSize(13).fontColor(COLORS.title)
Text(item.kind).fontSize(10).fontColor(COLORS.sub)
}.layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
Text(item.years + '年').fontSize(11).fontColor(COLORS.text3)
Row({ space: 8 }) {
Text('编').fontSize(9).fontColor(COLORS.tabOn)
.onClick(() => { this.editIdx = idx; this.showEdit = true; })
Text('删').fontSize(9).fontColor(COLORS.red)
.onClick(() => { this.delIdx = idx; this.showDel = true; })
}.margin({ left: 6 })
}
.width('100%').padding(10)
.backgroundColor(COLORS.card).borderRadius(8)
.margin({ top: 4 })
.rotate({ angle: tiltAngle(idx) })
}, (item: CraftsmanItem) => item.name)
}.width('100%')
}
@Builder
fanTab() {
Column({ space: 6 }) {
Row() {
Text('+').fontSize(16).fontColor(COLORS.tabOn)
Text('新增成扇').fontSize(13).fontColor(COLORS.tabOn).margin({ left: 4 })
}
.width('100%').padding({ left: 12, top: 8, bottom: 8 })
.backgroundColor(COLORS.card).borderRadius(8)
.onClick(() => { this.showAdd = true; })
ForEach(this.fanList, (item: FanItem, idx: number) => {
Row() {
Column({ space: 3 }) {
Text(item.name).fontSize(13).fontColor(COLORS.title)
Row() {
Text(item.kind).fontSize(10).fontColor(COLORS.sub)
Text(' · ' + item.size).fontSize(10).fontColor(COLORS.text3)
}.width('100%')
}.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('Y' + item.price).fontSize(13).fontColor(COLORS.fanC).fontWeight(FontWeight.Bold)
}
.width('100%').padding(10)
.backgroundColor(idx % 2 === 0 ? COLORS.card : COLORS.paper)
.borderRadius(8).margin({ top: 4 })
.rotate({ angle: tiltAngle(idx) })
}, (item: FanItem) => item.name)
}.width('100%')
}
@Builder
salesChart() {
Column({ space: 8 }) {
Row() {
Text('月度成扇产量').fontSize(14).fontColor(COLORS.title).layoutWeight(1)
Text('把').fontSize(11).fontColor(COLORS.text3)
}.width('100%')
Row({ space: 4 }) {
ForEach(MONTH_IDX, (m: number) => {
Column({ space: 4 }) {
Text(SALES_IDX[m].toString())
.fontSize(9).fontColor(COLORS.sub)
Column()
.width(24)
.height(SALES_IDX[m] * 0.15)
.backgroundColor(m % 2 === 0 ? COLORS.woodD : COLORS.fanA)
.borderRadius(4)
Text(MONTH_NAME_IDX[m])
.fontSize(9)
.fontColor(COLORS.text3)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (m: number) => 'ch' + m)
}
.width('100%').height(60)
.alignItems(VerticalAlign.Bottom)
}
.width('100%').padding(12)
.backgroundColor(COLORS.card).borderRadius(10)
.margin({ top: 10 })
}
@Builder
tabBar() {
Column({ space: 6 }) {
Row() {
ForEach(ROW1_IDX, (i: number) => {
Column({ space: 2 }) {
Text(TAB_LIST[i].icon).fontSize(16).fontColor(this.selTab === i ? COLORS.tabOn : COLORS.sub)
Text(TAB_LIST[i].label).fontSize(9).fontColor(this.selTab === i ? COLORS.tabOn : COLORS.sub)
}
.layoutWeight(1).height(48)
.backgroundColor(this.selTab === i ? COLORS.fanBg : COLORS.card)
.borderRadius(8)
.justifyContent(FlexAlign.Center)
.onClick(() => { this.selTab = i; })
}, (i: number) => 't1' + i)
}.width('100%')
Row() {
ForEach(ROW2_IDX, (i: number) => {
Column({ space: 2 }) {
Text(TAB_LIST[i].icon).fontSize(16).fontColor(this.selTab === i ? COLORS.tabOn : COLORS.sub)
Text(TAB_LIST[i].label).fontSize(9).fontColor(this.selTab === i ? COLORS.tabOn : COLORS.sub)
}
.layoutWeight(1).height(48)
.backgroundColor(this.selTab === i ? COLORS.fanBg : COLORS.card)
.borderRadius(8)
.justifyContent(FlexAlign.Center)
.onClick(() => { this.selTab = i; })
}, (i: number) => 't2' + i)
}.width('100%')
}
.width('100%').padding({ left: 8, right: 8, top: 6, bottom: 6 })
.backgroundColor(COLORS.card)
}
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
@Builder
addModal() {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.showAdd = false; })
Column({ space: 12 }) {
Text('新增记录').fontSize(16).fontColor(COLORS.title)
TextInput({ placeholder: '名称' })
.fontSize(13).fontColor(COLORS.title)
.backgroundColor(COLORS.paper).borderRadius(8)
TextInput({ placeholder: '种类' })
.fontSize(13).fontColor(COLORS.title)
.backgroundColor(COLORS.paper).borderRadius(8)
TextInput({ placeholder: '数值' })
.fontSize(13).fontColor(COLORS.title)
.backgroundColor(COLORS.paper).borderRadius(8)
TextInput({ placeholder: '单价' })
.fontSize(13).fontColor(COLORS.title)
.backgroundColor(COLORS.paper).borderRadius(8)
Row({ space: 10 }) {
Column() {
Text('确定').fontSize(13).fontColor(COLORS.cream)
}
.layoutWeight(1).height(38)
.backgroundColor(COLORS.woodD).borderRadius(8)
.justifyContent(FlexAlign.Center)
.onClick(() => { this.showAdd = false; })
Column() {
Text('取消').fontSize(13).fontColor(COLORS.sub)
}
.layoutWeight(1).height(38)
.backgroundColor(COLORS.line).borderRadius(8)
.justifyContent(FlexAlign.Center)
.onClick(() => { this.showAdd = false; })
}.width('100%')
}
.width('80%').padding(20)
.backgroundColor(COLORS.card).borderRadius(12)
.constraintSize({ maxHeight: '80%' })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
.backgroundColor(COLORS.transparent)
}
@Builder
editModal() {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.showEdit = false; })
Column({ space: 12 }) {
Text('编辑第 ' + (this.editIdx + 1) + ' 条记录').fontSize(16).fontColor(COLORS.title)
TextInput({ placeholder: '名称' })
.fontSize(13).fontColor(COLORS.title)
.backgroundColor(COLORS.paper).borderRadius(8)
TextInput({ placeholder: '种类' })
.fontSize(13).fontColor(COLORS.title)
.backgroundColor(COLORS.paper).borderRadius(8)
TextInput({ placeholder: '数值' })
.fontSize(13).fontColor(COLORS.title)
.backgroundColor(COLORS.paper).borderRadius(8)
TextInput({ placeholder: '单价' })
.fontSize(13).fontColor(COLORS.title)
.backgroundColor(COLORS.paper).borderRadius(8)
Row({ space: 10 }) {
Column() {
Text('保存').fontSize(13).fontColor(COLORS.cream)
}
.layoutWeight(1).height(38)
.backgroundColor(COLORS.woodD).borderRadius(8)
.justifyContent(FlexAlign.Center)
.onClick(() => { this.showEdit = false; })
Column() {
Text('取消').fontSize(13).fontColor(COLORS.sub)
}
.layoutWeight(1).height(38)
.backgroundColor(COLORS.line).borderRadius(8)
.justifyContent(FlexAlign.Center)
.onClick(() => { this.showEdit = false; })
}.width('100%')
}
.width('80%').padding(20)
.backgroundColor(COLORS.card).borderRadius(12)
.constraintSize({ maxHeight: '80%' })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
.backgroundColor(COLORS.transparent)
}
@Builder
delModal() {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.showDel = false; })
Column({ space: 12 }) {
Text('确认删除').fontSize(16).fontColor(COLORS.title)
Text('删除第 ' + (this.delIdx + 1) + ' 条记录?').fontSize(13).fontColor(COLORS.sub)
Text('删除后不可恢复,请谨慎操作。').fontSize(11).fontColor(COLORS.text3)
Row({ space: 10 }) {
Column() {
Text('确认删除').fontSize(13).fontColor(COLORS.cream)
}
.layoutWeight(1).height(38)
.backgroundColor(COLORS.red).borderRadius(8)
.justifyContent(FlexAlign.Center)
.onClick(() => { this.showDel = false; })
Column() {
Text('取消').fontSize(13).fontColor(COLORS.sub)
}
.layoutWeight(1).height(38)
.backgroundColor(COLORS.line).borderRadius(8)
.justifyContent(FlexAlign.Center)
.onClick(() => { this.showDel = false; })
}.width('100%')
}
.width('70%').padding(20)
.backgroundColor(COLORS.card).borderRadius(12)
.constraintSize({ maxHeight: '80%' })
}
.width('100%').height('100%')
.position({ x: 0, y: 0 })
.zIndex(999)
.backgroundColor(COLORS.transparent)
}
}
十五、深度总结
纵观整个折扇工艺数字化管理系统的技术实现,可以发现其在设计理念上体现了"形式追随内容"的核心原则。系统并非简单地将数据列表堆砌在屏幕上,而是通过一系列精心设计的技术手段,让界面的视觉形态本身成为折扇文化的表达载体。
在色彩层面,系统以暖棕米白为主基调,通过24种语义化颜色字段构建了完整的色彩体系。从深木色 #5A3E28 到背景米色 #F7F2EC,整个色域模拟了从老木扇骨到宣纸扇面的自然色彩过渡,让用户在浏览过程中潜移默化地感受到折扇材质的温润质感。三色渐变头部的135度角线性渐变,更是将这种色彩叙事直接呈现在屏幕顶部,成为整个系统的视觉门面。
在布局层面,系统独创性地引入了"扇形倾斜排列"的布局策略。通过 tiltAngle(idx) 函数的±1.5度交替倾斜,所有六个 Tab 的列表项都呈现出微微波动的扇面效果。这种倾斜幅度极为微妙——肉眼几乎无法察觉单张卡片的倾斜,但当多张卡片连续排列时,整体上便形成了一种如同折扇扇面般缓缓展开的视觉韵律。这一设计是整个系统在布局层面最具辨识度的特征。
在动画层面,系统采用了"单定时器多消费者"的动画驱动架构。一个700毫秒的 setInterval 定时器翻转 breath 布尔值,所有依赖于 breath 的 UI 属性(扇骨高度、旋转角度、透明度等)都会随之同步变化。配合 animation 属性的 PlayMode.Alternate 往返模式和 iterations: -1 无限循环,实现了流畅自然的呼吸效果。这种设计避免了为每个动画元素单独维护定时器的复杂性,极大地简化了动画管理的代码量。
在交互层面,系统实现了完整的 CRUD 操作闭环。新增按钮、列表项点击、编辑和删除操作按钮分别触发三个模态弹窗,弹窗通过 Stack 叠层 + zIndex(999) + 遮罩层点击关闭的标准模式实现。编辑弹窗动态显示操作索引(“编辑第X条记录”),删除弹窗使用红色按钮提供视觉警示,体现了对用户操作安全性的考量。

在数据层面,系统通过 @Observed 装饰器将六个实体类标记为可观察对象,配合 @State 数组绑定,实现了数据变更到 UI 更新的自动驱动。六个数据模型涵盖了扇骨、扇面、书画、装裱、匠人、成扇六大业务维度,每个模型都根据其业务特点设计了差异化的字段结构——扇骨有材质、书画有步骤数和描述、装裱有天数、匠人有年限——这种"因业务而异"的数据建模方式保证了信息表达的精准性。
更多推荐


所有评论(0)