27、番茄钟功能测试与界面构建

番茄钟功能测试与界面构建

1. 番茄钟业务逻辑测试

1.1 准备工作

在为番茄钟业务逻辑编写测试之前,我们需要创建一个辅助函数 getRepo 来获取存储库实例。以下是具体步骤:
1. 切换到 pomodoro 包目录:

$ cd $HOME/pragprog.com/rggo/interactiveTools/pomo/pomodoro
  1. 创建 inmemory_test.go 文件,并添加以下内容:
package pomodoro_test

import (
    "testing"
    "pragprog.com/rggo/interactiveTools/pomo/pomodoro"
    "pragprog.com/rggo/interactiveTools/pomo/pomodoro/repository"
)

func getRepo(t *testing.T) (pomodoro.Repository, func()) {
    t.Helper()
    return repository.NewInMemoryRepo(), func() {}
}

1.2 测试 NewConfig 函数

我们使用表驱动测试方法来测试 NewConfig 函数,包含三个测试用例: Default SingleInput MultiInput 。以下是测试代码:

package pomodoro_test

import (
    "context"
    "errors"
    "fmt"
    "testing"
    "time"
    "pragprog.com/rggo/interactiveTools/pomo/pomodoro"
)

func TestNewConfig(t *testing.T) {
    testCases := []struct {
        name    string
        input   [3]time.Duration
        expect  pomodoro.IntervalConfig
    }{
        {name: "Default",
            expect: pomodoro.IntervalConfig{
                PomodoroDuration:   25 * time.Minute,
                ShortBreakDuration: 5 * time.Minute,
                LongBreakDuration:  15 * time.Minute,
            },
        },
        {name: "SingleInput",
            input: [3]time.Duration{
                20 * time.Minute,
            },
            expect: pomodoro.IntervalConfig{
                PomodoroDuration:   20 * time.Minute,
                ShortBreakDuration: 5 * time.Minute,
                LongBreakDuration:  15 * time.Minute,
            },
        },
        {name: "MultiInput",
            input: [3]time.Duration{
                20 * time.Minute,
                10 * time.Minute,
                12 * time.Minute,
            },
            expect: pomodoro.IntervalConfig{
                PomodoroDuration:   20 * time.Minute,
                ShortBreakDuration: 10 * time.Minute,
                LongBreakDuration:  12 * time.Minute,
            },
        },
    }

    // Execute tests for NewConfig
    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            var repo pomodoro.Repository
            config := pomodoro.NewConfig(
                repo,
                tc.input[0],
                tc.input[1],
                tc.input[2],
            )

            if config.PomodoroDuration != tc.expect.PomodoroDuration {
                t.Errorf("Expected Pomodoro Duration %q, got %q instead\n",
                    tc.expect.PomodoroDuration, config.PomodoroDuration)
            }
            if config.ShortBreakDuration != tc.expect.ShortBreakDuration {
                t.Errorf("Expected ShortBreak Duration %q, got %q instead\n",
                    tc.expect.ShortBreakDuration, config.ShortBreakDuration)
            }
            if config.LongBreakDuration != tc.expect.LongBreakDuration {
                t.Errorf("Expected LongBreak Duration %q, got %q instead\n",
                    tc.expect.LongBreakDuration, config.LongBreakDuration)
            }
        })
    }
}

1.3 测试 GetInterval 函数

为了测试 GetInterval 函数,我们需要执行该函数 16 次,以确保它能正确获取具有适当类别的间隔。以下是测试代码:

func TestGetInterval(t *testing.T) {
    repo, cleanup := getRepo(t)
    defer cleanup()

    const duration = 1 * time.Millisecond
    config := pomodoro.NewConfig(repo, 3*duration, duration, 2*duration)

    for i := 1; i <= 16; i++ {
        var (
            expCategory  string
            expDuration time.Duration
        )

        switch {
        case i%2 != 0:
            expCategory = pomodoro.CategoryPomodoro
            expDuration = 3 * duration
        case i%8 == 0:
            expCategory = pomodoro.CategoryLongBreak
            expDuration = 2 * duration
        case i%2 == 0:
            expCategory = pomodoro.CategoryShortBreak
            expDuration = duration
        }

        testName := fmt.Sprintf("%s%d", expCategory, i)
        t.Run(testName, func(t *testing.T) {
            res, err := pomodoro.GetInterval(config)

            if err != nil {
                t.Errorf("Expected no error, got %q.\n", err)
            }
            noop := func(pomodoro.Interval) {}

            if err := res.Start(context.Background(), config,
                noop, noop, noop); err != nil {
                t.Fatal(err)
            }

            if res.Category != expCategory {
                t.Errorf("Expected category %q, got %q.\n",
                    expCategory, res.Category)
            }

            if res.PlannedDuration != expDuration {
                t.Errorf("Expected PlannedDuration %q, got %q.\n",
                    expDuration, res.PlannedDuration)
            }

            if res.State != pomodoro.StateNotStarted {
                t.Errorf("Expected State = %q, got %q.\n",
                    pomodoro.StateNotStarted, res.State)
            }

            ui, err := repo.ByID(res.ID)
            if err != nil {
                t.Errorf("Expected no error. Got %q.\n", err)
            }

            if ui.State != pomodoro.StateDone {
                t.Errorf("Expected State = %q, got %q.\n",
                    pomodoro.StateDone, res.State)
            }
        })
    }
}

1.4 测试 Pause 方法

为了测试 Pause 方法,我们设置了两个测试用例:一个测试间隔未运行时的情况,另一个测试暂停正在运行的间隔。以下是测试代码:

func TestPause(t *testing.T) {
    const duration = 2 * time.Second

    repo, cleanup := getRepo(t)
    defer cleanup()

    config := pomodoro.NewConfig(repo, duration, duration, duration)
    testCases := []struct {
        name         string
        start        bool
        expState     int
        expDuration  time.Duration
    }{
        {name: "NotStarted", start: false,
            expState: pomodoro.StateNotStarted, expDuration: 0},
        {name: "Paused", start: true,
            expState: pomodoro.StatePaused, expDuration: duration / 2},
    }

    expError := pomodoro.ErrIntervalNotRunning

    // Execute tests for Pause
    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            ctx, cancel := context.WithCancel(context.Background())

            i, err := pomodoro.GetInterval(config)
            if err != nil {
                t.Fatal(err)
            }

            start := func(pomodoro.Interval) {}
            end := func(pomodoro.Interval) {
                t.Errorf("End callback should not be executed")
            }
            periodic := func(i pomodoro.Interval) {
                if err := i.Pause(config); err != nil {
                    t.Fatal(err)
                }
            }

            if tc.start {
                if err := i.Start(ctx, config, start, periodic, end); err != nil {
                    t.Fatal(err)
                }
            }

            i, err = pomodoro.GetInterval(config)
            if err != nil {
                t.Fatal(err)
            }

            err = i.Pause(config)
            if err != nil {
                if !errors.Is(err, expError) {
                    t.Fatalf("Expected error %q, got %q", expError, err)
                }
            }

            if err == nil {
                t.Errorf("Expected error %q, got nil", expError)
            }

            i, err = repo.ByID(i.ID)
            if err != nil {
                t.Fatal(err)
            }

            if i.State != tc.expState {
                t.Errorf("Expected state %d, got %d.\n",
                    tc.expState, i.State)
            }

            if i.ActualDuration != tc.expDuration {
                t.Errorf("Expected duration %q, got %q.\n",
                    tc.expDuration, i.ActualDuration)
            }
            cancel()
        })
    }
}

1.5 测试 Start 方法

为了测试 Start 方法,我们设置了两个测试用例:一个测试间隔正常完成的情况,另一个测试在中间取消运行的情况。以下是测试代码:

func TestStart(t *testing.T) {
    const duration = 2 * time.Second

    repo, cleanup := getRepo(t)
    defer cleanup()

    config := pomodoro.NewConfig(repo, duration, duration, duration)

    testCases := []struct {
        name         string
        cancel       bool
        expState     int
        expDuration  time.Duration
    }{
        {name: "Finish", cancel: false,
            expState: pomodoro.StateDone, expDuration: duration},
        {name: "Cancel", cancel: true,
            expState: pomodoro.StateCancelled, expDuration: duration / 2},
    }

    // Execute tests for Start
    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            ctx, cancel := context.WithCancel(context.Background())

            i, err := pomodoro.GetInterval(config)
            if err != nil {
                t.Fatal(err)
            }

            start := func(i pomodoro.Interval) {
                if i.State != pomodoro.StateRunning {
                    t.Errorf("Expected state %d, got %d.\n",
                        pomodoro.StateRunning, i.State)
                }
                if i.ActualDuration >= i.PlannedDuration {
                    t.Errorf("Expected ActualDuration %q, less than Planned %q.\n",
                        i.ActualDuration, i.PlannedDuration)
                }
            }

            end := func(i pomodoro.Interval) {
                if i.State != tc.expState {
                    t.Errorf("Expected state %d, got %d.\n",
                        tc.expState, i.State)
                }
                if tc.cancel {
                    t.Errorf("End callback should not be executed")
                }
            }

            periodic := func(i pomodoro.Interval) {
                if i.State != pomodoro.StateRunning {
                    t.Errorf("Expected state %d, got %d.\n",
                        pomodoro.StateRunning, i.State)
                }
                if tc.cancel {
                    cancel()
                }
            }

            if err := i.Start(ctx, config, start, periodic, end); err != nil {
                t.Fatal(err)
            }

            i, err = repo.ByID(i.ID)
            if err != nil {
                t.Fatal(err)
            }

            if i.State != tc.expState {
                t.Errorf("Expected state %d, got %d.\n",
                    tc.expState, i.State)
            }

            if i.ActualDuration != tc.expDuration {
                t.Errorf("Expected ActualDuration %q, got %q.\n",
                    tc.expDuration, i.ActualDuration)
            }
            cancel()
        })
    }
}

1.6 执行测试

完成所有测试代码后,我们可以执行以下命令来运行测试:

$ go test -v .

如果所有测试都通过,说明番茄钟应用的业务逻辑已经准备好。

测试流程总结

步骤 操作
1 创建 inmemory_test.go 文件并定义 getRepo 函数
2 创建 interval_test.go 文件并添加测试函数
3 执行 go test -v . 命令运行测试
graph TD;
    A[创建 inmemory_test.go 文件] --> B[定义 getRepo 函数];
    B --> C[创建 interval_test.go 文件];
    C --> D[添加测试函数];
    D --> E[执行 go test -v . 命令];
    E --> F{测试是否通过};
    F -- 通过 --> G[业务逻辑准备好];
    F -- 未通过 --> H[修改代码并重新测试];

2. 构建终端界面

2.1 选择界面库

为了创建番茄钟应用的终端界面,我们选择使用 Termdash 仪表盘库。它具有跨平台、活跃开发和丰富功能等优点。我们使用 Tcell 作为后端库。

2.2 指定依赖版本

为了确保示例代码正常工作,我们使用 Go 模块指定 Termdash 的版本为 v0.13.0

$ cd $HOME/pragprog.com/rggo/interactiveTools/pomo
$ go mod edit -require github.com/mum4k/termdash@v0.13.0

2.3 设计界面

我们将界面分为四个主要部分:
1. Timer 部分 :以文本和图形甜甜圈的形式显示当前间隔剩余时间。
2. Type 部分 :显示当前间隔的类型或类别。
3. Info 部分 :显示相关的用户消息和状态。
4. Buttons 部分 :显示两个按钮,分别用于开始和暂停间隔。

2.4 实现界面组件

2.4.1 创建 widgets.go 文件
package app

import (
    "context"
    "github.com/mum4k/termdash/cell"
    "github.com/mum4k/termdash/widgets/donut"
    "github.com/mum4k/termdash/widgets/segmentdisplay"
    "github.com/mum4k/termdash/widgets/text"
)

type widgets struct {
    donTimer       *donut.Donut
    disType        *segmentdisplay.SegmentDisplay
    txtInfo        *text.Text
    txtTimer       *text.Text
    updateDonTimer  chan []int
    updateTxtInfo   chan string
    updateTxtTimer  chan string
    updateTxtType   chan string
}

func (w *widgets) update(timer []int, txtType, txtInfo, txtTimer string,
    redrawCh chan<- bool) {
    if txtInfo != "" {
        w.updateTxtInfo <- txtInfo
    }

    if txtType != "" {
        w.updateTxtType <- txtType
    }

    if txtTimer != "" {
        w.updateTxtTimer <- txtTimer
    }

    if len(timer) > 0 {
        w.updateDonTimer <- timer
    }

    redrawCh <- true
}

func newWidgets(ctx context.Context, errorCh chan<- error) (*widgets, error) {
    w := &widgets{}
    var err error

    w.updateDonTimer = make(chan []int)
    w.updateTxtType = make(chan string)
    w.updateTxtInfo = make(chan string)
    w.updateTxtTimer = make(chan string)

    w.donTimer, err = newDonut(ctx, w.updateDonTimer, errorCh)
    if err != nil {
        return nil, err
    }

    w.disType, err = newSegmentDisplay(ctx, w.updateTxtType, errorCh)
    if err != nil {
        return nil, err
    }

    w.txtInfo, err = newText(ctx, w.updateTxtInfo, errorCh)
    if err != nil {
        return nil, err
    }

    w.txtTimer, err = newText(ctx, w.updateTxtTimer, errorCh)
    if err != nil {
        return nil, err
    }

    return w, nil
}

func newText(ctx context.Context, updateText <-chan string,
    errorCh chan<- error) (*text.Text, error) {
    txt, err := text.New()
    if err != nil {
        return nil, err
    }

    // Goroutine to update Text
    go func() {
        for {
            select {
            case t := <-updateText:
                txt.Reset()
                errorCh <- txt.Write(t)
            case <-ctx.Done():
                return
            }
        }
    }()

    return txt, nil
}

func newDonut(ctx context.Context, donUpdater <-chan []int,
    errorCh chan<- error) (*donut.Donut, error) {
    don, err := donut.New(
        donut.Clockwise(),
        donut.CellOpts(cell.FgColor(cell.ColorBlue)),
    )

    if err != nil {
        return nil, err
    }

    go func() {
        for {
            select {
            case d := <-donUpdater:
                if d[0] <= d[1] {
                    errorCh <- don.Absolute(d[0], d[1])
                }
            case <-ctx.Done():
                return
            }
        }
    }()

    return don, nil
}

func newSegmentDisplay(ctx context.Context, updateText <-chan string,
    errorCh chan<- error) (*segmentdisplay.SegmentDisplay, error) {
    sd, err := segmentdisplay.New()
    if err != nil {
        return nil, err
    }

    // Goroutine to update SegmentDisplay
    go func() {
        for {
            select {
            case t := <-updateText:
                if t == "" {
                    t = " "
                }
                errorCh <- sd.Write([]*segmentdisplay.TextChunk{
                    segmentdisplay.NewChunk(t),
                })
            case <-ctx.Done():
                return
            }
        }
    }()

    return sd, nil
}
2.4.2 创建 buttons.go 文件
package app

import (
    "context"
    "fmt"
    "github.com/mum4k/termdash/cell"
    "github.com/mum4k/termdash/widgets/button"
    "pragprog.com/rggo/interactiveTools/pomo/pomodoro"
)

type buttonSet struct {
    btStart *button.Button
    btPause *button.Button
}

func newButtonSet(ctx context.Context, config *pomodoro.IntervalConfig,
    w *widgets, redrawCh chan<- bool, errorCh chan<- error) (*buttonSet, error) {
    startInterval := func() {
        i, err := pomodoro.GetInterval(config)
        errorCh <- err

        start := func(i pomodoro.Interval) {
            message := "Take a break"
            if i.Category == pomodoro.CategoryPomodoro {
                message = "Focus on your task"
            }
            w.update([]int{}, i.Category, message, "", redrawCh)
        }

        end := func(pomodoro.Interval) {
            w.update([]int{}, "", "Nothing running...", "", redrawCh)
        }

        periodic := func(i pomodoro.Interval) {
            w.update(
                []int{int(i.ActualDuration), int(i.PlannedDuration)},
                "", "",
                fmt.Sprint(i.PlannedDuration-i.ActualDuration),
                redrawCh,
            )
        }

        errorCh <- i.Start(ctx, config, start, periodic, end)
    }

    pauseInterval := func() {
        i, err := pomodoro.GetInterval(config)
        if err != nil {
            errorCh <- err
            return
        }

        if err := i.Pause(config); err != nil {
            if err == pomodoro.ErrIntervalNotRunning {
                return
            }
            errorCh <- err
            return
        }
        w.update([]int{}, "", "Paused... press start to continue", "", redrawCh)
    }

    btStart, err := button.New("(s)tart", func() error {
        go startInterval()
        return nil
    },
        button.GlobalKey('s'),
        button.WidthFor("(p)ause"),
        button.Height(2),
    )

    if err != nil {
        return nil, err
    }

    btPause, err := button.New("(p)ause", func() error {
        go pauseInterval()
        return nil
    },
        button.FillColor(cell.ColorNumber(220)),
        button.GlobalKey('p'),
        button.Height(2),
    )

    if err != nil {
        return nil, err
    }

    return &buttonSet{btStart, btPause}, nil
}

界面构建流程总结

步骤 操作
1 指定 Termdash 版本为 v0.13.0
2 创建 app 子目录
3 创建 widgets.go 文件并实现主要界面组件
4 创建 buttons.go 文件并实现按钮组件
graph TD;
    A[指定 Termdash 版本] --> B[创建 app 子目录];
    B --> C[创建 widgets.go 文件];
    C --> D[实现主要界面组件];
    D --> E[创建 buttons.go 文件];
    E --> F[实现按钮组件];

通过以上步骤,我们完成了番茄钟应用的业务逻辑测试和终端界面的初步构建。

3. 界面布局与功能集成

3.1 布局设计思路

在完成了界面组件的创建后,接下来需要将这些组件进行合理布局,以实现一个完整且易用的终端界面。我们要考虑各个组件的位置关系,确保用户能够清晰地看到每个组件所展示的信息,并且方便操作按钮。

3.2 集成界面组件

为了将之前创建的 widgets.go buttons.go 中的组件集成到一个完整的界面中,我们需要创建一个新的文件,例如 main.go 。以下是一个简单的示例代码,展示了如何集成这些组件:

package main

import (
    "context"
    "errors"
    "fmt"
    "pragprog.com/rggo/interactiveTools/pomo/app"
    "pragprog.com/rggo/interactiveTools/pomo/pomodoro"
    "github.com/mum4k/termdash"
    "github.com/mum4k/termdash/container"
    "github.com/mum4k/termdash/linestyle"
    "github.com/mum4k/termdash/terminal/tcell"
    "github.com/mum4k/termdash/terminal/terminalapi"
)

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    // 创建终端实例
    t, err := tcell.New()
    if err != nil {
        panic(err)
    }
    defer t.Close()

    // 创建存储库
    repo, cleanup := app.GetRepo()
    defer cleanup()

    // 创建配置
    const duration = 2 * time.Second
    config := pomodoro.NewConfig(repo, duration, duration, duration)

    // 创建错误通道
    errorCh := make(chan error)

    // 创建界面组件
    w, err := app.NewWidgets(ctx, errorCh)
    if err != nil {
        panic(err)
    }

    // 创建按钮组件
    btns, err := app.NewButtonSet(ctx, &config, w, nil, errorCh)
    if err != nil {
        panic(err)
    }

    // 创建容器布局
    c, err := container.New(
        t,
        container.Border(linestyle.Light),
        container.BorderTitle("Pomodoro App"),
        container.SplitHorizontal(
            container.Top(
                container.SplitVertical(
                    container.Left(
                        container.PlaceWidget(w.DonTimer),
                    ),
                    container.Right(
                        container.PlaceWidget(w.TxtTimer),
                    ),
                    container.SplitPercent(50),
                ),
            ),
            container.Bottom(
                container.SplitVertical(
                    container.Left(
                        container.PlaceWidget(w.DisType),
                    ),
                    container.Right(
                        container.PlaceWidget(w.TxtInfo),
                    ),
                    container.SplitPercent(50),
                ),
                container.PlaceWidget(btns.BtStart),
                container.PlaceWidget(btns.BtPause),
            ),
            container.SplitPercent(70),
        ),
    )
    if err != nil {
        panic(err)
    }

    // 启动错误处理协程
    go func() {
        for {
            select {
            case err := <-errorCh:
                if err != nil {
                    fmt.Fprintf(t, "Error: %v\n", err)
                    cancel()
                    return
                }
            case <-ctx.Done():
                return
            }
        }
    }()

    // 启动终端仪表盘
    if err := termdash.Run(ctx, t, c); err != nil {
        if errors.Is(err, context.Canceled) {
            return
        }
        panic(err)
    }
}

3.3 代码解释

  • 终端实例创建 :使用 tcell.New() 创建一个终端实例,用于显示界面。
  • 存储库和配置创建 :创建存储库和番茄钟配置,为后续操作提供基础。
  • 组件创建 :调用 app.NewWidgets() app.NewButtonSet() 分别创建界面组件和按钮组件。
  • 容器布局 :使用 container.New() 创建一个容器布局,将各个组件放置在合适的位置。通过 container.SplitHorizontal() container.SplitVertical() 进行水平和垂直分割,实现组件的排列。
  • 错误处理 :启动一个协程监听 errorCh 通道,处理可能出现的错误。
  • 终端仪表盘运行 :使用 termdash.Run() 启动终端仪表盘,显示界面。

集成流程总结

步骤 操作
1 创建终端实例
2 创建存储库和配置
3 创建界面组件和按钮组件
4 设计容器布局
5 启动错误处理协程
6 运行终端仪表盘
graph TD;
    A[创建终端实例] --> B[创建存储库和配置];
    B --> C[创建界面组件];
    C --> D[创建按钮组件];
    D --> E[设计容器布局];
    E --> F[启动错误处理协程];
    F --> G[运行终端仪表盘];

4. 总结与展望

4.1 总结

通过以上一系列步骤,我们完成了番茄钟应用的业务逻辑测试和终端界面的构建。从编写测试用例确保业务逻辑的正确性,到使用 Termdash 库创建终端界面的各个组件,再到将这些组件集成到一个完整的界面中,我们逐步实现了一个功能完整的番茄钟应用。

4.2 展望

虽然目前的番茄钟应用已经具备了基本的功能,但仍有许多可以改进和扩展的地方。例如:
1. 功能扩展 :可以添加更多的功能,如统计功能,记录用户的番茄钟使用情况,生成统计报表;或者添加任务管理功能,让用户可以创建和管理任务。
2. 界面优化 :进一步优化界面的布局和样式,提高用户体验。可以添加更多的动画效果,让界面更加生动。
3. 跨平台兼容性 :虽然 Termdash 是跨平台的,但可以进行更多的测试,确保应用在不同的操作系统和终端上都能正常运行。

通过不断的改进和扩展,我们可以让番茄钟应用更加完善,满足用户的更多需求。

随着信息技术在管理上越来越深入而广泛的应用,作为学校以及一些培训机构,都在用信息化战术来部署线上学习以及线上考试,可以线下的考试有机的结合在一起,实现基于SSM的小码创客教育教学资源库的设计实现在技术上已成熟。本文介绍了基于SSM的小码创客教育教学资源库的设计实现的开发全过程。通过分析企业对于基于SSM的小码创客教育教学资源库的设计实现的需求,创建了一个计算机管理基于SSM的小码创客教育教学资源库的设计实现的方案。文章介绍了基于SSM的小码创客教育教学资源库的设计实现的系统分析部分,包括可行性分析等,系统设计部分主要介绍了系统功能设计和数据库设计。 本基于SSM的小码创客教育教学资源库的设计实现有管理员,校长,教师,学员四个角色。管理员可以管理校长,教师,学员等基本信息,校长角色除了校长管理之外,其他管理员可以操作的校长角色都可以操作。教师可以发布论坛,课件,视频,作业,学员可以查看和下载所有发布的信息,还可以上传作业。因而具有一定的实用性。 本站是一个B/S模式系统,采用Java的SSM框架作为开发技术,MYSQL数据库设计开发,充分保证系统的稳定性。系统具有界面清晰、操作简单,功能齐全的特点,使得基于SSM的小码创客教育教学资源库的设计实现管理工作系统化、规范化。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值