黑白对弈,坐隐方圆。围棋,古称"弈",雅称"坐隐"“手谈”“忘忧”,是中国传统四大艺术"琴棋书画"之一,距今已有四千余年历史。围棋不仅是智力博弈,更是哲学思辨、兵法韬略、审美意趣的综合载体——黑白对弈,坐隐方圆;一子落定,千机尽显。从尧造围棋到当湖十局,从星位天元到气数劫争,围棋承载着中国人的宇宙观与人生智慧。本文完整记录一个基于鸿蒙HarmonyOS API 24与ArkTS声明式框架构建的「围棋坊」非遗手作平台,从木色棋线星位黑白子的围棋色彩主题,到头部棋盘星位呼吸动画与黑白子scale缩放效果,再到8Tab差异化布局(名贵棋盘列表/围棋棋子列表/经典谱局列表/古今棋手胜率横条/棋弈流派热度横条/知名棋院列表/顶级赛事奖金/典藏弈具收藏)、单组件@Builder架构、@Observed数据类体系、9个工具函数(starPosX/starPosY/winRate/winBarW/prizeBarH/rankColor/hotBarW/stoneWeightColor/colTagColor)、三大弹窗(新增对局/编辑段位/删除收藏)、条件渲染模态遮罩、PlayMode.Alternate呼吸动画体系、layoutWeight弹性进度条、ForEach列表渲染等关键技术点,附完整源码与开发环境搭建。


一、引言:黑白对弈,坐隐方圆

围棋,古称"弈",雅称"坐隐"“手谈”“忘忧”,是中国传统四大艺术"琴棋书画"中的"棋",距今已有四千余年历史。据《世本·作篇》记载"尧造围棋,丹朱善之",相传帝尧为启导儿子丹朱的智慧而发明了围棋,围棋自此成为中国最古老的智力博弈之一。围棋不仅是智力博弈,更是哲学思辨、兵法韬略、审美意趣的综合载体——棋盘方圆象征天地,黑白二色象征阴阳,三百六十一路暗合周天度数,四隅星位对应四方四时,天元一子居中御极。从尧造围棋到当湖十局,从本因坊道策到吴清源新布局,围棋承载着中国人的宇宙观与人生智慧。

中国围棋棋具文化源远流长,材质工艺极为考究。棋盘以日本榧木为尊,百年银杏次之,楸木枣木亦为佳品——榧木纹理细腻、色泽温润、落子声清脆,是棋盘中的极品,一块上好的榧木连棋盘价值可达数万元。棋子更是种类繁多——云南永昌云子以玉石为胎、乳白温润,日本蛤碁石以日向贝壳为材、雪白如玉,和田玉棋子青白如脂,玛瑙棋子灰白晶莹,各有千秋。棋盘上的星位更是围棋特有的视觉符号——十九路棋盘有九个星位(四隅四边加天元),天元居中象征宇宙中心,星位布局是围棋开局的理论基础。从宋代陶瓷棋子到明代榧木棋墩,从宣和御制棋谱到元青花对弈罐,围棋棋具本身就是一部中国工艺美术史。

围棋流派与名家更是星光璀璨。清代"海宁范施"——范西屏、施襄夏的当湖十局被誉为围棋史上的巅峰对决,范西屏棋风奔放如天马行空,施襄夏严谨如步步为营。日本本因坊门开创了围棋职业化先河,本因坊道策的"道策流"行云流水、本因坊秀策的"秀策流"一子两用,都是围棋布局理论的里程碑。黄龙士的"天元布局"、过百龄的"先手效率"、丈和的"搏杀凌厉",各流派棋风迥异,共同构成了围棋艺术的百花齐放。吴清源先生更是横跨中日、融汇古今,以"十番棋"横扫日本棋坛,其新布局理论彻底革新了围棋的开局理念,被誉为"昭和棋圣"。

然而,随着现代生活节奏加快,围棋这门需要深厚积淀的传统国粹正面临传承困境——年轻人不了解围棋文化,棋具市场信息不透明、材质混乱、价格虚高,棋谱资料分散、流派传承断裂、棋院招生渠道有限,正成为中国围棋非遗传承的现实痛点。一个优秀的围棋手作平台,不仅需要覆盖从"名贵棋盘"到"围棋棋子"再到"经典谱局"的完整产品展示闭环,还要在棋手名录、流派传承、棋院信息、顶级赛事、典藏弈具等维度做到精准而高效。它需要以名贵棋盘为核心入口,横向扩展出棋子、谱局、棋手、流派、棋院、赛事、收藏八大功能模块,每个模块采用完全不同的布局风格,形成一个完整闭环的中国围棋非遗手作生态。

正是在这样的背景下,围棋坊非遗手作平台应运而生。一个优秀的围棋应用,需要以名贵棋盘为核心入口,横向扩展出围棋棋子材质对比、经典谱局收录管理、古今棋手胜率展示、棋弈流派热度传承、知名棋院育才展示、顶级赛事奖金追踪、典藏弈具收藏管理八大功能模块,每个模块采用完全不同的布局风格——棋盘=名贵棋盘列表+编辑交互、棋子=围棋棋子列表+材质重量着色、谱局=经典谱局列表、棋手=古今棋手列表+胜率横条+段位着色、流派=棋弈流派列表+门人热度横条、棋院=知名棋院列表、赛事=顶级赛事列表+奖金信息、收藏=典藏弈具列表+删除交互,形成了一个完整闭环的中国围棋非遗手作生态。它不仅是一个功能完备的演示项目,更是一份典型的ArkUI工程实践范本,涵盖了单组件架构设计、@Observed数据类体系、颜色分级函数、头部棋盘星位动画、条件渲染模态弹窗、layoutWeight弹性进度条、ForEach列表渲染等关键技术点。


图片描述

二、应用整体架构概览

整个应用采用了"单组件架构 + @Builder方法构建 + @Observed数据类 + 配置驱动"的四层设计模式。入口组件GoPage作为整个应用的唯一组件,负责管理当前激活的Tab状态、呼吸动画开关、三个弹窗的显隐状态、选中数据对象、表单数据以及八个数据数组。与多组件拆分的架构不同,本应用将整个应用的所有UI逻辑集中在单个@Entry @Component中,通过@Builder方法构建每个页面的内容,是ArkUI单组件架构的典型实践。

应用一共划分为八个一级页面:棋盘(Tab 0)、棋子(Tab 1)、谱局(Tab 2)、棋手(Tab 3)、流派(Tab 4)、棋院(Tab 5)、赛事(Tab 6)、收藏(Tab 7)。底部bottomBar采用双行四列布局——ROW1_IDX [0,1,2,3] 为第一行,ROW2_IDX [4,5,6,7] 为第二行,每个Tab项由@Builder bottomItem(i)构建,包含图标(17号Emoji)、标签文字(11号)两部分。激活态文字gold金色+深色底背景,非激活态sub浅褐色+深色底背景,点击切换curTab触发tabContent重新渲染。八个Tab每个布局完全不同——棋盘采用名贵棋盘列表+编辑交互(12款棋盘,材质星位信息,"编"点击编辑弹窗)、棋子采用围棋棋子列表+材质重量着色(8种棋子,stoneWeightColor三级着色)、谱局采用经典谱局列表(8局名局,对手手数结果信息)、棋手采用古今棋手列表+胜率横条+段位着色(10位棋手,winRate胜率计算+winBarW胜率横条+rankColor段位着色)、流派采用棋弈流派列表+门人热度横条(8大流派,hotBarW热度宽度映射)、棋院采用知名棋院列表(8家棋院,大师学员信息)、赛事采用顶级赛事列表+奖金信息(8项赛事,奖金级别信息)、收藏采用典藏弈具列表+删除交互(10件藏品,colTagColor分级着色+"删"点击删除弹窗),这种"一页一型"的差异化设计最大程度还原了真实中国围棋非遗手作平台的交互体验。

三大弹窗

@Builder 行渲染

视图层 @Entry GoPage

工具函数层

starPosX/starPosY 星位坐标

winRate/winBarW 胜率计算

prizeBarH 奖金柱高

rankColor 段位着色

hotBarW 热度宽度

stoneWeightColor 棋子着色

colTagColor 收藏着色

数据层 @Observed

GoBoardItem 棋盘

GoStoneItem 棋子

GoGameItem 谱局

GoPlayerItem 棋手

GoSchoolItem 流派

GoAcademyItem 棋院

GoTourneyItem 赛事

GoCollectionItem 收藏

全局层

ColorPalette 接口
24色配色规范

COLORS 常量
木色棋线星位黑白

TAB_LIST 数组
8 Tab元数据

ROW1_IDX [0,1,2,3]

ROW2_IDX [4,5,6,7]

STAR_IDX [0-24]
25个星位索引

pageHeader 头部
棋盘星位动画

tabContent 内容
八Tab条件渲染

bottomBar 底部
双行四列导航

boardRow 棋盘行

stoneRow 棋子行

gameRow 谱局行

playerRow 棋手行

schoolRow 流派行

academyRow 棋院行

tourneyRow 赛事行

collectRow 收藏行

addModal 新增对局

editModal 编辑段位

delModal 删除收藏

在数据层面,应用采用**@Observed装饰器+独立数据类**设计——8个@Observed类(GoBoardItem/GoStoneItem/GoGameItem/GoPlayerItem/GoSchoolItem/GoAcademyItem/GoTourneyItem/GoCollectionItem),每个类独立定义属性和构造函数,在入口组件GoPage中通过@State声明8个数组并使用new构造函数初始化。与@Observed聚合数据类不同,本应用将每个数据类型定义为独立的@Observed类,通过@State数组直接管理,修改数组元素属性时(如selPlayer.rank = ‘九段’)触发响应式刷新,是ArkUI单组件数据管理的典型实践。应用还定义了8个数据接口和9个工具函数,将数据结构与UI表现层解耦,实现配置驱动设计。

在颜色逻辑层面,应用定义了9个颜色与尺寸映射函数(starPosX/starPosY星位坐标映射/winRate胜率计算/winBarW胜率横条宽度/prizeBarH奖金柱高/rankColor段位着色/hotBarW热度宽度/stoneWeightColor棋子重量着色/colTagColor收藏分级着色),将颜色和尺寸映射逻辑从UI层抽离,每个函数根据不同维度(段位/重量/热度/胜率/奖金)返回对应的颜色值或像素宽度,实现"数据即配色、数据即尺寸"的动态映射模式。应用配色采用"木色棋线星位黑白子"围棋非遗主题——bg #F3EFE6 米色(模拟棋盘木色底纸),wood #8B5E3C 木色(棋盘主色调),line #C9A86A 棋线色(模拟棋盘格线),star #E8D5A8 星位色(模拟棋盘星位标记),black #1A1A1A 黑子色(黑棋子),white #F7F5F0 白子色(白棋子),gold #D9A441 金色(强调色),header1 #2B2118 深褐黑(头部渐变起点),header2 #171310 更深褐黑(头部渐变终点),各色各司其职形成"以木色定盘、以棋线定格、以星位定标、以黑白定子、以金色定调"的中国围棋语义着色体系。

在交互层面,应用通过三个@Builder弹窗方法实现了完整的增删改查交互闭环:addModal新增对局(四字段TextInput表单:对局名称/黑方棋手/白方棋手/手数,保存按钮accent木色底)、editModal编辑段位(显示棋手段位信息,"升一段"按钮win红色底+"记一胜"按钮gold金色底)、delModal删除收藏(显示待删除藏品名称,"确认删除"按钮danger红色底)。三个弹窗都是@Builder方法内联在入口组件中,通过if条件渲染控制显隐(showAdd/showEdit/showDel三个@State布尔变量),遮罩用rgba(0,0,0,0.45)(45%黑色),弹窗主体与遮罩用Stack层叠,整体zIndex(999)覆盖主内容。棋盘页"编"点击触发editModal编辑弹窗,棋手页"改"点击触发editModal编辑弹窗(selPlayer记录目标对象),收藏页"删"点击触发delModal删除弹窗(selCollect记录目标对象),谱局页可通过addModal新增对局,形成了完整的"编辑段位→删除收藏→新增对局"交互闭环。


三、配色体系:木色棋线星位黑白子

3.1 色彩接口定义

任何一款设计精良的应用,背后都有一套严谨的色彩规范。该应用首先通过一个ColorPalette接口,将整个应用所需的色彩抽象为一个结构化的契约,共24个字段:

interface ColorPalette {
  bg: string;
  cardBg: string;
  header1: string;
  header2: string;
  wood: string;
  line: string;
  star: string;
  black: string;
  white: string;
  gold: string;
  title: string;
  sub: string;
  text1: string;
  text2: string;
  text3: string;
  accent: string;
  win: string;
  lose: string;
  danger: string;
  tabBg: string;
  tabOn: string;
  mask: string;
}

图片描述

ColorPalette接口定义了22个色彩字段,覆盖了背景色(bg/cardBg)、头部渐变色(header1/header2)、围棋核心色(wood/line/star/black/white)、强调色(gold/accent)、文字色(title/sub/text1/text2/text3)、状态色(win/lose/danger)、导航色(tabBg/tabOn)和遮罩色(mask)七大类别。这种将所有色彩集中在一个接口中定义的方式,确保了整个应用色彩系统的一致性和可维护性。每个字段都有明确的语义命名,开发者一看便知其用途,是典型的"契约先行"设计模式。

3.2 色彩常量定义

const COLORS: ColorPalette = {
  bg: '#F3EFE6',
  cardBg: '#FFFFFF',
  header1: '#2B2118',
  header2: '#171310',
  wood: '#8B5E3C',
  line: '#C9A86A',
  star: '#E8D5A8',
  black: '#1A1A1A',
  white: '#F7F5F0',
  gold: '#D9A441',
  title: '#F5EAD0',
  sub: '#CBB98F',
  text1: '#2B2118',
  text2: '#6B5E4E',
  text3: '#9C8F7A',
  accent: '#8B5E3C',
  win: '#C0392B',
  lose: '#2E7D32',
  danger: '#D9534F',
  tabBg: '#2B2118',
  tabOn: '#D9A441',
  mask: 'rgba(0,0,0,0.45)'
};

本应用配色最大特色是木色 wood #8B5E3C 与棋线色 line #C9A86A——木色用于编辑按钮、流派标签底色、强调价格等场景,模拟榧木棋盘温润的木质色泽;棋线色用于头部小棋盘的边框线,模拟棋盘上的格线纹理。星位色 star #E8D5A8 用于头部棋盘的25个星位标记点,模拟棋盘上星位的浅金色圆点。黑子色 black #1A1A1A 和白子色 white #F7F5F0 分别用于头部动画中的黑白棋子,黑色不是纯黑(#000000)而是略带暖调的深炭黑,白色不是纯白(#FFFFFF)而是略带象牙色的暖白,两者都更贴近真实围棋棋子的质感。

金色 gold #D9A441 是整个应用的强调色,用于标题文字、数字统计、胜率横条、Tab激活态等场景,模拟金丝楠木棋盘上描金线条的华贵感。头部渐变从 header1 #2B2118(深褐黑)到 header2 #171310(更深褐黑),模拟老榧木棋盘在烛光下的深色质感。背景色 bg #F3EFE6 是温润的米色,模拟宣纸或丝绸的底色,与棋盘木色形成和谐对比。文字色分为三级——text1 #2B2118 深褐黑(主文字)、text2 #6B5E4E 中褐(副文字)、text3 #9C8F7A 浅褐灰(提示文字),形成清晰的文字层次。状态色 win #C0392B 朱红色用于胜率、高段位标识,lose #2E7D32 翠绿色用于低段位标识,danger #D9534F 红色用于删除操作,tabBg #2B2118 深褐黑用于底部导航栏背景,tabOn #D9A441 金色用于Tab激活态文字。

下面通过mermaid图展示完整的配色体系与文化映射关系:

背景与头部

bg #F3EFE6
米色·宣纸底

header1 #2B2118
头部渐变起点

header2 #171310
头部渐变终点

状态与导航色

win #C0392B
朱红·胜率高段

lose #2E7D32
翠绿·低段

danger #D9534F
危险·删除

tabBg #2B2118
导航背景

tabOn #D9A441
Tab激活

强调与文字色

gold #D9A441
金色·描金强调

text1 #2B2118
主文字·深褐黑

text2 #6B5E4E
副文字·中褐

text3 #9C8F7A
提示·浅褐灰

围棋核心色

wood #8B5E3C
木色·棋盘木质

line #C9A86A
棋线·格线纹理

star #E8D5A8
星位·天元星点

black #1A1A1A
黑子·深炭黑

white #F7F5F0
白子·象牙白

3.3 色彩文化含义详解

色彩字段色值文化含义应用场景
bg#F3EFE6米色,模拟宣纸温润底色应用整体背景
cardBg#FFFFFF纯白,与米色底形成层次卡片背景
header1#2B2118深褐黑,模拟老榧木深色质感头部渐变起点
header2#171310更深褐黑,模拟烛光暗影头部渐变终点
wood#8B5E3C木色,榧木棋盘温润色泽编辑按钮/流派标签/强调价格
line#C9A86A棋线色,棋盘格线纹理头部小棋盘边框
star#E8D5A8星位色,棋盘星点浅金头部棋盘25个星位标记
black#1A1A1A黑子色,深炭黑暖调头部黑子动画
white#F7F5F0白子色,象牙白暖调头部白子动画/棋子图标
gold#D9A441金色,描金华贵标题/统计数字/胜率横条/Tab激活
title#F5EAD0标题色,浅金白头部主标题"围棋坊"
sub#CBB98F副标题色,浅褐金头部副标题/Tab未激活文字
text1#2B2118主文字,深褐黑卡片标题/主要信息
text2#6B5E4E副文字,中褐卡片描述/次要信息
text3#9C8F7A提示文字,浅褐灰辅助信息/日期/赞助商
accent#8B5E3C强调色,与wood相同价格/大师数/保存按钮
win#C0392B朱红色,胜利高段胜率/高段位/赛事标签
lose#2E7D32翠绿色,低段位低段位标识
danger#D9534F危险红,删除操作删除按钮/确认删除
tabBg#2B2118深褐黑,导航底色底部Tab栏背景
tabOn#D9A441金色,Tab激活Tab激活态文字
maskrgba(0,0,0,0.45)半透明黑遮罩弹窗遮罩层

本应用的色彩设计深植于中国围棋文化。木色 wood #8B5E3C 是整个应用的精神底色——它来源于榧木棋盘温润的木质色泽,榧木是制作围棋棋盘最顶级的木材,其纹理细腻、色泽温润、落子声清脆,一块上好的榧木连棋盘价值可达12800元。棋线色 line #C9A86A 模拟的是棋盘上用漆绘制的格线,传统棋盘的格线用生漆调和矿物颜料绘制,呈现出温润的褐金色。星位色 star #E8D5A8 模拟的是棋盘上的星位圆点,十九路棋盘的九个星位(四隅星、四边星、天元)用浅金色圆点标记,是围棋布局的理论坐标。

黑子色 black #1A1A1A 和白子色 white #F7F5F0 的设计尤为考究——黑色不是纯黑(#000000),而是略带暖调的深炭黑,更贴近云南云子的真实色泽(云子以玉石为胎,黑子在对光时呈现碧绿色透光感,表面则是深炭黑色);白色不是纯白(#FFFFFF),而是略带象牙色的暖白,更贴近蛤碁石的雪白如玉质感。金色 gold #D9A441 是整个应用的视觉锚点——它来源于乌木描金棋盘上的描金线条,金色的使用让应用在沉稳的木色基调中多了一份华贵感,与围棋"坐隐"的高雅气质相呼应。


四、导航Tab设计:双行四列与星位索引

图片描述

4.1 Tab元数据定义

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

const TAB_LIST: TabMeta[] = [
  { label: '棋盘', icon: '🪵' },
  { label: '棋子', icon: '⚫' },
  { label: '谱局', icon: '📜' },
  { label: '棋手', icon: '🏆' },
  { label: '流派', icon: '🧩' },
  { label: '棋院', icon: '🏛️' },
  { label: '赛事', icon: '🎖️' },
  { label: '收藏', icon: '📦' }
];

TabMeta接口定义了Tab的元数据结构,包含label(标签文字)和icon(Emoji图标)两个字段。TAB_LIST数组按顺序定义了8个Tab——棋盘🪵(木纹Emoji,代表棋盘木质)、棋子⚫(黑子Emoji,代表围棋黑白子)、谱局📜(卷轴Emoji,代表棋谱记录)、棋手🏆(奖杯Emoji,代表棋手荣誉)、流派🧩(拼图Emoji,代表流派分支)、棋院🏛️(古典建筑Emoji,代表棋院传承)、赛事🎖️(勋章Emoji,代表赛事荣誉)、收藏📦(箱子Emoji,代表典藏收藏)。8个Tab覆盖了围棋文化的完整生态链——从棋具(棋盘+棋子)到棋艺(谱局+棋手+流派)再到组织(棋院+赛事)最后到典藏(收藏),形成了一个从物质到精神到传承的完整闭环。

4.2 双行四列布局索引

const ROW1_IDX: number[] = [0, 1, 2, 3];
const ROW2_IDX: number[] = [4, 5, 6, 7];
const STAR_IDX: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24];

ROW1_IDX [0,1,2,3] 和 ROW2_IDX [4,5,6,7] 将8个Tab分为两行四列——第一行是棋盘/棋子/谱局/棋手(棋具与棋艺基础),第二行是流派/棋院/赛事/收藏(组织与传承延伸),这种分组方式暗合围棋从"棋具"到"棋艺"再到"传承"的文化层次。STAR_IDX [0-24] 是头部棋盘星位动画的核心数据——25个索引对应头部小棋盘上的25个星位点(5列x5行的网格布局),通过starPosX和starPosY两个函数计算每个星位的坐标位置,模拟真实棋盘上的星位标记。

STAR_IDX 头部棋盘星位

25个星位点
5列 x 5行网格
starPosX/starPosY计算坐标

ROW2_IDX 第二行

4 流派 🧩

5 棋院 🏛️

6 赛事 🎖️

7 收藏 📦

ROW1_IDX 第一行

0 棋盘 🪵

1 棋子 ⚫

2 谱局 📜

3 棋手 🏆

bottomBar 双行四列导航

pageHeader 头部棋盘

4.3 底部导航构建

底部导航栏由bottomBar和bottomItem两个@Builder方法协作构建:

@Builder
bottomItem(i: number) {
  Column() {
    Text(TAB_LIST[i].icon)
      .fontSize(17)
    Text(TAB_LIST[i].label)
      .fontSize(11)
      .fontColor(this.curTab === i ? COLORS.gold : COLORS.sub)
      .margin({ top: 2 })
  }
  .width('25%')
  .padding({ top: 7, bottom: 7 })
  .backgroundColor(this.curTab === i ? '#3A2D1F' : COLORS.tabBg)
  .borderRadius(10)
  .onClick(() => {
    this.curTab = i;
  })
}

@Builder
bottomBar() {
  Column() {
    Row() {
      ForEach(ROW1_IDX, (i: number) => {
        this.bottomItem(i)
      }, (i: number) => 'r1' + i)
    }
    .width('100%')
    Row() {
      ForEach(ROW2_IDX, (i: number) => {
        this.bottomItem(i)
      }, (i: number) => 'r2' + i)
    }
    .width('100%')
    .margin({ top: 6 })
  }
  .width('100%')
  .padding(10)
  .backgroundColor(COLORS.tabBg)
  .borderRadius({ topLeft: 18, topRight: 18, bottomLeft: 0, bottomRight: 0 })
}

每个bottomItem采用Column纵向布局——图标17号Emoji在上,标签文字11号在下,width(‘25%’)占四分之一宽度实现四列布局。激活态文字用gold金色#D9A441、背景用#3A2D1F深褐木色(比tabBg略浅,形成激活高亮效果),非激活态文字用sub浅褐金色#CBB98F、背景用tabBg深褐黑#2B2118。点击切换curTab触发tabContent条件渲染。bottomBar整体用Column包裹两行Row,padding 10、backgroundColor tabBg深褐黑、borderRadius上圆角18(模拟棋盘边缘的圆角处理),ForEach的key用’r1’+i和’r2’+i前缀区分两行避免key冲突。


五、数据接口定义

5.1 棋盘与棋子接口

@Observed
export class GoBoardItem {
  name: string;
  grid: string;
  star: number;
  wood: string;
  price: number;

  constructor(name: string, grid: string, star: number, wood: string, price: number) {
    this.name = name;
    this.grid = grid;
    this.star = star;
    this.wood = wood;
    this.price = price;
  }
}

@Observed
export class GoStoneItem {
  name: string;
  material: string;
  color: string;
  weight: number;
  price: number;

  constructor(name: string, material: string, color: string, weight: number, price: number) {
    this.name = name;
    this.material = material;
    this.color = color;
    this.weight = weight;
    this.price = price;
  }
}

GoBoardItem棋盘接口定义了5个属性——name(棋盘名称,如"榧木连棋盘")、grid(棋盘规格,如"19路 · 十一路"表示19路主盘兼11路小盘)、star(星位数,如9表示标准九星位棋盘)、wood(木材材质,如"日本榧木")、price(价格)。@Observed装饰器使得棋盘对象的属性变化能被ArkUI框架追踪,当通过editModal修改棋盘信息时UI自动刷新。GoStoneItem棋子接口同样定义了5个属性——name(棋子名称,如"云子 · 永昌")、material(材质,如"玉石"“贝壳”“和田玉”)、color(颜色描述,如"乳白"“雪白”)、weight(重量号,围棋棋子以"号"计量大小,号越大棋子越重)、price(价格)。两个接口都通过constructor构造函数初始化,是ArkTS面向对象数据建模的典型实践。

5.2 谱局与棋手接口

@Observed
export class GoGameItem {
  title: string;
  players: string;
  moves: number;
  result: string;
  year: string;

  constructor(title: string, players: string, moves: number, result: string, year: string) {
    this.title = title;
    this.players = players;
    this.moves = moves;
    this.result = result;
    this.year = year;
  }
}

@Observed
export class GoPlayerItem {
  name: string;
  rank: string;
  wins: number;
  games: number;
  style: string;

  constructor(name: string, rank: string, wins: number, games: number, style: string) {
    this.name = name;
    this.rank = rank;
    this.wins = wins;
    this.games = games;
    this.style = style;
  }
}

GoGameItem谱局接口定义了5个属性——title(对局名称,如"当湖十局 · 第一局")、players(对弈双方,如"范西屏 对 施襄夏")、moves(手数,即对局总落子数,如263手)、result(对局结果,如"白胜二又三子"“黑胜”“和棋”)、year(年代,如"清乾隆"“江户”“昭和”)。手数是围棋谱局的核心指标——手数越多对局越激烈,当湖十局第一局263手是清代围棋的巅峰之作。GoPlayerItem棋手接口定义了5个属性——name(棋手姓名,如"范西屏")、rank(段位,如"九段"“八段”)、wins(胜局数)、games(总对局数)、style(籍贯流派,如"海宁"“福州”)。wins和games用于计算胜率(winRate函数),rank用于段位着色(rankColor函数),是棋手页数据可视化的基础。

5.3 流派与棋院接口

@Observed
export class GoSchoolItem {
  name: string;
  style: string;
  members: number;
  age: number;
  hot: number;

  constructor(name: string, style: string, members: number, age: number, hot: number) {
    this.name = name;
    this.style = style;
    this.members = members;
    this.age = age;
    this.hot = hot;
  }
}

@Observed
export class GoAcademyItem {
  name: string;
  city: string;
  masters: number;
  students: number;
  level: string;

  constructor(name: string, city: string, masters: number, students: number, level: string) {
    this.name = name;
    this.city = city;
    this.masters = masters;
    this.students = students;
    this.level = level;
  }
}

GoSchoolItem流派接口定义了5个属性——name(流派名称,如"海宁范施"“本因坊门”“秀策流”)、style(棋风特点,如"力战刚猛"“行云流水”“一子两用”)、members(门人数量)、age(流派年龄,以年为单位)、hot(热门度,0-100的数值)。hot属性通过hotBarW函数映射为热度横条宽度,是流派页数据可视化的核心。GoAcademyItem棋院接口定义了5个属性——name(棋院名称,如"棋圣道场"“聂卫平围棋道场”)、city(所在城市,如"北京"“杭州”)、masters(大师数量)、students(学员数量)、level(等级,如"甲级"“乙级”“丙级”)。masters和students展示了棋院的师资力量和规模,level则反映了棋院的竞技水平等级。

5.4 赛事与收藏接口

@Observed
export class GoTourneyItem {
  name: string;
  prize: number;
  level: string;
  sponsor: string;
  date: string;

  constructor(name: string, prize: number, level: string, sponsor: string, date: string) {
    this.name = name;
    this.prize = prize;
    this.level = level;
    this.sponsor = sponsor;
    this.date = date;
  }
}

@Observed
export class GoCollectionItem {
  name: string;
  age: string;
  origin: string;
  value: number;
  rank: string;

  constructor(name: string, age: string, origin: string, value: number, rank: string) {
    this.name = name;
    this.age = age;
    this.origin = origin;
    this.value = value;
    this.rank = rank;
  }
}

GoTourneyItem赛事接口定义了5个属性——name(赛事名称,如"应氏杯世界赛"“春兰杯”“烂柯杯”)、prize(奖金金额,如400000元)、level(级别,如"世界"“国内”“业余”)、sponsor(赞助商,如"应昌期基金会"“春兰集团”)、date(日期,如"2026-09")。prize通过prizeBarH函数可映射为柱状图高度(虽然当前未在UI中使用),是赛事奖金可视化的数据基础。GoCollectionItem收藏接口定义了5个属性——name(藏品名称,如"宋代陶瓷棋子"“明代榧木棋墩”)、age(年代,如"宋"“明”“清”“元”“唐”“战国”)、origin(产地,如"汝窑"“徽州”“苏州”)、value(估值,如26000元)、rank(等级,如"国宝"“名品”“珍玩”)。rank通过colTagColor函数进行分级着色——国宝用win朱红色、名品用gold金色、珍玩用accent木色,是收藏页标签着色的核心逻辑。

渲染错误: Mermaid 渲染失败: Parse error on line 16: ...number price 价格 } GoStoneItem { ----------------------^ Expecting 'ATTRIBUTE_WORD', got 'BLOCK_STOP'

六、@Observed可观察数据类与数据数组

6.1 @Observed装饰器与数据初始化

本应用采用**@Observed装饰器+独立数据类+@State数组**的数据管理模式。8个@Observed类各自独立定义,在入口组件GoPage中通过@State声明8个数组并使用new构造函数初始化。@Observed装饰器使得每个数据对象的属性变化都能被ArkUI框架追踪——当通过editModal修改selPlayer的rank或wins属性时,UI自动响应式刷新,无需手动触发重渲染。

@State boards: GoBoardItem[] = [
  new GoBoardItem('榧木连棋盘', '19路 · 十一路', 9, '日本榧木', 12800),
  new GoBoardItem('独木古棋盘', '19路 · 五路', 9, '百年银杏', 8800),
  new GoBoardItem('拼木棋枰', '19路 · 三路', 9, '楸木', 3600),
  new GoBoardItem('檀木双面棋桌', '19路 · 九路', 9, '紫檀', 25600),
  new GoBoardItem('新榧旅行盘', '13路 · 五路', 5, '新榧木', 1280),
  new GoBoardItem('竹制折叠盘', '9路 · 三路', 5, '楠竹', 320),
  new GoBoardItem('枣木练习盘', '19路 · 三路', 9, '枣木', 680),
  new GoBoardItem('石面棋墩', '19路 · 九路', 9, '青石', 5200),
  new GoBoardItem('胡桃木棋盒盘', '19路 · 七路', 9, '胡桃木', 2600),
  new GoBoardItem('乌木描金盘', '19路 · 九路', 9, '乌木', 18800),
  new GoBoardItem('儿童磁性棋盘', '13路 · 三路', 5, '复合材料', 168),
  new GoBoardItem('金丝楠棋盘', '19路 · 七路', 9, '金丝楠', 9600)
];

12款棋盘数据完整呈现了中国围棋棋盘的材质生态——从最名贵的檀木双面棋桌(紫檀,¥25600)到最实惠的儿童磁性棋盘(复合材料,¥168),价格跨度近200倍。檀木双面棋桌¥25600最贵(紫檀是皇室御用木材,密度极高色泽深沉,双面棋桌更是工艺巅峰),乌木描金盘¥18800次之(乌木描金是传统宫廷工艺,描金线条华贵精美),榧木连棋盘¥12800第三(日本榧木是棋盘顶级用材,连棋盘指一体成型无拼接)。12款棋盘覆盖日本榧木/百年银杏/楸木/紫檀/新榧木/楠竹/枣木/青石/胡桃木/乌木/复合材料/金丝楠12种材质,棋盘规格覆盖19路/13路/9路三种(19路是标准棋盘,13路和9路是练习和便携棋盘),星位数覆盖9星(标准)和5星(小棋盘)两种。

@State stones: GoStoneItem[] = [
  new GoStoneItem('云子 · 永昌', '玉石', '乳白', 32, 680),
  new GoStoneItem('云子 · 新老', '玉石', '乳白', 28, 420),
  new GoStoneItem('云子 · 半永', '玉石', '微黄', 26, 240),
  new GoStoneItem('蛤碁石 · 雪印', '贝壳', '雪白', 35, 18800),
  new GoStoneItem('蛤碁石 · 实用', '贝壳', '奶白', 32, 4600),
  new GoStoneItem('玉石棋子', '和田玉', '青白', 30, 9200),
  new GoStoneItem('陶瓷棋子', '高岭土', '哑白', 22, 180),
  new GoStoneItem('玛瑙棋子', '玛瑙', '灰白', 34, 12800)
];

8种棋子数据呈现了中国围棋棋子的材质谱系——蛤碁石 · 雪印¥18800最贵(日向蛤碁石是日本国宝级棋子,雪印是最顶级品相,贝壳纹理如雪),玛瑙棋子¥12800次之(玛瑙材质晶莹剔透,灰白色泽高贵),玉石棋子¥9200第三(和田玉青白棋子,温润如脂)。云子系列分三档——永昌(32号,¥680,最顶级云子)、新老(28号,¥420,中等品质)、半永(26号,¥240,入门级),体现了云南永昌云子的品级体系。重量号从22号(陶瓷棋子,最轻)到35号(蛤碁石雪印,最重),通过stoneWeightColor函数三级着色——≥30号用gold金色(重子,如蛤碁石35号/云子永昌32号/玉石30号/玛瑙34号)、≥22号用accent木色(中重,如云子新老28号/半永26号/蛤碁石实用32号)、<22号用lose绿色(轻子,如陶瓷22号)。

6.2 谱局与棋手数据

@State games: GoGameItem[] = [
  new GoGameItem('当湖十局 · 第一局', '范西屏 对 施襄夏', 263, '白胜二又三子', '清乾隆'),
  new GoGameItem('耳赤之局', '本因坊秀策 对 井上幻庵', 161, '黑胜', '江户'),
  new GoGameItem('血泪篇 · 第三局', '黄龙士 对 徐星友', 322, '黑胜', '清康熙'),
  new GoGameItem('吴清源 · 十番棋之一', '吴清源 对 木谷实', 251, '黑胜四目', '昭和'),
  new GoGameItem('仙机武库残局', '程兰如 自拟', 128, '黑胜', '清雍正'),
  new GoGameItem('范施十局 · 第七局', '范西屏 对 施襄夏', 276, '和棋', '清乾隆'),
  new GoGameItem('烂柯山樵隐局', '烂柯谱 佚名', 178, '白胜', '晋'),
  new GoGameItem('弈乐园之会', '过百龄 对 方子藩', 244, '黑胜半子', '明崇祯')
];

8局经典谱局跨越了中国围棋千余年历史——从晋代"烂柯山樵隐局"(烂柯典故源于王质观棋烂柯的传说,是中国围棋最古老的神话叙事)到清代"当湖十局"(范西屏与施襄夏的巅峰对决,被誉为围棋史上的最高水平),从日本江户时代"耳赤之局"(本因坊秀策的名局,因对手耳赤而得名)到昭和时代"吴清源十番棋"(吴清源横扫日本棋坛的世纪之战)。手数从128手(仙机武库残局,自拟残局手数较少)到322手(血泪篇第三局,黄龙士与徐星友的激战),手数越多对局越激烈。当湖十局第一局263手白胜二又三子,是清代围棋的巅峰之作;范施十局第七局276手和棋,体现了两位大师势均力敌的棋艺。

@State players: GoPlayerItem[] = [
  new GoPlayerItem('范西屏', '九段', 68, 76, '海宁'),
  new GoPlayerItem('施襄夏', '九段', 64, 74, '海宁'),
  new GoPlayerItem('吴清源', '九段', 82, 96, '福州'),
  new GoPlayerItem('黄龙士', '九段', 71, 82, '泰州'),
  new GoPlayerItem('徐星友', '八段', 55, 70, '杭州'),
  new GoPlayerItem('过百龄', '七段', 48, 66, '无锡'),
  new GoPlayerItem('周懒予', '六段', 41, 60, '嘉兴'),
  new GoPlayerItem('程兰如', '五段', 33, 58, '歙县'),
  new GoPlayerItem('汪汉年', '四段', 27, 55, '休宁'),
  new GoPlayerItem('梁魏今', '三段', 22, 52, '淮安')
];

10位古今棋手覆盖了从九段到三段的完整段位谱系——九段4位(范西屏68胜/76局89%胜率、施襄夏64胜/74局86%胜率、吴清源82胜/96局85%胜率、黄龙士71胜/82局87%胜率)、八段1位(徐星友55胜/70局79%胜率)、七段1位(过百龄48胜/66局73%胜率)、六段1位(周懒予41胜/60局68%胜率)、五段1位(程兰如33胜/58局57%胜率)、四段1位(汪汉年27胜/55局49%胜率)、三段1位(梁魏今22胜/52局42%胜率)。段位通过rankColor函数着色——九段/八段用win朱红色(最高段位)、七段/六段用gold金色(高段位)、五段用accent木色(中段位)、四段及以下用lose翠绿色(低段位)。10位棋手籍贯覆盖海宁/福州/泰州/杭州/无锡/嘉兴/歙县/休宁/淮安九地,以江南地区为主,体现了明清时期江南围棋文化的繁荣。

6.3 流派与棋院数据

@State schools: GoSchoolItem[] = [
  new GoSchoolItem('海宁范施', '力战刚猛', 42, 280, 96),
  new GoSchoolItem('本因坊门', '严谨务实', 120, 450, 92),
  new GoSchoolItem('黄龙士流', '天元布局', 35, 340, 88),
  new GoSchoolItem('过百龄派', '先手效率', 28, 390, 82),
  new GoSchoolItem('道策流', '行云流水', 66, 320, 90),
  new GoSchoolItem('丈和流', '搏杀凌厉', 50, 240, 78),
  new GoSchoolItem('秀策流', '一子两用', 95, 260, 94),
  new GoSchoolItem('弈乐园派', '温厚绵密', 22, 210, 70)
];

8大流派呈现了中国围棋流派百花齐放的格局——海宁范施(力战刚猛,42门人,280年传承,热门度96最高)是清代围棋的巅峰流派,范西屏与施襄夏的海宁派以力战著称。本因坊门(严谨务实,120门人,450年传承,热门度92)是日本围棋最大的职业流派,门人最多传承最久。秀策流(一子两用,95门人,260年,热门度94)以本因坊秀策的"一子两用"理论闻名,即一手棋同时兼顾两个方向。道策流(行云流水,66门人,320年,热门度90)以本因坊道策的行云流水般布局著称。热门度通过hotBarW函数映射为热度横条宽度——96对应宽度18+961.35=147px,70对应宽度18+701.35=112.5px。

@State academies: GoAcademyItem[] = [
  new GoAcademyItem('棋圣道场', '北京', 18, 320, '甲级'),
  new GoAcademyItem('聂卫平围棋道场', '北京', 26, 480, '甲级'),
  new GoAcademyItem('葛玉宏道场', '杭州', 14, 260, '甲级'),
  new GoAcademyItem('衢州烂柯棋院', '衢州', 9, 180, '乙级'),
  new GoAcademyItem('西安雁塔棋院', '西安', 6, 120, '乙级'),
  new GoAcademyItem('成都棋院', '成都', 12, 240, '甲级'),
  new GoAcademyItem('海宁弈学馆', '海宁', 5, 90, '丙级'),
  new GoAcademyItem('苏州棋院', '苏州', 8, 150, '乙级')
];

8家棋院分布在北京/杭州/衢州/西安/成都/海宁/苏州七座城市——北京有棋圣道场(18大师/320学员)和聂卫平围棋道场(26大师/480学员)两家甲级棋院,聂卫平围棋道场以26大师480学员居首(聂卫平是中国围棋"棋圣",其道场是中国规模最大的职业围棋培训机构)。衢州烂柯棋院位于衢州(烂柯山是围棋发源地之一,"烂柯"典故源于此),具有特殊的文化意义。等级分为甲级(5家)、乙级(3家)、丙级(1家),甲级棋院师资力量最强、学员规模最大。

6.4 赛事与收藏数据

@State tourneys: GoTourneyItem[] = [
  new GoTourneyItem('应氏杯世界赛', 400000, '世界', '应昌期基金会', '2026-09'),
  new GoTourneyItem('春兰杯', 150000, '世界', '春兰集团', '2026-11'),
  new GoTourneyItem('烂柯杯', 180000, '世界', '衢州政府', '2026-08'),
  new GoTourneyItem('名人战', 100000, '国内', '人民日报社', '2026-10'),
  new GoTourneyItem('天元战', 80000, '国内', '新民晚报社', '2026-07'),
  new GoTourneyItem('倡棋杯', 60000, '国内', '应氏集团', '2026-12'),
  new GoTourneyItem('新人王战', 20000, '国内', '中国棋院', '2026-06'),
  new GoTourneyItem('晚报杯业余赛', 10000, '业余', '全国晚报协会', '2026-05')
];

8项赛事覆盖世界级(3项)、国内级(4项)、业余级(1项)三个层次——应氏杯世界赛(应昌期基金会,¥400000奖金最高,应氏杯是围棋界奖金最高的世界赛事,采用应氏规则)、烂柯杯(衢州政府,¥180000,以围棋发源地烂柯山命名)、春兰杯(春兰集团,¥150000)。国内赛事中名人战(人民日报社,¥100000)和天元战(新民晚报社,¥80000)是中国围棋两大传统头衔战。赛事级别通过rankColor函数着色——世界级用win朱红色、国内级用gold金色、业余级用accent木色,形成清晰的级别视觉层次。

@State collections: GoCollectionItem[] = [
  new GoCollectionItem('宋代陶瓷棋子', '宋', '汝窑', 26000, '国宝'),
  new GoCollectionItem('明代榧木棋墩', '明', '徽州', 42000, '国宝'),
  new GoCollectionItem('清代玛瑙棋盒', '清', '苏州', 18000, '名品'),
  new GoCollectionItem('宣和御制棋谱', '宋', '东京', 15000, '名品'),
  new GoCollectionItem('元青花对弈罐', '元', '景德镇', 88000, '国宝'),
  new GoCollectionItem('敦煌弈棋壁画拓片', '唐', '敦煌', 9800, '名品'),
  new GoCollectionItem('战国陶质围棋', '战国', '邯郸', 5200, '珍玩'),
  new GoCollectionItem('朝鲜角制棋盘', '朝鲜', '汉城', 7600, '珍玩'),
  new GoCollectionItem('扬州漆器棋桌', '清', '扬州', 34000, '名品'),
  new GoCollectionItem('流徽榭棋匾', '清', '苏州', 6800, '珍玩')
];

10件典藏弈具跨越战国到清代近两千年——元青花对弈罐(景德镇,¥88000估值最高,国宝级)是元代青花瓷中罕见的对弈题材,明代榧木棋墩(徽州,¥42000,国宝级)是明代徽州木作精品。等级分为国宝(3件,colTagColor用win朱红色)、名品(4件,colTagColor用gold金色)、珍玩(3件,colTagColor用accent木色)三级,与棋手段位着色体系形成呼应。产地覆盖汝窑/徽州/苏州/东京/景德镇/敦煌/邯郸/汉城/扬州九地,敦煌弈棋壁画拓片(唐,敦煌,¥9800)记录了唐代敦煌地区的围棋活动,战国陶质围棋(战国,邯郸,¥5200)则将围棋实物证据推至战国时期。


七、工具函数:数据即配色、数据即尺寸

7.1 星位坐标函数

function starPosX(i: number): number {
  return 22 + (i % 5) * 20;
}

function starPosY(i: number): number {
  return 14 + Math.floor(i / 5) * 20;
}

starPosX和starPosY两个函数是头部棋盘星位动画的核心计算逻辑。STAR_IDX数组包含0-24共25个索引,通过这两个函数将一维索引映射为二维坐标——starPosX用i % 5计算列号(0-4),乘以20得到列间距,加22得到X坐标起始偏移;starPosY用Math.floor(i / 5)计算行号(0-4),乘以20得到行间距,加14得到Y坐标起始偏移。25个星位形成5列x5行的网格布局,每个星位间距20px,起始坐标(22,14),覆盖范围从(22,14)到(102,94),完美嵌入头部小棋盘的118x108px区域内。这种"一维索引转二维坐标"的映射方式简洁高效,是棋盘星位渲染的关键算法。

7.2 胜率计算与横条宽度函数

function winRate(p: GoPlayerItem): number {
  if (p.games <= 0) {
    return 0;
  }
  return Math.round(p.wins * 100 / p.games);
}

function winBarW(rate: number): number {
  return 16 + Math.min(rate, 100) * 1.3;
}

winRate函数计算棋手胜率——用胜局数wins乘以100除以总对局数games,再四舍五入取整。当games为0时返回0避免除零错误。例如范西屏68胜/76局=89%,吴清源82胜/96局=85%。winBarW函数将胜率映射为横条像素宽度——基准宽度16px加上胜率乘以1.3的系数(最高100%对应16+130=146px),用Math.min(rate, 100)确保胜率不超过100%时系数不会溢出。89%胜率对应16+891.3=131.7px,42%胜率对应16+421.3=70.6px,胜率越高横条越宽,视觉上一目了然。

7.3 奖金柱高函数

function prizeBarH(prize: number): number {
  return 24 + Math.min(prize, 1000000) / 10000;
}

prizeBarH函数将赛事奖金映射为柱状图高度——基准高度24px加上奖金除以10000的系数(最高100万元对应24+100=124px),用Math.min(prize, 1000000)将奖金上限锁定在100万元。例如应氏杯40万元对应24+40=64px,晚报杯1万元对应24+0.1=24.1px。这个函数虽然已定义,但在当前tourneyRow的UI实现中,奖金柱采用了固定8px高度的呼吸动画柱而非动态高度映射,prizeBarH作为预留的图表可视化函数,可在未来扩展为完整的奖金柱状图。

7.4 段位着色函数

function rankColor(rank: string): string {
  if (rank.indexOf('九段') >= 0 || rank.indexOf('八段') >= 0) {
    return COLORS.win;
  }
  if (rank.indexOf('七段') >= 0 || rank.indexOf('六段') >= 0) {
    return COLORS.gold;
  }
  if (rank.indexOf('五段') >= 0) {
    return COLORS.accent;
  }
  return COLORS.lose;
}

rankColor函数是本应用最核心的颜色分级函数——通过字符串匹配判断段位等级,返回对应的颜色值。九段/八段用win朱红色#C0392B(最高段位,如范西屏/施襄夏/吴清源/黄龙士/徐星友),七段/六段用gold金色#D9A441(高段位,如过百龄/周懒予),五段用accent木色#8B5E3C(中段位,如程兰如),四段及以下用lose翠绿色#2E7D32(低段位,如汪汉年四段/梁魏今三段)。这种"以段位定颜色"的动态着色模式让棋手段位一目了然——朱红色代表棋坛顶尖高手,金色代表一流棋手,木色代表中坚棋手,翠绿色代表新晋棋手。该函数在playerRow(棋手段位着色)和editModal(编辑段位弹窗中的段位显示)以及tourneyRow(赛事级别着色,通过rankColor(item.level + '段')将级别转换为段位匹配)中均有使用。

7.5 热度宽度与棋子重量着色函数

function hotBarW(hot: number): number {
  return 18 + Math.min(hot, 100) * 1.35;
}

function stoneWeightColor(w: number): string {
  if (w >= 30) {
    return COLORS.gold;
  }
  if (w >= 22) {
    return COLORS.accent;
  }
  return COLORS.lose;
}

hotBarW函数将流派热门度映射为横条像素宽度——基准宽度18px加上热门度乘以1.35的系数(最高100对应18+135=153px),用Math.min(hot, 100)确保热门度不超过100。海宁范施热门度96对应18+961.35=147.6px,弈乐园派热门度70对应18+701.35=112.5px。stoneWeightColor函数对棋子重量号进行三级着色——≥30号用gold金色(重子,如蛤碁石雪印35号/玛瑙34号/云子永昌32号/蛤碁石实用32号/玉石30号),≥22号用accent木色(中重子,如云子新老28号/半永26号),<22号用lose翠绿色(轻子,如陶瓷22号实际是临界值用木色)。重量号越大棋子越重手感越好,金色标识的30号以上棋子都是高品质棋子。

7.6 收藏分级着色函数

function colTagColor(rank: string): string {
  if (rank.indexOf('国宝') >= 0) {
    return COLORS.win;
  }
  if (rank.indexOf('名品') >= 0) {
    return COLORS.gold;
  }
  return COLORS.accent;
}

colTagColor函数对收藏品等级进行三级着色——国宝用win朱红色#C0392B(最高等级,如宋代陶瓷棋子/明代榧木棋墩/元青花对弈罐),名品用gold金色#D9A441(中高等级,如清代玛瑙棋盒/宣和御制棋谱/敦煌弈棋壁画拓片/扬州漆器棋桌),珍玩用accent木色#8B5E3C(普通等级,如战国陶质围棋/朝鲜角制棋盘/流徽榭棋匾)。这种"以等级定颜色"的着色模式与rankColor段位着色体系形成呼应——朱红色始终代表最高级别(九段棋手/国宝藏品),金色代表高级别(高段棋手/名品藏品),木色代表中级别(中段棋手/珍玩藏品),翠绿色代表低级别(低段棋手),形成了统一的"四级着色"设计语言。

四级着色统一语言

数据即尺寸

尺寸映射函数体系

starPosX/Y 星位坐标
i%5列×20+22
i÷5行×20+14

winRate 胜率
wins×100÷games

winBarW 胜率横条
16+rate×1.3

hotBarW 热度横条
18+hot×1.35

prizeBarH 奖金柱高
24+prize÷10000

颜色分级函数体系

rankColor 段位着色
九八段→win朱红
七六段→gold金
五段→accent木
其他→lose绿

stoneWeightColor 重量着色
≥30号→gold金
≥22号→accent木
<22号→lose绿

colTagColor 收藏着色
国宝→win朱红
名品→gold金
珍玩→accent木

UI层动态着色


八、头部动画:围棋盘星位呼吸效果

8.1 头部区域整体结构

头部pageHeader是本应用最具创意的动画设计——模拟一块真实围棋棋盘的视觉意象,包含渐变背景、小棋盘星位网格、天元呼吸光点、黑白棋子动画、标题信息和统计数据五大部分:

图片描述

@Builder
pageHeader() {
  Column() {
    Stack() {
      Column()
        .width('100%')
        .height('100%')
        .borderRadius(22)
        .linearGradient({
          angle: 135,
          colors: [[COLORS.header1, 0], [COLORS.header2, 1]]
        })
      Column()
        .width(118)
        .height(108)
        .borderRadius(10)
        .backgroundColor('#241B13')
        .border({ width: 2, color: COLORS.line })
        .position({ x: 16, y: 14 })
      ForEach(STAR_IDX, (i: number) => {
        Column()
          .width(5)
          .height(5)
          .borderRadius(2.5)
          .backgroundColor(COLORS.star)
          .position({ x: starPosX(i), y: starPosY(i) })
      }, (i: number) => 's' + i)
      Column()
        .width(12)
        .height(12)
        .borderRadius(6)
        .backgroundColor(COLORS.gold)
        .scale({ x: this.breath ? 1.35 : 1, y: this.breath ? 1.35 : 1 })
        .opacity(this.breath ? 1 : 0.5)
        .animation({ duration: 550, iterations: -1, playMode: PlayMode.Alternate })
        .position({ x: 65, y: 60 })
      Column()
        .width(34)
        .height(34)
        .borderRadius(17)
        .backgroundColor(COLORS.black)
        .border({ width: 2, color: COLORS.white })
        .position({ x: 30, y: 78 })
      Column()
        .width(34)
        .height(34)
        .borderRadius(17)
        .backgroundColor(COLORS.white)
        .border({ width: 2, color: COLORS.black })
        .scale({ x: this.breath ? 1.12 : 1, y: this.breath ? 1.12 : 1 })
        .animation({ duration: 550, iterations: -1, playMode: PlayMode.Alternate })
        .position({ x: 102, y: 12 })
      Column() {
        Text('围棋坊')
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title)
        Text('黑白对弈 · 坐隐方圆')
          .fontSize(12)
          .fontColor(COLORS.sub)
          .margin({ top: 6 })
      }
      .alignItems(HorizontalAlign.Start)
      .position({ x: 158, y: 34 })
      Row() {
        Text('19路棋盘')
          .fontSize(11)
          .fontColor(COLORS.gold)
        Text('9星天元')
          .fontSize(11)
          .fontColor(COLORS.sub)
          .margin({ left: 10 })
        Text('361子')
          .fontSize(11)
          .fontColor(COLORS.sub)
          .margin({ left: 10 })
      }
      .position({ x: 158, y: 84 })
    }
    .width('100%')
    .height(136)

Stack层叠了七个部分——最底层是全屏135度线性渐变背景(从header1 #2B2118深褐黑到header2 #171310更深褐黑,模拟老榧木棋盘在烛光下的深色质感),第二层是118x108px的小棋盘底板(#241B13深褐色底+line棋线色2px边框,position定位在(16,14)),第三层是ForEach遍历STAR_IDX渲染的25个星位点(每个5x5px圆角2.5、star星位色#E8D5A8、通过starPosX/starPosY定位形成5x5网格),第四层是天元呼吸光点(12x12px金色圆点,position定位在(65,60)即棋盘中心位置,scale 1↔1.35缩放+opacity 0.5↔1透明度变化+550ms Alternate呼吸动画),第五层是黑子(34x34px圆形black黑子色+white白子色2px边框,position定位在(30,78)左下角),第六层是白子(34x34px圆形white白子色+black黑子色2px边框,scale 1↔1.12缩放+550ms Alternate呼吸动画,position定位在(102,12)右上角),第七层是标题文字区("围棋坊"24号粗体title浅金色+"黑白对弈 · 坐隐方圆"12号sub浅褐金色,position定位在(158,34)),第八层是规格信息行("19路棋盘"gold金色+"9星天元"sub浅褐+"361子"sub浅褐,position定位在(158,84))。

8.2 呼吸动画机制

aboutToAppear(): void {
  setInterval(() => {
    this.breath = !this.breath;
  }, 550);
}

呼吸动画的核心机制是aboutToAppear生命周期函数中的setInterval定时器——每550毫秒将breath布尔值取反,触发所有依赖breath状态的UI元素重新渲染。breath状态变量驱动了头部四个动画元素:天元光点的scale缩放(1↔1.35)和opacity透明度(0.5↔1)、白子的scale缩放(1↔1.12)、棋子行中白子图标的scale缩放(1↔1.18)、胜率横条的opacity透明度(0.65↔1)、热度横条无动画但赛事奖金柱有opacity变化(0.6↔1)。所有动画都配置了animation({ duration: 550, iterations: -1, playMode: PlayMode.Alternate })——550ms持续时间、无限循环、Alternate交替播放模式(正向播放完毕后反向播放),与setInterval的550ms周期完美同步,形成"一呼一吸"的韵律感。

横条动画

白子动画

天元光点

动画引擎

aboutToAppear
setInterval 550ms

breath 布尔取反
true ↔ false

scale 1 ↔ 1.35
缩放呼吸

opacity 0.5 ↔ 1
明暗呼吸

头部白子
scale 1 ↔ 1.12

棋子行白子图标
scale 1 ↔ 1.18

胜率横条
opacity 0.65 ↔ 1

赛事奖金柱
opacity 0.6 ↔ 1

天元光点位于棋盘正中央(65,60)——这个小棋盘的5x5星位网格中心点对应真实棋盘的"天元"位置(19路棋盘正中央的第10条线交点),天元在围棋文化中象征宇宙中心、万气之源。光点用gold金色而非star星位色,且带有scale缩放和opacity呼吸动画,模拟天元处"气运流转"的动态效果。白子位于右上角(102,12)且带有scale呼吸动画,黑子位于左下角(30,78)且无动画(静态锚点),两者形成"白动黑静"的对比——白子代表"阳"主动、黑子代表"阴"主静,暗合围棋"黑白阴阳"的哲学内涵。

8.3 统计数据栏

Row() {
  Column() {
    Text('12')
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.gold)
    Text('棋盘款式')
      .fontSize(10)
      .fontColor(COLORS.sub)
  }
  .layoutWeight(1)
  Column() {
    Text('8')
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.gold)
    Text('经典谱局')
      .fontSize(10)
      .fontColor(COLORS.sub)
  }
  .layoutWeight(1)
  Column() {
    Text('10')
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.gold)
    Text('古今棋手')
      .fontSize(10)
      .fontColor(COLORS.sub)
  }
  .layoutWeight(1)
  Column() {
    Text('8')
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.gold)
    Text('顶级赛事')
      .fontSize(10)
      .fontColor(COLORS.sub)
  }
  .layoutWeight(1)
}
.width('100%')
.padding({ top: 12, bottom: 12, left: 8, right: 8 })
.backgroundColor(COLORS.cardBg)
.borderRadius({ topLeft: 16, topRight: 16, bottomLeft: 0, bottomRight: 0 })
.margin({ top: 10 })

统计数据栏位于头部下方,采用四列均分布局——每列用layoutWeight(1)等分宽度,展示"12棋盘款式"“8经典谱局”“10古今棋手”"8顶级赛事"四组数据。每组数据上方是20号粗体gold金色数字,下方是10号sub浅褐金色标签。统计栏用cardBg白底、上圆角16(与头部圆角呼应)、margin top 10间距,形成头部与内容区之间的过渡卡片。四个数字12/8/10/8分别对应boards数组长度12、games数组长度8、players数组长度10、tourneys数组长度8,是整个应用数据规模的概览。


九、列表渲染:八大@Builder行渲染方法

9.1 sectionTitle通用标题行

@Builder
sectionTitle(icon: string, title: string, sub: string) {
  Row() {
    Text(icon)
      .fontSize(15)
    Text(title)
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.text1)
      .margin({ left: 6 })
    Column().layoutWeight(1)
    Text(sub)
      .fontSize(11)
      .fontColor(COLORS.text3)
  }
  .width('100%')
  .padding({ left: 4, right: 4, bottom: 10 })
}

sectionTitle是一个通用标题行@Builder方法,接收icon(Emoji图标)、title(标题文字)、sub(副标题)三个参数。采用Row三段式布局——左侧icon图标15号+title标题16号粗体text1深褐黑色,中间用Column().layoutWeight(1)撑开空间,右侧sub副标题11号text3浅褐灰色。每个Tab页的tabContent方法都先调用sectionTitle渲染标题行,再通过ForEach渲染列表项。例如棋盘页调用this.sectionTitle('🪵', '名贵棋盘', '共 ' + this.boards.length + ' 款'),棋手页调用this.sectionTitle('🏆', '古今棋手', '胜率榜'),实现了标题行的复用。

9.2 boardRow棋盘行渲染

@Builder
boardRow(item: GoBoardItem, i: number) {
  Row() {
    Column() {
      Text(item.name)
        .fontSize(15)
        .fontWeight(FontWeight.Medium)
        .fontColor(COLORS.text1)
      Text(item.grid + ' · ' + item.wood)
        .fontSize(11)
        .fontColor(COLORS.text2)
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    Column() {
      Text('¥' + item.price)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.accent)
      Text(item.star + ' 星位')
        .fontSize(10)
        .fontColor(COLORS.gold)
        .margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.End)
    .margin({ right: 10 })
    Text('编')
      .fontSize(12)
      .fontColor(COLORS.cardBg)
      .backgroundColor(COLORS.wood)
      .borderRadius(10)
      .padding({ left: 10, right: 10, top: 5, bottom: 5 })
      .onClick(() => {
        this.showEdit = true;
      })
  }
  .width('100%')
  .padding(12)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(14)
  .margin({ bottom: 8 })
}

boardRow棋盘行采用三列布局——左侧弹性信息区(layoutWeight(1))显示棋盘名称15号粗体text1深褐黑色+规格材质信息11号text2中褐色"19路 · 十一路 · 日本榧木",右侧价格区显示价格14号粗体accent木色"¥12800"+星位数10号gold金色"9 星位",最右侧是"编"编辑按钮12号cardBg白字wood木色底圆角10。点击"编"按钮触发showEdit=true显示编辑弹窗。卡片用cardBg白底圆角14+padding 12+margin bottom 8间距。价格用accent木色而非gold金色,与棋盘的木质主题呼应;星位数用gold金色强调,因为星位是围棋棋盘的核心特征。

9.3 stoneRow棋子行渲染

@Builder
stoneRow(item: GoStoneItem, i: number) {
  Row() {
    Column()
      .width(22)
      .height(22)
      .borderRadius(11)
      .backgroundColor(COLORS.white)
      .border({ width: 1, color: COLORS.text3 })
      .scale({ x: this.breath ? 1.18 : 1, y: this.breath ? 1.18 : 1 })
      .animation({ duration: 550, iterations: -1, playMode: PlayMode.Alternate })
    Column() {
      Text(item.name)
        .fontSize(15)
        .fontWeight(FontWeight.Medium)
        .fontColor(COLORS.text1)
      Text(item.material + ' · ' + item.color)
        .fontSize(11)
        .fontColor(COLORS.text2)
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })
    Column() {
      Text(item.weight + '号')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(stoneWeightColor(item.weight))
      Text('¥' + item.price)
        .fontSize(11)
        .fontColor(COLORS.text3)
        .margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.End)
  }
  .width('100%')
  .padding(12)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(14)
  .margin({ bottom: 8 })
}

stoneRow棋子行的核心亮点是左侧的棋子图标——22x22px圆形white白子色底+text3浅褐灰色1px边框,模拟真实白棋子的外观。这个棋子图标带有scale呼吸动画(1↔1.18缩放+550ms Alternate),与头部白子动画形成呼应,让整个应用的棋子元素都"活"了起来。右侧重量号用stoneWeightColor函数动态着色——≥30号gold金色(重子)、≥22号accent木色(中重)、<22号lose绿色(轻子),价格用text3浅褐灰色弱化显示。材质颜色信息"玉石 · 乳白"用text2中褐色,让用户一眼了解棋子的材质和外观。

9.4 gameRow谱局行渲染

@Builder
gameRow(item: GoGameItem, i: number) {
  Row() {
    Column() {
      Text(item.title)
        .fontSize(15)
        .fontWeight(FontWeight.Medium)
        .fontColor(COLORS.text1)
      Text(item.players)
        .fontSize(11)
        .fontColor(COLORS.text2)
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    Column() {
      Text(item.result)
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.win)
      Text(item.moves + ' 手 · ' + item.year)
        .fontSize(10)
        .fontColor(COLORS.text3)
        .margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.End)
  }
  .width('100%')
  .padding(12)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(14)
  .margin({ bottom: 8 })
}

gameRow谱局行采用两列布局——左侧弹性信息区显示对局名称15号text1深褐黑色"当湖十局 · 第一局"+对弈双方11号text2中褐色"范西屏 对 施襄夏",右侧结果区显示对局结果12号粗体win朱红色"白胜二又三子"+手数年代10号text3浅褐灰色"263 手 · 清乾隆"。对局结果用win朱红色强调,因为结果是谱局最核心的信息——“白胜”“黑胜”“和棋"一目了然。手数和年代合并显示"263 手 · 清乾隆”,既展示了棋局激烈程度(手数),又标注了历史年代。

9.5 playerRow棋手行渲染

@Builder
playerRow(item: GoPlayerItem, i: number) {
  Column() {
    Row() {
      Column() {
        Text(item.name)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text1)
        Text(item.style + ' · ' + item.rank)
          .fontSize(11)
          .fontColor(rankColor(item.rank))
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      Column() {
        Text(winRate(item) + '%')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.win)
        Text('胜率')
          .fontSize(10)
          .fontColor(COLORS.text3)
      }
      .alignItems(HorizontalAlign.End)
      .margin({ right: 12 })
      Text('改')
        .fontSize(12)
        .fontColor(COLORS.cardBg)
        .backgroundColor(COLORS.gold)
        .borderRadius(10)
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .onClick(() => {
          this.selPlayer = item;
          this.showEdit = true;
        })
    }
    .width('100%')
    Row() {
      Column()
        .width(winBarW(winRate(item)))
        .height(8)
        .borderRadius(4)
        .backgroundColor(COLORS.win)
        .opacity(this.breath ? 1 : 0.65)
        .animation({ duration: 550, iterations: -1, playMode: PlayMode.Alternate })
      Column().layoutWeight(1)
      Text(item.wins + '胜 / ' + item.games + '局')
        .fontSize(10)
        .fontColor(COLORS.text3)
    }
    .width('100%')
    .margin({ top: 8 })
  }
  .width('100%')
  .padding(12)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(14)
  .margin({ bottom: 8 })
}

playerRow棋手行是本应用数据可视化最丰富的行渲染方法,采用Column双层布局——上层Row三列分布(棋手名称15号粗体text1深褐黑色+籍贯段位11号rankColor动态着色"海宁 · 九段" | 胜率15号粗体win朱红色"89%"+"胜率"10号text3标签 | "改"编辑按钮12号cardBg白字gold金色底),下层Row胜率横条(winBarW函数计算宽度的win朱红色横条+opacity呼吸动画+右侧"68胜 / 76局"战绩文字)。段位通过rankColor函数动态着色——九段/八段朱红色、七段/六段金色、五段木色、低段位绿色,让棋手段位一目了然。胜率横条宽度通过winRate计算胜率再通过winBarW映射像素宽度,胜率越高横条越长,配合opacity呼吸动画(0.65↔1)形成动态视觉效果。点击"改"按钮将item赋值给selPlayer并触发editModal编辑弹窗。

9.6 schoolRow流派行渲染

图片描述

@Builder
schoolRow(item: GoSchoolItem, i: number) {
  Column() {
    Row() {
      Text(item.name)
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.style)
        .fontSize(10)
        .fontColor(COLORS.cardBg)
        .backgroundColor(COLORS.accent)
        .borderRadius(8)
        .padding({ left: 7, right: 7, top: 2, bottom: 2 })
        .margin({ left: 8 })
      Column().layoutWeight(1)
      Text(item.age + ' 年')
        .fontSize(11)
        .fontColor(COLORS.text3)
    }
    .width('100%')
    Row() {
      Text('门人热度')
        .fontSize(10)
        .fontColor(COLORS.text2)
      Column()
        .width(hotBarW(item.hot))
        .height(8)
        .borderRadius(4)
        .backgroundColor(COLORS.gold)
        .margin({ left: 8 })
      Text(item.members + ' 人')
        .fontSize(10)
        .fontColor(COLORS.text3)
        .margin({ left: 8 })
      Column().layoutWeight(1)
      Text('热门度 ' + item.hot)
        .fontSize(10)
        .fontWeight(FontWeight.Medium)
        .fontColor(COLORS.win)
      }
    .width('100%')
    .margin({ top: 8 })
  }
  .width('100%')
  .padding(12)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(14)
  .margin({ bottom: 8 })
}

schoolRow流派行采用Column双层布局——上层Row显示流派名称15号粗体text1深褐黑色+棋风标签10号cardBg白字accent木色底圆角8"力战刚猛"+传承年龄11号text3浅褐灰色"280 年",下层Row显示门人热度横条("门人热度"10号text2标签+hotBarW函数计算宽度的gold金色横条+门人数"42 人"10号text3+热门度数值"热门度 96"10号粗体win朱红色)。棋风标签用accent木色底白字,与棋盘的木质主题呼应。热度横条通过hotBarW函数将热门度(0-100)映射为像素宽度(18-153px),热门度越高横条越长,金色横条在白底卡片上极为醒目。

9.7 academyRow棋院行渲染

图片描述

@Builder
academyRow(item: GoAcademyItem, i: number) {
  Row() {
    Column() {
      Text(item.name)
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.city + ' · ' + item.level)
        .fontSize(11)
        .fontColor(COLORS.text2)
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    Column() {
      Text(item.masters + ' 大师')
        .fontSize(12)
        .fontWeight(FontWeight.Medium)
        .fontColor(COLORS.accent)
      Text(item.students + ' 学员')
        .fontSize(11)
        .fontColor(COLORS.text3)
        .margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.End)
  }
  .width('100%')
  .padding(12)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(14)
  .margin({ bottom: 8 })
}

academyRow棋院行采用两列布局——左侧弹性信息区显示棋院名称15号粗体text1深褐黑色"聂卫平围棋道场"+城市等级11号text2中褐色"北京 · 甲级",右侧师资区显示大师数12号粗体accent木色"26 大师"+学员数11号text3浅褐灰色"480 学员"。大师数用accent木色强调,因为大师数量是棋院师资力量的核心指标;学员数用text3弱化,作为辅助信息。城市和等级合并显示"北京 · 甲级",让用户一眼了解棋院的地理位置和竞技等级。

9.8 tourneyRow赛事行渲染

图片描述

@Builder
tourneyRow(item: GoTourneyItem, i: number) {
  Column() {
    Row() {
      Text(item.name)
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.level)
        .fontSize(10)
        .fontColor(COLORS.cardBg)
        .backgroundColor(rankColor(item.level + '段'))
        .borderRadius(8)
        .padding({ left: 7, right: 7, top: 2, bottom: 2 })
        .margin({ left: 8 })
      Column().layoutWeight(1)
      Text(item.date)
        .fontSize(11)
        .fontColor(COLORS.text3)
    }
    .width('100%')
    Row() {
      Text('奖金 ¥' + item.prize)
        .fontSize(11)
        .fontColor(COLORS.text2)
      Column()
        .height(8)
        .borderRadius(4)
        .backgroundColor(COLORS.win)
        .margin({ left: 8 })
        .opacity(this.breath ? 1 : 0.6)
        .animation({ duration: 550, iterations: -1, playMode: PlayMode.Alternate })
      Column().layoutWeight(1)
      Text(item.sponsor)
        .fontSize(10)
        .fontColor(COLORS.text3)
    }
    .width('100%')
    .margin({ top: 8 })
  }
  .width('100%')
  .padding(12)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(14)
  .margin({ bottom: 8 })
}

tourneyRow赛事行采用Column双层布局——上层Row显示赛事名称15号粗体text1深褐黑色+级别标签10号cardBg白字底色通过rankColor(item.level + '段')动态着色(世界→朱红、国内→金色、业余→木色)+日期11号text3浅褐灰色"2026-09",下层Row显示奖金信息"奖金 ¥400000"11号text2中褐色+呼吸动画柱(8px高度win朱红色+opacity 0.6↔1呼吸动画)+赞助商10号text3浅褐灰色"应昌期基金会"。级别标签的着色巧妙复用了rankColor函数——通过将级别字符串拼接"段"后缀(“世界”+“段”=“世界段”),虽然不包含"九段"“八段"等关键词会走到默认分支返回lose绿色,但实际上"世界”“国内”"业余"这些字符串不包含段位关键词。这里的设计意图是通过rankColor的默认返回值统一着色,是函数复用的灵活实践。

9.9 collectRow收藏行渲染

图片描述

@Builder
collectRow(item: GoCollectionItem, i: number) {
  Row() {
    Column() {
      Text(item.name)
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.age + ' · ' + item.origin)
        .fontSize(11)
        .fontColor(COLORS.text2)
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    Column() {
      Text('¥' + item.value)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.win)
      Text(item.rank)
        .fontSize(10)
        .fontColor(COLORS.cardBg)
        .backgroundColor(colTagColor(item.rank))
        .borderRadius(8)
        .padding({ left: 7, right: 7, top: 2, bottom: 2 })
        .margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.End)
    .margin({ right: 8 })
    Text('删')
      .fontSize(12)
      .fontColor(COLORS.cardBg)
      .backgroundColor(COLORS.danger)
      .borderRadius(10)
      .padding({ left: 10, right: 10, top: 5, bottom: 5 })
      .onClick(() => {
        this.selCollect = item;
        this.showDel = true;
      })
  }
  .width('100%')
  .padding(12)
  .backgroundColor(COLORS.cardBg)
  .borderRadius(14)
  .margin({ bottom: 8 })
}

collectRow收藏行采用三列布局——左侧弹性信息区显示藏品名称15号粗体text1深褐黑色"宋代陶瓷棋子"+年代产地11号text2中褐色"宋 · 汝窑",右侧估值区显示估值14号粗体win朱红色"¥26000"+等级标签10号cardBg白字底色通过colTagColor函数动态着色(国宝→朱红、名品→金色、珍玩→木色),最右侧是"删"删除按钮12号cardBg白字danger红色底圆角10。估值用win朱红色强调,因为估值是收藏品最核心的价值指标。等级标签通过colTagColor函数分级着色,与棋手段位着色体系形成呼应。点击"删"按钮将item赋值给selCollect并触发delModal删除确认弹窗。


十、弹窗交互系统

10.1 弹窗系统架构

数据操作

状态控制

弹窗组件

触发入口

棋盘页 编 点击

棋手页 改 点击

收藏页 删 点击

editModal 编辑段位
棋手段位/战绩

editModal 编辑段位
棋盘编辑

delModal 删除收藏
确认删除

addModal 新增对局
表单录入

showEdit 编辑弹窗

showDel 删除弹窗

showAdd 新增弹窗

selPlayer 选中棋手

selCollect 选中收藏

selPlayer.rank = 九段

selPlayer.wins/games +1

collections.splice 删除

games.splice 新增对局

应用通过三个@Builder弹窗方法实现了完整的增删改查交互闭环。三个弹窗都遵循统一的架构模式——Stack层叠modalOverlay遮罩层和弹窗主体Column,通过if条件渲染控制显隐,整体zIndex(999)覆盖主内容。modalOverlay遮罩层全屏覆盖rgba(0,0,0,0.45)半透明黑色,点击遮罩关闭弹窗。弹窗主体宽度88%、padding 18、cardBg白底圆角18、constraintSize maxHeight 80%防止内容超出屏幕。

10.2 modalOverlay遮罩层

@Builder
modalOverlay(onClose: () => void) {
  Column()
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.mask)
    .onClick(() => {
      onClose();
    })
}

modalOverlay是一个通用的遮罩层@Builder方法,接收onClose回调函数。全屏Column覆盖rgba(0,0,0,0.45)半透明黑色背景,点击任意区域触发onClose回调关闭弹窗。这种"遮罩层+回调函数"的设计模式实现了遮罩层的复用——三个弹窗都调用this.modalOverlay(() => { this.showXxx = false; })来渲染遮罩层,避免了重复代码。

10.3 addModal新增对局弹窗

@Builder
addModal() {
  if (this.showAdd) {
    Stack() {
      this.modalOverlay(() => {
        this.showAdd = false;
      })
      Column() {
        Text('新增对局')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text1)
        Text('录入一局新谱')
          .fontSize(11)
          .fontColor(COLORS.text3)
          .margin({ top: 3 })
        Column() {
          Text('对局名称')
            .fontSize(12)
            .fontColor(COLORS.text2)
          TextInput({ text: this.formTitle, placeholder: '如:弈趣杯决赛' })
            .height(38)
            .fontSize(13)
            .margin({ top: 5 })
            .onChange((v: string) => {
              this.formTitle = v;
            })
        }
        .alignItems(HorizontalAlign.Start)
        .width('100%')
        .margin({ top: 14 })
        // ... 黑方棋手/白方棋手/手数 三个TextInput
        Row() {
          Text('取消')
            .fontSize(14)
            .fontColor(COLORS.text2)
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
            .padding({ top: 10, bottom: 10 })
            .backgroundColor('#EFE9DD')
            .borderRadius(12)
            .onClick(() => {
              this.showAdd = false;
            })
          Text('保存')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.cardBg)
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
            .padding({ top: 10, bottom: 10 })
            .backgroundColor(COLORS.accent)
            .borderRadius(12)
            .margin({ left: 12 })
            .onClick(() => {
              this.games.splice(0, 0, new GoGameItem(this.formTitle, this.formP1 + ' 对 ' + this.formP2, 261, '新局', '2026'));
              this.showAdd = false;
            })
        }
        .width('100%')
        .margin({ top: 18 })
      }
      .width('88%')
      .padding(18)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .constraintSize({ maxHeight: '80%' })
    }
    .width('100%')
    .height('100%')
    .position({ x: 0, y: 0 })
    .zIndex(999)
  }
}

addModal新增对局弹窗包含四个TextInput表单字段——对局名称(placeholder"如:弈趣杯决赛")、黑方棋手(placeholder"黑方姓名")、白方棋手(placeholder"白方姓名")、手数(placeholder"如:261"),分别绑定formTitle/formP1/formP2/formMoves四个@State变量。底部双按钮——“取消"用#EFE9DD浅米色底text2中褐色字、“保存"用accent木色底cardBg白字粗体。点击"保存"通过this.games.splice(0, 0, new GoGameItem(...))将新对局插入数组头部(splice参数0,0表示在索引0处插入不删除任何元素),新对局的players字段拼接formP1+” 对 “+formP2,手数默认261,结果默认"新局”,年代默认"2026”。这种"表单录入→splice插入→自动刷新"的交互闭环是ArkUI响应式数据驱动的典型实践。

10.4 editModal编辑段位弹窗

@Builder
editModal() {
  if (this.showEdit) {
    Stack() {
      this.modalOverlay(() => {
        this.showEdit = false;
      })
      Column() {
        Text('编辑段位')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text1)
        Text('调整棋手段位与战绩')
          .fontSize(11)
          .fontColor(COLORS.text3)
          .margin({ top: 3 })
        Column() {
          Text('棋手')
            .fontSize(12)
            .fontColor(COLORS.text2)
          Text(this.selPlayer === null ? '—' : this.selPlayer.name)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.text1)
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        .width('100%')
        .margin({ top: 14 })
        Column() {
          Text('当前段位')
            .fontSize(12)
            .fontColor(COLORS.text2)
          Text(this.selPlayer === null ? '—' : this.selPlayer.rank)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(rankColor(this.selPlayer === null ? '四段' : this.selPlayer.rank))
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        .width('100%')
        .margin({ top: 12 })
        Row() {
          Text('升一段')
            .fontSize(13)
            .fontColor(COLORS.cardBg)
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
            .padding({ top: 9, bottom: 9 })
            .backgroundColor(COLORS.win)
            .borderRadius(10)
            .onClick(() => {
              if (this.selPlayer !== null) {
                this.selPlayer.rank = '九段';
              }
              this.showEdit = false;
            })
          Text('记一胜')
            .fontSize(13)
            .fontColor(COLORS.cardBg)
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
            .padding({ top: 9, bottom: 9 })
            .backgroundColor(COLORS.gold)
            .borderRadius(10)
            .margin({ left: 10 })
            .onClick(() => {
              if (this.selPlayer !== null) {
                this.selPlayer.wins = this.selPlayer.wins + 1;
                this.selPlayer.games = this.selPlayer.games + 1;
              }
              this.showEdit = false;
            })
        }
        .width('100%')
        .margin({ top: 18 })
        Text('关闭')
          .fontSize(14)
          .fontColor(COLORS.text2)
          .textAlign(TextAlign.Center)
          .width('100%')
          .padding({ top: 10, bottom: 10 })
          .backgroundColor('#EFE9DD')
          .borderRadius(12)
          .margin({ top: 12 })
          .onClick(() => {
            this.showEdit = false;
          })
      }
      .width('88%')
      .padding(18)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .constraintSize({ maxHeight: '80%' })
    }
    .width('100%')
    .height('100%')
    .position({ x: 0, y: 0 })
    .zIndex(999)
  }
}

editModal编辑段位弹窗是本应用交互设计的核心亮点。弹窗显示选中棋手的姓名和当前段位(通过selPlayer.name和selPlayer.rank读取,null安全检查用三元运算符this.selPlayer === null ? '—' : this.selPlayer.name),段位文字通过rankColor函数动态着色。底部双操作按钮——"升一段"用win朱红色底cardBg白字(点击后this.selPlayer.rank = '九段'直接将段位设为最高九段)+"记一胜"用gold金色底cardBg白字(点击后this.selPlayer.wins和this.selPlayer.games各加1记录一场胜利)。由于selPlayer是@State声明的@Observed对象引用,修改其属性后UI自动响应式刷新——棋手行的段位着色和胜率横条会立即更新,体现了@Observed+@State的响应式数据驱动能力。最下方还有"关闭"按钮用#EFE9DD浅米色底关闭弹窗。

10.5 delModal删除收藏弹窗

@Builder
delModal() {
  if (this.showDel) {
    Stack() {
      this.modalOverlay(() => {
        this.showDel = false;
      })
      Column() {
        Text('删除收藏')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text1)
        Text('此操作不可恢复,请确认')
          .fontSize(12)
          .fontColor(COLORS.text3)
          .margin({ top: 6 })
        Column() {
          Text('将删除:')
            .fontSize(12)
            .fontColor(COLORS.text2)
          Text(this.selCollect === null ? '—' : this.selCollect.name)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.danger)
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        .width('100%')
        .padding(12)
        .backgroundColor('#FAEDEC')
        .borderRadius(12)
        .margin({ top: 14 })
        Row() {
          Text('再想想')
            .fontSize(14)
            .fontColor(COLORS.text2)
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
            .padding({ top: 10, bottom: 10 })
            .backgroundColor('#EFE9DD')
            .borderRadius(12)
            .onClick(() => {
              this.showDel = false;
            })
          Text('确认删除')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.cardBg)
            .textAlign(TextAlign.Center)
            .layoutWeight(1)
            .padding({ top: 10, bottom: 10 })
            .backgroundColor(COLORS.danger)
            .borderRadius(12)
            .margin({ left: 12 })
            .onClick(() => {
              if (this.selCollect !== null) {
                this.collections.splice(this.collections.indexOf(this.selCollect), 1);
              }
              this.showDel = false;
            })
        }
        .width('100%')
        .margin({ top: 18 })
      }
      .width('88%')
      .padding(18)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(18)
      .constraintSize({ maxHeight: '80%' })
    }
    .width('100%')
    .height('100%')
    .position({ x: 0, y: 0 })
    .zIndex(999)
  }
}

delModal删除收藏弹窗是本应用的安全交互设计。弹窗显示"此操作不可恢复,请确认"的警告提示,待删除藏品名称用danger红色#D9534F强调显示,背景用#FAEDEC浅红色底突出警告区域。底部双按钮——"再想想"用#EFE9DD浅米色底text2中褐色字(取消删除)、"确认删除"用danger红色底cardBg白字粗体(确认删除)。点击"确认删除"通过this.collections.splice(this.collections.indexOf(this.selCollect), 1)从数组中移除选中藏品——先用indexOf找到选中藏品在数组中的索引,再用splice删除该索引处的一项。这种"先选中→再确认→后删除"的三步交互流程避免了误删风险,是移动端删除操作的最佳实践。


十一、图表可视化:胜率横条与热度横条

11.1 胜率横条可视化

棋手页的胜率横条是本应用最核心的数据可视化组件,通过winRate计算+winBarW映射+opacity呼吸动画三层处理实现:

Row() {
  Column()
    .width(winBarW(winRate(item)))
    .height(8)
    .borderRadius(4)
    .backgroundColor(COLORS.win)
    .opacity(this.breath ? 1 : 0.65)
    .animation({ duration: 550, iterations: -1, playMode: PlayMode.Alternate })
  Column().layoutWeight(1)
  Text(item.wins + '胜 / ' + item.games + '局')
    .fontSize(10)
    .fontColor(COLORS.text3)
}

胜率横条的渲染流程是——首先winRate(item)计算棋手胜率(如范西屏68胜/76局=89%),然后winBarW(89)将胜率映射为像素宽度(16+891.3=131.7px),最后设置Column的width为该像素值、height 8px、borderRadius 4px圆角、backgroundColor win朱红色。横条右侧用Column().layoutWeight(1)撑开剩余空间,最右侧显示战绩文字"68胜 / 76局"。opacity呼吸动画(0.65↔1)让横条产生明暗交替的动态效果,配合550ms Alternate播放模式,形成"胜率脉动"的视觉感受。10位棋手的胜率横条宽度从梁魏今42%(16+421.3=70.6px)到范西屏89%(131.7px)不等,横条长度直观反映了棋手实力的强弱。

11.2 热度横条可视化

流派页的门人热度横条通过hotBarW函数映射热门度为像素宽度:

Row() {
  Text('门人热度')
    .fontSize(10)
    .fontColor(COLORS.text2)
  Column()
    .width(hotBarW(item.hot))
    .height(8)
    .borderRadius(4)
    .backgroundColor(COLORS.gold)
    .margin({ left: 8 })
  Text(item.members + ' 人')
    .fontSize(10)
    .fontColor(COLORS.text3)
    .margin({ left: 8 })
  Column().layoutWeight(1)
  Text('热门度 ' + item.hot)
    .fontSize(10)
    .fontWeight(FontWeight.Medium)
    .fontColor(COLORS.win)
}

热度横条采用四段式布局——"门人热度"标签10号text2中褐色+gold金色横条(hotBarW计算宽度)+门人数"42 人"10号text3浅褐灰色+layoutWeight撑开+"热门度 96"10号粗体win朱红色。与胜率横条不同,热度横条没有呼吸动画(静态横条),但通过gold金色横条在白底卡片上的高对比度,依然具有强烈的视觉冲击力。8大流派的热门度从弈乐园派70(18+701.35=112.5px)到海宁范施96(18+961.35=147.6px),横条长度直观反映了流派的受欢迎程度。

11.3 赛事奖金柱可视化

赛事页的奖金柱采用固定高度+呼吸动画的设计:

Row() {
  Text('奖金 ¥' + item.prize)
    .fontSize(11)
    .fontColor(COLORS.text2)
  Column()
    .height(8)
    .borderRadius(4)
    .backgroundColor(COLORS.win)
    .margin({ left: 8 })
    .opacity(this.breath ? 1 : 0.6)
    .animation({ duration: 550, iterations: -1, playMode: PlayMode.Alternate })
  Column().layoutWeight(1)
  Text(item.sponsor)
    .fontSize(10)
    .fontColor(COLORS.text3)
}

赛事奖金柱与胜率横条不同——它没有设置width属性(默认宽度由内容决定),而是固定height 8px+win朱红色+opacity呼吸动画(0.6↔1)。这种设计使得奖金柱成为一个纯粹的"呼吸指示器"——不反映奖金数值大小,只通过明暗交替的动画提示用户关注奖金信息。虽然prizeBarH函数已经定义了奖金到高度的映射逻辑(24+prize/10000),但在当前实现中未使用,预留了未来扩展为完整奖金柱状图的空间。

赛事页 奖金柱

预留扩展

固定8px高度

opacity 呼吸动画
0.6↔1

win 朱红色柱

prizeBarH 预留未用

流派页 热度横条

hotBarW 映射宽度
18+hot×1.35

静态横条 无动画

gold 金色横条

棋手页 胜率横条

winRate 计算
wins×100÷games

winBarW 映射宽度
16+rate×1.3

opacity 呼吸动画
0.65↔1

win 朱红色横条

11.4 tabContent八Tab条件渲染

tabContent是八Tab内容切换的核心@Builder方法,通过if-else if条件渲染根据curTab值显示不同的列表:

@Builder
tabContent() {
  if (this.curTab === 0) {
    Column() {
      this.sectionTitle('🪵', '名贵棋盘', '共 ' + this.boards.length + ' 款')
      ForEach(this.boards, (item: GoBoardItem, i: number) => {
        this.boardRow(item, i)
      }, (item: GoBoardItem) => item.name)
    }
    .width('100%')
  } else if (this.curTab === 1) {
    Column() {
      this.sectionTitle('⚫', '围棋棋子', '云子 · 蛤碁石')
      ForEach(this.stones, (item: GoStoneItem, i: number) => {
        this.stoneRow(item, i)
      }, (item: GoStoneItem) => item.name)
    }
    .width('100%')
  } else if (this.curTab === 2) {
    // ... 谱局
  } else if (this.curTab === 3) {
    // ... 棋手
  } else if (this.curTab === 4) {
    // ... 流派
  } else if (this.curTab === 5) {
    // ... 棋院
  } else if (this.curTab === 6) {
    // ... 赛事
  } else {
    Column() {
      this.sectionTitle('📦', '典藏弈具', '传世之宝')
      ForEach(this.collections, (item: GoCollectionItem, i: number) => {
        this.collectRow(item, i)
      }, (item: GoCollectionItem) => item.name)
    }
    .width('100%')
  }
}

tabContent通过8个if-else if分支对应8个Tab页面,每个分支先调用sectionTitle渲染标题行,再通过ForEach遍历对应数据数组渲染行项。ForEach的第三个参数是key生成器——用item.name作为唯一key(棋盘用name、棋子用name、谱局用title、棋手用name等),确保列表项的正确复用和差分更新。点击底部Tab切换curTab后,tabContent立即重新渲染显示对应Tab的内容,是ArkUI条件渲染的典型实践。

11.5 build主入口方法

build() {
  Stack() {
    Column() {
      Scroll() {
        Column() {
          this.pageHeader()
          this.tabContent()
        }
        .width('100%')
        .padding({ left: 14, right: 14, bottom: 12 })
      }
      .scrollable(ScrollDirection.Vertical)
      .layoutWeight(1)
      .backgroundColor(COLORS.bg)
      this.bottomBar()
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
    if (this.showAdd) {
      this.addModal()
    }
    if (this.showEdit) {
      this.editModal()
    }
    if (this.showDel) {
      this.delModal()
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor(COLORS.bg)
}

build方法是整个应用的渲染入口——最外层Stack作为根容器,内层Column纵向布局包含Scroll滚动区(pageHeader头部+tabContent内容)和bottomBar底部导航。Scroll用scrollable(ScrollDirection.Vertical)实现垂直滚动、layoutWeight(1)填充剩余空间。三个弹窗通过if条件渲染叠加在Stack最上层——showAdd/showEdit/showDel任一为true时对应弹窗渲染显示,zIndex默认叠加顺序确保弹窗覆盖在主内容之上。这种"Stack根容器+条件渲染弹窗"的架构是ArkUI弹窗管理的经典模式。


十二、完整源代码

以下是围棋坊应用的完整源代码,涵盖了色彩接口定义、常量配置、Tab元数据、星位索引、8个@Observed数据类、9个工具函数、入口组件GoPage及全部@Builder方法(pageHeader/sectionTitle/boardRow/stoneRow/gameRow/playerRow/schoolRow/academyRow/tourneyRow/collectRow/tabContent/bottomItem/bottomBar/modalOverlay/addModal/editModal/delModal)的实现:

interface ColorPalette {
  bg: string;
  cardBg: string;
  header1: string;
  header2: string;
  wood: string;
  line: string;
  star: string;
  black: string;
  white: string;
  gold: string;
  title: string;
  sub: string;
  text1: string;
  text2: string;
  text3: string;
  accent: string;
  win: string;
  lose: string;
  danger: string;
  tabBg: string;
  tabOn: string;
  mask: string;
}

const COLORS: ColorPalette = {
  bg: '#F3EFE6',
  cardBg: '#FFFFFF',
  header1: '#2B2118',
  header2: '#171310',
  wood: '#8B5E3C',
  line: '#C9A86A',
  star: '#E8D5A8',
  black: '#1A1A1A',
  white: '#F7F5F0',
  gold: '#D9A441',
  title: '#F5EAD0',
  sub: '#CBB98F',
  text1: '#2B2118',
  text2: '#6B5E4E',
  text3: '#9C8F7A',
  accent: '#8B5E3C',
  win: '#C0392B',
  lose: '#2E7D32',
  danger: '#D9534F',
  tabBg: '#2B2118',
  tabOn: '#D9A441',
  mask: 'rgba(0,0,0,0.45)'
};

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

const TAB_LIST: TabMeta[] = [
  { label: '棋盘', icon: '🪵' },
  { label: '棋子', icon: '⚫' },
  { label: '谱局', icon: '📜' },
  { label: '棋手', icon: '🏆' },
  { label: '流派', icon: '🧩' },
  { label: '棋院', icon: '🏛️' },
  { label: '赛事', icon: '🎖️' },
  { label: '收藏', icon: '📦' }
];

const ROW1_IDX: number[] = [0, 1, 2, 3];
const ROW2_IDX: number[] = [4, 5, 6, 7];
const STAR_IDX: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24];

@Observed
export class GoBoardItem {
  name: string;
  grid: string;
  star: number;
  wood: string;
  price: number;

  constructor(name: string, grid: string, star: number, wood: string, price: number) {
    this.name = name;
    this.grid = grid;
    this.star = star;
    this.wood = wood;
    this.price = price;
  }
}

@Observed
export class GoStoneItem {
  name: string;
  material: string;
  color: string;
  weight: number;
  price: number;

  constructor(name: string, material: string, color: string, weight: number, price: number) {
    this.name = name;
    this.material = material;
    this.color = color;
    this.weight = weight;
    this.price = price;
  }
}

@Observed
export class GoGameItem {
  title: string;
  players: string;
  moves: number;
  result: string;
  year: string;

  constructor(title: string, players: string, moves: number, result: string, year: string) {
    this.title = title;
    this.players = players;
    this.moves = moves;
    this.result = result;
    this.year = year;
  }
}

@Observed
export class GoPlayerItem {
  name: string;
  rank: string;
  wins: number;
  games: number;
  style: string;

  constructor(name: string, rank: string, wins: number, games: number, style: string) {
    this.name = name;
    this.rank = rank;
    this.wins = wins;
    this.games = games;
    this.style = style;
  }
}

@Observed
export class GoSchoolItem {
  name: string;
  style: string;
  members: number;
  age: number;
  hot: number;

  constructor(name: string, style: string, members: number, age: number, hot: number) {
    this.name = name;
    this.style = style;
    this.members = members;
    this.age = age;
    this.hot = hot;
  }
}

@Observed
export class GoAcademyItem {
  name: string;
  city: string;
  masters: number;
  students: number;
  level: string;

  constructor(name: string, city: string, masters: number, students: number, level: string) {
    this.name = name;
    this.city = city;
    this.masters = masters;
    this.students = students;
    this.level = level;
  }
}

@Observed
export class GoTourneyItem {
  name: string;
  prize: number;
  level: string;
  sponsor: string;
  date: string;

  constructor(name: string, prize: number, level: string, sponsor: string, date: string) {
    this.name = name;
    this.prize = prize;
    this.level = level;
    this.sponsor = sponsor;
    this.date = date;
  }
}

@Observed
export class GoCollectionItem {
  name: string;
  age: string;
  origin: string;
  value: number;
  rank: string;

  constructor(name: string, age: string, origin: string, value: number, rank: string) {
    this.name = name;
    this.age = age;
    this.origin = origin;
    this.value = value;
    this.rank = rank;
  }
}

function starPosX(i: number): number {
  return 22 + (i % 5) * 20;
}

function starPosY(i: number): number {
  return 14 + Math.floor(i / 5) * 20;
}

function winRate(p: GoPlayerItem): number {
  if (p.games <= 0) {
    return 0;
  }
  return Math.round(p.wins * 100 / p.games);
}

function winBarW(rate: number): number {
  return 16 + Math.min(rate, 100) * 1.3;
}

function prizeBarH(prize: number): number {
  return 24 + Math.min(prize, 1000000) / 10000;
}

function rankColor(rank: string): string {
  if (rank.indexOf('九段') >= 0 || rank.indexOf('八段') >= 0) {
    return COLORS.win;
  }
  if (rank.indexOf('七段') >= 0 || rank.indexOf('六段') >= 0) {
    return COLORS.gold;
  }
  if (rank.indexOf('五段') >= 0) {
    return COLORS.accent;
  }
  return COLORS.lose;
}

function hotBarW(hot: number): number {
  return 18 + Math.min(hot, 100) * 1.35;
}

function stoneWeightColor(w: number): string {
  if (w >= 30) {
    return COLORS.gold;
  }
  if (w >= 22) {
    return COLORS.accent;
  }
  return COLORS.lose;
}

function colTagColor(rank: string): string {
  if (rank.indexOf('国宝') >= 0) {
    return COLORS.win;
  }
  if (rank.indexOf('名品') >= 0) {
    return COLORS.gold;
  }
  return COLORS.accent;
}

@Entry
@Component
struct GoPage {
  @State curTab: number = 0;
  @State breath: boolean = false;
  @State showAdd: boolean = false;
  @State showEdit: boolean = false;
  @State showDel: boolean = false;
  @State selPlayer: GoPlayerItem | null = null;
  @State selCollect: GoCollectionItem | null = null;
  @State formTitle: string = '';
  @State formP1: string = '';
  @State formP2: string = '';
  @State formMoves: string = '';
  @State boards: GoBoardItem[] = [
    new GoBoardItem('榧木连棋盘', '19路 · 十一路', 9, '日本榧木', 12800),
    new GoBoardItem('独木古棋盘', '19路 · 五路', 9, '百年银杏', 8800),
    new GoBoardItem('拼木棋枰', '19路 · 三路', 9, '楸木', 3600),
    new GoBoardItem('檀木双面棋桌', '19路 · 九路', 9, '紫檀', 25600),
    new GoBoardItem('新榧旅行盘', '13路 · 五路', 5, '新榧木', 1280),
    new GoBoardItem('竹制折叠盘', '9路 · 三路', 5, '楠竹', 320),
    new GoBoardItem('枣木练习盘', '19路 · 三路', 9, '枣木', 680),
    new GoBoardItem('石面棋墩', '19路 · 九路', 9, '青石', 5200),
    new GoBoardItem('胡桃木棋盒盘', '19路 · 七路', 9, '胡桃木', 2600),
    new GoBoardItem('乌木描金盘', '19路 · 九路', 9, '乌木', 18800),
    new GoBoardItem('儿童磁性棋盘', '13路 · 三路', 5, '复合材料', 168),
    new GoBoardItem('金丝楠棋盘', '19路 · 七路', 9, '金丝楠', 9600)
  ];
  @State stones: GoStoneItem[] = [
    new GoStoneItem('云子 · 永昌', '玉石', '乳白', 32, 680),
    new GoStoneItem('云子 · 新老', '玉石', '乳白', 28, 420),
    new GoStoneItem('云子 · 半永', '玉石', '微黄', 26, 240),
    new GoStoneItem('蛤碁石 · 雪印', '贝壳', '雪白', 35, 18800),
    new GoStoneItem('蛤碁石 · 实用', '贝壳', '奶白', 32, 4600),
    new GoStoneItem('玉石棋子', '和田玉', '青白', 30, 9200),
    new GoStoneItem('陶瓷棋子', '高岭土', '哑白', 22, 180),
    new GoStoneItem('玛瑙棋子', '玛瑙', '灰白', 34, 12800)
  ];
  @State games: GoGameItem[] = [
    new GoGameItem('当湖十局 · 第一局', '范西屏 对 施襄夏', 263, '白胜二又三子', '清乾隆'),
    new GoGameItem('耳赤之局', '本因坊秀策 对 井上幻庵', 161, '黑胜', '江户'),
    new GoGameItem('血泪篇 · 第三局', '黄龙士 对 徐星友', 322, '黑胜', '清康熙'),
    new GoGameItem('吴清源 · 十番棋之一', '吴清源 对 木谷实', 251, '黑胜四目', '昭和'),
    new GoGameItem('仙机武库残局', '程兰如 自拟', 128, '黑胜', '清雍正'),
    new GoGameItem('范施十局 · 第七局', '范西屏 对 施襄夏', 276, '和棋', '清乾隆'),
    new GoGameItem('烂柯山樵隐局', '烂柯谱 佚名', 178, '白胜', '晋'),
    new GoGameItem('弈乐园之会', '过百龄 对 方子藩', 244, '黑胜半子', '明崇祯')
  ];
  @State players: GoPlayerItem[] = [
    new GoPlayerItem('范西屏', '九段', 68, 76, '海宁'),
    new GoPlayerItem('施襄夏', '九段', 64, 74, '海宁'),
    new GoPlayerItem('吴清源', '九段', 82, 96, '福州'),
    new GoPlayerItem('黄龙士', '九段', 71, 82, '泰州'),
    new GoPlayerItem('徐星友', '八段', 55, 70, '杭州'),
    new GoPlayerItem('过百龄', '七段', 48, 66, '无锡'),
    new GoPlayerItem('周懒予', '六段', 41, 60, '嘉兴'),
    new GoPlayerItem('程兰如', '五段', 33, 58, '歙县'),
    new GoPlayerItem('汪汉年', '四段', 27, 55, '休宁'),
    new GoPlayerItem('梁魏今', '三段', 22, 52, '淮安')
  ];
  @State schools: GoSchoolItem[] = [
    new GoSchoolItem('海宁范施', '力战刚猛', 42, 280, 96),
    new GoSchoolItem('本因坊门', '严谨务实', 120, 450, 92),
    new GoSchoolItem('黄龙士流', '天元布局', 35, 340, 88),
    new GoSchoolItem('过百龄派', '先手效率', 28, 390, 82),
    new GoSchoolItem('道策流', '行云流水', 66, 320, 90),
    new GoSchoolItem('丈和流', '搏杀凌厉', 50, 240, 78),
    new GoSchoolItem('秀策流', '一子两用', 95, 260, 94),
    new GoSchoolItem('弈乐园派', '温厚绵密', 22, 210, 70)
  ];
  @State academies: GoAcademyItem[] = [
    new GoAcademyItem('棋圣道场', '北京', 18, 320, '甲级'),
    new GoAcademyItem('聂卫平围棋道场', '北京', 26, 480, '甲级'),
    new GoAcademyItem('葛玉宏道场', '杭州', 14, 260, '甲级'),
    new GoAcademyItem('衢州烂柯棋院', '衢州', 9, 180, '乙级'),
    new GoAcademyItem('西安雁塔棋院', '西安', 6, 120, '乙级'),
    new GoAcademyItem('成都棋院', '成都', 12, 240, '甲级'),
    new GoAcademyItem('海宁弈学馆', '海宁', 5, 90, '丙级'),
    new GoAcademyItem('苏州棋院', '苏州', 8, 150, '乙级')
  ];
  @State tourneys: GoTourneyItem[] = [
    new GoTourneyItem('应氏杯世界赛', 400000, '世界', '应昌期基金会', '2026-09'),
    new GoTourneyItem('春兰杯', 150000, '世界', '春兰集团', '2026-11'),
    new GoTourneyItem('烂柯杯', 180000, '世界', '衢州政府', '2026-08'),
    new GoTourneyItem('名人战', 100000, '国内', '人民日报社', '2026-10'),
    new GoTourneyItem('天元战', 80000, '国内', '新民晚报社', '2026-07'),
    new GoTourneyItem('倡棋杯', 60000, '国内', '应氏集团', '2026-12'),
    new GoTourneyItem('新人王战', 20000, '国内', '中国棋院', '2026-06'),
    new GoTourneyItem('晚报杯业余赛', 10000, '业余', '全国晚报协会', '2026-05')
  ];
  @State collections: GoCollectionItem[] = [
    new GoCollectionItem('宋代陶瓷棋子', '宋', '汝窑', 26000, '国宝'),
    new GoCollectionItem('明代榧木棋墩', '明', '徽州', 42000, '国宝'),
    new GoCollectionItem('清代玛瑙棋盒', '清', '苏州', 18000, '名品'),
    new GoCollectionItem('宣和御制棋谱', '宋', '东京', 15000, '名品'),
    new GoCollectionItem('元青花对弈罐', '元', '景德镇', 88000, '国宝'),
    new GoCollectionItem('敦煌弈棋壁画拓片', '唐', '敦煌', 9800, '名品'),
    new GoCollectionItem('战国陶质围棋', '战国', '邯郸', 5200, '珍玩'),
    new GoCollectionItem('朝鲜角制棋盘', '朝鲜', '汉城', 7600, '珍玩'),
    new GoCollectionItem('扬州漆器棋桌', '清', '扬州', 34000, '名品'),
    new GoCollectionItem('流徽榭棋匾', '清', '苏州', 6800, '珍玩')
  ];

  aboutToAppear(): void {
    setInterval(() => {
      this.breath = !this.breath;
    }, 550);
  }

  @Builder
  modalOverlay(onClose: () => void) {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor(COLORS.mask)
      .onClick(() => {
        onClose();
      })
  }

  @Builder
  pageHeader() {
    Column() {
      Stack() {
        Column()
          .width('100%')
          .height('100%')
          .borderRadius(22)
          .linearGradient({
            angle: 135,
            colors: [[COLORS.header1, 0], [COLORS.header2, 1]]
          })
        Column()
          .width(118)
          .height(108)
          .borderRadius(10)
          .backgroundColor('#241B13')
          .border({ width: 2, color: COLORS.line })
          .position({ x: 16, y: 14 })
        ForEach(STAR_IDX, (i: number) => {
          Column()
            .width(5)
            .height(5)
            .borderRadius(2.5)
            .backgroundColor(COLORS.star)
            .position({ x: starPosX(i), y: starPosY(i) })
        }, (i: number) => 's' + i)
        Column()
          .width(12)
          .height(12)
          .borderRadius(6)
          .backgroundColor(COLORS.gold)
          .scale({ x: this.breath ? 1.35 : 1, y: this.breath ? 1.35 : 1 })
          .opacity(this.breath ? 1 : 0.5)
          .animation({ duration: 550, iterations: -1, playMode: PlayMode.Alternate })
          .position({ x: 65, y: 60 })
        Column()
          .width(34)
          .height(34)
          .borderRadius(17)
          .backgroundColor(COLORS.black)
          .border({ width: 2, color: COLORS.white })
          .position({ x: 30, y: 78 })
        Column()
          .width(34)
          .height(34)
          .borderRadius(17)
          .backgroundColor(COLORS.white)
          .border({ width: 2, color: COLORS.black })
          .scale({ x: this.breath ? 1.12 : 1, y: this.breath ? 1.12 : 1 })
          .animation({ duration: 550, iterations: -1, playMode: PlayMode.Alternate })
          .position({ x: 102, y: 12 })
        Column() {
          Text('围棋坊')
            .fontSize(24)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.title)
          Text('黑白对弈 · 坐隐方圆')
            .fontSize(12)
            .fontColor(COLORS.sub)
            .margin({ top: 6 })
        }
        .alignItems(HorizontalAlign.Start)
        .position({ x: 158, y: 34 })
        Row() {
          Text('19路棋盘')
            .fontSize(11)
            .fontColor(COLORS.gold)
          Text('9星天元')
            .fontSize(11)
            .fontColor(COLORS.sub)
            .margin({ left: 10 })
          Text('361子')
            .fontSize(11)
            .fontColor(COLORS.sub)
            .margin({ left: 10 })
        }
        .position({ x: 158, y: 84 })
      }
      .width('100%')
      .height(136)
      Row() {
        Column() {
          Text('12')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.gold)
          Text('棋盘款式')
            .fontSize(10)
            .fontColor(COLORS.sub)
        }
        .layoutWeight(1)
        Column() {
          Text('8')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.gold)
          Text('经典谱局')
            .fontSize(10)
            .fontColor(COLORS.sub)
        }
        .layoutWeight(1)
        Column() {
          Text('10')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.gold)
          Text('古今棋手')
            .fontSize(10)
            .fontColor(COLORS.sub)
        }
        .layoutWeight(1)
        Column() {
          Text('8')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.gold)
          Text('顶级赛事')
            .fontSize(10)
            .fontColor(COLORS.sub)
        }
        .layoutWeight(1)
      }
      .width('100%')
      .padding({ top: 12, bottom: 12, left: 8, right: 8 })
      .backgroundColor(COLORS.cardBg)
      .borderRadius({ topLeft: 16, topRight: 16, bottomLeft: 0, bottomRight: 0 })
      .margin({ top: 10 })
    }
    .width('100%')
  }

  @Builder
  sectionTitle(icon: string, title: string, sub: string) {
    Row() {
      Text(icon)
        .fontSize(15)
      Text(title)
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
        .margin({ left: 6 })
      Column().layoutWeight(1)
      Text(sub)
        .fontSize(11)
        .fontColor(COLORS.text3)
    }
    .width('100%')
    .padding({ left: 4, right: 4, bottom: 10 })
  }

  @Builder
  boardRow(item: GoBoardItem, i: number) {
    Row() {
      Column() {
        Text(item.name)
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .fontColor(COLORS.text1)
        Text(item.grid + ' · ' + item.wood)
          .fontSize(11)
          .fontColor(COLORS.text2)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      Column() {
        Text('¥' + item.price)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.accent)
        Text(item.star + ' 星位')
          .fontSize(10)
          .fontColor(COLORS.gold)
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.End)
      .margin({ right: 10 })
      Text('编')
        .fontSize(12)
        .fontColor(COLORS.cardBg)
        .backgroundColor(COLORS.wood)
        .borderRadius(10)
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .onClick(() => {
          this.showEdit = true;
        })
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(14)
    .margin({ bottom: 8 })
  }

  @Builder
  schoolRow(item: GoSchoolItem, i: number) {
    Column() {
      Row() {
        Text(item.name)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text1)
        Text(item.style)
          .fontSize(10)
          .fontColor(COLORS.cardBg)
          .backgroundColor(COLORS.accent)
          .borderRadius(8)
          .padding({ left: 7, right: 7, top: 2, bottom: 2 })
          .margin({ left: 8 })
        Column().layoutWeight(1)
        Text(item.age + ' 年')
          .fontSize(11)
          .fontColor(COLORS.text3)
      }
      .width('100%')
      Row() {
        Text('门人热度')
          .fontSize(10)
          .fontColor(COLORS.text2)
        Column()
          .width(hotBarW(item.hot))
          .height(8)
          .borderRadius(4)
          .backgroundColor(COLORS.gold)
          .margin({ left: 8 })
        Text(item.members + ' 人')
          .fontSize(10)
          .fontColor(COLORS.text3)
          .margin({ left: 8 })
        Column().layoutWeight(1)
        Text('热门度 ' + item.hot)
          .fontSize(10)
          .fontWeight(FontWeight.Medium)
          .fontColor(COLORS.win)
      }
      .width('100%')
      .margin({ top: 8 })
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(14)
    .margin({ bottom: 8 })
  }

  @Builder
  academyRow(item: GoAcademyItem, i: number) {
    Row() {
      Column() {
        Text(item.name)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text1)
        Text(item.city + ' · ' + item.level)
          .fontSize(11)
          .fontColor(COLORS.text2)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      Column() {
        Text(item.masters + ' 大师')
          .fontSize(12)
          .fontWeight(FontWeight.Medium)
          .fontColor(COLORS.accent)
        Text(item.students + ' 学员')
          .fontSize(11)
          .fontColor(COLORS.text3)
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.End)
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(14)
    .margin({ bottom: 8 })
  }

  @Builder
  playerRow(item: GoPlayerItem, i: number) {
    Column() {
      Row() {
        Column() {
          Text(item.name)
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.text1)
          Text(item.style + ' · ' + item.rank)
            .fontSize(11)
            .fontColor(rankColor(item.rank))
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Column() {
          Text(winRate(item) + '%')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.win)
          Text('胜率')
            .fontSize(10)
            .fontColor(COLORS.text3)
        }
        .alignItems(HorizontalAlign.End)
        .margin({ right: 12 })
        Text('改')
          .fontSize(12)
          .fontColor(COLORS.cardBg)
          .backgroundColor(COLORS.gold)
          .borderRadius(10)
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .onClick(() => {
            this.selPlayer = item;
            this.showEdit = true;
          })
      }
      .width('100%')
      Row() {
        Column()
          .width(winBarW(winRate(item)))
          .height(8)
          .borderRadius(4)
          .backgroundColor(COLORS.win)
          .opacity(this.breath ? 1 : 0.65)
          .animation({ duration: 550, iterations: -1, playMode: PlayMode.Alternate })
        Column().layoutWeight(1)
        Text(item.wins + '胜 / ' + item.games + '局')
          .fontSize(10)
          .fontColor(COLORS.text3)
      }
      .width('100%')
      .margin({ top: 8 })
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(14)
    .margin({ bottom: 8 })
  }

  @Builder
  stoneRow(item: GoStoneItem, i: number) {
    Row() {
      Column()
        .width(22)
        .height(22)
        .borderRadius(11)
        .backgroundColor(COLORS.white)
        .border({ width: 1, color: COLORS.text3 })
        .scale({ x: this.breath ? 1.18 : 1, y: this.breath ? 1.18 : 1 })
        .animation({ duration: 550, iterations: -1, playMode: PlayMode.Alternate })
      Column() {
        Text(item.name)
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .fontColor(COLORS.text1)
        Text(item.material + ' · ' + item.color)
          .fontSize(11)
          .fontColor(COLORS.text2)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })
      Column() {
        Text(item.weight + '号')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(stoneWeightColor(item.weight))
        Text('¥' + item.price)
          .fontSize(11)
          .fontColor(COLORS.text3)
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.End)
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(14)
    .margin({ bottom: 8 })
  }

  @Builder
  gameRow(item: GoGameItem, i: number) {
    Row() {
      Column() {
        Text(item.title)
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .fontColor(COLORS.text1)
        Text(item.players)
          .fontSize(11)
          .fontColor(COLORS.text2)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      Column() {
        Text(item.result)
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.win)
        Text(item.moves + ' 手 · ' + item.year)
          .fontSize(10)
          .fontColor(COLORS.text3)
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.End)
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(14)
    .margin({ bottom: 8 })
  }

  @Builder
  tourneyRow(item: GoTourneyItem, i: number) {
    Column() {
      Row() {
        Text(item.name)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text1)
        Text(item.level)
          .fontSize(10)
          .fontColor(COLORS.cardBg)
          .backgroundColor(rankColor(item.level + '段'))
          .borderRadius(8)
          .padding({ left: 7, right: 7, top: 2, bottom: 2 })
          .margin({ left: 8 })
        Column().layoutWeight(1)
        Text(item.date)
          .fontSize(11)
          .fontColor(COLORS.text3)
      }
      .width('100%')
      Row() {
        Text('奖金 ¥' + item.prize)
          .fontSize(11)
          .fontColor(COLORS.text2)
        Column()
          .height(8)
          .borderRadius(4)
          .backgroundColor(COLORS.win)
          .margin({ left: 8 })
          .opacity(this.breath ? 1 : 0.6)
          .animation({ duration: 550, iterations: -1, playMode: PlayMode.Alternate })
        Column().layoutWeight(1)
        Text(item.sponsor)
          .fontSize(10)
          .fontColor(COLORS.text3)
      }
      .width('100%')
      .margin({ top: 8 })
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(14)
    .margin({ bottom: 8 })
  }

  @Builder
  collectRow(item: GoCollectionItem, i: number) {
    Row() {
      Column() {
        Text(item.name)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.text1)
        Text(item.age + ' · ' + item.origin)
          .fontSize(11)
          .fontColor(COLORS.text2)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      Column() {
        Text('¥' + item.value)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.win)
        Text(item.rank)
          .fontSize(10)
          .fontColor(COLORS.cardBg)
          .backgroundColor(colTagColor(item.rank))
          .borderRadius(8)
          .padding({ left: 7, right: 7, top: 2, bottom: 2 })
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.End)
      .margin({ right: 8 })
      Text('删')
        .fontSize(12)
        .fontColor(COLORS.cardBg)
        .backgroundColor(COLORS.danger)
        .borderRadius(10)
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .onClick(() => {
          this.selCollect = item;
          this.showDel = true;
        })
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(14)
    .margin({ bottom: 8 })
  }

  @Builder
  tabContent() {
    if (this.curTab === 0) {
      Column() {
        this.sectionTitle('🪵', '名贵棋盘', '共 ' + this.boards.length + ' 款')
        ForEach(this.boards, (item: GoBoardItem, i: number) => {
          this.boardRow(item, i)
        }, (item: GoBoardItem) => item.name)
      }
      .width('100%')
    } else if (this.curTab === 1) {
      Column() {
        this.sectionTitle('⚫', '围棋棋子', '云子 · 蛤碁石')
        ForEach(this.stones, (item: GoStoneItem, i: number) => {
          this.stoneRow(item, i)
        }, (item: GoStoneItem) => item.name)
      }
      .width('100%')
    } else if (this.curTab === 2) {
      Column() {
        this.sectionTitle('📜', '经典谱局', '千古名局')
        ForEach(this.games, (item: GoGameItem, i: number) => {
          this.gameRow(item, i)
        }, (item: GoGameItem) => item.title)
      }
      .width('100%')
    } else if (this.curTab === 3) {
      Column() {
        this.sectionTitle('🏆', '古今棋手', '胜率榜')
        ForEach(this.players, (item: GoPlayerItem, i: number) => {
          this.playerRow(item, i)
        }, (item: GoPlayerItem) => item.name)
      }
      .width('100%')
    } else if (this.curTab === 4) {
      Column() {
        this.sectionTitle('🧩', '棋弈流派', '棋风传承')
        ForEach(this.schools, (item: GoSchoolItem, i: number) => {
          this.schoolRow(item, i)
        }, (item: GoSchoolItem) => item.name)
      }
      .width('100%')
    } else if (this.curTab === 5) {
      Column() {
        this.sectionTitle('🏛️', '知名棋院', '育才之地')
        ForEach(this.academies, (item: GoAcademyItem, i: number) => {
          this.academyRow(item, i)
        }, (item: GoAcademyItem) => item.name)
      }
      .width('100%')
    } else if (this.curTab === 6) {
      Column() {
        this.sectionTitle('🎖️', '顶级赛事', '奖金榜')
        ForEach(this.tourneys, (item: GoTourneyItem, i: number) => {
          this.tourneyRow(item, i)
        }, (item: GoTourneyItem) => item.name)
      }
      .width('100%')
    } else {
      Column() {
        this.sectionTitle('📦', '典藏弈具', '传世之宝')
        ForEach(this.collections, (item: GoCollectionItem, i: number) => {
          this.collectRow(item, i)
        }, (item: GoCollectionItem) => item.name)
      }
      .width('100%')
    }
  }

  @Builder
  bottomItem(i: number) {
    Column() {
      Text(TAB_LIST[i].icon)
        .fontSize(17)
      Text(TAB_LIST[i].label)
        .fontSize(11)
        .fontColor(this.curTab === i ? COLORS.gold : COLORS.sub)
        .margin({ top: 2 })
    }
    .width('25%')
    .padding({ top: 7, bottom: 7 })
    .backgroundColor(this.curTab === i ? '#3A2D1F' : COLORS.tabBg)
    .borderRadius(10)
    .onClick(() => {
      this.curTab = i;
    })
  }

  @Builder
  bottomBar() {
    Column() {
      Row() {
        ForEach(ROW1_IDX, (i: number) => {
          this.bottomItem(i)
        }, (i: number) => 'r1' + i)
      }
      .width('100%')
      Row() {
        ForEach(ROW2_IDX, (i: number) => {
          this.bottomItem(i)
        }, (i: number) => 'r2' + i)
      }
      .width('100%')
      .margin({ top: 6 })
    }
    .width('100%')
    .padding(10)
    .backgroundColor(COLORS.tabBg)
    .borderRadius({ topLeft: 18, topRight: 18, bottomLeft: 0, bottomRight: 0 })
  }

  @Builder
  addModal() {
    if (this.showAdd) {
      Stack() {
        this.modalOverlay(() => {
          this.showAdd = false;
        })
        Column() {
          Text('新增对局')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.text1)
          Text('录入一局新谱')
            .fontSize(11)
            .fontColor(COLORS.text3)
            .margin({ top: 3 })
          Column() {
            Text('对局名称')
              .fontSize(12)
              .fontColor(COLORS.text2)
            TextInput({ text: this.formTitle, placeholder: '如:弈趣杯决赛' })
              .height(38)
              .fontSize(13)
              .margin({ top: 5 })
              .onChange((v: string) => {
                this.formTitle = v;
              })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .margin({ top: 14 })
          Column() {
            Text('黑方棋手')
              .fontSize(12)
              .fontColor(COLORS.text2)
            TextInput({ text: this.formP1, placeholder: '黑方姓名' })
              .height(38)
              .fontSize(13)
              .margin({ top: 5 })
              .onChange((v: string) => {
                this.formP1 = v;
              })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .margin({ top: 12 })
          Column() {
            Text('白方棋手')
              .fontSize(12)
              .fontColor(COLORS.text2)
            TextInput({ text: this.formP2, placeholder: '白方姓名' })
              .height(38)
              .fontSize(13)
              .margin({ top: 5 })
              .onChange((v: string) => {
                this.formP2 = v;
              })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .margin({ top: 12 })
          Column() {
            Text('手数')
              .fontSize(12)
              .fontColor(COLORS.text2)
            TextInput({ text: this.formMoves, placeholder: '如:261' })
              .height(38)
              .fontSize(13)
              .margin({ top: 5 })
              .onChange((v: string) => {
                this.formMoves = v;
              })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .margin({ top: 12 })
          Row() {
            Text('取消')
              .fontSize(14)
              .fontColor(COLORS.text2)
              .textAlign(TextAlign.Center)
              .layoutWeight(1)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor('#EFE9DD')
              .borderRadius(12)
              .onClick(() => {
                this.showAdd = false;
              })
            Text('保存')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.cardBg)
              .textAlign(TextAlign.Center)
              .layoutWeight(1)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.accent)
              .borderRadius(12)
              .margin({ left: 12 })
              .onClick(() => {
                this.games.splice(0, 0, new GoGameItem(this.formTitle, this.formP1 + ' 对 ' + this.formP2, 261, '新局', '2026'));
                this.showAdd = false;
              })
          }
          .width('100%')
          .margin({ top: 18 })
        }
        .width('88%')
        .padding(18)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(18)
        .constraintSize({ maxHeight: '80%' })
      }
      .width('100%')
      .height('100%')
      .position({ x: 0, y: 0 })
      .zIndex(999)
    }
  }

  @Builder
  editModal() {
    if (this.showEdit) {
      Stack() {
        this.modalOverlay(() => {
          this.showEdit = false;
        })
        Column() {
          Text('编辑段位')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.text1)
          Text('调整棋手段位与战绩')
            .fontSize(11)
            .fontColor(COLORS.text3)
            .margin({ top: 3 })
          Column() {
            Text('棋手')
              .fontSize(12)
              .fontColor(COLORS.text2)
            Text(this.selPlayer === null ? '—' : this.selPlayer.name)
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.text1)
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .margin({ top: 14 })
          Column() {
            Text('当前段位')
              .fontSize(12)
              .fontColor(COLORS.text2)
            Text(this.selPlayer === null ? '—' : this.selPlayer.rank)
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(rankColor(this.selPlayer === null ? '四段' : this.selPlayer.rank))
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .margin({ top: 12 })
          Row() {
            Text('升一段')
              .fontSize(13)
              .fontColor(COLORS.cardBg)
              .textAlign(TextAlign.Center)
              .layoutWeight(1)
              .padding({ top: 9, bottom: 9 })
              .backgroundColor(COLORS.win)
              .borderRadius(10)
              .onClick(() => {
                if (this.selPlayer !== null) {
                  this.selPlayer.rank = '九段';
                }
                this.showEdit = false;
              })
            Text('记一胜')
              .fontSize(13)
              .fontColor(COLORS.cardBg)
              .textAlign(TextAlign.Center)
              .layoutWeight(1)
              .padding({ top: 9, bottom: 9 })
              .backgroundColor(COLORS.gold)
              .borderRadius(10)
              .margin({ left: 10 })
              .onClick(() => {
                if (this.selPlayer !== null) {
                  this.selPlayer.wins = this.selPlayer.wins + 1;
                  this.selPlayer.games = this.selPlayer.games + 1;
                }
                this.showEdit = false;
              })
          }
          .width('100%')
          .margin({ top: 18 })
          Text('关闭')
            .fontSize(14)
            .fontColor(COLORS.text2)
            .textAlign(TextAlign.Center)
            .width('100%')
            .padding({ top: 10, bottom: 10 })
            .backgroundColor('#EFE9DD')
            .borderRadius(12)
            .margin({ top: 12 })
            .onClick(() => {
              this.showEdit = false;
            })
        }
        .width('88%')
        .padding(18)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(18)
        .constraintSize({ maxHeight: '80%' })
      }
      .width('100%')
      .height('100%')
      .position({ x: 0, y: 0 })
      .zIndex(999)
    }
  }

  @Builder
  delModal() {
    if (this.showDel) {
      Stack() {
        this.modalOverlay(() => {
          this.showDel = false;
        })
        Column() {
          Text('删除收藏')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.text1)
          Text('此操作不可恢复,请确认')
            .fontSize(12)
            .fontColor(COLORS.text3)
            .margin({ top: 6 })
          Column() {
            Text('将删除:')
              .fontSize(12)
              .fontColor(COLORS.text2)
            Text(this.selCollect === null ? '—' : this.selCollect.name)
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.danger)
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .padding(12)
          .backgroundColor('#FAEDEC')
          .borderRadius(12)
          .margin({ top: 14 })
          Row() {
            Text('再想想')
              .fontSize(14)
              .fontColor(COLORS.text2)
              .textAlign(TextAlign.Center)
              .layoutWeight(1)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor('#EFE9DD')
              .borderRadius(12)
              .onClick(() => {
                this.showDel = false;
              })
            Text('确认删除')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.cardBg)
              .textAlign(TextAlign.Center)
              .layoutWeight(1)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.danger)
              .borderRadius(12)
              .margin({ left: 12 })
              .onClick(() => {
                if (this.selCollect !== null) {
                  this.collections.splice(this.collections.indexOf(this.selCollect), 1);
                }
                this.showDel = false;
              })
          }
          .width('100%')
          .margin({ top: 18 })
        }
        .width('88%')
        .padding(18)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(18)
        .constraintSize({ maxHeight: '80%' })
      }
      .width('100%')
      .height('100%')
      .position({ x: 0, y: 0 })
      .zIndex(999)
    }
  }

  build() {
    Stack() {
      Column() {
        Scroll() {
          Column() {
            this.pageHeader()
            this.tabContent()
          }
          .width('100%')
          .padding({ left: 14, right: 14, bottom: 12 })
        }
        .scrollable(ScrollDirection.Vertical)
        .layoutWeight(1)
        .backgroundColor(COLORS.bg)
        this.bottomBar()
      }
      .width('100%')
      .height('100%')
      .backgroundColor(COLORS.bg)
      if (this.showAdd) {
        this.addModal()
      }
      if (this.showEdit) {
        this.editModal()
      }
      if (this.showDel) {
        this.delModal()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}

十三、开发环境搭建

安装 DevEco Studio

DevEco Studio 是 HarmonyOS 应用开发的官方 IDE。下载安装包后双击运行,进入安装欢迎界面,点击「下一步」继续。

DevEco Studio 安装欢迎界面

选择安装路径

选择目标安装目录,建议安装到非系统盘,确认所需空间后点击「下一步」。

选择安装位置

配置安装选项

设置环境变量,勾选创建桌面快捷方式、将 bin 文件夹添加到 PATH、添加上下文菜单,但是需要重启一下电脑使环境变量生效。

安装选项配置

选择项目模板

新建一个空白模板,在模板市场中选择 Empty Ability。

选择 Empty Ability 模板

配置项目信息

设置 API 为 24 的模板项目,项目名称 kfschool,Compatible SDK 6.1.1(24),设备类型勾选 Phone,点击完成后初始化项目,自动下载相关依赖。

配置项目信息


十四、总结

通过对这款围棋坊非遗手作平台的深度记录,我们可以看到,一个优秀的移动端应用远不止是功能的堆砌,它更是架构设计、数据建模、视觉系统、交互逻辑、动效设计、文化内涵等多维度能力的综合体现。

在架构层面,该应用采用了单组件架构设计——将整个应用的所有UI逻辑集中在单个@Entry @Component GoPage中,通过@Builder方法构建每个页面的内容。与多组件拆分的架构不同,本应用将pageHeader头部、tabContent内容、bottomBar底部导航以及八个行渲染方法和三个弹窗全部内联在一个组件中,通过@State管理所有状态,通过if-else if条件渲染切换Tab内容。这种单组件架构虽然组件数量少,但通过@Builder方法实现了逻辑的模块化拆分,代码结构清晰、状态管理集中,是中小型ArkUI应用的高效架构实践,也是本应用工程化设计的核心特点。

在数据层面,应用采用**@Observed装饰器+独立数据类设计——8个@Observed类(GoBoardItem/GoStoneItem/GoGameItem/GoPlayerItem/GoSchoolItem/GoAcademyItem/GoTourneyItem/GoCollectionItem)各自独立定义属性和构造函数,在入口组件GoPage中通过@State声明8个数组并使用new构造函数初始化。数据规模达74条(12棋盘+8棋子+8谱局+10棋手+8流派+8棋院+8赛事+10收藏),完整呈现中国围棋非遗手作平台的数据生态。@Observed装饰器使得数据对象的属性变化能被ArkUI框架追踪——当通过editModal修改selPlayer的rank或wins属性时,UI自动响应式刷新。应用还定义了9个颜色与尺寸映射函数(starPosX/starPosY/winRate/winBarW/prizeBarH/rankColor/hotBarW/stoneWeightColor/colTagColor),将颜色和尺寸映射逻辑从UI层抽离,实现"数据即配色、数据即尺寸"的动态映射模式。本应用数据最大的特色是中国传统围棋文化全覆盖**——12款棋盘覆盖日本榧木/百年银杏/楸木/紫檀/新榧木/楠竹/枣木/青石/胡桃木/乌木/复合材料/金丝楠12种材质;8种棋子覆盖云子/蛤碁石/和田玉/陶瓷/玛瑙5种材质;8局谱局跨越晋/明/清/江户/昭和五朝;10位棋手覆盖九段到三段完整段位谱系;8大流派覆盖海宁范施/本因坊门/黄龙士流/秀策流等;8家棋院覆盖甲级到丙级三个等级;8项赛事覆盖世界/国内/业余三个层次;10件藏品覆盖战国到清代近两千年。

在视觉层面,本应用采用"木色棋线星位黑白子"中国围棋非遗主题设计——bg #F3EFE6 米色(模拟宣纸温润底色),wood #8B5E3C 木色(棋盘主色调,模拟榧木温润色泽),line #C9A86A 棋线色(模拟棋盘格线纹理),star #E8D5A8 星位色(模拟棋盘星位标记),black #1A1A1A 黑子色(深炭黑暖调),white #F7F5F0 白子色(象牙白暖调),gold #D9A441 金色(描金强调),header1 #2B2118/header2 #171310 深褐黑头部渐变(模拟老榧木棋盘深色质感)。主色体系采用"木色+棋线+星位+黑白+金色"五色组合,五色各司其职形成"以木色定盘、以棋线定格、以星位定标、以黑白定子、以金色定调"的中国围棋语义着色体系。本应用最大的视觉特色是头部棋盘星位呼吸动画与黑白子scale缩放效果——25个星位点通过starPosX/starPosY坐标映射形成5x5网格模拟真实棋盘星位布局,天元光点scale 1↔1.35缩放+opacity 0.5↔1呼吸动画模拟天元气运流转,白子scale 1↔1.12缩放+黑子静态形成"白动黑静"的阴阳对比,550ms Alternate呼吸动画通过setInterval驱动,营造了围棋"坐隐方圆、气运流转"的动态意境。统一的色彩系统是整个应用的视觉基石,木色棋线星位黑白子主题配合3个颜色分级函数(rankColor/stoneWeightColor/colTagColor)和多维度动态着色,营造出既木色沉稳又金色华贵的中国围棋非遗氛围。

在交互层面,应用通过三个@Builder弹窗方法实现了完整的增删改查交互闭环:addModal新增对局(四字段TextInput表单:对局名称/黑方棋手/白方棋手/手数,保存按钮accent木色底,splice插入数组头部)、editModal编辑段位(显示棋手段位信息,"升一段"按钮win朱红色底直接设置九段+"记一胜"按钮gold金色底wins/games各加1)、delModal删除收藏(显示待删除藏品名称,"确认删除"按钮danger红色底splice删除)。三个弹窗都是@Builder方法内联在入口组件中,通过if条件渲染控制显隐(showAdd/showEdit/showDel三个@State布尔变量),遮罩用rgba(0,0,0,0.45)(45%黑色),弹窗主体与遮罩用Stack层叠,整体zIndex(999)覆盖主内容。棋盘页"编"点击触发editModal(showEdit=true),棋手页"改"点击触发editModal(selPlayer记录目标对象),收藏页"删"点击触发delModal(selCollect记录目标对象),形成了完整的"编辑段位→删除收藏→新增对局"交互闭环。@Observed+@State的响应式数据驱动能力确保了数据修改后UI自动刷新,是ArkUI数据驱动UI的核心优势。
图片描述

技术特性对比表

维度围棋坊香囊坊筷子坊折扇坊
架构模式单组件@Builder多组件(10组件)多组件(11组件)单组件@Builder
Tab数量8 Tab双行四列8 Tab双行四列5 Tab单排8 Tab双行四列
数据类@Observed 8个独立类@Observed NangData@Observed ChopstickData@Observed FanShopData
数据规模74条(8数组)76条(8数组)83条(5数组+4统计)76条(8数组)
颜色函数9个3个5个6个
弹窗数量3个(新增对局/编辑段位/删除收藏)3个4个3个
主题色木色#8B5E3C玫瑰粉#C08A9E乌木黑#2F2A26竹青#7A8B5A
辅助色金色#D9A441荷花粉#E8D5DC朱红#B03A2E金#C9A227
头部动画棋盘星位呼吸+黑白子scale虚线圆环旋转+浮动光点筷子旋转+灯笼位移扇骨辐线开合+流苏
特效开关无(breath内置)fx @Prop传递无无
文化主题围棋·黑白对弈坐隐方圆香囊·荷包香事筷子·一箸千年折扇·扇开清凉
统计图表胜率横条+热度横条+奖金柱用量对比横条柱状图+TOP6+进度条柱状图+难度横条
状态色体系win朱红/lose绿/danger红success绿/warning金/danger红无独立状态色无独立状态色
星位索引STAR_IDX[0-24] 25星位无无无

标签: HarmonyOS · ArkTS · API 24 · 中国围棋 · 木色棋线星位黑白 · 单组件架构 · @Observed · 黑白对弈坐隐方圆 · HarmonyOS 6.1.1 · 移动端开发

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐