Skip to content

第 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 list

10.9 扩展挑战(自己练手)

完成基础版后,试试加这些功能:

难度 ⭐

  1. 彩色输出:用 ANSI 转义码给不同优先级配颜色
  2. 导出 Markdowntodo export > todos.md

难度 ⭐⭐

  1. 标签系统todo add "x" --tag work,urgent
  2. 搜索todo search "kotlin"
  3. 撤销todo undo(需要把每次操作存起来)

难度 ⭐⭐⭐

  1. 同步到云:把 JSON 同步到 Gist / S3,多机共享
  2. Web 版:用 Ktor 暴露 HTTP API,浏览器能访问
  3. TUI 界面:用 Mordant 做交互式 TUI

难度 ⭐⭐⭐⭐

  1. 多用户:加用户名 / 密码登录
  2. 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
跨平台 AppKotlin 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 test

Q8. 假如这个项目要支持 1000 万条 todo,需要改什么?

  1. 存储:改 JSON → SQLite(轻量)或 PostgreSQL(重量),用 Exposed / Ktor SQL
  2. 加载:改全量加载 → 按需查询(分页 / 流式)
  3. 协程:用 Flow 替代 List 返回,按需消费
  4. 缓存:常用查询缓存到 Redis
  5. CLI 体验:list 默认只显示 active 前 20 条

Q9. Kotlin vs Python 做 CLI 工具,哪个更合适?

维度PythonKotlin
启动速度极快(毫秒级)慢(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 项目
   
                    🚀 下一站:选择你的方向 🚀

回到 → README | 学习计划

🎬 可视化演示

演示加载缓慢或样式异常?点此在新标签页打开 ↗

💻 示例代码

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 ↗