lakutata 3.1.4 → 3.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +11 -11
  2. package/README.zh-CN.md +5 -5
  3. package/dist/cjs/exports/NwjsAdapter.d.ts +1 -0
  4. package/dist/cjs/exports/NwjsAdapter.js +20 -0
  5. package/dist/cjs/exports/ReactNativeAdapter.d.ts +1 -0
  6. package/dist/cjs/exports/ReactNativeAdapter.js +20 -0
  7. package/dist/esm/exports/NwjsAdapter.js +1 -0
  8. package/dist/esm/exports/ReactNativeAdapter.js +1 -0
  9. package/dist/types/exports/NwjsAdapter.d.ts +1 -0
  10. package/dist/types/exports/ReactNativeAdapter.d.ts +1 -0
  11. package/doc/en/Application.md +6 -1
  12. package/doc/en/Barcode.md +386 -0
  13. package/doc/en/Cache.md +2 -1
  14. package/doc/en/Command.md +20 -4
  15. package/doc/en/ContainerdIntegration.md +1 -1
  16. package/doc/en/Database.md +4 -0
  17. package/doc/en/DatabaseDrivers.md +75 -4
  18. package/doc/en/Entrypoint.md +4 -2
  19. package/doc/en/Exceptions.md +1 -1
  20. package/doc/en/FFI.md +1 -1
  21. package/doc/en/GRPC.md +1 -1
  22. package/doc/en/GettingStarted.md +16 -2
  23. package/doc/en/HTTP.md +1 -0
  24. package/doc/en/I18n.md +1 -1
  25. package/doc/en/Installers.md +34 -6
  26. package/doc/en/Math.md +1 -1
  27. package/doc/en/Migrations.md +2 -2
  28. package/doc/en/NativeAddons.md +12 -2
  29. package/doc/en/Overview.md +14 -11
  30. package/doc/en/Packaging.md +8 -1
  31. package/doc/en/ProjectStructure.md +18 -3
  32. package/doc/en/ServiceCalls.md +85 -5
  33. package/doc/en/UpgradingFrom1.md +2 -2
  34. package/doc/en/UpgradingFrom2.md +4 -4
  35. package/doc/en/WebRTC.md +4 -4
  36. package/doc/en/examples/MobileApp.md +730 -0
  37. package/doc/en/examples/README.md +2 -0
  38. package/doc/en/examples/TrayApp.md +1061 -0
  39. package/doc/zh/Application.md +3 -1
  40. package/doc/zh/Barcode.md +313 -0
  41. package/doc/zh/Cache.md +1 -1
  42. package/doc/zh/Command.md +12 -3
  43. package/doc/zh/ContainerdIntegration.md +1 -1
  44. package/doc/zh/Database.md +2 -0
  45. package/doc/zh/DatabaseDrivers.md +52 -2
  46. package/doc/zh/Entrypoint.md +3 -1
  47. package/doc/zh/Exceptions.md +1 -1
  48. package/doc/zh/FFI.md +1 -1
  49. package/doc/zh/GRPC.md +1 -1
  50. package/doc/zh/GettingStarted.md +12 -2
  51. package/doc/zh/HTTP.md +1 -0
  52. package/doc/zh/I18n.md +1 -1
  53. package/doc/zh/Installers.md +16 -5
  54. package/doc/zh/Math.md +1 -1
  55. package/doc/zh/Migrations.md +1 -1
  56. package/doc/zh/NativeAddons.md +4 -2
  57. package/doc/zh/Overview.md +5 -4
  58. package/doc/zh/Packaging.md +6 -1
  59. package/doc/zh/ProjectStructure.md +5 -2
  60. package/doc/zh/ServiceCalls.md +65 -2
  61. package/doc/zh/UpgradingFrom1.md +2 -2
  62. package/doc/zh/UpgradingFrom2.md +3 -3
  63. package/doc/zh/WebRTC.md +1 -1
  64. package/doc/zh/examples/MobileApp.md +671 -0
  65. package/doc/zh/examples/README.md +2 -0
  66. package/doc/zh/examples/TrayApp.md +980 -0
  67. package/package.json +36 -16
@@ -0,0 +1,671 @@
1
+ # 手机应用
2
+
3
+ 这个示例构建一个可以在 Android、iOS 和鸿蒙上运行的记账本:用户记下每一笔支出,并查看每个月以及每个分类的合计。它从 `mobile-react` 模板开始,展示用 lakutata 开发的手机应用的两部分:一是 Node.js 端,它是一个 lakutata 应用,运行在手机应用内嵌的 Node.js 中,把支出保存在应用数据目录下的 SQLite 数据库里,并用迁移让数据库保持最新;二是 React Native 用户界面,它在三个系统上完全相同,通过原生模块调用 Node.js 端的动作,并接收 Node.js 端推送的事件。示例还展示了 Node.js 端的测试,测试在电脑上运行,不需要设备。
4
+
5
+ | 消息 | 作用 |
6
+ |---|---|
7
+ | `{cmd: 'expenses.add', amount: 1250, category: 'Food', note: 'Lunch', spentOn: '2026-11-04'}` | 添加一笔 12.50 的支出 |
8
+ | `{cmd: 'expenses.month', month: '2026-11'}` | 返回 2026 年 11 月的支出、总额和每个分类的合计 |
9
+ | `{cmd: 'expenses.delete', id: 1}` | 删除一笔支出 |
10
+
11
+ 添加或删除支出时,Node.js 端会向用户界面推送 `expenses.changed` 事件,参数是对应的月份。
12
+
13
+ ## 1. 创建项目
14
+
15
+ ```bash
16
+ npx @lakutata/cli create --name expenses --template mobile-react --yes
17
+ cd expenses
18
+ npm install better-sqlite3 -w app
19
+ rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
20
+ ```
21
+
22
+ 项目的根目录就是 React Native 应用,包含原生项目 `android`、`ios` 和 `harmony`;应用的各部分是工作区中的包(见 [ProjectStructure](../ProjectStructure.md)):`packages/app` 是 Node.js 端,`packages/ui` 是用户界面,`packages/ipc` 负责把两者连起来。项目的 README 列出了每个系统在电脑上需要的工具:带 NDK 的 Android SDK、Xcode、DevEco Studio。Node.js 端通过这里安装的 `better-sqlite3` 使用 SQLite:它是一个 C++ 插件,`lakutata mobile stage` 会用这些工具为每个系统从源码编译它(见 [DatabaseDrivers](../DatabaseDrivers.md) 的“手机应用”一节)。这里删掉了模板中的示例控制器。
23
+
24
+ ## 2. 实体
25
+
26
+ **`packages/app/src/entities/Expense.ts`**
27
+
28
+ ```typescript
29
+ import {Column, CreateDateColumn, Entity, PrimaryGeneratedColumn} from 'lakutata/decorator/orm'
30
+
31
+ /**
32
+ * 一笔支出:金额(单位为分)、分类、备注,以及花钱的日期
33
+ */
34
+ @Entity('expenses')
35
+ export class Expense {
36
+ @PrimaryGeneratedColumn()
37
+ public id: number
38
+
39
+ @Column({type: 'integer'})
40
+ public amount: number
41
+
42
+ @Column({type: 'varchar', length: 40})
43
+ public category: string
44
+
45
+ @Column({type: 'varchar', length: 200, default: ''})
46
+ public note: string
47
+
48
+ //日期,格式为 YYYY-MM-DD:前七个字符就是月份
49
+ @Column({type: 'varchar', length: 10})
50
+ public spentOn: string
51
+
52
+ @CreateDateColumn()
53
+ public createdAt: Date
54
+ }
55
+ ```
56
+
57
+ 实体是一个类,它的装饰器描述对应的表(见 [Entities](../Entities.md))。金额是以分为单位的整数:12.50 就是 1250,合计时相加的是整数,没有小数的舍入误差。日期是 `YYYY-MM-DD` 格式的文本,所以一个月的支出就是日期以 `2026-11-` 开头的那些。
58
+
59
+ ## 3. 迁移
60
+
61
+ **`packages/app/src/migrations/1793722916138-database.ts`**
62
+
63
+ ```typescript
64
+ import { MigrationInterface, QueryRunner } from "lakutata/orm";
65
+
66
+ export class Migrations1793722916138 implements MigrationInterface {
67
+ name = 'Migrations1793722916138'
68
+
69
+ public async up(queryRunner: QueryRunner): Promise<void> {
70
+ await queryRunner.query(`
71
+ CREATE TABLE "expenses" (
72
+ "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL,
73
+ "amount" integer NOT NULL,
74
+ "category" varchar(40) NOT NULL,
75
+ "note" varchar(200) NOT NULL DEFAULT (''),
76
+ "spentOn" varchar(10) NOT NULL,
77
+ "createdAt" datetime NOT NULL DEFAULT (datetime('now'))
78
+ )
79
+ `);
80
+ }
81
+
82
+ public async down(queryRunner: QueryRunner): Promise<void> {
83
+ await queryRunner.query(`
84
+ DROP TABLE "expenses"
85
+ `);
86
+ }
87
+
88
+ }
89
+ ```
90
+
91
+ 数据库在每个用户的设备上,应用更新时必须把它更新到最新:表结构的变化通过迁移完成,绝不能用 `synchronize`,因为它可能连同数据一起删掉某一列(见 [Migrations](../Migrations.md))。这个迁移是应用根据实体自己生成的,用的是 `lakutata migration` 命令(见 [Migrations](../Migrations.md) 第 2 节),在电脑上运行,电脑上的数据库是 `packages/app/data/expenses.sqlite`:
92
+
93
+ ```bash
94
+ npx lakutata migration generate --package app
95
+ ```
96
+
97
+ 你生成的迁移文件名中的时间戳会不一样:在选项中按那个名称导入它(见第 7 节)。实体有变化时,先用 `npx lakutata migration run --package app` 把电脑上的数据库更新到最新,因为这个命令是拿实体和数据库中的表作比较;然后生成下一个迁移,并在选项中把它列在这一个之后。
98
+
99
+ ## 4. 输入
100
+
101
+ **`packages/app/src/dtos/ExpenseDTO.ts`**
102
+
103
+ ```typescript
104
+ import {DTO} from 'lakutata'
105
+ import {Expect} from 'lakutata/decorator/dto'
106
+
107
+ /**
108
+ * 按 id 指定的一笔支出
109
+ */
110
+ export class ExpenseIdDTO extends DTO {
111
+ @Expect(DTO.Number().integer().positive().required())
112
+ public id: number
113
+ }
114
+
115
+ /**
116
+ * 一笔新支出
117
+ */
118
+ export class AddExpenseDTO extends DTO {
119
+ //单位为分(12.50 就是 1250):整数相加没有小数的舍入误差
120
+ @Expect(DTO.Number().integer().positive().max(100000000).required())
121
+ public amount: number
122
+
123
+ @Expect(DTO.String().min(1).max(40).required())
124
+ public category: string
125
+
126
+ @Expect(DTO.String().allow('').max(200).optional().default(''))
127
+ public note: string
128
+
129
+ //设备上的日期,由用户界面给出:今天是哪一天,由用户的时区决定
130
+ @Expect(DTO.String().pattern(/^\d{4}-\d{2}-\d{2}$/, 'day').required())
131
+ public spentOn: string
132
+ }
133
+
134
+ /**
135
+ * 一个月份,格式为 YYYY-MM
136
+ */
137
+ export class MonthDTO extends DTO {
138
+ @Expect(DTO.String().pattern(/^\d{4}-(0[1-9]|1[0-2])$/, 'month').required())
139
+ public month: string
140
+ }
141
+ ```
142
+
143
+ 每个动作运行前都先用 DTO 校验输入(见 [DTOValidation](../DTOValidation.md))。消息是 JSON,值会保留原来的类型:`amount` 必须是整数,`12.5` 会被拒绝,报错 `"amount" must be an integer`。日期是设备上的日期,由用户界面给出:今天是哪一天,不由 Node.js 端替用户决定。
144
+
145
+ ## 5. 异常
146
+
147
+ **`packages/app/src/exceptions/ExpenseNotFoundException.ts`**
148
+
149
+ ```typescript
150
+ import {Exception} from 'lakutata'
151
+
152
+ /**
153
+ * 请求的支出不存在
154
+ */
155
+ export class ExpenseNotFoundException extends Exception {
156
+ public errno: string = 'E_EXPENSE_NOT_FOUND'
157
+ }
158
+ ```
159
+
160
+ 异常带有 `errno`,用户界面根据这个代码判断错误(见 [Exceptions](../Exceptions.md))。用户界面收到的错误和抛出时一样,带有全部属性:`ipc.invoke()` 返回的 Promise 会以这个错误拒绝。
161
+
162
+ ## 6. 控制器
163
+
164
+ **`packages/app/src/controllers/ExpenseController.ts`**
165
+
166
+ ```typescript
167
+ import {Controller, type Entrypoint} from 'lakutata/com/entrypoint'
168
+ import {type Database} from 'lakutata/com/database'
169
+ import {ServiceAction} from 'lakutata/decorator/ctrl'
170
+ import {Inject} from 'lakutata/decorator/di'
171
+ import {Like, type Repository} from 'lakutata/orm'
172
+ import {Expense} from '../entities/Expense.js'
173
+ import {AddExpenseDTO, ExpenseIdDTO, MonthDTO} from '../dtos/ExpenseDTO.js'
174
+ import {ExpenseNotFoundException} from '../exceptions/ExpenseNotFoundException.js'
175
+
176
+ /**
177
+ * 一个月的支出:总额、每个分类的合计(最大的在前),以及各笔支出(最新的在前)
178
+ */
179
+ export type MonthSummary = { month: string, total: number, categories: { category: string, total: number }[], expenses: Expense[] }
180
+
181
+ /**
182
+ * 支出的动作,由用户界面调用:ipc.invoke({cmd: 'expenses.month', month: '2026-11'})
183
+ */
184
+ export class ExpenseController extends Controller {
185
+
186
+ /**
187
+ * 向客户端推送事件:用户界面(ipc.on),以及开发时的工具(GET /events)
188
+ */
189
+ @Inject('entrypoint')
190
+ protected readonly entrypoint: Entrypoint
191
+
192
+ @Inject('database')
193
+ protected readonly database: Database
194
+
195
+ protected get expenses(): Repository<Expense> {
196
+ return this.database.getRepository(Expense)
197
+ }
198
+
199
+ @ServiceAction({cmd: 'expenses.add'}, AddExpenseDTO)
200
+ public async add(input: AddExpenseDTO): Promise<Expense> {
201
+ const expense: Expense = await this.expenses.save(this.expenses.create({amount: input.amount, category: input.category, note: input.note, spentOn: input.spentOn}))
202
+ //这个月份有变化
203
+ this.entrypoint.broadcast('expenses.changed', expense.spentOn.slice(0, 7))
204
+ return expense
205
+ }
206
+
207
+ @ServiceAction({cmd: 'expenses.month'}, MonthDTO)
208
+ public async month(input: MonthDTO): Promise<MonthSummary> {
209
+ const expenses: Expense[] = await this.expenses.find({where: {spentOn: Like(`${input.month}-%`)}, order: {spentOn: 'DESC', id: 'DESC'}})
210
+ const totals: Map<string, number> = new Map()
211
+ for (const expense of expenses) totals.set(expense.category, (totals.get(expense.category) ?? 0) + expense.amount)
212
+ return {
213
+ month: input.month,
214
+ total: expenses.reduce((sum: number, expense: Expense): number => sum + expense.amount, 0),
215
+ categories: [...totals].map(([category, total]) => ({category: category, total: total})).sort((a, b): number => b.total - a.total),
216
+ expenses: expenses
217
+ }
218
+ }
219
+
220
+ @ServiceAction({cmd: 'expenses.delete'}, ExpenseIdDTO)
221
+ public async remove(input: ExpenseIdDTO): Promise<void> {
222
+ const expense: Expense | null = await this.expenses.findOneBy({id: input.id})
223
+ if (!expense) throw new ExpenseNotFoundException(`The expense ${input.id} does not exist`)
224
+ await this.expenses.remove(expense)
225
+ this.entrypoint.broadcast('expenses.changed', expense.spentOn.slice(0, 7))
226
+ }
227
+ }
228
+ ```
229
+
230
+ - 每个 `@ServiceAction` 由一条消息调用(见 [ServiceCalls](../ServiceCalls.md) 第 2 节):用户界面用 `ipc.invoke({cmd: 'expenses.month', month: '2026-11'})` 发送消息,经由原生模块及其套接字到达 Node.js。
231
+ - `this.entrypoint.broadcast()` 向所有客户端推送 `expenses.changed`:包括用户界面,以及开发时的工具(见第 10 节)。参数是有变化的月份,所以正在显示其他月份的用户界面不用重新加载。
232
+ - `Like` 来自 `lakutata/orm`,即 TypeORM 的 API(见 [Database](../Database.md))。
233
+
234
+ ## 7. 应用的选项
235
+
236
+ **`packages/app/src/config/Config.ts`**
237
+
238
+ ```typescript
239
+ import type {Duplex} from 'node:stream'
240
+ import type {ApplicationOptions} from 'lakutata'
241
+ import {buildDatabaseOptions} from 'lakutata/com/database'
242
+ import {buildEntrypoints, httpServiceEntrypoint} from 'lakutata/com/entrypoint'
243
+ import {reactNativeServiceEntrypoint} from 'lakutata/adapter/react-native'
244
+ import {ExpenseController} from '../controllers/ExpenseController.js'
245
+ import {Expense} from '../entities/Expense.js'
246
+ import {Migrations1793722916138} from '../migrations/1793722916138-database.js'
247
+
248
+ export type ConfigOptions = {
249
+ mode?: 'development' | 'production'
250
+ /**
251
+ * 向用户界面提供动作的宿主:'native' 表示手机应用的原生模块(LAKUTATA_NODE_FD 的套接字),也可以是代替它的套接字(测试中);
252
+ * 在开发用的电脑上没有
253
+ */
254
+ host?: 'native' | Duplex
255
+ /**
256
+ * 开发用的 HTTP 入口的端口,默认没有。工具(Postman、curl)用 POST / 和消息(模式和输入)调用动作,用 GET /events
257
+ * 接收推送给它们的事件(Server-Sent Events),并在 id 请求头(或 id 查询参数)中给出客户端 id
258
+ */
259
+ devPort?: number
260
+ }
261
+
262
+ /**
263
+ * 应用的选项:App.ts 交给 run() 的函数会调用它
264
+ * @param options
265
+ */
266
+ export function config(options: ConfigOptions = {}): ApplicationOptions {
267
+ return {
268
+ id: 'expenses',
269
+ name: 'expenses',
270
+ mode: options.mode ?? 'development',
271
+ components: {
272
+ //支出数据,放在应用的数据目录(@data)中
273
+ database: buildDatabaseOptions({
274
+ type: 'better-sqlite3',
275
+ database: '@data/expenses.sqlite',
276
+ entities: [Expense],
277
+ //迁移随应用的单文件包一起发布;数据库还没执行过的迁移会在连接时执行,所以应用更新后,设备上的数据库也随之更新
278
+ migrations: [Migrations1793722916138],
279
+ migrationsRun: true
280
+ }),
281
+ entrypoint: buildEntrypoints({
282
+ controllers: [ExpenseController],
283
+ //用户界面通过原生模块调用动作:ipc.invoke({cmd: 'expenses.month', month: '2026-11'})
284
+ service: [
285
+ ...options.host === undefined ? [] : [reactNativeServiceEntrypoint(options.host === 'native' ? {} : {socket: options.host})],
286
+ ...options.devPort === undefined ? [] : [httpServiceEntrypoint({port: options.devPort})]
287
+ ]
288
+ })
289
+ },
290
+ //先连接数据库并执行迁移,再提供动作
291
+ bootstrap: ['database', 'entrypoint']
292
+ }
293
+ }
294
+ ```
295
+
296
+ - 数据库是应用数据目录中的一个文件 `@data/expenses.sqlite`,数据库连接时会解析这个别名。模板的 `App.ts` 为每个系统声明别名 `@data`:Android 上是应用的 `files` 目录,iOS 上是 `Library/Application Support`,鸿蒙上是 `/data/storage/el2/base/files`,在电脑上是 `packages/app/data`。系统会一直保留这个目录,直到应用被卸载;`os.tmpdir()` 则是应用的缓存目录,系统可能清空它。
297
+ - 迁移以类的形式列出。在设备上,Node.js 端是一个单文件包(由 `lakutata mobile stage` 生成),里面没有 `build/migrations/*.js` 这类通配路径能找到的编译文件。`migrationsRun: true` 会在连接时执行设备上的数据库还没执行过的迁移,所以应用更新后第一次启动时,数据库就会更新到最新。
298
+ - `bootstrap` 先连接数据库并执行迁移,再提供动作。
299
+
300
+ ## 8. 用户界面
301
+
302
+ **`packages/ui/src/App.tsx`**
303
+
304
+ ```tsx
305
+ import {useCallback, useEffect, useState} from 'react'
306
+ import {Button, Pressable, ScrollView, StatusBar, StyleSheet, Text, TextInput, View} from 'react-native'
307
+ import {SafeAreaProvider, useSafeAreaInsets, type EdgeInsets} from 'react-native-safe-area-context'
308
+ import {ipc} from 'ipc'
309
+
310
+ /**
311
+ * 一笔支出,即动作返回的格式
312
+ */
313
+ type Expense = { id: number, amount: number, category: string, note: string, spentOn: string }
314
+
315
+ /**
316
+ * 一个月的支出,即 expenses.month 返回的格式
317
+ */
318
+ type MonthSummary = { month: string, total: number, categories: { category: string, total: number }[], expenses: Expense[] }
319
+
320
+ /**
321
+ * 设备时区中的日期,格式为 YYYY-MM-DD
322
+ * @param date
323
+ */
324
+ function day(date: Date): string {
325
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
326
+ }
327
+
328
+ /**
329
+ * 某个月份(YYYY-MM)的前一个月或后一个月
330
+ * @param month
331
+ * @param offset
332
+ */
333
+ function shift(month: string, offset: number): string {
334
+ const [year, index] = month.split('-').map(Number)
335
+ return day(new Date(year, index - 1 + offset, 1)).slice(0, 7)
336
+ }
337
+
338
+ /**
339
+ * 以分为单位的金额,显示为 12.50 的形式
340
+ * @param cents
341
+ */
342
+ function money(cents: number): string {
343
+ return (cents / 100).toFixed(2)
344
+ }
345
+
346
+ /**
347
+ * 应用:和模板一样,界面在设备的安全区域内
348
+ */
349
+ export default function App() {
350
+ //和模板一样,浅色界面上的状态栏用深色图标
351
+ return (
352
+ <SafeAreaProvider>
353
+ <StatusBar barStyle="dark-content"/>
354
+ <Expenses/>
355
+ </SafeAreaProvider>
356
+ )
357
+ }
358
+
359
+ function Expenses() {
360
+ //避开状态栏、摄像头和底部手势条
361
+ const insets: EdgeInsets = useSafeAreaInsets()
362
+ const [month, setMonth] = useState<string>(day(new Date()).slice(0, 7))
363
+ const [summary, setSummary] = useState<MonthSummary | null>(null)
364
+ const [amount, setAmount] = useState<string>('')
365
+ const [category, setCategory] = useState<string>('')
366
+ const [note, setNote] = useState<string>('')
367
+ const [failure, setFailure] = useState<string>('')
368
+ const [ended, setEnded] = useState<number | null>(null)
369
+
370
+ const load = useCallback((): void => {
371
+ ipc.invoke<MonthSummary>({cmd: 'expenses.month', month: month})
372
+ .then((loaded: MonthSummary): void => setSummary(loaded))
373
+ .catch((error: Error): void => setFailure(error.message))
374
+ }, [month])
375
+
376
+ useEffect((): () => void => {
377
+ load()
378
+ //添加或删除了支出:如果变化的正是当前显示的月份,就重新加载
379
+ return ipc.on('expenses.changed', (changed: string): void => {
380
+ if (changed === month) load()
381
+ })
382
+ }, [load, month])
383
+
384
+ //Node.js 结束了(process.exit()、未捕获的异常):手机应用会继续运行
385
+ useEffect((): () => void => ipc.on('exit', (code: number): void => setEnded(code)), [])
386
+
387
+ const restart = (): void => {
388
+ setEnded(null)
389
+ ipc.restart()
390
+ load()
391
+ }
392
+
393
+ const add = (): void => {
394
+ setFailure('')
395
+ //把 12.5 或 12,50 换算成分
396
+ const cents: number = Math.round(Number(amount.replace(',', '.')) * 100)
397
+ ipc.invoke({cmd: 'expenses.add', amount: cents, category: category.trim(), note: note.trim(), spentOn: day(new Date())})
398
+ .then((): void => {
399
+ setAmount('')
400
+ setNote('')
401
+ })
402
+ .catch((error: Error): void => setFailure(error.message))
403
+ }
404
+
405
+ const remove = (id: number): void => {
406
+ ipc.invoke({cmd: 'expenses.delete', id: id}).catch((error: Error): void => setFailure(error.message))
407
+ }
408
+
409
+ return (
410
+ //键盘显示时,点按钮也能点到(默认情况下,这一下只会收起键盘)
411
+ <ScrollView contentContainerStyle={{paddingTop: insets.top + 24, paddingBottom: insets.bottom + 24, paddingLeft: insets.left + 24, paddingRight: insets.right + 24}} keyboardShouldPersistTaps="handled">
412
+ <View style={styles.row}>
413
+ <Button title="‹" onPress={(): void => setMonth(shift(month, -1))}/>
414
+ <Text style={styles.title}>{month}</Text>
415
+ <Button title="›" onPress={(): void => setMonth(shift(month, 1))}/>
416
+ </View>
417
+ <Text style={styles.total}>{money(summary?.total ?? 0)}</Text>
418
+ {summary?.categories.map(({category: name, total}) => (
419
+ <Text key={name} style={styles.text}>{name}: {money(total)}</Text>
420
+ ))}
421
+ <TextInput style={styles.input} placeholder="Amount" keyboardType="decimal-pad" value={amount} onChangeText={setAmount}/>
422
+ <TextInput style={styles.input} placeholder="Category" value={category} onChangeText={setCategory}/>
423
+ <TextInput style={styles.input} placeholder="Note" value={note} onChangeText={setNote}/>
424
+ <Button title="Add today's expense" onPress={add}/>
425
+ {failure !== '' && <Text style={styles.failure}>{failure}</Text>}
426
+ {ended !== null && (
427
+ <>
428
+ <Text style={styles.failure}>Node.js ended (exit code {ended})</Text>
429
+ <Button title="Restart" onPress={restart}/>
430
+ </>
431
+ )}
432
+ {summary?.expenses.map((expense: Expense) => (
433
+ <Pressable key={expense.id} style={styles.expense} onLongPress={(): void => remove(expense.id)}>
434
+ <Text style={styles.text}>{expense.spentOn} {expense.category} {money(expense.amount)}</Text>
435
+ {expense.note !== '' && <Text style={styles.note}>{expense.note}</Text>}
436
+ </Pressable>
437
+ ))}
438
+ {summary?.expenses.length ? <Text style={styles.note}>Hold an expense to delete it</Text> : null}
439
+ </ScrollView>
440
+ )
441
+ }
442
+
443
+ const styles = StyleSheet.create({
444
+ row: {flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 24},
445
+ title: {fontSize: 20, fontWeight: 'bold'},
446
+ total: {fontSize: 32, fontWeight: 'bold', marginVertical: 16, textAlign: 'center'},
447
+ text: {fontSize: 16, marginBottom: 4},
448
+ note: {fontSize: 14, color: '#666'},
449
+ input: {borderWidth: 1, borderColor: '#ccc', borderRadius: 6, padding: 10, marginVertical: 6, fontSize: 16},
450
+ failure: {color: '#c00', marginVertical: 8},
451
+ expense: {paddingVertical: 10, borderBottomWidth: 1, borderBottomColor: '#eee'}
452
+ })
453
+ ```
454
+
455
+ - 和模板一样,界面在设备的安全区域内:`useSafeAreaInsets()` 给出状态栏、摄像头和底部手势条占用的空间。
456
+ - `ipc` 来自模板的 `ipc` 包:`ipc.invoke()` 调用动作,返回它的 `payload`,或者以它的错误拒绝;`ipc.on()` 监听事件,并返回移除监听器的函数。
457
+ - 收到 `expenses.changed` 时,如果变化的正是当前显示的月份,就重新加载这个月。
458
+ - `keyboardShouldPersistTaps="handled"` 让键盘显示时也能点到按钮:默认情况下,这一下只会收起键盘,输入的支出不会被添加。
459
+ - Node.js 结束时(未捕获的异常、`process.exit()`),手机应用会继续运行:`ipc` 发出 `exit`,`ipc.restart()` 重新启动 Node.js,和模板中一样。
460
+
461
+ ## 9. 运行
462
+
463
+ 先在单独的终端中启动 Metro,再在模拟器(或设备)上运行应用:
464
+
465
+ ```bash
466
+ npm start
467
+ npm run android # 或:npm run ios、npm run harmony
468
+ ```
469
+
470
+ `npm run android` 会构建各个包,生成应用的 Node.js 项目(`lakutata mobile stage`:Node.js 端的单文件包,以及为 Android 各架构编译的 `better-sqlite3`),构建调试版应用,安装并启动它。第一次运行时,它会下载这个系统的 libnode。用户界面来自 Metro,修改后立即生效;修改 `packages/app` 后,用 `npm run deploy:android`(或 `deploy:ios`、`deploy:harmony`)把它放进已安装的应用,不需要重新安装。
471
+
472
+ 添加一笔支出,关闭应用(在 Android 上,从最近使用的应用中把它划掉),再重新打开:这个月仍然列出了那笔支出,它是从数据目录中的数据库读出来的。按住一笔支出可以删除它。
473
+
474
+ ## 10. 在电脑上试用
475
+
476
+ `npm run dev -w app` 在电脑上运行 Node.js 端,数据库放在 `packages/app/data` 中,并通过 HTTP 在 9000 端口(`DEV_PORT`)提供动作,供 curl 或 Postman 等工具使用:`POST /` 发送消息,`GET /events` 接收某个客户端的事件(见 [ServiceCalls](../ServiceCalls.md) 第 4 节)。
477
+
478
+ ```bash
479
+ npm run dev -w app
480
+ ```
481
+
482
+ 在另一个终端中运行:
483
+
484
+ ```bash
485
+ curl -N 'localhost:9000/events?id=me' &
486
+ # event: client
487
+ # data: ["me"]
488
+
489
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "expenses.add", "amount": 1250, "category": "Food", "note": "Lunch", "spentOn": "2026-11-04"}'
490
+ # {"success":true,"error":null,"payload":{"amount":1250,"category":"Food","note":"Lunch","spentOn":"2026-11-04","id":1,"createdAt":"2026-11-03T16:30:55.000Z"}}
491
+ # event: expenses.changed
492
+ # data: ["2026-11"]
493
+
494
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "expenses.add", "amount": 4000, "category": "Transport", "note": "Train", "spentOn": "2026-11-04"}'
495
+
496
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "expenses.month", "month": "2026-11"}'
497
+ # {"success":true,"error":null,"payload":{"month":"2026-11","total":5250,"categories":[{"category":"Transport","total":4000},{"category":"Food","total":1250}],"expenses":[{"id":2,"amount":4000,"category":"Transport","note":"Train","spentOn":"2026-11-04","createdAt":"2026-11-03T16:30:55.000Z"},{"id":1,"amount":1250,"category":"Food","note":"Lunch","spentOn":"2026-11-04","createdAt":"2026-11-03T16:30:55.000Z"}]}}
498
+
499
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "expenses.add", "amount": 12.5, "category": "Food", "spentOn": "2026-11-04"}'
500
+ # {"success":false,"error":{…,"statusCode":400,"message":"\"amount\" must be an integer","errMsg":"\"amount\" must be an integer","err":"InvalidValueException","errno":"E_INVALID_VALUE"},"payload":null}
501
+
502
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "expenses.delete", "id": 7}'
503
+ # {"success":false,"error":{…,"message":"The expense 7 does not exist","errMsg":"The expense 7 does not exist","err":"ExpenseNotFoundException","errno":"E_EXPENSE_NOT_FOUND"},"payload":null}
504
+ ```
505
+
506
+ 错误带有异常的全部属性,包括调用栈(这里省略了)。
507
+
508
+ ## 11. 测试
509
+
510
+ **`packages/app/src/tests/App.spec.ts`**
511
+
512
+ ```typescript
513
+ import {after, before, describe, it} from 'node:test'
514
+ import assert from 'node:assert'
515
+ import {existsSync, mkdtempSync, rmSync} from 'node:fs'
516
+ import {connect, createServer, type Server, type Socket} from 'node:net'
517
+ import os from 'node:os'
518
+ import path from 'node:path'
519
+ import {Application} from 'lakutata'
520
+ import type {ReactNativeServiceMessage} from 'lakutata/adapter/react-native'
521
+ import type {MonthSummary} from '../controllers/ExpenseController.js'
522
+ import {config} from '../config/Config.js'
523
+
524
+ /**
525
+ * 测试中的手机应用原生模块:它这一端连接应用的套接字,以及它收到的消息(每条一行 JSON)
526
+ */
527
+ const received: ReactNativeServiceMessage[] = []
528
+ let host: Socket
529
+
530
+ let next: number = 0
531
+
532
+ /**
533
+ * 像原生模块那样写一条消息,即一行 JSON
534
+ * @param message
535
+ */
536
+ function send(message: Record<string, unknown>): void {
537
+ host.write(`${JSON.stringify(message)}\n`)
538
+ }
539
+
540
+ /**
541
+ * 像用户界面那样调用动作(ipc.invoke)
542
+ * @param data
543
+ */
544
+ async function invoke(data: Record<string, unknown>): Promise<any> {
545
+ const id: number = ++next
546
+ send({type: 'invoke', id: id, clientId: 'ui', data: data})
547
+ for (let attempt: number = 0; attempt < 500; attempt++) {
548
+ const response = received.find((message: ReactNativeServiceMessage): boolean => message.type === 'response' && message.id === id)
549
+ if (response) return response.response
550
+ await new Promise((resolve: (value: unknown) => void): unknown => setTimeout(resolve, 10))
551
+ }
552
+ throw new Error(`No response to ${JSON.stringify(data)}`)
553
+ }
554
+
555
+ /**
556
+ * 互相连接的两个套接字:原生模块一端和应用一端
557
+ */
558
+ async function connectedPair(): Promise<[Socket, Socket]> {
559
+ const server: Server = createServer()
560
+ await new Promise<void>((resolve: () => void): unknown => server.listen(0, '127.0.0.1', resolve))
561
+ const accepted: Promise<Socket> = new Promise((resolve: (socket: Socket) => void): unknown => server.once('connection', resolve))
562
+ const native: Socket = connect((server.address() as { port: number }).port, '127.0.0.1')
563
+ const application: Socket = await accepted
564
+ server.close()
565
+ return [native, application]
566
+ }
567
+
568
+ describe('expenses', (): void => {
569
+
570
+ let app: Application
571
+ let socket: Socket
572
+ //测试用的数据目录,数据库就在这里
573
+ const data: string = mkdtempSync(path.join(os.tmpdir(), 'expenses-'))
574
+
575
+ before(async (): Promise<void> => {
576
+ ;[host, socket] = await connectedPair()
577
+ let buffer: string = ''
578
+ host.setEncoding('utf8')
579
+ host.on('data', (chunk: string): void => {
580
+ buffer += chunk
581
+ for (let end: number = buffer.indexOf('\n'); end >= 0; end = buffer.indexOf('\n')) {
582
+ received.push(JSON.parse(buffer.slice(0, end)) as ReactNativeServiceMessage)
583
+ buffer = buffer.slice(end + 1)
584
+ }
585
+ })
586
+ app = await new Promise<Application>((resolve: (app: Application) => void): void => {
587
+ //像 App.ts 那样声明 @data
588
+ Application
589
+ .alias({'@data': data}, true)
590
+ .run(() => config({host: socket}))
591
+ .onLaunched((launched: Application): void => resolve(launched))
592
+ })
593
+ })
594
+
595
+ after(async (): Promise<void> => {
596
+ await app?.dispose()
597
+ host?.destroy()
598
+ socket?.destroy()
599
+ rmSync(data, {recursive: true, force: true})
600
+ })
601
+
602
+ it('adds the expenses, and gives the totals of a month', async (): Promise<void> => {
603
+ await invoke({cmd: 'expenses.add', amount: 1250, category: 'Food', note: 'Lunch', spentOn: '2026-11-03'})
604
+ await invoke({cmd: 'expenses.add', amount: 4000, category: 'Transport', spentOn: '2026-11-04'})
605
+ await invoke({cmd: 'expenses.add', amount: 830, category: 'Food', spentOn: '2026-11-04'})
606
+ await invoke({cmd: 'expenses.add', amount: 9900, category: 'Food', spentOn: '2026-10-31'})
607
+ const month: MonthSummary = (await invoke({cmd: 'expenses.month', month: '2026-11'})).payload
608
+ assert.strictEqual(month.total, 6080)
609
+ assert.deepStrictEqual(month.categories, [{category: 'Transport', total: 4000}, {category: 'Food', total: 2080}])
610
+ assert.deepStrictEqual(month.expenses.map((expense): string => `${expense.spentOn} ${expense.amount}`), ['2026-11-04 830', '2026-11-04 4000', '2026-11-03 1250'])
611
+ })
612
+
613
+ it('pushes expenses.changed to the user interface', async (): Promise<void> => {
614
+ //用户界面订阅了事件,订阅得到了应答
615
+ send({type: 'subscribe', clientId: 'ui'})
616
+ await invoke({cmd: 'expenses.add', amount: 300, category: 'Coffee', spentOn: '2026-11-05'})
617
+ assert.deepStrictEqual(received.filter((message: ReactNativeServiceMessage): boolean => message.type === 'event'), [{type: 'event', clientId: 'ui', event: 'expenses.changed', args: ['2026-11']}])
618
+ })
619
+
620
+ it('deletes an expense, and refuses one that does not exist', async (): Promise<void> => {
621
+ const [latest] = (await invoke({cmd: 'expenses.month', month: '2026-11'})).payload.expenses
622
+ assert.strictEqual((await invoke({cmd: 'expenses.delete', id: latest.id})).success, true)
623
+ assert.strictEqual((await invoke({cmd: 'expenses.month', month: '2026-11'})).payload.total, 6080)
624
+ const missing = await invoke({cmd: 'expenses.delete', id: latest.id})
625
+ assert.strictEqual(missing.success, false)
626
+ assert.strictEqual(missing.error.errno, 'E_EXPENSE_NOT_FOUND')
627
+ })
628
+
629
+ it('refuses an invalid input', async (): Promise<void> => {
630
+ const decimal = await invoke({cmd: 'expenses.add', amount: 12.5, category: 'Food', spentOn: '2026-11-03'})
631
+ assert.strictEqual(decimal.success, false)
632
+ assert.match(decimal.error.message, /amount/)
633
+ const month = await invoke({cmd: 'expenses.month', month: '2026-13'})
634
+ assert.strictEqual(month.success, false)
635
+ assert.match(month.error.message, /month/)
636
+ })
637
+
638
+ it('keeps the database in the data directory', async (): Promise<void> => {
639
+ assert.ok(existsSync(path.join(data, 'expenses.sqlite')))
640
+ })
641
+ })
642
+ ```
643
+
644
+ 测试用 Node.js 运行 Node.js 端,方式和原生模块在设备上运行它一样:一个套接字代替原生模块的套接字,测试往里写消息(每条一行 JSON:`invoke`、`subscribe`),并读取响应和事件。`@data` 是一个临时目录,像 `App.ts` 那样声明。
645
+
646
+ ```bash
647
+ npm test
648
+ ```
649
+
650
+ ```
651
+ app │ ✔ adds the expenses, and gives the totals of a month
652
+ app │ ✔ pushes expenses.changed to the user interface
653
+ app │ ✔ deletes an expense, and refuses one that does not exist
654
+ app │ ✔ refuses an invalid input
655
+ app │ ✔ keeps the database in the data directory
656
+ app │ ℹ pass 5
657
+ ✔ test ran in app
658
+ ```
659
+
660
+ 测试使用的是为电脑上的 Node.js 安装的 `better-sqlite3`;每个系统用的版本由 `lakutata mobile stage` 编译。
661
+
662
+ ## 12. 发布
663
+
664
+ 三个系统的发布版由各自的原生项目构建:它们把用户界面打包进应用,而不是从 Metro 加载,并且要为各自的应用商店签名。项目 README 的“The packaging”一节给出了构建命令;先用 `npm run stage:android`(或 `stage:ios`、`stage:harmony`)生成 Node.js 项目。调试版和发布版中的 Node.js 端完全相同,数据目录和迁移也一样:在旧版本上安装更新会保留数据库,更新后第一次启动时执行新的迁移。
665
+
666
+ <!--
667
+ check:
668
+ create: expenses mobile-react
669
+ remove: packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
670
+ run: npm test
671
+ -->