<!--
source: https://blueos.niceshare.site/guide/todo-app/
site: 蓝河文档馆
summary: 用列表页、详情页、本地存储和一次网络请求，串起一个完整的蓝河小应用。
-->
---
title: 做一个待办应用
description: 用列表页、详情页、本地存储和一次网络请求，串起一个完整的蓝河小应用。
---

这篇不引入新概念，只把前面学过的东西拼成一个能用的待办清单。

**学完能做什么：** 在 Studio 里对照粘贴，得到列表 + 详情 + 刷新后数据还在的应用；启动时尝试拉一句提示，失败则用本地文案。

**前置：** [数据、列表与事件](/guide/data-list-events/)。导入名以 [页面路由](/api/system/router/) 为准：`@blueos.app.appmanager.router`（部分旧示例写成 `@blueos.app.router`）。

数据形状固定为 `{ id, title, done }`。没有后端、没有登录。

## 1. 工程结构

在 Studio 新建工程后，改成：

```text
src/
  app.ux
  manifest.json
  pages/
    Todos/index.ux
    TodoDetail/index.ux
```

## 2. manifest.json

```json
{
  "package": "com.example.todos",
  "name": "待办",
  "icon": "/assets/images/logo.png",
  "versionName": "1.0",
  "versionCode": 1,
  "appCategory": ["utilities"],
  "config": {
    "designWidth": 466
  },
  "features": [
    { "name": "blueos.storage.storage" },
    { "name": "blueos.network.fetch" }
  ],
  "router": {
    "entry": "Todos",
    "pages": {
      "Todos": { "component": "index" },
      "TodoDetail": { "component": "index" }
    }
  }
}
```

图标路径改成你工程里真实的 icon。存储见 [K-V 数据存储](/api/storage/localstorage/)，请求见 [数据请求](/api/system/fetch/)。

## 3. app.ux

```html
<script>
  /**manifest**/
  export default {}
</script>
```

不要删除 `/**manifest**/`。

## 4. 列表页 `src/pages/Todos/index.ux`

```html
<script>
  import router from '@blueos.app.appmanager.router'
  import storage from '@blueos.storage.storage'
  import fetch from '@blueos.network.fetch'

  export default {
    data: {
      tip: '今天也值得认真对待',
      newTitle: '',
      todos: [],
    },
    onInit() {
      this.loadTodos()
      this.loadTip()
    },
    onShow() {
      this.loadTodos()
    },
    loadTodos() {
      storage.get({
        key: 'todos',
        success: (data) => {
          this.todos = Array.isArray(data) ? data : []
        },
        fail: () => {
          this.todos = []
        },
      })
    },
    saveTodos() {
      storage.set({
        key: 'todos',
        value: this.todos,
        fail: () => {},
      })
    },
    loadTip() {
      fetch.fetch({
        url: 'https://httpbin.org/json',
        responseType: 'json',
        success: (response) => {
          const title = response.data && response.data.slideshow && response.data.slideshow.title
          if (title) {
            this.tip = title
          }
        },
        fail: () => {},
      })
    },
    onTitleChange(evt) {
      this.newTitle = evt.value
    },
    addTodo() {
      const title = (this.newTitle || '').trim()
      if (!title) {
        return
      }
      const next = this.todos.concat([
        { id: String(Date.now()), title: title, done: false },
      ])
      this.todos = next
      this.newTitle = ''
      this.saveTodos()
    },
    openDetail(item) {
      router.push({
        uri: '/TodoDetail',
        params: { id: item.id },
      })
    },
  }
</script>

<template>
  <div class="flex flex-col w-full">
    <text class="text-white text-2xl">{{ tip }}</text>
    <div class="flex items-center w-full mt-10">
      <input class="title-input text-white" type="text" value="{{newTitle}}" placeholder="新的待办" onchange="onTitleChange" />
      <input class="text-white" type="button" value="添加" onclick="addTodo" />
    </div>
    <text if="{{todos.length === 0}}" class="text-white mt-10">还没有待办</text>
    <list class="w-full mt-10">
      <list-item type="todo" for="{{(index, item) in todos}}" tid="id" onclick="openDetail(item)">
        <div class="flex items-center w-full">
          <text class="text-white">{{ item.title }}</text>
        </div>
      </list-item>
    </list>
  </div>
</template>

<style>
@tailwind utilities;

.title-input {
  flex: 1;
}
</style>
```

`onShow` 会在每次回到列表时重新读取存储（详情页返回后才会看到最新状态）。

无网时 `loadTip` 走 `fail`，顶部保持默认文案。这是教学预期，不是缺陷。

## 5. 详情页 `src/pages/TodoDetail/index.ux`

路由参数会挂到页面实例上，类型是字符串。用 `params.id` 时，详情页通过 `this.id` 读取，所以 `id` 也要出现在 `data` 里。

```html
<script>
  import router from '@blueos.app.appmanager.router'
  import storage from '@blueos.storage.storage'

  export default {
    data: {
      id: '',
      title: '',
      done: false,
      todos: [],
    },
    onInit() {
      this.loadAndShow()
    },
    loadAndShow() {
      storage.get({
        key: 'todos',
        success: (data) => {
          const todos = Array.isArray(data) ? data : []
          this.todos = todos
          const found = todos.filter((item) => item.id === this.id)[0]
          if (found) {
            this.title = found.title
            this.done = found.done
          }
        },
        fail: () => {
          this.todos = []
        },
      })
    },
    saveTodos() {
      storage.set({
        key: 'todos',
        value: this.todos,
        fail: () => {},
      })
    },
    toggleDone() {
      const done = !this.done
      const next = this.todos.map((item) => {
        if (item.id === this.id) {
          return { id: item.id, title: item.title, done: done }
        }
        return item
      })
      this.todos = next
      this.done = done
      this.saveTodos()
    },
    removeTodo() {
      this.todos = this.todos.filter((item) => item.id !== this.id)
      storage.set({
        key: 'todos',
        value: this.todos,
        success: () => {
          router.back()
        },
        fail: () => {},
      })
    },
  }
</script>

<template>
  <div class="flex flex-col items-center w-full">
    <text class="text-white text-3xl">{{ title }}</text>
    <text class="text-white mt-10">{{ done ? '已完成' : '未完成' }}</text>
    <input class="text-white mt-10" type="button" value="切换完成" onclick="toggleDone" />
    <input class="text-white mt-10" type="button" value="删除" onclick="removeTodo" />
  </div>
</template>

<style>
@tailwind utilities;
</style>
```

从列表点进详情，切换完成或删除后返回。杀进程再进应用，列表应仍在（存储成功的前提下）。

删除后要等存储写入成功再返回，否则列表可能读到旧数据。

## 本页要点

- 两页都要写进 `router.pages`；存储和 fetch 要写进 `features`。
- 更新列表用 `this.todos = next`，不要只改 `this.todos[i].done`。
- 每个异步 API 都带 `fail`；请求失败就保留本地 `tip`。

下一篇：[常见问题](/guide/pitfalls/)
