珊瑚海洋馆:HarmonyOS ArkTS API 24基于 ArkUI 声明式范式的全场景水族馆管理应用深度解析,被 getKindColor 函数映射为颜色标签,实现了数据与视觉的联动
引言

在移动互联网与物联网深度融合的今天,水族馆作为集科普教育、休闲娱乐与生态保护于一体的公共场馆,其运营管理数字化需求日益迫切。一座中型水族馆通常涉及展缸监控、鱼种管理、演出排期、文创零售与会员体系等多个业务板块,每个板块都拥有独立的数据模型与交互逻辑。传统的单体管理后台往往以表格和表单为主,缺乏直观的可视化呈现,运营人员需要在多个系统间频繁切换,效率低下且容易出错。珊瑚海洋馆应用正是为解决这一痛点而设计,它将展缸、鱼种、表演、商店与年卡五大业务场景统一整合在一个移动端应用中,通过声明式 UI 框架构建出视觉一致、交互流畅的管理界面。
在设计理念层面,本应用采用了"海洋蓝"主题色彩体系,以深蓝为基调、珊瑚粉为点缀、气泡白为过渡,营造出沉浸式的水下视觉氛围。这种色彩选择并非随意为之,而是经过深思熟虑的设计决策:蓝色系能够传达专业、冷静与信任感,契合场馆管理的严肃属性;珊瑚粉和暖橙色则为界面注入活力与温度,避免纯蓝调带来的冰冷感;而气泡白与浅蓝的层叠运用,在视觉上模拟了水中气泡上升的动态效果,增强了主题沉浸感。整个色彩体系通过一个统一的接口对象进行集中管理,确保了所有组件在颜色引用上的一致性与可维护性。
技术选型方面,本应用基于 ArkUI 声明式开发范式构建。ArkUI 是鸿蒙生态中的核心 UI 框架,它采用类装饰器的组件声明语法,通过 @Entry、@Component、@State、@Prop、@ObjectLink 等装饰器实现组件化与状态驱动。相较于传统的命令式 UI 编程,声明式范式让开发者只需描述界面"是什么"而非"怎么做",框架自动处理状态变化到 UI 更新的映射,大幅降低了复杂界面的维护成本。此外,ArkUI 内置的 ForEach、Scroll、Column、Row 等容器组件提供了强大的布局能力,配合 layoutWeight 弹性权重机制,能够精确实现响应式布局。
组件化策略是本应用的另一大亮点。整个应用被拆分为一个主入口组件、五个内容页面组件、一个标签组件和四个弹窗组件,共计十一个独立组件。每个组件职责单一、接口清晰,通过回调函数实现跨组件通信。主入口组件充当协调者角色,负责标签切换与弹窗显隐的状态管理;内容页面组件专注于数据展示与图表渲染;弹窗组件则封装了表单交互逻辑。这种分层架构使得各组件可以独立开发、测试与维护,当需求变更时只需修改对应组件而不影响其他部分。@Observed 与 @ObjectLink 的配合使用实现了跨层级的数据响应式传递,让深层嵌套的子组件也能自动感知数据变化并触发重渲染。
在数据架构层面,应用采用了一个 @Observed 装饰的中央数据类集中持有所有业务数据,包括展缸列表、鱼种列表、演出列表、商品列表和年卡列表。这种"单一数据源"模式简化了状态管理复杂度,避免了多组件各自维护数据副本导致的同步问题。同时,应用还预置了周客流、鱼种分布、表演上座率与商品销量等统计型常量数据,用于驱动各页面的图表可视化模块,使运营数据一目了然。
整体架构概览

状态管理与数据流向

一、色彩体系接口定义

interface AquaPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
cardAlt: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
line: string;
success: string;
warning: string;
danger: string;
white: string;
coral: string;
bubble: string;
}
这段代码定义了一个名为 AquaPalette 的接口,它是整个应用色彩体系的核心契约。接口中声明了十九个字符串类型的属性,涵盖了主色、辅助色、背景色、文字色、边框色和语义色等完整的设计令牌(Design Token)类别。通过接口而非直接使用常量对象,开发者获得了类型安全保证——任何引用色彩体系的代码都会在编译期接受类型检查,如果引用了不存在的颜色属性将直接报错。这种设计模式在企业级项目中尤为常见,它将设计规范以代码形式固化,避免了设计师与开发者之间因沟通不畅导致的色彩偏差。接口中的属性命名采用了语义化命名策略,如 textPrimary、textSecondary、textHint 分别表示主要文字、次要文字和提示文字的颜色,而非 blue1、blue2 这类难以理解的编号命名。
二、色彩常量实例化

const COLORS: AquaPalette = {
primary: '#1890C9',
primaryLight: '#7CC7E8',
primaryDark: '#0B4E7A',
accent: '#FF7F9E',
accentLight: '#FFC4D3',
bg: '#EAF6FB',
cardBg: '#FFFFFF',
cardAlt: '#DFF1F9',
textPrimary: '#0E3A52',
textSecondary: '#5C8CA3',
textHint: '#A3C4D4',
border: '#D0EAF3',
line: '#D6EBF4',
success: '#3FB98A',
warning: '#F5A623',
danger: '#EF5B5B',
white: '#FFFFFF',
coral: '#FF7F9E',
bubble: '#BDE6F5'
};
COLORS 常量是 AquaPalette 接口的唯一实例化对象,它将所有十六进制色值集中在一处进行管理。主色 #1890C9 是一种偏青的海洋蓝,饱和度适中、明度适中,既能在浅色背景上保持足够对比度,又不会在深色区域显得过于刺眼。辅助色 #FF7F9E 是珊瑚粉色,与主色形成冷暖对比,用于强调按钮、标签和关键数据。背景色 #EAF6FB 是极浅的天蓝色,为整个应用奠定了水族馆主题基调。值得注意的是 cardBg(纯白)与 cardAlt(浅蓝)的搭配使用,在列表项中通过奇偶交替实现斑马纹效果,提升了信息可读性。语义色方面,success 用翠绿、warning 用琥珀橙、danger 用珊瑚红,遵循了业界通用的色彩语义约定。将 coral 与 accent 设为同色值,说明珊瑚粉在本应用中既是辅助色也是品牌强调色,体现了色彩复用的经济性原则。
三、标签枚举定义

enum AquaTab {
TANK = 0,
FISH = 1,
SHOW = 2,
SHOP = 3,
CARD = 4
}
AquaTab 枚举定义了应用底部导航栏的五个标签索引,分别对应展缸、鱼种、表演、商店和年卡五个业务场景。使用枚举而非魔法数字(magic number)是工程化开发的基本准则,它赋予了数值以语义含义,使得代码可读性大幅提升。枚举成员从零开始递增,与 ForEach 渲染列表的索引天然对齐,简化了标签切换的判断逻辑。在实际的 build 方法中,主组件通过 this.curTab === AquaTab.TANK 这样的比较来决定渲染哪个内容页面,语义清晰且不易出错。枚举值的连续性还便于后续扩展——如果需要新增标签,只需在末尾追加枚举成员并更新标签列表即可,不会破坏现有逻辑。这种设计体现了开闭原则:对扩展开放,对修改封闭。
四、标签元数据接口与列表

interface TabMeta {
key: string;
icon: string;
label: string;
color: string;
}
const TAB_LIST: TabMeta[] = [
{ key: 'tank', icon: '🫧', label: '展缸', color: '#1890C9' },
{ key: 'fish', icon: '🐠', label: '鱼种', color: '#FF7F9E' },
{ key: 'show', icon: '🐬', label: '表演', color: '#F5A623' },
{ key: 'shop', icon: '🐚', label: '商店', color: '#3FB98A' },
{ key: 'card', icon: '🎫', label: '年卡', color: '#7A6BD6' }
];
TabMeta 接口为每个标签定义了四元数据结构:唯一键值、Emoji 图标、中文标签和主题色。将标签数据以数组形式集中管理,使得底部导航栏可以通过 ForEach 循环动态生成,无需为每个标签手写重复的 UI 代码。这种数据驱动 UI 的方式是声明式范式的精髓所在——开发者只需维护一份数据源,框架自动完成渲染映射。每个标签的 color 属性在选中状态下用于边框着色,为不同业务场景赋予了视觉区分度。Emoji 图标的选择也颇具巧思:气泡代表展缸、热带鱼代表鱼种、海豚代表表演、贝壳代表商店、门票代表年卡,每个图标都与业务含义高度关联。key 属性在 ForEach 的键值生成函数中被用作唯一标识,确保列表渲染时能正确进行 diff 比对,避免不必要的重绘。
五、气泡列索引常量
const BUBBLE_COL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
BUBBLE_COL 是一个包含十个数字元素的简单数组,专门用于驱动顶部 Header 区域的气泡装饰行渲染。虽然它只是一个数字序列,但通过 ForEach 遍历时,每个元素的索引 i 被用于计算气泡的高度和透明度变化,从而营造出大小不一、错落有致的水下气泡效果。将这样一个简单数组提取为模块级常量而非内联在组件内部,体现了代码组织的一致性原则——所有可复用的数据都应提升到模块作用域。这种做法还便于后续调整气泡数量:只需修改数组长度即可全局生效。值得注意的是,数组中的实际数值在本场景中并不参与计算,真正被使用的是 ForEach 提供的索引参数,因此这个数组本质上是一个"计数器常量"。
六、展缸与鱼种数据模型
interface TankItem {
name: string;
zone: string;
fishCount: number;
volume: number;
temp: number;
emoji: string;
}
interface FishItem {
name: string;
kind: string;
origin: string;
price: number;
count: number;
emoji: string;
}
TankItem 和 FishItem 分别定义了展缸与鱼种的数据结构。展缸模型包含名称、区域、鱼数量、水体体积、水温与图标六个字段,涵盖了运营人员最关心的展缸核心指标。水温字段尤为关键,因为不同鱼类对水温的适应范围差异巨大——热带鱼需要 24-28 度而极地鱼需要 4-6 度,水温异常是展缸管理中的高频告警项。鱼种模型则包含名称、分类、产地、单价、存量与图标,其中分类字段(如"热带鱼"“冷水鱼”)不仅用于信息展示,还被 getKindColor 函数映射为颜色标签,实现了数据与视觉的联动。两个接口都包含了 emoji 字段,体现了应用在视觉表达上对 Emoji 图标的全面依赖——相较于加载图片资源,Emoji 具有体积小、渲染快、跨平台一致的优势,非常适合移动端的轻量级展示场景。
七、演出与商品数据模型
interface ShowItem {
name: string;
time: string;
seats: number;
joined: number;
type: string;
emoji: string;
}
interface ShopItem {
name: string;
price: number;
sales: number;
stock: number;
category: string;
emoji: string;
}
ShowItem 定义了演出场次的数据结构,包含名称、时间、座位总数、已售数、类型标签和图标。seats 与 joined 的比值即为上座率,是演出排期与场馆调度的核心指标。time 字段采用自由文本格式(如 “11:00 15:00”),可灵活表示单场或多场演出时间,虽然牺牲了一定的结构化查询能力,但在展示层面具有足够的表达力。ShopItem 模型则关注商品零售维度,包含价格、销量、库存与分类。库存字段通过 stock > 30 的阈值判断来切换"充足"与"告急"两种状态显示,这是一个典型的业务规则编码示例。两个接口同样遵循了"emoji 作为视觉标识"的设计约定,保持了数据模型层面的风格统一。type 和 category 字段分别通过 getShowColor 和 getCategoryColor 函数进行颜色映射,实现了分类标签的视觉差异化。
八、年卡与统计元数据接口
interface CardItem {
name: string;
price: number;
visits: number;
perks: string;
level: string;
emoji: string;
}
interface WeekMeta {
day: string;
value: number;
}
CardItem 定义了会员年卡的数据结构,包含名称、价格、年访问次数、权益描述、等级和图标。level 字段(如"基础"“家庭”“银卡”“金卡”“钻石”)通过 getLevelColor 函数映射为不同颜色,使卡片列表在视觉上形成等级梯度。visits 字段以"次/年"为单位量化了年卡的价值,方便用户横向比较不同卡种的性价比。WeekMeta 接口则是一个轻量级的统计元数据结构,仅包含星期标签和数值两个字段,专门用于周客流柱状图的数据驱动。这种"一个接口对应一种图表"的设计策略保持了数据模型的聚焦性——每个统计图表的数据结构只包含渲染所需的最小字段集,避免了过度设计。将业务模型与统计模型分离定义,也使得数据来源的语义更加清晰:业务模型来自运营管理,统计模型来自数据分析。
九、图表统计元数据接口群
interface FishMeta {
name: string;
count: number;
color: string;
}
interface ShowJoinMeta {
name: string;
rate: number;
color: string;
}
interface ShopTopMeta {
name: string;
sales: number;
color: string;
}
这三个接口分别服务于鱼种分布图、表演上座率图和商品销量排行榜图,结构高度相似:都包含名称、数值和颜色三个字段。这种结构同构性并非偶然,而是反映了水平进度条图表的通用数据需求——标签文本、进度数值和条形颜色。FishMeta 的 count 表示鱼种类数,ShowJoinMeta 的 rate 表示百分比上座率,ShopTopMeta 的 sales 表示销量件数,虽然语义不同但数据类型和图表渲染逻辑完全一致。color 字段内嵌在数据中而非通过函数动态计算,简化了图表渲染时的颜色获取流程。这种设计虽然牺牲了一定的灵活性(颜色与数据耦合),但在静态展示场景中减少了运行时计算开销,是性能与灵活性的合理权衡。
十、周客流统计数据
const WEEK_VISIT: WeekMeta[] = [
{ day: '周一', value: 32 },
{ day: '周二', value: 38 },
{ day: '周三', value: 35 },
{ day: '周四', value: 42 },
{ day: '周五', value: 58 },
{ day: '周六', value: 96 },
{ day: '周日', value: 88 }
];
WEEK_VISIT 常量定义了一周七天的客流量数据(单位:百人),呈现了典型的周末高峰分布特征。周六以 96(即 9600 人)达到峰值,周日紧随其后为 88,工作日则在 32-42 之间徘徊。这组数据在展缸页面的柱状图中被可视化渲染:柱子高度按 value * 1.2 比例缩放,超过 80 的高值柱用珊瑚粉(coral)标识,其余用主蓝色(primary)填充,实现了数据的视觉分层。将统计数据预置为常量而非从接口获取,是原型开发和演示场景中的常见做法——它确保了应用在无网络环境下也能展示完整功能。在真实生产环境中,这些数据应当替换为从后端 API 动态获取的结果,但由于数据模型接口(WeekMeta)已经定义好,替换过程只需修改数据来源而不影响渲染逻辑,体现了数据与视图解耦的架构优势。
十一、鱼种分布统计数据
const FISH_SHARE: FishMeta[] = [
{ name: '热带鱼', count: 4, color: '#1890C9' },
{ name: '冷水鱼', count: 2, color: '#FF7F9E' },
{ name: '观赏虾', count: 2, color: '#F5A623' },
{ name: '水母', count: 2, color: '#7CC7E8' },
{ name: '珊瑚', count: 1, color: '#3FB98A' },
{ name: '海龟', count: 1, color: '#7A6BD6' }
];
FISH_SHARE 常量记录了馆藏鱼种按分类的分布情况,共六个类别、合计十二种。热带鱼以四类占据最大份额,冷水鱼、观赏虾和水母各两类,珊瑚和海龟各一类。每个分类都携带了专属颜色,与 getKindColor 函数中的颜色映射保持一致,确保了同一分类在整个应用中的视觉标识统一。这组数据在展缸页面以水平进度条形式展示,进度条长度按 count / 4 的比例分配权重(4 是最大值),剩余部分用背景色填充,形成"已填充/未填充"的视觉对比。这种进度条布局利用了 ArkUI 的 layoutWeight 弹性权重机制,将进度比例转化为布局权重,无需手动计算像素宽度即可实现自适应进度条,是声明式布局的典型应用。
十二、表演上座率统计数据
const SHOW_JOIN: ShowJoinMeta[] = [
{ name: '海豚表演', rate: 96, color: '#1890C9' },
{ name: '海狮剧场', rate: 88, color: '#F5A623' },
{ name: '人鲨共舞', rate: 82, color: '#FF7F9E' },
{ name: '水母灯光秀', rate: 75, color: '#7CC7E8' },
{ name: '企鹅喂食', rate: 68, color: '#3FB98A' },
{ name: '夜宿海洋馆', rate: 60, color: '#7A6BD6' }
];
SHOW_JOIN 常量记录了六个核心表演项目的上座率数据,按降序排列。海豚表演以 96% 的上座率高居榜首,夜宿海洋馆以 60% 收尾。rate 字段以百分比形式存储,在渲染时直接用作 layoutWeight 的分子(分母为 100),实现了进度条长度与上座率的精确映射。降序排列使得图表在视觉上呈现从高到低的阶梯效果,运营人员一眼即可识别热门与冷门项目。值得注意的是,这组数据与 AquaData.shows 中的演出列表存在一定的数据冗余——演出列表中也有 joined 和 seats 字段可用于计算上座率。但在实际架构中,统计常量用于概览图表,业务数据用于明细列表,两者服务不同的展示目的,适度冗余换取了渲染逻辑的独立性。
十三、商品销量排行榜数据
const SHOP_TOP: ShopTopMeta[] = [
{ name: '海豚玩偶', sales: 486, color: '#1890C9' },
{ name: '水母夜灯', sales: 372, color: '#7CC7E8' },
{ name: '鲨鱼牙吊坠', sales: 268, color: '#FF7F9E' },
{ name: '企鹅钥匙扣', sales: 330, color: '#3FB98A' },
{ name: '贝壳风铃', sales: 214, color: '#F5A623' },
{ name: '海洋主题T恤', sales: 176, color: '#7A6BD6' }
];
SHOP_TOP 常量记录了商品销量前六名的排行榜数据。海豚玩偶以 486 件销量领跑,海洋主题 T 恤以 176 件垫底。进度条长度按 sales / 490 的比例计算(490 略大于最大值 486,确保最长的进度条不会撑满容器),这种归一化处理是数据可视化的标准做法。排行榜前三名在渲染时用珊瑚粉高亮序号,其余用灰色提示色标识,形成了"奖牌区"与"参与区"的视觉分层。与表演上座率数据类似,这组统计数据与 AquaData.shops 中的商品列表存在字段重叠,但用途不同:排行榜数据按销量降序排列并截取前六名,用于商店页面的 TOP 榜展示;商品列表则保持原始排列顺序,用于完整的商品浏览。
十四、中央数据源:展缸数据
@Observed
export class AquaData {
tanks: TankItem[] = [
{ name: '珊瑚礁主缸', zone: 'A区', fishCount: 1280, volume: 560, temp: 26, emoji: '🪸' },
{ name: '深海隧道', zone: 'A区', fishCount: 860, volume: 1200, temp: 24, emoji: '🌊' },
{ name: '水母馆', zone: 'B区', fishCount: 240, volume: 180, temp: 22, emoji: '🪼' },
{ name: '企鹅馆', zone: 'B区', fishCount: 18, volume: 320, temp: 6, emoji: '🐧' },
{ name: '鲨鱼湾', zone: 'C区', fishCount: 26, volume: 2400, temp: 25, emoji: '🦈' },
{ name: '海龟湾', zone: 'C区', fishCount: 12, volume: 800, temp: 27, emoji: '🐢' },
{ name: '淡水秘境', zone: 'D区', fishCount: 540, volume: 260, temp: 24, emoji: '🐟' },
{ name: '雨林溪流', zone: 'D区', fishCount: 380, volume: 190, temp: 25, emoji: '🌿' },
{ name: '珊瑚培育缸', zone: 'A区', fishCount: 60, volume: 120, temp: 27, emoji: '🪸' },
{ name: '触摸池', zone: 'E区', fishCount: 45, volume: 60, temp: 23, emoji: '✋' },
{ name: '极地冰海', zone: 'B区', fishCount: 32, volume: 900, temp: 4, emoji: '❄️' },
{ name: '海星花园', zone: 'E区', fishCount: 28, volume: 40, temp: 25, emoji: '⭐' }
];
@Observed 装饰器是 ArkUI 响应式系统的核心机制之一,它将 AquaData 类标记为可观察对象,使其内部属性的变化能被框架自动追踪并触发依赖组件的重渲染。export 关键字使该类可被其他模块导入使用。类中第一个属性 tanks 包含了十二个展缸条目,涵盖了从热带珊瑚礁到极地冰海的多种生态环境。每个条目的 zone 字段将展缸划分为 A 到 E 五个区域,便于场馆内的物理定位。volume 字段以吨为单位记录水体容量,鲨鱼湾以 2400 吨居首,触摸池仅 60 吨,体现了展缸规模的巨大差异。temp 字段的范围从 4 度(极地冰海)到 27 度(珊瑚培育缸),跨度达 23 度,在列表渲染时通过 temp <= 10 的阈值切换颜色——低温用浅蓝标识、常温用橙色标识,使运营人员能快速识别需要特殊温控的展缸。
十五、中央数据源:鱼种数据
fishs: FishItem[] = [
{ name: '小丑鱼', kind: '热带鱼', origin: '印度洋', price: 88, count: 96, emoji: '🐠' },
{ name: '蓝吊鱼', kind: '热带鱼', origin: '太平洋', price: 128, count: 64, emoji: '🐟' },
{ name: '火焰神仙鱼', kind: '热带鱼', origin: '红海', price: 268, count: 32, emoji: '🔥' },
{ name: '金鱼', kind: '冷水鱼', origin: '中国', price: 18, count: 140, emoji: '🧡' },
{ name: '锦鲤', kind: '冷水鱼', origin: '日本', price: 68, count: 58, emoji: '🎏' },
{ name: '水晶虾', kind: '观赏虾', origin: '亚洲', price: 38, count: 210, emoji: '🦐' },
{ name: '樱花虾', kind: '观赏虾', origin: '东南亚', price: 28, count: 180, emoji: '🌸' },
{ name: '海月水母', kind: '水母', origin: '全球温带', price: 158, count: 42, emoji: '🪼' },
{ name: '赤月水母', kind: '水母', origin: '太平洋', price: 198, count: 26, emoji: '🌕' },
{ name: '鹿角珊瑚', kind: '珊瑚', origin: '珊瑚海', price: 398, count: 18, emoji: '🪸' },
{ name: '绿海龟', kind: '海龟', origin: '大西洋', price: 880, count: 6, emoji: '🐢' },
{ name: '玳瑁', kind: '海龟', origin: '印度洋', price: 980, count: 4, emoji: '🧿' }
];
fishs 属性包含了十二种鱼及水生生物的详细信息。注意属性名使用了 fishs 而非标准拼写 fishes,这是一个有意为之或历史遗留的命名选择,在代码一致性方面只要全项目统一即可。数据覆盖了热带鱼、冷水鱼、观赏虾、水母、珊瑚和海龟六大分类,与 FISH_SHARE 统计数据完全对应。price 字段从 18 元(金鱼)到 980 元(玳瑁)不等,反映了不同物种的市场价值差异。count 字段表示当前馆藏存量,在列表渲染时通过 count >= 100 的阈值切换颜色——存量充足用绿色标识、存量偏低用橙色预警,实现了库存状态的视觉化提示。origin 字段记录了物种的地理来源,既具有科普价值也为采购决策提供了参考信息。
十六、中央数据源:演出数据
shows: ShowItem[] = [
{ name: '海豚表演', time: '11:00 15:00', seats: 800, joined: 768, type: '必看', emoji: '🐬' },
{ name: '海狮剧场', time: '12:30 16:30', seats: 400, joined: 352, type: '欢乐', emoji: '🦭' },
{ name: '人鲨共舞', time: '13:00 17:00', seats: 300, joined: 246, type: '刺激', emoji: '🦈' },
{ name: '水母灯光秀', time: '14:00 19:00', seats: 500, joined: 375, type: '梦幻', emoji: '🪼' },
{ name: '企鹅喂食', time: '10:30 14:30', seats: 200, joined: 136, type: '亲子', emoji: '🐧' },
{ name: '夜宿海洋馆', time: '20:00 次日', seats: 60, joined: 36, type: '夜场', emoji: '🌙' },
{ name: '海豚合影', time: '全天预约', seats: 40, joined: 29, type: '互动', emoji: '📸' },
{ name: '潜水体验', time: '10:00 15:00', seats: 12, joined: 7, type: '刺激', emoji: '🤿' },
{ name: '美人鱼秀', time: '13:30 17:30', seats: 350, joined: 294, type: '梦幻', emoji: '🧜♀️' },
{ name: '海龟学堂', time: '14:30', seats: 150, joined: 96, type: '亲子', emoji: '🐢' },
{ name: '鲸鲨投喂', time: '11:30', seats: 80, joined: 52, type: '必看', emoji: '🐋' },
{ name: '海洋科普课', time: '周末 10:00', seats: 60, joined: 63, type: '科普', emoji: '📚' }
];
shows 属性包含了十二个演出项目的完整信息,覆盖了表演、互动、科普和夜场等多种类型。time 字段的灵活格式值得一提:多数演出使用空格分隔的多时段格式(如 “11:00 15:00”),夜宿场次使用 “20:00 次日” 表示跨天演出,海豚合影使用 “全天预约” 表示非定时场次,海洋科普课则标注了 “周末” 限定。这种非结构化的时间表示虽然牺牲了程序化解析的便利性,但在人工阅读场景中表达力极强。type 字段通过 getShowColor 函数映射为八种不同的颜色标签(必看、欢乐、刺激、梦幻、亲子、夜场、互动、科普),为用户提供了快速筛选与识别的视觉线索。joined 与 seats 的比值在渲染时通过 joined >= seats * 0.8 的阈值判断来切换"已售"数字的颜色——接近满座用红色警示、销售正常用绿色表示。
十七、中央数据源:商品数据
shops: ShopItem[] = [
{ name: '海豚玩偶', price: 68, sales: 486, stock: 88, category: '玩偶', emoji: '🐬' },
{ name: '水母夜灯', price: 88, sales: 372, stock: 54, category: '家居', emoji: '🪼' },
{ name: '鲨鱼牙吊坠', price: 128, sales: 268, stock: 32, category: '饰品', emoji: '🦈' },
{ name: '企鹅钥匙扣', price: 25, sales: 330, stock: 120, category: '饰品', emoji: '🐧' },
{ name: '贝壳风铃', price: 58, sales: 214, stock: 46, category: '家居', emoji: '🐚' },
{ name: '海洋T恤', price: 89, sales: 176, stock: 38, category: '服饰', emoji: '👕' },
{ name: '珊瑚摆件', price: 168, sales: 128, stock: 14, category: '摆件', emoji: '🪸' },
{ name: '海豚冰箱贴', price: 19, sales: 520, stock: 200, category: '文创', emoji: '🧲' },
{ name: '鲸鱼抱枕', price: 98, sales: 186, stock: 42, category: '玩偶', emoji: '🐳' },
{ name: '海洋拼图', price: 69, sales: 142, stock: 36, category: '益智', emoji: '🧩' },
{ name: '贝壳项链', price: 78, sales: 198, stock: 50, category: '饰品', emoji: '📿' },
{ name: '章鱼玩偶', price: 72, sales: 164, stock: 44, category: '玩偶', emoji: '🐙' }
];
shops 属性包含了十二款文创商品的零售数据,涵盖了玩偶、家居、饰品、服饰、摆件、文创和益智七大品类。category 字段通过 getCategoryColor 函数映射为对应的颜色标签,使得不同品类的商品在列表中具有视觉区分度。stock 字段的阈值判断逻辑(stock > 30 为"充足",否则为"告急")是一个典型的库存预警规则,珊瑚摆件仅剩 14 件会被标红提示。值得注意的是海豚冰箱贴虽然销量最高(520 件),但因其单价最低(19 元),并未出现在 SHOP_TOP 排行榜中——排行榜数据按销量降序排列并截取了不同的商品子集,说明统计常量与业务数据之间存在一定的选择性映射关系,并非简单的全量复制。
十八、中央数据源:年卡数据
cards: CardItem[] = [
{ name: '单人年卡', price: 388, visits: 99, perks: '不限次入园', level: '基础', emoji: '🎫' },
{ name: '双人年卡', price: 688, visits: 199, perks: '两人不限次', level: '基础', emoji: '👥' },
{ name: '家庭年卡', price: 988, visits: 299, perks: '2大1小', level: '家庭', emoji: '👨👩👧' },
{ name: '亲子年卡', price: 888, visits: 249, perks: '1大1小', level: '家庭', emoji: '🧒' },
{ name: '银卡', price: 1288, visits: 365, perks: '表演VIP席', level: '银卡', emoji: '🥈' },
{ name: '金卡', price: 2588, visits: 365, perks: '含潜水体验', level: '金卡', emoji: '🥇' },
{ name: '钻石卡', price: 6888, visits: 365, perks: '全馆通VIP', level: '钻石', emoji: '💎' },
{ name: '学生年卡', price: 288, visits: 99, perks: '学生专享', level: '基础', emoji: '🎓' },
{ name: '老人年卡', price: 258, visits: 99, perks: '60岁+专享', level: '基础', emoji: '👴' },
{ name: '夜游年卡', price: 458, visits: 99, perks: '夜场不限次', level: '夜游', emoji: '🌙' },
{ name: '潜水年卡', price: 3288, visits: 365, perks: '含12次潜水', level: '金卡', emoji: '🤿' },
{ name: '终身卡', price: 9999, visits: 999, perks: '终身畅玩', level: '钻石', emoji: '👑' }
];
}
cards 属性是 AquaData 类的最后一个数据集合,包含了十二种年卡产品。卡种设计层次分明:基础卡覆盖单人、双人、学生和老人群体;家庭卡提供 2 大 1 小和 1 大 1 小两种组合;银卡、金卡和钻石卡构成 VIP 递进体系,价格从 1288 元跃升至 6888 元;终身卡以 9999 元封顶,提供了 999 次访问权益。level 字段通过 getLevelColor 函数映射为五种颜色,银卡使用灰色(#A0A0B0)、金卡使用暗金色(#D4A017),刻意区别于色彩体系中的 warning 橙色,传达了金属质感的等级暗示。price 字段在渲染时通过 price >= 3000 的阈值切换颜色——高价卡用橙色标识、常规卡用蓝色标识,引导用户关注高端产品。perks 字段以简洁短语概括核心权益,适合移动端的快速浏览。
十九、鱼种分类颜色映射函数
function getKindColor(kind: string): string {
if (kind === '热带鱼') {
return '#1890C9';
} else if (kind === '冷水鱼') {
return '#FF7F9E';
} else if (kind === '观赏虾') {
return '#F5A623';
} else if (kind === '水母') {
return '#7CC7E8';
} else if (kind === '珊瑚') {
return '#3FB98A';
}
return '#7A6BD6';
}
getKindColor 函数实现了鱼种分类到颜色的映射逻辑,采用经典的 if-else 链式判断结构。五个已知分类分别对应主蓝、珊瑚粉、琥珀橙、浅蓝和翠绿,未匹配的分类默认返回紫色(#7A6BD6)。虽然 switch-case 或对象字面量映射(lookup table)也能实现相同功能,但 if-else 链在可读性上并不逊色,且在分类数量有限(五到六个)的场景下性能差异可忽略。这个函数在 FishContent 组件中被两处调用:一处用于圆形背景图标的底色(getKindColor(f.kind) + '22',附加透明度后缀),另一处用于 AquaTag 标签的前景色和边框色。通过将颜色逻辑封装为独立函数而非内联在组件中,实现了颜色策略的集中管理——当需要调整某个分类的颜色时,只需修改函数内部的一行代码即可全局生效。
二十、演出类型颜色映射函数
function getShowColor(type: string): string {
if (type === '必看') {
return '#EF5B5B';
} else if (type === '欢乐') {
return '#F5A623';
} else if (type === '刺激') {
return '#1890C9';
} else if (type === '梦幻') {
return '#7CC7E8';
} else if (type === '亲子') {
return '#3FB98A';
} else if (type === '夜场') {
return '#7A6BD6';
} else if (type === '互动') {
return '#FF7F9E';
}
return '#5C8CA3';
}
getShowColor 函数的结构与 getKindColor 完全一致,但映射规模更大——覆盖了七种演出类型。颜色选择上颇有讲究:"必看"使用红色(danger)传达紧迫感与推荐强度;"欢乐"使用橙色(warning)传递温暖愉悦;"刺激"使用主蓝色传达冷静专业;"梦幻"使用浅蓝营造空灵氛围;"亲子"使用绿色暗示安全友好;"夜场"使用紫色暗示神秘;"互动"使用珊瑚粉表达热情。这种语义化的颜色分配使得用户在浏览演出列表时,仅凭标签颜色就能对演出风格形成初步认知,降低了信息筛选成本。默认返回值 #5C8CA3(即 textSecondary 色)为未匹配类型提供了优雅的降级处理,避免出现无色或异常着色的情况。
二十一、商品分类颜色映射函数
function getCategoryColor(category: string): string {
if (category === '玩偶') {
return '#1890C9';
} else if (category === '家居') {
return '#F5A623';
} else if (category === '饰品') {
return '#FF7F9E';
} else if (category === '服饰') {
return '#7A6BD6';
} else if (category === '摆件') {
return '#3FB98A';
} else if (category === '文创') {
return '#7CC7E8';
}
return '#5C8CA3';
}
getCategoryColor 函数覆盖了六种商品分类的颜色映射。与前两个函数相比,它的颜色分配策略更注重品类调性:玩偶作为主打品类使用主蓝色突出地位;家居品类使用暖橙色营造温馨感;饰品使用珊瑚粉传递精致感;服饰使用紫色暗示时尚属性;摆件使用绿色传达自然质感;文创使用浅蓝表达创意清新。当商品数据中新增"益智"分类时(如海洋拼图),该分类不在映射范围内,将使用默认灰色返回值,这在视觉上形成了一种"中性品类"的自然分组。这种设计虽然存在覆盖不全的风险,但在分类体系相对稳定的水族馆文创场景中是可接受的折中方案。若未来分类频繁扩展,可考虑改为对象字面量映射以降低维护成本。
二十二、年卡等级颜色映射函数
function getLevelColor(level: string): string {
if (level === '基础') {
return '#1890C9';
} else if (level === '家庭') {
return '#3FB98A';
} else if (level === '银卡') {
return '#A0A0B0';
} else if (level === '金卡') {
return '#D4A017';
}
return '#7A6BD6';
}
getLevelColor 函数是四个颜色映射函数中最简短的一个,仅覆盖四种年卡等级。银卡使用 #A0A0B0(偏灰的银色)和金卡使用 #D4A017(偏暗的金色)是两个刻意脱离全局色彩体系的颜色值,它们模拟了真实金属的视觉质感。这种"局部色彩例外"的设计决策说明开发者在视觉一致性之上优先考虑了语义准确性——银色和金色作为贵金属的颜色符号,其辨识度远高于任何从全局色板中选取的近似色。钻石卡和夜游卡落入默认分支返回紫色,虽然两者语义截然不同却共享同一颜色,这在当前数据量下不构成问题(两种等级的卡种在列表中相距较远),但如果未来需要更精细的视觉区分,可以扩展函数的分支逻辑。四个颜色映射函数共同构成了应用的"颜色策略层",将业务语义到视觉呈现的映射规则集中管理。
二十三、主入口组件状态声明
@Entry
@Component
struct AquaApp {
@State curTab: number = 0;
@State data: AquaData = new AquaData();
@State showAddFish: boolean = false;
@State showEditTank: boolean = false;
@State showDeleteShop: boolean = false;
@State showDetail: boolean = false;
@State delShopName: string = '';
@State detailShowName: string = '';
@State fishSwim: boolean = false;
@State bubbleRise: boolean = false;
@Entry 装饰器将 AquaApp 标记为应用的入口组件,框架会以此为根节点构建组件树。@Component 装饰器声明该结构体为一个自定义组件,使其可以被其他组件引用(虽然入口组件通常不被外部引用)。@State 装饰器为每个状态变量建立了响应式追踪——当变量值发生变化时,框架自动重新执行 build 方法中依赖该变量的 UI 部分。十个状态变量可分为三组:导航状态(curTab)、弹窗状态(showAddFish、showEditTank、showDeleteShop、showDetail)、上下文参数(delShopName、detailShowName)和动画状态(fishSwim、bubbleRise)。data 作为唯一的业务数据源被 @State 管理,但由于它同时是 @Observed 对象,其内部属性的变化也能被 @ObjectLink 子组件感知,形成了双重响应式保障。这种设计使得全局状态管理既集中又灵活。
二十四、模态遮罩构建器
@Builder modalOverlay(onClose: () => void) {
Column() {
Text('')
.width(0)
.height(0)
.opacity(0)
Button('')
.width(1)
.height(1)
.opacity(0)
.onClick(() => {
onClose();
})
}
.width(1)
.height(1)
}
modalOverlay 是一个使用 @Builder 装饰器定义的构建器函数,它接受一个 onClose 回调作为参数,生成一个不可见的点击遮罩层。这个构建器的实现非常巧妙:它通过将 Column 容器尺寸设为 1x1 像素、内部 Text 设为 0x0 像素、Button 设为 1x1 像素且全部透明度为零的方式,创建了一个视觉上不可见但可接收点击事件的"幽灵按钮"。虽然当前代码中该构建器未被实际调用(弹窗的关闭逻辑由各弹窗组件内部的关闭按钮直接处理),但它代表了模态交互的一种设计思路——通过透明遮罩捕获背景点击来关闭弹窗。@Builder 装饰器的价值在于将可复用的 UI 片段封装为函数,在 build 方法中通过 this.modalOverlay(() => {...}) 调用即可内联展开,避免了为每个使用场景创建独立组件的开销。
二十五、构建方法:顶部标题区域
build() {
Column() {
Column() {
Column() {
Row() {
Column() {
Text('🐠 珊瑚海洋馆')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('Coral Aquarium · 蔚蓝之境')
.fontSize(10)
.fontColor('#FFFFFFCC')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
build 方法是组件渲染的入口,ArkUI 框架在首次渲染和状态更新时自动调用它。方法内部通过嵌套的 Column 和 Row 容器构建布局结构。最外层 Column 将整个界面纵向分为内容区和底部导航栏两部分;第二层 Column 是内容区容器;第三层 Column 是 Header 区域容器。Header 内部的 Row 采用水平布局,左侧 Column 通过 layoutWeight(1) 占据剩余空间,展示应用标题"珊瑚海洋馆"和英文副标题"Coral Aquarium · 蔚蓝之境"。标题字号 20、粗体、白色,副标题字号 10、白色带 80% 透明度(#FFFFFFCC),形成了清晰的字号层级。alignItems(HorizontalAlign.Start) 确保文字左对齐,margin({ top: 3 }) 为副标题提供了与主标题的间距,这些细节共同塑造了精致的视觉排版。
二十六、构建方法:交互式头部图标
Row() {
Text('🐠')
.fontSize(20)
.onClick(() => {
this.fishSwim = !this.fishSwim;
})
.translate({ x: this.fishSwim ? 12 : 0 })
.animation({ duration: 600, curve: Curve.EaseInOut })
Text('🫧')
.fontSize(18)
.margin({ left: 10 })
.onClick(() => {
this.bubbleRise = !this.bubbleRise;
})
.translate({ y: this.bubbleRise ? -10 : 0 })
.opacity(this.bubbleRise ? 0.3 : 1)
.animation({ duration: 700, curve: Curve.EaseOut })
Text('🐬')
.fontSize(16)
.margin({ left: 6 })
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor('#FFFFFF26')
.borderRadius(18)
.border({ width: 1, color: '#FFFFFF40' })
Header 右侧的 Row 容器封装了三个可交互的 Emoji 图标,每个图标都绑定了独立的状态变量和动画效果。点击鱼图标会切换 fishSwim 状态,触发 translate 属性在 x: 12 和 x: 0 之间切换,配合 EaseInOut 缓动曲线的 600 毫秒动画,模拟出鱼游动的水平位移效果。点击气泡图标则切换 bubbleRise 状态,同时改变 translate.y(上移 10 像素)和 opacity(降至 0.3),配合 EaseOut 曲线的 700 毫秒动画,营造出气泡上升并逐渐消散的视觉效果。第三个海豚图标仅作装饰,未绑定交互事件。整个 Row 容器使用半透明白色背景(#FFFFFF26,约 15% 不透明度)和圆角边框,形成了类似"药丸按钮"的视觉容器。这种将动画属性直接绑定到状态变量的方式是 ArkUI 声明式动画的核心特性,开发者无需手动管理动画的开始与结束。
二十七、构建方法:气泡装饰行
Row() {
ForEach(BUBBLE_COL, (c: number, i: number) => {
Column()
.layoutWeight(1)
.height(6 + i % 3 * 4)
.backgroundColor('#FFFFFF55')
.borderRadius(50)
.margin({ left: 3, right: 3 })
}, (c: number) => 'bubA' + c)
}
.width('100%')
.height(14)
.margin({ top: 10 })
.alignItems(VerticalAlign.Bottom)
Row() {
ForEach(BUBBLE_COL, (c: number, i: number) => {
Column()
.layoutWeight(1)
.height(4 + (i + 1) % 3 * 3)
.backgroundColor('#FFFFFF40')
.borderRadius(50)
.margin({ left: 4, right: 4 })
}, (c: number) => 'bubB' + c)
}
.width('100%')
.height(10)
.margin({ top: 4 })
.alignItems(VerticalAlign.Bottom)
这两行气泡装饰是 Header 区域的视觉点缀,通过 ForEach 遍历 BUBBLE_COL 数组生成十个等宽的圆角矩形条。第一行气泡的高度通过 6 + i % 3 * 4 公式计算,产生 6、10、14 三种高度循环交替的效果;第二行使用 4 + (i + 1) % 3 * 3 公式,产生 7、10、4 的错位循环。两组公式的相位差(i % 3 vs (i+1) % 3)使得两行气泡的高度变化不完全同步,增强了视觉随机感。borderRadius(50) 将矩形变为胶囊形,配合半透明白色背景(#FFFFFF55 和 #FFFFFF40),模拟了水中气泡的通透质感。alignItems(VerticalAlign.Bottom) 使所有气泡底部对齐,高度差异仅体现在向上延伸的部分。ForEach 的键值生成函数使用 'bubA' + c 和 'bubB' + c 确保两行气泡的键值不冲突,避免 diff 比对时的误判。
二十八、构建方法:色彩主题标签
Row() {
Text('海洋蓝')
.fontSize(8)
.fontColor('#FFFFFFCC')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor('#FFFFFF2E')
.borderRadius(8)
Text('珊瑚粉')
.fontSize(8)
.fontColor('#FFFFFFCC')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor('#FFFFFF2E')
.borderRadius(8)
Text('气泡白')
.fontSize(8)
.fontColor('#FFFFFFCC')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor('#FFFFFF2E')
.borderRadius(8)
}
.width('100%')
.margin({ top: 8 })
Header 底部的色彩主题标签行展示了应用的三大色彩名称:“海洋蓝”“珊瑚粉"和"气泡白”。三个标签使用完全相同的样式规格——字号 8、白色文字(80% 透明度)、半透明白色背景(约 18% 不透明度)、8 像素圆角和对称内边距。这种高度一致的样式重复虽然可以通过 @Builder 函数提取为可复用片段,但在仅三处使用的场景下,内联重复的可读性反而更佳。标签的作用不仅是装饰性的——它向用户传达了应用的设计语言与色彩理念,增强了品牌识别度。从信息架构角度看,这些标签位于 Header 最底部,与上方的内容区域(标签切换区)形成视觉分隔,起到了"页眉收尾"的作用。margin({ top: 8 }) 提供了与上方气泡行的间距,确保布局疏密得当。
二十九、构建方法:头部容器收束
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor(COLORS.primary)
这段代码是 Header 区域的最外层 Column 容器的属性收束,它定义了 Header 的整体尺寸、内边距和背景色。width('100%') 使 Header 撑满屏幕宽度,padding 属性设置了左右 16、上下 12 的内边距,为 Header 内容提供了适当的呼吸空间。backgroundColor(COLORS.primary) 将整个 Header 背景设为主蓝色(#1890C9),与下方内容区的浅蓝背景形成鲜明对比,使 Header 在视觉上"浮"在内容之上。这种深色 Header + 浅色内容的配色方案是移动端设计的经典模式——深色 Header 突出品牌标识和导航功能,浅色内容区则降低视觉疲劳、突出信息呈现。Header 区域的 Column 容器没有显式设置高度,而是由内部内容的自然高度撑开,配合 padding 形成自适应的紧凑布局。
三十、构建方法:标签内容切换
if (this.curTab === AquaTab.TANK) {
TankContent({ data: this.data, onEdit: () => {
this.showEditTank = true;
} })
} else if (this.curTab === AquaTab.FISH) {
FishContent({ data: this.data, onAdd: () => {
this.showAddFish = true;
} })
} else if (this.curTab === AquaTab.SHOW) {
ShowContent({ data: this.data, onDetail: (n: string) => {
this.detailShowName = n;
this.showDetail = true;
} })
} else if (this.curTab === AquaTab.SHOP) {
ShopContent({ data: this.data, onDel: (n: string) => {
this.delShopName = n;
this.showDeleteShop = true;
} })
} else {
CardContent({ data: this.data })
}
这段代码是标签切换的核心逻辑,通过 if-else if-else 链根据 curTab 的值条件渲染对应的内容组件。每个分支在实例化内容组件时,通过参数传递完成两件事:数据注入和回调绑定。数据注入统一使用 this.data(即 AquaData 实例),由于 AquaData 被 @Observed 装饰且内容组件使用 @ObjectLink 接收,数据变化能自动穿透到子组件。回调绑定则实现了子组件到父组件的事件通信:onEdit 回调在展缸页面点击编辑图标时触发父组件的弹窗显示;onAdd 回调在鱼种页面点击新增按钮时触发;onDetail 和 onDel 回调额外携带了上下文参数(演出名称和商品名称),使弹窗能显示对应条目的详细信息。CardContent 是唯一不接收回调的组件,因为年卡页面没有交互弹窗。这种"数据向下流动、事件向上冒泡"的单向数据流模式是 ArkUI 推荐的组件通信范式。
三十一、构建方法:底部导航栏
Row() {
ForEach(TAB_LIST, (t: TabMeta) => {
Column() {
Text(t.icon)
.fontSize(19)
Text(t.label)
.fontSize(10)
.fontColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.white : COLORS.textHint)
.fontWeight(this.curTab === TAB_LIST.indexOf(t) ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.padding({ top: 8, bottom: 8 })
.backgroundColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.primary : COLORS.cardBg)
.borderRadius(16)
.border(this.curTab === TAB_LIST.indexOf(t) ? { width: 1, color: t.color + '88' } : { width: 1, color: COLORS.line })
.onClick(() => {
this.curTab = TAB_LIST.indexOf(t);
})
}, (t: TabMeta) => t.key)
}
.width('100%')
.height(60)
.padding({ left: 8, right: 8 })
.backgroundColor(COLORS.cardBg)
.border({ width: 1, color: COLORS.line })
底部导航栏通过 ForEach 遍历 TAB_LIST 数组动态生成五个标签项,每个标签项是一个包含图标和文字的 Column 容器。选中状态通过一系列三元表达式控制:选中时背景色为主蓝、文字为白色加粗、边框为对应标签色(附加 88 即 53% 透明度);未选中时背景为白色、文字为提示色、边框为浅色线条。layoutWeight(1) 使五个标签等分导航栏宽度,justifyContent(FlexAlign.Center) 确保图标和文字垂直居中。点击事件通过 this.curTab = TAB_LIST.indexOf(t) 切换当前标签,框架自动触发内容区域的条件渲染更新。TAB_LIST.indexOf(t) 用于获取当前标签的索引值,虽然每次渲染都会执行数组查找,但在五元素数组中性能影响可忽略。导航栏整体高度 60 像素,配合 8 像素的左右内边距和顶部边框线,形成了与内容区的视觉分隔。
三十二、构建方法:弹窗条件渲染
if (this.showAddFish) {
AddFishModal({
onClose: () => {
this.showAddFish = false;
}
})
}
if (this.showEditTank) {
EditTankModal({
onClose: () => {
this.showEditTank = false;
}
})
}
if (this.showDeleteShop) {
DeleteShopModal({
title: this.delShopName, onClose: () => {
this.showDeleteShop = false;
}
})
}
if (this.showDetail) {
DetailShowModal({
name: this.detailShowName, onClose: () => {
this.showDetail = false;
}
})
}
四个弹窗组件通过独立的 if 条件渲染挂载在组件树的最末尾,每个弹窗的显隐由对应的布尔状态变量控制。这种"条件挂载"方式意味着弹窗在不可见时不会存在于组件树中,避免了不必要的渲染开销,与 visibility 属性控制显隐的方式相比更为高效。每个弹窗在实例化时都接收一个 onClose 回调函数,该回调将对应的状态变量重置为 false,从而触发弹窗从组件树中移除。DeleteShopModal 和 DetailShowModal 额外接收了上下文参数(title 和 name),这些参数来自父组件在回调中设置的临时状态变量(delShopName 和 detailShowName),形成了"事件携带数据向上传递、数据通过参数向下注入"的完整通信闭环。由于四个 if 条件互斥(用户同一时刻只能操作一个弹窗),不存在弹窗层叠的情况,简化了 z-index 管理。
三十三、AquaTag 标签组件
@Component
struct AquaTag {
@Prop text: string;
@Prop color: string;
build() {
Text(this.text)
.fontSize(9)
.fontColor(this.color)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(this.color + '1F')
.borderRadius(12)
.border({ width: 1, color: this.color + '40' })
}
}
AquaTag 是一个高度可复用的标签组件,通过 @Prop 装饰器接收 text 和 color 两个只读属性。@Prop 与 @State 的关键区别在于:@Prop 用于父子组件间的单向数据传递,父组件的数据变化会同步到子组件,但子组件不能修改该值。标签的视觉设计遵循了"同色系三明治"模式:文字使用传入颜色、背景使用该颜色附加 1F(约 12% 透明度)的后缀、边框使用该颜色附加 40(约 25% 透明度)的后缀。这种通过十六进制透明度后缀(Alpha suffix)动态生成半透明色的技巧是 ArkUI 中实现色彩变体的常用手段,避免了为每种颜色的不同透明度变体预定义常量。AquaTag 在展缸列表(区域标签)、鱼种列表(分类标签)、演出列表(类型标签)、商品列表(品类标签)和年卡列表(等级标签)中被大量复用,是整个应用中复用频率最高的组件,充分体现了组件化设计的价值。
三十四、展缸页面:周客流柱状图
@Component
struct TankContent {
@ObjectLink data: AquaData;
onEdit: () => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('📈 周客流(百人)')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('周六9600人')
.fontSize(9)
.fontColor(COLORS.coral)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
ForEach(WEEK_VISIT, (w: WeekMeta) => {
Column() {
Text(w.value + '')
.fontSize(8)
.fontColor(w.value >= 80 ? COLORS.coral : COLORS.textSecondary)
Column()
.width(20)
.height(w.value * 1.2)
.backgroundColor(w.value >= 80 ? COLORS.coral : COLORS.primary)
.borderRadius(6)
.margin({ top: 4 })
Text(w.day)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (w: WeekMeta) => w.day)
}
.width('100%')
.height(140)
.alignItems(VerticalAlign.Bottom)
TankContent 是展缸页面的内容组件,使用 @ObjectLink 接收 AquaData 实例,确保数据变化能响应式触发重渲染。onEdit 回调以默认箭头函数初始化,保证即使父组件未传入回调也不会因 undefined 调用而报错。页面顶部是周客流柱状图卡片,通过 ForEach 遍历 WEEK_VISIT 生成七个柱子。每个柱子由三部分组成:顶部的数值标签、中间的彩色柱体和底部的星期标签。柱体高度按 w.value * 1.2 等比缩放,最大值 96 对应 115.2 像素高度,适合 140 像素的容器高度。w.value >= 80 的阈值判断将高客流日(周五、周六、周日)的柱体标为珊瑚粉,其余标为主蓝色,实现了数据的视觉分层。alignItems(VerticalAlign.Bottom) 使所有柱子底部对齐,形成标准的柱状图基线。整个图表卡片使用白色背景、18 像素圆角和主蓝色边框,视觉上独立于下方的列表区域。
三十五、展缸页面:鱼种分布进度条
Column() {
Row() {
Text('🐠 馆藏鱼种分布')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('全馆12类')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
ForEach(FISH_SHARE, (n: FishMeta) => {
Row() {
Text(n.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(60)
Row() {
Row()
.layoutWeight(n.count / 4)
.height(8)
.backgroundColor(n.color)
.borderRadius(4)
Row()
.layoutWeight(1 - n.count / 4)
.height(8)
.backgroundColor(COLORS.bg)
.borderRadius(4)
}
.layoutWeight(1)
.height(8)
Text(n.count + '类')
.fontSize(9)
.fontColor(n.color)
.width(32)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (n: FishMeta) => n.name)
}
鱼种分布卡片使用水平进度条可视化六种鱼类的馆藏数量分布。每行由三部分组成:左侧 60 像素宽的分类名称、中间的进度条和右侧 32 像素宽的数量标签。进度条的实现是 ArkUI layoutWeight 弹性权重的经典应用:已填充部分权重为 n.count / 4(4 是最大值),未填充部分权重为 1 - n.count / 4,两者共享父容器的剩余空间。这种基于权重的进度条无需计算像素宽度,自动适配不同屏幕尺寸。进度条高度统一为 8 像素,圆角 4 像素形成胶囊形。每行的颜色由数据中的 n.color 字段直接提供,与 getKindColor 函数的返回值保持一致。ForEach 的键值使用分类名称(n.name),在数据不变的情况下确保 diff 稳定。margin({ top: 8 }) 为每行提供了垂直间距,避免进度条过于密集影响可读性。
三十六、展缸页面:展缸列表头部
Column() {
Row() {
Text('🫧 展缸总览')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击✏️调整')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
Text('✏️')
.fontSize(13)
.margin({ left: 8 })
.onClick(() => {
this.onEdit();
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
展缸列表卡片的头部区域包含标题、操作提示和可点击的编辑图标。Row 容器使用 justifyContent(FlexAlign.SpaceBetween) 使三个元素分散对齐——标题靠左、提示居中、编辑图标靠右。点击铅笔图标(✏️)触发 this.onEdit() 回调,该回调在父组件 AquaApp 中被绑定为 this.showEditTank = true,从而弹出展缸编辑弹窗。这种"图标即按钮"的设计模式在移动端应用中非常普遍,它节省了传统按钮的边框和内边距空间,使界面更加紧凑。操作提示文字"点击✏️调整"以提示色显示,为用户提供了操作引导,降低了学习成本。margin({ bottom: 10 }) 在头部和下方列表之间创造了间距,形成了清晰的视觉层次。整个展缸列表卡片使用 cardAlt(浅蓝色)背景,与上方使用白色背景的两个图表卡片形成交替,增强了卡片的层次感。
三十七、展缸页面:展缸列表项
ForEach(this.data.tanks, (t: TankItem, i: number) => {
Row() {
Column()
.width(44)
.height(44)
.justifyContent(FlexAlign.Center)
.backgroundColor(COLORS.primary + '1F')
.borderRadius(22)
.border({ width: 2, color: COLORS.bubble })
Text(t.emoji)
.fontSize(19)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(t.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
AquaTag({ text: t.zone, color: '#1890C9' })
.margin({ left: 6 })
}
.width('100%')
Text('水体' + t.volume + '吨 · 鱼' + t.fishCount + '尾')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text(t.temp + '℃')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(t.temp <= 10 ? COLORS.primaryLight : COLORS.warning)
Text('水温')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (t: TankItem, i: number) => t.name + i)
展缸列表项的布局采用了三列结构:左侧圆形图标容器、中间信息区和右侧水温指标。圆形容器通过 borderRadius(22)(直径 44 的一半)实现正圆效果,内部填充主蓝色 12% 透明度背景并叠加气泡色边框,视觉上模拟了水面气泡的质感。Emoji 图标以绝对定位的方式叠加在圆形容器上方——这里利用了 ArkUI 默认的子元素堆叠行为,Text 紧跟 Column 之后会覆盖在前者之上。中间信息区使用 layoutWeight(1) 占据剩余空间,展示展缸名称、区域标签(通过 AquaTag 组件呈现)和水体与鱼数量信息。右侧水温区根据 t.temp <= 10 的阈值切换颜色:低温展缸(企鹅馆 6 度、极地冰海 4 度)使用浅蓝色、常温展缸使用橙色,使特殊温控需求一目了然。列表项背景通过 i % 2 实现斑马纹交替,提升了长列表的可读性。ForEach 键值使用 t.name + i 确保即使有同名展缸也能保持唯一标识。
三十八、鱼种页面:统计卡片
@Component
struct FishContent {
@ObjectLink data: AquaData;
onAdd: () => void = () => {};
build() {
Scroll() {
Column() {
Row() {
Text('🐠 馆藏鱼种')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击➕引进')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
Text('➕')
.fontSize(13)
.margin({ left: 8 })
.onClick(() => {
this.onAdd();
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
Row() {
Column() {
Text('12')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
Text('馆藏鱼种')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.border({ width: 1, color: COLORS.primary + '55' })
FishContent 的页面头部包含标题行和统计卡片行两部分。标题行与展缸页面的结构类似,点击加号图标触发 this.onAdd() 回调弹出鱼种引进弹窗。统计卡片行使用 Row 容器横向排列三个等宽的统计卡片,每个卡片通过 layoutWeight(1) 等分空间。第一个卡片展示"12 馆藏鱼种",使用主蓝色大号数字(字号 26、粗体)配次要色小标签(字号 9),形成了强烈的字号对比。卡片整体使用白色背景、18 像素圆角和对应颜色的半透明边框(附加 55 即 33% 透明度后缀),视觉风格统一而精致。三个卡片分别使用主蓝、珊瑚粉和琥珀橙作为强调色,对应"馆藏鱼种""最贵龟类(880 元)"和"水晶虾存量(210 尾)"三个关键指标,为运营人员提供了鱼种管理的核心数据概览。卡片之间的间距通过第二个和第三个卡片的 margin({ left: 10 }) 实现。
三十九、鱼种页面:鱼种列表项
ForEach(this.data.fishs, (f: FishItem, i: number) => {
Row() {
Column()
.width(42)
.height(42)
.justifyContent(FlexAlign.Center)
.backgroundColor(getKindColor(f.kind) + '22')
.borderRadius(21)
Text(f.emoji)
.fontSize(18)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(f.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
AquaTag({ text: f.kind, color: getKindColor(f.kind) })
.margin({ left: 6 })
}
.width('100%')
Text('产地' + f.origin + ' · ¥' + f.price + '/尾')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text(f.count + '尾')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(f.count >= 100 ? COLORS.success : COLORS.warning)
Text('存量')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (f: FishItem, i: number) => f.name + i)
鱼种列表项的布局结构与展缸列表项保持一致——三列布局:图标、信息区、指标区,这种跨页面的布局一致性降低了用户的学习成本。圆形图标容器使用 getKindColor(f.kind) + '22' 生成对应分类的 13% 透明度背景色,使不同分类的鱼种在视觉上具有色彩区分。信息区展示鱼种名称、分类标签(通过 AquaTag 组件)和产地价格信息。右侧指标区展示存量数量,通过 f.count >= 100 的阈值切换颜色:存量 100 以上用绿色(success)表示充足、100 以下用橙色(warning)表示偏低。与展缸列表项不同的是,鱼种列表项统一使用白色背景(cardBg)而非斑马纹交替,这是因为鱼种列表的条目数与展缸相同(十二条),但信息密度更高,统一背景有助于减少视觉干扰。ForEach 键值同样使用 f.name + i 保证唯一性。
四十、表演页面:上座率图表
@Component
struct ShowContent {
@ObjectLink data: AquaData;
onDetail: (n: string) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('🎪 表演上座率')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('海豚表演爆满')
.fontSize(9)
.fontColor(COLORS.danger)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
ForEach(SHOW_JOIN, (s: ShowJoinMeta) => {
Row() {
Text(s.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(84)
Row() {
Row()
.layoutWeight(s.rate / 100)
.height(8)
.backgroundColor(s.color)
.borderRadius(4)
Row()
.layoutWeight(1 - s.rate / 100)
.height(8)
.backgroundColor(COLORS.bg)
.borderRadius(4)
}
.layoutWeight(1)
.height(8)
Text(s.rate + '%')
.fontSize(9)
.fontColor(s.color)
.width(38)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (s: ShowJoinMeta) => s.name)
}
ShowContent 的首个卡片是表演上座率水平进度条图表,数据来源于 SHOW_JOIN 常量。图表结构与展缸页面的鱼种分布图几乎完全相同——三列布局:标签名、进度条、百分比数值。进度条权重计算使用 s.rate / 100,由于 rate 本身即为百分比值(60-96),分母设为 100 实现了精确的百分比映射。头部提示"海豚表演爆满"使用红色(danger)标识,与上座率 96% 的数据形成呼应,向运营人员传达了热门演出的紧迫感。进度条颜色由数据中的 s.color 字段直接提供,六个演出项目各使用不同颜色,使得进度条在视觉上丰富多彩。ForEach 键值使用演出名称(s.name),在数据固定的情况下保证了渲染稳定性。这个图表卡片使用白色背景和琥珀橙边框(COLORS.warning + '66'),与展缸页面图表卡片的主蓝色边框形成色彩区分。
四十一、表演页面:演出列表头部
Column() {
Row() {
Text('🐬 今日演出')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击查看详情')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
演出列表卡片的头部区域结构简洁,包含标题"今日演出"和操作提示"点击查看详情"。与展缸页面和鱼种页面的头部相比,这里没有放置可点击的图标按钮,而是将交互入口放在了每个列表项内部的"眼睛"图标上。这种设计选择是因为演出列表的交互对象是具体的演出项目而非整个列表——用户需要查看某一场演出的详情,而非对列表进行全局操作。操作提示文字以提示色显示,引导用户关注列表项中的详情入口。卡片使用 cardAlt(浅蓝色)背景,与上方的白色图表卡片形成交替,保持了与展缸页面一致的卡片色彩节奏。margin({ bottom: 10 }) 在头部和列表之间创造了间距。
四十二、表演页面:演出列表项
ForEach(this.data.shows, (s: ShowItem, i: number) => {
Row() {
Column()
.width(40)
.height(40)
.justifyContent(FlexAlign.Center)
.backgroundColor(getShowColor(s.type) + '22')
.borderRadius(20)
Text(s.emoji)
.fontSize(18)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(s.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
AquaTag({ text: s.type, color: getShowColor(s.type) })
.margin({ left: 6 })
}
.width('100%')
Text(s.time + ' · ' + s.seats + '座')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text(s.joined + '/' + s.seats)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(s.joined >= s.seats * 0.8 ? COLORS.danger : COLORS.success)
Text('已售')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
Text('👁️')
.fontSize(12)
.margin({ top: 3 })
.onClick(() => {
this.onDetail(s.name);
})
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (s: ShowItem, i: number) => s.name + i)
演出列表项的布局与展缸和鱼种列表项保持三列结构,但右侧指标区更为复杂——包含三行内容:已售/座位数、"已售"标签和可点击的眼睛图标。已售数量的颜色通过 s.joined >= s.seats * 0.8 的阈值判断:上座率超过 80% 用红色警示、低于 80% 用绿色表示正常。这个 80% 阈值是演出行业的常用预警线,接近满座的演出需要考虑加场或限流。点击眼睛图标(👁️)触发 this.onDetail(s.name) 回调,将演出名称传递给父组件并弹出详情弹窗。圆形图标容器使用 getShowColor(s.type) + '22' 生成对应类型的半透明背景色。列表项使用斑马纹背景交替(i % 2),与展缸列表项的处理方式一致。整个列表项的信息密度适中——左侧图标、中间名称与标签和时间座位信息、右侧销售数据和详情入口,每个区域职责明确。
四十三、商店页面:销量排行榜
@Component
struct ShopContent {
@ObjectLink data: AquaData;
onDel: (n: string) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('🐚 商品销量TOP6')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('冰箱贴夺冠')
.fontSize(9)
.fontColor(COLORS.accent)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(SHOP_TOP, (s: ShopTopMeta, i: number) => {
Row() {
Text((i + 1) + '')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(i < 3 ? COLORS.coral : COLORS.textHint)
.width(22)
Text(s.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(80)
Row() {
Row()
.layoutWeight(s.sales / 490)
.height(8)
.backgroundColor(s.color)
.borderRadius(4)
Row()
.layoutWeight(1 - s.sales / 490)
.height(8)
.backgroundColor(COLORS.bg)
.borderRadius(4)
}
.layoutWeight(1)
.height(8)
Text(s.sales + '件')
.fontSize(9)
.fontColor(s.color)
.width(44)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 7 })
}, (s: ShopTopMeta, i: number) => s.name + i)
}
ShopContent 的首个卡片是商品销量 TOP6 排行榜,数据来源于 SHOP_TOP 常量。排行榜的每行采用四列布局:排名序号、商品名称、进度条和销量数值。排名序号通过 i < 3 的判断实现"奖牌区"视觉分层——前三名使用珊瑚粉高亮、后三名使用灰色提示色。进度条权重使用 s.sales / 490 计算(490 略大于最大值 486),确保最长进度条不会完全撑满容器,留出视觉余量。头部提示"冰箱贴夺冠"使用辅助色(accent,即珊瑚粉)标识,与排行榜数据形成呼应——虽然排行榜按降序排列的第一名是海豚玩偶(486 件),但提示文字提到的"冰箱贴"实际销量为 520 件(在商品列表中),说明排行榜数据可能只统计了特定品类或时间段。这个细微差异反映了统计口径与业务数据之间的潜在不一致,在真实系统中需要明确数据来源说明。排行榜卡片使用白色背景和珊瑚粉边框(COLORS.coral + '66'),与其他页面的图表卡片边框色形成区分。
四十四、商店页面:商品列表项
Column() {
Row() {
Text('🛒 海洋商店')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击🗑️下架')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.shops, (s: ShopItem, i: number) => {
Row() {
Column()
.width(40)
.height(40)
.justifyContent(FlexAlign.Center)
.backgroundColor(getCategoryColor(s.category) + '22')
.borderRadius(14)
Text(s.emoji)
.fontSize(18)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(s.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
AquaTag({ text: s.category, color: getCategoryColor(s.category) })
.margin({ left: 6 })
}
.width('100%')
Text('¥' + s.price + ' · 已售' + s.sales + '件')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text(s.stock > 30 ? '充足' : '告急')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(s.stock > 30 ? COLORS.success : COLORS.danger)
Text('库存' + s.stock)
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
Text('🗑️')
.fontSize(12)
.margin({ top: 4 })
.onClick(() => {
this.onDel(s.name);
})
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (s: ShopItem, i: number) => s.name + i)
商品列表项的布局与演出列表项高度同构,右侧指标区同样包含三行:库存状态、“库存"标签和垃圾桶下架图标。库存状态通过 s.stock > 30 的阈值切换"充足”(绿色)和"告急"(红色),与鱼种列表的 count >= 100 阈值逻辑形成呼应。点击垃圾桶图标(🗑️)触发 this.onDel(s.name) 回调,将商品名称传递给父组件并弹出下架确认弹窗。值得注意的是商品图标容器使用 borderRadius(14) 而非 borderRadius(20) 或 21,形成的是圆角方形而非正圆,这与展缸(正圆 22)、鱼种(正圆 21)、演出(正圆 20)和年卡(正圆 21)的圆形图标形成了微妙的视觉差异。这种差异可能是有意为之——商品作为零售品类,圆角方形更贴近"商品包装"的视觉联想。列表项使用斑马纹背景交替,ForEach 键值使用 s.name + i。
四十五、年卡页面:统计卡片
@Component
struct CardContent {
@ObjectLink data: AquaData;
build() {
Scroll() {
Column() {
Row() {
Text('🎫 年卡中心')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('钻石卡终身畅玩')
.fontSize(9)
.fontColor(COLORS.coral)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
Row() {
Column() {
Text('12')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
Text('在售卡种')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.border({ width: 1, color: COLORS.primary + '55' })
Column() {
Text('¥258')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.success)
Text('最低年卡')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.border({ width: 1, color: COLORS.success + '55' })
.margin({ left: 10 })
CardContent 是唯一不接收交互回调的内容组件(仅有 @ObjectLink data),因为年卡页面没有弹窗交互。页面头部包含标题行和统计卡片行,结构与鱼种页面完全一致。三个统计卡片分别展示"12 在售卡种"(主蓝色)、“¥258 最低年卡”(绿色)和"¥9999 终身卡"(橙色),用大号数字突出关键指标。最低年卡价格 258 元对应数据中的老人年卡,终身卡价格 9999 元对应数据中的终身卡,这两个极端值帮助用户快速了解年卡的价格区间。统计卡片使用与鱼种页面完全相同的样式规格——字号 26 粗体数字、字号 9 标签、18 像素圆角、对应颜色 33% 透明度边框——保持了跨页面的视觉一致性。CardContent 不接收 onAdd 等回调,因此头部没有加号图标,这是它与 FishContent 的唯一结构差异。
四十六、年卡页面:年卡列表项
ForEach(this.data.cards, (c: CardItem, i: number) => {
Row() {
Column()
.width(42)
.height(42)
.justifyContent(FlexAlign.Center)
.backgroundColor(getLevelColor(c.level) + '22')
.borderRadius(21)
Text(c.emoji)
.fontSize(18)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(c.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
AquaTag({ text: c.level, color: getLevelColor(c.level) })
.margin({ left: 6 })
}
.width('100%')
Text(c.perks + ' · ' + c.visits + '次/年')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text('¥' + c.price)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(c.price >= 3000 ? COLORS.warning : COLORS.primary)
Text('/年')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (c: CardItem, i: number) => c.name + i)
年卡列表项的布局结构与展缸、鱼种列表项保持三列一致,但右侧指标区展示的是价格而非数量。价格通过 c.price >= 3000 的阈值切换颜色:高价卡(金卡 2588 不触发,但潜水年卡 3288、钻石卡 6888、终身卡 9999 触发)使用橙色标识、常规卡使用主蓝色。这个 3000 元阈值有效地将高端卡种从常规卡种中筛选出来,引导用户关注高价值产品。圆形图标容器使用 getLevelColor(c.level) + '22' 生成对应等级的半透明背景色,银卡的灰色和金卡的暗金色在这里呈现出独特的金属质感。信息区展示年卡名称、等级标签(通过 AquaTag 组件)和权益与年访问次数信息。ForEach 键值使用 c.name + i。列表项使用斑马纹背景交替,与前几个页面的列表项处理方式完全一致,形成了统一的列表视觉规范。整个年卡页面没有交互入口,用户仅能浏览信息,这与年卡管理的业务逻辑相符——年卡的购买和变更通常需要线下办理。
四十七、新增鱼种弹窗:头部区域
@Component
struct AddFishModal {
onClose: () => void = () => {};
build() {
Column() {
Column() {
Row() {
Text('🐠')
.fontSize(26)
Column() {
Text('引进新鱼种')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('录入鱼种资料')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor(COLORS.textHint)
.onClick(() => {
this.onClose();
})
}
.width('100%')
AddFishModal 是鱼种引进弹窗组件,通过 onClose 回调实现关闭逻辑。弹窗的最外层 Column 使用 justifyContent(FlexAlign.End) 使内容靠底部对齐,模拟从底部滑入的底部弹窗(Bottom Sheet)效果。内层 Column 是弹窗主体,使用白色背景、20 像素圆角和主蓝色边框,形成卡片式容器。头部 Row 采用三列布局:左侧鱼图标、中间标题区(通过 layoutWeight(1) 占据剩余空间)、右侧关闭按钮。标题区包含主标题"引进新鱼种"(字号 16、粗体)和副标题"录入鱼种资料"(字号 10、提示色),形成了清晰的信息层级。关闭按钮(✕)绑定 this.onClose() 回调,点击后触发父组件将 showAddFish 状态重置为 false,从而将弹窗从组件树中移除。整个头部区域的布局模式将在后续三个弹窗中被复用,形成了弹窗组件的视觉规范。
四十八、新增鱼种弹窗:表单字段
Row() {
Text('鱼种名称')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('黄尾蓝魔鱼')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 14 })
Row() {
Text('鱼种分类')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('热带鱼 · 珊瑚礁')
.fontSize(12)
.fontColor(COLORS.primary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('引进数量')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('40尾 · 检疫期14天')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('采购价')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('¥65/尾 · 含检疫费')
.fontSize(12)
.fontColor(COLORS.coral)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Text('✅ 新鱼种须完成隔离检疫并提交水质适配报告后方可入主缸。')
.fontSize(10)
.fontColor(COLORS.textHint)
.width('100%')
.margin({ top: 14 })
弹窗的表单区域由四个字段行和一个提示文本组成,每个字段行采用相同的布局结构:左侧 70 像素宽的标签、右侧 layoutWeight(1) 占满剩余空间的值。四个字段分别是鱼种名称、鱼种分类、引进数量和采购价,值文本使用不同的颜色编码——名称用主文字色、分类用主蓝色、数量用主文字色、价格用珊瑚粉色,这种颜色区分帮助用户快速定位关键信息。每个字段行使用 cardAlt(浅蓝色)背景和 12 像素圆角,形成了类似表单输入框的视觉容器。值得注意的是,这些字段行中的值都是静态文本而非可编辑的输入框(TextInput),说明当前弹窗是一个展示型表单而非交互型表单——它展示了预填的引进方案供用户确认,而非让用户自由输入。底部的提示文本"新鱼种须完成隔离检疫并提交水质适配报告后方可入主缸"以提示色显示,传达了鱼种引进的业务流程规范。
四十九、新增鱼种弹窗:操作按钮
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.onClick(() => {
this.onClose();
})
Text('确认引进')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.margin({ left: 10 })
.backgroundColor(COLORS.primary)
.borderRadius(12)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 16 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
.border({ width: 1, color: COLORS.primary + '66' })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
弹窗底部是两个等宽的操作按钮——“取消"和"确认引进”,通过 layoutWeight(1) 等分空间。"取消"按钮使用浅蓝背景和次要色文字,视觉上弱化;"确认引进"按钮使用主蓝背景、白色粗体文字,视觉上突出,引导用户选择主要操作。两个按钮都绑定了 this.onClose() 回调,说明当前实现中无论取消还是确认都会关闭弹窗,未对确认操作进行数据提交处理——这在原型阶段是合理的简化。按钮通过 Text 组件而非 Button 组件实现,利用 textAlign(TextAlign.Center) 和对称内边距模拟按钮外观,这种做法在 ArkUI 中常见于需要精细控制样式的场景。弹窗主体使用 constraintSize({ maxHeight: '78%' }) 限制最大高度为屏幕的 78%,防止内容过多时遮挡整个界面。外层 Column 的 justifyContent(FlexAlign.End) 确保弹窗在内容不足时靠底部显示,模拟底部弹窗的交互模式。
五十、编辑展缸弹窗:头部与表单
@Component
struct EditTankModal {
onClose: () => void = () => {};
build() {
Column() {
Column() {
Row() {
Text('🫧')
.fontSize(26)
Column() {
Text('编辑展缸')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('调整水温与密度')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor(COLORS.textHint)
.onClick(() => {
this.onClose();
})
}
.width('100%')
Row() {
Text('展缸名称')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('珊瑚礁主缸 · A区')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 14 })
Row() {
Text('水温调整')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('26℃ → 26.5℃')
.fontSize(12)
.fontColor(COLORS.warning)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('水质参数')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('pH 8.2 · 盐度1.024')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('维护备注')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('本周三换水10%,清洁滤材')
.fontSize(12)
.fontColor(COLORS.success)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
EditTankModal 的结构与 AddFishModal 几乎完全同构,仅在图标、标题和字段内容上有所不同。头部使用气泡图标和"编辑展缸"标题,副标题为"调整水温与密度"。四个表单字段分别是展缸名称、水温调整、水质参数和维护备注。水温调整字段展示"26 度到 26.5 度"的变化,使用橙色(warning)标识,提示水温变更需要谨慎操作。水质参数字段展示 pH 值和盐度,这两个指标是海水展缸的关键水质参数。维护备注字段使用绿色(success)标识,传达了维护计划已确定的信息。每个字段行使用与 AddFishModal 完全一致的样式规格——70 像素标签宽度、12 像素内边距、cardAlt 背景、12 像素圆角——确保了弹窗组件间的视觉一致性。这种高度同构的弹窗结构体现了组件设计的"模板化"思想:同一套布局模板适用于不同的业务表单。
五十一、编辑展缸弹窗:警示与按钮
Text('⚠️ 水温单次调整不宜超过1℃,需观察鱼群状态后再继续。')
.fontSize(10)
.fontColor(COLORS.textHint)
.width('100%')
.margin({ top: 14 })
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.onClick(() => {
this.onClose();
})
Text('保存修改')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.margin({ left: 10 })
.backgroundColor(COLORS.accent)
.borderRadius(12)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 16 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
.border({ width: 1, color: COLORS.accent + '66' })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
编辑展缸弹窗的底部区域包含一条业务警示和两个操作按钮。警示文本"水温单次调整不宜超过 1 度,需观察鱼群状态后再继续"使用警告图标(⚠️)和提示色文字,传达了展缸水温管理的专业规范——这种业务知识嵌入 UI 的做法提升了应用的实用价值。操作按钮"取消"和"保存修改"的布局与新增鱼种弹窗完全一致,但确认按钮的背景色从主蓝色(primary)改为了辅助色(accent,即珊瑚粉),这种颜色差异帮助用户区分不同弹窗的操作语境。弹窗主体的边框颜色也相应从主蓝色改为了辅助色(COLORS.accent + '66'),使整个弹窗的视觉基调与确认按钮一致。constraintSize({ maxHeight: '78%' }) 同样限制了弹窗最大高度。两个按钮都绑定 this.onClose() 回调,在原型阶段未区分取消和保存的实际行为差异。
五十二、下架商品弹窗
@Component
struct DeleteShopModal {
@Prop title: string;
onClose: () => void = () => {};
build() {
Column() {
Column() {
Column() {
Text('🗑️')
.fontSize(34)
Text('确认下架商品')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 10 })
Text('「' + this.title + '」将从商店移除,会员积分兑换不受影响。')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.textAlign(TextAlign.Center)
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.alignItems(HorizontalAlign.Center)
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.onClick(() => {
this.onClose();
})
Text('确认下架')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.margin({ left: 10 })
.backgroundColor(COLORS.danger)
.borderRadius(12)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 16 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
.border({ width: 1, color: COLORS.danger + '66' })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
DeleteShopModal 是一个确认型弹窗,结构与前两个表单型弹窗有显著差异——它没有字段行,而是以居中布局展示图标、标题和描述文本。垃圾桶图标(🗑️)以 34 像素的大字号居中显示,传达了删除操作的视觉警示。描述文本通过 this.title 动态引用父组件传入的商品名称,使用模板字符串拼接成完整的下架说明。@Prop 装饰器确保 title 属性的只读性,父组件修改该值时能自动同步到弹窗。描述文本中"会员积分兑换不受影响"的补充说明体现了业务逻辑的周全考虑——下架商品不影响已产生的积分兑换权益。确认按钮"确认下架"使用红色(danger)背景,与删除操作的语义一致;弹窗边框也使用红色(COLORS.danger + '66'),使整个弹窗的视觉基调统一在警示色系中。这种通过颜色语义化传达操作危险级别的设计模式是交互设计的最佳实践。
五十三、演出详情弹窗:头部与字段
@Component
struct DetailShowModal {
@Prop name: string;
onClose: () => void = () => {};
build() {
Column() {
Column() {
Row() {
Text('🐬')
.fontSize(26)
Column() {
Text('演出详情')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('演出「' + this.name + '」')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor(COLORS.textHint)
.onClick(() => {
this.onClose();
})
}
.width('100%')
Row() {
Text('演出场次')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('11:00 · 15:00 两场')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 14 })
Row() {
Text('上座情况')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('768/800座 · 已满96%')
.fontSize(12)
.fontColor(COLORS.danger)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('演出特色')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('海豚跃水 · 观众互动')
.fontSize(12)
.fontColor(COLORS.warning)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
DetailShowModal 的头部结构与前两个表单型弹窗一致,使用海豚图标和"演出详情"标题,副标题通过 this.name 动态显示演出名称。三个字段行分别展示演出场次、上座情况和演出特色。上座情况字段使用红色(danger)标识"768/800 座 · 已满 96%“,传达了演出接近满座的紧迫信息。演出特色字段使用橙色(warning)标识"海豚跃水 · 观众互动”,突出了演出的核心卖点。字段行的布局与新增鱼种弹窗和编辑展缸弹窗完全一致——70 像素标签宽度、layoutWeight(1) 值区域、cardAlt 背景和 12 像素圆角——形成了跨弹窗的统一表单视觉规范。@Prop name 确保了演出名称的只读传递,父组件在打开弹窗时通过 this.detailShowName 设置该值。弹窗边框使用浅蓝色(COLORS.primaryLight + '66'),与其他弹窗的边框色形成区分。
五十四、演出详情弹窗:说明与关闭按钮
Column() {
Text('演出说明')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('演出前15分钟停止入场,前3排为溅水区,建议携带雨衣。演出期间请勿使用闪光灯。')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.lineHeight(20)
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.alignItems(HorizontalAlign.Start)
.margin({ top: 8 })
Text('🎫 年卡会员可凭卡预约VIP观演席。')
.fontSize(10)
.fontColor(COLORS.textHint)
.width('100%')
.margin({ top: 14 })
Text('知道了')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS.primary)
.borderRadius(12)
.margin({ top: 16 })
.onClick(() => {
this.onClose();
})
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
.border({ width: 1, color: COLORS.primaryLight + '66' })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
演出详情弹窗的底部区域包含一个多行说明字段、一条提示文本和一个全宽关闭按钮。说明字段使用 Column 容器垂直排列标签和内容文本,内容文本通过 lineHeight(20) 设置行高,使多行文字(“演出前 15 分钟停止入场,前 3 排为溅水区,建议携带雨衣。演出期间请勿使用闪光灯。”)具有良好的可读性。alignItems(HorizontalAlign.Start) 确保文本左对齐。提示文本"年卡会员可凭卡预约 VIP 观演席"以提示色显示,将年卡权益与演出详情关联起来,形成了跨业务场景的交叉营销。与前三个弹窗的双按钮布局不同,详情弹窗使用单个全宽按钮"知道了",这是因为详情弹窗是信息展示型弹窗,用户无需在"取消"和"确认"之间做出选择,只需确认已阅读即可关闭。按钮使用主蓝色背景和白色粗体文字,width('100%') 使按钮撑满弹窗宽度,视觉上更为醒目。
组件关系总览
四个颜色映射函数的数据流向
技术要点对比总结
| 技术要点 | 实现方式 | 优势 | 适用场景 |
|---|---|---|---|
| 色彩体系管理 | AquaPalette 接口 + COLORS 常量集中定义 |
类型安全、全局一致、易于维护 | 需要统一设计规范的中大型应用 |
| 透明度变体生成 | 十六进制色值附加 Alpha 后缀(如 + '22') |
无需预定义多份常量、运行时动态计算 | 需要同一颜色的多种透明度变体 |
| 颜色语义映射 | 四个独立函数(getKindColor 等)if-else 链 |
业务逻辑集中、修改点单一 | 分类固定且数量有限的业务场景 |
| 响应式数据源 | @Observed 类 + @ObjectLink 子组件接收 |
跨层级自动响应、单一数据源 | 多组件共享同一数据实体的场景 |
| 导航状态管理 | @State curTab + if-else 条件渲染 |
简单直观、无需路由框架 | 标签数有限的底部导航应用 |
| 弹窗显隐控制 | @State 布尔变量 + if 条件挂载 |
不可见时零渲染开销 | 弹窗数量可控、无层叠需求 |
| 上下文参数传递 | @State 临时变量 + 回调赋值 + @Prop 注入 |
单向数据流、事件携带数据 | 需要将列表项数据传递给弹窗的场景 |
| 列表渲染 | ForEach + 键值生成函数 |
自动 diff 比对、高效更新 | 任意动态列表的渲染 |
| 弹性布局权重 | layoutWeight(n) 按比例分配空间 |
自适应屏幕尺寸、无需计算像素 | 进度条、等分卡片、响应式布局 |
| 斑马纹列表 | i % 2 三元表达式切换背景色 |
提升长列表可读性 | 条目数超过五行的列表 |
| 声明式动画 | 状态变量绑定 translate/opacity + animation |
无需手动管理动画生命周期 | 简单的状态切换动画 |
| 组件复用 | AquaTag 通过 @Prop 接收参数 |
一处定义、多处复用 | 标签、徽章等高频 UI 元素 |
| 弹窗模板化 | 四个弹窗共享头部/字段/按钮布局 | 减少代码重复、风格统一 | 同类弹窗数量较多的应用 |
| 业务规则编码 | 阈值判断内联在渲染表达式中 | 逻辑直观、与视图同位 | 简单的二元状态切换 |
| Emoji 图标系统 | 数据模型内嵌 emoji 字段 |
体积小、渲染快、跨平台一致 | 轻量级移动端应用的视觉标识 |
| 底部弹窗模拟 | justifyContent(FlexAlign.End) + constraintSize |
无需额外弹窗框架 | 表单型和确认型弹窗交互 |
| 统计图表自绘 | ForEach + layoutWeight 权重进度条 |
无需引入图表库、高度定制化 | 简单的柱状图和进度条图表 |
| 信息密度控制 | 字号层级(26/16/13/10/9/8)+ 字重对比 | 视觉层次清晰、重点突出 | 数据密集型管理界面 |
| 单向数据流 | 数据向下传递、事件向上冒泡 | 状态可预测、易于调试 | 组件层级较深的应用架构 |
| 设计令牌语义命名 | textPrimary/textSecondary/textHint 等 |
语义清晰、优于编号命名 | 需要长期维护的设计系统 |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:
初始化项目,自动下载相关依赖:

完整代码:
interface AquaPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
cardAlt: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
line: string;
success: string;
warning: string;
danger: string;
white: string;
coral: string;
bubble: string;
}
const COLORS: AquaPalette = {
primary: '#1890C9',
primaryLight: '#7CC7E8',
primaryDark: '#0B4E7A',
accent: '#FF7F9E',
accentLight: '#FFC4D3',
bg: '#EAF6FB',
cardBg: '#FFFFFF',
cardAlt: '#DFF1F9',
textPrimary: '#0E3A52',
textSecondary: '#5C8CA3',
textHint: '#A3C4D4',
border: '#D0EAF3',
line: '#D6EBF4',
success: '#3FB98A',
warning: '#F5A623',
danger: '#EF5B5B',
white: '#FFFFFF',
coral: '#FF7F9E',
bubble: '#BDE6F5'
};
enum AquaTab {
TANK = 0,
FISH = 1,
SHOW = 2,
SHOP = 3,
CARD = 4
}
interface TabMeta {
key: string;
icon: string;
label: string;
color: string;
}
const TAB_LIST: TabMeta[] = [
{ key: 'tank', icon: '🫧', label: '展缸', color: '#1890C9' },
{ key: 'fish', icon: '🐠', label: '鱼种', color: '#FF7F9E' },
{ key: 'show', icon: '🐬', label: '表演', color: '#F5A623' },
{ key: 'shop', icon: '🐚', label: '商店', color: '#3FB98A' },
{ key: 'card', icon: '🎫', label: '年卡', color: '#7A6BD6' }
];
const BUBBLE_COL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
interface TankItem {
name: string;
zone: string;
fishCount: number;
volume: number;
temp: number;
emoji: string;
}
interface FishItem {
name: string;
kind: string;
origin: string;
price: number;
count: number;
emoji: string;
}
interface ShowItem {
name: string;
time: string;
seats: number;
joined: number;
type: string;
emoji: string;
}
interface ShopItem {
name: string;
price: number;
sales: number;
stock: number;
category: string;
emoji: string;
}
interface CardItem {
name: string;
price: number;
visits: number;
perks: string;
level: string;
emoji: string;
}
interface WeekMeta {
day: string;
value: number;
}
interface FishMeta {
name: string;
count: number;
color: string;
}
interface ShowJoinMeta {
name: string;
rate: number;
color: string;
}
interface ShopTopMeta {
name: string;
sales: number;
color: string;
}
const WEEK_VISIT: WeekMeta[] = [
{ day: '周一', value: 32 },
{ day: '周二', value: 38 },
{ day: '周三', value: 35 },
{ day: '周四', value: 42 },
{ day: '周五', value: 58 },
{ day: '周六', value: 96 },
{ day: '周日', value: 88 }
];
const FISH_SHARE: FishMeta[] = [
{ name: '热带鱼', count: 4, color: '#1890C9' },
{ name: '冷水鱼', count: 2, color: '#FF7F9E' },
{ name: '观赏虾', count: 2, color: '#F5A623' },
{ name: '水母', count: 2, color: '#7CC7E8' },
{ name: '珊瑚', count: 1, color: '#3FB98A' },
{ name: '海龟', count: 1, color: '#7A6BD6' }
];
const SHOW_JOIN: ShowJoinMeta[] = [
{ name: '海豚表演', rate: 96, color: '#1890C9' },
{ name: '海狮剧场', rate: 88, color: '#F5A623' },
{ name: '人鲨共舞', rate: 82, color: '#FF7F9E' },
{ name: '水母灯光秀', rate: 75, color: '#7CC7E8' },
{ name: '企鹅喂食', rate: 68, color: '#3FB98A' },
{ name: '夜宿海洋馆', rate: 60, color: '#7A6BD6' }
];
const SHOP_TOP: ShopTopMeta[] = [
{ name: '海豚玩偶', sales: 486, color: '#1890C9' },
{ name: '水母夜灯', sales: 372, color: '#7CC7E8' },
{ name: '鲨鱼牙吊坠', sales: 268, color: '#FF7F9E' },
{ name: '企鹅钥匙扣', sales: 330, color: '#3FB98A' },
{ name: '贝壳风铃', sales: 214, color: '#F5A623' },
{ name: '海洋主题T恤', sales: 176, color: '#7A6BD6' }
];
@Observed
export class AquaData {
tanks: TankItem[] = [
{ name: '珊瑚礁主缸', zone: 'A区', fishCount: 1280, volume: 560, temp: 26, emoji: '🪸' },
{ name: '深海隧道', zone: 'A区', fishCount: 860, volume: 1200, temp: 24, emoji: '🌊' },
{ name: '水母馆', zone: 'B区', fishCount: 240, volume: 180, temp: 22, emoji: '🪼' },
{ name: '企鹅馆', zone: 'B区', fishCount: 18, volume: 320, temp: 6, emoji: '🐧' },
{ name: '鲨鱼湾', zone: 'C区', fishCount: 26, volume: 2400, temp: 25, emoji: '🦈' },
{ name: '海龟湾', zone: 'C区', fishCount: 12, volume: 800, temp: 27, emoji: '🐢' },
{ name: '淡水秘境', zone: 'D区', fishCount: 540, volume: 260, temp: 24, emoji: '🐟' },
{ name: '雨林溪流', zone: 'D区', fishCount: 380, volume: 190, temp: 25, emoji: '🌿' },
{ name: '珊瑚培育缸', zone: 'A区', fishCount: 60, volume: 120, temp: 27, emoji: '🪸' },
{ name: '触摸池', zone: 'E区', fishCount: 45, volume: 60, temp: 23, emoji: '✋' },
{ name: '极地冰海', zone: 'B区', fishCount: 32, volume: 900, temp: 4, emoji: '❄️' },
{ name: '海星花园', zone: 'E区', fishCount: 28, volume: 40, temp: 25, emoji: '⭐' }
];
fishs: FishItem[] = [
{ name: '小丑鱼', kind: '热带鱼', origin: '印度洋', price: 88, count: 96, emoji: '🐠' },
{ name: '蓝吊鱼', kind: '热带鱼', origin: '太平洋', price: 128, count: 64, emoji: '🐟' },
{ name: '火焰神仙鱼', kind: '热带鱼', origin: '红海', price: 268, count: 32, emoji: '🔥' },
{ name: '金鱼', kind: '冷水鱼', origin: '中国', price: 18, count: 140, emoji: '🧡' },
{ name: '锦鲤', kind: '冷水鱼', origin: '日本', price: 68, count: 58, emoji: '🎏' },
{ name: '水晶虾', kind: '观赏虾', origin: '亚洲', price: 38, count: 210, emoji: '🦐' },
{ name: '樱花虾', kind: '观赏虾', origin: '东南亚', price: 28, count: 180, emoji: '🌸' },
{ name: '海月水母', kind: '水母', origin: '全球温带', price: 158, count: 42, emoji: '🪼' },
{ name: '赤月水母', kind: '水母', origin: '太平洋', price: 198, count: 26, emoji: '🌕' },
{ name: '鹿角珊瑚', kind: '珊瑚', origin: '珊瑚海', price: 398, count: 18, emoji: '🪸' },
{ name: '绿海龟', kind: '海龟', origin: '大西洋', price: 880, count: 6, emoji: '🐢' },
{ name: '玳瑁', kind: '海龟', origin: '印度洋', price: 980, count: 4, emoji: '🧿' }
];
shows: ShowItem[] = [
{ name: '海豚表演', time: '11:00 15:00', seats: 800, joined: 768, type: '必看', emoji: '🐬' },
{ name: '海狮剧场', time: '12:30 16:30', seats: 400, joined: 352, type: '欢乐', emoji: '🦭' },
{ name: '人鲨共舞', time: '13:00 17:00', seats: 300, joined: 246, type: '刺激', emoji: '🦈' },
{ name: '水母灯光秀', time: '14:00 19:00', seats: 500, joined: 375, type: '梦幻', emoji: '🪼' },
{ name: '企鹅喂食', time: '10:30 14:30', seats: 200, joined: 136, type: '亲子', emoji: '🐧' },
{ name: '夜宿海洋馆', time: '20:00 次日', seats: 60, joined: 36, type: '夜场', emoji: '🌙' },
{ name: '海豚合影', time: '全天预约', seats: 40, joined: 29, type: '互动', emoji: '📸' },
{ name: '潜水体验', time: '10:00 15:00', seats: 12, joined: 7, type: '刺激', emoji: '🤿' },
{ name: '美人鱼秀', time: '13:30 17:30', seats: 350, joined: 294, type: '梦幻', emoji: '🧜♀️' },
{ name: '海龟学堂', time: '14:30', seats: 150, joined: 96, type: '亲子', emoji: '🐢' },
{ name: '鲸鲨投喂', time: '11:30', seats: 80, joined: 52, type: '必看', emoji: '🐋' },
{ name: '海洋科普课', time: '周末 10:00', seats: 100, joined: 63, type: '科普', emoji: '📚' }
];
shops: ShopItem[] = [
{ name: '海豚玩偶', price: 68, sales: 486, stock: 88, category: '玩偶', emoji: '🐬' },
{ name: '水母夜灯', price: 88, sales: 372, stock: 54, category: '家居', emoji: '🪼' },
{ name: '鲨鱼牙吊坠', price: 128, sales: 268, stock: 32, category: '饰品', emoji: '🦈' },
{ name: '企鹅钥匙扣', price: 25, sales: 330, stock: 120, category: '饰品', emoji: '🐧' },
{ name: '贝壳风铃', price: 58, sales: 214, stock: 46, category: '家居', emoji: '🐚' },
{ name: '海洋T恤', price: 89, sales: 176, stock: 38, category: '服饰', emoji: '👕' },
{ name: '珊瑚摆件', price: 168, sales: 128, stock: 14, category: '摆件', emoji: '🪸' },
{ name: '海豚冰箱贴', price: 19, sales: 520, stock: 200, category: '文创', emoji: '🧲' },
{ name: '鲸鱼抱枕', price: 98, sales: 186, stock: 42, category: '玩偶', emoji: '🐳' },
{ name: '海洋拼图', price: 69, sales: 142, stock: 36, category: '益智', emoji: '🧩' },
{ name: '贝壳项链', price: 78, sales: 198, stock: 50, category: '饰品', emoji: '📿' },
{ name: '章鱼玩偶', price: 72, sales: 164, stock: 44, category: '玩偶', emoji: '🐙' }
];
cards: CardItem[] = [
{ name: '单人年卡', price: 388, visits: 99, perks: '不限次入园', level: '基础', emoji: '🎫' },
{ name: '双人年卡', price: 688, visits: 199, perks: '两人不限次', level: '基础', emoji: '👥' },
{ name: '家庭年卡', price: 988, visits: 299, perks: '2大1小', level: '家庭', emoji: '👨👩👧' },
{ name: '亲子年卡', price: 888, visits: 249, perks: '1大1小', level: '家庭', emoji: '🧒' },
{ name: '银卡', price: 1288, visits: 365, perks: '表演VIP席', level: '银卡', emoji: '🥈' },
{ name: '金卡', price: 2588, visits: 365, perks: '含潜水体验', level: '金卡', emoji: '🥇' },
{ name: '钻石卡', price: 6888, visits: 365, perks: '全馆通VIP', level: '钻石', emoji: '💎' },
{ name: '学生年卡', price: 288, visits: 99, perks: '学生专享', level: '基础', emoji: '🎓' },
{ name: '老人年卡', price: 258, visits: 99, perks: '60岁+专享', level: '基础', emoji: '👴' },
{ name: '夜游年卡', price: 458, visits: 99, perks: '夜场不限次', level: '夜游', emoji: '🌙' },
{ name: '潜水年卡', price: 3288, visits: 365, perks: '含12次潜水', level: '金卡', emoji: '🤿' },
{ name: '终身卡', price: 9999, visits: 999, perks: '终身畅玩', level: '钻石', emoji: '👑' }
];
}
function getKindColor(kind: string): string {
if (kind === '热带鱼') {
return '#1890C9';
} else if (kind === '冷水鱼') {
return '#FF7F9E';
} else if (kind === '观赏虾') {
return '#F5A623';
} else if (kind === '水母') {
return '#7CC7E8';
} else if (kind === '珊瑚') {
return '#3FB98A';
}
return '#7A6BD6';
}
function getShowColor(type: string): string {
if (type === '必看') {
return '#EF5B5B';
} else if (type === '欢乐') {
return '#F5A623';
} else if (type === '刺激') {
return '#1890C9';
} else if (type === '梦幻') {
return '#7CC7E8';
} else if (type === '亲子') {
return '#3FB98A';
} else if (type === '夜场') {
return '#7A6BD6';
} else if (type === '互动') {
return '#FF7F9E';
}
return '#5C8CA3';
}
function getCategoryColor(category: string): string {
if (category === '玩偶') {
return '#1890C9';
} else if (category === '家居') {
return '#F5A623';
} else if (category === '饰品') {
return '#FF7F9E';
} else if (category === '服饰') {
return '#7A6BD6';
} else if (category === '摆件') {
return '#3FB98A';
} else if (category === '文创') {
return '#7CC7E8';
}
return '#5C8CA3';
}
function getLevelColor(level: string): string {
if (level === '基础') {
return '#1890C9';
} else if (level === '家庭') {
return '#3FB98A';
} else if (level === '银卡') {
return '#A0A0B0';
} else if (level === '金卡') {
return '#D4A017';
}
return '#7A6BD6';
}
@Entry
@Component
struct AquaApp {
@State curTab: number = 0;
@State data: AquaData = new AquaData();
@State showAddFish: boolean = false;
@State showEditTank: boolean = false;
@State showDeleteShop: boolean = false;
@State showDetail: boolean = false;
@State delShopName: string = '';
@State detailShowName: string = '';
@State fishSwim: boolean = false;
@State bubbleRise: boolean = false;
@Builder modalOverlay(onClose: () => void) {
Column() {
Text('')
.width(0)
.height(0)
.opacity(0)
Button('')
.width(1)
.height(1)
.opacity(0)
.onClick(() => {
onClose();
})
}
.width(1)
.height(1)
}
build() {
Column() {
Column() {
Column() {
Row() {
Column() {
Text('🐠 珊瑚海洋馆')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('Coral Aquarium · 蔚蓝之境')
.fontSize(10)
.fontColor('#FFFFFFCC')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Row() {
Text('🐠')
.fontSize(20)
.onClick(() => {
this.fishSwim = !this.fishSwim;
})
.translate({ x: this.fishSwim ? 12 : 0 })
.animation({ duration: 600, curve: Curve.EaseInOut })
Text('🫧')
.fontSize(18)
.margin({ left: 10 })
.onClick(() => {
this.bubbleRise = !this.bubbleRise;
})
.translate({ y: this.bubbleRise ? -10 : 0 })
.opacity(this.bubbleRise ? 0.3 : 1)
.animation({ duration: 700, curve: Curve.EaseOut })
Text('🐬')
.fontSize(16)
.margin({ left: 6 })
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor('#FFFFFF26')
.borderRadius(18)
.border({ width: 1, color: '#FFFFFF40' })
}
.width('100%')
Row() {
ForEach(BUBBLE_COL, (c: number, i: number) => {
Column()
.layoutWeight(1)
.height(6 + i % 3 * 4)
.backgroundColor('#FFFFFF55')
.borderRadius(50)
.margin({ left: 3, right: 3 })
}, (c: number) => 'bubA' + c)
}
.width('100%')
.height(14)
.margin({ top: 10 })
.alignItems(VerticalAlign.Bottom)
Row() {
ForEach(BUBBLE_COL, (c: number, i: number) => {
Column()
.layoutWeight(1)
.height(4 + (i + 1) % 3 * 3)
.backgroundColor('#FFFFFF40')
.borderRadius(50)
.margin({ left: 4, right: 4 })
}, (c: number) => 'bubB' + c)
}
.width('100%')
.height(10)
.margin({ top: 4 })
.alignItems(VerticalAlign.Bottom)
Row() {
Text('海洋蓝')
.fontSize(8)
.fontColor('#FFFFFFCC')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor('#FFFFFF2E')
.borderRadius(8)
Text('珊瑚粉')
.fontSize(8)
.fontColor('#FFFFFFCC')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor('#FFFFFF2E')
.borderRadius(8)
Text('气泡白')
.fontSize(8)
.fontColor('#FFFFFFCC')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor('#FFFFFF2E')
.borderRadius(8)
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor(COLORS.primary)
if (this.curTab === AquaTab.TANK) {
TankContent({ data: this.data, onEdit: () => {
this.showEditTank = true;
} })
} else if (this.curTab === AquaTab.FISH) {
FishContent({ data: this.data, onAdd: () => {
this.showAddFish = true;
} })
} else if (this.curTab === AquaTab.SHOW) {
ShowContent({ data: this.data, onDetail: (n: string) => {
this.detailShowName = n;
this.showDetail = true;
} })
} else if (this.curTab === AquaTab.SHOP) {
ShopContent({ data: this.data, onDel: (n: string) => {
this.delShopName = n;
this.showDeleteShop = true;
} })
} else {
CardContent({ data: this.data })
}
}
.width('100%')
.height('100%')
Row() {
ForEach(TAB_LIST, (t: TabMeta) => {
Column() {
Text(t.icon)
.fontSize(19)
Text(t.label)
.fontSize(10)
.fontColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.white : COLORS.textHint)
.fontWeight(this.curTab === TAB_LIST.indexOf(t) ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.padding({ top: 8, bottom: 8 })
.backgroundColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.primary : COLORS.cardBg)
.borderRadius(16)
.border(this.curTab === TAB_LIST.indexOf(t) ? { width: 1, color: t.color + '88' } : { width: 1, color: COLORS.line })
.onClick(() => {
this.curTab = TAB_LIST.indexOf(t);
})
}, (t: TabMeta) => t.key)
}
.width('100%')
.height(60)
.padding({ left: 8, right: 8 })
.backgroundColor(COLORS.cardBg)
.border({ width: 1, color: COLORS.line })
if (this.showAddFish) {
AddFishModal({
onClose: () => {
this.showAddFish = false;
}
})
}
if (this.showEditTank) {
EditTankModal({
onClose: () => {
this.showEditTank = false;
}
})
}
if (this.showDeleteShop) {
DeleteShopModal({
title: this.delShopName, onClose: () => {
this.showDeleteShop = false;
}
})
}
if (this.showDetail) {
DetailShowModal({
name: this.detailShowName, onClose: () => {
this.showDetail = false;
}
})
}
}
}
}
@Component
struct AquaTag {
@Prop text: string;
@Prop color: string;
build() {
Text(this.text)
.fontSize(9)
.fontColor(this.color)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(this.color + '1F')
.borderRadius(12)
.border({ width: 1, color: this.color + '40' })
}
}
@Component
struct TankContent {
@ObjectLink data: AquaData;
onEdit: () => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('📈 周客流(百人)')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('周六9600人')
.fontSize(9)
.fontColor(COLORS.coral)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
ForEach(WEEK_VISIT, (w: WeekMeta) => {
Column() {
Text(w.value + '')
.fontSize(8)
.fontColor(w.value >= 80 ? COLORS.coral : COLORS.textSecondary)
Column()
.width(20)
.height(w.value * 1.2)
.backgroundColor(w.value >= 80 ? COLORS.coral : COLORS.primary)
.borderRadius(6)
.margin({ top: 4 })
Text(w.day)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (w: WeekMeta) => w.day)
}
.width('100%')
.height(140)
.alignItems(VerticalAlign.Bottom)
.margin({ top: 10 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.border({ width: 1, color: COLORS.primary + '55' })
Column() {
Row() {
Text('🐠 馆藏鱼种分布')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('全馆12类')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
ForEach(FISH_SHARE, (n: FishMeta) => {
Row() {
Text(n.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(60)
Row() {
Row()
.layoutWeight(n.count / 4)
.height(8)
.backgroundColor(n.color)
.borderRadius(4)
Row()
.layoutWeight(1 - n.count / 4)
.height(8)
.backgroundColor(COLORS.bg)
.borderRadius(4)
}
.layoutWeight(1)
.height(8)
Text(n.count + '类')
.fontSize(9)
.fontColor(n.color)
.width(32)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (n: FishMeta) => n.name)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.border({ width: 1, color: COLORS.line })
.margin({ top: 12 })
Column() {
Row() {
Text('🫧 展缸总览')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击✏️调整')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
Text('✏️')
.fontSize(13)
.margin({ left: 8 })
.onClick(() => {
this.onEdit();
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.tanks, (t: TankItem, i: number) => {
Row() {
Column()
.width(44)
.height(44)
.justifyContent(FlexAlign.Center)
.backgroundColor(COLORS.primary + '1F')
.borderRadius(22)
.border({ width: 2, color: COLORS.bubble })
Text(t.emoji)
.fontSize(19)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(t.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
AquaTag({ text: t.zone, color: '#1890C9' })
.margin({ left: 6 })
}
.width('100%')
Text('水体' + t.volume + '吨 · 鱼' + t.fishCount + '尾')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text(t.temp + '℃')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(t.temp <= 10 ? COLORS.primaryLight : COLORS.warning)
Text('水温')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (t: TankItem, i: number) => t.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardAlt)
.borderRadius(18)
.border({ width: 1, color: COLORS.primary + '55' })
.margin({ top: 12 })
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct FishContent {
@ObjectLink data: AquaData;
onAdd: () => void = () => {};
build() {
Scroll() {
Column() {
Row() {
Text('🐠 馆藏鱼种')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击➕引进')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
Text('➕')
.fontSize(13)
.margin({ left: 8 })
.onClick(() => {
this.onAdd();
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
Row() {
Column() {
Text('12')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
Text('馆藏鱼种')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.border({ width: 1, color: COLORS.primary + '55' })
Column() {
Text('880')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.coral)
Text('最贵龟类')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.border({ width: 1, color: COLORS.coral + '55' })
.margin({ left: 10 })
Column() {
Text('210')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.warning)
Text('水晶虾存量')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.border({ width: 1, color: COLORS.warning + '55' })
.margin({ left: 10 })
}
.width('100%')
.margin({ bottom: 12 })
ForEach(this.data.fishs, (f: FishItem, i: number) => {
Row() {
Column()
.width(42)
.height(42)
.justifyContent(FlexAlign.Center)
.backgroundColor(getKindColor(f.kind) + '22')
.borderRadius(21)
Text(f.emoji)
.fontSize(18)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(f.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
AquaTag({ text: f.kind, color: getKindColor(f.kind) })
.margin({ left: 6 })
}
.width('100%')
Text('产地' + f.origin + ' · ¥' + f.price + '/尾')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text(f.count + '尾')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(f.count >= 100 ? COLORS.success : COLORS.warning)
Text('存量')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (f: FishItem, i: number) => f.name + i)
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct ShowContent {
@ObjectLink data: AquaData;
onDetail: (n: string) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('🎪 表演上座率')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('海豚表演爆满')
.fontSize(9)
.fontColor(COLORS.danger)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
ForEach(SHOW_JOIN, (s: ShowJoinMeta) => {
Row() {
Text(s.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(84)
Row() {
Row()
.layoutWeight(s.rate / 100)
.height(8)
.backgroundColor(s.color)
.borderRadius(4)
Row()
.layoutWeight(1 - s.rate / 100)
.height(8)
.backgroundColor(COLORS.bg)
.borderRadius(4)
}
.layoutWeight(1)
.height(8)
Text(s.rate + '%')
.fontSize(9)
.fontColor(s.color)
.width(38)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (s: ShowJoinMeta) => s.name)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.border({ width: 1, color: COLORS.warning + '66' })
Column() {
Row() {
Text('🐬 今日演出')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击查看详情')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.shows, (s: ShowItem, i: number) => {
Row() {
Column()
.width(40)
.height(40)
.justifyContent(FlexAlign.Center)
.backgroundColor(getShowColor(s.type) + '22')
.borderRadius(20)
Text(s.emoji)
.fontSize(18)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(s.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
AquaTag({ text: s.type, color: getShowColor(s.type) })
.margin({ left: 6 })
}
.width('100%')
Text(s.time + ' · ' + s.seats + '座')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text(s.joined + '/' + s.seats)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(s.joined >= s.seats * 0.8 ? COLORS.danger : COLORS.success)
Text('已售')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
Text('👁️')
.fontSize(12)
.margin({ top: 3 })
.onClick(() => {
this.onDetail(s.name);
})
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (s: ShowItem, i: number) => s.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardAlt)
.borderRadius(18)
.border({ width: 1, color: COLORS.line })
.margin({ top: 12 })
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct ShopContent {
@ObjectLink data: AquaData;
onDel: (n: string) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('🐚 商品销量TOP6')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('冰箱贴夺冠')
.fontSize(9)
.fontColor(COLORS.accent)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(SHOP_TOP, (s: ShopTopMeta, i: number) => {
Row() {
Text((i + 1) + '')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(i < 3 ? COLORS.coral : COLORS.textHint)
.width(22)
Text(s.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(80)
Row() {
Row()
.layoutWeight(s.sales / 490)
.height(8)
.backgroundColor(s.color)
.borderRadius(4)
Row()
.layoutWeight(1 - s.sales / 490)
.height(8)
.backgroundColor(COLORS.bg)
.borderRadius(4)
}
.layoutWeight(1)
.height(8)
Text(s.sales + '件')
.fontSize(9)
.fontColor(s.color)
.width(44)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 7 })
}, (s: ShopTopMeta, i: number) => s.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.border({ width: 1, color: COLORS.coral + '66' })
Column() {
Row() {
Text('🛒 海洋商店')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击🗑️下架')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.shops, (s: ShopItem, i: number) => {
Row() {
Column()
.width(40)
.height(40)
.justifyContent(FlexAlign.Center)
.backgroundColor(getCategoryColor(s.category) + '22')
.borderRadius(14)
Text(s.emoji)
.fontSize(18)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(s.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
AquaTag({ text: s.category, color: getCategoryColor(s.category) })
.margin({ left: 6 })
}
.width('100%')
Text('¥' + s.price + ' · 已售' + s.sales + '件')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text(s.stock > 30 ? '充足' : '告急')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(s.stock > 30 ? COLORS.success : COLORS.danger)
Text('库存' + s.stock)
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
Text('🗑️')
.fontSize(12)
.margin({ top: 4 })
.onClick(() => {
this.onDel(s.name);
})
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (s: ShopItem, i: number) => s.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardAlt)
.borderRadius(18)
.border({ width: 1, color: COLORS.line })
.margin({ top: 12 })
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct CardContent {
@ObjectLink data: AquaData;
build() {
Scroll() {
Column() {
Row() {
Text('🎫 年卡中心')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('钻石卡终身畅玩')
.fontSize(9)
.fontColor(COLORS.coral)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
Row() {
Column() {
Text('12')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
Text('在售卡种')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.border({ width: 1, color: COLORS.primary + '55' })
Column() {
Text('¥258')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.success)
Text('最低年卡')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.border({ width: 1, color: COLORS.success + '55' })
.margin({ left: 10 })
Column() {
Text('¥9999')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.warning)
Text('终身卡')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.border({ width: 1, color: COLORS.warning + '55' })
.margin({ left: 10 })
}
.width('100%')
.margin({ bottom: 12 })
ForEach(this.data.cards, (c: CardItem, i: number) => {
Row() {
Column()
.width(42)
.height(42)
.justifyContent(FlexAlign.Center)
.backgroundColor(getLevelColor(c.level) + '22')
.borderRadius(21)
Text(c.emoji)
.fontSize(18)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(c.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
AquaTag({ text: c.level, color: getLevelColor(c.level) })
.margin({ left: 6 })
}
.width('100%')
Text(c.perks + ' · ' + c.visits + '次/年')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text('¥' + c.price)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(c.price >= 3000 ? COLORS.warning : COLORS.primary)
Text('/年')
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (c: CardItem, i: number) => c.name + i)
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct AddFishModal {
onClose: () => void = () => {};
build() {
Column() {
Column() {
Row() {
Text('🐠')
.fontSize(26)
Column() {
Text('引进新鱼种')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('录入鱼种资料')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor(COLORS.textHint)
.onClick(() => {
this.onClose();
})
}
.width('100%')
Row() {
Text('鱼种名称')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('黄尾蓝魔鱼')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 14 })
Row() {
Text('鱼种分类')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('热带鱼 · 珊瑚礁')
.fontSize(12)
.fontColor(COLORS.primary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('引进数量')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('40尾 · 检疫期14天')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('采购价')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('¥65/尾 · 含检疫费')
.fontSize(12)
.fontColor(COLORS.coral)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Text('✅ 新鱼种须完成隔离检疫并提交水质适配报告后方可入主缸。')
.fontSize(10)
.fontColor(COLORS.textHint)
.width('100%')
.margin({ top: 14 })
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.onClick(() => {
this.onClose();
})
Text('确认引进')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.margin({ left: 10 })
.backgroundColor(COLORS.primary)
.borderRadius(12)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 16 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
.border({ width: 1, color: COLORS.primary + '66' })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
}
}
}
结尾总结

珊瑚海洋馆应用作为一个基于 ArkUI 声明式范式构建的全场景水族馆管理工具,在架构设计、色彩体系、组件化策略和状态管理等多个维度上展现了值得深入分析的工程实践。从整体架构来看,应用采用了经典的"主入口协调 + 内容组件自治 + 弹窗组件独立"的三层架构。主入口组件 AquaApp 承担了导航控制、弹窗调度和数据分发的协调职责,通过十个 @State 状态变量精确管理导航索引、弹窗显隐、上下文参数和动画状态。五个内容组件各自封装了独立的业务场景(展缸、鱼种、表演、商店、年卡),通过 @ObjectLink 接收中央数据源并自动响应数据变化。四个弹窗组件则通过条件挂载方式按需创建,每个弹窗通过 onClose 回调与父组件通信,形成了清晰的事件冒泡机制。这种分层架构使得各组件职责单一、接口明确,具备良好的可维护性和可测试性。
在色彩体系设计方面,应用通过 AquaPalette 接口和 COLORS 常量构建了完整的设计令牌体系,涵盖主色、辅助色、背景色、文字色、边框色和语义色六大类别共十九个色值。所有组件统一引用 COLORS 常量而非直接使用十六进制色值,确保了色彩引用的一致性和可维护性。更值得称道的是,应用通过十六进制透明度后缀(如 + '22'、+ '40'、+ '55'、+ '66')动态生成同一颜色的不同透明度变体,用于图标背景、标签边框和卡片边框等场景,避免了为每个透明度变体预定义常量的冗余。四个颜色映射函数(getKindColor、getShowColor、getCategoryColor、getLevelColor)将业务语义到视觉颜色的映射规则集中管理,实现了颜色策略的单一职责原则。银卡和金卡使用脱离全局色板的金属色值,体现了语义准确性优先于色彩一致性的设计判断。
组件化策略方面,AquaTag 作为复用频率最高的通用组件,通过 @Prop 接收文本和颜色两个只读属性,在五个内容页面的所有列表项中被统一使用,充分展现了组件化设计的价值。四个弹窗组件虽然业务场景不同,但共享了高度同构的布局结构——头部三列布局(图标、标题区、关闭按钮)、表单字段行(70 像素标签 + 弹性值区域)和底部操作按钮区——这种"模板化"设计减少了代码重复,使新增弹窗的开发成本降至最低。内容页面之间也保持了布局一致性:所有列表项采用三列结构(图标、信息区、指标区),所有图表卡片使用 layoutWeight 权重机制实现自适应进度条,所有统计卡片使用统一的字号层级和圆角规格。这种跨页面的视觉一致性降低了用户的学习成本,也体现了设计系统(Design System)的系统性思维。
状态管理是本应用的另一大亮点。@Observed 装饰的 AquaData 类作为唯一的业务数据源,集中持有展缸、鱼种、演出、商品和年卡五个列表,通过 @ObjectLink 实现了跨组件层级的响应式数据传递。统计型常量数据(周客流、鱼种分布、上座率、销量排行)与业务数据分离定义,各自服务不同的展示目的。弹窗的上下文参数(delShopName、detailShowName)通过 @State 临时存储,在子组件回调中赋值、在弹窗实例化时通过 @Prop 注入,形成了完整的"事件携带数据向上传递、数据通过参数向下注入"的单向数据流。动画状态(fishSwim、bubbleRise)直接绑定到 translate 和 opacity 属性,配合 animation 修饰器实现声明式动画,开发者无需手动管理动画生命周期。
从工程化角度看,应用还存在一些可优化的方向。首先,四个弹窗的表单字段使用静态 Text 组件展示预填数据,在真实生产环境中应替换为 TextInput 等可编辑组件以支持用户输入。其次,所有数据预置为常量或类属性初始值,在真实环境中应替换为从后端 API 动态获取的结果。第三,四个颜色映射函数使用 if-else 链式结构,在分类数量增长时可考虑改为对象字面量映射以降低维护成本。第四,AquaTag 和弹窗的结构高度同构,可通过 @Builder 函数或继承机制进一步减少代码重复。最后,应用缺少数据校验和错误处理逻辑,在真实环境中需要补充网络请求失败、数据为空等异常场景的处理。这些优化方向并不影响当前原型设计的完整性,但指明了从原型到产品的演进路径。
综上所述,珊瑚海洋馆应用在有限的代码量内实现了五个业务场景的完整覆盖,通过精心的色彩体系设计、严格的组件化拆分和清晰的响应式状态管理,构建了一个视觉精致、交互流畅、架构清晰的水族馆管理界面。它不仅是一个功能完整的应用原型,更是一个值得参考的 ArkUI 工程实践范例。
更多推荐




所有评论(0)