HarmonyOS ArkTS 嵌入式弹窗(Embedded Dialog)实战:书籍管理小业务(可直接跑)

HarmonyOS ArkTS 嵌入式弹窗(Embedded Dialog)实战:书籍管理小业务(可直接跑)

鸿蒙第四期开发者活动

目标很明确:做一个“书籍列表 + 新增/编辑 + 删除确认”的小应用。
删除时弹出嵌入式对话框确认;表单校验失败也用嵌入式对话框提示。
整个弹窗都绑定当前页面,不会出现那种“切页面了弹窗还挂在上面”的尴尬问题。


0. 先把 Embedded Dialog 的“人话逻辑”讲清楚

很多人第一次用弹窗,会用全局 show,那种弹窗很“强势”:

  • 页面都切走了,它还可能在上层挡着
  • 生命周期不跟页面走,你要自己记得 close
  • 页面内的状态联动也不太自然(尤其多个页面跳转)

嵌入式弹窗的好处就是:
它更像“页面里的一块 UI”,跟着页面一起生、一起灭。

我在这套 demo 里用的策略很简单:

  • @State visible 控制弹窗是否显示
  • 需要弹窗 → visible = true
  • 弹窗按钮点击/取消 → visible = false
  • 弹窗配置里用 levelMode: LevelMode.EMBEDDED(关键点)

你会发现:代码读起来很顺,和页面状态是一套思路。


1. 真实业务场景设定(为什么选“书籍管理”)

这个场景其实非常接近你以后做的业务页:

  • 首页列表:展示数据(书名、作者、价格)
  • 详情/编辑页:输入框校验 + 保存
  • 删除这种操作:必须二次确认(否则用户误删会骂人)

所以我们用它来演示嵌入式弹窗,是非常合理的。


2. 可运行工程目录结构(你照着建就行)

我建议你直接在 DevEco Studio 新建一个 Stage 工程,然后按下面目录补齐文件。

BookShelfEmbeddedDialogDemo/
└── entry/
    └── src/
        └── main/
            ├── module.json5
            ├── ets/
            │   ├── entryability/
            │   │   └── EntryAbility.ets
            │   ├── pages/
            │   │   ├── Index.ets          // 书籍列表页(删除确认弹窗)
            │   │   └── EditBook.ets       // 新增/编辑页(校验失败提示弹窗)
            │   ├── components/
            │   │   └── EmbeddedConfirmDialog.ets  // 可复用嵌入式弹窗组件
            │   ├── model/
            │   │   └── Book.ets           // 数据模型
            │   └── store/
            │       └── BookStore.ets      // 简单数据仓库(内存版)
            └── resources/
                └── base/
                    └── element/
                        └── string.json

这套结构是典型“业务分层”:

  • model:数据结构
  • store:数据管理(以后可换成 RDB/网络请求)
  • pages:页面
  • components:可复用组件

3. 先写数据模型(Book)

entry/src/main/ets/model/Book.ets

export type Book = {
  id: string
  title: string
  author: string
  price: number
  updatedAt: number
}

export function now(): number {
  return Date.now()
}

export function uuid(): string {
  // demo 用的轻量 id:够用、好理解
  return `${Date.now()}_${Math.floor(Math.random() * 100000)}`
}

这里我加了 updatedAt,主要是为了列表页可以按更新时间排序,让它更像真实应用。


4. 写一个“可替换”的数据仓库(BookStore)

注意:这里用“内存数组”做存储,目的是让你能立刻跑起来
之后如果你要换成 RDB 或网络接口,只需要改 store 层,页面不用大动。

entry/src/main/ets/store/BookStore.ets

import { Book, now, uuid } from '../model/Book'

export class BookStore {
  private static _instance: BookStore | null = null
  static get I(): BookStore {
    if (!this._instance) this._instance = new BookStore()
    return this._instance
  }

  private books: Book[] = [
    { id: uuid(), title: '鸿蒙开发入门', author: '程奕红', price: 59.9, updatedAt: now() },
    { id: uuid(), title: 'ArkTS 实战', author: 'Cheng Yihong', price: 79.0, updatedAt: now() },
    { id: uuid(), title: 'UI 组件设计笔记', author: 'Harmony Dev', price: 45.5, updatedAt: now() },
  ]

  list(): Book[] {
    return this.books.slice().sort((a, b) => b.updatedAt - a.updatedAt)
  }

  getById(id: string): Book | undefined {
    return this.books.find(b => b.id === id)
  }

  upsert(input: Omit<Book, 'updatedAt'>): void {
    const idx = this.books.findIndex(b => b.id === input.id)
    const item: Book = { ...input, updatedAt: now() }
    if (idx >= 0) this.books[idx] = item
    else this.books.unshift(item)
  }

  remove(id: string): void {
    this.books = this.books.filter(b => b.id !== id)
  }
}

5. 核心:写一个“嵌入式确认弹窗”组件(可复用)

这个组件我故意做得“业务化”一点:
传 title、message、按钮文案、确认/取消回调,很多页面都能直接用。

entry/src/main/ets/components/EmbeddedConfirmDialog.ets

@Component
export struct EmbeddedConfirmDialog {
  @Link visible: boolean

  title: string = '提示'
  message: string = ''

  cancelText: string = '取消'
  confirmText: string = '确认'

  onCancel?: () => void
  onConfirm?: () => void

  private closeOnly() {
    this.visible = false
  }

  build() {
    if (!this.visible) {
      return
    }

    // ✅ 关键:levelMode: LevelMode.EMBEDDED
    AlertDialog.show({
      title: this.title,
      message: this.message,
      autoCancel: false,
      alignment: DialogAlignment.Center,
      levelMode: LevelMode.EMBEDDED,

      primaryButton: {
        value: this.confirmText,
        action: () => {
          this.closeOnly()
          this.onConfirm?.()
        }
      },
      secondaryButton: {
        value: this.cancelText,
        action: () => {
          this.closeOnly()
          this.onCancel?.()
        }
      },
      onCancel: () => {
        this.closeOnly()
        this.onCancel?.()
      }
    })
  }
}

为什么我这里要 autoCancel: false

因为删除确认这种弹窗,最怕用户误触外部就直接消失(用户以为没点上)。
真实项目里我一般会让用户明确点按钮。


6. 首页:书籍列表(删除确认弹窗就在这里)

交互流程(很真实)

  • 用户点“删除”
  • 我先把要删的书 id/title 存起来
  • confirmVisible = true
  • 弹窗显示“确认删除《xxx》吗”
  • 用户点“删除” → 执行 remove → reload 刷新列表

entry/src/main/ets/pages/Index.ets

import { router } from '@kit.ArkUI'
import { Book } from '../model/Book'
import { BookStore } from '../store/BookStore'
import { EmbeddedConfirmDialog } from '../components/EmbeddedConfirmDialog'

@Entry
@Component
struct Index {
  @State books: Book[] = []

  // 删除确认弹窗
  @State confirmVisible: boolean = false
  @State pendingDeleteId: string = ''
  @State pendingDeleteTitle: string = ''

  aboutToAppear() {
    this.reload()
  }

  private reload() {
    this.books = BookStore.I.list()
  }

  private goAdd() {
    router.pushUrl({ url: 'pages/EditBook', params: { mode: 'add' } })
  }

  private goEdit(book: Book) {
    router.pushUrl({ url: 'pages/EditBook', params: { mode: 'edit', id: book.id } })
  }

  private askDelete(book: Book) {
    this.pendingDeleteId = book.id
    this.pendingDeleteTitle = book.title
    this.confirmVisible = true
  }

  private doDelete() {
    BookStore.I.remove(this.pendingDeleteId)
    this.pendingDeleteId = ''
    this.pendingDeleteTitle = ''
    this.reload()
  }

  build() {
    Column() {
      Row() {
        Text('📚 书架(Embedded Dialog Demo)')
          .fontSize(18)
          .fontWeight(FontWeight.Medium)

        Blank()

        Button('新增')
          .onClick(() => this.goAdd())
      }
      .width('100%')
      .padding(12)

      Divider()

      List() {
        ForEach(this.books, (b: Book) => {
          ListItem() {
            Row() {
              Column() {
                Text(b.title).fontSize(16).fontWeight(FontWeight.Medium)
                Text(`作者:${b.author}  ·  ¥${b.price.toFixed(2)}`)
                  .fontSize(12)
                  .fontColor('#666')
              }
              .layoutWeight(1)

              Button('编辑')
                .margin({ right: 8 })
                .onClick(() => this.goEdit(b))

              Button('删除')
                .backgroundColor('#E5484D')
                .fontColor('#FFF')
                .onClick(() => this.askDelete(b))
            }
            .padding(12)
          }
        }, (b: Book) => b.id)
      }
      .layoutWeight(1)

      // ✅ 嵌入式确认弹窗:归属当前页面
      EmbeddedConfirmDialog({
        visible: $confirmVisible,
        title: '确认删除',
        message: `确定要删除《${this.pendingDeleteTitle}》吗?删除后无法恢复。`,
        cancelText: '我再想想',
        confirmText: '删除',
        onConfirm: () => this.doDelete()
      })
    }
    .height('100%')
  }
}

7. 新增/编辑页:做校验失败提示(也是嵌入式弹窗)

真实业务里表单最常见的坑就是:

  • 用户输入空
  • 用户价格输入“abc”
  • 用户输入负数
  • 用户输入特别离谱的数(比如 999999)

所以我给你写了一套比较“像产品要求”的校验逻辑。

entry/src/main/ets/pages/EditBook.ets

import { router } from '@kit.ArkUI'
import { BookStore } from '../store/BookStore'
import { Book, uuid } from '../model/Book'
import { EmbeddedConfirmDialog } from '../components/EmbeddedConfirmDialog'

@Entry
@Component
struct EditBook {
  @State mode: 'add' | 'edit' = 'add'
  @State id: string = ''
  @State title: string = ''
  @State author: string = ''
  @State priceText: string = ''

  // 校验提示弹窗
  @State errorVisible: boolean = false
  @State errorMsg: string = ''

  aboutToAppear() {
    const params = router.getParams() as Record<string, any> | undefined
    const mode = (params?.mode as string) ?? 'add'
    this.mode = (mode === 'edit') ? 'edit' : 'add'

    if (this.mode === 'edit') {
      const id = String(params?.id ?? '')
      this.id = id
      const book = BookStore.I.getById(id)
      if (book) {
        this.title = book.title
        this.author = book.author
        this.priceText = String(book.price)
      } else {
        this.showError('没有找到这本书,可能已经被删除了。')
      }
    } else {
      this.id = uuid()
      this.title = ''
      this.author = ''
      this.priceText = ''
    }
  }

  private showError(msg: string) {
    this.errorMsg = msg
    this.errorVisible = true
  }

  private validate(): number | null {
    const t = this.title.trim()
    const a = this.author.trim()
    const p = this.priceText.trim()

    if (!t) { this.showError('书名不能为空。'); return null }
    if (t.length > 30) { this.showError('书名太长了(建议 30 字以内)。'); return null }
    if (!a) { this.showError('作者不能为空。'); return null }

    const price = Number(p)
    if (!p || Number.isNaN(price)) { this.showError('价格请输入数字,例如 59.9'); return null }
    if (price <= 0) { this.showError('价格要大于 0'); return null }
    if (price > 9999) { this.showError('价格太离谱了(>9999),检查一下输入。'); return null }

    return price
  }

  private save() {
    const price = this.validate()
    if (price === null) return

    const book: Omit<Book, 'updatedAt'> = {
      id: this.id,
      title: this.title.trim(),
      author: this.author.trim(),
      price
    }
    BookStore.I.upsert(book)
    router.back()
  }

  build() {
    Column() {
      Row() {
        Text(this.mode === 'add' ? '新增书籍' : '编辑书籍')
          .fontSize(18)
          .fontWeight(FontWeight.Medium)

        Blank()

        Button('保存')
          .onClick(() => this.save())
      }
      .width('100%')
      .padding(12)

      Divider()

      Column() {
        Text('书名').fontSize(12).fontColor('#666')
        TextInput({ text: this.title, placeholder: '例如:ArkTS 实战' })
          .onChange(v => this.title = v)
          .margin({ bottom: 12 })

        Text('作者').fontSize(12).fontColor('#666')
        TextInput({ text: this.author, placeholder: '例如:程奕红' })
          .onChange(v => this.author = v)
          .margin({ bottom: 12 })

        Text('价格').fontSize(12).fontColor('#666')
        TextInput({ text: this.priceText, placeholder: '例如:59.9' })
          .type(InputType.Number)
          .onChange(v => this.priceText = v)
      }
      .padding(12)

      // ✅ 校验提示:嵌入式弹窗
      EmbeddedConfirmDialog({
        visible: $errorVisible,
        title: '填写有问题',
        message: this.errorMsg,
        cancelText: '知道了',
        confirmText: '去修改'
      })
    }
    .height('100%')
  }
}

8. 入口 Ability(保证能加载首页)

entry/src/main/ets/entryability/EntryAbility.ets

import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'
import { window } from '@kit.ArkUI'

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam) { }

  onWindowStageCreate(windowStage: window.WindowStage) {
    windowStage.loadContent('pages/Index', (err) => {
      if (err.code) {
        console.error('loadContent failed: ' + JSON.stringify(err))
      }
    })
  }
}

9. module.json5(常见工程默认有,确认 pages 指向 Index)

entry/src/main/module.json5

{
  "module": {
    "name": "entry",
    "type": "entry",
    "mainElement": "EntryAbility",
    "deviceTypes": ["phone", "tablet"],
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "exported": true
      }
    ],
    "pages": "pages/Index"
  }
}

10. 你跑起来之后,应该看到什么(对照检查)

首页

  • 顶部:📚 书架(Embedded Dialog Demo) + 新增按钮
  • 列表 3 条数据(标题/作者/价格)
  • 每条右侧:编辑、删除

删除

  • 点删除 → 出现“确认删除《xxx》吗?”
  • 点“删除” → 列表立刻少一条
  • 点“我再想想” → 关闭,不删

新增/编辑

  • 新增:输入书名/作者/价格
  • 不填/填错:弹出“填写有问题”的嵌入式提示
  • 保存成功:返回首页,新增记录在顶部

11. 我给你补一句“真实项目里最容易踩的坑”

  1. 弹窗显示条件要可控
    别在 build 里每次都 show,不然你会遇到“状态更新导致重复弹出”的问题。
    我这套用 visible + show,确保只在你想弹的时候弹。
  2. 跨页面不要复用同一个弹窗实例
    嵌入式弹窗是“页面私有”的,页面 A 的弹窗不要拿去页面 B 用(很容易乱)。
  3. 删除确认按钮建议危险色
    我已经给“删除”按钮上了红色背景,这个是产品常规操作,用户更不容易误点。
posted @ 2025-12-22 08:54  想喝冰咖啡  阅读(0)  评论(0)    收藏  举报