为了账号安全,请及时绑定邮箱和手机立即绑定

用Encore.ts和Node/TypeScript构建在线投票系统

在这个教程中,我们将使用Encore.ts从头开始构建一个投票调查系统。

比如说:你和你的朋友们正在商量着周末去哪儿玩,大家意见不一。有的人想去某个地方,有的人想去另一个地方。

你想到创建一个让每个人都可以投票的投票调查,听起来挺好玩的,对吧?

从零开始创造出来的东西并与朋友们分享,这种感觉很特别。

那正是我们要干的事!我们要打造一个类似你在Messenger、WhatsApp或YouTube社区帖子中看到的完整投票功能。

在这篇教程中,我们将构建一个完整的在线投票系统。我们将重点构建后端的REST API,使用Encore.ts,并将API与前端集成以完成整个系统。

我将一步步从头开始教你使用Encore Cloud平台部署后端服务,我们将用PostgreSQL来存储投票结果数据。

Encore是什么呢?(意思是演出结束后观众要求再表演一次)

安可

Encore 是一个开源的后端框架,用于构建可扩展和分布式的系统。它是一个面向开发者的工具,其高性能 API 框架使构建健壮和类型安全的应用程序变得简单。无论你喜欢 TypeScript 还是 Go,Encore 都支持这两种语言,让开发者能够轻松使用。

Encore 还自带了一些开发工具,无论你是开发小型个人项目还是大型后端系统,这些工具都能为你提供帮助。

这对于那些注重简洁、拥有出色性能和可扩展性的开发者来说是一个很好的选择。

在 GitHub 上查看 Encore

前提条件

不需要任何特殊的专业知识就可以跟着本教程学习。但因为我们要构建一个 REST API,如果你对 REST API 有一定了解会很有帮助。像 GET、POST、PUT、DELETE 这样的常用方法在教程中会很有帮助。

如果你之前接触过 Node.js 或 Express,哪怕只是有一点点经验,那会是个很好的加分点。知道如何创建服务器路由或搭建一个基本服务器,将在学习过程中帮助你把各个环节连接起来。

在我们开始之前,请确保已安装最新版本的Node.js。

前往Node.js的官方网站,并在还没有下载的情况下,就去下载它。下载完之后,咱们就可以开始了。

Node.js

在我们开始之前,还有一件事要介绍:Encore CLI(Encore命令行工具)。访问encore.dev,那里有你需要的所有信息来安装它。

如果你用的是 Windows、macOS 或 Linux,請按照系統說明操作。

你也可以复制粘贴这些命令直接安装。

    # 在 macOS 上安装 Encore 项目
    使用 Homebrew 安装 Encore`brew install encoredev/tap/encore`

    # 在 Windows 的 PowerShell 中安装 Encore
    运行以下 PowerShell 命令: `iwr https://encore.dev/install.ps1 | iex`

    # 在 Linux 上通过命令行安装 Encore
    运行以下命令: `curl -L https://encore.dev/install.sh | bash`

全屏:进入,退出

既然你已经安装好了所有的东西,让我们检查一下你的机器上Node.js和Encore是否已经正确设置。就像你在启动新项目前确保所有工具都已就绪一样。

你可以通过以下方式检查node版本。

运行 `node -v` 命令

全屏 退出全屏

这将显示你安装的 Node.js 版本。如果它显示一个版本号,那就说明你已经安装成功了。

咱们来检查一下返场版本吧

"encore版本"

切换到全屏模式,退出全屏

如果 Encore 安装正常,它会显示它的版本。

看到这两个版本号了吗?

好的,大家准备好了,我们可以继续了。

如果不是这样的话,再检查一下安装步骤,我们会搞定。

图片

项目设置

现在我们来创建Encore项目。只需运行这个命令,它会一步步引导你完成Encore项目的设置。

这里是创建encore项目的命令:

创建 encore app

切换到全屏 / 退出全屏

当你运行这个时,Encore 会让你选择项目所用的语言。在这个教程中,我们将选择 TypeScript 来构建,它是一个构建健壮且可扩展 API 的绝佳选择。

使用键盘上的方向键选择TypeScript,然后按回车键。

图片
点击图片查看大图

当你运行命令 encore app create 时,Encore 会提供几个模板供你选择。

这些就像是预制的蓝图,每个都能帮助你根据你的项目类型开始。

但是在这个教程里,我们要从头开始,保持简单。我们将选择“空白应用”模板。

为什么呢?

从零开始建造是看清楚每样东西是如何工作的最好方法。

所以,点击 空应用 模板。Encore 会给你一个干净的空白项目。

一旦你这样做,Encore 将会为你生成一个干净且简洁的项目框架。无多余代码,无预设功能,只是一片等待你创意的空白空间。

这是一张图片链接:图片

现在到了给你的项目取名的有趣部分。在这次教程里,我将我的项目命名为“polling-system”,不过你可以根据自己的喜好来命名。

来看这张图片:图片,这是一张什么样的图片呢?

你只要按一下Enter,Encore 就会开始运行。它会下载“空应用” 模板,并在幕后帮你把项目设置好。

你将在终端看到一系列活动,包括文件创建和依赖项安装,所有内容都被整齐地组织在一个清晰的项目结构中。

一旦设置好了,Encore 很可能会提示您马上运行项目。它会建议您用类似 encore run 的命令来启动它。

但是等等一下,我们先别急着运行。相反,我们去代码编辑器那里,在那里打开项目,看看项目的结构,然后在那里运行。

这是我们编写和调整代码时更好的同步方式。

图片 这是一张相关图片。

当你在代码编辑器中打开项目时,你会觉得它非常简洁明了,让你感觉很舒服。没有杂乱的代码或文件,也没有吓人的依赖列表。

如果你查看 package.json,你会发现只有一个主要依赖项 encore.dev。就这样。

TypeScript 作为开发依赖默默地存在,让一切变得干净利落。

在你的编辑器中打开终端,然后运行以下命令:

再来一遍

点击这里进入全屏模式,点击这里退出全屏模式.

一按 Enter,瞧,Encore 不仅启动项目,还会自动打开你的浏览器,并带你到开发者的仪表板。

当我们开始构建API时,我们将详细探讨请求跟踪、调试错误以及更多内容。

但現在,先花點時間欣賞這樣順暢的情況吧。Encore承擔了主要的工作,讓你可以專心做真正重要的事情:寫出好的代碼。

咱们继续吧,还有好多好玩的事儿等着咱们去发现!

如图:图片

实现这些 API 端点

好的,让我们来创建我们第一个API端点。我们将构建一个简单的/hello端点,当你访问这个端点时,它会返回“你好,世界!”

我们来试试怎么达成这个目标。接下来,让我们回到代码编辑器。

如图所示:示例图片

我在项目根目录下新建了一个叫hello的文件夹,并在里面加了一个叫hello.ts的文件。

我们可以这样保持项目有序:随着项目的发展,合理地组织文件夹和文件。

hello.ts 文件里,我引入了 api 函数。这个函数是定义端点的关键。

要完成这件事,这主要需要两样东西,分别是:

  1. 一个对象作为第一个参数,其中我们定义方法(如 GET、POST 等)和路径(如 /hello),以及键 expose: true。设置 expose: true 表示此端点是公开的,任何人都可以访问它。

  2. 一个异步函数,我们在此编写端点返回内容的逻辑。在这种情形下,它是一个简单的“Hello, world!”消息。

我希望这能让你对端点有个基本的了解,端点的结构是怎样的,以及在处理它们时需要注意些什么。

现在我们已经掌握了基础知识,让我们换一个方向,开始为我们的在线投票系统构建REST APIs。

这里才开始有劲!

发起投票:

首先,在项目根目录下创建一个名为 polls 的新目录。然后,在这个新目录中,添加一个名为 polls.ts 的文件。

这是我们定义所有与投票功能相关的内容的地方,在开发过程中保持整洁有序,以便更好地管理。

接下来,让我们定义民意调查接口。

    // 数据模型
    interface Poll {
      id: number;
      question: string;
      createdTime: string;
    }

    interface Option {
      id: number;
      pollId: number;
      option_text: string;
      vote_count: number;
    }

    interface CreatePollParams {
      question: string;
      options: string[];
    }

    interface VoteParams {
      pollId: number;
      optionId: number;
    }

切换到全屏 退出全屏

这里把事情做得简单、易懂又高效。创建polls表时,我们只保存question,其他内容一概不存。

接着,在选项表格中我们保留 poll_id,并记录 option_textvote_count 这两个字段。

配置一下你的本地数据库

首先你需要在你的机器上运行Docker,Encore 使用它来自动管理你的本地数据库。

别着急,如果你没有 Docker,只需访问网站下载适合你操作系统的版本。安装很简单,你很快就能创建一个 PostgreSQL 实例。

启动了 Docker 后,暂时不需要再做任何操作。

现在,让我们专注于为我们的投票API创建一个数据库架构。为此,我们将利用迁移文件

迁移就像是数据库的版本管理,帮助你定义和随着时间更新数据库(库)。

我们就这样设置它。

  1. 在我们之前创建的 polls 目录里,我们将添加一个名为 migrations 的文件夹。这里将存放我们所有的数据库迁移文件。

    在 migrations 文件夹中,我们将创建一个名为 1_create_tables.up.sql 的文件。这个名字可能看起来有点特定,但实际上它只是一种约定。1 表示这是第一个迁移,而 .up.sql 表示此文件将用于创建或更新数据库模式。

在这个文件里,我们将定义 pollsoptions 表的结构。

例如:

    CREATE TABLE polls (
        id SERIAL PRIMARY KEY,
        question TEXT NOT NULL,
        created_at TIMESTAMP DEFAULT now()
    );

    CREATE TABLE options (
        id SERIAL PRIMARY KEY,
        poll_id INTEGER REFERENCES polls(id) ON DELETE CASCADE,
        option_text TEXT NOT NULL,
        vote_count INTEGER DEFAULT 0
    );

进入全屏 退出全屏

这个投票方案包括:

  • id 列,该列会随着每个新投票自动递增。
  • question 列,用于存储投票的问题文本。
  • created_at 列,用于记录投票的创建时间。

在选项表中如下:

  • 自动递增的 id 列,每创建一个新的投票就会增加。
  • 保存投票 ID 的 poll_id 列,用作引用。
  • 存储选项文本的 options_text 列。每个投票应包含恰好 4 个选项。
  • 当用户投票时更新的 vote_count 字段。

一旦这个迁移脚本到位,当我们使用 encore run 来运行应用时,Encore 会自动将它应用到数据库中。

就这样一来,我们的数据库就可以用来存调查了!

现在,我们将要使用我们应用程序中的数据库。为此目的,我们需要从Encore的sqldb模块导入SQLDatabase。这将使我们能够与PostgreSQL数据库无缝交互。

    import { SQLDatabase } from "encore.dev/storage/sqldb";

切换到全屏,切换回正常模式。

一旦我们把它导入,我们将定义一个数据库实例。因为我们的数据库叫做polls,以此为依据。

在数据库实例中,我们还需要指定 migrations 文件夹的路径。这样 Encore 就知道去哪里找我们之前创建的迁移文件了

const db = new SQL数据库("polls", { 迁移: "./迁移" });

进入全屏,退出全屏

通过这样的设置,Encore 知道准确的位置来找到迁移文件,并会在我们运行应用时自动将这些迁移文件应用到 polls 数据库中。

这在我们构建轮询系统时,用来管理数据库模式是一种简洁高效的方法。

让我们终于把所有的东西连接起来,测试一下我们创建的投票API。

    import { api, APIError } from "encore.dev/api";
    import { SQLDatabase } from "encore.dev/storage/sqldb";

    const db = new SQLDatabase("polls", { migrations: "./migrations" });

    接口 Poll {
      id: number;
      question: string;
      created_at: string;
    }

    接口 Option {
      id: number;
      poll_id: number;
      option_text: string;
      vote_count: number;
    }

    接口 CreatePollParams {
      question: string;
      options: string[];
    }

    // 创建一个民意调查
    导出 const createPoll = api<CreatePollParams, Poll>(
      {
        method: "POST",
        path: "/polls",
        expose: true,
      },
      async ({ question, options }: 创建民意调查参数): Promise<Poll> => {
        if (options.length < 2 || options.length > 4) {
          throw APIError.invalidArgument("民意调查必须包含2到4个选项。");
        }

        const poll = await db.queryRow<Poll>`
          INSERT INTO polls (question)
          VALUES (${question})
          返回 id, question, created_at`;

        if (!poll) {
          throw APIError.internal("创建民意调查失败");
        }

        for (const optionText of options) {
          await db.exec`
            插入到选项 (poll_id, option_text, vote_count)
            VALUES (${poll.id}, ${optionText}, 0)`;
        }

        return poll;
      }
    );

进入全屏 退出全屏

在这里我们有检查选项是否符合我们预期的条件,即选项必须大于2且小于等于4。

我们将问题添加到投票表,并将选项添加到选项表。

一旦你重新运行你的应用,你会发现,Docker在完成过程后,将会自动为你的数据库创建一个容器。

图片

不错,咱们去仪表板看看。在那里,你应该能看到我们刚刚创建的那个端点。

图片
点击图片查看大图

现在,当我们查看响应时,可以看到idquestioncreated_at字段。我们的数据已成功保存到了数据库。

因为我们只在 polls 表中保存问题,在 options 表中保存选项,所以一切运行得非常顺利。

如果你想更仔细地查看表格和数据的话,你可以使用这个Encore DB Shell工具。它是个很方便的工具,可以让你直接操作数据库。

要打开 shell(终端),只需在终端里运行如下这条命令:

再次运行 db shell 汇报

全屏模式 退出全屏

在这里,polls 是我们的数据库名称。运行后,你将进入数据库命令行界面,在这里你可以查看和操作表,执行 SQL 查询,并查看数据库里存了什么。

试试看,看看里面有什么门道吧!

图片

现在我们可以查询我们的表。

如图所示:
图片

我创建了一些投票选项,我们可以在这里看到所有选项及其票数统计。数据保存正确无误,我们已经确认过了。

看看所有民意调查:

现在,让我们建立我们的第二个端点,这次是为了从数据库中获取所有民意调查

就像我们为创建投票端点所做的那样,我们也将采取类似的方法。我们将定义端点,编写逻辑,从数据库中获取数据并将其作为响应返回。

这都是建立在我们已经学到的基础上。让我们直接开始,把它搞定!

    // 获取投票
    export const getPolls = api(
      {
        method: "GET",
        path: "/polls",
        expose: true,
      },
      async (): Promise<{ polls: Poll[] }> => {
        const rows = db.query`
          SELECT id, question, created_at
          FROM polls
          ORDER BY created_at DESC`;

        const polls: Poll[] = [];
        for await (const row of rows) {
          polls.push({
            id: row.id,
            question: row.question,
            created_at: row.created_at,
          });
        }

        return { polls };
      }
    );

点击进入全屏模式,点击退出全屏模式

试试新端点,看看所有投票是否都显示正确。

转到开发者仪表板,在下拉菜单中选择getPolls端点,然后点击发送。如果一切设置无误,你应该会看到存储在你数据库中的所有投票。

图片(示例图片)

通过ID获取民调结果。

现在,我们来创建一个端点来通过其ID获取单个民意调查。为此,我们将id作为参数传递。

然后,我们将编写一个简单的SQL查询来从数据库中获取与给定的id相匹配的调查。这很简单,只是在我们之前的操作上稍作修改。

咱们建起来试试看它怎么操作,行不行?

    // 通过ID获取民意调查
    export const getPollById = api<
      { id: number },
      { poll: Poll; options: Option[] }
    >(
      {
        method: "GET",
        path: "/polls/:id",
        expose: true,
      },
      async ({
        id,
      }: {
        id: number;
      }): Promise<{ poll: Poll; options: Option[] }> => {
        const poll = await db.queryRow<Poll>`
          SELECT id, question, created_at FROM polls WHERE id = ${id}`;
        if (!poll) {
          throw APIError.notFound("民意调查不存在");
        }

        const optionsRows = db.query`
          SELECT id, option_text, vote_count FROM options WHERE poll_id = ${id}`;

        const options: Option[] = [];
        for await (const row of optionsRows) {
          options.push({
            id: row.id,
            poll_id: id,
            option_text: row.option_text,
            vote_count: row.vote_count,
          });
        }

        return { poll, options };
      }
    );

全屏模式,退出

让我们使用开发人员仪表板来测试这个新的端点。在仪表板上找到getPollById端点,然后将ID设为1。点击发送按钮,你应该会在响应中看到这个问卷的详细信息。

这是一张图片图片

通过ID进行投票:

现在,让我们创建一个根据ID进行投票的端点。我们将id作为函数参数,并从请求体中提取optionId

然后,我们将编写一个简单的SQL查询来进行投票。我们将根据optionId来更新vote_count。让我们试一试,看看它是不是按预期工作!

    // 为选项投票
    export const votePoll = api<VoteParams, { message: string }>(
      {
        method: "POST",
        path: "/polls/:pollId/vote",
        expose: true,
      },
      async ({ pollId, optionId }: VoteParams): Promise<{ message: string }> => {
        const option = await db.queryRow<Option>`
          SELECT * FROM options WHERE id = ${optionId} AND poll_id = ${pollId}`;

        if (!option) {
          throw APIError.notFound("无效选项");
        }

        await db.exec`
          UPDATE options SET vote_count = vote_count + 1 WHERE id = ${optionId}`;

        return { message: "投票已成功记录" };
      }
    );

全屏模式 退出

让我们测试一下我们的投票接口。

图片

查看民意调查结果,

现在,我们来创建一个接口通过poll ID 获取结果。为此,我们在函数中将 id 作为参数。

接下来,我们将编写一个简单的SQL查询(SQL查询)通过投票ID来获取投票结果。我们来试试看,看看它到底怎么运行!

    // 获取投票结果
    export const getPollResults = api<{ id: number }, { results: Option[] }>(
      {
        method: "GET",
        path: "/polls/:id/results",
        expose: true,
      },
      async ({ id }: { id: number }): Promise<{ results: Option[] }> => {
        const optionsRows = db.query`
          SELECT id, option_text, vote_count FROM options WHERE poll_id = ${id}
          ORDER BY vote_count DESC`; // 按投票数降序排列

        const options: Option[] = [];
        for await (const row of optionsRows) {
          options.push({
            id: row.id,
            poll_id: id,
            option_text: row.option_text,
            vote_count: row.vote_count,
          });
        }

        if (options.length === 0) {
          throw APIError.notFound("未找到此投票");
        }

        return { results: options };
      }
    );

切换到全屏,退出全屏

让我们在开发者控制台上测试这个接口,

如图所示
图片

现在我们已经完成了在线投票系统API的所有操作步骤。

创建投票很简单,通过ID来获取投票,给投票投个票,以及获取投票结果。你已经有了搭建一个功能完备的应用程序的基础。

我希望这能让你对如何使用Encore来做应用的创建和管理有一个扎实的了解。

部署后端服务器:

Encore 中部署应用好像有魔法一样,非常有趣,还超级简单。

仅需运行3个命令,你就可以部署完整的后端代码和数据库。

它是这样工作的:

  1. 准备你的改动
git add .  

将所有文件添加到Git暂存区

  1. 提交更改时 附上消息:
git commit -m "你的提交信息在这里"

-m 后面加上你的提交信息

  1. 再演一把
git push 再次推送 (zài cì tū sòng)

就这样吧!Encore会搞定搭建应用、配置数据库环境以及把所有东西部署到云端的工作。

图片 这是一张示例图片。

现在,从app.encore.dev打开你的应用程序。

图片描述 (点击可查看图片)

这里是你实时网址!

注:如果需要更正式的翻译,可以改为“这里是你实时托管的网址!”

现在你可以使用这个URL来集成前端,我们稍后会做这个前端。

猜猜看,怎么样?它已经被部署在测试环境中了。

只需一分钟,你的应用就能跑起来,就可以开始测试和使用了,这有多酷啊!

再次让部署变得轻而易举。

API 与前端的集成

我们已经有了所有的端点,它们都位于Encore环境中。现在是时候通过添加前端来让这一切活起来。

为此,我正在创建一个 React应用程序 使用 shadcn UI —一个简洁现代的UI库,这将让我们的在线投票平台看起来既时尚又专业。

后台设置好了,现在我们把它和前端连起来吧!

    // App.tsx

    import { useEffect, useState } from "react";
    import CreatePollDialog from "./components/CreatePollDialog";
    import PollCard from "./components/PollCard";
    import { CreatePollData, Poll } from "./types";

    const base_url = "http://127.0.0.1:4000";

    function App() {
      const [polls, setPolls] = useState<Poll[]>([]);

      useEffect(() => {
        fetchPolls();
      }, []);

      const fetchPolls = async () => {
        try {
          const response = await fetch(`${base_url}/polls`);
          const data = await response.json();
          console.log(data);
          const pollsWithOptions = await Promise.all(
            data.polls.map(async (poll: Poll) => {
              const detailResponse = await fetch(`${base_url}/polls/${poll.id}`);
              const detailData = await detailResponse.json();
              return {
                ...poll,
                options: detailData.options,
              };
            })
          );

          console.log(pollsWithOptions);
          setPolls(pollsWithOptions);
        } catch (error) {
          console.error("获取投票数据失败:", error);
        }
      };

      const handleCreatePoll = async (data: CreatePollData) => {
        try {
          const response = await fetch(`${base_url}/polls`, {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
            },
            body: JSON.stringify(data),
          });
          if (response.ok) {
            fetchPolls();
          }
        } catch (error) {
          console.error("创建新投票失败:", error);
        }
      };

      const handleVote = async (pollId: number, optionId: number) => {
        try {
          const response = await fetch(`${base_url}/polls/${pollId}/vote`, {
            method: "POST",
            headers: {
              "Content-Type": "application/json",
            },
            body: JSON.stringify({ pollId, optionId }),
          });
          if (response.ok) {
            const resultsResponse = await fetch(
              `${base_url}/polls/${pollId}/results`
            );
            const { results } = await resultsResponse.json();
            setPolls((prev) =>
              prev.map((poll) =>
                poll.id === pollId ? { ...poll, options: results } : poll
              )
            );
          }
        } catch (error) {
          console.error("提交投票结果失败:", error);
        }
      };

      return (
        <div className="w-screen min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
          <div className="w-full px-4 py-8">
            <div className="max-w-7xl mx-auto mb-8">
              <div className="flex items-center justify-between flex-wrap gap-4">
                <div>
                  <h1 className="text-4xl font-bold text-gray-900 dark:text-white">
                    社区投票区
                  </h1>
                  <p className="mt-2 text-gray-600 dark:text-gray-300">
                    创建并参与社区投票
                  </p>
                </div>
                <CreatePollDialog onCreatePoll={handleCreatePoll} />
              </div>
            </div>

            <div className="max-w-7xl mx-auto space-y-6">
              {polls.map((poll) => (
                <PollCard key={poll.id} poll={poll} onVote={handleVote} />
              ))}
              {polls.length === 0 && (
                <div className="text-center py-12">
                  <p className="text-gray-500 dark:text-gray-400">
                    暂无投票。快来创建第一个投票吧!
                  </p>
                </div>
              )}
            </div>
          </div>
        </div>
      );
    }

    export default App;

切换到全屏/退出全屏

这个CreatePollDialog是一个简单的小对话框,用于创建投票。它有两个输入框,一个用于定义问题,另一个用于添加选项。

非常简单,用户可以轻松互动和参与其中。我们来看看它是怎么工作的,然后把它加到我们的应用里!

    // 创建投票对话框.tsx

    import { Button } from "@/components/ui/button";
    import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
    import { Input } from "@/components/ui/input";
    import { Label } from "@/components/ui/label";
    import { CreatePollData } from "@/types";
    import { Cross2Icon } from "@radix-ui/react-icons";
    import { PlusCircle } from "lucide-react";
    import { useState } from "react";

    interface CreatePollDialogProps {
      onCreatePoll: (data: CreatePollData) => Promise<void>;
    }

    export default function CreatePollDialog({
      onCreatePoll,
    }: CreatePollDialogProps) {
      const [question, setQuestion] = useState("");
      const [options, setOptions] = useState(["", ""]);
      const [isOpen, setIsOpen] = useState(false);

      const handleAddOption = () => {
        if (options.length < 4) {
          setOptions([...options, ""]);
        }
      };

      const handleRemoveOption = (index: number) => {
        if (options.length > 2) {
          setOptions(options.filter((_, i) => i !== index));
        }
      };

      const handleOptionChange = (index: number, value: string) => {
        const newOptions = [...options];
        newOptions[index] = value;
        setOptions(newOptions);
      };

      const handleSubmit = async () => {
        if (question.trim() && options.every((opt) => opt.trim())) {
          await onCreatePoll({
            question: question.trim(),
            options: options.map((opt) => opt.trim()),
          });
          setQuestion("");
          setOptions(["", ""]);
          setIsOpen(false);
        }
      };

      return (
        <Dialog open={isOpen} onOpenChange={setIsOpen}>
          <DialogTrigger asChild>
            <Button className="gap-2">
              <PlusCircle className="h-5 w-5" />
              创建新投票
            </Button>
          </DialogTrigger>
          <DialogContent className="sm:max-w-[425px]">
            <div className="flex justify-between items-center">
              <h2 className="text-lg font-semibold">创建新投票</h2>
              <Cross2Icon
                onClick={() => setIsOpen(false)}
                className="h-4 w-4 cursor-pointer"
              />
            </div>

            <div className="grid gap-4 py-4">
              <div className="space-y-2">
                <Label htmlFor="question">问题</Label>
                <Input
                  id="question"
                  value={question}
                  onChange={(e) => setQuestion(e.target.value)}
                  placeholder="请输入您的问题"
                />
              </div>
              <div className="space-y-4">
                <Label>选项</Label>
                {options.map((option, index) => (
                  <div key={index} className="flex items-center gap-2">
                    <Input
                      value={option}
                      onChange={(e) => handleOptionChange(index, e.target.value)}
                      placeholder={`选项 ${index + 1}(例如:选项1)`}
                    />
                    {options.length > 2 && (
                      <Cross2Icon
                        onClick={() => handleRemoveOption(index)}
                        className="h-4 w-4 cursor-pointer"
                      />
                    )}
                  </div>
                ))}
                {options.length < 4 && (
                  <Button
                    variant="outline"
                    className="w-full"
                    onClick={handleAddOption}
                  >
                    增加选项
                  </Button>
                )}
              </div>
            </div>
            <Button onClick={handleSubmit} className="w-full">
              提交投票
            </Button>
          </DialogContent>
        </Dialog>
      );
    }

全屏:退出全屏

而且,PollCard 就是你用来进行投票的地方。它还将显示平滑的动画,类似于 YouTube 社区投票。下面的演示视频展示了它的效果。

    // PollCard.tsx

    import { Button } from "@/components/ui/button";
    import {
      Card,
      CardContent,
      CardFooter,
      CardHeader,
    } from "@/components/ui/card";
    import { cn } from "@/lib/utils";
    import { Poll } from "@/types";
    import { motion } from "framer-motion";
    import { useState } from "react";

    interface PollCardProps {
      poll: Poll;
      onVote: (pollId: number, optionId: number) => Promise<void>;
    }

    export default function PollCard({ poll, onVote }: PollCardProps) {
      const [hasVoted, setHasVoted] = useState(false);
      const [selectedOption, setSelectedOption] = useState<number | null>(null);
      const totalVotes =
        poll.options?.reduce((sum, opt) => sum + opt.vote_count, 0) || 0;

      const handleVote = async (optionId: number) => {
        if (!hasVoted) {
          setSelectedOption(optionId);
          await onVote(poll.id, optionId);
          setHasVoted(true);
        }
      };

      const getPercentage = (votes: number) => {
        return totalVotes === 0 ? 0 : Math.round((votes / totalVotes) * 100);
      };

      return (
        <Card className="w-full">
          <CardHeader>
            <h3 className="text-xl font-semibold">{poll.question}</h3>
            <p className="text-sm text-muted-foreground">
              创建于 {new Date(poll.created_at).toLocaleDateString("zh-CN")}
            </p>
          </CardHeader>
          <CardContent className="space-y-4">
            {poll.options?.map((option) => (
              <div key={option.id} className="relative">
                <Button
                  variant={
                    hasVoted && selectedOption !== option.id
                      ? "secondary"
                      : "outline"
                  }
                  className={cn(
                    "w-full justify-start h-auto py-3 px-4 relative overflow-hidden",
                    selectedOption === option.id && "border-primary",
                    hasVoted &&
                      selectedOption !== option.id &&
                      "不选中选项时的透明度和文本颜色"
                  )}
                  onClick={() => handleVote(option.id)}
                  disabled={hasVoted && selectedOption !== option.id}
                >
                  {hasVoted && selectedOption === option.id && (
                    <motion.div
                      className="absolute left-0 top-0 bottom-0 bg-primary/20"
                      initial={{ width: 0 }}
                      animate={{ width: `${getPercentage(option.vote_count)}%` }}
                      transition={{ duration: 0.5, ease: "easeOut" }}
                    />
                  )}
                  <div className="relative z-10 flex justify-between w-full">
                    <span className="ml-2">{option.option_text}</span>
                    {hasVoted && (
                      <span className="ml-2 font-medium">
                        {getPercentage(option.vote_count)}%
                      </span>
                    )}
                  </div>
                </Button>
              </div>
            ))}
          </CardContent>
          <CardFooter>
            <p className="text-sm text-muted-foreground">
              {totalVotes} {totalVotes === 1 ? "票" : "票"}
            </p>
          </CardFooter>
        </Card>
      );
    }

点击全屏 点击退出全屏

这就是最终的产品。你投票时,一切都是实时更新的,结果会保存到后端数据库,你会看到流畅的动画,就像在 YouTube 社区投票以及其他在线投票中看到的一样。

收尾

哇哦,从搭建项目、开发第一个API到连接到PostgreSQL数据库并部署到Encore,看到这一切顺利实现真是太棒了。

Encore 让构建强大且类型安全的应用程序变得如此简单易行,这些应用程序可以轻松地扩展和伸缩。

如果你想更仔细地看看,可以查看一下Documentation

如果你喜欢,请给 Star Encore 点个星(Star)

读到最后了,感谢大家!

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

0 评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
微信客服

购课补贴
联系客服咨询优惠详情

帮助反馈 APP下载

慕课网APP
您的移动学习伙伴

公众号

扫描二维码
关注慕课网微信公众号

举报

0/150
提交
取消