Go 从 0 到精通 · 第 24 课:命令行小项目实战

学习定位:这是整套 Go 教程的第 24 课,也是阶段四(标准库与实战阶段)的最后一课。
前置要求:已经完成第 23 课,掌握了排序与集合操作。需要综合运用前面所有课程的知识。
本课目标:整合前面所学的结构体、切片、map、文件操作、JSON、排序、错误处理、时间处理、字符串处理等知识,从零完成一个具有实际功能的命令行待办事项管理器。


1. 本课你要解决的核心问题

前面 23 课你学了很多东西:变量、函数、结构体、切片、map、接口、文件、JSON、排序……但它们都是零散的。这一课要做的事就一个:把它们串起来,写一个真正能用的程序

我们要完成一个命令行待办事项管理器(Todo Manager),它能做到:

  • 添加待办事项(标题、分类、优先级、截止日期)
  • 列出所有待办事项(支持多种排序方式)
  • 标记任务为已完成
  • 删除任务
  • 按关键字搜索
  • 按分类筛选
  • 显示统计信息
  • 数据保存到文件(关闭程序后不丢失)

这个项目会用到你前面学的几乎所有东西:

知识点 对应课程 在项目中的用途
输入输出 第 3 课 命令行交互
条件判断与循环 第 4、5 课 菜单逻辑、遍历
函数 第 6 课 功能拆分
指针 第 7 课 修改任务状态
切片 第 9 课 存储任务列表
map 第 10 课 分类统计
字符串 第 11、21 课 搜索匹配
结构体 第 12 课 任务数据模型
方法 第 13 课 任务行为
错误处理 第 16 课 文件读写、输入校验
defer 第 17 课 文件关闭
文件操作 第 19 课 数据持久化
时间处理 第 20 课 截止日期、创建时间
JSON 第 22 课 数据序列化
排序 第 23 课 任务排序

学完这一课,你就完成了整个阶段四,具备了用 Go 做真实小项目的完整能力。


2. 项目设计——动手之前先想清楚

2.1 需求拆解

一个待办事项管理器,最核心的事情是什么?管理任务。一个任务需要哪些信息?

1
2
3
4
5
6
7
8
9
任务 = {
编号(唯一标识)
标题(做什么事)
分类(工作?学习?生活?)
优先级(高/中/低)
是否完成
创建时间
截止日期(可选)
}

用户通过命令行菜单操作这些任务,所有数据保存到一个 JSON 文件里。

2.2 交互设计

程序启动后显示菜单,用户输入数字选择操作:

1
2
3
4
5
6
7
8
9
10
11
========== 待办事项管理器 ==========
1. 添加任务
2. 查看所有任务
3. 标记任务完成
4. 删除任务
5. 搜索任务
6. 按分类查看
7. 统计信息
0. 保存并退出
====================================
请选择操作:

2.3 数据存储

用 JSON 文件存储,格式大概长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"next_id": 4,
"tasks": [
{
"id": 1,
"title": "学完 Go 第 24 课",
"category": "学习",
"priority": 1,
"done": false,
"created_at": "2025-01-15T10:30:00+08:00",
"due_date": "2025-01-20T23:59:59+08:00"
}
]
}

设计想清楚了,开始写代码。


3. 第一步:定义数据结构

3.1 任务结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import "time"

// 优先级常量
const (
PriorityHigh = 1
PriorityMedium = 2
PriorityLow = 3
)

// Task 表示一个待办事项
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Category string `json:"category"`
Priority int `json:"priority"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
DueDate time.Time `json:"due_date,omitempty"`
}

设计说明

  • ID 是唯一标识,方便用户指定"完成第几号任务"
  • Priority 用 int 表示(1=高、2=中、3=低),数字越小优先级越高,方便排序
  • DueDate 加了 omitempty,没设截止日期时 JSON 里不输出这个字段
  • JSON tag 用蛇形命名,这是 JSON 的常见风格(第 22 课)

3.2 优先级的显示方法

1
2
3
4
5
6
7
8
9
10
11
12
13
// PriorityText 返回优先级的中文描述
func PriorityText(p int) string {
switch p {
case PriorityHigh:
return "高"
case PriorityMedium:
return "中"
case PriorityLow:
return "低"
default:
return "未知"
}
}

3.3 任务的显示方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import "fmt"

// String 返回任务的格式化字符串
func (t Task) String() string {
status := "[ ]"
if t.Done {
status = "[✓]"
}

due := ""
if !t.DueDate.IsZero() {
due = fmt.Sprintf(" | 截止: %s", t.DueDate.Format("2006-01-02"))

// 如果未完成且已过期,标注提醒
if !t.Done && time.Now().After(t.DueDate) {
due += " (已过期!)"
}
}

return fmt.Sprintf("%s #%d [%s] %s (%s)%s",
status, t.ID, PriorityText(t.Priority), t.Title, t.Category, due)
}

这里用到了:

  • 方法(第 13 课):给 Task 定义 String() 方法
  • 时间格式化(第 20 课):t.DueDate.Format("2006-01-02")
  • 时间比较(第 20 课):time.Now().After(t.DueDate) 判断是否过期
  • 零值判断t.DueDate.IsZero() 判断有没有设置截止日期

3.4 数据存储结构

1
2
3
4
5
6
7
8
9
10
11
12
13
// TodoList 是整个待办事项列表,也是 JSON 文件的顶层结构
type TodoList struct {
NextID int `json:"next_id"`
Tasks []Task `json:"tasks"`
}

// NewTodoList 创建一个空的待办列表
func NewTodoList() *TodoList {
return &TodoList{
NextID: 1,
Tasks: []Task{},
}
}

NextID 记录下一个可用的 ID。每次添加任务就用这个 ID,然后自增。这样即使删除了任务,ID 也不会重复。


4. 第二步:实现核心功能

4.1 添加任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// AddTask 添加一个新任务
func (tl *TodoList) AddTask(title, category string, priority int, dueDate time.Time) {
task := Task{
ID: tl.NextID,
Title: title,
Category: category,
Priority: priority,
Done: false,
CreatedAt: time.Now(),
DueDate: dueDate,
}

tl.Tasks = append(tl.Tasks, task)
tl.NextID++
}

为什么用指针接收者(第 13 课):AddTask 要修改 TodoList 的内容(添加元素、递增 ID),如果用值接收者,修改的是副本,原始数据不会变。

4.2 标记完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import "errors"

// CompleteTask 将指定 ID 的任务标记为已完成
func (tl *TodoList) CompleteTask(id int) error {
for i := range tl.Tasks {
if tl.Tasks[i].ID == id {
if tl.Tasks[i].Done {
return fmt.Errorf("任务 #%d 已经完成过了", id)
}
tl.Tasks[i].Done = true
return nil
}
}
return fmt.Errorf("找不到任务 #%d", id)
}

注意 for i := range 而不是 for _, task := range

这是第 9 课和第 12 课提过的经典问题:for _, task := range 中的 task 是副本,修改 task.Done 不会影响切片中的原始数据。用索引 tl.Tasks[i].Done = true 才能真正修改。

错误处理(第 16 课):找不到任务或任务已完成,都返回错误信息。

4.3 删除任务

1
2
3
4
5
6
7
8
9
10
11
// DeleteTask 删除指定 ID 的任务
func (tl *TodoList) DeleteTask(id int) error {
for i, task := range tl.Tasks {
if task.ID == id {
// 用切片技巧删除元素(第 9 课)
tl.Tasks = append(tl.Tasks[:i], tl.Tasks[i+1:]...)
return nil
}
}
return fmt.Errorf("找不到任务 #%d", id)
}

切片删除元素的技巧,第 9 课讲过:append(s[:i], s[i+1:]...)

4.4 搜索任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import "strings"

// SearchTasks 按关键字搜索任务(匹配标题和分类)
func (tl *TodoList) SearchTasks(keyword string) []Task {
keyword = strings.ToLower(keyword)
result := []Task{}

for _, task := range tl.Tasks {
if strings.Contains(strings.ToLower(task.Title), keyword) ||
strings.Contains(strings.ToLower(task.Category), keyword) {
result = append(result, task)
}
}
return result
}

字符串处理(第 21 课):strings.ToLower 转小写实现大小写不敏感搜索,strings.Contains 判断是否包含子串。

4.5 按分类筛选

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// GetByCategory 返回指定分类的所有任务
func (tl *TodoList) GetByCategory(category string) []Task {
result := []Task{}
for _, task := range tl.Tasks {
if task.Category == category {
result = append(result, task)
}
}
return result
}

// GetCategories 返回所有分类名称(去重)
func (tl *TodoList) GetCategories() []string {
seen := make(map[string]bool)
categories := []string{}

for _, task := range tl.Tasks {
if !seen[task.Category] {
seen[task.Category] = true
categories = append(categories, task.Category)
}
}
return categories
}

去重用到了第 23 课的 map 去重技巧。

4.6 排序功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import "sort"

// SortByPriority 按优先级排序(高优先级在前)
func (tl *TodoList) SortByPriority() {
sort.SliceStable(tl.Tasks, func(i, j int) bool {
// 未完成的排在已完成的前面
if tl.Tasks[i].Done != tl.Tasks[j].Done {
return !tl.Tasks[i].Done
}
// 同状态按优先级排序(数字小 = 优先级高)
return tl.Tasks[i].Priority < tl.Tasks[j].Priority
})
}

// SortByDueDate 按截止日期排序(最紧急的在前)
func (tl *TodoList) SortByDueDate() {
sort.SliceStable(tl.Tasks, func(i, j int) bool {
// 未完成的排在已完成的前面
if tl.Tasks[i].Done != tl.Tasks[j].Done {
return !tl.Tasks[i].Done
}
// 有截止日期的排在没有的前面
iHasDue := !tl.Tasks[i].DueDate.IsZero()
jHasDue := !tl.Tasks[j].DueDate.IsZero()
if iHasDue != jHasDue {
return iHasDue
}
// 都有截止日期,早的排前面
if iHasDue && jHasDue {
return tl.Tasks[i].DueDate.Before(tl.Tasks[j].DueDate)
}
return false
})
}

// SortByCreatedAt 按创建时间排序(最新的在前)
func (tl *TodoList) SortByCreatedAt() {
sort.SliceStable(tl.Tasks, func(i, j int) bool {
return tl.Tasks[i].CreatedAt.After(tl.Tasks[j].CreatedAt)
})
}

为什么用 SliceStable(第 23 课):稳定排序保证同优先级的任务保持原来的顺序。

多字段排序(第 23 课):先按完成状态分组,再按优先级排序。这正是第 23 课讲的多字段排序技巧。

4.7 统计信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Stats 返回任务的统计信息
type Stats struct {
Total int
Done int
Pending int
Overdue int
ByCategory map[string]int
ByPriority map[string]int
}

func (tl *TodoList) GetStats() Stats {
stats := Stats{
Total: len(tl.Tasks),
ByCategory: make(map[string]int),
ByPriority: make(map[string]int),
}

now := time.Now()
for _, task := range tl.Tasks {
if task.Done {
stats.Done++
} else {
stats.Pending++
if !task.DueDate.IsZero() && now.After(task.DueDate) {
stats.Overdue++
}
}
stats.ByCategory[task.Category]++
stats.ByPriority[PriorityText(task.Priority)]++
}

return stats
}

map 统计(第 10 课):用 map[string]int 按分类和优先级计数。


5. 第三步:数据持久化

5.1 保存到文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import (
"encoding/json"
"os"
)

const dataFile = "todos.json"

// Save 将数据保存到 JSON 文件
func (tl *TodoList) Save() error {
data, err := json.MarshalIndent(tl, "", " ")
if err != nil {
return fmt.Errorf("序列化失败: %w", err)
}

err = os.WriteFile(dataFile, data, 0644)
if err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}

return nil
}

JSON 编码(第 22 课):json.MarshalIndent 生成格式化的 JSON,方便人类阅读。

文件写入(第 19 课):os.WriteFile 直接写入整个文件。

错误包装(第 16 课):用 %w 包装原始错误,保留错误链。

5.2 从文件加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Load 从 JSON 文件加载数据
func Load() (*TodoList, error) {
data, err := os.ReadFile(dataFile)
if err != nil {
if os.IsNotExist(err) {
// 文件不存在,返回空列表(首次使用)
return NewTodoList(), nil
}
return nil, fmt.Errorf("读取文件失败: %w", err)
}

var tl TodoList
err = json.Unmarshal(data, &tl)
if err != nil {
return nil, fmt.Errorf("解析 JSON 失败: %w", err)
}

return &tl, nil
}

文件不存在的处理:第一次运行程序时 todos.json 不存在,这是正常情况,不应该报错,而应该返回一个空列表。os.IsNotExist(err) 判断是否是"文件不存在"的错误。

JSON 解码(第 22 课):json.Unmarshal 把 JSON 数据解析到结构体中。


6. 第四步:命令行交互

6.1 主菜单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import "bufio"

func main() {
// 加载数据
todoList, err := Load()
if err != nil {
fmt.Printf("加载数据失败: %v\n", err)
fmt.Println("将使用空列表启动")
todoList = NewTodoList()
}

scanner := bufio.NewScanner(os.Stdin)

fmt.Println("欢迎使用待办事项管理器!")

for {
printMenu()

if !scanner.Scan() {
break
}
choice := strings.TrimSpace(scanner.Text())

switch choice {
case "1":
handleAdd(todoList, scanner)
case "2":
handleList(todoList, scanner)
case "3":
handleComplete(todoList, scanner)
case "4":
handleDelete(todoList, scanner)
case "5":
handleSearch(todoList, scanner)
case "6":
handleCategoryView(todoList, scanner)
case "7":
handleStats(todoList)
case "0":
err := todoList.Save()
if err != nil {
fmt.Printf("保存失败: %v\n", err)
} else {
fmt.Println("数据已保存,再见!")
}
return
default:
fmt.Println("无效的选项,请重新选择")
}
}
}

func printMenu() {
fmt.Println()
fmt.Println("========== 待办事项管理器 ==========")
fmt.Println(" 1. 添加任务")
fmt.Println(" 2. 查看所有任务")
fmt.Println(" 3. 标记任务完成")
fmt.Println(" 4. 删除任务")
fmt.Println(" 5. 搜索任务")
fmt.Println(" 6. 按分类查看")
fmt.Println(" 7. 统计信息")
fmt.Println(" 0. 保存并退出")
fmt.Println("====================================")
fmt.Print("请选择操作: ")
}

为什么用 bufio.Scanner 而不是 fmt.Scanfmt.Scan 遇到空格就停了,读不了一整行。bufio.Scanner 按行读取,能处理带空格的输入(比如任务标题"写 Go 作业")。

6.2 添加任务的交互

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import "strconv"

func handleAdd(tl *TodoList, scanner *bufio.Scanner) {
fmt.Println("\n--- 添加任务 ---")

// 读取标题
fmt.Print("任务标题: ")
if !scanner.Scan() {
return
}
title := strings.TrimSpace(scanner.Text())
if title == "" {
fmt.Println("标题不能为空")
return
}

// 读取分类
fmt.Print("分类 (如: 工作/学习/生活,默认: 其他): ")
if !scanner.Scan() {
return
}
category := strings.TrimSpace(scanner.Text())
if category == "" {
category = "其他"
}

// 读取优先级
fmt.Print("优先级 (1=高 2=中 3=低,默认: 2): ")
if !scanner.Scan() {
return
}
priorityStr := strings.TrimSpace(scanner.Text())
priority := PriorityMedium // 默认中等
if priorityStr != "" {
p, err := strconv.Atoi(priorityStr)
if err != nil || p < 1 || p > 3 {
fmt.Println("优先级无效,使用默认值(中)")
} else {
priority = p
}
}

// 读取截止日期
fmt.Print("截止日期 (格式: 2006-01-02,留空跳过): ")
if !scanner.Scan() {
return
}
dueDateStr := strings.TrimSpace(scanner.Text())
var dueDate time.Time
if dueDateStr != "" {
parsed, err := time.Parse("2006-01-02", dueDateStr)
if err != nil {
fmt.Println("日期格式不对,跳过截止日期")
} else {
// 设置为当天的 23:59:59
dueDate = parsed.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
}
}

tl.AddTask(title, category, priority, dueDate)
fmt.Printf("任务已添加: #%d %s\n", tl.NextID-1, title)
}

这个函数展示了完整的用户输入处理流程

  1. 提示用户输入
  2. 读取一行
  3. 去除空白(strings.TrimSpace,第 21 课)
  4. 校验输入(空值检查)
  5. 类型转换(strconv.Atoi,第 21 课)
  6. 提供默认值(分类默认"其他",优先级默认"中")
  7. 解析时间(time.Parse,第 20 课)

6.3 查看任务列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func handleList(tl *TodoList, scanner *bufio.Scanner) {
if len(tl.Tasks) == 0 {
fmt.Println("\n没有任何任务")
return
}

fmt.Println("\n排序方式:")
fmt.Println(" 1. 按优先级")
fmt.Println(" 2. 按截止日期")
fmt.Println(" 3. 按创建时间")
fmt.Print("选择 (默认: 1): ")

if !scanner.Scan() {
return
}
sortChoice := strings.TrimSpace(scanner.Text())

switch sortChoice {
case "2":
tl.SortByDueDate()
case "3":
tl.SortByCreatedAt()
default:
tl.SortByPriority()
}

fmt.Println("\n--- 任务列表 ---")
for _, task := range tl.Tasks {
fmt.Println(task) // 自动调用 task.String()
}
fmt.Printf("\n共 %d 个任务\n", len(tl.Tasks))
}

fmt.Println(task) 自动调用 String() 方法:这是 Go 的约定(第 13 课提到的 Stringer 接口)。只要类型实现了 String() string 方法,fmt.Println 就会用它来格式化输出。

6.4 标记完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
func handleComplete(tl *TodoList, scanner *bufio.Scanner) {
// 先显示未完成的任务
pending := []Task{}
for _, task := range tl.Tasks {
if !task.Done {
pending = append(pending, task)
}
}

if len(pending) == 0 {
fmt.Println("\n没有未完成的任务")
return
}

fmt.Println("\n--- 未完成的任务 ---")
for _, task := range pending {
fmt.Println(task)
}

fmt.Print("\n输入要完成的任务编号: ")
if !scanner.Scan() {
return
}
idStr := strings.TrimSpace(scanner.Text())
id, err := strconv.Atoi(idStr)
if err != nil {
fmt.Println("请输入有效的数字")
return
}

err = tl.CompleteTask(id)
if err != nil {
fmt.Printf("操作失败: %v\n", err)
} else {
fmt.Printf("任务 #%d 已标记为完成!\n", id)
}
}

6.5 删除任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
func handleDelete(tl *TodoList, scanner *bufio.Scanner) {
if len(tl.Tasks) == 0 {
fmt.Println("\n没有任何任务")
return
}

fmt.Println("\n--- 所有任务 ---")
for _, task := range tl.Tasks {
fmt.Println(task)
}

fmt.Print("\n输入要删除的任务编号: ")
if !scanner.Scan() {
return
}
idStr := strings.TrimSpace(scanner.Text())
id, err := strconv.Atoi(idStr)
if err != nil {
fmt.Println("请输入有效的数字")
return
}

// 二次确认
fmt.Printf("确认删除任务 #%d? (y/n): ", id)
if !scanner.Scan() {
return
}
confirm := strings.TrimSpace(strings.ToLower(scanner.Text()))
if confirm != "y" {
fmt.Println("已取消删除")
return
}

err = tl.DeleteTask(id)
if err != nil {
fmt.Printf("删除失败: %v\n", err)
} else {
fmt.Printf("任务 #%d 已删除\n", id)
}
}

二次确认是好习惯:删除是不可逆的操作,加一步确认可以防止误操作。

6.6 搜索任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func handleSearch(tl *TodoList, scanner *bufio.Scanner) {
fmt.Print("\n输入搜索关键字: ")
if !scanner.Scan() {
return
}
keyword := strings.TrimSpace(scanner.Text())
if keyword == "" {
fmt.Println("关键字不能为空")
return
}

results := tl.SearchTasks(keyword)
if len(results) == 0 {
fmt.Printf("没有找到包含 \"%s\" 的任务\n", keyword)
return
}

fmt.Printf("\n--- 搜索结果 (%d 条) ---\n", len(results))
for _, task := range results {
fmt.Println(task)
}
}

6.7 按分类查看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func handleCategoryView(tl *TodoList, scanner *bufio.Scanner) {
categories := tl.GetCategories()
if len(categories) == 0 {
fmt.Println("\n没有任何任务")
return
}

fmt.Println("\n可用分类:")
for i, cat := range categories {
count := len(tl.GetByCategory(cat))
fmt.Printf(" %d. %s (%d 个任务)\n", i+1, cat, count)
}

fmt.Print("\n选择分类编号: ")
if !scanner.Scan() {
return
}
idxStr := strings.TrimSpace(scanner.Text())
idx, err := strconv.Atoi(idxStr)
if err != nil || idx < 1 || idx > len(categories) {
fmt.Println("无效的选择")
return
}

category := categories[idx-1]
tasks := tl.GetByCategory(category)

fmt.Printf("\n--- 分类: %s ---\n", category)
for _, task := range tasks {
fmt.Println(task)
}
}

6.8 统计信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func handleStats(tl *TodoList) {
stats := tl.GetStats()

fmt.Println("\n--- 统计信息 ---")
fmt.Printf("总任务数: %d\n", stats.Total)

if stats.Total == 0 {
return
}

fmt.Printf("已完成: %d\n", stats.Done)
fmt.Printf("未完成: %d\n", stats.Pending)
if stats.Overdue > 0 {
fmt.Printf("已过期: %d\n", stats.Overdue)
}

// 完成率
rate := float64(stats.Done) / float64(stats.Total) * 100
fmt.Printf("完成率: %.1f%%\n", rate)

// 按分类统计
fmt.Println("\n按分类:")
for cat, count := range stats.ByCategory {
fmt.Printf(" %s: %d 个\n", cat, count)
}

// 按优先级统计
fmt.Println("\n按优先级:")
// 按 高→中→低 的顺序输出
for _, p := range []string{"高", "中", "低"} {
if count, ok := stats.ByPriority[p]; ok {
fmt.Printf(" %s: %d 个\n", p, count)
}
}
}

注意遍历顺序:直接遍历 map 的顺序是随机的(第 10 课)。所以按优先级输出时,手动指定了遍历顺序 []string{"高", "中", "低"}


7. 完整代码

把上面所有代码合并到一个 main.go 文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
package main

import (
"bufio"
"encoding/json"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
)

// ============================================================
// 常量与数据结构
// ============================================================

const dataFile = "todos.json"

const (
PriorityHigh = 1
PriorityMedium = 2
PriorityLow = 3
)

func PriorityText(p int) string {
switch p {
case PriorityHigh:
return "高"
case PriorityMedium:
return "中"
case PriorityLow:
return "低"
default:
return "未知"
}
}

type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Category string `json:"category"`
Priority int `json:"priority"`
Done bool `json:"done"`
CreatedAt time.Time `json:"created_at"`
DueDate time.Time `json:"due_date,omitempty"`
}

func (t Task) String() string {
status := "[ ]"
if t.Done {
status = "[v]"
}

due := ""
if !t.DueDate.IsZero() {
due = fmt.Sprintf(" | 截止: %s", t.DueDate.Format("2006-01-02"))
if !t.Done && time.Now().After(t.DueDate) {
due += " (已过期!)"
}
}

return fmt.Sprintf("%s #%d [%s] %s (%s)%s",
status, t.ID, PriorityText(t.Priority), t.Title, t.Category, due)
}

type TodoList struct {
NextID int `json:"next_id"`
Tasks []Task `json:"tasks"`
}

func NewTodoList() *TodoList {
return &TodoList{
NextID: 1,
Tasks: []Task{},
}
}

// ============================================================
// 核心功能
// ============================================================

func (tl *TodoList) AddTask(title, category string, priority int, dueDate time.Time) {
task := Task{
ID: tl.NextID,
Title: title,
Category: category,
Priority: priority,
Done: false,
CreatedAt: time.Now(),
DueDate: dueDate,
}
tl.Tasks = append(tl.Tasks, task)
tl.NextID++
}

func (tl *TodoList) CompleteTask(id int) error {
for i := range tl.Tasks {
if tl.Tasks[i].ID == id {
if tl.Tasks[i].Done {
return fmt.Errorf("任务 #%d 已经完成过了", id)
}
tl.Tasks[i].Done = true
return nil
}
}
return fmt.Errorf("找不到任务 #%d", id)
}

func (tl *TodoList) DeleteTask(id int) error {
for i, task := range tl.Tasks {
if task.ID == id {
tl.Tasks = append(tl.Tasks[:i], tl.Tasks[i+1:]...)
return nil
}
}
return fmt.Errorf("找不到任务 #%d", id)
}

func (tl *TodoList) SearchTasks(keyword string) []Task {
keyword = strings.ToLower(keyword)
result := []Task{}
for _, task := range tl.Tasks {
if strings.Contains(strings.ToLower(task.Title), keyword) ||
strings.Contains(strings.ToLower(task.Category), keyword) {
result = append(result, task)
}
}
return result
}

func (tl *TodoList) GetByCategory(category string) []Task {
result := []Task{}
for _, task := range tl.Tasks {
if task.Category == category {
result = append(result, task)
}
}
return result
}

func (tl *TodoList) GetCategories() []string {
seen := make(map[string]bool)
categories := []string{}
for _, task := range tl.Tasks {
if !seen[task.Category] {
seen[task.Category] = true
categories = append(categories, task.Category)
}
}
return categories
}

// ============================================================
// 排序
// ============================================================

func (tl *TodoList) SortByPriority() {
sort.SliceStable(tl.Tasks, func(i, j int) bool {
if tl.Tasks[i].Done != tl.Tasks[j].Done {
return !tl.Tasks[i].Done
}
return tl.Tasks[i].Priority < tl.Tasks[j].Priority
})
}

func (tl *TodoList) SortByDueDate() {
sort.SliceStable(tl.Tasks, func(i, j int) bool {
if tl.Tasks[i].Done != tl.Tasks[j].Done {
return !tl.Tasks[i].Done
}
iHasDue := !tl.Tasks[i].DueDate.IsZero()
jHasDue := !tl.Tasks[j].DueDate.IsZero()
if iHasDue != jHasDue {
return iHasDue
}
if iHasDue && jHasDue {
return tl.Tasks[i].DueDate.Before(tl.Tasks[j].DueDate)
}
return false
})
}

func (tl *TodoList) SortByCreatedAt() {
sort.SliceStable(tl.Tasks, func(i, j int) bool {
return tl.Tasks[i].CreatedAt.After(tl.Tasks[j].CreatedAt)
})
}

// ============================================================
// 统计
// ============================================================

type Stats struct {
Total int
Done int
Pending int
Overdue int
ByCategory map[string]int
ByPriority map[string]int
}

func (tl *TodoList) GetStats() Stats {
stats := Stats{
Total: len(tl.Tasks),
ByCategory: make(map[string]int),
ByPriority: make(map[string]int),
}
now := time.Now()
for _, task := range tl.Tasks {
if task.Done {
stats.Done++
} else {
stats.Pending++
if !task.DueDate.IsZero() && now.After(task.DueDate) {
stats.Overdue++
}
}
stats.ByCategory[task.Category]++
stats.ByPriority[PriorityText(task.Priority)]++
}
return stats
}

// ============================================================
// 文件持久化
// ============================================================

func (tl *TodoList) Save() error {
data, err := json.MarshalIndent(tl, "", " ")
if err != nil {
return fmt.Errorf("序列化失败: %w", err)
}
err = os.WriteFile(dataFile, data, 0644)
if err != nil {
return fmt.Errorf("写入文件失败: %w", err)
}
return nil
}

func Load() (*TodoList, error) {
data, err := os.ReadFile(dataFile)
if err != nil {
if os.IsNotExist(err) {
return NewTodoList(), nil
}
return nil, fmt.Errorf("读取文件失败: %w", err)
}
var tl TodoList
err = json.Unmarshal(data, &tl)
if err != nil {
return nil, fmt.Errorf("解析 JSON 失败: %w", err)
}
return &tl, nil
}

// ============================================================
// 命令行交互
// ============================================================

func printMenu() {
fmt.Println()
fmt.Println("========== 待办事项管理器 ==========")
fmt.Println(" 1. 添加任务")
fmt.Println(" 2. 查看所有任务")
fmt.Println(" 3. 标记任务完成")
fmt.Println(" 4. 删除任务")
fmt.Println(" 5. 搜索任务")
fmt.Println(" 6. 按分类查看")
fmt.Println(" 7. 统计信息")
fmt.Println(" 0. 保存并退出")
fmt.Println("====================================")
fmt.Print("请选择操作: ")
}

func handleAdd(tl *TodoList, scanner *bufio.Scanner) {
fmt.Println("\n--- 添加任务 ---")

fmt.Print("任务标题: ")
if !scanner.Scan() {
return
}
title := strings.TrimSpace(scanner.Text())
if title == "" {
fmt.Println("标题不能为空")
return
}

fmt.Print("分类 (如: 工作/学习/生活,默认: 其他): ")
if !scanner.Scan() {
return
}
category := strings.TrimSpace(scanner.Text())
if category == "" {
category = "其他"
}

fmt.Print("优先级 (1=高 2=中 3=低,默认: 2): ")
if !scanner.Scan() {
return
}
priorityStr := strings.TrimSpace(scanner.Text())
priority := PriorityMedium
if priorityStr != "" {
p, err := strconv.Atoi(priorityStr)
if err != nil || p < 1 || p > 3 {
fmt.Println("优先级无效,使用默认值(中)")
} else {
priority = p
}
}

fmt.Print("截止日期 (格式: 2006-01-02,留空跳过): ")
if !scanner.Scan() {
return
}
dueDateStr := strings.TrimSpace(scanner.Text())
var dueDate time.Time
if dueDateStr != "" {
parsed, err := time.Parse("2006-01-02", dueDateStr)
if err != nil {
fmt.Println("日期格式不对,跳过截止日期")
} else {
dueDate = parsed.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
}
}

tl.AddTask(title, category, priority, dueDate)
fmt.Printf("任务已添加: #%d %s\n", tl.NextID-1, title)
}

func handleList(tl *TodoList, scanner *bufio.Scanner) {
if len(tl.Tasks) == 0 {
fmt.Println("\n没有任何任务")
return
}

fmt.Println("\n排序方式:")
fmt.Println(" 1. 按优先级")
fmt.Println(" 2. 按截止日期")
fmt.Println(" 3. 按创建时间")
fmt.Print("选择 (默认: 1): ")

if !scanner.Scan() {
return
}
sortChoice := strings.TrimSpace(scanner.Text())

switch sortChoice {
case "2":
tl.SortByDueDate()
case "3":
tl.SortByCreatedAt()
default:
tl.SortByPriority()
}

fmt.Println("\n--- 任务列表 ---")
for _, task := range tl.Tasks {
fmt.Println(task)
}
fmt.Printf("\n共 %d 个任务\n", len(tl.Tasks))
}

func handleComplete(tl *TodoList, scanner *bufio.Scanner) {
pending := []Task{}
for _, task := range tl.Tasks {
if !task.Done {
pending = append(pending, task)
}
}

if len(pending) == 0 {
fmt.Println("\n没有未完成的任务")
return
}

fmt.Println("\n--- 未完成的任务 ---")
for _, task := range pending {
fmt.Println(task)
}

fmt.Print("\n输入要完成的任务编号: ")
if !scanner.Scan() {
return
}
idStr := strings.TrimSpace(scanner.Text())
id, err := strconv.Atoi(idStr)
if err != nil {
fmt.Println("请输入有效的数字")
return
}

err = tl.CompleteTask(id)
if err != nil {
fmt.Printf("操作失败: %v\n", err)
} else {
fmt.Printf("任务 #%d 已标记为完成!\n", id)
}
}

func handleDelete(tl *TodoList, scanner *bufio.Scanner) {
if len(tl.Tasks) == 0 {
fmt.Println("\n没有任何任务")
return
}

fmt.Println("\n--- 所有任务 ---")
for _, task := range tl.Tasks {
fmt.Println(task)
}

fmt.Print("\n输入要删除的任务编号: ")
if !scanner.Scan() {
return
}
idStr := strings.TrimSpace(scanner.Text())
id, err := strconv.Atoi(idStr)
if err != nil {
fmt.Println("请输入有效的数字")
return
}

fmt.Printf("确认删除任务 #%d? (y/n): ", id)
if !scanner.Scan() {
return
}
confirm := strings.TrimSpace(strings.ToLower(scanner.Text()))
if confirm != "y" {
fmt.Println("已取消删除")
return
}

err = tl.DeleteTask(id)
if err != nil {
fmt.Printf("删除失败: %v\n", err)
} else {
fmt.Printf("任务 #%d 已删除\n", id)
}
}

func handleSearch(tl *TodoList, scanner *bufio.Scanner) {
fmt.Print("\n输入搜索关键字: ")
if !scanner.Scan() {
return
}
keyword := strings.TrimSpace(scanner.Text())
if keyword == "" {
fmt.Println("关键字不能为空")
return
}

results := tl.SearchTasks(keyword)
if len(results) == 0 {
fmt.Printf("没有找到包含 \"%s\" 的任务\n", keyword)
return
}

fmt.Printf("\n--- 搜索结果 (%d 条) ---\n", len(results))
for _, task := range results {
fmt.Println(task)
}
}

func handleCategoryView(tl *TodoList, scanner *bufio.Scanner) {
categories := tl.GetCategories()
if len(categories) == 0 {
fmt.Println("\n没有任何任务")
return
}

fmt.Println("\n可用分类:")
for i, cat := range categories {
count := len(tl.GetByCategory(cat))
fmt.Printf(" %d. %s (%d 个任务)\n", i+1, cat, count)
}

fmt.Print("\n选择分类编号: ")
if !scanner.Scan() {
return
}
idxStr := strings.TrimSpace(scanner.Text())
idx, err := strconv.Atoi(idxStr)
if err != nil || idx < 1 || idx > len(categories) {
fmt.Println("无效的选择")
return
}

category := categories[idx-1]
tasks := tl.GetByCategory(category)

fmt.Printf("\n--- 分类: %s ---\n", category)
for _, task := range tasks {
fmt.Println(task)
}
}

func handleStats(tl *TodoList) {
stats := tl.GetStats()

fmt.Println("\n--- 统计信息 ---")
fmt.Printf("总任务数: %d\n", stats.Total)

if stats.Total == 0 {
return
}

fmt.Printf("已完成: %d\n", stats.Done)
fmt.Printf("未完成: %d\n", stats.Pending)
if stats.Overdue > 0 {
fmt.Printf("已过期: %d\n", stats.Overdue)
}

rate := float64(stats.Done) / float64(stats.Total) * 100
fmt.Printf("完成率: %.1f%%\n", rate)

fmt.Println("\n按分类:")
for cat, count := range stats.ByCategory {
fmt.Printf(" %s: %d 个\n", cat, count)
}

fmt.Println("\n按优先级:")
for _, p := range []string{"高", "中", "低"} {
if count, ok := stats.ByPriority[p]; ok {
fmt.Printf(" %s: %d 个\n", p, count)
}
}
}

// ============================================================
// 主函数
// ============================================================

func main() {
todoList, err := Load()
if err != nil {
fmt.Printf("加载数据失败: %v\n", err)
fmt.Println("将使用空列表启动")
todoList = NewTodoList()
}

scanner := bufio.NewScanner(os.Stdin)
fmt.Println("欢迎使用待办事项管理器!")

for {
printMenu()

if !scanner.Scan() {
break
}
choice := strings.TrimSpace(scanner.Text())

switch choice {
case "1":
handleAdd(todoList, scanner)
case "2":
handleList(todoList, scanner)
case "3":
handleComplete(todoList, scanner)
case "4":
handleDelete(todoList, scanner)
case "5":
handleSearch(todoList, scanner)
case "6":
handleCategoryView(todoList, scanner)
case "7":
handleStats(todoList)
case "0":
err := todoList.Save()
if err != nil {
fmt.Printf("保存失败: %v\n", err)
} else {
fmt.Println("数据已保存,再见!")
}
return
default:
fmt.Println("无效的选项,请重新选择")
}
}
}

8. 运行效果

把代码保存为 main.go,运行 go run main.go,实际操作一把:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
欢迎使用待办事项管理器!

========== 待办事项管理器 ==========
1. 添加任务
2. 查看所有任务
3. 标记任务完成
4. 删除任务
5. 搜索任务
6. 按分类查看
7. 统计信息
0. 保存并退出
====================================
请选择操作: 1

--- 添加任务 ---
任务标题: 学完 Go 第 24 课
分类 (如: 工作/学习/生活,默认: 其他): 学习
优先级 (1=高 2=中 3=低,默认: 2): 1
截止日期 (格式: 2006-01-02,留空跳过): 2025-01-20
任务已添加: #1 学完 Go 第 24 课

========== 待办事项管理器 ==========
请选择操作: 1

--- 添加任务 ---
任务标题: 买菜做饭
分类 (如: 工作/学习/生活,默认: 其他): 生活
优先级 (1=高 2=中 3=低,默认: 2):
截止日期 (格式: 2006-01-02,留空跳过):
任务已添加: #2 买菜做饭

请选择操作: 2

排序方式:
1. 按优先级
2. 按截止日期
3. 按创建时间
选择 (默认: 1): 1

--- 任务列表 ---
[ ] #1 [高] 学完 Go 第 24 课 (学习) | 截止: 2025-01-20
[ ] #2 [中] 买菜做饭 (生活)

共 2 个任务

请选择操作: 3

--- 未完成的任务 ---
[ ] #1 [高] 学完 Go 第 24 课 (学习) | 截止: 2025-01-20
[ ] #2 [中] 买菜做饭 (生活)

输入要完成的任务编号: 2
任务 #2 已标记为完成!

请选择操作: 7

--- 统计信息 ---
总任务数: 2
已完成: 1
未完成: 1
完成率: 50.0%

按分类:
学习: 1 个
生活: 1 个

按优先级:
: 1 个
: 1 个

请选择操作: 0
数据已保存,再见!

退出后再启动程序,数据还在——因为保存到了 todos.json 文件里。


9. 项目回顾——学到了什么

这个项目虽然不大,但已经包含了一个真实程序的完整要素。回顾一下每个关键设计决策:

9.1 为什么用 NextID 而不是数组索引做 ID

如果用数组索引做 ID,删除一个任务后,后面所有任务的 ID 都会变。用户说"完成第 3 号任务",删了中间一个后第 3 号指向的任务就变了。NextID 只增不减,保证每个任务有唯一稳定的编号。

9.2 为什么 CompleteTask 用索引遍历而不用 range 值

1
2
3
4
5
6
7
8
9
10
11
12
13
// 错误:修改的是副本
for _, task := range tl.Tasks {
if task.ID == id {
task.Done = true // 改了副本,原数据不变!
}
}

// 正确:通过索引修改原数据
for i := range tl.Tasks {
if tl.Tasks[i].ID == id {
tl.Tasks[i].Done = true // 直接修改切片中的元素
}
}

这是第 9 课和第 12 课反复强调的:range 的第二个值是副本。

9.3 为什么用 bufio.Scanner 读输入

fmt.Scan 以空白为分隔符,如果用户输入"写 Go 作业",fmt.Scan 只能读到"写"。bufio.Scanner 按行读取,能处理完整的一行输入。

9.4 为什么每个 handle 函数都传 scanner

因为 bufio.Scanner 包装了 os.Stdin。如果在多个地方各自创建 Scanner,它们的内部缓冲区会互相抢数据,导致读取混乱。一个 stdin 只用一个 Scanner

9.5 为什么排序用 SliceStable 而不是 Slice

用户操作任务时有个朴素的期望:如果两个任务优先级一样,它们应该保持我添加时的顺序。SliceStable 满足这个期望,Slice 不保证。

9.6 保存时机的选择

当前实现是退出时保存。这意味着如果程序崩溃或者被强制关闭,数据会丢失。更稳妥的做法是每次修改后都自动保存,但那样代码更复杂。对于学习项目,退出时保存够用了。


10. 常见坑总结

10.1 Scanner 的缓冲区大小

1
2
3
4
scanner := bufio.NewScanner(os.Stdin)
// 默认缓冲区大小是 64KB
// 如果一行输入超过 64KB,scanner.Scan() 会返回 false
// 对于命令行交互来说绰绰有余

10.2 time.Parse 的参考时间

1
2
3
4
5
6
7
8
9
// Go 的时间解析用的参考时间是固定的:2006-01-02 15:04:05
// 不是随便写的,而是 Go 的"诞生时间"
// 如果格式写错了,解析结果就是错的

// 正确
time.Parse("2006-01-02", "2025-01-20")

// 错误——格式字符串用了错误的数字
time.Parse("2023-01-02", "2025-01-20") // 解析结果不对

10.3 JSON 的零值问题

1
2
3
4
5
6
7
type Task struct {
DueDate time.Time `json:"due_date,omitempty"`
}

// omitempty 对 time.Time 的判断是 IsZero()
// 即 0001-01-01T00:00:00Z
// 没设截止日期的任务不会输出 due_date 字段

10.4 切片删除后的索引

1
2
3
4
5
6
7
8
// 删除后不要继续用原来的索引遍历
for i, task := range tl.Tasks {
if shouldDelete(task) {
tl.Tasks = append(tl.Tasks[:i], tl.Tasks[i+1:]...)
// 这里应该 return 或 break
// 如果继续循环,索引 i+1 对应的元素已经变了
}
}

这个项目里每次只删一个,删完就 return,所以没有问题。但如果你要批量删除,需要从后往前遍历,或者用新切片收集要保留的元素。

10.5 map 遍历顺序不确定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 错误:直接遍历 map,每次顺序可能不同
for cat, count := range stats.ByCategory {
fmt.Printf("%s: %d\n", cat, count)
}

// 如果需要固定顺序,先收集 key,排序后遍历
keys := make([]string, 0, len(stats.ByCategory))
for k := range stats.ByCategory {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("%s: %d\n", k, stats.ByCategory[k])
}

10.6 输入校验不能省

1
2
3
4
5
6
// 如果用户输入的不是数字,strconv.Atoi 会返回错误
id, err := strconv.Atoi(idStr)
if err != nil {
fmt.Println("请输入有效的数字")
return // 别忘了 return,否则会用零值继续执行
}

每个用户输入都要假设它可能是错的。不做校验的程序迟早会崩。


11. 本课练习

练习 1:添加"修改任务"功能

要求:

  • 用户可以修改已有任务的标题、分类、优先级、截止日期
  • 先显示当前值,用户输入新值(留空表示不修改)

提示:找到任务后逐个字段提示用户输入,空输入跳过。


练习 2:添加"批量完成"功能

要求:

  • 用户可以一次性输入多个任务编号,用逗号分隔(如 1,3,5
  • 逐个处理,输出每个任务的处理结果

提示:用 strings.Split 按逗号分割,循环处理每个编号。


练习 3:数据备份功能

要求:

  • 添加一个菜单选项"备份数据"
  • 把当前数据保存到带日期的文件名,如 todos_20250120.json
  • time.Now().Format("20060102") 生成日期字符串

练习 4:改进排序——支持同时按多个条件排序

要求:

  • 让用户可以选择"先按分类排,再按优先级排"
  • 或者"先按完成状态排,再按截止日期排"

提示:在 less 函数中嵌套条件判断,跟第 23 课的多字段排序一样。


练习 5:导出为文本报告

要求:

  • 添加一个菜单选项"导出报告"
  • 生成一个纯文本的任务报告文件 report.txt
  • 内容包括统计信息和所有任务列表

提示:用 os.WriteFilebufio.Writer 写文件。


12. 自测题

12.1 概念题

  1. 为什么 AddTask 方法要用指针接收者 *TodoList 而不是值接收者 TodoList
  2. for _, task := range tl.Tasks 中修改 task.Done 为什么不生效?怎么改?
  3. 为什么用 bufio.Scanner 读输入而不是 fmt.Scan
  4. os.IsNotExist(err) 判断的是什么?为什么要单独处理这个错误?
  5. json.MarshalIndent(tl, "", " ") 的第二个和第三个参数分别是什么意思?
  6. 为什么 NextID 只增不减,即使删了任务也不回收 ID?
  7. 为什么一个 os.Stdin 只应该创建一个 bufio.Scanner
  8. sort.SliceStablesort.Slice 多保证了什么?在这个项目中为什么选择用它?

12.2 代码阅读题

预测以下代码的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Item struct {
Name string
Done bool
}

items := []Item{
{"A", false},
{"B", false},
{"C", false},
}

for _, item := range items {
item.Done = true
}

for _, item := range items {
fmt.Printf("%s: %v\n", item.Name, item.Done)
}
点击查看答案
1
2
3
A: false
B: false
C: false

解释:

  1. 第一个 for range 中的 item 是副本,修改 item.Done 不影响切片中的原始数据
  2. 要修改原始数据,必须用索引:items[i].Done = true
  3. 这正是本课 CompleteTask 方法中为什么用 for i := range 而不是 for _, task := range 的原因

13. 本课总结

这是阶段四的最后一课,你通过一个完整的项目把前面学的知识串了起来。

你做了什么 用到了哪些知识
定义 Task 结构体 结构体、JSON tag
实现 String() 方法 方法、Stringer 接口、时间格式化
添加/完成/删除任务 指针接收者、切片操作、错误处理
搜索和筛选 字符串处理、切片过滤
多种排序方式 sort.SliceStable、多字段排序
统计信息 map 计数、格式化输出
数据持久化 JSON 编解码、文件读写
命令行交互 bufio.Scanner、strconv、输入校验

最重要的三件事:

  1. 写项目先想清楚数据结构——结构体和切片是 Go 程序的骨架
  2. 用户输入永远不可信——每个输入都要校验,每个错误都要处理
  3. 知识串起来才有用——单独会排序、会文件操作没用,能组合在一起解决实际问题才算掌握

恭喜你完成了阶段四!到这里,你已经具备了用 Go 编写真实小项目的完整能力。


14. 下一课预告

下一课进入阶段五:并发与工程阶段,这是 Go 最有特色的部分。

第 25 课:goroutine 入门

会重点讲:

  • Go 并发的基本概念——什么是 goroutine
  • 怎么创建和运行 goroutine
  • 主协程退出问题——为什么你的 goroutine “没有执行”
  • goroutine 和线程的区别

学完下一课,你就踏入了 Go 最强大也最有意思的领域。