☰
Vue3+Express实战:搭建双女主恋综投票专题页
2026/10/26 15:48:15 网站建设 项目流程

综艺内容在宣发期,往往需要一套能承载角色介绍、互动投票、实时榜单的线上专题页。“双女主《雾雾恋综》”就是一个很典型的场景:从产品角度看,它要展示两位主要女性的角色设定、内容片段和用户互动结果;从技术角度看,它实际是一个前后端分离的内容互动站点。本文以这个项目为代号,使用 Vue 3、Vite、Pinia 搭建前端,使用 Express 提供数据接口,实现一个可以切换双女主信息、为角色投票、查看排行榜的最小案例。读完这篇文章后,你可以照着代码跑通一个完整的“角色专题页”,并且知道后续该如何扩展成可上线的活动系统。

1. 先把“双女主《雾雾恋综》”的内容需求拆成技术问题

1.1 内容站点不能只做静态页面

如果“双女主《雾雾恋综》”只需要展示两张角色图、两段简介,那确实做一个静态 HTML 页面就够了。但这类专题页通常会有持续变化的运营需求:

  • 活动第一周只开放角色介绍,第二周上线投票入口。
  • 两位女主的票数每天变化,页面需要同步更新。
  • 榜单前三名信息要展示到活动页中。
  • 运营同学需要在不改代码的情况下替换文案和头图。

这些需求一旦加入,静态页面的维护成本会迅速上升。每改一次票数,就要重新发布一次页面;每换一张图,就要改一轮 HTML。更麻烦的是,前端要把“数据”和“展示”混在一起,后续加功能时会越来越乱。

所以更合理的做法是:前端只负责渲染,后端只负责提供数据。页面从接口读取角色内容,用户点击投票后把结果提交给后端,前端再根据接口返回刷新展示。这就是“数据驱动页面”的基本思路。本文的示例项目就按照这个思路来设计。

1.2 把专题页拆成三个模块

在动手写代码前,应该先做模块拆解。以“双女主《雾雾恋综》”专题页为例,可以拆成三个主要模块:

模块主要页面需要的数据接口数据说明
内容展示首页、角色详情GET /api/characters角色姓名、简介、标签、封面图
用户互动角色切换、投票POST /api/characters/:id/vote用户选择、投票记录、防重复
数据统计排行榜GET /api/votes/ranking按票数排序后的角色列表

这样拆完之后,开发任务就清晰了。前端需要做的不是“一个页面”,而是“三个模块的组合”。数据模型先定义好,页面和后端再各自实现。本文后面的代码也是按照这个模块顺序展开的。

1.3 技术选型:Vue3 + Vite + Pinia + Express

这个示例项目选择 Vue 3、Vite、Pinia 和 Express,主要不是为了追求新版本,而是因为它们组合在一起非常轻,适合做内容互动类小项目。

Vue 3 负责页面和组件。角色切换、投票按钮、排行榜这些交互都是由 Vue 的响应式数据驱动的。Vite 负责开发服务器和打包。它启动快,配置文件简单,对新手比较友好。Pinia 负责管理前端全局状态,比如当前角色列表、用户已经投过哪些角色。Express 负责后端接口。这里先用 JSON 文件模拟数据库,等跑通流程后再替换成 MySQL 或 PostgreSQL。

这个组合适合“最小可运行项目”。如果只是非常简单的静态展示,不需要引入这么重的全家桶;但如果后续要加评论、抽奖、管理后台,这套结构能给后面留出扩展空间。

2. 环境准备与工程初始化

2.1 环境要求

本文的命令行示例基于 macOS 或 Linux,Windows 上的 Git Bash 或 PowerShell 也可以执行,只是路径写法稍有不同。开始之前先确认两个工具:

工具建议版本检查命令说明
Node.js18 及以上node -vVite 5/6 对 Node 版本有要求,建议使用 LTS 版本
npm9 及以上npm -v随 Node 一起安装,建议升级到最新版

如果node -v输出低于 18,建议先安装 Node.js 18 LTS 或更高版本。不同版本之间可能影响依赖安装和本地服务器启动,所以不要忽略这一步。

2.2 初始化前端工程

在项目根目录下创建前端子目录,这里把项目名取为misty-love-front,对应“雾雾恋综”的拼音风格。打开终端执行:

npm create vite@latest misty-love-front -- --template vue

执行过程中,Vite 会开始下载脚手架模板。创建完成后进入目录并安装依赖:

cd misty-love-front npm install npm install vue-router@4 pinia axios

这里安装了三样东西:

  • vue-router@4:用来做页面路由,虽然 demo 页面较少,但后续加详情页时能用上。
  • pinia:用来管理全局角色数据和投票状态。
  • axios:用来请求后端接口。

如果网络下载较慢,可以临时使用国内镜像源:

npm config set registry https://registry.npmmirror.com npm install

安装完成后,先删除脚手架自带的src/components/HelloWorld.vue,后面会替换成我们自己的组件。不删除也不影响运行,但项目会比较乱。

2.3 初始化后端工程

在项目根目录下再创建misty-love-server目录,作为 Express 后端:

mkdir -p misty-love-server cd misty-love-server npm init -y npm install express cors

cors用来解决本地开发时的跨域问题。虽然前端开了 Vite 代理后不一定需要它,但后端加上这个中间件,可以直接用接口调试工具访问,避免被浏览器拦截。

2.4 目录结构说明

最终的项目目录建议保持下面这种结构:

misty-love-project/ ├── misty-love-front/ # 前端 Vue 项目 │ ├── index.html │ ├── package.json │ ├── vite.config.js │ └── src/ │ ├── main.js │ ├── App.vue │ ├── stores/ │ │ └── characters.js │ └── components/ │ └── CharacterPanel.vue └── misty-love-server/ # 后端 Express 项目 ├── package.json ├── characters.json └── server.js

前端和后端放在同一个大目录下,方便用 Git 统一管理,也方便后续做容器化部署。如果公司里前后端代码仓库是分开的,也可以各自独立维护,但接口约定要保持一致。

3. 数据模型与后端接口先行

3.1 用 JSON 描述双女主角色的数据结构

前端页面先不急着写,应该先把角色数据长什么样定义清楚。以下是misty-love-server/characters.json的内容,它描述了两个角色:

{ "characters": [ { "id": "wenwen", "name": "温温", "subtitle": "温柔坚定的内容策划", "description": "在镜头前安静,但总能一句话点醒现场。她的成长线偏向职场和自我认同。", "tags": ["温柔", "策划", "慢热"], "coverUrl": "/images/wenwen.jpg", "voteCount": 128 }, { "id": "ranran", "name": "然然", "subtitle": "热烈直率的独立音乐人", "description": "出场自带气场,喜欢用音乐表达情绪。她的互动线更像一次自我突破。", "tags": ["直率", "音乐", "行动派"], "coverUrl": "/images/ranran.jpg", "voteCount": 256 } ] }

这里有几个字段值得解释:

  • id是角色唯一标识,不能重复,接口路径中会用到。
  • name是展示名称。
  • subtitle是一句短介绍,适合放在卡片上。
  • description是长介绍,适合放在详情区域。
  • tags是字符串数组,方便前端循环渲染标签。
  • coverUrl是封面图路径。示例里没有真实图片,前端可以用占位图。
  • voteCount是演示字段,真实项目中应该拆分成单独的投票数据表,而不是直接写在角色表里。

先定义 JSON 的好处是,后端接口和前端组件都知道字段叫什么。后面即使把 JSON 换成数据库,字段名也最好保持一致,避免大面积改动前端。

3.2 接口设计

本示例需要四个接口:

方法路径说明返回内容
GET/api/characters获取全部角色列表{ code, data: characters[] }
GET/api/characters/:id根据 id 获取单个角色{ code, data: character }
POST/api/characters/:id/vote给指定角色投票{ code, message, data: character }
GET/api/votes/ranking按票数从高到低返回角色{ code, data: characters[] }

接口统一返回{ code, data, message }结构,前端通过code判断请求是否成功。这个习惯在项目变大后非常重要。如果每个接口返回格式都不一样,前端每个请求都要写不同的判断逻辑。

3.3 Express 后端实现

在misty-love-server/server.js中写入以下代码:

const express = require('express'); const cors = require('cors'); const fs = require('fs'); const path = require('path'); const app = express(); const PORT = 3000; const dataFile = path.join(__dirname, 'characters.json'); app.use(cors()); app.use(express.json()); function readData() { const raw = fs.readFileSync(dataFile, 'utf-8'); return JSON.parse(raw); } function writeData(data) { fs.writeFileSync(dataFile, JSON.stringify(data, null, 2), 'utf-8'); } app.get('/api/characters', (req, res) => { const data = readData(); res.json({ code: 0, data: data.characters }); }); app.get('/api/characters/:id', (req, res) => { const data = readData(); const character = data.characters.find((item) => item.id === req.params.id); if (!character) { return res.status(404).json({ code: 404, message: '角色不存在' }); } res.json({ code: 0, data: character }); }); app.post('/api/characters/:id/vote', (req, res) => { const data = readData(); const character = data.characters.find((item) => item.id === req.params.id); if (!character) { return res.status(404).json({ code: 404, message: '角色不存在' }); } character.voteCount += 1; writeData(data); res.json({ code: 0, message: '投票成功', data: character }); }); app.get('/api/votes/ranking', (req, res) => { const data = readData(); const sorted = [...data.characters].sort((a, b) => b.voteCount - a.voteCount); res.json({ code: 0, data: sorted }); }); app.listen(PORT, () => { console.log(`API server running at http://localhost:${PORT}`); });

这段代码比较简单,但有几点要注意:

  • readData每次请求都会读取一次文件。对演示项目来说够用,但生产环境不能这样写,否则高并发下文件读写会成为瓶颈。
  • writeData用fs.writeFileSync写回文件。多个用户同时投票时,如果有多个 Node 进程,可能造成文件覆盖。生产环境应该换成数据库。
  • 投票接口没有做真正的去重。前端可以限制同一个浏览器只投一次,但后端并没有校验 IP 或用户身份。这一点在第 6 章会展开。

用node server.js启动后,接口就会监听在 3000 端口。可以先在浏览器访问http://localhost:3000/api/characters,能看到 JSON 数据就说明后端正常。

3.4 前端 Vite 代理配置

为了让前端开发服务器能访问后端接口,修改misty-love-front/vite.config.js:

import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; export default defineConfig({ plugins: [vue()], server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } });

这里配置的含义是:当浏览器请求http://localhost:5173/api/characters时,Vite 开发服务器会把请求转发到http://localhost:3000/api/characters。浏览器认为请求还是发给当前前端页面,所以不会出现跨域问题。

changeOrigin: true表示转发时修改请求头里的 Host 字段。有些后端会对 Host 做校验,建议保持这个设置。生产环境中,前端构建后的静态文件通常由 Nginx 托管,也需要在 Nginx 里配置/api反向代理,思路和这里一致。

4. 前端实现:双女主状态、切换、投票和榜单

4.1 Pinia store 统一管理角色数据

前端会多处用到角色数据,比如卡片组件、详情组件、排行榜。如果每个组件都自己请求接口,数据会很难同步。这里用 Pinia 做一个全局 store。

新建misty-love-front/src/stores/characters.js:

import { defineStore } from 'pinia'; import axios from 'axios'; export const useCharactersStore = defineStore('characters', { state: () => ({ characters: [], loading: false, votedIds: JSON.parse(localStorage.getItem('mistyVotedIds') || '[]') }), actions: { async fetchCharacters() { this.loading = true; try { const { data } = await axios.get('/api/characters'); if (data.code === 0) { this.characters = data.data; } } finally { this.loading = false; } }, async vote(id) { if (this.votedIds.includes(id)) { return; } const { data } = await axios.post(`/api/characters/${id}/vote`); if (data.code === 0) { this.votedIds.push(id); localStorage.setItem('mistyVotedIds', JSON.stringify(this.votedIds)); await this.fetchCharacters(); } } } });

这里最关键的设计是把votedIds放进localStorage。用户投完某个角色后,刷新页面时仍然会记住投票结果,避免同一浏览器反复投票。但要注意,这只是一种体验上的“防重复”,不是真正安全的防刷。用户清掉浏览器缓存,或者换一个浏览器,投票限制就会被绕过。

vote方法调用成功后,会重新调用fetchCharacters,这样页面上的票数会自动更新。如果不重新拉取,投票后前端显示的还是旧数据,用户会以为投票没有生效。

4.2 双女主切换组件

页面核心是“双女主”的切换展示。这里做一个CharacterPanel.vue,它包含两个角色卡片,点击卡片后右侧显示对应详情。

<script setup> import { ref, computed, onMounted } from 'vue'; import { useCharactersStore } from '../stores/characters'; const store = useCharactersStore(); const activeId = ref(''); const activeCharacter = computed(() => { return store.characters.find((item) => item.id === activeId.value) || null; }); const sortedCharacters = computed(() => { return [...store.characters].sort((a, b) => b.voteCount - a.voteCount); }); function selectCharacter(id) { activeId.value = id; } onMounted(async () => { await store.fetchCharacters(); if (store.characters.length > 0) { activeId.value = store.characters[0].id; } }); </script> <template> <div class="character-panel"> <div class="cards"> <div v-for="character in store.characters" :key="character.id" class="card" :class="{ active: character.id === activeId }" @click="selectCharacter(character.id)" > <img :src="character.coverUrl || 'https://via.placeholder.com/300x400'" :alt="character.name" /> <h3>{{ character.name }}</h3> <p>{{ character.subtitle }}</p> </div> </div> <div v-if="activeCharacter" class="detail"> <h2>{{ activeCharacter.name }}</h2> <p>{{ activeCharacter.description }}</p> <div class="tags"> <span v-for="tag in activeCharacter.tags" :key="tag">{{ tag }}</span> </div> <div class="vote-tip"> 当前票数:{{ activeCharacter.voteCount }} </div> </div> </div> </template>

这里用activeId表示当前选中的角色 ID,用computed从角色列表里找出详情对象。这样不需要单独保存一个角色对象,数据始终和store.characters保持一致。如果从接口重新拉取数据,详情页也会跟着刷新。

图片地址写了一个占位图。实际项目中,图片通常放到 CDN,或者由后端直接返回完整 URL。

4.3 投票按钮和防重复

投票按钮放在详情区域下方。点击后调用 store 的vote方法。为了防止用户反复点击,按钮要绑定disabled状态:

<button class="vote-btn" :disabled="store.votedIds.includes(activeCharacter.id)" @click="store.vote(activeCharacter.id)" > {{ store.votedIds.includes(activeCharacter.id) ? '已投票' : '为她投票' }} </button>

这个写法的优点是把状态展示和事件绑定分开。用户投过票后,votedIds里已经存在当前角色 ID,按钮显示“已投票”,并且不能再点击。

这里有一个常见的坑:如果只在前端禁用按钮,用户可以直接用 Postman 或 curl 请求投票接口无限投票。所以生产环境中,后端必须有校验。前端限制只是为了避免正常用户误操作,不能作为安全手段。

4.4 排行榜条形图

排行榜展示两个角色的票数高低,这里不引入 ECharts,直接用 div 宽度画简单条形图。

<template> <div class="rank-list"> <div v-for="(character, index) in sortedCharacters" :key="character.id" class="rank-item"> <span class="rank-index">{{ index + 1 }}</span> <span class="rank-name">{{ character.name }}</span> <div class="rank-bar-wrap"> <div class="rank-bar" :style="{ width: `${(character.voteCount / maxVote) * 100}%` }" ></div> </div> <span class="rank-count">{{ character.voteCount }} 票</span> </div> </div> </template> <script setup> import { computed } from 'vue'; import { useCharactersStore } from '../stores/characters'; const store = useCharactersStore(); const sortedCharacters = computed(() => { return [...store.characters].sort((a, b) => b.voteCount - a.voteCount); }); const maxVote = computed(() => { return Math.max(...store.characters.map((item) => item.voteCount), 1); }); </script>

宽度百分比用character.voteCount / maxVote计算。如果票数为 0,最低条宽也是 0%,这符合直觉。用maxVote做分母而不是固定值,可以让榜单在不同票数规模下都有较好的视觉效果。

这里要注意一个边界情况:当characters为空数组时,Math.max(...[])会返回-Infinity,所以maxVote的默认值必须带上1。否则条形图宽度计算会出现异常。

5. 启动验证与问题排查

5.1 启动顺序与验证步骤

开发时先启动后端,再启动前端。后端启动命令:

cd misty-love-server node server.js

出现API server running at http://localhost:3000后,再启动前端:

cd misty-love-front npm run dev

浏览器打开http://localhost:5173,应该能看到两位女主卡片。点击卡片可以切换右侧详情,点击投票按钮后“当前票数”会加一,排行榜的条形图也会重新排序。

还可以用 curl 验证接口是否正常:

curl http://localhost:3000/api/characters

看到 JSON 数组表示后端正常。继续执行:

curl -X POST http://localhost:3000/api/characters/ranran/vote

如果返回投票成功,说明接口写入正常。再访问排行榜接口:

curl http://localhost:3000/api/votes/ranking

返回结果中ranran的票数应该比刚才多 1。

5.2 常见问题排查

问题现象常见原因检查方式处理建议
前端页面接口返回 404后端没有启动,或 Vite 代理配置没生效直接访问 http://localhost:3000/api/characters先启动后端,再检查 vite.config.js 中 proxy 配置
curl 访问接口正常,浏览器显示 CORS 错误后端没启用 cors,前端也没配代理看后端 app.use(cors()) 是否存在后端加 cors,或在前端 Vite 里配置代理
投票后页面票数没变化前端没有重新 fetchCharacters打开 Network 面板看是否有 GET /api/characters在 vote 成功后调用 fetchCharacters
端口被占用:EADDRINUSE3000 或 5173 端口已被其他进程占用执行 lsof -i:3000 查看占用进程换端口或结束占用进程
修改 characters.json 不生效后端缓存了数据查看 server.js 是否每次 readData按示例方式每次请求读取文件,或重启后端
npm install 很慢网络问题查看终端下载速度换镜像源并删除 node_modules 重新安装

排查顺序建议从简单到复杂:先确认后端启动,再确认接口能访问,最后看前端页面请求是否到达。不要一开始就怀疑代码逻辑,很多问题其实出在进程没有启动或端口不对。

5.3 学习环境与生产环境的差异

这个 demo 适合本地学习和快速验证,但直接部署到线上会有一堆问题:

维度本地学习环境生产环境
数据存储characters.json 文件MySQL / PostgreSQL / MongoDB
投票防刷前端 localStorageIP、用户、设备指纹,Redis 限流
管理员维护直接改 JSON 文件权限系统 + 管理后台
日志控制台输出结构化日志 + 日志采集系统
部署npm run devNginx + 后端服务,使用 HTTPS
监控无接口监控、错误告警、数据备份

不建议把 JSON 文件写入逻辑直接搬到生产环境。并发投票时文件写入可能会相互覆盖,而且数据不可追溯、不可备份。生产环境至少使用一个轻量数据库,并给接口加上身份认证。

6. 上线前必做的工程改造和可复用清单

6.1 从 JSON 文件换成真正的数据库

把characters.json换成数据库时,后端接口路径可以保持不动。设计一个characters表,字段名和 JSON 保持一致:

字段类型说明
idvarchar(50)主键,角色唯一标识
namevarchar(100)角色名
subtitlevarchar(200)短介绍
descriptiontext长介绍
tagsjson标签数组
cover_urlvarchar(500)封面图地址
vote_countint票数,冗余字段

这里vote_count是一个冗余字段,为的是排行榜查询简单。如果投票行为需要做非常精细的统计,例如按小时、按渠道查看票数,就需要另外建一张vote_log表,vote_count只作为汇总展示字段。写入时可以先更新vote_count,再插入vote_log,但是这两步不是原子的,生产环境需要开启事务,避免数据不一致。

6.2 接口安全与反刷

投票接口在综艺活动中属于高频互动入口,必须考虑“反刷”。最简单的方式是在后端记录每个 IP 或用户 ID 的投票次数。架构上可以这样拆:

  1. 用户发起投票请求,后端校验 token 或 session。
  2. 检查 Redis 中这个用户的投票计数。
  3. 如果超过阈值,返回限制错误。
  4. 通过后再更新数据库投票数。
  5. 同时记录投票日志,用于后续审计。

在活动类项目中,不要只依赖前端按钮禁用,也不要只依赖 IP 判断。IP 可能因为 NAT 导致多个人共享同一个出口,也可能被绕过。合理做法是组合设备指纹、用户 ID、操作时间等多个维度。

6.3 可复用清单

以后做类似的内容互动专题页,可以套用下面这份检查清单:

  • 业务模块是否拆成了“内容展示、用户互动、数据统计”三块。
  • 角色和内容数据是否独立于页面组件。
  • 接口是否统一返回 code、data、message 结构。
  • 前端是否通过全局 store 管理列表和投票状态。
  • 开发环境下是否配好了 Vite 代理或后端 CORS。
  • 投票类操作是否有防重复点击和基本的服务端校验。
  • 排行榜大数据量时是否有缓存方案。
  • 管理端是否可能直接编辑运营数据。
  • 部署后是否验证过静态资源图片路径。
  • 是否做好了日志、监控、数据备份和回滚方案。

这些清单项并不是每个 demo 都需要,但如果这个页面要长时间运营,越早补上工程能力,后期越省心。

6.4 继续扩展的方向

“双女主《雾雾恋综》”这个案例还可以继续扩展。比如增加角色评论、每日签到、分享海报、抽奖等互动功能。前端如果觉得页面打开速度不够快,可以引入 SSR 或静态生成,让角色介绍页在秒开。运营后台可以用成熟的 Headless CMS 或者自建一个简单的表单管理页面,让非开发人员也能更新角色资料。

实际项目中,最重要的不是一开始就做一个大而全的管理系统,而是先把“数据展示 -> 用户互动 -> 结果统计”这条链路跑通,再根据活动反馈逐步加功能。这样既能快速验证业务,又不会让技术方案一开始就失去控制。建议读者把本文的代码本地跑一遍,再替换成自己的角色数据,体验从接口到页面的完整数据流。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询