精酿啤酒社区与评测应用ArkTS 声明式 UI 架构与实现深度解析,由 aboutToAppear 中的定时器每 700 毫秒切换 bubble 状态值驱动,通过条件渲染实现气泡元素的显示与隐藏循环
一、技术背景与行业语境
声明式 UI(Declarative UI)是近年来前端与跨端开发领域的核心范式转变。与传统的命令式 UI 不同,声明式 UI 要求开发者描述界面"应该是什么样子",而非"如何一步步去构建它"。框架内部通过虚拟 DOM diff、状态依赖追踪、细粒度更新等机制,自动将状态变化映射到真实视图。这一思想最早在 React 中得到大规模工业化验证,随后被 Flutter、SwiftUI、Jetpack Compose 等框架广泛采纳。在鸿蒙(HarmonyOS)生态中,ArkUI 框架以 ArkTS 为开发语言,将声明式 UI 范式与 TypeScript 静态类型系统深度融合,为多设备、多形态的分布式应用提供了一套统一的 UI 描述能力。
ArkTS 是华为在 TypeScript 基础上扩展并约束而成的应用开发语言。它在保留 TypeScript 类型安全能力的同时,引入了 @Component、@Entry、@State、@Builder、@Observed 等装饰器语法,使开发者能以接近自然语言的方式描述组件结构、状态管理与数据流。相较于纯 TypeScript,ArkTS 对动态特性进行了严格限制(禁止 any 类型、限制 delete 操作等),这些约束从源码层面保证了编译期可进行更深层次的静态分析与优化,从而在移动端有限算力下实现更流畅的渲染表现。
鸿蒙生态的应用开发强调"一次开发,多端部署"的分布式理念。ArkUI 提供的布局组件(Row、Column、Stack、Scroll、List 等)天然适配不同屏幕尺寸与设备形态,状态管理机制(@State、@Prop、@Link、@Provide/@Consume、@Observed/@ObjectLink)则覆盖了从组件局部状态到跨组件树共享的全场景数据流需求。在精酿啤酒这类以内容浏览、社区互动、评分排行、地图导览为核心场景的应用中,页面往往需要同时承载大量列表数据、多维度筛选状态、模态弹框交互与动效反馈,对状态驱动的 UI 刷新效率提出了较高要求,ArkUI 的响应式机制正好契合这一需求。
@Observed 装饰器是 ArkUI 状态管理体系中处理"复杂对象深度变化"的关键机制。当一个类的实例被 @Observed 修饰后,框架会为其生成代理对象,使实例属性的赋值操作能被框架感知并触发 @ObjectLink 视图更新。在没有 @Observed 的情况下,对数组内对象属性的修改无法被 @State 直接观测到。在精酿啤酒评测社区场景中,评测列表的点赞数、内容文本、评分等字段频繁被用户编辑,@Observed 机制确保这些细粒度变更能精准驱动 UI 更新,避免全列表重渲染的性能损耗。
精酿啤酒行业本身具有显著的内容密集型与社区驱动特征。每一款啤酒都拥有风格、酒精度 ABV、苦度 IBU、产地、酒厂、评分、价格、标签等多维度属性;每一家精酿酒馆需要展示地理位置、距离、酒头数量、评分与特色标签;社区评测则包含用户头像、品鉴感受、配图、点赞与时间戳。这种"高维度数据 × 大量条目 × 频繁用户交互"的业务形态,对前端的数据建模、列表渲染性能、状态同步效率都构成工程挑战,也正是检验 ArkTS 声明式架构是否健壮的绝佳试金石。
二、项目概述与功能模块
本项目"酿造社"是一个面向精酿啤酒爱好者与微型酿造从业者的社区评测应用,采用 ArkTS 声明式 UI 架构实现。整体页面通过顶部固定头部、中部可滚动内容区、底部双排标签栏的经典三段式布局组织,共划分为七个功能 Tab,每个 Tab 拥有完全不同的布局风格与交互模式。以下逐一介绍各功能模块:
推荐模块(Tab 0):作为应用首页,采用"精选大卡 + 风格宫格 + 推荐列表"的复合布局。精选大卡以居中大图形式突出当日推荐酒款(罗斯福10号修道院啤酒),展示酒厂、产地、酒精度、IBU、评分与价格;风格宫格以 2×3 网格展示六大啤酒风格及各自酒款数量;推荐列表则横向排列五款本周精选酒款,每款包含图标、名称、风格标签、酒厂产地、评分、ABV、IBU 与购买入口。
酒款模块(Tab 1):以"卡片列表 + 筛选 chips"形式呈现完整酒款库。顶部为横向滚动的风格筛选条(全部、IPA、世涛、拉格、酸啤、小麦、波特),支持点击切换筛选条件;下方为酒款卡片流,每张卡片展示图标、价格、名称、风格标签、酒厂产地、评分、ABV、IBU、标签 chips,以及"购买"和"写评测"两个操作按钮。点击"写评测"会触发新增评测弹框,并预填当前酒款索引。
评测模块(Tab 2):以"评分分布图 + 评测信息流"为核心。顶部柱状图直观展示社区评分分布(5星68条、4星22条、3星8条、2星以下2条),柱高按比例缩放;中部为"热门/最新"切换 chips 与"写评测"入口;下方为评测卡片信息流,每条评测展示用户头像、昵称、品鉴酒款、时间、评分、品鉴正文、点赞数、配图数,以及编辑与删除操作。编辑与删除分别触发编辑弹框与删除确认弹框。
酒馆模块(Tab 3):以"酒馆列表 + 距离排序"形式展示附近精酿酒馆。顶部为"附近/评分最高/酒头最多"三种排序切换;下方为酒馆卡片流,每张卡片展示图标、距离、名称、评分、地址、酒头数量、特色标签,以及"导航"和"预约"两个操作按钮,模拟真实 O2O 场景下的到店引流闭环。
地图模块(Tab 4):以"附近酒馆列表 + 热力标记"形式呈现。顶部为模拟地图区域(以 emoji 地图图标占位),下方为热力排行榜,以奖牌图标(金、银、铜及数字排名)配合酒馆名称、评分、距离、酒头数量,按热度降序排列前五家酒馆。
排行模块(Tab 5):以"排行榜 + 趋势"形式呈现精酿啤酒社区排行。顶部为"周榜/月榜/总榜"三档切换;下方为前十名啤酒排行榜,每行展示奖牌图标、酒款名称、评分、产地,以及"试饮"操作入口,营造社区竞榜氛围。
我的模块(Tab 6):以"个人档案 + 品鉴笔记 + 设置"形式呈现用户中心。顶部为用户头像、昵称、品鉴等级、已品鉴酒款数与达人认证标识;中部为三宫格统计(已品鉴、评测数、收藏酒款);下方为品鉴笔记列表(取前四条评测摘要)与设置区(口味偏好、上新提醒、通用设置)。
弹框系统:应用内置三套模态弹框——新增评测弹框(含酒款名称输入、星级评分选择、品鉴感受多行输入、取消与发布按钮)、编辑弹框(含当前酒款提示、修改内容输入、取消与保存按钮)、删除确认弹框(含警示文案、取消与确认删除按钮)。所有弹框共用 modalOverlay 遮罩构建器,通过条件渲染与 zIndex 层叠管理实现弹出与关闭动画。
气泡特效层:在内容区顶部叠加一层气泡动画,由 aboutToAppear 中的定时器每 700 毫秒切换 bubble 状态值驱动,通过条件渲染实现气泡元素的显示与隐藏循环,模拟精酿啤酒"发酵冒泡"的视觉氛围。
三、架构设计与整体技术选型
本项目的架构设计遵循"数据模型层 — 静态常量层 — 辅助函数层 — 视图构建层 — 主入口构建层"五层分离原则。数据模型层由三个 @Observed 装饰的类(Beer、Review、PubItem)构成;静态常量层包含 COLORS、TAB_LIST、STYLE_LIST、BEER_INIT、REVIEW_INIT、PUB_INIT、RATING_DIST、BEER_RANK/RANK_SCORES 等;辅助函数层包含 styleColor、ratingBarH、rankMedal、rankColor 四个纯函数,负责将业务数据映射为视觉属性;视图构建层由 Page824 内的多个 @Builder 方法构成;主入口构建层即 build() 方法,负责整体布局编排与条件渲染。
在状态管理选型上,本项目综合运用了 @State 与 @Observed 两套机制。@State 用于管理组件内部的简单状态(tabIdx、bubble、弹框开关、表单字段、styleFilter)以及数组状态(beerList、reviewList、pubList);@Observed 用于让数组元素(Beer、Review、PubItem 实例)的属性变更可被框架观测。虽然在当前实现中删除评测通过 splice 整体触发数组变更,但 @Observed 的存在为未来扩展(如直接修改某条评测的点赞数)预留了细粒度更新能力。
在布局技术选型上,项目大量使用 Row、Column、Stack、Scroll 四种基础布局组件。layoutWeight 属性被广泛用于实现弹性宽度分配,padding、borderRadius、backgroundColor 三件套统一了卡片式视觉风格。ForEach 是列表渲染的唯一手段,通过键值绑定实现高效 diff。position 与 zIndex 用于特效层与弹框层的绝对定位与层叠管理,constraintSize 用于约束弹框最大高度。
四、逐段代码深度分析
4.1 配色接口与色板常量定义

interface ColorPalette {
bg: string;
card: string;
dark: string;
darkL: string;
title: string;
sub: string;
text3: string;
gold: string;
goldD: string;
amber: string;
brown: string;
brownD: string;
orange: string;
red: string;
green: string;
blue: string;
purple: string;
line: string;
tabOn: string;
mask: string;
chip: string;
}
const COLORS: ColorPalette = {
bg: '#F8F1E4',
card: '#FFFFFF',
dark: '#EDE3D0',
darkL: '#DDD0BA',
title: '#3D2817',
sub: '#7A5C42',
text3: '#A89078',
gold: '#D4A017',
goldD: '#B07C12',
amber: '#C77D2E',
brown: '#6B4226',
brownD: '#4A2E18',
orange: '#E8722C',
red: '#D04545',
green: '#4A9E6C',
blue: '#3D7DC8',
purple: '#8A5FC0',
line: '#DDD0BA',
tabOn: '#C77D2E',
mask: 'rgba(61,40,23,0.55)',
chip: 'rgba(199,125,46,0.12)'
};
配色体系是整个应用视觉风格统一性的根基。本项目首先定义了一个 ColorPalette 接口,以强类型方式约束配色对象的字段结构,确保所有颜色字段名在编译期即可被 TypeScript 检查器校验,避免拼写错误或遗漏字段。接口共声明了 21 个颜色字段,涵盖背景色(bg)、卡片色(card)、文字层级色(title、sub、text3 三级文字对比度)、主题色系(gold、amber、brown 等)、功能色(red、green、blue、purple)、辅助色(line、tabOn、mask、chip)。
COLORS 常量严格实现了 ColorPalette 接口,将麦芽金棕色调作为应用主基调:背景色 #F8F1E4 呈现温暖的奶油麦芽色,卡片色 #FFFFFF 保证内容区的纯净对比,标题色 #3D2817 是深邃的咖啡棕,琥珀金 #D4A017 用于评分与高亮,琥珀橙 #C77D2E 则是交互色与 Tab 选中色。遮罩色与 chip 底色都采用 RGBA 而非 HEX 格式,因为需要控制透明度以实现层叠视觉效果。将所有颜色集中管理而非散落在各组件内部,是典型的设计令牌(Design Token)思想实践,便于后续主题切换与设计系统维护。
4.2 标签元数据与标签列表

interface TabMeta {
icon: string;
label: string;
}
const TAB_LIST: TabMeta[] = [
{ icon: '🍺', label: '推荐' },
{ icon: '🍾', label: '酒款' },
{ icon: '✍️', label: '评测' },
{ icon: '🏪', label: '酒馆' },
{ icon: '📍', label: '地图' },
{ icon: '🏆', label: '排行' },
{ icon: '👤', label: '我的' }
];
标签栏是应用导航的中枢。TabMeta 接口定义了每个标签所需的两个字段:icon(emoji 图标字符串)与 label(中文标签文本)。使用 emoji 作为图标而非图片资源或字体图标,是一种轻量化的选择——无需引入额外的资源文件,直接利用 Unicode 字符的跨平台渲染能力。
TAB_LIST 数组按推荐、酒款、评测、酒馆、地图、排行、我的顺序定义了七个标签。这一顺序遵循了"内容消费优先、社区互动次之、工具服务在后、个人中心收尾"的典型移动应用信息架构原则。推荐作为首页承接内容发现需求,酒款与评测构成核心内容消费场景,酒馆与地图提供线下到店服务能力,排行强化社区竞榜氛围,我的收束用户个人数据。在底部标签栏中,这七个标签会被拆分为两排(4+3),前四个为高频入口,后三个为次级功能入口。
4.3 风格元数据与风格列表

interface StyleMeta {
name: string;
icon: string;
color: string;
count: number;
}
const STYLE_LIST: StyleMeta[] = [
{ name: 'IPA', icon: '🌿', color: COLORS.green, count: 86 },
{ name: '世涛', icon: '🌑', color: COLORS.brownD, count: 42 },
{ name: '拉格', icon: '🌾', color: COLORS.gold, count: 68 },
{ name: '酸啤', icon: '🍋', color: COLORS.amber, count: 35 },
{ name: '小麦', icon: '🌽', color: COLORS.goldD, count: 52 },
{ name: '波特', icon: '☕', color: COLORS.brown, count: 28 }
];
啤酒风格是精酿啤酒分类体系的核心维度。StyleMeta 接口定义了每个风格所需的四个字段:name(风格中文名)、icon(代表 emoji)、color(该风格对应的主题色,引用自 COLORS)、count(该风格下的酒款数量)。将颜色直接绑定到风格元数据上,使得多处视觉元素可以共享同一颜色映射,保证风格色彩在整个应用中的一致性。
STYLE_LIST 涵盖了六大经典精酿啤酒风格:IPA(绿色代表酒花草本气息)、世涛(深棕代表焦香)、拉格(金色代表清爽)、酸啤(琥珀橙代表果酸)、小麦(深金代表谷物质感)、波特(棕色代表咖啡巧克力风味)。每种风格的 count 数值反映了该风格在社区酒款库中的占比分布,IPA 以 86 款居首符合精酿社区对酒花型啤酒的偏好趋势。这一列表在推荐页的风格宫格、酒款页的筛选 chips 两处被复用,体现了数据定义与视图消费的解耦设计。
4.4 Beer 数据模型类
@Observed export class Beer {
id: number;
name: string;
brewery: string;
style: string;
abv: string;
ibu: number;
rating: number;
price: string;
icon: string;
origin: string;
tags: string[];
constructor(id: number, name: string, brewery: string, style: string, abv: string, ibu: number, rating: number, price: string, icon: string, origin: string, tags: string[]) {
this.id = id; this.name = name; this.brewery = brewery; this.style = style;
this.abv = abv; this.ibu = ibu; this.rating = rating; this.price = price;
this.icon = icon; this.origin = origin; this.tags = tags;
}
}
Beer 类是精酿啤酒业务实体的数据模型,被 @Observed 装饰器修饰并 export 导出,可被其他模块导入复用。@Observed 为该类的实例创建代理,使框架能够追踪实例属性变更,配合 @ObjectLink 使用时属性赋值会触发绑定视图的局部刷新。类中定义了 11 个属性:id(唯一标识)、name(酒款名称)、brewery(酒厂)、style(风格)、abv(酒精度,字符串)、ibu(国际苦度值,数值)、rating(评分,数值)、price(价格,字符串)、icon(emoji 图标)、origin(产地)、tags(标签字符串数组)。
构造函数采用全参数形式,在函数体内通过分号分隔的连续赋值语句完成初始化。这种紧凑写法在 ArkTS 中是常见模式——由于 ArkTS 不支持参数属性语法糖,必须显式声明属性并在构造函数中赋值。abv 与 price 选择字符串而非数值类型,是因为它们在 UI 中需要直接拼接百分号或货币符号展示;ibu 与 rating 选择数值类型则是因为它们需要参与数值计算(如评分分布图的高度换算)。这种按使用场景选择数据类型的做法,是数据建模阶段的常见工程权衡。
4.5 Review 数据模型类
@Observed export class Review {
id: number;
user: string;
avatar: string;
beerName: string;
rating: number;
content: string;
time: string;
likes: number;
photos: number;
constructor(id: number, user: string, avatar: string, beerName: string, rating: number, content: string, time: string, likes: number, photos: number) {
this.id = id; this.user = user; this.avatar = avatar; this.beerName = beerName;
this.rating = rating; this.content = content; this.time = time;
this.likes = likes; this.photos = photos;
}
}
Review 类是用户评测业务实体的数据模型,同样被 @Observed 修饰并导出。它定义了 9 个属性:id(评测唯一标识)、user(用户昵称)、avatar(头像 emoji)、beerName(被评测的酒款名称)、rating(评分,整数 1-5)、content(品鉴感受正文)、time(发布时间,字符串如 '2小时前')、likes(点赞数)、photos(配图数量)。
评测类的设计体现了社区内容实体的典型特征:既有用户维度信息(user、avatar),又有关联业务对象信息(beerName),还有内容正文(content)与社交互动指标(likes、photos)。beerName 以字符串形式存储而非引用 Beer 实例或 beerId,这是一种反范式设计——直接冗余存储酒款名称避免了每次渲染时的关联查询,提升了列表渲染性能。time 字段以"2小时前"这种相对时间字符串存储而非时间戳,使前端无需引入时间格式化库即可直接展示。
4.6 PubItem 数据模型类
@Observed export class PubItem {
id: number;
name: string;
address: string;
distance: string;
rating: number;
taps: number;
icon: string;
tags: string[];
constructor(id: number, name: string, address: string, distance: string, rating: number, taps: number, icon: string, tags: string[]) {
this.id = id; this.name = name; this.address = address; this.distance = distance;
this.rating = rating; this.taps = taps; this.icon = icon; this.tags = tags;
}
}
PubItem 类是精酿酒馆业务实体的数据模型,第三个 @Observed 修饰并导出的类。它定义了 8 个属性:id(酒馆唯一标识)、name(酒馆名称)、address(地址,如"朝阳区三里屯路19号")、distance(距离,字符串如 '320m'、'1.2km')、rating(评分,浮点数如 4.8)、taps(酒头数量,整数)、icon(emoji 图标)、tags(特色标签数组,如精酿吧、现打、氛围好)。
distance 选择字符串而非数值类型,是因为距离在不同量级下需要不同的单位(米或千米),以字符串预格式化存储避免了单位转换逻辑。taps(酒头数量)是精酿酒吧场景下的特色字段——酒头数量直接反映了酒馆的精酿品类丰富度,是精酿爱好者选择酒馆的重要决策指标。rating 为数值类型以支持排序与格式化展示(toFixed(1))。三个 @Observed 类共同构成了应用的领域模型层,覆盖了啤酒、评测、酒馆三大核心业务对象。
4.7 啤酒初始数据集
const BEER_INIT: Beer[] = [
new Beer(1, '朋克IPA', 'BrewDog', 'IPA', '5.6%', 45, 4.6, '¥28', '🌿', '苏格兰', ['花香', '柑橘', '经典']),
new Beer(2, '左手牛奶世涛', 'Left Hand', '世涛', '6.0%', 25, 4.8, '¥38', '🌑', '美国', ['咖啡', '巧克力', '丝滑']),
new Beer(3, '鹅岛IPA', 'Goose Island', 'IPA', '5.9%', 55, 4.5, '¥25', '🌿', '美国', ['柑橘', '松针', '入门']),
new Beer(4, '林德曼樱桃', 'Lindemans', '酸啤', '3.5%', 14, 4.7, '¥45', '🍒', '比利时', ['果味', '酸甜', '女性友好']),
new Beer(5, '福佳白', 'Hoegaarden', '小麦', '4.9%', 10, 4.4, '¥18', '🌽', '比利时', ['芫荽', '橙皮', '清爽']),
new Beer(6, '健力士', 'Guinness', '世涛', '4.2%', 40, 4.5, '¥22', '🌑', '爱尔兰', ['焦香', '奶油', '经典']),
new Beer(7, '角鲨头90分钟', 'Dogfish Head', 'IPA', '9.0%', 90, 4.9, '¥58', '🌿', '美国', ['烈性', '琥珀', '进阶']),
new Beer(8, '青岛经典', '青岛啤酒', '拉格', '4.3%', 12, 4.2, '¥8', '🌾', '中国', ['清爽', '国产', '百搭']),
new Beer(9, '罗斯福10号', 'Rochefort', '修道院', '11.3%', 27, 4.9, '¥68', '⛪', '比利时', ['烈性', '果香', '收藏']),
new Beer(10, '布雷特野菌', 'Cascade', '酸啤', '6.5%', 18, 4.6, '¥52', '🍋', '美国', ['野菌', '酸爽', '小众']),
// ... 其余5款酒款数据结构相同(熊猫精酿蜂蜜、巨象花生酱、布鲁克林拉格、海底捞酸啤、鹅岛波本世涛)
];
BEER_INIT 是啤酒列表的初始数据源,包含 15 款覆盖全球主要产酒国与经典风格的精酿啤酒。每条数据通过 new Beer(...) 构造函数实例化,参数顺序严格对应 Beer 类构造函数的签名。数据集的设计兼顾了风格多样性(IPA、世涛、拉格、酸啤、小麦、波特、修道院七大风格均有覆盖)、产地多样性(苏格兰、美国、比利时、爱尔兰、中国五国产地)、价位梯度(从 ¥8 的青岛经典到 ¥68 的罗斯福10号)、评分梯度(4.1 到 4.9)与标签丰富度。
从数据质量角度审视,这份数据集具有较高的真实性与专业性:朋克IPA 来自苏格兰 BrewDog 是入门精酿的标志性产品;罗斯福10号属于比利时修道院啤酒(Trappist)体系,11.3% 的高酒精度与 27 IBU 的低苦度形成了修道院啤酒典型的"高醇低苦"特征;角鲨头90分钟以 90 IBU 的高苦度著称;鹅岛波本世涛则体现了"过桶"(barrel-aged)工艺。这些数据不仅是 UI 展示的素材,更构成了精酿啤酒知识库的雏形。BEER_INIT 在推荐页的推荐列表(取前五款)与酒款页的完整列表两处被引用,同时也是 beerList 状态的初始值来源。
4.8 评测初始数据集
const REVIEW_INIT: Review[] = [
new Review(1, '精酿老饕', '🍺', '罗斯福10号', 5, '11.3%的修道院啤酒,入口果香浓郁,中段焦糖甜味,收口微苦回甘。层次感极强,不愧是修道院啤酒的巅峰之作。', '2小时前', 328, 3),
new Review(2, '微醺少女', '🌸', '林德曼樱桃', 5, '朋友推荐的入门酸啤,樱桃味超级浓郁!酸甜平衡得很好,完全喝不出3.5%的酒味。女生友好度满分。', '5小时前', 256, 2),
new Review(3, '啤酒猎人', '🎯', '角鲨头90分钟', 5, '9度的IPA,持续90分钟的酒花投放,入口就是爆炸性的柑橘和松针香。IBU 90但完全不觉得苦,因为麦芽甜味平衡得刚刚好。', '昨天', 189, 4),
new Review(4, '夜饮诗人', '🌙', '健力士', 4, '经典的氮气世涛,倒出来那一下的瀑布效果太治愈了。咖啡焦香浓郁,口感丝滑如奶油。不过4.2%略淡了些。', '昨天', 142, 1),
new Review(5, '酒杯观察者', '🔬', '左手牛奶世涛', 5, '倒进杯子里就像一杯液态巧克力蛋糕。咖啡、巧克力、焦糖三层香气依次展开。6%酒精度恰到好处,冬天喝一杯太幸福了。', '2天前', 98, 2),
// ... 其余5条评测数据结构相同(啤酒新手村、品鉴大师、深夜酒客、精酿探险家、快乐饮酒人)
];
REVIEW_INIT 是评测列表的初始数据源,包含 10 条由不同用户发布的精酿啤酒品鉴评测。每条评测通过 new Review(...) 构造函数实例化,涵盖用户昵称、emoji 头像、品鉴酒款、评分、品鉴正文、发布时间、点赞数与配图数量。数据集的设计在用户维度上展现了精酿社区的多样性——从"精酿老饕"这类资深品鉴者到"微醺少女"这类入门女性用户,从"啤酒猎人"这类探索型用户到"夜饮诗人"这类文艺型用户,构建了一个有温度的社区画像。
评测正文的专业性与可读性是这份数据集的亮点。每条评测都围绕具体的感官维度展开描述:罗斯福10号评测描述了"入口果香浓郁,中段焦糖甜味,收口微苦回甘"的三段式品鉴层次;角鲨头90分钟评测解释了"90分钟酒花投放"工艺与麦芽平衡原理;鹅岛波本世涛评测点明了"波本桶陈酿"工艺与风味叠加。这些正文不仅是 UI 展示内容,更构成了精酿啤酒品鉴知识的传播载体。点赞数从 328 递减至 38,模拟了社区热度衰减规律。REVIEW_INIT 在评测页的评测信息流与我的页的品鉴笔记列表两处被引用,同时也是 reviewList 状态的初始值来源,删除评测操作即针对此数组执行 splice。
4.9 酒馆初始数据集与排行常量
const PUB_INIT: PubItem[] = [
new PubItem(1, '大跃精酿啤酒', '朝阳区三里屯路19号', '320m', 4.8, 24, '🍺', ['精酿吧', '现打', '氛围好']),
new PubItem(2, '拳击猫精酿', '静安区南京西路1788号', '580m', 4.7, 18, '🥊', ['精酿吧', '餐配', '人气高']),
new PubItem(3, '鹅岛鲜吧', '黄浦区中山东一路18号', '1.2km', 4.6, 12, '🦢', ['品牌店', '江景', '约会']),
new PubItem(4, '深夜酒馆', '徐汇区永康路52号', '850m', 4.9, 30, '🌙', ['小众', '隐藏款', '老饕推荐']),
new PubItem(5, '啤酒研究所', '海淀区中关村大街27号', '2.3km', 4.5, 16, '🔬', ['科主题', '学生党', '平价']),
// ... 其余3家酒馆数据结构相同(精酿实验室、麦芽角落、泡沫共和国)
];
const RATING_DIST: number[] = [68, 22, 8, 2];
const RATING_LABELS: string[] = ['5星', '4星', '3星', '2星以下'];
const MAX_RATING: number = 68;
const BEER_RANK: string[] = [
'罗斯福10号', '角鲨头90分钟', '左手牛奶世涛', '鹅岛波本世涛',
'林德曼樱桃', '朋克IPA', '健力士', '巨象花生酱',
'布雷特野菌', '鹅岛IPA'
];
const RANK_SCORES: number[] = [4.9, 4.9, 4.8, 4.8, 4.7, 4.6, 4.5, 4.7, 4.6, 4.5];
PUB_INIT 是酒馆列表的初始数据源,包含 8 家位于北京与上海核心商圈的精酿酒馆。每家酒馆数据涵盖名称、详细地址(精确到门牌号)、距离(从 320m 到 3.1km)、评分(4.4-4.9)、酒头数量(10-30)、emoji 图标与特色标签。酒馆名称的设计兼顾了品牌店(鹅岛鲜吧)、独立精酿吧(大跃精酿、拳击猫精酿)、主题酒馆(啤酒研究所)等多种业态。地址的选择具有地域真实感——三里屯、南京西路、外滩等都是京沪两地的实际商业地标,使应用的数据具有强烈的在地性。
RATING_DIST 与 RATING_LABELS 是评测页评分分布图的数据源,分别定义了四个评分区间的条目数量与标签文本。[68, 22, 8, 2] 的分布反映了精酿社区评测的高评分偏好——5星评测占 68% 远超其他区间,这与精酿啤酒爱好者群体"自我选择偏差"的现实分布相符。MAX_RATING 被提取为常量(值为 68),作为评分分布图柱高计算的基准值。BEER_RANK 与 RANK_SCORES 是排行页数据源,按评分降序排列前十名酒款及其评分,其中罗斯福10号与角鲨头90分钟并列第一(4.9分)。
4.10 辅助函数:风格颜色映射
function styleColor(s: string): string {
if (s === 'IPA') { return COLORS.green; }
if (s === '世涛') { return COLORS.brownD; }
if (s === '拉格') { return COLORS.gold; }
if (s === '酸啤') { return COLORS.amber; }
if (s === '小麦') { return COLORS.goldD; }
if (s === '波特') { return COLORS.brown; }
return COLORS.purple;
}
styleColor 是一个纯函数,接收风格名称字符串,返回对应的主题色字符串。函数体采用连续的 if 语句而非 switch-case 或对象映射表实现映射。连续 if 的写法虽然冗长,但在编译期可被优化为跳转表,运行时性能稳定。
映射逻辑与 STYLE_LIST 中的颜色定义保持一致:IPA 对应绿色、世涛对应深棕、拉格对应金色、酸啤对应琥珀橙、小麦对应深金、波特对应棕色。当传入的风格名称不匹配任何已知风格时(如"修道院"),函数返回 COLORS.purple(紫色)作为兜底色——紫色与修道院的宗教意象相呼应。styleColor 在推荐页与酒款页的风格标签两处被调用,是风格色彩一致性的核心保障。
4.11 辅助函数:评分柱高、奖牌图标与排名颜色
function ratingBarH(v: number): number {
return Math.round(v / MAX_RATING * 60);
}
function rankMedal(i: number): string {
if (i === 0) { return '🥇'; }
if (i === 1) { return '🥈'; }
if (i === 2) { return '🥉'; }
return (i + 1).toString();
}
function rankColor(i: number): string {
if (i === 0) { return COLORS.gold; }
if (i === 1) { return COLORS.sub; }
if (i === 2) { return COLORS.amber; }
return COLORS.text3;
}
这三个辅助函数分别服务于评分分布图与排行榜的视觉渲染。ratingBarH 接收评分数量 v,返回柱状图的像素高度。计算公式为 v / MAX_RATING * 60,即以 MAX_RATING(68,5星评测数)作为满刻度基准,映射到 60 像素的最大柱高。Math.round 确保返回整数,避免亚像素渲染导致的边缘模糊。5 星评测的柱高为 60px(满高),4 星约 19px,3 星约 7px,2 星以下约 2px,形成了显著的视觉落差,直观传达了评分分布的偏态特征。
rankMedal 接收排名索引 i(从 0 开始),返回对应的奖牌 emoji 或数字字符串。前三名分别返回金、银、铜牌 emoji,第四名及以后返回 (i+1).toString() 数字排名文本。rankColor 返回对应的主题色:第一名金色、第二名副标题棕色、第三名琥珀橙、第四名及以后辅助文字浅棕色。这两个函数共同构成了排行榜的视觉差异化体系——前三名通过奖牌 emoji 与高饱和度色彩获得视觉突出,四名以后以中性色弱化处理,形成"头部突出、尾部收敛"的视觉层次。
4.12 主组件入口与状态声明
@Entry
@Component
struct Page824 {
@State tabIdx: number = 0;
@State bubble: boolean = false;
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = 0;
@State addBeer: string = '';
@State addRating: number = 5;
@State addContent: string = '';
@State editContent: string = '';
@State styleFilter: string = '全部';
@State beerList: Beer[] = BEER_INIT;
@State reviewList: Review[] = REVIEW_INIT;
@State pubList: PubItem[] = PUB_INIT;
private timer: number = -1;
Page824 是应用的入口组件,被 @Entry 与 @Component 双重装饰器修饰。@Entry 标识该组件为页面入口,编译器会为其生成页面注册与生命周期管理的胶水代码;@Component 声明该结构体为 ArkUI 组件,使其内部可使用 @State、@Builder、build() 等组件级语法。组件内部通过 @State 声明了 13 个响应式状态变量,覆盖了应用运行期的全部可变数据。
状态变量按功能可分为四组:导航状态(tabIdx)、特效状态(bubble)、弹框状态(addModal/editModal/delModal 三个布尔值与 editIdx 索引)、表单状态(addBeer/addRating/addContent/editContent)、筛选状态(styleFilter)、列表数据状态(beerList/reviewList/pubList 分别初始化为 BEER_INIT/REVIEW_INIT/PUB_INIT)。timer 被声明为 private 而非 @State,因为它是定时器 ID,不需要驱动 UI 更新,仅用于组件销毁时清理定时器避免内存泄漏。这一状态声明体系是整个应用交互逻辑的数据基础,任何用户操作都最终映射为这些状态变量的变更,进而触发 ArkUI 的响应式 UI 刷新。
4.13 生命周期函数
aboutToAppear(): void {
this.timer = setInterval(() => { this.bubble = !this.bubble; }, 700);
}
aboutToDisappear(): void {
if (this.timer > 0) { clearInterval(this.timer); }
}
aboutToAppear 与 aboutToDisappear 是 ArkUI 组件生命周期的两个关键钩子。aboutToAppear 在组件创建后、build() 执行前被调用,适合执行数据初始化、定时器注册等副作用操作;aboutToDisappear 在组件销毁前被调用,适合执行资源清理、定时器注销等收尾操作。
在本项目中,aboutToAppear 内通过 setInterval 注册了一个每 700 毫秒执行一次的定时器,回调函数执行 this.bubble = !this.bubble,对 bubble 布尔状态取反。由于 bubble 是 @State 变量,每次取反都会触发 ArkUI 的依赖追踪机制,重新评估 build() 中依赖 bubble 的条件渲染分支,从而实现气泡元素的周期性显示与隐藏。700 毫秒的间隔经过调优——过短会导致闪烁感影响阅读,过长则失去动态氛围感。
aboutToDisappear 内检查 this.timer > 0,若为真则调用 clearInterval 注销定时器。这一清理操作至关重要——若组件销毁后定时器仍在运行,回调函数会持续尝试修改已销毁组件的状态,导致内存泄漏或运行时异常。aboutToAppear/aboutToDisappear 的配对使用是 ArkUI 资源管理的标准范式。
4.14 头部构建器
@Builder
header() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🍺').fontSize(16)
Text('酿造社').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('🔍').fontSize(16)
Text('👤').fontSize(16)
}
.width('100%')
// 搜索栏
Row({ space: 8 }) {
TextInput({ placeholder: '搜索精酿啤酒、酒厂...' })
.fontSize(11).layoutWeight(1).backgroundColor(COLORS.card)
.borderRadius(20).padding({ left: 12, right: 12, top: 6, bottom: 6 })
Column() {
Text('🔍').fontSize(14)
}
.width(32).height(32).borderRadius(16)
.backgroundColor(COLORS.amber)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
.width('100%')
// 评分横条(三列数据 + 分割线 + 引导入口)
Row({ space: 10 }) {
Column({ space: 1 }) {
Text('1,280').fontSize(16).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('酒款').fontSize(8).fontColor(COLORS.sub)
}
Column().width(1).height(22).backgroundColor(COLORS.line)
Column({ space: 1 }) {
Text('4.6').fontSize(16).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
Text('平均评分').fontSize(8).fontColor(COLORS.sub)
}
Column().width(1).height(22).backgroundColor(COLORS.line)
Column({ space: 1 }) {
Text('86').fontSize(16).fontColor(COLORS.brown).fontWeight(FontWeight.Bold)
Text('酒馆').fontSize(8).fontColor(COLORS.sub)
}
Column().layoutWeight(1)
Text('本周新品 3 ›').fontSize(9).fontColor(COLORS.amber)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
}
.width('100%').padding({ left: 14, right: 14, top: 10, bottom: 8 })
.backgroundColor(COLORS.bg)
}
header 是一个 @Builder 装饰的构建器方法,负责渲染应用顶部固定头部区域。@Builder 是 ArkUI 中用于封装可复用 UI 片段的语法——与独立组件不同,Builder 方法不拥有独立的状态,而是直接访问所属组件的 this 上下文,因此可以读取 @State 变量并触发响应式刷新。头部区域整体采用 Column 垂直布局,space: 10 控制子元素间的垂直间距,包含三行内容:品牌行、搜索栏、评分横条。
品牌行使用 Row 水平布局,左侧是啤酒 emoji 与"酿造社"品牌名称(粗体深棕色),中间通过 Column().layoutWeight(1) 实现弹性占位将右侧元素推至行尾。搜索栏同样使用 Row,左侧是 TextInput 输入框(圆角 20,白色背景),右侧是 32×32 的圆形搜索按钮(琥珀橙背景)。评分横条是头部信息密度最高的区域,使用 Row 横向排列三个数据列(酒款数 1,280、平均评分 4.6、酒馆数 86),每列由大号数字(16px 粗体,分别使用金、琥珀、棕三色区分)与小号标签(8px 副标题色)组成,列间以分割线隔开。整个头部以麦芽金棕背景色为底,卡片区域使用纯白背景与圆角,形成了层次分明的视觉结构。
4.15 底部标签栏构建器
@Builder
tabBar() {
Column({ space: 0 }) {
// 第一排:推荐、酒款、评测、酒馆
Row() {
ForEach([0, 1, 2, 3], (i: number) => {
Column({ space: 2 }) {
Text(TAB_LIST[i].icon).fontSize(18).opacity(this.tabIdx === i ? 1.0 : 0.4)
Text(TAB_LIST[i].label).fontSize(9)
.fontColor(this.tabIdx === i ? COLORS.tabOn : COLORS.sub)
.fontWeight(this.tabIdx === i ? FontWeight.Bold : FontWeight.Normal)
if (this.tabIdx === i) {
Column().width(16).height(2).backgroundColor(COLORS.tabOn).borderRadius(1)
}
}
.layoutWeight(1).padding({ top: 4, bottom: 4 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.tabIdx = i; })
})
}
.width('100%')
// 第二排:地图、排行、我的 + 弹性占位(结构同上,遍历 [4,5,6])
Row() {
ForEach([4, 5, 6], (i: number) => {
Column({ space: 2 }) {
Text(TAB_LIST[i].icon).fontSize(18).opacity(this.tabIdx === i ? 1.0 : 0.4)
Text(TAB_LIST[i].label).fontSize(9)
.fontColor(this.tabIdx === i ? COLORS.tabOn : COLORS.sub)
.fontWeight(this.tabIdx === i ? FontWeight.Bold : FontWeight.Normal)
if (this.tabIdx === i) {
Column().width(16).height(2).backgroundColor(COLORS.tabOn).borderRadius(1)
}
}
.layoutWeight(1).padding({ top: 4, bottom: 4 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.tabIdx = i; })
})
Column().layoutWeight(1)
}
.width('100%')
}
.width('100%').backgroundColor(COLORS.card)
.border({ width: { top: 1 }, color: COLORS.line })
}
tabBar 构建器渲染底部的双排标签栏。七个标签被拆分为两排:第一排四个(推荐、酒款、评测、酒馆),第二排三个加一个弹性占位(地图、排行、我的 + Column().layoutWeight(1))。第二排末尾的弹性占位确保了三个标签均匀分布在前 3/4 的宽度内,与第一排四个标签的宽度比例保持视觉协调。这种 4+3 的双排设计在移动端七 Tab 场景中是较为少见的布局选择,但优势在于所有标签一次可见、无需二次操作即可触达。
每个标签项通过 Column 垂直排列图标与文本,图标 18px 字号,文本 9px 字号。选中态与未选中态的视觉差异通过三个维度体现:图标透明度(选中 1.0,未选中 0.4)、文本颜色(选中琥珀橙,未选中棕色)、文本字重(选中 Bold,未选中 Normal)。选中态额外渲染一条 16×2 像素的底部指示条。点击事件 onClick 内执行 this.tabIdx = i,由于 tabIdx 是 @State 变量,赋值后立即触发整个 build() 方法的重新评估,使内容区的条件渲染分支切换到对应 Tab 的 Builder。这一过程是 ArkUI 响应式机制的核心体现——状态变更自动驱动 UI 更新,开发者无需手动操作 DOM。
4.16 模态遮罩构建器
@Builder
modalOverlay(onClose: () => void) {
Column().width('100%').height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
modalOverlay 是一个接收回调函数参数的通用模态遮罩构建器。它渲染一个铺满父容器的 Column,背景色为 COLORS.mask(rgba(61,40,23,0.55),即半透明深棕色),点击时执行传入的 onClose 回调。这一构建器是三种弹框(新增、编辑、删除)共用的遮罩层,通过参数化回调实现了关闭逻辑的解耦——每个弹框传入自己的关闭函数(如 () => { this.addModal = false; }),无需在遮罩内部硬编码具体的关闭逻辑。
这种"构建器 + 回调参数"的模式是 ArkUI 中实现 UI 片段复用的惯用手法。与独立子组件相比,Builder 方法直接在父组件的渲染上下文中执行,可以无缝访问 this 上的状态与方法,无需通过 @Prop/@Link 进行父子组件间的数据传递。遮罩层使用半透明背景,既遮挡了底层内容区的视觉干扰,又保留了底层内容的轮廓可辨识度。
4.17 推荐页构建器
@Builder
recommendTab() {
Column({ space: 12 }) {
// 精选大卡
Column({ space: 8 }) {
Text('⛪').fontSize(36)
Text('罗斯福10号').fontSize(16).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('Rochefort · 比利时修道院 · 11.3% ABV').fontSize(10).fontColor(COLORS.sub)
Row({ space: 8 }) {
Text('⭐ 4.9').fontSize(11).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('IBU 27').fontSize(10).fontColor(COLORS.sub)
Text('¥68').fontSize(12).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
}
Column() {
Text('查看详情').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.padding({ left: 20, right: 20, top: 6, bottom: 6 }).borderRadius(14)
.backgroundColor(COLORS.amber)
}
.width('100%').padding(20).borderRadius(16)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Text('🍺 按风格浏览').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 风格宫格 2x3(三行结构相同,分别遍历 [0,1]、[2,3]、[4,5])
Row({ space: 10 }) {
ForEach([0, 1], (i: number) => {
Column({ space: 4 }) {
Text(STYLE_LIST[i].icon).fontSize(24)
Text(STYLE_LIST[i].name).fontSize(11).fontColor(COLORS.title)
Text(STYLE_LIST[i].count + '款').fontSize(8).fontColor(STYLE_LIST[i].color)
}
.layoutWeight(1).padding(12).borderRadius(12)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
})
}
.width('100%')
// ... 第二行遍历 [2,3]、第三行遍历 [4,5],结构完全一致
Text('🔥 本周推荐酒款').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
ForEach([0, 1, 2, 3, 4], (i: number) => {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(BEER_INIT[i].icon).fontSize(26)
}
.width(40).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Row({ space: 4 }) {
Text(BEER_INIT[i].name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text(BEER_INIT[i].style).fontSize(8).fontColor(styleColor(BEER_INIT[i].style))
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
}
Text(BEER_INIT[i].brewery + ' · ' + BEER_INIT[i].origin).fontSize(9).fontColor(COLORS.sub)
Row({ space: 6 }) {
Text('⭐ ' + BEER_INIT[i].rating.toFixed(1)).fontSize(9).fontColor(COLORS.gold)
Text(BEER_INIT[i].abv + ' ABV').fontSize(9).fontColor(COLORS.sub)
Text('IBU ' + BEER_INIT[i].ibu).fontSize(9).fontColor(COLORS.sub)
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column({ space: 3 }) {
Text(BEER_INIT[i].price).fontSize(12).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
Column() { Text('购买').fontSize(9).fontColor('#FFFFFF') }
.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(6)
.backgroundColor(COLORS.amber)
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
recommendTab 是推荐页的构建器,整体采用 Column 垂直布局,space: 12 控制各区块间距,包含四大区块:精选大卡、风格宫格、推荐列表标题与卡片流。精选大卡以居中对齐的 Column 呈现,顶部是 36px 字号的教堂 emoji(呼应罗斯福10号的修道院身份),下方依次是酒款名称(16px 粗体)、酒厂·产地·酒精度副信息、评分/IBU/价格三列数据行,底部是琥珀橙的"查看详情"按钮。大卡使用 20px 内边距与 16px 圆角的白色卡片样式,是整个推荐页的视觉焦点。
风格宫格通过三个 Row 各包含两个 ForEach 渲染的宫格项实现 2×3 布局。每个宫格项是一个 Column,包含风格 emoji(24px)、风格名称(11px 标题色)、酒款数量(8px,颜色取自 STYLE_LIST[i].color 即风格专属色)。三行 Row 分别遍历索引 [0,1]、[2,3]、[4,5],对应 STYLE_LIST 的六个风格。推荐列表通过 ForEach 遍历索引 [0,1,2,3,4] 渲染前五款啤酒的横向卡片,每张卡片左侧是图标列,中间是弹性宽度的信息列(名称+风格标签、酒厂·产地、评分·ABV·IBU),右侧是价格与购买按钮。styleColor 函数在此处被调用为风格标签着色,toFixed(1) 将评分格式化为一位小数。整个推荐页从大卡到宫格再到列表,形成了"焦点-导航-浏览"的递进式内容发现路径。
4.18 酒款页构建器
@Builder
beerTab() {
Column({ space: 8 }) {
// 筛选chips
Scroll() {
Row({ space: 6 }) {
Text('全部').fontSize(10)
.fontColor(this.styleFilter === '全部' ? '#FFFFFF' : COLORS.sub)
.backgroundColor(this.styleFilter === '全部' ? COLORS.amber : COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
.onClick(() => { this.styleFilter = '全部'; })
ForEach(STYLE_LIST, (s: StyleMeta) => {
Text(s.icon + ' ' + s.name).fontSize(10)
.fontColor(this.styleFilter === s.name ? '#FFFFFF' : COLORS.sub)
.backgroundColor(this.styleFilter === s.name ? COLORS.amber : COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
.onClick(() => { this.styleFilter = s.name; })
})
}
.padding({ left: 4, right: 4 })
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(30)
Column({ space: 6 }) {
ForEach(this.beerList, (b: Beer, idx: number) => {
Column({ space: 6 }) {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(b.icon).fontSize(30)
Text(b.price).fontSize(11).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
}
.width(50).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Row({ space: 4 }) {
Text(b.name).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(b.style).fontSize(8).fontColor(styleColor(b.style))
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
}
Text(b.brewery + ' · ' + b.origin).fontSize(9).fontColor(COLORS.sub)
Row({ space: 8 }) {
Text('⭐ ' + b.rating.toFixed(1)).fontSize(9).fontColor(COLORS.gold)
Text(b.abv + ' ABV').fontSize(9).fontColor(COLORS.sub)
Text('IBU ' + b.ibu).fontSize(9).fontColor(COLORS.sub)
}
Row({ space: 4 }) {
ForEach(b.tags, (tag: string) => {
Text('#' + tag).fontSize(8).fontColor(COLORS.amber)
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip) // 标签 chips 样式
})
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%')
Row({ space: 8 }) {
Column() { Text('购买').fontSize(10).fontColor('#FFFFFF') }
.layoutWeight(1).padding({ top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor(COLORS.amber).alignItems(HorizontalAlign.Center)
Column() { Text('写评测').fontSize(10).fontColor(COLORS.amber) }
.layoutWeight(1).padding({ top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor(COLORS.chip).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editIdx = idx; this.addModal = true; })
}
.width('100%')
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
beerTab 是酒款页的构建器,由顶部的横向筛选 chips 条与下方的酒款卡片流两部分组成。筛选条使用 Scroll 容器包裹一个 Row,scrollable(ScrollDirection.Horizontal) 支持横向滑动,scrollBar(BarState.Off) 隐藏滚动条。筛选 chips 由固定的"全部"项与 ForEach 遍历 STYLE_LIST 生成的六个风格项组成,每个 chip 的选中态通过三元运算符动态切换文字颜色与背景色,点击事件将 this.styleFilter 设置为对应风格名。由于 styleFilter 是 @State 变量,赋值后自动触发 chips 重渲染。
酒款卡片流通过 ForEach 遍历 this.beerList 渲染 15 张酒款卡片。每张卡片是垂直布局的 Column,上半部分是信息行:左侧 50px 宽的图标与价格列,中间弹性宽度的信息列(名称+风格标签、酒厂·产地、评分·ABV·IBU、标签 chips 行),下半部分是操作行:购买按钮与写评测按钮。写评测按钮的 onClick 执行 this.editIdx = idx; this.addModal = true;,先记录当前酒款索引再打开新增评测弹框。标签 chips 通过嵌套 ForEach 遍历 b.tags 数组渲染,每个 tag 以 # 前缀+琥珀橙文字+淡橙底色的样式呈现。需要注意的是,虽然 styleFilter 状态被声明,但当前实现中筛选逻辑尚未接入 beerList 的过滤,这一状态为未来接入实际筛选逻辑预留了接口。
4.19 评测页构建器
@Builder
reviewTab() {
Column({ space: 8 }) {
Text('📊 评分分布').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 评分分布图
Column({ space: 6 }) {
Row({ space: 8 }) {
ForEach([0, 1, 2, 3], (d: number) => {
Column({ space: 3 }) {
Text(RATING_DIST[d].toString()).fontSize(9).fontColor(COLORS.gold)
Column()
.width(28).height(ratingBarH(RATING_DIST[d]))
.backgroundColor(d === 0 ? COLORS.gold : COLORS.amberD)
.borderRadius({ topLeft: 4, topRight: 4 })
Text(RATING_LABELS[d]).fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
.width('100%').padding({ top: 8, bottom: 8 })
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
Row({ space: 6 }) {
Text('热门').fontSize(10).fontColor('#FFFFFF')
.backgroundColor(COLORS.amber)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
Text('最新').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
Column().layoutWeight(1)
Text('✏️ 写评测').fontSize(10).fontColor(COLORS.amber)
.onClick(() => { this.addModal = true; })
}
.width('100%')
Column({ space: 8 }) {
ForEach(this.reviewList, (r: Review, idx: number) => {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(r.avatar).fontSize(20)
Column({ space: 2 }) {
Text(r.user).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Row({ space: 4 }) {
Text('🍺 ' + r.beerName).fontSize(9).fontColor(COLORS.amber)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(r.time).fontSize(9).fontColor(COLORS.sub)
}
}
.alignItems(HorizontalAlign.Start)
Column().layoutWeight(1)
Text('⭐ ' + r.rating).fontSize(10).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
}
.width('100%')
Text(r.content).fontSize(10).fontColor(COLORS.sub)
.width('100%')
Row({ space: 12 }) {
Row({ space: 4 }) {
Text('❤️').fontSize(11)
Text(r.likes.toString()).fontSize(9).fontColor(COLORS.sub)
}
Row({ space: 4 }) {
Text('📷').fontSize(11)
Text(r.photos + '张').fontSize(9).fontColor(COLORS.sub)
}
Column().layoutWeight(1)
Text('✏️').fontSize(11).fontColor(COLORS.amber)
.onClick(() => { this.editIdx = idx; this.editModal = true; })
Text('🗑️').fontSize(11).fontColor(COLORS.red)
.onClick(() => { this.editIdx = idx; this.delModal = true; })
}
.width('100%')
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
reviewTab 是评测页的构建器,由评分分布图、排序切换栏与评测信息流三部分组成。评分分布图通过 ForEach 遍历索引 [0,1,2,3] 渲染四个柱状条,每个条由顶部数字(评分数量,9px 金色)、中部柱体(28px 宽,高度由 ratingBarH 计算,第一根金色其余琥珀深色,顶部圆角)、底部标签三部分组成。ratingBarH 函数将评分数量按 MAX_RATING(68)等比缩放到最大 60px 高度,使 5 星评测的柱体显著高于其他区间,直观呈现社区评分偏态分布。
排序切换栏使用 Row 横向排列"热门"(选中态)与"最新"(未选中态)两个 chips,右侧是"写评测"文字按钮(点击打开新增评测弹框)。评测信息流通过 ForEach 遍历 this.reviewList 渲染评测卡片,每张卡片包含三行:用户信息行(头像、昵称、酒款名·时间、评分)、评测正文(10px 副标题色)、互动操作行(点赞数、配图数、编辑按钮、删除按钮)。编辑按钮设置 editIdx 并打开 editModal,删除按钮设置 editIdx 并打开 delModal,两个操作都先记录目标索引再触发弹框。
4.20 酒馆页构建器
@Builder
pubTab() {
Column({ space: 8 }) {
Row({ space: 6 }) {
Text('附近').fontSize(10).fontColor('#FFFFFF')
.backgroundColor(COLORS.amber)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('评分最高').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('酒头最多').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Column().layoutWeight(1)
Text('📍').fontSize(14).fontColor(COLORS.amber)
}
.width('100%')
Column({ space: 6 }) {
ForEach(this.pubList, (p: PubItem, idx: number) => {
Column({ space: 6 }) {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(p.icon).fontSize(28)
Text(p.distance).fontSize(9).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
}
.width(50).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Row({ space: 4 }) {
Text(p.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('⭐ ' + p.rating.toFixed(1)).fontSize(9).fontColor(COLORS.gold)
}
Text(p.address).fontSize(9).fontColor(COLORS.sub)
Row({ space: 4 }) {
Text('🍺 ' + p.taps + '个酒头').fontSize(9).fontColor(COLORS.amber)
ForEach(p.tags, (tag: string) => {
Text('#' + tag).fontSize(8).fontColor(COLORS.amber)
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
})
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%')
Row({ space: 8 }) {
Column() { Text('导航').fontSize(10).fontColor('#FFFFFF') }
.layoutWeight(1).padding({ top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor(COLORS.amber).alignItems(HorizontalAlign.Center)
Column() { Text('预约').fontSize(10).fontColor(COLORS.amber) }
.layoutWeight(1).padding({ top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor(COLORS.chip).alignItems(HorizontalAlign.Center)
}
.width('100%')
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
pubTab 是酒馆页的构建器,由顶部排序切换栏与酒馆卡片流组成。排序栏提供"附近"(选中态)、“评分最高”、"酒头最多"三种排序模式 chips。酒馆卡片流通过 ForEach 遍历 this.pubList 渲染 8 张酒馆卡片,每张卡片包含信息行与操作行两部分。信息行左侧是 50px 宽的图标与距离列(距离用琥珀橙粗体突出),中间是弹性宽度的信息列(酒馆名称+评分、地址、酒头数量+特色标签 chips)。
操作行提供"导航"(琥珀橙实心按钮)与"预约"(琥珀橙文字+淡橙底色按钮)两个操作入口,模拟 O2O 场景下的到店引流闭环。酒头数量以 🍺 N个酒头 的形式展示,直接反映了酒馆的精酿品类丰富度。特色标签通过嵌套 ForEach 遍历 p.tags 渲染,标签如"精酿吧"、“现打”、"氛围好"等,既描述了业态属性也描绘了消费场景。整个酒馆页的布局逻辑与酒款页保持了高度一致的卡片流模式,这种跨页面的布局一致性降低了用户的学习成本。
4.21 地图页构建器
@Builder
mapTab() {
Column({ space: 10 }) {
Text('📍 附近精酿酒馆').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 模拟地图区域
Column({ space: 4 }) {
Row({ space: 20 }) {
Text('🗺️').fontSize(60).opacity(0.3)
}
.width('100%').height(120)
.backgroundColor(COLORS.dark)
.borderRadius(12).justifyContent(FlexAlign.Center)
Text('点击地图上的标记查看酒馆详情').fontSize(9).fontColor(COLORS.sub)
}
.width('100%').alignItems(HorizontalAlign.Center)
Text('📊 热力排行').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
ForEach([0, 1, 2, 3, 4], (i: number) => {
Row({ space: 10 }) {
Column() {
Text(rankMedal(i)).fontSize(i < 3 ? 20 : 14)
.fontColor(rankColor(i)).fontWeight(FontWeight.Bold)
}
.width(28).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text(PUB_INIT[i].name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Row({ space: 4 }) {
Text('⭐ ' + PUB_INIT[i].rating.toFixed(1)).fontSize(9).fontColor(COLORS.gold)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(PUB_INIT[i].distance).fontSize(9).fontColor(COLORS.amber)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(PUB_INIT[i].taps + '酒头').fontSize(9).fontColor(COLORS.sub)
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('›').fontSize(14).fontColor(COLORS.sub)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
mapTab 是地图页的构建器,由地图区域与热力排行榜两部分组成。地图区域是一个 120px 高的 Column,背景色为 COLORS.dark(淡棕色),内部居中放置一个 60px 字号、0.3 透明度的地图 emoji 作为占位符。在真实应用中,这一区域会嵌入华为地图 SDK 或 MapComponent 组件渲染交互式地图;当前实现以 emoji 占位,既保持了页面结构的完整性,又避免了引入地图 SDK 的依赖开销。地图下方有一行 9px 的提示文字"点击地图上的标记查看酒馆详情",引导用户理解交互预期。
热力排行榜通过 ForEach 遍历索引 [0,1,2,3,4] 渲染前五家酒馆的排行卡片。每张卡片左侧是 28px 宽的奖牌列——前三名使用 rankMedal 返回的金银铜 emoji(20px 字号),四名以后使用数字文本(14px 字号),颜色由 rankColor 返回。中间是弹性宽度的信息列,包含酒馆名称与评分·距离·酒头数量的数据行。右侧是右箭头"›"引导用户点击查看详情。这里的字号差异化处理(i < 3 ? 20 : 14)是排行榜视觉层次设计的典型手法。
4.22 排行页构建器
@Builder
rankTab() {
Column({ space: 10 }) {
Row({ space: 6 }) {
Text('周榜').fontSize(10).fontColor('#FFFFFF')
.backgroundColor(COLORS.amber)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
Text('月榜').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
Text('总榜').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
}
.width('100%')
Text('🏆 精酿啤酒排行榜').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], (i: number) => {
Row({ space: 10 }) {
Column() {
Text(rankMedal(i)).fontSize(i < 3 ? 22 : 16)
.fontColor(rankColor(i)).fontWeight(FontWeight.Bold)
}
.width(36).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text(BEER_RANK[i]).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Row({ space: 6 }) {
Text('⭐ ' + RANK_SCORES[i].toFixed(1)).fontSize(9).fontColor(COLORS.gold)
Text('·').fontSize(9).fontColor(COLORS.text3)
// 产地数组内联取值,避免关联查询
Text(['比利时', '美国', '美国', '美国', '比利时', '苏格兰', '爱尔兰', '美国', '美国', '美国'][i])
.fontSize(9).fontColor(COLORS.sub)
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
// 试饮按钮:精酿场景特色交互,导流线下消费
Column() { Text('试饮').fontSize(9).fontColor('#FFFFFF') }
.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(6)
.backgroundColor(COLORS.amber)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
rankTab 是排行页的构建器,由档期切换栏与啤酒排行榜两部分组成。档期切换栏提供"周榜"(选中态)、“月榜”、"总榜"三档切换 chips。排行榜通过 ForEach 遍历索引 [0,1,2,3,4,5,6,7,8,9] 渲染前十名啤酒的排行卡片。每张卡片左侧是 36px 宽的奖牌列——与地图页热力排行相同的 rankMedal/rankColor 逻辑,但字号略大(前三名 22px,其余 16px),因为排行页是核心内容页而非辅助功能区。中间是弹性宽度的信息列,包含酒款名称(取自 BEER_RANK[i])与评分·产地的数据行。产地信息通过内联数组字面量 ['比利时', '美国', ...] 按索引取值,而非从 Beer 实例中查询——这是一种简化处理,避免了在排行数据与酒款数据之间建立关联查询的开销。
右侧是"试饮"操作按钮(琥珀橙实心,9px 白字),为每款上榜啤酒提供试饮入口——这是精酿啤酒场景下的特色交互,因为精酿酒吧通常会提供小杯试饮服务,让用户在购买整杯前先品尝。"试饮"按钮的引入使排行页不仅是展示型列表,更具备了转化能力。整个排行页的信息架构清晰:档期切换控制数据范围,排行榜主体展示竞争态势,每行的试饮按钮提供转化入口,形成了"筛选-浏览-转化"的完整用户路径。
4.23 个人页构建器
@Builder
profileTab() {
Column({ space: 12 }) {
Column({ space: 8 }) {
Row({ space: 12 }) {
Column() {
Text('🍺').fontSize(32)
}
.width(56).height(56).borderRadius(28).backgroundColor(COLORS.chip)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('精酿老饕').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('品鉴等级 Lv.12 · 已品鉴186款').fontSize(10).fontColor(COLORS.sub)
Text('🏅 精酿达人认证').fontSize(9).fontColor(COLORS.amber)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%')
}
.width('100%').padding(14).borderRadius(16).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('186').fontSize(20).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
Text('已品鉴').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('42').fontSize(20).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('评测数').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('28').fontSize(20).fontColor(COLORS.brown).fontWeight(FontWeight.Bold)
Text('收藏酒款').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
.width('100%')
Text('📝 品鉴笔记').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
ForEach([0, 1, 2, 3], (i: number) => {
Row({ space: 10 }) {
Text(REVIEW_INIT[i].avatar).fontSize(20)
Column({ space: 2 }) {
Text(REVIEW_INIT[i].beerName).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text(REVIEW_INIT[i].content.substring(0, 30) + '...').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('⭐ ' + REVIEW_INIT[i].rating).fontSize(10).fontColor(COLORS.gold)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
.onClick(() => { this.editIdx = i; this.editModal = true; })
})
}
.width('100%')
Text('⚙️ 设置').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 0 }) {
// 设置项结构相同:emoji + 标题(layoutWeight弹性占位) + 当前值/箭头
Row({ space: 10 }) {
Text('🍺').fontSize(16)
Text('口味偏好').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('IPA / 世涛 ›').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Text('🔔').fontSize(16)
Text('上新提醒').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('已开启 ›').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
// ... 第三行"设置"项结构完全一致
}
.width('100%')
}
.width('100%')
}
profileTab 是个人页的构建器,由个人档案卡、三宫格统计、品鉴笔记列表、设置区四部分组成。个人档案卡是一个白色卡片,内部 Row 横向排列 56×56 圆形头像与用户信息列(昵称"精酿老饕"14px 粗体、品鉴等级与已品鉴数、达人认证标识)。三宫格统计使用 Row 等宽排列三个 Column,分别展示已品鉴数(186,琥珀橙)、评测数(42,金色)、收藏酒款数(28,棕色),每列由 20px 粗体数字与 9px 标签组成,三色区分增强了数据辨识度。
品鉴笔记列表通过 ForEach 遍历索引 [0,1,2,3] 渲染 REVIEW_INIT 前四条评测的摘要卡片。每张卡片横向排列头像 emoji、酒款名称与正文摘要(通过 content.substring(0, 30) + '...' 截取前 30 个字符并追加省略号)、右侧评分。substring 是 JavaScript/TypeScript 原生字符串方法,用于控制文本长度避免布局溢出。卡片整体绑定了 onClick 事件,点击后设置 editIdx 并打开 editModal,实现从个人笔记快捷编辑评测的交互。设置区包含三行设置项,每行由 emoji 图标、设置项名称(弹性宽度)、当前值或箭头组成。
4.24 主构建方法与内容区分发
build() {
Stack({ alignContent: Alignment.Center }) {
Column() {
this.header()
Scroll() {
Column({ space: 10 }) {
if (this.tabIdx === 0) { this.recommendTab() }
else if (this.tabIdx === 1) { this.beerTab() }
else if (this.tabIdx === 2) { this.reviewTab() }
else if (this.tabIdx === 3) { this.pubTab() }
else if (this.tabIdx === 4) { this.mapTab() }
else if (this.tabIdx === 5) { this.rankTab() }
else { this.profileTab() }
}
.width('100%').padding({ left: 14, right: 14, top: 10, bottom: 16 })
}
.layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top)
this.tabBar()
}
.width('100%').height('100%')
// 气泡特效层
if (this.bubble) {
Row({ space: 50 }) {
Text('○').fontSize(10).fontColor(COLORS.gold).opacity(0.3)
Text('◦').fontSize(8).fontColor(COLORS.amber).opacity(0.2)
Text('○').fontSize(12).fontColor(COLORS.gold).opacity(0.15)
Text('◦').fontSize(6).fontColor(COLORS.amber).opacity(0.25)
}
.width('100%').height(16).position({ x: 0, y: 80 }).zIndex(1)
}
build() 是组件的根构建方法,使用 Stack 作为根容器,alignContent: Alignment.Center 使子元素居中对齐。Stack 内首先是一个铺满全屏的 Column,垂直排列三个部分:header() 头部、Scroll 可滚动内容区(layoutWeight(1) 占据剩余空间)、tabBar() 底部标签栏。内容区的 Scroll 内嵌一个 Column,通过 if-else if-else 链根据 this.tabIdx 的值分发到对应 Tab 的 Builder 方法。这种基于条件渲染的 Tab 切换是 ArkUI 中的常见模式,每次 tabIdx 变更都会触发 build() 重新执行,条件分支自动切换到新的 Tab Builder。
Scroll 的 scrollBar(BarState.Off) 隐藏了滚动条,align(Alignment.Top) 确保内容从顶部开始排列。气泡特效层是 Stack 的第二个子元素,通过 if (this.bubble) 条件渲染控制显隐。当 bubble 为 true 时,渲染一个包含四个圆形字符(○、◦ 交替)的 Row,每个字符具有不同的字号、颜色与透明度,整体通过 position({ x: 0, y: 80 }) 绝对定位在头部下方区域,zIndex(1) 确保其层叠在内容区之上。由于 aboutToAppear 中的定时器每 700 毫秒切换 bubble 值,这一气泡层会周期性地出现与消失,模拟精酿啤酒发酵冒泡的视觉效果。
4.25 弹框系统构建
// ===== 新增评测弹框 =====
if (this.addModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.addModal = false; })
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('✍️ 写评测').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.addModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('品鉴酒款').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '输入酒款名称', text: this.addBeer })
.onChange((v: string) => { this.addBeer = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.dark)
.fontColor(COLORS.title)
Text('评分').fontSize(10).fontColor(COLORS.sub)
Row({ space: 8 }) {
ForEach([5, 4, 3, 2, 1], (r: number) => {
Text(r <= this.addRating ? '⭐' : '☆').fontSize(18)
.onClick(() => { this.addRating = r; })
})
}
.width('100%')
Text('品鉴感受').fontSize(10).fontColor(COLORS.sub)
TextArea({ placeholder: '描述口感、香气、回味...' })
.onChange((v: string) => { this.addContent = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.dark)
.fontColor(COLORS.title).height(60)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = false; })
Column() { Text('发布评测').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.amber).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = false; })
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '80%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
新增评测弹框是弹框系统的核心组件,通过 if (this.addModal) 条件渲染控制显隐。弹框整体使用 Stack 层叠布局,内含两层:底层是 modalOverlay 遮罩(点击关闭),上层是弹框内容卡片(82% 宽度,最大高度 80%,白色背景,16px 圆角)。弹框内容从上到下依次为:标题行("写评测"标题 + 关闭按钮)、分割线、酒款名称输入框(TextInput,onChange 同步到 this.addBeer)、评分选择器(五颗星,ForEach 遍历 [5,4,3,2,1] 生成,根据 r <= this.addRating 判断实心或空心,点击设置评分)、品鉴感受多行输入框(TextArea,onChange 同步到 this.addContent)、操作按钮行(取消 + 发布评测)。
星级评分选择器的实现逻辑值得深入分析:ForEach 遍历的数组是 [5,4,3,2,1] 而非 [1,2,3,4,5],意味着星星从左到右按 5 星到 1 星排列,符合用户对评分"从高到低"的视觉预期。每颗星的渲染状态由 r <= this.addRating 决定——当用户点击第 3 颗星时,addRating 被设为 3,r 为 3、2、1 的星星显示实心,r 为 5、4 的显示空心。这种"点击哪颗星,该星及右侧所有星变实心"的交互逻辑正是移动端星级评分组件的标准行为。zIndex(999) 确保弹框层叠在所有内容之上。
4.26 编辑弹框与删除确认弹框
// ===== 编辑弹框 =====
if (this.editModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.editModal = false; })
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('✏️ 编辑评测').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.editModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('当前: ' + (this.editIdx < this.reviewList.length ? this.reviewList[this.editIdx].beerName : '')).fontSize(11).fontColor(COLORS.amber)
Text('修改内容').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '输入修改后的内容', text: this.editContent })
.onChange((v: string) => { this.editContent = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.dark)
.fontColor(COLORS.title)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editModal = false; })
Column() { Text('保存').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.amber).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editModal = false; })
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '70%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
// ===== 删除确认弹框 =====
if (this.delModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.delModal = false; })
Column({ space: 12 }) {
Text('🗑️').fontSize(36)
Text('删除这条评测?').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('删除后评测记录将不可恢复').fontSize(10).fontColor(COLORS.red)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.delModal = false; })
Column() { Text('确认删除').fontSize(11).fontColor('#FFFFFF') }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.red).alignItems(HorizontalAlign.Center)
.onClick(() => {
this.reviewList.splice(this.editIdx, 1);
this.delModal = false;
})
}
.width('100%')
}
.width('76%').constraintSize({ maxHeight: '60%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
}
.width('100%').height('100%').zIndex(999)
}
}
.width('100%').height('100%').backgroundColor(COLORS.bg)
}
}
编辑弹框与删除确认弹框是与新增评测弹框平行的另外两个模态层。编辑弹框的结构与新增弹框类似但更简洁:标题"编辑评测"、当前评测的酒款名称提示(通过 this.editIdx < this.reviewList.length 安全访问,前置长度检查避免索引越界)、修改内容输入框(TextInput 绑定 this.editContent)、取消与保存按钮。editIdx 在评测页的编辑按钮与个人页的品鉴笔记点击两处被设置,确保编辑弹框能定位到正确的评测条目。
删除确认弹框是三个弹框中最简洁的,采用居中对齐的警示型布局:垃圾桶 emoji、"删除这条评测?"标题、红色警示文案、取消与确认删除按钮。确认删除按钮的 onClick 执行 this.reviewList.splice(this.editIdx, 1) 从评测数组中移除指定索引的评测,然后 this.delModal = false 关闭弹框。由于 reviewList 是 @State 变量,splice 对数组的直接修改会被 ArkUI 响应式系统捕获,自动触发评测信息流的 ForEach 重新渲染。这是整个应用中唯一一处实际修改业务数据的操作,其余弹框的"发布"和"保存"按钮目前仅关闭弹框而未实际写入数据,为未来接入后端 API 预留了扩展点。
五、完整流程图
以下是应用从生命周期启动到渲染到交互的完整流程:
六、技术对比表
| 技术维度 | 实现方式 | 设计特点 | 性能考量 |
|---|---|---|---|
| 配色管理 | ColorPalette 接口 + COLORS 常量集中定义 | 强类型约束 21 个颜色字段,设计令牌思想 | 避免运行时颜色字符串拼接,编译期常量内联 |
| 数据模型 | @Observed 装饰的 Beer/Review/PubItem 三类 | 领域模型与视图解耦,支持细粒度属性追踪 | 代理对象带来微量开销,换取精准 UI 刷新 |
| 初始数据 | 三个 const 数组(BEER_INIT/REVIEW_INIT/PUB_INIT) | 静态数据预实例化,反范式冗余存储 | 避免渲染时关联查询,以空间换时间 |
| 状态管理 | @State 声明 13 个响应式变量 | 按功能分组(导航/特效/弹框/表单/筛选/列表) | 简单状态用 @State,复杂对象用 @Observed |
| 生命周期 | aboutToAppear 注册定时器,aboutToDisappear 清理 | 资源获取与释放配对,防止内存泄漏 | 定时器 700ms 间隔平衡动画流畅度与 CPU 开销 |
| Tab 切换 | if-else if-else 链条件渲染分发 | 每次切换销毁旧 Tab 节点、创建新 Tab 节点 | 避免同时挂载 7 个 Tab 的内存占用 |
| 列表渲染 | ForEach 遍历索引数组或数据数组 | 通过键值绑定实现高效 diff | 前五/前十等小规模列表无性能瓶颈 |
| 弹框系统 | 三个独立 if 条件渲染 + 共用 modalOverlay | 互斥显示,zIndex(999) 层叠管理 | 弹框不显示时零渲染开销 |
| 遮罩复用 | @Builder modalOverlay(onClose 回调) | 参数化回调实现关闭逻辑解耦 | Builder 在父上下文执行,无组件通信开销 |
| 星级评分 | ForEach 遍历 [5,4,3,2,1] + r <= addRating 判断 | 从高到低排列,点击填充左侧实心星 | 纯状态驱动,无额外计算 |
| 评分分布图 | ratingBarH 纯函数计算柱高 + ForEach 渲染 | 以 MAX_RATING 为基准等比缩放到 60px | Math.round 确保整数像素,避免亚像素模糊 |
| 排行视觉 | rankMedal/rankColor 双函数驱动前三名差异化 | 金银铜奖牌 + 高饱和色突出头部 | 前三名 emoji 渲染成本与普通文本一致 |
| 气泡特效 | setInterval 驱动 bubble 布尔翻转 + 条件渲染 | 四个圆形字符不同字号/颜色/透明度模拟气泡 | 700ms 间隔的条件渲染开销极低 |
| 布局策略 | Row/Column/Stack/Scroll 四组件覆盖全场景 | layoutWeight 弹性分配 + padding/borderRadius 统一卡片风格 | 线性布局的测量与布局复杂度为 O(n) |
| 文本截断 | content.substring(0, 30) + ‘…’ | 原生字符串方法实现摘要截取 | 无正则或第三方库开销 |
| 数据删除 | reviewList.splice(editIdx, 1) | 直接修改 @State 数组触发响应式刷新 | splice 后 ArkUI 仅 diff 变更项,非全量重渲染 |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
// ============================================================
// 824 酿造社 · 精酿啤酒社区与评测(现代行业:精酿啤酒 / 微型酿造)
// 头部样式:购物电商头部(酒款搜索栏 + 评分横条 + 产地筛选)无动画
// 布局风格:7 个 tab 两排(4+3),每个布局完全不同
// 推荐=精选大卡+风格宫格+推荐列表 / 酒款=卡片列表+筛选chips
// 评测=评测信息流+评分分布图 / 酒馆=酒馆列表+距离排序
// 地图=附近酒馆列表+热力标记 / 排行=排行榜+趋势
// 我的=个人档案+品鉴笔记
// 底部 7 tab 两排(4+3)
// 主题:麦芽金棕底 + 琥珀金 + 深棕
// ============================================================
interface ColorPalette {
bg: string;
card: string;
dark: string;
darkL: string;
title: string;
sub: string;
text3: string;
gold: string;
goldD: string;
amber: string;
brown: string;
brownD: string;
orange: string;
red: string;
green: string;
blue: string;
purple: string;
line: string;
tabOn: string;
mask: string;
chip: string;
}
const COLORS: ColorPalette = {
bg: '#F8F1E4',
card: '#FFFFFF',
dark: '#EDE3D0',
darkL: '#DDD0BA',
title: '#3D2817',
sub: '#7A5C42',
text3: '#A89078',
gold: '#D4A017',
goldD: '#B07C12',
amber: '#C77D2E',
brown: '#6B4226',
brownD: '#4A2E18',
orange: '#E8722C',
red: '#D04545',
green: '#4A9E6C',
blue: '#3D7DC8',
purple: '#8A5FC0',
line: '#DDD0BA',
tabOn: '#C77D2E',
mask: 'rgba(61,40,23,0.55)',
chip: 'rgba(199,125,46,0.12)'
};
interface TabMeta {
icon: string;
label: string;
}
const TAB_LIST: TabMeta[] = [
{ icon: '🍺', label: '推荐' },
{ icon: '🍾', label: '酒款' },
{ icon: '✍️', label: '评测' },
{ icon: '🏪', label: '酒馆' },
{ icon: '📍', label: '地图' },
{ icon: '🏆', label: '排行' },
{ icon: '👤', label: '我的' }
];
interface StyleMeta {
name: string;
icon: string;
color: string;
count: number;
}
const STYLE_LIST: StyleMeta[] = [
{ name: 'IPA', icon: '🌿', color: COLORS.green, count: 86 },
{ name: '世涛', icon: '🌑', color: COLORS.brownD, count: 42 },
{ name: '拉格', icon: '🌾', color: COLORS.gold, count: 68 },
{ name: '酸啤', icon: '🍋', color: COLORS.amber, count: 35 },
{ name: '小麦', icon: '🌽', color: COLORS.goldD, count: 52 },
{ name: '波特', icon: '☕', color: COLORS.brown, count: 28 }
];
@Observed export class Beer {
id: number;
name: string;
brewery: string;
style: string;
abv: string;
ibu: number;
rating: number;
price: string;
icon: string;
origin: string;
tags: string[];
constructor(id: number, name: string, brewery: string, style: string, abv: string, ibu: number, rating: number, price: string, icon: string, origin: string, tags: string[]) {
this.id = id; this.name = name; this.brewery = brewery; this.style = style;
this.abv = abv; this.ibu = ibu; this.rating = rating; this.price = price;
this.icon = icon; this.origin = origin; this.tags = tags;
}
}
@Observed export class Review {
id: number;
user: string;
avatar: string;
beerName: string;
rating: number;
content: string;
time: string;
likes: number;
photos: number;
constructor(id: number, user: string, avatar: string, beerName: string, rating: number, content: string, time: string, likes: number, photos: number) {
this.id = id; this.user = user; this.avatar = avatar; this.beerName = beerName;
this.rating = rating; this.content = content; this.time = time;
this.likes = likes; this.photos = photos;
}
}
@Observed export class PubItem {
id: number;
name: string;
address: string;
distance: string;
rating: number;
taps: number;
icon: string;
tags: string[];
constructor(id: number, name: string, address: string, distance: string, rating: number, taps: number, icon: string, tags: string[]) {
this.id = id; this.name = name; this.address = address; this.distance = distance;
this.rating = rating; this.taps = taps; this.icon = icon; this.tags = tags;
}
}
const BEER_INIT: Beer[] = [
new Beer(1, '朋克IPA', 'BrewDog', 'IPA', '5.6%', 45, 4.6, '¥28', '🌿', '苏格兰', ['花香', '柑橘', '经典']),
new Beer(2, '左手牛奶世涛', 'Left Hand', '世涛', '6.0%', 25, 4.8, '¥38', '🌑', '美国', ['咖啡', '巧克力', '丝滑']),
new Beer(3, '鹅岛IPA', 'Goose Island', 'IPA', '5.9%', 55, 4.5, '¥25', '🌿', '美国', ['柑橘', '松针', '入门']),
new Beer(4, '林德曼樱桃', 'Lindemans', '酸啤', '3.5%', 14, 4.7, '¥45', '🍒', '比利时', ['果味', '酸甜', '女性友好']),
new Beer(5, '福佳白', 'Hoegaarden', '小麦', '4.9%', 10, 4.4, '¥18', '🌽', '比利时', ['芫荽', '橙皮', '清爽']),
new Beer(6, '健力士', 'Guinness', '世涛', '4.2%', 40, 4.5, '¥22', '🌑', '爱尔兰', ['焦香', '奶油', '经典']),
new Beer(7, '角鲨头90分钟', 'Dogfish Head', 'IPA', '9.0%', 90, 4.9, '¥58', '🌿', '美国', ['烈性', '琥珀', '进阶']),
new Beer(8, '青岛经典', '青岛啤酒', '拉格', '4.3%', 12, 4.2, '¥8', '🌾', '中国', ['清爽', '国产', '百搭']),
new Beer(9, '罗斯福10号', 'Rochefort', '修道院', '11.3%', 27, 4.9, '¥68', '⛪', '比利时', ['烈性', '果香', '收藏']),
new Beer(10, '布雷特野菌', 'Cascade', '酸啤', '6.5%', 18, 4.6, '¥52', '🍋', '美国', ['野菌', '酸爽', '小众']),
new Beer(11, '熊猫精酿蜂蜜', '熊猫精酿', '小麦', '5.0%', 15, 4.3, '¥20', '🍯', '中国', ['蜂蜜', '国产', '微甜']),
new Beer(12, '巨象花生酱', 'Big Musk', '波特', '6.5%', 30, 4.7, '¥42', '🥜', '美国', ['花生酱', '甜品', '独特']),
new Beer(13, '布鲁克林拉格', 'Brooklyn', '拉格', '5.2%', 20, 4.4, '¥22', '🌾', '美国', ['经典', '平衡', '易饮']),
new Beer(14, '海底捞酸啤', '海底捞精酿', '酸啤', '4.0%', 12, 4.1, '¥15', '🍋', '中国', ['餐配', '国产', '酸爽']),
new Beer(15, '鹅岛波本世涛', 'Goose Island', '世涛', '11.2%', 50, 4.8, '¥65', '🥃', '美国', ['波本', '过桶', '限量'])
];
const REVIEW_INIT: Review[] = [
new Review(1, '精酿老饕', '🍺', '罗斯福10号', 5, '11.3%的修道院啤酒,入口果香浓郁,中段焦糖甜味,收口微苦回甘。层次感极强,不愧是修道院啤酒的巅峰之作。', '2小时前', 328, 3),
new Review(2, '微醺少女', '🌸', '林德曼樱桃', 5, '朋友推荐的入门酸啤,樱桃味超级浓郁!酸甜平衡得很好,完全喝不出3.5%的酒味。女生友好度满分。', '5小时前', 256, 2),
new Review(3, '啤酒猎人', '🎯', '角鲨头90分钟', 5, '9度的IPA,持续90分钟的酒花投放,入口就是爆炸性的柑橘和松针香。IBU 90但完全不觉得苦,因为麦芽甜味平衡得刚刚好。', '昨天', 189, 4),
new Review(4, '夜饮诗人', '🌙', '健力士', 4, '经典的氮气世涛,倒出来那一下的瀑布效果太治愈了。咖啡焦香浓郁,口感丝滑如奶油。不过4.2%略淡了些。', '昨天', 142, 1),
new Review(5, '酒杯观察者', '🔬', '左手牛奶世涛', 5, '倒进杯子里就像一杯液态巧克力蛋糕。咖啡、巧克力、焦糖三层香气依次展开。6%酒精度恰到好处,冬天喝一杯太幸福了。', '2天前', 98, 2),
new Review(6, '啤酒新手村', '🙋', '朋克IPA', 4, '第一次喝精酿就选了朋克IPA,果然名不虚传。花香和柑橘味很明显,5.6%很友好。比超市里的工业啤酒好太多了。', '2天前', 76, 0),
new Review(7, '品鉴大师', '🏆', '鹅岛波本世涛', 5, '波本桶陈酿的世涛,开瓶就是浓烈的波本威士忌香。11.2%的酒精度,入口温热,巧克力、香草、波本三层味觉轰炸。限量款值得收藏。', '3天前', 67, 3),
new Review(8, '深夜酒客', '🍷', '巨象花生酱波特', 4, '花生酱味的啤酒?第一次听到也觉得离谱。但喝了一口就真香了。花生酱香气浓郁但不腻,6.5%酒精度刚好。甜品啤酒的独特体验。', '3天前', 54, 1),
new Review(9, '精酿探险家', '🗺️', '布雷特野菌', 4, '野菌发酵的酸啤,有一种独特的农场气味。第一次喝可能会不习惯,但越喝越上头。酸度适中,层次丰富。小众但值得尝试。', '4天前', 43, 2),
new Review(10, '快乐饮酒人', '😄', '福佳白', 4, '夏天的标配!芫荽和橙皮的香气让人心情大好。4.9%清爽易饮,配海鲜绝了。虽然不算硬核精酿,但永远是我的口粮酒。', '4天前', 38, 0)
];
const PUB_INIT: PubItem[] = [
new PubItem(1, '大跃精酿啤酒', '朝阳区三里屯路19号', '320m', 4.8, 24, '🍺', ['精酿吧', '现打', '氛围好']),
new PubItem(2, '拳击猫精酿', '静安区南京西路1788号', '580m', 4.7, 18, '🥊', ['精酿吧', '餐配', '人气高']),
new PubItem(3, '鹅岛鲜吧', '黄浦区中山东一路18号', '1.2km', 4.6, 12, '🦢', ['品牌店', '江景', '约会']),
new PubItem(4, '深夜酒馆', '徐汇区永康路52号', '850m', 4.9, 30, '🌙', ['小众', '隐藏款', '老饕推荐']),
new PubItem(5, '啤酒研究所', '海淀区中关村大街27号', '2.3km', 4.5, 16, '🔬', ['科主题', '学生党', '平价']),
new PubItem(6, '精酿实验室', '长宁区愚园路753号', '1.5km', 4.7, 22, '⚗️', ['实验款', '限定', '专业']),
new PubItem(7, '麦芽角落', '浦东新区张杨路500号', '3.1km', 4.4, 10, '🌾', ['安静', '品鉴', '适合独饮']),
new PubItem(8, '泡沫共和国', '西城区什刹海后海北沿15号', '1.8km', 4.8, 28, '🍺', ['湖景', '露天', '周末'])
];
const RATING_DIST: number[] = [68, 22, 8, 2];
const RATING_LABELS: string[] = ['5星', '4星', '3星', '2星以下'];
const MAX_RATING: number = 68;
const BEER_RANK: string[] = [
'罗斯福10号', '角鲨头90分钟', '左手牛奶世涛', '鹅岛波本世涛',
'林德曼樱桃', '朋克IPA', '健力士', '巨象花生酱',
'布雷特野菌', '鹅岛IPA'
];
const RANK_SCORES: number[] = [4.9, 4.9, 4.8, 4.8, 4.7, 4.6, 4.5, 4.7, 4.6, 4.5];
function styleColor(s: string): string {
if (s === 'IPA') { return COLORS.green; }
if (s === '世涛') { return COLORS.brownD; }
if (s === '拉格') { return COLORS.gold; }
if (s === '酸啤') { return COLORS.amber; }
if (s === '小麦') { return COLORS.goldD; }
if (s === '波特') { return COLORS.brown; }
return COLORS.purple;
}
function ratingBarH(v: number): number {
return Math.round(v / MAX_RATING * 60);
}
function rankMedal(i: number): string {
if (i === 0) { return '🥇'; }
if (i === 1) { return '🥈'; }
if (i === 2) { return '🥉'; }
return (i + 1).toString();
}
function rankColor(i: number): string {
if (i === 0) { return COLORS.gold; }
if (i === 1) { return COLORS.sub; }
if (i === 2) { return COLORS.amber; }
return COLORS.text3;
}
@Entry
@Component
struct Page824 {
@State tabIdx: number = 0;
@State bubble: boolean = false;
@State addModal: boolean = false;
@State editModal: boolean = false;
@State delModal: boolean = false;
@State editIdx: number = 0;
@State addBeer: string = '';
@State addRating: number = 5;
@State addContent: string = '';
@State editContent: string = '';
@State styleFilter: string = '全部';
@State beerList: Beer[] = BEER_INIT;
@State reviewList: Review[] = REVIEW_INIT;
@State pubList: PubItem[] = PUB_INIT;
private timer: number = -1;
aboutToAppear(): void {
this.timer = setInterval(() => { this.bubble = !this.bubble; }, 700);
}
aboutToDisappear(): void {
if (this.timer > 0) { clearInterval(this.timer); }
}
@Builder
header() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🍺').fontSize(16)
Text('酿造社').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('🔍').fontSize(16)
Text('👤').fontSize(16)
}
.width('100%')
// 搜索栏
Row({ space: 8 }) {
TextInput({ placeholder: '搜索精酿啤酒、酒厂...' })
.fontSize(11).layoutWeight(1).backgroundColor(COLORS.card)
.borderRadius(20).padding({ left: 12, right: 12, top: 6, bottom: 6 })
Column() {
Text('🔍').fontSize(14)
}
.width(32).height(32).borderRadius(16)
.backgroundColor(COLORS.amber)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}
.width('100%')
// 评分横条
Row({ space: 10 }) {
Column({ space: 1 }) {
Text('1,280').fontSize(16).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('酒款').fontSize(8).fontColor(COLORS.sub)
}
Column().width(1).height(22).backgroundColor(COLORS.line)
Column({ space: 1 }) {
Text('4.6').fontSize(16).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
Text('平均评分').fontSize(8).fontColor(COLORS.sub)
}
Column().width(1).height(22).backgroundColor(COLORS.line)
Column({ space: 1 }) {
Text('86').fontSize(16).fontColor(COLORS.brown).fontWeight(FontWeight.Bold)
Text('酒馆').fontSize(8).fontColor(COLORS.sub)
}
Column().layoutWeight(1)
Text('本周新品 3 ›').fontSize(9).fontColor(COLORS.amber)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
}
.width('100%').padding({ left: 14, right: 14, top: 10, bottom: 8 })
.backgroundColor(COLORS.bg)
}
@Builder
tabBar() {
Column({ space: 0 }) {
Row() {
ForEach([0, 1, 2, 3], (i: number) => {
Column({ space: 2 }) {
Text(TAB_LIST[i].icon).fontSize(18).opacity(this.tabIdx === i ? 1.0 : 0.4)
Text(TAB_LIST[i].label).fontSize(9)
.fontColor(this.tabIdx === i ? COLORS.tabOn : COLORS.sub)
.fontWeight(this.tabIdx === i ? FontWeight.Bold : FontWeight.Normal)
if (this.tabIdx === i) {
Column().width(16).height(2).backgroundColor(COLORS.tabOn).borderRadius(1)
}
}
.layoutWeight(1).padding({ top: 4, bottom: 4 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.tabIdx = i; })
})
}
.width('100%')
Row() {
ForEach([4, 5, 6], (i: number) => {
Column({ space: 2 }) {
Text(TAB_LIST[i].icon).fontSize(18).opacity(this.tabIdx === i ? 1.0 : 0.4)
Text(TAB_LIST[i].label).fontSize(9)
.fontColor(this.tabIdx === i ? COLORS.tabOn : COLORS.sub)
.fontWeight(this.tabIdx === i ? FontWeight.Bold : FontWeight.Normal)
if (this.tabIdx === i) {
Column().width(16).height(2).backgroundColor(COLORS.tabOn).borderRadius(1)
}
}
.layoutWeight(1).padding({ top: 4, bottom: 4 })
.alignItems(HorizontalAlign.Center)
.onClick(() => { this.tabIdx = i; })
})
Column().layoutWeight(1)
}
.width('100%')
}
.width('100%').backgroundColor(COLORS.card)
.border({ width: { top: 1 }, color: COLORS.line })
}
@Builder
modalOverlay(onClose: () => void) {
Column().width('100%').height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => { onClose(); })
}
// ===== Tab 0: 推荐 =====
@Builder
recommendTab() {
Column({ space: 12 }) {
// 精选大卡
Column({ space: 8 }) {
Text('⛪').fontSize(36)
Text('罗斯福10号').fontSize(16).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('Rochefort · 比利时修道院 · 11.3% ABV').fontSize(10).fontColor(COLORS.sub)
Row({ space: 8 }) {
Text('⭐ 4.9').fontSize(11).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('IBU 27').fontSize(10).fontColor(COLORS.sub)
Text('¥68').fontSize(12).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
}
Column() {
Text('查看详情').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.padding({ left: 20, right: 20, top: 6, bottom: 6 }).borderRadius(14)
.backgroundColor(COLORS.amber)
}
.width('100%').padding(20).borderRadius(16)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Text('🍺 按风格浏览').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 风格宫格 2x3
Row({ space: 10 }) {
ForEach([0, 1], (i: number) => {
Column({ space: 4 }) {
Text(STYLE_LIST[i].icon).fontSize(24)
Text(STYLE_LIST[i].name).fontSize(11).fontColor(COLORS.title)
Text(STYLE_LIST[i].count + '款').fontSize(8).fontColor(STYLE_LIST[i].color)
}
.layoutWeight(1).padding(12).borderRadius(12)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
})
}
.width('100%')
Row({ space: 10 }) {
ForEach([2, 3], (i: number) => {
Column({ space: 4 }) {
Text(STYLE_LIST[i].icon).fontSize(24)
Text(STYLE_LIST[i].name).fontSize(11).fontColor(COLORS.title)
Text(STYLE_LIST[i].count + '款').fontSize(8).fontColor(STYLE_LIST[i].color)
}
.layoutWeight(1).padding(12).borderRadius(12)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
})
}
.width('100%')
Row({ space: 10 }) {
ForEach([4, 5], (i: number) => {
Column({ space: 4 }) {
Text(STYLE_LIST[i].icon).fontSize(24)
Text(STYLE_LIST[i].name).fontSize(11).fontColor(COLORS.title)
Text(STYLE_LIST[i].count + '款').fontSize(8).fontColor(STYLE_LIST[i].color)
}
.layoutWeight(1).padding(12).borderRadius(12)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
})
}
.width('100%')
Text('🔥 本周推荐酒款').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
ForEach([0, 1, 2, 3, 4], (i: number) => {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(BEER_INIT[i].icon).fontSize(26)
}
.width(40).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Row({ space: 4 }) {
Text(BEER_INIT[i].name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text(BEER_INIT[i].style).fontSize(8).fontColor(styleColor(BEER_INIT[i].style))
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
}
Text(BEER_INIT[i].brewery + ' · ' + BEER_INIT[i].origin).fontSize(9).fontColor(COLORS.sub)
Row({ space: 6 }) {
Text('⭐ ' + BEER_INIT[i].rating.toFixed(1)).fontSize(9).fontColor(COLORS.gold)
Text(BEER_INIT[i].abv + ' ABV').fontSize(9).fontColor(COLORS.sub)
Text('IBU ' + BEER_INIT[i].ibu).fontSize(9).fontColor(COLORS.sub)
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column({ space: 3 }) {
Text(BEER_INIT[i].price).fontSize(12).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
Column() { Text('购买').fontSize(9).fontColor('#FFFFFF') }
.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(6)
.backgroundColor(COLORS.amber)
}
.alignItems(HorizontalAlign.End)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
// ===== Tab 1: 酒款 =====
@Builder
beerTab() {
Column({ space: 8 }) {
// 筛选chips
Scroll() {
Row({ space: 6 }) {
Text('全部').fontSize(10)
.fontColor(this.styleFilter === '全部' ? '#FFFFFF' : COLORS.sub)
.backgroundColor(this.styleFilter === '全部' ? COLORS.amber : COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
.onClick(() => { this.styleFilter = '全部'; })
ForEach(STYLE_LIST, (s: StyleMeta) => {
Text(s.icon + ' ' + s.name).fontSize(10)
.fontColor(this.styleFilter === s.name ? '#FFFFFF' : COLORS.sub)
.backgroundColor(this.styleFilter === s.name ? COLORS.amber : COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
.onClick(() => { this.styleFilter = s.name; })
})
}
.padding({ left: 4, right: 4 })
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(30)
Column({ space: 6 }) {
ForEach(this.beerList, (b: Beer, idx: number) => {
Column({ space: 6 }) {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(b.icon).fontSize(30)
Text(b.price).fontSize(11).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
}
.width(50).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Row({ space: 4 }) {
Text(b.name).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(b.style).fontSize(8).fontColor(styleColor(b.style))
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
}
Text(b.brewery + ' · ' + b.origin).fontSize(9).fontColor(COLORS.sub)
Row({ space: 8 }) {
Text('⭐ ' + b.rating.toFixed(1)).fontSize(9).fontColor(COLORS.gold)
Text(b.abv + ' ABV').fontSize(9).fontColor(COLORS.sub)
Text('IBU ' + b.ibu).fontSize(9).fontColor(COLORS.sub)
}
Row({ space: 4 }) {
ForEach(b.tags, (tag: string) => {
Text('#' + tag).fontSize(8).fontColor(COLORS.amber)
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
})
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%')
Row({ space: 8 }) {
Column() { Text('购买').fontSize(10).fontColor('#FFFFFF') }
.layoutWeight(1).padding({ top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor(COLORS.amber).alignItems(HorizontalAlign.Center)
Column() { Text('写评测').fontSize(10).fontColor(COLORS.amber) }
.layoutWeight(1).padding({ top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor(COLORS.chip).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editIdx = idx; this.addModal = true; })
}
.width('100%')
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
// ===== Tab 2: 评测 =====
@Builder
reviewTab() {
Column({ space: 8 }) {
Text('📊 评分分布').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 评分分布图
Column({ space: 6 }) {
Row({ space: 8 }) {
ForEach([0, 1, 2, 3], (d: number) => {
Column({ space: 3 }) {
Text(RATING_DIST[d].toString()).fontSize(9).fontColor(COLORS.gold)
Column()
.width(28).height(ratingBarH(RATING_DIST[d]))
.backgroundColor(d === 0 ? COLORS.gold : COLORS.amberD)
.borderRadius({ topLeft: 4, topRight: 4 })
Text(RATING_LABELS[d]).fontSize(8).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
})
}
.width('100%').padding({ top: 8, bottom: 8 })
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
Row({ space: 6 }) {
Text('热门').fontSize(10).fontColor('#FFFFFF')
.backgroundColor(COLORS.amber)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
Text('最新').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
Column().layoutWeight(1)
Text('✏️ 写评测').fontSize(10).fontColor(COLORS.amber)
.onClick(() => { this.addModal = true; })
}
.width('100%')
Column({ space: 8 }) {
ForEach(this.reviewList, (r: Review, idx: number) => {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(r.avatar).fontSize(20)
Column({ space: 2 }) {
Text(r.user).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Row({ space: 4 }) {
Text('🍺 ' + r.beerName).fontSize(9).fontColor(COLORS.amber)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(r.time).fontSize(9).fontColor(COLORS.sub)
}
}
.alignItems(HorizontalAlign.Start)
Column().layoutWeight(1)
Text('⭐ ' + r.rating).fontSize(10).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
}
.width('100%')
Text(r.content).fontSize(10).fontColor(COLORS.sub)
.width('100%')
Row({ space: 12 }) {
Row({ space: 4 }) {
Text('❤️').fontSize(11)
Text(r.likes.toString()).fontSize(9).fontColor(COLORS.sub)
}
Row({ space: 4 }) {
Text('📷').fontSize(11)
Text(r.photos + '张').fontSize(9).fontColor(COLORS.sub)
}
Column().layoutWeight(1)
Text('✏️').fontSize(11).fontColor(COLORS.amber)
.onClick(() => { this.editIdx = idx; this.editModal = true; })
Text('🗑️').fontSize(11).fontColor(COLORS.red)
.onClick(() => { this.editIdx = idx; this.delModal = true; })
}
.width('100%')
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
// ===== Tab 3: 酒馆 =====
@Builder
pubTab() {
Column({ space: 8 }) {
Row({ space: 6 }) {
Text('附近').fontSize(10).fontColor('#FFFFFF')
.backgroundColor(COLORS.amber)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('评分最高').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Text('酒头最多').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 8, right: 8, top: 4, bottom: 4 }).borderRadius(8)
Column().layoutWeight(1)
Text('📍').fontSize(14).fontColor(COLORS.amber)
}
.width('100%')
Column({ space: 6 }) {
ForEach(this.pubList, (p: PubItem, idx: number) => {
Column({ space: 6 }) {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text(p.icon).fontSize(28)
Text(p.distance).fontSize(9).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
}
.width(50).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Row({ space: 4 }) {
Text(p.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('⭐ ' + p.rating.toFixed(1)).fontSize(9).fontColor(COLORS.gold)
}
Text(p.address).fontSize(9).fontColor(COLORS.sub)
Row({ space: 4 }) {
Text('🍺 ' + p.taps + '个酒头').fontSize(9).fontColor(COLORS.amber)
ForEach(p.tags, (tag: string) => {
Text('#' + tag).fontSize(8).fontColor(COLORS.amber)
.padding({ left: 4, right: 4, top: 1, bottom: 1 }).borderRadius(4)
.backgroundColor(COLORS.chip)
})
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%')
Row({ space: 8 }) {
Column() { Text('导航').fontSize(10).fontColor('#FFFFFF') }
.layoutWeight(1).padding({ top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor(COLORS.amber).alignItems(HorizontalAlign.Center)
Column() { Text('预约').fontSize(10).fontColor(COLORS.amber) }
.layoutWeight(1).padding({ top: 6, bottom: 6 }).borderRadius(8)
.backgroundColor(COLORS.chip).alignItems(HorizontalAlign.Center)
}
.width('100%')
}
.width('100%').padding(12).borderRadius(12)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
// ===== Tab 4: 地图 =====
@Builder
mapTab() {
Column({ space: 10 }) {
Text('📍 附近精酿酒馆').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
// 模拟地图区域
Column({ space: 4 }) {
Row({ space: 20 }) {
Text('🗺️').fontSize(60).opacity(0.3)
}
.width('100%').height(120)
.backgroundColor(COLORS.dark)
.borderRadius(12).justifyContent(FlexAlign.Center)
Text('点击地图上的标记查看酒馆详情').fontSize(9).fontColor(COLORS.sub)
}
.width('100%').alignItems(HorizontalAlign.Center)
Text('📊 热力排行').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
ForEach([0, 1, 2, 3, 4], (i: number) => {
Row({ space: 10 }) {
Column() {
Text(rankMedal(i)).fontSize(i < 3 ? 20 : 14)
.fontColor(rankColor(i)).fontWeight(FontWeight.Bold)
}
.width(28).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text(PUB_INIT[i].name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Row({ space: 4 }) {
Text('⭐ ' + PUB_INIT[i].rating.toFixed(1)).fontSize(9).fontColor(COLORS.gold)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(PUB_INIT[i].distance).fontSize(9).fontColor(COLORS.amber)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(PUB_INIT[i].taps + '酒头').fontSize(9).fontColor(COLORS.sub)
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('›').fontSize(14).fontColor(COLORS.sub)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
// ===== Tab 5: 排行 =====
@Builder
rankTab() {
Column({ space: 10 }) {
Row({ space: 6 }) {
Text('周榜').fontSize(10).fontColor('#FFFFFF')
.backgroundColor(COLORS.amber)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
Text('月榜').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
Text('总榜').fontSize(10).fontColor(COLORS.sub)
.backgroundColor(COLORS.card)
.padding({ left: 10, right: 10, top: 4, bottom: 4 }).borderRadius(10)
}
.width('100%')
Text('🏆 精酿啤酒排行榜').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], (i: number) => {
Row({ space: 10 }) {
Column() {
Text(rankMedal(i)).fontSize(i < 3 ? 22 : 16)
.fontColor(rankColor(i)).fontWeight(FontWeight.Bold)
}
.width(36).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text(BEER_RANK[i]).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Row({ space: 6 }) {
Text('⭐ ' + RANK_SCORES[i].toFixed(1)).fontSize(9).fontColor(COLORS.gold)
Text('·').fontSize(9).fontColor(COLORS.text3)
Text(['比利时', '美国', '美国', '美国', '比利时', '苏格兰', '爱尔兰', '美国', '美国', '美国'][i]).fontSize(9).fontColor(COLORS.sub)
}
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column() {
Text('试饮').fontSize(9).fontColor('#FFFFFF')
}
.padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(6)
.backgroundColor(COLORS.amber)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
})
}
.width('100%')
}
.width('100%')
}
// ===== Tab 6: 我的 =====
@Builder
profileTab() {
Column({ space: 12 }) {
Column({ space: 8 }) {
Row({ space: 12 }) {
Column() {
Text('🍺').fontSize(32)
}
.width(56).height(56).borderRadius(28).backgroundColor(COLORS.chip)
.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('精酿老饕').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('品鉴等级 Lv.12 · 已品鉴186款').fontSize(10).fontColor(COLORS.sub)
Text('🏅 精酿达人认证').fontSize(9).fontColor(COLORS.amber)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
}
.width('100%')
}
.width('100%').padding(14).borderRadius(16).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('186').fontSize(20).fontColor(COLORS.amber).fontWeight(FontWeight.Bold)
Text('已品鉴').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('42').fontSize(20).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Text('评测数').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('28').fontSize(20).fontColor(COLORS.brown).fontWeight(FontWeight.Bold)
Text('收藏酒款').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).padding(10).borderRadius(10)
.backgroundColor(COLORS.card).alignItems(HorizontalAlign.Center)
}
.width('100%')
Text('📝 品鉴笔记').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 6 }) {
ForEach([0, 1, 2, 3], (i: number) => {
Row({ space: 10 }) {
Text(REVIEW_INIT[i].avatar).fontSize(20)
Column({ space: 2 }) {
Text(REVIEW_INIT[i].beerName).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
Text(REVIEW_INIT[i].content.substring(0, 30) + '...').fontSize(9).fontColor(COLORS.sub)
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('⭐ ' + REVIEW_INIT[i].rating).fontSize(10).fontColor(COLORS.gold)
}
.width('100%').padding(10).borderRadius(10)
.backgroundColor(COLORS.card)
.onClick(() => { this.editIdx = i; this.editModal = true; })
})
}
.width('100%')
Text('⚙️ 设置').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.width('100%')
Column({ space: 0 }) {
Row({ space: 10 }) {
Text('🍺').fontSize(16)
Text('口味偏好').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('IPA / 世涛 ›').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Text('🔔').fontSize(16)
Text('上新提醒').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('已开启 ›').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
Row({ space: 10 }) {
Text('⚙️').fontSize(16)
Text('设置').fontSize(12).fontColor(COLORS.sub).layoutWeight(1)
Text('›').fontSize(10).fontColor(COLORS.amber)
}
.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
}
.width('100%')
}
.width('100%')
}
build() {
Stack({ alignContent: Alignment.Center }) {
Column() {
this.header()
Scroll() {
Column({ space: 10 }) {
if (this.tabIdx === 0) { this.recommendTab() }
else if (this.tabIdx === 1) { this.beerTab() }
else if (this.tabIdx === 2) { this.reviewTab() }
else if (this.tabIdx === 3) { this.pubTab() }
else if (this.tabIdx === 4) { this.mapTab() }
else if (this.tabIdx === 5) { this.rankTab() }
else { this.profileTab() }
}
.width('100%').padding({ left: 14, right: 14, top: 10, bottom: 16 })
}
.layoutWeight(1).scrollBar(BarState.Off).align(Alignment.Top)
this.tabBar()
}
.width('100%').height('100%')
// 气泡特效层
if (this.bubble) {
Row({ space: 50 }) {
Text('○').fontSize(10).fontColor(COLORS.gold).opacity(0.3)
Text('◦').fontSize(8).fontColor(COLORS.amber).opacity(0.2)
Text('○').fontSize(12).fontColor(COLORS.gold).opacity(0.15)
Text('◦').fontSize(6).fontColor(COLORS.amber).opacity(0.25)
}
.width('100%').height(16).position({ x: 0, y: 80 }).zIndex(1)
}
// ===== 新增评测弹框 =====
if (this.addModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.addModal = false; })
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('✍️ 写评测').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.addModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('品鉴酒款').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '输入酒款名称', text: this.addBeer })
.onChange((v: string) => { this.addBeer = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.dark)
.fontColor(COLORS.title)
Text('评分').fontSize(10).fontColor(COLORS.sub)
Row({ space: 8 }) {
ForEach([5, 4, 3, 2, 1], (r: number) => {
Text(r <= this.addRating ? '⭐' : '☆').fontSize(18)
.onClick(() => { this.addRating = r; })
})
}
.width('100%')
Text('品鉴感受').fontSize(10).fontColor(COLORS.sub)
TextArea({ placeholder: '描述口感、香气、回味...' })
.onChange((v: string) => { this.addContent = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.dark)
.fontColor(COLORS.title).height(60)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = false; })
Column() { Text('发布评测').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.amber).alignItems(HorizontalAlign.Center)
.onClick(() => { this.addModal = false; })
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '80%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
// ===== 编辑弹框 =====
if (this.editModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.editModal = false; })
Column({ space: 12 }) {
Row({ space: 8 }) {
Text('✏️ 编辑评测').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('✕').fontSize(16).fontColor(COLORS.sub)
.onClick(() => { this.editModal = false; })
}
.width('100%')
Divider().color(COLORS.line)
Text('当前: ' + (this.editIdx < this.reviewList.length ? this.reviewList[this.editIdx].beerName : '')).fontSize(11).fontColor(COLORS.amber)
Text('修改内容').fontSize(10).fontColor(COLORS.sub)
TextInput({ placeholder: '输入修改后的内容', text: this.editContent })
.onChange((v: string) => { this.editContent = v; })
.fontSize(12).padding(10).borderRadius(8).backgroundColor(COLORS.dark)
.fontColor(COLORS.title)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editModal = false; })
Column() { Text('保存').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.amber).alignItems(HorizontalAlign.Center)
.onClick(() => { this.editModal = false; })
}
.width('100%')
}
.width('82%').constraintSize({ maxHeight: '70%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
}
.width('100%').height('100%').zIndex(999)
}
// ===== 删除确认弹框 =====
if (this.delModal) {
Stack({ alignContent: Alignment.Center }) {
this.modalOverlay(() => { this.delModal = false; })
Column({ space: 12 }) {
Text('🗑️').fontSize(36)
Text('删除这条评测?').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('删除后评测记录将不可恢复').fontSize(10).fontColor(COLORS.red)
Row({ space: 10 }) {
Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.darkL).alignItems(HorizontalAlign.Center)
.onClick(() => { this.delModal = false; })
Column() { Text('确认删除').fontSize(11).fontColor('#FFFFFF') }
.layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
.backgroundColor(COLORS.red).alignItems(HorizontalAlign.Center)
.onClick(() => {
this.reviewList.splice(this.editIdx, 1);
this.delModal = false;
})
}
.width('100%')
}
.width('76%').constraintSize({ maxHeight: '60%' }).padding(16)
.borderRadius(16).backgroundColor(COLORS.card)
.alignItems(HorizontalAlign.Center)
}
.width('100%').height('100%').zIndex(999)
}
}
.width('100%').height('100%').backgroundColor(COLORS.bg)
}
}
七、总结

从架构层面审视,本项目展示了 ArkTS 声明式 UI 在内容密集型社区应用中的完整工程实践。五层分离架构(数据模型层、静态常量层、辅助函数层、视图构建层、主入口构建层)使代码职责清晰、关注点分离——数据模型的变更不会波及视图构建逻辑,配色方案的调整不会影响业务数据处理。这种分层由 ArkTS 的语言特性(接口约束、装饰器语义、Builder 作用域)自然引导。在实际团队协作中,前端开发者可以并行工作于不同 Tab 的 Builder 而互不冲突,设计师可独立调整 COLORS 常量而无需理解组件代码。
从状态管理角度反思,本项目对 @State 与 @Observed 的组合运用提供了有价值的实践范本。@State 适用于组件内部的简单状态(布尔开关、索引值、字符串输入),其变更直接触发 build() 重新评估;@Observed 适用于数组元素的复杂对象,其属性变更可被 @ObjectLink 精准捕获。项目中删除评测通过 splice 修改 @State 数组触发整体刷新;但若未来需要支持"点赞数实时 +1"这类元素属性变更,则需要在评测卡片组件中通过 @ObjectLink 绑定 Review 实例实现局部刷新。这一扩展路径已在当前架构中预留。
从性能优化维度考量,本项目的实现策略在移动端有限算力下表现稳健。Tab 切换采用条件渲染而非 Visibility 控制,避免了同时挂载七个 Tab 内容树的内存占用;ForEach 的列表规模均控制在 15 条以内,diff 开销可忽略;Scroll 容器隐藏滚动条减少绘制开销;弹框通过条件渲染实现按需挂载,不显示时零渲染开销。若未来数据量增长,可考虑引入 LazyForEach 替代 ForEach 实现懒加载,将内存占用从 O(n) 降至 O(viewport)。
从交互设计维度分析,本项目的弹框系统体现了"状态即交互"的声明式理念。三个弹框的显隐完全由 addModal、editModal、delModal 三个布尔状态驱动——打开弹框就是将状态设为 true,关闭就是 false,UI 可见性自动跟随状态变化。editIdx 状态作为弹框与列表之间的"桥梁变量",在打开弹框前记录目标评测索引,使弹框内部能精确定位到正确的数据条目。
从可扩展性维度展望,本项目的代码结构为后续功能迭代预留了充分的扩展空间。酒款页的 styleFilter 状态已声明但筛选逻辑尚未接入,未来只需在 beerList 的 ForEach 前增加 filter 逻辑即可实现风格筛选;新增评测弹框的表单数据已绑定到状态,未来在"发布评测"按钮的 onClick 中增加 this.reviewList.unshift(new Review(...)) 即可实现评测发布;地图页的 emoji 占位可替换为华为地图 SDK 的 MapComponent,证明当前架构具备可生长的扩展骨架。
从行业适配维度总结,精酿啤酒社区应用的数据特征(高维度属性、大量条目、频繁交互、社区驱动)对前端框架提出了综合性挑战,ArkTS 的声明式 UI 范式在这一场景中展现了良好的适配性。@Observed 机制天然适配社区内容的频繁编辑场景;@Builder 封装使七个风格迥异的 Tab 页面能共享统一的代码组织模式;ForEach 的高效 diff 支撑了长列表的流畅滚动;Stack 层叠布局优雅地解决了弹框与特效层的 z-axis 管理问题。这些技术能力的组合使一个超过千行源码的复杂社区应用能够以单一组件结构体的形式完整表达,对于其他内容密集型社区应用亦具参考价值。
更多推荐



所有评论(0)