主题
第 10 章 综合实战项目:Kotlin CLI Todo 工具
学习目标:把前 9 章的所有知识串起来,做一个能用、能交付的小项目。完成后你就能:在简历上写"用 Kotlin 做过完整 CLI 工具",并且这套架构可以扩展成 Android / 服务端版本。
10.1 项目目标
做一个命令行 Todo 工具,支持:
bash
$ todo add "学 Kotlin 协程"
✅ 添加成功 #1: 学 Kotlin 协程
$ todo add "做实战项目" --priority HIGH --due 2026-05-01
✅ 添加成功 #2: 做实战项目 [HIGH] 截止 2026-05-01
$ todo list
[ ] #1 [MID] 学 Kotlin 协程
[ ] #2 [HIGH] 做实战项目 (截止 2026-05-01)
$ todo done 1
✅ 完成 #1: 学 Kotlin 协程
$ todo list --filter active
[ ] #2 [HIGH] 做实战项目 (截止 2026-05-01)
$ todo stats
总数: 2 | 已完成: 1 (50%) | 高优: 1数据持久化:JSON 文件保存到 ~/.todo/data.json。
10.2 知识点串讲
这个项目用到了所有前 9 章的核心知识:
| 知识点 | 用在哪 |
|---|---|
val / var / 类型推断(第 3 章) | 几乎全程 |
| 字符串模板(第 3 章) | 输出格式化 |
when 表达式(第 3 章) | 命令分发 |
| 默认参数 + 命名参数(第 4 章) | Todo 构造、命令选项 |
| 高阶函数 + Lambda(第 4 章) | 集合操作 |
| 扩展函数(第 4 章) | 美化输出 |
| Null 安全(第 5 章) | 解析 CLI 参数 |
data class + copy()(第 6 章) | Todo 不可变更新 |
enum class(第 6 章) | Priority |
sealed class(第 6 章) | Command 类型 |
object 单例(第 6 章) | Repository |
| 集合操作(第 7 章) | 筛选、排序、统计 |
| 作用域函数(第 8 章) | 链式调用、配置对象 |
| 协程(第 9 章) | 文件 IO 异步加载 |
10.3 项目结构
todo-cli/
├── build.gradle.kts
├── settings.gradle.kts
├── README.md
└── src/main/kotlin/
├── Main.kt ← 入口,命令解析
├── Todo.kt ← 数据模型 + 状态
├── Command.kt ← sealed class 命令类型
├── Repository.kt ← 持久化(JSON 读写)
└── Output.kt ← 输出格式化10.4 数据模型 Todo.kt
kotlin
import kotlinx.serialization.Serializable
@Serializable
enum class Priority { LOW, MID, HIGH }
@Serializable
data class Todo(
val id: Int,
val title: String,
val priority: Priority = Priority.MID,
val due: String? = null, // 简单用 String,避免引日期库
val done: Boolean = false,
val createdAt: Long = System.currentTimeMillis(),
)
// 扩展函数:美化打印
fun Todo.format(): String {
val mark = if (done) "✓" else " "
val prio = "[${priority.name.padEnd(4)}]"
val title = if (done) "\u001B[9m${title}\u001B[0m" else title // 删除线
val dueStr = due?.let { " (截止 $it)" } ?: ""
return "[$mark] #$id $prio $title$dueStr"
}10.5 命令模型 Command.kt
用 sealed class 表达"命令是这几种之一":
kotlin
sealed class Command {
data class Add(val title: String, val priority: Priority, val due: String?) : Command()
data class Done(val id: Int) : Command()
data class Remove(val id: Int) : Command()
data class List(val filter: Filter) : Command()
object Stats : Command()
object Help : Command()
enum class Filter { ALL, ACTIVE, DONE }
}解析 CLI 参数
kotlin
object CommandParser {
fun parse(args: Array<String>): Command {
if (args.isEmpty()) return Command.Help
return when (val cmd = args[0].lowercase()) {
"add" -> parseAdd(args.drop(1))
"done" -> Command.Done(args.getOrNull(1)?.toIntOrNull() ?: error("done 需要 id"))
"rm", "remove" -> Command.Remove(args.getOrNull(1)?.toIntOrNull() ?: error("rm 需要 id"))
"list", "ls" -> {
val filter = when (args.getOrNull(2)) {
"active" -> Command.Filter.ACTIVE
"done" -> Command.Filter.DONE
else -> Command.Filter.ALL
}
Command.List(filter)
}
"stats" -> Command.Stats
"help", "-h", "--help" -> Command.Help
else -> error("未知命令: $cmd")
}
}
private fun parseAdd(args: List<String>): Command.Add {
require(args.isNotEmpty()) { "add 需要标题" }
val title = args[0]
var priority = Priority.MID
var due: String? = null
var i = 1
while (i < args.size) {
when (args[i]) {
"--priority", "-p" -> {
priority = Priority.valueOf(args[++i].uppercase())
}
"--due", "-d" -> {
due = args[++i]
}
}
i++
}
return Command.Add(title, priority, due)
}
}10.6 仓储 Repository.kt
kotlin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
object Repository {
private val dataFile = File(System.getProperty("user.home"), ".todo/data.json")
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
suspend fun load(): MutableList<Todo> = withContext(Dispatchers.IO) {
if (!dataFile.exists()) return@withContext mutableListOf()
runCatching {
json.decodeFromString<List<Todo>>(dataFile.readText()).toMutableList()
}.getOrElse {
println("⚠️ 数据文件损坏,使用空列表: ${it.message}")
mutableListOf()
}
}
suspend fun save(todos: List<Todo>) = withContext(Dispatchers.IO) {
dataFile.parentFile.mkdirs()
dataFile.writeText(json.encodeToString(todos))
}
suspend fun nextId(todos: List<Todo>): Int = (todos.maxOfOrNull { it.id } ?: 0) + 1
}10.7 入口 Main.kt
kotlin
import kotlinx.coroutines.runBlocking
fun main(args: Array<String>) = runBlocking {
try {
val cmd = CommandParser.parse(args)
execute(cmd)
} catch (e: Throwable) {
System.err.println("❌ ${e.message}")
kotlin.system.exitProcess(1)
}
}
private suspend fun execute(cmd: Command) {
val todos = Repository.load()
when (cmd) {
is Command.Add -> {
val newTodo = Todo(
id = Repository.nextId(todos),
title = cmd.title,
priority = cmd.priority,
due = cmd.due,
)
todos.add(newTodo)
Repository.save(todos)
println("✅ 添加成功: ${newTodo.format()}")
}
is Command.Done -> {
val idx = todos.indexOfFirst { it.id == cmd.id }
require(idx >= 0) { "找不到 #${cmd.id}" }
todos[idx] = todos[idx].copy(done = true)
Repository.save(todos)
println("✅ 完成: ${todos[idx].format()}")
}
is Command.Remove -> {
val removed = todos.removeIf { it.id == cmd.id }
require(removed) { "找不到 #${cmd.id}" }
Repository.save(todos)
println("🗑️ 删除 #${cmd.id}")
}
is Command.List -> {
val filtered = todos.filter {
when (cmd.filter) {
Command.Filter.ACTIVE -> !it.done
Command.Filter.DONE -> it.done
Command.Filter.ALL -> true
}
}
if (filtered.isEmpty()) println("(空)") else filtered.forEach { println(it.format()) }
}
Command.Stats -> {
val total = todos.size
val done = todos.count { it.done }
val highPrio = todos.count { it.priority == Priority.HIGH && !it.done }
val pct = if (total > 0) done * 100.0 / total else 0.0
println("总数: $total | 已完成: $done (${"%.1f".format(pct)}%) | 高优待办: $highPrio")
}
Command.Help -> {
println("""
|Todo CLI - 一个用 Kotlin 写的 todo 工具
|
|用法:
| todo add <title> [--priority LOW|MID|HIGH] [--due YYYY-MM-DD]
| todo done <id>
| todo rm <id>
| todo list [active|done|all]
| todo stats
| todo help
""".trimMargin())
}
}
}10.8 跑起来
10.8.1 准备 build.gradle.kts
kotlin
plugins {
kotlin("jvm") version "2.0.21"
kotlin("plugin.serialization") version "2.0.21"
application
}
repositories { mavenCentral() }
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
}
application {
mainClass.set("MainKt")
}
kotlin { jvmToolchain(17) }10.8.2 跑
bash
./gradlew run --args="add '学 Kotlin 协程'"
./gradlew run --args="add '做实战项目' --priority HIGH --due 2026-05-01"
./gradlew run --args="list"
./gradlew run --args="done 1"
./gradlew run --args="stats"10.8.3 打包成原生命令
bash
./gradlew installDist
# 生成 build/install/todo-cli/bin/todo-cli
# 加到 PATH
echo 'export PATH="$PWD/build/install/todo-cli/bin:$PATH"' >> ~/.zshrc
# 现在就能直接用:
todo-cli add "买菜"
todo-cli list10.9 扩展挑战(自己练手)
完成基础版后,试试加这些功能:
难度 ⭐
- 彩色输出:用 ANSI 转义码给不同优先级配颜色
- 导出 Markdown:
todo export > todos.md
难度 ⭐⭐
- 标签系统:
todo add "x" --tag work,urgent - 搜索:
todo search "kotlin" - 撤销:
todo undo(需要把每次操作存起来)
难度 ⭐⭐⭐
- 同步到云:把 JSON 同步到 Gist / S3,多机共享
- Web 版:用 Ktor 暴露 HTTP API,浏览器能访问
- TUI 界面:用 Mordant 做交互式 TUI
难度 ⭐⭐⭐⭐
- 多用户:加用户名 / 密码登录
- Android 版:把数据层共享,用 Compose 做 UI(KMP)
10.10 章末小结:你学到了什么
完成这个项目,恭喜你已经具备了写 Kotlin 工程项目的全部基础能力:
★ 你已经掌握的技能 ★
│
┌─────────────────┼─────────────────┐
│ │ │
┌──▼──┐ ┌──▼──┐ ┌──▼──┐
│ 语言 │ │ 工程 │ │ 工具 │
├─────┤ ├─────┤ ├─────┤
│val/var│ │Gradle│ │IDEA │
│data │ │依赖 │ │REPL │
│null │ │打包 │ │CLI │
│协程 │ │JSON │ │版本 │
│集合 │ │IO │ │ │
│作用域│ │ │ │ │
│object│ │ │ │ │
│sealed│ │ │ │ │
└─────┘ └─────┘ └─────┘接下来你可以选哪条路?
| 想做啥 | 下一步学 |
|---|---|
| Android 应用 | Jetpack Compose + ViewModel + Navigation |
| 后端服务 | Spring Boot Kotlin / Ktor |
| 跨平台 App | Kotlin Multiplatform (KMP) + Compose Multiplatform |
| 数据科学 | Kotlin Notebook + Kotlin DataFrame |
| Web 前端 | Kotlin/JS + KVision 或 Compose Web |
| 构建工具 | 把项目的 build.gradle.kts 搞精通 |
| DSL 写法 | 看 Kotest / Ktor / Compose 的 DSL 实现 |
🎤 10.11 章末 + 全书面试题(10 道)
Q1. 你做这个项目过程中遇到最大的挑战是什么?怎么解决的?
示例答:"最大挑战是 sealed class + when 模式匹配。一开始用 if-else 链处理命令,加新命令要改多个地方。用 sealed Command 重构后,编译器会强制 when 穷尽所有子类,加新命令时编译器自动告诉我所有需要改的地方。"
Q2. 为啥用 data class 而不是普通 class?
示例答:"Todo 是不可变值对象,需要 equals/hashCode(用于 List 操作)和 copy(用于不可变更新)。data class 一行搞定,普通 class 要写 50+ 行模板代码。"
Q3. 为啥仓储用 object 而不是 class?
示例答:"仓储是单例,整个 app 共享一份数据访问。object 比 class + 单例模式更简洁、线程安全(JVM 类加载机制保证)、零模板。"
Q4. 这个项目的 IO 操作为啥要用协程?
示例答:"文件读写是 IO 阻塞,如果直接在主流程跑,命令多了响应会卡。用 withContext(Dispatchers.IO) 把 IO 切到 IO 线程池,主线程就能去做别的事。CLI 工具影响不大,但同样的代码可以无缝挂到 GUI 或 Web 后端。"
Q5. 怎么处理 CLI 参数解析时的 null 和异常?
示例答:
args.getOrNull(1)?.toIntOrNull() ?: error("...")—— 安全调用 + Elvis + error 三连Priority.valueOf(...)找不到会抛异常,用 try-catch 或 require 拦截- 顶层 try-catch 捕获所有异常,统一打错误信息 + 非零退出码
Q6. 这个项目为什么选 kotlinx.serialization 而不是 Jackson / Gson?
示例答:"kotlinx.serialization 是 Kotlin 官方序列化库:
- 支持 Multiplatform(同一份代码 JVM/JS/Native 都能用)
- 不依赖反射(用编译期 plugin 生成代码,性能好)
- 跟 data class / sealed class 集成更顺
- 缺点是生态没 Jackson 那么全,复杂场景可能要自定义 Serializer"
Q7. 怎么测试这个项目?
答:
kotlin
// build.gradle.kts
testImplementation(kotlin("test"))
// src/test/kotlin/TodoTest.kt
import kotlin.test.*
class TodoTest {
@Test fun testFormatActive() {
val t = Todo(1, "test", Priority.MID, null, false)
assertTrue("[ ]" in t.format())
}
@Test fun testFormatDone() {
val t = Todo(1, "test", done = true)
assertTrue("[✓]" in t.format())
}
}
// 跑测试
./gradlew testQ8. 假如这个项目要支持 1000 万条 todo,需要改什么?
答:
- 存储:改 JSON → SQLite(轻量)或 PostgreSQL(重量),用 Exposed / Ktor SQL
- 加载:改全量加载 → 按需查询(分页 / 流式)
- 协程:用
Flow替代List返回,按需消费 - 缓存:常用查询缓存到 Redis
- CLI 体验:list 默认只显示 active 前 20 条
Q9. Kotlin vs Python 做 CLI 工具,哪个更合适?
答:
| 维度 | Python | Kotlin |
|---|---|---|
| 启动速度 | 极快(毫秒级) | 慢(JVM 启动开销 200-500ms) |
| 部署 | 需要 Python 环境 | 需要 JDK,或编译 native |
| 生态 | argparse / click / rich,CLI 库丰富 | 相对较少(clikt, mordant) |
| 类型安全 | 弱(即使有 typing) | 强 |
| 性能 | 中等 | 高 |
| 学习曲线 | 低 | 中 |
💡 结论:小工具脚本用 Python,复杂工程化 CLI 用 Kotlin(特别是要复用 JVM 生态时)。或者用 Kotlin/Native 编译成原生二进制,启动也很快。
Q10. 整套 Kotlin 学下来,最大的感受是什么?跟你以前用的语言(Java / Python)比?
示例答: "Kotlin 给我最深的感受是『该简洁的地方极致简洁,该严谨的地方一丝不苟』。
- 比 Java 简洁:
data class一行 vs 50 行 - 比 Python 严谨:编译期类型检查 + null 安全
- 协程让异步代码看起来跟同步一样
- 跟 Java 100% 互操作,不用推翻老项目重写
最大的『心智负担』是 5 个作用域函数(let/run/with/apply/also)和协程的 Scope/Job/Dispatcher 概念,但搞懂之后写代码会上瘾 —— 一种克制又灵活的设计哲学。"
🎉 恭喜完成全部章节!
★★★★★ 你已经走完了 14 天 Kotlin 学习之旅 ★★★★★
Day 1-2 ✅ 入门:Hello Kotlin
Day 3-5 ✅ 进阶:函数 + 类
Day 6-8 ✅ 熟练:null 安全 + 集合 + 作用域函数
Day 9-11 ✅ 异步:协程
Day 12-14 ✅ 实战:CLI 项目
🚀 下一站:选择你的方向 🚀🎬 可视化演示
演示加载缓慢或样式异常?点此在新标签页打开 ↗
💻 示例代码
txt
/**
* 第 10 章 · 实战项目 — Gradle 构建脚本
*
* 包含:
* - kotlin("jvm") 主插件
* - kotlin("plugin.serialization") 用于 JSON 序列化
* - application 插件让 ./gradlew run 可用
* - kotlinx-coroutines-core 协程依赖
* - kotlinx-serialization-json JSON 依赖
*
* 用法:
* ./gradlew run --args="add '学 Kotlin'"
* ./gradlew installDist # 打包成 build/install/todo-cli/bin/
*/
plugins {
kotlin("jvm") version "2.0.21"
kotlin("plugin.serialization") version "2.0.21"
application
}
group = "com.example.kotlin.todo"
version = "1.0.0"
repositories {
mavenCentral()
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
testImplementation(kotlin("test"))
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
}
application {
mainClass.set("MainKt")
applicationName = "todo"
}
tasks.test {
useJUnitPlatform()
}
kotlin {
jvmToolchain(17)
}kotlin
/**
* 第 10 章 · 实战项目 — 命令模型 + 解析器
*
* 用 sealed class 表示"命令是这几种之一",配合 when 表达式,编译器
* 强制覆盖所有分支,新增命令时不会漏。
*/
sealed class Command {
data class Add(val title: String, val priority: Priority, val due: String?) : Command()
data class Done(val id: Int) : Command()
data class Remove(val id: Int) : Command()
data class List(val filter: Filter) : Command()
object Stats : Command()
object Help : Command()
enum class Filter { ALL, ACTIVE, DONE }
}
object CommandParser {
fun parse(args: Array<String>): Command {
if (args.isEmpty()) return Command.Help
return when (val cmd = args[0].lowercase()) {
"add" -> parseAdd(args.drop(1))
"done" -> Command.Done(args.getOrNull(1)?.toIntOrNull()
?: error("done 需要数字 id"))
"rm", "remove" -> Command.Remove(args.getOrNull(1)?.toIntOrNull()
?: error("rm 需要数字 id"))
"list", "ls" -> Command.List(parseFilter(args.getOrNull(1)))
"stats" -> Command.Stats
"help", "-h", "--help" -> Command.Help
else -> error("未知命令: $cmd(用 todo help 查看用法)")
}
}
private fun parseFilter(s: String?): Command.Filter = when (s) {
"active" -> Command.Filter.ACTIVE
"done" -> Command.Filter.DONE
null, "all" -> Command.Filter.ALL
else -> error("未知 filter: $s(可选 active / done / all)")
}
private fun parseAdd(args: kotlin.collections.List<String>): Command.Add {
require(args.isNotEmpty()) { "add 需要标题" }
val title = args[0]
var priority = Priority.MID
var due: String? = null
var i = 1
while (i < args.size) {
when (args[i]) {
"--priority", "-p" -> {
val v = args.getOrNull(++i) ?: error("--priority 需要值")
priority = runCatching { Priority.valueOf(v.uppercase()) }
.getOrElse { error("无效优先级: $v(可选 LOW / MID / HIGH)") }
}
"--due", "-d" -> {
due = args.getOrNull(++i) ?: error("--due 需要值")
}
else -> error("未知参数: ${args[i]}")
}
i++
}
return Command.Add(title, priority, due)
}
}kotlin
/**
* 第 10 章 · 实战项目 — CLI 入口
*
* 用法:
* todo add "学 Kotlin"
* todo add "做项目" --priority HIGH --due 2026-05-01
* todo list [active|done|all]
* todo done <id>
* todo rm <id>
* todo stats
* todo help
*/
import kotlinx.coroutines.runBlocking
fun main(args: Array<String>) = runBlocking {
try {
val cmd = CommandParser.parse(args)
execute(cmd)
} catch (e: IllegalArgumentException) {
System.err.println("❌ ${e.message}")
kotlin.system.exitProcess(1)
} catch (e: IllegalStateException) {
System.err.println("❌ ${e.message}")
kotlin.system.exitProcess(1)
} catch (e: Throwable) {
System.err.println("❌ 内部错误: ${e.message}")
e.printStackTrace()
kotlin.system.exitProcess(2)
}
}
private suspend fun execute(cmd: Command) {
val todos = Repository.load()
when (cmd) {
is Command.Add -> {
val newTodo = Todo(
id = Repository.nextId(todos),
title = cmd.title,
priority = cmd.priority,
due = cmd.due,
)
todos.add(newTodo)
Repository.save(todos)
println("✅ 添加成功: ${newTodo.format()}")
}
is Command.Done -> {
val idx = todos.indexOfFirst { it.id == cmd.id }
require(idx >= 0) { "找不到 #${cmd.id}" }
todos[idx] = todos[idx].copy(done = true)
Repository.save(todos)
println("✅ 完成: ${todos[idx].format()}")
}
is Command.Remove -> {
val removed = todos.removeIf { it.id == cmd.id }
require(removed) { "找不到 #${cmd.id}" }
Repository.save(todos)
println("🗑️ 删除 #${cmd.id}")
}
is Command.List -> {
val filtered = todos.filter {
when (cmd.filter) {
Command.Filter.ACTIVE -> !it.done
Command.Filter.DONE -> it.done
Command.Filter.ALL -> true
}
}
// 按优先级倒序 + 创建时间正序
val sorted = filtered.sortedWith(
compareByDescending<Todo> { it.priority.ordinal }
.thenBy { it.createdAt }
)
if (sorted.isEmpty()) {
println("(空)")
} else {
sorted.forEach { println(it.format()) }
println("\n${sorted.summary()}")
}
}
Command.Stats -> {
println(todos.summary())
// 按优先级分组统计
val byPriority = todos.groupingBy { it.priority }.eachCount()
println("按优先级:")
Priority.entries.forEach { p ->
println(" ${p.name.padEnd(4)}: ${byPriority[p] ?: 0}")
}
}
Command.Help -> println(HELP_TEXT)
}
}
private val HELP_TEXT = """
Todo CLI - 一个用 Kotlin 写的 todo 工具
用法:
todo add <title> [--priority LOW|MID|HIGH] [--due YYYY-MM-DD]
todo done <id> 标记完成
todo rm <id> 删除
todo list [active|done|all]
todo stats 统计
todo help 显示帮助
例子:
todo add "学 Kotlin"
todo add "做项目" --priority HIGH --due 2026-05-01
todo list active
""".trimIndent()kotlin
/**
* 第 10 章 · 实战项目 — 数据持久化(JSON 文件)
*
* 用 kotlinx.serialization 做 JSON,用协程把 IO 切到 IO 线程池。
*/
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
object Repository {
private val dataFile = File(System.getProperty("user.home"), ".todo/data.json")
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
}
suspend fun load(): MutableList<Todo> = withContext(Dispatchers.IO) {
if (!dataFile.exists()) return@withContext mutableListOf()
runCatching {
json.decodeFromString<List<Todo>>(dataFile.readText()).toMutableList()
}.getOrElse { e ->
System.err.println("⚠️ 数据文件损坏,使用空列表: ${e.message}")
mutableListOf()
}
}
suspend fun save(todos: List<Todo>) = withContext(Dispatchers.IO) {
dataFile.parentFile?.mkdirs()
dataFile.writeText(json.encodeToString(todos))
}
fun nextId(todos: List<Todo>): Int = (todos.maxOfOrNull { it.id } ?: 0) + 1
}kotlin
/**
* 第 10 章 · 实战项目 — Todo 数据模型
*/
import kotlinx.serialization.Serializable
@Serializable
enum class Priority { LOW, MID, HIGH }
@Serializable
data class Todo(
val id: Int,
val title: String,
val priority: Priority = Priority.MID,
val due: String? = null,
val done: Boolean = false,
val createdAt: Long = System.currentTimeMillis(),
)
/**
* 美化打印一个 todo。
* - [✓] 表示已完成(带删除线)
* - [ ] 表示未完成
* - 优先级用方括号
* - 截止日期可选
*/
fun Todo.format(): String {
val mark = if (done) "✓" else " "
val prio = "[${priority.name.padEnd(4)}]"
val displayTitle = if (done) "\u001B[9m${title}\u001B[0m" else title
val dueStr = due?.let { " (截止 $it)" } ?: ""
return "[$mark] #$id $prio $displayTitle$dueStr"
}
fun List<Todo>.summary(): String {
val total = size
val doneCount = count { it.done }
val highPrio = count { it.priority == Priority.HIGH && !it.done }
val pct = if (total > 0) doneCount * 100.0 / total else 0.0
return "总数: $total | 已完成: $doneCount (${"%.1f".format(pct)}%) | 高优待办: $highPrio"
}build.gradle.kts ↗ · Command.kt ↗ · Main.kt ↗ · Repository.kt ↗ · Todo.kt ↗