效果展示
投票结果
投票选项
点击下面的选项即可投票
开源代码
后端代码:基于Cloudflare Workers实现的投票功能
前端代码:基于Vue+Antd实现的前端管理页面
原理
想象一下,SVG就像是一种"数字画布",跟普通图片不一样的是,它不是由一堆像素点组成的,而是由一系列"绘图指令"组成 —— 就像告诉画家"在这里画条线"、“在那里画个圆"一样。
为什么用SVG来展示投票结果很酷?
1. 它其实就是一堆文字指令
看代码里的那些<svg>、<text>、<rect>标签,这不是图片,而是一串描述"如何画图"的指令。服务器每次都是现场"写"出这些指令,就像是:
- “画个标题,写’投票结果'”
- “画条红色长条,长度是根据票数决定的”
2. 即插即用,像贴纸一样
这种SVG图表可以像普通图片一样,贴到任何网页里。只要在某处放个:
<img src="https://你的网站/api/vote/123/result.svg">
就能显示实时投票结果,超简单!
3. “永远新鲜"的投票结果
每次有人看这个SVG,浏览器都会重新请求一次,服务器就会重新生成一次,所以看到的总是最新数据。这就像是一个魔法黑板,每次看它都会自动更新内容。
4. 轻量级"自助餐”
不需要拖入笨重的图表库、不需要JavaScript、不需要任何花里胡哨的东西。服务器直接做好一张"图"发给你,浏览器拿到就能显示。就像点外卖,已经做好了直接吃就行。
实际工作原理很像"填空题”
服务器上有个SVG模板,像是:
<svg>
<text>__标题__</text>
<rect width="__票数比例__" />
...其他元素...
</svg>
每次有请求来,就:
- 从数据库拉取最新投票数据
- 把数据填入这个"SVG模板"的空位里
- 把填好的"SVG文本"发回给用户
浏览器收到后,看到是SVG格式,就会按照这些指令把图形画出来,就像照着菜谱做菜一样,每次都是现做现画的。
代码实现
定义数据表
我们需要两个表,一个是Topics表,用来存储投票主题,另一个是Options表,用来存储投票选项。
除此之外,我们还需要一个IpVotes表,用来存储用户的投票记录。避免用户重复刷票。
-- 1. 创建topic表
CREATE TABLE Topics (
TopicID INTEGER PRIMARY KEY AUTOINCREMENT,
Title VARCHAR(1024) NOT NULL,
Description TEXT,
OptionsCount INTEGER NOT NULL
);
-- 2. 创建option表
CREATE TABLE Options (
OptionID INTEGER PRIMARY KEY AUTOINCREMENT,
TopicID INTEGER,
OptionText VARCHAR(1024) NOT NULL,
Votes INTEGER NOT NULL,
FOREIGN KEY (TopicID) REFERENCES Topics(TopicID) ON DELETE CASCADE
);
-- 3. 创建ipvotes表
CREATE TABLE IpVotes (
IpAddress VARCHAR(15),
TopicID INTEGER,
LastVoteTime TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (TopicID) REFERENCES Topics(TopicID) ON DELETE CASCADE
);
绘制svg
我们的实现是运行在Cloudflare Workers上的,所以我们需要使用JavaScript来生成SVG。
// 获取svg格式的投票结果
router.get('/api/vote/:id/result.svg', async ({ params },env,ctx) => {
const topic = await env.DB.prepare(
"SELECT * FROM Topics WHERE TopicID = ?"
)
.bind(params.id)
.all();
const options = await env.DB.prepare(
"SELECT * FROM Options WHERE TopicId = ?"
)
.bind(params.id)
.all();
const result = {
topic: topic['results'],
options: options['results']
};
// 生成svg
let startY=140;
const stepY=40;
let totalVotes=0;
for (const option of result.options) {
totalVotes += option['Votes'];
}
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="500" height="${140 + 40 * result.options.length +20}">
<rect width="500" height="${140 + 40 * result.options.length + 20}" style="fill:rgb(255,255,255);stroke-width:3;stroke:rgb(0,0,0)" />
<!-- 投票结果 -->
<text x="250" y="50" font-size="20" text-anchor="middle">投票结果</text>
`
if (result.topic[0]['Title'].length > 23) {
svg +=
`
<text x="250" y="80" font-size="10" text-anchor="middle">${result.topic[0]['Title']}</text>
`
} else {
svg +=
`
<text x="250" y="80" font-size="20" text-anchor="middle">${result.topic[0]['Title']}</text>
`
}
for (const option of result.options) {
svg +=
`<text x="100" y="${startY}" font-size="18">${option['OptionText']}: ${option['Votes']} 票</text>
<rect x="100" y="${startY+10}" width="${300*option['Votes']/totalVotes}" height="12" style="fill:rgb(255,0,0)" />
`;
startY += stepY;
}
svg +=
`</svg>`;
// header禁止缓存
return new Response(svg, { headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-cache, no-store, must-revalidate' } });
});
投票功能的api
发起投票
使用了/api/vote/add来发起投票,用户可以通过这个api来发起投票。
这个函数处理创建投票的请求,将投票主题和选项插入到数据库中,并返回新创建的投票的详细信息。
-
接收请求并解析 JSON 内容: 函数首先从请求中解析 JSON 数据,获取投票的详细信息,包括标题、描述和选项。
-
插入投票主题到
Topics表: 将投票的标题、描述和选项数量插入到Topics表中,并获取插入操作的结果。 -
获取新创建的投票主题的 ID: 从插入操作的结果中获取新创建的投票主题的 ID。
-
插入投票选项到
Options表: 遍历投票选项,并将每个选项插入到Options表中,初始票数为 0。 -
从数据库中获取新创建的投票主题和选项: 从数据库中获取新创建的投票主题和选项的详细信息。
-
构建响应结果并返回: 构建包含投票主题和选项的响应结果,并将其作为 JSON 响应返回给客户端。
// 创建vote
router.post('/api/vote/add', async (request,env,ctx) => {
const content = await request.json();
const results = await env.DB.prepare(
"INSERT INTO Topics (Title, Description, OptionsCount) VALUES (?, ?, ?)"
)
.bind(content.Title, content.Description, content.Options.length)
.run();
const topicId = results['meta']['last_row_id'];
for (const option of content.Options) {
await env.DB.prepare(
"INSERT INTO Options (TopicId, OptionText, Votes) VALUES (?, ?, ?)"
)
.bind(topicId, option.OptionText, 0)
.run();
}
// 从数据库获取投票
const topic = await env.DB.prepare(
"SELECT * FROM Topics WHERE TopicID = ?"
)
.bind(topicId)
.all();
const options = await env.DB.prepare(
"SELECT * FROM Options WHERE TopicId = ?"
)
.bind(topicId)
.all();
const result = {
topic: topic['results'],
options: options['results']
};
return new Response(JSON.stringify(result));
});
投票
这个函数处理用户的投票请求,验证用户输入的验证码,检查是否已经投过票,记录投票信息,并返回相应的结果页面。
使用了Cloudflare的turnstile来验证用户是否是人类,
避免了用户重复投票,如果用户重复投票,会返回错误信息。
-
接收请求并解析参数: 函数首先从请求中解析查询参数和路径参数,包括投票选项 ID 和验证码。
-
防止非 HTML 请求: 如果请求的
accept头不包含html,则返回一个简单的 SVG 图像,防止非 HTML 请求。 -
获取客户端 IP 地址: 从请求头中获取客户端的 IP 地址,如果没有获取到,则使用默认 IP 地址
1.1.1.1。 -
处理 GET 请求: 如果请求中没有包含验证码,则返回一个包含验证码表单的 HTML 页面,要求用户输入验证码。
-
验证验证码: 调用
checkVerifyCode函数验证用户输入的验证码。如果验证失败,则返回一个错误页面,提示用户重新输入验证码。 -
检查是否已经投过票: 查询数据库,检查该 IP 地址是否已经对该投票主题投过票。如果已经投过票且距离上次投票时间小于 1 小时,则返回一个错误页面,提示用户已经投过票。
-
记录投票信息: 如果该 IP 地址没有投过票,或者距离上次投票时间大于 1 小时,则更新数据库,记录投票信息。
-
更新投票选项的票数: 更新数据库中对应投票选项的票数,将票数加 1。
-
返回投票成功页面: 返回一个 HTML 页面,提示用户投票成功,并在 3 秒后自动关闭页面。
// 投票并返回原网页
router.all('/api/vote/:id/voteUrl', async (request,env,ctx) => {
const { query,params } =request
// 防止image
if(request.headers.get('accept').indexOf('html') == -1){
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50">
<text x="25" y="25" font-size="20" text-anchor="middle">哎嘿</text>
</svg>`;
return new Response(svg, { headers: { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-cache, no-store, must-revalidate' } });
}
const token = query['cf-turnstile-response']
const optionId = query.optionId
let ip = request.headers.get('cf-connecting-ip');
if (!ip){
ip='1.1.1.1'
}
// 如果是get请求,返回html
if (!token) {
// 验证码
return new Response(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>验证码</title>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
</head>
<body>
<center>
<h1>投票验证码</h1>
<form action="/api/vote/${params.id}/voteUrl?optionId=${query.optionId} method="get">
<input type="hidden" name="optionId" value="${query.optionId}">
<div class="cf-turnstile" data-sitekey="0x"></div>
<input type="submit" value="继续投票">
</form>
</center>
</body>
</html>`, { headers: { 'Content-Type': 'text/html' } });
}
const verifyResult = await checkVerifyCode(token,ip);
if (!verifyResult.success) {
return new Response(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>验证码</title>
</head>
<body>
<center>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script>
<h1>验证码错误请重试</h1>
<form action="/api/vote/${params.id}/voteUrl?optionId=${query.optionId} method="get">
<input type="hidden" name="optionId" value="${query.optionId}">
<div class="cf-turnstile" data-sitekey="0x"></div>
<input type="submit" value="继续投票">
</form>
</center>
</body>
</html>`, { headers: { 'Content-Type': 'text/html' } })
}
// 检查是否已经投过票
const check = await env.DB.prepare(
"SELECT * FROM IpVotes WHERE IpAddress = ? AND TopicID = ?"
)
.bind(ip,params.id)
.all();
// 如果已经投过票,且距离上次投票时间小于1小时,则返回错误
// ip未出现过
if (check['results'].length === 0) {
// 插入ip
await env.DB.prepare(
"INSERT INTO IpVotes (IpAddress, TopicID, LastVoteTime) VALUES (?, ?, ?)"
)
.bind(ip,params.id,new Date().getTime())
.run();
} else if (check['results'][0] && check['results'][0]['LastVoteTime'] &&
check['results'][0]['LastVoteTime'] + 360000000 > new Date().getTime()) {
// 如果已经投过票,且距离上次投票时间小于1小时,则返回错误
return new Response(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>投票失败</title>
</head>
<body>
<center>
<h1>投票失败,是不是已经参与过投票了呢</h1>
<a href="https://vote.zzdx.eu.org/">发起投票</a>
<script>
setTimeout(function(){
window.close();
},3000);
</script>
</center>
</body>
</html>`, { headers: { 'Content-Type': 'text/html' } });
} else {
// 已经投过票,但是距离上次投票时间大于1小时,更新数据库
await env.DB.prepare(
"UPDATE IpVotes SET LastVoteTime = ? WHERE IpAddress = ? AND TopicID = ?"
)
.bind(new Date().getTime(),ip,params.id)
.run();
}
// 投票
const options = await env.DB.prepare(
"UPDATE Options SET Votes = Votes + 1 WHERE TopicId = ? AND OptionId = ?"
)
.bind(params.id,query.optionId)
.all();
// 返回html,提示投票成功,并且三秒后关闭当前页面
return new Response(`<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>投票成功</title>
</head>
<body>
<center>
<h1>投票成功,3秒内自动关闭本页面</h1>
<a href="https://vote.zzdx.eu.org/">发起投票</a>
<script>
setTimeout(function(){
window.close();
},3000);
</script>
</center>
</body>
</html>`, { headers: { 'Content-Type': 'text/html' } });
});
前端管理页面
我们使用了Vue来实现前端管理页面,这个页面可以用来创建投票,查看投票结果。
代码开源在这里