HarmonyOS ArkTS 实战:实现一个校园证件照拍摄与预约应用

HarmonyOS ArkTS 实战:从零实现一个校园证件照拍摄与预约应用

HarmonyOS ArkTS 实战:从零实现一个校园证件照拍摄与预约应用

本文将带你使用 HarmonyOS + ArkTS 从零搭建一个功能完整的校园证件照拍摄与预约应用,涵盖尺寸选择、背景配色、在线预约、选片下载、打印配送等核心模块,附完整可运行代码。


目录

  • 一、项目背景与效果预览

  • 二、技术栈与环境准备

  • 三、项目结构设计

  • 四、数据模型设计

    • 4.1 证件照订单模型

    • 4.2 照片规格模型

    • 4.3 拍摄样片模型

    • 4.4 设计思路

  • 五、页面布局与 UI 实现

    • 5.1 整体布局架构

    • 5.2 顶部横幅与统计概览

    • 5.3 尺寸规格选择网格

    • 5.4 背景颜色选择器

    • 5.5 拍摄样片展示

    • 5.6 价格表展示

    • 5.7 预约时间选择

    • 5.8 我的订单列表

    • 5.9 预约下单弹窗

  • 六、核心功能实现详解

    • 6.1 预约拍摄下单

    • 6.2 尺寸与背景选择

    • 6.3 在线选片功能

    • 6.4 打印配送流程

    • 6.5 订单状态流转

    • 6.6 拍摄提醒机制

  • 七、样式与交互动效

    • 7.1 主题色系统

    • 7.2 状态颜色体系

    • 7.3 交互动效

  • 八、完整代码结构

  • 九、运行与调试

  • 十、技术要点总结

  • 十一、扩展方向


一、项目背景与效果预览

大学校园里,证件照是刚需中的刚需------入学登记、考试报名、求职简历、社团招新......每隔一段时间就需要拍一次。但传统照相馆排队久、价格贵、取片慢,学生体验很不好。

本项目以"校园证件照"为切入点,使用 HarmonyOS ArkTS 开发一个集在线预约、尺寸选择、背景配色、在线选片、打印配送于一体的证件照服务应用,让学生足不出寝就能搞定证件照。

核心功能一览

功能模块 说明
📷 拍摄预约 选择日期时段,一键预约拍摄
📐 尺寸选择 一寸、二寸、小一寸等多种规格
🎨 背景配色 蓝底、白底、红底,一键切换预览
🖼️ 在线选片 拍摄后在线挑选满意的照片
⬇️ 照片下载 电子照直接下载保存
📦 打印配送 打印纸质版配送到寝
🖼️ 拍摄样片 展示不同尺寸和背景的效果样片
💰 价格表 清晰透明的价格体系
📋 我的订单 所有预约订单一目了然
⏰ 拍摄提醒 预约时间到了自动提醒

运行效果

应用整体采用石板灰(#64748B)为主色调,搭配浅灰背景,传递专业、正式、可靠的摄影服务感。顶部为横幅和统计概览,中部为尺寸选择、样片展示、价格表等内容,底部为 Tab 导航,支持上下滑动浏览。


二、技术栈与环境准备

开发环境

项目 版本要求 说明
DevEco Studio 5.0+ HarmonyOS 官方 IDE
HarmonyOS SDK API 24+ 基于 ArkTS 声明式开发范式
运行设备 HarmonyOS 4.0+ 手机 / 平板均可

项目亮点

本项目不仅是一个 UI 练习,更包含了完整的证件照服务业务闭环

  1. 规格系统:多种尺寸规格 + 多种背景色,组合灵活

  2. 预约流程:选规格 → 选背景 → 选时间 → 确认下单,完整链路

  3. 订单状态机:已预约 → 拍摄中 → 待选片 → 已完成 / 配送中

  4. 选片体验:模拟在线选片交互,支持多选、放大预览

  5. 配送服务:电子照下载 + 纸质照配送,两种交付方式


三、项目结构设计

Plaintext 复制代码
entry/src/main/
├── ets/
│   ├── pages/
│   │   └── Index.ets              # 主页面(单页应用)
│   ├── model/
│   │   ├── PhotoOrder.ets         # 证件照订单数据模型
│   │   ├── PhotoSize.ets          # 照片规格数据模型
│   │   └── SamplePhoto.ets        # 拍摄样片数据模型
│   ├── components/
│   │   ├── HeaderBanner.ets       # 顶部横幅组件
│   │   ├── StatsOverview.ets      # 统计概览组件
│   │   ├── SizeSelector.ets       # 尺寸选择组件
│   │   ├── ColorPicker.ets        # 背景颜色选择器
│   │   ├── SampleGallery.ets      # 样片展示组件
│   │   ├── PriceTable.ets         # 价格表组件
│   │   ├── TimeSlotPicker.ets     # 预约时间选择器
│   │   ├── OrderList.ets          # 订单列表组件
│   │   └── BookingDialog.ets      # 预约下单弹窗
│   └── utils/
│       ├── MockData.ets           # 模拟数据
│       └── OrderStatus.ets        # 状态常量与工具函数
├── resources/
│   ├── base/
│   │   ├── element/               # 颜色、尺寸等资源
│   │   └── media/                 # 图片资源
│   └── rawfile/                   # 其他资源
└── module.json5                   # 模块配置

💡 架构说明 :本项目采用单页面 + 组件化 架构。主页面 Index.ets 负责状态管理和业务逻辑,各功能模块拆分为独立组件,通过 @Prop 和事件回调通信。这种设计既保持了代码清晰,又降低了初学者的理解门槛。


四、数据模型设计

4.1 证件照订单模型

订单是整个应用的核心实体,记录了一笔证件照预约的所有信息。

TypeScript 复制代码
/**
 * 证件照订单数据结构
 * 描述一笔完整的证件照预约订单
 */
interface PhotoOrder {
  id: number;                  // 订单唯一标识
  sizeId: number;              // 选择的尺寸 ID
  sizeName: string;            // 尺寸名称(冗余,方便展示)
  sizeSpec: string;            // 尺寸规格(如 25mm×35mm)
  backgroundColor: string;     // 背景颜色(blue/white/red)
  backgroundColorName: string; // 背景颜色名称
  quantity: number;            // 打印数量
  appointmentDate: string;     // 预约日期
  appointmentTime: string;     // 预约时段
  price: number;               // 总价
  isPrinted: boolean;          // 是否需要打印
  deliveryAddress: string;     // 配送地址(打印时需要)
  status: OrderStatus;         // 订单状态
  orderTime: string;           // 下单时间
  selectedPhotos: number[];    // 已选照片 ID 列表
  remark: string;              // 备注信息
  review: Review | null;       // 评价(完成后才有)
}

/**
 * 订单状态
 * booked:     已预约(未拍摄)
 * shooting:   拍摄中(正在拍摄)
 * selecting:  待选片(拍摄完成,等待选片)
 * completed:  已完成(电子照已交付)
 * delivering: 配送中(纸质照配送中)
 * finished:   已结束(纸质照已送达)
 * cancelled:  已取消
 */
type OrderStatus = 'booked' | 'shooting' | 'selecting' | 'completed' | 'delivering' | 'finished' | 'cancelled';

4.2 照片规格模型

TypeScript 复制代码
/**
 * 证件照尺寸规格数据结构
 */
interface PhotoSize {
  id: number;           // 规格 ID
  name: string;         // 规格名称(如"一寸")
  sizeMm: string;       // 毫米尺寸(如 25mm×35mm)
  sizePx: string;       // 像素尺寸(如 295×413px)
  basePrice: number;    // 基础价格(电子照)
  printPrice: number;   // 打印价格(每版)
  description: string;  // 用途说明
  icon: string;         // 图标 emoji
  isHot: boolean;       // 是否热门
}

4.3 拍摄样片模型

TypeScript 复制代码
/**
 * 拍摄样片数据结构
 */
interface SamplePhoto {
  id: number;           // 样片 ID
  sizeId: number;       // 对应尺寸 ID
  backgroundColor: string; // 背景颜色
  imageUrl: string;     // 样片图片
  title: string;        // 样片标题
  description: string;  // 样片描述
}

/**
 * 评价数据结构
 */
interface Review {
  id: number;
  orderId: number;
  rating: number;       // 评分 1-5
  content: string;      // 评价内容
  time: string;         // 评价时间
}

4.4 设计思路

设计决策 理由
订单状态 7 种 覆盖完整生命周期:预约→拍摄→选片→完成→配送→结束
分离电子照和打印 两种交付方式,价格和流程不同
背景色 3 种经典色 蓝底、白底、红底覆盖 90% 以上证件照需求
尺寸规格独立模型 便于扩展新规格,价格统一管理
选片用 ID 列表 轻量存储,避免重复存图片数据

五、页面布局与 UI 实现

5.1 整体布局架构

页面采用顶部横幅 + 内容区 + 底部 Tab 的经典三段式布局:

Plaintext 复制代码
┌─────────────────────────┐
│   顶部横幅 + 统计概览     │  ← HeaderBanner + StatsOverview
├─────────────────────────┤
│                         │
│      尺寸选择网格        │  ← SizeSelector
│                         │
├─────────────────────────┤
│                         │
│      背景颜色选择        │  ← ColorPicker
│                         │
├─────────────────────────┤
│                         │
│      拍摄样片展示        │  ← SampleGallery
│                         │
├─────────────────────────┤
│                         │
│      价格表 / 订单列表    │  ← 根据 Tab 切换
│                         │
├─────────────────────────┤
│ 首页 | 订单 | 样片 | 我的 │  ← BottomTabBar
└─────────────────────────┘

核心布局骨架:

TypeScript 复制代码
@Entry
@Component
struct Index {
  @State currentTab: number = 0; // 0:首页 1:订单 2:样片 3:我的
  
  build() {
    Column() {
      // 顶部区域(首页 Tab 时显示横幅)
      if (this.currentTab === 0) {
        HeaderBanner()
        StatsOverview({ stats: this.appStats })
      }
      
      // 内容区域(可滚动)
      Scroll() {
        Column() {
          if (this.currentTab === 0) {
            // 首页:尺寸选择 + 背景选择 + 价格表
            this.HomeContent()
          } else if (this.currentTab === 1) {
            // 订单
            OrderList({ 
              orders: this.orders,
              onOrderClick: (order) => this.showOrderDetail(order)
            })
          } else if (this.currentTab === 2) {
            // 样片
            SampleGallery({ samples: this.samplePhotos })
          } else {
            // 我的
            this.ProfileContent()
          }
        }
        .width('100%')
      }
      .layoutWeight(1)
      
      // 底部 Tab 栏
      BottomTabBar({ 
        currentTab: this.currentTab,
        onTabChange: (index) => this.currentTab = index
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F8FAFC') // 浅灰背景
  }
}

5.2 顶部横幅与统计概览

顶部横幅展示应用主题和核心数据,营造专业摄影服务的氛围。

TypeScript 复制代码
@Component
struct HeaderBanner {
  onBookClick: () => void;
  
  build() {
    Stack() {
      // 背景渐变
      Column()
        .width('100%')
        .height(180)
        .linearGradient({
          direction: GradientDirection.RightLeft,
          colors: [['#64748B', 0.0], ['#475569', 1.0]]
        })
      
      // 内容
      Row() {
        Column() {
          Text('校园证件照')
            .fontSize(24)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          
          Text('专业拍摄 · 快速取片 · 配送到寝')
            .fontSize(13)
            .fontColor('#CBD5E1')
            .margin({ top: 6 })
          
          Button('立即预约')
            .width(100)
            .height(36)
            .margin({ top: 16 })
            .borderRadius(18)
            .backgroundColor('#FFFFFF')
            .fontColor('#64748B')
            .fontSize(14)
            .fontWeight(FontWeight.Medium)
            .onClick(() => {
              this.onBookClick();
            })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        
        // 右侧相机图标
        Text('📷')
          .fontSize(72)
          .opacity(0.3)
      }
      .width('100%')
      .padding(20)
    }
    .width('100%')
    .height(180)
  }
}
统计概览面板

展示关键数据,让用户一目了然服务情况。

TypeScript 复制代码
@Component
struct StatsOverview {
  @Prop stats: AppStats;
  
  build() {
    Row() {
      this.StatCard('可预约', this.stats.availableSlots.toString(), '#64748B', '📅')
      this.StatCard('待拍摄', this.stats.pendingShoots.toString(), '#F59E0B', '⏳')
      this.StatCard('已完成', this.stats.completedCount.toString(), '#10B981', '✅')
      this.StatCard('我的订单', this.stats.myOrderCount.toString(), '#3B82F6', '📋')
    }
    .width('100%')
    .padding(16)
    .justifyContent(FlexAlign.SpaceBetween)
    .margin({ top: -20 }) // 向上偏移,与横幅叠加
  }
  
  @Builder
  StatCard(label: string, value: string, color: string, icon: string) {
    Column() {
      Text(icon)
        .fontSize(20)
      Text(value)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor(color)
        .margin({ top: 4 })
      Text(label)
        .fontSize(11)
        .fontColor('#666666')
        .margin({ top: 2 })
    }
    .width('23%')
    .padding(12)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .shadow({ radius: 8, color: '#1A000000', offsetX: 0, offsetY: 2 })
    .alignItems(HorizontalAlign.Center)
  }
}

interface AppStats {
  availableSlots: number;    // 可预约时段数
  pendingShoots: number;     // 待拍摄数
  completedCount: number;    // 已完成数
  myOrderCount: number;      // 我的订单数
}

5.3 尺寸规格选择网格

证件照的核心是尺寸规格,用网格布局展示各种规格,直观清晰。

TypeScript 复制代码
@Component
struct SizeSelector {
  @State sizes: PhotoSize[] = [];
  @State selectedSizeId: number = 1;
  onSizeSelect: (size: PhotoSize) => void;
  
  build() {
    Column() {
      // 标题
      Row() {
        Text('选择尺寸')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        Text('共 ' + this.sizes.length + ' 种规格')
          .fontSize(12)
          .fontColor('#999999')
          .margin({ left: 8 })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 16, bottom: 12 })
      
      // 尺寸网格
      Grid() {
        ForEach(this.sizes, (size: PhotoSize) => {
          GridItem() {
            this.SizeCard(size)
          }
        }, (size: PhotoSize) => size.id.toString())
      }
      .columnsTemplate('1fr 1fr')
      .rowsGap(12)
      .columnsGap(12)
      .padding({ left: 16, right: 16, bottom: 16 })
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ left: 16, right: 16, bottom: 12 })
  }
  
  @Builder
  SizeCard(size: PhotoSize) {
    Column() {
      // 热门标签
      if (size.isHot) {
        Text('热门')
          .fontSize(10)
          .fontColor('#FFFFFF')
          .backgroundColor('#EF4444')
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .borderRadius(8)
          .position({ x: 8, y: 8 })
      }
      
      // 图标
      Text(size.icon)
        .fontSize(36)
      
      // 名称
      Text(size.name)
        .fontSize(15)
        .fontWeight(FontWeight.Medium)
        .fontColor(this.selectedSizeId === size.id ? '#64748B' : '#333333')
        .margin({ top: 8 })
      
      // 尺寸
      Text(size.sizeMm)
        .fontSize(11)
        .fontColor('#999999')
        .margin({ top: 2 })
      
      // 价格
      Row() {
        Text('¥')
          .fontSize(12)
          .fontColor('#EF4444')
        Text(size.basePrice.toString())
          .fontSize(18)
          .fontColor('#EF4444')
          .fontWeight(FontWeight.Bold)
        Text('起')
          .fontSize(11)
          .fontColor('#999999')
          .margin({ left: 2 })
      }
      .margin({ top: 8 })
    }
    .width('100%')
    .padding(16)
    .backgroundColor(this.selectedSizeId === size.id ? '#F1F5F9' : '#FAFAFA')
    .borderRadius(10)
    .border({
      width: this.selectedSizeId === size.id ? 2 : 1,
      color: this.selectedSizeId === size.id ? '#64748B' : '#E5E7EB'
    })
    .alignItems(HorizontalAlign.Center)
    .onClick(() => {
      this.selectedSizeId = size.id;
      this.onSizeSelect(size);
    })
  }
}

设计要点

  • 双列网格:充分利用屏幕宽度,信息密度适中

  • 选中态高亮:边框变色 + 背景变色,清晰指示当前选择

  • 热门标签:红色标签突出热门规格,引导用户选择

  • 价格突出:红色大字价格,一眼看到核心信息

5.4 背景颜色选择器

证件照的背景色是关键选择项,用色块 + 名称的方式直观展示。

TypeScript 复制代码
@Component
struct ColorPicker {
  @State colors: BgColorOption[] = [];
  @State selectedColor: string = 'blue';
  onColorSelect: (color: string) => void;
  
  build() {
    Column() {
      Text('背景颜色')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .width('100%')
        .padding({ left: 16, right: 16, top: 16, bottom: 12 })
      
      Row() {
        ForEach(this.colors, (color: BgColorOption) => {
          this.ColorOption(color)
        }, (color: BgColorOption) => color.value)
      }
      .width('100%')
      .padding({ left: 16, right: 16, bottom: 16 })
      .justifyContent(FlexAlign.SpaceAround)
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ left: 16, right: 16, bottom: 12 })
  }
  
  @Builder
  ColorOption(color: BgColorOption) {
    Column() {
      // 色块
      Stack() {
        Circle({ width: 48, height: 48 })
          .fill(color.hex)
          .stroke(this.selectedColor === color.value ? '#64748B' : '#E5E7EB')
          .strokeWidth(this.selectedColor === color.value ? 3 : 1)
        
        // 选中对勾
        if (this.selectedColor === color.value) {
          Text('✓')
            .fontSize(20)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Bold)
        }
      }
      
      // 颜色名称
      Text(color.name)
        .fontSize(13)
        .fontColor(this.selectedColor === color.value ? '#64748B' : '#666666')
        .fontWeight(this.selectedColor === color.value ? FontWeight.Medium : FontWeight.Normal)
        .margin({ top: 8 })
      
      // 适用场景
      Text(color.scene)
        .fontSize(10)
        .fontColor('#999999')
        .margin({ top: 2 })
    }
    .alignItems(HorizontalAlign.Center)
    .onClick(() => {
      this.selectedColor = color.value;
      this.onColorSelect(color.value);
    })
  }
}

interface BgColorOption {
  value: string;       // 颜色值标识
  name: string;        // 颜色名称
  hex: string;         // 十六进制颜色
  scene: string;       // 适用场景
}

背景色配置

颜色 色值 适用场景
蓝底 #1E40AF 简历、毕业证、驾照等最常用
白底 #FFFFFF 护照、签证、身份证等
红底 #DC2626 结婚照、党员证、保险等

5.5 拍摄样片展示

展示实际拍摄效果,让用户有直观预期。

TypeScript 复制代码
@Component
struct SampleGallery {
  @State samples: SamplePhoto[] = [];
  @State currentSizeFilter: number = 0; // 0:全部
  
  build() {
    Column() {
      // 标题
      Row() {
        Text('拍摄样片')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
        .layoutWeight(1)
        Text('查看更多 >')
          .fontSize(12)
          .fontColor('#64748B')
          .onClick(() => {
            // 跳转到样片 Tab
          })
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 16, bottom: 12 })
      
      // 样片网格
      Grid() {
        ForEach(this.displaySamples, (sample: SamplePhoto) => {
          GridItem() {
            this.SampleCard(sample)
          }
        }, (sample: SamplePhoto) => sample.id.toString())
      }
      .columnsTemplate('1fr 1fr 1fr')
      .rowsGap(8)
      .columnsGap(8)
      .padding({ left: 16, right: 16, bottom: 16 })
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ left: 16, right: 16, bottom: 12 })
  }
  
  private get displaySamples(): SamplePhoto[] {
    return this.samples.slice(0, 6); // 首页只展示6张
  }
  
  @Builder
  SampleCard(sample: SamplePhoto) {
    Column() {
      // 样片占位(实际项目中用真实图片)
      Stack() {
        Column()
          .width('100%')
          .aspectRatio(0.72) // 证件照比例
          .backgroundColor(sample.backgroundColor === 'white' ? '#F5F5F5' : 
                          sample.backgroundColor === 'blue' ? '#1E40AF' : '#DC2626')
          .borderRadius(8)
        
        Text('👤')
          .fontSize(32)
          .opacity(0.5)
      }
      
      Text(sample.title)
        .fontSize(11)
        .fontColor('#666666')
        .margin({ top: 6 })
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .width('100%')
    }
    .width('100%')
  }
}

5.6 价格表展示

清晰透明的价格表,让用户放心消费。

TypeScript 复制代码
@Component
struct PriceTable {
  @State sizes: PhotoSize[] = [];
  
  build() {
    Column() {
      Text('价格表')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .width('100%')
        .padding({ left: 16, right: 16, top: 16, bottom: 12 })
      
      // 表头
      Row() {
        Text('规格')
          .fontSize(13)
          .fontColor('#999999')
          .width(80)
        Text('尺寸(mm)')
          .fontSize(13)
          .fontColor('#999999')
          .layoutWeight(1)
        Text('电子照')
          .fontSize(13)
          .fontColor('#999999')
          .width(60)
          .textAlign(TextAlign.End)
        Text('打印版')
          .fontSize(13)
          .fontColor('#999999')
          .width(60)
          .textAlign(TextAlign.End)
      }
      .width('100%')
      .padding({ left: 16, right: 16, bottom: 8 })
      
      Divider()
        .color('#E5E7EB')
        .margin({ left: 16, right: 16 })
      
      // 价格行
      ForEach(this.sizes, (size: PhotoSize) => {
        Column() {
          Row() {
            Column() {
              Text(size.name)
                .fontSize(14)
                .fontColor('#333333')
                .fontWeight(FontWeight.Medium)
              if (size.isHot) {
                Text('热门')
                  .fontSize(9)
                  .fontColor('#EF4444')
                  .backgroundColor('#FEE2E2')
                  .padding({ left: 4, right: 4, top: 1, bottom: 1 })
                  .borderRadius(4)
                  .margin({ top: 2 })
              }
            }
            .width(80)
            .alignItems(HorizontalAlign.Start)
            
            Text(size.sizeMm)
              .fontSize(13)
              .fontColor('#666666')
              .layoutWeight(1)
            
            Text('¥' + size.basePrice)
              .fontSize(14)
              .fontColor('#333333')
              .fontWeight(FontWeight.Medium)
              .width(60)
              .textAlign(TextAlign.End)
            
            Text('¥' + size.printPrice)
              .fontSize(14)
              .fontColor('#EF4444')
              .fontWeight(FontWeight.Medium)
              .width(60)
              .textAlign(TextAlign.End)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 12 })
          
          Divider()
            .color('#F0F0F0')
            .margin({ left: 16, right: 16 })
        }
      }, (size: PhotoSize) => size.id.toString())
      
      // 说明
      Text('💡 打印版每版含8张(一寸)或4张(二寸)')
        .fontSize(11)
        .fontColor('#999999')
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 16 })
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ left: 16, right: 16, bottom: 12 })
  }
}

5.7 预约时间选择

选择拍摄日期和时段,是预约流程的关键步骤。

TypeScript 复制代码
@Component
struct TimeSlotPicker {
  @State selectedDate: string = '';
  @State selectedTime: string = '';
  @State availableDates: DateOption[] = [];
  @State availableTimes: string[] = [];
  onTimeSelect: (date: string, time: string) => void;
  
  build() {
    Column() {
      Text('选择时间')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333')
        .width('100%')
        .padding({ left: 16, right: 16, top: 16, bottom: 12 })
      
      // 日期选择(横向滚动)
      Text('选择日期')
        .fontSize(13)
        .fontColor('#666666')
        .width('100%')
        .padding({ left: 16, right: 16, bottom: 8 })
      
      Scroll() {
        Row({ space: 10 }) {
          ForEach(this.availableDates, (date: DateOption) => {
            this.DateItem(date)
          }, (date: DateOption) => date.dateStr)
        }
        .padding({ left: 16, right: 16 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .margin({ bottom: 16 })
      
      // 时段选择
      Text('选择时段')
        .fontSize(13)
        .fontColor('#666666')
        .width('100%')
        .padding({ left: 16, right: 16, bottom: 8 })
      
      Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
        ForEach(this.availableTimes, (time: string) => {
          this.TimeSlotItem(time)
        }, (time: string) => time)
      }
      .padding({ left: 16, right: 16, bottom: 16 })
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .margin({ left: 16, right: 16, bottom: 12 })
  }
  
  @Builder
  DateItem(date: DateOption) {
    Column() {
      Text(date.weekday)
        .fontSize(11)
        .fontColor(this.selectedDate === date.dateStr ? '#FFFFFF' : '#999999')
      Text(date.day)
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor(this.selectedDate === date.dateStr ? '#FFFFFF' : '#333333')
        .margin({ top: 2 })
      Text(date.month)
        .fontSize(10)
        .fontColor(this.selectedDate === date.dateStr ? '#FFFFFF' : '#999999')
    }
    .width(56)
    .height(64)
    .backgroundColor(this.selectedDate === date.dateStr ? '#64748B' : '#F5F5F5')
    .borderRadius(10)
    .justifyContent(FlexAlign.Center)
    .opacity(date.available ? 1 : 0.4)
    .onClick(() => {
      if (date.available) {
        this.selectedDate = date.dateStr;
        // 更新可选时段
        this.availableTimes = this.getTimeSlotsForDate(date.dateStr);
      }
    })
  }
  
  @Builder
  TimeSlotItem(time: string) {
    Text(time)
      .fontSize(13)
      .fontColor(this.selectedTime === time ? '#FFFFFF' : '#666666')
      .padding({ left: 16, right: 16, top: 8, bottom: 8 })
      .backgroundColor(this.selectedTime === time ? '#64748B' : '#F5F5F5')
      .borderRadius(8)
      .margin({ right: 8, bottom: 8 })
      .onClick(() => {
        this.selectedTime = time;
        this.onTimeSelect(this.selectedDate, time);
      })
  }
  
  private getTimeSlotsForDate(date: string): string[] {
    // 模拟数据:返回该日期的可预约时段
    return ['09:00-09:30', '09:30-10:00', '10:00-10:30', '10:30-11:00',
            '14:00-14:30', '14:30-15:00', '15:00-15:30', '15:30-16:00'];
  }
}

interface DateOption {
  dateStr: string;    // 日期字符串
  weekday: string;    // 星期几
  day: string;        // 日期数字
  month: string;      // 月份
  available: boolean; // 是否可预约
}

5.8 我的订单列表

展示用户的所有预约订单,按状态分类。

TypeScript 复制代码
@Component
struct OrderList {
  @State orders: PhotoOrder[] = [];
  @State statusFilter: string = 'all'; // all/booked/completed
  onOrderClick: (order: PhotoOrder) => void;
  
  private get filteredOrders(): PhotoOrder[] {
    if (this.statusFilter === 'all') return this.orders;
    if (this.statusFilter === 'booked') {
      return this.orders.filter(o => 
        o.status === 'booked' || o.status === 'shooting' || o.status === 'selecting'
      );
    }
    if (this.statusFilter === 'completed') {
      return this.orders.filter(o => 
        o.status === 'completed' || o.status === 'delivering' || o.status === 'finished'
      );
    }
    return this.orders;
  }
  
  build() {
    Column() {
      // 筛选 Tab
      Row() {
        this.FilterTab('全部', 'all', this.orders.length)
        this.FilterTab('进行中', 'booked', this.inProgressCount)
        this.FilterTab('已完成', 'completed', this.completedCount)
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .padding({ top: 12, bottom: 8 })
      
      // 订单列表
      List({ space: 12 }) {
        ForEach(this.filteredOrders, (order: PhotoOrder) => {
          ListItem() {
            this.OrderCard(order)
          }
        }, (order: PhotoOrder) => order.id.toString())
        
        if (this.filteredOrders.length === 0) {
          ListItem() {
            Column() {
              Text('📭')
                .fontSize(48)
              Text('暂无订单')
                .fontSize(14)
                .fontColor('#999999')
                .margin({ top: 12 })
            }
            .width('100%')
            .padding(40)
            .alignItems(HorizontalAlign.Center)
          }
        }
      }
      .width('100%')
      .padding(16)
    }
  }
  
  private get inProgressCount(): number {
    return this.orders.filter(o => 
      o.status === 'booked' || o.status === 'shooting' || o.status === 'selecting'
    ).length;
  }
  
  private get completedCount(): number {
    return this.orders.filter(o => 
      o.status === 'completed' || o.status === 'delivering' || o.status === 'finished'
    ).length;
  }
  
  @Builder
  FilterTab(label: string, value: string, count: number) {
    Column() {
      Text(`${label} (${count})`)
        .fontSize(14)
        .fontColor(this.statusFilter === value ? '#64748B' : '#666666')
        .fontWeight(this.statusFilter === value ? FontWeight.Bold : FontWeight.Normal)
      
      if (this.statusFilter === value) {
        Divider()
          .width(40)
          .height(3)
          .color('#64748B')
          .borderRadius(2)
          .margin({ top: 8 })
      }
    }
    .width('33.3%')
    .alignItems(HorizontalAlign.Center)
    .onClick(() => {
      this.statusFilter = value;
    })
  }
  
  @Builder
  OrderCard(order: PhotoOrder) {
    Column() {
      // 顶部:尺寸 + 状态
      Row() {
        Row() {
          Text('📷')
            .fontSize(16)
          Text(order.sizeName)
            .fontSize(14)
            .fontColor('#333333')
            .fontWeight(FontWeight.Medium)
            .margin({ left: 6 })
          Text('· ' + order.backgroundColorName)
            .fontSize(12)
            .fontColor('#999999')
            .margin({ left: 4 })
        }
        .layoutWeight(1)
        
        Text(this.getStatusText(order.status))
          .fontSize(12)
          .fontColor('#FFFFFF')
          .backgroundColor(this.getStatusColor(order.status))
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .borderRadius(10)
      }
      .width('100%')
      
      // 订单信息
      Row() {
        Column() {
          Text('预约时间')
            .fontSize(11)
            .fontColor('#999999')
          Text(order.appointmentDate + ' ' + order.appointmentTime)
            .fontSize(13)
            .fontColor('#333333')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        
        Column() {
          Text('数量')
            .fontSize(11)
            .fontColor('#999999')
          Text(order.quantity + '张')
            .fontSize(13)
            .fontColor('#333333')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        
        Column() {
          Text('金额')
            .fontSize(11)
            .fontColor('#999999')
          Text('¥' + order.price)
            .fontSize(14)
            .fontColor('#EF4444')
            .fontWeight(FontWeight.Bold)
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      .margin({ top: 12 })
      
      // 底部操作按钮
      Row() {
        Text('订单号:' + order.id)
          .fontSize(11)
          .fontColor('#999999')
          .layoutWeight(1)
        
        if (order.status === 'booked') {
          Button('取消预约')
            .width(72)
            .height(28)
            .borderRadius(14)
            .backgroundColor('#FEF2F2')
            .fontColor('#EF4444')
            .fontSize(12)
            .margin({ right: 8 })
        }
        
        if (order.status === 'selecting') {
          Button('去选片')
            .width(72)
            .height(28)
            .borderRadius(14)
            .backgroundColor('#64748B')
            .fontColor('#FFFFFF')
            .fontSize(12)
        }
        
        if (order.status === 'completed' && !order.isPrinted) {
          Button('打印配送')
            .width(80)
            .height(28)
            .borderRadius(14)
            .backgroundColor('#64748B')
            .fontColor('#FFFFFF')
            .fontSize(12)
        }
      }
      .width('100%')
      .margin({ top: 12 })
    }
    .width('100%')
    .padding(14)
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .onClick(() => {
      this.onOrderClick(order);
    })
  }
  
  private getStatusText(status: OrderStatus): string {
    const texts: Record<OrderStatus, string> = {
      booked: '待拍摄',
      shooting: '拍摄中',
      selecting: '待选片',
      completed: '已完成',
      delivering: '配送中',
      finished: '已结束',
      cancelled: '已取消'
    };
    return texts[status] || '未知';
  }
  
  private getStatusColor(status: OrderStatus): string {
    const colors: Record<OrderStatus, string> = {
      booked: '#F59E0B',
      shooting: '#3B82F6',
      selecting: '#8B5CF6',
      completed: '#10B981',
      delivering: '#64748B',
      finished: '#9CA3AF',
      cancelled: '#9CA3AF'
    };
    return colors[status] || '#9CA3AF';
  }
}

5.9 预约下单弹窗

整合尺寸、背景、时间选择,完成预约下单。

TypeScript 复制代码
@CustomDialog
struct BookingDialog {
  controller: CustomDialogController;
  onConfirm: (orderData: Partial<PhotoOrder>) => void;
  
  @State selectedSizeId: number = 1;
  @State selectedColor: string = 'blue';
  @State selectedDate: string = '';
  @State selectedTime: string = '';
  @State quantity: number = 8;
  @State needPrint: boolean = false;
  
  private sizes: PhotoSize[] = [
    { id: 1, name: '一寸', sizeMm: '25×35mm', sizePx: '295×413px', basePrice: 9.9, printPrice: 15, description: '简历、证件', icon: '📷', isHot: true },
    { id: 2, name: '二寸', sizeMm: '35×49mm', sizePx: '413×579px', basePrice: 12.9, printPrice: 20, description: '毕业证、签证', icon: '🖼️', isHot: true },
    { id: 3, name: '小一寸', sizeMm: '22×32mm', sizePx: '260×378px', basePrice: 9.9, printPrice: 15, description: '驾驶证、学生证', icon: '📸', isHot: false },
    { id: 4, name: '小二寸', sizeMm: '35×45mm', sizePx: '413×531px', basePrice: 12.9, printPrice: 20, description: '护照、签证', icon: '📷', isHot: false },
  ];
  
  private colors: BgColorOption[] = [
    { value: 'blue', name: '蓝底', hex: '#1E40AF', scene: '最常用' },
    { value: 'white', name: '白底', hex: '#FFFFFF', scene: '护照签证' },
    { value: 'red', name: '红底', hex: '#DC2626', scene: '保险党员' },
  ];
  
  build() {
    Scroll() {
      Column() {
        Text('预约拍摄')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .margin({ bottom: 20 })
        
        // 尺寸选择
        Text('选择尺寸')
          .fontSize(14)
          .fontColor('#666666')
          .alignSelf(ItemAlign.Start)
          .margin({ bottom: 8 })
        
        Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
          ForEach(this.sizes, (size: PhotoSize) => {
            this.SizeOption(size)
          }, (size: PhotoSize) => size.id.toString())
        }
        .width('100%')
        
        // 背景颜色
        Text('背景颜色')
          .fontSize(14)
          .fontColor('#666666')
          .alignSelf(ItemAlign.Start)
          .margin({ top: 20, bottom: 8 })
        
        Row() {
          ForEach(this.colors, (color: BgColorOption) => {
            this.ColorOption(color)
          }, (color: BgColorOption) => color.value)
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceAround)
        
        // 数量
        Text('打印数量')
          .fontSize(14)
          .fontColor('#666666')
          .alignSelf(ItemAlign.Start)
          .margin({ top: 20, bottom: 8 })
        
        Row() {
          Button('-')
            .width(32)
            .height(32)
            .borderRadius(16)
            .backgroundColor('#F5F5F5')
            .fontColor('#333333')
            .onClick(() => {
              if (this.quantity > 1) this.quantity--;
            })
          
          Text(this.quantity + ' 张')
            .fontSize(16)
            .fontColor('#333333')
            .width(80)
            .textAlign(TextAlign.Center)
          
          Button('+')
            .width(32)
            .height(32)
            .borderRadius(16)
            .backgroundColor('#F5F5F5')
            .fontColor('#333333')
            .onClick(() => {
              this.quantity++;
            })
        }
        
        // 是否打印
        Row() {
          Text('需要打印配送')
            .fontSize(14)
            .fontColor('#333333')
            .layoutWeight(1)
          Toggle({ type: ToggleType.Switch, isOn: this.needPrint })
            .onChange((value: boolean) => {
              this.needPrint = value;
            })
        }
        .width('100%')
        .margin({ top: 20 })
        
        // 价格汇总
        Row() {
          Text('合计')
            .fontSize(14)
            .fontColor('#666666')
            .layoutWeight(1)
          
          Text('¥' + this.totalPrice.toFixed(1))
            .fontSize(24)
            .fontColor('#EF4444')
            .fontWeight(FontWeight.Bold)
        }
        .width('100%')
        .margin({ top: 20, bottom: 8 })
        
        // 提交按钮
        Button('确认预约')
          .width('100%')
          .height(48)
          .margin({ top: 16 })
          .borderRadius(24)
          .backgroundColor('#64748B')
          .fontColor('#FFFFFF')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .enabled(this.canSubmit())
          .opacity(this.canSubmit() ? 1 : 0.5)
          .onClick(() => {
            this.onConfirm({
              sizeId: this.selectedSizeId,
              backgroundColor: this.selectedColor,
              quantity: this.quantity,
              isPrinted: this.needPrint,
              price: this.totalPrice
            });
            this.controller.close();
          })
      }
      .width('100%')
      .padding(24)
    }
    .layoutWeight(1)
  }
  
  private get totalPrice(): number {
    const size = this.sizes.find(s => s.id === this.selectedSizeId);
    if (!size) return 0;
    let price = size.basePrice; // 电子照基础价
    if (this.needPrint) {
      price += size.printPrice; // 打印费
    }
    return price;
  }
  
  private canSubmit(): boolean {
    return this.selectedSizeId > 0 && this.selectedColor !== '';
  }
  
  @Builder
  SizeOption(size: PhotoSize) {
    Column() {
      Text(size.icon)
        .fontSize(24)
      Text(size.name)
        .fontSize(12)
        .fontColor(this.selectedSizeId === size.id ? '#64748B' : '#666666')
        .margin({ top: 4 })
      Text('¥' + size.basePrice)
        .fontSize(11)
        .fontColor('#EF4444')
        .margin({ top: 2 })
    }
    .width('22%')
    .padding(10)
    .margin({ bottom: 8, right: '3%' })
    .backgroundColor(this.selectedSizeId === size.id ? '#F1F5F9' : '#FAFAFA')
    .borderRadius(10)
    .border({
      width: this.selectedSizeId === size.id ? 2 : 1,
      color: this.selectedSizeId === size.id ? '#64748B' : '#E5E7EB'
    })
    .alignItems(HorizontalAlign.Center)
    .onClick(() => {
      this.selectedSizeId = size.id;
    })
  }
  
  @Builder
  ColorOption(color: BgColorOption) {
    Column() {
      Stack() {
        Circle({ width: 40, height: 40 })
          .fill(color.hex)
          .stroke(this.selectedColor === color.value ? '#64748B' : '#E5E7EB')
          .strokeWidth(this.selectedColor === color.value ? 3 : 1)
        
        if (this.selectedColor === color.value) {
          Text('✓')
            .fontSize(18)
            .fontColor('#FFFFFF')
            .fontWeight(FontWeight.Bold)
        }
      }
      Text(color.name)
        .fontSize(12)
        .fontColor(this.selectedColor === color.value ? '#64748B' : '#666666')
        .margin({ top: 6 })
    }
    .alignItems(HorizontalAlign.Center)
    .onClick(() => {
      this.selectedColor = color.value;
    })
  }
}

六、核心功能实现详解

6.1 预约拍摄下单

用户选择尺寸、背景、数量后,提交预约订单。

TypeScript 复制代码
/**
 * 预约拍摄
 */
private bookPhoto(orderData: Partial<PhotoOrder>): void {
  // 1. 校验
  if (!orderData.sizeId || !orderData.backgroundColor) {
    console.warn('请选择尺寸和背景颜色');
    return;
  }
  
  const size = this.photoSizes.find(s => s.id === orderData.sizeId);
  if (!size) return;
  
  // 2. 获取颜色名称
  const colorNames: Record<string, string> = {
    blue: '蓝底',
    white: '白底',
    red: '红底'
  };
  
  // 3. 创建订单
  const newOrder: PhotoOrder = {
    id: this.nextOrderId,
    sizeId: size.id,
    sizeName: size.name,
    sizeSpec: size.sizeMm,
    backgroundColor: orderData.backgroundColor || 'blue',
    backgroundColorName: colorNames[orderData.backgroundColor || 'blue'] || '蓝底',
    quantity: orderData.quantity || 8,
    appointmentDate: orderData.appointmentDate || '明天',
    appointmentTime: orderData.appointmentTime || '10:00-10:30',
    price: orderData.price || size.basePrice,
    isPrinted: orderData.isPrinted || false,
    deliveryAddress: orderData.deliveryAddress || '',
    status: 'booked',
    orderTime: this.formatDate(new Date()),
    selectedPhotos: [],
    remark: orderData.remark || '',
    review: null
  };
  
  // 4. 加入订单列表(最新的排前面)
  this.orders = [newOrder, ...this.orders];
  
  // 5. 更新统计
  this.nextOrderId++;
  this.appStats.myOrderCount++;
  this.appStats.pendingShoots++;
}

关键设计

  • 价格计算:电子照基础价 + 打印费(如果需要打印)

  • 状态初始化 :新订单状态为 booked(已预约)

  • 时间默认值:未选择时间时默认明天第一个时段

6.2 尺寸与背景选择

尺寸和背景是证件照的两个核心属性,组合决定了最终效果。

TypeScript 复制代码
/**
 * 选择尺寸
 */
private selectSize(sizeId: number): void {
  const size = this.photoSizes.find(s => s.id === sizeId);
  if (!size) return;
  
  this.selectedSize = size;
  this.calculatePrice();
}

/**
 * 选择背景颜色
 */
private selectBackgroundColor(color: string): void {
  this.selectedBackgroundColor = color;
}

/**
 * 计算价格
 */
private calculatePrice(): number {
  if (!this.selectedSize) return 0;
  
  let price = this.selectedSize.basePrice;
  if (this.needPrint) {
    price += this.selectedSize.printPrice;
  }
  return price;
}

为什么尺寸和背景分开选择?

  • 灵活性:每种尺寸都可以搭配任意背景色,组合数 = 尺寸数 × 颜色数

  • 可扩展性:新增尺寸或背景色时互不影响

  • 用户体验:分步选择降低认知负担,每一步只做一个决策

6.3 在线选片功能

拍摄完成后,用户可以在线挑选满意的照片。

TypeScript 复制代码
/**
 * 切换照片选中状态
 */
private togglePhotoSelection(photoId: number): void {
  const index = this.selectedPhotos.indexOf(photoId);
  if (index > -1) {
    // 取消选中
    this.selectedPhotos = this.selectedPhotos.filter(id => id !== photoId);
  } else {
    // 选中(最多选8张)
    if (this.selectedPhotos.length < 8) {
      this.selectedPhotos = [...this.selectedPhotos, photoId];
    } else {
      console.warn('最多选择8张照片');
    }
  }
}

/**
 * 确认选片
 */
private confirmSelection(orderId: number): void {
  if (this.selectedPhotos.length === 0) {
    console.warn('请至少选择一张照片');
    return;
  }
  
  this.orders = this.orders.map(o =>
    o.id === orderId
      ? {
          ...o,
          selectedPhotos: [...this.selectedPhotos],
          status: 'completed' as OrderStatus
        }
      : o
  );
  
  // 更新统计
  this.appStats.pendingShoots--;
  this.appStats.completedCount++;
}

选片交互设计要点

  1. 数量限制:一寸照一版8张,限制选择数量符合实际

  2. 视觉反馈:选中照片有边框高亮 + 选中角标

  3. 放大预览:点击照片可放大查看细节

  4. 进度提示:显示"已选 X/8 张",让用户知道进度

6.4 打印配送流程

用户可以选择将照片打印出来配送到宿舍。

TypeScript 复制代码
/**
 * 选择打印配送
 */
private chooseDelivery(orderId: number, address: string): boolean {
  if (!address.trim()) {
    console.warn('请填写配送地址');
    return false;
  }
  
  this.orders = this.orders.map(o =>
    o.id === orderId
      ? {
          ...o,
          isPrinted: true,
          deliveryAddress: address,
          status: 'delivering' as OrderStatus
        }
      : o
  );
  
  return true;
}

/**
 * 确认送达
 */
private confirmDelivery(orderId: number): void {
  this.orders = this.orders.map(o =>
    o.id === orderId
      ? { ...o, status: 'finished' as OrderStatus }
      : o
  );
}

6.5 订单状态流转

证件照订单的状态机比普通电商订单更复杂,因为包含拍摄、选片等特殊环节。

Plaintext 复制代码
┌──────────┐
                    │  booked  │  已预约(待拍摄)
                    └────┬─────┘
                         │ 开始拍摄
                         ▼
                    ┌──────────┐
                    │ shooting │  拍摄中
                    └────┬─────┘
                         │ 拍摄完成
                         ▼
                   ┌────────────┐
                   │ selecting  │  待选片
                   └─────┬──────┘
                         │ 确认选片
                         ▼
                   ┌────────────┐
                   │ completed  │  已完成(电子照)
                   └─────┬──────┘
                         │ 申请打印
                         ▼
                   ┌────────────┐
                   │ delivering │  配送中
                   └─────┬──────┘
                         │ 确认送达
                         ▼
                   ┌────────────┐
                   │  finished  │  已结束
                   └────────────┘
                    
          booked  ──取消──→  cancelled
TypeScript 复制代码
/**
 * 更新订单状态
 */
private updateOrderStatus(orderId: number, newStatus: OrderStatus): void {
  this.orders = this.orders.map(o => {
    if (o.id !== orderId) return o;
    
    if (!this.canTransition(o.status, newStatus)) {
      console.warn(`无法从 ${o.status} 转换到 ${newStatus}`);
      return o;
    }
    
    return { ...o, status: newStatus };
  });
}

/**
 * 检查状态转换是否合法
 */
private canTransition(current: OrderStatus, target: OrderStatus): boolean {
  const validTransitions: Record<OrderStatus, OrderStatus[]> = {
    booked: ['shooting', 'cancelled'],
    shooting: ['selecting'],
    selecting: ['completed'],
    completed: ['delivering'],
    delivering: ['finished'],
    finished: [],
    cancelled: []
  };
  return validTransitions[current]?.includes(target) ?? false;
}

6.6 拍摄提醒机制

预约时间快到时提醒用户,避免错过拍摄。

TypeScript 复制代码
/**
 * 检查并发送拍摄提醒
 */
private checkShootingReminders(): void {
  const now = new Date();
  
  this.orders.forEach(order => {
    if (order.status !== 'booked') return;
    
    // 计算预约时间与当前时间的差值
    const shootTime = this.parseDateTime(order.appointmentDate, order.appointmentTime);
    const diffMinutes = (shootTime.getTime() - now.getTime()) / (1000 * 60);
    
    // 提前30分钟提醒
    if (diffMinutes <= 30 && diffMinutes > 0 && !order.reminderSent) {
      this.sendReminder(order);
      this.markReminderSent(order.id);
    }
  });
}

/**
 * 发送提醒(模拟)
 */
private sendReminder(order: PhotoOrder): void {
  console.log(`提醒:您的${order.sizeName}证件照预约将在30分钟后开始`);
  // 实际项目中可以接入推送通知 API
}

七、样式与交互动效

7.1 主题色系统

应用采用石板灰为主色调,传递"专业、正式、可靠"的摄影服务感:

用途 色值 说明
主色 #64748B 石板灰,按钮、标题、重点元素
主色深 #475569 深灰,渐变、强调
主色浅 #94A3B8 浅灰,次要元素
主色极浅 #F1F5F9 极浅灰,选中背景
背景色 #F8FAFC 页面背景
卡片背景 #FFFFFF 卡片、输入框
文字主色 #333333 正文
文字次色 #666666 辅助信息
文字弱色 #999999 次要信息
价格色 #EF4444 价格、重要数字
成功色 #10B981 完成、成功状态
警告色 #F59E0B 待处理、提醒状态

7.2 状态颜色体系

订单的 7 种状态各有专属颜色:

状态 颜色 语义
待拍摄 🟠 橙色 #F59E0B 等待中,需要关注
拍摄中 🔵 蓝色 #3B82F6 进行中
待选片 🟣 紫色 #8B5CF6 需要用户操作
已完成 🟢 绿色 #10B981 电子照完成
配送中 ⚫ 石板灰 #64748B 纸质照配送中
已结束 ⚪ 浅灰 #9CA3AF 全部完成
已取消 ⚪ 灰色 #9CA3AF 已取消

7.3 交互动效

TypeScript 复制代码
// 尺寸选择卡片点击动画
private animateSizeCard(): void {
  animateTo({
    duration: 200,
    curve: Curve.EaseInOut,
    onFinish: () => {
      animateTo({
        duration: 200,
        curve: Curve.EaseInOut
      }, () => {
        this.cardScale = 1;
      });
    }
  }, () => {
    this.cardScale = 0.95;
  });
}

// 背景色块切换动画
private animateColorChange(): void {
  animateTo({
    duration: 300,
    curve: Curve.EaseOut
  }, () => {
    this.previewBgColor = this.selectedColor;
  });
}

八、完整代码结构

由于篇幅限制,以上展示了核心组件和逻辑。完整的 Index.ets 文件包含所有组件定义、状态管理、业务逻辑和模拟数据,可直接在 DevEco Studio 中运行。

TypeScript 复制代码
// Index.ets 完整结构概览

@Entry
@Component
struct Index {
  // ===== 状态定义 =====
  @State orders: PhotoOrder[] = [];
  @State photoSizes: PhotoSize[] = [];
  @State samplePhotos: SamplePhoto[] = [];
  @State currentTab: number = 0;
  @State nextOrderId: number = 20;
  @State selectedSize: PhotoSize | null = null;
  @State selectedColor: string = 'blue';
  @State appStats: AppStats = { ... };
  @State showBookingDialog: boolean = false;
  @State showOrderDetail: boolean = false;
  @State selectedOrder: PhotoOrder | null = null;
  
  // ===== 生命周期 =====
  aboutToAppear() {
    this.initMockData();
    this.checkShootingReminders();
  }
  
  // ===== 业务方法 =====
  private bookPhoto(...) { ... }
  private selectSize(...) { ... }
  private selectBackgroundColor(...) { ... }
  private togglePhotoSelection(...) { ... }
  private confirmSelection(...) { ... }
  private chooseDelivery(...) { ... }
  private confirmDelivery(...) { ... }
  private updateOrderStatus(...) { ... }
  private canTransition(...) { ... }
  private checkShootingReminders(...) { ... }
  
  // ===== 页面构建 =====
  build() {
    Column() {
      if (this.currentTab === 0) { HeaderBanner() + StatsOverview() }
      Scroll() { /* 内容区 */ }
      BottomTabBar({ ... })
    }
  }
}

// ===== 子组件 =====
@Component struct HeaderBanner { ... }
@Component struct StatsOverview { ... }
@Component struct SizeSelector { ... }
@Component struct ColorPicker { ... }
@Component struct SampleGallery { ... }
@Component struct PriceTable { ... }
@Component struct TimeSlotPicker { ... }
@Component struct OrderList { ... }
@Component struct BottomTabBar { ... }
@CustomDialog struct BookingDialog { ... }

// ===== 接口与类型 =====
interface PhotoOrder { ... }
interface PhotoSize { ... }
interface SamplePhoto { ... }
interface Review { ... }
interface AppStats { ... }
interface BgColorOption { ... }
interface DateOption { ... }
type OrderStatus = ...;

九、运行与调试

9.1 运行步骤

  1. 打开 DevEco Studio,新建 HarmonyOS 项目(Empty Ability 模板)

  2. entry/src/main/ets/pages/Index.ets 替换为完整代码

  3. resources/base/media 中添加图标资源

  4. 连接 HarmonyOS 设备或启动模拟器

  5. 点击运行按钮 ▶️

9.2 常见问题

Q: 列表数据不更新?

  • A: ArkTS 中 @State 装饰的数组需要整体替换才能触发更新。使用 mapfilter、展开运算符等方式返回新数组。

Q: Grid 布局不生效?

  • A: 确保使用 Grid + GridItem 组合,并设置 columnsTemplate 定义列数。子元素必须是 GridItem

Q: 自定义弹窗不显示?

  • A: 确保 CustomDialogController 已正确初始化,且 @CustomDialog 装饰器已添加到组件上。

Q: 页面内容超出屏幕?

  • A: 使用 Scroll 组件包裹可滚动内容,固定区域放在 Scroll 外面。使用 layoutWeight(1) 让 Scroll 自动填充剩余空间。

Q: 渐变背景不生效?

  • A: linearGradient 需要设置在有尺寸的容器上。确保设置了 widthheight,并且容器有实际内容或明确尺寸。

9.3 调试技巧

  1. Previewer 预览:DevEco Studio 内置 Previewer,可实时预览 UI 效果

  2. 状态快照:在关键节点打印状态快照,追踪数据流

  3. 组件树检查:使用 DevTools 查看组件层级和属性

  4. 颜色调试:临时给组件加背景色,快速定位布局问题


十、技术要点总结

通过本项目,你将掌握以下 ArkTS 核心技能:

技术点 应用场景 重要程度
@State 状态管理 所有动态数据驱动 ⭐⭐⭐⭐⭐
@Component 自定义组件 模块化 UI 开发 ⭐⭐⭐⭐⭐
@Prop 父子组件通信 组件间数据传递 ⭐⭐⭐⭐⭐
@CustomDialog 自定义弹窗 预约表单、订单详情 ⭐⭐⭐⭐
@Builder 构建函数 UI 片段复用 ⭐⭐⭐⭐
Grid 网格布局 尺寸选择、样片展示 ⭐⭐⭐⭐
List + ForEach 列表 订单列表 ⭐⭐⭐⭐⭐
条件渲染(if/else) Tab 切换、状态显示 ⭐⭐⭐⭐
状态机模式 订单状态流转 ⭐⭐⭐⭐
Stack 叠加布局 横幅、色块选中效果 ⭐⭐⭐⭐

业务知识收获

除了技术技能,本项目还涵盖了丰富的业务知识:

  • 证件照规格体系:各种尺寸规格和用途

  • 摄影服务流程:预约→拍摄→选片→交付的完整链路

  • 价格策略:电子照 + 打印的组合定价

  • 选片体验:在线选片的交互设计

  • 配送服务:电子交付 + 实物配送的双模式


十一、扩展方向

本项目作为学习示例,还有大量可以扩展和深化的方向:

功能扩展

  1. AI 智能抠图:上传自拍,AI 自动抠图换背景

  2. 美颜修图:一键美颜、皮肤优化、证件照美化

  3. 合规检测:自动检测照片是否符合证件照规范(尺寸、比例、表情等)

  4. 电子照保存:保存到相册、分享到微信

  5. 照片冲印:多种尺寸、材质、数量的打印选项

  6. 拍照指南:教用户如何拍出合格的证件照

  7. 批量处理:一次处理多张不同规格

  8. 服装更换:AI 换装,添加正装效果

  9. 底片保存:保存原始底片,后续可重复出片

  10. 套餐优惠:多人团购、学生特惠等套餐

技术优化

  1. 相机接入:调用系统相机直接拍摄

  2. 图片处理:使用 Canvas 进行图片裁剪、缩放、换底

  3. 数据持久化:使用数据库保存订单和照片

  4. 网络请求:接入后端 API,实现真实预约

  5. 推送通知:预约提醒、订单状态变更推送

  6. 性能优化:图片懒加载、虚拟列表

  7. 深色模式:适配系统深色模式

  8. 国际化:支持多语言

架构升级

  1. MVVM 架构:引入 ViewModel 层,分离业务逻辑和 UI

  2. Repository 模式:数据层抽象,支持本地和远程数据源切换

  3. 图片处理模块:封装图片裁剪、压缩、换底等工具函数

  4. 单元测试:为业务逻辑编写单元测试

  5. 组件库:抽离通用组件,建立项目 UI 组件库

后端对接

  1. 预约系统:真实的预约排班、时段管理

  2. 照片存储:云存储用户照片,支持多端同步

  3. 支付系统:对接第三方支付

  4. 配送管理:配送员管理、配送轨迹追踪

  5. 评价系统:完整的评价和反馈体系

运行效果

相关推荐
爱写代码的阿森2 分钟前
鸿蒙三方库 | harmony-utils之PreferencesUtil用户首选项读写详解
华为·harmonyos·鸿蒙·huawei
达子6661 小时前
第6章_HarmonyOS 图解 Ability任务调度
华为·harmonyos
FrameNotWork1 小时前
HarmonyOS 6.0 分栏布局与折叠适配
华为·harmonyos
爱写代码的阿森1 小时前
鸿蒙三方库 | harmony-utils之PreferencesUtil首选项数据监听详解
服务器·华为·harmonyos·鸿蒙·huawei
爱写代码的森1 小时前
蒙三方库 | harmony-utils之FileUtil文件重命名与属性查询详解
linux·运维·服务器·华为·harmonyos·鸿蒙·huawei
二流小码农2 小时前
鸿蒙开发:以登录案例了解代码架构MVVM
android·ios·harmonyos
风华圆舞4 小时前
rawfile 资源与强类型词库加载器:schema / data / source 三层版本
harmonyos·arkui·resourcemanager·rawfile·arkts 编译·arkts 强类型
程序员黑豆4 小时前
鸿蒙应用开发:Grid组件实现九宫格布局教程
前端·华为·harmonyos
程序员黑豆6 小时前
鸿蒙应用开发:Flex 组件从入门到实战
前端·华为·harmonyos
<小智>6 小时前
鸿蒙多功能工具箱开发实战(二十)-性能优化与打包发布
ui·华为·harmonyos