前言

在HarmonyOS应用开发中,ArkTS作为声明式UI开发语言,以其简洁、高效、类型安全的特性受到了开发者的广泛关注。本文将通过一个完整的艺术展览管理系统案例,逐段解析其源码实现,深入剖析每一个代码段背后的设计思路、技术选型和工程实践。本案例涵盖了状态管理、自定义组件、数据可视化、模态弹窗、Tab页面切换、Swiper轮播等HarmonyOS ArkTS开发中的核心知识点,非常适合作为进阶学习材料。

在这里插入图片描述

本应用以美术馆/展览馆为业务场景,实现了展览信息展示、艺术品库管理(含增删改查)、活动日程时间线、票务订单管理等四大核心模块,完整展示了一个中大型HarmonyOS应用的架构设计思路。


一、数据模型层:接口定义与类型系统

1.1 接口定义

interface ExhibitionMeta {
  name: string; hall: string; startDate: string; endDate: string
  theme: string; description: string; cover: string
}
interface ArtTypeMeta { label: string; icon: string; color: string }
interface EventItem {
  id: number; name: string; type: string; date: string
  time: string; location: string; speaker: string; capacity: number
}
interface TicketOrder {
  id: number; exhibitionId: number; visitor: string
  date: string; count: number; price: number; status: string
}
interface HallChartItem { label: string; value: number; color: string }
interface TrendChartItem { label: string; value: number }

在这里插入图片描述

深度解析:

这段代码定义了整个应用的数据契约(Data Contract),是整个应用的"骨架"。在ArkTS开发中,interface是TypeScript类型系统的核心特性,它为数据对象提供了明确的形状约束。

  • ExhibitionMeta接口:描述了一个展览的元信息,包含名称(name)、展厅(hall)、起止日期(startDate/endDate)、主题(theme)、描述(description)和封面色值(cover)。其中cover字段设计为颜色字符串(如#FF6B6B)而非图片URL,这是一种轻量化的设计选择——在原型展示阶段,使用纯色背景代替图片资源,避免了对网络加载和本地资源的依赖,降低了开发复杂度,同时也保持了视觉层次感。

  • ArtTypeMeta接口:定义了艺术品类型的元数据模型,包含显示标签(label)、图标字符(icon)和主题色(color)。将类型元信息抽象为独立接口而非硬编码在组件中,是一种关注点分离的优秀实践,使得类型配置可以集中维护、灵活扩展。

  • EventItem接口:活动事件的完整数据模型,包含活动ID、名称、类型标签、日期、时间、地点、演讲者/主持人、容量限制。这是一个典型的"实体"设计,所有字段都是基础类型,便于序列化和传输。

  • TicketOrder接口:票务订单数据模型,通过exhibitionId字段建立了与展览的关联关系(外键式设计),status字段使用字符串表示订单状态(已售/验票/退票),这种设计在原型阶段简洁直观,但在生产环境中建议改用枚举类型以获得更强的类型安全。

  • HallChartItem与TrendChartItem:两个专门为数据可视化组件设计的数据接口,分别用于展厅展品数量柱状图和月度参观人数趋势图。将图表数据抽象为独立接口,使得图表组件可以接收标准化数据,与业务逻辑解耦。

值得注意的是,所有接口都采用了"扁平化"设计(没有嵌套结构),这在移动端开发中是一个常见的优化策略——扁平结构的数据更容易在UI中直接绑定,减少了深层数据访问带来的性能开销。


1.2 可观察数据类:ArtworkItem

@Observed
export class ArtworkItem {
  id: number = 0
  name: string = ''
  artist: string = ''
  type: string = ''
  year: number = 0
  exhibitionId: number = 0
  hall: string = ''
  description: string = ''
  value: number = 0
  material: string = ''
  constructor(id: number, name: string, artist: string, type: string, year: number,
    exhibitionId: number, hall: string, description: string, value: number, material: string) {
    this.id = id
    this.name = name
    this.artist = artist
    this.type = type
    this.year = year
    this.exhibitionId = exhibitionId
    this.hall = hall
    this.description = description
    this.value = value
    this.material = material
  }
}

在这里插入图片描述

深度解析:

这段代码是整个应用中最关键的数据类定义,其中蕴含了ArkTS响应式状态管理的核心技术要点:

  • @Observed装饰器:这是ArkTS状态管理框架的核心装饰器之一。当我们在类上使用@Observed时,ArkUI的渲染引擎会为该类的所有属性自动安装"深度观察"能力。这意味着当ArtworkItem实例的任何属性(如name、artist、value等)发生变化时,所有引用该实例的UI组件都会自动触发重新渲染。这是实现"数据驱动UI"的基石。

  • 为什么其他接口用interface而这里用@Observed class:这是一个精心考量的架构决策。ExhibitionMeta、EventItem、TicketOrder这些数据类型在本应用中不会被直接修改(它们是只读的展示数据),因此使用轻量的interface即可。而ArtworkItem需要支持编辑操作——用户在编辑弹窗中修改属性后,UI需要实时反映变化,因此必须使用@Observed装饰的类来确保状态变化的可观测性。

  • 构造函数参数顺序设计:构造函数接收11个参数,顺序遵循了"身份信息 -> 创作信息 -> 归属信息 -> 详细描述"的逻辑分组。虽然参数较多,但在本案例的Mock数据场景中是可接受的。在生产环境中,可以考虑使用Builder模式或工厂方法来简化对象创建。

  • 默认值初始化:所有属性都给出了类型匹配的默认值(数字为0,字符串为空串),这是ArkTS的严格要求——类属性必须在声明时或构造函数中初始化,否则编译器会报错。这种严格性在编译期就能捕获大量潜在的空引用错误。

  • value字段:代表艺术品的估值,使用number类型存储金额。在金融级应用中,由于浮点数精度问题,通常建议使用分/厘为单位的整数来表示金额,但在本艺术管理场景中,估值主要是展示用途,使用浮点数是合理的简化。


二、常量数据层:展览与艺术品类型配置

2.1 展览数据配置

const EXHIBITIONS: Record<string, ExhibitionMeta> = {
  'expo1': { name: '时光之眼', hall: '主展厅A', startDate: '2024-03-01', endDate: '2024-06-30',
    theme: '穿越时空的艺术对话', description: '汇聚古今中外艺术精品,以时间的维度重新审视艺术流变', cover: '#FF6B6B' },
  'expo2': { name: '数字幻境', hall: '主展厅B', startDate: '2024-04-15', endDate: '2024-08-15',
    theme: 'AI与新媒体艺术的碰撞', description: '探索人工智能与数字技术如何重塑当代艺术表达', cover: '#4ECDC4' },
  'expo3': { name: '墨韵千秋', hall: '书法厅', startDate: '2024-05-01', endDate: '2024-09-30',
    theme: '中国传统书画艺术大展', description: '从魏晋风骨到现代水墨,呈现千年书画艺术传承', cover: '#2C3E50' },
  'expo4': { name: '凝视之间', hall: '影像厅', startDate: '2024-06-01', endDate: '2024-10-31',
    theme: '当代摄影与视觉叙事', description: '通过镜头记录时代的脉搏,探索视觉叙事的无限可能', cover: '#3498DB' },
  'expo5': { name: '造物之光', hall: '雕塑厅', startDate: '2024-07-01', endDate: '2024-11-30',
    theme: '雕塑与空间艺术的对话', description: '从传统雕塑到空间装置,探索三维艺术的边界', cover: '#E67E22' },
  'expo6': { name: '跨界共生', hall: '当代艺术厅', startDate: '2024-08-01', endDate: '2024-12-31',
    theme: '跨媒介艺术实验展', description: '打破艺术门类边界,呈现多媒介融合的创新实践', cover: '#9B59B6' }
}

在这里插入图片描述

深度解析:

这段代码展示了HarmonyOS应用中静态配置数据的管理模式:

  • Record<string, ExhibitionMeta>类型:这是TypeScript工具类型Record的经典用法,定义了一个键为字符串、值为ExhibitionMeta的字典类型。使用Record而非普通对象类型,既能获得类型推导的好处,又保持了字典操作的灵活性。

  • 使用字符串键(‘expo1’~‘expo6’)而非数字索引:这是一个重要的设计决策。字符串键具有自描述性,便于在代码中直接引用(如EXHIBITIONS['expo1']),且不依赖数组顺序。这种Key-Value模式在HarmonyOS开发中非常常见,尤其是当数据需要被唯一标识且可能被跨模块引用时。

  • 展览日期的交错设计:六个展览的起止日期呈现阶梯式排列(从3月到12月依次展开),模拟了真实场景中展览的滚动排期。这种时间线设计使得系统在任何时间点都有"进行中"和"即将开始"的展览,为票务管理和活动日程提供了丰富的上下文。

  • cover颜色选择策略:六个展览使用了高区分度的色彩方案——暖色系(#FF6B6B珊瑚红、#E67E22橙色)、冷色系(#4ECDC4青色、#3498DB蓝色)、中性色(#2C3E50深灰蓝、#9B59B6紫色),确保在Swiper轮播中每个展览卡片在视觉上都有鲜明的辨识度。

  • 展厅分配逻辑:六个展览分布在主展厅A/B、书法厅、影像厅、雕塑厅、当代艺术厅六个不同空间,形成了一对一的映射关系,但代码中并未强制这种约束,保留了灵活性(例如未来可以有多个展览共享同一展厅)。


2.2 艺术品类型配置

const ART_TYPES: Record<string, ArtTypeMeta> = {
  'oil': { label: '油画', icon: '', color: '#F44336' },
  'sculpture': { label: '雕塑', icon: '', color: '#795548' },
  'installation': { label: '装置', icon: '', color: '#607D8B' },
  'photo': { label: '摄影', icon: '', color: '#2196F3' },
  'calligraphy': { label: '书法', icon: '', color: '#212121' },
  'digital': { label: '数字艺术', icon: '', color: '#9C27B0' }
}

在这里插入图片描述

深度解析:

这是一个典型的枚举配置映射模式,将业务中的分类概念转化为结构化的配置数据:

  • 色彩语义化设计:每种艺术类型的颜色都经过精心挑选,具有强烈的文化暗示——油画的#F44336(红色)暗示了油画颜料的热烈与厚重;雕塑的#795548(棕色)呼应了青铜、石材等传统雕塑材料;装置艺术的#607D8B(蓝灰色)传达了科技感和工业感;摄影的#2196F3(蓝色)暗示了暗房中的蓝色安全灯;书法的#212121(近黑色)对应了水墨的黑;数字艺术的#9C27B0(紫色)则代表了虚拟与创新。

  • icon字段使用Unicode字符:虽然本例中使用了通用符号,但在实际应用中,可以根据不同类型使用更具表现力的图标字符或Symbol字体中的图标,实现"零图片资源"的图标方案,大幅减少应用包体积。

  • 与ArtworkItem.type的关联设计:ArtworkItem.type字段存储的是这里的key值(如'oil'、'sculpture'),通过ART_TYPES[item.type]即可获取显示信息。这种"存储ID、查询配置"的模式是前端开发中极其常见的**数据规范化(Data Normalization)**实践,避免了在数据对象中冗余存储显示文本和样式信息。


三、Mock数据层:模拟真实业务数据

3.1 艺术品数据生成

function getMockArtworks(): ArtworkItem[] {
  return [
    new ArtworkItem(1, '晨光', '林风眠', 'oil', 2023, 1, '主展厅A', '描绘晨曦中江南水乡的油画作品', 500000, '布面油画'),
    new ArtworkItem(2, '星空变奏', '赵无极', 'oil', 2022, 1, '主展厅A', '抽象表现主义星空系列代表作', 1200000, '布面油画'),
    // ... 共27件艺术品
    new ArtworkItem(27, '水墨意象', '刘国松', 'calligraphy', 2024, 3, '书法厅', '现代水墨实验作品', 230000, '宣纸综合材料'),
  ]
}

深度解析:

这个Mock数据生成函数是整个应用的数据核心,体现了多条工程实践原则:

  • 使用new ArtworkItem()构造函数创建对象:而不是使用对象字面量。这一选择至关重要——因为ArtworkItem被@Observed装饰,只有通过构造函数创建的实例才能被ArkUI的响应式系统正确追踪。如果使用普通对象字面量({ id: 1, name: '...', ... }),即使形状匹配ArtworkItem接口,也不会具备可观察性。

  • 数据覆盖的全面性:27件艺术品精心分配在六个展览中,覆盖了所有六种艺术类型(油画5件、雕塑4件、装置4件、摄影4件、书法5件、数字艺术5件),每种类型都有多个代表性作品。这种均衡分布确保了展厅图表数据的丰富性,避免了某个展厅或类型为空的边界情况。

  • 艺术家名称的真实性:代码中使用了真实的知名艺术家姓名(林风眠、赵无极、吴冠中、teamLab等),这大大增强了Demo的"沉浸感"和说服力。在实际开发中,虽然最终会替换为真实数据,但Mock数据的质量直接影响开发和测试的效果。

  • exhibitionId从1开始的映射逻辑:exhibitionId使用16的数字,对应`EXHIBITIONS`中的第16个展览(通过数组索引exhibitionKeys[item.exhibitionId - 1]访问)。注意这里采用了"1-based indexing"而非程序中常见的"0-based indexing",这是一种偏向业务语义的设计——对业务人员来说,"展览编号1"比"展览编号0"更直观。

  • 估值字段的量级设计:从16万到120万不等,符合高端艺术品市场的真实估值范围,使得票务管理模块中的价格数据更具真实感。


3.2 活动事件数据

function getMockEvents(): EventItem[] {
  return [
    { id: 1, name: '开幕仪式', type: '开幕式', date: '2024-03-01', time: '10:00', location: '主展厅A', speaker: '馆长 李明', capacity: 200 },
    { id: 2, name: '策展人导览', type: '导览', date: '2024-03-08', time: '14:00', location: '主展厅A', speaker: '策展人 张艺', capacity: 50 },
    // ... 共17场活动
  ]
}

深度解析:

活动日程数据的设计体现了业务场景的多样性覆盖:

  • 活动类型丰富:17场活动涵盖了开幕式、导览、工作坊、讲座、特别活动、鉴赏、预展、表演、研讨会等9种类型,完整模拟了艺术展馆的运营生态。每种活动类型的capacity(容量)也符合其场景特征——开幕仪式容量200人、VIP鉴赏会仅40人、艺术之夜可达500人。

  • 时间线的合理性:活动日期与展览日期严格对应——每个展览的开幕式都在该展览的startDate当天举办,后续活动也都在展期内。这种数据一致性对于Demo展示至关重要。

  • 演讲者角色的多样性:馆长、策展人、艺术家、摄影师、雕塑家、鉴定专家、拍卖师、教育总监等不同角色,体现了展馆运营的跨部门协作特征。


3.3 票务订单数据与过滤函数

function getMockTickets(): TicketOrder[] {
  return [
    { id: 1, exhibitionId: 1, visitor: '张三', date: '2024-03-05', count: 2, price: 120, status: '已售' },
    // ... 共12笔订单
  ]
}

function getArtworksByExhibition(id: number, artworks: ArtworkItem[]): ArtworkItem[] {
  return artworks.filter((a: ArtworkItem) => a.exhibitionId === id)
}

深度解析:

  • 票务数据的状态分布:12笔订单中,"已售"7笔、"验票"3笔、"退票"2笔,形成了一个接近真实运营比例的分布。这种精心设计的数据使得票务Tab页的统计摘要(已售/验票/退票计数)能够展示有意义的数字。

  • getArtworksByExhibition过滤函数:这是一个纯函数,接收展览ID和艺术品数组,返回属于该展览的艺术品子集。将其抽取为独立函数而非内联在组件中,体现了函数式编程的思想——逻辑复用、可测试性强、与UI层解耦。该函数在"当前展览"Tab页中被调用来筛选特定展览的艺术品展示。


四、枚举定义与组件架构

enum ExhibitionTab { EXHIBITION = 0, ARTWORKS = 1, EVENTS = 2, TICKETS = 3 }

深度解析:

  • 枚举作为Tab索引:使用枚举类型而非魔法数字来表示Tab页索引,极大提升了代码的可读性和可维护性。在条件判断(if (this.activeTab === ExhibitionTab.EXHIBITION))中,枚举值自描述其含义,消除了if (tab === 0)这种晦涩的编码方式。

  • 数值从0开始:与前面展览ID从1开始不同,Tab枚举从0开始,遵循了编程中索引从0开始的惯例,用于数组下标和条件判断。


五、数据可视化组件:自定义图表

5.1 展厅展品数量柱状图

@Component
struct HallBarChart {
  @Prop data: HallChartItem[]
  @Prop maxVal: number = 0
  build() {
    Column() {
      Text('各展厅展品数量')
        .fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333')
        .margin({ bottom: 12 })
      Row() {
        ForEach(this.data, (item: HallChartItem) => {
          Column() {
            Text(item.value.toString())
              .fontSize(11).fontColor('#666').margin({ bottom: 4 })
            Column()
              .width(36)
              .height(this.maxVal > 0 ? item.value / this.maxVal * 120 : 0)
              .backgroundColor(item.color)
              .borderRadius({ topLeft: 4, topRight: 4 })
            Text(item.label)
              .fontSize(10).fontColor('#999').maxLines(1).margin({ top: 4 })
          }.alignItems(HorizontalAlign.Center).layoutWeight(1)
        })
      }.width('100%').alignItems(VerticalAlign.Bottom).padding({ top: 8 })
    }.width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(10).margin({ top: 8 })
  }
}

深度解析:

这是一个完全使用纯声明式UI绘制的柱状图组件,没有依赖任何第三方图表库,展示了ArkTS强大的布局能力:

  • @Prop装饰器的使用:@Prop表示"单向父子组件数据传递"。父组件传入data和maxVal后,子组件可以读取但不应修改这些值。这与@Link(双向绑定)形成对比——图表组件只需要展示数据,不需要回写数据,因此@Prop是正确的选择。

  • 柱高的比例计算:item.value / this.maxVal * 120——通过当前值与最大值的比值乘以120(最大柱高像素),实现等比例缩放。当maxVal为0时返回0高度,这是一个防御性编程的体现。这种归一化计算确保了无论数据如何变化,柱状图都能正确适配。

  • borderRadius({ topLeft: 4, topRight: 4 }):只对顶部两个角做圆角,底部保持直角,模拟了传统柱状图的"底部对齐"视觉效果。这种细节处理展现了UI实现的精致度。

  • layoutWeight(1):使每根柱子平均分配水平空间。配合外层Row的width('100%'),无论有多少展厅数据,柱子都能均匀分布。

  • alignItems(VerticalAlign.Bottom):外层Row的底部对齐,确保所有柱子从同一基线向上生长,这是柱状图的标准布局。

  • 白色卡片式设计:白色背景 + 圆角 + 内边距,形成了一个"卡片"容器,与灰色背景(#F0F2F5)形成层次感。


5.2 月度参观人数趋势图

@Component
struct VisitorTrendChart {
  @Prop data: TrendChartItem[]
  @Prop maxVal: number = 0
  build() {
    Column() {
      Text('月度参观人数趋势')
        .fontSize(16).fontWeight(FontWeight.Bold).fontColor('#333')
        .margin({ bottom: 12 })
      Row() {
        ForEach(this.data, (item: TrendChartItem) => {
          Column() {
            Text(item.value.toString())
              .fontSize(10).fontColor('#4A90D9').margin({ bottom: 4 })
            Column()
              .width(28)
              .height(this.maxVal > 0 ? item.value / this.maxVal * 100 : 0)
              .backgroundColor('#4A90D9')
              .borderRadius({ topLeft: 3, topRight: 3 })
            Text(item.label)
              .fontSize(10).fontColor('#999').margin({ top: 4 })
          }.alignItems(HorizontalAlign.Center).layoutWeight(1)
        })
      }.width('100%').alignItems(VerticalAlign.Bottom).padding({ top: 8 })
    }.width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(10).margin({ top: 10, bottom: 10 })
  }
}

深度解析:

趋势图组件与柱状图组件采用了相同的设计模式,但在细节上有差异化处理:

  • 统一色调:所有柱子使用同一个颜色(#4A90D9),这是趋势图与分类柱状图的视觉区分策略——分类图用多色区分类别,趋势图用单色突出趋势走向。

  • 柱宽和最大高度不同:趋势图柱宽28px(vs 柱状图36px)、最大高度100px(vs 120px),这是因为趋势图有12个月份(数据点更多),需要更窄的柱子来适配屏幕宽度。

  • 组件复用思考:两个图表组件结构高度相似,在实际项目中可以考虑抽象出一个通用的BarChart组件,通过参数控制颜色策略(单色/多色)、尺寸等。但本案例保持了两个独立组件,牺牲了一定复用性,换取了代码的可读性和可定制性——这对于教学Demo来说是合理的选择。


六、主组件:ExhibitionManager 状态管理与生命周期

6.1 状态声明矩阵

@Entry
@Component
struct ExhibitionManager {
  @State artworks: ArtworkItem[] = getMockArtworks()
  @State events: EventItem[] = getMockEvents()
  @State tickets: TicketOrder[] = getMockTickets()
  @State activeTab: ExhibitionTab = ExhibitionTab.EXHIBITION
  @State currentExhibitIdx: number = 0
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State selectedArtwork: ArtworkItem | null = null
  @State addFormName: string = ''
  @State addFormArtist: string = ''
  @State addFormTypeIdx: number = 0
  @State addFormYear: string = ''
  @State addFormExhibitionIdx: number = 0
  @State addFormHall: string = ''
  @State addFormDescription: string = ''
  @State addFormValue: string = ''
  @State addFormMaterial: string = ''
  @State editFormName: string = ''
  @State editFormArtist: string = ''
  @State editFormTypeIdx: number = 0
  @State editFormYear: string = ''
  @State editFormExhibitionIdx: number = 0
  @State editFormHall: string = ''
  @State editFormDescription: string = ''
  @State editFormValue: string = ''
  @State editFormMaterial: string = ''
  @State typeKeys: string[] = []
  @State typeLabels: string[] = []
  @State exhibitionKeys: string[] = []
  @State exhibitionLabels: string[] = []
  @State hallChartData: HallChartItem[] = []
  @State hallChartMax: number = 0
  @State trendChartData: TrendChartItem[] = []
  @State trendChartMax: number = 0
  @State nextArtworkId: number = 28

深度解析:

这是整个应用状态架构的全景视图,约40个@State变量构成了应用完整的状态树。我们可以将这些状态按功能域进行分组分析:

核心业务数据状态(3个):

  • artworks、events、tickets:三个核心数据数组,是应用的主要内容状态。任何数据变更都会触发UI刷新。

导航与选择状态(2个):

  • activeTab:当前激活的Tab页索引,控制底部导航栏高亮和内容区域切换。
  • currentExhibitIdx:当前选中的展览索引,控制Swiper轮播位置和展览详情展示。

模态弹窗控制状态(3个):

  • showAddModal、showEditModal、showDeleteModal:三个布尔值分别控制新增、编辑、删除确认弹窗的显示/隐藏。使用独立的布尔标志而非统一的modalType枚举,简化了条件渲染逻辑。

表单数据状态(18个):

  • 新增表单9个字段(addForm*)+ 编辑表单9个字段(editForm*):两套完全独立的表单状态。虽然存在一定的冗余,但这种设计避免了新增和编辑之间的状态冲突——用户在新增表单中填写的内容不会影响编辑表单,反之亦然。

配置派生状态(4个):

  • typeKeys、typeLabels、exhibitionKeys、exhibitionLabels:从常量配置中提取的键/标签数组,用于TextPicker组件的数据源。

图表数据状态(4个):

  • hallChartData、hallChartMax、trendChartData、trendChartMax:图表组件的数据源,由computeHallChartData()和computeTrendChartData()计算得出。

ID自增计数器(1个):

  • nextArtworkId:自增ID生成器,确保新增艺术品有唯一标识。初始值为28(因为Mock数据已有27件)。

这种扁平化状态管理方案在中小型应用中非常实用。对于更大规模的应用,可以考虑引入@Provide/@Consume进行跨组件状态共享,或使用状态管理框架(如AppStorage)来避免组件间深层的回调传递。


6.2 生命周期初始化

aboutToAppear(): void {
  this.typeKeys = Object.keys(ART_TYPES)
  this.typeLabels = this.typeKeys.map((k: string) => ART_TYPES[k].label)
  this.exhibitionKeys = Object.keys(EXHIBITIONS)
  this.exhibitionLabels = this.exhibitionKeys.map((k: string) => EXHIBITIONS[k].name)
  this.computeHallChartData()
  this.computeTrendChartData()
}

深度解析:

aboutToAppear()是ArkTS组件的生命周期钩子,在组件即将显示之前被调用,相当于传统前端框架中的mounted或created。

  • 配置数据预处理:通过Object.keys()提取字典的键数组,再通过map()提取标签数组。这种预处理避免了在UI渲染时反复执行字典查询操作,是一种计算前置化的优化策略。

  • 图表数据初始化:在组件创建时就完成图表数据的计算,确保图表在用户首次切换到"艺术品库"Tab时就能立即渲染,无需等待。

  • 执行顺序的意义:先初始化配置数据(typeKeys等),再计算图表数据——因为图表计算不依赖配置数据,但配置数据可能在后续的表单逻辑中被立即使用。不过从代码依赖角度看,这里的顺序调换也不会产生问题,说明初始化逻辑之间保持了良好的独立性。


6.3 图表数据计算方法

computeHallChartData(): void {
  const hallCount: Record<string, number> = {}
  const hallColors: string[] = ['#F44336', '#2196F3', '#4CAF50', '#FF9800', '#9C27B0', '#795548']
  let colorIdx: number = 0
  for (let i = 0; i < this.artworks.length; i++) {
    const hall = this.artworks[i].hall
    if (hallCount[hall] === undefined) { hallCount[hall] = 0 }
    hallCount[hall] = hallCount[hall] + 1
  }
  const halls: string[] = Object.keys(hallCount)
  const result: HallChartItem[] = []
  let maxVal: number = 0
  for (let i = 0; i < halls.length; i++) {
    const val = hallCount[halls[i]]
    result.push({ label: halls[i], value: val, color: hallColors[colorIdx % hallColors.length] })
    colorIdx = colorIdx + 1
    if (val > maxVal) { maxVal = val }
  }
  this.hallChartData = result
  this.hallChartMax = maxVal
}

深度解析:

这个方法实现了一个完整的聚合统计->格式化->渲染的数据处理管道:

  • 聚合阶段:遍历所有艺术品,使用Record<string, number>字典统计每个展厅的作品数量。使用for循环而非reduce(),这是ArkTS中性能更优的写法(避免了闭包创建和函数调用开销)。

  • 颜色分配策略:预定义了6种颜色,通过colorIdx % hallColors.length循环分配。即使展厅数量超过6个(虽然不太可能),颜色也能循环使用不会越界。

  • 同时计算最大值:在遍历生成结果数组的同时记录maxVal,避免了额外的遍历,是O(n)时间复杂度的高效实现。

  • 触发时机:此方法在三个地方被调用——初始化(aboutToAppear)、添加艺术品后(addArtwork)、编辑/删除艺术品后(updateArtwork、confirmDeleteArtwork)。这种"数据变更->重新计算"的模式确保了图表数据始终与业务数据同步。

computeTrendChartData(): void {
  const months: string[] = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
  const values: number[] = [820, 650, 1200, 1500, 1350, 1680, 1900, 2100, 1850, 1720, 1580, 2300]
  const result: TrendChartItem[] = []
  let maxVal: number = 0
  for (let i = 0; i < months.length; i++) {
    result.push({ label: months[i], value: values[i] })
    if (values[i] > maxVal) { maxVal = values[i] }
  }
  this.trendChartData = result
  this.trendChartMax = maxVal
}

深度解析:

与展厅图表的动态计算不同,趋势图使用的是硬编码的模拟数据。这是合理的业务设计——月度参观人数通常来自后端统计系统,前端只需要展示数据即可。在真实项目中,这里会改为从网络API获取数据,但数据处理逻辑(格式化、求最大值)会保持不变。

数据呈现的参观趋势特征:年初较低(1-2月春节前后),3月随"时光之眼"展览开幕大幅上升,之后随展览增多逐步攀升,8月达到高峰(2100人),年末因"艺术之夜"等特别活动再次冲高至2300人。这种数据"故事性"使得Demo展示更加生动可信。


6.4 业务逻辑方法

onEditArtwork(item: ArtworkItem): void {
  this.selectedArtwork = item
  this.editFormName = item.name
  this.editFormArtist = item.artist
  this.editFormTypeIdx = this.typeKeys.indexOf(item.type) >= 0 ? this.typeKeys.indexOf(item.type) : 0
  this.editFormYear = item.year.toString()
  this.editFormExhibitionIdx = item.exhibitionId - 1
  this.editFormHall = item.hall
  this.editFormDescription = item.description
  this.editFormValue = item.value.toString()
  this.editFormMaterial = item.material
  this.showEditModal = true
}

深度解析:

这个方法展示了数据对象到表单状态的单向映射过程:

  • 保存选中对象引用:this.selectedArtwork = item直接保存了对象的引用(而非拷贝)。由于ArtworkItem被@Observed装饰,后续对selectedArtwork属性的修改会实时反映到UI中。

  • 类型转换处理:this.typeKeys.indexOf(item.type) >= 0 ? this.typeKeys.indexOf(item.type) : 0——通过防御性检查确保类型键存在,不存在时回退到索引0。year和value从number转为string以适配TextInput的文本输入。

  • 索引偏移处理:this.editFormExhibitionIdx = item.exhibitionId - 1——将1-based的exhibitionId转换为0-based的数组索引,用于TextPicker的selected属性。

addArtwork(): void {
  if (this.addFormName.trim() === '') { return }
  const newArtwork: ArtworkItem = new ArtworkItem(
    this.nextArtworkId, this.addFormName, this.addFormArtist,
    this.typeKeys[this.addFormTypeIdx], parseInt(this.addFormYear) || 2024,
    this.addFormExhibitionIdx + 1, this.addFormHall, this.addFormDescription,
    parseInt(this.addFormValue) || 0, this.addFormMaterial
  )
  this.artworks = this.artworks.concat([newArtwork])
  this.nextArtworkId = this.nextArtworkId + 1
  this.showAddModal = false
  this.resetAddForm()
  this.computeHallChartData()
}

深度解析:

这是新增艺术品的完整流程,每一步都有其设计考量:

  • 输入校验:if (this.addFormName.trim() === '') { return }——仅校验名称非空。在实际应用中,应增加更多校验(艺术家非空、年份合法性等),但Demo中保持了简洁。

  • 不可变更新模式:this.artworks = this.artworks.concat([newArtwork])——使用concat创建新数组而非push修改原数组。这是ArkTS响应式系统的关键要求:@State装饰的数组必须通过整体替换(赋新值)来触发UI刷新,直接修改数组内容(如push、splice)不会被框架检测到。

  • 自增ID:this.nextArtworkId++确保每个新艺术品都有唯一ID,避免了与现有数据的冲突。

  • 完整的后续清理:关闭弹窗 -> 重置表单 -> 重新计算图表,形成了一个完整的操作闭环。

updateArtwork(): void {
  if (this.selectedArtwork === null) { return }
  this.selectedArtwork.name = this.editFormName
  this.selectedArtwork.artist = this.editFormArtist
  this.selectedArtwork.type = this.typeKeys[this.editFormTypeIdx]
  this.selectedArtwork.year = parseInt(this.editFormYear) || 2024
  this.selectedArtwork.exhibitionId = this.editFormExhibitionIdx + 1
  this.selectedArtwork.hall = this.editFormHall
  this.selectedArtwork.description = this.editFormDescription
  this.selectedArtwork.value = parseInt(this.editFormValue) || 0
  this.selectedArtwork.material = this.editFormMaterial
  this.showEditModal = false
  this.selectedArtwork = null
  this.computeHallChartData()
}

深度解析:

与新增操作不同,编辑操作直接修改selectedArtwork的属性。由于ArtworkItem被@Observed装饰,这种属性级别的修改能够被ArkUI的响应式系统检测到,从而自动刷新UI。这是@Observed装饰器的核心价值——无需替换整个数组,只需修改对象属性即可触发局部UI更新,性能更优。

confirmDeleteArtwork(): void {
  if (this.selectedArtwork === null) { return }
  const targetId: number = this.selectedArtwork.id
  this.artworks = this.artworks.filter((a: ArtworkItem) => a.id !== targetId)
  this.showDeleteModal = false
  this.selectedArtwork = null
  this.computeHallChartData()
}

深度解析:

删除操作采用了不可变过滤模式——filter创建新数组排除目标元素,然后整体赋值给@State变量。先保存targetId再执行filter,避免在回调中访问可能为null的selectedArtwork,这是一种防御性编程。


6.5 状态颜色辅助方法

getStatusColor(status: string): string {
  if (status === '已售') { return '#4CAF50' }
  if (status === '验票') { return '#2196F3' }
  return '#F44336'
}

深度解析:

简单的状态-颜色映射函数,使用"已售"绿色、"验票"蓝色、"退票"红色的配色方案。虽然逻辑简单,但将其抽取为独立方法而非内联三元表达式,使得颜色逻辑可以集中维护。在生产环境中,建议使用枚举+映射表的方式替代if-else链。


七、UI构建层:主布局与路由

7.1 根布局

build() {
  Stack() {
    Column() {
      this.headerBuilder()
      Column() {
        if (this.activeTab === ExhibitionTab.EXHIBITION) { this.exhibitionTabBuilder() }
        if (this.activeTab === ExhibitionTab.ARTWORKS) { this.artworksTabBuilder() }
        if (this.activeTab === ExhibitionTab.EVENTS) { this.eventsTabBuilder() }
        if (this.activeTab === ExhibitionTab.TICKETS) { this.ticketsTabBuilder() }
      }.layoutWeight(1).width('100%')
      this.bottomTabsBuilder()
    }.width('100%').height('100%').backgroundColor('#F0F2F5')
    if (this.showAddModal) { Column() {}.width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)').onClick(() => { this.showAddModal = false }) }
    if (this.showAddModal) { this.addArtworkModalBuilder() }
    if (this.showEditModal) { Column() {}.width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)').onClick(() => { this.showEditModal = false }) }
    if (this.showEditModal) { this.editArtworkModalBuilder() }
    if (this.showDeleteModal) { Column() {}.width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)').onClick(() => { this.showDeleteModal = false }) }
    if (this.showDeleteModal) { this.deleteConfirmModalBuilder() }
  }.width('100%').height('100%')
}

深度解析:

这段代码是整个应用的布局骨架,采用了"Stack + Column"的分层结构:

  • Stack布局的妙用:外层使用Stack(层叠布局),将主内容层和模态弹窗层叠加在一起。模态弹窗浮在主内容之上,实现了"覆盖层"效果,而无需使用绝对定位或复杂的Z-index管理。

  • 条件渲染Tab内容:使用if条件判断根据activeTab的值渲染不同的Tab内容。注意这里没有使用Tabs组件,而是手动实现了Tab切换逻辑——这提供了更大的布局灵活性,因为自定义底部导航栏可以完全按需设计。

  • layoutWeight(1)中间区域:内容区域使用layoutWeight(1)占据剩余空间,头部和底部导航栏使用固定高度,形成经典的"头-体-尾"三段式布局。

  • 模态遮罩层:每个弹窗都配有一个全屏半透明黑色遮罩(rgba(0,0,0,0.5)),点击遮罩关闭弹窗。这是移动端模态弹窗的标准交互模式——点击外部区域关闭弹窗。

  • 模态弹窗的成对出现:遮罩层和弹窗内容是两个独立的if条件块,都依赖同一个状态变量(如showAddModal),确保遮罩和弹窗内容同步显示/隐藏。


7.2 顶部标题栏

@Builder headerBuilder() {
  Row() {
    Text('艺术展览管理')
      .fontSize(20).fontWeight(FontWeight.Bold).fontColor(Color.White)
    Text(EXHIBITIONS[this.exhibitionKeys[this.currentExhibitIdx]]?.name || '')
      .fontSize(13).fontColor('rgba(255,255,255,0.7)')
      .constraintSize({ maxWidth: '40%' })
      .maxLines(1).margin({ left: 12 })
  }.width('100%').height(48).backgroundColor('#2C3E50')
  .padding({ left: 16, right: 16 })
  .justifyContent(FlexAlign.Start).alignItems(VerticalAlign.Center)
}

深度解析:

  • 动态副标题:右侧显示当前展览名称,会随Swiper滑动实时变化(因为currentExhibitIdx是响应式的)。使用可选链操作符?.防止数组越界。

  • constraintSize({ maxWidth: '40%' }):限制副标题最大宽度为40%,防止展览名称过长挤压布局。配合maxLines(1)实现文本溢出截断。

  • 深色主题标题栏:#2C3E50(深灰蓝)作为品牌主色贯穿整个应用,从标题栏到按钮到Tab高亮都保持一致,形成了统一的视觉语言。


八、四大Tab页面详解

8.1 当前展览Tab页

@Builder exhibitionTabBuilder() {
  Column() {
    Swiper() {
      ForEach(this.exhibitionKeys, (key: string, idx: number) => {
        this.exhibitionCardBuilder(EXHIBITIONS[key], idx)
      })
    }
    .loop(true)
    .indicator(true)
    .indicatorStyle({ selectedColor: '#2C3E50', color: '#CCC' })
    .onChange((index: number) => { this.currentExhibitIdx = index })
    .width('100%').height(260)
    .padding({ top: 12, bottom: 8 })
    Scroll() {
      Column() {
        // 展览描述信息
        // 展厅与展期信息
        // 本展艺术品Grid
      }
    }.scrollable(ScrollDirection.Vertical).width('100%')
  }.width('100%')
}

深度解析:

这是整个应用视觉冲击力最强的页面,结合了Swiper轮播和瀑布式内容滚动:

  • Swiper轮播组件:loop(true)启用循环滑动,indicator(true)显示分页指示器,onChange回调同步更新currentExhibitIdx。Swiper的onChange与展览详情展示之间形成了双向数据绑定——用户滑动Swiper时,下方的展览详情、展厅信息、艺术品列表都会随之更新。

  • Swiper与详情的联动:这是一个经典的"主从联动"模式——Swiper是"主选择器",下方的描述和Grid是"从展示区"。两者通过currentExhibitIdx这个共享状态变量实现联动。

  • Grid双列布局:columnsTemplate('1fr 1fr')创建等宽双列网格,是移动端商品/图片展示的经典布局。columnsGap(10).rowsGap(10)设置统一的间距。


8.2 艺术品库Tab页

@Builder artworksTabBuilder() {
  Column() {
    Row() {
      Text('艺术品库').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#333')
      Text(' (' + this.artworks.length + '件)').fontSize(14).fontColor('#999')
      Row() {}.layoutWeight(1)
      Button() {
        Row() {
          Text('+').fontSize(18).fontColor(Color.White).margin({ right: 4 })
          Text('新增').fontSize(14).fontColor(Color.White)
        }
      }
      .height(36).backgroundColor('#2C3E50').borderRadius(8)
      .padding({ left: 12, right: 12 })
      .onClick(() => { this.showAddModal = true })
    }.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })
    Scroll() {
      Column() {
        Grid() {
          ForEach(this.artworks, (item: ArtworkItem) => {
            GridItem() { this.artworkGridItemBuilder(item) }
          })
        }.columnsTemplate('1fr 1fr').columnsGap(10).rowsGap(10)
        .padding({ left: 16, right: 16 }).width('100%')
        Column() {
          HallBarChart({ data: this.hallChartData, maxVal: this.hallChartMax })
          VisitorTrendChart({ data: this.trendChartData, maxVal: this.trendChartMax })
        }.padding({ left: 16, right: 16, bottom: 16 }).width('100%')
      }
    }.scrollable(ScrollDirection.Vertical).width('100%')
  }.width('100%')
}

深度解析:

这是功能最丰富的Tab页,集成了艺术品列表管理和数据可视化:

  • 自定义按钮内容:按钮内嵌了一个Row,包含"+"符号和"新增"文字。通过自定义按钮子组件,实现了比标准按钮更丰富的视觉效果。

  • Row() {}.layoutWeight(1)空白占位:利用一个空的Row占据剩余空间,将"新增"按钮推向右侧。这是Flexbox布局中实现"两端对齐"的常用技巧。

  • 动态计数:this.artworks.length + '件'——由于artworks是@State变量,当新增或删除艺术品时,计数会自动更新。

  • 图表区域:在艺术品Grid下方放置了两个图表组件,形成了"列表+图表"的复合布局。由于整个区域在Scroll中,用户可以平滑滚动查看所有内容。


8.3 活动日程Tab页

@Builder eventsTabBuilder() {
  Column() {
    Row() {
      Text('活动日程').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#333')
      Text(' (' + this.events.length + '场)').fontSize(14).fontColor('#999')
    }.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })
    Scroll() {
      Column() {
        ForEach(this.events, (item: EventItem) => { this.eventTimelineItemBuilder(item) })
      }.padding({ left: 16, right: 16, bottom: 16 })
    }.scrollable(ScrollDirection.Vertical).width('100%')
  }.width('100%')
}

深度解析:

活动日程Tab页结构最简洁,重点在于其时间线子组件的视觉设计:

  • 使用ForEach渲染列表:与艺术品Grid不同,活动列表使用垂直列表(Column + ForEach),因为时间线布局不适合Grid的等宽分列。

  • 纯粹的数据展示:没有新增/编辑/删除操作,说明活动数据是"只读"的(由后台管理系统维护),这符合实际业务场景——活动排期通常由运营人员在管理后台配置,而非在前端应用中操作。


8.4 票务管理Tab页

@Builder ticketsTabBuilder() {
  Column() {
    Row() {
      Text('票务管理').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#333')
      Text(' (' + this.tickets.length + '笔)').fontSize(14).fontColor('#999')
    }.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })
    Row() {
      Text('已售').fontSize(12).fontColor('#4CAF50').fontWeight(FontWeight.Medium)
      Text(' ' + this.tickets.filter((t: TicketOrder) => t.status === '已售').length + ' ')
        .fontSize(12).fontColor('#333')
      Text('验票').fontSize(12).fontColor('#2196F3').fontWeight(FontWeight.Medium)
      Text(' ' + this.tickets.filter((t: TicketOrder) => t.status === '验票').length + ' ')
        .fontSize(12).fontColor('#333')
      Text('退票').fontSize(12).fontColor('#F44336').fontWeight(FontWeight.Medium)
      Text(' ' + this.tickets.filter((t: TicketOrder) => t.status === '退票').length)
        .fontSize(12).fontColor('#333')
    }.width('100%').padding({ left: 16, right: 16, bottom: 8 })
    Scroll() {
      Column() {
        ForEach(this.tickets, (item: TicketOrder) => { this.ticketOrderItemBuilder(item) })
      }.padding({ left: 16, right: 16, bottom: 16 })
    }.scrollable(ScrollDirection.Vertical).width('100%')
  }.width('100%')
}

深度解析:

票务Tab页的特色在于实时统计摘要行:

  • 内联过滤统计:直接在UI中通过filter().length实时计算各状态的数量。虽然这种写法会在每次渲染时执行过滤操作,但对于12条数据量级来说性能影响可以忽略不计。在生产环境中,如果数据量大,建议将统计结果缓存为计算属性。

  • 颜色编码:绿色=已售、蓝色=验票、红色=退票,与订单卡片中的状态颜色保持一致,形成全局统一的颜色语义。


九、底部导航栏

@Builder bottomTabsBuilder() {
  Row() {
    Column() {
      Text(this.activeTab === ExhibitionTab.EXHIBITION ? '▪' : '')
        .fontSize(20)
        .fontColor(this.activeTab === ExhibitionTab.EXHIBITION ? '#2C3E50' : 'transparent')
      Text('当前展览')
        .fontSize(12)
        .fontColor(this.activeTab === ExhibitionTab.EXHIBITION ? '#2C3E50' : '#999')
        .fontWeight(this.activeTab === ExhibitionTab.EXHIBITION ? FontWeight.Bold : FontWeight.Normal)
    }.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center).layoutWeight(1)
    .onClick(() => { this.activeTab = ExhibitionTab.EXHIBITION })
    // ... 其余三个Tab类似
  }.width('100%').height(56).backgroundColor('#FFFFFF').padding({ top: 4, bottom: 4 })
}

深度解析:

这是一个完全自定义实现的底部导航栏,而非使用系统提供的Tabs组件:

  • 自定义激活指示器:使用实心方块字符▪作为激活指示器,选中时显示品牌色,未选中时设为transparent(透明)。这种设计比底部线条或背景色变化更轻盈优雅。

  • 统一布局模式:四个Tab使用完全相同的布局结构(Column内嵌指示器文字+标签文字),通过layoutWeight(1)均分宽度。每个Tab的高度为56px,符合Material Design底部导航栏的推荐高度。

  • 为什么不使用Tabs组件:自定义底部导航栏虽然代码量更多,但提供了完全的样式控制权——可以自定义激活动画、指示器样式、字体变化等。当系统组件无法满足设计需求时,自定义实现是正确选择。


十、展览卡片与艺术品Grid卡片

10.1 展览轮播卡片

@Builder exhibitionCardBuilder(meta: ExhibitionMeta, idx: number) {
  Column() {
    Column() {
      Text(meta.theme).fontSize(18).fontColor(Color.White).fontWeight(FontWeight.Bold)
        .textAlign(TextAlign.Center).padding({ left: 20, right: 20 })
    }.width('92%').height(150).backgroundColor(meta.cover).borderRadius(14)
    .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
    .shadow({ radius: 8, color: 'rgba(0,0,0,0.15)', offsetY: 2 })
    Column() {
      Text(meta.name).fontSize(22).fontWeight(FontWeight.Bold).fontColor('#1A1A1A').margin({ top: 10 })
      Text(meta.startDate + '  ~  ' + meta.endDate).fontSize(13).fontColor('#888').margin({ top: 4 })
      Row() {
        Text(meta.hall).fontSize(12).fontColor(Color.White)
          .backgroundColor('#2C3E50').borderRadius(4)
          .padding({ left: 8, right: 8, top: 2, bottom: 2 })
      }.margin({ top: 6 })
    }.alignItems(HorizontalAlign.Center)
  }.width('100%').padding({ left: 16, right: 16 })
}

深度解析:

展览卡片是整个应用的视觉焦点,设计层次分明:

  • "色块+文字"的极简设计:上方的彩色区域使用展览的主题色作为背景,居中显示主题文字。这种设计不需要图片资源,仅通过颜色和排版就传达了展览的调性。

  • 卡片阴影:.shadow({ radius: 8, color: 'rgba(0,0,0,0.15)', offsetY: 2 })——8px模糊半径、15%透明度的黑色阴影、向下偏移2px,模拟了自然光照下的卡片悬浮效果。

  • 展厅标签:使用深色背景白色文字的小标签展示展厅信息,与上方的彩色区域形成视觉呼应。borderRadius(4)的微圆角保持了精致感。

  • 92%宽度设计:卡片宽度为父容器的92%,两侧各留4%的间距,在Swiper滑动时露出相邻卡片的边缘,暗示了"还有更多内容"的可滑动性。


10.2 艺术品Grid卡片

@Builder artworkGridItemBuilder(item: ArtworkItem) {
  Column() {
    Column() {
      Row() {
        Text(ART_TYPES[item.type]?.icon || '').fontSize(28)
      }.width('100%').height(100)
      .backgroundColor(ART_TYPES[item.type]?.color || '#CCCCCC')
      .justifyContent(FlexAlign.Center)
      .border({ width: 3, color: '#1A1A1A' })
    }.width('100%').borderRadius(6)
    Column() {
      Text(item.name).fontSize(13).fontWeight(FontWeight.Medium).fontColor('#1A1A1A')
        .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      Text(item.artist).fontSize(11).fontColor('#888').maxLines(1).margin({ top: 2 })
      Row() {
        Text(ART_TYPES[item.type]?.label || '').fontSize(10).fontColor(Color.White)
          .backgroundColor(ART_TYPES[item.type]?.color || '#999')
          .borderRadius(3).padding({ left: 6, right: 6, top: 1, bottom: 1 })
        Text(' ' + item.hall).fontSize(10).fontColor('#AAA').maxLines(1)
      }.width('100%').margin({ top: 4 })
    }.width('100%').padding({ left: 6, right: 6, top: 6, bottom: 6 }).alignItems(HorizontalAlign.Start)
  }.width('100%').backgroundColor('#FFFFFF').borderRadius(8)
  .shadow({ radius: 3, color: 'rgba(0,0,0,0.08)', offsetY: 1 })
  .onClick(() => { this.onEditArtwork(item) })
}

深度解析:

艺术品卡片是应用中复用度最高的组件——在"当前展览"和"艺术品库"两个Tab页中都被使用:

  • 图标区替代图片区:顶部100px高的区域使用艺术类型的主题色作为背景,居中显示类型图标。border({ width: 3, color: '#1A1A1A' })模拟了画框的黑色边框,巧妙地将UI组件转化为"画作展示"的隐喻。

  • 文本溢出处理:.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })——名称和展厅信息限制为单行,超出部分以省略号截断,确保Grid布局的整齐性。

  • 类型标签:在卡片底部使用与"图标区"同色的小标签展示类型名称,形成了视觉呼应——用户可以通过颜色快速识别艺术类型。

  • 点击事件:.onClick(() => { this.onEditArtwork(item) })——点击卡片直接打开编辑弹窗。这是一种"就地编辑"的交互模式,省去了"进入详情页->点击编辑"的两步操作。


十一、时间线与票务订单组件

11.1 活动时间线

@Builder eventTimelineItemBuilder(item: EventItem) {
  Row() {
    Column() {
      Column().width(10).height(10).backgroundColor('#2C3E50').borderRadius(5)
      Column().width(2).height(60).backgroundColor('#E0E0E0')
    }.alignItems(HorizontalAlign.Center).margin({ right: 12 })
    Column() {
      Row() {
        Text(item.date).fontSize(12).fontColor('#2C3E50').fontWeight(FontWeight.Bold)
        Text('  ' + item.time).fontSize(12).fontColor('#666')
        Row() {}.layoutWeight(1)
        Text(item.type).fontSize(11).fontColor(Color.White)
          .backgroundColor('#2C3E50').borderRadius(4)
          .padding({ left: 6, right: 6, top: 1, bottom: 1 })
      }.width('100%')
      Text(item.name).fontSize(15).fontWeight(FontWeight.Medium).fontColor('#1A1A1A').margin({ top: 4 })
      Row() {
        Text(item.location + ' | ' + item.speaker).fontSize(12).fontColor('#888')
        Row() {}.layoutWeight(1)
        Text(item.capacity + '人').fontSize(11).fontColor('#AAA')
      }.width('100%').margin({ top: 3 })
    }.layoutWeight(1).backgroundColor('#FFFFFF').borderRadius(8).padding(12)
    .shadow({ radius: 2, color: 'rgba(0,0,0,0.05)', offsetY: 1 }).margin({ bottom: 10 })
  }.width('100%').alignItems(VerticalAlign.Top)
}

深度解析:

这是一个纯UI实现的时间线(Timeline)组件,无需第三方库:

  • 时间轴结构:左侧由"圆点(10x10圆形)+ 竖线(2x60矩形)"构成时间轴线,右侧是内容卡片。圆点代表事件节点,竖线代表时间流逝,这是时间线UI的标准隐喻。

  • 信息密度分层:第一行(日期时间 + 类型标签)、第二行(活动名称)、第三行(地点演讲者 + 容量),从上到下信息重要性递减,字号也相应递减。

  • 类型标签的一致性:活动类型标签使用了与底部Tab栏和按钮相同的品牌色(#2C3E50),保持了全局视觉一致性。

  • 容量的巧妙位置:将人数容量放在右下角,字号最小(11px),颜色最浅(#AAA),作为辅助信息不抢夺视觉焦点。


11.2 票务订单卡片

@Builder ticketOrderItemBuilder(item: TicketOrder) {
  Row() {
    Column() {
      Text(item.visitor).fontSize(15).fontWeight(FontWeight.Medium).fontColor('#1A1A1A')
      Text('展览: ' + (EXHIBITIONS[this.exhibitionKeys[item.exhibitionId - 1]]?.name || ''))
        .fontSize(12).fontColor('#888').margin({ top: 2 })
      Text(item.date + ' | ' + item.count + '张')
        .fontSize(11).fontColor('#AAA').margin({ top: 2 })
    }.alignItems(HorizontalAlign.Start).layoutWeight(1)
    Column() {
      Text(this.getStatusColor(item.status) === '#F44336' ? ('-' + item.price) : item.price.toString())
        .fontSize(16).fontWeight(FontWeight.Bold).fontColor(this.getStatusColor(item.status))
      Text(item.status).fontSize(12).fontColor(this.getStatusColor(item.status))
        .fontWeight(FontWeight.Medium).margin({ top: 2 })
    }.alignItems(HorizontalAlign.End)
  }.width('100%').backgroundColor('#FFFFFF').borderRadius(8)
  .padding({ left: 14, right: 14, top: 10, bottom: 10 })
  .shadow({ radius: 2, color: 'rgba(0,0,0,0.05)', offsetY: 1 })
  .margin({ bottom: 8 })
}

深度解析:

票务订单卡片采用了左右分栏布局,左侧信息、右侧金额和状态:

  • 退票金额的特殊处理:this.getStatusColor(item.status) === '#F44336' ? ('-' + item.price) : item.price.toString()——退票订单显示负数金额(如-120),这是一个精巧的UI细节,直观地传达了"退款"的概念。

  • 展览名称的反向查询:通过item.exhibitionId - 1在exhibitionKeys中查找展览名称。可选链?.防止索引越界,|| ''提供空字符串兜底。

  • 颜色状态的复用:通过getStatusColor()方法统一管理颜色逻辑,确保价格和状态文字使用相同的颜色,形成视觉关联。


十二、模态弹窗系统

12.1 新增艺术品弹窗

@Builder addArtworkModalBuilder() {
  Column() {
    Column() {
      Text('新增艺术品').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
        .margin({ bottom: 14 })
      Scroll() {
        Column() {
          // 9个表单字段,每个包含标签 + TextInput
          Text('名称').fontSize(13).fontColor('#666').margin({ bottom: 4 })
          TextInput({ text: this.addFormName, placeholder: '请输入艺术品名称' })
            .width('100%').height(40).backgroundColor('#F5F5F5').borderRadius(6)
            .padding({ left: 10, right: 10 })
            .onChange((value: string) => { this.addFormName = value })
          // ... 其余8个字段
        }
      }.constraintSize({ maxHeight: '60%' }).width('100%')
      Row() {
        Button('取消').fontSize(14).backgroundColor('#E0E0E0').fontColor('#666')
          .borderRadius(8).height(40).layoutWeight(1).margin({ right: 8 })
          .onClick(() => { this.showAddModal = false })
        Button('确认添加').fontSize(14).backgroundColor('#2C3E50').fontColor(Color.White)
          .borderRadius(8).height(40).layoutWeight(1).margin({ left: 8 })
          .onClick(() => { this.addArtwork() })
      }.width('100%').margin({ top: 14 })
    }.width('88%').backgroundColor(Color.White).borderRadius(16)
    .padding({ left: 20, right: 20, top: 20, bottom: 16 })
  }.width('100%').height('100%').justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}

深度解析:

新增弹窗是一个完整的表单UI组件,包含多个输入类型:

  • 弹窗容器设计:外层Column占满全屏并居中对齐,内层白色卡片占88%宽度。16px的大圆角使弹窗显得柔和友好。

  • Scroll包裹表单:.constraintSize({ maxHeight: '60%' })将表单区域限制在屏幕60%高度内,超出部分可滚动。这是在有限空间中展示长表单的标准做法。

  • 表单字段布局:每个字段由"标签 + 输入框"组成,标签使用浅灰色小字(13px, #666),输入框使用浅灰背景(#F5F5F5)和6px圆角,营造了简洁的表单风格。

  • TextInput双向绑定:通过onChange回调将用户输入实时同步到@State变量。虽然看起来有些冗余(每个输入框都需要写onChange),但这是ArkTS当前版本的标准数据绑定方式。

  • TextPicker选择器:艺术类型和所属展览使用TextPicker组件,以滚动选择的方式替代下拉菜单,更符合移动端的交互习惯。range属性传入标签数组,selected属性绑定当前选中索引。

  • 数字输入类型:估值字段的TextInput设置了.type(InputType.Number),在移动端会自动弹出数字键盘。

  • 底部按钮区:取消按钮使用灰色(#E0E0E0),确认按钮使用品牌色(#2C3E50),通过颜色对比引导用户操作。


12.2 编辑艺术品弹窗

@Builder editArtworkModalBuilder() {
  // ... 与新增弹窗结构类似,但有三个按钮
  Row() {
    Button('取消').fontSize(14).backgroundColor('#E0E0E0').fontColor('#666')
      .borderRadius(8).height(40).layoutWeight(1).margin({ right: 6 })
      .onClick(() => { this.showEditModal = false })
    Button('删除').fontSize(14).backgroundColor('#F44336').fontColor(Color.White)
      .borderRadius(8).height(40).layoutWeight(1).margin({ left: 3, right: 3 })
      .onClick(() => { this.showEditModal = false; this.showDeleteModal = true })
    Button('保存').fontSize(14).backgroundColor('#2C3E50').fontColor(Color.White)
      .borderRadius(8).height(40).layoutWeight(1).margin({ left: 6 })
      .onClick(() => { this.updateArtwork() })
  }.width('100%').margin({ top: 14 })
}

深度解析:

编辑弹窗与新增弹窗的主要差异在底部按钮区域:

  • 三按钮布局:取消(灰色)- 删除(红色)- 保存(品牌色),形成了"左撤右进"的操作逻辑。删除按钮使用红色(#F44336),传达了"危险操作"的视觉警示。

  • 弹窗链式跳转:点击删除按钮时,先关闭编辑弹窗(this.showEditModal = false),再打开删除确认弹窗(this.showDeleteModal = true)。这种"编辑->确认删除"的两步操作流程,防止了用户误删。

  • constraintSize({ maxHeight: '55%' }):比新增弹窗的60%稍小,因为编辑弹窗可能不需要用户填写所有字段(部分已有值),视觉上更紧凑。


12.3 删除确认弹窗

@Builder deleteConfirmModalBuilder() {
  Column() {
    Column() {
      Text('确认删除').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
        .margin({ bottom: 10 })
      Text('确定要删除这件艺术品吗?此操作不可撤销。')
        .fontSize(14).fontColor('#666').textAlign(TextAlign.Center).lineHeight(22)
        .margin({ bottom: 8 })
      if (this.selectedArtwork !== null) {
        Text('"' + this.selectedArtwork?.name + '" - ' + this.selectedArtwork?.artist)
          .fontSize(15).fontWeight(FontWeight.Medium).fontColor('#F44336')
          .textAlign(TextAlign.Center).margin({ bottom: 16 })
      }
      Row() {
        Button('取消').fontSize(14).backgroundColor('#E0E0E0').fontColor('#666')
          .borderRadius(8).height(40).layoutWeight(1).margin({ right: 8 })
          .onClick(() => { this.showDeleteModal = false })
        Button('确认删除').fontSize(14).backgroundColor('#F44336').fontColor(Color.White)
          .borderRadius(8).height(40).layoutWeight(1).margin({ left: 8 })
          .onClick(() => { this.confirmDeleteArtwork() })
      }.width('100%').margin({ top: 6 })
    }.width('75%').backgroundColor(Color.White).borderRadius(16)
    .padding({ left: 24, right: 24, top: 24, bottom: 20 })
  }.width('100%').height('100%').justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
}

深度解析:

删除确认弹窗是一个警示型对话框,设计上与其他弹窗有明显差异:

  • 更窄的宽度(75% vs 88%):确认弹窗内容较少,使用更窄的宽度使视觉焦点更集中,同时也传达了"这是一个需要慎重决策的小窗口"的心理暗示。

  • 显示待删除对象信息:以红色文字显示待删除艺术品的名称和艺术家,让用户在确认前最后核实操作对象,这是UX设计中"可逆性"原则的体现。

  • "不可撤销"警告文字:明确告知用户删除操作的后果,强化警示效果。

  • 条件渲染:if (this.selectedArtwork !== null)——虽然理论上删除确认弹窗只在有选中对象时才会出现,但这个条件判断体现了防御性编程的思想。


十三、模块关键技术点对比总结

下表从多个维度对比了本应用各模块的关键技术实现差异:

对比维度当前展览 Tab艺术品库 Tab活动日程 Tab票务管理 Tab
模块名称ExhibitionTab.EXHIBITIONExhibitionTab.ARTWORKSExhibitionTab.EVENTSExhibitionTab.TICKETS
核心功能展览轮播浏览与展览详情联动展示艺术品CRUD管理 + 数据可视化图表活动时间线只读展示票务订单列表与状态统计
主要布局组件Swiper + Scroll + GridScroll + Grid + 自定义图表Scroll + ForEach列表Scroll + ForEach列表 + Row摘要
数据绑定方式@State currentExhibitIdx 驱动联动@State artworks + @Observed ArtworkItem@State events(只读)@State tickets(只读)
状态管理要点Swiper.onChange回调同步currentExhibitIdx;展览描述/艺术品Grid随索引联动更新表单@State变量双向绑定;数组不可变更新(concat);@Observed属性级编辑无交互状态变更,纯展示实时filter统计各状态数量
关键交互Swiper滑动切换展览新增/编辑/删除弹窗,点击卡片编辑无编辑操作无编辑操作,仅展示
自定义子组件exhibitionCardBuilder、artworkGridItemBuilderartworkGridItemBuilder、HallBarChart、VisitorTrendCharteventTimelineItemBuilderticketOrderItemBuilder
模态弹窗无新增弹窗、编辑弹窗、删除确认弹窗无无
数据可视化无展厅柱状图 + 月度趋势图无无(但有色编码统计)
设计模式主从联动模式(Swiper联动详情)CRUD模式 + 弹窗表单模式时间线展示模式列表+摘要统计模式
数据可变性只读展示可增删改(核心CRUD)只读展示只读展示
核心装饰器@State、@Builder@State、@Observed、@Prop、@Builder、@Component@State、@Builder@State、@Builder

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

interface ExhibitionMeta {
  name: string; hall: string; startDate: string; endDate: string
  theme: string; description: string; cover: string
}
interface ArtTypeMeta { label: string; icon: string; color: string }
interface EventItem {
  id: number; name: string; type: string; date: string
  time: string; location: string; speaker: string; capacity: number
}
interface TicketOrder {
  id: number; exhibitionId: number; visitor: string
  date: string; count: number; price: number; status: string
}
interface HallChartItem { label: string; value: number; color: string }
interface TrendChartItem { label: string; value: number }

@Observed
export class ArtworkItem {
  id: number = 0
  name: string = ''
  artist: string = ''
  type: string = ''
  year: number = 0
  exhibitionId: number = 0
  hall: string = ''
  description: string = ''
  value: number = 0
  material: string = ''
  constructor(id: number, name: string, artist: string, type: string, year: number,
    exhibitionId: number, hall: string, description: string, value: number, material: string) {
    this.id = id
    this.name = name
    this.artist = artist
    this.type = type
    this.year = year
    this.exhibitionId = exhibitionId
    this.hall = hall
    this.description = description
    this.value = value
    this.material = material
  }
}

const EXHIBITIONS: Record<string, ExhibitionMeta> = {
  'expo1': { name: '时光之眼', hall: '主展厅A', startDate: '2024-03-01', endDate: '2024-06-30',
    theme: '穿越时空的艺术对话', description: '汇聚古今中外艺术精品,以时间的维度重新审视艺术流变', cover: '#FF6B6B' },
  'expo2': { name: '数字幻境', hall: '主展厅B', startDate: '2024-04-15', endDate: '2024-08-15',
    theme: 'AI与新媒体艺术的碰撞', description: '探索人工智能与数字技术如何重塑当代艺术表达', cover: '#4ECDC4' },
  'expo3': { name: '墨韵千秋', hall: '书法厅', startDate: '2024-05-01', endDate: '2024-09-30',
    theme: '中国传统书画艺术大展', description: '从魏晋风骨到现代水墨,呈现千年书画艺术传承', cover: '#2C3E50' },
  'expo4': { name: '凝视之间', hall: '影像厅', startDate: '2024-06-01', endDate: '2024-10-31',
    theme: '当代摄影与视觉叙事', description: '通过镜头记录时代的脉搏,探索视觉叙事的无限可能', cover: '#3498DB' },
  'expo5': { name: '造物之光', hall: '雕塑厅', startDate: '2024-07-01', endDate: '2024-11-30',
    theme: '雕塑与空间艺术的对话', description: '从传统雕塑到空间装置,探索三维艺术的边界', cover: '#E67E22' },
  'expo6': { name: '跨界共生', hall: '当代艺术厅', startDate: '2024-08-01', endDate: '2024-12-31',
    theme: '跨媒介艺术实验展', description: '打破艺术门类边界,呈现多媒介融合的创新实践', cover: '#9B59B6' }
}

const ART_TYPES: Record<string, ArtTypeMeta> = {
  'oil': { label: '油画', icon: '', color: '#F44336' },
  'sculpture': { label: '雕塑', icon: '', color: '#795548' },
  'installation': { label: '装置', icon: '', color: '#607D8B' },
  'photo': { label: '摄影', icon: '', color: '#2196F3' },
  'calligraphy': { label: '书法', icon: '', color: '#212121' },
  'digital': { label: '数字艺术', icon: '', color: '#9C27B0' }
}

function getMockArtworks(): ArtworkItem[] {
  return [
    new ArtworkItem(1, '晨光', '林风眠', 'oil', 2023, 1, '主展厅A', '描绘晨曦中江南水乡的油画作品', 500000, '布面油画'),
    new ArtworkItem(2, '星空变奏', '赵无极', 'oil', 2022, 1, '主展厅A', '抽象表现主义星空系列代表作', 1200000, '布面油画'),
    new ArtworkItem(3, '秋韵', '吴冠中', 'oil', 2021, 1, '主展厅B', '以点线面构成的秋日风景', 800000, '布面油画'),
    new ArtworkItem(4, '静物与窗', '陈逸飞', 'oil', 2023, 2, '主展厅B', '古典写实风格的静物油画', 650000, '布面油画'),
    new ArtworkItem(5, '山河', '徐冰', 'oil', 2024, 6, '当代艺术厅', '新水墨风格的大幅山水创作', 950000, '布面油彩'),
    new ArtworkItem(6, '沉思者变体', '罗丹工作室', 'sculpture', 2022, 5, '雕塑厅', '致敬经典雕塑的当代演绎', 300000, '青铜'),
    new ArtworkItem(7, '风的形状', '展望', 'sculpture', 2023, 5, '雕塑厅', '不锈钢抽象雕塑作品', 450000, '不锈钢'),
    new ArtworkItem(8, '生命的螺旋', '隋建国', 'sculpture', 2024, 1, '主展厅A', '大型装置雕塑探索生命形态', 700000, '综合材料'),
    new ArtworkItem(9, '记忆碎片', '向京', 'sculpture', 2023, 6, '当代艺术厅', '纤维与金属混合雕塑', 280000, '纤维金属'),
    new ArtworkItem(10, '数据流', '曹斐', 'installation', 2024, 6, '当代艺术厅', '多媒体交互装置实时数据可视化', 550000, '电子屏幕金属'),
    new ArtworkItem(11, '回声壁', '邱志杰', 'installation', 2023, 6, '当代艺术厅', '声音互动装置艺术作品', 380000, '音响木材'),
    new ArtworkItem(12, '光的甬道', '奥拉维尔埃利亚松', 'installation', 2024, 2, '主展厅B', '利用光与镜面创造沉浸式空间', 900000, '镜面LED'),
    new ArtworkItem(13, '重组风景', '尹秀珍', 'installation', 2023, 4, '影像厅', '旧物与影像结合的装置作品', 320000, '综合材料'),
    new ArtworkItem(14, '城市的呼吸', '王庆松', 'photo', 2024, 4, '影像厅', '大型城市景观摄影系列', 180000, '艺术微喷'),
    new ArtworkItem(15, '人像三部曲', '刘铮', 'photo', 2023, 4, '影像厅', '黑白人像摄影组合作品', 150000, '银盐相纸'),
    new ArtworkItem(16, '消逝的边界', '杉本博司', 'photo', 2024, 2, '主展厅B', '海景系列摄影作品', 500000, '明胶银盐'),
    new ArtworkItem(17, '数字面孔', '张洹', 'photo', 2023, 6, '当代艺术厅', '行为艺术摄影记录', 220000, '数码微喷'),
    new ArtworkItem(18, '兰亭序临本', '启功', 'calligraphy', 2022, 3, '书法厅', '行书临兰亭序经典作品', 200000, '宣纸墨'),
    new ArtworkItem(19, '草书千字文', '沈鹏', 'calligraphy', 2023, 3, '书法厅', '草书千字文四条屏', 350000, '宣纸墨'),
    new ArtworkItem(20, '心经篆刻', '韩天衡', 'calligraphy', 2024, 3, '书法厅', '篆书心经全文', 180000, '宣纸朱砂'),
    new ArtworkItem(21, '现代诗抄', '王冬龄', 'calligraphy', 2023, 6, '当代艺术厅', '现代书法实验性创作', 160000, '宣纸丙烯'),
    new ArtworkItem(22, '虚拟花园', 'teamLab', 'digital', 2024, 2, '主展厅B', '数字投影互动艺术作品', 750000, '投影传感'),
    new ArtworkItem(23, '算法山水', '陆扬', 'digital', 2024, 2, '主展厅B', 'AI生成动态山水画卷', 420000, 'LED屏计算机'),
    new ArtworkItem(24, '像素佛陀', '缪晓春', 'digital', 2023, 6, '当代艺术厅', '3D动画数字艺术作品', 380000, '4K屏幕'),
    new ArtworkItem(25, '数据肖像', 'aaajiao', 'digital', 2024, 6, '当代艺术厅', '生成艺术算法实时肖像', 290000, '显示屏传感器'),
    new ArtworkItem(26, '梦境记录仪', '刘窗', 'digital', 2024, 2, '主展厅B', '脑电波数据可视化作品', 460000, '脑电设备投影'),
    new ArtworkItem(27, '水墨意象', '刘国松', 'calligraphy', 2024, 3, '书法厅', '现代水墨实验作品', 230000, '宣纸综合材料'),
  ]
}

function getMockEvents(): EventItem[] {
  return [
    { id: 1, name: '开幕仪式', type: '开幕式', date: '2024-03-01', time: '10:00', location: '主展厅A', speaker: '馆长 李明', capacity: 200 },
    { id: 2, name: '策展人导览', type: '导览', date: '2024-03-08', time: '14:00', location: '主展厅A', speaker: '策展人 张艺', capacity: 50 },
    { id: 3, name: '数字幻境开幕式', type: '开幕式', date: '2024-04-15', time: '10:00', location: '主展厅B', speaker: '馆长 李明', capacity: 180 },
    { id: 4, name: 'AI艺术工作坊', type: '工作坊', date: '2024-04-20', time: '14:00', location: '教育活动室', speaker: '艺术家 王磊', capacity: 30 },
    { id: 5, name: '墨韵千秋开幕式', type: '开幕式', date: '2024-05-01', time: '09:30', location: '书法厅', speaker: '馆长 李明', capacity: 150 },
    { id: 6, name: '书法大师课', type: '工作坊', date: '2024-05-15', time: '14:00', location: '书法厅', speaker: '书法家 陈墨', capacity: 25 },
    { id: 7, name: '凝视之间开幕式', type: '开幕式', date: '2024-06-01', time: '10:00', location: '影像厅', speaker: '馆长 李明', capacity: 160 },
    { id: 8, name: '摄影创作分享', type: '讲座', date: '2024-06-10', time: '15:00', location: '报告厅', speaker: '摄影师 刘铮', capacity: 80 },
    { id: 9, name: '造物之光开幕式', type: '开幕式', date: '2024-07-01', time: '10:00', location: '雕塑厅', speaker: '馆长 李明', capacity: 140 },
    { id: 10, name: '雕塑创作体验', type: '工作坊', date: '2024-07-12', time: '14:00', location: '雕塑工作室', speaker: '雕塑家 郑路', capacity: 20 },
    { id: 11, name: '跨界共生开幕式', type: '开幕式', date: '2024-08-01', time: '10:00', location: '当代艺术厅', speaker: '馆长 李明', capacity: 200 },
    { id: 12, name: '新媒体艺术论坛', type: '讲座', date: '2024-08-10', time: '14:00', location: '报告厅', speaker: '策展人 张艺', capacity: 100 },
    { id: 13, name: '艺术之夜', type: '特别活动', date: '2024-09-15', time: '18:00', location: '全馆', speaker: '全体艺术家', capacity: 500 },
    { id: 14, name: '藏品鉴赏会', type: '鉴赏', date: '2024-10-01', time: '14:00', location: 'VIP厅', speaker: '鉴定专家 王教授', capacity: 40 },
    { id: 15, name: '年度艺术拍卖预展', type: '预展', date: '2024-11-20', time: '10:00', location: '主展厅A', speaker: '拍卖师 赵明', capacity: 120 },
    { id: 16, name: '闭幕特别演出', type: '表演', date: '2024-12-28', time: '19:00', location: '主展厅A', speaker: '跨界艺术家团队', capacity: 300 },
    { id: 17, name: '艺术教育研讨会', type: '研讨会', date: '2024-12-15', time: '09:00', location: '报告厅', speaker: '教育总监 林华', capacity: 60 },
  ]
}

function getMockTickets(): TicketOrder[] {
  return [
    { id: 1, exhibitionId: 1, visitor: '张三', date: '2024-03-05', count: 2, price: 120, status: '已售' },
    { id: 2, exhibitionId: 1, visitor: '李四', date: '2024-03-10', count: 1, price: 60, status: '验票' },
    { id: 3, exhibitionId: 2, visitor: '王五', date: '2024-04-20', count: 3, price: 180, status: '已售' },
    { id: 4, exhibitionId: 2, visitor: '赵六', date: '2024-05-01', count: 2, price: 120, status: '退票' },
    { id: 5, exhibitionId: 3, visitor: '孙七', date: '2024-05-10', count: 1, price: 80, status: '验票' },
    { id: 6, exhibitionId: 4, visitor: '周八', date: '2024-06-15', count: 2, price: 100, status: '已售' },
    { id: 7, exhibitionId: 5, visitor: '吴九', date: '2024-07-20', count: 4, price: 200, status: '已售' },
    { id: 8, exhibitionId: 6, visitor: '郑十', date: '2024-08-05', count: 1, price: 100, status: '验票' },
    { id: 9, exhibitionId: 3, visitor: '陈十一', date: '2024-06-20', count: 2, price: 160, status: '退票' },
    { id: 10, exhibitionId: 1, visitor: '刘十二', date: '2024-04-01', count: 5, price: 300, status: '已售' },
    { id: 11, exhibitionId: 4, visitor: '黄十三', date: '2024-08-12', count: 2, price: 100, status: '已售' },
    { id: 12, exhibitionId: 5, visitor: '林十四', date: '2024-09-03', count: 1, price: 50, status: '退票' },
  ]
}

function getArtworksByExhibition(id: number, artworks: ArtworkItem[]): ArtworkItem[] {
  return artworks.filter((a: ArtworkItem) => a.exhibitionId === id)
}

enum ExhibitionTab { EXHIBITION = 0, ARTWORKS = 1, EVENTS = 2, TICKETS = 3 }

@Component
struct HallBarChart {
  @Prop data: HallChartItem[]
  @Prop maxVal: number = 0
  build() {
    Column() {
      Text('各展厅展品数量')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333')
        .margin({ bottom: 12 })
      Row() {
        ForEach(this.data, (item: HallChartItem) => {
          Column() {
            Text(item.value.toString())
              .fontSize(11)
              .fontColor('#666')
              .margin({ bottom: 4 })
            Column()
              .width(36)
              .height(this.maxVal > 0 ? item.value / this.maxVal * 120 : 0)
              .backgroundColor(item.color)
              .borderRadius({ topLeft: 4, topRight: 4 })
            Text(item.label)
              .fontSize(10)
              .fontColor('#999')
              .maxLines(1)
              .margin({ top: 4 })
          }.alignItems(HorizontalAlign.Center).layoutWeight(1)
        })
      }.width('100%').alignItems(VerticalAlign.Bottom).padding({ top: 8 })
    }.width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(10).margin({ top: 8 })
  }
}

@Component
struct VisitorTrendChart {
  @Prop data: TrendChartItem[]
  @Prop maxVal: number = 0
  build() {
    Column() {
      Text('月度参观人数趋势')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333')
        .margin({ bottom: 12 })
      Row() {
        ForEach(this.data, (item: TrendChartItem) => {
          Column() {
            Text(item.value.toString())
              .fontSize(10)
              .fontColor('#4A90D9')
              .margin({ bottom: 4 })
            Column()
              .width(28)
              .height(this.maxVal > 0 ? item.value / this.maxVal * 100 : 0)
              .backgroundColor('#4A90D9')
              .borderRadius({ topLeft: 3, topRight: 3 })
            Text(item.label)
              .fontSize(10)
              .fontColor('#999')
              .margin({ top: 4 })
          }.alignItems(HorizontalAlign.Center).layoutWeight(1)
        })
      }.width('100%').alignItems(VerticalAlign.Bottom).padding({ top: 8 })
    }.width('100%').padding(14).backgroundColor('#FFFFFF').borderRadius(10).margin({ top: 10, bottom: 10 })
  }
}

@Entry
@Component
struct ExhibitionManager {
  @State artworks: ArtworkItem[] = getMockArtworks()
  @State events: EventItem[] = getMockEvents()
  @State tickets: TicketOrder[] = getMockTickets()
  @State activeTab: ExhibitionTab = ExhibitionTab.EXHIBITION
  @State currentExhibitIdx: number = 0
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State selectedArtwork: ArtworkItem | null = null
  @State addFormName: string = ''
  @State addFormArtist: string = ''
  @State addFormTypeIdx: number = 0
  @State addFormYear: string = ''
  @State addFormExhibitionIdx: number = 0
  @State addFormHall: string = ''
  @State addFormDescription: string = ''
  @State addFormValue: string = ''
  @State addFormMaterial: string = ''
  @State editFormName: string = ''
  @State editFormArtist: string = ''
  @State editFormTypeIdx: number = 0
  @State editFormYear: string = ''
  @State editFormExhibitionIdx: number = 0
  @State editFormHall: string = ''
  @State editFormDescription: string = ''
  @State editFormValue: string = ''
  @State editFormMaterial: string = ''
  @State typeKeys: string[] = []
  @State typeLabels: string[] = []
  @State exhibitionKeys: string[] = []
  @State exhibitionLabels: string[] = []
  @State hallChartData: HallChartItem[] = []
  @State hallChartMax: number = 0
  @State trendChartData: TrendChartItem[] = []
  @State trendChartMax: number = 0
  @State nextArtworkId: number = 28

  aboutToAppear(): void {
    this.typeKeys = Object.keys(ART_TYPES)
    this.typeLabels = this.typeKeys.map((k: string) => ART_TYPES[k].label)
    this.exhibitionKeys = Object.keys(EXHIBITIONS)
    this.exhibitionLabels = this.exhibitionKeys.map((k: string) => EXHIBITIONS[k].name)
    this.computeHallChartData()
    this.computeTrendChartData()
  }

  computeHallChartData(): void {
    const hallCount: Record<string, number> = {}
    const hallColors: string[] = ['#F44336', '#2196F3', '#4CAF50', '#FF9800', '#9C27B0', '#795548']
    let colorIdx: number = 0
    for (let i = 0; i < this.artworks.length; i++) {
      const hall = this.artworks[i].hall
      if (hallCount[hall] === undefined) { hallCount[hall] = 0 }
      hallCount[hall] = hallCount[hall] + 1
    }
    const halls: string[] = Object.keys(hallCount)
    const result: HallChartItem[] = []
    let maxVal: number = 0
    for (let i = 0; i < halls.length; i++) {
      const val = hallCount[halls[i]]
      result.push({ label: halls[i], value: val, color: hallColors[colorIdx % hallColors.length] })
      colorIdx = colorIdx + 1
      if (val > maxVal) { maxVal = val }
    }
    this.hallChartData = result
    this.hallChartMax = maxVal
  }

  computeTrendChartData(): void {
    const months: string[] = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
    const values: number[] = [820, 650, 1200, 1500, 1350, 1680, 1900, 2100, 1850, 1720, 1580, 2300]
    const result: TrendChartItem[] = []
    let maxVal: number = 0
    for (let i = 0; i < months.length; i++) {
      result.push({ label: months[i], value: values[i] })
      if (values[i] > maxVal) { maxVal = values[i] }
    }
    this.trendChartData = result
    this.trendChartMax = maxVal
  }

  onEditArtwork(item: ArtworkItem): void {
    this.selectedArtwork = item
    this.editFormName = item.name
    this.editFormArtist = item.artist
    this.editFormTypeIdx = this.typeKeys.indexOf(item.type) >= 0 ? this.typeKeys.indexOf(item.type) : 0
    this.editFormYear = item.year.toString()
    this.editFormExhibitionIdx = item.exhibitionId - 1
    this.editFormHall = item.hall
    this.editFormDescription = item.description
    this.editFormValue = item.value.toString()
    this.editFormMaterial = item.material
    this.showEditModal = true
  }

  addArtwork(): void {
    if (this.addFormName.trim() === '') { return }
    const newArtwork: ArtworkItem = new ArtworkItem(
      this.nextArtworkId, this.addFormName, this.addFormArtist,
      this.typeKeys[this.addFormTypeIdx], parseInt(this.addFormYear) || 2024,
      this.addFormExhibitionIdx + 1, this.addFormHall, this.addFormDescription,
      parseInt(this.addFormValue) || 0, this.addFormMaterial
    )
    this.artworks = this.artworks.concat([newArtwork])
    this.nextArtworkId = this.nextArtworkId + 1
    this.showAddModal = false
    this.resetAddForm()
    this.computeHallChartData()
  }

  updateArtwork(): void {
    if (this.selectedArtwork === null) { return }
    this.selectedArtwork.name = this.editFormName
    this.selectedArtwork.artist = this.editFormArtist
    this.selectedArtwork.type = this.typeKeys[this.editFormTypeIdx]
    this.selectedArtwork.year = parseInt(this.editFormYear) || 2024
    this.selectedArtwork.exhibitionId = this.editFormExhibitionIdx + 1
    this.selectedArtwork.hall = this.editFormHall
    this.selectedArtwork.description = this.editFormDescription
    this.selectedArtwork.value = parseInt(this.editFormValue) || 0
    this.selectedArtwork.material = this.editFormMaterial
    this.showEditModal = false
    this.selectedArtwork = null
    this.computeHallChartData()
  }

  confirmDeleteArtwork(): void {
    if (this.selectedArtwork === null) { return }
    const targetId: number = this.selectedArtwork.id
    this.artworks = this.artworks.filter((a: ArtworkItem) => a.id !== targetId)
    this.showDeleteModal = false
    this.selectedArtwork = null
    this.computeHallChartData()
  }

  resetAddForm(): void {
    this.addFormName = ''
    this.addFormArtist = ''
    this.addFormTypeIdx = 0
    this.addFormYear = ''
    this.addFormExhibitionIdx = 0
    this.addFormHall = ''
    this.addFormDescription = ''
    this.addFormValue = ''
    this.addFormMaterial = ''
  }

  getStatusColor(status: string): string {
    if (status === '已售') { return '#4CAF50' }
    if (status === '验票') { return '#2196F3' }
    return '#F44336'
  }

  build() {
    Stack() {
      Column() {
        this.headerBuilder()
        Column() {
          if (this.activeTab === ExhibitionTab.EXHIBITION) { this.exhibitionTabBuilder() }
          if (this.activeTab === ExhibitionTab.ARTWORKS) { this.artworksTabBuilder() }
          if (this.activeTab === ExhibitionTab.EVENTS) { this.eventsTabBuilder() }
          if (this.activeTab === ExhibitionTab.TICKETS) { this.ticketsTabBuilder() }
        }.layoutWeight(1).width('100%')
        this.bottomTabsBuilder()
      }.width('100%').height('100%').backgroundColor('#F0F2F5')
      if (this.showAddModal) { Column() {}.width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)').onClick(() => { this.showAddModal = false }) }
      if (this.showAddModal) { this.addArtworkModalBuilder() }
      if (this.showEditModal) { Column() {}.width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)').onClick(() => { this.showEditModal = false }) }
      if (this.showEditModal) { this.editArtworkModalBuilder() }
      if (this.showDeleteModal) { Column() {}.width('100%').height('100%').backgroundColor('rgba(0,0,0,0.5)').onClick(() => { this.showDeleteModal = false }) }
      if (this.showDeleteModal) { this.deleteConfirmModalBuilder() }
    }.width('100%').height('100%')
  }

  @Builder headerBuilder() {
    Row() {
      Text('艺术展览管理')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
      Text(EXHIBITIONS[this.exhibitionKeys[this.currentExhibitIdx]]?.name || '')
        .fontSize(13)
        .fontColor('rgba(255,255,255,0.7)')
        .constraintSize({ maxWidth: '40%' })
        .maxLines(1)
        .margin({ left: 12 })
    }.width('100%').height(48).backgroundColor('#2C3E50')
    .padding({ left: 16, right: 16 })
    .justifyContent(FlexAlign.Start)
    .alignItems(VerticalAlign.Center)
  }

  @Builder exhibitionTabBuilder() {
    Column() {
      Swiper() {
        ForEach(this.exhibitionKeys, (key: string, idx: number) => {
          this.exhibitionCardBuilder(EXHIBITIONS[key], idx)
        })
      }
      .loop(true)
      .indicator(true)
      .indicatorStyle({ selectedColor: '#2C3E50', color: '#CCC' })
      .onChange((index: number) => { this.currentExhibitIdx = index })
      .width('100%')
      .height(260)
      .padding({ top: 12, bottom: 8 })
      Scroll() {
        Column() {
          if (this.currentExhibitIdx < this.exhibitionKeys.length) {
            Text(EXHIBITIONS[this.exhibitionKeys[this.currentExhibitIdx]]?.description || '')
              .fontSize(14)
              .fontColor('#555')
              .lineHeight(22)
              .padding(16)
              .width('100%')
          }
          Row() {
            Text('展厅: ').fontSize(13).fontColor('#888')
            Text(EXHIBITIONS[this.exhibitionKeys[this.currentExhibitIdx]]?.hall || '')
              .fontSize(13)
              .fontColor('#2C3E50')
              .fontWeight(FontWeight.Medium)
            Text(' | 展期: ').fontSize(13).fontColor('#888')
            Text(EXHIBITIONS[this.exhibitionKeys[this.currentExhibitIdx]]?.startDate + ' ~ ' + EXHIBITIONS[this.exhibitionKeys[this.currentExhibitIdx]]?.endDate || '')
              .fontSize(13)
              .fontColor('#2C3E50')
              .fontWeight(FontWeight.Medium)
          }.width('100%').padding({ left: 16, right: 16, bottom: 12 })
          Text('本展艺术品')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333')
            .margin({ left: 16, top: 8, bottom: 8 })
          Grid() {
            ForEach(getArtworksByExhibition(this.currentExhibitIdx + 1, this.artworks), (item: ArtworkItem) => {
              GridItem() { this.artworkGridItemBuilder(item) }
            })
          }.columnsTemplate('1fr 1fr').columnsGap(10).rowsGap(10)
          .padding({ left: 16, right: 16, bottom: 20 })
          .width('100%')
        }
      }.scrollable(ScrollDirection.Vertical).width('100%')
    }.width('100%')
  }

  @Builder artworksTabBuilder() {
    Column() {
      Row() {
        Text('艺术品库')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333')
        Text(' (' + this.artworks.length + '件)')
          .fontSize(14)
          .fontColor('#999')
        Row() {}.layoutWeight(1)
        Button() {
          Row() {
            Text('+').fontSize(18).fontColor(Color.White).margin({ right: 4 })
            Text('新增').fontSize(14).fontColor(Color.White)
          }
        }
        .height(36)
        .backgroundColor('#2C3E50')
        .borderRadius(8)
        .padding({ left: 12, right: 12 })
        .onClick(() => { this.showAddModal = true })
      }.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })
      Scroll() {
        Column() {
          Grid() {
            ForEach(this.artworks, (item: ArtworkItem) => {
              GridItem() { this.artworkGridItemBuilder(item) }
            })
          }.columnsTemplate('1fr 1fr').columnsGap(10).rowsGap(10)
          .padding({ left: 16, right: 16 })
          .width('100%')
          Column() {
            HallBarChart({ data: this.hallChartData, maxVal: this.hallChartMax })
            VisitorTrendChart({ data: this.trendChartData, maxVal: this.trendChartMax })
          }.padding({ left: 16, right: 16, bottom: 16 }).width('100%')
        }
      }.scrollable(ScrollDirection.Vertical).width('100%')
    }.width('100%')
  }

  @Builder eventsTabBuilder() {
    Column() {
      Row() {
        Text('活动日程')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333')
        Text(' (' + this.events.length + '场)')
          .fontSize(14)
          .fontColor('#999')
      }.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })
      Scroll() {
        Column() {
          ForEach(this.events, (item: EventItem) => { this.eventTimelineItemBuilder(item) })
        }.padding({ left: 16, right: 16, bottom: 16 })
      }.scrollable(ScrollDirection.Vertical).width('100%')
    }.width('100%')
  }

  @Builder ticketsTabBuilder() {
    Column() {
      Row() {
        Text('票务管理')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333')
        Text(' (' + this.tickets.length + '笔)')
          .fontSize(14)
          .fontColor('#999')
      }.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 8 })
      Row() {
        Text('已售').fontSize(12).fontColor('#4CAF50').fontWeight(FontWeight.Medium)
        Text(' ' + this.tickets.filter((t: TicketOrder) => t.status === '已售').length + ' ')
          .fontSize(12).fontColor('#333')
        Text('验票').fontSize(12).fontColor('#2196F3').fontWeight(FontWeight.Medium)
        Text(' ' + this.tickets.filter((t: TicketOrder) => t.status === '验票').length + ' ')
          .fontSize(12).fontColor('#333')
        Text('退票').fontSize(12).fontColor('#F44336').fontWeight(FontWeight.Medium)
        Text(' ' + this.tickets.filter((t: TicketOrder) => t.status === '退票').length)
          .fontSize(12).fontColor('#333')
      }.width('100%').padding({ left: 16, right: 16, bottom: 8 })
      Scroll() {
        Column() {
          ForEach(this.tickets, (item: TicketOrder) => { this.ticketOrderItemBuilder(item) })
        }.padding({ left: 16, right: 16, bottom: 16 })
      }.scrollable(ScrollDirection.Vertical).width('100%')
    }.width('100%')
  }

  @Builder bottomTabsBuilder() {
    Row() {
      Column() {
        Text(this.activeTab === ExhibitionTab.EXHIBITION ? '▪' : '')
          .fontSize(20)
          .fontColor(this.activeTab === ExhibitionTab.EXHIBITION ? '#2C3E50' : 'transparent')
        Text('当前展览')
          .fontSize(12)
          .fontColor(this.activeTab === ExhibitionTab.EXHIBITION ? '#2C3E50' : '#999')
          .fontWeight(this.activeTab === ExhibitionTab.EXHIBITION ? FontWeight.Bold : FontWeight.Normal)
      }.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center).layoutWeight(1)
      .onClick(() => { this.activeTab = ExhibitionTab.EXHIBITION })
      Column() {
        Text(this.activeTab === ExhibitionTab.ARTWORKS ? '▪' : '')
          .fontSize(20)
          .fontColor(this.activeTab === ExhibitionTab.ARTWORKS ? '#2C3E50' : 'transparent')
        Text('艺术品库')
          .fontSize(12)
          .fontColor(this.activeTab === ExhibitionTab.ARTWORKS ? '#2C3E50' : '#999')
          .fontWeight(this.activeTab === ExhibitionTab.ARTWORKS ? FontWeight.Bold : FontWeight.Normal)
      }.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center).layoutWeight(1)
      .onClick(() => { this.activeTab = ExhibitionTab.ARTWORKS })
      Column() {
        Text(this.activeTab === ExhibitionTab.EVENTS ? '▪' : '')
          .fontSize(20)
          .fontColor(this.activeTab === ExhibitionTab.EVENTS ? '#2C3E50' : 'transparent')
        Text('活动日程')
          .fontSize(12)
          .fontColor(this.activeTab === ExhibitionTab.EVENTS ? '#2C3E50' : '#999')
          .fontWeight(this.activeTab === ExhibitionTab.EVENTS ? FontWeight.Bold : FontWeight.Normal)
      }.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center).layoutWeight(1)
      .onClick(() => { this.activeTab = ExhibitionTab.EVENTS })
      Column() {
        Text(this.activeTab === ExhibitionTab.TICKETS ? '▪' : '')
          .fontSize(20)
          .fontColor(this.activeTab === ExhibitionTab.TICKETS ? '#2C3E50' : 'transparent')
        Text('票务管理')
          .fontSize(12)
          .fontColor(this.activeTab === ExhibitionTab.TICKETS ? '#2C3E50' : '#999')
          .fontWeight(this.activeTab === ExhibitionTab.TICKETS ? FontWeight.Bold : FontWeight.Normal)
      }.alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center).layoutWeight(1)
      .onClick(() => { this.activeTab = ExhibitionTab.TICKETS })
    }.width('100%').height(56).backgroundColor('#FFFFFF').padding({ top: 4, bottom: 4 })
  }

  @Builder exhibitionCardBuilder(meta: ExhibitionMeta, idx: number) {
    Column() {
      Column() {
        Text(meta.theme)
          .fontSize(18)
          .fontColor(Color.White)
          .fontWeight(FontWeight.Bold)
          .textAlign(TextAlign.Center)
          .padding({ left: 20, right: 20 })
      }.width('92%').height(150).backgroundColor(meta.cover).borderRadius(14)
      .justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
      .shadow({ radius: 8, color: 'rgba(0,0,0,0.15)', offsetY: 2 })
      Column() {
        Text(meta.name)
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1A1A1A')
          .margin({ top: 10 })
        Text(meta.startDate + '  ~  ' + meta.endDate)
          .fontSize(13)
          .fontColor('#888')
          .margin({ top: 4 })
        Row() {
          Text(meta.hall)
            .fontSize(12)
            .fontColor(Color.White)
            .backgroundColor('#2C3E50')
            .borderRadius(4)
            .padding({ left: 8, right: 8, top: 2, bottom: 2 })
        }.margin({ top: 6 })
      }.alignItems(HorizontalAlign.Center)
    }.width('100%').padding({ left: 16, right: 16 })
  }

  @Builder artworkGridItemBuilder(item: ArtworkItem) {
    Column() {
      Column() {
        Row() {
          Text(ART_TYPES[item.type]?.icon || '')
            .fontSize(28)
        }.width('100%').height(100)
        .backgroundColor(ART_TYPES[item.type]?.color || '#CCCCCC')
        .justifyContent(FlexAlign.Center)
        .border({ width: 3, color: '#1A1A1A' })
      }.width('100%').borderRadius(6)
      Column() {
        Text(item.name)
          .fontSize(13)
          .fontWeight(FontWeight.Medium)
          .fontColor('#1A1A1A')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(item.artist)
          .fontSize(11)
          .fontColor('#888')
          .maxLines(1)
          .margin({ top: 2 })
        Row() {
          Text(ART_TYPES[item.type]?.label || '')
            .fontSize(10)
            .fontColor(Color.White)
            .backgroundColor(ART_TYPES[item.type]?.color || '#999')
            .borderRadius(3)
            .padding({ left: 6, right: 6, top: 1, bottom: 1 })
          Text(' ' + item.hall)
            .fontSize(10)
            .fontColor('#AAA')
            .maxLines(1)
        }.width('100%').margin({ top: 4 })
      }.width('100%').padding({ left: 6, right: 6, top: 6, bottom: 6 }).alignItems(HorizontalAlign.Start)
    }.width('100%').backgroundColor('#FFFFFF').borderRadius(8)
    .shadow({ radius: 3, color: 'rgba(0,0,0,0.08)', offsetY: 1 })
    .onClick(() => { this.onEditArtwork(item) })
  }

  @Builder eventTimelineItemBuilder(item: EventItem) {
    Row() {
      Column() {
        Column()
          .width(10)
          .height(10)
          .backgroundColor('#2C3E50')
          .borderRadius(5)
        Column()
          .width(2)
          .height(60)
          .backgroundColor('#E0E0E0')
      }.alignItems(HorizontalAlign.Center).margin({ right: 12 })
      Column() {
        Row() {
          Text(item.date)
            .fontSize(12)
            .fontColor('#2C3E50')
            .fontWeight(FontWeight.Bold)
          Text('  ' + item.time)
            .fontSize(12)
            .fontColor('#666')
          Row() {}.layoutWeight(1)
          Text(item.type)
            .fontSize(11)
            .fontColor(Color.White)
            .backgroundColor('#2C3E50')
            .borderRadius(4)
            .padding({ left: 6, right: 6, top: 1, bottom: 1 })
        }.width('100%')
        Text(item.name)
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .fontColor('#1A1A1A')
          .margin({ top: 4 })
        Row() {
          Text(item.location + ' | ' + item.speaker)
            .fontSize(12)
            .fontColor('#888')
          Row() {}.layoutWeight(1)
          Text(item.capacity + '人')
            .fontSize(11)
            .fontColor('#AAA')
        }.width('100%').margin({ top: 3 })
      }.layoutWeight(1).backgroundColor('#FFFFFF').borderRadius(8).padding(12)
      .shadow({ radius: 2, color: 'rgba(0,0,0,0.05)', offsetY: 1 }).margin({ bottom: 10 })
    }.width('100%').alignItems(VerticalAlign.Top)
  }

  @Builder ticketOrderItemBuilder(item: TicketOrder) {
    Row() {
      Column() {
        Text(item.visitor)
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .fontColor('#1A1A1A')
        Text('展览: ' + (EXHIBITIONS[this.exhibitionKeys[item.exhibitionId - 1]]?.name || ''))
          .fontSize(12)
          .fontColor('#888')
          .margin({ top: 2 })
        Text(item.date + ' | ' + item.count + '张')
          .fontSize(11)
          .fontColor('#AAA')
          .margin({ top: 2 })
      }.alignItems(HorizontalAlign.Start).layoutWeight(1)
      Column() {
        Text(this.getStatusColor(item.status) === '#F44336' ? ('-' + item.price) : item.price.toString())
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.getStatusColor(item.status))
        Text(item.status)
          .fontSize(12)
          .fontColor(this.getStatusColor(item.status))
          .fontWeight(FontWeight.Medium)
          .margin({ top: 2 })
      }.alignItems(HorizontalAlign.End)
    }.width('100%').backgroundColor('#FFFFFF').borderRadius(8)
    .padding({ left: 14, right: 14, top: 10, bottom: 10 })
    .shadow({ radius: 2, color: 'rgba(0,0,0,0.05)', offsetY: 1 })
    .margin({ bottom: 8 })
  }

  @Builder addArtworkModalBuilder() {
    Column() {
      Column() {
        Text('新增艺术品')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1A1A1A')
          .margin({ bottom: 14 })
        Scroll() {
          Column() {
            Text('名称').fontSize(13).fontColor('#666').margin({ bottom: 4 })
            TextInput({ text: this.addFormName, placeholder: '请输入艺术品名称' })
              .width('100%').height(40).backgroundColor('#F5F5F5').borderRadius(6).padding({ left: 10, right: 10 })
              .onChange((value: string) => { this.addFormName = value })
            Text('艺术家').fontSize(13).fontColor('#666').margin({ top: 10, bottom: 4 })
            TextInput({ text: this.addFormArtist, placeholder: '请输入艺术家姓名' })
              .width('100%').height(40).backgroundColor('#F5F5F5').borderRadius(6).padding({ left: 10, right: 10 })
              .onChange((value: string) => { this.addFormArtist = value })
            Text('类型').fontSize(13).fontColor('#666').margin({ top: 10, bottom: 4 })
            TextPicker({ range: this.typeLabels, selected: this.addFormTypeIdx })
              .width('100%').backgroundColor('#F5F5F5').borderRadius(6)
            Text('年份').fontSize(13).fontColor('#666').margin({ top: 10, bottom: 4 })
            TextInput({ text: this.addFormYear, placeholder: '请输入创作年份' })
              .width('100%').height(40).backgroundColor('#F5F5F5').borderRadius(6).padding({ left: 10, right: 10 })
              .onChange((value: string) => { this.addFormYear = value })
            Text('所属展览').fontSize(13).fontColor('#666').margin({ top: 10, bottom: 4 })
            TextPicker({ range: this.exhibitionLabels, selected: this.addFormExhibitionIdx })
              .width('100%').backgroundColor('#F5F5F5').borderRadius(6)
            Text('展厅').fontSize(13).fontColor('#666').margin({ top: 10, bottom: 4 })
            TextInput({ text: this.addFormHall, placeholder: '请输入展厅名称' })
              .width('100%').height(40).backgroundColor('#F5F5F5').borderRadius(6).padding({ left: 10, right: 10 })
              .onChange((value: string) => { this.addFormHall = value })
            Text('描述').fontSize(13).fontColor('#666').margin({ top: 10, bottom: 4 })
            TextInput({ text: this.addFormDescription, placeholder: '请输入艺术品描述' })
              .width('100%').height(40).backgroundColor('#F5F5F5').borderRadius(6).padding({ left: 10, right: 10 })
              .onChange((value: string) => { this.addFormDescription = value })
            Text('估值').fontSize(13).fontColor('#666').margin({ top: 10, bottom: 4 })
            TextInput({ text: this.addFormValue, placeholder: '请输入估值金额' })
              .width('100%').height(40).backgroundColor('#F5F5F5').borderRadius(6).padding({ left: 10, right: 10 })
              .type(InputType.Number)
              .onChange((value: string) => { this.addFormValue = value })
            Text('材质').fontSize(13).fontColor('#666').margin({ top: 10, bottom: 4 })
            TextInput({ text: this.addFormMaterial, placeholder: '请输入材质信息' })
              .width('100%').height(40).backgroundColor('#F5F5F5').borderRadius(6).padding({ left: 10, right: 10 })
              .onChange((value: string) => { this.addFormMaterial = value })
          }
        }.constraintSize({ maxHeight: '60%' }).width('100%')
        Row() {
          Button('取消')
            .fontSize(14).backgroundColor('#E0E0E0').fontColor('#666')
            .borderRadius(8).height(40).layoutWeight(1).margin({ right: 8 })
            .onClick(() => { this.showAddModal = false })
          Button('确认添加')
            .fontSize(14).backgroundColor('#2C3E50').fontColor(Color.White)
            .borderRadius(8).height(40).layoutWeight(1).margin({ left: 8 })
            .onClick(() => { this.addArtwork() })
        }.width('100%').margin({ top: 14 })
      }.width('88%').backgroundColor(Color.White).borderRadius(16)
      .padding({ left: 20, right: 20, top: 20, bottom: 16 })
    }.width('100%').height('100%').justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
  }

  @Builder editArtworkModalBuilder() {
    Column() {
      Column() {
        Text('编辑艺术品')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1A1A1A')
          .margin({ bottom: 14 })
        Scroll() {
          Column() {
            Text('名称').fontSize(13).fontColor('#666').margin({ bottom: 4 })
            TextInput({ text: this.editFormName, placeholder: '请输入艺术品名称' })
              .width('100%').height(40).backgroundColor('#F5F5F5').borderRadius(6).padding({ left: 10, right: 10 })
              .onChange((value: string) => { this.editFormName = value })
            Text('艺术家').fontSize(13).fontColor('#666').margin({ top: 10, bottom: 4 })
            TextInput({ text: this.editFormArtist, placeholder: '请输入艺术家姓名' })
              .width('100%').height(40).backgroundColor('#F5F5F5').borderRadius(6).padding({ left: 10, right: 10 })
              .onChange((value: string) => { this.editFormArtist = value })
            Text('类型').fontSize(13).fontColor('#666').margin({ top: 10, bottom: 4 })
            TextPicker({ range: this.typeLabels, selected: this.editFormTypeIdx })
              .width('100%').backgroundColor('#F5F5F5').borderRadius(6)
            Text('年份').fontSize(13).fontColor('#666').margin({ top: 10, bottom: 4 })
            TextInput({ text: this.editFormYear, placeholder: '请输入创作年份' })
              .width('100%').height(40).backgroundColor('#F5F5F5').borderRadius(6).padding({ left: 10, right: 10 })
              .onChange((value: string) => { this.editFormYear = value })
            Text('所属展览').fontSize(13).fontColor('#666').margin({ top: 10, bottom: 4 })
            TextPicker({ range: this.exhibitionLabels, selected: this.editFormExhibitionIdx })
              .width('100%').backgroundColor('#F5F5F5').borderRadius(6)
            Text('展厅').fontSize(13).fontColor('#666').margin({ top: 10, bottom: 4 })
            TextInput({ text: this.editFormHall, placeholder: '请输入展厅名称' })
              .width('100%').height(40).backgroundColor('#F5F5F5').borderRadius(6).padding({ left: 10, right: 10 })
              .onChange((value: string) => { this.editFormHall = value })
            Text('描述').fontSize(13).fontColor('#666').margin({ top: 10, bottom: 4 })
            TextInput({ text: this.editFormDescription, placeholder: '请输入艺术品描述' })
              .width('100%').height(40).backgroundColor('#F5F5F5').borderRadius(6).padding({ left: 10, right: 10 })
              .onChange((value: string) => { this.editFormDescription = value })
            Text('估值').fontSize(13).fontColor('#666').margin({ top: 10, bottom: 4 })
            TextInput({ text: this.editFormValue, placeholder: '请输入估值金额' })
              .width('100%').height(40).backgroundColor('#F5F5F5').borderRadius(6).padding({ left: 10, right: 10 })
              .type(InputType.Number)
              .onChange((value: string) => { this.editFormValue = value })
            Text('材质').fontSize(13).fontColor('#666').margin({ top: 10, bottom: 4 })
            TextInput({ text: this.editFormMaterial, placeholder: '请输入材质信息' })
              .width('100%').height(40).backgroundColor('#F5F5F5').borderRadius(6).padding({ left: 10, right: 10 })
              .onChange((value: string) => { this.editFormMaterial = value })
          }
        }.constraintSize({ maxHeight: '55%' }).width('100%')
        Row() {
          Button('取消')
            .fontSize(14).backgroundColor('#E0E0E0').fontColor('#666')
            .borderRadius(8).height(40).layoutWeight(1).margin({ right: 6 })
            .onClick(() => { this.showEditModal = false })
          Button('删除')
            .fontSize(14).backgroundColor('#F44336').fontColor(Color.White)
            .borderRadius(8).height(40).layoutWeight(1).margin({ left: 3, right: 3 })
            .onClick(() => { this.showEditModal = false; this.showDeleteModal = true })
          Button('保存')
            .fontSize(14).backgroundColor('#2C3E50').fontColor(Color.White)
            .borderRadius(8).height(40).layoutWeight(1).margin({ left: 6 })
            .onClick(() => { this.updateArtwork() })
        }.width('100%').margin({ top: 14 })
      }.width('88%').backgroundColor(Color.White).borderRadius(16)
      .padding({ left: 20, right: 20, top: 20, bottom: 16 })
    }.width('100%').height('100%').justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
  }

  @Builder deleteConfirmModalBuilder() {
    Column() {
      Column() {
        Text('确认删除')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1A1A1A')
          .margin({ bottom: 10 })
        Text('确定要删除这件艺术品吗?此操作不可撤销。')
          .fontSize(14)
          .fontColor('#666')
          .textAlign(TextAlign.Center)
          .lineHeight(22)
          .margin({ bottom: 8 })
        if (this.selectedArtwork !== null) {
          Text('"' + this.selectedArtwork?.name + '" - ' + this.selectedArtwork?.artist)
            .fontSize(15)
            .fontWeight(FontWeight.Medium)
            .fontColor('#F44336')
            .textAlign(TextAlign.Center)
            .margin({ bottom: 16 })
        }
        Row() {
          Button('取消')
            .fontSize(14).backgroundColor('#E0E0E0').fontColor('#666')
            .borderRadius(8).height(40).layoutWeight(1).margin({ right: 8 })
            .onClick(() => { this.showDeleteModal = false })
          Button('确认删除')
            .fontSize(14).backgroundColor('#F44336').fontColor(Color.White)
            .borderRadius(8).height(40).layoutWeight(1).margin({ left: 8 })
            .onClick(() => { this.confirmDeleteArtwork() })
        }.width('100%').margin({ top: 6 })
      }.width('75%').backgroundColor(Color.White).borderRadius(16)
      .padding({ left: 24, right: 24, top: 24, bottom: 20 })
    }.width('100%').height('100%').justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)
  }
}

在这里插入图片描述

结语

通过对本案例的逐段解析,我们可以看到HarmonyOS ArkTS声明式UI开发的核心编程范式:以@State驱动的响应式状态管理为根基,以@Builder封装的可复用UI构建方法为手段,以@Component实现的自定义组件为模块化载体,最终构建出结构清晰、交互丰富、可维护性强的移动端应用。

本案例中的多项技术实践值得在项目中借鉴:

  1. 使用@Observed装饰类实现对象属性的深度响应式追踪
  2. 使用concat/filter的不可变数组更新确保@State触发刷新
  3. 使用Stack实现模态弹窗的层叠布局
  4. 使用自定义@Builder方法实现复杂UI的模块化拆分
  5. 使用纯声明式UI(Column/Row/ForEach)实现图表等可视化组件
  6. 使用枚举替代魔法数字增强代码可读性
Logo

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

更多推荐