鸿蒙ArkTS实战:端砚坊移动应用开发全解析——从色彩体系到组件化架构的深度剖析
摘要:本文以一个完整的端砚文化展示与产销管理应用"端砚坊"为案例,深入剖析鸿蒙HarmonyOS ArkTS声明式UI开发范式的核心要点。从以"紫石·墨绿·暖金"三色为核心的古雅色彩体系的接口化定义,到响应式数据流
@Observed/@ObjectLink在多Tab场景下的深度运用,再到自定义模态弹窗体系、条件渲染路由、layoutWeight弹性布局权重等复杂交互模式的完整实现,逐段拆解每一个关键代码块背后的设计思想与技术原理。全文覆盖接口约束、枚举安全、数据建模、状态驱动渲染、组件拆分策略、颜色透明度拼接、ForEach键值策略等20+个技术知识点,适合有一定ArkTS基础、希望深入理解鸿蒙声明式UI架构的开发者进阶阅读。
一、前言:千年砚文化的数字化新生
端砚,位列中国"四大名砚"之首,因产于广东肇庆(古称端州)而得名,自唐代起便为文人墨客所重。一方好砚,石质温润、发墨如油、贮墨不涸,历来是书画家案头的至宝。"紫石发墨,一砚千秋",这不仅是对端砚工艺的赞誉,更是背后数千年来制砚、赏砚、藏砚文化积淀的缩影。

在数字化浪潮席卷文化领域的今天,将这样一门承载着深厚底蕴的传统工艺以移动应用的形式呈现给现代用户,既是对非物质文化遗产传播方式的一次创新探索,也是对鸿蒙开发技术的一次深度实践。本文所剖析的"端砚坊"应用,正是围绕这一主题构建的一个集端砚陈列、砚石库、雕工名作、订单管理与客户评价于一体的综合性移动端展示平台。
在整个应用的开发过程中,我们不仅需要关注UI层面的视觉表现力——如何用代码还原端砚工艺的温润质感与古典韵味,更需要深入理解ArkTS框架的底层机制:状态管理如何驱动UI刷新、组件间如何高效通信、数据流如何在父子组件间双向传递、自定义弹窗如何优雅地叠加在页面上方。这些问题的答案,都隐藏在接下来的每一行代码之中。本文将从架构总览开始,逐层深入,带你完成一次从"能写"到"写好"的鸿蒙开发进阶之旅。

二、项目架构总览:分层设计与模块化思想
本应用采用经典的单文件声明式架构,所有逻辑集中在一个源码文件中,通过清晰的分层组织实现高内聚低耦合。整体架构可以划分为以下六个层次:
第一层——类型定义层(第1~132行):包含InkstonePalette色彩接口、InkstoneTab枚举、TabMeta标签元数据接口以及全部业务实体接口(InkstoneItem、StoneItem、CarveItem、InkstoneOrderItem、InkstoneReviewItem)和图表数据元接口(WeekInkstoneMeta、PitShareMeta、CarveHotMeta、InkstoneTopMeta)。这一层是整个应用的"契约",所有后续的数据结构都必须严格遵循这些接口定义。
第二层——常量实例层(第23~167行):将抽象的类型定义具象化为可用的运行时对象,包括COLORS色彩常量、TAB_LIST标签列表、INKSTONE_COL砚点阵列以及四组静态图表数据(WEEK_SOLD周销量、PIT_SHARE坑口占比、CARVE_HOT雕工热度、INKSTONE_TOP名砚排行)。
第三层——响应式数据类(第169~245行):@Observed装饰的InkstoneData类,封装了五组核心业务数据的动态数组,是整个应用的状态中枢。
第四层——工具函数层(第247~302行):五个纯函数式的颜色映射器(getPitColor、getStoneColor、getInkstoneColor、getOrderColor、getReviewTagColor),根据业务规则返回语义化的颜色值。
第五层——主入口与容器组件(第304~522行):@Entry标记的InkstoneApp根组件,负责全局状态管理、页面骨架搭建、Tab路由分发和弹窗条件渲染。
第六层——子组件体系(第524行至文件末尾):包括1个标签组件(InkstoneTag)、5个内容组件(分别对应5个Tab页)和4个模态弹窗组件,共同构成完整的功能矩阵。

这种分层设计的优势在于:当需要修改某个功能模块时,开发者可以快速定位到对应的层次进行修改,而不会对其他部分产生连锁反应。例如,如果需要调整配色方案,只需修改第一层的接口定义和第二层的常量实例;如果需要新增一个Tab页面,只需在第五层添加路由分支并在第六层新增对应的内容组件即可。
三、色彩体系设计:接口约束与常量定义
3.1 调色板接口定义
interface InkstonePalette {
primary: string; // 主色调——紫石(端砚本色)
primaryLight: string; // 主色浅变体
primaryDark: string; // 主色深变体(顶栏背景)
accent: string; // 强调色——墨绿(石青)
accentLight: string; // 强调色浅变体——青灰
bg: string; // 页面背景——米宣纸色
cardBg: string; // 卡片背景——纯白
cardAlt: string; // 交替卡片背景——浅米
textPrimary: string; // 一级文本——墨黑
textSecondary: string; // 二级文本——灰紫
textHint: string; // 提示文本——淡紫
border: string; // 边框色
line: string; // 分割线色
success: string; // 成功色——竹青
warning: string; // 警告色——暖金
danger: string; // 危险色——暗红
white: string; // 纯白
endPurple: string; // 端紫(砚材专用)
inkGreen: string; // 墨绿(砚材专用)
}

这段代码看似简单,却蕴含着深刻的设计思想。首先,使用interface而不是type来定义调色板结构,是因为interface在ArkTS中具有更好的类型推断能力和扩展性。接口中定义了19个颜色字段,覆盖了以下几大语义类别:
- 主色调系列(primary / primaryLight / primaryDark):以紫灰色
#6E5A7C为核心,模拟端砚石料"紫玉"般的本色。三个梯度分别用于常规主色、悬停/次要强调和深色背景区域(顶栏)。 - 强调色系列(accent / accentLight):墨绿色
#3E4A5A取自石眼周围的青绿石晕,用于关键操作按钮和高亮元素;其浅变体青灰色#7A8A9A则呼应砚石表面的冰纹与青花纹理。 - 背景色系(bg / cardBg / cardAlt):米宣纸色
#F2EDE4作为页面底色营造古籍翻阅感;纯白#FFFFFF作为卡片底色保证内容可读性;浅米#EDE6DA作为交替行背景增强列表视觉节奏。 - 文本色三级灰度(textPrimary / textSecondary / textHint):从墨黑
#332B38到灰紫#756A7C再到淡紫#A79BA8,形成清晰的信息层级。 - 功能语义色(success / warning / danger):竹青
#5E8A6E表示成功/优质,暖金#C9A24B表示提醒/待办,暗红#9E2E20表示危险/超支。 - 主题专属色(endPurple / inkGreen):端紫复用主色但语义独立,专指砚材相关元素;墨绿
#3E4A5A精确对应雕工与砚石的冷色调点缀。
这种19色的精细划分,使得整个应用的每一个视觉元素都有明确的"色彩归属",避免了随意使用硬编码颜色值带来的视觉混乱。更重要的是,通过接口约束,任何遗漏字段或类型不匹配都会在编译阶段被TypeScript编译器捕获,实现了"编译期安全"。
3.2 色彩常量实例化
const COLORS: InkstonePalette = {
primary: '#6E5A7C',
primaryLight: '#9A86A8',
primaryDark: '#4A3A58',
accent: '#3E4A5A',
accentLight: '#7A8A9A',
bg: '#F2EDE4',
cardBg: '#FFFFFF',
cardAlt: '#EDE6DA',
textPrimary: '#332B38',
textSecondary: '#756A7C',
textHint: '#A79BA8',
border: '#E1D8CC',
line: '#F0EAE0',
success: '#5E8A6E',
warning: '#C9A24B',
danger: '#9E2E20',
white: '#FFFFFF',
endPurple: '#6E5A7C',
inkGreen: '#3E4A5A'
};
这里有一个非常值得注意的细节:COLORS常量的类型被显式标注为InkstonePalette。这意味着TypeScript编译器会在编译阶段检查COLORS对象是否完整实现了接口定义的所有19个字段。如果遗漏了任何一个颜色字段,或者字段类型不匹配(比如不小心把数字赋给了string类型的字段),编译器会立即报错。这种"编译期安全"是大型项目中极其重要的保障,它能在代码运行之前就消灭掉大量潜在的类型错误。
此外,所有颜色值均采用标准的#RRGGBB六位十六进制格式,而非CSS变量名或语义别名。这种选择在ArkTS生态中是务实的——ArkTS的声明式UI直接支持字符串形式的颜色值,无需额外的运行时解析开销。同时,将所有颜色集中在一个常量对象中管理,使得后续的主题切换(如实现"暗色模式")只需要替换这个常量对象即可,体现了良好的架构前瞻性。
四、枚举与标签配置:类型安全的页面路由
4.1 Tab枚举定义
enum InkstoneTab {
INKSTONE = 0,
STONE = 1,
CARVE = 2,
ORDER = 3,
REVIEW = 4
}
InkstoneTab枚举定义了应用底部导航栏的五个页面标识,从0到4依次对应:砚台列表、砚石库、雕工名作、订单台账、客户评价。使用枚举而非魔术数字(magic number)的好处是多方面的:
第一,类型安全。当我们在代码中编写this.curTab === InkstoneTab.INKSTONE时,编辑器会提供自动补全和拼写检查,有效避免将INKSTONE误写成INKSTOME之类的低级错误。
第二,语义明确。枚举成员名称本身就是最好的文档——任何阅读代码的人都能立刻理解InkstoneTab.REVIEW代表的是"评价"页面,而数字4则需要查阅上下文才能确定含义。
第三,重构友好。如果未来需要调整Tab的顺序(比如把"订单"移到第二个位置),只需修改枚举值的赋值顺序,所有引用该枚举的地方都会自动适配。
4.2 标签元数据配置
interface TabMeta {
key: string;
icon: string;
label: string;
color: string;
}
const TAB_LIST: TabMeta[] = [
{ key: 'inkstone', icon: '🪨', label: '砚台', color: '#6E5A7C' },
{ key: 'stone', icon: '⛰️', label: '砚石', color: '#7A8A9A' },
{ key: 'carve', icon: '🔪', label: '雕工', color: '#3E4A5A' },
{ key: 'order', icon: '📦', label: '订单', color: '#C9A24B' },
{ key: 'review', icon: '⭐', label: '客评', color: '#9E2E20' }
];
这是一个非常优雅的设计模式:将标签的配置信息(唯一标识key、图标emoji、显示文字label、主题色color)抽象为一个数据数组,而非硬编码在组件的build()方法中。这种"数据驱动UI"的思路,使得后续新增或修改标签时,只需要修改TAB_LIST数组即可,无需触碰任何组件代码。
此外,代码中还定义了一组辅助列阵:
const INKSTONE_COL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
INKSTONE_COL是一个简单的数字数组(0~11,共12项),专门用于顶部Header中砚点装饰的遍历渲染。配合ForEach与r % 3的模运算,它让12颗点缀圆点呈现"大→中→小"三种尺寸循环排列,模拟端砚石眼与冰纹相间的纹理节奏。这种设计把"装饰性迭代"也数据化了,避免了手写12次重复UI节点。
每个标签还拥有自己的主题色(color字段),这为选中态的视觉表现提供了丰富的个性化空间。例如,"砚台"Tab选中时使用紫石色高亮,"客评"Tab则使用暗红色,让用户在不同页面间切换时能获得一致的色彩反馈但又各具辨识度。

五、数据模型设计:接口驱动的类型安全
5.1 业务实体接口定义
interface InkstoneItem {
name: string; // 砚名
pit: string; // 坑口(老坑/坑仔岩/...)
stone: string; // 石种(端石/绿石/...)
price: number; // 价格(元)
emoji: string; // 表情图标
}
interface StoneItem {
name: string; // 石名
texture: string; // 纹理(冰纹/青花/...)
level: number; // 石质等级(0-100)
place: string; // 产地
emoji: string;
}
interface CarveItem {
name: string; // 雕工名作
style: string; // 技法(浮雕/浅刻/...)
level: number; // 工级(0-100)
tool: string; // 工具(凿/刀/钻/针)
emoji: string;
}
interface InkstoneOrderItem {
name: string; // 客户名称
region: string; // 所属区域
count: number; // 订购数量
amount: number; // 订单金额
emoji: string;
}
interface InkstoneReviewItem {
name: string; // 评价人姓名
score: number; // 评分(1-5星)
date: string; // 评价日期
content: string; // 评价内容
tag: string; // 评价标签
emoji: string;
}

这里展示了五种业务实体的接口定义。每个接口都精心设计了字段,紧密围绕端砚行业的实际业务场景:
InkstoneItem(砚台条目):包含砚名(如"老坑端砚"、"水岩砚")、坑口(老坑、坑仔岩、麻子坑等端砚名坑)、石种(端石、绿石、白石、紫石)、价格和表情图标。其中pit字段不仅是展示信息,还是后续getPitColor()颜色映射函数的输入参数;price则是getInkstoneColor()的映射依据。StoneItem(砚石条目):记录石名、纹理(冰纹、青花、火捺、石眼等端砚典型石品花纹)、石质等级(数值型,用于进度条可视化)、产地(西江、羚羊峡、北岭等)。level字段是砚石页面的核心数据维度,直接影响每方石的"石质"进度条长度和颜色。CarveItem(雕工条目):记录雕工名作(双龙戏珠、云海月明、兰亭序等)、技法(浮雕、浅刻、镂雕、圆雕、线刻)、工级、所用工具(凿、刀、钻、针)和表情图标。level与style/tool一起构成雕工卡片的核心信息。InkstoneOrderItem(订单条目):记录客户名称、所属区域、订购数量和金额。amount字段是getOrderColor()的颜色映射依据——金额越高颜色越"危险"(暗红),形成直观的业务警示。InkstoneReviewItem(评价条目):包含评分(1-5星)、日期、评价内容和标签。tag字段是getReviewTagColor()的映射输入,不同类型的评价标签被赋予不同的主题色。
这五组接口构成了整个应用的数据模型基石。值得注意的是,所有接口中的emoji字段虽然不是严格的"业务数据",但在ArkTS的Text组件中可以直接渲染表情符号,为每个条目提供了轻量级的视觉标识,增强了列表的可读性和趣味性。
5.2 图表数据元接口
interface WeekInkstoneMeta {
day: string; // 星期几
value: number; // 销量
}
interface PitShareMeta {
name: string; // 坑口名称
count: number; // 数量
color: string; // 主题色
}
interface CarveHotMeta {
name: string; // 雕工名称
heat: number; // 热度值
color: string; // 主题色
}
interface InkstoneTopMeta {
name: string; // 砚名
sold: number; // 销量
color: string; // 主题色
}
这四个接口专门服务于应用中的四种图表/排行榜可视化组件。它们的设计遵循了一个共同的范式:名称 + 数值 + 主题色的三元组结构。这种统一的数据形状使得四种图表可以共享同一套渲染逻辑(ForEach + layoutWeight条形图),只是数据源不同而已。
WeekInkstoneMeta用于垂直柱状图(周销量走势),PitShareMeta/CarveHotMeta/InkstoneTopMeta用于水平条形图(占比/热度/排行)。每种接口都自带color字段,使得每个数据条都可以拥有独立的颜色,而不是强制使用统一的系列色。
六、静态数据集:应用的基础数据源
6.1 图表静态数据
const WEEK_SOLD: WeekInkstoneMeta[] = [
{ day: '周一', value: 16 },
{ day: '周二', value: 21 },
{ day: '周三', value: 19 },
{ day: '周四', value: 26 },
{ day: '周五', value: 30 },
{ day: '周六', value: 42 },
{ day: '周日', value: 37 }
];
这里展示了周销量数据。在实际项目中,这些数据通常来自后端API接口,但本应用作为展示型Demo采用了静态数据的方式。数据的设计颇具巧思——销量从周一到周六逐步递增(16→21→19→26→30→42),周日略有回落(37),这完全符合零售业"工作日平稳、周末高峰、周日略降"的实际规律,让数据看起来真实可信。

const PIT_SHARE: PitShareMeta[] = [
{ name: '老坑', count: 5, color: '#6E5A7C' },
{ name: '坑仔岩', count: 4, color: '#7A8A9A' },
{ name: '麻子坑', count: 3, color: '#3E4A5A' },
{ name: '宋坑', count: 2, color: '#C9A24B' }
];
坑口构成数据反映了端砚界最常见的四大名坑及其市场占比。老坑(5方)居首,老坑石质细腻温润,为端砚中的上品;坑仔岩(4方)次之,石品花纹丰富;麻子坑(3方)石眼多见;宋坑(2方)则以"猪肝紫"色著称。四种颜色分别对应COLORS中的主色、青灰、墨绿与暖金,与品牌色系一脉相承。
6.2 雕工热度与排行榜数据
const CARVE_HOT: CarveHotMeta[] = [
{ name: '开膛取石', heat: 97, color: '#6E5A7C' },
{ name: '磨墨理堂', heat: 95, color: '#7A8A9A' },
{ name: '浮雕龙纹', heat: 94, color: '#3E4A5A' },
{ name: '镂雕云月', heat: 93, color: '#5E8A6E' },
{ name: '线刻铭文', heat: 92, color: '#C9A24B' },
{ name: '上蜡养护', heat: 90, color: '#9E2E20' }
];
const INKSTONE_TOP: InkstoneTopMeta[] = [
{ name: '老坑端砚', sold: 97, color: '#6E5A7C' },
{ name: '坑仔岩砚', sold: 93, color: '#7A8A9A' },
{ name: '麻子坑砚', sold: 89, color: '#3E4A5A' },
{ name: '绿端砚', sold: 86, color: '#5E8A6E' },
{ name: '宋坑砚', sold: 82, color: '#C9A24B' },
{ name: '紫端砚', sold: 78, color: '#9E2E20' }
];
雕工热度数据涵盖了端砚制作的六大核心工序,从"开膛取石"(97分)到"上蜡养护"(90分),热度值递减但差距不大,说明每一道工序在制砚师心中都至关重要。名砚销量TOP6则列出了最受欢迎的六款砚,"老坑端砚"以97的销量遥遥领先——老坑石历来为藏家所重,发墨快而墨汁润泽。



七、响应式数据类:@Observed装饰器的深度运用
@Observed
export class InkstoneData {
inkstones: InkstoneItem[] = [
{ name: '老坑端砚', pit: '老坑', stone: '端石', price: 1280, emoji: '🪨' },
{ name: '坑仔岩砚', pit: '坑仔岩', stone: '端石', price: 980, emoji: '⛰️' },
{ name: '麻子坑砚', pit: '麻子坑', stone: '端石', price: 880, emoji: '🌑' },
{ name: '宋坑砚', pit: '宋坑', stone: '端石', price: 680, emoji: '🏮' },
{ name: '梅花坑砚', pit: '梅花坑', stone: '端石', price: 720, emoji: '🌸' },
{ name: '绿端砚', pit: '绿端', stone: '绿石', price: 1080, emoji: '💚' },
{ name: '白端砚', pit: '白端', stone: '白石', price: 880, emoji: '🤍' },
{ name: '紫端砚', pit: '紫端', stone: '紫石', price: 920, emoji: '💜' },
{ name: '水岩砚', pit: '水岩', stone: '端石', price: 1580, emoji: '💧' },
{ name: '朝天岩砚', pit: '朝天岩', stone: '端石', price: 760, emoji: '🔭' },
{ name: '宣德岩砚', pit: '宣德岩', stone: '端石', price: 860, emoji: '🏯' },
{ name: '古塔岩砚', pit: '古塔岩', stone: '端石', price: 820, emoji: '🗼' }
];
stones: StoneItem[] = [
{ name: '老坑石', texture: '冰纹', level: 96, place: '西江', emoji: '❄️' },
{ name: '坑仔岩石', texture: '青花', level: 95, place: '羚羊峡', emoji: '💠' },
// ... 共12种砚石(斧柯石/菱角石等)
];
carves: CarveItem[] = [
{ name: '双龙戏珠', style: '浮雕', level: 96, tool: '凿', emoji: '🐉' },
{ name: '云海月明', style: '浅刻', level: 95, tool: '刀', emoji: '🌙' },
// ... 共12件雕工名作
];
orders: InkstoneOrderItem[] = [
{ name: '书协雅集', region: '华东', count: 300, amount: 270000, emoji: '🖌️' },
{ name: '文房商号', region: '华南', count: 500, amount: 320000, emoji: '🏪' },
// ... 共12条订单
];
reviews: InkstoneReviewItem[] = [
{ name: '书协理事', score: 5, date: '09-03',
content: '老坑端砚下发墨快,研出的墨汁润泽细腻,行笔顺滑。',
tag: '下发墨好', emoji: '🖌️' },
// ... 共12条评价
];
}
这是整个应用最核心的数据层代码。@Observed装饰器是ArkTS框架提供的一个关键装饰器,它的作用是将一个普通的类标记为"可观察的"。当一个类被@Observed标记后,该类实例的属性变化会被ArkTS框架自动追踪,任何引用了该实例属性的@ObjectLink或@State变量都会在属性变化时自动触发UI重新渲染。
InkstoneData类封装了五组核心业务数据:
inkstones(12款端砚):涵盖从"老坑端砚"(¥1280,老坑端石)到"水岩砚"(¥1580,水岩端石)的全价位段产品矩阵,坑口覆盖老坑、坑仔岩、麻子坑、宋坑、梅花坑、绿端、白端、紫端、水岩、朝天岩、宣德岩、古塔岩等十二大名坑。stones(12种砚石):纹理涵盖冰纹、青花、火捺、猪肝紫、梅花点、翠绿、凝脂白、细罗纹、金星点、水波纹、石眼、青苔纹等端砚典型石品,石质等级90-96不等,产地分布于西江、羚羊峡、北岭、斧柯山等。carves(12件雕工名作):技法涵盖浮雕、浅刻、镂雕、圆雕、线刻五种,工级91-96,所用工具涵盖凿、刀、钻、针。orders(12条订单):客户涵盖书协、商号、画院、国学院、海外文房、买手店、茶空间、酒店、拍卖行、学校、景区、私人定制等多元渠道,区域分布华东/华南/华中/华北/海外/西南。reviews(12条评价):全部4-5星好评,标签涵盖"下发墨好"、"纹理雅致"、"蓄墨不涸"、"端方厚重"等多维度评价维度。
这种将所有业务数据集中在一个@Observed类中的设计,带来了一个重要的架构优势:单一数据源原则(Single Source of Truth)。无论哪个子组件需要访问砚台列表、砚石数据还是订单信息,它们都通过@ObjectLink引用同一个InkstoneData实例。当任何一个数据发生变化(比如新增了一款砚台、删除了一方砚石),所有引用该数据的组件都会自动收到通知并刷新UI,无需手动编写繁琐的事件监听和数据同步逻辑。
八、颜色工具函数:纯函数的设计哲学
function getPitColor(pit: string): string {
if (pit === '老坑') return '#6E5A7C';
else if (pit === '坑仔岩') return '#7A8A9A';
else if (pit === '麻子坑') return '#3E4A5A';
else if (pit === '宋坑') return '#C9A24B';
return '#5E8A6E'; // 默认 fallback
}
function getStoneColor(level: number): string {
if (level >= 95) return '#6E5A7C';
else if (level >= 92) return '#7A8A9A';
return '#3E4A5A';
}
function getInkstoneColor(price: number): string {
if (price >= 1200) return '#9E2E20'; // 高价 → 暗红
else if (price >= 900) return '#6E5A7C';
else if (price >= 700) return '#7A8A9A';
return '#5E8A6E'; // 低价 → 竹青
}
function getOrderColor(amount: number): string {
if (amount >= 250000) return '#9E2E20';
else if (amount >= 150000) return '#6E5A7C';
else if (amount >= 100000) return '#7A8A9A';
return '#5E8A6E';
}
function getReviewTagColor(tag: string): string {
if (tag === '下发墨好' || tag === '蓄墨不涸' || tag === '石眼灿然' || tag === '越用越润')
return '#6E5A7C';
else if (tag === '纹理雅致' || tag === '端方厚重' || tag === '包装厚实')
return '#7A8A9A';
else if (tag === '一物两用' || tag === '磨感顺滑' || tag === '氛围好')
return '#5E8A6E';
else if (tag === '雅趣横生' || tag === '走量飞快')
return '#C9A24B';
return '#9E2E20';
}

这五个函数构成了应用的颜色映射层,它们都是纯函数(Pure Function)——相同的输入永远产生相同的输出,没有任何副作用。这种函数式的设计哲学在ArkTS开发中具有多重价值:
第一,可测试性极强。每个函数都可以独立编写单元测试,传入已知输入、断言输出颜色,无需mock任何组件或状态。
第二,逻辑集中且可复用。颜色映射规则分散在多个组件中是维护噩梦——如果要调整"高价砚"的阈值从1200改为1300,需要在每个使用处逐一修改。而现在只需改getInkstoneColor一处。
第三,语义化表达业务规则。函数名本身就是最好的文档——getInkstoneColor(price)一眼就能看出是根据价格返回砚台卡片的主题色;getReviewTagColor(tag)则是根据评价标签返回标签颜色。
特别值得分析的是getInkstoneColor的阶梯式阈值设计:价格≥1200返回暗红(danger色,暗示"珍品高端"),900-1199返回主色紫,700-899返回青灰,<700返回竹青(success色,暗示"性价比之选")。这种将业务语义(价格区间)映射到视觉语义(颜色情感)的模式,是数据可视化中的经典手法。
九、主入口组件:应用骨架与路由管理
9.1 状态声明
@Entry
@Component
struct InkstoneApp {
@State curTab: number = 0;
@State data: InkstoneData = new InkstoneData();
@State showAddInkstone: boolean = false;
@State showEditOrder: boolean = false;
@State showDeleteStone: boolean = false;
@State showDetailCarve: boolean = false;
@State delStoneName: string = '';
@State detailCarveName: string = '';
@State brushRotate: boolean = false;
@State inkFloat: boolean = false;
InkstoneApp是整个应用的根组件,由@Entry装饰器标记为应用入口,由@Component标记为可复用组件。它声明了10个@State状态变量,可以分为三类:
路由与数据状态(2个):
curTab:当前选中的Tab索引(0-4),控制底部导航栏的高亮和内容区的切换。data:InkstoneData实例,作为全局唯一的数据源传递给所有子组件。
弹窗显隐状态(4个布尔值 + 2个字符串):
showAddInkstone/showEditOrder/showDeleteStone/showDetailCarve:分别控制四种弹窗的显示/隐藏。delStoneName/detailCarveName:传递给删除确认弹窗和详情弹窗的上下文参数(要删除的石名、要查看详情的雕工名)。
动画交互状态(2个布尔值):
brushRotate:控制头部毛笔图标的旋转动画(点击切换90°旋转)。inkFloat:控制水滴图标的位移动画(点击切换水平/垂直位移)。
这种细粒度的状态拆分,使得每个UI交互都有独立的状态变量驱动,互不干扰。当需要添加新的弹窗或交互效果时,只需新增一个@State变量即可,不会影响现有逻辑。
9.2 构建方法与页面结构
build()方法是ArkTS声明式UI的核心——它描述了"UI应该长什么样",框架负责在状态变化时高效地 diff 和更新真实DOM。InkstoneApp的build()方法构建了以下三层结构:
第一层:主内容区(外层Column)。包含两个子区域:
- 顶部Header区域:深紫色(
primaryDark)背景,包含应用标题"🪨 端砚坊"、英文副标题"Inkstone · 紫石发墨 一砚千秋"、右侧操作图标区(毛笔旋转、水滴位移、画笔图标),以及下方的12颗砚点点缀(用三种尺寸和颜色的圆形模拟石眼与冰纹相间的排列)。 - 内容切换区:根据
curTab的值,使用if-else if-else链条件渲染对应的内容组件(InkstoneContent/StoneContent/CarveContent/InkstoneOrderContent/InkstoneReviewContent)。
第二层:底部导航栏(固定在底部)。使用ForEach遍历TAB_LIST数组渲染5个Tab项,每个Tab项根据是否为当前选中项来决定:文字颜色(主题色 vs 提示色)、字重(Bold vs Normal)、背景色(半透明主题色 vs 透明)、边框(有 vs 无)。点击时更新curTab状态触发切换。
第三层:弹窗叠加层。四个if条件分别控制四种弹窗的渲染,弹窗以条件渲染的方式覆盖在主内容区上方。
9.3 弹窗的条件渲染
if (this.showAddInkstone) {
AddInkstoneModal({
onClose: () => { this.showAddInkstone = false; }
})
}
if (this.showEditOrder) {
EditInkstoneOrderModal({
onClose: () => { this.showEditOrder = false; }
})
}
if (this.showDeleteStone) {
DeleteStoneModal({
title: this.delStoneName,
onClose: () => { this.showDeleteStone = false; }
})
}
if (this.showDetailCarve) {
DetailCarveModal({
name: this.detailCarveName,
onClose: () => { this.showDetailCarve = false; }
})
}
弹窗的渲染采用了条件渲染 + 回调关闭的模式。每个弹窗组件接收一个onClose回调函数,当用户点击弹窗内的"取消"/"确认"/"知道了"等按钮时,调用onClose()将对应的showXxx状态设为false,从而触发弹窗从DOM中移除。
DeleteStoneModal和DetailCarveModal额外接收了title/name属性,用于在弹窗内显示具体的操作对象(要删除哪方石、查看哪件雕工的详情)。这种"状态提升"(Lifting State Up)的模式——将弹窗所需的上下文数据存储在父组件的状态中、通过props传递给弹窗——是React/ArkTS中处理跨组件数据流的经典方案。
9.4 头部交互动画
在Header右侧,两个图标承载了轻量动画:
Text('🖋️')
.fontSize(20)
.onClick(() => { this.brushRotate = !this.brushRotate; })
.rotate({ angle: this.brushRotate ? 90 : 0 })
.animation({ duration: 700, curve: Curve.EaseOut })
Text('💧')
.fontSize(16)
.margin({ left: 10 })
.onClick(() => { this.inkFloat = !this.inkFloat; })
.translate({ x: this.inkFloat ? 14 : -8, y: this.inkFloat ? -8 : 0 })
.animation({ duration: 620, curve: Curve.EaseOut })
🖋️毛笔图标点击后通过.rotate()在0°与90°之间切换,并配合.animation()声明式过渡(时长700ms、缓出曲线);💧水滴图标点击后通过.translate()在初始位移与右上位移之间切换(时长620ms)。这种状态驱动动画的优势在于:开发者只需描述"点击后状态翻转、属性随之变化、变化过程用动画过渡",框架自动管理动画的起始值与结束值,无需手动监听动画生命周期或调用start()/stop()。

十、内容组件体系:@ObjectLink与数据双向绑定
10.1 标签组件 InkstoneTag
@Component
struct InkstoneTag {
@Prop text: string;
@Prop color: string;
build() {
Text(this.text)
.fontSize(9)
.fontColor(this.color)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.backgroundColor(this.color + '14') // 8% 透明度
.borderRadius(10)
.border({ width: 1, color: this.color + '40' }) // 25% 透明度边框
}
}
InkstoneTag是一个极简但精心设计的标签组件,仅接收两个@Prop属性:text(标签文字)和color(主题色)。它的设计精髓在于颜色透明度的十六进制拼接技巧:
- 背景色使用
this.color + '14',即在原色hex后面追加14(即8%不透明度的alpha通道),生成类似#6E5A7C14的8位颜色值。ArkTS原生支持这种8位hex颜色格式,前6位是RGB,后2位是Alpha(00=完全透明,FF=完全不透明)。 - 边框色使用
this.color + '40'(25%不透明度),比背景更明显但不抢眼。
这种"同色系多层透明度叠加"的技法,使得标签在任何背景下都能保持和谐的视觉效果——背景色淡淡的(8%)提供区域感,边框稍深(25%)勾勒轮廓,文字用原色(100%)保证可读性。三者来自同一个color变量,天然协调,永远不会出现"背景是紫色但文字是红色"的色彩冲突。
10.2 砚台内容组件 InkstoneContent
@Component
struct InkstoneContent {
@ObjectLink data: InkstoneData;
onAdd: () => void = () => {};
build() {
Scroll() {
Column() {
// 销量图表卡片
Column() { /* ... */ }
// 端砚名品列表
ForEach(this.data.inkstones, (p: InkstoneItem, i: number) => {
Row() { /* 卡片内容 */ }
}, (p: InkstoneItem, i: number) => 'ik' + p.name + i)
}
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
}
}
InkstoneContent是砚台Tab页的主体内容组件。它使用了@ObjectLink装饰器来接收父组件传递的InkstoneData实例。@ObjectLink与@Prop的关键区别在于:@ObjectLink建立的是引用关系,而非值的拷贝——当InkstoneData实例的属性(如inkstones数组)发生变化时,InkstoneContent中引用这些属性的部分会自动刷新。这使得数据在父子组件间实现了"双向绑定"的效果。
组件内部由两大部分组成:
第一部分:本周销量图表卡片。顶部有一排装饰性的砚点点缀(模仿石眼与冰纹相间的简化版),下方是标题"📈 本周砚台销量"和副标题"周六42方"。柱状图使用ForEach遍历WEEK_SOLD数据,每根柱子的高度由value决定(1单位=1px,范围16~42px),颜色由阈值判断决定(≥37用主色紫,否则用青灰)。柱子使用borderRadius(8)实现圆角,底部对齐(alignItems(VerticalAlign.Bottom))模拟真实柱状图的基线对齐效果。
第二部分:端砚名品列表。使用ForEach遍历this.data.inkstones数组,每张卡片是一个Row横向布局,包含三个区域:
- 左侧图标区(宽60):圆形头像区显示emoji,下方有一枚小圆点模拟石眼。整体背景色由
getInkstoneColor(p.price)决定并附加极低透明度(0D=5%),边框用33%透明度。 - 中间信息区(
layoutWeight(1)占据剩余空间):第一行是砚名(粗体)+ 价格(带颜色),第二行是坑口标签(InkstoneTag)+ 石种标签(InkstoneTag)+ "天工开砚"提示文字。 - 卡片背景色使用
i % 2 === 0实现斑马纹交替(白色/浅米),增强列表的可读性。

10.3 其他内容组件
StoneContent(砚石库):结构与InkstoneContent类似,但顶部图表替换为"名砚销量TOP6"水平条形图(使用INKSTONE_TOP数据和layoutWeight实现比例条),列表项改为砚石卡片——左侧显示emoji+石质圆点+青灰点缀,中间显示石名+纹理+产地,下方显示"石质"进度条(同样用layoutWeight(c.level)实现比例填充)。右侧有删除图标,点击触发onDel回调。
CarveContent(雕工名作):顶部图表为"雕工热度"水平条形图(CARVE_HOT数据),列表项为雕工卡片——显示雕工emoji+工级圆点、雕工名+技法、下方显示"X法"标签(根据tool字段生成,如"凿法")+ 工级。右侧有详情图标,点击触发onDetail回调。
InkstoneOrderContent(订单台账):顶部图表为"坑口构成占比"水平条形图(PIT_SHARE数据),列表项为订单卡片——显示客户emoji+名称+区域+数量+金额(金额颜色由getOrderColor决定),右侧有编辑图标,点击触发onEdit回调。
InkstoneReviewContent(客户评价):无顶部图表,直接是评价列表。每条评价显示用户emoji+姓名+日期+星级(用⭐.repeat(score)重复渲染)+ 标签(InkstoneTag)+ 评价正文。星级使用repeat()方法根据score值动态生成对应数量的星星字符,简洁而巧妙。
十一、弹窗组件体系:自定义模态对话框
应用包含四个自定义弹窗组件,它们共享一致的结构模式但各有不同的内容和用途:
11.1 定制砚台弹窗 AddInkstoneModal
弹窗采用全屏遮罩 + 居中卡片的经典模态布局:
- 外层Column:
width('100%') height('100%'),背景色#66000000(40%黑色半透明遮罩),justifyContent(FlexAlign.Center)使内容居中。 - 内层卡片:宽度86%、最大高度78%、圆角18px、白色背景、16px内边距。
- 标题栏:"🪨 定制砚台" + 右侧"✕"关闭按钮,两端对齐。
- 内容区:三行信息展示(砚名、坑口、预算),每行使用
cardAlt背景色+圆角的统一样式。 - 操作栏:左"取消"(描边按钮)+ 右"确认定制"(实心主色按钮),右对齐。
11.2 其他弹窗组件
- EditInkstoneOrderModal(修改订单):结构与AddInkstoneModal完全对称,内容改为客户/数量/金额三行信息(数量显示"300 → 330 方"的拟修改态,金额显示¥297000),确认按钮文案为"保存修改"。
- DeleteStoneModal(撤下砚石):接收
title属性显示要删除的石名("确定撤下「xxx」吗?"),内容为确认提示文字,确认按钮使用危险色(danger= 暗红)以警示操作的不可逆性。 - DetailCarveModal(雕工详情):接收
name属性,内容包含大号emoji+雕工名展示区、雕工工艺文化介绍长文("端砚雕工重因石施艺……")、技法信息和匠人评级,单按钮"知道了"关闭。其介绍文字细致描述了浮雕取石眼为珠、镂雕透光见影、线刻疏密有致等端砚雕工精髓,体现了深厚的文化底蕴。
四个弹窗的一致性设计(相同的外层遮罩、卡片尺寸、圆角、内边距、标题栏布局)确保了用户在使用不同功能时获得统一的交互体验。差异仅在内容区和按钮文案/颜色上体现各自的功能特性。
十二、关键技术点深度解析
12.1 layoutWeight布局权重的妙用
layoutWeight是ArkTS弹性布局中最强大的属性之一。在本应用中被广泛用于两类场景:
水平条形图:在INKSTONE_TOP、CARVE_HOT、PIT_SHARE的渲染中,每行的彩色条使用layoutWeight(dataValue)设置宽度权重,后面的灰色占位条使用layoutWeight(maxValue - dataValue)补足剩余空间。两者之和固定(TOP/HEAT为100,PIT_SHARE为20),从而精确实现按比例分配宽度的效果——无需手动计算像素值,框架自动处理。例如PIT_SHARE中老坑count=5对应layoutWeight(5),灰色补layoutWeight(15),整体占比5/20。
列表卡片内部布局:砚台卡片、砚石卡片等信息区的中间列使用layoutWeight(1)占据所有剩余空间,左右固定宽度的图标区和操作区保持不变。这种"两头固定、中间弹性"的布局模式在移动端UI中极为常见。
12.2 颜色透明度的十六进制拼接
本应用大量使用了hex颜色 + 两位alpha的8位颜色格式,例如:
COLORS.accentLight + '2E'→#7A8A9A2E(约18%不透明度,用于按钮背景)COLORS.accentLight + '66'→#7A8A9A66(约40%不透明度,用于Header分割线)COLORS.primary + '55'→#6E5A7C55(约33%不透明度,用于卡片边框)t.color + '14'→ 如#6E5A7C14(约8%不透明度,用于标签背景)
这种技法的优势在于:不需要预先定义几十个半透明色变量,而是根据运行时的主题色动态生成任意透明度的衍生色。只要知道基础色和目标透明度,字符串拼接一行搞定。需要注意的是,alpha值的范围是00(全透明)到FF(完全不透明),常用参考值:0D≈5%,14≈8%,2E≈18%,33≈20%,40≈25%,55≈33%,66≈40%,80≈50%。
12.3 ForEach的键值生成策略
应用中所有的ForEach循环都精心设计了键值生成函数(key generator):
// 砚台列表
(p: InkstoneItem, i: number) => 'ik' + p.name + i
// 砚石列表
(c: StoneItem, i: number) => 'sn' + c.name + i
// 雕工列表
(d: CarveItem, i: number) => 'cv' + d.name + i
// 订单列表
(o: InkstoneOrderItem, i: number) => 'od' + o.name + i
// 评价列表
(r: InkstoneReviewItem, i: number) => 'rv' + r.name + i
// Tab栏
(t: TabMeta) => t.key
// 图表数据
(w: WeekInkstoneMeta) => w.day
(s: PitShareMeta) => s.name
(d: CarveHotMeta) => d.name
(t: InkstoneTopMeta) => t.name
每个键值都采用了**"前缀 + 唯一标识 + 索引"**的三段式命名:
- 前缀(
ik/sn/cv/od/rv)区分不同的ForEach作用域,避免跨列表键值冲突。 - 唯一标识(
p.name/c.name等)保证同一条数据始终映射到同一个键。 - 索引(
i)作为兜底,即使两条数据name相同(理论上不应发生但有防御价值),索引也能保证唯一性。
正确的键值策略对于ArkTS的ForEach性能至关重要——框架通过键值来判断哪些节点是"新增"、哪些是"移动"、哪些是"删除",从而执行最小量的DOM操作。错误的键值(如使用Math.random())会导致每次渲染都销毁重建所有节点,造成严重的性能问题。
12.4 @Observed/@ObjectLink响应式机制
本应用的响应式数据流遵循以下路径:
InkstoneData (@Observed 类)
↓ @ObjectLink 引用传递
InkstoneContent / StoneContent / CarveContent / InkstoneOrderContent / InkstoneReviewContent
↓ 组件内部读取属性
ForEach / Text / 等UI节点
当InkstoneData的某个数组属性发生变化(如inkstones.push(newItem)),ArkTS框架的响应式系统会:
- 检测到
@Observed类实例的属性变更。 - 通知所有持有该实例
@ObjectLink引用的组件。 - 这些组件重新执行
build()方法。 - 框架diff新旧虚拟DOM树,只更新实际变化的部分。
这种机制比传统的"手动调用setState/refresh"模式更加声明式和自动化,开发者只需描述"数据是什么样子",框架负责"如何高效地更新UI"。
12.5 条件渲染与状态驱动UI
应用的Tab切换和弹窗显示/隐藏完全由状态变量驱动,没有使用任何命令式的DOM操作(如appendChild/removeChild):
- Tab切换:
if (this.curTab === InkstoneTab.INKSTONE)渲染InkstoneContent,否则渲染其他组件。curTab的变化由底部Tab栏的onClick事件触发。 - 弹窗显示:
if (this.showAddInkstone)渲染AddInkstoneModal。showAddInkstone由"➕开砚"按钮的onClick设为true,由弹窗内部的"取消"/"确认"按钮的onClick通过onClose回调设为false。
这种"状态即UI"(State is UI)的范式是现代前端框架的核心思想——UI是状态的纯函数,给定相同的状态必然产生相同的UI。这使得应用的行为完全可预测、可调试、可测试。
十三、技术要点对比表
| 技术维度 | 本应用实现方案 | 设计意图与优势 |
|---|---|---|
| 状态管理 | @State(10个) + @Observed类 + @ObjectLink引用 |
单一数据源 + 自动响应式更新,避免手动同步 |
| 组件通信 | Props单向数据流 + 回调函数(onClose/onAdd/onDel/onDetail/onEdit) | 父→子通过Props,子→父通过回调,清晰的单向数据流 |
| 路由方案 | if-else if-else条件渲染 + 枚举索引 |
适合Tab数量固定(5个)的场景,简单直接无额外依赖 |
| 弹窗模式 | 条件渲染 + 全屏遮罩 + 居中卡片 + 回调关闭 | 统一的模态交互规范,遮罩阻止背景交互 |
| 列表渲染 | ForEach + 三段式键值('prefix'+name+index) |
高效diff、防冲突、可预测的节点复用 |
| 布局策略 | layoutWeight弹性权重 + 固定像素结合 |
两头固定中间弹性的经典移动端布局 |
| 颜色系统 | 19色接口约束 + hex+alpha透明度拼接 | 编译期类型安全 + 运行时动态透明度 |
| 数据建模 | 5个业务接口 + 4个图表接口 + 纯函数颜色映射 | 类型安全 + 逻辑集中 + 可独立测试 |
| 动画交互 | .rotate() + .translate() + .animation() 声明式动画 |
状态驱动动画,无需手动管理动画生命周期 |
| 斑马纹列表 | i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt |
偶数行白色、奇数行浅米,增强列表可读性 |
十四、总结与展望
14.1 架构设计总结
"端砚坊"应用虽然是一个单文件Demo,但其内部蕴含的架构思想值得认真总结:
分层清晰,职责分明。从顶部的类型定义层到底部的子组件层,每一层都有明确的单一职责。类型定义层管"契约",常量实例层管"数据",响应式类管"状态",工具函数层管"逻辑",入口组件管"路由",子组件管"展示"。这种分层使得无论是添加新功能、修bug、还是做性能优化,都能快速定位到正确的层次。
接口先行,类型安全。所有数据结构都先定义接口,再实现具体数据。TypeScript编译器在编译阶段就能捕获大部分类型错误,大大降低了运行时出错的概率。19色调色板接口、5组业务实体接口、4组图表接口——合计超过10个接口定义,构筑了坚实的类型安全防线。
数据驱动,声明式渲染。UI完全由状态驱动,没有命令式的DOM操作。ForEach遍历数据数组生成列表,if条件根据状态渲染不同组件,@Observed/@ObjectLink自动追踪数据变化并刷新UI。这种范式让代码更接近"描述UI应该是什么样子"而非"如何一步步构建UI"。
14.2 技术亮点回顾
回顾全文,以下几个技术点尤其值得关注和借鉴:
- 19色古雅色彩体系:以"紫石·墨绿·暖金"为核心的三色系设计,配合精细的透明度梯度(5%/8%/18%/25%/33%/40%),在视觉上完美还原了端砚文化的温润质感。接口约束 + 常量实例化的双层设计,兼顾了类型安全和运行效率。
@Observed+@ObjectLink的深度运用:将全部业务数据封装在一个可观察类中,通过@ObjectLink引用传递给5个子组件,实现了真正的"单一数据源 + 双向绑定"。任何数据变化都会自动级联刷新所有相关UI,无需手动编写事件监听。- 纯函数颜色映射层:5个无副作用的颜色映射函数,将业务规则(价格区间、石质等级、评价标签等)映射到视觉语义(颜色情感)。逻辑集中、可测试、可复用。
layoutWeight驱动的数据可视化:不依赖任何图表库,仅用ArkTS内置的layoutWeight属性就实现了4种精美的条形图/柱状图。这种"零依赖"的数据可视化方案在轻量级应用中极具实用价值。- 统一的弹窗交互规范:4个弹窗共享相同的结构模板(遮罩+居中卡片+标题栏+内容区+操作栏),仅在内容和按钮细节上差异化。这种一致性设计大幅降低了用户的认知负荷。
14.3 优化建议与展望
尽管本应用在架构和实现上已经相当成熟,但从工程实践的角度,仍有以下几点可以进一步优化:
第一,状态管理的精细化。当前10个@State变量全部集中在根组件中,随着功能增长可能导致"状态爆炸"。建议引入轻量级状态管理方案(如ArkTS的@Provide/@Consume或AppStorage),将弹窗状态下沉到各自的父组件中,根组件只保留全局路由状态。
第二,网络数据对接的准备。当前所有数据均为静态硬编码。InkstoneData类的数组属性已经为未来对接真实后端API留出了清晰的接口——只需将静态初始化改为异步fetch调用,UI层无需任何修改。这种"函数化的封装方式"体现了良好的架构前瞻性。
第三,组件的进一步拆分。当前每个内容组件(如InkstoneContent)内部同时包含了图表卡片和列表卡片两种UI形态。如果未来需要单独复用"周销量图表"或"端砚列表",可以考虑将它们拆分为独立的子组件(WeeklyChart、InkstoneList),提升复用性。
第四,无障碍访问(Accessibility)的支持。当前组件未设置accessibilityText等无障碍属性。对于一款面向文化传播的应用,确保视障用户也能通过屏幕阅读器获取端砚信息,是产品完善度的重要维度。
第五,国际化(i18n)的预留。虽然"端砚坊"面向中文用户,但如果未来需要推出多语言版本(如面向海外藏家的英文版),建议将所有硬编码的中文字符串提取到资源文件(string.json)中,通过$r()引用。当前的TAB_LIST、静态数据中的中文字符串都需要做相应改造。
总而言之,"端砚坊"应用以其精巧的古雅色彩体系、严谨的类型安全设计、高效的响应式数据流和优雅的组件化架构,为我们提供了一个学习鸿蒙ArkTS声明式UI开发的优秀范本。从"能写出能跑的代码"到"写出架构清晰、可维护、可扩展的代码",这正是每一位鸿蒙开发者应当追求的成长路径。愿这篇深度解析能为你的进阶之路提供一份有价值的参考。
十五、附录:DevEco Studio 安装与环境搭建
为方便读者将本文分析的"端砚坊"代码真正跑起来,下面补充一份 DevEco Studio 的安装与环境配置简明教程(配图来源于官方文档)。
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,设置完务必重启:

新建一个空白模板:

设置API为24的模板项目:

初始化项目,自动下载相关依赖:

完整代码
interface InkstonePalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
cardAlt: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
line: string;
success: string;
warning: string;
danger: string;
white: string;
endPurple: string;
inkGreen: string;
}
const COLORS: InkstonePalette = {
primary: '#6E5A7C',
primaryLight: '#9A86A8',
primaryDark: '#4A3A58',
accent: '#3E4A5A',
accentLight: '#7A8A9A',
bg: '#F2EDE4',
cardBg: '#FFFFFF',
cardAlt: '#EDE6DA',
textPrimary: '#332B38',
textSecondary: '#756A7C',
textHint: '#A79BA8',
border: '#E1D8CC',
line: '#F0EAE0',
success: '#5E8A6E',
warning: '#C9A24B',
danger: '#9E2E20',
white: '#FFFFFF',
endPurple: '#6E5A7C',
inkGreen: '#3E4A5A'
};
enum InkstoneTab {
INKSTONE = 0,
STONE = 1,
CARVE = 2,
ORDER = 3,
REVIEW = 4
}
interface TabMeta {
key: string;
icon: string;
label: string;
color: string;
}
const TAB_LIST: TabMeta[] = [
{ key: 'inkstone', icon: '🪨', label: '砚台', color: '#6E5A7C' },
{ key: 'stone', icon: '⛰️', label: '砚石', color: '#7A8A9A' },
{ key: 'carve', icon: '🔪', label: '雕工', color: '#3E4A5A' },
{ key: 'order', icon: '📦', label: '订单', color: '#C9A24B' },
{ key: 'review', icon: '⭐', label: '客评', color: '#9E2E20' }
];
const INKSTONE_COL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
interface InkstoneItem {
name: string;
pit: string;
stone: string;
price: number;
emoji: string;
}
interface StoneItem {
name: string;
texture: string;
level: number;
place: string;
emoji: string;
}
interface CarveItem {
name: string;
style: string;
level: number;
tool: string;
emoji: string;
}
interface InkstoneOrderItem {
name: string;
region: string;
count: number;
amount: number;
emoji: string;
}
interface InkstoneReviewItem {
name: string;
score: number;
date: string;
content: string;
tag: string;
emoji: string;
}
interface WeekInkstoneMeta {
day: string;
value: number;
}
interface PitShareMeta {
name: string;
count: number;
color: string;
}
interface CarveHotMeta {
name: string;
heat: number;
color: string;
}
interface InkstoneTopMeta {
name: string;
sold: number;
color: string;
}
const WEEK_SOLD: WeekInkstoneMeta[] = [
{ day: '周一', value: 16 },
{ day: '周二', value: 21 },
{ day: '周三', value: 19 },
{ day: '周四', value: 26 },
{ day: '周五', value: 30 },
{ day: '周六', value: 42 },
{ day: '周日', value: 37 }
];
const PIT_SHARE: PitShareMeta[] = [
{ name: '老坑', count: 5, color: '#6E5A7C' },
{ name: '坑仔岩', count: 4, color: '#7A8A9A' },
{ name: '麻子坑', count: 3, color: '#3E4A5A' },
{ name: '宋坑', count: 2, color: '#C9A24B' }
];
const CARVE_HOT: CarveHotMeta[] = [
{ name: '开膛取石', heat: 97, color: '#6E5A7C' },
{ name: '磨墨理堂', heat: 95, color: '#7A8A9A' },
{ name: '浮雕龙纹', heat: 94, color: '#3E4A5A' },
{ name: '镂雕云月', heat: 93, color: '#5E8A6E' },
{ name: '线刻铭文', heat: 92, color: '#C9A24B' },
{ name: '上蜡养护', heat: 90, color: '#9E2E20' }
];
const INKSTONE_TOP: InkstoneTopMeta[] = [
{ name: '老坑端砚', sold: 97, color: '#6E5A7C' },
{ name: '坑仔岩砚', sold: 93, color: '#7A8A9A' },
{ name: '麻子坑砚', sold: 89, color: '#3E4A5A' },
{ name: '绿端砚', sold: 86, color: '#5E8A6E' },
{ name: '宋坑砚', sold: 82, color: '#C9A24B' },
{ name: '紫端砚', sold: 78, color: '#9E2E20' }
];
@Observed
export class InkstoneData {
inkstones: InkstoneItem[] = [
{ name: '老坑端砚', pit: '老坑', stone: '端石', price: 1280, emoji: '🪨' },
{ name: '坑仔岩砚', pit: '坑仔岩', stone: '端石', price: 980, emoji: '⛰️' },
{ name: '麻子坑砚', pit: '麻子坑', stone: '端石', price: 880, emoji: '🌑' },
{ name: '宋坑砚', pit: '宋坑', stone: '端石', price: 680, emoji: '🏮' },
{ name: '梅花坑砚', pit: '梅花坑', stone: '端石', price: 720, emoji: '🌸' },
{ name: '绿端砚', pit: '绿端', stone: '绿石', price: 1080, emoji: '💚' },
{ name: '白端砚', pit: '白端', stone: '白石', price: 880, emoji: '🤍' },
{ name: '紫端砚', pit: '紫端', stone: '紫石', price: 920, emoji: '💜' },
{ name: '水岩砚', pit: '水岩', stone: '端石', price: 1580, emoji: '💧' },
{ name: '朝天岩砚', pit: '朝天岩', stone: '端石', price: 760, emoji: '🔭' },
{ name: '宣德岩砚', pit: '宣德岩', stone: '端石', price: 860, emoji: '🏯' },
{ name: '古塔岩砚', pit: '古塔岩', stone: '端石', price: 820, emoji: '🗼' }
];
stones: StoneItem[] = [
{ name: '老坑石', texture: '冰纹', level: 96, place: '西江', emoji: '❄️' },
{ name: '坑仔岩石', texture: '青花', level: 95, place: '羚羊峡', emoji: '💠' },
{ name: '麻子坑石', texture: '火捺', level: 94, place: '老坑下', emoji: '🔥' },
{ name: '宋坑石', texture: '猪肝紫', level: 93, place: '北岭', emoji: '🐷' },
{ name: '梅花坑石', texture: '梅花点', level: 92, place: '九龙山', emoji: '🌸' },
{ name: '绿端石', texture: '翠绿', level: 95, place: '七星岩', emoji: '💚' },
{ name: '白端石', texture: '凝脂白', level: 93, place: '羚羊峡', emoji: '🤍' },
{ name: '朝天岩石', texture: '细罗纹', level: 92, place: '北岭', emoji: '🌀' },
{ name: '宣德岩石', texture: '金星点', level: 91, place: '西江', emoji: '✨' },
{ name: '古塔岩石', texture: '水波纹', level: 90, place: '古塔山', emoji: '🌊' },
{ name: '斧柯石', texture: '石眼', level: 94, place: '斧柯山', emoji: '👁️' },
{ name: '菱角石', texture: '青苔纹', level: 91, place: '西江底', emoji: '🪸' }
];
carves: CarveItem[] = [
{ name: '双龙戏珠', style: '浮雕', level: 96, tool: '凿', emoji: '🐉' },
{ name: '云海月明', style: '浅刻', level: 95, tool: '刀', emoji: '🌙' },
{ name: '荷塘清趣', style: '镂雕', level: 94, tool: '钻', emoji: '🪷' },
{ name: '松鹤延年', style: '浮雕', level: 95, tool: '凿', emoji: '🦢' },
{ name: '兰亭序', style: '线刻', level: 93, tool: '针', emoji: '📜' },
{ name: '福禄寿', style: '圆雕', level: 94, tool: '刀', emoji: '🧧' },
{ name: '竹报平安', style: '浅刻', level: 92, tool: '刀', emoji: '🎋' },
{ name: '凤穿牡丹', style: '镂雕', level: 95, tool: '钻', emoji: '🦚' },
{ name: '五福捧寿', style: '浮雕', level: 93, tool: '凿', emoji: '🦇' },
{ name: '山水清音', style: '浅刻', level: 92, tool: '刀', emoji: '🏔️' },
{ name: '游龙戏水', style: '线刻', level: 91, tool: '针', emoji: '🐲' },
{ name: '鲤鱼跃龙门', style: '镂雕', level: 94, tool: '钻', emoji: '🐟' }
];
orders: InkstoneOrderItem[] = [
{ name: '书协雅集', region: '华东', count: 300, amount: 270000, emoji: '🖌️' },
{ name: '文房商号', region: '华南', count: 500, amount: 320000, emoji: '🏪' },
{ name: '书画院', region: '华中', count: 260, amount: 234000, emoji: '🎨' },
{ name: '国学院', region: '华北', count: 220, amount: 176000, emoji: '📖' },
{ name: '海外文房', region: '海外', count: 180, amount: 198000, emoji: '🌏' },
{ name: '文创买手店', region: '华东', count: 400, amount: 240000, emoji: '🎁' },
{ name: '茶空间连锁', region: '华中', count: 150, amount: 120000, emoji: '🍵' },
{ name: '酒店书吧', region: '华北', count: 120, amount: 96000, emoji: '🏨' },
{ name: '拍卖行定制', region: '华南', count: 60, amount: 150000, emoji: '🔨' },
{ name: '学校书法课', region: '西南', count: 350, amount: 105000, emoji: '🎒' },
{ name: '景区文创', region: '西南', count: 300, amount: 120000, emoji: '🏞️' },
{ name: '私人定制', region: '华东', count: 80, amount: 160000, emoji: '💎' }
];
reviews: InkstoneReviewItem[] = [
{ name: '书协理事', score: 5, date: '09-03', content: '老坑端砚下发墨快,研出的墨汁润泽细腻,行笔顺滑。', tag: '下发墨好', emoji: '🖌️' },
{ name: '文房店主', score: 5, date: '09-02', content: '石眼天成纹理雅致,客人见之即爱,回购率很高。', tag: '纹理雅致', emoji: '🏪' },
{ name: '画院院长', score: 5, date: '09-01', content: '墨堂深而堂底平,蓄墨不涸,作画连用数日不需添墨。', tag: '蓄墨不涸', emoji: '🎨' },
{ name: '国学院长', score: 5, date: '08-31', content: '砚形端庄厚重,置于案头稳如磐石,气度不凡。', tag: '端方厚重', emoji: '📖' },
{ name: '海外藏家', score: 5, date: '08-30', content: '漂洋过海无损,包装厚实,石皮包浆自然。', tag: '包装厚实', emoji: '🌏' },
{ name: '买手店主', score: 5, date: '08-29', content: '坑仔岩水波纹如云似水,当摆件也当砚台。', tag: '一物两用', emoji: '🎁' },
{ name: '茶室主人', score: 5, date: '08-28', content: '茶台旁置一方绿端,客来品茗赏砚,雅趣横生。', tag: '雅趣横生', emoji: '🍵' },
{ name: '酒店采购', score: 4, date: '08-27', content: '书吧配砚氛围好,若附砚匣更显档次。', tag: '氛围好', emoji: '🏨' },
{ name: '书法老师', score: 4, date: '08-26', content: '学生用砚磨感顺,若加刻姓名更贴心。', tag: '磨感顺滑', emoji: '🎒' },
{ name: '拍卖顾问', score: 5, date: '08-25', content: '水岩砚石眼灿然,拍场估价高出一筹。', tag: '石眼灿然', emoji: '🔨' },
{ name: '景区店长', score: 5, date: '08-24', content: '游客人手一方小砚,走量飞快还当伴手礼。', tag: '走量飞快', emoji: '🏞️' },
{ name: '老砚友', score: 5, date: '08-23', content: '第三方老坑砚了,越用越润,包浆喜人。', tag: '越用越润', emoji: '💎' }
];
}
function getPitColor(pit: string): string {
if (pit === '老坑') {
return '#6E5A7C';
} else if (pit === '坑仔岩') {
return '#7A8A9A';
} else if (pit === '麻子坑') {
return '#3E4A5A';
} else if (pit === '宋坑') {
return '#C9A24B';
}
return '#5E8A6E';
}
function getStoneColor(level: number): string {
if (level >= 95) {
return '#6E5A7C';
} else if (level >= 92) {
return '#7A8A9A';
}
return '#3E4A5A';
}
function getInkstoneColor(price: number): string {
if (price >= 1200) {
return '#9E2E20';
} else if (price >= 900) {
return '#6E5A7C';
} else if (price >= 700) {
return '#7A8A9A';
}
return '#5E8A6E';
}
function getOrderColor(amount: number): string {
if (amount >= 250000) {
return '#9E2E20';
} else if (amount >= 150000) {
return '#6E5A7C';
} else if (amount >= 100000) {
return '#7A8A9A';
}
return '#5E8A6E';
}
function getReviewTagColor(tag: string): string {
if (tag === '下发墨好' || tag === '蓄墨不涸' || tag === '石眼灿然' || tag === '越用越润') {
return '#6E5A7C';
} else if (tag === '纹理雅致' || tag === '端方厚重' || tag === '包装厚实') {
return '#7A8A9A';
} else if (tag === '一物两用' || tag === '磨感顺滑' || tag === '氛围好') {
return '#5E8A6E';
} else if (tag === '雅趣横生' || tag === '走量飞快') {
return '#C9A24B';
}
return '#9E2E20';
}
@Entry
@Component
struct InkstoneApp {
@State curTab: number = 0;
@State data: InkstoneData = new InkstoneData();
@State showAddInkstone: boolean = false;
@State showEditOrder: boolean = false;
@State showDeleteStone: boolean = false;
@State showDetailCarve: boolean = false;
@State delStoneName: string = '';
@State detailCarveName: string = '';
@State brushRotate: boolean = false;
@State inkFloat: boolean = false;
@Builder modalOverlay(onClose: () => void) {
Column() {
Text('')
.width(0)
.height(0)
.opacity(0)
Button('')
.width(1)
.height(1)
.opacity(0)
.onClick(() => {
onClose();
})
}
.width(1)
.height(1)
}
build() {
Column() {
Column() {
Column() {
Row() {
Column() {
Text('🪨 端砚坊')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('Inkstone · 紫石发墨 一砚千秋')
.fontSize(10)
.fontColor(COLORS.accentLight)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Row() {
Text('🖋️')
.fontSize(20)
.onClick(() => {
this.brushRotate = !this.brushRotate;
})
.rotate({ angle: this.brushRotate ? 90 : 0 })
.animation({ duration: 700, curve: Curve.EaseOut })
Text('💧')
.fontSize(16)
.margin({ left: 10 })
.onClick(() => {
this.inkFloat = !this.inkFloat;
})
.translate({ x: this.inkFloat ? 14 : -8, y: this.inkFloat ? -8 : 0 })
.animation({ duration: 620, curve: Curve.EaseOut })
Text('🖌️')
.fontSize(14)
.margin({ left: 6 })
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(COLORS.accentLight + '2E')
.borderRadius(24)
.border({ width: 1, color: COLORS.accentLight + '80' })
}
.width('100%')
Row() {
ForEach(INKSTONE_COL, (r: number) => {
Row() {
if (r % 3 === 0) {
Column()
.width(12)
.height(10)
.backgroundColor(COLORS.inkGreen)
.borderRadius(6)
.border({ width: 2, color: COLORS.white })
} else if (r % 3 === 1) {
Column()
.width(8)
.height(8)
.backgroundColor(COLORS.accentLight)
.borderRadius(4)
} else {
Column()
.width(5)
.height(5)
.backgroundColor(COLORS.white)
.borderRadius(3)
}
}
.width(14)
.justifyContent(FlexAlign.Center)
}, (r: number) => 'inkstone' + r)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ top: 12 })
Row() {
Column()
.layoutWeight(1)
.height(1)
.backgroundColor(COLORS.accentLight + '66')
Text('🖌️ 温润如紫玉 · 发墨似春泉 💧')
.fontSize(9)
.fontColor(COLORS.accentLight)
.margin({ left: 8, right: 8 })
Column()
.layoutWeight(1)
.height(1)
.backgroundColor(COLORS.accentLight + '66')
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor(COLORS.primaryDark)
if (this.curTab === InkstoneTab.INKSTONE) {
InkstoneContent({ data: this.data, onAdd: () => {
this.showAddInkstone = true;
} })
} else if (this.curTab === InkstoneTab.STONE) {
StoneContent({ data: this.data, onDel: (n: string) => {
this.delStoneName = n;
this.showDeleteStone = true;
} })
} else if (this.curTab === InkstoneTab.CARVE) {
CarveContent({ data: this.data, onDetail: (n: string) => {
this.detailCarveName = n;
this.showDetailCarve = true;
} })
} else if (this.curTab === InkstoneTab.ORDER) {
InkstoneOrderContent({ data: this.data, onEdit: () => {
this.showEditOrder = true;
} })
} else {
InkstoneReviewContent({ data: this.data })
}
}
.width('100%')
.height('100%')
Column() {
Column()
.width('100%')
.height(3)
.backgroundColor(COLORS.primary)
Row() {
ForEach(TAB_LIST, (t: TabMeta) => {
Column() {
Text(t.icon)
.fontSize(19)
Text(t.label)
.fontSize(10)
.fontColor(this.curTab === TAB_LIST.indexOf(t) ? t.color : COLORS.textHint)
.fontWeight(this.curTab === TAB_LIST.indexOf(t) ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.padding({ top: 7, bottom: 7 })
.backgroundColor(this.curTab === TAB_LIST.indexOf(t) ? t.color + '14' : '#00000000')
.borderRadius(18)
.border(this.curTab === TAB_LIST.indexOf(t) ? { width: 1, color: t.color } : { width: 0 })
.onClick(() => {
this.curTab = TAB_LIST.indexOf(t);
})
}, (t: TabMeta) => t.key)
}
.width('100%')
.height(60)
.padding({ left: 8, right: 8 })
.backgroundColor(COLORS.cardBg)
}
.width('100%')
if (this.showAddInkstone) {
AddInkstoneModal({
onClose: () => {
this.showAddInkstone = false;
}
})
}
if (this.showEditOrder) {
EditInkstoneOrderModal({
onClose: () => {
this.showEditOrder = false;
}
})
}
if (this.showDeleteStone) {
DeleteStoneModal({
title: this.delStoneName, onClose: () => {
this.showDeleteStone = false;
}
})
}
if (this.showDetailCarve) {
DetailCarveModal({
name: this.detailCarveName, onClose: () => {
this.showDetailCarve = false;
}
})
}
}
}
}
@Component
struct InkstoneTag {
@Prop text: string;
@Prop color: string;
build() {
Text(this.text)
.fontSize(9)
.fontColor(this.color)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.backgroundColor(this.color + '14')
.borderRadius(10)
.border({ width: 1, color: this.color + '40' })
}
}
@Component
struct InkstoneContent {
@ObjectLink data: InkstoneData;
onAdd: () => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Column()
.width(12)
.height(10)
.backgroundColor(COLORS.inkGreen)
.borderRadius(6)
.border({ width: 2, color: COLORS.white })
Column()
.layoutWeight(1)
.height(2)
.backgroundColor(COLORS.primary + '55')
.margin({ left: 4, right: 4 })
Column()
.width(8)
.height(8)
.backgroundColor(COLORS.accentLight)
.borderRadius(4)
.margin({ right: 4 })
Column()
.width(12)
.height(10)
.backgroundColor(COLORS.inkGreen)
.borderRadius(6)
.border({ width: 2, color: COLORS.white })
Column()
.layoutWeight(1)
.height(2)
.backgroundColor(COLORS.primary + '55')
.margin({ left: 4, right: 4 })
Column()
.width(12)
.height(10)
.backgroundColor(COLORS.inkGreen)
.borderRadius(6)
.border({ width: 2, color: COLORS.white })
}
.width('100%')
.margin({ bottom: 10 })
Row() {
Text('📈 本周砚台销量')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('周六42方')
.fontSize(9)
.fontColor(COLORS.primary)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
ForEach(WEEK_SOLD, (w: WeekInkstoneMeta) => {
Column() {
Text(w.value + '')
.fontSize(8)
.fontColor(w.value >= 37 ? COLORS.primary : COLORS.accentLight)
Column()
.width(16)
.height(w.value)
.backgroundColor(w.value >= 37 ? COLORS.primary : COLORS.accentLight)
.borderRadius(8)
.margin({ top: 4 })
Text(w.day)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (w: WeekInkstoneMeta) => w.day)
}
.width('100%')
.height(150)
.alignItems(VerticalAlign.Bottom)
.margin({ top: 10 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
.border({ width: 1, color: COLORS.primary + '55' })
Column() {
Row() {
Text('🪨 端砚名品')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击➕开砚')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
Text('➕')
.fontSize(14)
.margin({ left: 8 })
.onClick(() => {
this.onAdd();
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.inkstones, (p: InkstoneItem, i: number) => {
Row() {
Column() {
Column() {
Text(p.emoji)
.fontSize(18)
.textAlign(TextAlign.Center)
}
.width(44)
.height(44)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor(getInkstoneColor(p.price) + '14')
.borderRadius(28)
.border({ width: 1, color: getInkstoneColor(p.price) + '66' })
Column()
.width(14)
.height(14)
.backgroundColor(getInkstoneColor(p.price))
.borderRadius(7)
.border({ width: 2, color: COLORS.white })
.margin({ top: 5 })
}
.width(60)
.padding({ top: 10, bottom: 6 })
.backgroundColor(getInkstoneColor(p.price) + '0D')
.borderRadius(28)
.border({ width: 1, color: getInkstoneColor(p.price) + '33' })
Column() {
Row() {
Text(p.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('¥' + p.price)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(getInkstoneColor(p.price))
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
InkstoneTag({ text: p.pit + '料', color: getPitColor(p.pit) })
InkstoneTag({ text: p.stone, color: COLORS.accentLight })
Text('天工开砚')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.Start)
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
}
.width('100%')
.padding(10)
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(18)
.border({ width: 1, color: COLORS.line })
.margin({ top: 8 })
}, (p: InkstoneItem, i: number) => 'ik' + p.name + i)
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
}
}
@Component
struct StoneContent {
@ObjectLink data: InkstoneData;
onDel: (n: string) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('🏆 砚台销量 TOP6')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('老坑端砚居首')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(INKSTONE_TOP, (t: InkstoneTopMeta) => {
Row() {
Text(t.name)
.fontSize(10)
.fontColor(COLORS.textPrimary)
.width(90)
Row() {
Column()
.layoutWeight(t.sold)
.height(12)
.backgroundColor(t.color)
.borderRadius(6)
Column()
.layoutWeight(100 - t.sold)
.height(12)
.backgroundColor(COLORS.line)
.borderRadius(6)
}
.layoutWeight(1)
Text(t.sold + '方')
.fontSize(9)
.fontColor(t.color)
.width(40)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 6 })
}, (t: InkstoneTopMeta) => t.name)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
.border({ width: 1, color: COLORS.primary + '55' })
Column() {
Row() {
Text('⛰️ 砚石库')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击🗑撤石')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.stones, (c: StoneItem, i: number) => {
Row() {
Column() {
Text(c.emoji)
.fontSize(18)
.textAlign(TextAlign.Center)
Row() {
Column()
.width(10)
.height(10)
.backgroundColor(getStoneColor(c.level))
.borderRadius(5)
.border({ width: 2, color: COLORS.white })
Column()
.width(8)
.height(8)
.backgroundColor(COLORS.accentLight)
.borderRadius(4)
.margin({ left: 4 })
}
.width(28)
.justifyContent(FlexAlign.Center)
.margin({ top: 5 })
}
.width(56)
.padding({ top: 10, bottom: 6 })
.backgroundColor(getStoneColor(c.level) + '0D')
.borderRadius(16)
.border({ width: 1, color: getStoneColor(c.level) + '33' })
Column() {
Row() {
Text(c.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(c.texture + '纹')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
Text('石质')
.fontSize(9)
.fontColor(COLORS.textHint)
Row() {
Column()
.layoutWeight(c.level)
.height(6)
.backgroundColor(getStoneColor(c.level))
.borderRadius(3)
Column()
.layoutWeight(100 - c.level)
.height(6)
.backgroundColor(COLORS.line)
.borderRadius(3)
}
.layoutWeight(1)
.margin({ left: 6 })
}
.width('100%')
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
Text('🗑️')
.fontSize(14)
.onClick(() => {
this.onDel(c.name);
})
}
.width('100%')
.padding(10)
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(18)
.border({ width: 1, color: COLORS.line })
.margin({ top: 8 })
}, (c: StoneItem, i: number) => 'sn' + c.name + i)
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
}
}
@Component
struct CarveContent {
@ObjectLink data: InkstoneData;
onDetail: (n: string) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('🔥 雕工热度')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('开膛取石97')
.fontSize(9)
.fontColor(COLORS.primary)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(CARVE_HOT, (d: CarveHotMeta) => {
Row() {
Text(d.name)
.fontSize(10)
.fontColor(COLORS.textPrimary)
.width(80)
Row() {
Column()
.layoutWeight(d.heat)
.height(14)
.backgroundColor(d.color)
.borderRadius(7)
Column()
.layoutWeight(100 - d.heat)
.height(14)
.backgroundColor(COLORS.line)
.borderRadius(7)
}
.layoutWeight(1)
Text(d.heat + '')
.fontSize(9)
.fontColor(d.color)
.width(32)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 6 })
}, (d: CarveHotMeta) => d.name)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
.border({ width: 1, color: COLORS.primary + '55' })
Column() {
Row() {
Text('🔪 雕工名作')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击👁️了解')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.carves, (d: CarveItem, i: number) => {
Row() {
Column() {
Text(d.emoji)
.fontSize(18)
.textAlign(TextAlign.Center)
Column()
.width(14)
.height(14)
.backgroundColor(getStoneColor(d.level))
.borderRadius(7)
.border({ width: 2, color: COLORS.white })
.margin({ top: 5 })
}
.width(56)
.padding({ top: 10, bottom: 6 })
.backgroundColor(getStoneColor(d.level) + '0D')
.borderRadius(16)
.border({ width: 1, color: getStoneColor(d.level) + '33' })
Column() {
Row() {
Text(d.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(d.style)
.fontSize(9)
.fontColor(getStoneColor(d.level))
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
InkstoneTag({ text: d.tool + '法', color: COLORS.textSecondary })
Text('工级' + d.level)
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.Start)
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
Text('👁️')
.fontSize(14)
.onClick(() => {
this.onDetail(d.name);
})
}
.width('100%')
.padding(10)
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(18)
.border({ width: 1, color: COLORS.line })
.margin({ top: 8 })
}, (d: CarveItem, i: number) => 'cv' + d.name + i)
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
}
}
@Component
struct InkstoneOrderContent {
@ObjectLink data: InkstoneData;
onEdit: () => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('🧩 坑口构成')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('老坑占比最高')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(PIT_SHARE, (s: PitShareMeta) => {
Row() {
Column()
.width(8)
.height(8)
.backgroundColor(s.color)
.borderRadius(4)
Text(s.name)
.fontSize(10)
.fontColor(COLORS.textPrimary)
.margin({ left: 6 })
Text('×' + s.count)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ left: 4 })
Row() {
Column()
.layoutWeight(s.count)
.height(10)
.backgroundColor(s.color)
.borderRadius(5)
Column()
.layoutWeight(20 - s.count)
.height(10)
.backgroundColor(COLORS.line)
.borderRadius(5)
}
.layoutWeight(1)
.margin({ left: 10 })
}
.width('100%')
.margin({ top: 6 })
}, (s: PitShareMeta) => s.name)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
.border({ width: 1, color: COLORS.primary + '55' })
Column() {
Row() {
Text('📦 订单台账')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('点击✏️改单')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
Text('✏️')
.fontSize(14)
.margin({ left: 8 })
.onClick(() => {
this.onEdit();
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 10 })
ForEach(this.data.orders, (o: InkstoneOrderItem, i: number) => {
Row() {
Text(o.emoji)
.fontSize(16)
Column() {
Text(o.name)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(o.region + ' · ' + o.count + '方')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text('¥' + o.amount)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(getOrderColor(o.amount))
Text('✏️')
.fontSize(12)
.margin({ left: 10 })
.onClick(() => {
this.onEdit();
})
}
.width('100%')
.padding(10)
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(18)
.border({ width: 1, color: COLORS.line })
.margin({ top: 8 })
}, (o: InkstoneOrderItem, i: number) => 'od' + o.name + i)
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
}
}
@Component
struct InkstoneReviewContent {
@ObjectLink data: InkstoneData;
build() {
Scroll() {
Column() {
Row() {
Text('⭐ 客评口碑')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('12条好评 · 平均4.9')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ bottom: 6 })
ForEach(this.data.reviews, (r: InkstoneReviewItem, i: number) => {
Column() {
Row() {
Text(r.emoji)
.fontSize(15)
Column() {
Text(r.name)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(r.date)
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ top: 1 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 8 })
Text('⭐'.repeat(r.score))
.fontSize(10)
.fontColor(COLORS.warning)
InkstoneTag({ text: r.tag, color: getReviewTagColor(r.tag) })
}
.width('100%')
Text(r.content)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.lineHeight(16)
.margin({ top: 6 })
}
.width('100%')
.padding(12)
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(18)
.border({ width: 1, color: COLORS.line })
.margin({ top: 8 })
}, (r: InkstoneReviewItem, i: number) => 'rv' + r.name + i)
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
}
.width('100%')
.constraintSize({ maxHeight: '100%' })
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
}
}
@Component
struct AddInkstoneModal {
onClose: () => void = () => {};
build() {
Column() {
Column() {
Row() {
Text('🪨 定制砚台')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('✕')
.fontSize(16)
.fontColor(COLORS.textHint)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
Text('砚名')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('老坑端砚')
.fontSize(11)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 14 })
Row() {
Text('坑口')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('老坑 · 端石 · 天工开砚')
.fontSize(11)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('预算')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('¥1280')
.fontSize(11)
.fontColor(COLORS.primary)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('取消')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.borderRadius(12)
.border({ width: 1, color: COLORS.border })
.onClick(() => {
this.onClose();
})
Text('确认定制')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.backgroundColor(COLORS.primary)
.borderRadius(12)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: 14 })
}
.width('86%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.constraintSize({ maxHeight: '78%' })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#66000000')
}
}
@Component
struct EditInkstoneOrderModal {
onClose: () => void = () => {};
build() {
Column() {
Column() {
Row() {
Text('✏️ 修改订单')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('✕')
.fontSize(16)
.fontColor(COLORS.textHint)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
Text('客户')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('书协雅集')
.fontSize(11)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 14 })
Row() {
Text('数量')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('300 → 330 方')
.fontSize(11)
.fontColor(COLORS.primary)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('金额')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('¥297000')
.fontSize(11)
.fontColor(COLORS.accent)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('取消')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.borderRadius(12)
.border({ width: 1, color: COLORS.border })
.onClick(() => {
this.onClose();
})
Text('保存修改')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.backgroundColor(COLORS.primary)
.borderRadius(12)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: 14 })
}
.width('86%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.constraintSize({ maxHeight: '78%' })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#66000000')
}
}
@Component
struct DeleteStoneModal {
@Prop title: string;
onClose: () => void = () => {};
build() {
Column() {
Column() {
Text('🗑️ 撤下砚石')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('确定撤下「' + this.title + '」吗?')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.margin({ top: 10 })
Text('撤下的砚石将从石库移除。')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 4 })
Row() {
Text('再想想')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.borderRadius(12)
.border({ width: 1, color: COLORS.border })
.onClick(() => {
this.onClose();
})
Text('确认撤下')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.backgroundColor(COLORS.danger)
.borderRadius(12)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: 16 })
}
.width('86%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.constraintSize({ maxHeight: '78%' })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#66000000')
}
}
@Component
struct DetailCarveModal {
@Prop name: string;
onClose: () => void = () => {};
build() {
Column() {
Column() {
Row() {
Text('👁️ 雕工详情')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('✕')
.fontSize(16)
.fontColor(COLORS.textHint)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Column() {
Text('🔪')
.fontSize(30)
Text(this.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ top: 6 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 12 })
Text('端砚雕工重因石施艺:先相石理脉,再定池堂深浅。浮雕龙纹取石眼为珠,镂雕云月透光见影,浅刻山水疏密有致。一刀一凿皆留锋痕,砚成之日须以细砂水磨,方显紫石温润。')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.lineHeight(17)
.margin({ top: 10 })
Row() {
Text('技法')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('浮雕 · 凿法')
.fontSize(11)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Row() {
Text('匠人评级')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('甲级 · 刻砚卅载')
.fontSize(11)
.fontColor(COLORS.primary)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(10)
.margin({ top: 8 })
Text('知道了')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 18, right: 18, top: 7, bottom: 7 })
.backgroundColor(COLORS.primary)
.borderRadius(12)
.margin({ top: 12 })
.onClick(() => {
this.onClose();
})
}
.width('86%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.constraintSize({ maxHeight: '78%' })
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#66000000')
}
}更多推荐



所有评论(0)