声明式UI的核心在于"数据驱动视图"——当状态数据发生变化时,框架自动计算差异并更新对应的UI节点,开发者无需手动操作DOM或调用刷新方法。HarmonyOS ArkTS通过@State@Builder@Component等装饰器,将这一理念贯彻到了移动端电商应用的开发实践中。

本文以一个完整的手账文具电商应用为分析样本,该应用涵盖了商品浏览、分类筛选、购物详情、订单管理、个人中心等电商核心场景,并融合了奶黄便签与复古印刷蓝的主题视觉设计,展现了ArkTS在构建多页面、多模态交互应用时的架构能力。

在HarmonyOS 6.1.1平台上,ArkTS提供了声明式UI范式(ArkUI),开发者通过链式调用配置组件属性与事件,配合TypeScript类型系统,能够在保证运行效率的同时获得良好的开发体验。本应用正是基于这一技术栈构建的典型范例。

一、引言

手账文化近年来在中国年轻消费群体中蓬勃发展,从手账本、胶带到钢笔墨水、贴纸印章,品类繁多且个性化需求强烈。本应用定位于一站式手账文具电商平台,采用得物风格的视觉设计语言,以奶黄便签色搭配复古印刷蓝为核心主题色,营造出温暖而精致的文具购物体验。应用整体架构遵循HarmonyOS ArkTS声明式UI范式,通过组件化拆分实现了首页、手账本、胶带、钢笔墨水、贴纸、个人中心六大功能模块的独立运作与统一调度。

在技术架构层面,应用采用了数据模型与视图组件分离的设计策略。首先通过interface定义了包括NotebookItemTapeItemPenItemInkItemStickerItem等在内的十余种强类型数据接口,再以const数组初始化静态商品数据源,最后通过纯函数进行数据过滤、格式化与状态计算。这种分层设计使得业务逻辑与视图渲染完全解耦,纯函数模块可以在不依赖组件上下文的情况下独立测试与复用。

在交互设计层面,应用充分运用了ArkTS提供的动画能力,包括setInterval驱动的定时器动画(如纸屑飘落、胶带卷轴旋转、印章缩放)、linearGradient渐变背景、rotate/scale/translate变换属性,以及@Builder装饰器封装的弹层覆盖层(modalOverlay)。每个商品Tab页均实现了完整的增删改查(CRUD)操作流程,通过@State状态变量驱动模态弹窗的显示与隐藏,配合TextInput输入框采集用户编辑数据,最终通过数组操作(pushspliceunshift)完成数据更新并自动触发视图刷新。

二、色彩体系与数据模型设计

应用首先定义了一套完整的色彩调色板接口,通过统一的ColorPalette接口约束所有主题色变量,确保视觉一致性。

interface ColorPalette {
  bg: string
  card: string
  card2: string
  primary: string
  accent: string
  star: string
  text: string
  sub: string
  line: string
  glow: string
}

const JS: ColorPalette = {
  bg: '#FDF6E3',
  card: '#FFFFFF',
  card2: '#F7EEDB',
  primary: '#2B4A6F',
  accent: '#F0C75E',
  star: '#E8923D',
  text: '#3A4A5C',
  sub: '#8A93A3',
  line: '#E3D9C0',
  glow: '#D98E32'
}

在这里插入图片描述

这段代码定义了应用的全局色彩系统。bg使用了暖调奶黄色#FDF6E3作为页面背景,营造出纸质般的温暖感;primary#2B4A6F是复古印刷蓝,用于标题、按钮、选中态等关键交互元素;accent#F0C75E为奶黄色,起到点缀和强调作用。通过ColorPalette接口的约束,所有色彩字段都必须存在且类型为字符串,这保证了在后续组件引用中不会出现undefined或类型错误。

色彩体系的一致性是声明式UI设计中的关键原则。通过将主题色集中管理,当需要实现暗色模式或多主题切换时,只需替换一个常量对象即可实现全局换肤,而无需逐个组件修改硬编码色值。

三、商品数据模型与静态数据源

应用为每种商品品类定义了对应的interface,并初始化了丰富的静态数据源。以下展示手账本和胶带两种核心品类的数据结构。

interface NotebookItem {
  name: string
  kind: string
  pages: number
  price: number
  orig: number
  sale: number
  stars: number
  tag: string
}

const NOTE_LIST: NotebookItem[] = [
  { name: '复古牛皮 A5 手账本', kind: '空白', pages: 192, price: 32, orig: 42, sale: 320, stars: 5, tag: '经典' },
  { name: '星空计划手账 A6', kind: '时间轴', pages: 128, price: 28, orig: 36, sale: 210, stars: 4, tag: '热销' },
  { name: '极简纯白布面手账', kind: '方格', pages: 240, price: 45, orig: 55, sale: 156, stars: 5, tag: '布面' },
  { name: '国风水墨手账本', kind: '空白', pages: 160, price: 38, orig: 48, sale: 188, stars: 5, tag: '国风' },
  { name: '复古印刷蓝经典手账', kind: '横线', pages: 192, price: 36, orig: 46, sale: 265, stars: 5, tag: '经典' },
  { name: '旅行手账 TN 护照款', kind: '空白', pages: 96, price: 58, orig: 68, sale: 98, stars: 4, tag: 'TN' },
  { name: '甜点主题便携手账', kind: '横线', pages: 128, price: 26, orig: 33, sale: 342, stars: 4, tag: '萌系' },
  { name: '手账元年周计划本', kind: '周计划', pages: 208, price: 42, orig: 52, sale: 132, stars: 4, tag: '效率' },
  { name: '猫爪印萌系手账', kind: '方格', pages: 112, price: 24, orig: 30, sale: 410, stars: 4, tag: '萌系' },
  { name: '植物标本手账本', kind: '空白', pages: 176, price: 48, orig: 58, sale: 87, stars: 5, tag: '植物' },
  { name: '复古报纸风手账', kind: '横线', pages: 160, price: 33, orig: 41, sale: 175, stars: 4, tag: '复古' },
  { name: '五年日记手账本', kind: '日记', pages: 365, price: 68, orig: 82, sale: 76, stars: 5, tag: '日记' },
  { name: '咖啡色皮面手账', kind: '横线', pages: 240, price: 88, orig: 108, sale: 64, stars: 5, tag: '皮面' },
  { name: '和风樱花手账', kind: '方格', pages: 144, price: 30, orig: 38, sale: 226, stars: 4, tag: '和风' },
  { name: '图书馆索引卡手账', kind: '索引', pages: 200, price: 52, orig: 62, sale: 92, stars: 4, tag: '索引' },
  { name: '夜间灵感黑卡手账', kind: '空白', pages: 128, price: 35, orig: 45, sale: 143, stars: 4, tag: '黑卡' }
]

在这里插入图片描述

NotebookItem接口包含了手账本的全部商品属性:名称、内页类型(kind)、页数、当前价格、原价、月销量、评分星级和标签。值得注意的是,kind字段涵盖了空白、横线、方格、时间轴、周计划、日记、索引七种内页格式,这为后续的分类筛选功能提供了数据基础。NOTE_LIST数组初始化了16款手账本商品数据,每款商品的价格、销量、标签都有差异化设计,使应用呈现出真实电商平台的丰富感。

interface TapeItem {
  name: string
  width: number
  len: number
  price: number
  orig: number
  sale: number
  pattern: string
}

const TAPE_LIST: TapeItem[] = [
  { name: '波点复古胶带', width: 15, len: 10, price: 12, orig: 15, sale: 520, pattern: '波点' },
  { name: '手绘植物胶带', width: 20, len: 8, price: 15, orig: 18, sale: 380, pattern: '植物' },
  { name: '复古报纸印花胶带', width: 25, len: 5, price: 18, orig: 22, sale: 264, pattern: '报纸' },
  { name: '烫金几何胶带', width: 10, len: 12, price: 16, orig: 19, sale: 312, pattern: '几何' },
  { name: '和风纹样胶带', width: 30, len: 6, price: 22, orig: 26, sale: 198, pattern: '和风' },
  { name: '彩虹渐变胶带', width: 15, len: 10, price: 13, orig: 16, sale: 456, pattern: '渐变' },
  { name: '猫咪日常胶带', width: 20, len: 8, price: 14, orig: 17, sale: 389, pattern: '猫咪' },
  { name: '文字印章胶带', width: 25, len: 5, price: 19, orig: 23, sale: 233, pattern: '文字' },
  { name: '星空烫银胶带', width: 18, len: 10, price: 20, orig: 24, sale: 178, pattern: '星空' },
  { name: '奶油系宽胶带', width: 40, len: 4, price: 26, orig: 32, sale: 121, pattern: '奶油' }
]

胶带模型TapeItem增加了width(宽度mm)和len(长度m)两个维度属性,以及pattern(印花图案)分类字段。10款胶带覆盖了波点、植物、报纸、几何、和风、渐变、猫咪、文字、星空、奶油十种印花风格,为筛选功能提供了丰富的测试数据。类似地,钢笔、墨水、贴纸品类也分别定义了PenItemInkItemStickerItem接口和对应的静态数组。

四、纯函数工具集设计

应用将所有业务逻辑封装为全局纯函数,不依赖任何组件实例状态,确保函数的输入输出具有确定性。

function jsPrice(p: number): string {
  return '¥' + p.toFixed(0)
}

function jsOff(p: number, o: number): number {
  return o > 0 ? Math.round((1 - p / o) * 100) : 0
}

function jsNum(v: string): number {
  let n = Number(v)
  if (isNaN(n)) {
    return 0
  }
  return n
}

function jsBarW(v: number): string {
  let w = v > 100 ? 100 : v
  return w + '%'
}

在这里插入图片描述

jsPrice将数字价格格式化为带人民币符号的字符串;jsOff计算折扣百分比,通过(1 - p / o) * 100公式得出降价幅度并取整;jsNum是一个安全的字符串转数字函数,当转换失败时返回0而非NaN,这在处理用户输入时尤为重要——用户可能在TextInput中输入非数字字符,直接使用Number()转换会导致NaN传播到后续计算。jsBarW将数值限制在0-100范围内并附加百分号,用于柱状图宽度计算。

function jsKindColor(k: string): string {
  if (k === '空白') {
    return '#2B4A6F'
  } else if (k === '横线') {
    return '#5B8AB5'
  } else if (k === '方格') {
    return '#8B6FB5'
  } else if (k === '时间轴') {
    return '#D98E32'
  } else if (k === '周计划') {
    return '#5BA88F'
  } else if (k === '日记') {
    return '#C96B6B'
  } else if (k === '索引') {
    return '#6BA3A3'
  }
  return '#8A93A3'
}

function jsRateStr(st: number): string {
  return '★★★★★'.substring(0, st) + '☆☆☆☆☆'.substring(0, 5 - st)
}

jsKindColor根据手账本内页类型返回对应的主题色,每种内页格式都有独特的颜色标识,例如空白页用复古蓝、横线页用浅蓝、方格页用紫色等。这种颜色映射使得用户在浏览商品列表时能够快速区分不同内页类型。jsRateStr巧妙地利用字符串截取方法生成星级评分字符串——用实心星截取前st位,再用空心星补齐剩余位数,这是一种简洁高效的评分显示方案。

纯函数的设计原则要求函数不产生副作用、不依赖外部可变状态。本应用中的工具函数虽然引用了全局const数组(如NOTE_LIST),但由于这些数组在运行期间不被修改(仅在组件内部通过slice()创建副本后操作),因此仍然保持了函数的确定性特征。

五、数据过滤与热门商品计算

应用通过纯函数实现了商品筛选和热门推荐逻辑,这些函数接收数组和过滤条件作为参数,返回新的过滤后数组。

function jsFilterNotes(arr: NotebookItem[], k: string): NotebookItem[] {
  if (k === '全部') {
    return arr
  }
  let out: NotebookItem[] = []
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].kind === k) {
      out.push(arr[i])
    }
  }
  return out
}

function jsHotNotes(): NotebookItem[] {
  let out: NotebookItem[] = []
  for (let i = 0; i < NOTE_LIST.length; i++) {
    if (NOTE_LIST[i].sale >= 200) {
      out.push(NOTE_LIST[i])
    }
  }
  return out
}

function jsPriceRange(p: number): string {
  if (p < 20) {
    return 'A 20元内'
  } else if (p < 40) {
    return 'B 20-40'
  } else if (p < 60) {
    return 'C 40-60'
  }
  return 'D 60+'
}

function jsRangeCount(r: string): number {
  let c = 0
  for (let i = 0; i < NOTE_LIST.length; i++) {
    if (jsPriceRange(NOTE_LIST[i].price) === r) {
      c = c + 1
    }
  }
  return c
}

在这里插入图片描述

jsFilterNotes实现了手账本的内页类型筛选逻辑:当筛选条件为"全部"时直接返回原数组,否则遍历数组并筛选kind字段匹配的商品。jsHotNotes从全局数据源中筛选月销量大于等于200的热销手账本,用于首页推荐模块。jsPriceRangejsRangeCount组合使用,先将价格映射到A/B/C/D四个区间,再统计每个区间的商品数量,用于生成价格分布柱状图。

六、主入口组件与Tab导航架构

应用的主入口组件JsApp通过@Entry@Component装饰器声明,管理当前选中的Tab索引和关于弹窗状态。

@Entry
@Component
struct JsApp {
  @State curTab: number = 0
  @State showAbout: boolean = false

  @Builder
  modalOverlay() {
    if (this.showAbout) {
      Column() {
        Column() {
          Text('手账文具社 · 关于我们')
            .fontSize(16)
            .fontColor(JS.text)
            .fontWeight(FontWeight.Bold)
          Text('一家专注手账与文具的复古小店,\n从手账本到胶带、从钢笔墨水到贴纸,\n每一件都经过编辑部亲手试写。')
            .fontSize(12)
            .fontColor(JS.sub)
            .lineHeight(20)
            .margin({ top: 12 })
          Row() {
            Text('手账 16 款')
              .fontSize(10)
              .fontColor(JS.primary)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10)
              .backgroundColor(JS.card2)
            Text('胶带 10 卷')
              .fontSize(10)
              .fontColor(JS.primary)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10)
              .backgroundColor(JS.card2)
              .margin({ left: 8 })
          }
          .margin({ top: 14 })
          Text('我知道了')
            .fontSize(13)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Bold)
            .width(200)
            .height(36)
            .textAlign(TextAlign.Center)
            .borderRadius(18)
            .backgroundColor(JS.primary)
            .margin({ top: 18 })
            .onClick(() => { this.showAbout = false })
        }
        .width('82%')
        .padding(18)
        .borderRadius(16)
        .backgroundColor(JS.card)
        .border({ width: 2, color: JS.line })
      }
      .position({ x: 0, y: 0 })
      .zIndex(999)
      .width('100%')
      .height('100%')
      .backgroundColor('#66000000')
      .justifyContent(FlexAlign.Center)
    }
  }

@State curTab驱动六个子组件的条件渲染,通过if-else链判断当前应展示哪个Tab页面。@Builder modalOverlay()是一个构建器函数,封装了"关于我们"弹窗的UI结构——当showAbouttrue时渲染一个居中的白色卡片,背景使用半透明黑色遮罩#66000000覆盖全屏,zIndex(999)确保弹窗位于所有内容之上。弹窗内部包含标题、描述文本、商品数量标签和关闭按钮,关闭按钮通过onClickshowAbout设为false,触发UI自动移除弹窗。

  build() {
    Stack() {
      Column() {
        Row() {
          Column() {
            Text('手账文具社')
              .fontSize(17)
              .fontColor(JS.text)
              .fontWeight(FontWeight.Bold)
            Text('复古印刷 · 纸质手作')
              .fontSize(10)
              .fontColor(JS.sub)
              .margin({ top: 1 })
          }
          .alignItems(HorizontalAlign.Start)
          .onClick(() => { this.showAbout = true })
          Row() {
            Text('⌕')
              .fontSize(15)
              .fontColor(JS.sub)
            Text('搜索手账 / 胶带 / 钢笔')
              .fontSize(11)
              .fontColor(JS.sub)
              .margin({ left: 6 })
          }
          .layoutWeight(1)
          .height(32)
          .borderRadius(16)
          .backgroundColor(JS.card)
          .border({ width: 1, color: JS.line })
          .justifyContent(FlexAlign.Center)
          .margin({ left: 12, right: 12 })
          Text('✉')
            .fontSize(17)
            .fontColor(JS.text)
          Text('☰')
            .fontSize(16)
            .fontColor(JS.text)
            .margin({ left: 14 })
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 8, bottom: 8 })
        .backgroundColor(JS.bg)
        .border({ width: { bottom: 2 }, color: JS.line })

        if (this.curTab === 0) {
          JsHomeTab({ onJump: (t: number) => { this.curTab = t } })
        } else if (this.curTab === 1) {
          JsBookTab()
        } else if (this.curTab === 2) {
          JsTapeTab()
        } else if (this.curTab === 3) {
          JsPenTab()
        } else if (this.curTab === 4) {
          JsStickerTab()
        } else {
          JsMineTab()
        }

        Row() {
          ForEach(TAB_LIST, (t: string, idx: number) => {
            Column() {
              Text(TAB_ICONS[idx])
                .fontSize(13)
                .fontColor('#FFFFFF')
                .fontWeight(FontWeight.Bold)
                .width(24)
                .height(24)
                .textAlign(TextAlign.Center)
                .borderRadius(12)
                .backgroundColor(idx === this.curTab ? JS.primary : JS.card2)
              Text(t)
                .fontSize(9)
                .fontColor(idx === this.curTab ? JS.primary : JS.sub)
                .fontWeight(idx === this.curTab ? FontWeight.Bold : FontWeight.Normal)
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .justifyContent(FlexAlign.Center)
            .height(54)
            .onClick(() => { this.curTab = idx })
          }, (t: string) => t)
        }
        .width('100%')
        .backgroundColor(JS.card)
        .border({ width: { top: 2 }, color: JS.line })
      }
      .width('100%')
      .height('100%')
      .backgroundColor(JS.bg)
      this.modalOverlay()
    }
    .width('100%')
    .height('100%')
  }
}

在这里插入图片描述

主组件的build()方法构建了应用的骨架结构:顶部导航栏(标题、搜索框、图标)、中间内容区域(根据curTab条件渲染对应Tab组件)、底部Tab栏。底部Tab栏通过ForEach遍历TAB_LIST数组渲染6个标签项,每个标签项包含一个图标圆角方块和文字标签。选中态通过idx === this.curTab条件判断,动态切换背景色、文字颜色和字重。值得注意的是,首页组件JsHomeTab接收了一个onJump回调函数,允许子组件在用户点击分类导航时直接切换父组件的curTab状态——这是ArkTS中子向父通信的常见模式。

七、首页动画与纸屑特效

首页组件JsHomeTab通过定时器驱动多种动画效果,为应用增添了生动感。

@Component
struct JsHomeTab {
  @State fallPhase: number = 0
  @State tapeRoll: number = 0
  @State stampScale: number = 1
  @State selNote: number = -1
  @State selTape: number = -1
  @State selStick: number = -1
  @State showTip: boolean = false
  onJump: (t: number) => void = (t: number) => {}

  aboutToAppear(): void {
    setInterval(() => {
      this.fallPhase = (this.fallPhase + 1) % 120
      this.stampScale = this.stampScale > 1.05 ? 0.88 : this.stampScale + 0.06
    }, 90)
    setInterval(() => {
      this.tapeRoll = (this.tapeRoll + 6) % 360
    }, 80)
  }

组件在aboutToAppear生命周期中启动了两个setInterval定时器:第一个每90ms递增fallPhase(0-119循环)并交替调整stampScale(印章缩放,在0.88和1.05+之间振荡),第二个每80ms递增tapeRoll角度(0-359循环,步进6度)。这些状态值通过@State装饰器声明,当定时器修改它们时,ArkUI框架会自动检测到状态变化并重新渲染依赖这些状态的UI节点,实现帧动画效果。

  build() {
    Stack() {
      Scroll() {
      Column() {
        Stack() {
          ForEach(PAPER_BITS, (b: PaperBit, i: number) => {
            Column() {}
              .width(7)
              .height(7)
              .backgroundColor(jsFallC(b.c))
              .rotate({ angle: i * 35 + this.fallPhase })
              .opacity(0.85)
              .position({ x: b.x + '%', y: jsFallY(this.fallPhase, i) })
          }, (b: PaperBit) => b.x + '_' + b.c)
          Text('◍')
            .fontSize(64)
            .fontColor('#F0C75E')
            .opacity(0.9)
            .rotate({ angle: this.tapeRoll })
            .position({ x: '68%', y: '12%' })
          Text('新店')
            .fontSize(13)
            .fontColor('#E8923D')
            .fontWeight(FontWeight.Bold)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .border({ width: 2, color: '#E8923D' })
            .borderRadius(4)
            .rotate({ angle: -12 })
            .scale({ x: this.stampScale, y: this.stampScale })
            .animation({ duration: 250 })
            .position({ x: '84%', y: '8%' })
          Column() {
            Text('手账新人礼')
              .fontSize(20)
              .fontColor(JS.text)
              .fontWeight(FontWeight.Bold)
            Text('全场手账本 8 折起 · 满 3 件赠贴纸包')
              .fontSize(11)
              .fontColor(JS.sub)
              .margin({ top: 6 })
            Row() {
              Text('新客立减 ¥5')
                .fontSize(10)
                .fontColor('#FFFFFF')
                .fontWeight(FontWeight.Bold)
                .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                .borderRadius(10)
                .backgroundColor(JS.primary)
              Text('包邮到家')
                .fontSize(10)
                .fontColor('#FFFFFF')
                .fontWeight(FontWeight.Bold)
                .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                .borderRadius(10)
                .backgroundColor('#D98E32')
                .margin({ left: 8 })
            }
            .margin({ top: 12 })
          }
          .alignItems(HorizontalAlign.Start)
          .position({ x: '6%', y: '20%' })
        }
        .width('100%')
        .height(150)
        .borderRadius(16)
        .backgroundColor(JS.card)
        .border({ width: 2, color: JS.line })
        .clip(true)
        .margin({ top: 10 })

在这里插入图片描述

首页顶部的横幅区域使用Stack布局叠加了多层元素:最底层是通过ForEach渲染的PAPER_BITS纸屑粒子,每个粒子是一个7x7的小方块,颜色由jsFallC函数决定(三种颜色循环),位置通过jsFallY函数计算Y坐标形成飘落动画,旋转角度随fallPhase递增;中间层是一个旋转的胶带卷轴图标(字符),通过tapeRoll角度驱动旋转;上方还有一个"新店"印章标签,通过stampScale实现呼吸式缩放动画,配合.animation({ duration: 250 })实现平滑过渡。整个横幅区域设置了.clip(true)裁剪,确保超出边界的纸屑粒子不会溢出。

八、首页分类导航与商品推荐

首页继续向下排列了分类导航宫格和热销商品横滑列表。

        Column() {
          Text('分类导航')
            .fontSize(15)
            .fontColor(JS.text)
            .fontWeight(FontWeight.Bold)
          Row() {
            ForEach(jsCatsA(), (c: CatItem, idx: number) => {
              Column() {
                Text(c.icon)
                  .fontSize(17)
                  .fontColor('#FFFFFF')
                  .fontWeight(FontWeight.Bold)
                  .width(42)
                  .height(42)
                  .textAlign(TextAlign.Center)
                  .borderRadius(21)
                  .backgroundColor(c.color)
                Text(c.name)
                  .fontSize(11)
                  .fontColor(JS.text)
                  .margin({ top: 6 })
              }
              .layoutWeight(1)
              .onClick(() => {
                if (idx === 0) {
                  this.onJump(1)
                } else if (idx === 1) {
                  this.onJump(2)
                } else if (idx === 2) {
                  this.onJump(3)
                } else if (idx === 3) {
                  this.onJump(3)
                } else {
                  this.showTip = true
                }
              })
            }, (c: CatItem) => c.name)
          }
          .width('100%')
          .margin({ top: 14 })
          Row() {
            ForEach(jsCatsB(), (c: CatItem, idx: number) => {
              Column() {
                Text(c.icon)
                  .fontSize(17)
                  .fontColor('#FFFFFF')
                  .fontWeight(FontWeight.Bold)
                  .width(42)
                  .height(42)
                  .textAlign(TextAlign.Center)
                  .borderRadius(21)
                  .backgroundColor(c.color)
                Text(c.name)
                  .fontSize(11)
                  .fontColor(JS.text)
                  .margin({ top: 6 })
              }
              .layoutWeight(1)
              .onClick(() => {
                if (idx === 0) {
                  this.onJump(4)
                } else {
                  this.showTip = true
                }
              })
            }, (c: CatItem) => c.name)
          }
          .width('100%')
          .margin({ top: 14 })
        }

在这里插入图片描述

分类导航分为两行(jsCatsA()返回前4个分类,jsCatsB()返回后4个),共8个分类入口:手账本、胶带、钢笔、墨水、贴纸、便签、印章、礼盒。每个分类项显示一个带背景色的圆角图标和分类名称,点击时通过onJump回调跳转到对应的Tab页面。对于尚未上线的分类(如便签、印章、礼盒),点击后触发showTip状态显示"敬请期待"提示弹窗。这种分类跳转机制通过回调函数实现了子组件对父组件状态的控制。

        Scroll() {
          Row() {
            ForEach(jsHotNotes(), (n: NotebookItem, idx: number) => {
              Column() {
                Text(jsNoteImgN(n.name))
                  .fontSize(24)
                  .fontColor('#FFFFFF')
                  .fontWeight(FontWeight.Bold)
                  .width(62)
                  .height(62)
                  .textAlign(TextAlign.Center)
                  .borderRadius(16)
                  .backgroundColor(JS.primary)
                Text(n.name)
                  .fontSize(12)
                  .fontColor(JS.text)
                  .fontWeight(FontWeight.Bold)
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                  .margin({ top: 8 })
                Text(n.kind + '内页 · ' + n.pages + '页')
                  .fontSize(9)
                  .fontColor(JS.sub)
                  .margin({ top: 4 })
                Row() {
                  Text(jsPrice(n.price))
                    .fontSize(14)
                    .fontColor('#D98E32')
                    .fontWeight(FontWeight.Bold)
                  Text('月销' + n.sale)
                    .fontSize(9)
                    .fontColor(JS.sub)
                    .margin({ left: 6 })
                }
                .margin({ top: 5 })
              }
              .alignItems(HorizontalAlign.Start)
              .width(140)
              .padding(10)
              .borderRadius(12)
              .backgroundColor(JS.card)
              .border({ width: 1, color: JS.line })
              .margin({ right: 10 })
              .onClick(() => { this.selNote = jsFindNoteIdx(NOTE_LIST, n.name) })
            }, (n: NotebookItem) => n.name)
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
        .margin({ top: 10 })
        .align(Alignment.Start)

在这里插入图片描述

热销手账本区域使用水平滚动的Scroll容器包裹Row布局,通过ForEach渲染jsHotNotes()返回的热销商品。每个商品卡片宽度固定为140,包含图标(取商品名首字符)、名称、内页信息、价格和销量。maxLines(1)配合textOverflow({ overflow: TextOverflow.Ellipsis })实现单行文本溢出省略,防止长名称破坏布局。scrollBar(BarState.Off)隐藏滚动条,营造流畅的横滑浏览体验。点击商品卡片时,通过jsFindNoteIdx查找商品在全局数组中的索引并赋值给selNote,触发详情弹窗显示。

九、手账本Tab的CRUD操作与价格分布图

手账本Tab页面JsBookTab实现了完整的商品管理功能,包括价格分布统计图、分类筛选和增删改查操作。

@Component
struct JsBookTab {
  @State notes: NotebookItem[] = []
  @State kind: string = '全部'
  @State selIdx: number = -1
  @State showAdd: boolean = false
  @State addName: string = '复古牛皮 A5 手账本'
  @State addKind: string = '空白'
  @State addPages: string = '192'
  @State addPrice: string = '32'
  @State showEdit: boolean = false
  @State editIdx: number = -1
  @State editName: string = ''
  @State editKind: string = '空白'
  @State editPages: string = ''
  @State editPrice: string = ''
  @State showDel: boolean = false
  @State delIdx: number = -1

  aboutToAppear(): void {
    this.notes = NOTE_LIST.slice()
  }

组件在aboutToAppear中通过NOTE_LIST.slice()创建数据源副本。slice()方法返回数组的浅拷贝,确保后续的增删改操作不会影响全局NOTE_LIST常量。组件维护了大量@State状态变量,分为三组:列表数据(notes)、筛选条件(kind)、以及CRUD操作相关的弹窗控制状态和表单输入字段。新增表单的默认值(addNameaddKind等)预设了常用商品模板,减少用户输入量。

  build() {
    Stack() {
      Scroll() {
      Column() {
        Row() {
          Column() {
            Text('价格区间')
              .fontSize(15)
              .fontColor(JS.text)
              .fontWeight(FontWeight.Bold)
            Text('PRICE RANGE')
              .fontSize(9)
              .fontColor(JS.glow)
              .letterSpacing(1)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          Column().layoutWeight(1)
          Text('共 ' + this.notes.length + ' 款')
            .fontSize(11)
            .fontColor(JS.sub)
        }
        .width('100%')
        .margin({ top: 12 })

        Column() {
          Row() {
            Text('A 20元内').fontSize(11).fontColor(JS.sub).width(64)
            Stack() {
              Row() {}.width('100%').height(10).borderRadius(5).backgroundColor(JS.card2)
              Row() {}.width(jsBarW(jsRangeCount('A 20元内') * 22)).height(10).borderRadius(5).backgroundColor('#2B4A6F').margin({ right: 130 })
            }
            .layoutWeight(1)
            Text(' ' + jsRangeCount('A 20元内') + ' 款').fontSize(11).fontColor(JS.text).width(52).textAlign(TextAlign.End)
          }
          .width('100%')
          .margin({ top: 10 })
          Row() {
            Text('B 20-40').fontSize(11).fontColor(JS.sub).width(64)
            Stack() {
              Row() {}.width('100%').height(10).borderRadius(5).backgroundColor(JS.card2)
              Row() {}.width(jsBarW(jsRangeCount('B 20-40') * 22)).height(10).borderRadius(5).backgroundColor('#5B8AB5').margin({ right: 130 })
            }
            .layoutWeight(1)
            Text(' ' + jsRangeCount('B 20-40') + ' 款').fontSize(11).fontColor(JS.text).width(52).textAlign(TextAlign.End)
          }
          .width('100%')
          .margin({ top: 8 })

价格分布图使用了Stack叠加两层Row实现进度条效果:底层是满宽的灰色背景条(JS.card2),上层是根据商品数量计算宽度的彩色进度条。每个价格区间使用不同颜色(A用复古蓝、B用浅蓝、C用橙色、D用玫红),通过jsRangeCount统计该区间的商品数量,再乘以22得到柱状图宽度百分比。margin({ right: 130 })确保进度条不会占据全部宽度,留出右侧空间显示数值。这种通过布局叠加实现自定义进度条的方法是ArkUI中常见的可视化技巧。

点击筛选

点击商品

点击新增

保存

编辑

保存

删除

确认

用户进入手账本Tab

aboutToAppear初始化数据副本

渲染价格分布图

渲染分类筛选胶囊

用户操作

jsFilterNotes过滤列表

渲染过滤后商品列表

设置selIdx触发详情弹窗

显示商品详情/编辑/删除按钮

显示新增表单弹窗

unshift插入数组头部

显示编辑表单弹窗

splice替换数组元素

显示删除确认弹窗

splice删除数组元素

十、手账本详情弹窗与新增表单

当用户点击列表中的商品时,selIdx被设置为对应索引,触发详情弹窗显示。

  @Builder
  modalOverlay() {
    if (this.selIdx >= 0) {
      Column() {
        Column() {
          Row() {
            Text(jsNoteImgN(this.notes[this.selIdx].name))
              .fontSize(26)
              .fontColor('#FFFFFF')
              .fontWeight(FontWeight.Bold)
              .width(54)
              .height(54)
              .textAlign(TextAlign.Center)
              .borderRadius(14)
              .backgroundColor(JS.primary)
            Column() {
              Text(this.notes[this.selIdx].name)
                .fontSize(15)
                .fontColor(JS.text)
                .fontWeight(FontWeight.Bold)
              Text(jsRateStr(this.notes[this.selIdx].stars))
                .fontSize(11)
                .fontColor('#E8923D')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })
            Column() {
              Text(jsPrice(this.notes[this.selIdx].price))
                .fontSize(17)
                .fontColor('#D98E32')
                .fontWeight(FontWeight.Bold)
              Text('月销' + this.notes[this.selIdx].sale)
                .fontSize(10)
                .fontColor(JS.sub)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          Row() {
            Column() {
              Text('内页').fontSize(10).fontColor(JS.sub)
              Text(this.notes[this.selIdx].kind).fontSize(13).fontColor(jsKindColor(this.notes[this.selIdx].kind)).fontWeight(FontWeight.Bold).margin({ top: 4 })
            }
            .layoutWeight(1)
            Column() {
              Text('页数').fontSize(10).fontColor(JS.sub)
              Text(this.notes[this.selIdx].pages + '页').fontSize(13).fontColor(JS.text).fontWeight(FontWeight.Bold).margin({ top: 4 })
            }
            .layoutWeight(1)
            Column() {
              Text('标签').fontSize(10).fontColor(JS.sub)
              Text(this.notes[this.selIdx].tag).fontSize(13).fontColor(JS.glow).fontWeight(FontWeight.Bold).margin({ top: 4 })
            }
            .layoutWeight(1)
          }
          .width('100%')
          .padding(10)
          .borderRadius(10)
          .backgroundColor(JS.card2)
          .margin({ top: 12 })
          Row() {
            Text('编辑')
              .fontSize(12)
              .fontColor(JS.text)
              .width(76)
              .height(32)
              .textAlign(TextAlign.Center)
              .borderRadius(16)
              .backgroundColor(JS.card2)
              .onClick(() => {
                this.editIdx = this.selIdx
                this.editName = this.notes[this.selIdx].name
                this.editKind = this.notes[this.selIdx].kind
                this.editPages = this.notes[this.selIdx].pages.toFixed(0)
                this.editPrice = this.notes[this.selIdx].price.toFixed(0)
                this.selIdx = -1
                this.showEdit = true
              })
            Text('删除')
              .fontSize(12)
              .fontColor('#C96B6B')
              .width(76)
              .height(32)
              .textAlign(TextAlign.Center)
              .borderRadius(16)
              .backgroundColor('#F7E3E3')
              .margin({ left: 10 })
              .onClick(() => {
                this.delIdx = this.selIdx
                this.showDel = true
              })
            Text('加入购物车')
              .fontSize(12)
              .fontColor('#FFFFFF')
              .fontWeight(FontWeight.Bold)
              .layoutWeight(1)
              .height(32)
              .textAlign(TextAlign.Center)
              .borderRadius(16)
              .backgroundColor(JS.primary)
              .margin({ left: 10 })
              .onClick(() => { this.selIdx = -1 })
          }
          .width('100%')
          .margin({ top: 14 })

详情弹窗的@Builder modalOverlay()通过if (this.selIdx >= 0)条件判断是否渲染。弹窗内容分为三部分:顶部商品信息行(图标、名称、评分、价格、销量)、中间属性网格(内页类型、页数、标签,三等分布局)、底部操作按钮行(编辑、删除、加入购物车)。编辑按钮的onClick处理器首先将当前商品数据复制到编辑表单状态变量中,然后关闭详情弹窗(selIdx = -1)并打开编辑弹窗(showEdit = true)。这种通过状态切换实现弹窗切换的方式,避免了同时显示多个弹窗的布局冲突。

@Builder装饰器是ArkUI中复用UI片段的关键机制。与独立组件不同,@Builder方法在当前组件上下文中执行,可以直接访问this上的状态变量和方法,适合封装组件内部的弹窗、卡片等局部UI结构。

十一、新增表单与数据持久化操作

新增手账本的表单弹窗提供了完整的商品信息输入界面。

    if (this.showAdd) {
      Column() {
        Column() {
          Row() {
            Text('+ 新增手账本')
              .fontSize(16)
              .fontColor(JS.text)
              .fontWeight(FontWeight.Bold)
            Column().layoutWeight(1)
            Text('✕')
              .fontSize(16)
              .fontColor(JS.sub)
              .onClick(() => { this.showAdd = false })
          }
          .width('100%')
          Text('手账本名称')
            .fontSize(11)
            .fontColor(JS.sub)
            .width('100%')
            .margin({ top: 12 })
          TextInput({ placeholder: '输入名称', text: this.addName })
            .onChange((v: string) => { this.addName = v })
            .fontSize(12)
            .fontColor(JS.text)
            .height(38)
            .backgroundColor(JS.card2)
            .borderRadius(8)
            .margin({ top: 6 })
          Text('内页类型(点按切换)')
            .fontSize(11)
            .fontColor(JS.sub)
            .width('100%')
            .margin({ top: 12 })
          Text(this.addKind)
            .fontSize(12)
            .fontColor(jsKindColor(this.addKind))
            .fontWeight(FontWeight.Bold)
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .borderRadius(8)
            .backgroundColor(JS.card2)
            .margin({ top: 6 })
            .onClick(() => { this.addKind = jsNextKind(this.addKind) })
          Row() {
            Column() {
              Text('页数').fontSize(11).fontColor(JS.sub)
              TextInput({ placeholder: '0', text: this.addPages })
                .onChange((v: string) => { this.addPages = v })
                .type(InputType.Number)
                .fontSize(13)
                .fontColor(JS.text)
                .width(110)
                .height(32)
                .backgroundColor(JS.card2)
                .borderRadius(8)
                .margin({ top: 6 })
            }
            .alignItems(HorizontalAlign.Start)
            Column() {
              Text('售价').fontSize(11).fontColor(JS.sub)
              TextInput({ placeholder: '0', text: this.addPrice })
                .onChange((v: string) => { this.addPrice = v })
                .type(InputType.Number)
                .fontSize(13)
                .fontColor('#D98E32')
                .width(110)
                .height(32)
                .backgroundColor(JS.card2)
                .borderRadius(8)
                .margin({ top: 6 })
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 20 })
          }
          .width('100%')
          .margin({ top: 12 })
          Text('保存上架')
            .fontSize(13)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Bold)
            .width('100%')
            .height(38)
            .textAlign(TextAlign.Center)
            .borderRadius(19)
            .backgroundColor(JS.primary)
            .margin({ top: 16 })
            .onClick(() => {
              let newItem: NotebookItem = {
                name: this.addName,
                kind: this.addKind,
                pages: jsNum(this.addPages),
                price: jsNum(this.addPrice),
                orig: 0,
                sale: 0,
                stars: 4,
                tag: '新品'
              }
              this.notes.unshift(newItem)
              this.showAdd = false
            })
        }
        .width('88%')
        .padding(16)
        .borderRadius(16)
        .backgroundColor(JS.card)
        .border({ width: 2, color: JS.line })
      }
      .position({ x: 0, y: 0 })
      .zIndex(999)
      .width('100%')
      .height('100%')
      .backgroundColor('#66000000')
      .justifyContent(FlexAlign.Center)
    }

新增表单弹窗的特色在于内页类型的选择方式——并非使用下拉菜单或单选按钮,而是通过点击文本标签循环切换addKind值(jsNextKind函数在BOOK_KINDS数组中查找当前值并返回下一个)。这种交互方式简化了表单结构,同时通过jsKindColor为每种内页类型显示对应颜色,提供视觉反馈。页数和售价输入框使用InputType.Number限制为数字键盘输入,确保数据格式正确。保存时通过jsNum安全转换字符串为数字,构造NotebookItem对象后使用unshift插入数组头部,使新商品立即出现在列表最上方。

十二、胶带Tab的宽度排行与旋转动画

胶带Tab页面JsTapeTab展示了一个独特的宽度排行榜和旋转胶带卷轴动画。

@Component
struct JsTapeTab {
  @State tapes: TapeItem[] = []
  @State pattern: string = '全部'
  @State tapeRoll: number = 0
  @State selIdx: number = -1
  @State showAdd: boolean = false
  @State showEdit: boolean = false
  @State showDel: boolean = false
  // ... 编辑和删除相关状态省略

  aboutToAppear(): void {
    this.tapes = TAPE_LIST.slice()
    setInterval(() => {
      this.tapeRoll = (this.tapeRoll + 6) % 360
    }, 80)
  }

胶带Tab的aboutToAppear中启动了tapeRoll旋转定时器,每80ms递增6度。这个角度值被用于列表中每个胶带图标的rotate变换,使得所有胶带卷轴图标持续旋转,营造出胶带正在展开的视觉效果。

  build() {
    Stack() {
      Scroll() {
      Column() {
        Row() {
          Column() {
            Text('宽度排行')
              .fontSize(15)
              .fontColor(JS.text)
              .fontWeight(FontWeight.Bold)
            Text('WIDTH TOP5')
              .fontSize(9)
              .fontColor(JS.glow)
              .letterSpacing(1)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          Column().layoutWeight(1)
          Text('共 ' + this.tapes.length + ' 卷')
            .fontSize(11)
            .fontColor(JS.sub)
        }
        .width('100%')
        .margin({ top: 12 })

        Column() {
          Row() {
            Text('奶油系宽').fontSize(11).fontColor(JS.sub).width(72)
            Stack() {
              Row() {}.width('100%').height(10).borderRadius(5).backgroundColor(JS.card2)
              Row() {}.width(jsTapeW(TAPE_LIST[9])).height(10).borderRadius(5).backgroundColor('#E8C9A0').margin({ right: 130 })
            }
            .layoutWeight(1)
            Text(' ' + TAPE_LIST[9].width + 'mm').fontSize(11).fontColor(JS.text).width(48).textAlign(TextAlign.End)
          }
          .width('100%')
          .margin({ top: 8 })
        }
        .width('100%')
        .padding(12)
        .borderRadius(12)
        .backgroundColor(JS.card)
        .border({ width: 1, color: JS.line })
        .margin({ top: 10 })

宽度排行榜通过jsTapeW函数将胶带宽度(mm)转换为百分比宽度,Math.round(tape.width * 100 / 40)以40mm为满宽基准计算。排行榜展示了宽度最大的5款胶带,每行包含名称、进度条和数值。列表区域的每个胶带卡片使用Stack叠加旋转的图标和印花首字符,图标颜色由jsTapeC根据印花图案返回对应色彩。

十三、钢笔墨水Tab的笔尖分布与双品类管理

钢笔墨水Tab页面JsPenTab独特之处在于管理两个品类(钢笔和墨水),通过子筛选切换显示。

struct JsPenTab {
  @State pens: PenItem[] = []
  @State inks: InkItem[] = []
  @State sub: string = '全部'
  @State selP: number = -1
  @State selI: number = -1
  @State inkRoll: number = 0
  // ... 大量CRUD状态变量

  aboutToAppear(): void {
    this.pens = PEN_LIST.slice()
    this.inks = INK_LIST.slice()
    setInterval(() => {
      this.inkRoll = (this.inkRoll + 5) % 360
    }, 90)
  }

组件维护了两组独立的@State变量(pens/inks用于数据,selP/selI用于选中索引,showAddP/showAddI用于弹窗控制等),通过sub筛选值决定显示钢笔区、墨水区或两者同时显示。inkRoll定时器驱动墨水图标中"墨"字旋转,营造出墨水瓶中墨水旋转的视觉效果。

          if (this.sub !== '墨水') {
            Row() {
              Text('钢笔货架')
                .fontSize(14)
                .fontColor(JS.text)
                .fontWeight(FontWeight.Bold)
              Column().layoutWeight(1)
              Text('+ 上新')
                .fontSize(12)
                .fontColor(JS.primary)
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
                .borderRadius(12)
                .backgroundColor(JS.card2)
                .onClick(() => { this.showAddP = true })
            }
            .width('100%')
            .margin({ top: 14 })

            ForEach(this.pens, (p: PenItem, idx: number) => {
              Row() {
                Stack() {
                  Text(p.name.charAt(0))
                    .fontSize(22)
                    .fontColor('#FFFFFF')
                    .fontWeight(FontWeight.Bold)
                }
                .width(46)
                .height(46)
                .borderRadius(23)
                .backgroundColor('#5B8AB5')
                Column() {
                  Text(p.name)
                    .fontSize(13)
                    .fontColor(JS.text)
                    .fontWeight(FontWeight.Bold)
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Row() {
                    Text(p.tip)
                      .fontSize(9)
                      .fontColor(jsTipColor(p.tip))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .borderRadius(6)
                      .backgroundColor(JS.card2)
                    Text(p.mat)
                      .fontSize(9)
                      .fontColor(JS.sub)
                      .margin({ left: 6 })
                  }
                  .margin({ top: 5 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 10 })
                Column() {
                  Text(jsPrice(p.price))
                    .fontSize(14)
                    .fontColor('#D98E32')
                    .fontWeight(FontWeight.Bold)
                  Text('月销' + p.sale)
                    .fontSize(9)
                    .fontColor(JS.sub)
                    .margin({ top: 3 })
                }
                .alignItems(HorizontalAlign.End)
              }
              .width('100%')
              .padding(10)
              .borderRadius(12)
              .backgroundColor(JS.card)
              .border({ width: 1, color: JS.line })
              .margin({ top: 8 })
              .onClick(() => { this.selP = jsFindPenIdx(this.pens, p.name) })
            }, (p: PenItem) => p.name)
          }

          if (this.sub !== '钢笔') {
            Row() {
              Text('墨水区')
                .fontSize(14)
                .fontColor(JS.text)
                .fontWeight(FontWeight.Bold)
              Column().layoutWeight(1)
              Text('+ 上新')
                .fontSize(12)
                .fontColor(JS.primary)
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
                .borderRadius(12)
                .backgroundColor(JS.card2)
                .onClick(() => { this.showAddI = true })
            }
            .width('100%')
            .margin({ top: 14 })

            ForEach(this.inks, (i: InkItem, idx: number) => {
              Row() {
                Stack() {
                  Text('墨')
                    .fontSize(18)
                    .fontColor('#FFFFFF')
                    .fontWeight(FontWeight.Bold)
                    .rotate({ angle: this.inkRoll })
                }
                .width(46)
                .height(46)
                .borderRadius(12)
                .backgroundColor(i.color)
                Column() {
                  Text(i.name)
                    .fontSize(13)
                    .fontColor(JS.text)
                    .fontWeight(FontWeight.Bold)
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Row() {
                    Text(i.vol + 'ml')
                      .fontSize(9)
                      .fontColor(i.color)
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .borderRadius(6)
                      .backgroundColor(JS.card2)
                    Text('玻璃瓶')
                      .fontSize(9)
                      .fontColor(JS.sub)
                      .margin({ left: 6 })
                  }
                  .margin({ top: 5 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 10 })
                Column() {
                  Text(jsPrice(i.price))
                    .fontSize(14)
                    .fontColor('#D98E32')
                    .fontWeight(FontWeight.Bold)
                  Text('月销' + i.sale)
                    .fontSize(9)
                    .fontColor(JS.sub)
                    .margin({ top: 3 })
                }
                .alignItems(HorizontalAlign.End)
              }
              .width('100%')
              .padding(10)
              .borderRadius(12)
              .backgroundColor(JS.card)
              .border({ width: 1, color: JS.line })
              .margin({ top: 8 })
              .onClick(() => { this.selI = idx })
            }, (i: InkItem) => i.name)
          }

笔尖分布图通过jsTipBarjsTipCount函数统计F、EF、M三种笔尖的分布情况并渲染为水平柱状图。墨水列表的每个图标背景色直接使用墨水自身的color属性(如#2E8B8B松石绿、#2B4A6F复古蓝黑等),让用户直观感知墨水颜色。sub筛选值为"全部"时同时显示钢笔和墨水列表,为"钢笔"时只显示钢笔,为"墨水"时只显示墨水,通过if条件判断控制各区域的渲染。

十四、个人中心Tab与会员卡光泽特效

个人中心页面JsMineTab实现了会员卡、消费统计、订单管理和收藏列表等功能。

struct JsMineTab {
  @State glowX: number = -160
  @State selOrder: number = -1
  @State showCoupon: boolean = false
  @State showEditInfo: boolean = false
  @State nickName: string = '文具收藏家'
  @State city: string = '上海'
  @State sign: string = '手账十年,笔耕不辍'

  aboutToAppear(): void {
    setInterval(() => {
      this.glowX = (this.glowX + 14) % 540 - 170
    }, 40)
  }

会员卡的光泽扫过特效通过glowX状态变量实现:每40ms递增14,对540取模后减170,使得一个半透明白色条在会员卡上方横向移动。这个白色条是一个Row组件,设置了backgroundColor('#FFFFFF2E')(约18%透明度白色)和rotate({ angle: 18 })(18度倾斜),通过translate({ x: this.glowX })改变水平位置。当glowX在-170到370之间循环变化时,白色条从卡片左侧扫到右侧,模拟出金属光泽的视觉效果。

          Stack() {
            Column() {
              Row() {
                Stack() {
                  Text('我')
                    .fontSize(18)
                    .fontColor('#FFFFFF')
                    .fontWeight(FontWeight.Bold)
                }
                .width(44)
                .height(44)
                .borderRadius(22)
                .backgroundColor('#FFFFFF66')
                Column() {
                  Text(this.nickName)
                    .fontSize(15)
                    .fontColor('#FFFFFF')
                    .fontWeight(FontWeight.Bold)
                  Text('Lv.6 手账收藏家')
                    .fontSize(10)
                    .fontColor('#FFFFFFAA')
                    .margin({ top: 3 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 10 })
                Text('编辑')
                  .fontSize(11)
                  .fontColor('#FFFFFF')
                  .padding({ left: 12, right: 12, top: 5, bottom: 5 })
                  .borderRadius(12)
                  .backgroundColor('#FFFFFF33')
                  .onClick(() => { this.showEditInfo = true })
              }
              .width('100%')
              Row() {
                Text('会员积分')
                  .fontSize(10)
                  .fontColor('#FFFFFFAA')
                Column().layoutWeight(1)
                Text('1860 分')
                  .fontSize(12)
                  .fontColor('#FFFFFF')
                  .fontWeight(FontWeight.Bold)
              }
              .width('100%')
              .margin({ top: 14 })
            }
            .width('100%')
            .height(120)
            .padding(14)
            .borderRadius(16)
            .backgroundColor(JS.primary)
            Row() {
              Row() {}
                .width(90)
                .height(60)
                .borderRadius(30)
                .backgroundColor('#FFFFFF2E')
                .rotate({ angle: 18 })
                .translate({ x: this.glowX })
            }
            .width('100%')
            .height('100%')
            .justifyContent(FlexAlign.Start)
            .clip(true)
          }
          .width('100%')
          .borderRadius(16)
          .margin({ top: 12 })
          .clip(true)

会员卡使用Stack叠加两层:底层是backgroundColor(JS.primary)的复古蓝背景,包含头像、昵称、等级和积分信息;上层是移动的光泽条。外层.clip(true)确保光泽条超出卡片边界时被裁剪。会员卡下方的统计数据行(8订单、6收藏、4优惠券)使用layoutWeight(1)三等分布局,点击优惠券区域可弹出优惠券列表弹窗。

光泽扫过动画是电商应用中提升VIP卡片质感的经典手法。通过高频定时器(40ms间隔)驱动一个半透明倾斜条的横向位移,配合clip(true)裁剪,能够在不使用Lottie动画或自定义绘制的情况下实现流畅的光效。

十五、技术特性对比与总结

技术维度实现方式特点分析
状态管理@State装饰器组件内部状态自动驱动UI刷新,无需手动调用invalidate
组件通信onJump回调函数子组件通过回调修改父组件状态,实现Tab跳转
弹窗管理@Builder modalOverlay() + 条件渲染通过boolean状态控制弹窗显隐,zIndex管理层级
动画方案setInterval + @State定时器修改状态值,框架自动重渲染实现帧动画
数据隔离slice()创建副本组件维护独立数据副本,避免修改全局常量
表单输入TextInput + onChange双向绑定式数据采集,jsNum安全转换
列表渲染ForEach + keyGenerator以商品name作为唯一key,优化diff性能
文本溢出maxLines(1) + textOverflow单行省略号处理长文本,保持布局稳定
数据可视化Stack叠加Row通过布局叠加实现自定义进度条和柱状图
色彩系统ColorPalette接口 + const集中管理主题色,支持统一视觉规范
筛选逻辑纯函数过滤数组jsFilterNotes等函数接收数组和条件返回新数组
CRUD操作push/unshift/splice原生数组方法操作数据副本,触发自动刷新

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

import { display } from '@kit.ArkUI'

interface ColorPalette {
  bg: string
  card: string
  card2: string
  primary: string
  accent: string
  star: string
  text: string
  sub: string
  line: string
  glow: string
}

const JS: ColorPalette = {
  bg: '#FDF6E3',
  card: '#FFFFFF',
  card2: '#F7EEDB',
  primary: '#2B4A6F',
  accent: '#F0C75E',
  star: '#E8923D',
  text: '#3A4A5C',
  sub: '#8A93A3',
  line: '#E3D9C0',
  glow: '#D98E32'
}

interface PaperBit {
  x: number
  c: number
}

const PAPER_BITS: PaperBit[] = [
  { x: 8, c: 0 }, { x: 18, c: 1 }, { x: 30, c: 2 }, { x: 42, c: 0 },
  { x: 55, c: 1 }, { x: 66, c: 2 }, { x: 78, c: 0 }, { x: 90, c: 1 }
]

interface NotebookItem {
  name: string
  kind: string
  pages: number
  price: number
  orig: number
  sale: number
  stars: number
  tag: string
}

const NOTE_LIST: NotebookItem[] = [
  { name: '复古牛皮 A5 手账本', kind: '空白', pages: 192, price: 32, orig: 42, sale: 320, stars: 5, tag: '经典' },
  { name: '星空计划手账 A6', kind: '时间轴', pages: 128, price: 28, orig: 36, sale: 210, stars: 4, tag: '热销' },
  { name: '极简纯白布面手账', kind: '方格', pages: 240, price: 45, orig: 55, sale: 156, stars: 5, tag: '布面' },
  { name: '国风水墨手账本', kind: '空白', pages: 160, price: 38, orig: 48, sale: 188, stars: 5, tag: '国风' },
  { name: '复古印刷蓝经典手账', kind: '横线', pages: 192, price: 36, orig: 46, sale: 265, stars: 5, tag: '经典' },
  { name: '旅行手账 TN 护照款', kind: '空白', pages: 96, price: 58, orig: 68, sale: 98, stars: 4, tag: 'TN' },
  { name: '甜点主题便携手账', kind: '横线', pages: 128, price: 26, orig: 33, sale: 342, stars: 4, tag: '萌系' },
  { name: '手账元年周计划本', kind: '周计划', pages: 208, price: 42, orig: 52, sale: 132, stars: 4, tag: '效率' },
  { name: '猫爪印萌系手账', kind: '方格', pages: 112, price: 24, orig: 30, sale: 410, stars: 4, tag: '萌系' },
  { name: '植物标本手账本', kind: '空白', pages: 176, price: 48, orig: 58, sale: 87, stars: 5, tag: '植物' },
  { name: '复古报纸风手账', kind: '横线', pages: 160, price: 33, orig: 41, sale: 175, stars: 4, tag: '复古' },
  { name: '五年日记手账本', kind: '日记', pages: 365, price: 68, orig: 82, sale: 76, stars: 5, tag: '日记' },
  { name: '咖啡色皮面手账', kind: '横线', pages: 240, price: 88, orig: 108, sale: 64, stars: 5, tag: '皮面' },
  { name: '和风樱花手账', kind: '方格', pages: 144, price: 30, orig: 38, sale: 226, stars: 4, tag: '和风' },
  { name: '图书馆索引卡手账', kind: '索引', pages: 200, price: 52, orig: 62, sale: 92, stars: 4, tag: '索引' },
  { name: '夜间灵感黑卡手账', kind: '空白', pages: 128, price: 35, orig: 45, sale: 143, stars: 4, tag: '黑卡' }
]

interface TapeItem {
  name: string
  width: number
  len: number
  price: number
  orig: number
  sale: number
  pattern: string
}

const TAPE_LIST: TapeItem[] = [
  { name: '波点复古胶带', width: 15, len: 10, price: 12, orig: 15, sale: 520, pattern: '波点' },
  { name: '手绘植物胶带', width: 20, len: 8, price: 15, orig: 18, sale: 380, pattern: '植物' },
  { name: '复古报纸印花胶带', width: 25, len: 5, price: 18, orig: 22, sale: 264, pattern: '报纸' },
  { name: '烫金几何胶带', width: 10, len: 12, price: 16, orig: 19, sale: 312, pattern: '几何' },
  { name: '和风纹样胶带', width: 30, len: 6, price: 22, orig: 26, sale: 198, pattern: '和风' },
  { name: '彩虹渐变胶带', width: 15, len: 10, price: 13, orig: 16, sale: 456, pattern: '渐变' },
  { name: '猫咪日常胶带', width: 20, len: 8, price: 14, orig: 17, sale: 389, pattern: '猫咪' },
  { name: '文字印章胶带', width: 25, len: 5, price: 19, orig: 23, sale: 233, pattern: '文字' },
  { name: '星空烫银胶带', width: 18, len: 10, price: 20, orig: 24, sale: 178, pattern: '星空' },
  { name: '奶油系宽胶带', width: 40, len: 4, price: 26, orig: 32, sale: 121, pattern: '奶油' }
]

interface PenItem {
  name: string
  tip: string
  mat: string
  price: number
  orig: number
  sale: number
  tag: string
}

const PEN_LIST: PenItem[] = [
  { name: '复古钢笔墨水笔 百年蓝', tip: 'F', mat: '树脂', price: 128, orig: 158, sale: 96, tag: '经典' },
  { name: '黄铜笔杆钢笔', tip: 'EF', mat: '黄铜', price: 268, orig: 318, sale: 45, tag: '黄铜' },
  { name: '入门透明示范笔', tip: 'F', mat: '树脂', price: 88, orig: 108, sale: 210, tag: '入门' },
  { name: '国潮青瓷钢笔', tip: 'M', mat: '陶瓷', price: 158, orig: 188, sale: 67, tag: '国潮' },
  { name: '极简黑金钢笔', tip: 'F', mat: '金属', price: 198, orig: 238, sale: 54, tag: '极简' },
  { name: '复古红杆钢笔', tip: 'F', mat: '树脂', price: 118, orig: 138, sale: 132, tag: '复古' },
  { name: '木质钢笔礼盒', tip: 'M', mat: '木', price: 328, orig: 388, sale: 23, tag: '礼盒' },
  { name: '便携口袋钢笔', tip: 'EF', mat: '金属', price: 98, orig: 118, sale: 176, tag: '便携' }
]

interface InkItem {
  name: string
  vol: number
  color: string
  price: number
  orig: number
  sale: number
}

const INK_LIST: InkItem[] = [
  { name: '松石绿墨水', vol: 30, color: '#2E8B8B', price: 45, orig: 52, sale: 88 },
  { name: '复古蓝黑墨水', vol: 50, color: '#2B4A6F', price: 38, orig: 45, sale: 143 },
  { name: '勃艮第红墨水', vol: 30, color: '#8E3B46', price: 42, orig: 48, sale: 76 },
  { name: '墨绿金粉墨水', vol: 30, color: '#4A6B4A', price: 55, orig: 65, sale: 54 },
  { name: '永久黑墨水', vol: 60, color: '#3A3A3A', price: 35, orig: 42, sale: 190 },
  { name: '黄昏橘色墨水', vol: 30, color: '#E8923D', price: 46, orig: 54, sale: 62 }
]

interface StickerItem {
  name: string
  count: number
  price: number
  orig: number
  sale: number
  style: string
}

const STICKER_LIST: StickerItem[] = [
  { name: '复古邮票贴纸包', count: 36, price: 15, orig: 18, sale: 430, style: '邮票' },
  { name: '手绘植物贴纸', count: 48, price: 18, orig: 22, sale: 356, style: '植物' },
  { name: '文字云贴纸', count: 30, price: 12, orig: 15, sale: 502, style: '文字' },
  { name: '复古票据贴纸', count: 42, price: 16, orig: 19, sale: 288, style: '票据' },
  { name: '日常美食贴纸', count: 50, price: 14, orig: 17, sale: 412, style: '美食' },
  { name: '天文星空贴纸', count: 36, price: 17, orig: 20, sale: 265, style: '星空' },
  { name: '花体英文贴纸', count: 40, price: 19, orig: 23, sale: 198, style: '英文' },
  { name: '复古相框贴纸', count: 28, price: 13, orig: 16, sale: 334, style: '相框' }
]

interface OrderItem {
  id: string
  name: string
  price: number
  time: string
  status: string
}

const ORDER_LIST: OrderItem[] = [
  { id: 'JS20260825001', name: '复古牛皮 A5 手账本', price: 32, time: '08-25 09:12', status: '待收货' },
  { id: 'JS20260822002', name: '波点复古胶带', price: 12, time: '08-22 15:47', status: '已完成' },
  { id: 'JS20260818003', name: '复古钢笔墨水笔 百年蓝', price: 128, time: '08-18 20:03', status: '已完成' },
  { id: 'JS20260812004', name: '文字云贴纸', price: 12, time: '08-12 11:26', status: '已发货' },
  { id: 'JS20260808005', name: '五年日记手账本', price: 68, time: '08-08 18:55', status: '已完成' },
  { id: 'JS20260803006', name: '复古蓝黑墨水', price: 38, time: '08-03 10:38', status: '待付款' },
  { id: 'JS20260730007', name: '猫咪日常胶带', price: 14, time: '07-30 14:21', status: '已完成' },
  { id: 'JS20260725008', name: '木质钢笔礼盒', price: 328, time: '07-25 16:09', status: '已完成' }
]

interface FavItem {
  name: string
  price: number
  tag: string
}

const FAV_LIST: FavItem[] = [
  { name: '国风水墨手账本', price: 38, tag: '国风' },
  { name: '烫金几何胶带', price: 16, tag: '烫金' },
  { name: '黄铜笔杆钢笔', price: 268, tag: '黄铜' },
  { name: '墨绿金粉墨水', price: 55, tag: '金粉' },
  { name: '天文星空贴纸', price: 17, tag: '星空' },
  { name: '旅行手账 TN 护照款', price: 58, tag: 'TN' }
]

const TAB_LIST: string[] = ['首页', '手账本', '胶带', '钢笔墨水', '贴纸', '我的']
const TAB_ICONS: string[] = ['手', '账', '胶', '墨', '贴', '我']
const BOOK_KINDS: string[] = ['全部', '空白', '横线', '方格', '时间轴', '周计划']
const PEN_FILTERS: string[] = ['全部', '钢笔', '墨水']
const TIP_TYPES: string[] = ['F', 'EF', 'M']
const TAPE_PATTERNS: string[] = ['全部', '复古', '植物', '猫咪', '星空']
const MONTH_VALS: number[] = [42, 88, 36, 120, 66, 95]
const MONTH_LABELS: string[] = ['3月', '4月', '5月', '6月', '7月', '8月']
const PEN_MATS: string[] = ['树脂', '黄铜', '陶瓷', '金属', '木']
const INK_COLORS: string[] = ['#2B4A6F', '#2E8B8B', '#8E3B46', '#4A6B4A', '#3A3A3A', '#E8923D']
const STICKER_STYLES: string[] = ['全部', '邮票', '植物', '文字', '美食', '星空']

interface CouponItem {
  name: string
  val: string
  cond: string
  end: string
}

const COUPON_LIST: CouponItem[] = [
  { name: '满减券', val: '¥10', cond: '满 99 可用', end: '09-30 到期' },
  { name: '手账本专享券', val: '¥15', cond: '满 129 可用', end: '09-15 到期' },
  { name: '钢笔礼盒券', val: '¥30', cond: '满 299 可用', end: '10-08 到期' },
  { name: '无门槛券', val: '¥5', cond: '全场通用', end: '08-31 到期' }
]

// ============ 全局纯函数 ============
function jsPrice(p: number): string {
  return '¥' + p.toFixed(0)
}

function jsOff(p: number, o: number): number {
  return o > 0 ? Math.round((1 - p / o) * 100) : 0
}

function jsNum(v: string): number {
  let n = Number(v)
  if (isNaN(n)) {
    return 0
  }
  return n
}

function jsBarW(v: number): string {
  let w = v > 100 ? 100 : v
  return w + '%'
}

function jsMonthBar(i: number): string {
  let w = MONTH_VALS[i] * 100 / 120
  if (w > 100) {
    w = 100
  }
  return w + '%'
}

function jsMonthLabel(i: number): string {
  return MONTH_LABELS[i]
}

function jsMonthVal(i: number): string {
  return MONTH_VALS[i] + '元'
}

function jsFallY(ph: number, i: number): number {
  return (ph * 2 + i * 47) % 175 - 30
}

function jsFallX(i: number): number {
  return 6 + i * 12
}

function jsFallC(c: number): string {
  return c === 0 ? '#F0C75E' : (c === 1 ? '#E8923D' : '#2B4A6F')
}

function jsKindColor(k: string): string {
  if (k === '空白') {
    return '#2B4A6F'
  } else if (k === '横线') {
    return '#5B8AB5'
  } else if (k === '方格') {
    return '#8B6FB5'
  } else if (k === '时间轴') {
    return '#D98E32'
  } else if (k === '周计划') {
    return '#5BA88F'
  } else if (k === '日记') {
    return '#C96B6B'
  } else if (k === '索引') {
    return '#6BA3A3'
  }
  return '#8A93A3'
}

function jsStatusColor(s: string): string {
  return s === '已发货' ? '#5B8AB5' : (s === '待付款' ? '#E8923D' : (s === '已完成' ? '#2B4A6F' : '#8A93A3'))
}

function jsTipColor(t: string): string {
  return t === 'F' ? '#2B4A6F' : (t === 'EF' ? '#5BA88F' : '#C96B6B')
}

function jsRateStr(st: number): string {
  return '★★★★★'.substring(0, st) + '☆☆☆☆☆'.substring(0, 5 - st)
}

function jsNoteImg(i: number): string {
  return NOTE_LIST[i].name.charAt(0)
}

function jsNoteImgN(n: string): string {
  for (let i = 0; i < NOTE_LIST.length; i++) {
    if (NOTE_LIST[i].name === n) {
      return NOTE_LIST[i].name.charAt(0)
    }
  }
  return '账'
}

function jsTapeImg(i: number): string {
  return TAPE_LIST[i].pattern.charAt(0)
}

function jsTapeImgN(n: string): string {
  for (let i = 0; i < TAPE_LIST.length; i++) {
    if (TAPE_LIST[i].name === n) {
      return TAPE_LIST[i].pattern.charAt(0)
    }
  }
  return '带'
}

function jsTapeC(p: string): string {
  if (p === '波点') {
    return '#C96B6B'
  } else if (p === '植物') {
    return '#4A6B4A'
  } else if (p === '报纸') {
    return '#6B6B6B'
  } else if (p === '几何') {
    return '#D9A441'
  } else if (p === '和风') {
    return '#C96BA3'
  } else if (p === '渐变') {
    return '#8B6FB5'
  } else if (p === '猫咪') {
    return '#D98E32'
  } else if (p === '文字') {
    return '#2B4A6F'
  } else if (p === '星空') {
    return '#5B8AB5'
  }
  return '#E8C9A0'
}

function jsPenImg(i: number): string {
  return PEN_LIST[i].name.charAt(0)
}

function jsInkColor(i: number): string {
  return INK_LIST[i].color
}

function jsStickImg(i: number): string {
  return STICKER_LIST[i].style.charAt(0)
}

function jsFilterNotes(arr: NotebookItem[], k: string): NotebookItem[] {
  if (k === '全部') {
    return arr
  }
  let out: NotebookItem[] = []
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].kind === k) {
      out.push(arr[i])
    }
  }
  return out
}

function jsFilterTapes(arr: TapeItem[], p: string): TapeItem[] {
  if (p === '全部') {
    return arr
  }
  let out: TapeItem[] = []
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].pattern === p) {
      out.push(arr[i])
    }
  }
  return out
}

function jsHotNotes(): NotebookItem[] {
  let out: NotebookItem[] = []
  for (let i = 0; i < NOTE_LIST.length; i++) {
    if (NOTE_LIST[i].sale >= 200) {
      out.push(NOTE_LIST[i])
    }
  }
  return out
}

function jsHotTapes(): TapeItem[] {
  let out: TapeItem[] = []
  for (let i = 0; i < TAPE_LIST.length; i++) {
    if (TAPE_LIST[i].sale >= 300) {
      out.push(TAPE_LIST[i])
    }
  }
  return out
}

function jsNewStickers(): StickerItem[] {
  return [STICKER_LIST[5], STICKER_LIST[6], STICKER_LIST[7]]
}

function jsCatsA(): CatItem[] {
  return [CAT_LIST[0], CAT_LIST[1], CAT_LIST[2], CAT_LIST[3]]
}

function jsCatsB(): CatItem[] {
  return [CAT_LIST[4], CAT_LIST[5], CAT_LIST[6], CAT_LIST[7]]
}

function jsPriceRange(p: number): string {
  if (p < 20) {
    return 'A 20元内'
  } else if (p < 40) {
    return 'B 20-40'
  } else if (p < 60) {
    return 'C 40-60'
  }
  return 'D 60+'
}

function jsRangeCount(r: string): number {
  let c = 0
  for (let i = 0; i < NOTE_LIST.length; i++) {
    if (jsPriceRange(NOTE_LIST[i].price) === r) {
      c = c + 1
    }
  }
  return c
}

function jsTapeW(tape: TapeItem): string {
  let w = Math.round(tape.width * 100 / 40)
  if (w > 100) {
    w = 100
  }
  return w + '%'
}

function jsTapeL(tape: TapeItem): string {
  let w = Math.round(tape.len * 100 / 12)
  if (w > 100) {
    w = 100
  }
  return w + '%'
}

function jsTipCount(t: string): number {
  let c = 0
  for (let i = 0; i < PEN_LIST.length; i++) {
    if (PEN_LIST[i].tip === t) {
      c = c + 1
    }
  }
  return c
}

function jsStickPct(c: number): string {
  let w = Math.round(c * 100 / 50)
  if (w > 100) {
    w = 100
  }
  return w + '%'
}

function jsNextKind(k: string): string {
  for (let i = 0; i < BOOK_KINDS.length; i++) {
    if (BOOK_KINDS[i] === k) {
      return BOOK_KINDS[(i + 1) % BOOK_KINDS.length]
    }
  }
  return '空白'
}

function jsNextTip(t: string): string {
  if (t === 'F') {
    return 'EF'
  } else if (t === 'EF') {
    return 'M'
  }
  return 'F'
}

function jsNextPattern(p: string): string {
  for (let i = 0; i < TAPE_PATTERNS.length; i++) {
    if (TAPE_PATTERNS[i] === p) {
      return TAPE_PATTERNS[(i + 1) % TAPE_PATTERNS.length]
    }
  }
  return '复古'
}

function jsFindNoteIdx(arr: NotebookItem[], n: string): number {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].name === n) {
      return i
    }
  }
  return -1
}

function jsFindTapeIdx(arr: TapeItem[], n: string): number {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].name === n) {
      return i
    }
  }
  return -1
}

function jsFindPenIdx(arr: PenItem[], n: string): number {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].name === n) {
      return i
    }
  }
  return -1
}

function jsFindStickIdx(arr: StickerItem[], n: string): number {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].name === n) {
      return i
    }
  }
  return -1
}

function jsNextMat(m: string): string {
  for (let i = 0; i < PEN_MATS.length; i++) {
    if (PEN_MATS[i] === m) {
      return PEN_MATS[(i + 1) % PEN_MATS.length]
    }
  }
  return '树脂'
}

function jsNextInkColor(c: string): string {
  for (let i = 0; i < INK_COLORS.length; i++) {
    if (INK_COLORS[i] === c) {
      return INK_COLORS[(i + 1) % INK_COLORS.length]
    }
  }
  return INK_COLORS[0]
}

function jsNextStyle(s: string): string {
  const arr: string[] = ['邮票', '植物', '文字', '美食', '星空', '相框']
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] === s) {
      return arr[(i + 1) % arr.length]
    }
  }
  return '邮票'
}

function jsInkVolPct(v: number): string {
  let w = Math.round(v * 100 / 60)
  if (w > 100) {
    w = 100
  }
  return w + '%'
}

function jsTipBar(t: string): string {
  let w = Math.round(jsTipCount(t) * 100 / 3)
  if (w > 100) {
    w = 100
  }
  return w + '%'
}

function jsFilterSticks(arr: StickerItem[], s: string): StickerItem[] {
  if (s === '全部') {
    return arr
  }
  let out: StickerItem[] = []
  for (let i = 0; i < arr.length; i++) {
    if (arr[i].style === s) {
      out.push(arr[i])
    }
  }
  return out
}

function jsStickStyleColor(s: string): string {
  if (s === '邮票') {
    return '#C96B6B'
  } else if (s === '植物') {
    return '#4A6B4A'
  } else if (s === '文字') {
    return '#2B4A6F'
  } else if (s === '美食') {
    return '#E8923D'
  } else if (s === '星空') {
    return '#8B6FB5'
  }
  return '#C96BA3'
}

function jsHotSticks(): StickerItem[] {
  let out: StickerItem[] = []
  for (let i = 0; i < STICKER_LIST.length; i++) {
    if (STICKER_LIST[i].sale >= 300) {
      out.push(STICKER_LIST[i])
    }
  }
  return out
}

function jsTopOrders(): OrderItem[] {
  return [ORDER_LIST[0], ORDER_LIST[1], ORDER_LIST[2], ORDER_LIST[3]]
}

function jsFindOrderIdx(id: string): number {
  for (let i = 0; i < ORDER_LIST.length; i++) {
    if (ORDER_LIST[i].id === id) {
      return i
    }
  }
  return -1
}

function jsFavColor(t: string): string {
  if (t === '国风') {
    return '#2B4A6F'
  } else if (t === '烫金') {
    return '#D9A441'
  } else if (t === '黄铜') {
    return '#B5763A'
  } else if (t === '金粉') {
    return '#C96BA3'
  } else if (t === '星空') {
    return '#8B6FB5'
  }
  return '#5BA88F'
}

// ============ 分类配置 ============
interface CatItem {
  name: string
  icon: string
  color: string
}

const CAT_LIST: CatItem[] = [
  { name: '手账本', icon: '册', color: '#2B4A6F' },
  { name: '胶带', icon: '带', color: '#D98E32' },
  { name: '钢笔', icon: '笔', color: '#5B8AB5' },
  { name: '墨水', icon: '墨', color: '#4A6B4A' },
  { name: '贴纸', icon: '贴', color: '#C96BA3' },
  { name: '便签', icon: '签', color: '#E8923D' },
  { name: '印章', icon: '印', color: '#8B6FB5' },
  { name: '礼盒', icon: '盒', color: '#C96B6B' }
]

// ============ 主组件 ============
@Entry
@Component
struct JsApp {
  @State curTab: number = 0
  @State showAbout: boolean = false

  @Builder
  modalOverlay() {
    if (this.showAbout) {
      Column() {
        Column() {
          Text('手账文具社 · 关于我们')
            .fontSize(16)
            .fontColor(JS.text)
            .fontWeight(FontWeight.Bold)
          Text('一家专注手账与文具的复古小店,\n从手账本到胶带、从钢笔墨水到贴纸,\n每一件都经过编辑部亲手试写。')
            .fontSize(12)
            .fontColor(JS.sub)
            .lineHeight(20)
            .margin({ top: 12 })
          Row() {
            Text('手账 16 款')
              .fontSize(10)
              .fontColor(JS.primary)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10)
              .backgroundColor(JS.card2)
            Text('胶带 10 卷')
              .fontSize(10)
              .fontColor(JS.primary)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10)
              .backgroundColor(JS.card2)
              .margin({ left: 8 })
          }
          .margin({ top: 14 })
          Text('我知道了')
            .fontSize(13)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Bold)
            .width(200)
            .height(36)
            .textAlign(TextAlign.Center)
            .borderRadius(18)
            .backgroundColor(JS.primary)
            .margin({ top: 18 })
            .onClick(() => { this.showAbout = false })
        }
        .width('82%')
        .padding(18)
        .borderRadius(16)
        .backgroundColor(JS.card)
        .border({ width: 2, color: JS.line })
      }
      .position({ x: 0, y: 0 })
      .zIndex(999)
      .width('100%')
      .height('100%')
      .backgroundColor('#66000000')
      .justifyContent(FlexAlign.Center)
    }
  }

  build() {
    Stack() {
      Column() {
        Row() {
          Column() {
            Text('手账文具社')
              .fontSize(17)
              .fontColor(JS.text)
              .fontWeight(FontWeight.Bold)
            Text('复古印刷 · 纸质手作')
              .fontSize(10)
              .fontColor(JS.sub)
              .margin({ top: 1 })
          }
          .alignItems(HorizontalAlign.Start)
          .onClick(() => { this.showAbout = true })
          Row() {
            Text('⌕')
              .fontSize(15)
              .fontColor(JS.sub)
            Text('搜索手账 / 胶带 / 钢笔')
              .fontSize(11)
              .fontColor(JS.sub)
              .margin({ left: 6 })
          }
          .layoutWeight(1)
          .height(32)
          .borderRadius(16)
          .backgroundColor(JS.card)
          .border({ width: 1, color: JS.line })
          .justifyContent(FlexAlign.Center)
          .margin({ left: 12, right: 12 })
          Text('✉')
            .fontSize(17)
            .fontColor(JS.text)
          Text('☰')
            .fontSize(16)
            .fontColor(JS.text)
            .margin({ left: 14 })
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 8, bottom: 8 })
        .backgroundColor(JS.bg)
        .border({ width: { bottom: 2 }, color: JS.line })

        if (this.curTab === 0) {
          JsHomeTab({ onJump: (t: number) => { this.curTab = t } })
        } else if (this.curTab === 1) {
          JsBookTab()
        } else if (this.curTab === 2) {
          JsTapeTab()
        } else if (this.curTab === 3) {
          JsPenTab()
        } else if (this.curTab === 4) {
          JsStickerTab()
        } else {
          JsMineTab()
        }

        Row() {
          ForEach(TAB_LIST, (t: string, idx: number) => {
            Column() {
              Text(TAB_ICONS[idx])
                .fontSize(13)
                .fontColor('#FFFFFF')
                .fontWeight(FontWeight.Bold)
                .width(24)
                .height(24)
                .textAlign(TextAlign.Center)
                .borderRadius(12)
                .backgroundColor(idx === this.curTab ? JS.primary : JS.card2)
              Text(t)
                .fontSize(9)
                .fontColor(idx === this.curTab ? JS.primary : JS.sub)
                .fontWeight(idx === this.curTab ? FontWeight.Bold : FontWeight.Normal)
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .justifyContent(FlexAlign.Center)
            .height(54)
            .onClick(() => { this.curTab = idx })
          }, (t: string) => t)
        }
        .width('100%')
        .backgroundColor(JS.card)
        .border({ width: { top: 2 }, color: JS.line })
      }
      .width('100%')
      .height('100%')
      .backgroundColor(JS.bg)
      this.modalOverlay()
    }
    .width('100%')
    .height('100%')
  }
}

// ============ Tab1 首页 ============
@Component
struct JsHomeTab {
  @State fallPhase: number = 0
  @State tapeRoll: number = 0
  @State stampScale: number = 1
  @State selNote: number = -1
  @State selTape: number = -1
  @State selStick: number = -1
  @State showTip: boolean = false
  onJump: (t: number) => void = (t: number) => {}

  aboutToAppear(): void {
    setInterval(() => {
      this.fallPhase = (this.fallPhase + 1) % 120
      this.stampScale = this.stampScale > 1.05 ? 0.88 : this.stampScale + 0.06
    }, 90)
    setInterval(() => {
      this.tapeRoll = (this.tapeRoll + 6) % 360
    }, 80)
  }

  @Builder
  modalOverlay() {
    if (this.selNote >= 0) {
      Column() {
        Column() {
          Row() {
            Text(jsNoteImgN(NOTE_LIST[this.selNote].name))
              .fontSize(26)
              .fontColor('#FFFFFF')
              .fontWeight(FontWeight.Bold)
              .width(54)
              .height(54)
              .textAlign(TextAlign.Center)
              .borderRadius(14)
              .backgroundColor(JS.primary)
            Column() {
              Text(NOTE_LIST[this.selNote].name)
                .fontSize(15)
                .fontColor(JS.text)
                .fontWeight(FontWeight.Bold)
              Text(jsRateStr(NOTE_LIST[this.selNote].stars))
                .fontSize(11)
                .fontColor('#E8923D')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })
            Column() {
              Text(jsPrice(NOTE_LIST[this.selNote].price))
                .fontSize(17)
                .fontColor('#D98E32')
                .fontWeight(FontWeight.Bold)
              Text(jsPrice(NOTE_LIST[this.selNote].orig))
                .fontSize(11)
                .fontColor(JS.sub)
                .decoration({ type: TextDecorationType.LineThrough })
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          Row() {
            Text('内页 · ' + NOTE_LIST[this.selNote].kind)
              .fontSize(11)
              .fontColor(jsKindColor(NOTE_LIST[this.selNote].kind))
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10)
              .backgroundColor(JS.card2)
            Text(NOTE_LIST[this.selNote].pages + ' 页')
              .fontSize(11)
              .fontColor(JS.sub)
              .margin({ left: 10 })
            Column().layoutWeight(1)
            Text('月销 ' + NOTE_LIST[this.selNote].sale + ' 本')
              .fontSize(11)
              .fontColor(JS.glow)
          }
          .width('100%')
          .margin({ top: 12 })
          Text('加入购物车')
            .fontSize(13)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Bold)
            .width(200)
            .height(38)
            .textAlign(TextAlign.Center)
            .borderRadius(19)
            .backgroundColor(JS.primary)
            .margin({ top: 16 })
            .onClick(() => { this.selNote = -1 })
          Text('关闭')
            .fontSize(12)
            .fontColor(JS.sub)
            .margin({ top: 10 })
            .onClick(() => { this.selNote = -1 })
        }
        .width('86%')
        .padding(16)
        .borderRadius(16)
        .backgroundColor(JS.card)
        .border({ width: 2, color: JS.line })
      }
      .position({ x: 0, y: 0 })
      .zIndex(999)
      .width('100%')
      .height('100%')
      .backgroundColor('#66000000')
      .justifyContent(FlexAlign.Center)
    }
    if (this.selTape >= 0) {
      Column() {
        Column() {
          Row() {
            Stack() {
              Text('◍')
                .fontSize(40)
                .fontColor(jsTapeC(TAPE_LIST[this.selTape].pattern))
                .rotate({ angle: this.tapeRoll })
              Text(jsTapeImg(this.selTape))
                .fontSize(11)
                .fontColor('#FFFFFF')
                .fontWeight(FontWeight.Bold)
            }
            .width(56)
            .height(56)
            Column() {
              Text(TAPE_LIST[this.selTape].name)
                .fontSize(15)
                .fontColor(JS.text)
                .fontWeight(FontWeight.Bold)
              Text(TAPE_LIST[this.selTape].pattern + ' 印花')
                .fontSize(11)
                .fontColor(JS.sub)
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })
            Column() {
              Text(jsPrice(TAPE_LIST[this.selTape].price))
                .fontSize(17)
                .fontColor('#D98E32')
                .fontWeight(FontWeight.Bold)
              Text('月销' + TAPE_LIST[this.selTape].sale)
                .fontSize(10)
                .fontColor(JS.sub)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          Row() {
            Column() {
              Text('宽度').fontSize(10).fontColor(JS.sub)
              Text(TAPE_LIST[this.selTape].width + 'mm').fontSize(14).fontColor(JS.text).fontWeight(FontWeight.Bold).margin({ top: 4 })
            }
            .layoutWeight(1)
            Column() {
              Text('长度').fontSize(10).fontColor(JS.sub)
              Text(TAPE_LIST[this.selTape].len + 'm').fontSize(14).fontColor(JS.text).fontWeight(FontWeight.Bold).margin({ top: 4 })
            }
            .layoutWeight(1)
            Column() {
              Text('印花').fontSize(10).fontColor(JS.sub)
              Text(TAPE_LIST[this.selTape].pattern).fontSize(14).fontColor(jsTapeC(TAPE_LIST[this.selTape].pattern)).fontWeight(FontWeight.Bold).margin({ top: 4 })
            }
            .layoutWeight(1)
          }
          .width('100%')
          .padding(10)
          .borderRadius(10)
          .backgroundColor(JS.card2)
          .margin({ top: 12 })
          Text('立即购买')
            .fontSize(13)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Bold)
            .width(200)
            .height(38)
            .textAlign(TextAlign.Center)
            .borderRadius(19)
            .backgroundColor(JS.primary)
            .margin({ top: 16 })
            .onClick(() => { this.selTape = -1 })
          Text('关闭')
            .fontSize(12)
            .fontColor(JS.sub)
            .margin({ top: 10 })
            .onClick(() => { this.selTape = -1 })
        }
        .width('86%')
        .padding(16)
        .borderRadius(16)
        .backgroundColor(JS.card)
        .border({ width: 2, color: JS.line })
      }
      .position({ x: 0, y: 0 })
      .zIndex(999)
      .width('100%')
      .height('100%')
      .backgroundColor('#66000000')
      .justifyContent(FlexAlign.Center)
    }
    if (this.selStick >= 0) {
      Column() {
        Column() {
          Row() {
            Text(jsStickImg(this.selStick))
              .fontSize(24)
              .fontColor('#FFFFFF')
              .fontWeight(FontWeight.Bold)
              .width(52)
              .height(52)
              .textAlign(TextAlign.Center)
              .borderRadius(14)
              .backgroundColor('#C96BA3')
            Column() {
              Text(STICKER_LIST[this.selStick].name)
                .fontSize(15)
                .fontColor(JS.text)
                .fontWeight(FontWeight.Bold)
              Text(STICKER_LIST[this.selStick].style + ' 系列 · ' + STICKER_LIST[this.selStick].count + ' 枚')
                .fontSize(11)
                .fontColor(JS.sub)
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })
            Text(jsPrice(STICKER_LIST[this.selStick].price))
              .fontSize(17)
              .fontColor('#D98E32')
              .fontWeight(FontWeight.Bold)
          }
          .width('100%')
          Stack() {
            Row() {}.width('100%').height(8).borderRadius(4).backgroundColor(JS.card2)
            Row() {}.width(jsStickPct(STICKER_LIST[this.selStick].count)).height(8).borderRadius(4).backgroundColor('#C96BA3').margin({ right: 130 })
          }
          .width('100%')
          .margin({ top: 12 })
          Row() {
            Text('枚数占比').fontSize(10).fontColor(JS.sub)
            Column().layoutWeight(1)
            Text(STICKER_LIST[this.selStick].count + ' / 50 枚')
              .fontSize(11)
              .fontColor('#C96BA3')
              .fontWeight(FontWeight.Bold)
          }
          .width('100%')
          .margin({ top: 4 })
          Text('加入购物车')
            .fontSize(13)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Bold)
            .width(200)
            .height(38)
            .textAlign(TextAlign.Center)
            .borderRadius(19)
            .backgroundColor('#C96BA3')
            .margin({ top: 16 })
            .onClick(() => { this.selStick = -1 })
          Text('关闭')
            .fontSize(12)
            .fontColor(JS.sub)
            .margin({ top: 10 })
            .onClick(() => { this.selStick = -1 })
        }
        .width('86%')
        .padding(16)
        .borderRadius(16)
        .backgroundColor(JS.card)
        .border({ width: 2, color: JS.line })
      }
      .position({ x: 0, y: 0 })
      .zIndex(999)
      .width('100%')
      .height('100%')
      .backgroundColor('#66000000')
      .justifyContent(FlexAlign.Center)
    }
    if (this.showTip) {
      Column() {
        Column() {
          Text('便签 · 敬请期待')
            .fontSize(16)
            .fontColor(JS.text)
            .fontWeight(FontWeight.Bold)
          Text('该分类正在上架中,\n先去逛逛手账本专区吧~')
            .fontSize(12)
            .fontColor(JS.sub)
            .lineHeight(20)
            .textAlign(TextAlign.Center)
            .margin({ top: 12 })
          Text('去逛逛')
            .fontSize(13)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Bold)
            .width(160)
            .height(36)
            .textAlign(TextAlign.Center)
            .borderRadius(18)
            .backgroundColor(JS.primary)
            .margin({ top: 16 })
            .onClick(() => {
              this.showTip = false
              this.onJump(1)
            })
        }
        .width('72%')
        .padding(18)
        .borderRadius(16)
        .backgroundColor(JS.card)
        .border({ width: 2, color: JS.line })
      }
      .position({ x: 0, y: 0 })
      .zIndex(999)
      .width('100%')
      .height('100%')
      .backgroundColor('#66000000')
      .justifyContent(FlexAlign.Center)
    }
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Stack() {
            ForEach(PAPER_BITS, (b: PaperBit, i: number) => {
              Column() {}
              .width(7)
              .height(7)
              .backgroundColor(jsFallC(b.c))
              .rotate({ angle: i * 35 + this.fallPhase })
              .opacity(0.85)
              .position({ x: b.x + '%', y: jsFallY(this.fallPhase, i) })
            }, (b: PaperBit) => b.x + '_' + b.c)
            Text('◍')
              .fontSize(64)
              .fontColor('#F0C75E')
              .opacity(0.9)
              .rotate({ angle: this.tapeRoll })
              .position({ x: '68%', y: '12%' })
            Text('新店')
              .fontSize(13)
              .fontColor('#E8923D')
              .fontWeight(FontWeight.Bold)
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .border({ width: 2, color: '#E8923D' })
              .borderRadius(4)
              .rotate({ angle: -12 })
              .scale({ x: this.stampScale, y: this.stampScale })
              .animation({ duration: 250 })
              .position({ x: '84%', y: '8%' })
            Column() {
              Text('手账新人礼')
                .fontSize(20)
                .fontColor(JS.text)
                .fontWeight(FontWeight.Bold)
              Text('全场手账本 8 折起 · 满 3 件赠贴纸包')
                .fontSize(11)
                .fontColor(JS.sub)
                .margin({ top: 6 })
              Row() {
                Text('新客立减 ¥5')
                  .fontSize(10)
                  .fontColor('#FFFFFF')
                  .fontWeight(FontWeight.Bold)
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .borderRadius(10)
                  .backgroundColor(JS.primary)
                Text('包邮到家')
                  .fontSize(10)
                  .fontColor('#FFFFFF')
                  .fontWeight(FontWeight.Bold)
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .borderRadius(10)
                  .backgroundColor('#D98E32')
                  .margin({ left: 8 })
              }
              .margin({ top: 12 })
            }
            .alignItems(HorizontalAlign.Start)
            .position({ x: '6%', y: '20%' })
          }
          .width('100%')
          .height(150)
          .borderRadius(16)
          .backgroundColor(JS.card)
          .border({ width: 2, color: JS.line })
          .clip(true)
          .margin({ top: 10 })

          Column() {
            Text('分类导航')
              .fontSize(15)
              .fontColor(JS.text)
              .fontWeight(FontWeight.Bold)
            Row() {
              ForEach(jsCatsA(), (c: CatItem, idx: number) => {
                Column() {
                  Text(c.icon)
                    .fontSize(17)
                    .fontColor('#FFFFFF')
                    .fontWeight(FontWeight.Bold)
                    .width(42)
                    .height(42)
                    .textAlign(TextAlign.Center)
                    .borderRadius(21)
                    .backgroundColor(c.color)
                  Text(c.name)
                    .fontSize(11)
                    .fontColor(JS.text)
                    .margin({ top: 6 })
                }
                .layoutWeight(1)
                .onClick(() => {
                  if (idx === 0) {
                    this.onJump(1)
                  } else if (idx === 1) {
                    this.onJump(2)
                  } else if (idx === 2) {
                    this.onJump(3)
                  } else if (idx === 3) {
                    this.onJump(3)
                  } else {
                    this.showTip = true
                  }
                })
              }, (c: CatItem) => c.name)
            }
            .width('100%')
            .margin({ top: 14 })
            Row() {
              ForEach(jsCatsB(), (c: CatItem, idx: number) => {
                Column() {
                  Text(c.icon)
                    .fontSize(17)
                    .fontColor('#FFFFFF')
                    .fontWeight(FontWeight.Bold)
                    .width(42)
                    .height(42)
                    .textAlign(TextAlign.Center)
                    .borderRadius(21)
                    .backgroundColor(c.color)
                  Text(c.name)
                    .fontSize(11)
                    .fontColor(JS.text)
                    .margin({ top: 6 })
                }
                .layoutWeight(1)
                .onClick(() => {
                  if (idx === 0) {
                    this.onJump(4)
                  } else {
                    this.showTip = true
                  }
                })
              }, (c: CatItem) => c.name)
            }
            .width('100%')
            .margin({ top: 14 })
          }
          .width('100%')
          .padding(14)
          .borderRadius(16)
          .backgroundColor(JS.card)
          .border({ width: 2, color: JS.line })
          .margin({ top: 10 })

          Row() {
            Column() {
              Text('热销手账本')
                .fontSize(15)
                .fontColor(JS.text)
                .fontWeight(FontWeight.Bold)
              Text('HOT NOTEBOOKS')
                .fontSize(9)
                .fontColor(JS.glow)
                .letterSpacing(1)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start)
            Column().layoutWeight(1)
            Text('查看全部 ▸')
              .fontSize(11)
              .fontColor(JS.sub)
              .onClick(() => { this.onJump(1) })
          }
          .width('100%')
          .margin({ top: 16 })

          Scroll() {
            Row() {
              ForEach(jsHotNotes(), (n: NotebookItem, idx: number) => {
                Column() {
                  Text(jsNoteImgN(n.name))
                    .fontSize(24)
                    .fontColor('#FFFFFF')
                    .fontWeight(FontWeight.Bold)
                    .width(62)
                    .height(62)
                    .textAlign(TextAlign.Center)
                    .borderRadius(16)
                    .backgroundColor(JS.primary)
                  Text(n.name)
                    .fontSize(12)
                    .fontColor(JS.text)
                    .fontWeight(FontWeight.Bold)
                    .maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                    .margin({ top: 8 })
                  Text(n.kind + '内页 · ' + n.pages + '页')
                    .fontSize(9)
                    .fontColor(JS.sub)
                    .margin({ top: 4 })
                  Row() {
                    Text(jsPrice(n.price))
                      .fontSize(14)
                      .fontColor('#D98E32')
                      .fontWeight(FontWeight.Bold)
                    Text('月销' + n.sale)
                      .fontSize(9)
                      .fontColor(JS.sub)
                      .margin({ left: 6 })
                  }
                  .margin({ top: 5 })
                }
                .alignItems(HorizontalAlign.Start)
                .width(140)
                .padding(10)
                .borderRadius(12)
                .backgroundColor(JS.card)
                .border({ width: 1, color: JS.line })
                .margin({ right: 10 })
                .onClick(() => { this.selNote = jsFindNoteIdx(NOTE_LIST, n.name) })
              }, (n: NotebookItem) => n.name)
            }
          }
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)
          .width('100%')
          .margin({ top: 10 })
          .align(Alignment.Start)

          Row() {
            Column() {
              Text('热门胶带')
                .fontSize(15)
                .fontColor(JS.text)
                .fontWeight(FontWeight.Bold)
              Text('HOT TAPES')
                .fontSize(9)
                .fontColor('#D98E32')
                .letterSpacing(1)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start)
            Column().layoutWeight(1)
            Text('全部胶带 ▸')
              .fontSize(11)
              .fontColor(JS.sub)
              .onClick(() => { this.onJump(2) })
          }
          .width('100%')
          .margin({ top: 16 })

          ForEach(jsHotTapes(), (t: TapeItem, idx: number) => {
            Row() {
              Text('◍')
                .fontSize(30)
                .fontColor(jsTapeC(t.pattern))
              Column() {
                Text(t.name)
                  .fontSize(13)
                  .fontColor(JS.text)
                  .fontWeight(FontWeight.Bold)
                Text(t.width + 'mm × ' + t.len + 'm')
                  .fontSize(10)
                  .fontColor(JS.sub)
                  .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 10 })
              Column() {
                Text(jsPrice(t.price))
                  .fontSize(14)
                  .fontColor('#D98E32')
                  .fontWeight(FontWeight.Bold)
                Text('月销' + t.sale)
                  .fontSize(9)
                  .fontColor(JS.sub)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding(10)
            .borderRadius(12)
            .backgroundColor(JS.card)
            .border({ width: 1, color: JS.line })
            .margin({ top: 8 })
            .onClick(() => { this.selTape = jsFindTapeIdx(TAPE_LIST, t.name) })
          }, (t: TapeItem) => t.name)

          Row() {
            Column() {
              Text('新品贴纸')
                .fontSize(15)
                .fontColor(JS.text)
                .fontWeight(FontWeight.Bold)
              Text('NEW STICKERS')
                .fontSize(9)
                .fontColor('#C96BA3')
                .letterSpacing(1)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start)
            Column().layoutWeight(1)
            Text('全部贴纸 ▸')
              .fontSize(11)
              .fontColor(JS.sub)
              .onClick(() => { this.onJump(4) })
          }
          .width('100%')
          .margin({ top: 16 })

          ForEach(jsNewStickers(), (s: StickerItem, idx: number) => {
            Row() {
              Text(jsStickImg(idx + 5))
                .fontSize(18)
                .fontColor('#FFFFFF')
                .fontWeight(FontWeight.Bold)
                .width(44)
                .height(44)
                .textAlign(TextAlign.Center)
                .borderRadius(12)
                .backgroundColor('#C96BA3')
              Column() {
                Text(s.name)
                  .fontSize(13)
                  .fontColor(JS.text)
                  .fontWeight(FontWeight.Bold)
                Text(s.count + ' 枚 · ' + s.style + '系列')
                  .fontSize(10)
                  .fontColor(JS.sub)
                  .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 10 })
              Column() {
                Text(jsPrice(s.price))
                  .fontSize(14)
                  .fontColor('#D98E32')
                  .fontWeight(FontWeight.Bold)
                Text(jsPrice(s.orig))
                  .fontSize(10)
                  .fontColor(JS.sub)
                  .decoration({ type: TextDecorationType.LineThrough })
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding(10)
            .borderRadius(12)
            .backgroundColor(JS.card)
            .border({ width: 1, color: JS.line })
            .margin({ top: 8 })
            .onClick(() => { this.selStick = idx + 5 })
          }, (s: StickerItem) => s.name)

          Column().height(12)
        }
        .padding({ left: 14, right: 14, bottom: 10 })
      }
      .scrollBar(BarState.Off)
      .width('100%')
      .height('100%')
      .align(Alignment.Top)
      this.modalOverlay()
    }
    .width('100%')
    .height('100%')
  }
}

// ============ Tab2 手账本 ============
@Component
struct JsBookTab {
  @State notes: NotebookItem[] = []
  @State kind: string = '全部'
  @State selIdx: number = -1
  @State showAdd: boolean = false
  @State addName: string = '复古牛皮 A5 手账本'
  @State addKind: string = '空白'
  @State addPages: string = '192'
  @State addPrice: string = '32'
  @State showEdit: boolean = false
  @State editIdx: number = -1
  @State editName: string = ''
  @State editKind: string = '空白'
  @State editPages: string = ''
  @State editPrice: string = ''
  @State showDel: boolean = false
  @State delIdx: number = -1

  aboutToAppear(): void {
    this.notes = NOTE_LIST.slice()
  }

  @Builder
  modalOverlay() {
    if (this.selIdx >= 0) {
      Column() {
        Column() {
          Row() {
            Text(jsNoteImgN(this.notes[this.selIdx].name))
              .fontSize(26)
              .fontColor('#FFFFFF')
              .fontWeight(FontWeight.Bold)
              .width(54)
              .height(54)
              .textAlign(TextAlign.Center)
              .borderRadius(14)
              .backgroundColor(JS.primary)
            Column() {
              Text(this.notes[this.selIdx].name)
                .fontSize(15)
                .fontColor(JS.text)
                .fontWeight(FontWeight.Bold)
              Text(jsRateStr(this.notes[this.selIdx].stars))
                .fontSize(11)
                .fontColor('#E8923D')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })
            Column() {
              Text(jsPrice(this.notes[this.selIdx].price))
                .fontSize(17)
                .fontColor('#D98E32')
                .fontWeight(FontWeight.Bold)
              Text('月销' + this.notes[this.selIdx].sale)
                .fontSize(10)
                .fontColor(JS.sub)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          Row() {
            Column() {
              Text('内页').fontSize(10).fontColor(JS.sub)
              Text(this.notes[this.selIdx].kind).fontSize(13).fontColor(jsKindColor(this.notes[this.selIdx].kind)).fontWeight(FontWeight.Bold).margin({ top: 4 })
            }
            .layoutWeight(1)
            Column() {
              Text('页数').fontSize(10).fontColor(JS.sub)
              Text(this.notes[this.selIdx].pages + '页').fontSize(13).fontColor(JS.text).fontWeight(FontWeight.Bold).margin({ top: 4 })
            }
            .layoutWeight(1)
            Column() {
              Text('标签').fontSize(10).fontColor(JS.sub)
              Text(this.notes[this.selIdx].tag).fontSize(13).fontColor(JS.glow).fontWeight(FontWeight.Bold).margin({ top: 4 })
            }
            .layoutWeight(1)
          }
          .width('100%')
          .padding(10)
          .borderRadius(10)
          .backgroundColor(JS.card2)
          .margin({ top: 12 })
          Row() {
            Text('编辑')
              .fontSize(12)
              .fontColor(JS.text)
              .width(76)
              .height(32)
              .textAlign(TextAlign.Center)
              .borderRadius(16)
              .backgroundColor(JS.card2)
              .onClick(() => {
                this.editIdx = this.selIdx
                this.editName = this.notes[this.selIdx].name
                this.editKind = this.notes[this.selIdx].kind
                this.editPages = this.notes[this.selIdx].pages.toFixed(0)
                this.editPrice = this.notes[this.selIdx].price.toFixed(0)
                this.selIdx = -1
                this.showEdit = true
              })
            Text('删除')
              .fontSize(12)
              .fontColor('#C96B6B')
              .width(76)
              .height(32)
              .textAlign(TextAlign.Center)
              .borderRadius(16)
              .backgroundColor('#F7E3E3')
              .margin({ left: 10 })
              .onClick(() => {
                this.delIdx = this.selIdx
                this.showDel = true
              })
            Text('加入购物车')
              .fontSize(12)
              .fontColor('#FFFFFF')
              .fontWeight(FontWeight.Bold)
              .layoutWeight(1)
              .height(32)
              .textAlign(TextAlign.Center)
              .borderRadius(16)
              .backgroundColor(JS.primary)
              .margin({ left: 10 })
              .onClick(() => { this.selIdx = -1 })
          }
          .width('100%')
          .margin({ top: 14 })
          Text('关闭')
            .fontSize(12)
            .fontColor(JS.sub)
            .margin({ top: 10 })
            .onClick(() => { this.selIdx = -1 })
        }
        .width('88%')
        .padding(16)
        .borderRadius(16)
        .backgroundColor(JS.card)
        .border({ width: 2, color: JS.line })
      }
      .position({ x: 0, y: 0 })
      .zIndex(999)
      .width('100%')
      .height('100%')
      .backgroundColor('#66000000')
      .justifyContent(FlexAlign.Center)
    }
    if (this.showAdd) {
      Column() {
        Column() {
          Row() {
            Text('+ 新增手账本')
              .fontSize(16)
              .fontColor(JS.text)
              .fontWeight(FontWeight.Bold)
            Column().layoutWeight(1)
            Text('✕')
              .fontSize(16)
              .fontColor(JS.sub)
              .onClick(() => { this.showAdd = false })
          }
          .width('100%')
          Text('手账本名称')
            .fontSize(11)
            .fontColor(JS.sub)
            .width('100%')
            .margin({ top: 12 })
          TextInput({ placeholder: '输入名称', text: this.addName })
            .onChange((v: string) => { this.addName = v })
            .fontSize(12)
            .fontColor(JS.text)
            .height(38)
            .backgroundColor(JS.card2)
            .borderRadius(8)
            .margin({ top: 6 })
          Text('内页类型(点按切换)')
            .fontSize(11)
            .fontColor(JS.sub)
            .width('100%')
            .margin({ top: 12 })
          Text(this.addKind)
            .fontSize(12)
            .fontColor(jsKindColor(this.addKind))
            .fontWeight(FontWeight.Bold)
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .borderRadius(8)
            .backgroundColor(JS.card2)
            .margin({ top: 6 })
            .onClick(() => { this.addKind = jsNextKind(this.addKind) })
          Row() {
            Column() {
              Text('页数').fontSize(11).fontColor(JS.sub)
              TextInput({ placeholder: '0', text: this.addPages })
                .onChange((v: string) => { this.addPages = v })
                .type(InputType.Number)
                .fontSize(13)
                .fontColor(JS.text)
                .width(110)
                .height(32)
                .backgroundColor(JS.card2)
                .borderRadius(8)
                .margin({ top: 6 })
            }
            .alignItems(HorizontalAlign.Start)
            Column() {
              Text('售价').fontSize(11).fontColor(JS.sub)
              TextInput({ placeholder: '0', text: this.addPrice })
                .onChange((v: string) => { this.addPrice = v })
                .type(InputType.Number)
                .fontSize(13)
                .fontColor('#D98E32')
                .width(110)
                .height(32)
                .backgroundColor(JS.card2)
                .borderRadius(8)
                .margin({ top: 6 })
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 20 })
          }
          .width('100%')
          .margin({ top: 12 })
          Text('保存上架')
            .fontSize(13)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Bold)
            .width('100%')
            .height(38)
            .textAlign(TextAlign.Center)
            .borderRadius(19)
            .backgroundColor(JS.primary)
            .margin({ top: 16 })
            .onClick(() => {
              let newItem: NotebookItem = {
                name: this.addName,
                kind: this.addKind,
                pages: jsNum(this.addPages),
                price: jsNum(this.addPrice),
                orig: 0,
                sale: 0,
                stars: 4,
                tag: '新品'
              }
              this.notes.unshift(newItem)
              this.showAdd = false
            })
        }
        .width('88%')
        .padding(16)
        .borderRadius(16)
        .backgroundColor(JS.card)
        .border({ width: 2, color: JS.line })
      }
      .position({ x: 0, y: 0 })
      .zIndex(999)
      .width('100%')
      .height('100%')
      .backgroundColor('#66000000')
      .justifyContent(FlexAlign.Center)
    }
    if (this.showEdit) {
      Column() {
        Column() {
          Row() {
            Text('✎ 编辑手账本')
              .fontSize(16)
              .fontColor(JS.text)
              .fontWeight(FontWeight.Bold)
            Column().layoutWeight(1)
            Text('✕')
              .fontSize(16)
              .fontColor(JS.sub)
              .onClick(() => { this.showEdit = false })
          }
          .width('100%')
          Text('名称')
            .fontSize(11)
            .fontColor(JS.sub)
            .width('100%')
            .margin({ top: 12 })
          TextInput({ placeholder: '名称', text: this.editName })
            .onChange((v: string) => { this.editName = v })
            .fontSize(12)
            .fontColor(JS.text)
            .height(38)
            .backgroundColor(JS.card2)
            .borderRadius(8)
            .margin({ top: 6 })
          Row() {
            Column() {
              Text('内页(点按切换)').fontSize(11).fontColor(JS.sub)
              Text(this.editKind)
                .fontSize(12)
                .fontColor(jsKindColor(this.editKind))
                .fontWeight(FontWeight.Bold)
                .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                .borderRadius(8)
                .backgroundColor(JS.card2)
                .margin({ top: 6 })
                .onClick(() => { this.editKind = jsNextKind(this.editKind) })
            }
            .alignItems(HorizontalAlign.Start)
            Column() {
              Text('页数').fontSize(11).fontColor(JS.sub)
              TextInput({ placeholder: '0', text: this.editPages })
                .onChange((v: string) => { this.editPages = v })
                .type(InputType.Number)
                .fontSize(13)
                .fontColor(JS.text)
                .width(90)
                .height(32)
                .backgroundColor(JS.card2)
                .borderRadius(8)
                .margin({ top: 6 })
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 16 })
            Column() {
              Text('售价').fontSize(11).fontColor(JS.sub)
              TextInput({ placeholder: '0', text: this.editPrice })
                .onChange((v: string) => { this.editPrice = v })
                .type(InputType.Number)
                .fontSize(13)
                .fontColor('#D98E32')
                .width(90)
                .height(32)
                .backgroundColor(JS.card2)
                .borderRadius(8)
                .margin({ top: 6 })
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 16 })
          }
          .width('100%')
          .margin({ top: 12 })
          Text('保存修改')
            .fontSize(13)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Bold)
            .width('100%')
            .height(38)
            .textAlign(TextAlign.Center)
            .borderRadius(19)
            .backgroundColor(JS.primary)
            .margin({ top: 16 })
            .onClick(() => {
              this.notes.splice(this.editIdx, 1, {
                name: this.editName,
                kind: this.editKind,
                pages: jsNum(this.editPages),
                price: jsNum(this.editPrice),
                orig: this.notes[this.editIdx].orig,
                sale: this.notes[this.editIdx].sale,
                stars: this.notes[this.editIdx].stars,
                tag: this.notes[this.editIdx].tag
              })
              this.showEdit = false
            })
        }
        .width('88%')
        .padding(16)
        .borderRadius(16)
        .backgroundColor(JS.card)
        .border({ width: 2, color: JS.line })
      }
      .position({ x: 0, y: 0 })
      .zIndex(999)
      .width('100%')
      .height('100%')
      .backgroundColor('#66000000')
      .justifyContent(FlexAlign.Center)
    }
    if (this.showDel) {
      Column() {
        Column() {
          Text('删除确认')
            .fontSize(16)
            .fontColor(JS.text)
            .fontWeight(FontWeight.Bold)
          Text('确定删除「' + this.notes[this.delIdx].name + '」吗?\n删除后将从货架移除。')
            .fontSize(12)
            .fontColor(JS.sub)
            .lineHeight(20)
            .textAlign(TextAlign.Center)
            .margin({ top: 12 })
          Row() {
            Text('取消')
              .fontSize(13)
              .fontColor(JS.text)
              .layoutWeight(1)
              .height(36)
              .textAlign(TextAlign.Center)
              .borderRadius(18)
              .backgroundColor(JS.card2)
              .onClick(() => {
                this.showDel = false
                this.delIdx = -1
              })
            Text('确认删除')
              .fontSize(13)
              .fontColor('#FFFFFF')
              .layoutWeight(1)
              .height(36)
              .textAlign(TextAlign.Center)
              .borderRadius(18)
              .backgroundColor('#C96B6B')
              .margin({ left: 12 })
              .onClick(() => {
                if (this.delIdx >= 0) {
                  this.notes.splice(this.delIdx, 1)
                }
                this.selIdx = -1
                this.showDel = false
                this.delIdx = -1
              })
          }
          .width('100%')
          .margin({ top: 16 })
        }
        .width('76%')
        .padding(18)
        .borderRadius(16)
        .backgroundColor(JS.card)
        .border({ width: 2, color: JS.line })
      }
      .position({ x: 0, y: 0 })
      .zIndex(1000)
      .width('100%')
      .height('100%')
      .backgroundColor('#88000000')
      .justifyContent(FlexAlign.Center)
    }
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Row() {
            Column() {
              Text('价格区间')
                .fontSize(15)
                .fontColor(JS.text)
                .fontWeight(FontWeight.Bold)
              Text('PRICE RANGE')
                .fontSize(9)
                .fontColor(JS.glow)
                .letterSpacing(1)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start)
            Column().layoutWeight(1)
            Text('共 ' + this.notes.length + ' 款')
              .fontSize(11)
              .fontColor(JS.sub)
          }
          .width('100%')
          .margin({ top: 12 })

          Column() {
            Row() {
              Text('A 20元内').fontSize(11).fontColor(JS.sub).width(64)
              Stack() {
                Row() {}.width('100%').height(10).borderRadius(5).backgroundColor(JS.card2)
                Row() {}.width(jsBarW(jsRangeCount('A 20元内') * 22)).height(10).borderRadius(5).backgroundColor('#2B4A6F').margin({ right: 130 })
              }
              .layoutWeight(1)
              Text(' ' + jsRangeCount('A 20元内') + ' 款').fontSize(11).fontColor(JS.text).width(52).textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 10 })
            Row() {
              Text('B 20-40').fontSize(11).fontColor(JS.sub).width(64)
              Stack() {
                Row() {}.width('100%').height(10).borderRadius(5).backgroundColor(JS.card2)
                Row() {}.width(jsBarW(jsRangeCount('B 20-40') * 22)).height(10).borderRadius(5).backgroundColor('#5B8AB5').margin({ right: 130 })
              }
              .layoutWeight(1)
              Text(' ' + jsRangeCount('B 20-40') + ' 款').fontSize(11).fontColor(JS.text).width(52).textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
            Row() {
              Text('C 40-60').fontSize(11).fontColor(JS.sub).width(64)
              Stack() {
                Row() {}.width('100%').height(10).borderRadius(5).backgroundColor(JS.card2)
                Row() {}.width(jsBarW(jsRangeCount('C 40-60') * 22)).height(10).borderRadius(5).backgroundColor('#D98E32').margin({ right: 130 })
              }
              .layoutWeight(1)
              Text(' ' + jsRangeCount('C 40-60') + ' 款').fontSize(11).fontColor(JS.text).width(52).textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
            Row() {
              Text('D 60+').fontSize(11).fontColor(JS.sub).width(64)
              Stack() {
                Row() {}.width('100%').height(10).borderRadius(5).backgroundColor(JS.card2)
                Row() {}.width(jsBarW(jsRangeCount('D 60+') * 22)).height(10).borderRadius(5).backgroundColor('#C96B6B').margin({ right: 130 })
              }
              .layoutWeight(1)
              Text(' ' + jsRangeCount('D 60+') + ' 款').fontSize(11).fontColor(JS.text).width(52).textAlign(TextAlign.End)
            }
            .width('100%')
            .margin({ top: 8 })
          }
          .width('100%')
          .padding(12)
          .borderRadius(12)
          .backgroundColor(JS.card)
          .border({ width: 1, color: JS.line })
          .margin({ top: 10 })

          Scroll() {
            Row() {
              ForEach(BOOK_KINDS, (k: string, idx: number) => {
                Text(k)
                  .fontSize(12)
                  .fontColor(k === this.kind ? '#FFFFFF' : JS.sub)
                  .fontWeight(k === this.kind ? FontWeight.Bold : FontWeight.Normal)
                  .padding({ left: 13, right: 13, top: 6, bottom: 6 })
                  .borderRadius(14)
                  .backgroundColor(k === this.kind ? JS.primary : JS.card2)
                  .margin({ right: 8 })
                  .onClick(() => { this.kind = k })
              }, (k: string) => k)
            }
          }
          .scrollable(ScrollDirection.Horizontal)
          .scrollBar(BarState.Off)
          .width('100%')
          .margin({ top: 12 })
          .align(Alignment.Start)

          Row() {
            Text('手账清单')
              .fontSize(14)
              .fontColor(JS.text)
              .fontWeight(FontWeight.Bold)
            Column().layoutWeight(1)
            Text('+ 新增')
              .fontSize(12)
              .fontColor(JS.primary)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .borderRadius(12)
              .backgroundColor(JS.card2)
              .onClick(() => { this.showAdd = true })
          }
          .width('100%')
          .margin({ top: 14 })

          ForEach(jsFilterNotes(this.notes, this.kind), (n: NotebookItem, idx: number) => {
            Row() {
              Text(jsNoteImgN(n.name))
                .fontSize(22)
                .fontColor('#FFFFFF')
                .fontWeight(FontWeight.Bold)
                .width(50)
                .height(50)
                .textAlign(TextAlign.Center)
                .borderRadius(13)
                .backgroundColor(JS.primary)
              Column() {
                Text(n.name)
                  .fontSize(13)
                  .fontColor(JS.text)
                  .fontWeight(FontWeight.Bold)
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                Row() {
                  Text(n.kind)
                    .fontSize(9)
                    .fontColor(jsKindColor(n.kind))
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .borderRadius(6)
                    .backgroundColor(JS.card2)
                  Text(n.pages + '页')
                    .fontSize(9)
                    .fontColor(JS.sub)
                    .margin({ left: 6 })
                  Text(n.tag)
                    .fontSize(9)
                    .fontColor(JS.glow)
                    .margin({ left: 6 })
                }
                .margin({ top: 5 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 10 })
              Column() {
                Text(jsPrice(n.price))
                  .fontSize(14)
                  .fontColor('#D98E32')
                  .fontWeight(FontWeight.Bold)
                Text('月销' + n.sale)
                  .fontSize(9)
                  .fontColor(JS.sub)
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding(10)
            .borderRadius(12)
            .backgroundColor(JS.card)
            .border({ width: 1, color: JS.line })
            .margin({ top: 8 })
            .onClick(() => { this.selIdx = jsFindNoteIdx(this.notes, n.name) })
          }, (n: NotebookItem) => n.name)

          Column().height(12)
        }
        .padding({ left: 14, right: 14, bottom: 10 })
      }
      .scrollBar(BarState.Off)
      .width('100%')
      .height('100%')
      .align(Alignment.Top)
      this.modalOverlay()
    }
    .width('100%')
    .height('100%')
  }
}

// ============ Tab3 胶带 ============
@Component
struct JsTapeTab {
  @State tapes: TapeItem[] = []
  @State pattern: string = '全部'
  @State tapeRoll: number = 0
  @State selIdx: number = -1
  @State showAdd: boolean = false
  @State addName: string = '奶油波点胶带'
  @State addPattern: string = '复古'
  @State addWidth: string = '15'
  @State addLen: string = '10'
  @State addPrice: string = '12'
  @State showEdit: boolean = false
  @State editIdx: number = -1
  @State editName: string = ''
  @State editPattern: string = '复古'
  @State editPrice: string = ''
  @State showDel: boolean = false
  @State delIdx: number = -1

  aboutToAppear(): void {
    this.tapes = TAPE_LIST.slice()
    setInterval(() => {
      this.tapeRoll = (this.tapeRoll + 6) % 360
    }, 80)
  }

  @Builder
  modalOverlay() {
    if (this.selIdx >= 0) {
      Column() {
        Column() {
          Row() {
            Stack() {
              Text('◍')
                .fontSize(40)
                .fontColor(jsTapeC(this.tapes[this.selIdx].pattern))
                .rotate({ angle: this.tapeRoll })
              Text(jsTapeImgN(this.tapes[this.selIdx].name))
                .fontSize(11)
                .fontColor('#FFFFFF')
                .fontWeight(FontWeight.Bold)
            }
            .width(56)
            .height(56)
            Column() {
              Text(this.tapes[this.selIdx].name)
                .fontSize(15)
                .fontColor(JS.text)
                .fontWeight(FontWeight.Bold)
              Text(this.tapes[this.selIdx].pattern + ' 印花')
                .fontSize(11)
                .fontColor(JS.sub)
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })
            Column() {
              Text(jsPrice(this.tapes[this.selIdx].price))
                .fontSize(17)
                .fontColor('#D98E32')
                .fontWeight(FontWeight.Bold)
              Text('月销' + this.tapes[this.selIdx].sale)
                .fontSize(10)
                .fontColor(JS.sub)
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          Row() {
            Column() {
              Text('宽度').fontSize(10).fontColor(JS.sub)
              Text(this.tapes[this.selIdx].width + 'mm').fontSize(14).fontColor(JS.text).fontWeight(FontWeight.Bold).margin({ top: 4 })
            }
            .layoutWeight(1)
            Column() {
              Text('长度').fontSize(10).fontColor(JS.sub)
              Text(this.tapes[this.selIdx].len + 'm').fontSize(14).fontColor(JS.text).fontWeight(FontWeight.Bold).margin({ top: 4 })
            }
            .layoutWeight(1)
            Column() {
              Text('印花').fontSize(10).fontColor(JS.sub)
              Text(this.tapes[this.selIdx].pattern).fontSize(14).fontColor(jsTapeC(this.tapes[this.selIdx].pattern)).fontWeight(FontWeight.Bold).margin({ top: 4 })
            }
            .layoutWeight(1)
            Column() {
              Text('原价').fontSize(10).fontColor(JS.sub)
              Text(jsPrice(this.tapes[this.selIdx].orig)).fontSize(13).fontColor(JS.sub).decoration({ type: TextDecorationType.LineThrough }).margin({ top: 4 })
            }
            .layoutWeight(1)
          }
          .width('100%')
          .padding(10)
          .borderRadius(10)
          .backgroundColor(JS.card2)
          .margin({ top: 12 })
          Row() {
            Text('编辑')
              .fontSize(12)
              .fontColor(JS.text)
              .width(76)
              .height(32)
              .textAlign(TextAlign.Center)
              .borderRadius(16)
              .backgroundColor(JS.card2)
              .onClick(() => {
                this.editIdx = this.selIdx
                this.editName = this.tapes[this.selIdx].name
                this.editPattern = this.tapes[this.selIdx].pattern
                this.editPrice = this.tapes[this.selIdx].price.toFixed(0)
                this.selIdx = -1
                this.showEdit = true
              })
            Text('删除')
              .fontSize(12)
              .fontColor('#C96B6B')
              .width(76)
              .height(32)
              .textAlign(TextAlign.Center)
              .borderRadius(16)
              .backgroundColor('#F7E3E3')
              .margin({ left: 10 })
              .onClick(() => {
                this.delIdx = this.selIdx
                this.showDel = true
              })
            Text('立即购买')
              .fontSize(12)
              .fontColor('#FFFFFF')
              .fontWeight(FontWeight.Bold)
              .layoutWeight(1)
              .height(32)
              .textAlign(TextAlign.Center)
              .borderRadius(16)
              .backgroundColor(JS.primary)
              .margin({ left: 10 })
              .onClick(() => { this.selIdx = -1 })
          }
          .width('100%')
          .margin({ top: 14 })
          Text('关闭')
            .fontSize(12)
            .fontColor(JS.sub)
            .margin({ top: 10 })
            .onClick(() => { this.selIdx = -1 })
        }
   
          .align(Alignment.Start)

          Column().height(12)
        }
        .padding({ left: 14, right: 14, bottom: 10 })
      }
      .scrollBar(BarState.Off)
      .width('100%')
      .height('100%')
      .align(Alignment.Top)
      this.modalOverlay()
    }
    .width('100%')
    .height('100%')
  }
}

在这里插入图片描述

十六、总结

本文详细分析了一个基于HarmonyOS ArkTS声明式UI范式构建的手账文具电商应用。该应用以复古印刷蓝与奶黄便签色为核心主题,涵盖了手账本、胶带、钢笔墨水、贴纸四大商品品类,实现了首页推荐、分类筛选、商品详情、增删改查、订单管理、个人中心等电商全链路功能。通过纯函数工具集与组件状态管理的分离设计,应用在保持代码可维护性的同时实现了丰富的交互效果,包括纸屑飘落、胶带旋转、印章缩放、会员卡光泽扫过等多种动画特效。

从架构设计角度,应用采用了"数据接口定义 -> 静态数据源初始化 -> 纯函数工具集 -> 组件化渲染"的四层结构。interface定义确保了TypeScript类型安全,const数组提供了初始数据,纯函数封装了所有业务逻辑(过滤、格式化、统计、查找),@Component组件则专注于UI渲染和用户交互。这种分层设计使得每个层次都可以独立演进——例如替换数据源只需修改const数组,调整视觉样式只需修改ColorPalette常量,新增商品品类只需定义新的interface和对应的Tab组件。

在HarmonyOS 6.1.1平台上,ArkTS的声明式UI范式展现出了强大的表达能力和高效的渲染机制。@State装饰器的响应式更新、@Builder方法的UI片段复用、ForEach的列表渲染优化、链式调用的属性配置方式,共同构成了一个完整的声明式UI开发体系。对于希望深入掌握HarmonyOS应用开发的工程师而言,本应用的代码结构提供了从数据建模到UI渲染的全流程参考,尤其是在多Tab电商场景下的状态管理、动画实现和弹窗交互方面,具有很高的实践价值。

Logo

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

更多推荐