引言

在这里插入图片描述

在移动应用开发领域,垂直行业的精细化管理工具正成为一股不可忽视的潮流。桌游俱乐部作为一个融合了社交、竞技、收藏与休闲的复合型实体业态,其数字化管理需求涵盖了桌游库存管理、会员体系运营、赛事组织调度、活动报名追踪以及珍品收藏展示等多个维度。本文将深入剖析一个基于 HarmonyOS 声明式开发范式的桌游俱乐部管理应用,从色彩体系、数据建模、状态管理、组件化拆分到交互细节,全景展示如何用一套代码构建出兼具视觉美感与功能完备的跨端管理工具。

该应用采用了复古棋盘格视觉语言作为核心设计理念。棋盘格是桌游文化最具辨识度的视觉符号——从国际象棋的黑白方阵到大富翁的环形路径,格子的交替排列天然承载了策略博弈的隐喻。设计团队将这一视觉元素提炼为顶部装饰条纹,通过两种色彩的交替铺排模拟棋盘质感,同时搭配可交互的骰子与棋子动效,使界面在第一时间即传递出"桌游"这一品类基因。这种视觉语义的精准映射,远胜于泛泛的卡片堆砌。

技术选型方面,该应用完全基于 ArkUI 声明式开发范式构建。ArkUI 是 HarmonyOS 的官方 UI 框架,采用类似 SwiftUI 的声明式语法,通过 struct 组件、@State/@Prop/@ObjectLink 等状态装饰器以及 @Builder 构建器实现界面的描述式组装。相较于命令式编程,声明式范式让开发者只需关注"界面应该是什么样子"而非"如何一步步操作界面",显著降低了状态同步的复杂度。应用还充分利用了 @Observed 装饰器实现跨组件的响应式数据流,确保数据变化能自动驱动所有依赖它的视图更新。

色彩体系是本应用的一大亮点。设计团队精心调配了一套以"棋盘绿"为主色调、以"琥珀金"为强调色的复古调色板。主色 #3E6B4F 是一种深沉而温润的森林绿,呼应实木棋盘的质感;强调色 #C9A227 则是醇厚的琥珀金,模拟棋子的镀金边框。背景色 #F2EDDE 采用温暖的米黄底,搭配 #FFFDF4 的卡片白底,营造出旧式桌游说明书般的怀旧氛围。此外还定义了 success/warning/danger 三种语义色以及 cream、gold 等辅助色,共计十八种颜色,构成了一套自洽且丰富的色彩语言。

组件化策略上,应用采用了"主入口 + 内容页 + 弹窗"的三层拆分模式。主入口 BoardApp 组件负责整体布局骨架,包括顶部棋格装饰条、底部 Tab 导航栏和弹窗调度;五个内容页组件分别对应桌游、会员、赛事、活动、收藏五个业务模块,各自封装了统计图表和列表渲染逻辑;四个弹窗组件则处理新增桌游、编辑会员、删除赛事、活动详情等交互动作。这种拆分方式使每个组件职责单一、边界清晰,符合高内聚低耦合的工程原则。同时,公共的 BoardTag 标签组件被抽出复用,避免了多处重复编码。

数据模型层面,应用定义了十个接口类型来描述各业务实体——从 GameItem 的桌游信息到 CollectItem 的收藏珍品,每个接口都精确刻画了该实体所需的属性维度。所有业务数据集中在 BoardData 这个 @Observed 类中统一管理,通过 @ObjectLink 在子组件间共享引用。这种集中式的状态管理使数据来源唯一可追溯,避免了散落各处导致的不一致风险。此外,应用还预置了周开桌次数、类型分布、赛事热度、活动报名率等多组统计元数据,用于驱动各页面的可视化图表。

接下来,本文将逐段拆解源代码,从色彩定义到数据建模,从主入口结构到每个子组件的实现细节,进行全景式的深度分析。


组件架构总览

在这里插入图片描述

在进入逐段分析之前,先通过流程图了解整个应用的组件关系与数据流向。

GAME

MEMBER

MATCH

EVENT

COLLECT

BoardApp 主入口组件

顶部棋格装饰区

curTab 状态判断

底部 Tab 导航栏

弹窗调度层

GameContent 桌游页

MemberContent 会员页

MatchContent 赛事页

EventContent 活动页

CollectContent 收藏页

BoardData @Observed

AddGameModal 新增桌游弹窗

EditMemberModal 编辑会员弹窗

DeleteMatchModal 删除赛事弹窗

DetailEventModal 活动详情弹窗

BoardTag 公共标签组件

如图所示,BoardApp 作为根组件,通过 curTab 状态变量驱动条件渲染,在五个内容页之间切换。所有内容页通过 @ObjectLink 共享同一个 BoardData 实例,确保数据的一致性。弹窗组件则由各自的触发状态(showAddGameshowEditMember 等)独立控制显隐,形成解耦的交互层。


逐段代码分析

在这里插入图片描述

第一段:BoardPalette 色彩接口定义

interface BoardPalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  accent: string;
  accentLight: string;
  board: string;
  boardLight: string;
  bg: string;
  cardBg: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  line: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
  cream: string;
  gold: string;
}

这一段定义了 BoardPalette 接口,作为整个应用色彩体系的类型契约。接口而非类的选择体现了 TypeScript 的结构化类型思想——我们只需要约束形状,不需要实例化。十八个字段涵盖了主色三阶(primary/light/dark)、强调色二阶、棋盘色二阶、背景与卡片底色、三阶文字色、分割线色、三种语义色以及辅助色。这种命名方式遵循了 Material Design 的色彩层级约定,但色值本身却完全服务于复古棋盘的视觉主题。通过接口集中管理,避免了硬编码色值散落各处导致的维护噩梦。任何色彩调整只需修改一处常量定义,全局自动生效。

第二段:COLORS 色彩常量实例

在这里插入图片描述

const COLORS: BoardPalette = {
  primary: '#3E6B4F',
  primaryLight: '#7FA98C',
  primaryDark: '#2A4A36',
  accent: '#C9A227',
  accentLight: '#F0E3B6',
  board: '#2E5D3F',
  boardLight: '#5C8A6A',
  bg: '#F2EDDE',
  cardBg: '#FFFDF4',
  textPrimary: '#2B3B30',
  textSecondary: '#6E7A6F',
  textHint: '#A8B0A4',
  line: '#E3DCC8',
  success: '#5B8C5A',
  warning: '#D89A3E',
  danger: '#C05B4D',
  white: '#FFFFFF',
  cream: '#F5EFE2',
  gold: '#D9B64C'
};

这里将 COLORS 常量实例化,为前述接口的每个字段赋予具体色值。主色 #3E6B4F 是一种偏深的苔藓绿,在 HSV 色彩空间中饱和度适中、明度偏低,给人以沉稳厚重之感,恰如老式橡木棋盘的视觉印象。强调色 #C9A227 是一种饱和度极高的琥珀金,与主绿形成互补色对比,在界面中用于突出评分、标题等关键信息。背景色 #F2EDDE 带有轻微的暖黄偏移,模拟泛黄纸张的质感。文字三阶色从深墨绿到浅灰绿渐变,保证了在米色背景上的可读性层级。这套色彩组合在色盲友好性方面也表现良好,因为各色相之间通过明度差异保持了足够区分度。

第三段:BoardTab 枚举、TabMeta 接口与导航数据

在这里插入图片描述

enum BoardTab {
  GAME = 0,
  MEMBER = 1,
  MATCH = 2,
  EVENT = 3,
  COLLECT = 4
}

interface TabMeta {
  key: string;
  icon: string;
  label: string;
  color: string;
}

const TAB_LIST: TabMeta[] = [
  { key: 'game', icon: '🎲', label: '桌游', color: '#3E6B4F' },
  { key: 'member', icon: '♟️', label: '会员', color: '#C05B4D' },
  { key: 'match', icon: '🏆', label: '赛事', color: '#C9A227' },
  { key: 'event', icon: '🎪', label: '活动', color: '#5B8C5A' },
  { key: 'collect', icon: '💎', label: '收藏', color: '#8A6B4D' }
];

const CHECK_COL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

这段代码集中定义了导航体系的基础设施。BoardTab 枚举使用具名常量替代魔术数字,使代码语义清晰且编译器能提供越界检查。TabMeta 接口描述了标签的元数据结构,四个字段分别承载唯一键、emoji 图标、中文标签和主题色。TAB_LIST 常量数组实例化了五个导航标签,每个标签的 emoji 与中文标签语义高度对应——骰子代表桌游、棋子代表会员、奖杯代表赛事。使用 emoji 而非图标字体是一个轻量化的工程决策,天然跨平台、无需额外资源文件。CHECK_COL 定义了零到九的十元素数组,专门驱动顶部棋盘格条纹的 ForEach 渲染,十格宽度恰好对应十等分屏幕宽度。这种数据驱动的标签定义方式使标签的增删改只需修改数据数组。

第四段:GameItem 与 MemberItem 数据接口

在这里插入图片描述

interface GameItem {
  name: string;
  type: string;
  difficulty: string;
  rating: number;
  plays: number;
  minutes: number;
  emoji: string;
}

interface MemberItem {
  name: string;
  level: string;
  points: number;
  visits: number;
  joined: string;
  emoji: string;
}

这段定义了桌游和会员两个核心业务实体的数据接口。GameItem 的七个字段涵盖了名称、类型、难度、评分、开桌次数、单局时长和表情图标,ratingplays 使用 number 类型便于数值比较和进度条计算。difficulty 用字符串而非枚举,牺牲了类型安全性换取了灵活性。MemberItem 的六个字段构成了精简但完整的会员档案,level 存储等级名称,pointsvisits 分别反映消费能力和活跃程度。joined 采用 "YYYY-MM" 格式既简洁又满足按月统计需求。两个接口的 emoji 字段使每条数据自带视觉标识,在列表渲染时提供第一眼辨识度。将展示属性与业务属性混合在同一接口中,在中小型应用中是务实且高效的。

第五段:MatchItem 与 EventItem 数据接口

在这里插入图片描述

interface MatchItem {
  name: string;
  date: string;
  players: number;
  prize: number;
  status: string;
  emoji: string;
}

interface EventItem {
  name: string;
  time: string;
  seats: number;
  joined: number;
  type: string;
  emoji: string;
}

这两个接口分别定义了赛事和活动实体的数据结构。MatchItemdate 使用 "MM-DD" 短日期格式,因为赛事列表只需展示日期而无需精确到时分。playersprize 作为数值类型支持大小比较和排序,status 用字符串描述状态在 UI 层通过颜色映射区分。EventItemtime 采用自由文本格式而非标准日期类型,这是因为活动时间表达方式多样——有周期型也有定点型,统一建模反而增加解析复杂度。seatsjoined 这对数值字段天然支持计算报名率用于进度条可视化。这种灵活性与结构性的平衡设计,体现了对业务场景多样性的尊重。

第六段:CollectItem 与统计元数据接口

在这里插入图片描述

interface CollectItem {
  name: string;
  rarity: string;
  owner: string;
  price: number;
  stage: string;
  emoji: string;
}

interface WeekMeta {
  day: string;
  value: number;
}

CollectItem 接口定义了桌游珍品收藏的数据结构,六个字段完整描述了藏品从归属到状态的全生命周期信息。rarity 描述稀有度是收藏品的核心价值指标,price 使用数值类型便于汇总计算总估值,stage 标注当前流转状态。WeekMeta 接口结构极简——仅包含星期标签和数值两个字段,专门服务于柱状图可视化。将统计元数据独立定义为接口而非内联在组件中,使数据与视图实现了关注点分离。如果未来需要增加同比环比数据,只需扩展接口字段而无需重构渲染逻辑。这种"数据先行"的设计思维使统计图表的配置完全数据驱动。

第七段:TypeMeta、MatchHotMeta 与 EventJoinMeta 接口

interface TypeMeta {
  name: string;
  count: number;
  color: string;
}

interface MatchHotMeta {
  name: string;
  players: number;
  color: string;
}

interface EventJoinMeta {
  name: string;
  rate: number;
  color: string;
}

这三个接口分别定义了类型分布、赛事热度和活动报名率统计的数据单元。三者结构相似但语义不同——TypeMeta 以数量为核心、MatchHotMeta 以绝对人数为核心、EventJoinMeta 以百分比为核心,因此保持为独立接口而非合并。这种"语义优先于结构"的接口设计原则避免了用一个泛化接口承载多种含义不同的数据。三个接口都内嵌了 color 属性,使每条数据自带视觉属性,渲染时无需额外查表。这种"自带色彩"的设计模式在数据量小且色彩映射固定时非常高效。不过其代价是色彩与数据耦合,如果未来需要支持主题切换会增加改造难度。

第八段:WEEK_TABLE 与 TYPE_SHARE 统计数据

const WEEK_TABLE: WeekMeta[] = [
  { day: '周一', value: 24 },
  { day: '周二', value: 30 },
  { day: '周三', value: 28 },
  { day: '周四', value: 36 },
  { day: '周五', value: 48 },
  { day: '周六', value: 72 },
  { day: '周日', value: 60 }
];

const TYPE_SHARE: TypeMeta[] = [
  { name: '策略', count: 3, color: '#3E6B4F' },
  { name: '聚会', count: 2, color: '#C9A227' },
  { name: '卡牌', count: 2, color: '#C05B4D' },
  { name: '战棋', count: 2, color: '#5B8C5A' },
  { name: '解谜', count: 2, color: '#8A6B4D' },
  { name: '合作', count: 1, color: '#6E7A6F' }
];

WEEK_TABLE 预置了一周七天的开桌次数数据,呈现出典型的周末高峰特征:周六以 72 局居首,工作日则从 24 到 48 递增。这种分布反映了桌游俱乐部作为休闲业态的自然客流规律。在 UI 层,数值大于等于 60 的柱子用深绿色突出形成"周末热区"视觉提示。TYPE_SHARE 定义了馆藏桌游的类型分布,六个类型合计十二款,策略类以三款居首。每个类型的数据自带专属色彩,与标签系统的色彩映射保持一致。进度条的填充比例通过 count / 3 计算(三款为满格),使不同类型之间的数量差异一目了然。预置数据而非从服务器获取,在原型阶段可以独立调试界面。

第九段:MATCH_HOT 与 EVENT_JOIN 统计数据

const MATCH_HOT: MatchHotMeta[] = [
  { name: '年度桌游嘉年华', players: 120, color: '#C9A227' },
  { name: '狼人杀城市赛', players: 80, color: '#3E6B4F' },
  { name: '血染钟楼夜赛', players: 60, color: '#C05B4D' },
  { name: '卡坦争霸赛', players: 32, color: '#5B8C5A' },
  { name: '大富翁家庭赛', players: 28, color: '#8A6B4D' },
  { name: '星蚀银河赛', players: 24, color: '#D89A3E' }
];

const EVENT_JOIN: EventJoinMeta[] = [
  { name: '俱乐部周年庆', rate: 92, color: '#C9A227' },
  { name: '极客嘉年华', rate: 88, color: '#3E6B4F' },
  { name: '午夜桌游长夜', rate: 82, color: '#C05B4D' },
  { name: '卡牌收藏展', rate: 74, color: '#5B8C5A' },
  { name: '桌游盲盒交换会', rate: 66, color: '#8A6B4D' },
  { name: '拼图马拉松', rate: 58, color: '#D89A3E' }
];

MATCH_HOT 预置了六场赛事的热度排行数据,按报名人数从 120 到 24 降序排列。年度桌游嘉年华以 120 人参赛居首,进度条以 120 为基准做 players / 120 的归一化计算使冠军赛事满格。前三个赛事的排名序号用红色突出显示模拟领奖台视觉差异。EVENT_JOIN 定义了六场活动的报名率数据,从 92% 到 58% 递减。进度条通过 rate / 100 直接计算填充比例无需归一化处理,使进度条长度与数值形成直观的线性映射。两组数据都自带独立色彩,在条形图中形成多彩的排行展示效果,让运营者一眼识别最受欢迎的赛事和活动。

第十段:BoardData 可观察类——桌游数据

@Observed
export class BoardData {
  games: GameItem[] = [
    { name: '卡坦岛', type: '策略', difficulty: '进阶', rating: 9.2, plays: 268, minutes: 90, emoji: '🏝️' },
    { name: '三国杀', type: '卡牌', difficulty: '新手', rating: 8.5, plays: 412, minutes: 40, emoji: '⚔️' },
    { name: '大富翁', type: '聚会', difficulty: '新手', rating: 8.0, plays: 556, minutes: 90, emoji: '🏦' },
    { name: '狼人杀', type: '聚会', difficulty: '新手', rating: 8.8, plays: 623, minutes: 60, emoji: '🐺' },
    { name: '星蚀', type: '策略', difficulty: '大师', rating: 9.0, plays: 96, minutes: 180, emoji: '🚀' },
    { name: '七宗罪', type: '卡牌', difficulty: '进阶', rating: 8.6, plays: 187, minutes: 45, emoji: '😈' },
    { name: '战争之匣', type: '战棋', difficulty: '进阶', rating: 9.1, plays: 143, minutes: 120, emoji: '🧱' },
    { name: '幽灵马车', type: '解谜', difficulty: '新手', rating: 7.9, plays: 98, minutes: 40, emoji: '🚗' },
  ];

BoardData 类通过 @Observed 装饰器标记为可观察对象,这是 ArkUI 响应式数据流的核心机制。@Observed 会使类的属性变化被框架追踪,当 games 数组内容变化时,所有通过 @ObjectLink 引用该实例的子组件都会自动重新渲染。games 数组预置了十二款经典桌游数据,从卡坦岛到神秘大地,涵盖策略、卡牌、聚会、战棋、解谜、合作六大类型。每款游戏的 ratingplays 等数值字段在 UI 层用于评分进度条和热度计算。将所有业务数据集中在单个类中而非分散到多个 Store,简化了数据流转路径,适合中小型应用的状态管理需求。

第十一段:BoardData——会员数据

  members: MemberItem[] = [
    { name: '林小满', level: '资深', points: 12800, visits: 156, joined: '2024-03', emoji: '🦊' },
    { name: '陈一舟', level: '新人', points: 3200, visits: 42, joined: '2026-01', emoji: '🐼' },
    { name: '苏糖', level: '殿堂', points: 25600, visits: 210, joined: '2023-06', emoji: '🐰' },
    { name: '王铁蛋', level: '进阶', points: 8600, visits: 98, joined: '2025-04', emoji: '🐻' },
    { name: '阿岚', level: '殿堂', points: 30100, visits: 268, joined: '2022-11', emoji: '🦌' },
    { name: '老K', level: '进阶', points: 7400, visits: 86, joined: '2025-08', emoji: '🐺' },
    { name: '草莓', level: '新人', points: 4100, visits: 51, joined: '2025-12', emoji: '🐹' },
    { name: '赵一飞', level: '资深', points: 13900, visits: 162, joined: '2024-08', emoji: '🦁' },
  ];

members 数组预置了十二位会员数据,等级覆盖了新人、进阶、资深、殿堂四个层次。殿堂级会员阿岚以 30100 积分和 268 次到店位居榜首,代表了俱乐部最核心的忠实用户群体。joined 字段的时间跨度从 2022 年到 2026 年,反映了俱乐部运营的历史纵深。emoji 为每位会员分配了动物头像,增添了趣味性和辨识度。这些数据不仅用于列表展示,还支撑了会员等级分布统计模块的数据来源。在真实场景中这些数据应从后端 API 获取,此处预置为静态数据用于前端独立调试。将会员数据与桌游数据放在同一个 BoardData 类中,使跨模块的数据关联(如收藏品的归属人)可以在同一对象内完成查找。

第十二段:BoardData——赛事数据

  matches: MatchItem[] = [
    { name: '春季狼人杯', date: '03-15', players: 48, prize: 3000, status: '已结束', emoji: '🏅' },
    { name: '卡坦争霸赛', date: '03-22', players: 32, prize: 2000, status: '报名中', emoji: '🧭' },
    { name: '星蚀银河赛', date: '04-05', players: 24, prize: 5000, status: '筹备中', emoji: '🚀' },
    { name: '三国杀双人赛', date: '04-12', players: 40, prize: 1500, status: '已结束', emoji: '🃏' },
    { name: '血染钟楼夜赛', date: '04-19', players: 60, prize: 4000, status: '报名中', emoji: '🕰️' },
    { name: '神秘大地大师赛', date: '05-03', players: 16, prize: 8000, status: '筹备中', emoji: '🌍' },
    { name: '大富翁家庭赛', date: '05-10', players: 28, prize: 800, status: '已结束', emoji: '💰' },
    { name: '战争之匣挑战赛', date: '05-17', players: 20, prize: 3000, status: '报名中', emoji: '🧱' },
  ];

matches 数组包含了十二场赛事数据,时间跨度从三月到六月,覆盖了春季到初夏的完整赛事日历。status 字段的三种状态——已结束、报名中、筹备中——在 UI 层通过颜色映射实现视觉区分:已结束用灰色、报名中用绿色、筹备中用默认色。prize 奖池金额从 800 到 15000 不等,年度嘉年华赛事以 15000 元的奖池成为最重量级比赛。赛事数据按时间顺序排列而非按热度排列,使用户能按时间线规划参赛计划。每场赛事的 emoji 与赛事名称语义对应,提供了快速的视觉辨识。players 字段记录已报名人数,在赛事热度榜中用于归一化计算排行进度条。

第十三段:BoardData——活动数据

  events: EventItem[] = [
    { name: '周五新手教学夜', time: '每周五 19:30', seats: 30, joined: 24, type: '教学', emoji: '🎓' },
    { name: '桌游盲盒交换会', time: '03-18 14:00', seats: 24, joined: 18, type: '交换', emoji: '📦' },
    { name: '拼图马拉松', time: '03-25 10:00', seats: 20, joined: 12, type: '挑战', emoji: '🧩' },
    { name: '古董桌游鉴赏', time: '04-02 15:00', seats: 18, joined: 16, type: '鉴赏', emoji: '🏺' },
    { name: '亲子桌游日', time: '04-09 09:30', seats: 32, joined: 20, type: '亲子', emoji: '👨‍👩‍👧' },
    { name: '桌游设计工作坊', time: '04-16 13:00', seats: 18, joined: 14, type: '创作', emoji: '✏️' },
    { name: '午夜桌游长夜', time: '04-23 20:00', seats: 28, joined: 22, type: '夜场', emoji: '🌙' },
    { name: '卡牌收藏展', time: '05-07 10:00', seats: 40, joined: 26, type: '展会', emoji: '🃏' },
  ];

events 数组预置了十二场活动数据,活动类型丰富多元——从教学到交换、从挑战到鉴赏、从亲子到创作。time 字段的自由文本格式适应了周期型活动(“每周五 19:30”)和定点型活动(“03-18 14:00”)的不同表达需求。seatsjoined 的比值用于计算报名进度条,当报名率超过 80% 时进度条变红色提示即将满额。活动类型通过 BoardTag 组件以标签形式展示,增强了列表信息的结构化程度。十二场活动的多样性体现了俱乐部运营的丰富生态,从新手入门到深度参与都有对应的活动场景。数据按时间排列,使用户能按时间线规划参与计划。

第十四段:BoardData——收藏数据

  collects: CollectItem[] = [
    { name: '1987首版大富翁', rarity: '传奇', owner: '老K', price: 12000, stage: '展出', emoji: '🏛️' },
    { name: '德国限定卡坦', rarity: '稀有', owner: '阿岚', price: 8600, stage: '借出', emoji: '📦' },
    { name: '手绘三国杀典藏', rarity: '稀有', owner: '赵一飞', price: 5200, stage: '展出', emoji: '🎨' },
    { name: '铁盒版战争之匣', rarity: '稀有', owner: '林小满', price: 6800, stage: '维护', emoji: '🔧' },
    { name: '签名版狼人杀', rarity: '普通', owner: '苏糖', price: 2400, stage: '展出', emoji: '📜' },
    { name: '木刻王冠棋', rarity: '传奇', owner: '十七', price: 9800, stage: '展出', emoji: '🪵' },
    { name: '日版星蚀限定', rarity: '稀有', owner: '温酒', price: 7500, stage: '借出', emoji: '🚀' },
    { name: '绝版幽灵马车', rarity: '普通', owner: '米娅', price: 1800, stage: '展出', emoji: '👻' },
  ];

collects 数组定义了十二件珍藏品的档案,稀有度覆盖了传奇、稀有、普通三个等级。1987 首版大富翁以 12000 元估值成为镇馆之宝,首届全国赛纪念盘紧随其后。stage 字段记录了藏品的当前流转状态——展出、借出、维护,支撑了藏品的全生命周期追踪。owner 字段关联了藏品归属人,与会员数据形成了跨模块的关联关系。这些数据在 UI 层通过 getRarityColor 工具函数将稀有度映射为语义色彩,传奇用红色、稀有用金色、普通用灰色。整个 BoardData 类至此定义完毕,所有业务数据集中管理,通过 @Observed 实现响应式追踪,为子组件的 @ObjectLink 绑定提供了统一的数据源。

第十五段:三组语义色彩映射函数

function getTypeColor(type: string): string {
  if (type === '策略') return '#3E6B4F';
  if (type === '聚会') return '#C9A227';
  if (type === '卡牌') return '#C05B4D';
  if (type === '战棋') return '#5B8C5A';
  if (type === '解谜') return '#8A6B4D';
  return '#6E7A6F';
}

function getLevelColor(level: string): string {
  if (level === '新人') return '#5B8C5A';
  if (level === '进阶') return '#C9A227';
  if (level === '资深') return '#3E6B4F';
  return '#C05B4D';
}

function getRarityColor(rarity: string): string {
  if (rarity === '传奇') return '#C05B4D';
  if (rarity === '稀有') return '#C9A227';
  return '#6E7A6F';
}

这三个函数共同构成了应用的三组语义色彩服务,将业务概念转化为色彩值。getTypeColor 将桌游类型映射为六种独立色彩,策略绿、聚会金、卡牌红、战棋浅绿、解谜棕、合作灰。getLevelColor 将会员等级映射为四色,从新人浅绿到殿堂红色形成从冷到暖的渐进序列。getRarityColor 将收藏稀有度映射为三档色彩,传奇红突出最高价值。三个函数都使用 if 链而非 switch 或字典查找,在分支数量较少时具有最佳可读性。末尾的默认返回值处理了未知值的情况,避免了运行时返回 undefined 导致色彩缺失。这种集中式的色彩映射函数使类型与色彩的对应关系只在此处维护,修改时只需调整这些函数。它们是策略模式的简化版实现。

第十六段:BoardApp 主入口组件——状态声明

@Entry
@Component
struct BoardApp {
  @State curTab: number = 0;
  @State data: BoardData = new BoardData();
  @State showAddGame: boolean = false;
  @State showEditMember: boolean = false;
  @State showDeleteMatch: boolean = false;
  @State showDetail: boolean = false;
  @State delMatchName: string = '';
  @State detailEventName: string = '';
  @State diceSpin: boolean = false;
  @State pawnMove: boolean = false;

BoardApp 是应用的入口组件,通过 @Entry@Component 装饰器声明。九个 @State 状态变量管理着应用的全局状态:curTab 控制当前激活的标签页,data 持有唯一的业务数据实例,四个 show* 布尔值分别控制四个弹窗的显隐,两个字符串变量暂存弹窗所需的上下文参数,两个布尔值驱动顶部骰子和棋子的动画状态。@State 装饰器使这些变量的变化能自动触发组件的重新渲染,是声明式 UI 的核心机制。将所有状态集中在根组件中管理,形成了一个轻量级的中心化状态管理方案,适合当前规模的应用。每个状态变量都有明确的初始值,确保应用启动时的初始状态是确定性的。

第十七段:modalOverlay 构建器

  @Builder
  modalOverlay(onClose: () => void) {
    Column() {
      Text('')
        .width(0)
        .height(0)
        .opacity(0)
      Button('')
        .width(1)
        .height(1)
        .opacity(0)
        .onClick(() => {
          onClose();
        })
    }
    .width(1)
    .height(1)
  }

modalOverlay 是一个 @Builder 修饰的构建器函数,用于生成弹窗的透明遮罩层。构建器接收一个 onClose 回调函数作为参数,实现关闭弹窗的交互。遮罩层使用了一个几乎不可见的 Button(宽高为 1 像素、透明度为 0)作为点击捕获区域,这是一种轻量化的弹窗背景交互方案。@Builder 的优势在于可以将重复的 UI 片段封装为可复用的函数,避免代码冗余。与 @Component 不同,@Builder 不创建独立的组件实例,而是在调用处内联展开,性能开销更小。在实际使用中,各弹窗组件自行管理了关闭逻辑,这个构建器在当前版本中作为预留的基础设施存在。

第十八段:build 方法——顶部标题区

  build() {
    Column() {
      Column() {
        Column() {
          Row() {
            Column() {
              Text('🎲 桌游俱乐部')
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('Board Game Club · 复古棋格')
                .fontSize(10)
                .fontColor('#FFFFFFCC')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

build 方法是组件的渲染入口,这里开始了顶部标题区的构建。标题使用 20 号粗体白色文字,搭配骰子 emoji 营造桌游氛围。副标题"Board Game Club · 复古棋格"使用 10 号半透明白色(#FFFFFFCC),既提供了英文副标题的国际化层次,又以"复古棋格"点明了设计主题。layoutWeight(1) 使标题列占据剩余空间,将右侧的交互图标推到行尾。alignItems(HorizontalAlign.Start) 使文本左对齐,符合从左到右的阅读习惯。整个标题区嵌套在多层 Column 中,为后续的棋格装饰条和标签预留了垂直布局空间。这种多层嵌套的布局结构是 ArkUI 声明式语法的典型特征。

第十九段:build 方法——骰子与棋子动画

            Row() {
              Text('🎲')
                .fontSize(20)
                .onClick(() => {
                  this.diceSpin = !this.diceSpin;
                })
                .rotate({ angle: this.diceSpin ? 360 : 0 })
                .animation({ duration: 600, curve: Curve.EaseOut })
              Text('♟️')
                .fontSize(16)
                .margin({ left: 10 })
                .onClick(() => {
                  this.pawnMove = !this.pawnMove;
                })
                .translate({ x: this.pawnMove ? 8 : 0 })
                .animation({ duration: 400, curve: Curve.EaseInOut })
              Text('👑')
                .fontSize(16)
                .margin({ left: 6 })
            }
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor('#FFFFFF1F')
            .borderRadius(18)
            .border({ width: 1, color: '#FFFFFF40' })

这段代码实现了顶部装饰区的骰子旋转和棋子平移动画。点击骰子时 diceSpin 状态翻转,触发 360 度旋转动画,持续 600 毫秒使用 EaseOut 缓动曲线——先快后慢模拟骰子落定的物理感。点击棋子时 pawnMove 状态翻转,触发 X 轴 8 像素的平移动画,持续 400 毫秒使用 EaseInOut 缓动模拟棋子滑行的丝滑感。皇冠作为静态装饰元素。整个图标组用半透明白色背景和圆角边框包裹,形成胶囊状交互容器。animation 修饰器使属性变化自动产生过渡动画,开发者只需声明起始和终止状态,框架自动计算中间帧。这些微动效显著提升了应用的趣味性和品牌辨识度。

第二十段:build 方法——棋盘格装饰条

          Column() {
            ForEach(CHECK_COL, (c: number, i: number) => {
              Column()
                .layoutWeight(1)
                .height(9)
                .backgroundColor(i % 2 === 0 ? COLORS.boardLight : COLORS.cream)
            }, (c: number) => 'top' + c)
            ForEach(CHECK_COL, (c: number, i: number) => {
              Column()
                .layoutWeight(1)
                .height(9)
                .backgroundColor((i + 1) % 2 === 0 ? COLORS.boardLight : COLORS.cream)
            }, (c: number) => 'bottom' + c)
          }
          .width('100%')
          .height(18)
          .margin({ top: 10 })
          .borderRadius(4)
          .clip(true)

这段代码渲染了顶部的棋盘格装饰条——应用最具辨识度的视觉元素。上下两行各十格,通过 i % 2(i + 1) % 2 的交错取余实现棋盘式的色彩交替:上行偶数位为深绿、奇数位为米色,下行则相反。layoutWeight(1) 使十格均分宽度,height(9) 设定每行高度。clip(true) 配合 borderRadius(4) 使两端圆角裁切避免方块溢出。ForEach 的键值生成器使用 'top' + c'bottom' + c 前缀确保上下两行键值不冲突。这种用数据驱动图案生成的方式比手写二十个色块组件要优雅得多,且支持灵活调整格数。二十个色块构成的两行棋盘格图案,在视觉上完美呼应了桌游文化的核心符号。

第二十一段:build 方法——色彩标签与头部容器

          Row() {
            Text('棋盘绿')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#FFFFFF2E')
              .borderRadius(8)
            Text('米白')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#FFFFFF2E')
              .borderRadius(8)
            Text('琥珀金')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#FFFFFF2E')
              .borderRadius(8)
          }
          .width('100%')
          .margin({ top: 8 })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .backgroundColor(COLORS.board)

这段代码在棋盘格下方渲染了三个色彩名称标签——棋盘绿、米白、琥珀金,并以深绿色背景收尾整个头部容器。三个标签以 8 号极小字体呈现,使用半透明白色背景和文字,形成低对比度的辅助信息层,如同产品包装上的色卡说明。三个标签的样式完全一致,仅在文字内容上不同,这为后续提取为 @Builder 或子组件提供了重构空间。头部容器的 padding 设置了 16 像素左右内边距和 12 像素上下内边距,确保内容与屏幕边缘保持舒适距离。backgroundColor(COLORS.board) 将整个头部背景设为棋盘绿色,与白色文字形成高对比度确保可读性。这种深色头部加浅色内容区的配色方案是移动应用的经典布局模式。

第二十二段:build 方法——Tab 内容路由

        if (this.curTab === BoardTab.GAME) {
          GameContent({
            data: this.data, onAdd: () => {
              this.showAddGame = true;
            }
          })
        } else if (this.curTab === BoardTab.MEMBER) {
          MemberContent({
            data: this.data, onEdit: () => {
              this.showEditMember = true;
            }
          })
        } else if (this.curTab === BoardTab.MATCH) {
          MatchContent({
            data: this.data, onDel: (n: string) => {
              this.delMatchName = n;
              this.showDeleteMatch = true;
            }
          })
        } else if (this.curTab === BoardTab.EVENT) {
          EventContent({
            data: this.data, onDetail: (n: string) => {
              this.detailEventName = n;
              this.showDetail = true;
            }
          })
        } else {
          CollectContent({ data: this.data })
        }

这段代码是 Tab 内容路由的核心逻辑,通过 if-else if-else 链根据 curTab 的值条件渲染对应的内容页组件。每个内容页组件接收 data 参数(共享的 BoardData 实例)和对应的回调函数。回调函数采用闭包模式,在触发时设置弹窗的显示状态和上下文参数。例如 MatchContentonDel 回调接收赛事名称字符串,先暂存到 delMatchName 状态再触发弹窗。这种"回调 + 状态提升"的模式将子组件的用户交互事件向上传递到根组件统一处理,实现了组件间的松耦合通信。CollectContent 没有回调参数,因为收藏页面当前不需要弹窗交互。条件渲染确保只有当前标签页的组件被创建和渲染,节省了内存和计算资源。

0

1

2

3

4

onAdd

onEdit

onDel

onDetail

curTab 状态

GameContent

MemberContent

MatchContent

EventContent

CollectContent

showAddGame = true

showEditMember = true

showDeleteMatch = true

showDetail = true

如上所示,Tab 路由与弹窗触发形成了清晰的事件流转链路。每个内容页只负责自身的数据渲染和事件抛出,弹窗的显隐控制权统一归属根组件。

第二十三段:build 方法——底部 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) ? COLORS.white : COLORS.textHint)
              .fontWeight(this.curTab === TAB_LIST.indexOf(t) ? FontWeight.Bold : FontWeight.Normal)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .justifyContent(FlexAlign.Center)
          .padding({ top: 8, bottom: 8 })
          .backgroundColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.board : COLORS.cardBg)
          .borderRadius(16)
          .border(this.curTab === TAB_LIST.indexOf(t) ? { width: 1, color: t.color + '66' } :
            { 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 })

这段代码实现了底部 Tab 导航栏,通过 ForEach 遍历 TAB_LIST 数据动态渲染五个标签按钮。每个标签的选中态通过 this.curTab === TAB_LIST.indexOf(t) 判断,选中时文字变白色加粗、背景变棋盘绿、边框使用标签自带色值加 40% 透明度;未选中时文字为灰色、背景为卡片白、边框为浅色线条。onClick 回调将 curTab 更新为当前点击标签的索引,触发条件渲染切换内容页。t.color + '66' 的字符串拼接是一种巧妙的透明度控制方式——在六位 HEX 色值后追加两位透明度字符(66 即 40%)。整个导航栏使用 layoutWeight(1) 使五个标签均分宽度形成等距排列,60 像素的固定高度确保了舒适的触摸区域。

第二十四段:build 方法——弹窗调度

      if (this.showAddGame) {
        AddGameModal({
          onClose: () => {
            this.showAddGame = false;
          }
        })
      }
      if (this.showEditMember) {
        EditMemberModal({
          onClose: () => {
            this.showEditMember = false;
          }
        })
      }
      if (this.showDeleteMatch) {
        DeleteMatchModal({
          title: this.delMatchName, onClose: () => {
            this.showDeleteMatch = false;
          }
        })
      }
      if (this.showDetail) {
        DetailEventModal({
          name: this.detailEventName, onClose: () => {
            this.showDetail = false;
          }
        })
      }

这段代码是弹窗调度的核心逻辑,四个独立的 if 语句根据各自的 show* 布尔状态决定是否渲染对应的弹窗组件。每个弹窗组件接收一个 onClose 回调函数,在弹窗内部触发关闭时将对应状态置回 falseDeleteMatchModalDetailEventModal 还额外接收 titlename 参数,传递弹窗所需的上下文信息。使用独立 if 而非 if-else 链是因为弹窗之间不存在互斥关系——理论上可以同时弹出多个弹窗。这种"状态驱动渲染"的弹窗管理方式是声明式 UI 的典型范式:开发者只需声明"当某状态为真时渲染某组件",框架自动处理组件的创建和销毁。这种模式使弹窗的显隐逻辑完全透明可追溯。

第二十五段:BoardTag 公共标签组件

@Component
struct BoardTag {
  @Prop text: string;
  @Prop color: string;

  build() {
    Text(this.text)
      .fontSize(9)
      .fontColor(this.color)
      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
      .backgroundColor(this.color + '1F')
      .borderRadius(6)
      .border({ width: 1, color: this.color + '40' })
  }
}

BoardTag 是一个公共标签组件,被所有内容页复用。通过 @Prop 装饰器接收 textcolor 两个属性——@Prop 表示单向数据传递,父组件修改时子组件同步更新,但子组件不能反向修改。标签的视觉风格是:9 号极小字体、文字使用传入色彩、背景使用同色加 12% 透明度(1F)、边框使用同色加 25% 透明度(40)。这种"同色三阶"的设计方式使标签的色彩统一而有层次——文字最浓、边框次之、背景最淡。BoardTag 的提取复用是 DRY 原则的典型实践,在五个内容页中累计被调用了数十次,避免了大量重复的样式代码。通过 color + '1F' 的字符串拼接动态生成透明度色值,是一种轻量灵活的色彩处理技巧。

第二十六段:GameContent——周开桌次数标题与柱状图

@Component
struct GameContent {
  @ObjectLink data: BoardData;
  onAdd: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('📈 周开桌次数')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('周六峰值')
              .fontSize(9)
              .fontColor(COLORS.danger)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          Row() {
            ForEach(WEEK_TABLE, (w: WeekMeta) => {
              Column() {
                Text(w.value + '')
                  .fontSize(8)
                  .fontColor(w.value >= 60 ? COLORS.board : COLORS.textSecondary)
                Column()
                  .width(20)
                  .height(w.value * 0.9)
                  .backgroundColor(w.value >= 60 ? COLORS.board : COLORS.primary)
                  .borderRadius(4)
                  .margin({ top: 4 })
                Text(w.day)
                  .fontSize(9)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (w: WeekMeta) => w.day)
          }
          .width('100%')
          .height(130)
          .alignItems(VerticalAlign.Bottom)
          .margin({ top: 10 })

GameContent 是桌游页面组件,通过 @ObjectLink 引用 BoardData 实例实现响应式数据绑定。@ObjectLink@Prop 的区别在于:@Prop 做值的浅拷贝,而 @ObjectLink 做引用传递,使子组件直接操作父组件的可观察对象。页面使用 Scroll 包裹整体内容支持竖向滚动。第一张卡片是"周开桌次数"柱状图,ForEach 遍历 WEEK_TABLE 的七天数据,每天渲染数值文字、柱体和星期标签三层。柱体高度通过 w.value * 0.9 计算,0.9 是缩放系数确保最高值 72 不超过 65 像素。数值大于等于 60 的柱体用深绿色突出形成"周末热区"视觉提示。alignItems(VerticalAlign.Bottom) 使所有柱体底部对齐模拟从底部长起的柱状图效果。这种纯 UI 组件绘制的柱状图不依赖图表库,既轻量又灵活。

第二十七段:GameContent——类型分布进度条

        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(TYPE_SHARE, (n: TypeMeta) => {
            Row() {
              Text(n.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(60)
              Row() {
                Row()
                  .layoutWeight(n.count / 3)
                  .height(8)
                  .backgroundColor(n.color)
                  .borderRadius(4)
                Row()
                  .layoutWeight(1 - n.count / 3)
                  .height(8)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(4)
              }
              .layoutWeight(1)
              .height(8)
              Text(n.count + '款')
                .fontSize(9)
                .fontColor(n.color)
                .width(32)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (n: TypeMeta) => n.name)

这段代码渲染了类型分布的水平进度条。每行包含类型名称(60px 固定宽)、进度条(layoutWeight(1) 自适应)和数量标签(32px 固定宽)三部分。进度条由两个 Row 组成:填充部分使用 layoutWeight(n.count / 3) 按比例占据空间,背景部分使用 layoutWeight(1 - n.count / 3) 占据剩余空间。以三款为满格基准(count / 3),策略类三款满格、合作类一款占三分之一。borderRadius(4) 使进度条两端圆角。数量标签使用类型自带的色彩值,与进度条填充色呼应。这种双 Row 拼接的进度条实现方式简洁有效,无需依赖任何图表组件库。标题区"全馆12款"的概要数据使用 9 号灰色文字标注,与标题形成主次层次。

第二十八段:GameContent——馆藏桌游列表头部

        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 })

这段代码是"馆藏桌游"列表的头部区域。标题右侧的加号 emoji 是可点击的交互元素,点击时调用 this.onAdd() 回调触发新增桌游弹窗。这种将操作按钮直接嵌入标题区的设计模式,使用户无需滚动到页面底部寻找操作入口,提升了交互效率。"点击➕上架"的提示文字用 9 号灰色标注,既引导了用户又不喧宾夺主。头部的 margin({ bottom: 10 }) 与下方的列表内容保持间距。整个卡片使用 COLORS.cream 米色背景搭配金色边框,与前两张白底卡片形成微妙的层次区分。这种通过背景色和边框色区分卡片的策略,在保持统一布局结构的同时提供了视觉层次感。

第二十九段:GameContent——桌游列表项渲染

          ForEach(this.data.games, (g: GameItem, i: number) => {
            Row() {
              Column()
                .width(6)
                .height(44)
                .backgroundColor(getTypeColor(g.type))
                .borderRadius(3)
              Text(g.emoji)
                .fontSize(18)
                .width(34)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(g.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  BoardTag({ text: g.type, color: getTypeColor(g.type) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text(g.difficulty + '难度 · ' + g.minutes + '分钟 · 已开' + g.plays + '局')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
                Row() {
                  Row()
                    .layoutWeight(g.rating / 10)
                    .height(5)
                    .backgroundColor(g.rating >= 9 ? COLORS.danger : COLORS.primary)
                    .borderRadius(2)
                  Row()
                    .layoutWeight(1 - g.rating / 10)
                    .height(5)
                    .backgroundColor(COLORS.bg)
                    .borderRadius(2)
                }
                .width('100%')
                .height(5)
                .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })

这段代码渲染了馆藏桌游列表的每一项。每行左侧用 6 像素宽的色条标记桌游类型,通过 getTypeColor 函数获取对应色彩。色条右侧是 18 号 emoji 图标,再右侧是信息区:名称用 13 号粗体搭配 BoardTag 类型标签;下方 10 号灰色文字拼接难度、时长和开桌次数三项信息;底部是评分进度条。进度条使用 layoutWeight(g.rating / 10) 按十分制比例填充,评分大于等于 9 的游戏用红色进度条标记为"高分佳作",其余用主绿色。这种"色条 + 图标 + 信息 + 进度条"的四段式列表项布局在所有内容页中统一使用,形成了高度一致的视觉节奏。ForEach 的键值使用 g.name + i 拼接名称和索引确保正确 diff。

第三十段:GameContent——评分数值与列表收尾

              Column() {
                Text(g.rating + '')
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.accent)
                Text('评分')
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (g: GameItem, i: number) => g.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cream)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })
        .margin({ top: 12 })

这段代码渲染了列表项右侧的评分数值和列表容器的收尾样式。评分数值用 12 号粗体琥珀金色显示,下方 8 号灰色"评分"文字作为说明。这种"进度条 + 数值"的双重展示方式既提供了视觉化的评分感知又提供了精确的数值信息,满足不同用户的阅读偏好。每个列表项使用 COLORS.cardBg 白色背景搭配浅色边框和 10 像素圆角,形成独立的卡片单元。padding({ left: 10, right: 12, top: 9, bottom: 9 }) 使内容与卡片边缘保持舒适距离。整个列表容器使用米色背景搭配金色边框,与头部卡片形成层次区分。margin({ bottom: 8 }) 使每个列表项之间保持间距,避免视觉拥挤。

第三十一段:MemberContent——统计卡片

@Component
struct MemberContent {
  @ObjectLink data: BoardData;
  onEdit: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Text('12')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.board)
            Text('会员总数')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          Column() {
            Text('12.6万')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.accent)
            Text('累计积分')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          Column() {
            Text('8')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.danger)
            Text('本周到店')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%')
        .padding({ top: 14, bottom: 14 })
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })

MemberContent 是会员页面组件,顶部是三列统计卡片矩阵,分别展示会员总数(12)、累计积分(12.6万)和本周到店(8)。每个统计单元包含 18 号粗体数值和 9 号灰色说明文字,数值分别使用棋盘绿、琥珀金和红色,与对应的业务语义呼应——绿色表示基础数据、金色表示价值指标、红色表示活跃指标。三列使用 layoutWeight(1) 均分宽度,alignItems(HorizontalAlign.Center) 使内容居中对齐。这种 KPI 卡片矩阵是数据仪表盘的经典布局,使用户在进入页面的第一眼就能获取核心运营指标。卡片使用金色边框装饰,提升了数据展示的仪式感和层次感。

第三十二段:MemberContent——等级分布进度条

        Column() {
          Row() {
            Text('👑 等级分布')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('殿堂2人')
              .fontSize(9)
              .fontColor(COLORS.danger)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          Row() {
            Text('新人')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .width(60)
            Row() {
              Row()
                .layoutWeight(3 / 12)
                .height(8)
                .backgroundColor(getLevelColor('新人'))
                .borderRadius(4)
              Row()
                .layoutWeight(9 / 12)
                .height(8)
                .backgroundColor(COLORS.bg)
                .borderRadius(4)
            }
            .layoutWeight(1)
            .height(8)
            Text('3人')
              .fontSize(9)
              .fontColor(getLevelColor('新人'))
              .width(32)
              .textAlign(TextAlign.End)
          }
          .width('100%')
          .margin({ top: 8 })

这段代码渲染了会员等级分布的可视化进度条。以十二人为基数,新人三人占四分之一(3/12)、进阶四人占三分之一(4/12)、资深三人占四分之一(3/12)、殿堂两人占六分之一(2/12)。每个等级的进度条通过 layoutWeight(count / 12) 计算填充比例。进度条颜色通过 getLevelColor 函数获取,与等级标签的色彩一致。"殿堂2人"用红色文字在标题区标注,突出最高等级会员的稀缺性。四行进度条从上到下排列,形成了会员等级金字塔的可视化表达——底部宽(新人多)、顶部窄(殿堂少),直观反映会员结构。进度条的布局方式与类型分布进度条完全一致,保持了跨页面的视觉一致性。

第三十三段:MemberContent——会员名册头部与列表项

        Column() {
          Row() {
            Text('♟️ 会员名册')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击✏️编辑')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
            Text('✏️')
              .fontSize(13)
              .margin({ left: 8 })
              .onClick(() => {
                this.onEdit();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.members, (m: MemberItem, i: number) => {
            Row() {
              Text(m.emoji)
                .fontSize(17)
                .width(38)
                .textAlign(TextAlign.Center)
                .padding(6)
                .backgroundColor(COLORS.cream)
                .borderRadius(18)
                .border({ width: 1, color: COLORS.line })

这段代码渲染了会员名册列表的头部和每个会员项的左侧头像区。头部结构与馆藏桌游列表一致,铅笔 emoji 可点击触发编辑弹窗。每个会员项左侧的 emoji 头像使用 17 号字体,包裹在 38 像素宽的圆形容器中(borderRadius(18) 配合 padding(6)),背景为米色、边框为浅色线条,形成圆形头像的视觉效果。这种用 emoji 加圆形容器模拟头像的方式,在缺少图片资源时是一种轻量而美观的替代方案。列表项的键值使用 m.name + i 拼接确保重名会员也能正确 diff。整个会员名册卡片使用淡红色背景(#C05B4D14),与会员标签的红色主题呼应,通过色彩实现模块间的视觉分区。

第三十四段:MemberContent——会员信息与积分

              Column() {
                Row() {
                  Text(m.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  BoardTag({ text: m.level, color: getLevelColor(m.level) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text(m.joined + '入会 · 到店' + m.visits + '次')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Column() {
                Text(m.points + '')
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.board)
                Text('积分')
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (m: MemberItem, i: number) => m.name + i)

这段代码渲染了会员列表项的信息区和右侧积分区。信息区包含会员名称(13 号粗体)搭配等级标签,以及 10 号灰色的入会时间和到店次数。右侧积分数值用 11 号粗体棋盘绿色显示,下方 8 号灰色"积分"文字说明。这种"名称 + 标签 + 描述 + 数值"的四段式列表项布局与桌游列表项保持一致,形成了统一的列表视觉语言。积分用绿色而非金色,是为了与累计积分卡片的金色形成区分——卡片是总量(金色),列表是个人(绿色)。layoutWeight(1) 使信息区自适应宽度,alignItems(HorizontalAlign.Start) 使文字左对齐。每个列表项的样式与桌游列表项完全一致,体现了高度统一的组件设计规范。

第三十五段:MatchContent——赛事热度榜

@Component
struct MatchContent {
  @ObjectLink data: BoardData;
  onDel: (n: string) => void = (n: string) => {};

  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, (h: MatchHotMeta, idx: number) => {
            Row() {
              Text(idx + 1 + '')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(idx < 3 ? COLORS.danger : COLORS.textHint)
                .width(22)
              Text(h.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(96)

MatchContent 是赛事页面组件,第一张卡片是赛事热度排行榜。排行榜通过 ForEach 遍历 MATCH_HOT 数据,每行包含排名序号、赛事名称、进度条和人数标签四部分。排名序号用 idx + 1 生成(从 1 开始),前三名用红色突出显示模拟领奖台效果,第四名以后用灰色。序号固定 22 像素宽,赛事名称固定 96 像素宽,进度条自适应剩余空间。onDel 回调接收赛事名称字符串用于后续的删除弹窗触发。热度榜让运营者快速了解哪场赛事最受关注,辅助资源调配决策。标题区"按报名人数"的标注说明了排序维度,使用户理解排行逻辑。

第三十六段:MatchContent——热度进度条与列表头部

              Row() {
                Row()
                  .layoutWeight(h.players / 120)
                  .height(9)
                  .backgroundColor(h.color)
                  .borderRadius(4)
                Row()
                  .layoutWeight(1 - h.players / 120)
                  .height(9)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(4)
              }
              .layoutWeight(1)
              .height(9)
              Text(h.players + '人')
                .fontSize(9)
                .fontColor(h.color)
                .width(40)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (h: MatchHotMeta) => h.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })

        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 })

这段代码渲染了赛事热度榜的进度条和第二张卡片"全部赛事"的头部。进度条以 120 人为满格基准(h.players / 120),年度嘉年华 120 人满格、星蚀银河赛 24 人占五分之一。进度条颜色使用赛事数据自带的 h.color 色值,六条进度条呈现六种不同色彩。右侧人数标签同样使用赛事自带色彩与进度条呼应。热度榜卡片使用金色边框,与第二张赛事列表卡片的浅色边框形成层次区分。第二张卡片标题使用方格旗 emoji,右侧"点击🗑️删除"提示引导用户使用删除功能。与馆藏桌游和会员名册不同,赛事列表的删除操作入口不在头部而是嵌入到每个列表项中,因为删除操作需要知道具体删除哪场赛事。

第三十七段:MatchContent——赛事列表项与删除按钮

          ForEach(this.data.matches, (m: MatchItem, i: number) => {
            Row() {
              Column() {
                Text(m.emoji)
                  .fontSize(17)
                Text(m.date)
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .width(42)
              .alignItems(HorizontalAlign.Center)
              Column() {
                Row() {
                  Text(m.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  BoardTag({ text: m.status, color: m.status === '已结束' ? COLORS.textHint : COLORS.success })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('奖金 ¥' + m.prize + ' · 已报' + m.players + '人')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Text('🗑️')
                .fontSize(14)
                .padding({ left: 8, right: 8, top: 6, bottom: 6 })
                .backgroundColor('#C05B4D1F')
                .borderRadius(8)
                .onClick(() => {
                  this.onDel(m.name);
                })
            }
            .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)

这段代码渲染了赛事列表项的完整结构。左侧图标区使用 42 像素宽的 Column,上方 17 号赛事 emoji,下方 8 号灰色日期文字。信息区包含赛事名称搭配状态标签——已结束用灰色、报名中和筹备中用绿色,通过三元表达式动态选择色彩。下方 10 号灰色文字拼接奖金和报名人数两项信息。右侧删除按钮使用垃圾桶 emoji,包裹在淡红色背景(#C05B4D1F)的圆角容器中,形成与删除语义一致的视觉提示。点击时调用 this.onDel(m.name) 回调将赛事名称传递给父组件触发删除确认弹窗。这种将危险操作按钮用红色系视觉标记的设计模式,符合用户对"删除"操作的心理预期,能有效降低误触风险。

第三十八段:EventContent——报名进度榜

@Component
struct EventContent {
  @ObjectLink data: BoardData;
  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.danger)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          ForEach(EVENT_JOIN, (e: EventJoinMeta) => {
            Row() {
              Text(e.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(96)
              Row() {
                Row()
                  .layoutWeight(e.rate / 100)
                  .height(9)
                  .backgroundColor(e.color)
                  .borderRadius(4)
                Row()
                  .layoutWeight(1 - e.rate / 100)
                  .height(9)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(4)
              }
              .layoutWeight(1)
              .height(9)
              Text(e.rate + '%')
                .fontSize(9)
                .fontColor(e.color)
                .width(36)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (e: EventJoinMeta) => e.name)

EventContent 是活动页面组件,第一张卡片是活动报名进度榜。结构与赛事热度榜类似,通过 ForEach 遍历 EVENT_JOIN 数据渲染六行进度条。进度条通过 e.rate / 100 直接计算填充比例——因为 rate 本身就是百分比值,无需额外归一化。标题右侧"周年庆最热"用红色文字标注热度最高的活动。每条数据自带独立色彩,进度条和百分比标签都使用同一色值形成呼应。onDetail 回调接收活动名称字符串用于后续的详情弹窗触发。进度榜帮助运营者识别哪些活动需要加大推广力度。与赛事热度榜以绝对人数为基准不同,活动报名率以百分比为基准,进度条直接映射百分比值。

第三十九段:EventContent——活动列表头部与列表项

        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.events, (e: EventItem, i: number) => {
            Row() {
              Text(e.emoji)
                .fontSize(17)
                .width(34)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(e.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  BoardTag({ text: e.type, color: COLORS.board })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text(e.time + ' · ' + e.joined + '/' + e.seats + '人')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })

这段代码渲染了活动列表的头部和每个活动项的图标区与信息区。头部标题使用派对弹炮 emoji,右侧"点击查看详情"提示引导用户使用详情功能。每个活动项左侧使用 34 像素宽的 emoji 图标,信息区包含活动名称搭配类型标签(统一用棋盘绿色),以及 10 号灰色的活动时间和报名情况(如"24/30人")。整个活动列表卡片使用淡绿色背景(#5B8C5A14)搭配绿色边框(#5B8C5A40),与活动标签的绿色主题呼应。这种每个内容页的列表卡片使用不同色彩背景的做法,在保持统一布局结构的同时通过色彩差异提供模块辨识度,是一种巧妙的视觉分区策略。

第四十段:EventContent——报名进度条与详情按钮

                Row() {
                  Row()
                    .layoutWeight(e.joined / e.seats)
                    .height(5)
                    .backgroundColor(e.joined / e.seats >= 0.8 ? COLORS.danger : COLORS.primary)
                    .borderRadius(2)
                  Row()
                    .layoutWeight(1 - e.joined / e.seats)
                    .height(5)
                    .backgroundColor(COLORS.bg)
                    .borderRadius(2)
                }
                .width('100%')
                .height(5)
                .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Text('详情')
                .fontSize(10)
                .fontColor(COLORS.accent)
                .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                .backgroundColor(COLORS.accentLight)
                .borderRadius(6)
                .onClick(() => {
                  this.onDetail(e.name);
                })
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (e: EventItem, i: number) => e.name + i)

这段代码渲染了活动列表项的报名进度条和右侧"详情"按钮。报名进度条通过 e.joined / e.seats 计算填充比例——已报名人数除以总席位。当报名率超过 80% 时(>= 0.8),进度条变红色提示即将满额,否则用主绿色。这种动态色彩切换的进度条在活动管理场景中非常实用,使运营者一眼识别哪些活动即将满额需要加场。右侧"详情"按钮使用 10 号琥珀金文字搭配浅金色背景(COLORS.accentLight)和 6 像素圆角。点击时调用 this.onDetail(e.name) 回调将活动名称传递给父组件触发详情弹窗。与赛事列表的删除按钮使用红色不同,活动列表的详情按钮使用金色——因为详情是只读操作不涉及数据变更,而删除是危险操作需要红色警示。

第四十一段:CollectContent——收藏概览卡片

@Component
struct CollectContent {
  @ObjectLink data: BoardData;

  build() {
    Scroll() {
      Column() {
        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)
          Row() {
            Text('传奇4')
              .fontSize(9)
              .fontColor('#C05B4D')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#C05B4D1F')
              .borderRadius(6)
            Text('稀有6')
              .fontSize(9)
              .fontColor('#C9A227')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#C9A2271F')
              .borderRadius(6)
            Text('普通2')
              .fontSize(9)
              .fontColor('#6E7A6F')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#6E7A6F1F')
              .borderRadius(6)
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ top: 10 })
          Text('累计估值 ¥10.7万 · 全部投保展出')
            .fontSize(9)
            .fontColor(COLORS.textHint)
            .width('100%')
            .margin({ top: 8 })

CollectContent 是收藏页面组件,与其它内容页不同,它没有回调函数——因为收藏页面当前没有弹窗交互。第一张卡片是收藏概览,包含标题、稀有度统计标签和总估值信息。三个稀有度标签(传奇4、稀有6、普通2)手动内联了 BoardTag 的样式而非使用组件,这是因为概览卡片需要标签横向排列且各自独立,直接内联比使用 BoardTag 组件更直观。每个标签的色彩遵循 getRarityColor 的映射规则:传奇红、稀有金、普通灰。标签的背景使用同色加 12% 透明度(1F),与 BoardTag 组件样式完全一致保持了视觉统一性。底部"累计估值 ¥10.7万 · 全部投保展出"提供了收藏总价值和保险状态的概要信息。

第四十二段:CollectContent——珍品展柜列表

        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.collects, (c: CollectItem, i: number) => {
            Row() {
              Column()
                .width(6)
                .height(44)
                .backgroundColor(getRarityColor(c.rarity))
                .borderRadius(3)
              Text(c.emoji)
                .fontSize(18)
                .width(36)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(c.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  BoardTag({ text: c.rarity, color: getRarityColor(c.rarity) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('藏家 ' + c.owner + ' · ' + c.stage)
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Column() {
                Text('¥' + c.price)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.danger)
                Text('估值')
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (c: CollectItem, i: number) => c.name + i)

这段代码渲染了珍品展柜列表的完整结构。列表结构与馆藏桌游列表完全一致——左侧 6 像素色条标记稀有度,通过 getRarityColor 获取色彩:传奇红、稀有金、普通灰。色条右侧是 18 号 emoji 图标,信息区包含藏品名称搭配稀有度标签,以及 10 号灰色的藏家姓名和流转状态。右侧估值用 11 号粗体红色显示(COLORS.danger),下方 8 号灰色"估值"文字说明。估值使用红色而非金色,是因为估值涉及金钱价值,红色在语义上比金色更紧迫——它暗示"高价值"而非"装饰性金色"。"按市值排序"的提示文字表明藏品列表应按 price 字段降序排列,当前数据预置时已手动排序。整个列表项的布局结构与桌游列表项、会员列表项完全一致,形成了统一的列表视觉语言。

第四十三段:AddGameModal——头部与表单字段

@Component
struct AddGameModal {
  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 })

AddGameModal 是新增桌游弹窗组件,结构与其它三个弹窗组件保持统一模式。弹窗头部包含 26 号大号 emoji 图标、标题列(16 号粗体标题 + 10 号灰色副标题)和右侧关闭按钮。标题列使用 layoutWeight(1) 占据中间空间,关闭按钮使用 onClick 触发 onClose 回调。onClose 的默认值为空函数确保健壮性。表单信息行每行包含 70 像素宽的标签和自适应宽度的值两部分。标签用 11 号灰色文字,值用 12 号文字,色彩根据语义变化——普通信息用深色,类型信息用红色。每行使用 COLORS.bg 米色背景和 10 像素圆角形成独立的表单字段卡片。首行 margin({ top: 14 }) 与头部保持较大间距。当前值为预置静态文本,在真实场景中应替换为可编辑的 TextInput 组件。

第四十四段:AddGameModal——操作按钮与容器

        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.board)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.gold })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

这段代码渲染了新增桌游弹窗的操作提示、按钮组和容器样式。提示文字描述上架前的检查清单作为业务规则的轻量化提示。下方是"取消"和"确认上架"两个按钮均分宽度(各 layoutWeight(1)),中间 10 像素间距。取消按钮用米色背景搭配灰色文字,确认按钮用棋盘绿背景搭配白色粗体文字——这种"次要按钮浅色 + 主操作按钮深色"的设计模式是移动端表单的经典实践。两个按钮的 onClick 都调用 this.onClose() 关闭弹窗。内层 Column 使用卡片白底、18 像素大圆角和金色边框形成精致的弹窗卡片。外层使用 justifyContent(FlexAlign.End) 使弹窗底部对齐模拟从底部弹出。constraintSize({ maxHeight: '78%' }) 限制弹窗最大高度不超过屏幕的 78%,确保弹窗不会遮挡整个界面。

第四十五段:EditMemberModal——头部与表单

@Component
struct EditMemberModal {
  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('温酒 · 编号021')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 14 })

EditMemberModal 是编辑会员弹窗组件,结构与 AddGameModal 完全平行。头部使用棋子 emoji 作为图标,标题"编辑会员"搭配副标题"调整等级与备注"。关闭按钮的位置和交互方式与新增桌游弹窗一致,保持了弹窗交互的统一性。这种跨弹窗的结构一致性,使用户在第一次学会关闭一个弹窗后就能自然而然地关闭所有弹窗,降低了学习成本。弹窗的 onClose 回调由父组件 BoardApp 在实例化时传入,关闭时将 showEditMember 状态置回 false 触发条件渲染移除弹窗组件。表单字段行结构与新增桌游弹窗一致——70 像素宽标签搭配自适应宽度的值。等级行的值使用金色(COLORS.accent)突出等级差值信息。

第四十六段:EditMemberModal——操作按钮与容器

        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: 1, color: COLORS.gold })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

这段代码渲染了编辑会员弹窗的操作提示和按钮组。提示文字描述了等级修改后的自动校验逻辑和休眠提醒规则,作为业务规则的轻量化提示。按钮组结构与新增桌游弹窗一致——"取消"和"保存修改"两个按钮均分宽度。区别在于确认按钮使用 COLORS.primary 主绿色(#3E6B4F)而非 COLORS.board 棋盘绿(#2E5D3F)——primaryboard 略浅一些,在视觉上形成两种确认按钮的微妙差异。这种通过主色和棋盘色的细微区分来标识不同弹窗的确认按钮,是一种不引人注目但有效的设计细节。两个按钮的 onClick 都调用 this.onClose() 关闭弹窗。弹窗容器样式与新增桌游弹窗完全一致——卡片白底、18 像素大圆角、金色边框、底部对齐、78% 最大高度约束。

第四十七段:DeleteMatchModal——删除确认弹窗

@Component
struct DeleteMatchModal {
  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('#C05B4D14')
          .borderRadius(10)
          .margin({ top: 14 })

        Text('⚠️ 删除后已报名选手将收到自动退款,奖池余额同步返还赞助方。')
          .fontSize(10)
          .fontColor(COLORS.warning)
          .width('100%')
          .margin({ top: 12 })

DeleteMatchModal 是删除赛事确认弹窗,与编辑和新增弹窗不同,它接收 title 参数来显示待删除的赛事名称。头部使用奖杯 emoji,标题"删除赛事"搭配副标题"确认移除所选赛事"。赛事名称用 12 号粗体红色文字显示,搭配淡红色背景(#C05B4D14),形成醒目的确认信息卡片,确保用户明确知道正在删除哪场赛事。下方的警告提示用 10 号橙色文字(COLORS.warning),描述了删除操作的连锁影响——自动退款和奖池返还。这种将操作的后果影响明确告知用户的设计,是负责任的交互实践。使用 warning 橙色而非 danger 红色,是因为提示文字本身不是危险操作而是操作的影响说明,色彩层级应低于实际操作按钮。这个弹窗的核心目的是在执行不可逆的删除操作前让用户二次确认。

第四十八段:DeleteMatchModal——操作按钮与容器

        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: 1, color: COLORS.danger + '40' })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

这段代码渲染了删除赛事弹窗的按钮组和容器。与新增和编辑弹窗的按钮命名不同,删除弹窗的取消按钮命名为"保留赛事"——使用业务语义而非通用语义,使用户更清楚地理解取消操作的含义。确认按钮命名为"确认删除"并使用 COLORS.danger 红色背景,与删除操作的语义一致。红色确认按钮是危险操作的标准视觉标识,能有效引起用户警觉。两个按钮的 onClick 都调用 this.onClose() 关闭弹窗,在真实场景中确认删除按钮还应包含调用后端删除接口的逻辑。弹窗卡片使用红色边框(COLORS.danger + '40')进一步强化危险操作的视觉警示。容器结构与其它弹窗一致——底部对齐、78% 最大高度约束。至此四个弹窗组件中有三个使用金色边框,唯独删除弹窗使用红色边框,通过边框色彩传达操作的危险等级。

第四十九段:DetailEventModal——头部与活动信息

@Component
struct DetailEventModal {
  name: 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.name)
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.board)
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.cream)
          .borderRadius(10)
          .margin({ top: 14 })

        Row() {
          Text('活动时间')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('2026-08-22 19:30')
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

DetailEventModal 是活动详情弹窗组件,接收 name 参数显示活动名称。头部使用帐篷 emoji,标题"活动详情"搭配副标题"查看场次安排"。与其它弹窗的头部结构完全一致保持了高度统一性。活动名称用 12 号粗体棋盘绿色文字显示,搭配米色背景(COLORS.cream),与删除弹窗的红色名称卡片形成对比——详情是只读操作用绿色,删除是危险操作用红色。表单字段使用与新增和编辑弹窗完全一致的布局——70 像素标签搭配自适应值。活动时间值为预置静态文本,在真实场景中应从 EventItemtime 字段或后端接口获取。这种展示型详情弹窗在原型阶段用于验证信息架构和视觉布局。

第五十段:DetailEventModal——表单、按钮与容器

        Row() {
          Text('活动地点')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('俱乐部大厅 · 8号桌区')
            .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('38/50 · 余位充足')
            .fontSize(12)
            .fontColor(COLORS.success)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Text('✅ 建议提前15分钟签到,活动提供茶歇与规则讲解,新手友好。')
          .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.board)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.gold })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

这段代码渲染了活动详情弹窗的地点、报名字段、提示文字和按钮。活动地点用深色文字显示预置的"俱乐部大厅 · 8号桌区"。报名人数用绿色文字(COLORS.success)显示"38/50 · 余位充足",绿色表示当前报名状态良好。这种通过色彩语义传达状态信息的设计,使用户无需细读文字就能快速感知活动状态。提示文字描述了签到建议和活动福利信息。与其它弹窗的双按钮不同,详情弹窗只有"我知道了"单个按钮——因为详情是只读操作,无需取消和确认的二元选择。按钮使用棋盘绿背景搭配白色粗体文字,layoutWeight(1) 使按钮占据整行宽度。弹窗容器使用卡片白底、18 像素大圆角和金色边框。justifyContent(FlexAlign.End)constraintSize({ maxHeight: '78%' }) 实现底部弹出的半屏弹窗效果。至此四个弹窗组件的结构分析全部完成,它们共享统一的头部-表单-按钮布局模式,仅在内容字段和按钮配置上各有差异。


状态管理数据流

渲染错误: Mermaid 渲染失败: Parse error on line 20: ...ent] S2 -->|@ObjectLink| C1 ---------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'

上图展示了应用完整的状态管理数据流。九个 @State 状态变量集中在根组件 BoardApp 中管理,通过条件渲染驱动五个内容页和四个弹窗的显隐。BoardData 实例通过 @ObjectLink 在内容页之间共享引用,确保数据一致性。内容页的用户交互通过回调函数向上传递到根组件,根组件再通过修改状态变量触发条件渲染,形成闭环的数据流。这种单向数据流的设计保证了状态变更的可追溯性,避免了多组件直接修改共享状态导致的不一致风险。


弹窗生命周期

关闭阶段

渲染阶段

触发阶段

用户点击列表项操作按钮

回调函数执行

设置上下文参数

设置show状态为true

条件渲染触发

弹窗组件创建

弹窗UI呈现

用户点击关闭/确认

onClose回调执行

show状态置为false

条件渲染移除

弹窗组件销毁

弹窗的生命周期完整展示了从触发到销毁的全过程。以删除赛事弹窗为例:用户点击列表项的垃圾桶按钮触发 onDel 回调,回调先将赛事名称存入 delMatchName,再将 showDeleteMatch 置为 true。条件渲染检测到状态变化创建 DeleteMatchModal 组件并传入 title 参数。用户在弹窗中点击"保留赛事"或"确认删除"触发 onClose 回调,将 showDeleteMatch 置回 false,条件渲染移除弹窗组件。这种状态驱动的弹窗生命周期管理,完全由声明式范式自动处理,开发者无需手动管理组件的创建和销毁。


技术对比表格

技术要点 实现方式 优势 适用场景
色彩体系管理 BoardPalette接口 + COLORS常量 集中管理、类型安全、全局一致 中大型应用需要统一视觉规范
语义色彩映射 getTypeColor/getLevelColor/getRarityColor函数 业务概念与视觉色彩解耦、集中维护 需要根据业务状态动态着色的场景
状态管理 @State中心化 + @ObjectLink共享 数据来源唯一、响应式自动更新、单向数据流 中小型应用的状态管理
可观察数据 @Observed装饰BoardData类 跨组件响应式追踪、自动驱动视图更新 需要多组件共享同一数据源
组件通信 回调函数向上传递 + 状态提升 子组件松耦合、父组件统一调度 父子组件需要交互反馈的场景
条件渲染 if-else链根据curTab切换内容页 简洁直观、只渲染当前页节省性能 少量页面的Tab切换
弹窗管理 show布尔状态 + 条件渲染 声明式管理、自动创建销毁 少量弹窗的显隐控制
列表渲染 ForEach + layoutWeight比例布局 数据驱动、自动diff、无需图表库 中等数据量的列表和图表
进度条实现 双Row拼接 + layoutWeight比例 纯UI实现、轻量灵活、无第三方依赖 简单进度条和比例条可视化
柱状图实现 ForEach + Column高度计算 纯UI实现、可自定义色彩和高度 少量数据的柱状图展示
标签复用 BoardTag公共@Component DRY原则、统一样式、一处修改全局生效 多处使用的标签组件
动画交互 @State + rotate/translate + animation 声明式动画、状态驱动、简洁优雅 微交互动效和状态过渡
棋盘格装饰 ForEach + 交错取余色彩交替 数据驱动图案、可调格数、代码简洁 装饰性图案和纹理效果
弹窗布局 FlexAlign.End + maxHeight约束 底部弹出、半屏高度、拇指友好 移动端弹窗交互
危险操作防护 红色视觉警示 + 二次确认弹窗 防止误操作、明确后果告知 涉及数据删除和不可逆操作
模块分区色 各列表卡片不同背景色和边框色 统一结构下的视觉分区、增强辨识度 多模块并列的内容页面
数据预置 常量数组 + @Observed类内初始化 前端独立调试、不依赖后端联调 原型阶段和演示场景
回调默认值 空函数作为默认值 健壮性保障、防止未传入时报错 可选回调参数的组件
键值生成 名称+索引拼接作为ForEach键 确保重名项正确diff、避免渲染错误 可能存在重名数据的列表
表单布局 固定宽标签 + layoutWeight值 对齐整齐、自适应值宽度、结构统一 展示型和编辑型表单

安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:

interface BoardPalette {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  accent: string;
  accentLight: string;
  board: string;
  boardLight: string;
  bg: string;
  cardBg: string;
  textPrimary: string;
  textSecondary: string;
  textHint: string;
  line: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
  cream: string;
  gold: string;
}

const COLORS: BoardPalette = {
  primary: '#3E6B4F',
  primaryLight: '#7FA98C',
  primaryDark: '#2A4A36',
  accent: '#C9A227',
  accentLight: '#F0E3B6',
  board: '#2E5D3F',
  boardLight: '#5C8A6A',
  bg: '#F2EDDE',
  cardBg: '#FFFDF4',
  textPrimary: '#2B3B30',
  textSecondary: '#6E7A6F',
  textHint: '#A8B0A4',
  line: '#E3DCC8',
  success: '#5B8C5A',
  warning: '#D89A3E',
  danger: '#C05B4D',
  white: '#FFFFFF',
  cream: '#F5EFE2',
  gold: '#D9B64C'
};

enum BoardTab {
  GAME = 0,
  MEMBER = 1,
  MATCH = 2,
  EVENT = 3,
  COLLECT = 4
}

interface TabMeta {
  key: string;
  icon: string;
  label: string;
  color: string;
}

const TAB_LIST: TabMeta[] = [
  { key: 'game', icon: '🎲', label: '桌游', color: '#3E6B4F' },
  { key: 'member', icon: '♟️', label: '会员', color: '#C05B4D' },
  { key: 'match', icon: '🏆', label: '赛事', color: '#C9A227' },
  { key: 'event', icon: '🎪', label: '活动', color: '#5B8C5A' },
  { key: 'collect', icon: '💎', label: '收藏', color: '#8A6B4D' }
];

const CHECK_COL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];

interface GameItem {
  name: string;
  type: string;
  difficulty: string;
  rating: number;
  plays: number;
  minutes: number;
  emoji: string;
}

interface MemberItem {
  name: string;
  level: string;
  points: number;
  visits: number;
  joined: string;
  emoji: string;
}

interface MatchItem {
  name: string;
  date: string;
  players: number;
  prize: number;
  status: string;
  emoji: string;
}

interface EventItem {
  name: string;
  time: string;
  seats: number;
  joined: number;
  type: string;
  emoji: string;
}

interface CollectItem {
  name: string;
  rarity: string;
  owner: string;
  price: number;
  stage: string;
  emoji: string;
}

interface WeekMeta {
  day: string;
  value: number;
}

interface TypeMeta {
  name: string;
  count: number;
  color: string;
}

interface MatchHotMeta {
  name: string;
  players: number;
  color: string;
}

interface EventJoinMeta {
  name: string;
  rate: number;
  color: string;
}

const WEEK_TABLE: WeekMeta[] = [
  { day: '周一', value: 24 },
  { day: '周二', value: 30 },
  { day: '周三', value: 28 },
  { day: '周四', value: 36 },
  { day: '周五', value: 48 },
  { day: '周六', value: 72 },
  { day: '周日', value: 60 }
];

const TYPE_SHARE: TypeMeta[] = [
  { name: '策略', count: 3, color: '#3E6B4F' },
  { name: '聚会', count: 2, color: '#C9A227' },
  { name: '卡牌', count: 2, color: '#C05B4D' },
  { name: '战棋', count: 2, color: '#5B8C5A' },
  { name: '解谜', count: 2, color: '#8A6B4D' },
  { name: '合作', count: 1, color: '#6E7A6F' }
];

const MATCH_HOT: MatchHotMeta[] = [
  { name: '年度桌游嘉年华', players: 120, color: '#C9A227' },
  { name: '狼人杀城市赛', players: 80, color: '#3E6B4F' },
  { name: '血染钟楼夜赛', players: 60, color: '#C05B4D' },
  { name: '卡坦争霸赛', players: 32, color: '#5B8C5A' },
  { name: '大富翁家庭赛', players: 28, color: '#8A6B4D' },
  { name: '星蚀银河赛', players: 24, color: '#D89A3E' }
];

const EVENT_JOIN: EventJoinMeta[] = [
  { name: '俱乐部周年庆', rate: 92, color: '#C9A227' },
  { name: '极客嘉年华', rate: 88, color: '#3E6B4F' },
  { name: '午夜桌游长夜', rate: 82, color: '#C05B4D' },
  { name: '卡牌收藏展', rate: 74, color: '#5B8C5A' },
  { name: '桌游盲盒交换会', rate: 66, color: '#8A6B4D' },
  { name: '拼图马拉松', rate: 58, color: '#D89A3E' }
];

@Observed
export class BoardData {
  games: GameItem[] = [
    { name: '卡坦岛', type: '策略', difficulty: '进阶', rating: 9.2, plays: 268, minutes: 90, emoji: '🏝️' },
    { name: '三国杀', type: '卡牌', difficulty: '新手', rating: 8.5, plays: 412, minutes: 40, emoji: '⚔️' },
    { name: '大富翁', type: '聚会', difficulty: '新手', rating: 8.0, plays: 556, minutes: 90, emoji: '🏦' },
    { name: '狼人杀', type: '聚会', difficulty: '新手', rating: 8.8, plays: 623, minutes: 60, emoji: '🐺' },
    { name: '星蚀', type: '策略', difficulty: '大师', rating: 9.0, plays: 96, minutes: 180, emoji: '🚀' },
    { name: '七宗罪', type: '卡牌', difficulty: '进阶', rating: 8.6, plays: 187, minutes: 45, emoji: '😈' },
    { name: '战争之匣', type: '战棋', difficulty: '进阶', rating: 9.1, plays: 143, minutes: 120, emoji: '🧱' },
    { name: '幽灵马车', type: '解谜', difficulty: '新手', rating: 7.9, plays: 98, minutes: 40, emoji: '🚗' },
    { name: '铁路环游', type: '合作', difficulty: '新手', rating: 8.3, plays: 274, minutes: 75, emoji: '🚂' },
    { name: '血染钟楼', type: '聚会', difficulty: '进阶', rating: 9.4, plays: 351, minutes: 90, emoji: '🕰️' },
    { name: '神秘大地', type: '策略', difficulty: '大师', rating: 9.3, plays: 89, minutes: 150, emoji: '🌍' },
    { name: '王冠棋', type: '战棋', difficulty: '进阶', rating: 8.9, plays: 167, minutes: 100, emoji: '👑' }
  ];

  members: MemberItem[] = [
    { name: '林小满', level: '资深', points: 12800, visits: 156, joined: '2024-03', emoji: '🦊' },
    { name: '陈一舟', level: '新人', points: 3200, visits: 42, joined: '2026-01', emoji: '🐼' },
    { name: '苏糖', level: '殿堂', points: 25600, visits: 210, joined: '2023-06', emoji: '🐰' },
    { name: '王铁蛋', level: '进阶', points: 8600, visits: 98, joined: '2025-04', emoji: '🐻' },
    { name: '阿岚', level: '殿堂', points: 30100, visits: 268, joined: '2022-11', emoji: '🦌' },
    { name: '老K', level: '进阶', points: 7400, visits: 86, joined: '2025-08', emoji: '🐺' },
    { name: '草莓', level: '新人', points: 4100, visits: 51, joined: '2025-12', emoji: '🐹' },
    { name: '赵一飞', level: '资深', points: 13900, visits: 162, joined: '2024-08', emoji: '🦁' },
    { name: '温酒', level: '进阶', points: 9200, visits: 104, joined: '2025-02', emoji: '🐯' },
    { name: '米娅', level: '资深', points: 15600, visits: 178, joined: '2024-01', emoji: '🐱' },
    { name: '大熊', level: '新人', points: 2900, visits: 35, joined: '2026-02', emoji: '🐘' },
    { name: '十七', level: '殿堂', points: 27800, visits: 231, joined: '2023-03', emoji: '🦉' }
  ];

  matches: MatchItem[] = [
    { name: '春季狼人杯', date: '03-15', players: 48, prize: 3000, status: '已结束', emoji: '🏅' },
    { name: '卡坦争霸赛', date: '03-22', players: 32, prize: 2000, status: '报名中', emoji: '🧭' },
    { name: '星蚀银河赛', date: '04-05', players: 24, prize: 5000, status: '筹备中', emoji: '🚀' },
    { name: '三国杀双人赛', date: '04-12', players: 40, prize: 1500, status: '已结束', emoji: '🃏' },
    { name: '血染钟楼夜赛', date: '04-19', players: 60, prize: 4000, status: '报名中', emoji: '🕰️' },
    { name: '神秘大地大师赛', date: '05-03', players: 16, prize: 8000, status: '筹备中', emoji: '🌍' },
    { name: '大富翁家庭赛', date: '05-10', players: 28, prize: 800, status: '已结束', emoji: '💰' },
    { name: '战争之匣挑战赛', date: '05-17', players: 20, prize: 3000, status: '报名中', emoji: '🧱' },
    { name: '铁路环游接力赛', date: '05-24', players: 36, prize: 1800, status: '筹备中', emoji: '🚂' },
    { name: '王冠棋快棋赛', date: '05-31', players: 18, prize: 2500, status: '已结束', emoji: '👑' },
    { name: '狼人杀城市赛', date: '06-07', players: 80, prize: 6000, status: '报名中', emoji: '🐺' },
    { name: '年度桌游嘉年华', date: '06-21', players: 120, prize: 15000, status: '筹备中', emoji: '🎪' }
  ];

  events: EventItem[] = [
    { name: '周五新手教学夜', time: '每周五 19:30', seats: 30, joined: 24, type: '教学', emoji: '🎓' },
    { name: '桌游盲盒交换会', time: '03-18 14:00', seats: 24, joined: 18, type: '交换', emoji: '📦' },
    { name: '拼图马拉松', time: '03-25 10:00', seats: 20, joined: 12, type: '挑战', emoji: '🧩' },
    { name: '古董桌游鉴赏', time: '04-02 15:00', seats: 18, joined: 16, type: '鉴赏', emoji: '🏺' },
    { name: '亲子桌游日', time: '04-09 09:30', seats: 32, joined: 20, type: '亲子', emoji: '👨‍👩‍👧' },
    { name: '桌游设计工作坊', time: '04-16 13:00', seats: 18, joined: 14, type: '创作', emoji: '✏️' },
    { name: '午夜桌游长夜', time: '04-23 20:00', seats: 28, joined: 22, type: '夜场', emoji: '🌙' },
    { name: '卡牌收藏展', time: '05-07 10:00', seats: 40, joined: 26, type: '展会', emoji: '🃏' },
    { name: '棋类大师对谈', time: '05-14 16:00', seats: 20, joined: 15, type: '讲座', emoji: '🎙️' },
    { name: '桌游版画工坊', time: '05-21 14:00', seats: 16, joined: 10, type: '手作', emoji: '🎨' },
    { name: '俱乐部周年庆', time: '06-01 18:00', seats: 60, joined: 45, type: '庆典', emoji: '🎂' },
    { name: '极客嘉年华', time: '06-14 11:00', seats: 50, joined: 38, type: '综合', emoji: '🎡' }
  ];

  collects: CollectItem[] = [
    { name: '1987首版大富翁', rarity: '传奇', owner: '老K', price: 12000, stage: '展出', emoji: '🏛️' },
    { name: '德国限定卡坦', rarity: '稀有', owner: '阿岚', price: 8600, stage: '借出', emoji: '📦' },
    { name: '手绘三国杀典藏', rarity: '稀有', owner: '赵一飞', price: 5200, stage: '展出', emoji: '🎨' },
    { name: '铁盒版战争之匣', rarity: '稀有', owner: '林小满', price: 6800, stage: '维护', emoji: '🔧' },
    { name: '签名版狼人杀', rarity: '普通', owner: '苏糖', price: 2400, stage: '展出', emoji: '📜' },
    { name: '木刻王冠棋', rarity: '传奇', owner: '十七', price: 9800, stage: '展出', emoji: '🪵' },
    { name: '日版星蚀限定', rarity: '稀有', owner: '温酒', price: 7500, stage: '借出', emoji: '🚀' },
    { name: '绝版幽灵马车', rarity: '普通', owner: '米娅', price: 1800, stage: '展出', emoji: '👻' },
    { name: '金属棋子收藏盒', rarity: '稀有', owner: '大熊', price: 3900, stage: '维护', emoji: '🔩' },
    { name: '首届全国赛纪念盘', rarity: '传奇', owner: '老K', price: 15000, stage: '展出', emoji: '🏆' },
    { name: '手工皮面神秘大地', rarity: '稀有', owner: '王铁蛋', price: 8900, stage: '借出', emoji: '📚' },
    { name: '1990初版铁路环游', rarity: '传奇', owner: '阿岚', price: 13500, stage: '展出', emoji: '🚂' }
  ];
}

function getTypeColor(type: string): string {
  if (type === '策略') return '#3E6B4F';
  if (type === '聚会') return '#C9A227';
  if (type === '卡牌') return '#C05B4D';
  if (type === '战棋') return '#5B8C5A';
  if (type === '解谜') return '#8A6B4D';
  return '#6E7A6F';
}

function getLevelColor(level: string): string {
  if (level === '新人') return '#5B8C5A';
  if (level === '进阶') return '#C9A227';
  if (level === '资深') return '#3E6B4F';
  return '#C05B4D';
}

function getRarityColor(rarity: string): string {
  if (rarity === '传奇') return '#C05B4D';
  if (rarity === '稀有') return '#C9A227';
  return '#6E7A6F';
}

@Entry
@Component
struct BoardApp {
  @State curTab: number = 0;
  @State data: BoardData = new BoardData();
  @State showAddGame: boolean = false;
  @State showEditMember: boolean = false;
  @State showDeleteMatch: boolean = false;
  @State showDetail: boolean = false;
  @State delMatchName: string = '';
  @State detailEventName: string = '';
  @State diceSpin: boolean = false;
  @State pawnMove: boolean = false;

  @Builder
  modalOverlay(onClose: () => void) {
    Column() {
      Text('')
        .width(0)
        .height(0)
        .opacity(0)
      Button('')
        .width(1)
        .height(1)
        .opacity(0)
        .onClick(() => {
          onClose();
        })
    }
    .width(1)
    .height(1)
  }

  build() {
    Column() {
      Column() {
        Column() {
          Row() {
            Column() {
              Text('🎲 桌游俱乐部')
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('Board Game Club · 复古棋格')
                .fontSize(10)
                .fontColor('#FFFFFFCC')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Row() {
              Text('🎲')
                .fontSize(20)
                .onClick(() => {
                  this.diceSpin = !this.diceSpin;
                })
                .rotate({ angle: this.diceSpin ? 360 : 0 })
                .animation({ duration: 600, curve: Curve.EaseOut })
              Text('♟️')
                .fontSize(16)
                .margin({ left: 10 })
                .onClick(() => {
                  this.pawnMove = !this.pawnMove;
                })
                .translate({ x: this.pawnMove ? 8 : 0 })
                .animation({ duration: 400, curve: Curve.EaseInOut })
              Text('👑')
                .fontSize(16)
                .margin({ left: 6 })
            }
            .padding({
              left: 10,
              right: 10,
              top: 6,
              bottom: 6
            })
            .backgroundColor('#FFFFFF1F')
            .borderRadius(18)
            .border({ width: 1, color: '#FFFFFF40' })
          }
          .width('100%')

          Column() {
            ForEach(CHECK_COL, (c: number, i: number) => {
              Column()
                .layoutWeight(1)
                .height(9)
                .backgroundColor(i % 2 === 0 ? COLORS.boardLight : COLORS.cream)
            }, (c: number) => 'top' + c)
            ForEach(CHECK_COL, (c: number, i: number) => {
              Column()
                .layoutWeight(1)
                .height(9)
                .backgroundColor((i + 1) % 2 === 0 ? COLORS.boardLight : COLORS.cream)
            }, (c: number) => 'bottom' + c)
          }
          .width('100%')
          .height(18)
          .margin({ top: 10 })
          .borderRadius(4)
          .clip(true)

          Row() {
            Text('棋盘绿')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({
                left: 8,
                right: 8,
                top: 3,
                bottom: 3
              })
              .backgroundColor('#FFFFFF2E')
              .borderRadius(8)
            Text('米白')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({
                left: 8,
                right: 8,
                top: 3,
                bottom: 3
              })
              .backgroundColor('#FFFFFF2E')
              .borderRadius(8)
            Text('琥珀金')
              .fontSize(8)
              .fontColor('#FFFFFFCC')
              .padding({
                left: 8,
                right: 8,
                top: 3,
                bottom: 3
              })
              .backgroundColor('#FFFFFF2E')
              .borderRadius(8)
          }
          .width('100%')
          .margin({ top: 8 })
        }
        .width('100%')
        .padding({
          left: 16,
          right: 16,
          top: 12,
          bottom: 12
        })
        .backgroundColor(COLORS.board)

        if (this.curTab === BoardTab.GAME) {
          GameContent({
            data: this.data, onAdd: () => {
              this.showAddGame = true;
            }
          })
        } else if (this.curTab === BoardTab.MEMBER) {
          MemberContent({
            data: this.data, onEdit: () => {
              this.showEditMember = true;
            }
          })
        } else if (this.curTab === BoardTab.MATCH) {
          MatchContent({
            data: this.data, onDel: (n: string) => {
              this.delMatchName = n;
              this.showDeleteMatch = true;
            }
          })
        } else if (this.curTab === BoardTab.EVENT) {
          EventContent({
            data: this.data, onDetail: (n: string) => {
              this.detailEventName = n;
              this.showDetail = true;
            }
          })
        } else {
          CollectContent({ data: this.data })
        }
      }
      .width('100%')
      .height('100%')

      Row() {
        ForEach(TAB_LIST, (t: TabMeta) => {
          Column() {
            Text(t.icon)
              .fontSize(19)
            Text(t.label)
              .fontSize(10)
              .fontColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.white : COLORS.textHint)
              .fontWeight(this.curTab === TAB_LIST.indexOf(t) ? FontWeight.Bold : FontWeight.Normal)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .justifyContent(FlexAlign.Center)
          .padding({ top: 8, bottom: 8 })
          .backgroundColor(this.curTab === TAB_LIST.indexOf(t) ? COLORS.board : COLORS.cardBg)
          .borderRadius(16)
          .border(this.curTab === TAB_LIST.indexOf(t) ? { width: 1, color: t.color + '66' } :
            { 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.showAddGame) {
        AddGameModal({
          onClose: () => {
            this.showAddGame = false;
          }
        })
      }
      if (this.showEditMember) {
        EditMemberModal({
          onClose: () => {
            this.showEditMember = false;
          }
        })
      }
      if (this.showDeleteMatch) {
        DeleteMatchModal({
          title: this.delMatchName, onClose: () => {
            this.showDeleteMatch = false;
          }
        })
      }
      if (this.showDetail) {
        DetailEventModal({
          name: this.detailEventName, onClose: () => {
            this.showDetail = false;
          }
        })
      }
    }
  }
}

@Component
struct BoardTag {
  @Prop text: string;
  @Prop color: string;

  build() {
    Text(this.text)
      .fontSize(9)
      .fontColor(this.color)
      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
      .backgroundColor(this.color + '1F')
      .borderRadius(6)
      .border({ width: 1, color: this.color + '40' })
  }
}

@Component
struct GameContent {
  @ObjectLink data: BoardData;
  onAdd: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('📈 周开桌次数')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('周六峰值')
              .fontSize(9)
              .fontColor(COLORS.danger)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          Row() {
            ForEach(WEEK_TABLE, (w: WeekMeta) => {
              Column() {
                Text(w.value + '')
                  .fontSize(8)
                  .fontColor(w.value >= 60 ? COLORS.board : COLORS.textSecondary)
                Column()
                  .width(20)
                  .height(w.value * 0.9)
                  .backgroundColor(w.value >= 60 ? COLORS.board : COLORS.primary)
                  .borderRadius(4)
                  .margin({ top: 4 })
                Text(w.day)
                  .fontSize(9)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (w: WeekMeta) => w.day)
          }
          .width('100%')
          .height(130)
          .alignItems(VerticalAlign.Bottom)
          .margin({ top: 10 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })

        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(TYPE_SHARE, (n: TypeMeta) => {
            Row() {
              Text(n.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(60)
              Row() {
                Row()
                  .layoutWeight(n.count / 3)
                  .height(8)
                  .backgroundColor(n.color)
                  .borderRadius(4)
                Row()
                  .layoutWeight(1 - n.count / 3)
                  .height(8)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(4)
              }
              .layoutWeight(1)
              .height(8)
              Text(n.count + '款')
                .fontSize(9)
                .fontColor(n.color)
                .width(32)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (n: TypeMeta) => n.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })

        Column() {
          Row() {
            Text('🎮 馆藏桌游')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击➕上架')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
            Text('➕')
              .fontSize(13)
              .margin({ left: 8 })
              .onClick(() => {
                this.onAdd();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.games, (g: GameItem, i: number) => {
            Row() {
              Column()
                .width(6)
                .height(44)
                .backgroundColor(getTypeColor(g.type))
                .borderRadius(3)
              Text(g.emoji)
                .fontSize(18)
                .width(34)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(g.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  BoardTag({ text: g.type, color: getTypeColor(g.type) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text(g.difficulty + '难度 · ' + g.minutes + '分钟 · 已开' + g.plays + '局')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
                Row() {
                  Row()
                    .layoutWeight(g.rating / 10)
                    .height(5)
                    .backgroundColor(g.rating >= 9 ? COLORS.danger : COLORS.primary)
                    .borderRadius(2)
                  Row()
                    .layoutWeight(1 - g.rating / 10)
                    .height(5)
                    .backgroundColor(COLORS.bg)
                    .borderRadius(2)
                }
                .width('100%')
                .height(5)
                .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Column() {
                Text(g.rating + '')
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.accent)
                Text('评分')
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (g: GameItem, i: number) => g.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cream)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct MemberContent {
  @ObjectLink data: BoardData;
  onEdit: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Text('12')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.board)
            Text('会员总数')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          Column() {
            Text('12.6万')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.accent)
            Text('累计积分')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          Column() {
            Text('8')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.danger)
            Text('本周到店')
              .fontSize(9)
              .fontColor(COLORS.textSecondary)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%')
        .padding({ top: 14, bottom: 14 })
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })

        Column() {
          Row() {
            Text('👑 等级分布')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('殿堂2人')
              .fontSize(9)
              .fontColor(COLORS.danger)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          Row() {
            Text('新人')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .width(60)
            Row() {
              Row()
                .layoutWeight(3 / 12)
                .height(8)
                .backgroundColor(getLevelColor('新人'))
                .borderRadius(4)
              Row()
                .layoutWeight(9 / 12)
                .height(8)
                .backgroundColor(COLORS.bg)
                .borderRadius(4)
            }
            .layoutWeight(1)
            .height(8)
            Text('3人')
              .fontSize(9)
              .fontColor(getLevelColor('新人'))
              .width(32)
              .textAlign(TextAlign.End)
          }
          .width('100%')
          .margin({ top: 8 })
          Row() {
            Text('进阶')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .width(60)
            Row() {
              Row()
                .layoutWeight(4 / 12)
                .height(8)
                .backgroundColor(getLevelColor('进阶'))
                .borderRadius(4)
              Row()
                .layoutWeight(8 / 12)
                .height(8)
                .backgroundColor(COLORS.bg)
                .borderRadius(4)
            }
            .layoutWeight(1)
            .height(8)
            Text('4人')
              .fontSize(9)
              .fontColor(getLevelColor('进阶'))
              .width(32)
              .textAlign(TextAlign.End)
          }
          .width('100%')
          .margin({ top: 8 })
          Row() {
            Text('资深')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .width(60)
            Row() {
              Row()
                .layoutWeight(3 / 12)
                .height(8)
                .backgroundColor(getLevelColor('资深'))
                .borderRadius(4)
              Row()
                .layoutWeight(9 / 12)
                .height(8)
                .backgroundColor(COLORS.bg)
                .borderRadius(4)
            }
            .layoutWeight(1)
            .height(8)
            Text('3人')
              .fontSize(9)
              .fontColor(getLevelColor('资深'))
              .width(32)
              .textAlign(TextAlign.End)
          }
          .width('100%')
          .margin({ top: 8 })
          Row() {
            Text('殿堂')
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
              .width(60)
            Row() {
              Row()
                .layoutWeight(2 / 12)
                .height(8)
                .backgroundColor(getLevelColor('殿堂'))
                .borderRadius(4)
              Row()
                .layoutWeight(10 / 12)
                .height(8)
                .backgroundColor(COLORS.bg)
                .borderRadius(4)
            }
            .layoutWeight(1)
            .height(8)
            Text('2人')
              .fontSize(9)
              .fontColor(getLevelColor('殿堂'))
              .width(32)
              .textAlign(TextAlign.End)
          }
          .width('100%')
          .margin({ top: 8 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })

        Column() {
          Row() {
            Text('♟️ 会员名册')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击✏️编辑')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
            Text('✏️')
              .fontSize(13)
              .margin({ left: 8 })
              .onClick(() => {
                this.onEdit();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.members, (m: MemberItem, i: number) => {
            Row() {
              Text(m.emoji)
                .fontSize(17)
                .width(38)
                .textAlign(TextAlign.Center)
                .padding(6)
                .backgroundColor(COLORS.cream)
                .borderRadius(18)
                .border({ width: 1, color: COLORS.line })
              Column() {
                Row() {
                  Text(m.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  BoardTag({ text: m.level, color: getLevelColor(m.level) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text(m.joined + '入会 · 到店' + m.visits + '次')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Column() {
                Text(m.points + '')
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.board)
                Text('积分')
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (m: MemberItem, i: number) => m.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor('#C05B4D14')
        .borderRadius(14)
        .border({ width: 1, color: '#C05B4D40' })
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct MatchContent {
  @ObjectLink data: BoardData;
  onDel: (n: string) => void = (n: string) => {};

  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, (h: MatchHotMeta, idx: number) => {
            Row() {
              Text(idx + 1 + '')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(idx < 3 ? COLORS.danger : COLORS.textHint)
                .width(22)
              Text(h.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(96)
              Row() {
                Row()
                  .layoutWeight(h.players / 120)
                  .height(9)
                  .backgroundColor(h.color)
                  .borderRadius(4)
                Row()
                  .layoutWeight(1 - h.players / 120)
                  .height(9)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(4)
              }
              .layoutWeight(1)
              .height(9)
              Text(h.players + '人')
                .fontSize(9)
                .fontColor(h.color)
                .width(40)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (h: MatchHotMeta) => h.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })

        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.emoji)
                  .fontSize(17)
                Text(m.date)
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .width(42)
              .alignItems(HorizontalAlign.Center)
              Column() {
                Row() {
                  Text(m.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  BoardTag({ text: m.status, color: m.status === '已结束' ? COLORS.textHint : COLORS.success })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('奖金 ¥' + m.prize + ' · 已报' + m.players + '人')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Text('🗑️')
                .fontSize(14)
                .padding({ left: 8, right: 8, top: 6, bottom: 6 })
                .backgroundColor('#C05B4D1F')
                .borderRadius(8)
                .onClick(() => {
                  this.onDel(m.name);
                })
            }
            .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.cream)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct EventContent {
  @ObjectLink data: BoardData;
  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.danger)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          ForEach(EVENT_JOIN, (e: EventJoinMeta) => {
            Row() {
              Text(e.name)
                .fontSize(10)
                .fontColor(COLORS.textSecondary)
                .width(96)
              Row() {
                Row()
                  .layoutWeight(e.rate / 100)
                  .height(9)
                  .backgroundColor(e.color)
                  .borderRadius(4)
                Row()
                  .layoutWeight(1 - e.rate / 100)
                  .height(9)
                  .backgroundColor(COLORS.bg)
                  .borderRadius(4)
              }
              .layoutWeight(1)
              .height(9)
              Text(e.rate + '%')
                .fontSize(9)
                .fontColor(e.color)
                .width(36)
                .textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (e: EventJoinMeta) => e.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })

        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.events, (e: EventItem, i: number) => {
            Row() {
              Text(e.emoji)
                .fontSize(17)
                .width(34)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(e.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  BoardTag({ text: e.type, color: COLORS.board })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text(e.time + ' · ' + e.joined + '/' + e.seats + '人')
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
                Row() {
                  Row()
                    .layoutWeight(e.joined / e.seats)
                    .height(5)
                    .backgroundColor(e.joined / e.seats >= 0.8 ? COLORS.danger : COLORS.primary)
                    .borderRadius(2)
                  Row()
                    .layoutWeight(1 - e.joined / e.seats)
                    .height(5)
                    .backgroundColor(COLORS.bg)
                    .borderRadius(2)
                }
                .width('100%')
                .height(5)
                .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Text('详情')
                .fontSize(10)
                .fontColor(COLORS.accent)
                .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                .backgroundColor(COLORS.accentLight)
                .borderRadius(6)
                .onClick(() => {
                  this.onDetail(e.name);
                })
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (e: EventItem, i: number) => e.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor('#5B8C5A14')
        .borderRadius(14)
        .border({ width: 1, color: '#5B8C5A40' })
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct CollectContent {
  @ObjectLink data: BoardData;

  build() {
    Scroll() {
      Column() {
        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)
          Row() {
            Text('传奇4')
              .fontSize(9)
              .fontColor('#C05B4D')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#C05B4D1F')
              .borderRadius(6)
            Text('稀有6')
              .fontSize(9)
              .fontColor('#C9A227')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#C9A2271F')
              .borderRadius(6)
            Text('普通2')
              .fontSize(9)
              .fontColor('#6E7A6F')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .backgroundColor('#6E7A6F1F')
              .borderRadius(6)
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ top: 10 })
          Text('累计估值 ¥10.7万 · 全部投保展出')
            .fontSize(9)
            .fontColor(COLORS.textHint)
            .width('100%')
            .margin({ top: 8 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.gold })

        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.collects, (c: CollectItem, i: number) => {
            Row() {
              Column()
                .width(6)
                .height(44)
                .backgroundColor(getRarityColor(c.rarity))
                .borderRadius(3)
              Text(c.emoji)
                .fontSize(18)
                .width(36)
                .textAlign(TextAlign.Center)
              Column() {
                Row() {
                  Text(c.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  BoardTag({ text: c.rarity, color: getRarityColor(c.rarity) })
                    .margin({ left: 6 })
                }
                .width('100%')
                Text('藏家 ' + c.owner + ' · ' + c.stage)
                  .fontSize(10)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding({ left: 8 })
              Column() {
                Text('¥' + c.price)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.danger)
                Text('估值')
                  .fontSize(8)
                  .fontColor(COLORS.textHint)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ left: 10, right: 12, top: 9, bottom: 9 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(10)
            .border({ width: 1, color: COLORS.line })
            .margin({ bottom: 8 })
          }, (c: CollectItem, i: number) => c.name + i)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cream)
        .borderRadius(14)
        .border({ width: 1, color: COLORS.line })
        .margin({ top: 12 })
      }
      .width('100%')
      .padding(14)
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

@Component
struct AddGameModal {
  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('策略 · 2-4人')
            .fontSize(12)
            .fontColor(COLORS.danger)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Row() {
          Text('上手难度')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('新手 · 45分钟一局')
            .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 })

        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.board)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.gold })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

@Component
struct EditMemberModal {
  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('温酒 · 编号021')
            .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('进阶 · 距资深差1400分')
            .fontSize(12)
            .fontColor(COLORS.accent)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Row() {
          Text('累计积分')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('9200 · 本月+860')
            .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('08-16 周五 · 深夜场')
            .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.primary)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.gold })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

@Component
struct DeleteMatchModal {
  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('#C05B4D14')
          .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: 1, color: COLORS.danger + '40' })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}

@Component
struct DetailEventModal {
  name: 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.name)
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.board)
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.cream)
          .borderRadius(10)
          .margin({ top: 14 })

        Row() {
          Text('活动时间')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(70)
          Text('2026-08-22 19:30')
            .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('俱乐部大厅 · 8号桌区')
            .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('38/50 · 余位充足')
            .fontSize(12)
            .fontColor(COLORS.success)
            .layoutWeight(1)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 8 })

        Text('✅ 建议提前15分钟签到,活动提供茶歇与规则讲解,新手友好。')
          .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.board)
            .borderRadius(12)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .border({ width: 1, color: COLORS.gold })
    }
    .width('100%')
    .justifyContent(FlexAlign.End)
    .constraintSize({ maxHeight: '78%' })
  }
}


结尾总结

在这里插入图片描述

回顾整个桌游俱乐部管理应用的架构设计,我们可以从多个维度提炼出有价值的工程实践和设计思考。

色彩体系维度:应用构建了一套以 BoardPalette 接口为类型契约、以 COLORS 常量为实例的十八色复古调色板。这套色彩体系以棋盘绿为主色调、琥珀金为强调色、米黄为背景色,形成了自洽且富有品类基因的视觉语言。三组语义色彩映射函数(getTypeColorgetLevelColorgetRarityColor)将业务概念转化为色彩值,集中管理了所有语义色彩逻辑。每个内容页的列表卡片还通过不同的背景色和边框色实现了模块间的视觉分区,在保持统一布局结构的同时提供了辨识度。这种"统一调色板 + 语义映射函数 + 模块分区色"的三层色彩管理策略,在保证视觉一致性的同时提供了足够的灵活性。

状态管理维度:应用采用了"中心化状态 + @ObjectLink 共享 + 回调通信"的三层状态管理架构。九个 @State 状态变量集中在根组件 BoardApp 中管理,涵盖了标签切换、数据引用、弹窗显隐和动画状态四个类别。BoardData 作为 @Observed 可观察类,通过 @ObjectLink 在五个内容页之间共享引用,确保了数据的一致性和响应性。内容页的用户交互通过回调函数向上传递到根组件,根组件通过修改状态变量触发条件渲染,形成了完整的单向数据流闭环。这种架构在中小型应用中是轻量而高效的,但如果业务规模增长到数十个组件和上百个状态变量时,可能需要引入更专业的状态管理方案。

组件化维度:应用采用了"主入口 + 内容页 + 弹窗 + 公共组件"的四层组件拆分模式。主入口 BoardApp 负责整体布局骨架和状态调度;五个内容页组件各自封装了统计图表和列表渲染逻辑,职责单一、边界清晰;四个弹窗组件共享统一的头部-表单-按钮布局模式,仅在内容字段和按钮配置上各有差异;公共的 BoardTag 标签组件被所有内容页复用,避免了重复编码。这种拆分方式使每个组件的代码量控制在合理范围内,可读性和可维护性都得到了保障。四段式列表项布局(色条 + 图标 + 信息 + 数值)在所有内容页中统一使用,形成了高度一致的视觉节奏。

数据建模维度:应用定义了十个接口类型来描述各业务实体,从 GameItemCollectItem,每个接口都精确刻画了该实体所需的属性维度。所有业务数据集中在 BoardData 类中统一管理,通过 @Observed 装饰器实现响应式追踪。此外还预置了四组统计元数据(WEEK_TABLETYPE_SHAREMATCH_HOTEVENT_JOIN),用于驱动各页面的可视化图表。这种将统计结论预编码为常量数组的方式,让界面在无后端支持时也能呈现完整的数据分析视图,非常适合原型阶段的快速验证。数据接口的字段类型选择也体现了务实精神——数值字段用 number 支持计算,文本字段用 string 支持灵活表达,emoji 字段内嵌在数据结构中省去了独立的图标资源系统。

交互设计维度:应用的交互设计体现了"视觉引导 + 微动效 + 危险防护"三层策略。顶部骰子和棋子的点击动画为应用增添了趣味性,吸引用户探索。所有列表的操作入口(加号、铅笔、垃圾桶、详情按钮)都嵌入在标题区或列表项中,使用户无需滚动寻找操作入口。删除操作通过红色按钮和红色边框实现视觉警示,并通过二次确认弹窗防止误操作。弹窗采用底部弹出的半屏模式,符合拇指操作的人体工学。报名进度条超过 80% 时变红色提示即将满额,这种动态色彩反馈使运营者能快速识别需要关注的项。整体交互设计既考虑了效率也考虑了安全性,在功能完备与体验流畅之间取得了良好平衡。

可视化维度:应用在纯 UI 组件层面实现了多种数据可视化——柱状图(周开桌次数)、水平进度条(类型分布、等级分布、赛事热度、活动报名率)、评分进度条和报名进度条。这些可视化元素完全通过 ArkUI 的 layoutWeight 比例布局实现,不依赖任何图表库,既轻量又灵活。进度条的双 Row 拼接技术(填充 Row + 背景 Row)是一种简洁有效的实现方式,通过 layoutWeight 的比例分配自动计算填充宽度。评分进度条通过 rating / 10 计算十分制比例,报名进度条通过 joined / seats 计算百分比,赛事热度榜通过 players / 120 归一化——每种进度条都根据自身数据特点选择合适的归一化策略。

工程规范维度:应用在工程规范方面遵循了多项最佳实践。使用枚举(BoardTab)而非魔术数字管理标签索引;使用接口(interface)而非类定义数据结构,体现了结构化类型思想;使用 @Observed@ObjectLink 实现响应式数据流;使用 @Builder 封装可复用 UI 片段;使用 ForEach 的键值生成器确保列表 diff 的正确性;回调函数的默认值为空函数确保健壮性;色彩映射函数末尾的兜底返回值防止 undefined。这些工程规范的遵循,使代码在可读性、可维护性和健壮性方面都达到了较高水准。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐