拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Vue3+Express实战:搭建双女主恋综投票专题页

综艺内容在宣发期往往需要一套能承载角色介绍、互动投票、实时榜单的线上专题页。“双女主《雾雾恋综》”就是一个很典型的场景从产品角度看它要展示两位主要女性的角色设定、内容片段和用户互动结果从技术角度看它实际是一个前后端分离的内容互动站点。本文以这个项目为代号使用 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 或 LinuxWindows 上的 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 vitelatest misty-love-front -- --template vue执行过程中Vite 会开始下载脚手架模板。创建完成后进入目录并安装依赖cd misty-love-front npm install npm install vue-router4 pinia axios这里安装了三样东西vue-router4用来做页面路由虽然 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 corscors用来解决本地开发时的跨域问题。虽然前端开了 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.jsimport { 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.jsimport { 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 classcharacter-panel div classcards div v-forcharacter in store.characters :keycharacter.id classcard :class{ active: character.id activeId } clickselectCharacter(character.id) img :srccharacter.coverUrl || https://via.placeholder.com/300x400 :altcharacter.name / h3{{ character.name }}/h3 p{{ character.subtitle }}/p /div /div div v-ifactiveCharacter classdetail h2{{ activeCharacter.name }}/h2 p{{ activeCharacter.description }}/p div classtags span v-fortag in activeCharacter.tags :keytag{{ tag }}/span /div div classvote-tip 当前票数{{ activeCharacter.voteCount }} /div /div /div /template这里用activeId表示当前选中的角色 ID用computed从角色列表里找出详情对象。这样不需要单独保存一个角色对象数据始终和store.characters保持一致。如果从接口重新拉取数据详情页也会跟着刷新。图片地址写了一个占位图。实际项目中图片通常放到 CDN或者由后端直接返回完整 URL。4.3 投票按钮和防重复投票按钮放在详情区域下方。点击后调用 store 的vote方法。为了防止用户反复点击按钮要绑定disabled状态button classvote-btn :disabledstore.votedIds.includes(activeCharacter.id) clickstore.vote(activeCharacter.id) {{ store.votedIds.includes(activeCharacter.id) ? 已投票 : 为她投票 }} /button这个写法的优点是把状态展示和事件绑定分开。用户投过票后votedIds里已经存在当前角色 ID按钮显示“已投票”并且不能再点击。这里有一个常见的坑如果只在前端禁用按钮用户可以直接用 Postman 或 curl 请求投票接口无限投票。所以生产环境中后端必须有校验。前端限制只是为了避免正常用户误操作不能作为安全手段。4.4 排行榜条形图排行榜展示两个角色的票数高低这里不引入 ECharts直接用 div 宽度画简单条形图。template div classrank-list div v-for(character, index) in sortedCharacters :keycharacter.id classrank-item span classrank-index{{ index 1 }}/span span classrank-name{{ character.name }}/span div classrank-bar-wrap div classrank-bar :style{ width: ${(character.voteCount / maxVote) * 100}% } /div /div span classrank-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 的投票次数。架构上可以这样拆用户发起投票请求后端校验 token 或 session。检查 Redis 中这个用户的投票计数。如果超过阈值返回限制错误。通过后再更新数据库投票数。同时记录投票日志用于后续审计。在活动类项目中不要只依赖前端按钮禁用也不要只依赖 IP 判断。IP 可能因为 NAT 导致多个人共享同一个出口也可能被绕过。合理做法是组合设备指纹、用户 ID、操作时间等多个维度。6.3 可复用清单以后做类似的内容互动专题页可以套用下面这份检查清单业务模块是否拆成了“内容展示、用户互动、数据统计”三块。角色和内容数据是否独立于页面组件。接口是否统一返回 code、data、message 结构。前端是否通过全局 store 管理列表和投票状态。开发环境下是否配好了 Vite 代理或后端 CORS。投票类操作是否有防重复点击和基本的服务端校验。排行榜大数据量时是否有缓存方案。管理端是否可能直接编辑运营数据。部署后是否验证过静态资源图片路径。是否做好了日志、监控、数据备份和回滚方案。这些清单项并不是每个 demo 都需要但如果这个页面要长时间运营越早补上工程能力后期越省心。6.4 继续扩展的方向“双女主《雾雾恋综》”这个案例还可以继续扩展。比如增加角色评论、每日签到、分享海报、抽奖等互动功能。前端如果觉得页面打开速度不够快可以引入 SSR 或静态生成让角色介绍页在秒开。运营后台可以用成熟的 Headless CMS 或者自建一个简单的表单管理页面让非开发人员也能更新角色资料。实际项目中最重要的不是一开始就做一个大而全的管理系统而是先把“数据展示 - 用户互动 - 结果统计”这条链路跑通再根据活动反馈逐步加功能。这样既能快速验证业务又不会让技术方案一开始就失去控制。建议读者把本文的代码本地跑一遍再替换成自己的角色数据体验从接口到页面的完整数据流。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门