街头滑板公园应用:HarmonyOS ArkTS API 24从色彩体系到组件架构的全景解析,实现了跨组件的深度可观察数据流,使得状态变更能够自动驱动所有依赖该数据的视图更新
引言

在移动应用开发领域,垂直社区类应用正逐渐成为连接特定兴趣群体的核心载体。本文将深入剖析一款面向街头滑板文化社区的移动端应用,该应用覆盖了场地管理、板友排行、赛事组织、装备商城和课程教学五大核心业务场景。通过这款应用,滑板爱好者可以一站式获取场地客流信息、查看板友排名、报名参加赛事、选购专业装备以及预约教学课程。从产品定位来看,这不仅仅是一个信息聚合工具,更是一个承载街头文化精神的数字平台,其设计理念始终围绕着"街头、自由、表达"三个核心关键词展开。
在技术选型方面,该应用采用了声明式UI开发范式,基于ArkTS语言和ArkUI框架构建。ArkTS在TypeScript基础上扩展了UI描述能力,通过装饰器系统(如@Entry、@Component、@State、@Observed、@ObjectLink、@Prop等)实现了响应式数据绑定和组件化开发模式。这种选择带来了几个显著优势:首先,声明式UI让开发者只需描述界面在任意状态下的样子,框架自动处理状态到视图的映射,大幅降低了命令式编程中手动操作DOM的复杂度。其次,ArkTS的类型系统在编译期提供严格检查,有效减少了运行时错误。再者,@Observed与@ObjectLink的组合实现了跨组件的深度可观察数据流,使得状态变更能够自动驱动所有依赖该数据的视图更新。
色彩体系是该应用视觉设计的灵魂。整个应用定义了一套名为"SkatePalette"的调色板,包含20个精心调配的色彩变量。主色采用充满活力的橙红色(#FF6B35),辅以明黄色(#FFC93C)作为强调色,营造出街头运动的热情与能量。深色墨黑(#23232B)用于文字和描边,确保信息层级的清晰度。粉色(#FF2E63)和青色(#35D0BA)作为点缀色分别承载危险/热门状态和成功/可用状态。背景采用暖米色(#F4F0E9),模拟城市街道的混凝土质感。这套色彩体系不仅统一了视觉风格,更通过色彩语义化映射,让用户能够直觉性地理解信息含义——例如红色代表S级赛事,黄色代表中级难度,绿色代表初级课程。
在组件化策略上,该应用采用了"主入口+内容页+弹窗+原子组件"的四层架构。主入口组件SkateApp作为应用壳层,负责顶部标题栏渲染、底部Tab导航和弹窗调度。五个内容页组件(ParkContent、SkaterContent、MatchContent、GearContent、LessonContent)各自独立管理对应业务模块的列表渲染和数据可视化。四个弹窗组件(AddSkaterModal、EditMatchModal、DeleteGearModal、DetailParkModal)处理用户交互的二次确认和信息录入。SkateTag作为原子级可复用组件,在多个内容页中提供统一的标签渲染能力。这种分层策略确保了单一职责原则的落实,每个组件只关注自己的业务域,通过回调函数和可观察数据实现组件间通信。
数据架构方面,应用采用了集中式数据管理模式。所有业务数据封装在@Observed装饰的SkateData类中,包含场地、板友、赛事、装备、课程五个数组属性。主入口通过@State持有该数据实例的引用,各内容页通过@ObjectLink建立对同一数据实例的可观察链接。这意味着任何对数据的修改都会自动触发所有相关视图的更新,无需手动调用刷新方法。静态配置数据(如每日客流、风格占比、赛事热度、装备销量排行)则定义为模块级常量,在编译期就确定值,避免了运行时不必要的重计算。
逐段代码分析
一、色彩体系与调色板定义

interface SkatePalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
ink: string;
pink: string;
cyan: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
line: string;
success: string;
warning: string;
danger: string;
white: string;
concrete: string;
lime: string;
}
这段代码定义了SkatePalette接口,它声明了应用所需的全部20个颜色字段。通过接口定义颜色契约,而非直接使用字面量字符串,设计上实现了类型安全与语义化双重目标。每个字段名都有明确的语义指向:primary系列表示品牌主色及其明暗变体,accent系列表示辅助强调色,ink表示深色墨水色用于文本和描边,textPrimary到textHint构成三级文字颜色梯度。success、warning、danger三个语义状态色借鉴了交通信号灯的认知模型,让用户无需阅读文字即可判断状态。这种接口先行的设计模式使得颜色管理具有可扩展性——未来如果需要支持暗色主题,只需实现一个新的SkatePalette对象即可,所有消费颜色的组件无需修改。
const COLORS: SkatePalette = {
primary: '#FF6B35',
primaryLight: '#FFA07A',
primaryDark: '#D84A12',
accent: '#FFC93C',
accentLight: '#FFF3D0',
ink: '#23232B',
pink: '#FF2E63',
cyan: '#35D0BA',
bg: '#F4F0E9',
cardBg: '#FFFFFF',
textPrimary: '#23232B',
textSecondary: '#6E6A63',
textHint: '#A8A49C',
line: '#E8E1D6',
success: '#35D0BA',
warning: '#FFC93C',
danger: '#FF2E63',
white: '#FFFFFF',
concrete: '#CFC9BF',
lime: '#B8E64A'
};
COLORS常量是调色板接口的具体实现,以SkatePalette类型约束确保了字段完整性。从色彩学角度分析,主色#FF6B35位于色相环的橙色区域,饱和度高、明度适中,传递出活力与运动的视觉信号。#FFC93C金黄色作为强调色,与主色形成类似色搭配,和谐而不失亮点。#23232B墨黑色作为文字主色,与暖米色背景#F4F0E9形成足够对比度,确保可读性。值得注意的是success和cyan共用了#35D0BA,warning和accent共用了#FFC93C,这种复用策略减少了视觉噪音,让色彩语言更加凝练。lime色#B8E64A是整套色板中最亮的颜色,专门用于课程模块以区分其他业务场景。将所有颜色集中在一个常量对象中,使得全局色彩调整只需修改一处代码,体现了DRY(Don’t Repeat Yourself)原则的工程价值。
二、Tab导航系统

enum SkateTab {
PARK = 0,
SKATER = 1,
MATCH = 2,
GEAR = 3,
LESSON = 4
}
interface TabMeta {
key: string;
icon: string;
label: string;
color: string;
}
const TAB_LIST: TabMeta[] = [
{ key: 'park', icon: '🛹', label: '场地', color: '#FF6B35' },
{ key: 'skater', icon: '🧑🎤', label: '板友', color: '#FF2E63' },
{ key: 'match', icon: '🏆', label: '赛事', color: '#FFC93C' },
{ key: 'gear', icon: '🎒', label: '装备', color: '#35D0BA' },
{ key: 'lesson', icon: '🎓', label: '课程', color: '#B8E64A' }
];
Tab导航系统由三部分组成:SkateTab枚举定义了五个页面的索引值,TabMeta接口描述了每个Tab的元数据结构,TAB_LIST数组聚合了所有Tab的配置信息。使用枚举而非魔法数字提升了代码可读性,在条件分支中curTab === SkateTab.PARK远比curTab === 0直观。TabMeta将图标、标签、主题色打包为一个语义单元,符合内聚性设计原则。每个Tab分配了独立的主题色,场地用主色橙、板友用粉色、赛事用金黄、装备用青色、课程用亮绿,这种"一Tab一色"的策略让用户可以通过色彩快速定位当前所在页面。使用Emoji作为图标是一种轻量化方案,无需引入图标字体或图片资源,同时保持了跨平台的一致性表现。Tab元数据以数组形式组织,天然支持ForEach遍历渲染,后续底部导航栏的构建完全数据驱动。
三、数据模型接口定义

interface ParkItem {
name: string;
area: string;
difficulty: string;
capacity: number;
flow: number;
rating: number;
emoji: string;
}
interface SkaterItem {
name: string;
level: string;
tricks: number;
style: string;
fans: number;
rank: number;
emoji: string;
}
ParkItem和SkaterItem分别定义了场地和板友两个核心业务实体的数据结构。ParkItem包含名称、区域、难度、容量、热度值、评分和图标七个字段,覆盖了用户选择场地所需的全部决策信息。其中flow热度值是一个0到100的数值,后续在UI层映射为进度条宽度,实现数据的可视化呈现。SkaterItem的设计则聚焦于社交属性,rank排名字段驱动了前三名特殊样式(粉色背景徽章),fans粉丝数超过5000时显示奖牌图标,level水平标签通过SkateTag组件统一渲染。两个接口都包含emoji字段,这是一种刻意的设计选择——用Emoji替代图片资源既减小了应用体积,又保持了视觉的活泼感。字段类型严格使用string和number,避免了any类型的滥用,体现了TypeScript类型最佳实践。
interface MatchItem {
name: string;
date: string;
prize: string;
signup: number;
joined: number;
level: string;
emoji: string;
}
interface GearItem {
name: string;
brand: string;
price: number;
stock: number;
sales: number;
rating: number;
emoji: string;
}
interface LessonItem {
name: string;
coach: string;
price: number;
seats: number;
joined: number;
level: string;
emoji: string;
}
MatchItem、GearItem和LessonItem分别描述了赛事、装备和课程三个业务实体。赛事模型中signup和joined两个字段的组合,既能展示报名进度(已报/总额),又能计算报名率作为热度指标。装备模型的price和stock字段支持价格展示和库存预警。课程模型的seats和joined组合用于计算报名进度条,当joined === seats时触发"已满员"状态。三个接口都有一个level字段,但语义不同:赛事的level是S/A/B/C等级评定,装备没有level但有brand品牌分类,课程的level是初级/中级/高级/全级别。这种同名字段不同语义的设计在实际业务中很常见,通过上下文区分含义是合理的工程取舍。所有接口都保持了扁平结构,没有嵌套对象,这简化了ArkUI中ForEach的数据绑定逻辑。
interface FlowMeta {
day: string;
value: number;
}
interface StyleMeta {
name: string;
count: number;
color: string;
}
interface MatchHotMeta {
name: string;
joined: number;
color: string;
}
interface GearTopMeta {
name: string;
sales: number;
color: string;
}
这四个接口定义了图表和排行榜所需的元数据结构。FlowMeta用于每日客流量柱状图,day为周几标签,value为客流数值。StyleMeta、MatchHotMeta和GearTopMeta都携带color字段,这是因为它们都用于渲染彩色进度条——每个条目有独立颜色以增强视觉区分度。将颜色数据与业务数据绑定在一起,而非在UI层通过索引或条件判断分配颜色,是一种数据驱动UI的设计策略。当数据顺序变化时,颜色会随数据一起移动,保证视觉一致性。这些元数据接口与前面的ParkItem等主实体接口分离,体现了"展示数据"与"业务数据"的关注点分离。主实体接口用于ForEach渲染列表项,元数据接口用于渲染图表和统计模块。
四、静态配置数据

const DAILY_FLOW: FlowMeta[] = [
{ day: '周一', value: 32 },
{ day: '周二', value: 28 },
{ day: '周三', value: 35 },
{ day: '周四', value: 41 },
{ day: '周五', value: 58 },
{ day: '周六', value: 92 },
{ day: '周日', value: 86 }
];
const STYLE_SHARE: StyleMeta[] = [
{ name: '街式', count: 4, color: '#FF6B35' },
{ name: '碗池', count: 3, color: '#FF2E63' },
{ name: '竞速', count: 2, color: '#FFC93C' },
{ name: '技巧', count: 2, color: '#35D0BA' },
{ name: '涂鸦', count: 1, color: '#B8E64A' }
];
DAILY_FLOW定义了场地一周客流数据,数据趋势真实反映了滑板运动的周期规律——工作日客流较低(28-41),周末客流爆满(86-92)。这组数据在UI层被渲染为柱状图,高度为value * 1.05像素,数值超过80的柱子用粉色高亮表示"爆满"状态。STYLE_SHARE定义了板友风格分布,共12人分属5种风格。街式占比最高(4人),这与现实中街式滑板的流行度吻合。每种风格分配了调色板中的独立颜色,进度条宽度通过count / 4的layoutWeight比例计算实现。将这类展示性数据定义为模块级常量而非运行时获取,在原型开发和概念验证阶段是高效的策略,它让开发者可以专注于UI实现而暂不关心后端接口对接。后续接入真实数据时,只需将常量替换为API返回值即可,组件层代码无需改动。
const MATCH_HOT: MatchHotMeta[] = [
{ name: '年终总决赛', joined: 175, color: '#FF2E63' },
{ name: '街头之王赛', joined: 168, color: '#FF6B35' },
{ name: '新人积分赛', joined: 156, color: '#35D0BA' },
{ name: '城市巡回赛', joined: 128, color: '#FFC93C' },
{ name: '碗池公开赛', joined: 122, color: '#B8E64A' },
{ name: '团队对抗赛', joined: 96, color: '#FF6B35' }
];
const GEAR_TOP: GearTopMeta[] = [
{ name: '枫木板面', sales: 96, color: '#FF6B35' },
{ name: '掌心防滑贴', sales: 95, color: '#FF2E63' },
{ name: '软轮套组', sales: 92, color: '#35D0BA' },
{ name: '头盔护具', sales: 90, color: '#FFC93C' },
{ name: '夜光轮', sales: 87, color: '#B8E64A' },
{ name: '轴承八件套', sales: 85, color: '#FF6B35' }
];
MATCH_HOT和GEAR_TOP分别是赛事热度榜和装备销量榜的数据源。赛事热度榜按报名人数降序排列,年终总决赛以175人位居榜首。进度条宽度以最大值175为基准计算joined / 175的比例,确保最长的条目填满整个进度槽。装备销量榜的基准值为96(枫木板面),其他商品的进度条按sales / 96的比例渲染。两组数据都使用了与Tab系统一致的五色调色板,视觉上与应用整体风格统一。排行榜数据按值降序排列,这在数据层就保证了展示顺序,UI层无需额外排序逻辑。值得注意的是,两组数据都只有6条记录,这在移动端屏幕上恰好填满一屏,避免了滚动带来的信息过载。这种"一屏可览"的设计考量在移动端信息密度控制中尤为重要。
五、可观察数据模型

@Observed
export class SkateData {
parks: ParkItem[] = [
{ name: 'U型碗池', area: '碗池区', difficulty: '高级', capacity: 30, flow: 85, rating: 4.9, emoji: '🏟️' },
{ name: '街式道具区', area: '街区', difficulty: '中级', capacity: 45, flow: 92, rating: 4.8, emoji: '🧱' },
{ name: '迷你坡道', area: '坡道区', difficulty: '初级', capacity: 25, flow: 78, rating: 4.7, emoji: '🛤️' },
{ name: '竞速直线道', area: '竞速区', difficulty: '中级', capacity: 35, flow: 66, rating: 4.8, emoji: '🏁' },
{ name: '初学者乐园', area: '新手区', difficulty: '初级', capacity: 50, flow: 95, rating: 4.9, emoji: '🎈' },
{ name: '空中技巧台', area: '技巧区', difficulty: '高级', capacity: 20, flow: 58, rating: 4.8, emoji: '🚀' },
{ name: '街头阶梯场', area: '街区', difficulty: '高级', capacity: 28, flow: 72, rating: 4.7, emoji: '🪜' },
{ name: '室内旱冰场', area: '室内', difficulty: '初级', capacity: 40, flow: 64, rating: 4.6, emoji: '🏠' },
{ name: '金字塔道具', area: '道具区', difficulty: '中级', capacity: 22, flow: 69, rating: 4.7, emoji: '🔺' },
{ name: '碗池深水区', area: '碗池区', difficulty: '专家', capacity: 15, flow: 45, rating: 4.9, emoji: '🌊' },
{ name: '夜光涂鸦场', area: '涂鸦区', difficulty: '初级', capacity: 38, flow: 88, rating: 4.8, emoji: '🌌' },
{ name: '亲子趣味场', area: '亲子区', difficulty: '初级', capacity: 32, flow: 74, rating: 4.8, emoji: '🧒' }
];
@Observed装饰器将SkateData类标记为可观察对象,这是ArkUI响应式系统的核心机制之一。被@Observed标记的类,其实例属性的变化会被框架自动追踪,任何通过@ObjectLink或@State引用该实例的组件都会在属性变化时收到通知并触发重新渲染。parks数组包含12个场地条目,覆盖了碗池、街区、坡道、竞速、新手、技巧、室内、道具、涂鸦、亲子等全场景类型。每个场地的flow热度值与其difficulty难度形成有趣的反差——例如初学者乐园热度95但难度初级,碗池深水区热度45但难度专家,这反映了用户群体在不同场地的分布规律。capacity容量字段从15到50不等,为后续的容量管理功能预留了数据基础。数据在类定义时直接初始化,无需构造函数,这简化了实例化过程——new SkateData()即可获得完整数据。
skaters: SkaterItem[] = [
{ name: '阿凯', level: '职业', tricks: 540, style: '街式', fans: 8200, rank: 1, emoji: '🧢' },
{ name: '小野', level: '职业', tricks: 486, style: '碗池', fans: 7600, rank: 2, emoji: '🕶️' },
{ name: '豆豆', level: '进阶', tricks: 320, style: '街区', fans: 4300, rank: 3, emoji: '🐶' },
{ name: '阿杰', level: '进阶', tricks: 298, style: '技巧', fans: 3900, rank: 4, emoji: '🔥' },
{ name: '琪琪', level: '进阶', tricks: 275, style: '竞速', fans: 3500, rank: 5, emoji: '🎀' },
{ name: '大熊', level: '职业', tricks: 512, style: '坡道', fans: 6800, rank: 6, emoji: '🐻' },
{ name: '阿豪', level: '初级', tricks: 168, style: '新手', fans: 1500, rank: 7, emoji: '🎒' },
{ name: '桃子', level: '进阶', tricks: 246, style: '涂鸦', fans: 2800, rank: 8, emoji: '🍑' },
{ name: '小满', level: '职业', tricks: 468, style: '街区', fans: 6100, rank: 9, emoji: '🌾' },
{ name: '阿泽', level: '初级', tricks: 145, style: '新手', fans: 1200, rank: 10, emoji: '🎧' },
{ name: '彤彤', level: '进阶', tricks: 232, style: '亲子', fans: 2400, rank: 11, emoji: '🦋' },
{ name: '老狼', level: '职业', tricks: 555, style: '碗池', fans: 8900, rank: 12, emoji: '🐺' }
];
skaters数组定义了12位板友数据,排名从1到12。数据设计上体现了多样性:level字段覆盖了职业、进阶、初级三个等级;style字段涵盖了街式、碗池、街区、技巧、竞速、坡道、新手、涂鸦、亲子九种风格。tricks技巧数量与level正相关——职业选手的技巧数普遍在450以上,而初级选手在150左右。fans粉丝数超过5000的板友会在UI层显示奖牌图标。排名前三的板友使用粉色背景徽章高亮,这与体育赛事中金银铜牌的视觉惯例一致。值得注意的是,老狼以8900粉丝排名第12,而阿凯以8200粉丝排名第1,说明排名依据并非粉丝数而是综合竞技水平。这种多维度的数据设计让排行榜的信息密度更高,用户可以从不同角度解读数据。
matches: MatchItem[] = [
{ name: '街头之王挑战赛', date: '07-20', prize: '2万', signup: 200, joined: 168, level: 'S级', emoji: '🏆' },
{ name: '碗池全国公开赛', date: '08-02', prize: '5万', signup: 150, joined: 122, level: 'S级', emoji: '🥇' },
{ name: '涂鸦风格赛', date: '08-15', prize: '8千', signup: 120, joined: 88, level: 'A级', emoji: '🎨' },
{ name: '新人积分赛', date: '08-22', prize: '3千', signup: 180, joined: 156, level: 'B级', emoji: '🌱' },
{ name: '夜光刷街夜', date: '09-01', prize: '5千', signup: 300, joined: 240, level: 'A级', emoji: '🌌' },
{ name: '竞速破纪录赛', date: '09-10', prize: '1万', signup: 80, joined: 55, level: 'A级', emoji: '🏁' },
{ name: '亲子趣味赛', date: '09-20', prize: '2千', signup: 100, joined: 86, level: 'C级', emoji: '🧒' },
{ name: '技巧大师赛', date: '10-05', prize: '3万', signup: 90, joined: 71, level: 'S级', emoji: '🚀' },
{ name: '城市巡回赛', date: '10-18', prize: '2万', signup: 160, joined: 128, level: 'A级', emoji: '🏙️' },
{ name: '坡道翻板赛', date: '11-01', prize: '8千', signup: 70, joined: 52, level: 'B级', emoji: '🛤️' },
{ name: '团队对抗赛', date: '11-15', prize: '1.5万', signup: 120, joined: 96, level: 'A级', emoji: '⚔️' },
{ name: '年终总决赛', date: '12-20', prize: '10万', signup: 200, joined: 175, level: 'S级', emoji: '👑' }
];
matches数组包含12场赛事,时间跨度从7月到12月,覆盖了整个下半年赛历。date字段采用MM-DD格式,在UI层通过substring(5)提取日、substring(0, 5)提取月,巧妙地拆分显示为日历样式。prize奖金字段使用中文字符串(如"2万"、“10万”)而非纯数字,这在展示层面更直观。level字段采用S/A/B/C四级评定体系,其中S级赛事有4场、A级5场、B级2场、C级1场,分布合理。joined与signup的比值反映了报名率,例如夜光刷街夜的报名率高达80%(240/300),而竞速破纪录赛只有69%(55/80)。这些数据在赛事热度榜中以进度条形式可视化呈现。年终总决赛以10万奖金和175人报名成为年度最高规格赛事,获得了皇冠Emoji的视觉标记。
gears: GearItem[] = [
{ name: '枫木板面', brand: '板面', price: 399, stock: 42, sales: 96, rating: 4.9, emoji: '🛹' },
{ name: '铝合金支架', brand: '支架', price: 299, stock: 35, sales: 88, rating: 4.8, emoji: '⚙️' },
{ name: '软轮套组', brand: '轮子', price: 199, stock: 60, sales: 92, rating: 4.8, emoji: '🛞' },
{ name: '轴承八件套', brand: '轴承', price: 159, stock: 48, sales: 85, rating: 4.8, emoji: '🔩' },
{ name: '头盔护具套装', brand: '护具', price: 459, stock: 30, sales: 90, rating: 4.9, emoji: '🪖' },
{ name: '滑板鞋', brand: '鞋履', price: 899, stock: 18, sales: 76, rating: 4.8, emoji: '👟' },
{ name: '掌心防滑贴', brand: '配件', price: 49, stock: 120, sales: 95, rating: 4.9, emoji: '🖐️' },
{ name: '便携工具组', brand: '工具', price: 129, stock: 55, sales: 82, rating: 4.7, emoji: '🔧' },
{ name: '夜光轮', brand: '轮子', price: 259, stock: 40, sales: 87, rating: 4.8, emoji: '🌟' },
{ name: '长板滑板', brand: '整板', price: 1299, stock: 12, sales: 68, rating: 4.9, emoji: '🏄' },
{ name: '儿童滑板', brand: '整板', price: 599, stock: 26, sales: 79, rating: 4.8, emoji: '🧒' },
{ name: '滑板背包', brand: '背包', price: 349, stock: 32, sales: 74, rating: 4.7, emoji: '🎒' }
];
gears数组定义了12件装备商品,brand字段覆盖了板面、支架、轮子、轴承、护具、鞋履、配件、工具、整板、背包十大品类。价格区间从49元的防滑贴到1299元的长板,跨度近27倍,满足不同消费能力用户的需求。stock库存字段从12到120不等,滑板鞋和长板库存最低(18和12),这些低库存商品在UI层可能需要库存预警提示。sales月销量数据与GEAR_TOP静态常量保持一致,枫木板面以96件月销位居榜首。rating评分均在4.7以上,反映了商品质量控制的高标准。brand字段在UI层通过SkateTag组件以青色标签呈现,统一了品类标识的视觉语言。每个商品都有独立的Emoji图标,用户在浏览列表时可以通过图标快速识别商品类型。
lessons: LessonItem[] = [
{ name: '零基础入门班', coach: '阿凯', price: 199, seats: 20, joined: 18, level: '初级', emoji: '🎓' },
{ name: '街式动作进阶', coach: '小野', price: 299, seats: 15, joined: 12, level: '中级', emoji: '🧱' },
{ name: '碗池入坑课', coach: '大熊', price: 349, seats: 12, joined: 10, level: '中级', emoji: '🌊' },
{ name: '竞速提速训练', coach: '阿杰', price: 259, seats: 16, joined: 13, level: '中级', emoji: '🏁' },
{ name: '涂鸦滑行体验', coach: '桃子', price: 229, seats: 18, joined: 16, level: '初级', emoji: '🎨' },
{ name: '空中技巧专项', coach: '老狼', price: 499, seats: 8, joined: 6, level: '高级', emoji: '🚀' },
{ name: '亲子启蒙课', coach: '彤彤', price: 259, seats: 15, joined: 14, level: '初级', emoji: '🧒' },
{ name: '女子滑板营', coach: '琪琪', price: 279, seats: 14, joined: 12, level: '中级', emoji: '🎀' },
{ name: '夜刷安全课', coach: '豆豆', price: 189, seats: 22, joined: 19, level: '初级', emoji: '🌌' },
{ name: '道具进阶班', coach: '阿豪', price: 319, seats: 10, joined: 8, level: '中级', emoji: '🔺' },
{ name: '大师特训营', coach: '老狼', price: 799, seats: 6, joined: 4, level: '高级', emoji: '👑' },
{ name: '学期集训班', coach: '小满', price: 999, seats: 20, joined: 17, level: '全级别', emoji: '📚' }
];
}
lessons数组是SkateData类的最后一个属性,定义了12门课程。coach教练字段引用了skaters数组中的板友姓名,建立了数据间的关联关系——阿凯既是排名第一的板友,也是零基础入门班的教练。seats与joined的比值用于计算报名进度条,当两者相等时显示"已满员"红色提示。课程价格从189元到999元,大师特训营和学期集训班价格最高。level字段覆盖初级、中级、高级和全级别四档,与场地难度体系和板友水平体系保持一致。全级别课程"学期集训班"是一个特殊设计,表示该课程适合所有水平的学员。每个课程的Emoji与对应场地或赛事的Emoji保持一致(如🌊对应碗池、🚀对应空中技巧),建立了跨模块的视觉关联。类定义在此结束,@Observed装饰器使得整个类的所有数组属性都具备可观察性。
六、工具函数

function getLevelColor(level: string): string {
return level === 'S级' ? COLORS.danger : (level === 'A级' ? COLORS.primary : (level === 'B级' ? COLORS.warning : COLORS.cyan));
}
function getDiffColor(diff: string): string {
return diff === '专家' ? COLORS.ink : (diff === '高级' ? COLORS.danger : (diff === '中级' ? COLORS.warning : COLORS.success));
}
两个工具函数分别处理赛事等级和场地难度的颜色映射。getLevelColor将S级映射为红色(danger)、A级映射为橙色(primary)、B级映射为黄色(warning)、其他映射为青色(cyan)。getDiffColor将专家级映射为墨黑色(ink)、高级映射为红色、中级映射为黄色、初级映射为绿色(success)。这两个函数采用了嵌套三元表达式链的形式,虽然可读性不如switch-case,但在行数上更紧凑。颜色映射逻辑体现了语义化设计:等级越高颜色越深越暖(红>橙>黄>青),难度越高颜色越深越暖(黑>红>黄>绿)。这种色彩递进关系让用户能够通过颜色直觉判断等级高低,无需阅读文字。函数是纯函数,无副作用,相同输入永远产生相同输出,适合在任何上下文中调用。将颜色决策逻辑从UI组件中抽离为独立函数,是关注点分离原则的体现——组件负责渲染,函数负责色彩策略。
七、主入口组件
@Entry
@Component
struct SkateApp {
@State curTab: number = 0;
@State data: SkateData = new SkateData();
@State showAddSkater: boolean = false;
@State showEditMatch: boolean = false;
@State showDeleteGear: boolean = false;
@State showDetail: boolean = false;
@State delGearName: string = '';
@State detailPark: string = '';
@State boardFlip: boolean = false;
@State sprayShake: boolean = false;
@Entry标识SkateApp为应用入口组件,@Component声明它是一个自定义组件。@State装饰器标记的属性是组件的内部状态,当状态值变化时,框架自动触发组件的重新渲染。curTab初始值为0,对应SkateTab.PARK,即应用启动时默认显示场地页。data属性持有SkateData的可观察实例,这是整个应用的数据中枢。四个布尔状态(showAddSkater、showEditMatch、showDeleteGear、showDetail)分别控制四个弹窗的显示与隐藏,采用"状态驱动UI"模式——弹窗的显示完全由状态变量决定,而非命令式的show()/hide()调用。delGearName和detailPark作为弹窗的上下文参数,在用户点击列表项时被赋值,随后传递给弹窗组件。boardFlip和sprayShake是两个趣味交互状态,控制标题栏滑板图标翻转和喷漆图标抖动的动画效果。将所有状态集中管理在主入口组件中,形成了"状态提升"模式,子组件通过回调函数通知父组件状态变化。
@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装饰的构建器函数,用于生成弹窗的遮罩层。它创建了一个1x1像素的透明容器,内含一个不可见的文本和一个1x1像素的透明按钮。按钮的onClick回调调用传入的onClose函数,实现点击遮罩区域关闭弹窗的交互模式。这种设计看似冗余——为什么不用更大的遮罩区域?实际上,这种极小尺寸的遮罩是一种工程妥协:在ArkUI中,弹窗组件通常以全屏Column形式叠加在内容之上,如果遮罩区域过大,可能会拦截底层内容的滚动事件。通过将遮罩缩小到1x1像素,既保留了"点击外部关闭"的交互能力,又避免了对底层交互的干扰。@Builder是ArkUI的可复用UI片段机制,它不是组件(没有独立状态),而是可以在多个位置内联的UI模板,适合这种需要在多个弹窗中复用的简单结构。
build() {
Column() {
Column() {
Column() {
Row() {
Column() {
Text('🛹 街头滑板公园')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text('SkateZone · 涂鸦街头文化')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
build方法是组件的渲染入口,返回组件的UI结构树。整个应用的外层是一个纵向Column,内部嵌套一个内容区Column和一个底部导航栏Row。内容区顶部是标题栏,采用Row横向布局,左侧Column包含主标题和副标题。主标题"街头滑板公园"使用20号粗体墨黑色字体,副标题"SkateZone · 涂鸦街头文化"使用10号灰色字体,形成14号字号的视觉落差。layoutWeight(1)让左侧标题列占据所有可用空间,将右侧交互按钮推到行末。这种主副标题的双行设计在移动端头部区域很常见,主标题传递品牌名称,副标题补充定位说明。alignItems(HorizontalAlign.Start)确保文字左对齐,避免在宽屏设备上居中导致的视觉割裂。标题栏的设计简洁有力,没有过多装饰元素,为下方内容区留出了最大空间。
Row() {
Text('🛹')
.fontSize(20)
.onClick(() => {
this.boardFlip = !this.boardFlip;
})
.rotate({ angle: this.boardFlip ? 180 : 0 })
.animation({ duration: 600, curve: Curve.EaseOut })
Text('🎨')
.fontSize(16)
.margin({ left: 10 })
.onClick(() => {
this.sprayShake = !this.sprayShake;
})
.translate({ x: this.sprayShake ? 6 : 0 })
.rotate({ angle: this.sprayShake ? -12 : 0 })
.animation({ duration: 500, curve: Curve.EaseOut })
Text('🎧')
.fontSize(16)
.margin({ left: 6 })
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(COLORS.accentLight)
.borderRadius(18)
.border({ width: 1, color: COLORS.accent })
标题栏右侧是一个交互按钮组,包含滑板、喷漆和耳机三个Emoji图标。滑板图标点击后触发180度翻转动画,通过rotate({ angle: this.boardFlip ? 180 : 0 })实现,animation指定600毫秒缓出曲线。喷漆图标点击后产生水平位移和旋转的抖动效果,translate和rotate的组合模拟了喷涂时的手部动作。这两个交互虽然不影响业务逻辑,但增强了应用的趣味性和街头文化氛围——用户可以把玩标题栏的图标,这种微交互设计是提升用户体验感的有效手段。按钮组容器使用浅金色背景、18度圆角和金色边框,形成药丸形按钮组。@State驱动的动画是声明式的:状态变化触发属性变化,框架自动插入过渡动画,开发者无需手动管理动画状态机。耳机图标作为纯装饰元素,没有绑定点击事件,保持了视觉对称性。
Row() {
Text('STREET')
.fontSize(8)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.ink)
.borderRadius(8)
Text('GRAFFITI')
.fontSize(8)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.margin({ left: 6 })
.backgroundColor(COLORS.pink)
.borderRadius(8)
Text('URBAN')
.fontSize(8)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.margin({ left: 6 })
.backgroundColor(COLORS.primary)
.borderRadius(8)
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor(COLORS.accentLight)
标题栏下方是一组文化标签徽章:STREET(墨黑底)、GRAFFITI(粉红底)、URBAN(橙色底)。三个标签使用8号粗体白色字体,圆角8度的小药丸形态,紧凑排列在标题栏下方。这三个英文标签不仅是装饰元素,更是街头文化的三个核心关键词——"街头"代表场地属性,"涂鸦"代表艺术表达,"城市"代表生活方式。标签的背景色分别取自调色板的ink、pink和primary,形成深-浅-暖的色彩节奏。整个标题栏区域(包括主标题、交互按钮和标签徽章)使用accentLight浅金色背景统一包裹,在视觉上将头部信息与下方内容区分离。标签徽章的引入让应用的视觉语言更加丰富,传递了"这不仅是一个工具应用,更是一个文化社区"的产品定位。标签排列采用左对齐而非居中,保持了与上方主标题的视觉对齐线。
if (this.curTab === SkateTab.PARK) {
ParkContent({ data: this.data, onDetail: (n: string) => {
this.detailPark = n;
this.showDetail = true;
} })
} else if (this.curTab === SkateTab.SKATER) {
SkaterContent({ data: this.data, onAdd: () => {
this.showAddSkater = true;
} })
} else if (this.curTab === SkateTab.MATCH) {
MatchContent({ data: this.data, onEdit: () => {
this.showEditMatch = true;
} })
} else if (this.curTab === SkateTab.GEAR) {
GearContent({ data: this.data, onDel: (n: string) => {
this.delGearName = n;
this.showDeleteGear = true;
} })
} else {
LessonContent({ data: this.data })
}
这段代码是内容区的条件渲染核心,根据curTab的值选择渲染对应的内容页组件。if-else if-else链覆盖了全部五个Tab状态,确保任何时刻只有一个内容页被渲染。每个内容页组件接收data参数(SkateData可观察实例)和业务回调函数。ParkContent的onDetail回调在用户点击场地卡片时触发,将场地名称存入detailPark状态并打开详情弹窗。SkaterContent的onAdd回调打开新增板友弹窗。MatchContent的onEdit回调打开编辑赛事弹窗。GearContent的onDel回调接收装备名称并打开下架确认弹窗。LessonContent没有回调,因为课程模块当前不支持交互操作。这种"子组件触发回调→父组件更新状态→状态驱动弹窗显示"的数据流模式,确保了状态管理的单向性,避免了子组件直接操作弹窗带来的耦合问题。条件渲染的另一个好处是性能优化——非当前Tab的组件不会被构建和渲染,节省了内存和计算资源。
Row() {
ForEach(TAB_LIST, (t: TabMeta) => {
Column() {
Text(t.icon)
.fontSize(19)
Text(t.label)
.fontSize(10)
.fontColor(this.curTab === TAB_LIST.indexOf(t) ? t.color : COLORS.textHint)
.fontWeight(this.curTab === TABTab.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.accentLight : COLORS.cardBg)
.borderRadius(16)
.border(this.curTab === TAB_LIST.indexOf(t) ? { width: 2, color: t.color } : { 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数组动态渲染五个Tab按钮。每个Tab是一个纵向Column,包含图标(19号字体)和文字标签(10号字体)。选中态和未选中态的视觉差异通过三重对比实现:文字颜色(Tab主题色 vs 灰色hint)、字重(Bold vs Normal)、背景色(浅金色 vs 白色)、边框(2px主题色 vs 1px浅灰)。这种多维度对比确保了选中态的辨识度,即使用户快速扫过也能立即定位当前页面。layoutWeight(1)让五个Tab等分导航栏宽度,justifyContent(FlexAlign.Center)居中内容。onClick回调通过TAB_LIST.indexOf(t)获取当前Tab索引并赋值给curTab,触发条件渲染切换内容页。ForEach的第二个参数是键值生成函数(t: TabMeta) => t.key,使用Tab的key属性作为唯一标识,确保列表更新时的diff效率。导航栏高度固定为60像素,配合顶部和底部8像素内边距,保证触摸目标尺寸符合人体工学要求。
if (this.showAddSkater) {
AddSkaterModal({
onClose: () => {
this.showAddSkater = false;
}
})
}
if (this.showEditMatch) {
EditMatchModal({
onClose: () => {
this.showEditMatch = false;
}
})
}
if (this.showDeleteGear) {
DeleteGearModal({
title: this.delGearName, onClose: () => {
this.showDeleteGear = false;
}
})
}
if (this.showDetail) {
DetailParkModal({
name: this.detailPark, onClose: () => {
this.showDetail = false;
}
})
}
}
}
}
弹窗渲染区位于build方法的最外层Column的末尾,在底部导航栏之后。四个if条件块分别对应四个弹窗,每个弹窗组件接收必要的参数和一个onClose回调。onClose回调将对应的显示状态设为false,触发条件块求值为false,弹窗从渲染树中移除。这种"条件渲染弹窗"的模式是ArkUI中实现弹窗的常用方式之一(另一种是bindSheet/CustomDialog),优点是弹窗的UI结构完全由组件树控制,可以自由定制样式和动画。DeleteGearModal额外接收title参数(装备名称),DetailParkModal额外接收name参数(场地名称),这些参数在用户点击列表项时已经赋值到状态变量中。弹窗组件渲染在最外层Column的最后位置,由于Column的默认排列方式是从上到下,弹窗会出现在导航栏下方。但在实际布局中,弹窗组件内部使用了justifyContent(FlexAlign.End)将自身推到屏幕底部,形成了底部弹起的交互效果。
八、原子组件:SkateTag
@Component
struct SkateTag {
@Prop text: string;
@Prop color: string;
build() {
Text(this.text)
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(this.color)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(this.color + '1F')
.borderRadius(6)
.border({ width: 1, color: this.color + '50' })
}
}
SkateTag是一个原子级可复用标签组件,在板友列表、赛事列表、装备列表和课程列表中广泛使用。@Prop装饰器声明了text和color两个只读属性——与@State不同,@Prop是父到子的单向数据流,子组件不能修改这些值。标签的视觉设计精巧:文字使用传入的颜色,背景使用同一颜色追加1F后缀(即15%透明度的十六进制alpha通道),边框使用同一颜色追加50后缀(31%透明度)。这种"同色三透明度"策略创造了柔和的层次感——文字为100%不透明、边框为31%、背景为15%,三层渐变让小尺寸标签也能有视觉深度。9号粗体字体在移动端是最小可读尺寸,配合紧凑的2-6像素内边距,形成了精致的小药丸标签。SkateTag的复用性体现在:传入不同颜色即可适配不同语义——粉色表示职业/S级、橙色表示A级、青色表示品牌品类、绿色表示初级等。这种"一个组件适配多种场景"的设计减少了代码重复,是组件化设计的精髓。
九、场地内容页
@Component
struct ParkContent {
@ObjectLink data: SkateData;
onDetail: (n: string) => void = (n: string) => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('📈 本周客流(百人次)')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('周末爆满')
.fontSize(9)
.fontColor(COLORS.pink)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
ParkContent是场地页的内容组件,使用@ObjectLink建立与SkateData实例的可观察链接。@ObjectLink与@Prop的关键区别在于:@Prop做值拷贝,子组件修改不影响父组件;@ObjectLink做引用链接,双方共享同一对象实例,任何一方的修改都会通知另一方。onDetail回调使用默认值语法= (n: string) => {},确保即使父组件未传入回调也不会报错。整个内容页包裹在Scroll组件中,支持纵向滚动浏览。第一个模块是客流统计卡片,标题行使用SpaceBetween布局将标题和"周末爆满"提示分置两端。粉色提示文字与黑色标题形成色彩对比,快速传达"周末高峰"的信息。这种"数据+状态摘要"的标题设计让用户在查看详细数据前就能获得关键洞察。卡片标题使用13号粗体字,在移动端可读性和信息密度之间取得了平衡。
Row() {
ForEach(DAILY_FLOW, (f: FlowMeta) => {
Column() {
Text(f.value + '')
.fontSize(8)
.fontColor(f.value >= 80 ? COLORS.pink : COLORS.textSecondary)
Column()
.width(20)
.height(f.value * 1.05)
.backgroundColor(f.value >= 80 ? COLORS.pink : COLORS.primary)
.borderRadius(4)
.margin({ top: 4 })
Text(f.day)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (f: FlowMeta) => f.day)
}
.width('100%')
.height(140)
.alignItems(VerticalAlign.Bottom)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 2, color: COLORS.ink })
这段代码实现了客流柱状图的渲染。ForEach遍历DAILY_FLOW数组的7个数据项,每个项渲染为一个纵向Column,包含数值文字、柱体和周几标签。柱体高度为f.value * 1.05像素,乘以1.05的微调系数确保最大值92对应的柱体高度约97像素,在140像素的行高内留出了数值文字和周几标签的空间。数值超过80的柱子使用粉色高亮(f.value >= 80 ? COLORS.pink : COLORS.primary),通过颜色语义化地标识"爆满"状态。layoutWeight(1)让7个柱子等分宽度,alignItems(HorizontalAlign.Center)居中每个柱子的内容。行容器使用alignItems(VerticalAlign.Bottom)让柱子从底部对齐,这是柱状图的标准视觉规范——所有柱子共享同一条基线。ForEach的键值函数使用f.day(周几)作为唯一标识,由于周几是固定的7个值,不会出现重复键值问题。卡片使用白色背景、14度圆角和2像素墨黑色边框,在暖米色页面背景上形成清晰的视觉边界。
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(STYLE_SHARE, (s: StyleMeta) => {
Row() {
Text(s.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(56)
Row() {
Row()
.layoutWeight(s.count / 4)
.height(8)
.backgroundColor(s.color)
.borderRadius(4)
Row()
.layoutWeight(1 - s.count / 4)
.height(8)
.backgroundColor(COLORS.bg)
.borderRadius(4)
}
.layoutWeight(1)
.height(8)
Text(s.count + '人')
.fontSize(9)
.fontColor(s.color)
.width(32)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (s: StyleMeta) => s.name)
板友风格分布模块使用水平进度条展示5种滑板风格的人数占比。每个风格项是一个Row,包含风格名称(固定56像素宽)、进度条和人数标签(固定32像素宽)。进度条由两个Row组成:彩色部分使用layoutWeight(s.count / 4)按比例占据宽度,灰色背景部分使用layoutWeight(1 - s.count / 4)填充剩余空间。以最大值4(街式)为基准,count / 4的比值范围是0.25到1.0,确保最长的进度条(街式)填满整个槽位。这种双Row进度条方案是ArkUI中实现比例条的常用技巧——因为layoutWeight支持小数,可以用数学表达式直接计算比例。人数标签使用与进度条相同的颜色,形成"名称-条形-数值"三段式的信息呈现模式。整个模块采用与客流卡片一致的视觉规范——白色背景、14度圆角,但边框使用1像素浅灰色,与客流卡片的2像素深色边框形成主次区分。
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.parks, (p: ParkItem, i: number) => {
Row() {
Text(p.emoji)
.fontSize(20)
.width(42)
.height(42)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.accentLight)
.borderRadius(12)
场地列表模块的标题行右侧提示"点卡片看详情",引导用户进行点击交互。ForEach遍历this.data.parks数组,因为data是@ObjectLink可观察链接,当parks数组内容变化时,ForEach会自动增量更新列表。每个场地卡片是一个Row,左侧是42x42像素的Emoji图标容器,使用浅金色背景和12度圆角,形成圆角方块图标。图标使用textAlign(TextAlign.Center)确保Emoji在容器内居中。键值函数使用p.name + i(名称加索引)作为唯一标识,这比单纯使用p.name更安全——如果存在同名场地,纯名称键值会导致ForEach的diff逻辑混乱。索引后缀确保了键值的绝对唯一性。场地卡片模块的容器使用concrete + '33'(即#CFC9BF33)作为背景色,15%透明度的灰色模拟了混凝土质感,呼应了街头滑板的城市环境主题。
Column() {
Row() {
Text(p.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(p.difficulty)
.fontSize(8)
.fontWeight(FontWeight.Bold)
.fontColor(getDiffColor(p.difficulty))
.padding({ left: 5, right: 5, top: 2, bottom: 2 })
.backgroundColor(COLORS.cardBg)
.borderRadius(4)
.border({ width: 1, color: getDiffColor(p.difficulty) })
.margin({ left: 6 })
.rotate({ angle: -6 })
}
.width('100%')
Text(p.area + ' · 容量 ' + p.capacity + ' 人')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
场地卡片的中间区域是信息主体,包含场地名称、难度标签、区域和容量信息。难度标签使用getDiffColor函数获取语义颜色——专家为黑色、高级为红色、中级为黄色、初级为绿色。标签使用SkateTag类似的设计语言(背景透明度+边框),但内联实现而非调用SkateTag组件,这是因为难度标签额外添加了rotate({ angle: -6 })旋转效果。6度逆时针旋转模拟了街头贴纸的随意感,是街头文化视觉语言的体现。区域和容量信息使用10号灰色字体,以"区域 · 容量 N 人"的格式拼接,中圆点分隔符是移动端信息列表的常用格式。信息层级从上到下递减:13号粗体名称→8号难度标签→10号灰色描述,形成了清晰的信息优先级。width('100%')确保信息列占据卡片中间区域的所有可用宽度,与左侧图标和右侧数据形成三栏布局。
Row() {
Row()
.layoutWeight(p.flow / 100)
.height(5)
.backgroundColor(p.flow >= 90 ? COLORS.danger : COLORS.primary)
.borderRadius(2)
Row()
.layoutWeight(1 - p.flow / 100)
.height(5)
.backgroundColor(COLORS.bg)
.borderRadius(2)
}
.width('100%')
.height(5)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 10 })
Column() {
Text('热度' + p.flow)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.pink)
Text('⭐' + p.rating)
.fontSize(9)
.fontColor(COLORS.accent)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 12, right: 12, top: 9, bottom: 9 })
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
.onClick(() => {
this.onDetail(p.name);
})
}, (p: ParkItem, i: number) => p.name + i)
}
场地卡片的底部是热度进度条,使用p.flow / 100的比例渲染,热度超过90的场地使用红色进度条而非默认橙色。右侧数据区显示热度数值(粉色粗体)和星级评分(金色)。layoutWeight(1)让中间信息列占据图标和数据列之间的所有空间,padding({ left: 10 })确保信息与图标之间有适当间距。右侧数据列使用alignItems(HorizontalAlign.End)右对齐,确保数值在视觉上与卡片右边缘对齐。整个卡片绑定onClick事件,点击时调用this.onDetail(p.name)将场地名称传递给父组件。父组件收到回调后设置detailPark和showDetail状态,触发详情弹窗显示。这种"点击列表项→回调通知→状态更新→弹窗显示"的交互链条是列表-详情模式的典型实现。卡片间距通过margin({ bottom: 8 })控制,8像素的间距既保证了视觉分离,又不会显得过于松散。
.width('100%')
.padding(14)
.backgroundColor(COLORS.concrete + '33')
.borderRadius(14)
.border({ width: 2, color: COLORS.ink })
.margin({ top: 12 })
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
ParkContent的结尾部分完成了布局层级的闭合。场地列表卡片使用concrete + '33'背景色和2像素墨黑色边框,与上方的客流卡片和风格分布卡片形成视觉层级区分——2像素深色边框用于"主"卡片,1像素浅色边框用于"次"卡片。margin({ top: 12 })在卡片之间创造了12像素的垂直间距。内部Column使用padding(14)统一内边距,确保内容与卡片边缘之间有足够的呼吸空间。最外层的Scroll使用constraintSize({ maxHeight: '100%' })约束高度不超过父容器,这是滚动容器的标准配置——如果不设上限,Scroll可能会无限扩展导致无法滚动。整个ParkContent的布局结构是:Scroll→Column→[客流卡片, 风格分布卡片, 场地列表卡片],三个卡片从上到下依次排列,用户可以上下滚动浏览全部内容。ParkContent只负责渲染,不持有任何业务状态,所有数据通过@ObjectLink从父组件获取,体现了"展示组件"的无状态设计理念。
十、板友内容页
@Component
struct SkaterContent {
@ObjectLink data: SkateData;
onAdd: () => void = () => {};
build() {
Scroll() {
Column() {
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 })
SkaterContent是板友页的内容组件,结构上与ParkContent类似但更简洁——只有一个板友榜模块。标题行使用SpaceBetween布局,左侧是"板友榜"标题,右侧是"点击➕纳新"提示和➕按钮。➕按钮绑定onClick调用this.onAdd()回调,父组件收到回调后设置showAddSkater = true打开新增板友弹窗。标题行的设计巧妙地将提示文字和操作按钮融合在一起——"点击➕纳新"既是引导文案,➕又是可点击的操作入口。这种"文字即按钮"的设计减少了UI元素数量,在有限的移动端屏幕空间中提高了信息密度。onAdd回调使用默认值语法确保即使未传入也能安全调用。整个内容页同样包裹在Scroll中,支持纵向滚动浏览全部12位板友。
ForEach(this.data.skaters, (s: SkaterItem, i: number) => {
Row() {
Column() {
Text(s.rank + '')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(s.rank <= 3 ? COLORS.white : COLORS.textSecondary)
}
.width(24)
.height(24)
.justifyContent(FlexAlign.Center)
.backgroundColor(s.rank <= 3 ? COLORS.pink : COLORS.bg)
.borderRadius(12)
Text(s.emoji)
.fontSize(18)
.width(34)
.textAlign(TextAlign.Center)
板友列表的每个条目以排名徽章开头。排名前三的板友使用粉色背景圆形徽章和白色文字,其余排名使用灰色背景和灰色文字。这种"前三特殊化"的设计借鉴了体育赛事的奖牌文化,让头部板友在视觉上脱颖而出。徽章尺寸为24x24像素,12度圆角形成圆形外观。紧随徽章之后的是板友的Emoji头像,18号字体大小,34像素宽。Emoji头像虽然没有真实照片的辨识度,但在原型和概念验证阶段提供了足够的视觉区分度。排名徽章和Emoji头像构成了板友列表项的左侧视觉区域,与右侧的信息列和粉丝数据形成三栏布局。ForEach的键值函数使用s.name + i确保唯一性。列表项的布局逻辑清晰:排名→头像→信息→数据,从左到右信息优先级递减。
Column() {
Row() {
Text(s.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SkateTag({ text: s.level, color: s.rank <= 3 ? COLORS.pink : COLORS.primary })
.margin({ left: 6 })
}
.width('100%')
Text('风格 ' + s.style + ' · 技巧 ' + s.tricks + ' 招')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text('粉丝 ' + s.fans)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text(s.fans >= 5000 ? '🏅' : '')
.fontSize(11)
.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 })
}, (s: SkaterItem, i: number) => s.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 2, color: COLORS.ink })
板友列表项的信息列包含板友名称、水平标签、风格和技巧数。水平标签通过SkateTag组件渲染,排名前三使用粉色标签,其余使用橙色标签,再次强化了头部板友的视觉特权。信息列下方的描述行使用"风格 X · 技巧 N 招"的格式,中圆点分隔两项信息。右侧数据区显示粉丝数和奖牌图标——粉丝超过5000时显示🏅奖牌,否则为空字符串(不渲染内容)。这种条件渲染使用三元表达式s.fans >= 5000 ? '🏅' : '',简洁地实现了"有则显示无则隐藏"的逻辑。整个列表项的视觉规范与场地卡片保持一致:白色背景、10度圆角、1像素浅色边框、8像素底部间距。外层卡片容器使用2像素墨黑色边框,作为"主卡片"的视觉标记。SkaterContent只有一个模块(板友榜),没有额外的统计图表,结构比ParkContent更简洁。
十一、赛事内容页
@Component
struct MatchContent {
@ObjectLink data: SkateData;
onEdit: () => void = () => {};
build() {
Scroll() {
Column() {
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)
MatchContent是赛事页的内容组件,包含赛事热度榜和赛事日历两个模块。赛事热度榜模块的标题行设计与其他模块一致——左侧粗体标题、右侧灰色提示。"按报名人数"提示说明了排行榜的排序依据,帮助用户理解数据含义。热度榜使用MATCH_HOT静态常量数据,渲染6场赛事的报名人数进度条。进度条的最大基准值是175(年终总决赛),其他赛事的进度条按joined / 175的比例渲染。赛事热度榜的视觉设计与板友风格分布模块类似——水平进度条+标签+数值的三段式布局,但使用了独立的颜色映射,每场赛事有自己的主题色。onEdit回调在赛事日历模块中触发,用于打开编辑赛事弹窗。
ForEach(MATCH_HOT, (m: MatchHotMeta) => {
Row() {
Text(m.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Row() {
Row()
.layoutWeight(m.joined / 175)
.height(7)
.backgroundColor(m.color)
.borderRadius(3)
Row()
.layoutWeight(1 - m.joined / 175)
.height(7)
.backgroundColor(COLORS.bg)
.borderRadius(3)
}
.width(100)
.height(7)
Text(m.joined + '')
.fontSize(9)
.fontColor(m.color)
.width(30)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (m: MatchHotMeta) => m.name)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
赛事热度榜的进度条设计与板友风格分布有细微差异:进度条区域使用固定100像素宽度而非layoutWeight(1)弹性宽度。固定宽度的进度条在不同屏幕尺寸上保持一致的视觉比例,避免了宽屏设备上进度条过长的问题。每场赛事的颜色来自MatchHotMeta的color字段,年终总决赛用粉色、街头之王赛用橙色、新人积分赛用青色,六场赛事分配了五种颜色。数值标签使用与进度条相同的颜色,强化了"颜色-数据"的视觉关联。热度榜卡片使用1像素浅色边框(次卡片),与下方的赛事日历卡片(2像素深色边框,主卡片)形成主次区分。ForEach的键值函数使用m.name(赛事名称),因为赛事名称在数据集中是唯一的,无需追加索引。
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.matches, (m: MatchItem, i: number) => {
Row() {
Column() {
Text(m.date.substring(5))
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text(m.date.substring(0, 5))
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.width(42)
.alignItems(HorizontalAlign.Center)
.padding({ top: 6, bottom: 6 })
.backgroundColor(COLORS.accentLight)
.borderRadius(8)
赛事日历模块是MatchContent的第二个卡片,标题行右侧提示"点击✎改赛程",引导用户点击编辑按钮。每个赛事条目左侧是一个42像素宽的日期标签,设计为日历撕页的视觉样式。日期通过substring方法拆分显示:substring(5)提取"07-20"中的"20"(日),以12号粗体墨黑色显示在上方;substring(0, 5)提取"07-20"中的"07-"(月),以8号灰色显示在下方。这种"日大月小"的日期显示方式是移动端日历组件的常见设计,让用户快速定位日期。日期标签使用浅金色背景和8度圆角,模拟了日历页签的视觉形态。日期标签的alignItems(HorizontalAlign.Center)确保日和月在标签内居中对齐。这种将日期从赛事信息中拆分出来独立显示的设计,增强了时间信息的辨识度,方便用户按日期浏览赛事。
Column() {
Row() {
Text(m.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SkateTag({ text: m.level, color: getLevelColor(m.level) })
.margin({ left: 6 })
}
.width('100%')
Text('奖金 ' + m.prize + ' · 报名 ' + m.joined + '/' + m.signup)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 10 })
Text('✎')
.fontSize(13)
.fontColor(COLORS.textHint)
.onClick(() => {
this.onEdit();
})
}
.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 })
}, (m: MatchItem, i: number) => m.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.concrete + '33')
.borderRadius(14)
.border({ width: 2, color: COLORS.ink })
.margin({ top: 12 })
赛事条目的中间区域包含赛事名称、等级标签、奖金和报名信息。等级标签通过getLevelColor函数获取颜色——S级红色、A级橙色、B级黄色、C级青色。描述行使用"奖金 X · 报名 N/M"的格式,中圆点分隔奖金和报名进度。右侧的✎编辑图标绑定onClick调用this.onEdit(),触发编辑赛事弹窗。✎图标的灰色暗示其可点击性,但又不过于醒目,避免在浏览模式下干扰信息阅读。赛事日历卡片的容器使用concrete + '33'背景色和2像素墨黑色边框,与场地列表卡片的视觉规范完全一致。margin({ top: 12 })在热度榜和日历卡片之间创造了间距。ForEach的键值函数使用m.name + i确保唯一性。赛事条目的三栏布局——日期标签、信息列、编辑按钮——清晰地划分了"时间-内容-操作"三个信息维度。
十二、装备内容页
@Component
struct GearContent {
@ObjectLink data: SkateData;
onDel: (n: string) => void = (n: string) => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('🛒 销量 TOP6')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('月度')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
ForEach(GEAR_TOP, (g: GearTopMeta) => {
Row() {
Text(g.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Row() {
Row()
.layoutWeight(g.sales / 96)
.height(7)
.backgroundColor(g.color)
.borderRadius(3)
Row()
.layoutWeight(1 - g.sales / 96)
.height(7)
.backgroundColor(COLORS.bg)
.borderRadius(3)
}
.width(100)
.height(7)
Text(g.sales + '')
.fontSize(9)
.fontColor(g.color)
.width(30)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (g: GearTopMeta) => g.name)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
GearContent是装备页的内容组件,包含销量TOP6排行和装备商店两个模块。销量TOP6使用GEAR_TOP静态常量数据,渲染6件装备的月销量进度条。进度条最大基准值为96(枫木板面),其他装备按sales / 96比例渲染。进度条区域使用固定100像素宽度,与赛事热度榜的设计一致。六件装备分配了五种颜色,枫木板面和轴承八件套共用橙色。销量榜卡片使用1像素浅色边框(次卡片),与下方的装备商店卡片(2像素深色边框,主卡片)形成主次层级。onDel回调在装备商店模块中触发,接收装备名称参数并打开下架确认弹窗。GearContent的整体结构——排行榜+列表——与MatchContent保持一致,这种跨模块的结构一致性降低了用户的学习成本。
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.gears, (g: GearItem, i: number) => {
Row() {
Text(g.emoji)
.fontSize(20)
.width(42)
.height(42)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.accentLight)
.borderRadius(12)
Column() {
Row() {
Text(g.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SkateTag({ text: g.brand, color: COLORS.cyan })
.margin({ left: 6 })
}
.width('100%')
Text('月销 ' + g.sales + ' 件 · 库存 ' + g.stock + ' 件')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 10 })
装备商店模块的每个商品条目结构与场地卡片高度相似——左侧Emoji图标、中间信息列、右侧数据区。品牌标签通过SkateTag组件以青色统一渲染,无论什么品牌都使用相同的颜色,这与赛事等级和板友水平的彩色标签形成对比——品牌是分类信息而非等级信息,不需要颜色区分。描述行使用"月销 N 件 · 库存 M 件"的格式,帮助用户了解商品的受欢迎程度和供应状态。装备条目的左侧图标使用与场地卡片完全相同的视觉规范:42x42像素、浅金色背景、12度圆角。这种跨模块的视觉一致性是设计系统落地的体现——不同业务场景中的同类元素使用相同的样式,让用户形成视觉记忆。ForEach的键值函数使用g.name + i确保唯一性。
Column() {
Text('¥' + g.price)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.danger)
Text('✕')
.fontSize(12)
.fontColor(COLORS.textHint)
.margin({ top: 4 })
.onClick(() => {
this.onDel(g.name);
})
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 12, right: 12, top: 9, bottom: 9 })
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (g: GearItem, i: number) => g.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.concrete + '33')
.borderRadius(14)
.border({ width: 2, color: COLORS.ink })
.margin({ top: 12 })
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
装备条目右侧的数据区显示价格和下架按钮。价格使用12号粗体红色字体,前缀人民币符号¥,红色暗示消费行为。✕下架按钮使用灰色字体,与赛事条目的✎编辑按钮设计语言一致——操作图标使用灰色,既可点击又不喧宾夺主。onClick调用this.onDel(g.name)将装备名称传递给父组件,父组件设置delGearName和showDeleteGear状态,触发下架确认弹窗。装备商店卡片使用concrete + '33'背景色和2像素墨黑色边框,与场地列表和赛事日历的"主卡片"视觉规范保持一致。整个GearContent的结构完整闭合:Scroll→Column→[销量TOP6卡片, 装备商店卡片],两个卡片从上到下排列,用户可以滚动浏览。constraintSize({ maxHeight: '100%' })确保Scroll不会超出父容器高度。
十三、课程内容页
@Component
struct LessonContent {
@ObjectLink data: SkateData;
build() {
Scroll() {
Column() {
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 })
LessonContent是课程页的内容组件,是五个内容页中结构最简洁的——只有一个课程列表模块,没有额外的统计图表。@ObjectLink data建立可观察链接,但与其他内容页不同,LessonContent没有定义任何回调函数,因为课程模块当前不支持交互操作(没有新增、编辑、删除功能)。标题行右侧提示"教练驻场教学",传达了课程的教学保障信息。这种"只读展示"的设计可能是产品迭代的第一步——先让用户浏览课程信息,后续版本再添加报名交互。标题行的设计与前四个内容页完全一致:13号粗体标题+9号灰色提示,SpaceBetween布局分置两端。margin({ bottom: 10 })在标题和列表之间创造了10像素的间距。
ForEach(this.data.lessons, (l: LessonItem, i: number) => {
Row() {
Text(l.emoji)
.fontSize(20)
.width(42)
.height(42)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.lime + '33')
.borderRadius(12)
Column() {
Row() {
Text(l.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SkateTag({ text: l.level, color: getDiffColor(l.level) })
.margin({ left: 6 })
}
.width('100%')
Text('教练 ' + l.coach + ' · 报名 ' + l.joined + '/' + l.seats)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
课程条目的左侧图标使用lime + '33'(亮绿色15%透明度)背景,这是课程模块与其他模块的视觉区分点——场地、板友、装备使用浅金色(accentLight)图标背景,而课程使用浅绿色。这种模块间的色彩差异帮助用户在不同Tab间切换时快速识别当前所在页面。课程等级标签通过getDiffColor函数获取颜色,复用了场地难度的颜色映射逻辑——初级绿色、中级黄色、高级红色、全级别黑色。描述行使用"教练 X · 报名 N/M"的格式,将教练姓名和报名进度信息合并显示。教练姓名引用了skaters数组中的板友数据,建立了课程与板友之间的数据关联。ForEach的键值函数使用l.name + i确保唯一性。课程条目的信息层级与装备条目一致——名称+标签在上,描述在下。
Row() {
Row()
.layoutWeight(l.joined / l.seats)
.height(5)
.backgroundColor(l.joined === l.seats ? COLORS.pink : COLORS.primary)
.borderRadius(2)
Row()
.layoutWeight(1 - l.joined / l.seats)
.height(5)
.backgroundColor(COLORS.bg)
.borderRadius(2)
}
.width('100%')
.height(5)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 10 })
Column() {
Text('¥' + l.price)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.danger)
Text(l.joined === l.seats ? '已满员' : '可报名')
.fontSize(9)
.fontColor(l.joined === l.seats ? COLORS.pink : COLORS.success)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 12, right: 12, top: 9, bottom: 9 })
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (l: LessonItem, i: number) => l.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 2, color: COLORS.ink })
课程条目在信息列中额外包含了报名进度条,这是课程模块独有的设计元素。进度条使用l.joined / l.seats的比例渲染,当报名满员时(l.joined === l.seats)进度条变为粉色。右侧数据区显示价格和状态文字——满员时显示粉色"已满员",未满员时显示绿色"可报名"。这种双色状态文字(粉/绿)让用户无需阅读进度条就能快速判断课程可报名性。课程条目的三栏布局——图标、信息列(含进度条)、价格状态——比装备条目多了一个进度条元素,但整体视觉密度仍然可控。getDiffColor函数在课程等级标签中的复用是一个巧妙的工程决策——虽然语义不同(课程等级vs场地难度),但颜色映射规则一致(越高越红),用户无需学习新的颜色语义。课程列表卡片使用2像素墨黑色边框,是唯一的"主卡片",因为没有次卡片需要区分。
十四、新增板友弹窗
@Component
struct AddSkaterModal {
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%')
AddSkaterModal是新增板友弹窗组件,onClose回调用于关闭弹窗。弹窗的头部采用三栏布局:左侧Emoji图标(26号字体)、中间标题列(16号粗体标题+10号灰色副标题)、右侧关闭按钮。标题"新增板友"和副标题"录入新人资料"形成了功能说明。✕关闭按钮绑定onClick调用this.onClose(),用户可以通过点击关闭按钮取消操作。弹窗头部的布局模式在四个弹窗中完全一致——图标+标题列+关闭按钮,这种一致性降低了用户的学习成本,用户熟悉了一个弹窗的交互方式后,在其他弹窗中也能直觉操作。layoutWeight(1)让标题列占据图标和关闭按钮之间的所有空间。弹窗内部使用嵌套Column结构——外层Column作为定位容器,内层Column作为内容卡片。
Row() {
Text('板友昵称')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('阿飞 · 街式新人')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ top: 14 })
Row() {
Text('滑板水平')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('进阶 · 已完成 186 招')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('主攻风格')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('街式 · 道具区常驻')
.fontSize(12)
.fontColor(COLORS.pink)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('初始粉丝')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('1600 · 预计周增长 8%')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ top: 8 })
弹窗的表单区域包含四行信息:板友昵称、滑板水平、主攻风格和初始粉丝。每行采用标签-值二栏布局:左侧11号灰色标签(固定70像素宽)、右侧12号主色值文本。值得注意的是,表单值是静态文本而非输入框——这说明弹窗当前是展示型原型,没有实现真正的表单输入功能。后续迭代中可以将Text替换为TextInput组件实现可编辑表单。每行使用bg背景色和10度圆角,形成独立的信息卡片。"主攻风格"行的值使用粉色字体,是四行中唯一使用非默认颜色的——粉色突出了风格信息,因为风格是板友的核心属性。行间距通过margin({ top: 8 })控制,首行使用14像素的更大间距与弹窗头部区分离。表单值的文本中包含了额外的上下文信息——"186 招"说明了进阶水平的技术指标、"预计周增长 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.bg)
.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: 2, color: COLORS.ink })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
弹窗底部包含安全提示和操作按钮。安全提示"新人需通过安全培训考核后,方可使用高级道具区域"使用10号灰色字体,传达了场地安全管理的业务规则。操作按钮区采用双按钮布局:左侧"取消"使用灰色背景、灰色文字,右侧"确认纳新"使用橙色背景、白色粗体文字。两个按钮都绑定onClick调用this.onClose()——在当前原型中,确认按钮没有实际提交逻辑,仅关闭弹窗。后续实现中,确认按钮需要调用数据写入接口,将新板友数据追加到SkateData.skaters数组中。按钮使用layoutWeight(1)等分宽度,textAlign(TextAlign.Center)居中文字,padding({ top: 11, bottom: 11 })保证足够的触摸面积。弹窗内层卡片使用白色背景、18度圆角和2像素墨黑色边框,外层Column使用justifyContent(FlexAlign.End)将卡片推到屏幕底部,constraintSize({ maxHeight: '78%' })限制弹窗最大高度为屏幕的78%,确保弹窗不会覆盖整个屏幕。
十五、编辑赛事弹窗
@Component
struct EditMatchModal {
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%')
EditMatchModal是编辑赛事弹窗组件,布局结构与AddSkaterModal高度一致。头部同样采用图标+标题列+关闭按钮的三栏布局,标题"编辑赛事"和副标题"调整赛程与奖金"说明了弹窗的功能范围。🏆奖杯图标与赛事业务模块的主题图标一致,帮助用户建立"弹窗属于赛事模块"的认知关联。onClose回调的绑定方式与其他弹窗完全相同——✕按钮和底部取消按钮都调用this.onClose()。弹窗的视觉规范——18度圆角、2像素边框——也与AddSkaterModal一致,但边框颜色使用了COLORS.accent(金色)而非COLORS.ink(墨黑色)。这种弹窗间边框颜色的差异是一个细微但有意义的设计——通过边框颜色暗示弹窗所属的业务模块,金色对应赛事Tab的主题色。
Row() {
Text('赛事名称')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('街头之王挑战赛')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ top: 14 })
Row() {
Text('开赛时间')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('07-20 → 延期至 08-02')
.fontSize(12)
.fontColor(COLORS.warning)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('奖金池')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('2万 → 提升至 3万')
.fontSize(12)
.fontColor(COLORS.pink)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('报名名额')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('200 人 · 已报 168 人')
.fontSize(12)
.fontColor(COLORS.success)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ top: 8 })
编辑赛事弹窗的表单包含四行:赛事名称、开赛时间、奖金池和报名名额。与AddSkaterModal的静态表单值不同,EditMatchModal的表单值使用了"原值 → 新值"的变更格式:“07-20 → 延期至 08-02”、“2万 → 提升至 3万”。箭头符号清晰表达了数据的变更方向,让用户一目了然地看到修改前后的对比。更精妙的是,每行使用了不同的语义颜色:开赛时间用黄色(warning,暗示时间变更需要注意)、奖金池用粉色(pink,强调金额提升)、报名名额用绿色(success,表示报名状态正常)。这种"一行一色"的信息编码让用户通过颜色就能快速定位需要关注的变更项。赛事名称行使用默认主色文字,因为名称通常不会修改。表单值的"原值 → 新值"格式在原型阶段是静态文本,后续实现中需要替换为可编辑的输入组件,并在用户修改时实时更新箭头后的值。
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.bg)
.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: 2, color: COLORS.accent })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
编辑赛事弹窗的底部包含业务提示和操作按钮。提示文本"赛程变更将自动推送至已报名选手,请同步更新场馆排期"揭示了赛事编辑的联动效应——修改赛程不仅影响赛事本身,还涉及通知选手和调整场馆排期两个关联操作。这种业务规则提示帮助用户理解操作的完整影响范围。操作按钮的布局与AddSkaterModal一致——左侧灰色"取消"、右侧主色"保存修改"。但确认按钮的背景色使用了COLORS.accent(金色)而非COLORS.primary(橙色),与弹窗边框颜色保持一致,形成了"金色=赛事"的色彩主题。确认按钮的文字是"保存修改"而非"确认纳新",更准确地描述了操作语义——编辑弹窗是修改已有数据,新增弹窗是创建新数据。弹窗的constraintSize({ maxHeight: '78%' })和justifyContent(FlexAlign.End)确保了底部弹起效果和高度限制。
十六、下架装备弹窗
@Component
struct DeleteGearModal {
title: string = '';
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%')
Text('商品名称:' + this.title)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.danger)
.width('100%')
.padding(12)
.backgroundColor(COLORS.accentLight)
.borderRadius(10)
.margin({ top: 14 })
DeleteGearModal是下架装备弹窗组件,与其他弹窗的区别在于接收一个title参数(装备名称)。头部布局与前面两个弹窗一致,但🎒图标和"下架装备"标题明确了操作的危险性。装备名称通过this.title动态显示,使用红色粗体字体和浅金色背景突出展示。这种将操作目标单独高亮的设计是危险操作确认弹窗的标准模式——让用户在确认前清楚地看到将要操作的具体对象,避免误操作。红色字体暗示了删除操作的不可逆性。title参数由父组件在打开弹窗时传入(this.delGearName),确保弹窗显示的装备名称与用户点击的装备一致。弹窗边框使用COLORS.danger(红色),与操作的危险等级相匹配。margin({ top: 14 })在头部和商品名称之间创造了间距。
Text('⚠️ 下架后商品从商店移除,已售装备继续享受质保服务。')
.fontSize(10)
.fontColor(COLORS.warning)
.width('100%')
.margin({ top: 12 })
Row() {
Text('保留商品')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS.bg)
.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: 2, color: COLORS.danger })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
下架装备弹窗的提示文字使用黄色(warning)字体和⚠️图标,传达了"下架有影响但非毁灭性"的警告级别。提示内容"已售装备继续享受质保服务"是一个重要的业务保障信息——告诉用户下架商品不会影响已售出的售后服务。操作按钮的文案使用了"保留商品"而非"取消",这种正向措辞比"取消"更符合确认弹窗的心理模型——用户选择"保留"而非"取消下架"。确认按钮"确认下架"使用红色(danger)背景,与弹窗边框颜色一致,形成统一的危险操作视觉语言。与前两个弹窗的橙色或金色确认按钮不同,红色确认按钮让用户在下架操作前多一份警觉。弹窗整体结构精简——没有多行表单字段,只有商品名称和警告提示,因为下架操作不需要用户输入数据,只需确认或取消。
十七、场地详情弹窗
@Component
struct DetailParkModal {
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%')
DetailParkModal是场地详情弹窗组件,接收name参数(场地名称)并展示场地的详细信息。头部布局中,副标题位置直接显示this.name(场地名称),而非固定的功能描述文字。这种动态副标题让用户在弹窗头部就能确认当前查看的场地。🛹滑板图标与场地模块的主题图标一致。弹窗边框使用COLORS.ink(墨黑色),与AddSkaterModal的边框颜色相同——墨黑色作为"中性"边框色用于非危险操作的弹窗,红色用于危险操作弹窗(DeleteGearModal),金色用于赛事弹窗(EditMatchModal)。这种"边框颜色编码弹窗类型"的策略虽然细微,但为有经验的用户提供了快速识别弹窗性质的视觉线索。name参数由父组件在打开弹窗时传入(this.detailPark),确保弹窗显示的场地名称与用户点击的场地卡片一致。
Row() {
Text('场地面积')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('1200㎡ · 全钢架结构')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ top: 14 })
Row() {
Text('开放时段')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('09:00 - 22:00 · 周末延长')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.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.bg)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('人气指数')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('周末峰值 92% · 建议错峰')
.fontSize(12)
.fontColor(COLORS.pink)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ top: 8 })
场地详情弹窗的表单包含四行:场地面积、开放时段、安全措施和人气指数。与编辑赛事弹窗类似,每行使用不同的语义颜色——面积和时段用默认主色、安全措施用绿色(success,表示安全保障到位)、人气指数用粉色(pink,暗示高峰时段需要关注)。这种语义化颜色编码让用户在浏览信息时能够通过颜色快速判断哪些信息需要重点关注。表单值中的"1200㎡ · 全钢架结构"提供了场地面积和建筑结构两个信息点,"09:00 - 22:00 · 周末延长"说明了营业时间和周末特殊情况,"周末峰值 92% · 建议错峰"不仅给出了数据还提供了行动建议。这些信息虽然在前端是静态文本,但设计上模拟了真实场地详情页的信息结构。后续实现中,这些数据应该从ParkItem接口扩展字段或后端API获取。
Text('🛹 该场地配备专业摄影机位,供选手录制动作回放与赛事直播使用。')
.fontSize(10)
.fontColor(COLORS.textHint)
.width('100%')
.margin({ top: 14 })
Row() {
Text('关闭')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS.primary)
.borderRadius(12)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 16 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.border({ width: 2, color: COLORS.ink })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
场地详情弹窗的底部包含场地特色提示和关闭按钮。提示"该场地配备专业摄影机位,供选手录制动作回放与赛事直播使用"揭示了场地的增值服务——专业摄影设备支持动作回放和赛事直播。这类信息对于有录制需求的进阶选手和赛事组织者有重要参考价值。与前面三个弹窗的双按钮布局不同,DetailParkModal只有一个"关闭"按钮。这是因为详情弹窗是纯展示型弹窗,不需要用户做任何决策——用户浏览完信息后直接关闭即可。单按钮设计简化了交互,减少了用户的操作步骤。关闭按钮使用橙色(primary)背景和白色粗体文字,与整个应用的确认按钮视觉规范一致。弹窗的constraintSize({ maxHeight: '78%' })限制了最大高度,配合justifyContent(FlexAlign.End)实现了底部弹起效果。至此,全部四个弹窗组件的代码分析完成。
技术对比总结
| 技术要点 | 实现方式 | 优势 | 适用场景 |
|---|---|---|---|
| 色彩体系管理 | SkatePalette接口+COLORS常量集中管理 | 类型安全、语义化、全局可调 | 中大型应用需要统一视觉规范 |
| 响应式数据流 | @Observed类+@ObjectLink链接+@State引用 | 跨组件深度观察、自动增量更新 | 多组件共享同一数据实例的场景 |
| 弹窗管理 | 布尔状态变量+条件渲染组件 | 状态可追溯、样式可定制、无耦合 | 需要自定义弹窗样式的交互场景 |
| Tab导航 | 枚举索引+元数据数组+ForEach渲染 | 数据驱动、可扩展、一致性高 | 底部Tab栏、顶部标签页 |
| 数据可视化 | layoutWeight比例布局+双Row进度条 | 零依赖、原生性能、灵活适配 | 简单柱状图、进度条、排行榜 |
| 组件复用 | @Component+@Prop/@ObjectLink参数传递 | 单向数据流、类型安全、职责清晰 | 列表项、标签、卡片等重复UI |
| 颜色语义映射 | 纯函数返回语义颜色(getLevelColor等) | 可测试、可复用、关注点分离 | 等级标识、状态标签、难度标尺 |
| 列表渲染 | ForEach+键值函数(name+index) | 高效diff、避免重复键、增量更新 | 任意长度的数组数据列表 |
| 状态提升 | 子组件回调通知+父组件状态更新 | 单向数据流、可预测、易调试 | 多子组件需要协调状态变化 |
| 条件渲染 | if-else if链根据curTab切换内容页 | 性能优化(非当前页不渲染) | 页面切换、弹窗显隐、多态展示 |
| 模块化常量 | 模块级const数组(DAILY_FLOW等) | 编译期确定、无运行时开销 | 原型阶段静态数据、配置数据 |
| 微交互动画 | @State驱动+animation修饰符 | 声明式动画、无需状态机、自动过渡 | 图标翻转、抖动、缩放等趣味效果 |
| 弹窗参数传递 | 组件参数(title/name)+父组件状态 | 弹窗上下文可定制、数据一致 | 需要显示动态内容的确认弹窗 |
| 视觉层级区分 | 边框宽度(2px vs 1px)+边框颜色 | 主次卡片清晰区分、信息优先级 | 多卡片并列的信息展示场景 |
| Emoji图标系统 | 字符串Emoji替代图片资源 | 零体积、跨平台一致、色彩活泼 | 原型开发、轻量应用、趣味社区 |
| 滚动容器 | Scroll+constraintSize(maxHeight) | 内容超出可滚动、高度不溢出 | 列表页、详情页等可滚动内容 |
| 安全提示模式 | 弹窗内嵌入业务规则提示文本 | 用户决策前了解影响范围 | 危险操作确认、业务规则告知 |
| 数据驱动样式 | 三元表达式控制颜色/字重/背景 | 紧凑内联、即时响应、无额外抽象 | 条件样式、状态标识、等级区分 |
本文从色彩体系、数据模型、组件架构、状态管理、数据可视化、交互设计、工程方法论等维度,全面解析了这款街头滑板公园社区应用的技术实现。该应用虽然是一个原型级实现,但其架构设计——从@Observed集中式数据管理到四层组件化策略,从语义化色彩体系到layoutWeight驱动的数据可视化——为同类垂直社区应用的ArkUI开发提供了有价值的参考范式。随着后续迭代中真实API的接入、表单输入的实现和持久化存储的添加,该应用有望从原型成长为完整的滑板文化社区平台。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
interface SkatePalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
ink: string;
pink: string;
cyan: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
line: string;
success: string;
warning: string;
danger: string;
white: string;
concrete: string;
lime: string;
}
const COLORS: SkatePalette = {
primary: '#FF6B35',
primaryLight: '#FFA07A',
primaryDark: '#D84A12',
accent: '#FFC93C',
accentLight: '#FFF3D0',
ink: '#23232B',
pink: '#FF2E63',
cyan: '#35D0BA',
bg: '#F4F0E9',
cardBg: '#FFFFFF',
textPrimary: '#23232B',
textSecondary: '#6E6A63',
textHint: '#A8A49C',
line: '#E8E1D6',
success: '#35D0BA',
warning: '#FFC93C',
danger: '#FF2E63',
white: '#FFFFFF',
concrete: '#CFC9BF',
lime: '#B8E64A'
};
enum SkateTab {
PARK = 0,
SKATER = 1,
MATCH = 2,
GEAR = 3,
LESSON = 4
}
interface TabMeta {
key: string;
icon: string;
label: string;
color: string;
}
const TAB_LIST: TabMeta[] = [
{ key: 'park', icon: '🛹', label: '场地', color: '#FF6B35' },
{ key: 'skater', icon: '🧑🎤', label: '板友', color: '#FF2E63' },
{ key: 'match', icon: '🏆', label: '赛事', color: '#FFC93C' },
{ key: 'gear', icon: '🎒', label: '装备', color: '#35D0BA' },
{ key: 'lesson', icon: '🎓', label: '课程', color: '#B8E64A' }
];
interface ParkItem {
name: string;
area: string;
difficulty: string;
capacity: number;
flow: number;
rating: number;
emoji: string;
}
interface SkaterItem {
name: string;
level: string;
tricks: number;
style: string;
fans: number;
rank: number;
emoji: string;
}
interface MatchItem {
name: string;
date: string;
prize: string;
signup: number;
joined: number;
level: string;
emoji: string;
}
interface GearItem {
name: string;
brand: string;
price: number;
stock: number;
sales: number;
rating: number;
emoji: string;
}
interface LessonItem {
name: string;
coach: string;
price: number;
seats: number;
joined: number;
level: string;
emoji: string;
}
interface FlowMeta {
day: string;
value: number;
}
interface StyleMeta {
name: string;
count: number;
color: string;
}
interface MatchHotMeta {
name: string;
joined: number;
color: string;
}
interface GearTopMeta {
name: string;
sales: number;
color: string;
}
const DAILY_FLOW: FlowMeta[] = [
{ day: '周一', value: 32 },
{ day: '周二', value: 28 },
{ day: '周三', value: 35 },
{ day: '周四', value: 41 },
{ day: '周五', value: 58 },
{ day: '周六', value: 92 },
{ day: '周日', value: 86 }
];
const STYLE_SHARE: StyleMeta[] = [
{ name: '街式', count: 4, color: '#FF6B35' },
{ name: '碗池', count: 3, color: '#FF2E63' },
{ name: '竞速', count: 2, color: '#FFC93C' },
{ name: '技巧', count: 2, color: '#35D0BA' },
{ name: '涂鸦', count: 1, color: '#B8E64A' }
];
const MATCH_HOT: MatchHotMeta[] = [
{ name: '年终总决赛', joined: 175, color: '#FF2E63' },
{ name: '街头之王赛', joined: 168, color: '#FF6B35' },
{ name: '新人积分赛', joined: 156, color: '#35D0BA' },
{ name: '城市巡回赛', joined: 128, color: '#FFC93C' },
{ name: '碗池公开赛', joined: 122, color: '#B8E64A' },
{ name: '团队对抗赛', joined: 96, color: '#FF6B35' }
];
const GEAR_TOP: GearTopMeta[] = [
{ name: '枫木板面', sales: 96, color: '#FF6B35' },
{ name: '掌心防滑贴', sales: 95, color: '#FF2E63' },
{ name: '软轮套组', sales: 92, color: '#35D0BA' },
{ name: '头盔护具', sales: 90, color: '#FFC93C' },
{ name: '夜光轮', sales: 87, color: '#B8E64A' },
{ name: '轴承八件套', sales: 85, color: '#FF6B35' }
];
@Observed
export class SkateData {
parks: ParkItem[] = [
{ name: 'U型碗池', area: '碗池区', difficulty: '高级', capacity: 30, flow: 85, rating: 4.9, emoji: '🏟️' },
{ name: '街式道具区', area: '街区', difficulty: '中级', capacity: 45, flow: 92, rating: 4.8, emoji: '🧱' },
{ name: '迷你坡道', area: '坡道区', difficulty: '初级', capacity: 25, flow: 78, rating: 4.7, emoji: '🛤️' },
{ name: '竞速直线道', area: '竞速区', difficulty: '中级', capacity: 35, flow: 66, rating: 4.8, emoji: '🏁' },
{ name: '初学者乐园', area: '新手区', difficulty: '初级', capacity: 50, flow: 95, rating: 4.9, emoji: '🎈' },
{ name: '空中技巧台', area: '技巧区', difficulty: '高级', capacity: 20, flow: 58, rating: 4.8, emoji: '🚀' },
{ name: '街头阶梯场', area: '街区', difficulty: '高级', capacity: 28, flow: 72, rating: 4.7, emoji: '🪜' },
{ name: '室内旱冰场', area: '室内', difficulty: '初级', capacity: 40, flow: 64, rating: 4.6, emoji: '🏠' },
{ name: '金字塔道具', area: '道具区', difficulty: '中级', capacity: 22, flow: 69, rating: 4.7, emoji: '🔺' },
{ name: '碗池深水区', area: '碗池区', difficulty: '专家', capacity: 15, flow: 45, rating: 4.9, emoji: '🌊' },
{ name: '夜光涂鸦场', area: '涂鸦区', difficulty: '初级', capacity: 38, flow: 88, rating: 4.8, emoji: '🌌' },
{ name: '亲子趣味场', area: '亲子区', difficulty: '初级', capacity: 32, flow: 74, rating: 4.8, emoji: '🧒' }
];
skaters: SkaterItem[] = [
{ name: '阿凯', level: '职业', tricks: 540, style: '街式', fans: 8200, rank: 1, emoji: '🧢' },
{ name: '小野', level: '职业', tricks: 486, style: '碗池', fans: 7600, rank: 2, emoji: '🕶️' },
{ name: '豆豆', level: '进阶', tricks: 320, style: '街区', fans: 4300, rank: 3, emoji: '🐶' },
{ name: '阿杰', level: '进阶', tricks: 298, style: '技巧', fans: 3900, rank: 4, emoji: '🔥' },
{ name: '琪琪', level: '进阶', tricks: 275, style: '竞速', fans: 3500, rank: 5, emoji: '🎀' },
{ name: '大熊', level: '职业', tricks: 512, style: '坡道', fans: 6800, rank: 6, emoji: '🐻' },
{ name: '阿豪', level: '初级', tricks: 168, style: '新手', fans: 1500, rank: 7, emoji: '🎒' },
{ name: '桃子', level: '进阶', tricks: 246, style: '涂鸦', fans: 2800, rank: 8, emoji: '🍑' },
{ name: '小满', level: '职业', tricks: 468, style: '街区', fans: 6100, rank: 9, emoji: '🌾' },
{ name: '阿泽', level: '初级', tricks: 145, style: '新手', fans: 1200, rank: 10, emoji: '🎧' },
{ name: '彤彤', level: '进阶', tricks: 232, style: '亲子', fans: 2400, rank: 11, emoji: '🦋' },
{ name: '老狼', level: '职业', tricks: 555, style: '碗池', fans: 8900, rank: 12, emoji: '🐺' }
];
matches: MatchItem[] = [
{ name: '街头之王挑战赛', date: '07-20', prize: '2万', signup: 200, joined: 168, level: 'S级', emoji: '🏆' },
{ name: '碗池全国公开赛', date: '08-02', prize: '5万', signup: 150, joined: 122, level: 'S级', emoji: '🥇' },
{ name: '涂鸦风格赛', date: '08-15', prize: '8千', signup: 120, joined: 88, level: 'A级', emoji: '🎨' },
{ name: '新人积分赛', date: '08-22', prize: '3千', signup: 180, joined: 156, level: 'B级', emoji: '🌱' },
{ name: '夜光刷街夜', date: '09-01', prize: '5千', signup: 300, joined: 240, level: 'A级', emoji: '🌌' },
{ name: '竞速破纪录赛', date: '09-10', prize: '1万', signup: 80, joined: 55, level: 'A级', emoji: '🏁' },
{ name: '亲子趣味赛', date: '09-20', prize: '2千', signup: 100, joined: 86, level: 'C级', emoji: '🧒' },
{ name: '技巧大师赛', date: '10-05', prize: '3万', signup: 90, joined: 71, level: 'S级', emoji: '🚀' },
{ name: '城市巡回赛', date: '10-18', prize: '2万', signup: 160, joined: 128, level: 'A级', emoji: '🏙️' },
{ name: '坡道翻板赛', date: '11-01', prize: '8千', signup: 70, joined: 52, level: 'B级', emoji: '🛤️' },
{ name: '团队对抗赛', date: '11-15', prize: '1.5万', signup: 120, joined: 96, level: 'A级', emoji: '⚔️' },
{ name: '年终总决赛', date: '12-20', prize: '10万', signup: 200, joined: 175, level: 'S级', emoji: '👑' }
];
gears: GearItem[] = [
{ name: '枫木板面', brand: '板面', price: 399, stock: 42, sales: 96, rating: 4.9, emoji: '🛹' },
{ name: '铝合金支架', brand: '支架', price: 299, stock: 35, sales: 88, rating: 4.8, emoji: '⚙️' },
{ name: '软轮套组', brand: '轮子', price: 199, stock: 60, sales: 92, rating: 4.8, emoji: '🛞' },
{ name: '轴承八件套', brand: '轴承', price: 159, stock: 48, sales: 85, rating: 4.8, emoji: '🔩' },
{ name: '头盔护具套装', brand: '护具', price: 459, stock: 30, sales: 90, rating: 4.9, emoji: '🪖' },
{ name: '滑板鞋', brand: '鞋履', price: 899, stock: 18, sales: 76, rating: 4.8, emoji: '👟' },
{ name: '掌心防滑贴', brand: '配件', price: 49, stock: 120, sales: 95, rating: 4.9, emoji: '🖐️' },
{ name: '便携工具组', brand: '工具', price: 129, stock: 55, sales: 82, rating: 4.7, emoji: '🔧' },
{ name: '夜光轮', brand: '轮子', price: 259, stock: 40, sales: 87, rating: 4.8, emoji: '🌟' },
{ name: '长板滑板', brand: '整板', price: 1299, stock: 12, sales: 68, rating: 4.9, emoji: '🏄' },
{ name: '儿童滑板', brand: '整板', price: 599, stock: 26, sales: 79, rating: 4.8, emoji: '🧒' },
{ name: '滑板背包', brand: '背包', price: 349, stock: 32, sales: 74, rating: 4.7, emoji: '🎒' }
];
lessons: LessonItem[] = [
{ name: '零基础入门班', coach: '阿凯', price: 199, seats: 20, joined: 18, level: '初级', emoji: '🎓' },
{ name: '街式动作进阶', coach: '小野', price: 299, seats: 15, joined: 12, level: '中级', emoji: '🧱' },
{ name: '碗池入坑课', coach: '大熊', price: 349, seats: 12, joined: 10, level: '中级', emoji: '🌊' },
{ name: '竞速提速训练', coach: '阿杰', price: 259, seats: 16, joined: 13, level: '中级', emoji: '🏁' },
{ name: '涂鸦滑行体验', coach: '桃子', price: 229, seats: 18, joined: 16, level: '初级', emoji: '🎨' },
{ name: '空中技巧专项', coach: '老狼', price: 499, seats: 8, joined: 6, level: '高级', emoji: '🚀' },
{ name: '亲子启蒙课', coach: '彤彤', price: 259, seats: 15, joined: 14, level: '初级', emoji: '🧒' },
{ name: '女子滑板营', coach: '琪琪', price: 279, seats: 14, joined: 12, level: '中级', emoji: '🎀' },
{ name: '夜刷安全课', coach: '豆豆', price: 189, seats: 22, joined: 19, level: '初级', emoji: '🌌' },
{ name: '道具进阶班', coach: '阿豪', price: 319, seats: 10, joined: 8, level: '中级', emoji: '🔺' },
{ name: '大师特训营', coach: '老狼', price: 799, seats: 6, joined: 4, level: '高级', emoji: '👑' },
{ name: '学期集训班', coach: '小满', price: 999, seats: 20, joined: 17, level: '全级别', emoji: '📚' }
];
}
function getLevelColor(level: string): string {
return level === 'S级' ? COLORS.danger : (level === 'A级' ? COLORS.primary : (level === 'B级' ? COLORS.warning : COLORS.cyan));
}
function getDiffColor(diff: string): string {
return diff === '专家' ? COLORS.ink : (diff === '高级' ? COLORS.danger : (diff === '中级' ? COLORS.warning : COLORS.success));
}
@Entry
@Component
struct SkateApp {
@State curTab: number = 0;
@State data: SkateData = new SkateData();
@State showAddSkater: boolean = false;
@State showEditMatch: boolean = false;
@State showDeleteGear: boolean = false;
@State showDetail: boolean = false;
@State delGearName: string = '';
@State detailPark: string = '';
@State boardFlip: boolean = false;
@State sprayShake: 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.ink)
Text('SkateZone · 涂鸦街头文化')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Row() {
Text('🛹')
.fontSize(20)
.onClick(() => {
this.boardFlip = !this.boardFlip;
})
.rotate({ angle: this.boardFlip ? 180 : 0 })
.animation({ duration: 600, curve: Curve.EaseOut })
Text('🎨')
.fontSize(16)
.margin({ left: 10 })
.onClick(() => {
this.sprayShake = !this.sprayShake;
})
.translate({ x: this.sprayShake ? 6 : 0 })
.rotate({ angle: this.sprayShake ? -12 : 0 })
.animation({ duration: 500, curve: Curve.EaseOut })
Text('🎧')
.fontSize(16)
.margin({ left: 6 })
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(COLORS.accentLight)
.borderRadius(18)
.border({ width: 1, color: COLORS.accent })
}
.width('100%')
Row() {
Text('STREET')
.fontSize(8)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.ink)
.borderRadius(8)
Text('GRAFFITI')
.fontSize(8)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.margin({ left: 6 })
.backgroundColor(COLORS.pink)
.borderRadius(8)
Text('URBAN')
.fontSize(8)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.margin({ left: 6 })
.backgroundColor(COLORS.primary)
.borderRadius(8)
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor(COLORS.accentLight)
if (this.curTab === SkateTab.PARK) {
ParkContent({ data: this.data, onDetail: (n: string) => {
this.detailPark = n;
this.showDetail = true;
} })
} else if (this.curTab === SkateTab.SKATER) {
SkaterContent({ data: this.data, onAdd: () => {
this.showAddSkater = true;
} })
} else if (this.curTab === SkateTab.MATCH) {
MatchContent({ data: this.data, onEdit: () => {
this.showEditMatch = true;
} })
} else if (this.curTab === SkateTab.GEAR) {
GearContent({ data: this.data, onDel: (n: string) => {
this.delGearName = n;
this.showDeleteGear = true;
} })
} else {
LessonContent({ data: this.data })
}
}
.width('100%')
.height('100%')
Row() {
ForEach(TAB_LIST, (t: TabMeta) => {
Column() {
Text(t.icon)
.fontSize(19)
Text(t.label)
.fontSize(10)
.fontColor(this.curTab === TAB_LIST.indexOf(t) ? t.color : 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.accentLight : COLORS.cardBg)
.borderRadius(16)
.border(this.curTab === TAB_LIST.indexOf(t) ? { width: 2, color: t.color } : { 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.showAddSkater) {
AddSkaterModal({
onClose: () => {
this.showAddSkater = false;
}
})
}
if (this.showEditMatch) {
EditMatchModal({
onClose: () => {
this.showEditMatch = false;
}
})
}
if (this.showDeleteGear) {
DeleteGearModal({
title: this.delGearName, onClose: () => {
this.showDeleteGear = false;
}
})
}
if (this.showDetail) {
DetailParkModal({
name: this.detailPark, onClose: () => {
this.showDetail = false;
}
})
}
}
}
}
@Component
struct SkateTag {
@Prop text: string;
@Prop color: string;
build() {
Text(this.text)
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(this.color)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(this.color + '1F')
.borderRadius(6)
.border({ width: 1, color: this.color + '50' })
}
}
@Component
struct ParkContent {
@ObjectLink data: SkateData;
onDetail: (n: string) => void = (n: string) => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('📈 本周客流(百人次)')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('周末爆满')
.fontSize(9)
.fontColor(COLORS.pink)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
ForEach(DAILY_FLOW, (f: FlowMeta) => {
Column() {
Text(f.value + '')
.fontSize(8)
.fontColor(f.value >= 80 ? COLORS.pink : COLORS.textSecondary)
Column()
.width(20)
.height(f.value * 1.05)
.backgroundColor(f.value >= 80 ? COLORS.pink : COLORS.primary)
.borderRadius(4)
.margin({ top: 4 })
Text(f.day)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (f: FlowMeta) => f.day)
}
.width('100%')
.height(140)
.alignItems(VerticalAlign.Bottom)
.margin({ top: 10 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 2, color: COLORS.ink })
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(STYLE_SHARE, (s: StyleMeta) => {
Row() {
Text(s.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(56)
Row() {
Row()
.layoutWeight(s.count / 4)
.height(8)
.backgroundColor(s.color)
.borderRadius(4)
Row()
.layoutWeight(1 - s.count / 4)
.height(8)
.backgroundColor(COLORS.bg)
.borderRadius(4)
}
.layoutWeight(1)
.height(8)
Text(s.count + '人')
.fontSize(9)
.fontColor(s.color)
.width(32)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (s: StyleMeta) => s.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 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.parks, (p: ParkItem, i: number) => {
Row() {
Text(p.emoji)
.fontSize(20)
.width(42)
.height(42)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.accentLight)
.borderRadius(12)
Column() {
Row() {
Text(p.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(p.difficulty)
.fontSize(8)
.fontWeight(FontWeight.Bold)
.fontColor(getDiffColor(p.difficulty))
.padding({ left: 5, right: 5, top: 2, bottom: 2 })
.backgroundColor(COLORS.cardBg)
.borderRadius(4)
.border({ width: 1, color: getDiffColor(p.difficulty) })
.margin({ left: 6 })
.rotate({ angle: -6 })
}
.width('100%')
Text(p.area + ' · 容量 ' + p.capacity + ' 人')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
Row() {
Row()
.layoutWeight(p.flow / 100)
.height(5)
.backgroundColor(p.flow >= 90 ? COLORS.danger : COLORS.primary)
.borderRadius(2)
Row()
.layoutWeight(1 - p.flow / 100)
.height(5)
.backgroundColor(COLORS.bg)
.borderRadius(2)
}
.width('100%')
.height(5)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 10 })
Column() {
Text('热度' + p.flow)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.pink)
Text('⭐' + p.rating)
.fontSize(9)
.fontColor(COLORS.accent)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 12, right: 12, top: 9, bottom: 9 })
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
.onClick(() => {
this.onDetail(p.name);
})
}, (p: ParkItem, i: number) => p.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.concrete + '33')
.borderRadius(14)
.border({ width: 2, color: COLORS.ink })
.margin({ top: 12 })
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct SkaterContent {
@ObjectLink data: SkateData;
onAdd: () => void = () => {};
build() {
Scroll() {
Column() {
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.skaters, (s: SkaterItem, i: number) => {
Row() {
Column() {
Text(s.rank + '')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(s.rank <= 3 ? COLORS.white : COLORS.textSecondary)
}
.width(24)
.height(24)
.justifyContent(FlexAlign.Center)
.backgroundColor(s.rank <= 3 ? COLORS.pink : COLORS.bg)
.borderRadius(12)
Text(s.emoji)
.fontSize(18)
.width(34)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(s.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SkateTag({ text: s.level, color: s.rank <= 3 ? COLORS.pink : COLORS.primary })
.margin({ left: 6 })
}
.width('100%')
Text('风格 ' + s.style + ' · 技巧 ' + s.tricks + ' 招')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 8 })
Column() {
Text('粉丝 ' + s.fans)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text(s.fans >= 5000 ? '🏅' : '')
.fontSize(11)
.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 })
}, (s: SkaterItem, i: number) => s.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 2, color: COLORS.ink })
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct MatchContent {
@ObjectLink data: SkateData;
onEdit: () => void = () => {};
build() {
Scroll() {
Column() {
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)
ForEach(MATCH_HOT, (m: MatchHotMeta) => {
Row() {
Text(m.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Row() {
Row()
.layoutWeight(m.joined / 175)
.height(7)
.backgroundColor(m.color)
.borderRadius(3)
Row()
.layoutWeight(1 - m.joined / 175)
.height(7)
.backgroundColor(COLORS.bg)
.borderRadius(3)
}
.width(100)
.height(7)
Text(m.joined + '')
.fontSize(9)
.fontColor(m.color)
.width(30)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (m: MatchHotMeta) => m.name)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
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.matches, (m: MatchItem, i: number) => {
Row() {
Column() {
Text(m.date.substring(5))
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.ink)
Text(m.date.substring(0, 5))
.fontSize(8)
.fontColor(COLORS.textHint)
.margin({ top: 2 })
}
.width(42)
.alignItems(HorizontalAlign.Center)
.padding({ top: 6, bottom: 6 })
.backgroundColor(COLORS.accentLight)
.borderRadius(8)
Column() {
Row() {
Text(m.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SkateTag({ text: m.level, color: getLevelColor(m.level) })
.margin({ left: 6 })
}
.width('100%')
Text('奖金 ' + m.prize + ' · 报名 ' + m.joined + '/' + m.signup)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 10 })
Text('✎')
.fontSize(13)
.fontColor(COLORS.textHint)
.onClick(() => {
this.onEdit();
})
}
.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 })
}, (m: MatchItem, i: number) => m.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.concrete + '33')
.borderRadius(14)
.border({ width: 2, color: COLORS.ink })
.margin({ top: 12 })
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct GearContent {
@ObjectLink data: SkateData;
onDel: (n: string) => void = (n: string) => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('🛒 销量 TOP6')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('月度')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
ForEach(GEAR_TOP, (g: GearTopMeta) => {
Row() {
Text(g.name)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Row() {
Row()
.layoutWeight(g.sales / 96)
.height(7)
.backgroundColor(g.color)
.borderRadius(3)
Row()
.layoutWeight(1 - g.sales / 96)
.height(7)
.backgroundColor(COLORS.bg)
.borderRadius(3)
}
.width(100)
.height(7)
Text(g.sales + '')
.fontSize(9)
.fontColor(g.color)
.width(30)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (g: GearTopMeta) => g.name)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
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.gears, (g: GearItem, i: number) => {
Row() {
Text(g.emoji)
.fontSize(20)
.width(42)
.height(42)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.accentLight)
.borderRadius(12)
Column() {
Row() {
Text(g.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SkateTag({ text: g.brand, color: COLORS.cyan })
.margin({ left: 6 })
}
.width('100%')
Text('月销 ' + g.sales + ' 件 · 库存 ' + g.stock + ' 件')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 10 })
Column() {
Text('¥' + g.price)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.danger)
Text('✕')
.fontSize(12)
.fontColor(COLORS.textHint)
.margin({ top: 4 })
.onClick(() => {
this.onDel(g.name);
})
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 12, right: 12, top: 9, bottom: 9 })
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (g: GearItem, i: number) => g.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.concrete + '33')
.borderRadius(14)
.border({ width: 2, color: COLORS.ink })
.margin({ top: 12 })
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct LessonContent {
@ObjectLink data: SkateData;
build() {
Scroll() {
Column() {
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.lessons, (l: LessonItem, i: number) => {
Row() {
Text(l.emoji)
.fontSize(20)
.width(42)
.height(42)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.lime + '33')
.borderRadius(12)
Column() {
Row() {
Text(l.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
SkateTag({ text: l.level, color: getDiffColor(l.level) })
.margin({ left: 6 })
}
.width('100%')
Text('教练 ' + l.coach + ' · 报名 ' + l.joined + '/' + l.seats)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.margin({ top: 3 })
Row() {
Row()
.layoutWeight(l.joined / l.seats)
.height(5)
.backgroundColor(l.joined === l.seats ? COLORS.pink : COLORS.primary)
.borderRadius(2)
Row()
.layoutWeight(1 - l.joined / l.seats)
.height(5)
.backgroundColor(COLORS.bg)
.borderRadius(2)
}
.width('100%')
.height(5)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 10 })
Column() {
Text('¥' + l.price)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.danger)
Text(l.joined === l.seats ? '已满员' : '可报名')
.fontSize(9)
.fontColor(l.joined === l.seats ? COLORS.pink : COLORS.success)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ left: 12, right: 12, top: 9, bottom: 9 })
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.border({ width: 1, color: COLORS.line })
.margin({ bottom: 8 })
}, (l: LessonItem, i: number) => l.name + i)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
.border({ width: 2, color: COLORS.ink })
}
.width('100%')
.padding(14)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
}
@Component
struct AddSkaterModal {
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.bg)
.borderRadius(10)
.margin({ top: 14 })
Row() {
Text('滑板水平')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('进阶 · 已完成 186 招')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('主攻风格')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('街式 · 道具区常驻')
.fontSize(12)
.fontColor(COLORS.pink)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('初始粉丝')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('1600 · 预计周增长 8%')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.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.bg)
.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: 2, color: COLORS.ink })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
@Component
struct EditMatchModal {
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.bg)
.borderRadius(10)
.margin({ top: 14 })
Row() {
Text('开赛时间')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('07-20 → 延期至 08-02')
.fontSize(12)
.fontColor(COLORS.warning)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('奖金池')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('2万 → 提升至 3万')
.fontSize(12)
.fontColor(COLORS.pink)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('报名名额')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('200 人 · 已报 168 人')
.fontSize(12)
.fontColor(COLORS.success)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.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.bg)
.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: 2, color: COLORS.accent })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
@Component
struct DeleteGearModal {
title: string = '';
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%')
Text('商品名称:' + this.title)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.danger)
.width('100%')
.padding(12)
.backgroundColor(COLORS.accentLight)
.borderRadius(10)
.margin({ top: 14 })
Text('⚠️ 下架后商品从商店移除,已售装备继续享受质保服务。')
.fontSize(10)
.fontColor(COLORS.warning)
.width('100%')
.margin({ top: 12 })
Row() {
Text('保留商品')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS.bg)
.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: 2, color: COLORS.danger })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
@Component
struct DetailParkModal {
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('1200㎡ · 全钢架结构')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ top: 14 })
Row() {
Text('开放时段')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('09:00 - 22:00 · 周末延长')
.fontSize(12)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.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.bg)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('人气指数')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(70)
Text('周末峰值 92% · 建议错峰')
.fontSize(12)
.fontColor(COLORS.pink)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
.margin({ top: 8 })
Text('🛹 该场地配备专业摄影机位,供选手录制动作回放与赛事直播使用。')
.fontSize(10)
.fontColor(COLORS.textHint)
.width('100%')
.margin({ top: 14 })
Row() {
Text('关闭')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS.primary)
.borderRadius(12)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 16 })
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.border({ width: 2, color: COLORS.ink })
}
.width('100%')
.justifyContent(FlexAlign.End)
.constraintSize({ maxHeight: '78%' })
}
}
结尾总结

通过对这款街头滑板公园应用的全面代码解析,我们可以从多个维度提炼出其架构设计的核心要点。在色彩工程维度,应用通过SkatePalette接口和COLORS常量构建了一套20色的语义化调色板系统。每个颜色都有明确的语义指向——primary代表品牌活力、danger代表热门或危险、success代表可用或安全、warning代表注意或中级。色彩不仅用于视觉美化,更承担了信息编码的功能——场地难度通过颜色递进(绿→黄→红→黑)传达等级高低,赛事级别通过颜色(青→黄→橙→红)映射重要程度。这种"色彩即语义"的设计理念让用户能够在不阅读文字的情况下快速获取关键信息,极大提升了信息消费效率。
在状态管理维度,应用采用了@Observed+@ObjectLink+@State的三层响应式数据流架构。SkateData作为集中式数据中枢,通过@Observed标记为可观察对象,其所有数组属性的变化都会被框架自动追踪。主入口组件通过@State持有数据实例的引用,五个内容页通过@ObjectLink建立对同一实例的可观察链接。这种设计确保了数据的一致性——任何组件对数据的修改都会立即反映到所有依赖该数据的视图中。弹窗的显示/隐藏通过四个布尔状态变量控制,采用"状态驱动UI"模式而非命令式调用,使弹窗的生命周期完全由状态管理框架托管。状态提升到主入口组件的做法虽然增加了父组件的复杂度,但确保了状态变化的可追溯性和可调试性。
在组件化维度,应用采用了四层组件架构:主入口(SkateApp)→内容页(ParkContent等5个)→弹窗(AddSkaterModal等4个)→原子组件(SkateTag)。每一层有明确的职责边界:主入口负责导航和状态管理,内容页负责列表渲染和数据可视化,弹窗负责交互确认,原子组件负责跨模块复用。SkateTag作为唯一的原子组件,通过"同色三透明度"的视觉策略(文字100%+边框31%+背景15%)实现了精致的标签效果,在不同业务场景中通过传入不同颜色适配不同语义。组件间通信采用回调函数模式——子组件通过回调通知父组件状态变化,父组件更新状态后通过响应式系统驱动弹窗显示,形成了单向数据流。这种"子组件触发回调→父组件更新状态→状态驱动UI"的数据流模式确保了状态管理的可预测性。
在数据可视化维度,应用在不引入图表库的情况下,利用ArkUI的layoutWeight特性实现了柱状图、进度条和排行榜三种数据可视化形式。柱状图通过height(f.value * 1.05)将数据值映射为像素高度,进度条通过双Row的layoutWeight(count / max)和layoutWeight(1 - count / max)实现了比例条效果。这些可视化方案的精妙之处在于完全利用了ArkUI布局引擎的原生能力,无需额外的渲染开销。颜色编码在可视化中扮演了关键角色——柱状图中超过80的柱子用粉色高亮、进度条中超过90的热度用红色标识、满员的课程进度条用粉色变化。这种"数据驱动颜色"的策略让用户能够通过视觉快速识别异常值和关键数据点。
在工程方法论维度,应用体现了几个重要的工程实践:首先,接口先行的设计模式(SkatePalette、TabMeta、各Item接口)在类型层面约束了数据结构,减少了运行时错误。其次,静态配置数据(DAILY_FLOW、STYLE_SHARE、MATCH_HOT、GEAR_TOP)与动态业务数据(SkateData)的分离,使得原型阶段可以专注于UI实现,后续接入真实API时只需替换数据源。再者,颜色映射函数(getLevelColor、getDiffColor)将颜色决策逻辑从UI组件中抽离,实现了关注点分离。最后,模块级常量(COLORS、TAB_LIST)的集中管理使得全局调整只需修改一处,体现了DRY原则。这些工程实践虽然各自看似简单,但组合在一起形成了一套可维护、可扩展的代码架构。
在用户体验维度,应用在多个层面注入了街头文化的设计元素。标题栏的滑板翻转和喷漆抖动微交互增加了应用的趣味性;场地难度标签的-6度旋转模拟了街头贴纸的随意感;文化标签徽章(STREET、GRAFFITI、URBAN)传递了社区的文化定位;板友排名前三的粉色徽章借鉴了体育赛事的奖牌文化;弹窗的底部弹起效果和78%高度限制确保了操作时的上下文可见性。这些设计细节虽然不直接影响功能,但共同营造了"街头、自由、表达"的产品氛围,让用户在使用过程中感受到文化认同感。应用还通过提示文字(“周末爆满”、“建议错峰”、“已售装备继续享受质保服务”)在关键信息点提供了行动建议和业务保障说明,体现了以用户为中心的设计思维。
在可扩展性维度,应用的架构为后续迭代预留了充足空间。SkateData类的@Observed标记使得新增数据属性后自动获得可观察能力。TAB_LIST数组的结构使得新增Tab只需追加一条配置(key、icon、label、color),无需修改导航栏渲染逻辑。条件渲染链(if-else if-else)虽然简单,但新增页面只需追加一个else if分支。弹窗的状态驱动模式使得新增弹窗只需定义一个布尔状态和一个条件渲染块。表单弹窗中的静态文本值后续可以替换为TextInput组件实现真正的表单输入。颜色映射函数可以扩展为策略模式(Strategy Pattern)以支持更复杂的映射规则。这些扩展点的设计虽然不是传统意义上的设计模式应用,但在声明式UI的语境下,它们有效地平衡了简洁性与可扩展性。
更多推荐




所有评论(0)