HarmonyOS 6.1.1代表了华为在全场景分布式操作系统领域的又一次技术飞跃。在这个版本中,ArkTS作为核心开发语言已经发展到API 24阶段,形成了完整的声明式UI编程体系、响应式状态管理框架和跨设备分布式能力集。ArkTS以TypeScript为语言基座,在保留静态类型系统优势的同时,通过@Component@State@Observed@Builder等装饰器系统,将声明式UI的理念深度融入语言层面。开发者编写的每一行ArkTS代码都会被编译器优化为高效的声明式渲染指令,由框架的渲染引擎执行最小化的DOM更新,确保应用在各种设备上都能保持60fps的流畅体验。

在HarmonyOS ArkTS API 24的生态中,声明式UI的核心价值在于"状态即视图"的设计哲学。开发者无需手动操作DOM节点、无需调用appendChildremoveChild、无需管理视图更新队列,只需要定义好数据模型与状态变量,描述界面在任意状态下的呈现效果,框架便会自动建立从状态到视图的响应式映射链路。在构建上门宠物摄影这类需要展示套系列表、摄影师档期、样片画廊、道具库存和订单状态等多维度信息的场景中,这种开发范式极大地简化了代码复杂度。用户的每一次收藏样片、切换风格标签、调整精修张数操作,都会通过@State装饰器触发对应UI组件的精准更新,无需开发者手动刷新整个列表。

美团类场景在本地生活服务领域的商业模式已经相当成熟。其核心在于将垂直服务(如上门宠物摄影)通过平台化运营,实现"搜索发现-查看套系-选摄影师-预约下单-上门拍摄-出片交付"的完整闭环。在上门宠物摄影这一细分赛道中,应用需要展示不同风格的拍摄套系(日系清新/户外跟拍/古风汉服/胶片质感)、签约摄影师档期、样片画廊、道具借用情况和订单全流程状态。这类应用对视觉沉浸感、信息层级和操作引导有特殊要求——用户通常是宠物主人,情感驱动较强,需要通过样片画廊和摄影师风格快速建立信任并完成预约决策。

在视觉交互层面,本应用创新性地采用了"相机机身式内容Tab"的设计模式。每个Tab项被设计为相机的物理形态——闪光灯(顶部矩形)、机身(矩形主体+圆形镜头)和标签名称,模拟真实相机的结构特征。这种设计不仅强化了"拾光机·宠物摄影"的品牌主题,也通过物理隐喻增强了用户对功能导航的认知。当选中某个Tab时,闪光灯变为强调色、机身变为主色调、镜头变为强调色,配合文字加粗和背景色切换,形成清晰的多层级选中态反馈。配合静态渐变头部和相机/宠物主题的浮动emoji粒子动画层,整个应用呈现出温暖柔和的摄影工作室氛围。

在数据架构层面,本应用同样采用interface定义数据契约、@Observed class implements实现可观测数据模型的架构模式。通过PackSnapperSamplePropInfaceOrder五个接口定义拍摄套系、摄影师、样片、道具和订单的数据结构,再通过对应的PackItemSnapperItemSampleItemPropItemOrderItem可观测类实现响应式更新。特别值得注意的是PropInface接口的命名——使用"Inface"后缀避免了与ArkTS保留字interface的冲突,体现了开发中对语言特性的细致考量。


一、色彩系统与摄影主题配色

在HarmonyOS ArkTS应用开发中,色彩系统是构建品牌情感连接的第一步。本应用通过ColorPalette接口和COLORS常量构建了一套棕色调暖色系摄影主题色彩体系。

interface ColorPalette {
  bg: string
  card: string
  white: string
  primary: string
  primaryDeep: string
  accent: string
  accentSoft: string
  gold: string
  textPrimary: string
  textSecond: string
  textThird: string
  line: string
  tagBg: string
  warn: string
  danger: string
  ok: string
}

const COLORS: ColorPalette = {
  bg: '#FAF6F2',
  card: '#FFFFFF',
  white: '#FFFFFF',
  primary: '#8D6E63',
  primaryDeep: '#5D4037',
  accent: '#FF8A80',
  accentSoft: '#FDEBE7',
  gold: '#E8A33D',
  textPrimary: '#4A342C',
  textSecond: '#8C7A70',
  textThird: '#BFAEA4',
  line: '#EEE5DD',
  tagBg: '#F4EDE6',
  warn: '#E65100',
  danger: '#C62828',
  ok: '#2E7D32'
}

在这里插入图片描述

COLORS常量中,主色调#8D6E63是经典棕褐色,呼应复古相机的皮革机身色彩;深色#5D4037为深棕,模拟相机深色部分;强调色#FF8A80为珊瑚粉,代表温暖和宠物友好的品牌调性;金色#E8A33D用于价格和评分高亮;背景色#FAF6F2为暖白色,营造摄影棚的柔和光照感。三级文本色系从#4A342C(深棕黑)到#BFAEA4(浅棕灰),形成温馨的视觉层级。这种"棕+珊瑚粉"的配色方案,让应用在第一眼就传达出"温暖·专业·宠物友好"的品牌氛围。


二、interface接口定义与@Observed类implements模式

2.1 拍摄套系数据模型

interface Pack {
  id: number
  name: string
  price: number
  mins: number
  scenes: number
  tag: string
  hot: boolean
}

@Observed
class PackItem implements Pack {
  id: number = 0
  name: string = ''
  price: number = 0
  mins: number = 0
  scenes: number = 0
  tag: string = ''
  hot: boolean = false

  constructor(o: Pack) {
    this.id = o.id
    this.name = o.name
    this.price = o.price
    this.mins = o.mins
    this.scenes = o.scenes
    this.tag = o.tag
    this.hot = o.hot ? o.hot : false
  }
}

Pack接口定义了拍摄套系的核心数据结构。mins字段为拍摄时长(分钟),在UI中以大号数字展示在卡片左侧;scenes字段为拍摄场景数量;tag字段标识套系标签(含10张精修/户外公园/含相框摆件等)。hot标记用于展示"🔥热卖"标签。套系列表通过ForEach渲染,每个卡片左侧展示时长数字块,右侧展示名称、标签、场景数和价格。

2.2 摄影师数据模型

interface Snapper {
  id: number
  name: string
  style: string
  score: number
  orders: number
  city: string
  fav: boolean
}

@Observed
class SnapperItem implements Snapper {
  id: number = 0
  name: string = ''
  style: string = ''
  score: number = 0
  orders: number = 0
  city: string = ''
  fav: boolean = false

  constructor(o: Snapper) {
    this.id = o.id
    this.name = o.name
    this.style = o.style
    this.score = o.score
    this.orders = o.orders
    this.city = o.city
    this.fav = o.fav ? o.fav : false
  }
}

Snapper接口定义了签约宠物摄影师信息,包含姓名、风格(日系清新/户外跟拍/古风汉服等)、评分、累计订单数、常驻城市和收藏标记。摄影师列表在卡片中展示评分星级、订单数和城市信息,以及"本周还可约3个档期"的实时档期提示。用户可以通过点击爱心图标收藏摄影师,fav状态的变更通过@Observed机制即时反映到UI。

2.3 样片数据模型

interface Sample {
  id: number
  title: string
  pet: string
  style: string
  likes: number
  liked: boolean
}

@Observed
class SampleItem implements Sample {
  id: number = 0
  title: string = ''
  pet: string = ''
  style: string = ''
  likes: number = 0
  liked: boolean = false

  constructor(o: Sample) {
    this.id = o.id
    this.title = o.title
    this.pet = o.pet
    this.style = o.style
    this.likes = o.likes
    this.liked = o.liked ? o.liked : false
  }
}

在这里插入图片描述

Sample接口定义了样片作品信息,包含标题、宠物名称、风格、点赞数和点赞状态。样片画廊采用Flex布局双列展示,每张卡片宽度48.5%,形成两列瀑布流效果。likes字段是动态的——用户点击爱心图标时,toggleLike方法会增减likes数值并切换liked状态,这种"点赞交互"在样片展示中是提升用户参与度的关键设计。

2.4 道具与订单数据模型

interface PropInface {
  id: number
  name: string
  kind: string
  stock: number
  price: number
}

@Observed
class PropItem implements PropInface {
  id: number = 0
  name: string = ''
  kind: string = ''
  stock: number = 0
  price: number = 0

  constructor(o: PropInface) {
    this.id = o.id
    this.name = o.name
    this.kind = o.kind
    this.stock = o.stock
    this.price = o.price
  }
}

在这里插入图片描述

PropInface接口定义了道具库信息。注意这里使用"PropInface"而非"PropInterface"作为接口名,是因为ArkTS中interface是保留字,直接以"Prop"开头的接口名可能与关键字产生歧义。kind字段标识道具类型(服饰/布景/道具),stock字段标识库存数量。当stock为0时,按钮显示"缺货"灰色不可点击,并提示"本周借完·下周补货";当有库存时,按钮显示"借用"并可触发道具借用弹框。

interface Order {
  id: number
  pack: string
  pet: string
  date: string
  status: string
  price: number
}

@Observed
class OrderItem implements Order {
  id: number = 0
  pack: string = ''
  pet: string = ''
  date: string = ''
  status: string = ''
  price: number = 0

  constructor(o: Order) {
    this.id = o.id
    this.pack = o.pack
    this.pet = o.pet
    this.date = o.date
    this.status = o.status
    this.price = o.price
  }
}

Order接口定义了拍摄订单信息。订单状态有"待拍摄"、“修图中”、“已完成"和"已取消"四种,每种状态关联不同的操作按钮——待拍摄显示"改期”(打开拍摄计划弹框)、修图中显示"加急"(打开加急出片弹框)、已完成显示"相册"、已取消显示"删除"(打开取消弹框)。这种基于状态的多分支操作设计,让用户能够根据订单当前阶段执行最合适的操作。


在这里插入图片描述

三、静态数据数组与业务常量

3.1 套系列表数据

const PACKS: PackItem[] = [
  new PackItem({ id: 1, name: '喵星人单人写真', price: 328, mins: 60, scenes: 2, tag: '含10张精修', hot: true }),
  new PackItem({ id: 2, name: '狗子奔跑跟拍', price: 388, mins: 75, scenes: 3, tag: '户外公园', hot: true }),
  new PackItem({ id: 3, name: '猫狗双全全家福', price: 528, mins: 90, scenes: 3, tag: '含相框摆件', hot: false }),
  new PackItem({ id: 4, name: '幼宠满月纪念', price: 298, mins: 50, scenes: 2, tag: '手印纪念卡', hot: true }),
  new PackItem({ id: 5, name: '上门到家随拍', price: 268, mins: 45, scenes: 1, tag: '免通勤应激', hot: false }),
  new PackItem({ id: 6, name: '宠物生日派对记录', price: 458, mins: 80, scenes: 3, tag: '含布景布置', hot: false }),
  new PackItem({ id: 7, name: '萌宠日历12宫格', price: 598, mins: 120, scenes: 4, tag: '年历成品册', hot: false }),
  new PackItem({ id: 8, name: '宠物告别纪念册', price: 666, mins: 90, scenes: 3, tag: '温情陪护', hot: false }),
  new PackItem({ id: 9, name: '异宠微距特写', price: 358, mins: 60, scenes: 2, tag: '爬宠鸟类', hot: false }),
  new PackItem({ id: 10, name: '遛狗跟拍月卡', price: 888, mins: 240, scenes: 6, tag: '4次上门', hot: false }),
  new PackItem({ id: 11, name: '古风宠物汉服', price: 488, mins: 85, scenes: 3, tag: '含3套服饰', hot: true }),
  new PackItem({ id: 12, name: '胶片质感套系', price: 428, mins: 70, scenes: 2, tag: '富士sp3000', hot: false })
]

在这里插入图片描述

PACKS数组涵盖了12种不同风格和价位的拍摄套系,从268元的上门随拍到888元的月卡跟拍,覆盖了从入门到深度用户的全价位段。tag标签丰富多样——含10张精修、户外公园、手印纪念卡、免通勤应激、含布景布置、年历成品册、温情陪护等,每个标签都传达了套系的独特卖点。hot标记的套系在卡片中显示"🔥热卖"红色标签,引导用户关注热门选择。

3.2 摄影师与样片数据

const SNAPPERS: SnapperItem[] = [
  new SnapperItem({ id: 1, name: '阿茶', style: '日系清新', score: 4.9, orders: 686, city: '北京', fav: true }),
  new SnapperItem({ id: 2, name: '馒头爸', style: '户外跟拍', score: 4.9, orders: 520, city: '北京', fav: false }),
  new SnapperItem({ id: 3, name: 'Luna', style: '古风汉服', score: 4.8, orders: 342, city: '上海', fav: false }),
  new SnapperItem({ id: 4, name: '老白', style: '胶片质感', score: 4.7, orders: 288, city: '杭州', fav: false }),
  new SnapperItem({ id: 5, name: '豆花', style: '幼宠特写', score: 5.0, orders: 460, city: '成都', fav: true }),
  new SnapperItem({ id: 6, name: 'Kevin', style: '猫咪棚拍', score: 4.8, orders: 375, city: '深圳', fav: false }),
  new SnapperItem({ id: 7, name: '小满', style: '生日记录', score: 4.9, orders: 298, city: '北京', fav: false }),
  new SnapperItem({ id: 8, name: '叶子', style: '异宠微距', score: 4.6, orders: 156, city: '广州', fav: false }),
  new SnapperItem({ id: 9, name: '桃子', style: '全家福', score: 4.9, orders: 412, city: '北京', fav: false }),
  new SnapperItem({ id: 10, name: '阿岁', style: '告别纪念', score: 5.0, orders: 132, city: '上海', fav: false })
]
const SAMPLES: SampleItem[] = [
  new SampleItem({ id: 1, title: '窗边的橘猫午后', pet: '橘猫·大福', style: '日系', likes: 1286, liked: true }),
  new SampleItem({ id: 2, title: '草地上飞奔的柯基', pet: '柯基·面包', style: '跟拍', likes: 982, liked: false }),
  new SampleItem({ id: 3, title: '汉服布偶的春日', pet: '布偶·雪球', style: '古风', likes: 1154, liked: false }),
  new SampleItem({ id: 4, title: '满月奶猫初睁眼', pet: '奶猫·年糕', style: '特写', likes: 1560, liked: false }),
  new SampleItem({ id: 5, title: '柴犬的胶片夏天', pet: '柴犬·麻薯', style: '胶片', likes: 876, liked: false }),
  new SampleItem({ id: 6, title: '生日帽下的比熊', pet: '比熊·糯米', style: '记录', likes: 1043, liked: true }),
  new SampleItem({ id: 7, title: '守在门边的金毛', pet: '金毛·向阳', style: '纪实', likes: 1392, liked: false }),
  new SampleItem({ id: 8, title: '缸里的小乌龟', pet: '草龟·石头', style: '微距', likes: 542, liked: false })
]

在这里插入图片描述

样片数据SAMPLES数组的每条记录都包含感性的标题(如"窗边的橘猫午后"、“满月奶猫初睁眼”)、宠物名称和品种、风格分类和点赞数。这些标题不是简单的套系名称,而是富有画面感的情感文案,直接触达宠物主人的情感需求。likes字段模拟社交媒体的点赞机制,用户点击爱心图标可以点赞/取消点赞,liked状态和likes数值同步更新。

3.3 道具数据与图表常量

const PROPS: PropItem[] = [
  new PropItem({ id: 1, name: '宠物汉服三件套', kind: '服饰', stock: 4, price: 68 }),
  new PropItem({ id: 2, name: '生日派对布景', kind: '布景', stock: 2, price: 128 }),
  new PropItem({ id: 3, name: '小清新草帽', kind: '服饰', stock: 8, price: 30 }),
  new PropItem({ id: 4, name: '英伦格子围巾', kind: '服饰', stock: 6, price: 35 }),
  new PropItem({ id: 5, name: '奶油色地毯', kind: '布景', stock: 3, price: 88 }),
  new PropItem({ id: 6, name: '玩具球道具组', kind: '道具', stock: 12, price: 25 }),
  new PropItem({ id: 7, name: '干花手捧花束', kind: '道具', stock: 0, price: 45 }),
  new PropItem({ id: 8, name: '复古皮质行李箱', kind: '布景', stock: 1, price: 158 })
]
const MONTH_LABELS: string[] = ['3月', '4月', '5月', '6月', '7月', '8月']
const MONTH_SHOTS: number[] = [2, 4, 3, 6, 5, 7]
const STYLE_LABELS: string[] = ['日系清新', '户外跟拍', '古风汉服', '胶片质感']
const STYLE_PCTS: number[] = [34, 28, 22, 16]
const STYLE_COLORS: string[] = ['#8D6E63', '#FF8A80', '#5D4037', '#E8A33D']
const STYLE_TAGS: string[] = ['日系', '古风', '胶片', '纪实']
const DAY_TAGS: string[] = ['工作日晚间', '周末白天', '周末晚间']
const SPEED_TAGS: string[] = ['24小时', '48小时', '72小时']
const ORDER_STATUS: string[] = ['全部', '待拍摄', '修图中', '已完成', '已取消']

在这里插入图片描述

道具数据中stock为0的"干花手捧花束"会显示"缺货"按钮和"本周借完·下周补货"提示,stock为1的"复古皮质行李箱"属于稀缺道具。STYLE_PCTSSTYLE_COLORS配合绘制风格占比堆叠条形图——日系清新34%(棕色)、户外跟拍28%(珊瑚粉)、古风汉服22%(深棕)、胶片质感16%(金色)。ORDER_STATUS比前两个应用多一个"修图中"状态,反映了摄影服务特有的后期处理环节。


四、辅助函数与色彩动态映射

function shotBar(i: number): number {
  return MONTH_SHOTS[i] * 10
}

function stockColor(n: number): string {
  if (n <= 0) {
    return COLORS.danger
  }
  if (n < 3) {
    return COLORS.warn
  }
  return COLORS.ok
}

function statusColor(s: string): string {
  if (s === '待拍摄') {
    return COLORS.warn
  }
  if (s === '修图中') {
    return COLORS.primary
  }
  if (s === '已完成') {
    return COLORS.ok
  }
  return COLORS.danger
}

在这里插入图片描述

shotBar函数将月度拍摄单数乘以10转为柱状图高度。stockColor根据道具库存数量返回三种颜色——0件为危险红、3件以下为警告橙、3件以上为成功绿。statusColor函数是三个应用中分支最多的——待拍摄为橙色(提醒前往拍摄)、修图中为棕色主色调(处理中的品牌色)、已完成为绿色(交易完成)、已取消为红色(操作警示)。这种"修图中=主色调"的设计选择,使得处于后期处理状态的订单在视觉上与品牌色调一致,强化了"正在进行中"的认知。


五、组件状态管理与生命周期

@Entry
@Component
struct PagePetPhoto {
  @State curTab: number = 0
  @State mainTab: number = 0
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State bizOpen: boolean = false
  @State packList: PackItem[] = PACKS
  @State sampleList: SampleItem[] = SAMPLES
  @State snapperList: SnapperItem[] = SNAPPERS
  @State orderFilter: number = 0
  @State rev: number = 0
  @State tick: number = 0
  @State styleTags: number[] = [0]
  @State dayTags: number[] = [0]
  @State speedTags: number[] = [0]
  @State retouchStep: number = 10
  @State petStep: number = 2
  @State homeFlag: boolean = true
  @State fastFlag: boolean = false
  private timer: number = -1

状态变量在导航、弹框和数据三类的基础上增加了摄影特有的交互状态:retouchStep精修张数步进器(5-40张,步进为5)、petStep出镜宠物数步进器(1-5只)、homeFlag到府拍摄模式开关、fastFlag优先插队开关。orderFilter控制订单筛选状态(5个选项比其他应用多一个"修图中")。tick的定时器间隔为106ms,略快于其他两个应用的111ms和113ms。

  aboutToAppear(): void {
    this.timer = setInterval(() => {
      this.tick = this.tick + 1
    }, 106)
  }

  aboutToDisappear(): void {
    if (this.timer >= 0) {
      clearInterval(this.timer)
    }
  }

  refreshPacks(): void {
    this.packList.unshift(this.packList[this.packList.length - 1])
    this.packList.splice(this.packList.length - 1, 1)
    this.rev = this.rev + 1
  }

  toggleLike(i: number): void {
    if (this.sampleList[i].liked) {
      this.sampleList[i].liked = false
      this.sampleList[i].likes = this.sampleList[i].likes - 1
    } else {
      this.sampleList[i].liked = true
      this.sampleList[i].likes = this.sampleList[i].likes + 1
    }
    this.rev = this.rev + 1
  }

  toggleSnapperFav(i: number): void {
    this.snapperList[i].fav = !this.snapperList[i].fav
    this.rev = this.rev + 1
  }

  photoTotal(): number {
    return 328 + this.retouchStep * 8
  }

toggleLike方法是本应用独有的交互逻辑——同时修改liked布尔状态和likes数值。这种"状态+数值"双更新的模式比简单的布尔切换更复杂,需要确保两个属性的变更在同一帧内完成,得益于@Observed的批量更新机制,这种双属性更新不会导致界面闪烁。photoTotal方法计算预约总价:328 + retouchStep * 8,即基础价328元加上每张精修8元的增量价格。


六、fxLayer浮动动画特效层

  @Builder
  fxLayer() {
    Stack() {
      Text('📷')
        .fontSize(24)
        .rotate({ angle: (this.tick % 10) * 4 - 20 })
        .position({ x: 70, y: 170 })
        .opacity(0.8)
      Text('🐶')
        .fontSize(20)
        .translate({ x: this.tick % 26 - 13 })
        .position({ x: 330, y: 260 })
      Text('🐾')
        .fontSize(18)
        .opacity((this.tick % 6) / 6 + 0.2)
        .position({ x: 540, y: 150 })
      Text('🪄')
        .fontSize(20)
        .scale({ x: 1 + (this.tick % 8) * 0.04, y: 1 + (this.tick % 8) * 0.04 })
        .position({ x: 200, y: 380 })
        .opacity(0.6)
      Text('✨')
        .fontSize(14)
        .position({ x: 130, y: 330 })
        .opacity((this.tick % 7) / 7 + 0.15)
    }
    .width('100%')
    .height('100%')
    .hitTestBehavior(HitTestMode.None)
  }

五个emoji粒子与宠物摄影主题紧密关联:📷相机使用rotate实现左右摇摆动画(模拟手持拍摄的微抖动),角度范围为-20到+20度;🐶小狗使用translate实现水平往返位移(模拟宠物奔跑的活泼感);🐾爪印通过opacity脉冲实现闪烁(模拟宠物走过的足迹显现/消失);🪄魔法棒通过scale实现缩放呼吸(模拟修图的魔法变换效果);闪光通过opacity实现渐变闪烁。hitTestBehavior(HitTestMode.None)确保该层不拦截触摸事件。


七、静态渐变头部构建

  @Builder
  header() {
    Column() {
      Row() {
        Text('📷')
          .fontSize(24)
        Text('拾光机')
          .fontSize(21)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: 8 })
        Column()
          .layoutWeight(1)
        Text('📍上门拍摄 · 师傅1小时到')
          .fontSize(12)
          .fontColor('#EBD9C8')
        Text('🔔')
          .fontSize(19)
          .margin({ left: 12 })
          .onClick(() => {
            this.mainTab = 3
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

      Row() {
        Text('🔍')
          .fontSize(15)
        Text('搜套系 / 摄影师 / 样片')
          .fontSize(13)
          .fontColor('#D9C3B0')
          .margin({ left: 8 })
        Column()
          .layoutWeight(1)
        Text('搜索')
          .fontSize(13)
          .fontColor(COLORS.textPrimary)
          .padding({ left: 14, right: 14, top: 6, bottom: 6 })
          .backgroundColor(COLORS.accent)
          .borderRadius(14)
          .onClick(() => {
            this.curTab = 0
          })
      }
      .width('100%')
      .padding({ left: 10, right: 10 })
      .margin({ top: 10 })
      .backgroundColor('rgba(255,255,255,0.18)')
      .borderRadius(20)

      Row({ space: 8 }) {
        Text('免通勤应激')
          .fontSize(11)
          .fontColor(COLORS.white)
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .backgroundColor('rgba(255,255,255,0.22)')
          .borderRadius(12)
        Text('底片全送')
          .fontSize(11)
          .fontColor(COLORS.white)
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .backgroundColor('rgba(255,255,255,0.22)')
          .borderRadius(12)
        Text('猫狗鸟爬虫都拍')
          .fontSize(11)
          .fontColor(COLORS.white)
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .backgroundColor('rgba(255,255,255,0.22)')
          .borderRadius(12)
        Text('不满意重拍')
          .fontSize(11)
          .fontColor(COLORS.white)
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .backgroundColor('rgba(255,255,255,0.22)')
          .borderRadius(12)
      }
      .width('100%')
      .padding({ left: 16, right: 16 })
      .margin({ top: 10, bottom: 14 })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
    .linearGradient({ angle: 135, colors: [[COLORS.primary, 0], [COLORS.primaryDeep, 1]] })
  }

头部品牌行展示"📷 拾光机"和"上门拍摄·师傅1小时到"的服务承诺,搜索栏文案为"搜套系/摄影师/样片"。促销标签聚焦宠物摄影的核心卖点——免通勤应激(无需带宠物外出)、底片全送(无隐形消费)、猫狗鸟爬虫都拍(全品类覆盖)、不满意重拍(质量保障)。搜索按钮使用COLORS.accent珊瑚粉色背景,在棕色渐变头部上形成温暖而不刺眼的对比。整个头部通过linearGradient设置135度渐变,从#8D6E63#5D4037,模拟复古相机从浅棕到深棕的色彩过渡。


八、相机机身式内容Tab构建

lensTab构建器是本应用视觉设计的核心创新。每个Tab项被设计为相机的物理形态——闪光灯(顶部矩形)、机身(矩形主体+圆形镜头)和标签名称。

  @Builder
  lensTab(name: string, icon: string, idx: number) {
    Column() {
      Rect({ width: 8, height: 3 })
        .fill(this.curTab === idx ? COLORS.accent : COLORS.line)
        .radius(2)
        .margin({ bottom: 1 })
      Stack() {
        Rect({ width: 26, height: 16 })
          .fill(this.curTab === idx ? COLORS.primary : COLORS.line)
          .radius(4)
        Circle({ width: 12, height: 12 })
          .fill(this.curTab === idx ? COLORS.accent : COLORS.tagBg)
        Text(icon)
          .fontSize(8)
      }
      Text(name)
        .fontSize(11)
        .fontWeight(this.curTab === idx ? FontWeight.Bold : FontWeight.Normal)
        .fontColor(this.curTab === idx ? COLORS.primary : COLORS.textSecond)
        .margin({ top: 3 })
    }
    .width('15.5%')
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 8, bottom: 8 })
    .backgroundColor(this.curTab === idx ? COLORS.card : COLORS.bg)
    .borderRadius(12)
    .margin({ top: 6 })
    .onClick(() => {
      this.switchTab(idx)
    })
  }

lensTab组件结构从上到下依次为:Rect(宽8高3,radius 2模拟闪光灯条),Stack中嵌套Rect(宽26高16,radius 4模拟机身主体)、Circle(宽12高12模拟镜头)和Text(emoji图标),Text(标签名称)。宽度为15.5%,配合4vp间距,六个Tab刚好填满屏幕宽度。

选中态的视觉变化:闪光灯从line灰色变为accent珊瑚粉、机身从灰色变为primary棕色、镜头从tagBg浅色变为accent珊瑚粉、文字从Normal变为Bold、文字色从textSecond变为primary、容器背景从bg变为card。这种"棕+珊瑚粉"的选中态配色方案,让相机Tab在选中时呈现出完整的双色彩结构——机身棕色代表相机本体,闪光灯和镜头珊瑚粉代表相机的工作状态,视觉辨识度温暖而专业。

  @Builder
  tabBar() {
    Row({ space: 4 }) {
      this.lensTab('套系', '🎞', 0)
      this.lensTab('摄影师', '🧑‍🎨', 1)
      this.lensTab('样片', '🖼', 2)
      this.lensTab('道具', '🎈', 3)
      this.lensTab('订单', '🧾', 4)
      this.lensTab('会员', '👑', 5)
    }
    .width('100%')
    .padding({ left: 6, right: 6, top: 10 })
  }

六个相机Tab分别对应套系、摄影师、样片、道具、订单和会员六个功能页面。与前两个应用的Tab结构对比:脱口秀应用的麦克风Tab为6个(话筒头+手柄+底座),风洞应用的降落伞Tab为5个(伞盖+伞绳+吊篮),本应用的相机Tab为6个(闪光灯+机身+镜头),三种Tab各具特色,但都遵循"物理隐喻+多段式结构+多属性选中态切换"的设计原则。


九、套系列表页面构建

  @Builder
  pagePack() {
    Column() {
      Row() {
        Column() {
          Text('本周末档期 · 上门拍猫狗')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('到府布光 · 60分钟 · 底片全送+10张精修')
            .fontSize(11)
            .fontColor('#EBD9C8')
            .margin({ top: 4 })
          Text('新客价 ¥288')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.gold)
            .margin({ top: 6 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('🐾')
          .fontSize(38)
          .margin({ right: 12 })
      }
      .width('100%')
      .padding(14)
      .borderRadius(14)
      .linearGradient({ angle: 120, colors: [[COLORS.primaryDeep, 0], [COLORS.accent, 1]] })
      .onClick(() => {
        this.addOpen = true
      })

顶部推荐卡片使用120度渐变,从primaryDeep深棕到accent珊瑚粉,营造从暗到亮的摄影布光效果。卡片内容包含档期标题、服务详情(到府布光·60分钟·底片全送+10张精修)和新客价格。价格文字使用COLORS.gold金色,在深色渐变背景上形成高光效果。

      this.sectionTitle('人气套系', '换一批', 0)
      ForEach(this.packList, (it: PackItem, i: number) => {
        Row() {
          Stack() {
            Column()
              .width(52)
              .height(52)
              .backgroundColor(it.hot ? COLORS.accentSoft : COLORS.tagBg)
              .borderRadius(10)
            Column() {
              Text(it.mins.toString())
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.primary)
              Text('分钟')
                .fontSize(9)
                .fontColor(COLORS.textSecond)
            }
          }
          Column() {
            Row() {
              Text(it.name)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
              Text(it.hot ? '🔥热卖' : '')
                .fontSize(9)
                .fontColor(COLORS.danger)
                .margin({ left: 6 })
            }
            Text(it.scenes.toString() + '个场景 · ' + it.tag + ' · 可加急出片')
              .fontSize(10)
              .fontColor(COLORS.textThird)
              .margin({ top: 4 })
            Text('¥' + it.price)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primary)
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 12 })
          Text('预约')
            .fontSize(12)
            .fontColor(COLORS.white)
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .backgroundColor(COLORS.primary)
            .borderRadius(16)
            .onClick(() => {
              this.addOpen = true
            })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .margin({ left: 16, right: 16, top: 8 })
      }, (it: PackItem) => 'pa' + it.id.toString() + '_' + this.rev.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

套系列表的左侧数字块背景色会根据hot标记变化——热卖套系使用COLORS.accentSoft浅粉色背景,普通套系使用COLORS.tagBg米色背景。这种细微的色彩差异在视觉上帮助用户快速识别热门选择。右侧信息区展示了场景数、标签和"可加急出片"提示,价格使用primary棕色加粗显示。


十、弹框组件体系构建

10.1 预约拍摄弹框

  @Builder
  modalBodyAdd() {
    Column() {
      Row() {
        Text('📷 预约拍摄 PHOTO ORDER')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Column()
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.textThird)
          .onClick(() => {
            this.addOpen = false
          })
      }
      .width('100%')

      Text('拍摄风格(可多选)')
        .fontSize(13)
        .fontColor(COLORS.textSecond)
        .margin({ top: 14 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(STYLE_TAGS, (s: string, i: number) => {
          Text(s)
            .fontSize(12)
            .fontColor(this.styleTags.indexOf(i) >= 0 ? COLORS.white : COLORS.primary)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(this.styleTags.indexOf(i) >= 0 ? COLORS.primary : COLORS.accentSoft)
            .borderRadius(14)
            .margin({ right: 8, top: 8 })
            .onClick(() => {
              this.toggleTag(this.styleTags, i)
            })
        }, (s: string) => 'stt' + s)
      }
      .width('100%')
      .margin({ top: 4 })

      Text('精修张数')
        .fontSize(13)
        .fontColor(COLORS.textSecond)
        .margin({ top: 16 })
      Row() {
        Text('-')
          .fontSize(16)
          .fontColor(COLORS.textSecond)
          .padding(10)
          .backgroundColor(COLORS.tagBg)
          .borderRadius(10)
          .onClick(() => {
            if (this.retouchStep > 5) {
              this.retouchStep = this.retouchStep - 5
            }
          })
        Column() {
          Text(this.retouchStep.toString() + ' 张')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        Text('+')
          .fontSize(16)
          .fontColor(COLORS.white)
          .padding(10)
          .backgroundColor(COLORS.primary)
          .borderRadius(10)
          .onClick(() => {
            if (this.retouchStep < 40) {
              this.retouchStep = this.retouchStep + 5
            }
          })
      }
      .width('100%')
      .margin({ top: 8 })

      Text('底片全送 · 拍摄不满意免费重拍一次')
        .fontSize(11)
        .fontColor(COLORS.textThird)
        .margin({ top: 8 })

      Row() {
        Text('去支付')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 20, right: 20, top: 10, bottom: 10 })
          .backgroundColor(COLORS.primary)
          .borderRadius(20)
          .onClick(() => {
            this.addOpen = false
          })
        Column()
          .layoutWeight(1)
        Text('合计 ¥' + this.photoTotal())
          .fontSize(13)
          .fontColor(COLORS.gold)
      }
      .width('100%')
      .margin({ top: 20, bottom: 20 })
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 16 })
    .backgroundColor(COLORS.card)
    .borderRadius(20)
    .constraintSize({ maxHeight: '80%' })
  }

预约弹框标题为"📷 预约拍摄 PHOTO ORDER",包含拍摄风格多选标签(日系/古风/胶片/纪实)、精修张数步进器(5-40张,步进为5)和支付按钮。精修张数步进器以5为步进单位,photoTotal()方法计算总价为328 + retouchStep * 8元,即基础价328元加上每张精修8元。弹框底部展示"底片全送·拍摄不满意免费重拍一次"的服务承诺,增强用户下单信心。

10.2 拍摄计划弹框

  @Builder
  modalBodyEdit() {
    Column() {
      Row() {
        Text('🗓 拍摄计划 SHOT PLAN')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Column()
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.textThird)
          .onClick(() => {
            this.editOpen = false
          })
      }
      .width('100%')

      Text('拍摄时段(可多选)')
        .fontSize(13)
        .fontColor(COLORS.textSecond)
        .margin({ top: 14 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(DAY_TAGS, (t: string, i: number) => {
          Text(t)
            .fontSize(12)
            .fontColor(this.dayTags.indexOf(i) >= 0 ? COLORS.white : COLORS.accent)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(this.dayTags.indexOf(i) >= 0 ? COLORS.accent : COLORS.accentSoft)
            .borderRadius(14)
            .margin({ right: 8, top: 8 })
            .onClick(() => {
              this.toggleTag(this.dayTags, i)
            })
        }, (t: string) => 'dt' + t)
      }
      .width('100%')
      .margin({ top: 4 })

      Text('出镜宠物数')
        .fontSize(13)
        .fontColor(COLORS.textSecond)
        .margin({ top: 16 })
      Row() {
        Text('-')
          .fontSize(16)
          .fontColor(COLORS.textSecond)
          .padding(10)
          .backgroundColor(COLORS.tagBg)
          .borderRadius(10)
          .onClick(() => {
            if (this.petStep > 1) {
              this.petStep = this.petStep - 1
            }
          })
        Column() {
          Text(this.petStep.toString() + ' 只')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        Text('+')
          .fontSize(16)
          .fontColor(COLORS.white)
          .padding(10)
          .backgroundColor(COLORS.accent)
          .borderRadius(10)
          .onClick(() => {
            if (this.petStep < 5) {
              this.petStep = this.petStep + 1
            }
          })
      }
      .width('100%')
      .margin({ top: 8 })

      Row() {
        Column() {
          Text('到府拍摄模式')
            .fontSize(14)
            .fontColor(COLORS.textPrimary)
          Text('免通勤应激 · 自有灯光布景上门')
            .fontSize(11)
            .fontColor(COLORS.textThird)
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text(this.homeFlag ? '已开启' : '已关闭')
          .fontSize(12)
          .fontColor(this.homeFlag ? COLORS.white : COLORS.textSecond)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor(this.homeFlag ? COLORS.primary : COLORS.tagBg)
          .borderRadius(14)
          .onClick(() => {
            this.homeFlag = !this.homeFlag
          })
      }
      .width('100%')
      .margin({ top: 16 })
      .padding(12)
      .backgroundColor(COLORS.tagBg)
      .borderRadius(12)

拍摄计划弹框标题为"🗓 拍摄计划 SHOT PLAN",包含拍摄时段多选标签(工作日晚间/周末白天/周末晚间)、出镜宠物数步进器(1-5只)和到府拍摄模式开关。homeFlag布尔变量控制到府拍摄模式的开关状态,开启时按钮为primary棕色底白字"已开启",关闭时为tagBg米色底灰字"已关闭"。到府拍摄模式是本应用的核心卖点——“免通勤应激·自有灯光布景上门”,即摄影师携带设备上门拍摄,避免宠物因外出而产生应激反应。

10.3 取消订单弹框与加急出片弹框

  @Builder
  modalBodyDel() {
    Column() {
      Row() {
        Text('⚠️ 取消订单确认')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.danger)
        Column()
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.textThird)
          .onClick(() => {
            this.delOpen = false
          })
      }
      .width('100%')

      Column() {
        Text('📷')
          .fontSize(34)
        Text('周日10点上门拍摄即将取消')
          .fontSize(13)
          .fontColor(COLORS.textPrimary)
          .margin({ top: 8 })
        Text('开拍前24小时外取消免收损失费')
          .fontSize(11)
          .fontColor(COLORS.warn)
          .margin({ top: 6 })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Center)
      .padding({ top: 18, bottom: 18 })
      .backgroundColor('#FDECEA')
      .borderRadius(14)
      .margin({ top: 14 })

取消弹框展示取消订单、已付金额和取消费用三个数据卡片。退款策略为"开拍前24小时外取消免收损失费",取消费用显示为"¥0"绿色文字,给用户安心的取消保障。底部提供"再想想"和"确认取消"两个操作按钮。

  @Builder
  modalBodyBiz() {
    Column() {
      Row() {
        Text('⚡ 加急出片 RUSH RETOUCH')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Column()
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.textThird)
          .onClick(() => {
            this.bizOpen = false
          })
      }
      .width('100%')

      Row() {
        Column() {
          Text('修图加急 · 朋友圈先发')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('排队中第3位 · 预计明日出图')
            .fontSize(11)
            .fontColor('#EBD9C8')
            .margin({ top: 4 })
          Text('加急 ¥49起')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.gold)
            .margin({ top: 6 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('⚡')
          .fontSize(36)
      }
      .width('100%')
      .padding(14)
      .borderRadius(14)
      .linearGradient({ angle: 120, colors: [[COLORS.primaryDeep, 0], [COLORS.accent, 1]] })
      .margin({ top: 14 })

      Text('期望出片时限')
        .fontSize(13)
        .fontColor(COLORS.textSecond)
        .margin({ top: 14 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(SPEED_TAGS, (s: string, i: number) => {
          Text(s)
            .fontSize(12)
            .fontColor(this.speedTags.indexOf(i) >= 0 ? COLORS.white : COLORS.primary)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(this.speedTags.indexOf(i) >= 0 ? COLORS.primary : COLORS.accentSoft)
            .borderRadius(14)
            .margin({ right: 8, top: 8 })
            .onClick(() => {
              this.toggleTag(this.speedTags, i)
            })
        }, (s: string) => 'spt' + s)
      }
      .width('100%')
      .margin({ top: 4 })

      Row() {
        Column() {
          Text('优先插队排期')
            .fontSize(14)
            .fontColor(COLORS.textPrimary)
          Text('资深修图师优先处理您的订单')
            .fontSize(11)
            .fontColor(COLORS.textThird)
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text(this.fastFlag ? '已开启' : '已关闭')
          .fontSize(12)
          .fontColor(this.fastFlag ? COLORS.white : COLORS.textSecond)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor(this.fastFlag ? COLORS.primary : COLORS.tagBg)
          .borderRadius(14)
          .onClick(() => {
            this.fastFlag = !this.fastFlag
          })
      }
      .width('100%')
      .margin({ top: 16 })
      .padding(12)
      .backgroundColor(COLORS.tagBg)
      .borderRadius(12)

加急出片弹框是本应用独有的特色弹框——当订单状态为"修图中"时,用户可以点击"加急"按钮触发该弹框。弹框展示当前排队位置(第3位)、预计出图时间(明日出图)和加急费用(¥49起)。用户可以选择期望出片时限(24小时/48小时/72小时多选标签)和是否开启优先插队排期(fastFlag开关)。加急按钮使用COLORS.primary棕色背景,加急费显示为COLORS.gold金色。

10.4 弹框覆盖层

  @Builder
  modalOverlay() {
    Stack({ alignContent: Alignment.Bottom }) {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(62,42,32,0.5)')
        .onClick(() => {
          this.closeAll()
        })
      if (this.addOpen) {
        this.modalBodyAdd()
      }
      if (this.editOpen) {
        this.modalBodyEdit()
      }
      if (this.delOpen) {
        this.modalBodyDel()
      }
      if (this.bizOpen) {
        this.modalBodyBiz()
      }
    }
    .width('100%')
    .height('100%')
  }

遮罩层使用半透明棕色背景rgba(62,42,32,0.5),与品牌棕色调一致。四个弹框通过条件判断渲染,同一时间只显示一个。点击遮罩层调用closeAll()方法关闭所有弹框。


十一、build方法主架构与页面路由

  @Builder
  mainContent() {
    Scroll() {
      Column() {
        this.tabBar()
        if (this.mainTab === 0) {
          if (this.curTab === 0) {
            this.pagePack()
          }
          if (this.curTab === 1) {
            this.pageSnapper()
          }
          if (this.curTab === 2) {
            this.pageSample()
          }
          if (this.curTab === 3) {
            this.pageProp()
          }
          if (this.curTab === 4) {
            this.pageOrder()
          }
          if (this.curTab === 5) {
            this.pageMember()
          }
        } else {
          this.pageMainOther()
        }
      }
      .width('100%')
    }
    .width('100%')
    .layoutWeight(1)
    .scrollBar(BarState.Off)
    .edgeEffect(EdgeEffect.Spring)
  }

  build() {
    Stack() {
      Column() {
        this.header()
        this.mainContent()
        this.bottomBar()
      }
      .width('100%')
      .height('100%')
      this.fxLayer()
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }

mainContent通过mainTabcurTab的双重索引路由到六个子页面:套系列表(pagePack)、摄影师列表(pageSnapper)、样片画廊(pageSample)、道具库(pageProp)、订单管理(pageOrder)和会员中心(pageMember)。build方法通过Stack将内容层(header+mainContent+bottomBar)和动画层(fxLayer)叠加,背景色为COLORS.bg暖白色。


十二、Mermaid架构流程图

mainTab=0

mainTab!=0

curTab=0

curTab=1

curTab=2

curTab=3

curTab=4

curTab=5

待拍摄

修图中

已取消

build 根渲染入口

Stack 布局层

Column 内容层

fxLayer 浮动动画层

header 静态渐变头部

mainContent 主内容区

bottomBar 底部导航栏

品牌标识行 - 相机emoji

搜索栏 - 搜套系/摄影师/样片

促销标签组 - 免应激/底片全送/全品类/重拍

tabBar 相机机身式Tab

mainTab 判断

curTab 判断

pageMainOther

pagePack 套系列表

pageSnapper 摄影师列表

pageSample 样片画廊

pageProp 道具库

pageOrder 订单管理

pageMember 会员中心

modalBodyAdd 预约拍摄

modalBodyEdit 拍摄计划

modalBodyBiz 加急出片

modalBodyDel 取消订单

modalOverlay 弹框覆盖层

上图展示了拾光机宠物摄影应用从build入口到各子页面的完整组件树结构。订单管理页面(pageOrder)的弹框路由最为复杂——根据订单状态的不同,分别关联拍摄计划弹框(待拍摄)、加急出片弹框(修图中)和取消订单弹框(已取消),只有已完成状态不触发弹框而是直接查看相册。这种基于订单状态的多分支弹框路由,是本应用交互设计的核心亮点。


十三、数据模型对比表

数据模型 接口名 可观测类名 核心字段 用途场景 弹框关联
拍摄套系 Pack PackItem name/price/mins/scenes/tag/hot 套系列表展示与预约 modalBodyAdd 预约拍摄
摄影师 Snapper SnapperItem name/style/score/orders/city/fav 摄影师列表与收藏 modalBodyEdit 拍摄计划
样片作品 Sample SampleItem title/pet/style/likes/liked 样片画廊与点赞 modalBodyAdd 预约拍摄
道具库 PropInface PropItem name/kind/stock/price 道具借用与库存 modalBodyBiz 加急出片
拍摄订单 Order OrderItem pack/pet/date/status/price 订单全流程管理 按状态分流弹框
组件名称 类型 参数 功能描述 选中态视觉变化
lensTab @Builder name/icon/idx 相机机身式内容Tab项 闪光灯变accent、机身变primary、镜头变accent
header @Builder 静态渐变头部(棕色调) 固定135度渐变,无选中态
fxLayer @Builder 宠物摄影主题emoji粒子动画 相机摇摆/小狗位移/爪印闪烁/魔法棒缩放
sectionTitle @Builder title/sub/act 分区标题与操作按钮 按钮accentSoft底色
modalOverlay @Builder 弹框统一覆盖层 底部对齐,半透明棕色调遮罩
bottomBar @Builder 底部五项导航栏 选中项文字变primary色
辅助函数 输入参数 返回值 业务逻辑 关联色彩
shotBar i: number number MONTH_SHOTS[i]*10
stockColor n: number string 0=danger, <3=warn, >=3=ok danger/warn/ok
statusColor s: string string 待拍摄=warn, 修图中=primary, 已完成=ok, 已取消=danger warn/primary/ok/danger
状态变量 类型 默认值 作用 关联弹框
retouchStep number 10 精修张数步进器(5-40,步进5) modalBodyAdd
petStep number 2 出镜宠物数步进器(1-5) modalBodyEdit
homeFlag boolean true 到府拍摄模式开关 modalBodyEdit
fastFlag boolean false 优先插队排期开关 modalBodyBiz
styleTags number[] [0] 拍摄风格多选标签 modalBodyAdd
dayTags number[] [0] 拍摄时段多选标签 modalBodyEdit
speedTags number[] [0] 出片时限多选标签 modalBodyBiz

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// 布局风格:相机机身式内容tab(单排6个,闪光灯+机身+镜头)+ 静态渐变头部
// 弹框:预约拍摄 / 拍摄计划 / 取消警示 / 加急出片
// ============================================================

interface ColorPalette {
  bg: string
  card: string
  white: string
  primary: string
  primaryDeep: string
  accent: string
  accentSoft: string
  gold: string
  textPrimary: string
  textSecond: string
  textThird: string
  line: string
  tagBg: string
  warn: string
  danger: string
  ok: string
}

const COLORS: ColorPalette = {
  bg: '#FAF6F2',
  card: '#FFFFFF',
  white: '#FFFFFF',
  primary: '#8D6E63',
  primaryDeep: '#5D4037',
  accent: '#FF8A80',
  accentSoft: '#FDEBE7',
  gold: '#E8A33D',
  textPrimary: '#4A342C',
  textSecond: '#8C7A70',
  textThird: '#BFAEA4',
  line: '#EEE5DD',
  tagBg: '#F4EDE6',
  warn: '#E65100',
  danger: '#C62828',
  ok: '#2E7D32'
}

interface Pack {
  id: number
  name: string
  price: number
  mins: number
  scenes: number
  tag: string
  hot: boolean
}

@Observed
class PackItem implements Pack {
  id: number = 0
  name: string = ''
  price: number = 0
  mins: number = 0
  scenes: number = 0
  tag: string = ''
  hot: boolean = false

  constructor(o: Pack) {
    this.id = o.id
    this.name = o.name
    this.price = o.price
    this.mins = o.mins
    this.scenes = o.scenes
    this.tag = o.tag
    this.hot = o.hot ? o.hot : false
  }
}

interface Snapper {
  id: number
  name: string
  style: string
  score: number
  orders: number
  city: string
  fav: boolean
}

@Observed
class SnapperItem implements Snapper {
  id: number = 0
  name: string = ''
  style: string = ''
  score: number = 0
  orders: number = 0
  city: string = ''
  fav: boolean = false

  constructor(o: Snapper) {
    this.id = o.id
    this.name = o.name
    this.style = o.style
    this.score = o.score
    this.orders = o.orders
    this.city = o.city
    this.fav = o.fav ? o.fav : false
  }
}

interface Sample {
  id: number
  title: string
  pet: string
  style: string
  likes: number
  liked: boolean
}

@Observed
class SampleItem implements Sample {
  id: number = 0
  title: string = ''
  pet: string = ''
  style: string = ''
  likes: number = 0
  liked: boolean = false

  constructor(o: Sample) {
    this.id = o.id
    this.title = o.title
    this.pet = o.pet
    this.style = o.style
    this.likes = o.likes
    this.liked = o.liked ? o.liked : false
  }
}

interface PropInface {
  id: number
  name: string
  kind: string
  stock: number
  price: number
}

@Observed
class PropItem implements PropInface {
  id: number = 0
  name: string = ''
  kind: string = ''
  stock: number = 0
  price: number = 0

  constructor(o: PropInface) {
    this.id = o.id
    this.name = o.name
    this.kind = o.kind
    this.stock = o.stock
    this.price = o.price
  }
}

interface Order {
  id: number
  pack: string
  pet: string
  date: string
  status: string
  price: number
}

@Observed
class OrderItem implements Order {
  id: number = 0
  pack: string = ''
  pet: string = ''
  date: string = ''
  status: string = ''
  price: number = 0

  constructor(o: Order) {
    this.id = o.id
    this.pack = o.pack
    this.pet = o.pet
    this.date = o.date
    this.status = o.status
    this.price = o.price
  }
}

const PACKS: PackItem[] = [
  new PackItem({ id: 1, name: '喵星人单人写真', price: 328, mins: 60, scenes: 2, tag: '含10张精修', hot: true }),
  new PackItem({ id: 2, name: '狗子奔跑跟拍', price: 388, mins: 75, scenes: 3, tag: '户外公园', hot: true }),
  new PackItem({ id: 3, name: '猫狗双全全家福', price: 528, mins: 90, scenes: 3, tag: '含相框摆件', hot: false }),
  new PackItem({ id: 4, name: '幼宠满月纪念', price: 298, mins: 50, scenes: 2, tag: '手印纪念卡', hot: true }),
  new PackItem({ id: 5, name: '上门到家随拍', price: 268, mins: 45, scenes: 1, tag: '免通勤应激', hot: false }),
  new PackItem({ id: 6, name: '宠物生日派对记录', price: 458, mins: 80, scenes: 3, tag: '含布景布置', hot: false }),
  new PackItem({ id: 7, name: '萌宠日历12宫格', price: 598, mins: 120, scenes: 4, tag: '年历成品册', hot: false }),
  new PackItem({ id: 8, name: '宠物告别纪念册', price: 666, mins: 90, scenes: 3, tag: '温情陪护', hot: false }),
  new PackItem({ id: 9, name: '异宠微距特写', price: 358, mins: 60, scenes: 2, tag: '爬宠鸟类', hot: false }),
  new PackItem({ id: 10, name: '遛狗跟拍月卡', price: 888, mins: 240, scenes: 6, tag: '4次上门', hot: false }),
  new PackItem({ id: 11, name: '古风宠物汉服', price: 488, mins: 85, scenes: 3, tag: '含3套服饰', hot: true }),
  new PackItem({ id: 12, name: '胶片质感套系', price: 428, mins: 70, scenes: 2, tag: '富士sp3000', hot: false })
]

const SNAPPERS: SnapperItem[] = [
  new SnapperItem({ id: 1, name: '阿茶', style: '日系清新', score: 4.9, orders: 686, city: '北京', fav: true }),
  new SnapperItem({ id: 2, name: '馒头爸', style: '户外跟拍', score: 4.9, orders: 520, city: '北京', fav: false }),
  new SnapperItem({ id: 3, name: 'Luna', style: '古风汉服', score: 4.8, orders: 342, city: '上海', fav: false }),
  new SnapperItem({ id: 4, name: '老白', style: '胶片质感', score: 4.7, orders: 288, city: '杭州', fav: false }),
  new SnapperItem({ id: 5, name: '豆花', style: '幼宠特写', score: 5.0, orders: 460, city: '成都', fav: true }),
  new SnapperItem({ id: 6, name: 'Kevin', style: '猫咪棚拍', score: 4.8, orders: 375, city: '深圳', fav: false }),
  new SnapperItem({ id: 7, name: '小满', style: '生日记录', score: 4.9, orders: 298, city: '北京', fav: false }),
  new SnapperItem({ id: 8, name: '叶子', style: '异宠微距', score: 4.6, orders: 156, city: '广州', fav: false }),
  new SnapperItem({ id: 9, name: '桃子', style: '全家福', score: 4.9, orders: 412, city: '北京', fav: false }),
  new SnapperItem({ id: 10, name: '阿岁', style: '告别纪念', score: 5.0, orders: 132, city: '上海', fav: false })
]

const SAMPLES: SampleItem[] = [
  new SampleItem({ id: 1, title: '窗边的橘猫午后', pet: '橘猫·大福', style: '日系', likes: 1286, liked: true }),
  new SampleItem({ id: 2, title: '草地上飞奔的柯基', pet: '柯基·面包', style: '跟拍', likes: 982, liked: false }),
  new SampleItem({ id: 3, title: '汉服布偶的春日', pet: '布偶·雪球', style: '古风', likes: 1154, liked: false }),
  new SampleItem({ id: 4, title: '满月奶猫初睁眼', pet: '奶猫·年糕', style: '特写', likes: 1560, liked: false }),
  new SampleItem({ id: 5, title: '柴犬的胶片夏天', pet: '柴犬·麻薯', style: '胶片', likes: 876, liked: false }),
  new SampleItem({ id: 6, title: '生日帽下的比熊', pet: '比熊·糯米', style: '记录', likes: 1043, liked: true }),
  new SampleItem({ id: 7, title: '守在门边的金毛', pet: '金毛·向阳', style: '纪实', likes: 1392, liked: false }),
  new SampleItem({ id: 8, title: '缸里的小乌龟', pet: '草龟·石头', style: '微距', likes: 542, liked: false }),
  new SampleItem({ id: 9, title: '一家三口猫狗同框', pet: '布偶&柯基', style: '全家福', likes: 1215, liked: false }),
  new SampleItem({ id: 10, title: '雪地里的萨摩耶', pet: '萨摩耶·汤圆', style: '跟拍', likes: 1478, liked: false })
]

const PROPS: PropItem[] = [
  new PropItem({ id: 1, name: '宠物汉服三件套', kind: '服饰', stock: 4, price: 68 }),
  new PropItem({ id: 2, name: '生日派对布景', kind: '布景', stock: 2, price: 128 }),
  new PropItem({ id: 3, name: '小清新草帽', kind: '服饰', stock: 8, price: 30 }),
  new PropItem({ id: 4, name: '英伦格子围巾', kind: '服饰', stock: 6, price: 35 }),
  new PropItem({ id: 5, name: '奶油色地毯', kind: '布景', stock: 3, price: 88 }),
  new PropItem({ id: 6, name: '玩具球道具组', kind: '道具', stock: 12, price: 25 }),
  new PropItem({ id: 7, name: '干花手捧花束', kind: '道具', stock: 0, price: 45 }),
  new PropItem({ id: 8, name: '复古皮质行李箱', kind: '布景', stock: 1, price: 158 })
]

const ORDERS: OrderItem[] = [
  new OrderItem({ id: 1, pack: '喵星人单人写真', pet: '大福', date: '周日 10:00', status: '待拍摄', price: 328 }),
  new OrderItem({ id: 2, pack: '满月奶猫初睁眼', pet: '年糕', date: '上周三 14:00', status: '修图中', price: 298 }),
  new OrderItem({ id: 3, pack: '狗子奔跑跟拍', pet: '面包', date: '上周日 16:00', status: '已完成', price: 388 }),
  new OrderItem({ id: 4, pack: '古风宠物汉服', pet: '雪球', date: '上上周六 09:30', status: '已完成', price: 488 }),
  new OrderItem({ id: 5, pack: '胶片质感套系', pet: '麻薯', date: '上上周日 15:00', status: '已取消', price: 428 }),
  new OrderItem({ id: 6, pack: '宠物生日派对记录', pet: '糯米', date: '下周六 11:00', status: '待拍摄', price: 458 }),
  new OrderItem({ id: 7, pack: '上门到家随拍', pet: '向阳', date: '上周五 10:00', status: '已完成', price: 268 }),
  new OrderItem({ id: 8, pack: '萌宠日历12宫格', pet: '汤圆', date: '7月20 13:00', status: '已完成', price: 598 })
]

const MONTH_LABELS: string[] = ['3月', '4月', '5月', '6月', '7月', '8月']
const MONTH_SHOTS: number[] = [2, 4, 3, 6, 5, 7]
const STYLE_LABELS: string[] = ['日系清新', '户外跟拍', '古风汉服', '胶片质感']
const STYLE_PCTS: number[] = [34, 28, 22, 16]
const STYLE_COLORS: string[] = ['#8D6E63', '#FF8A80', '#5D4037', '#E8A33D']
const STYLE_TAGS: string[] = ['日系', '古风', '胶片', '纪实']
const DAY_TAGS: string[] = ['工作日晚间', '周末白天', '周末晚间']
const SPEED_TAGS: string[] = ['24小时', '48小时', '72小时']
const ORDER_STATUS: string[] = ['全部', '待拍摄', '修图中', '已完成', '已取消']

function shotBar(i: number): number {
  return MONTH_SHOTS[i] * 10
}

function stockColor(n: number): string {
  if (n <= 0) {
    return COLORS.danger
  }
  if (n < 3) {
    return COLORS.warn
  }
  return COLORS.ok
}

function statusColor(s: string): string {
  if (s === '待拍摄') {
    return COLORS.warn
  }
  if (s === '修图中') {
    return COLORS.primary
  }
  if (s === '已完成') {
    return COLORS.ok
  }
  return COLORS.danger
}

@Entry
@Component
struct PagePetPhoto {
  @State curTab: number = 0
  @State mainTab: number = 0
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State bizOpen: boolean = false
  @State packList: PackItem[] = PACKS
  @State sampleList: SampleItem[] = SAMPLES
  @State snapperList: SnapperItem[] = SNAPPERS
  @State orderFilter: number = 0
  @State rev: number = 0
  @State tick: number = 0
  @State styleTags: number[] = [0]
  @State dayTags: number[] = [0]
  @State speedTags: number[] = [0]
  @State retouchStep: number = 10
  @State petStep: number = 2
  @State homeFlag: boolean = true
  @State fastFlag: boolean = false
  private timer: number = -1

  aboutToAppear(): void {
    this.timer = setInterval(() => {
      this.tick = this.tick + 1
    }, 106)
  }

  aboutToDisappear(): void {
    if (this.timer >= 0) {
      clearInterval(this.timer)
    }
  }

  switchTab(i: number): void {
    this.curTab = i
  }

  switchMain(i: number): void {
    this.mainTab = i
  }

  refreshPacks(): void {
    this.packList.unshift(this.packList[this.packList.length - 1])
    this.packList.splice(this.packList.length - 1, 1)
    this.rev = this.rev + 1
  }

  refreshSamples(): void {
    this.sampleList.unshift(this.sampleList[this.sampleList.length - 1])
    this.sampleList.splice(this.sampleList.length - 1, 1)
    this.rev = this.rev + 1
  }

  toggleLike(i: number): void {
    if (this.sampleList[i].liked) {
      this.sampleList[i].liked = false
      this.sampleList[i].likes = this.sampleList[i].likes - 1
    } else {
      this.sampleList[i].liked = true
      this.sampleList[i].likes = this.sampleList[i].likes + 1
    }
    this.rev = this.rev + 1
  }

  toggleSnapperFav(i: number): void {
    this.snapperList[i].fav = !this.snapperList[i].fav
    this.rev = this.rev + 1
  }

  toggleTag(list: number[], i: number): void {
    if (list.indexOf(i) >= 0) {
      list.splice(list.indexOf(i), 1)
    } else {
      list.push(i)
    }
  }

  orderShown(i: number): boolean {
    if (this.orderFilter === 0) {
      return true
    }
    return ORDERS[i].status === ORDER_STATUS[this.orderFilter]
  }

  closeAll(): void {
    this.addOpen = false
    this.editOpen = false
    this.delOpen = false
    this.bizOpen = false
  }

  photoTotal(): number {
    return 328 + this.retouchStep * 8
  }

  @Builder
  fxLayer() {
    Stack() {
      Text('📷')
        .fontSize(24)
        .rotate({ angle: (this.tick % 10) * 4 - 20 })
        .position({ x: 70, y: 170 })
        .opacity(0.8)
      Text('🐶')
        .fontSize(20)
        .translate({ x: this.tick % 26 - 13 })
        .position({ x: 330, y: 260 })
      Text('🐾')
        .fontSize(18)
        .opacity((this.tick % 6) / 6 + 0.2)
        .position({ x: 540, y: 150 })
      Text('🪄')
        .fontSize(20)
        .scale({ x: 1 + (this.tick % 8) * 0.04, y: 1 + (this.tick % 8) * 0.04 })
        .position({ x: 200, y: 380 })
        .opacity(0.6)
      Text('✨')
        .fontSize(14)
        .position({ x: 130, y: 330 })
        .opacity((this.tick % 7) / 7 + 0.15)
    }
    .width('100%')
    .height('100%')
    .hitTestBehavior(HitTestMode.None)
  }

  @Builder
  header() {
    Column() {
      Row() {
        Text('📷')
          .fontSize(24)
        Text('拾光机')
          .fontSize(21)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: 8 })
        Column()
          .layoutWeight(1)
        Text('📍上门拍摄 · 师傅1小时到')
          .fontSize(12)
          .fontColor('#EBD9C8')
        Text('🔔')
          .fontSize(19)
          .margin({ left: 12 })
          .onClick(() => {
            this.mainTab = 3
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 12 })

      Row() {
        Text('🔍')
          .fontSize(15)
        Text('搜套系 / 摄影师 / 样片')
          .fontSize(13)
          .fontColor('#D9C3B0')
          .margin({ left: 8 })
        Column()
          .layoutWeight(1)
        Text('搜索')
          .fontSize(13)
          .fontColor(COLORS.textPrimary)
          .padding({ left: 14, right: 14, top: 6, bottom: 6 })
          .backgroundColor(COLORS.accent)
          .borderRadius(14)
          .onClick(() => {
            this.curTab = 0
          })
      }
      .width('100%')
      .padding({ left: 10, right: 10 })
      .margin({ top: 10 })
      .backgroundColor('rgba(255,255,255,0.18)')
      .borderRadius(20)

      Row({ space: 8 }) {
        Text('免通勤应激')
          .fontSize(11)
          .fontColor(COLORS.white)
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .backgroundColor('rgba(255,255,255,0.22)')
          .borderRadius(12)
        Text('底片全送')
          .fontSize(11)
          .fontColor(COLORS.white)
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .backgroundColor('rgba(255,255,255,0.22)')
          .borderRadius(12)
        Text('猫狗鸟爬虫都拍')
          .fontSize(11)
          .fontColor(COLORS.white)
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .backgroundColor('rgba(255,255,255,0.22)')
          .borderRadius(12)
        Text('不满意重拍')
          .fontSize(11)
          .fontColor(COLORS.white)
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .backgroundColor('rgba(255,255,255,0.22)')
          .borderRadius(12)
      }
      .width('100%')
      .padding({ left: 16, right: 16 })
      .margin({ top: 10, bottom: 14 })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
    .linearGradient({ angle: 135, colors: [[COLORS.primary, 0], [COLORS.primaryDeep, 1]] })
  }

  @Builder
  lensTab(name: string, icon: string, idx: number) {
    Column() {
      Rect({ width: 8, height: 3 })
        .fill(this.curTab === idx ? COLORS.accent : COLORS.line)
        .radius(2)
        .margin({ bottom: 1 })
      Stack() {
        Rect({ width: 26, height: 16 })
          .fill(this.curTab === idx ? COLORS.primary : COLORS.line)
          .radius(4)
        Circle({ width: 12, height: 12 })
          .fill(this.curTab === idx ? COLORS.accent : COLORS.tagBg)
        Text(icon)
          .fontSize(8)
      }
      Text(name)
        .fontSize(11)
        .fontWeight(this.curTab === idx ? FontWeight.Bold : FontWeight.Normal)
        .fontColor(this.curTab === idx ? COLORS.primary : COLORS.textSecond)
        .margin({ top: 3 })
    }
    .width('15.5%')
    .alignItems(HorizontalAlign.Center)
    .padding({ top: 8, bottom: 8 })
    .backgroundColor(this.curTab === idx ? COLORS.card : COLORS.bg)
    .borderRadius(12)
    .margin({ top: 6 })
    .onClick(() => {
      this.switchTab(idx)
    })
  }

  @Builder
  tabBar() {
    Row({ space: 4 }) {
      this.lensTab('套系', '🎞', 0)
      this.lensTab('摄影师', '🧑‍🎨', 1)
      this.lensTab('样片', '🖼', 2)
      this.lensTab('道具', '🎈', 3)
      this.lensTab('订单', '🧾', 4)
      this.lensTab('会员', '👑', 5)
    }
    .width('100%')
    .padding({ left: 6, right: 6, top: 10 })
  }

  @Builder
  sectionTitle(title: string, sub: string, act: number) {
    Row() {
      Column() {
        Text(title)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Rect({ width: 22, height: 3 })
          .fill(COLORS.accent)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      Column()
        .layoutWeight(1)
      Text(sub)
        .fontSize(11)
        .fontColor(COLORS.primary)
        .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        .backgroundColor(COLORS.accentSoft)
        .borderRadius(10)
        .onClick(() => {
          if (act === 0) {
            this.refreshPacks()
          } else if (act === 1) {
            this.refreshSamples()
          } else {
            this.addOpen = true
          }
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 8, bottom: 6 })
  }

  @Builder
  pagePack() {
    Column() {
      Row() {
        Column() {
          Text('本周末档期 · 上门拍猫狗')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('到府布光 · 60分钟 · 底片全送+10张精修')
            .fontSize(11)
            .fontColor('#EBD9C8')
            .margin({ top: 4 })
          Text('新客价 ¥288')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.gold)
            .margin({ top: 6 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('🐾')
          .fontSize(38)
          .margin({ right: 12 })
      }
      .width('100%')
      .padding(14)
      .borderRadius(14)
      .linearGradient({ angle: 120, colors: [[COLORS.primaryDeep, 0], [COLORS.accent, 1]] })
      .onClick(() => {
        this.addOpen = true
      })

      this.sectionTitle('人气套系', '换一批', 0)
      ForEach(this.packList, (it: PackItem, i: number) => {
        Row() {
          Stack() {
            Column()
              .width(52)
              .height(52)
              .backgroundColor(it.hot ? COLORS.accentSoft : COLORS.tagBg)
              .borderRadius(10)
            Column() {
              Text(it.mins.toString())
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.primary)
              Text('分钟')
                .fontSize(9)
                .fontColor(COLORS.textSecond)
            }
          }
          Column() {
            Row() {
              Text(it.name)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
              Text(it.hot ? '🔥热卖' : '')
                .fontSize(9)
                .fontColor(COLORS.danger)
                .margin({ left: 6 })
            }
            Text(it.scenes.toString() + '个场景 · ' + it.tag + ' · 可加急出片')
              .fontSize(10)
              .fontColor(COLORS.textThird)
              .margin({ top: 4 })
            Text('¥' + it.price)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.primary)
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 12 })
          Text('预约')
            .fontSize(12)
            .fontColor(COLORS.white)
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .backgroundColor(COLORS.primary)
            .borderRadius(16)
            .onClick(() => {
              this.addOpen = true
            })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .margin({ left: 16, right: 16, top: 8 })
      }, (it: PackItem) => 'pa' + it.id.toString() + '_' + this.rev.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  @Builder
  pageSnapper() {
    Column() {
      this.sectionTitle('签约宠物摄影师', '档期实时', 2)
      ForEach(this.snapperList, (it: SnapperItem, i: number) => {
        Row() {
          Stack() {
            Circle({ width: 46, height: 46 })
              .fill(COLORS.accentSoft)
            Text('🧑‍🎨')
              .fontSize(22)
          }
          Column() {
            Row() {
              Text(it.name)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
              Text(it.style)
                .fontSize(9)
                .fontColor(COLORS.white)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .backgroundColor(COLORS.primary)
                .borderRadius(8)
                .margin({ left: 8 })
            }
            Text(it.orders + '单拍摄 · 常驻' + it.city)
              .fontSize(11)
              .fontColor(COLORS.textSecond)
              .margin({ top: 3 })
            Text('⭐' + it.score + ' · 本周还可约3个档期')
              .fontSize(10)
              .fontColor(COLORS.gold)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 12 })
          Column() {
            Text(it.fav ? '❤️' : '🤍')
              .fontSize(15)
              .onClick(() => {
                this.toggleSnapperFav(i)
              })
            Text('约拍')
              .fontSize(11)
              .fontColor(COLORS.white)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .backgroundColor(COLORS.accent)
              .borderRadius(12)
              .margin({ top: 6 })
              .onClick(() => {
                this.editOpen = true
              })
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .margin({ left: 16, right: 16, top: 8 })
        .onClick(() => {
          this.editOpen = true
        })
      }, (it: SnapperItem) => 'sn' + it.id.toString() + '_' + this.rev.toString())

      Column() {
        Text('风格占比')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Row() {
          ForEach(STYLE_PCTS, (p: number, i: number) => {
            Column()
              .layoutWeight(p)
              .height(12)
              .backgroundColor(STYLE_COLORS[i])
          }, (p: number, i: number) => 'spc' + i.toString())
        }
        .width('100%')
        .borderRadius(6)
        .clip(true)
        .margin({ top: 10 })
        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(STYLE_LABELS, (l: string, i: number) => {
            Row() {
              Circle({ width: 7, height: 7 })
                .fill(STYLE_COLORS[i])
              Text(l + ' ' + STYLE_PCTS[i] + '%')
                .fontSize(10)
                .fontColor(COLORS.textSecond)
                .margin({ left: 4 })
            }
            .margin({ right: 12, top: 8 })
          }, (l: string) => 'sll' + l)
        }
        .margin({ top: 2 })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.card)
      .borderRadius(12)
      .margin({ left: 16, right: 16, top: 12 })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  @Builder
  pageSample() {
    Column() {
      this.sectionTitle('本周样片精选', '换一批', 1)
      Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
        ForEach(this.sampleList, (it: SampleItem, i: number) => {
          Column() {
            Stack() {
              Column()
                .width('100%')
                .height(86)
                .backgroundColor(COLORS.tagBg)
                .borderRadius(10)
              Text('🖼')
                .fontSize(30)
            }
            .width('100%')
            Row() {
              Column() {
                Text(it.title)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(it.pet + ' · ' + it.style + '风')
                  .fontSize(10)
                  .fontColor(COLORS.textThird)
                  .margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              Column() {
                Text(it.liked ? '❤️' : '🤍')
                  .fontSize(14)
                  .onClick(() => {
                    this.toggleLike(i)
                  })
                Text(it.likes.toString())
                  .fontSize(9)
                  .fontColor(COLORS.textSecond)
              }
              .alignItems(HorizontalAlign.End)
              .margin({ left: 4 })
            }
            .width('100%')
            .margin({ top: 6 })
          }
          .width('48.5%')
          .alignItems(HorizontalAlign.Start)
          .padding(8)
          .backgroundColor(COLORS.card)
          .borderRadius(12)
          .margin({ top: 8 })
          .onClick(() => {
            this.addOpen = true
          })
        }, (it: SampleItem) => 'sm' + it.id.toString() + '_' + this.rev.toString())
      }
      .width('94%')

      Column() {
        Text('近6月拍摄单量')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Row({ space: 12 }) {
          ForEach(MONTH_LABELS, (m: string, i: number) => {
            Column() {
              Text(MONTH_SHOTS[i].toString())
                .fontSize(9)
                .fontColor(COLORS.textThird)
              Column()
                .width(16)
                .height(shotBar(i))
                .backgroundColor(i === 5 ? COLORS.accent : COLORS.primary)
                .borderRadius(4)
                .opacity(i === 5 ? 1 : 0.5)
                .margin({ top: 3 })
              Text(m)
                .fontSize(10)
                .fontColor(COLORS.textSecond)
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Center)
          }, (m: string) => 'shom' + m)
        }
        .margin({ top: 10 })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.card)
      .borderRadius(12)
      .margin({ left: 16, right: 16, top: 12 })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  @Builder
  pageProp() {
    Column() {
      this.sectionTitle('免费道具库', '到府携带', 2)
      ForEach(PROPS, (it: PropItem, i: number) => {
        Row() {
          Stack() {
            Column()
              .width(46)
              .height(46)
              .backgroundColor(COLORS.tagBg)
              .borderRadius(8)
            Text('🎈')
              .fontSize(22)
          }
          Column() {
            Row() {
              Text(it.name)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
              Text(it.kind)
                .fontSize(9)
                .fontColor(COLORS.primary)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .backgroundColor(COLORS.accentSoft)
                .borderRadius(8)
                .margin({ left: 8 })
            }
            Text(it.stock > 0 ? '剩余 ' + it.stock + ' 件 · 可随单借用' : '本周借完 · 下周补货')
              .fontSize(10)
              .fontColor(stockColor(it.stock))
              .margin({ top: 3 })
            Text('单租 ¥' + it.price + ' / 拍摄套餐免费')
              .fontSize(10)
              .fontColor(COLORS.textThird)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 12 })
          Text(it.stock > 0 ? '借用' : '缺货')
            .fontSize(12)
            .fontColor(COLORS.white)
            .padding({ left: 12, right: 12, top: 7, bottom: 7 })
            .backgroundColor(it.stock > 0 ? COLORS.primary : COLORS.textThird)
            .borderRadius(14)
            .onClick(() => {
              if (it.stock > 0) {
                this.bizOpen = true
              }
            })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        .margin({ left: 16, right: 16, top: 8 })
      }, (it: PropItem) => 'pr' + it.id.toString())

      Column() {
        Text('道具借用须知')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Text('① 汉服类需提前1天预约量体尺寸')
          .fontSize(11)
          .fontColor(COLORS.textSecond)
          .margin({ top: 6 })
        Text('② 布景道具由摄影师上门安装并回收')
          .fontSize(11)
          .fontColor(COLORS.textSecond)
          .margin({ top: 4 })
        Text('③ 宠物应激可随时撤除道具,不加收费用')
          .fontSize(11)
          .fontColor(COLORS.textSecond)
          .margin({ top: 4 })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.card)
      .borderRadius(12)
      .margin({ left: 16, right: 16, top: 12 })
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  @Builder
  pageOrder() {
    Column() {
      this.sectionTitle('拍摄订单', '全流程可查', 2)
      Row({ space: 6 }) {
        ForEach(ORDER_STATUS, (f: string, i: number) => {
          Text(f)
            .fontSize(11)
            .fontColor(this.orderFilter === i ? COLORS.white : COLORS.textSecond)
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor(this.orderFilter === i ? COLORS.primary : COLORS.card)
            .borderRadius(14)
            .onClick(() => {
              this.orderFilter = i
              this.rev = this.rev + 1
            })
        }, (f: string) => 'os' + f)
      }
      .width('100%')
      .padding({ left: 16, right: 16 })
      .margin({ top: 2 })

      ForEach(ORDERS, (it: OrderItem, i: number) => {
        if (this.orderShown(i)) {
          Row() {
            Column() {
              Text(it.pack)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
              Text('毛孩子:' + it.pet)
                .fontSize(11)
                .fontColor(COLORS.textSecond)
                .margin({ top: 3 })
              Text(it.date + ' · ¥' + it.price)
                .fontSize(10)
                .fontColor(COLORS.textThird)
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column() {
              Text(it.status)
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(statusColor(it.status))
              if (it.status === '待拍摄') {
                Text('改期')
                  .fontSize(10)
                  .fontColor(COLORS.warn)
                  .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                  .backgroundColor(COLORS.accentSoft)
                  .borderRadius(10)
                  .margin({ top: 5 })
                  .onClick(() => {
                    this.editOpen = true
                  })
              } else if (it.status === '修图中') {
                Text('加急')
                  .fontSize(10)
                  .fontColor(COLORS.danger)
                  .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                  .backgroundColor('#FDECEA')
                  .borderRadius(10)
                  .margin({ top: 5 })
                  .onClick(() => {
                    this.bizOpen = true
                  })
              } else if (it.status === '已取消') {
                Text('删除')
                  .fontSize(10)
                  .fontColor(COLORS.danger)
                  .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                  .backgroundColor('#FDECEA')
                  .borderRadius(10)
                  .margin({ top: 5 })
                  .onClick(() => {
                    this.delOpen = true
                  })
              } else {
                Text('相册')
                  .fontSize(10)
                  .fontColor(COLORS.textSecond)
                  .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                  .backgroundColor(COLORS.tagBg)
                  .borderRadius(10)
                  .margin({ top: 5 })
              }
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.card)
          .borderRadius(12)
          .margin({ left: 16, right: 16, top: 8 })
          .onClick(() => {
            if (it.status === '待拍摄') {
              this.delOpen = true
            }
          })
        }
      }, (it: OrderItem) => 'od' + it.id.toString() + '_' + this.orderFilter.toString() + '_' + this.rev.toString())
    }
    .width('100%')
    .padding({ bottom: 12 })
  }

  @Builder
  pageMember() {
    Column() {
      Row() {
        Column() {
          Text('拾光会员 · 爪印卡')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('再拍1单升级「铲屎官尊享」9.5折')
            .fontSize(11)
            .fontColor('#EBD9C8')
            .margin({ top: 4 })
          Row() {
            Column()
              .width(120)
              .height(6)
              .backgroundColor('rgba(255,255,255,0.24)')
              .borderRadius(3)
            Column()
              .width(102)
              .height(6)
              .backgroundColor(COLORS.gold)
              .borderRadius(3)
          }
          .margin({ top: 8 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('👑')
          .fontSize(38)
      }
      .width('100%')
      .padding(16)
      .borderRadius(16)
      .linearGradient({ angle: 120, colors: [[COLORS.primaryDeep, 0], [COLORS.primary, 1]] })
      .margin({ top: 8 })

      Row({ space: 8 }) {
        Column() {
          Text('27单')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
          Text('累计拍摄')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(10)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        Column() {
          Text('4只')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
          Text('家庭成员')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(10)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
        Column() {
          Text('214G')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.gold)
          Text('云端相册')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(10)
        .backgroundColor(COLORS.card)
        .borderRadius(12)
      }
      .width('100%')
      .margin({ top: 12 })

      Column() {
        Text('会员权益')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Row() {
          Text('🎞')
            .fontSize(20)
          Column() {
            Text('每月1张8折拍摄券')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('全套系通用 · 与新客价不同享')
              .fontSize(10)
              .fontColor(COLORS.textThird)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 10 })
          Text('领取')
            .fontSize(11)
            .fontColor(COLORS.white)
            .padding({ left: 12, right: 12, top: 5, bottom: 5 })
            .backgroundColor(COLORS.primary)
            .borderRadius(12)
            .onClick(() => {
              this.addOpen = true
            })
        }
        .width('100%')
        .margin({ top: 10 })
        Row() {
          Text('☁️')
            .fontSize(20)
          Column() {
            Text('云端相册无限扩容')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('原片永久保存 · 一键分享家人')
              .fontSize(10)
              .fontColor(COLORS.textThird)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 10 })
          Text('详情')
            .fontSize(11)
            .fontColor(COLORS.primary)
            .padding({ left: 12, right: 12, top: 5, bottom: 5 })
            .backgroundColor(COLORS.accentSoft)
            .borderRadius(12)
            .onClick(() => {
              this.editOpen = true
            })
        }
        .width('100%')
        .margin({ top: 10 })
        Row() {
          Text('📸')
            .fontSize(20)
          Column() {
            Text('生日月免费加拍')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
            Text('毛孩子生日当月送15分钟跟拍')
              .fontSize(10)
              .fontColor(COLORS.textThird)
              .margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          .margin({ left: 10 })
          Text('开通')
            .fontSize(11)
            .fontColor(COLORS.textPrimary)
            .padding({ left: 12, right: 12, top: 5, bottom: 5 })
            .backgroundColor(COLORS.accent)
            .borderRadius(12)
            .onClick(() => {
              this.bizOpen = true
            })
        }
        .width('100%')
        .margin({ top: 10 })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.card)
      .borderRadius(12)
      .margin({ top: 10 })
      .alignItems(HorizontalAlign.Start)

      Text('注销会员卡')
        .fontSize(12)
        .fontColor(COLORS.danger)
        .padding({ left: 16, right: 16, top: 8, bottom: 8 })
        .backgroundColor('#FDECEA')
        .borderRadius(16)
        .margin({ top: 14 })
        .onClick(() => {
          this.delOpen = true
        })
    }
    .width('100%')
    .padding({ left: 16, right: 16, bottom: 12 })
  }

  @Builder
  pageMainOther() {
    Column() {
      Text('📷')
        .fontSize(40)
        .margin({ top: 60 })
      Text('我的待拍订单')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .margin({ top: 10 })
      Text('喵星人单人写真 · 大福')
        .fontSize(12)
        .fontColor(COLORS.textSecond)
        .margin({ top: 6 })
      Text('周日 10:00 上门 · 摄影师阿茶')
        .fontSize(12)
        .fontColor(COLORS.ok)
        .margin({ top: 4 })
      Text('取消订单')
        .fontSize(12)
        .fontColor(COLORS.danger)
        .padding({ left: 16, right: 16, top: 8, bottom: 8 })
        .backgroundColor('#FDECEA')
        .borderRadius(16)
        .margin({ top: 12 })
        .onClick(() => {
          this.delOpen = true
        })
    }
    .width('100%')
    .alignItems(HorizontalAlign.Center)
  }

  @Builder
  modalBodyAdd() {
    Column() {
      Row() {
        Text('📷 预约拍摄 PHOTO ORDER')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Column()
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.textThird)
          .onClick(() => {
            this.addOpen = false
          })
      }
      .width('100%')

      Text('拍摄风格(可多选)')
        .fontSize(13)
        .fontColor(COLORS.textSecond)
        .margin({ top: 14 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(STYLE_TAGS, (s: string, i: number) => {
          Text(s)
            .fontSize(12)
            .fontColor(this.styleTags.indexOf(i) >= 0 ? COLORS.white : COLORS.primary)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(this.styleTags.indexOf(i) >= 0 ? COLORS.primary : COLORS.accentSoft)
            .borderRadius(14)
            .margin({ right: 8, top: 8 })
            .onClick(() => {
              this.toggleTag(this.styleTags, i)
            })
        }, (s: string) => 'stt' + s)
      }
      .width('100%')
      .margin({ top: 4 })

      Text('精修张数')
        .fontSize(13)
        .fontColor(COLORS.textSecond)
        .margin({ top: 16 })
      Row() {
        Text('-')
          .fontSize(16)
          .fontColor(COLORS.textSecond)
          .padding(10)
          .backgroundColor(COLORS.tagBg)
          .borderRadius(10)
          .onClick(() => {
            if (this.retouchStep > 5) {
              this.retouchStep = this.retouchStep - 5
            }
          })
        Column() {
          Text(this.retouchStep.toString() + ' 张')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        Text('+')
          .fontSize(16)
          .fontColor(COLORS.white)
          .padding(10)
          .backgroundColor(COLORS.primary)
          .borderRadius(10)
          .onClick(() => {
            if (this.retouchStep < 40) {
              this.retouchStep = this.retouchStep + 5
            }
          })
      }
      .width('100%')
      .margin({ top: 8 })

      Text('底片全送 · 拍摄不满意免费重拍一次')
        .fontSize(11)
        .fontColor(COLORS.textThird)
        .margin({ top: 8 })

      Row() {
        Text('去支付')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 20, right: 20, top: 10, bottom: 10 })
          .backgroundColor(COLORS.primary)
          .borderRadius(20)
          .onClick(() => {
            this.addOpen = false
          })
        Column()
          .layoutWeight(1)
        Text('合计 ¥' + this.photoTotal())
          .fontSize(13)
          .fontColor(COLORS.gold)
      }
      .width('100%')
      .margin({ top: 20, bottom: 20 })
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 16 })
    .backgroundColor(COLORS.card)
    .borderRadius(20)
    .constraintSize({ maxHeight: '80%' })
  }

  @Builder
  modalBodyEdit() {
    Column() {
      Row() {
        Text('🗓 拍摄计划 SHOT PLAN')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Column()
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.textThird)
          .onClick(() => {
            this.editOpen = false
          })
      }
      .width('100%')

      Text('拍摄时段(可多选)')
        .fontSize(13)
        .fontColor(COLORS.textSecond)
        .margin({ top: 14 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(DAY_TAGS, (t: string, i: number) => {
          Text(t)
            .fontSize(12)
            .fontColor(this.dayTags.indexOf(i) >= 0 ? COLORS.white : COLORS.accent)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(this.dayTags.indexOf(i) >= 0 ? COLORS.accent : COLORS.accentSoft)
            .borderRadius(14)
            .margin({ right: 8, top: 8 })
            .onClick(() => {
              this.toggleTag(this.dayTags, i)
            })
        }, (t: string) => 'dt' + t)
      }
      .width('100%')
      .margin({ top: 4 })

      Text('出镜宠物数')
        .fontSize(13)
        .fontColor(COLORS.textSecond)
        .margin({ top: 16 })
      Row() {
        Text('-')
          .fontSize(16)
          .fontColor(COLORS.textSecond)
          .padding(10)
          .backgroundColor(COLORS.tagBg)
          .borderRadius(10)
          .onClick(() => {
            if (this.petStep > 1) {
              this.petStep = this.petStep - 1
            }
          })
        Column() {
          Text(this.petStep.toString() + ' 只')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        Text('+')
          .fontSize(16)
          .fontColor(COLORS.white)
          .padding(10)
          .backgroundColor(COLORS.accent)
          .borderRadius(10)
          .onClick(() => {
            if (this.petStep < 5) {
              this.petStep = this.petStep + 1
            }
          })
      }
      .width('100%')
      .margin({ top: 8 })

      Row() {
        Column() {
          Text('到府拍摄模式')
            .fontSize(14)
            .fontColor(COLORS.textPrimary)
          Text('免通勤应激 · 自有灯光布景上门')
            .fontSize(11)
            .fontColor(COLORS.textThird)
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text(this.homeFlag ? '已开启' : '已关闭')
          .fontSize(12)
          .fontColor(this.homeFlag ? COLORS.white : COLORS.textSecond)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor(this.homeFlag ? COLORS.primary : COLORS.tagBg)
          .borderRadius(14)
          .onClick(() => {
            this.homeFlag = !this.homeFlag
          })
      }
      .width('100%')
      .margin({ top: 16 })
      .padding(12)
      .backgroundColor(COLORS.tagBg)
      .borderRadius(12)

      Row() {
        Text('保存计划')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 20, right: 20, top: 10, bottom: 10 })
          .backgroundColor(COLORS.primary)
          .borderRadius(20)
          .onClick(() => {
            this.editOpen = false
          })
        Column()
          .layoutWeight(1)
        Text('已选 ' + this.dayTags.length + ' 个时段')
          .fontSize(11)
          .fontColor(COLORS.textThird)
      }
      .width('100%')
      .margin({ top: 20, bottom: 20 })
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 16 })
    .backgroundColor(COLORS.card)
    .borderRadius(20)
    .constraintSize({ maxHeight: '80%' })
  }

  @Builder
  modalBodyDel() {
    Column() {
      Row() {
        Text('⚠️ 取消订单确认')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.danger)
        Column()
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.textThird)
          .onClick(() => {
            this.delOpen = false
          })
      }
      .width('100%')

      Column() {
        Text('📷')
          .fontSize(34)
        Text('周日10点上门拍摄即将取消')
          .fontSize(13)
          .fontColor(COLORS.textPrimary)
          .margin({ top: 8 })
        Text('开拍前24小时外取消免收损失费')
          .fontSize(11)
          .fontColor(COLORS.warn)
          .margin({ top: 6 })
      }
      .width('100%')
      .alignItems(HorizontalAlign.Center)
      .padding({ top: 18, bottom: 18 })
      .backgroundColor('#FDECEA')
      .borderRadius(14)
      .margin({ top: 14 })

      Row({ space: 8 }) {
        Column() {
          Text('1单')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.primary)
          Text('取消订单')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(8)
        .backgroundColor(COLORS.tagBg)
        .borderRadius(10)
        Column() {
          Text('¥328')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.warn)
          Text('已付金额')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(8)
        .backgroundColor(COLORS.tagBg)
        .borderRadius(10)
        Column() {
          Text('¥0')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.ok)
          Text('取消费用')
            .fontSize(10)
            .fontColor(COLORS.textSecond)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .padding(8)
        .backgroundColor(COLORS.tagBg)
        .borderRadius(10)
      }
      .width('100%')
      .margin({ top: 12 })

      Row() {
        Text('再想想')
          .fontSize(14)
          .fontColor(COLORS.textSecond)
          .padding({ left: 20, right: 20, top: 10, bottom: 10 })
          .backgroundColor(COLORS.tagBg)
          .borderRadius(20)
          .onClick(() => {
            this.delOpen = false
          })
        Column()
          .layoutWeight(1)
        Text('确认取消')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 20, right: 20, top: 10, bottom: 10 })
          .backgroundColor(COLORS.danger)
          .borderRadius(20)
          .onClick(() => {
            this.delOpen = false
          })
      }
      .width('100%')
      .margin({ top: 18, bottom: 20 })
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 16 })
    .backgroundColor(COLORS.card)
    .borderRadius(20)
    .constraintSize({ maxHeight: '80%' })
  }

  @Builder
  modalBodyBiz() {
    Column() {
      Row() {
        Text('⚡ 加急出片 RUSH RETOUCH')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Column()
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.textThird)
          .onClick(() => {
            this.bizOpen = false
          })
      }
      .width('100%')

      Row() {
        Column() {
          Text('修图加急 · 朋友圈先发')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('排队中第3位 · 预计明日出图')
            .fontSize(11)
            .fontColor('#EBD9C8')
            .margin({ top: 4 })
          Text('加急 ¥49起')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.gold)
            .margin({ top: 6 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('⚡')
          .fontSize(36)
      }
      .width('100%')
      .padding(14)
      .borderRadius(14)
      .linearGradient({ angle: 120, colors: [[COLORS.primaryDeep, 0], [COLORS.accent, 1]] })
      .margin({ top: 14 })

      Text('期望出片时限')
        .fontSize(13)
        .fontColor(COLORS.textSecond)
        .margin({ top: 14 })
      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(SPEED_TAGS, (s: string, i: number) => {
          Text(s)
            .fontSize(12)
            .fontColor(this.speedTags.indexOf(i) >= 0 ? COLORS.white : COLORS.primary)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(this.speedTags.indexOf(i) >= 0 ? COLORS.primary : COLORS.accentSoft)
            .borderRadius(14)
            .margin({ right: 8, top: 8 })
            .onClick(() => {
              this.toggleTag(this.speedTags, i)
            })
        }, (s: string) => 'spt' + s)
      }
      .width('100%')
      .margin({ top: 4 })

      Row() {
        Column() {
          Text('优先插队排期')
            .fontSize(14)
            .fontColor(COLORS.textPrimary)
          Text('资深修图师优先处理您的订单')
            .fontSize(11)
            .fontColor(COLORS.textThird)
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text(this.fastFlag ? '已开启' : '已关闭')
          .fontSize(12)
          .fontColor(this.fastFlag ? COLORS.white : COLORS.textSecond)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .backgroundColor(this.fastFlag ? COLORS.primary : COLORS.tagBg)
          .borderRadius(14)
          .onClick(() => {
            this.fastFlag = !this.fastFlag
          })
      }
      .width('100%')
      .margin({ top: 16 })
      .padding(12)
      .backgroundColor(COLORS.tagBg)
      .borderRadius(12)

      Row() {
        Text('支付加急')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 20, right: 20, top: 10, bottom: 10 })
          .backgroundColor(COLORS.primary)
          .borderRadius(20)
          .onClick(() => {
            this.bizOpen = false
          })
        Column()
          .layoutWeight(1)
        Text('加急费 ¥49')
          .fontSize(13)
          .fontColor(COLORS.gold)
      }
      .width('100%')
      .margin({ top: 20, bottom: 20 })
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 16 })
    .backgroundColor(COLORS.card)
    .borderRadius(20)
    .constraintSize({ maxHeight: '80%' })
  }

  @Builder
  modalOverlay() {
    Stack({ alignContent: Alignment.Bottom }) {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(62,42,32,0.5)')
        .onClick(() => {
          this.closeAll()
        })
      if (this.addOpen) {
        this.modalBodyAdd()
      }
      if (this.editOpen) {
        this.modalBodyEdit()
      }
      if (this.delOpen) {
        this.modalBodyDel()
      }
      if (this.bizOpen) {
        this.modalBodyBiz()
      }
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  bottomBar() {
    Row() {
      Column() {
        Text('🏠')
          .fontSize(20)
        Text('首页')
          .fontSize(10)
          .fontColor(this.mainTab === 0 ? COLORS.primary : COLORS.textThird)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.switchMain(0)
      })
      Column() {
        Text('🧾')
          .fontSize(20)
        Text('订单')
          .fontSize(10)
          .fontColor(this.mainTab === 1 ? COLORS.primary : COLORS.textThird)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.switchMain(1)
      })
      Column() {
        Text('➕')
          .fontSize(24)
          .fontColor(COLORS.white)
          .padding(10)
          .backgroundColor(COLORS.primary)
          .borderRadius(26)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.addOpen = true
      })
      Column() {
        Text('💬')
          .fontSize(20)
        Text('消息')
          .fontSize(10)
          .fontColor(this.mainTab === 3 ? COLORS.primary : COLORS.textThird)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.switchMain(3)
      })
      Column() {
        Text('👤')
          .fontSize(20)
        Text('我的')
          .fontSize(10)
          .fontColor(this.mainTab === 4 ? COLORS.primary : COLORS.textThird)
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .onClick(() => {
        this.switchMain(4)
      })
    }
    .width('100%')
    .padding({ top: 6, bottom: 6 })
    .backgroundColor(COLORS.card)
  }

  @Builder
  mainContent() {
    Scroll() {
      Column() {
        this.tabBar()
        if (this.mainTab === 0) {
          if (this.curTab === 0) {
            this.pagePack()
          }
          if (this.curTab === 1) {
            this.pageSnapper()
          }
          if (this.curTab === 2) {
            this.pageSample()
          }
          if (this.curTab === 3) {
            this.pageProp()
          }
          if (this.curTab === 4) {
            this.pageOrder()
          }
          if (this.curTab === 5) {
            this.pageMember()
          }
        } else {
          this.pageMainOther()
        }
      }
      .width('100%')
    }
    .width('100%')
    .layoutWeight(1)
    .scrollBar(BarState.Off)
    .edgeEffect(EdgeEffect.Spring)
  }

  build() {
    Stack() {
      Column() {
        this.header()
        this.mainContent()
        this.bottomBar()
      }
      .width('100%')
      .height('100%')
      this.fxLayer()
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}

在这里插入图片描述


结语

通过对拾光机上门宠物摄影与写真应用的全面深度解析,我们可以清晰地看到HarmonyOS 6.1.1 ArkTS API 24在构建情感驱动型本地生活服务场景中的技术优势和应用价值。从ColorPalette棕色暖调配色体系到@Observed可观测数据模型的响应式更新,从相机机身式内容Tab的创意交互到四种弹框的完整预约-拍摄-出片闭环,整个应用展现了ArkTS声明式UI编程在宠物摄影垂直领域的系统化实现方案。

在数据架构层面,PackSnapperSamplePropInfaceOrder五个接口与对应的PackItem等可观测类构成的"接口契约+可观测实现"模式,为拍摄套系、摄影师、样片、道具和订单提供了类型安全且响应式的数据管理方案。特别值得关注的是Sample接口的toggleLike方法——同时修改liked布尔状态和likes数值,这种"状态+数值"双更新模式在社交媒体类应用中极为常见,而@Observed的批量更新机制确保了双属性变更的原子性,避免了界面闪烁。PropInface接口的命名也体现了开发中对ArkTS语言保留字的细致规避。

在视觉设计层面,相机机身式内容Tab的三段式结构——闪光灯(Rect+radius)、机身(Rect+radius+Circle镜头)、标签(Text)——通过基础组件的精确组合实现了相机物理隐喻的视觉化表达。选中态的"棕+珊瑚粉"双色同步切换,使得选中Tab呈现出完整的相机工作状态——闪光灯亮起(珊瑚粉)、机身激活(棕色)、镜头聚焦(珊瑚粉),视觉辨识度温暖而专业。fxLayer中的五个emoji粒子——相机摇摆、小狗位移、爪印闪烁、魔法棒缩放、闪光渐变——与宠物摄影主题紧密关联,营造出温暖的拍摄工作室氛围感。

在状态管理层面,orderFilter变量配合orderShown方法实现了订单的多状态筛选——全部/待拍摄/修图中/已完成/已取消,其中"修图中"状态是摄影服务特有的后期处理阶段。订单页面根据状态的不同,展示不同的操作按钮——待拍摄显示"改期"、修图中显示"加急"、已取消显示"删除"、已完成显示"相册"——每种操作关联不同的弹框组件。这种基于订单状态的多分支操作路由,使得用户能够根据订单当前阶段执行最合适的操作,是本应用交互设计的核心亮点。homeFlag到府拍摄模式开关和fastFlag优先插队开关分别控制拍摄计划和加急出片弹框中的高级选项,体现了摄影服务场景的业务深度。

在弹框体系层面,四种弹框(预约拍摄/拍摄计划/取消订单/加急出片)各自承担不同的服务环节。预约弹框处理正常的套系预约,包含风格多选和精修张数步进;拍摄计划弹框管理拍摄时段和宠物数量,包含到府拍摄模式开关;取消弹框展示退款策略和费用明细,以红色警示色为主调;加急出片弹框是修图中订单的特色功能,通过加急费用和优先插队排期缩短出片周期。这四种弹框通过modalOverlay统一管理,使用半透明棕色遮罩层,底部对齐方式弹出。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐