Featured image of post 实现一个基于svg的投票功能 用于给markdown文章添加投票功能

实现一个基于svg的投票功能 用于给markdown文章添加投票功能

实现一个基于svg的投票功能 用于给markdown文章添加投票功能

效果展示

投票结果

投票结果

投票选项

点击下面的选项即可投票

选项1

选项2

选项3

开源代码

后端代码:基于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>

每次有请求来,就:

  1. 从数据库拉取最新投票数据
  2. 把数据填入这个"SVG模板"的空位里
  3. 把填好的"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来发起投票。

这个函数处理创建投票的请求,将投票主题和选项插入到数据库中,并返回新创建的投票的详细信息。

  1. 接收请求并解析 JSON 内容: 函数首先从请求中解析 JSON 数据,获取投票的详细信息,包括标题、描述和选项。

  2. 插入投票主题到 Topics: 将投票的标题、描述和选项数量插入到 Topics 表中,并获取插入操作的结果。

  3. 获取新创建的投票主题的 ID: 从插入操作的结果中获取新创建的投票主题的 ID。

  4. 插入投票选项到 Options: 遍历投票选项,并将每个选项插入到 Options 表中,初始票数为 0。

  5. 从数据库中获取新创建的投票主题和选项: 从数据库中获取新创建的投票主题和选项的详细信息。

  6. 构建响应结果并返回: 构建包含投票主题和选项的响应结果,并将其作为 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来验证用户是否是人类,

避免了用户重复投票,如果用户重复投票,会返回错误信息。

  1. 接收请求并解析参数: 函数首先从请求中解析查询参数和路径参数,包括投票选项 ID 和验证码。

  2. 防止非 HTML 请求: 如果请求的 accept 头不包含 html,则返回一个简单的 SVG 图像,防止非 HTML 请求。

  3. 获取客户端 IP 地址: 从请求头中获取客户端的 IP 地址,如果没有获取到,则使用默认 IP 地址 1.1.1.1

  4. 处理 GET 请求: 如果请求中没有包含验证码,则返回一个包含验证码表单的 HTML 页面,要求用户输入验证码。

  5. 验证验证码: 调用 checkVerifyCode 函数验证用户输入的验证码。如果验证失败,则返回一个错误页面,提示用户重新输入验证码。

  6. 检查是否已经投过票: 查询数据库,检查该 IP 地址是否已经对该投票主题投过票。如果已经投过票且距离上次投票时间小于 1 小时,则返回一个错误页面,提示用户已经投过票。

  7. 记录投票信息: 如果该 IP 地址没有投过票,或者距离上次投票时间大于 1 小时,则更新数据库,记录投票信息。

  8. 更新投票选项的票数: 更新数据库中对应投票选项的票数,将票数加 1。

  9. 返回投票成功页面: 返回一个 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来实现前端管理页面,这个页面可以用来创建投票,查看投票结果。

代码开源在这里

使用 Hugo 构建
主题 StackJimmy 设计