HarmonyOS APP开发---「积木图」乐高MOC管理App

1 阅读8分钟

如果你想做一个乐高MOC管理App,需要用到Preferences和零件库存

想要高效管理你的乐高MOC项目和零件库存?打开鸿蒙应用市场,搜索「积木图」下载体验,让每一个零件都清清楚楚。


写在前面

玩乐高MOC(My Own Creation,自己设计创作)的人都知道,最头疼的事情不是设计本身,而是零件管理。你设计了一个超酷的模型,打开零件清单一看——需要24个2x4红色砖块,你手里只有18个;需要6个科技系列连接销,你一个都没有。要么临时去买,要么改设计,都很麻烦。

「积木图」这个App就是帮你管这些的。你把MOC项目的零件清单录入进去,App自动对比你的库存,标记出缺件。每个步骤分页展示,用到哪些零件一目了然。零件库存也可以单独管理,买新零件了就更新数量。

这篇文章重点聊:零件库存的数据结构设计、用量统计和缺件计算、步骤分页的展示逻辑。

这篇文章聊什么

  1. 零件数据模型——零件编号、名称、颜色、类别、库存数量
  2. MOC项目数据结构——步骤列表、每个步骤的零件清单
  3. 用量统计——统计整个项目需要多少个每种零件
  4. 缺件计算——需求量减去库存量,标记缺件
  5. 步骤分页展示——每个步骤单独一页,显示该步骤需要的零件

第一步:零件数据模型

乐高零件的标识系统很规范。每个零件有一个唯一的零件编号(Part Number),加上颜色就确定了具体的零件。比如 "3001" 是2x4砖块,"3001 Red" 就是红色2x4砖块。

React版本的零件数据

// 零件类别
const PART_CATEGORIES = [
  'brick',      // 砖块
  'plate',     // 板
  'tile',      // 光面板
  'slope',     // 斜面
  'technic',   // 科技系列
  'minifig',   // 人仔
  'accessory', // 配件
  'other',     // 其他
];

// 库存中的一个零件
const createPart = () => ({
  partId: '',              // 零件编号,如 "3001"
  name: '',                // 名称,如 "Brick 2x4"
  color: '',               // 颜色,如 "Red"
  category: 'brick',        // 类别
  quantity: 0,              // 库存数量
  imageUrl: '',             // 零件图片URL(可选)
});

// MOC项目的一个步骤
const createStep = () => ({
  stepNumber: 1,
  description: '',
  parts: [],                // 该步骤需要的零件 [{partId, color, quantity}]
  notes: '',
});

// MOC项目
const createMOCProject = () => ({
  id: Date.now(),
  name: '',
  author: '',
  steps: [],
  totalParts: 0,            // 总零件种类数
  totalPieces: 0,           // 总零件个数
  missingParts: [],          // 缺件列表
  createdAt: new Date().toISOString(),
});

parts 数组里的每个元素是 partId + color + quantity。为什么需要颜色?因为同一个零件编号可能有多种颜色,红色2x4砖块和蓝色2x4砖块是不同的零件。

ArkTS版本的零件数据模型

// 零件类别
enum PartCategory {
  BRICK = 'brick',
  PLATE = 'plate',
  TILE = 'tile',
  SLOPE = 'slope',
  TECHNIC = 'technic',
  MINIFIG = 'minifig',
  ACCESSORY = 'accessory',
  OTHER = 'other',
}

// 库存零件
interface InventoryPart {
  partId: string;
  name: string;
  color: string;
  category: PartCategory;
  quantity: number;
  imageUrl: string;
}

// 步骤中的零件需求
interface StepPartNeed {
  partId: string;
  color: string;
  quantity: number;         // 需要的数量
}

// MOC步骤
interface MOCStep {
  stepNumber: number;
  description: string;
  parts: StepPartNeed[];
  notes: string;
}

// 缺件信息
interface MissingPart {
  partId: string;
  color: string;
  needed: number;           // 需要数量
  have: number;              // 库存数量
  short: number;             // 缺少数量
}

// MOC项目
interface MOCProject {
  id: number;
  name: string;
  author: string;
  steps: MOCStep[];
  totalParts: number;
  totalPieces: number;
  missingParts: MissingPart[];
  createdAt: string;
}

MissingPart 接口比 StepPartNeed 多了两个字段:have(库存数量)和 short(缺少数量)。short = needed - have,如果 short > 0 就说明缺件。

第二步:用量统计——汇总整个项目的零件需求

一个MOC项目可能有几十个步骤,每个步骤用到不同的零件。我们需要把所有步骤的零件需求汇总起来,统计每种零件的总需求量。

React版本:用量统计

function calculatePartUsage(steps) {
  const usageMap = {};

  steps.forEach((step) => {
    step.parts.forEach((part) => {
      // 用 partId + color 作为唯一标识
      const key = `${part.partId}_${part.color}`;
      if (!usageMap[key]) {
        usageMap[key] = {
          partId: part.partId,
          color: part.color,
          totalNeeded: 0,
          usedInSteps: [],
        };
      }
      usageMap[key].totalNeeded += part.quantity;
      usageMap[key].usedInSteps.push(step.stepNumber);
    });
  });

  return Object.values(usageMap);
}

usedInSteps 记录了每种零件在哪些步骤中被使用。这个信息很有用——如果你发现某个零件缺了,你就知道它影响的是哪些步骤,可以优先处理。

ArkTS版本:用量统计

interface PartUsage {
  partId: string;
  color: string;
  totalNeeded: number;
  usedInSteps: number[];
}

function calculatePartUsage(steps: MOCStep[]): PartUsage[] {
  const usageMap: Map<string, PartUsage> = new Map();

  steps.forEach((step) => {
    step.parts.forEach((part) => {
      const key = `${part.partId}_${part.color}`;

      if (usageMap.has(key)) {
        const existing = usageMap.get(key)!;
        existing.totalNeeded += part.quantity;
        existing.usedInSteps.push(step.stepNumber);
      } else {
        usageMap.set(key, {
          partId: part.partId,
          color: part.color,
          totalNeeded: part.quantity,
          usedInSteps: [step.stepNumber],
        });
      }
    });
  });

  return Array.from(usageMap.values());
}

ArkTS版本用了 Map 来做汇总,比用普通对象更规范。Map.get(key)! 后面的感叹号是非空断言,因为我们已经通过 has 确认了key存在,所以这里不会是undefined。

第三步:缺件计算

有了用量统计和库存数据,缺件计算就是一一对比。

React版本:缺件计算

function calculateMissingParts(partUsage, inventory) {
  const missing = [];

  partUsage.forEach((usage) => {
    // 在库存中查找匹配的零件
    const invPart = inventory.find(
      (p) => p.partId === usage.partId && p.color === usage.color
    );

    const have = invPart ? invPart.quantity : 0;
    const short = usage.totalNeeded - have;

    if (short > 0) {
      missing.push({
        partId: usage.partId,
        color: usage.color,
        needed: usage.totalNeeded,
        have,
        short,
      });
    }
  });

  // 按缺少数量从大到小排序
  missing.sort((a, b) => b.short - a.short);

  return missing;
}

缺件按缺少数量从大到小排序,这样最紧缺的零件排在前面,用户一眼就能看到最需要优先购买的零件。

ArkTS版本:缺件计算

function calculateMissingParts(
  partUsage: PartUsage[],
  inventory: InventoryPart[]
): MissingPart[] {
  const missing: MissingPart[] = [];

  partUsage.forEach((usage) => {
    const invPart = inventory.find(
      (p) => p.partId === usage.partId && p.color === usage.color
    );

    const have = invPart ? invPart.quantity : 0;
    const short = usage.totalNeeded - have;

    if (short > 0) {
      missing.push({
        partId: usage.partId,
        color: usage.color,
        needed: usage.totalNeeded,
        have,
        short,
      });
    }
  });

  // 按缺少数量排序
  missing.sort((a, b) => b.short - a.short);

  return missing;
}

逻辑和React版本完全一样。这里有个优化空间:如果零件很多,用 find 去遍历库存数组效率不高。更好的做法是先把库存转成一个Map,用 partId_color 作为key,这样查找就是O(1)了。但对于几十到几百个零件的规模,find 完全够用。

第四步:步骤分页展示

MOC的搭建说明通常是分步骤的。每个步骤展示需要的零件和操作说明。我们用分页(Swiper)组件来实现。

ArkTS版本:步骤分页

@Component
struct StepPageView {
  steps: MOCStep[] = [];
  inventory: InventoryPart[] = [];

  build() {
    Swiper() {
      ForEach(this.steps, (step: MOCStep) => {
        this.StepContent(step);
      }, (step: MOCStep) => `step_${step.stepNumber}`);
    }
    .index(0)
    .autoPlay(false)
    .indicator(true)
    .loop(false)
    .width('100%')
    .height('100%');
  }

  @Builder
  StepContent(step: MOCStep) {
    Scroll() {
      Column({ space: 16 }) {
        // 步骤标题
        Row() {
          Text(`步骤 ${step.stepNumber}`)
            .fontSize(22)
            .fontWeight(FontWeight.Bold)
            .fontColor('#E65100');
          Text(`/ 共 ${this.steps.length} 步`)
            .fontSize(14)
            .fontColor('#999999')
            .margin({ left: 8 });
        }
        .width('100%');

        // 步骤描述
        if (step.description) {
          Text(step.description)
            .fontSize(14)
            .fontColor('#666666')
            .width('100%');
        }

        // 该步骤的零件列表
        Text('本步骤零件')
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .margin({ top: 8 });

        ForEach(step.parts, (part: StepPartNeed) => {
          this.PartRow(part);
        }, (part: StepPartNeed) => `${part.partId}_${part.color}`);

        // 备注
        if (step.notes) {
          Text(step.notes)
            .fontSize(13)
            .fontColor('#999999')
            .fontStyle(FontStyle.Italic)
            .margin({ top: 8 });
        }
      }
      .padding(20);
    }
    .scrollable(ScrollDirection.Vertical);
  }

  @Builder
  PartRow(part: StepPartNeed) {
    Row({ space: 12 }) {
      // 零件颜色色块
      Column()
        .width(32)
        .height(32)
        .borderRadius(4)
        .backgroundColor(this.getColorHex(part.color))
        .borderWidth(1)
        .borderColor('#E0E0E0');

      // 零件信息
      Column({ space: 2 }) {
        Text(part.partId)
          .fontSize(14)
          .fontWeight(FontWeight.Medium);
        Text(part.color)
          .fontSize(12)
          .fontColor('#999999');
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start);

      // 需要数量
      Text(`x${part.quantity}`)
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#333333');
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#FAFAFA')
    .borderRadius(8);
  }

  // 颜色名称转十六进制(简化版,实际应用中应该用完整的映射表)
  getColorHex(colorName: string): string {
    const colorMap: Record<string, string> = {
      'Red': '#B71C1C',
      'Blue': '#0D47A1',
      'Green': '#1B5E20',
      'Yellow': '#F57F17',
      'White': '#FAFAFA',
      'Black': '#212121',
      'Orange': '#E65100',
      'Gray': '#757575',
      'Light Gray': '#BDBDBD',
      'Brown': '#4E342E',
      'Tan': '#D7CCC8',
      'Dark Blue': '#1A237E',
      'Dark Green': '#004D40',
      'Lime': '#827717',
      'Pink': '#C2185B',
      'Purple': '#4A148C',
    };
    return colorMap[colorName] || '#9E9E9E';
  }
}

Swiper 是鸿蒙的分页滑动组件,非常适合步骤浏览的场景。loop: false 禁止循环滑动——到最后一步就不能再往右滑了,符合步骤的线性逻辑。indicator: true 显示底部的页面指示器,让用户知道当前在第几步。

getColorHex 是一个颜色名称到十六进制值的映射。实际应用中,乐高的颜色名称有上百种,这里只列了常用的十几种。完整的映射可以维护在一个单独的配置文件中。

第五步:库存管理和缺件标记

ArkTS版本:缺件标记组件

@Component
struct MissingPartsPanel {
  missingParts: MissingPart[] = [];

  build() {
    Column({ space: 12 }) {
      Text('缺件清单')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#D32F2F');

      if (this.missingParts.length === 0) {
        Text('零件齐全,可以开始搭建')
          .fontSize(14)
          .fontColor('#4CAF50')
          .textAlign(TextAlign.Center)
          .width('100%')
          .padding(24);
      } else {
        Text(`共缺 ${this.missingParts.length} 种零件`)
          .fontSize(14)
          .fontColor('#FF5252');

        ForEach(this.missingParts, (part: MissingPart) => {
          Row({ space: 12 }) {
            Column({ space: 2 }) {
              Text(`${part.partId} (${part.color})`)
                .fontSize(14)
                .fontWeight(FontWeight.Medium);
              Text(`需要 ${part.needed} 个,库存 ${part.have} 个`)
                .fontSize(12)
                .fontColor('#666666');
            }
            .layoutWeight(1);

            // 缺少数量,红色高亮
            Text(`缺 ${part.short}`)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#D32F2F')
              .padding({ left: 8, right: 8, top: 4, bottom: 4 })
              .backgroundColor('#FFEBEE')
              .borderRadius(4);
          }
          .width('100%')
          .padding(12)
          .backgroundColor('#FFF5F5')
          .borderRadius(8);
        }, (part: MissingPart) => `${part.partId}_${part.color}`);
      }
    }
    .width('100%')
    .padding(16);
  }
}

缺件面板用红色系配色来强调紧迫感。每种缺件显示零件编号、颜色、需要数量、库存数量和缺少数量。如果零件齐全,显示绿色的"零件齐全"提示。

流程图

flowchart TD
    A[打开积木图] --> B{选择操作}
    B -->|新建MOC项目| C[输入项目名称]
    B -->|管理库存| D[添加/编辑零件库存]
    B -->|查看项目| E[加载已有项目]
    C --> F[添加搭建步骤]
    F --> G[为每个步骤添加零件需求]
    G --> H[保存项目]
    H --> I[统计零件用量]
    I --> J[对比库存计算缺件]
    J --> K{有缺件?}
    K -->|是| L[显示缺件清单]
    K -->|否| M[显示零件齐全]
    L --> N[按缺少数量排序]
    N --> O[用户购买缺件]
    O --> P[更新库存数量]
    P --> Q[重新计算缺件]
    E --> R[分页浏览步骤]
    R --> S[查看每步零件需求]
    S --> T[标记已完成的步骤]

React vs ArkTS 对比表

对比项React (Web)ArkTS (鸿蒙)
分页组件轮播库或CSS scroll-snapSwiper组件
零件列表map + divForEach + Row/Column
颜色映射JS对象Record类型
数据汇总reduce + 对象Map + 遍历
缺件排序Array.sort同React
数据存储localStoragePreferences

总结

这篇文章我们实现了一个乐高MOC管理App的核心功能。几个要点:

  1. 零件用 partId + color 唯一标识:同一个零件编号可能有多种颜色,必须组合起来才能确定具体零件。
  2. 用量统计用Map汇总:遍历所有步骤的零件需求,按 partId_color 累加数量,同时记录使用了哪些步骤。
  3. 缺件计算是需求减库存:简单的减法,但要注意库存中找不到的零件视为库存为0。
  4. 步骤分页用Swiper:鸿蒙的Swiper组件天然适合步骤浏览,禁止循环和自动播放。
  5. 缺件按紧缺程度排序:缺少最多的排在前面,方便用户优先采购。

乐高MOC的乐趣在于创造,但创造的基础是零件管理到位。希望积木图能帮你把零件管得明明白白,把精力集中在设计上。