综艺内容在宣发期,往往需要一套能承载角色介绍、互动投票、实时榜单的线上专题页。“双女主《雾雾恋综》”就是一个很典型的场景:从产品角度看,它要展示两位主要女性的角色设定、内容片段和用户互动结果;从技术角度看,它实际是一个前后端分离的内容互动站点。本文以这个项目为代号,使用 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.js | 18 及以上 | node -v | Vite 5/6 对 Node 版本有要求,建议使用 LTS 版本 |
| npm | 9 及以上 | 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 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.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 |
| 端口被占用:EADDRINUSE | 3000 或 5173 端口已被其他进程占用 | 执行 lsof -i:3000 查看占用进程 | 换端口或结束占用进程 |
| 修改 characters.json 不生效 | 后端缓存了数据 | 查看 server.js 是否每次 readData | 按示例方式每次请求读取文件,或重启后端 |
| npm install 很慢 | 网络问题 | 查看终端下载速度 | 换镜像源并删除 node_modules 重新安装 |
排查顺序建议从简单到复杂:先确认后端启动,再确认接口能访问,最后看前端页面请求是否到达。不要一开始就怀疑代码逻辑,很多问题其实出在进程没有启动或端口不对。
5.3 学习环境与生产环境的差异
这个 demo 适合本地学习和快速验证,但直接部署到线上会有一堆问题:
| 维度 | 本地学习环境 | 生产环境 |
|---|---|---|
| 数据存储 | characters.json 文件 | MySQL / PostgreSQL / MongoDB |
| 投票防刷 | 前端 localStorage | IP、用户、设备指纹,Redis 限流 |
| 管理员维护 | 直接改 JSON 文件 | 权限系统 + 管理后台 |
| 日志 | 控制台输出 | 结构化日志 + 日志采集系统 |
| 部署 | npm run dev | Nginx + 后端服务,使用 HTTPS |
| 监控 | 无 | 接口监控、错误告警、数据备份 |
不建议把 JSON 文件写入逻辑直接搬到生产环境。并发投票时文件写入可能会相互覆盖,而且数据不可追溯、不可备份。生产环境至少使用一个轻量数据库,并给接口加上身份认证。
6. 上线前必做的工程改造和可复用清单
6.1 从 JSON 文件换成真正的数据库
把characters.json换成数据库时,后端接口路径可以保持不动。设计一个characters表,字段名和 JSON 保持一致:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | varchar(50) | 主键,角色唯一标识 |
| name | varchar(100) | 角色名 |
| subtitle | varchar(200) | 短介绍 |
| description | text | 长介绍 |
| tags | json | 标签数组 |
| cover_url | varchar(500) | 封面图地址 |
| vote_count | int | 票数,冗余字段 |
这里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 或者自建一个简单的表单管理页面,让非开发人员也能更新角色资料。
实际项目中,最重要的不是一开始就做一个大而全的管理系统,而是先把“数据展示 -> 用户互动 -> 结果统计”这条链路跑通,再根据活动反馈逐步加功能。这样既能快速验证业务,又不会让技术方案一开始就失去控制。建议读者把本文的代码本地跑一遍,再替换成自己的角色数据,体验从接口到页面的完整数据流。