星河天文馆:HarmonyOS ArkTS API 24主题应用架构全解析,content 字段在列表中通过 maxLines(1) 和 textOverflow 进行单行截断,确保列表项高度的一致性
引言

在移动应用开发领域,天文科普类应用一直是一个兼具教育价值与视觉表现力的细分赛道。本文将以"星河天文馆"应用为例,深入剖析一个基于 HarmonyOS ArkTS 框架构建的综合性天文科普平台的技术实现。该应用覆盖了展览管理、行星图鉴、课程报名、天文商店和游客评价五大核心业务模块,充分展示了声明式 UI 范式在复杂业务场景下的工程实践能力。
从设计理念来看,该应用采用"深空夜空"作为整体视觉主题,旨在为用户营造沉浸式的宇宙探索氛围。深空蓝作为主色调(#5B6EE1),搭配星光金(#F5C451)作为强调色,构建出星空中星辰与深空交辉的视觉体验。背景色采用极深的靛蓝(#0E1330),模拟夜空的深邃感,而卡片背景(#1A2140)与交替背景(#222B52)的层次递进,为信息密度较高的列表场景提供了清晰的视觉分区。这种色彩体系不仅服务于美学追求,更承担着信息层级划分、状态指示和交互引导的多重职能。
在技术选型方面,该应用充分运用了 ArkTS 的核心特性:@Entry 和 @Component 装饰器构建组件化架构,@State 管理组件内部状态,@Observed 和 @ObjectLink 实现跨组件的响应式数据传递,@Prop 完成单向数据流传递。这种分层状态管理策略,使得父组件能够统一管控全局弹窗的显隐状态,而子组件则专注于各自业务领域的渲染逻辑,实现了关注点的清晰分离。
组件化策略是该应用的另一大亮点。整个应用拆分为一个主入口组件 SkyApp、一个通用标签组件 SkyTag、五个内容页面组件和四个弹窗组件。主入口组件作为容器,通过 curTab 状态变量驱动条件渲染,在五个内容页面之间切换。每个内容页面组件通过 @ObjectLink 与共享的 SkyData 实例建立双向绑定,确保数据的实时同步。弹窗组件则采用条件挂载的方式,由父组件的布尔状态变量控制其生命周期,这种设计避免了全局状态管理库的引入,在保证功能完备的同时保持了架构的轻量级。
色彩体系的设计体现了高度的工程化思维。SkyPalette 接口定义了二十一个色彩槽位,从主色、强调色到语义色(成功、警告、危险)一应俱全。通过将色彩常量集中管理,应用实现了主题的统一可控。同时,四个工具函数(getThemeColor、getLevelColor、getCategoryColor、getTagColor)将业务语义映射到色彩值,使得列表项能够根据自身属性动态着色,这种策略大大增强了信息展示的直观性和可读性。数据可视化方面,应用大量采用 layoutWeight 实现的进度条和柱状图,在不引入第三方图表库的前提下,通过纯声明式布局完成了数据可视化的需求,体现了对框架原生能力的深度挖掘。
逐段代码分析

第一段:色彩体系接口定义
interface SkyPalette {
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;
star: string;
orbit: string;
}
这段代码定义了应用的核心色彩体系接口 SkyPalette,它声明了二十一个色彩属性,覆盖了应用视觉设计的全部维度。从结构上分析,色彩被划分为四个层次:主色调系(primary、primaryLight、primaryDark)负责品牌识别和主要交互元素;强调色系(accent、accentLight)用于高亮关键信息和数据峰值;背景层(bg、cardBg、cardAlt)构建视觉纵深;文字层(textPrimary、textSecondary、textHint)形成三级信息层级。语义色(success、warning、danger)则承担状态指示功能。
这种将色彩体系抽象为接口的设计模式,带来三个显著的工程优势。首先是类型安全,任何使用 SkyPalette 类型的地方都会在编译期获得完整的属性检查,避免了拼写错误导致的色彩丢失。其次是可维护性,若未来需要支持暗色/亮色双主题切换,只需定义第二个实现该接口的常量即可。最后是文档价值,接口本身就是色彩系统的设计文档,开发者一目了然地知道应用支持哪些色彩槽位。star 和 orbit 两个特殊色彩属性体现了天文主题的定制化考量。
第二段:色彩常量实现

const COLORS: SkyPalette = {
primary: '#5B6EE1',
primaryLight: '#8FA3FF',
primaryDark: '#1B2248',
accent: '#F5C451',
accentLight: '#FFE9B0',
bg: '#0E1330',
cardBg: '#1A2140',
cardAlt: '#222B52',
textPrimary: '#F2F4FF',
textSecondary: '#A7B0D9',
textHint: '#6B74A6',
border: '#2C3560',
line: '#2C3560',
success: '#5BD6A8',
warning: '#F5C451',
danger: '#FF6B81',
white: '#FFFFFF',
star: '#F5C451',
orbit: '#5B6EE1'
};
这是 SkyPalette 接口的具体实现,采用 const 声明确保全局唯一性。色彩值的选取遵循了深空夜空主题的设计语言:背景色 #0E1330 是近乎纯黑的靛蓝色,模拟无光污染的夜空环境;主色 #5B6EE1 是偏冷的蓝紫色,对应深空中星云的色彩印象;强调色 #F5C451 是温暖的金黄色,模拟恒星的星光。冷暖色调的对比在视觉上形成了"星辰点缀深空"的效果。
值得注意的是,文字三级色彩(#F2F4FF、#A7B0D9、#6B74A6)的明度递减经过精心计算,在深色背景上保持了足够的对比度,确保了可访问性。卡片背景色 #1A2140 与 #222B52 之间的微妙差异,用于列表项的交替着色,在不引入强烈视觉跳跃的前提下实现了行间区分。success 使用青绿色而非传统的纯绿色,danger 使用珊瑚红而非纯红,这些微调都是为了与整体冷色调和谐统一。
第三段:标签页枚举定义

enum SkyTab {
EXHIBIT = 0,
PLANET = 1,
COURSE = 2,
SHOP = 3,
REVIEW = 4
}
SkyTab 枚举定义了应用的五个主功能模块标识符,分别对应展览、行星、课程、商店和评价五个页面。使用枚举而非魔法数字是经典的工程实践,它为代码赋予了自文档化能力——当我们在条件判断中看到 SkyTab.EXHIBIT 时,无需查阅注释即可理解其业务含义。枚举值从零开始递增,与底部标签栏的 ForEach 索引天然对齐,简化了标签切换的逻辑。
从架构设计的角度,这个枚举实际上是应用导航状态的有限状态机定义。curTab 状态变量的取值被严格约束在这五个枚举值内,任何超出范围的值都不会触发有效的页面渲染。这种设计有效防止了无效状态的出现,比使用字符串字面量更加安全。同时,枚举的数值连续性使得未来如果需要增加或调整模块顺序,只需修改枚举定义而无需改动渲染逻辑。
第四段:标签元数据接口与列表

interface TabMeta {
key: string;
icon: string;
label: string;
color: string;
}
const TAB_LIST: TabMeta[] = [
{ key: 'exhibit', icon: '🔭', label: '展览', color: '#5B6EE1' },
{ key: 'planet', icon: '🪐', label: '行星', color: '#F5C451' },
{ key: 'course', icon: '🌌', label: '课程', color: '#8FA3FF' },
{ key: 'shop', icon: '🛰️', label: '商店', color: '#5BD6A8' },
{ key: 'review', icon: '⭐', label: '评价', color: '#FF6B81' }
];
TabMeta 接口定义了标签的元数据结构,包含键值、图标、标签文本和主题色四个字段。TAB_LIST 常量将五个标签的元数据以数组形式集中管理。这种数据驱动的设计模式,使得底部标签栏的渲染完全由 ForEach 自动完成,新增或删除标签只需修改数组而无需改动 UI 代码。
每个标签都拥有独立的主题色,这是一个精巧的设计决策。展览标签使用主色蓝、行星标签使用金色、课程标签使用浅蓝、商店标签使用绿色、评价标签使用红色,五种色彩在底部导航栏中形成彩虹般的视觉序列,既美观又起到了功能区分的作用。图标采用 Emoji 而非图标字体或 SVG,这是一个务实的工程选择——Emoji 在所有设备上原生支持,无需额外的字体加载,且色彩丰富,契合天文主题的趣味性表达。
第五段:星星列常量

const STAR_COL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
STAR_COL 是一个简单的数字数组,从零到九共十个元素。这个常量的用途并非存储有意义的数据,而是作为 ForEach 迭代器的计数器,用于在顶部标题栏生成三行装饰性的星星条。这种将迭代次数抽象为命名常量的做法,避免了在模板中直接使用魔法数字,提高了代码的可读性。
从更深层次来看,这种设计体现了声明式 UI 的核心理念——UI 元素的生成由数据驱动。三行星星条每行十颗星,每行采用不同的间隔策略点亮(第零、一、二个位置开始每隔三个点亮),形成错落有致的星空效果。如果未来需要调整星星密度,只需修改这个数组的长度即可,所有三行星星条会自动适配新的数量。
第六段:展览数据模型

interface ExhibitItem {
name: string;
theme: string;
visitors: number;
rating: number;
zone: string;
emoji: string;
}
ExhibitItem 接口定义了展览项目的数据结构,包含名称、主题、访客数、评分、展区和图标六个字段。这个模型设计体现了"恰好够用"的原则——每个字段都在 UI 渲染中被直接使用,没有冗余属性。theme 字段是字符串而非枚举,提供了灵活性以支持未来新增主题类型,同时通过 getThemeColor 函数实现主题到色彩的映射。
visitors 和 rating 两个数值字段的类型选择值得关注。访客数使用 number 而非 string,使得前端可以直接进行数值比较和格式化运算。评分采用十进制数值(如 9.6),在 UI 层通过 layoutWeight 比例直接渲染为进度条,无需额外的数据转换。emoji 字段的存在使得每个展览项目都拥有视觉标识,在列表渲染时提供了即时的图形化识别能力。
第七段:行星数据模型
interface PlanetItem {
name: string;
type: string;
distance: number;
size: number;
moons: number;
emoji: string;
}
PlanetItem 接口定义了行星图鉴的数据结构。distance 以天文单位(AU)为计量,size 以地球半径为基准的倍数表示,moons 记录卫星数量。这三个数值字段共同构成了行星的核心物理特征,在 UI 层被格式化为"距太阳X AU · 半径X倍地球 · X颗卫星"的复合文本展示。
type 字段采用字符串存储行星类型(如"气态巨行星"、"岩质行星"等),这个字段同时承担着数据分类和视觉着色两个职责。在 PlanetContent 组件中,type 值被用于条件表达式来决定行星图标的背景色,同时通过 SkyTag 组件以标签形式展示。这种一字段多用途的设计减少了数据冗余,但也要求 type 的取值范围保持稳定,否则着色逻辑可能出现未覆盖的情况。
第八段:课程数据模型
interface CourseItem {
name: string;
teacher: string;
seats: number;
joined: number;
level: string;
emoji: string;
}
CourseItem 接口定义了天文课程的数据模型。seats 和 joined 两个数值字段形成了"已报名/总名额"的比值关系,在 UI 层被用于判断课程是否接近满员——当报名率达到百分之八十时,数字会变为危险色(红色)以提醒用户尽快报名。这种基于数据比值的动态视觉反馈,是数据驱动 UI 的典型应用。
level 字段存储课程难度等级(入门、进阶、大师),通过 getLevelColor 函数映射到不同色彩。入门级使用成功色(绿色)暗示易于上手,进阶级使用警告色(金色)提示需要一定基础,大师级使用危险色(红色)表示高难度挑战。这种色彩语义化的设计让用户在浏览课程列表时能够快速识别难度层级,无需细读文字即可获得关键信息。
第九段:商店数据模型
interface ShopItem {
name: string;
price: number;
sales: number;
stock: number;
category: string;
emoji: string;
}
ShopItem 接口定义了天文周边商品的数据结构。price、sales、stock 三个数值字段分别对应价格、销量和库存,构成了商品的核心商业属性。stock 字段在 UI 层被用于库存状态判断——大于十显示"充足"(绿色),否则显示"告急"(红色),这种二元状态简化了用户决策过程。
category 字段通过 getCategoryColor 函数映射到七种不同的色彩,覆盖了家居、收藏、模型、饰品、手办、益智和文创七个品类。这种细粒度的分类着色系统,使得商品列表在视觉上呈现出丰富的色彩多样性,同时每个色彩都承载着品类识别的语义功能。emoji 字段为每个商品提供了直观的图形化预览,弥补了纯文本列表在视觉表现力上的不足。
第十段:评价数据模型
interface ReviewItem {
name: string;
score: number;
date: string;
content: string;
tag: string;
emoji: string;
}
ReviewItem 接口定义了游客评价的数据模型。score 字段存储一到五的整数评分,在 UI 层通过 '⭐'.repeat(Math.round(r.score)) 动态生成对应数量的星星图标,这是声明式 UI 中数据驱动渲染的经典应用。date 字段采用简化的"月-日"格式(如"08-15"),适合短列表的紧凑展示。
tag 字段存储评价标签(如"体验极佳"、"亲子友好"等),通过 getTagColor 函数进行语义化着色。值得注意的是,标签着色函数采用了多标签合并映射的策略——多个语义相近的标签共享同一色彩。例如"体验极佳"和"干货满满"都映射到主色蓝,这种聚类着色减少了视觉碎片感,使评价列表在色彩上保持有序。content 字段在列表中通过 maxLines(1) 和 textOverflow 进行单行截断,确保列表项高度的一致性。
第十一段:统计图元数据接口群
interface WeekMeta {
day: string;
value: number;
}
interface PlanetMeta {
name: string;
count: number;
color: string;
}
interface ClassJoinMeta {
name: string;
rate: number;
color: string;
}
interface ShopTopMeta {
name: string;
sales: number;
color: string;
}
interface RateMeta {
name: string;
count: number;
color: string;
}
这组接口定义了五个统计图表专用的数据结构,每个接口都服务于一个特定的可视化场景。WeekMeta 用于周参观人数柱状图,包含日期标签和数值;其余四个接口都包含 color 字段,因为它们的可视化场景需要为每个数据项分配独立的色彩。这种将色彩内置于数据模型而非通过函数计算的设计,适用于数据项较少且色彩映射固定的场景。
从接口设计的角度,这五个接口之间存在结构相似性——都包含 name 和一个数值字段。可以考虑抽象为泛型基类 StatMeta<T>,但这里选择了保持独立定义。这一决策的合理性在于,每个接口的字段名称(value、count、rate、sales)都承载着不同的业务语义,合并为通用接口反而会降低代码的可读性和类型安全性。
第十二段:周参观人数数据
const WEEK_VISIT: WeekMeta[] = [
{ day: '周一', value: 18 },
{ day: '周二', value: 22 },
{ day: '周三', value: 20 },
{ day: '周四', value: 26 },
{ day: '周五', value: 38 },
{ day: '周六', value: 62 },
{ day: '周日', value: 55 }
];
WEEK_VISIT 常量存储了一周七天的参观人数数据。数据分布呈现出明显的周末高峰特征——周六达到六万二千人的峰值,是工作日均值的近三倍。这组数据在 UI 层被渲染为柱状图,柱高通过 value * 1.1 的比例计算得出,数值大于等于五十的柱子使用强调色(金色)高亮,其余使用主色(蓝色),形成视觉上的峰值强调。
将统计数据作为常量集中管理,是该应用数据层的组织策略之一。与动态数据(展览、行星等)不同,统计图表的数据通常是预聚合的分析结果,变更频率低且来源固定。将其声明为文件级常量,既避免了在组件内部声明大量数据导致的臃肿,又使得数据与渲染逻辑保持清晰的物理分离。若未来需要从服务端动态获取,只需将常量替换为异步加载逻辑即可。
第十三段:行星类型分布数据
const PLANET_SHARE: PlanetMeta[] = [
{ name: '气态巨行星', count: 3, color: '#5B6EE1' },
{ name: '岩质行星', count: 3, color: '#F5C451' },
{ name: '冰巨星', count: 2, color: '#8FA3FF' },
{ name: '矮行星', count: 2, color: '#5BD6A8' },
{ name: '系外行星', count: 1, color: '#FF6B81' },
{ name: '卫星', count: 1, color: '#C792EA' }
];
PLANET_SHARE 常量记录了行星图鉴中各类型的数量分布。六种行星类型各自分配了独立的色彩,从主色蓝到紫色形成完整的色相光谱。数据条以水平进度条形式展示,进度比例通过 count / 3 计算——以三作为除数是因为最大计数为三,确保最长的进度条填满可用空间。
这种将色彩直接嵌入数据模型的设计,与前文提到的工具函数着色策略形成了互补。工具函数适用于数据项可能动态扩展的场景(如展览主题可能新增),而内联色彩适用于固定枚举值的场景。PLANET_SHARE 中的六种行星类型是天文学的固定分类,不会动态变化,因此内联色彩更加直接和高效。值得注意的是,第六种类型"卫星"使用了 #C792EA 紫色,这个色彩没有出现在 SkyPalette 中,是为数据可视化场景特别定义的扩展色。
第十四段:课程报名率数据
const CLASS_JOIN: ClassJoinMeta[] = [
{ name: '观星入门课', rate: 94, color: '#5B6EE1' },
{ name: '天文摄影课', rate: 88, color: '#F5C451' },
{ name: '行星探索课', rate: 82, color: '#8FA3FF' },
{ name: '火箭原理课', rate: 76, color: '#5BD6A8' },
{ name: '星座传说课', rate: 68, color: '#FF6B81' },
{ name: '黑洞漫游课', rate: 61, color: '#C792EA' }
];
CLASS_JOIN 常量记录了六门课程的报名进度率。数据按报名率从高到低排列,从百分之九十四递减到百分之六十一,形成清晰的梯度。进度条通过 rate / 100 的比例渲染,数值直接以百分比形式显示在条形右侧。这种从高到低的排序方式,使用户能够一眼识别出最受欢迎的课程。
色彩分配策略在这里体现了"彩虹序列"的设计意图——六门课程依次使用蓝、金、浅蓝、绿、红、紫六种色彩,在视觉上形成从冷到暖的渐变。这种色彩排列虽然不承载语义信息(不像 level 字段那样映射难度等级),但它在视觉上营造了丰富的层次感,避免了单色进度条的沉闷感。课程名称右侧的百分比数字也使用对应的课程色彩,加强了数据项的视觉关联。
第十五段:商店销量排行数据
const SHOP_TOP: ShopTopMeta[] = [
{ name: '星空投影灯', sales: 326, color: '#5B6EE1' },
{ name: '陨石标本盲盒', sales: 258, color: '#F5C451' },
{ name: '火箭拼装模型', sales: 214, color: '#8FA3FF' },
{ name: '行星项链', sales: 186, color: '#5BD6A8' },
{ name: '宇航员手办', sales: 152, color: '#FF6B81' },
{ name: '星图桌垫', sales: 128, color: '#C792EA' }
];
SHOP_TOP 常量定义了周边商品销量前六名的排行数据。销量从三百二十六件递减到一百二十八件,进度条通过 sales / 330 的比例渲染——选择三百三十作为除数略大于最大值三百二十六,确保最长的进度条不会完全填满,在视觉上留出适当的呼吸空间。前三名商品的排名数字使用强调色(金色)高亮,后三名使用提示色(灰色),形成"头部突出"的视觉层级。
这个排行数据与 SkyData 中的 shops 数组存在数据冗余——商店列表中的 sales 字段也记录了销量。但在实际应用中,排行榜数据通常是服务端预计算的结果,包含了全量商品的排序信息,而列表中展示的只是当前页面的商品子集。将排行数据独立维护,使得排行榜模块可以独立于商品列表渲染,两者之间的数据更新也可以解耦。
第十六段:评分分布数据
const RATE_SHARE: RateMeta[] = [
{ name: '5星', count: 7, color: '#F5C451' },
{ name: '4星', count: 3, color: '#5B6EE1' },
{ name: '3星', count: 1, color: '#8FA3FF' },
{ name: '2星', count: 1, color: '#5BD6A8' }
];
RATE_SHARE 常量记录了评价的星级分布统计。五星评价占七条(最多),四星三条,三星和二星各一条,没有一星评价。这组数据在 UI 层被渲染为水平进度条,比例通过 count / 7 计算——以最大值七作为除数。五星使用金色(强调色)高亮,其余星级使用对应的主题色,形成"高分突出"的视觉效果。
数据按星级从高到低排列,这与用户的阅读习惯一致——人们更关心高分评价的占比。值得注意的是,数据中没有一星评价,这种"截断式"的数据呈现是刻意的设计选择。在实际应用中,完整的星级分布应该包含所有星级(包括零条记录的星级),但出于 UI 紧凑性的考虑,这里省略了零计数的星级,使得图表更加精简。平均评分"4.5分"作为补充信息显示在标题栏右侧。
第十七段:可观察数据类——展览数据
@Observed
export class SkyData {
exhibits: ExhibitItem[] = [
{ name: '星际穿越特展', theme: '深空', visitors: 12800, rating: 9.6, zone: 'A馆', emoji: '🌌' },
{ name: '土星环之谜', theme: '行星', visitors: 9800, rating: 9.2, zone: 'A馆', emoji: '🪐' },
{ name: '登月五十年', theme: '航天', visitors: 11200, rating: 9.0, zone: 'B馆', emoji: '🚀' },
{ name: '黑洞电影院', theme: '深空', visitors: 8700, rating: 9.4, zone: '球幕厅', emoji: '🕳️' },
{ name: '小行星工坊', theme: '互动', visitors: 6400, rating: 8.8, zone: 'C馆', emoji: '☄️' },
{ name: '极光秘境', theme: '现象', visitors: 7200, rating: 9.1, zone: 'B馆', emoji: '🌠' },
{ name: '火星基地模拟', theme: '航天', visitors: 5900, rating: 8.6, zone: 'C馆', emoji: '🔴' },
{ name: '宇宙之眼', theme: '天文台', visitors: 4800, rating: 8.9, zone: '观象台', emoji: '🔭' },
{ name: '星座画廊', theme: '互动', visitors: 5300, rating: 8.4, zone: 'A馆', emoji: '✨' },
{ name: '太阳风暴秀', theme: '现象', visitors: 6100, rating: 8.7, zone: '球幕厅', emoji: '☀️' },
{ name: '银河系漫游', theme: '深空', visitors: 8300, rating: 9.3, zone: '球幕厅', emoji: '🌀' },
{ name: '太空种子园', theme: '互动', visitors: 3900, rating: 8.2, zone: 'C馆', emoji: '🌱' }
];
SkyData 类是整个应用的数据中枢,使用 @Observed 装饰器标记为可观察对象。@Observed 的核心作用是使类的属性变化能够被 @ObjectLink 修饰的子组件感知,从而实现跨组件的响应式数据更新。当 exhibits 数组中的任何元素被修改、添加或删除时,所有通过 @ObjectLink 绑定到该 SkyData 实例的组件都会自动触发重新渲染。
exhibits 数组包含十二条展览数据,覆盖了深空、行星、航天、互动、现象和天文台六个主题。每条数据都经过精心设计,访客数从近一万三千到不足四千形成自然梯度,评分从九点六到八点二覆盖了"优秀"到"良好"的评价区间。zone 字段的值(A馆、B馆、C馆、球幕厅、观象台)暗示了场馆的物理空间布局,为用户提供了实际的导览参考价值。数据中使用了丰富的 Emoji 图标,每个都与展览主题高度契合,如黑洞用🕳️、极光用🌠、太阳用☀️,这些图标在列表渲染时提供了即时的视觉识别。
第十八段:可观察数据类——行星数据
planets: PlanetItem[] = [
{ name: '木星', type: '气态巨行星', distance: 5.2, size: 11.2, moons: 95, emoji: '🟤' },
{ name: '土星', type: '气态巨行星', distance: 9.5, size: 9.4, moons: 146, emoji: '🪐' },
{ name: '火星', type: '岩质行星', distance: 1.5, size: 0.5, moons: 2, emoji: '🔴' },
{ name: '金星', type: '岩质行星', distance: 0.7, size: 0.9, moons: 0, emoji: '🟡' },
{ name: '海王星', type: '冰巨星', distance: 30.1, size: 3.9, moons: 16, emoji: '🔵' },
{ name: '天王星', type: '冰巨星', distance: 19.2, size: 4.0, moons: 28, emoji: '🟢' },
{ name: '地球', type: '岩质行星', distance: 1.0, size: 1.0, moons: 1, emoji: '🌍' },
{ name: '水星', type: '岩质行星', distance: 0.4, size: 0.4, moons: 0, emoji: '🌑' },
{ name: '冥王星', type: '矮行星', distance: 39.5, size: 0.2, moons: 5, emoji: '🧊' },
{ name: '谷神星', type: '矮行星', distance: 2.8, size: 0.07, moons: 0, emoji: '⛰️' },
{ name: '开普勒22b', type: '系外行星', distance: 600, size: 2.4, moons: 0, emoji: '🛸' },
{ name: '月球', type: '卫星', distance: 0.0026, size: 0.27, moons: 0, emoji: '🌙' }
];
planets 数组包含十二条行星数据,覆盖了太阳系八大行星、两颗矮行星、一颗系外行星和月球。数据的物理参数(距离、半径、卫星数)基于真实天文数据,如土星拥有一百四十六颗卫星、木星半径是地球的十一点二倍等,这些数据赋予了应用教育价值。距离字段以天文单位(AU)计量,从月球的零点零零二六 AU 到开普勒22b 的六百 AU,跨度极大。
行星的排列顺序并非严格按照距离排序,而是将木星和土星这两颗最具辨识度的气态巨行星放在最前面,这是从用户体验角度的考量——列表首屏展示最具吸引力的内容。type 字段的六种取值与 PLANET_SHARE 统计数据一一对应,保证了数据的一致性。每颗行星的 Emoji 颜色与其物理特征关联,如火星用红色🔴、金星用黄色🟡、海王星用蓝色🔵,这种色彩编码帮助用户在浏览列表时快速识别行星。
第十九段:可观察数据类——课程数据
courses: CourseItem[] = [
{ name: '观星入门课', teacher: '陈观星', seats: 30, joined: 28, level: '入门', emoji: '🔭' },
{ name: '天文摄影课', teacher: '许夜穹', seats: 20, joined: 18, level: '进阶', emoji: '📷' },
{ name: '行星探索课', teacher: '林星河', seats: 40, joined: 33, level: '入门', emoji: '🪐' },
{ name: '火箭原理课', teacher: '周航天', seats: 25, joined: 19, level: '进阶', emoji: '🚀' },
{ name: '星座传说课', teacher: '苏织女', seats: 50, joined: 34, level: '入门', emoji: '✨' },
{ name: '黑洞漫游课', teacher: '李引力', seats: 18, joined: 11, level: '大师', emoji: '🕳️' },
{ name: '陨石鉴定课', teacher: '王天石', seats: 22, joined: 15, level: '进阶', emoji: '☄️' },
{ name: '极光成因课', teacher: '赵磁暴', seats: 35, joined: 24, level: '入门', emoji: '🌠' },
{ name: '宇宙尺度课', teacher: '陈观星', seats: 28, joined: 17, level: '进阶', emoji: '📏' },
{ name: '外星生命课', teacher: '钱星海', seats: 45, joined: 31, level: '入门', emoji: '🛸' },
{ name: '望远镜DIY课', teacher: '孙镜片', seats: 16, joined: 9, level: '大师', emoji: '🔧' },
{ name: '航天史漫谈', teacher: '周航天', seats: 60, joined: 41, level: '入门', emoji: '🛰️' }
];
courses 数组包含十二门天文课程,覆盖了观测、摄影、行星、火箭、星座、黑洞、陨石、极光、宇宙尺度、外星生命、望远镜和航天史等主题。teacher 字段的命名极具天文特色——陈观星、许夜穹、林星河、周航天等,这些虚构的姓名本身就是天文主题的延伸,增强了应用的沉浸感。部分教师出现多次(如陈观星教授两门课程、周航天教授两门课程),这种设计暗示了教师资源的合理分配。
joined 与 seats 的比值在 UI 层被用于动态着色:当报名率达到百分之八十阈值时,"已报名"数字从成功色(绿色)变为危险色(红色)。在数据中,观星入门课的报名率为二十八分之三十(约百分之九十三)、天文摄影课为十八分之二十(百分之九十),这两门课都触发了红色高亮,与统计图表中的高报名率数据相互印证。课程难度涵盖了入门、进阶和大师三个层级,为不同知识水平的用户提供了分层选择。
第二十段:可观察数据类——商店数据
shops: ShopItem[] = [
{ name: '星空投影灯', price: 129, sales: 326, stock: 18, category: '家居', emoji: '💡' },
{ name: '陨石标本盲盒', price: 89, sales: 258, stock: 6, category: '收藏', emoji: '☄️' },
{ name: '火箭拼装模型', price: 199, sales: 214, stock: 24, category: '模型', emoji: '🚀' },
{ name: '行星项链', price: 59, sales: 186, stock: 42, category: '饰品', emoji: '📿' },
{ name: '宇航员手办', price: 158, sales: 152, stock: 15, category: '手办', emoji: '🧑🚀' },
{ name: '星图桌垫', price: 45, sales: 128, stock: 55, category: '家居', emoji: '🗺️' },
{ name: '月相挂钟', price: 168, sales: 97, stock: 9, category: '家居', emoji: '🕰️' },
{ name: '望远镜钥匙扣', price: 25, sales: 342, stock: 80, category: '饰品', emoji: '🔭' },
{ name: '星空拼图', price: 79, sales: 143, stock: 33, category: '益智', emoji: '🧩' },
{ name: '宇航服摆件', price: 238, sales: 64, stock: 5, category: '手办', emoji: '🪖' },
{ name: '流星雨明信片', price: 15, sales: 410, stock: 120, category: '文创', emoji: '💌' },
{ name: '天文台T恤', price: 99, sales: 176, stock: 38, category: '服饰', emoji: '👕' }
];
shops 数组包含十二件天文周边商品,价格从十五元到二百三十八元覆盖了不同消费层级。stock 字段的分布从五件到一百二十件,在 UI 层触发了库存状态的动态展示——陨石标本盲盒(六件)、月相挂钟(九件)和宇航服摆件(五件)都低于十件阈值,显示"告急"红色警告。这种基于阈值的二元状态判断,为运营人员提供了即时的库存预警。
category 字段覆盖了七个品类,与 getCategoryColor 函数中的映射完全对应。值得注意的是,望远镜钥匙扣的销量(三百四十二件)和流星雨明信片的销量(四百一十件)都高于排行榜中的第一名星空投影灯(三百二十六件),但它们并未出现在 SHOP_TOP 排行榜中。这是因为排行榜数据是独立维护的静态数据,与商品列表的数据并非实时同步——在实际应用中,排行榜应该是服务端定期计算的聚合结果,而非前端实时排序。
第二十一段:可观察数据类——评价数据
reviews: ReviewItem[] = [
{ name: '星野', score: 5, date: '08-15', content: '黑洞电影院太震撼了,球幕包裹感一流!', tag: '体验极佳', emoji: '🦉' },
{ name: '阿澈', score: 5, date: '08-15', content: '极光秘境还原度超高,适合带孩子来看。', tag: '亲子友好', emoji: '🐼' },
{ name: '木木', score: 4, date: '08-14', content: '观星课老师讲得很生动,就是周末人多。', tag: '课程好评', emoji: '🐰' },
{ name: '深蓝', score: 5, date: '08-13', content: '陨石鉴定课帮我确认了收藏的陨石真伪!', tag: '专业靠谱', emoji: '🐺' },
{ name: '小火箭', score: 4, date: '08-12', content: '火箭模型做工精致,拼装说明书再细点更好。', tag: '商品满意', emoji: '🚀' },
{ name: '夜航', score: 5, date: '08-11', content: '周六的流星雨观测活动组织得非常好。', tag: '活动精彩', emoji: '🌠' },
{ name: '团团', score: 3, date: '08-10', content: '星际穿越展排队太久,建议线上预约。', tag: '排队较长', emoji: '🐹' },
{ name: '引力', score: 5, date: '08-09', content: '黑洞课的内容深度超出预期,值回票价。', tag: '干货满满', emoji: '🦁' },
{ name: '橙子', score: 4, date: '08-08', content: '星球冰淇淋好吃,行星项链也很精致。', tag: '配套完善', emoji: '🐱' },
{ name: '北辰', score: 5, date: '08-07', content: '天文台望远镜观土星环,终身难忘的体验。', tag: '体验极佳', emoji: '🔭' },
{ name: '麦穗', score: 4, date: '08-06', content: '星座画廊互动做得好,小朋友玩得不想走。', tag: '亲子友好', emoji: '🌾' },
{ name: '曦和', score: 5, date: '08-05', content: '太阳风暴球幕秀的声光效果绝了!', tag: '视觉盛宴', emoji: '☀️' }
];
}
reviews 数组是 SkyData 类的最后一个属性,包含十二条游客评价。数据按日期从近到远排列(八月十五日到八月五日),符合"最新优先"的展示逻辑。评价内容覆盖了展览体验、课程反馈、商品评价和活动评价等多个维度,每条评价的 tag 字段都经过分类标注,在 UI 层通过 getTagColor 函数映射到语义化色彩。
评价数据中包含一条三星评价(团团的"排队较长"),这条评价的标签使用危险色(红色)高亮,在视觉上与其他高分评价形成对比。这种不回避负面评价的设计,增加了评价体系的可信度。每条评价的 emoji 字段使用动物或自然元素的图标作为用户头像的替代,在保护隐私的同时保持了视觉趣味性。content 字段的文案经过精心撰写,每条都包含具体的产品或服务名称,提供了有价值的参考信息。
第二十二段:主题色映射函数
function getThemeColor(theme: string): string {
if (theme === '深空') {
return '#5B6EE1';
} else if (theme === '行星') {
return '#F5C451';
} else if (theme === '航天') {
return '#5BD6A8';
} else if (theme === '互动') {
return '#FF6B81';
} else if (theme === '现象') {
return '#C792EA';
} else if (theme === '天文台') {
return '#8FA3FF';
}
return '#6B74A6';
}
getThemeColor 函数将展览主题字符串映射到对应的色彩值。六个主题分别映射到主色蓝、强调金、成功绿、危险红、扩展紫和浅蓝,形成完整的色相覆盖。函数末尾的默认返回值 #6B74A6(提示色灰蓝)作为兜底策略,确保任何未识别的主题字符串都能获得一个合理的色彩,避免 UI 出现透明或未着色的异常状态。
这种基于 if-else 链的实现方式在可读性上优于 switch 语句,且在条件数量较少时性能差异可以忽略。函数的输入是字符串而非枚举,这提供了灵活性——未来新增主题类型时只需在函数中添加新的条件分支,无需修改枚举定义和数据模型。然而,这种设计的代价是失去了编译期的类型检查,拼写错误不会被编译器捕获。在主题类型相对稳定且数量有限的场景下,这种权衡是合理的。
第二十三段:课程难度色映射函数
function getLevelColor(level: string): string {
if (level === '入门') {
return '#5BD6A8';
} else if (level === '进阶') {
return '#F5C451';
}
return '#FF6B81';
}
getLevelColor 函数将课程难度等级映射到语义化色彩。入门级使用成功色(绿色),暗示该课程易于上手、风险低;进阶级使用警告色(金色),提示需要一定基础;大师级使用危险色(红色),表示高难度挑战。这种色彩语义化设计借鉴了交通信号灯的视觉语言,利用用户已有的色彩认知来降低理解成本。
函数只显式处理了"入门"和"进阶"两个条件,其余所有值都返回危险色红色。这种设计意味着"大师"级别会落入默认分支获得红色,但如果传入了未预期的字符串(如空字符串或拼写错误),也会获得红色。这是一种"安全失败"策略——在无法识别难度时,默认显示最高难度的警告色,避免用户低估课程难度。函数的简洁性也体现了"恰好够用"的工程原则,三个难度等级用两个显式条件和一个默认返回即可覆盖。
第二十四段:商品品类色映射函数
function getCategoryColor(category: string): string {
if (category === '家居') {
return '#5B6EE1';
} else if (category === '收藏') {
return '#F5C451';
} else if (category === '模型') {
return '#8FA3FF';
} else if (category === '饰品') {
return '#FF6B81';
} else if (category === '手办') {
return '#5BD6A8';
} else if (category === '益智') {
return '#C792EA';
} else if (category === '文创') {
return '#FF9DB1';
}
return '#6B74A6';
}
getCategoryColor 函数将七个商品品类映射到七种不同的色彩。与前两个函数相比,这个函数需要处理更多的条件分支。值得注意的是,"文创"品类使用了 #FF9DB1 粉红色,这个色彩没有出现在 SkyPalette 标准色板中,是为第八个品类特别定义的扩展色。这说明当业务品类超过标准色板的覆盖范围时,函数选择引入新的色彩而非复用已有色彩。
七个品类的色彩分配避免了重复使用同一种色彩,确保了每个品类在视觉上的独特性。这种"一品一色"的策略在品类数量较少时效果良好,但如果品类继续增加(超过十种),色彩区分度将开始下降。在实际应用中,当品类超过七到八个时,通常会采用色相自动生成算法或将小品类合并为大类来处理。当前的 if-else 实现在条件数量适中时保持了最佳的可读性和维护性。
第二十五段:评价标签色映射函数
function getTagColor(tag: string): string {
if (tag === '体验极佳' || tag === '干货满满') {
return '#5B6EE1';
} else if (tag === '亲子友好' || tag === '活动精彩' || tag === '视觉盛宴') {
return '#F5C451';
} else if (tag === '课程好评' || tag === '专业靠谱') {
return '#5BD6A8';
} else if (tag === '排队较长') {
return '#FF6B81';
}
return '#8FA3FF';
}
getTagColor 函数采用了与前几个函数不同的策略——多个语义相近的标签共享同一种色彩。具体来说,"体验极佳"和"干货满满"都映射到主色蓝,暗示这两类标签都代表正面的核心体验;“亲子友好”、"活动精彩"和"视觉盛宴"都映射到金色,代表附加价值;"课程好评"和"专业靠谱"映射到绿色,代表专业性认可;"排队较长"映射到红色,代表负面体验。
这种聚类着色策略是经过深思熟虑的设计决策。如果每个标签都分配独立色彩,十二条评价中可能出现七到八种不同的标签色彩,导致视觉碎片化。通过将语义相近的标签合并为同一色彩组,评价列表在视觉上呈现出四到五种主色调,保持了色彩的整体协调性。函数使用 || 运算符将多个条件合并到一个分支中,相比分开写 if-else 更加紧凑。默认返回浅蓝色作为兜底,与主色蓝形成同色系的安全降级。
第二十六段:主入口组件状态声明
@Entry
@Component
struct SkyApp {
@State curTab: number = 0;
@State data: SkyData = new SkyData();
@State showAddExhibit: boolean = false;
@State showEditPlanet: boolean = false;
@State showDeleteShop: boolean = false;
@State showDetail: boolean = false;
@State delShopName: string = '';
@State detailReviewName: string = '';
@State meteorFly: boolean = false;
@State saturnSpin: boolean = false;
SkyApp 是应用的入口组件,使用 @Entry 和 @Component 双重装饰器标记。@Entry 声明该组件为页面入口,@Component 声明其为自定义组件。组件内部通过九个 @State 变量管理全部应用状态。curTab 控制当前激活的标签页索引,初始值为零对应展览页;data 持有 SkyData 的实例,作为全局数据中心供所有子组件共享。
四个布尔状态变量(showAddExhibit、showEditPlanet、showDeleteShop、showDetail)分别控制四个弹窗的显隐。这种"一个弹窗一个布尔"的管理方式在弹窗数量较少时清晰直观,每个布尔变量的名称直接对应其控制的弹窗。delShopName 和 detailReviewName 两个字符串变量作为弹窗的上下文参数,在用户点击列表项时被赋值,传递给弹窗组件展示对应内容。meteorFly 和 saturnSpin 两个布尔变量控制顶部标题栏的装饰性动画——流星飞行和土星旋转,为应用增添了趣味性交互。
第二十七段:模态遮罩构建器
@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 方法,用于构建模态遮罩层。它的实现非常精巧——通过一个尺寸为零乘零的 Text 组件和一个尺寸为一乘一、透明度为零的 Button 组件构成。Button 的 onClick 事件绑定了 onClose 回调,使得用户在点击遮罩区域时能够关闭弹窗。整个 Column 容器的尺寸被限制为一乘一像素,确保遮罩本身不占据可视空间。
这种设计的巧妙之处在于,它利用了 ArkTS 的事件冒泡机制。虽然 Button 的可见尺寸只有一像素,但其点击区域可以通过父容器的布局扩展来覆盖更大的范围。这种"隐形按钮"模式在不引入额外视觉元素的前提下,实现了点击遮罩关闭弹窗的交互需求。@Builder 方法接受一个 onClose 回调函数作为参数,使得该遮罩可以被多个弹窗复用,每个弹窗只需传入自己的关闭逻辑即可。
第二十八段:顶部标题栏
build() {
Column() {
Column() {
Column() {
Row() {
Column() {
Text('🔭 星河天文馆')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('Planetarium · 观星之夜')
.fontSize(10)
.fontColor('#FFFFFFAA')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
build 方法是组件的渲染入口,整个应用的结构从这里开始展开。最外层的 Column 容器将页面垂直划分为三个区域:顶部标题栏、中间内容区和底部标签栏。顶部标题栏使用 COLORS.primaryDark(深靛蓝)作为背景,与整体深空主题保持一致。标题"🔭 星河天文馆"使用二十号字体加粗白色渲染,副标题"Planetarium · 观星之夜"使用十号字体半透明白色,形成主副标题的视觉层次。
标题栏左侧使用了 layoutWeight(1) 占据剩余空间,将右侧的交互按钮组推向右端。alignItems(HorizontalAlign.Start) 确保标题文字左对齐。这种"左侧标题 + 右侧操作"的布局模式是移动端导航栏的经典范式。副标题中英文混排的设计,既传达了应用的中文定位,又通过英文副标题增添了国际化氛围,这种双语标题策略在天文科普类应用中尤为常见。
第二十九段:顶部交互按钮组
Row() {
Text('🌠')
.fontSize(20)
.onClick(() => {
this.meteorFly = !this.meteorFly;
})
.translate({ x: this.meteorFly ? 14 : 0, y: this.meteorFly ? 6 : 0 })
.opacity(this.meteorFly ? 0.4 : 1)
.animation({ duration: 700, curve: Curve.EaseOut })
Text('🪐')
.fontSize(18)
.margin({ left: 10 })
.onClick(() => {
this.saturnSpin = !this.saturnSpin;
})
.rotate({ angle: this.saturnSpin ? 360 : 0 })
.animation({ duration: 800, curve: Curve.EaseInOut })
Text('⭐')
.fontSize(16)
.margin({ left: 6 })
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor('#FFFFFF1A')
.borderRadius(18)
.border({ width: 1, color: '#FFFFFF33' })
标题栏右侧的交互按钮组由三个 Emoji 图标组成,封装在一个半透明圆角容器中。流星图标(🌠)点击后触发位移动画——通过 translate 属性在 X 轴移动十四像素、Y 轴移动六像素,同时透明度降至零点四,模拟流星划过的效果。土星图标(🪐)点击后触发旋转动画——通过 rotate 属性旋转三百六十度,模拟行星自转。这两个动画都使用了 animation 修饰器,流星动画时长七百毫秒使用 EaseOut 缓动曲线,土星旋转时长八百毫秒使用 EaseInOut 缓动曲线。
这种将装饰性动画与状态变量绑定的设计,是声明式动画的核心范式。meteorFly 和 saturnSpin 两个布尔状态变量作为动画的触发开关,点击时在 true 和 false 之间切换,ArkTS 框架自动在两个状态之间插值生成过渡动画。按钮组容器的样式(半透明白色背景、圆角、细边框)营造了"悬浮控制面板"的视觉效果,与深色标题栏背景形成层次对比。星标图标(⭐)作为纯装饰元素,没有绑定点击事件,仅提供视觉点缀。
第三十段:星星装饰条
Column() {
ForEach(STAR_COL, (c: number, i: number) => {
Column()
.layoutWeight(1)
.height(8)
.backgroundColor(i % 3 === 0 ? COLORS.star : '#FFFFFF26')
.borderRadius(4)
.margin({ left: 2, right: 2 })
}, (c: number) => 'starA' + c)
ForEach(STAR_COL, (c: number, i: number) => {
Column()
.layoutWeight(1)
.height(8)
.backgroundColor((i + 1) % 3 === 0 ? COLORS.star : '#FFFFFF1F')
.borderRadius(4)
.margin({ left: 2, right: 2 })
}, (c: number) => 'starB' + c)
ForEach(STAR_COL, (c: number, i: number) => {
Column()
.layoutWeight(1)
.height(8)
.backgroundColor((i + 2) % 3 === 0 ? COLORS.star : '#FFFFFF18')
.borderRadius(4)
.margin({ left: 2, right: 2 })
}, (c: number) => 'starC' + c)
}
.width('100%')
.height(34)
.margin({ top: 10 })
标题栏下方的星星装饰条由三行 ForEach 生成,每行十个星点元素。三行分别采用不同的点亮策略:第一行 i % 3 === 0(位置零、三、六、九点亮)、第二行 (i + 1) % 3 === 0(位置二、五、八点亮)、第三行 (i + 2) % 3 === 0(位置一、四、七点亮)。这种错位点亮策略在视觉上形成了类似星空闪烁的效果,每行都有约三分之一的星点使用金色(COLORS.star)高亮,其余使用不同透明度的白色。
未点亮的星点透明度从第一行的 #FFFFFF26(约百分之十五)递减到第三行的 #FFFFFF18(约百分之九),形成了从上到下逐渐暗淡的渐变效果。这种微妙的透明度递减模拟了大气层对星光的衰减效应,是一个精心设计的视觉细节。每个星点使用 layoutWeight(1) 等分容器宽度,通过 borderRadius(4) 形成圆角短条形状,既像星星又像进度条片段,与天文主题巧妙呼应。
第三十一段:色彩标签展示
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 })
标题栏底部展示了三个色彩标签——“深空蓝”、“星光金"和"陨石灰”,这些标签以半透明白色为背景的小圆角胶囊形式呈现。标签的文字内容本身就是色彩体系的设计命名,相当于在 UI 中向用户展示应用的设计语言。这种"元设计"展示在注重视觉品质的应用中越来越常见,它让用户感知到应用在设计层面的用心。
三个标签使用相同的样式模板(字号八、半透明白色文字、半透明白色背景、圆角八),仅文字内容不同。这种高度统一的样式确保了视觉一致性。标签采用 Row 水平排列,没有使用 layoutWeight 或 justifyContent,因此它们会自然左对齐排列。如果标签数量增加,可以考虑使用 Flex 容器实现自动换行。八号字体是可读性的下限,在如此小的尺寸下,半透明白色文字在深色背景上仍然保持了足够的对比度。
第三十二段:标签页条件渲染
if (this.curTab === SkyTab.EXHIBIT) {
ExhibitContent({ data: this.data, onAdd: () => {
this.showAddExhibit = true;
} })
} else if (this.curTab === SkyTab.PLANET) {
PlanetContent({ data: this.data, onEdit: () => {
this.showEditPlanet = true;
} })
} else if (this.curTab === SkyTab.COURSE) {
CourseContent({ data: this.data })
} else if (this.curTab === SkyTab.SHOP) {
ShopContent({ data: this.data, onDel: (n: string) => {
this.delShopName = n;
this.showDeleteShop = true;
} })
} else {
ReviewContent({ data: this.data, onDetail: (n: string) => {
this.detailReviewName = n;
this.showDetail = true;
} })
}
这段代码是主入口组件的核心路由逻辑,通过 if-else if-else 链根据 curTab 的值条件渲染对应的内容页面组件。每个内容页面组件都接收 this.data(SkyData 实例)作为参数,建立了父子组件之间的数据传递通道。ExhibitContent、PlanetContent、ShopContent 和 ReviewContent 还接收回调函数参数(onAdd、onEdit、onDel、onDetail),这些回调函数在子组件内部被调用时,会修改父组件的状态变量来触发弹窗显示。
这种"子组件触发事件、父组件管理状态"的设计模式,是组件化架构中处理跨组件通信的经典方案。子组件不直接管理弹窗状态,而是通过回调函数将"需要打开弹窗"的意图传递给父组件,由父组件统一调度。这种设计保证了状态管理的单一数据源原则——所有弹窗的显隐状态都由 SkyApp 组件集中管控。回调函数中接收的参数(如商品名称 n)被赋值给父组件的状态变量,再传递给弹窗组件,形成了一条从列表项到弹窗的数据传递链路。CourseContent 是唯一不接收回调函数的内容组件,因为该页面没有弹窗交互需求。
第三十三段:底部标签栏
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.primaryDark : 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 容器中。标签的选中状态通过多重样式差异来强调:选中标签的文字使用白色加粗,未选中标签使用灰色常规字重;选中标签的背景使用深色(primaryDark),未选中使用卡片背景色;选中标签的边框使用该标签的主题色加百分之五十三透明度,未选中使用线条色。
TAB_LIST.indexOf(t) 在每次渲染时都被调用三次(文字颜色、字重、背景色、边框色),这在标签数量固定为五个的情况下性能开销可以忽略。选中标签的边框使用 t.color + '88' 拼接十六进制透明度后缀,这是一个巧妙的技巧——在六位十六进制色值后追加两位透明度值(八十八的十六进制),无需使用 rgba 函数即可实现半透明效果。标签栏整体高度六十像素,使用 padding 在左右各留出八像素的间距,使标签按钮不会紧贴屏幕边缘。onClick 事件中直接修改 curTab 状态变量,触发 ArkTS 框架的响应式更新机制,自动重新渲染内容区域。
第三十四段:弹窗条件挂载
if (this.showAddExhibit) {
AddExhibitModal({
onClose: () => {
this.showAddExhibit = false;
}
})
}
if (this.showEditPlanet) {
EditPlanetModal({
onClose: () => {
this.showEditPlanet = false;
}
})
}
if (this.showDeleteShop) {
DeleteShopModal({
title: this.delShopName, onClose: () => {
this.showDeleteShop = false;
}
})
}
if (this.showDetail) {
DetailReviewModal({
name: this.detailReviewName, onClose: () => {
this.showDetail = false;
}
})
}
四个弹窗组件通过独立的 if 条件语句控制挂载,每个弹窗对应一个布尔状态变量。当状态变量为 true 时,弹窗组件被渲染到 DOM 树中;为 false 时,组件被完全卸载。这种"条件挂载"策略相比"始终渲染但通过 visibility 属性控制显隐"的方式,优势在于弹窗关闭时不会占用任何渲染资源,劣势在于每次打开弹窗都需要重新创建组件实例。
每个弹窗组件都接收一个 onClose 回调函数,函数体内将对应的状态变量设为 false,实现关闭弹窗的功能。DeleteShopModal 和 DetailReviewModal 还分别接收 title 和 name 参数,这些参数在用户点击列表项时被赋值(参见前文第三十二段的路由逻辑),传递了弹窗所需的上下文信息。这种参数传递方式确保了弹窗组件是无状态的——它们不持有任何业务数据,所有需要的信息都通过入参获取,关闭后状态自动清理。四个弹窗使用独立的 if 语句而非 if-else if 链,这意味着理论上多个弹窗可以同时显示,但在实际交互中,由于每次只能在一个页面操作,不会出现多弹窗并存的场景。
第三十五段:通用标签组件
@Component
struct SkyTag {
@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 + '26')
.borderRadius(6)
.border({ width: 1, color: this.color + '55' })
}
}
SkyTag 是应用中唯一的通用 UI 组件,使用 @Prop 接收 text 和 color 两个属性。@Prop 装饰器实现了单向数据流——父组件传递的值在子组件内部是只读的,子组件无法修改这些值。这种单向数据流保证了组件的纯函数特性,相同的输入永远产生相同的输出,便于调试和测试。SkyTag 在整个应用中被五个内容页面组件共二十多次调用,是复用频率最高的组件。
组件的视觉设计采用了"同色系三层着色"策略:文字使用完整色值、背景使用色值加 26(约百分之十五透明度)、边框使用色值加 55(约百分之三十三透明度)。这种基于同一色值生成三个透明度层级的技巧,确保了标签在任何色彩下都能保持协调的视觉效果。九号字体配合六像素的水平内边距和二像素的垂直内边距,形成了紧凑的小标签尺寸,适合在信息密度较高的列表项中展示。圆角六像素与文字大小比例协调,营造出柔和的胶囊形状。
第三十六段:展览页——周参观人数柱状图
@Component
struct ExhibitContent {
@ObjectLink data: SkyData;
onAdd: () => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('📈 周参观人数')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('周六峰值6.2万')
.fontSize(9)
.fontColor(COLORS.accent)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
ForEach(WEEK_VISIT, (w: WeekMeta) => {
Column() {
Text(w.value + '')
.fontSize(8)
.fontColor(w.value >= 50 ? COLORS.accent : COLORS.textSecondary)
Column()
.width(20)
.height(w.value * 1.1)
.backgroundColor(w.value >= 50 ? COLORS.accent : COLORS.primary)
.borderRadius(4)
.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 })
ExhibitContent 组件使用 @ObjectLink 与父组件传递的 SkyData 实例建立双向绑定。当 SkyData 中的 exhibits 数组发生变化时,该组件会自动触发重新渲染。onAdd 回调函数的默认值是一个空箭头函数,这是一种防御性编程策略——即使父组件未传递回调,调用时也不会报错。整个页面通过 Scroll 容器包裹,支持内容超出视口时的滚动浏览,constraintSize({ maxHeight: '100%' }) 确保滚动区域不会超出父容器边界。
柱状图的实现完全基于声明式布局,没有使用任何图表库。每天的数据通过 Column 垂直排列数值、柱体和日期标签三部分。柱体高度通过 w.value * 1.1 计算,乘以一点一的系数是为了在最大值六十二的情况下,柱体高度不超过约六十八像素,留出空间给数值和日期标签。数值大于等于五十的柱子使用强调色(金色),其余使用主色(蓝色),这种基于阈值的着色策略使峰值数据一目了然。整个图表的 Row 容器使用 alignItems(VerticalAlign.Bottom) 确保所有柱子底部对齐,形成标准的柱状图基线。
第三十七段:展览页——行星热度分布图
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(PLANET_SHARE, (n: PlanetMeta) => {
Row() {
Text(n.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(84)
Row() {
Row()
.layoutWeight(n.count / 3)
.height(8)
.backgroundColor(n.color)
.borderRadius(4)
Row()
.layoutWeight(1 - n.count / 3)
.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: PlanetMeta) => n.name)
行星热度分布图采用水平进度条形式展示六种行星类型的数量分布。每个进度条由两个 Row 组成——前段使用类型对应色彩,layoutWeight 为 count / 3;后段使用背景色,layoutWeight 为 1 - count / 3。两个 layoutWeight 值之和始终为一,确保进度条总长度填满容器。以三作为除数是因为最大计数为三(气态巨行星和岩质行星各三颗),使得最大值对应的进度条恰好填满。
这种"双段 layoutWeight"的进度条实现方式是 ArkTS 声明式布局的经典模式。相比使用百分比宽度,layoutWeight 的优势在于它会自动根据容器剩余空间按比例分配,无需手动计算像素值。进度条左侧的类型名称使用固定宽度八十四像素,右侧的数量标签使用固定宽度三十二像素,中间的进度条使用 layoutWeight(1) 占据剩余空间。这种"固定宽度 + 弹性宽度"的混合布局策略,确保了标签对齐的同时最大化进度条的展示空间。数量标签的文字颜色使用与进度条相同的色彩,加强了数据项的视觉关联。
第三十八段:展览页——展览列表头部
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.onAdd();
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
展览列表的头部区域包含标题、提示文字和添加按钮三部分。标题"🔭 馆藏展览"使用十三号字体加粗主文字色渲染,提示文字"点击➕上架"使用九号字体提示色(灰色)渲染。添加按钮"➕"使用十三号字体,绑定了 this.onAdd() 回调——当用户点击时,回调函数被调用,进而触发父组件将 showAddExhibit 状态设为 true,打开新增展览弹窗。
头部 Row 使用 justifyContent(FlexAlign.SpaceBetween) 实现两端对齐——标题在左,提示和按钮在右。这种布局模式在卡片头部中反复出现,形成了统一的视觉节奏。提示文字"点击➕上架"的措辞采用了"动词 + 操作方式"的格式,在保持简洁的同时提供了明确的操作指引。整个列表容器使用 cardAlt(交替背景色)而非 cardBg(卡片背景色),与上方的统计图表卡片形成背景色差异,实现了视觉上的模块分隔。
第三十九段:展览页——展览列表项
ForEach(this.data.exhibits, (e: ExhibitItem, i: number) => {
Row() {
Column()
.width(38)
.height(38)
.justifyContent(FlexAlign.Center)
.backgroundColor(getThemeColor(e.theme) + '2E')
.borderRadius(19)
.border({ width: 1, color: getThemeColor(e.theme) + '66' })
Text(e.emoji)
.fontSize(17)
.width(30)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(e.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SkyTag({ text: e.theme, color: getThemeColor(e.theme) })
.margin({ left: 6 })
}
.width('100%')
Text(e.zone + ' · 累计参观' + e.visitors + '人')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
Row() {
Row()
.layoutWeight(e.rating / 10)
.height(5)
.backgroundColor(e.rating >= 9 ? COLORS.accent : COLORS.primary)
.borderRadius(2)
Row()
.layoutWeight(1 - e.rating / 10)
.height(5)
.backgroundColor(COLORS.bg)
.borderRadius(2)
}
.width('100%')
.height(5)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
展览列表项采用"图标 + 信息 + 评分"三栏布局。左侧的主题图标使用三十八乘三十八像素的圆形容器,背景色通过 getThemeColor(e.theme) + '2E' 生成低透明度的主题色,边框使用 + '66' 生成中等透明度的主题色。Emoji 图标通过绝对定位叠加在圆形容器上方,形成了"彩色圆形背景 + Emoji 前景"的图标效果。中间的信息栏使用 layoutWeight(1) 占据剩余空间,展示展览名称、主题标签、展区和访客数。
信息栏中的评分进度条是列表项中最精巧的设计。进度条通过两个 Row 的 layoutWeight 比例渲染——前段 layoutWeight(e.rating / 10) 对应评分占比,后段 layoutWeight(1 - e.rating / 10) 对应剩余部分。评分大于等于九的使用强调色(金色),否则使用主色(蓝色)。这种十分制评分进度条在仅五像素高度的空间内清晰传达了评分信息,与右侧的数字评分形成"图形 + 数字"的双重表达。列表项的 ForEach 使用 e.name + i 作为键值,将名称和索引组合确保唯一性,避免同名展览的渲染冲突。
第四十段:展览页——评分展示与列表项尾部
Column() {
Text(e.rating + '')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
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(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (e: ExhibitItem, i: number) => e.name + i)
列表项右侧的评分展示区使用右对齐的 Column,展示评分数值和"评分"标签。评分数值使用十二号字体加粗强调色(金色)渲染,下方"评分"二字使用八号字体提示色(灰色)。这种"大号数值 + 小号说明"的组合,使评分信息在视觉上突出且含义明确。整个列表项容器使用卡片背景色、十像素圆角和细边框,与 SkyTag 组件的视觉语言保持一致。
列表项的内边距设置为左右十到十二像素、上下九像素,这个数值经过精心调校,在信息密度和视觉呼吸感之间取得了平衡。底部外边距八像素在列表项之间形成了适度的间距。整个列表项的设计体现了"信息卡片"的设计范式——圆角矩形容器内通过 Row-Column 嵌套布局实现复杂的信息编排,每个信息单元都有明确的视觉层级和语义色彩。ForEach 的键值生成函数使用 e.name + i 的组合策略,确保即使展览名称重复,每个列表项也有唯一的标识符。
第四十一段:行星页——统计卡片
@Component
struct PlanetContent {
@ObjectLink data: SkyData;
onEdit: () => 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.onEdit();
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
Row() {
Column() {
Text('12')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('已收录行星')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.primary + '66' })
PlanetContent 组件的头部包含标题和编辑入口。标题"🪐 行星图鉴"使用十五号字体,比展览页的十三号标题大两号,这种字号差异为不同页面赋予了视觉个性。编辑按钮"✏️"绑定了 this.onEdit() 回调,点击后触发父组件打开编辑行星弹窗。头部下方紧跟着三个统计卡片,以等分布局展示"已收录行星"(十二颗)、“经典八行星”(八颗)和"土星卫星数"(一百四十六颗)三个关键指标。
每个统计卡片使用 layoutWeight(1) 等分水平空间,二十六号字体的数值在卡片中极具视觉冲击力。三个卡片分别使用强调色(金色)、成功色(绿色)和警告色(金色),对应的边框色也使用各自主题色加百分之四十透明度。卡片之间通过 margin({ left: 10 }) 产生间距,但第一个卡片没有左外边距。这种统计卡片设计在数据可视化领域被称为"KPI 卡片",能够在极小的空间内传达最关键的数字信息。卡片内边距上下十四像素,确保了数值和标签在卡片内的居中展示。
第四十二段:行星页——行星列表
ForEach(this.data.planets, (p: PlanetItem, i: number) => {
Row() {
Column()
.width(44)
.height(44)
.justifyContent(FlexAlign.Center)
.backgroundColor(p.type === '气态巨行星' ? '#5B6EE12E' : p.type === '岩质行星' ? '#F5C4512E' : p.type === '冰巨星' ? '#8FA3FF2E' : '#5BD6A82E')
.borderRadius(22)
.border({ width: 1, color: '#F5C45166' })
Text(p.emoji)
.fontSize(19)
.width(30)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(p.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SkyTag({ text: p.type, color: getThemeColor(p.type) })
.margin({ left: 6 })
}
.width('100%')
Text('距太阳' + p.distance + 'AU · 半径' + p.size + '倍地球 · ' + p.moons + '颗卫星')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text(p.moons + '')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryLight)
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(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (p: PlanetItem, i: number) => p.name + i)
行星列表项与展览列表项的结构高度相似,但在细节上有几处显著差异。首先是图标背景色的计算方式——展览列表通过 getThemeColor 函数获取色彩,而行星列表直接在三元表达式中根据 type 字段判断。这是因为行星类型的着色逻辑与主题色映射函数不完全一致,需要在组件内部重新定义。图标容器使用四十四乘四十四像素(比展览列表大三十八像素更大),边框统一使用金色 #F5C45166。
行星列表项引入了交替背景色设计——i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt。奇数索引的列表项使用交替背景色,在视觉上形成斑马纹效果,增强了长列表的可读性。右侧展示卫星数量而非评分,使用浅蓝色(primaryLight)作为数值色彩,与展览列表的评分使用金色形成区分。信息文本采用"距太阳X AU · 半径X倍地球 · X颗卫星"的复合格式,通过分隔符将三个物理属性串联展示,在有限空间内最大化信息密度。
第四十三段:课程页——报名进度图
@Component
struct CourseContent {
@ObjectLink data: SkyData;
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(CLASS_JOIN, (c: ClassJoinMeta) => {
Row() {
Text(c.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(84)
Row() {
Row()
.layoutWeight(c.rate / 100)
.height(8)
.backgroundColor(c.color)
.borderRadius(4)
Row()
.layoutWeight(1 - c.rate / 100)
.height(8)
.backgroundColor(COLORS.bg)
.borderRadius(4)
}
.layoutWeight(1)
.height(8)
Text(c.rate + '%')
.fontSize(9)
.fontColor(c.color)
.width(38)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (c: ClassJoinMeta) => c.name)
CourseContent 是唯一不接收回调函数的内容页面组件,因为课程页面没有弹窗交互需求。页面顶部的报名进度图使用与行星热度分布图相同的水平进度条模式,但进度比例的计算方式不同——使用 c.rate / 100 将百分比直接转换为 layoutWeight 比例。进度条右侧显示百分比文字,宽度固定为三十八像素并右对齐,确保所有百分比的数字右端对齐。
卡片头部右侧的提示文字"观星课爆满"使用危险色(红色)渲染,这与统计图表中观星入门课百分之九十四的最高报名率相呼应。这种将数据洞察以自然语言形式呈现在标题栏的设计,使用户无需仔细阅读图表即可获得关键信息。进度条的背景段使用 COLORS.bg(最深背景色),与卡片背景形成微妙的色差,使得进度条的轮廓清晰可辨。课程名称使用固定宽度八十四像素,与行星热度分布图中的类型名称宽度一致,保持了整个应用中进度条组件的视觉一致性。
第四十四段:课程页——课程列表
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)
.margin({ bottom: 10 })
ForEach(this.data.courses, (c: CourseItem, i: number) => {
Row() {
Column()
.width(40)
.height(40)
.justifyContent(FlexAlign.Center)
.backgroundColor(getLevelColor(c.level) + '26')
.borderRadius(20)
Text(c.emoji)
.fontSize(18)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(c.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SkyTag({ text: c.level, color: getLevelColor(c.level) })
.margin({ left: 6 })
}
.width('100%')
Text(c.teacher + ' 主讲 · ' + c.seats + '人班')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
课程列表项的图标容器使用了圆形(borderRadius(20) 配合四十乘四十尺寸),与展览列表的圆形图标和行星列表的圆形图标保持一致。图标背景色通过 getLevelColor(c.level) 函数获取,根据课程难度动态着色——入门级绿色、进阶级金色、大师级红色。信息栏展示课程名称、难度标签、教师和班级规模,其中教师信息采用"X 主讲"的格式,为教师姓名赋予了角色语义。
课程列表项右侧的报名状态展示采用了动态着色逻辑。当 c.joined >= c.seats * 0.8(报名率达到百分之八十)时,"已报名"数字使用危险色(红色),否则使用成功色(绿色)。这种基于数据比值的动态色彩变化,是数据驱动 UI 的典型应用。班级规模信息采用"X人班"的格式,将数字与单位紧凑结合,在十号字体的有限空间内传达了完整的容量信息。课程列表项没有使用交替背景色,所有列表项使用统一的卡片背景色,这与行星列表的斑马纹设计形成了视觉差异。
第四十五段:课程页——报名状态展示
Column() {
Text(c.joined + '/' + c.seats)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(c.joined >= c.seats * 0.8 ? COLORS.danger : COLORS.success)
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(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (c: CourseItem, i: number) => c.name + i)
报名状态展示区使用"已报名数/总名额"的分数格式(如"28/30"),这种格式在教育类应用中是通用的容量表达方式。分数的视觉着色通过条件表达式 c.joined >= c.seats * 0.8 动态决定——当报名率达到或超过百分之八十时使用红色警告,否则使用绿色安全色。这种实时反映数据状态的色彩反馈机制,使用户在浏览课程列表时能够快速识别即将满员的课程。
列表项的样式与展览列表项保持高度一致——相同的内边距、圆角、边框和间距。这种跨页面的样式一致性,是组件化设计的重要原则。虽然该应用没有将列表项抽象为独立的通用组件,但通过在各页面中保持一致的样式参数,实现了视觉上的统一。如果未来需要支持主题切换或响应式布局,可以考虑将这些列表项抽取为带配置参数的通用组件。ForEach 的键值同样使用 c.name + i 的组合策略,确保课程名称重复时不会发生渲染冲突。
第四十六段:商店页——销量排行图
@Component
struct ShopContent {
@ObjectLink data: SkyData;
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.accent : COLORS.textHint)
.width(22)
Text(s.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(90)
Row() {
Row()
.layoutWeight(s.sales / 330)
.height(8)
.backgroundColor(s.color)
.borderRadius(4)
Row()
.layoutWeight(1 - s.sales / 330)
.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 组件的销量排行榜与课程报名进度图结构相似,但增加了排名序号列。排名序号通过 (i + 1) 生成,前三名使用强调色(金色),后三名使用提示色(灰色)。这种"头部突出"的着色策略,使前三名在视觉上与后三名形成明确的层级区分。排行榜的进度比例使用 s.sales / 330 计算——选择三百三十作为除数略大于最大值三百二十六,确保最长的进度条不会完全填满容器。
排行榜中每个商品名称的固定宽度为九十像素,比行星分布图的八十四像素和课程进度图的八十四像素更宽,这是因为商品名称通常比行星类型或课程名称更长。销量数字使用"X件"的格式,宽度固定为四十四像素并右对齐。整个排行榜的 ForEach 使用 s.name + i 作为键值,虽然商品名称在理论上可能重复(如不同品类的同名商品),但在当前数据中不存在这种情况。onDel 回调函数接收商品名称字符串作为参数,为后续的删除弹窗提供上下文信息。
第四十七段:商店页——商品列表
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) + '26')
.borderRadius(12)
Text(s.emoji)
.fontSize(18)
.width(30)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(s.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SkyTag({ 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 })
商品列表项的图标容器使用了十二像素的圆角矩形(borderRadius(12)),而非其他列表使用的圆形。这种形状差异为商店页赋予了视觉个性,暗示商品与展览/行星等自然物体的不同——商品是人造物,适合用直角形状表达。图标背景色通过 getCategoryColor 函数获取,七个品类各自映射到不同色彩,使得商品列表在视觉上呈现出比其他列表更丰富的色彩多样性。
商品信息栏展示名称、品类标签、价格和销量。价格使用"¥X"的格式,货币符号直接前置。价格和销量通过分隔符连接为"¥129 · 已售326件"的复合文本,在十号字体的有限空间内传达了两个核心商业信息。右侧的操作区域包含库存状态和删除按钮,库存状态的着色逻辑将在下一段分析。整个商品列表的设计体现了电商类应用的信息展示范式——图标、名称、标签、价格、销量、库存和操作按钮在一行内完整呈现。
第四十八段:商店页——库存状态与删除操作
Column() {
Text(s.stock > 10 ? '充足' : '告急')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(s.stock > 10 ? 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(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (s: ShopItem, i: number) => s.name + i)
库存状态展示采用了与课程报名状态类似的二元着色策略,但阈值不同——库存大于十时显示"充足"(绿色),否则显示"告急"(红色)。这种基于阈值的文字和色彩双重反馈,使运营人员能够一眼识别需要补货的商品。库存数值以"库存X"的格式显示在状态文字下方,提供了精确的数字参考。删除按钮"🗑️"绑定了 this.onDel(s.name) 回调,将商品名称传递给父组件,触发删除确认弹窗。
商品列表项使用了与行星列表相同的交替背景色设计(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt),而展览列表和课程列表则使用统一背景色。这种不同页面采用不同列表样式的策略,可能是刻意为之——交替背景色在商品列表中帮助区分连续的商品项,而在展览列表中统一背景色则强调了展览的整体性。删除操作的交互流程是:用户点击删除图标 → 子组件调用 onDel 回调并传递商品名称 → 父组件将名称赋值给 delShopName 并设置 showDeleteShop = true → 删除弹窗渲染并显示商品名称。
第四十九段:评价页——评分分布图
@Component
struct ReviewContent {
@ObjectLink data: SkyData;
onDetail: (n: string) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('⭐ 评分分布')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('平均4.5分')
.fontSize(9)
.fontColor(COLORS.accent)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
ForEach(RATE_SHARE, (r: RateMeta) => {
Row() {
Text(r.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(36)
Row() {
Row()
.layoutWeight(r.count / 7)
.height(8)
.backgroundColor(r.color)
.borderRadius(4)
Row()
.layoutWeight(1 - r.count / 7)
.height(8)
.backgroundColor(COLORS.bg)
.borderRadius(4)
}
.layoutWeight(1)
.height(8)
Text(r.count + '条')
.fontSize(9)
.fontColor(r.color)
.width(36)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (r: RateMeta) => r.name)
ReviewContent 组件的评分分布图与前几个进度条图表结构一致,但进度比例使用 r.count / 7 计算——以最大值七(五星评价数)作为除数。星级名称使用固定宽度三十六像素,比其他图表的标签更窄,因为"5星"、"4星"等文字较短。右侧的数量标签使用"X条"的格式,同样三十六像素宽并右对齐。五星评价使用金色(强调色)高亮,在视觉上成为最突出的数据条。
卡片头部右侧的"平均4.5分"使用强调色渲染,这是从评价数据中计算出的汇总指标。平均评分的计算逻辑是:(5×7 + 4×3 + 3×1 + 2×1) / 12 = 54/12 = 4.5。这个数值在应用启动时即为固定值,因此在实际应用中应该由服务端预计算并返回。评分分布图与平均评分的组合展示,为用户提供了"分布 + 均值"的完整数据视图,帮助用户快速判断评价的整体质量水平。
第五十段:评价页——评价列表
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.reviews, (r: ReviewItem, i: number) => {
Row() {
Column()
.width(38)
.height(38)
.justifyContent(FlexAlign.Center)
.backgroundColor(COLORS.orbit + '2E')
.borderRadius(19)
Text(r.emoji)
.fontSize(16)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(r.name)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('⭐'.repeat(Math.round(r.score)) + ' ' + r.score + '.0')
.fontSize(10)
.fontColor(COLORS.accent)
.margin({ left: 6 })
}
.width('100%')
Text(r.content)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 3 })
Row() {
SkyTag({ text: r.tag, color: getTagColor(r.tag) })
Text(r.date)
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ left: 6 })
}
.width('100%')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
评价列表项的图标容器使用 COLORS.orbit(轨道色,即主色蓝)作为背景色基础,所有评价项的图标背景统一使用蓝色系。这与展览列表根据主题动态着色、行星列表根据类型着色、商店列表根据品类着色的策略不同——评价列表的图标背景不承载语义信息,而是通过统一的蓝色保持视觉简洁。用户头像使用动物或自然元素的 Emoji(如🦉、🐼、🐰),在保护隐私的同时增加了趣味性。
评分展示使用了 '⭐'.repeat(Math.round(r.score)) 动态生成星星字符串,这是 ArkTS 中字符串操作的典型应用。Math.round 确保评分为浮点数时也能正确取整。星星字符串后拼接空格和数值评分(如"⭐⭐⭐⭐⭐ 5.0"),形成"图形 + 数字"的双重评分表达。评价内容通过 maxLines(1) 和 textOverflow({ overflow: TextOverflow.Ellipsis }) 进行单行截断,超出部分显示省略号。这种截断策略确保了列表项高度的一致性,同时完整内容可以在详情弹窗中查看。标签和日期信息排列在底部一行,通过 SkyTag 组件和日期文字的左间距实现了视觉分组。
第五十一段:评价页——详情入口与列表项尾部
Text('👁️')
.fontSize(14)
.onClick(() => {
this.onDetail(r.name);
})
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (r: ReviewItem, i: number) => r.name + i)
评价列表项右侧的详情入口使用眼睛图标"👁️",绑定了 this.onDetail(r.name) 回调。点击后,评价者姓名被传递给父组件,触发评价详情弹窗的显示。这种"列表项 + 详情弹窗"的交互模式是内容浏览类应用的标准范式——列表提供概览信息,弹窗提供完整详情。列表项使用交替背景色(与行星列表和商店列表一致),在长评价列表中增强了可读性。
评价列表项的整体结构与展览列表项高度相似,但信息内容的编排方式有所不同。展览列表项的右侧展示评分数值,而评价列表项的右侧展示操作按钮(眼睛图标),评分信息被整合到了信息栏中(星星字符串 + 数值)。这种差异反映了不同业务场景的信息优先级——展览的评分是核心决策信息需要突出展示,而评价的评分是辅助信息可以内联展示。ForEach 的键值使用 r.name + i 的组合,尽管评价者姓名理论上可能重复(同一用户多次评价),但当前数据中不存在这种情况。
第五十二段:新增展览弹窗——头部
@Component
struct AddExhibitModal {
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%')
AddExhibitModal 是新增展览的弹窗组件,不使用 @Prop 或 @ObjectLink,仅通过 onClose 回调函数与父组件通信。弹窗的头部采用"大图标 + 标题副标题 + 关闭按钮"三栏布局。二十六号字体的望远镜图标作为视觉锚点,标题"新增展览"使用十六号字体加粗,副标题"录入全新科普展览"使用十号字体提示色。关闭按钮"✕"绑定了 this.onClose() 回调,点击后父组件将 showAddExhibit 设为 false,弹窗被卸载。
弹窗组件不持有任何状态,所有数据都是硬编码的静态文本。这意味着当前的新增展览弹窗是一个"展示型"弹窗——它呈现了新增展览的表单结构,但用户无法实际输入数据。在实际应用中,需要在弹窗中添加 @State 变量来绑定输入框的值,并在"确认上架"按钮的回调中将数据写入 SkyData 实例。当前的设计更侧重于 UI 原型展示,验证弹窗的布局和视觉设计。弹窗外层 Column 使用 justifyContent(FlexAlign.End) 将内容推到底部,配合 constraintSize({ maxHeight: '78%' }) 限制最大高度,形成了从底部弹出的底部弹窗效果。
第五十三段:新增展览弹窗——表单字段
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(10)
.margin({ top: 14 })
Row() {
Text('展览主题')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('深空 · A馆')
.fontSize(12)
.fontColor(COLORS.primaryLight)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
弹窗的表单字段采用"标签 + 值"的双列布局,标签使用十一号字体次要文字色,固定宽度七十像素;值使用十二号字体,通过 layoutWeight(1) 占据剩余空间。每个字段行使用交替背景色(cardAlt)、十像素圆角和上下外边距,形成独立的表单项卡片。字段之间的外边距为八像素,首个字段与头部之间的外边距为十四像素,形成了适当的视觉间距。
四个表单字段(展览名称、展览主题、预约方式、开放周期)的值文字使用了不同的色彩——名称使用主文字色(白色)、主题使用浅蓝色(primaryLight)、预约方式使用主文字色、开放周期使用成功色(绿色)。这种色彩差异并非随意,而是与字段的语义相关联:主题字段使用浅蓝色暗示其与主色调的关联,开放周期使用绿色表示时间段的有效性。在实际应用中,这些值应该通过 TextInput 组件实现用户输入,当前使用 Text 组件展示的是预设值,相当于表单的占位符效果。
第五十四段:新增展览弹窗——提示与按钮
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 })
表单字段下方的提示文字"✅ 新展览需提前完成内容审查与设备调试…"使用十号字体提示色,提供了业务流程的注意事项说明。这种在表单底部添加操作提示的设计,帮助用户理解提交后的后续流程,降低了操作的不确定性。提示文字使用全角句号结尾,文案语气正式但友好。
底部按钮区采用"取消 + 确认"的双按钮布局,两个按钮通过 layoutWeight(1) 等分宽度。取消按钮使用交替背景色和次要文字色,视觉上较为低调;确认按钮"确认上架"使用主色背景、白色加粗文字,视觉上更加突出。这种"主次按钮"的视觉对比设计,引导用户优先点击确认按钮。两个按钮都绑定了 this.onClose() 回调,但在实际应用中,确认按钮应该先执行数据提交逻辑再关闭弹窗。按钮的内边距上下十一像素提供了足够的点击区域,圆角十二像素与表单卡片的十像素圆角略有差异,为按钮赋予了更圆润的视觉感。
第五十五段:编辑行星弹窗
@Component
struct EditPlanetModal {
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%')
EditPlanetModal 的结构与 AddExhibitModal 高度一致,仅在图标、标题和副标题上有所差异。二十六号字体的土星图标"🪐"作为视觉锚点,标题"编辑行星"和副标题"更新行星观测数据"明确了弹窗的业务语境。关闭按钮的样式和交互与新增展览弹窗完全一致,体现了弹窗组件之间的设计统一性。
弹窗的表单字段包括行星名称、行星类型、最新观测和备注标签四项。与新增展览弹窗的预设值不同,编辑行星弹窗的值更具体——“开普勒22b”(系外行星名)、“系外行星 · 宜居带”(类型描述)、“半径2.4倍地球 · 无水汽信号”(观测数据)和"候选适居行星"(备注标签)。这些值暗示了编辑弹窗是在已有数据基础上进行修改,而非新建。弹窗底部的提示文字"⚠️ 修改观测数据将同步至图鉴、展览与教学课件三处"使用警告图标,提醒用户数据修改的影响范围。确认按钮使用强调色(金色)背景而非主色蓝,与新增展览弹窗的主色确认按钮形成色彩差异,帮助用户通过色彩区分不同类型的操作。
第五十六段:编辑行星弹窗——表单与按钮
Row() {
Text('行星名称')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('开普勒22b')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 14 })
Row() {
Text('行星类型')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('系外行星 · 宜居带')
.fontSize(12)
.fontColor(COLORS.accent)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
编辑行星弹窗的表单字段结构与新增展览弹窗完全一致——标签宽度七十像素、值占据剩余空间、交替背景色卡片、十像素圆角。字段值的色彩运用体现了语义化设计:行星名称使用主文字色(白色)表示基本信息,行星类型使用强调色(金色)突出其分类特征,最新观测使用主文字色展示数据,备注标签使用成功色(绿色)暗示其"候选适居行星"的正面属性。
四个字段的值构成了一条完整的行星编辑信息链:名称标识对象、类型描述分类、观测数据记录最新发现、备注标签提供补充判断。这种字段编排逻辑使得用户在编辑时能够获得完整的上下文信息。提示文字"修改观测数据将同步至图鉴、展览与教学课件三处"揭示了数据修改的级联影响——行星数据在应用中被三个模块共享,修改一处即触发三处更新。这种数据一致性提醒在实际应用中至关重要,防止用户在不知情的情况下影响其他模块。底部按钮的"保存修改"使用强调色(金色)背景,与新增展览弹窗的"确认上架"使用主色背景形成区分,使用户能够通过按钮色彩快速识别操作类型。
第五十七段:下架商品弹窗
@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)
DeleteShopModal 是下架商品的确认弹窗,使用 @Prop 接收 title 参数(商品名称)。与前两个弹窗的表单型布局不同,下架弹窗采用了居中型布局——三十四号字体的垃圾桶图标居中显示,下方是标题"确认下架商品"和描述文字。描述文字使用模板字符串动态拼接商品名称:“「星图桌垫」将从商店移除,库存数据会保留在后台。”,明确告知用户操作的具体对象和后果。
@Prop 装饰器在这里的作用是实现父组件到子组件的单向数据传递。当父组件的 delShopName 状态变量变化时,title 属性会自动更新。但由于 @Prop 是单向的,子组件内部对 title 的修改不会反映到父组件。这种设计适合下架弹窗的场景——商品名称是只读的上下文信息,不需要被修改。描述文字中"库存数据会保留在后台"是一个重要的业务说明,它告诉用户下架操作不会永久删除数据,降低了用户的操作焦虑。整个居中布局的 Column 使用 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 })
下架弹窗的按钮区采用与新增展览弹窗相同的"取消 + 确认"双按钮布局,但确认按钮使用危险色(红色)背景而非主色或强调色。这种色彩选择遵循了"破坏性操作使用红色"的设计原则——下架商品是一个不可逆的操作(尽管数据保留在后台),使用红色确认按钮能够引起用户的注意,防止误操作。取消按钮的样式与前两个弹窗完全一致,保持了视觉统一性。
弹窗的边框也使用了危险色 COLORS.danger + '88'(红色加百分之五十三透明度),与确认按钮的红色形成呼应。这种"边框色与按钮色同色系"的设计策略,使整个弹窗在视觉上传达出"警告"的整体氛围。弹窗的最大高度限制为百分之七十八,与所有弹窗保持一致。两个按钮都绑定了 this.onClose() 回调,但在实际应用中,"确认下架"按钮应该先执行从 SkyData.shops 数组中移除对应商品的逻辑,再关闭弹窗。这种数据操作需要通过回调函数将操作意图传递回父组件,由父组件修改 SkyData 实例。
第五十九段:评价详情弹窗——头部
@Component
struct DetailReviewModal {
@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%')
DetailReviewModal 是评价详情弹窗,使用 @Prop 接收 name 参数(评价者姓名)。弹窗头部与新增展览弹窗和编辑行星弹窗结构一致——二十六号图标、标题、副标题和关闭按钮。副标题"游客「星野」"通过模板字符串动态拼接评价者姓名,使弹窗的上下文信息明确化。关闭按钮的交互与其他弹窗完全一致,体现了弹窗组件的交互统一性。
@Prop 在这里的使用方式与 DeleteShopModal 相同——接收父组件传递的只读参数。评价者姓名是在用户点击评价列表项的眼睛图标时,通过 onDetail 回调传递给父组件,再由父组件传递给弹窗的。这条数据传递链路跨越了三个组件层级(列表项 → 父组件 → 弹窗),体现了 ArkTS 组件化架构中"属性向下、事件向上"的数据流模式。弹窗头部的副标题使用提示色(灰色)渲染,与标题的主文字色(白色)形成视觉层级。alignItems(HorizontalAlign.Start) 确保标题和副标题左对齐,与关闭按钮的右对齐形成对称布局。
第六十段:评价详情弹窗——字段与内容
Row() {
Text('综合评分')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('⭐⭐⭐⭐⭐ 5.0')
.fontSize(12)
.fontColor(COLORS.accent)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 14 })
Column() {
Text('评价内容')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('黑洞电影院太震撼了,球幕包裹感一流!孩子全程目不转睛,周末还会再来观星课程。')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.lineHeight(20)
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.alignItems(HorizontalAlign.Start)
.margin({ top: 8 })
Text('🔒 游客隐私信息已脱敏,仅展示公开评价内容。')
.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();
})
评价详情弹窗的字段区包含综合评分、游览日期、评价标签和评价内容四部分。综合评分使用"⭐⭐⭐⭐⭐ 5.0"的格式,将图形化星星与数值评分结合展示,使用强调色(金色)高亮。评价内容字段使用 Column 容器而非 Row,因为内容文字较长需要换行展示。lineHeight(20) 设置了二十像素的行高,确保多行文字的阅读舒适度。
弹窗底部的隐私提示"🔒 游客隐私信息已脱敏,仅展示公开评价内容"使用锁图标,明确告知用户隐私保护策略。这种隐私声明的展示在涉及用户生成内容的应用中是必要的合规措施。底部的"知道了"按钮使用单按钮布局而非双按钮布局,因为这是信息查看型弹窗而非操作确认型弹窗——用户只有一个选择(关闭弹窗),不需要"取消/确认"的二元决策。按钮使用主色背景和白色加粗文字,与新增展览弹窗的确认按钮样式一致。弹窗的边框使用浅蓝色(primaryLight)加透明度,营造柔和的视觉边框效果。
组件关系与数据流图
状态管理流程图
技术对比表格
| 技术要点 | 实现方式 | 优势 | 适用场景 |
|---|---|---|---|
| 色彩体系管理 | SkyPalette 接口 + COLORS 常量集中定义 | 类型安全、全局一致、易于主题切换 | 需要统一视觉风格的中小型应用 |
| 状态管理 | @State 集中管理 + @ObjectLink 双向绑定 + @Prop 单向传递 | 无需第三方状态管理库、数据流清晰 | 中等复杂度单页面应用 |
| 组件通信 | 属性向下传递 + 回调函数向上传递 | 子组件无状态、易于测试和复用 | 父子组件层级较浅的场景 |
| 弹窗管理 | 布尔状态变量条件挂载 | 弹窗关闭零资源占用、生命周期清晰 | 弹窗数量可控(少于十个)的应用 |
| 数据可视化 | layoutWeight 比例渲染进度条和柱状图 | 零依赖、高性能、高度可定制 | 简单图表场景(柱状图、进度条) |
| 语义化着色 | 工具函数将业务语义映射到色彩值 | 数据项自动着色、视觉信息密度高 | 多品类列表展示场景 |
| 标签页路由 | if-else 条件渲染 + 枚举驱动 | 无需路由框架、切换即时 | 底部标签导航类应用 |
| 动画交互 | 布尔状态 + animation 修饰器声明式动画 | 代码简洁、状态驱动、自动插值 | 简单的切换动画和装饰性动画 |
| 列表渲染 | ForEach + 键值组合策略 | 增量更新高效、避免重复渲染 | 数据量中等的列表场景 |
| 透明度叠加 | 十六进制色值后拼接透明度后缀 | 同一色值生成多层级透明度 | 需要背景、边框、文字同色系场景 |
| 交替背景色 | 索引取模判断 + 条件背景色 | 增强长列表可读性 | 数据行数超过五条的列表 |
| 阈值动态着色 | 数值比较条件表达式驱动色彩切换 | 实时反映数据状态变化 | 库存预警、报名进度等监控场景 |
| 统计图表数据 | 文件级常量独立维护 | 与渲染逻辑物理分离、易于替换 | 预聚合的静态分析数据 |
| 模态遮罩 | @Builder 方法构建隐形按钮 | 可复用、不占可视空间 | 需要点击遮罩关闭弹窗的场景 |
| 隐私保护 | 脱敏展示 + 隐私声明提示 | 合规性强、用户信任度高 | 涉及用户生成内容的应用 |
| 数据驱动 UI | 状态变量绑定 UI 属性 | 数据变化自动触发渲染更新 | 需要实时响应数据变化的场景 |
| 组件复用 | SkyTag 通用标签组件跨页面复用 | 减少重复代码、保证视觉一致性 | 多个页面需要相同 UI 元素的场景 |
| 双语标题 | 中文主标题 + 英文副标题 | 兼顾本土化和国际化氛围 | 科普类、教育类应用 |
| Emoji 图标 | 使用 Emoji 替代图标字体 | 零依赖、原生支持、色彩丰富 | 趣味性强的消费级应用 |
| 圆角视觉语言 | 统一 borderRadius 参数体系 | 视觉风格一致、品牌识别度高 | 注重视觉品质的应用 |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
interface SkyPalette {
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;
star: string;
orbit: string;
}
const COLORS: SkyPalette = {
primary: '#5B6EE1',
primaryLight: '#8FA3FF',
primaryDark: '#1B2248',
accent: '#F5C451',
accentLight: '#FFE9B0',
bg: '#0E1330',
cardBg: '#1A2140',
cardAlt: '#222B52',
textPrimary: '#F2F4FF',
textSecondary: '#A7B0D9',
textHint: '#6B74A6',
border: '#2C3560',
line: '#2C3560',
success: '#5BD6A8',
warning: '#F5C451',
danger: '#FF6B81',
white: '#FFFFFF',
star: '#F5C451',
orbit: '#5B6EE1'
};
enum SkyTab {
EXHIBIT = 0,
PLANET = 1,
COURSE = 2,
SHOP = 3,
REVIEW = 4
}
interface TabMeta {
key: string;
icon: string;
label: string;
color: string;
}
const TAB_LIST: TabMeta[] = [
{ key: 'exhibit', icon: '🔭', label: '展览', color: '#5B6EE1' },
{ key: 'planet', icon: '🪐', label: '行星', color: '#F5C451' },
{ key: 'course', icon: '🌌', label: '课程', color: '#8FA3FF' },
{ key: 'shop', icon: '🛰️', label: '商店', color: '#5BD6A8' },
{ key: 'review', icon: '⭐', label: '评价', color: '#FF6B81' }
];
const STAR_COL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
interface ExhibitItem {
name: string;
theme: string;
visitors: number;
rating: number;
zone: string;
emoji: string;
}
interface PlanetItem {
name: string;
type: string;
distance: number;
size: number;
moons: number;
emoji: string;
}
interface CourseItem {
name: string;
teacher: string;
seats: number;
joined: number;
level: string;
emoji: string;
}
interface ShopItem {
name: string;
price: number;
sales: number;
stock: number;
category: string;
emoji: string;
}
interface ReviewItem {
name: string;
score: number;
date: string;
content: string;
tag: string;
emoji: string;
}
interface WeekMeta {
day: string;
value: number;
}
interface PlanetMeta {
name: string;
count: number;
color: string;
}
interface ClassJoinMeta {
name: string;
rate: number;
color: string;
}
interface ShopTopMeta {
name: string;
sales: number;
color: string;
}
interface RateMeta {
name: string;
count: number;
color: string;
}
const WEEK_VISIT: WeekMeta[] = [
{ day: '周一', value: 18 },
{ day: '周二', value: 22 },
{ day: '周三', value: 20 },
{ day: '周四', value: 26 },
{ day: '周五', value: 38 },
{ day: '周六', value: 62 },
{ day: '周日', value: 55 }
];
const PLANET_SHARE: PlanetMeta[] = [
{ name: '气态巨行星', count: 3, color: '#5B6EE1' },
{ name: '岩质行星', count: 3, color: '#F5C451' },
{ name: '冰巨星', count: 2, color: '#8FA3FF' },
{ name: '矮行星', count: 2, color: '#5BD6A8' },
{ name: '系外行星', count: 1, color: '#FF6B81' },
{ name: '卫星', count: 1, color: '#C792EA' }
];
const CLASS_JOIN: ClassJoinMeta[] = [
{ name: '观星入门课', rate: 94, color: '#5B6EE1' },
{ name: '天文摄影课', rate: 88, color: '#F5C451' },
{ name: '行星探索课', rate: 82, color: '#8FA3FF' },
{ name: '火箭原理课', rate: 76, color: '#5BD6A8' },
{ name: '星座传说课', rate: 68, color: '#FF6B81' },
{ name: '黑洞漫游课', rate: 61, color: '#C792EA' }
];
const SHOP_TOP: ShopTopMeta[] = [
{ name: '星空投影灯', sales: 326, color: '#5B6EE1' },
{ name: '陨石标本盲盒', sales: 258, color: '#F5C451' },
{ name: '火箭拼装模型', sales: 214, color: '#8FA3FF' },
{ name: '行星项链', sales: 186, color: '#5BD6A8' },
{ name: '宇航员手办', sales: 152, color: '#FF6B81' },
{ name: '星图桌垫', sales: 128, color: '#C792EA' }
];
const RATE_SHARE: RateMeta[] = [
{ name: '5星', count: 7, color: '#F5C451' },
{ name: '4星', count: 3, color: '#5B6EE1' },
{ name: '3星', count: 1, color: '#8FA3FF' },
{ name: '2星', count: 1, color: '#5BD6A8' }
];
@Observed
export class SkyData {
exhibits: ExhibitItem[] = [
{ name: '星际穿越特展', theme: '深空', visitors: 12800, rating: 9.6, zone: 'A馆', emoji: '🌌' },
{ name: '土星环之谜', theme: '行星', visitors: 9800, rating: 9.2, zone: 'A馆', emoji: '🪐' },
{ name: '登月五十年', theme: '航天', visitors: 11200, rating: 9.0, zone: 'B馆', emoji: '🚀' },
{ name: '黑洞电影院', theme: '深空', visitors: 8700, rating: 9.4, zone: '球幕厅', emoji: '🕳️' },
{ name: '小行星工坊', theme: '互动', visitors: 6400, rating: 8.8, zone: 'C馆', emoji: '☄️' },
{ name: '极光秘境', theme: '现象', visitors: 7200, rating: 9.1, zone: 'B馆', emoji: '🌠' },
{ name: '火星基地模拟', theme: '航天', visitors: 5900, rating: 8.6, zone: 'C馆', emoji: '🔴' },
{ name: '宇宙之眼', theme: '天文台', visitors: 4800, rating: 8.9, zone: '观象台', emoji: '🔭' },
{ name: '星座画廊', theme: '互动', visitors: 5300, rating: 8.4, zone: 'A馆', emoji: '✨' },
{ name: '太阳风暴秀', theme: '现象', visitors: 6100, rating: 8.7, zone: '球幕厅', emoji: '☀️' },
{ name: '银河系漫游', theme: '深空', visitors: 8300, rating: 9.3, zone: '球幕厅', emoji: '🌀' },
{ name: '太空种子园', theme: '互动', visitors: 3900, rating: 8.2, zone: 'C馆', emoji: '🌱' }
];
planets: PlanetItem[] = [
{ name: '木星', type: '气态巨行星', distance: 5.2, size: 11.2, moons: 95, emoji: '🟤' },
{ name: '土星', type: '气态巨行星', distance: 9.5, size: 9.4, moons: 146, emoji: '🪐' },
{ name: '火星', type: '岩质行星', distance: 1.5, size: 0.5, moons: 2, emoji: '🔴' },
{ name: '金星', type: '岩质行星', distance: 0.7, size: 0.9, moons: 0, emoji: '🟡' },
{ name: '海王星', type: '冰巨星', distance: 30.1, size: 3.9, moons: 16, emoji: '🔵' },
{ name: '天王星', type: '冰巨星', distance: 19.2, size: 4.0, moons: 28, emoji: '🟢' },
{ name: '地球', type: '岩质行星', distance: 1.0, size: 1.0, moons: 1, emoji: '🌍' },
{ name: '水星', type: '岩质行星', distance: 0.4, size: 0.4, moons: 0, emoji: '🌑' },
{ name: '冥王星', type: '矮行星', distance: 39.5, size: 0.2, moons: 5, emoji: '🧊' },
{ name: '谷神星', type: '矮行星', distance: 2.8, size: 0.07, moons: 0, emoji: '⛰️' },
{ name: '开普勒22b', type: '系外行星', distance: 600, size: 2.4, moons: 0, emoji: '🛸' },
{ name: '月球', type: '卫星', distance: 0.0026, size: 0.27, moons: 0, emoji: '🌙' }
];
courses: CourseItem[] = [
{ name: '观星入门课', teacher: '陈观星', seats: 30, joined: 28, level: '入门', emoji: '🔭' },
{ name: '天文摄影课', teacher: '许夜穹', seats: 20, joined: 18, level: '进阶', emoji: '📷' },
{ name: '行星探索课', teacher: '林星河', seats: 40, joined: 33, level: '入门', emoji: '🪐' },
{ name: '火箭原理课', teacher: '周航天', seats: 25, joined: 19, level: '进阶', emoji: '🚀' },
{ name: '星座传说课', teacher: '苏织女', seats: 50, joined: 34, level: '入门', emoji: '✨' },
{ name: '黑洞漫游课', teacher: '李引力', seats: 18, joined: 11, level: '大师', emoji: '🕳️' },
{ name: '陨石鉴定课', teacher: '王天石', seats: 22, joined: 15, level: '进阶', emoji: '☄️' },
{ name: '极光成因课', teacher: '赵磁暴', seats: 35, joined: 24, level: '入门', emoji: '🌠' },
{ name: '宇宙尺度课', teacher: '陈观星', seats: 28, joined: 17, level: '进阶', emoji: '📏' },
{ name: '外星生命课', teacher: '钱星海', seats: 45, joined: 31, level: '入门', emoji: '🛸' },
{ name: '望远镜DIY课', teacher: '孙镜片', seats: 16, joined: 9, level: '大师', emoji: '🔧' },
{ name: '航天史漫谈', teacher: '周航天', seats: 60, joined: 41, level: '入门', emoji: '🛰️' }
];
shops: ShopItem[] = [
{ name: '星空投影灯', price: 129, sales: 326, stock: 18, category: '家居', emoji: '💡' },
{ name: '陨石标本盲盒', price: 89, sales: 258, stock: 6, category: '收藏', emoji: '☄️' },
{ name: '火箭拼装模型', price: 199, sales: 214, stock: 24, category: '模型', emoji: '🚀' },
{ name: '行星项链', price: 59, sales: 186, stock: 42, category: '饰品', emoji: '📿' },
{ name: '宇航员手办', price: 158, sales: 152, stock: 15, category: '手办', emoji: '🧑🚀' },
{ name: '星图桌垫', price: 45, sales: 128, stock: 55, category: '家居', emoji: '🗺️' },
{ name: '月相挂钟', price: 168, sales: 97, stock: 9, category: '家居', emoji: '🕰️' },
{ name: '望远镜钥匙扣', price: 25, sales: 342, stock: 80, category: '饰品', emoji: '🔭' },
{ name: '星空拼图', price: 79, sales: 143, stock: 33, category: '益智', emoji: '🧩' },
{ name: '宇航服摆件', price: 238, sales: 64, stock: 5, category: '手办', emoji: '🪖' },
{ name: '流星雨明信片', price: 15, sales: 410, stock: 120, category: '文创', emoji: '💌' },
{ name: '天文台T恤', price: 99, sales: 176, stock: 38, category: '服饰', emoji: '👕' }
];
reviews: ReviewItem[] = [
{ name: '星野', score: 5, date: '08-15', content: '黑洞电影院太震撼了,球幕包裹感一流!', tag: '体验极佳', emoji: '🦉' },
{ name: '阿澈', score: 5, date: '08-15', content: '极光秘境还原度超高,适合带孩子来看。', tag: '亲子友好', emoji: '🐼' },
{ name: '木木', score: 4, date: '08-14', content: '观星课老师讲得很生动,就是周末人多。', tag: '课程好评', emoji: '🐰' },
{ name: '深蓝', score: 5, date: '08-13', content: '陨石鉴定课帮我确认了收藏的陨石真伪!', tag: '专业靠谱', emoji: '🐺' },
{ name: '小火箭', score: 4, date: '08-12', content: '火箭模型做工精致,拼装说明书再细点更好。', tag: '商品满意', emoji: '🚀' },
{ name: '夜航', score: 5, date: '08-11', content: '周六的流星雨观测活动组织得非常好。', tag: '活动精彩', emoji: '🌠' },
{ name: '团团', score: 3, date: '08-10', content: '星际穿越展排队太久,建议线上预约。', tag: '排队较长', emoji: '🐹' },
{ name: '引力', score: 5, date: '08-09', content: '黑洞课的内容深度超出预期,值回票价。', tag: '干货满满', emoji: '🦁' },
{ name: '橙子', score: 4, date: '08-08', content: '星球冰淇淋好吃,行星项链也很精致。', tag: '配套完善', emoji: '🐱' },
{ name: '北辰', score: 5, date: '08-07', content: '天文台望远镜观土星环,终身难忘的体验。', tag: '体验极佳', emoji: '🔭' },
{ name: '麦穗', score: 4, date: '08-06', content: '星座画廊互动做得好,小朋友玩得不想走。', tag: '亲子友好', emoji: '🌾' },
{ name: '曦和', score: 5, date: '08-05', content: '太阳风暴球幕秀的声光效果绝了!', tag: '视觉盛宴', emoji: '☀️' }
];
}
function getThemeColor(theme: string): string {
if (theme === '深空') {
return '#5B6EE1';
} else if (theme === '行星') {
return '#F5C451';
} else if (theme === '航天') {
return '#5BD6A8';
} else if (theme === '互动') {
return '#FF6B81';
} else if (theme === '现象') {
return '#C792EA';
} else if (theme === '天文台') {
return '#8FA3FF';
}
return '#6B74A6';
}
function getLevelColor(level: string): string {
if (level === '入门') {
return '#5BD6A8';
} else if (level === '进阶') {
return '#F5C451';
}
return '#FF6B81';
}
function getCategoryColor(category: string): string {
if (category === '家居') {
return '#5B6EE1';
} else if (category === '收藏') {
return '#F5C451';
} else if (category === '模型') {
return '#8FA3FF';
} else if (category === '饰品') {
return '#FF6B81';
} else if (category === '手办') {
return '#5BD6A8';
} else if (category === '益智') {
return '#C792EA';
} else if (category === '文创') {
return '#FF9DB1';
}
return '#6B74A6';
}
function getTagColor(tag: string): string {
if (tag === '体验极佳' || tag === '干货满满') {
return '#5B6EE1';
} else if (tag === '亲子友好' || tag === '活动精彩' || tag === '视觉盛宴') {
return '#F5C451';
} else if (tag === '课程好评' || tag === '专业靠谱') {
return '#5BD6A8';
} else if (tag === '排队较长') {
return '#FF6B81';
}
return '#8FA3FF';
}
@Entry
@Component
struct SkyApp {
@State curTab: number = 0;
@State data: SkyData = new SkyData();
@State showAddExhibit: boolean = false;
@State showEditPlanet: boolean = false;
@State showDeleteShop: boolean = false;
@State showDetail: boolean = false;
@State delShopName: string = '';
@State detailReviewName: string = '';
@State meteorFly: boolean = false;
@State saturnSpin: 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('Planetarium · 观星之夜')
.fontSize(10)
.fontColor('#FFFFFFAA')
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Row() {
Text('🌠')
.fontSize(20)
.onClick(() => {
this.meteorFly = !this.meteorFly;
})
.translate({ x: this.meteorFly ? 14 : 0, y: this.meteorFly ? 6 : 0 })
.opacity(this.meteorFly ? 0.4 : 1)
.animation({ duration: 700, curve: Curve.EaseOut })
Text('🪐')
.fontSize(18)
.margin({ left: 10 })
.onClick(() => {
this.saturnSpin = !this.saturnSpin;
})
.rotate({ angle: this.saturnSpin ? 360 : 0 })
.animation({ duration: 800, curve: Curve.EaseInOut })
Text('⭐')
.fontSize(16)
.margin({ left: 6 })
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor('#FFFFFF1A')
.borderRadius(18)
.border({ width: 1, color: '#FFFFFF33' })
}
.width('100%')
Column() {
ForEach(STAR_COL, (c: number, i: number) => {
Column()
.layoutWeight(1)
.height(8)
.backgroundColor(i % 3 === 0 ? COLORS.star : '#FFFFFF26')
.borderRadius(4)
.margin({ left: 2, right: 2 })
}, (c: number) => 'starA' + c)
ForEach(STAR_COL, (c: number, i: number) => {
Column()
.layoutWeight(1)
.height(8)
.backgroundColor((i + 1) % 3 === 0 ? COLORS.star : '#FFFFFF1F')
.borderRadius(4)
.margin({ left: 2, right: 2 })
}, (c: number) => 'starB' + c)
ForEach(STAR_COL, (c: number, i: number) => {
Column()
.layoutWeight(1)
.height(8)
.backgroundColor((i + 2) % 3 === 0 ? COLORS.star : '#FFFFFF18')
.borderRadius(4)
.margin({ left: 2, right: 2 })
}, (c: number) => 'starC' + c)
}
.width('100%')
.height(34)
.margin({ top: 10 })
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.primaryDark)
if (this.curTab === SkyTab.EXHIBIT) {
ExhibitContent({ data: this.data, onAdd: () => {
this.showAddExhibit = true;
} })
} else if (this.curTab === SkyTab.PLANET) {
PlanetContent({ data: this.data, onEdit: () => {
this.showEditPlanet = true;
} })
} else if (this.curTab === SkyTab.COURSE) {
CourseContent({ data: this.data })
} else if (this.curTab === SkyTab.SHOP) {
ShopContent({ data: this.data, onDel: (n: string) => {
this.delShopName = n;
this.showDeleteShop = true;
} })
} else {
ReviewContent({ data: this.data, onDetail: (n: string) => {
this.detailReviewName = n;
this.showDetail = true;
} })
}
}
.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.primaryDark : 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.showAddExhibit) {
AddExhibitModal({
onClose: () => {
this.showAddExhibit = false;
}
})
}
if (this.showEditPlanet) {
EditPlanetModal({
onClose: () => {
this.showEditPlanet = false;
}
})
}
if (this.showDeleteShop) {
DeleteShopModal({
title: this.delShopName, onClose: () => {
this.showDeleteShop = false;
}
})
}
if (this.showDetail) {
DetailReviewModal({
name: this.detailReviewName, onClose: () => {
this.showDetail = false;
}
})
}
}
}
}
@Component
struct SkyTag {
@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 + '26')
.borderRadius(6)
.border({ width: 1, color: this.color + '55' })
}
}
@Component
struct ExhibitContent {
@ObjectLink data: SkyData;
onAdd: () => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('📈 周参观人数')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('周六峰值6.2万')
.fontSize(9)
.fontColor(COLORS.accent)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
ForEach(WEEK_VISIT, (w: WeekMeta) => {
Column() {
Text(w.value + '')
.fontSize(8)
.fontColor(w.value >= 50 ? COLORS.accent : COLORS.textSecondary)
Column()
.width(20)
.height(w.value * 1.1)
.backgroundColor(w.value >= 50 ? COLORS.accent : COLORS.primary)
.borderRadius(4)
.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(14)
.border({ width: 1, color: COLORS.primary + '66' })
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(PLANET_SHARE, (n: PlanetMeta) => {
Row() {
Text(n.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(84)
Row() {
Row()
.layoutWeight(n.count / 3)
.height(8)
.backgroundColor(n.color)
.borderRadius(4)
Row()
.layoutWeight(1 - n.count / 3)
.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: PlanetMeta) => n.name)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.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.onAdd();
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.exhibits, (e: ExhibitItem, i: number) => {
Row() {
Column()
.width(38)
.height(38)
.justifyContent(FlexAlign.Center)
.backgroundColor(getThemeColor(e.theme) + '2E')
.borderRadius(19)
.border({ width: 1, color: getThemeColor(e.theme) + '66' })
Text(e.emoji)
.fontSize(17)
.width(30)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(e.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SkyTag({ text: e.theme, color: getThemeColor(e.theme) })
.margin({ left: 6 })
}
.width('100%')
Text(e.zone + ' · 累计参观' + e.visitors + '人')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
Row() {
Row()
.layoutWeight(e.rating / 10)
.height(5)
.backgroundColor(e.rating >= 9 ? COLORS.accent : COLORS.primary)
.borderRadius(2)
Row()
.layoutWeight(1 - e.rating / 10)
.height(5)
.backgroundColor(COLORS.bg)
.borderRadius(2)
}
.width('100%')
.height(5)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text(e.rating + '')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
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(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (e: ExhibitItem, i: number) => e.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.primary + '66' })
.margin({ top: 12 })
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct PlanetContent {
@ObjectLink data: SkyData;
onEdit: () => 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.onEdit();
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
Row() {
Column() {
Text('12')
.fontSize(26)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text('已收录行星')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 14, bottom: 14 })
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.primary + '66' })
Column() {
Text('8')
.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(14)
.border({ width: 1, color: COLORS.success + '66' })
.margin({ left: 10 })
Column() {
Text('146')
.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(14)
.border({ width: 1, color: COLORS.warning + '66' })
.margin({ left: 10 })
}
.width('100%')
.margin({ bottom: 12 })
ForEach(this.data.planets, (p: PlanetItem, i: number) => {
Row() {
Column()
.width(44)
.height(44)
.justifyContent(FlexAlign.Center)
.backgroundColor(p.type === '气态巨行星' ? '#5B6EE12E' : p.type === '岩质行星' ? '#F5C4512E' : p.type === '冰巨星' ? '#8FA3FF2E' : '#5BD6A82E')
.borderRadius(22)
.border({ width: 1, color: '#F5C45166' })
Text(p.emoji)
.fontSize(19)
.width(30)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(p.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SkyTag({ text: p.type, color: getThemeColor(p.type) })
.margin({ left: 6 })
}
.width('100%')
Text('距太阳' + p.distance + 'AU · 半径' + p.size + '倍地球 · ' + p.moons + '颗卫星')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text(p.moons + '')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryLight)
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(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (p: PlanetItem, i: number) => p.name + i)
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct CourseContent {
@ObjectLink data: SkyData;
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(CLASS_JOIN, (c: ClassJoinMeta) => {
Row() {
Text(c.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(84)
Row() {
Row()
.layoutWeight(c.rate / 100)
.height(8)
.backgroundColor(c.color)
.borderRadius(4)
Row()
.layoutWeight(1 - c.rate / 100)
.height(8)
.backgroundColor(COLORS.bg)
.borderRadius(4)
}
.layoutWeight(1)
.height(8)
Text(c.rate + '%')
.fontSize(9)
.fontColor(c.color)
.width(38)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (c: ClassJoinMeta) => c.name)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.accent + '66' })
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)
.margin({ bottom: 10 })
ForEach(this.data.courses, (c: CourseItem, i: number) => {
Row() {
Column()
.width(40)
.height(40)
.justifyContent(FlexAlign.Center)
.backgroundColor(getLevelColor(c.level) + '26')
.borderRadius(20)
Text(c.emoji)
.fontSize(18)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(c.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SkyTag({ text: c.level, color: getLevelColor(c.level) })
.margin({ left: 6 })
}
.width('100%')
Text(c.teacher + ' 主讲 · ' + c.seats + '人班')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text(c.joined + '/' + c.seats)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(c.joined >= c.seats * 0.8 ? COLORS.danger : COLORS.success)
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(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (c: CourseItem, i: number) => c.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ top: 12 })
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct ShopContent {
@ObjectLink data: SkyData;
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.accent : COLORS.textHint)
.width(22)
Text(s.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(90)
Row() {
Row()
.layoutWeight(s.sales / 330)
.height(8)
.backgroundColor(s.color)
.borderRadius(4)
Row()
.layoutWeight(1 - s.sales / 330)
.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(14)
.border({ width: 1, color: COLORS.success + '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) + '26')
.borderRadius(12)
Text(s.emoji)
.fontSize(18)
.width(30)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(s.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SkyTag({ 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 > 10 ? '充足' : '告急')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(s.stock > 10 ? 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(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (s: ShopItem, i: number) => s.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ top: 12 })
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct ReviewContent {
@ObjectLink data: SkyData;
onDetail: (n: string) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('⭐ 评分分布')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('平均4.5分')
.fontSize(9)
.fontColor(COLORS.accent)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
ForEach(RATE_SHARE, (r: RateMeta) => {
Row() {
Text(r.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(36)
Row() {
Row()
.layoutWeight(r.count / 7)
.height(8)
.backgroundColor(r.color)
.borderRadius(4)
Row()
.layoutWeight(1 - r.count / 7)
.height(8)
.backgroundColor(COLORS.bg)
.borderRadius(4)
}
.layoutWeight(1)
.height(8)
Text(r.count + '条')
.fontSize(9)
.fontColor(r.color)
.width(36)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (r: RateMeta) => r.name)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.accent + '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.reviews, (r: ReviewItem, i: number) => {
Row() {
Column()
.width(38)
.height(38)
.justifyContent(FlexAlign.Center)
.backgroundColor(COLORS.orbit + '2E')
.borderRadius(19)
Text(r.emoji)
.fontSize(16)
.width(28)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(r.name)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('⭐'.repeat(Math.round(r.score)) + ' ' + r.score + '.0')
.fontSize(10)
.fontColor(COLORS.accent)
.margin({ left: 6 })
}
.width('100%')
Text(r.content)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 3 })
Row() {
SkyTag({ text: r.tag, color: getTagColor(r.tag) })
Text(r.date)
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ left: 6 })
}
.width('100%')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Text('👁️')
.fontSize(14)
.onClick(() => {
this.onDetail(r.name);
})
}
.width('100%')
.padding({ left: 10, right: 12, top: 9, bottom: 9 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (r: ReviewItem, i: number) => r.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ top: 12 })
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct AddExhibitModal {
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(10)
.margin({ top: 14 })
Row() {
Text('展览主题')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('深空 · A馆')
.fontSize(12)
.fontColor(COLORS.primaryLight)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
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(10)
.margin({ top: 8 })
Row() {
Text('开放周期')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('2026-09-01 至 2026-12-31')
.fontSize(12)
.fontColor(COLORS.success)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.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(18)
.border({ width: 1, color: COLORS.primary + '88' })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
@Component
struct EditPlanetModal {
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('开普勒22b')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 14 })
Row() {
Text('行星类型')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('系外行星 · 宜居带')
.fontSize(12)
.fontColor(COLORS.accent)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('最新观测')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('半径2.4倍地球 · 无水汽信号')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('备注标签')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('候选适居行星')
.fontSize(12)
.fontColor(COLORS.success)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.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.accent)
.borderRadius(12)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 16 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.border({ width: 1, color: COLORS.accent + '88' })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
@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(18)
.border({ width: 1, color: COLORS.danger + '88' })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
@Component
struct DetailReviewModal {
@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('⭐⭐⭐⭐⭐ 5.0')
.fontSize(12)
.fontColor(COLORS.accent)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 14 })
Row() {
Text('游览日期')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('2026-08-15 · 亲子票')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('评价标签')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('体验极佳 · 亲子友好')
.fontSize(12)
.fontColor(COLORS.success)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Column() {
Text('评价内容')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('黑洞电影院太震撼了,球幕包裹感一流!孩子全程目不转睛,周末还会再来观星课程。')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.lineHeight(20)
.margin({ top: 8 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.alignItems(HorizontalAlign.Start)
.margin({ top: 8 })
Text('🔒 游客隐私信息已脱敏,仅展示公开评价内容。')
.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(18)
.border({ width: 1, color: COLORS.primaryLight + '88' })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
结尾总结

纵观整个星河天文馆应用的技术实现,我们可以从多个维度提炼出有价值的架构设计要点。从状态管理的维度来看,该应用采用了"集中管理 + 分层传递"的策略:主入口组件 SkyApp 通过九个 @State 变量集中管控全局状态,包括当前标签页、数据实例和四个弹窗的显隐状态。子内容组件通过 @ObjectLink 与共享的 SkyData 实例建立双向绑定,确保数据的实时同步。弹窗组件则通过 @Prop 接收只读参数,保持了数据流的单向性。这种分层状态管理避免了全局状态管理库的引入,在保证功能完备的同时保持了架构的轻量级,适合中等复杂度的单页面应用。
从组件化设计的维度来看,应用将 UI 拆分为四个层次:主入口容器组件负责整体布局和状态调度;通用组件(SkyTag)提供跨页面的可复用 UI 单元;内容页面组件专注于各业务模块的渲染逻辑;弹窗组件处理模态交互场景。这种分层使得每个组件的职责单一且内聚,组件之间的通信通过属性传递(向下)和回调函数(向上)实现,形成了清晰的数据流方向。值得注意的是,所有弹窗组件都不持有业务状态,它们通过入参获取上下文信息,关闭后状态自动清理,这种无状态设计大大降低了组件的测试和维护成本。
从数据可视化的维度来看,该应用展示了在不引入第三方图表库的前提下,通过纯声明式布局实现数据可视化的工程能力。柱状图通过 Column 的高度比例渲染,水平进度条通过双段 layoutWeight 比例渲染,排行榜通过序号着色实现头部突出。这些可视化方案虽然不如专业图表库功能丰富,但在轻量级场景下具有零依赖、高性能、易于定制的优势。色彩在数据可视化中扮演了关键角色——四个工具函数将业务语义(主题、难度、品类、标签)映射到色彩值,使得每条数据都能通过色彩传达额外的语义信息。
从色彩体系的维度来看,SkyPalette 接口定义了二十一个色彩槽位,覆盖了主色、强调色、背景、文字、语义色和主题定制色六个维度。通过将色彩集中管理为常量对象,应用实现了主题的统一可控和全局一致性。工具函数中的扩展色(如紫色 #C792EA 和粉红 #FF9DB1)为数据可视化场景提供了标准色板之外的补充,这种"标准色板 + 扩展色"的双层色彩体系,在保证主题统一性的同时满足了多品类着色的需求。十六进制色值后拼接透明度后缀(如 + '26'、+ '66')的技巧,使得同一色值能够生成背景、边框、文字三个透明度层级,极大地提高了色彩复用率。
从交互设计的维度来看,应用通过条件渲染实现了标签页切换和弹窗显隐两种核心交互模式。标签页切换通过修改 curTab 状态变量触发 if-else 链的条件判断,自动渲染对应的内容页面组件。弹窗显隐通过独立的布尔状态变量控制组件的挂载和卸载,这种"条件挂载"策略确保了弹窗关闭后不占用任何渲染资源。顶部标题栏的装饰性动画(流星位移和土星旋转)通过布尔状态变量与 animation 修饰器的配合实现,展示了 ArkTS 声明式动画的简洁范式。所有列表项的交互都采用"回调函数向上传递"的模式——子组件不直接修改全局状态,而是通过回调将操作意图传递给父组件,由父组件统一调度状态变更和弹窗显示。
从工程质量的维度来看,该应用体现了类型安全、防御性编程和可维护性三大原则。TypeScript 接口为所有数据模型提供了编译期类型检查,避免了运行时类型错误。工具函数的默认返回值确保了未识别输入时的安全降级。回调函数的默认空箭头函数防止了未传递回调时的调用错误。ForEach 的键值生成函数使用名称加索引的组合策略,确保了列表项的唯一标识。这些工程细节虽然微小,但共同构成了应用的健壮性基础。总体而言,该应用的架构设计在轻量级和功能完备之间取得了良好的平衡,为同类 HarmonyOS 应用提供了有价值的工程参考。
更多推荐




所有评论(0)