用Encore.ts和Node/TypeScript构建在线投票系统
在这个教程中,我们将使用Encore.ts从头开始构建一个投票调查系统。
比如说:你和你的朋友们正在商量着周末去哪儿玩,大家意见不一。有的人想去某个地方,有的人想去另一个地方。
你想到创建一个让每个人都可以投票的投票调查,听起来挺好玩的,对吧?
从零开始创造出来的东西并与朋友们分享,这种感觉很特别。
那正是我们要干的事!我们要打造一个类似你在Messenger、WhatsApp或YouTube社区帖子中看到的完整投票功能。
在这篇教程中,我们将构建一个完整的在线投票系统。我们将重点构建后端的REST API,使用Encore.ts,并将API与前端集成以完成整个系统。
我将一步步从头开始教你使用Encore Cloud平台部署后端服务,我们将用PostgreSQL来存储投票结果数据。
Encore是什么呢?(意思是演出结束后观众要求再表演一次)
Encore 是一个开源的后端框架,用于构建可扩展和分布式的系统。它是一个面向开发者的工具,其高性能 API 框架使构建健壮和类型安全的应用程序变得简单。无论你喜欢 TypeScript 还是 Go,Encore 都支持这两种语言,让开发者能够轻松使用。
Encore 还自带了一些开发工具,无论你是开发小型个人项目还是大型后端系统,这些工具都能为你提供帮助。
这对于那些注重简洁、拥有出色性能和可扩展性的开发者来说是一个很好的选择。
前提条件不需要任何特殊的专业知识就可以跟着本教程学习。但因为我们要构建一个 REST API,如果你对 REST API 有一定了解会很有帮助。像 GET、POST、PUT、DELETE 这样的常用方法在教程中会很有帮助。
如果你之前接触过 Node.js 或 Express,哪怕只是有一点点经验,那会是个很好的加分点。知道如何创建服务器路由或搭建一个基本服务器,将在学习过程中帮助你把各个环节连接起来。
在我们开始之前,请确保已安装最新版本的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端点。我们将构建一个简单的/hello
端点,当你访问这个端点时,它会返回“你好,世界!”。
我们来试试怎么达成这个目标。接下来,让我们回到代码编辑器。
如图所示:
我在项目根目录下新建了一个叫hello的文件夹,并在里面加了一个叫hello.ts
的文件。
我们可以这样保持项目有序:随着项目的发展,合理地组织文件夹和文件。
在 hello.ts
文件里,我引入了 api
函数。这个函数是定义端点的关键。
要完成这件事,这主要需要两样东西,分别是:
-
一个对象作为第一个参数,其中我们定义方法(如 GET、POST 等)和路径(如
/hello
),以及键expose: true
。设置expose: true
表示此端点是公开的,任何人都可以访问它。 - 一个异步函数,我们在此编写端点返回内容的逻辑。在这种情形下,它是一个简单的“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_text
和 vote_count
这两个字段。
首先你需要在你的机器上运行Docker,Encore 使用它来自动管理你的本地数据库。
别着急,如果你没有 Docker,只需访问网站下载适合你操作系统的版本。安装很简单,你很快就能创建一个 PostgreSQL 实例。
启动了 Docker 后,暂时不需要再做任何操作。
现在,让我们专注于为我们的投票API创建一个数据库架构。为此,我们将利用迁移文件。
迁移就像是数据库的版本管理,帮助你定义和随着时间更新数据库(库)。
我们就这样设置它。
-
在我们之前创建的 polls 目录里,我们将添加一个名为
migrations
的文件夹。这里将存放我们所有的数据库迁移文件。在 migrations 文件夹中,我们将创建一个名为
1_create_tables.up.sql
的文件。这个名字可能看起来有点特定,但实际上它只是一种约定。1
表示这是第一个迁移,而.up.sql
表示此文件将用于创建或更新数据库模式。
在这个文件里,我们将定义 polls
和 options
表的结构。
例如:
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在完成过程后,将会自动为你的数据库创建一个容器。
不错,咱们去仪表板看看。在那里,你应该能看到我们刚刚创建的那个端点。
点击图片查看大图
现在,当我们查看响应时,可以看到id
、question
和created_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个命令,你就可以部署完整的后端代码和数据库。
它是这样工作的:
- 准备你的改动:
git add .
将所有文件添加到Git暂存区
- 提交更改时 附上消息:
git commit -m "你的提交信息在这里"
在 -m
后面加上你的提交信息
- 再演一把
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)。
读到最后了,感谢大家!
共同学习,写下你的评论
暂无评论
作者其他优质文章