资讯动态

HTML+CSS+JS实现应援活动专题页:倒计时与留言墙实战

发布时间:2026/9/29 14:45:37 来源:尧图企业网站定制
应援页面的心思和一场演唱会其实很像先有主题再有氛围最后才是细节的呈现。这篇文章就围绕“Mermaid Festa vol.1 人鱼狂欢节——无法停下的爱恋”这个主题完整拆解一个应援活动专题页从需求到落地的全过程。项目本身以静态前端为主不依赖后端服务适合用来练习 HTML 结构设计、CSS 视觉还原、JavaScript 倒计时逻辑和本地存储也适合作为社团、同好群或个人练手的小型活动页面开发案例。1. 项目背景与需求分析1.1 这是一个什么项目项目标题里的“Mermaid Festa vol.1 人鱼狂欢节——无法停下的爱恋”是一个带有明确主题色彩的活动名称。在真实场景中这类名称通常来自虚拟偶像企划、线上 live、同人作品发布或者主题茶会。作为开发任务来看我们要做的事情不是分析歌曲本身而是围绕这个主题做一个“活动专题页”。活动专题页和普通官网页面不太一样。它更强调一次性活动的氛围感主视觉要突出、倒计时要有仪式感、参与者要能留下自己的痕迹。比如页面里可以设计一块留言墙让访客写下自己对活动的期待也可以放一段倒计时让距离活动开始的时间变成一种可视化的期待。具体到本文我不会依赖任何第三方接口也不引入 Vue、React 这类重量级框架而是用 HTML CSS 原生 JavaScript 完成一个可以直接双击打开、也可以丢到 Nginx 静态服务器上运行的单页应用。这样做的好处是完全可控、容易理解、不依赖网络适合初学者一眼看懂整个项目从前到后的逻辑。1.2 需求拆解我们把这个页面拆成 5 个核心模块模块功能说明活动主题区展示活动名称、活动时间、活动简介是页面的视觉重心倒计时区计算距离活动开始还剩多少天、多少小时、多少分钟、多少秒留言墙访客可以提交昵称和留言数据保存在浏览器 localStorage页面状态活动未开始、进行中、已结束三种状态的自动切换响应式布局在手机和桌面浏览器上都能正常阅读和操作为了便于读者复现我在代码里把“活动时间”做成一个常量变量。你可以把它改成你自己的活动时间不需要修改其他逻辑。后面第 4 章会详细说明改哪里、为什么这么设计。2. 环境准备与项目结构2.1 环境说明这个项目的运行环境非常简单。操作系统Windows / macOS / Linux 都可以浏览器Chrome、Edge、Firefox 等现代浏览器编辑器VS Code 或其他代码编辑器不需要安装 Node.js不需要安装数据库不需要任何第三方 CDN代码全部本地完成如果你希望把页面部署到服务器上可以选择任意一个静态托管服务比如 Nginx、GitHub Pages、对象存储静态网站等。但本文的核心步骤不依赖部署你直接在本地双击index.html就能看到效果。版本方面不需要纠结HTML5、CSS3、ES6 已经是很成熟的标准所有代码均以这些通用规范编写。2.2 项目目录结构我建议按照下面的结构组织文件mermaid-festa-page/ ├── index.html ├── css/ │ └── style.css └── js/ └── main.js先解释一下为什么这样分把样式单独放在css/style.css把交互逻辑单独放在js/main.jsindex.html只负责页面结构和内容引用。这种“结构、表现、行为分离”的方式后期调整配色、修改文案、修复倒计时逻辑时不需要在 HTML 里大海捞针。如果你习惯把样式写成style内联块、把脚本写成script内联块也可以但建议仅用于几十行的演示。真实项目哪怕再小单独文件都是更值得养成的习惯。3. 核心知识点拆解在写完整代码之前先把 4 组关键知识点讲清楚。这些点是初学者最容易卡住的地方也是本项目的骨架。3.1 语义化 HTML 结构HTML 不是简单地把内容堆在一起而是要用标签表达内容的“身份”。比如header表示页头区域main表示页面主体section表示一个独立的内容分区article表示可以独立分发的内容footer表示页脚区域活动专题页虽然只有一个页面但结构上依然建议使用语义标签。这样做不仅对搜索引擎友好也方便后续维护。比如你要把留言区搬到页面上方只需要调整section的位置而不是去猜哪个div才是留言区。一个常见误区是把div当万能盒子页面里全是div嵌套。这不违法但会导致可读性下降。推荐的做法是先用语义化标签确定大框架再在需要做样式容器的地方使用div。3.2 CSS 手动布局与主题氛围做活动页面时视觉上最容易翻车的是配色和字体。主题给人“人鱼”“狂欢”“海洋”等联想时配色可以选择深蓝、青蓝、珍珠白作为主色高亮处使用金色或珊瑚色点缀。但要注意颜色的数量不是越多越好。一个页面建议主色不超过两种、辅助色不超过三种。否则容易显得杂乱。布局方面本项目采用传统的居中容器 flex 组合。倒计时区域使用 flex 横向排列一行展示天、时、分、秒。移动端通过媒体查询切换成纵向排列保证小屏可读性。3.3 倒计时的时间计算思路倒计时的原理很简单用目标时间减去当前时间得到剩余毫秒数再换算成天数、小时、分钟、秒数。换算公式const remaining targetTime - Date.now(); const days Math.floor(remaining / (1000 * 60 * 60 * 24)); const hours Math.floor((remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); const minutes Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); const seconds Math.floor((remaining % (1000 * 60)) / 1000);这里有两个细节容易犯错Date.now()返回的是当前时间的毫秒时间戳是数值类型。Math.floor必须用不能用Math.round。因为剩余 1.9 秒时我们应该显示 1 秒而不是四舍五入成 2 秒。如果剩余时间小于 0就要进入“活动已开始”或“活动已结束”的状态。3.4 localStorage 数据持久化留言墙希望保存用户写下的内容。如果只是放在内存数组里刷新页面就丢了。而 localStorage 是浏览器提供的一种本地存储能力数据会保存在当前域名下关闭浏览器也不会丢失。它的用法非常简单// 保存 localStorage.setItem(key, JSON.stringify(data)); // 读取 const data JSON.parse(localStorage.getItem(key) || []);需要注意localStorage 存的是字符串。如果我们要存数组或对象必须先转换成 JSON 字符串。读取时再转回原来的结构。另外localStorage 有隐私和容量限制适合存轻量级数据不适合存上传文件、大段私密内容。生产环境如果需要用户跨设备查看留言必须配合后端数据库来实现。4. 完整实战实现“Mermaid Festa vol.1”应援专题页下面进入完整实现环节。整个页面的最终效果是蓝色海洋主题的活动首页包含活动主视觉文案、四格倒计时、留言表单和留言列表。代码可以直接复制到本地运行。4.1 创建 HTML 骨架文件路径index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleMermaid Festa vol.1 人鱼狂欢节/title link relstylesheet hrefcss/style.css /head body header classhero p classhero-sub无法停下的爱恋/p h1 classhero-titleMermaid Festa vol.1/h1 p classhero-desc人鱼狂欢节·应援活动专题页/p p classhero-time活动时间span idevent-time2025-08-08 20:00:00/span/p /header main !-- 倒计时区 -- section classcountdown-section h2距离活动开始/h2 div classcountdown-grid div classcountdown-item span classcountdown-number iddays0/span span classcountdown-label天/span /div div classcountdown-item span classcountdown-number idhours0/span span classcountdown-label时/span /div div classcountdown-item span classcountdown-number idminutes0/span span classcountdown-label分/span /div div classcountdown-item span classcountdown-number idseconds0/span span classcountdown-label秒/span /div /div p classstatus-tip idstatus-tip正在加载活动状态.../p /section !-- 留言墙 -- section classmessage-section h2留下你的应援留言/h2 form idmessage-form classmessage-form label fornickname昵称/label input typetext idnickname namenickname placeholder你的昵称 maxlength20 required label forcontent留言内容/label textarea idcontent namecontent placeholder写下想说的话 rows4 maxlength200 required/textarea button typesubmit发布留言/button /form h3留言列表/h3 ul idmessage-list classmessage-list li classmessage-empty还没有留言来抢沙发吧。/li /ul /section /main footer classpage-footer p本页面仅用于前端学习与应援活动展示非官方页面。/p /footer script srcjs/main.js/script /body /html这段 HTML 的关键点倒计时数字用了span iddays等方式挂载方便 JavaScript 单独更新数字。表单使用required做基础空值校验减少无效留言。留言列表使用无序列表ul后续由 JavaScript 动态生成li。底部页脚加了一行提示避免把学习案例误解为官方物料。4.2 编写页面样式文件路径css/style.css/* 全局基础 */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: PingFang SC, Microsoft YaHei, sans-serif; background: linear-gradient(180deg, #0b1c3a 0%, #123b6d 40%, #1e5f8f 100%); color: #eaf2fb; min-height: 100vh; line-height: 1.6; } /* 主题区 */ .hero { text-align: center; padding: 80px 20px 40px; background: radial-gradient(circle at 50% 0%, rgba(64, 128, 200, 0.35), transparent 65%); } .hero-sub { letter-spacing: 4px; color: #a8d0f0; font-size: 18px; margin-bottom: 10px; } .hero-title { font-size: clamp(32px, 6vw, 56px); letter-spacing: 2px; background: linear-gradient(90deg, #cbe6ff, #f6e7b0, #ffffff); -webkit-background-clip: text; background-clip: text; color: transparent; margin-bottom: 16px; } .hero-desc { color: #c5d9ef; font-size: 18px; margin-bottom: 24px; } .hero-time { display: inline-block; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 30px; padding: 8px 20px; font-size: 14px; color: #d8e9ff; } /* 通用容器 */ main { max-width: 900px; margin: 0 auto; padding: 20px; } section { background: rgba(255, 255, 255, 0.08); border-radius: 16px; padding: 28px; margin-bottom: 24px; backdrop-filter: blur(8px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); } h2 { font-size: 24px; margin-bottom: 18px; text-align: center; } /* 倒计时 */ .countdown-grid { display: flex; justify-content: center; gap: 16px; flex-wrap: wrap; } .countdown-item { background: rgba(0, 20, 40, 0.45); border-radius: 14px; padding: 18px 10px; min-width: 84px; text-align: center; border: 1px solid rgba(255, 255, 255, 0.12); } .countdown-number { display: block; font-size: 36px; font-weight: 700; color: #fff; } .countdown-label { display: block; margin-top: 6px; font-size: 14px; color: #9fc4e8; } .status-tip { text-align: center; margin-top: 16px; color: #f6e7b0; font-size: 16px; } /* 留言表单 */ .message-form { display: flex; flex-direction: column; gap: 12px; margin-bottom: 24px; } .message-form label { font-size: 14px; color: #b8d4ef; } .message-form input, .message-form textarea { background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 10px; padding: 10px 14px; color: #eaf2fb; font-size: 15px; font-family: inherit; outline: none; } .message-form input:focus, .message-form textarea:focus { border-color: #8ab8e8; background: rgba(255, 255, 255, 0.16); } .message-form button { align-self: flex-start; background: linear-gradient(90deg, #1e6fb8, #2f9bc6); border: none; border-radius: 30px; padding: 10px 32px; color: #fff; font-size: 16px; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; } .message-form button:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); } /* 留言列表 */ .message-list { list-style: none; margin-top: 12px; } .message-list li { background: rgba(255, 255, 255, 0.06); border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; border-left: 3px solid #4f9fd8; } .message-list .message-nickname { font-weight: 600; color: #f6e7b0; } .message-list .message-time { font-size: 12px; color: #8faecf; margin-left: 10px; } .message-list .message-content { margin-top: 6px; color: #dceaf8; word-break: break-word; } .message-empty { color: #9fc4e8; text-align: center; padding: 20px; border-left: none !important; } /* 页脚 */ .page-footer { text-align: center; padding: 30px 20px; color: #7d9fc4; font-size: 13px; } /* 响应式 */ media (max-width: 540px) { .countdown-grid { gap: 8px; } .countdown-item { min-width: 70px; padding: 12px 6px; } .countdown-number { font-size: 26px; } section { padding: 18px; } }这里解释几个样式设计意图背景用深蓝色渐变模拟海洋夜晚的氛围正文用浅色文字保证对比度。标题文字使用渐变色填充增加主题的“华丽感”。clamp(32px, 6vw, 56px)是 CSS 响应式字体函数页面宽时标题变大手机窄屏时自动变小。backdrop-filter: blur(8px)给区块增加毛玻璃效果让底层背景产生层次感。留言内容的word-break: break-word防止长留言把布局撑破。4.3 编写倒计时与留言逻辑文件路径js/main.js// 配置区修改目标时间即可 // 活动时间格式YYYY-MM-DDTHH:mm:ss const EVENT_TIME 2025-08-08T20:00:00; // DOM 元素 const daysEl document.getElementById(days); const hoursEl document.getElementById(hours); const minutesEl document.getElementById(minutes); const secondsEl document.getElementById(seconds); const statusTipEl document.getElementById(status-tip); const eventTimeEl document.getElementById(event-time); // 倒计时逻辑 function padZero(num) { return String(num).padStart(2, 0); } function formatEventTime(isoString) { const date new Date(isoString); const year date.getFullYear(); const month padZero(date.getMonth() 1); const day padZero(date.getDate()); const hours padZero(date.getHours()); const minutes padZero(date.getMinutes()); const seconds padZero(date.getSeconds()); return ${year}-${month}-${day} ${hours}:${minutes}:${seconds}; } function updateCountdown() { const targetTime new Date(EVENT_TIME).getTime(); const now Date.now(); const remaining targetTime - now; if (remaining 0) { const days Math.floor(remaining / (1000 * 60 * 60 * 24)); const hours Math.floor((remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); const minutes Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60)); const seconds Math.floor((remaining % (1000 * 60)) / 1000); daysEl.textContent days; hoursEl.textContent padZero(hours); minutesEl.textContent padZero(minutes); secondsEl.textContent padZero(seconds); statusTipEl.textContent 活动尚未开始敬请期待; } else if (remaining 0) { statusTipEl.textContent 活动开始啦; } else { const elapsed Math.abs(remaining); const days Math.floor(elapsed / (1000 * 60 * 60 * 24)); const hours Math.floor((elapsed % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); const minutes Math.floor((elapsed % (1000 * 60 * 60)) / (1000 * 60)); const seconds Math.floor((elapsed % (1000 * 60)) / 1000); daysEl.textContent days; hoursEl.textContent padZero(hours); minutesEl.textContent padZero(minutes); secondsEl.textContent padZero(seconds); statusTipEl.textContent 活动正在进行中欢迎参与; } } // 页面加载后立即执行一次再进入定时器 updateCountdown(); setInterval(updateCountdown, 1000); // 留言墙逻辑 const messageForm document.getElementById(message-form); const nicknameInput document.getElementById(nickname); const contentInput document.getElementById(content); const messageListEl document.getElementById(message-list); const STORAGE_KEY mermaid_festa_messages; function getMessages() { try { const raw localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : []; } catch (e) { console.error(读取留言失败, e); return []; } } function saveMessages(messages) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); } catch (e) { console.error(保存留言失败, e); } } function renderMessages() { const messages getMessages(); messageListEl.innerHTML ; if (messages.length 0) { const emptyLi document.createElement(li); emptyLi.className message-empty; emptyLi.textContent 还没有留言来抢沙发吧。; messageListEl.appendChild(emptyLi); return; } // 倒序显示新的留言在前 for (let i messages.length - 1; i 0; i--) { const item messages[i]; const li document.createElement(li); const nicknameSpan document.createElement(span); nicknameSpan.className message-nickname; nicknameSpan.textContent item.nickname; const timeSpan document.createElement(span); timeSpan.className message-time; timeSpan.textContent item.time || ; const contentDiv document.createElement(div); contentDiv.className message-content; contentDiv.textContent item.content; li.appendChild(nicknameSpan); li.appendChild(timeSpan); li.appendChild(contentDiv); messageListEl.appendChild(li); } } function formatNow() { const date new Date(); return ${padZero(date.getMonth() 1)}-${padZero(date.getDate())} ${padZero(date.getHours())}:${padZero(date.getMinutes())}; } messageForm.addEventListener(submit, function (event) { event.preventDefault(); const nickname nicknameInput.value.trim(); const content contentInput.value.trim(); if (!nickname || !content) { alert(昵称和留言内容不能为空); return; } const messages getMessages(); messages.push({ nickname: nickname, content: content, time: formatNow() }); saveMessages(messages); renderMessages(); messageForm.reset(); }); // 页面加载时渲染留言 renderMessages(); // 显示格式化后的活动时间 eventTimeEl.textContent formatEventTime(EVENT_TIME);下面逐段解释这段代码。配置区为什么要单独放变量把EVENT_TIME单独放在文件顶部是为了降低修改成本。活动时间是一个业务常量它可能被倒计时逻辑、页面显示逻辑、状态判断逻辑多处使用。如果你把时间值散落写到各个函数里后期调整日期会很容易漏改。集中配置是这段代码最重要的维护性设计。padZero函数的作用当天数小于 10 时我们依然希望显示09而不是9这样视觉上更整齐。String(num).padStart(2, 0)表示如果字符串长度不足 2 位在前面补一个0。倒计时状态为什么分三种如果只用“剩余时间”数字用户分不清活动到底开始没有。所以这里输出状态文案remaining 0活动还没开始remaining 0刚好开始这一帧概率极低但逻辑上要覆盖remaining 0活动已开始或已结束并把倒计时数字改成活动已持续的时间严格来说活动结束后应该显示“已结束”但本项目为了保持倒计时模块的生命周期展示的是“已进行时间”。你可以根据实际需求改成结束文案。定时器为什么使用setInterval(updateCountdown, 1000)倒计时每秒刷新一次是合理频率。如果每 100 毫秒刷新一次数字跳动太快视觉上反而不稳定如果每 10 秒刷新一次秒位会跳变不连续。1 秒是最贴合用户直觉的间隔。localStorage 为什么用 try-catchlocalStorage 在隐私模式下可能不可用也可能因为配额超限写入失败。如果读取时解析失败会导致整个留言区崩溃。用 try-catch 包裹后即使存储异常页面也能正常渲染顶多留言功能失效。渲染留言为什么倒序用户提交新留言后希望立刻看到自己的留言出现在显眼位置。数组是先进先出的顺序倒序遍历展示恰好能做到“新留言在上”。4.4 运行与验证操作步骤确保index.html、css/style.css、js/main.js在正确目录下。双击打开index.html。观察页面是否出现海洋渐变背景、活动标题、倒计时。输入昵称和留言内容点击“发布留言”。刷新页面确认留言仍然存在。按 F12 打开浏览器开发者工具切到 Application 面板找到 Local Storage确认mermaid_festa_messages键已经写入。预期效果倒计时从 1 开始每秒跳动留言墙从“还没有留言”切换为一条或多条留言卡片。如果双击打开时页面样式丢失优先检查路径大小写和目录结构。5. 常见问题与排查清单问题现象常见原因解决思路页面打开后没有任何样式link标签路径错误或 CSS 文件未保存检查link relstylesheet hrefcss/style.css路径是否正确倒计时显示 NaNEVENT_TIME字符串格式不被浏览器识别确认格式是YYYY-MM-DDTHH:mm:ss注意中间的T倒计时不刷新setInterval被清除或 JS 文件加载失败打开控制台看是否有语法错误确认main.js被正确引用留言刷新后消失使用了localStorage但被浏览器禁用在隐私模式下测试确认控制台没有报错手机端布局错乱没有meta nameviewport或媒体查询未生效检查 HTML head 中是否包含 viewport 标签输入中文后发布控制台报解析错误存储内容包含特殊字符JSON 序列化失败检查文本中是否有不可见字符必要时对内容做转义活动结束后倒计时仍显示“距离活动开始”状态判断逻辑没有覆盖已结束场景检查remaining 0分支的状态文案如果遇到和上面不完全一样的问题建议按下面的顺序排查打开浏览器控制台看有没有红色报错。报错信息中有具体行号点击行号跳到源码位置。检查HTML中的 ID 名称与JS中getElementById的名称是否完全一致。检查三个文件的相对路径是否正确。用console.log打印关键变量的值比如remaining是否为有效数字。6. 最佳实践与工程建议6.1 时间处理不要硬编码散落调用接口、活动开始时间、留言时间戳在真实项目里都可能是动态数据。本文示例把活动时间统一为一个常量这是小型页面的合理做法。但如果你的页面需要对接后端建议把时间数据放在接口返回里然后交给同一个渲染函数处理不要在页面里写多个new Date(...)。6.2 留言墙的边界条件留言功能在生产项目中必须注意以下几点内容长度限制HTML 里设置maxlength只是前端约束后端必须再次校验。内容展示安全使用textContent而不是innerHTML来渲染用户留言避免 XSS 注入风险。存储容量控制localStorage 一般只有 5MB 左右容量个人感觉不宜存超过几百条留言。如果留言量预期较大应使用后端 API 和数据库。用户身份本文没有记录真实用户身份。如果活动页支持实名参与留言必须考虑用户协议和内容审核。这里特别强调innerHTML问题。如果用户留言内容里包含img或script标签用innerHTML直接拼接会导致页面执行不预期的内容。使用textContent可以避免这个问题因为浏览器只会把它当作纯文本处理。6.3 页面性能与图片体积活动专题页通常包含大量视觉素材。如果使用背景图建议使用 WebP 或压缩后的 JPG 格式单图控制在 200KB 内。优先使用 CSS 渐变和纯色营造氛围减少大图依赖。如果必须用大图可以设置loadinglazy延迟加载非首屏图片。避免使用 GIF 作为大尺寸背景体积通常不可控。6.4 响应式设计不只是一套媒体查询本文只写了一个media (max-width: 540px)能覆盖大多数手机。但实际项目中建议从“移动优先”的角度出发先写小屏样式再用媒体查询增强桌面体验。这样既能减少覆盖样式的优先级冲突也能让代码更清晰。6.5 部署与版本管理即使只是静态页面也建议使用 Git 管理git init git add . git commit -m feat: 完成 Mermaid Festa vol.1 应援专题页部署静态站点时选择 HTTPS 环境避免浏览器对不安全资源的限制。7. 总结与下一步学习方向本文围绕一个主题型活动专题页完整实现了三部分核心功能语义化 HTML 结构、海洋氛围 CSS 布局、倒计时与留言墙的 JavaScript 逻辑。你可以把这个项目当作一个前端综合练习也可以在此基础上扩展出更复杂的版本。如果继续深入学习建议按下面的路径走学习 CSS Grid替代部分 flex 布局处理更复杂的栅格结构。学习模块化 JavaScript把倒计时和留言功能拆成独立模块文件。学习 Promise 和 fetch把留言墙改造成调用后端 API 的真实交互功能。学习构建工具将项目接入 Vite 或 webpack实现开发、打包、压缩一体化。动手做一遍的收获比干看代码大得多。你可以先把活动时间改成自己最近在意的一个日期观察倒计时走到零点时的状态变化也可以把配色改成自己更喜欢的主题色体验从设计到实现的完整链路。如果这篇文章对你有帮助可以收藏备用后续需要做类似的活动专题页时直接照着改。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑