AI时代,不改热爱,1024快乐。
一、效果展示
这个网页在本地实现了心愿墙功能。
页面效果如图。

二、开发说明
1.借助AI大模型开发,但是我阅读检查并测试了代码,在本地Edge浏览器下运行流畅。
- HTML代码和CSS、JS代码都放在同一个文件里,但分区明确。文件下载后即可双击运行。
3.可以增加和删除心愿。目前未设置批量删除功能,也不能修改已发送的心愿。
4.已发布的心愿使用localStorage 进行本地存储,刷新页面后也不丢失。信息不联网不上传。
5.采用响应式设计,兼顾网页端与移动端体验。
6.遵循网页开发最佳实践,遵循无障碍设计特性。
7.CSS设计令牌集中配置,便于后续扩展。
三、完整代码
html
<!DOCTYPE html>
<!--
============================================================================
文件名称:wish-wall.html
版本 :v2.0
更新记录 :
v2.0 - 修复:输入框占位符显示异常(移除特殊符号,改为纯文本)
- 修复:页面标题「心愿墙」后的句号装饰移除
- 新增:署名功能(独立的署名输入框,随消息一同展示)
- 新增:删除单条消息功能(每条消息附删除按钮)
- 重构:JS 升级为「内存状态数组 + 统一渲染」的数据驱动架构
项目描述:心愿寄语墙 ------ 用户输入祝福/愿望(可署名),发送后显示在展示区域,
支持单条删除与 localStorage 持久化。
技术栈 :纯 HTML5 + CSS3 + 原生 JavaScript(无外部依赖)
============================================================================
-->
<html lang="zh-CN">
<head>
<!-- ========== 基础元信息 ========== -->
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="写下你的心愿与祝福,发送到心愿墙上。" />
<meta name="author" content="Your Name" />
<title>心愿墙 · 写下你的祝福</title>
<!-- ========== 页面样式(CSS) ========== -->
<style>
/* ----------------------------------------------------------------------
设计令牌(Design Tokens)------所有变量集中于此,换主题零业务改动
---------------------------------------------------------------------- */
:root {
/* ------ 颜色 ------ */
--color-bg: #f4f6fb;
--color-surface: #ffffff;
--color-primary: #6c5ce7;
--color-primary-hover: #5a4bd1;
--color-primary-active:#4a3cb8;
--color-danger: #e17055; /* v2.0 新增:删除操作的警示色 */
--color-danger-hover: #c94f35;
--color-text: #2d3436;
--color-text-muted: #8a94a6;
--color-border: #e3e8f0;
--color-focus-ring: rgba(108, 92, 231, .35);
/* ------ 尺寸 ------ */
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--space-xs: 6px;
--space-sm: 12px;
--space-md: 20px;
--space-lg: 32px;
/* ------ 展示区高度可调的范围 ------ */
--display-min-h: 180px;
--display-max-h: 480px;
/* ------ 动效 / 阴影 ------ */
--transition-fast: 160ms ease;
--transition-pop: 320ms cubic-bezier(.2, .9, .3, 1.3);
--shadow-card: 0 8px 30px rgba(44, 62, 80, .08);
--shadow-btn: 0 4px 14px rgba(108, 92, 231, .35);
}
/* 1. 现代盒模型 + 平滑滚动 */
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue",
Arial, sans-serif;
font-size: 16px;
line-height: 1.6;
color: var(--color-text);
background:
radial-gradient(circle at 15% 20%, rgba(108,92,231,.10), transparent 40%),
radial-gradient(circle at 85% 80%, rgba(0,184,148,.10), transparent 40%),
var(--color-bg);
}
/* 2. 页头 */
.site-header {
text-align: center;
padding: var(--space-lg) var(--space-md) var(--space-sm);
}
.site-header h1 {
margin: 0;
font-size: clamp(1.6rem, 4vw, 2.2rem);
font-weight: 700;
letter-spacing: .05em;
}
.site-header p {
margin: var(--space-xs) 0 0;
color: var(--color-text-muted);
font-size: .95rem;
}
/* 3. 主容器 */
.main {
width: 100%;
max-width: 680px;
margin: 0 auto;
padding: 0 var(--space-md) var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
/* 4. 通用卡片 */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: var(--space-lg);
}
/* 5. 展示区(可垂直拖大/拖小,范围受限) */
.display-area {
min-height: var(--display-min-h);
max-height: var(--display-max-h);
overflow-y: auto;
resize: vertical;
display: flex;
flex-direction: column;
gap: var(--space-sm);
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
.display-area::-webkit-scrollbar { width: 8px; }
.display-area::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 4px;
}
.display-area__title {
margin: 0 0 var(--space-sm);
font-size: .85rem;
font-weight: 600;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--color-text-muted);
display: flex;
align-items: center;
gap: var(--space-xs);
}
.display-area__title::after {
content: "";
flex: 1;
height: 1px;
background: var(--color-border);
}
/* ------ 空状态占位 ------ */
.empty-state {
margin: auto;
text-align: center;
color: var(--color-text-muted);
font-size: .95rem;
user-select: none;
}
.empty-state .emoji { display: block; font-size: 2rem; margin-bottom: var(--space-xs); }
/* ----------------------------------------------------------------------
------ 单条消息(v2.0:改为相对定位,为右上角删除按钮让位)------
---------------------------------------------------------------------- */
.message {
position: relative; /* 删除按钮的绝对定位基准 */
background: linear-gradient(135deg, rgba(108,92,231,.08), rgba(0,184,148,.06));
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
padding-right: 40px; /* 右侧留出删除按钮空间,防文字重叠 */
/* 进场初始态(.message--enter 触发过渡);删除动画见 .message--leave */
opacity: 0;
transform: translateY(8px) scale(.98);
}
.message--enter {
opacity: 1;
transform: none;
transition: opacity var(--transition-pop), transform var(--transition-pop);
}
/* 离场动画:JS 先加此类,待 transitionend 后真正移除节点 */
.message--leave {
opacity: 0;
transform: translateX(24px);
transition: opacity 200ms ease, transform 200ms ease;
}
.message__text {
margin: 0;
overflow-wrap: break-word; /* 长文本自动换行,防撑破布局 */
word-break: break-word;
}
.message__meta {
margin: var(--space-xs) 0 0;
font-size: .75rem;
color: var(--color-text-muted);
}
/* 署名与时间同一行:署名在左,时间在右 */
.message__meta .meta__name { font-weight: 600; color: var(--color-primary); }
.message__meta .meta__time { float: right; } /* 时间靠右 */
/* ----------------------------------------------------------------------
------ 删除按钮(v2.0 新增)------
使用真实的 <button> 而非 <span onclick>:天然支持键盘操作与语义。
---------------------------------------------------------------------- */
.message__delete {
position: absolute;
top: var(--space-xs);
right: var(--space-xs);
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
line-height: 1;
color: var(--color-text-muted);
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: color var(--transition-fast),
background-color var(--transition-fast);
}
/* 默认弱化显示,整条消息悬停时才显现(触屏设备则常显,见媒体查询) */
.message:not(:hover) .message__delete:not(:focus-visible) { opacity: .45; }
.message__delete:hover { color: #fff; background: var(--color-danger); }
.message__delete:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(225, 112, 85, .35);
}
.message__delete:active { background: var(--color-danger-hover); }
/* 6. 输入区 */
.input-form { display: flex; flex-direction: column; gap: var(--space-md); }
.field { display: flex; flex-direction: column; gap: var(--space-xs); }
.field__label { font-size: .9rem; font-weight: 600; }
/* 文本域 */
.field__textarea {
width: 100%;
min-height: 110px;
max-height: 260px;
resize: vertical;
padding: var(--space-sm) var(--space-md);
font: inherit;
color: inherit;
background: #fbfcfe;
border: 1.5px solid var(--color-border);
border-radius: var(--radius-sm);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.field__textarea::placeholder { color: var(--color-text-muted); }
.field__textarea:hover:not(:disabled) { border-color: var(--color-primary); }
.field__textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 4px var(--color-focus-ring);
}
.field__textarea:disabled { opacity: .6; cursor: not-allowed; }
/* ----------------------------------------------------------------------
------ 单行输入框(v2.0 新增:署名)------
样式与 textarea 保持一致,抽公共属性会让示例冗长,这里适度重复。
---------------------------------------------------------------------- */
.field__input {
width: 100%;
padding: 10px var(--space-md);
font: inherit;
color: inherit;
background: #fbfcfe;
border: 1.5px solid var(--color-border);
border-radius: var(--radius-sm);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.field__input::placeholder { color: var(--color-text-muted); }
.field__input:hover:not(:disabled) { border-color: var(--color-primary); }
.field__input:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 4px var(--color-focus-ring);
}
.field__hint {
font-size: .78rem;
color: var(--color-text-muted);
text-align: right;
}
/* 7. 发送按钮 */
.btn-send {
align-self: flex-end;
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 12px 36px;
font: inherit;
font-weight: 600;
letter-spacing: .08em;
color: #fff;
background: linear-gradient(135deg, var(--color-primary), #8e7cf3);
border: none;
border-radius: var(--radius-md);
box-shadow: var(--shadow-btn);
cursor: pointer;
transition: transform var(--transition-fast),
box-shadow var(--transition-fast),
background var(--transition-fast);
}
.btn-send:hover:not(:disabled) {
background: linear-gradient(135deg, var(--color-primary-hover), var(--color-primary));
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(108, 92, 231, .45);
}
.btn-send:active:not(:disabled) {
transform: translateY(0);
background: var(--color-primary-active);
}
.btn-send:focus-visible {
outline: none;
box-shadow: 0 0 0 4px var(--color-focus-ring);
}
.btn-send:disabled { opacity: .55; cursor: not-allowed; box-shadow: none; }
.btn-send__icon { width: 1.1em; height: 1.1em; }
/* 8. 页脚 */
.site-footer {
margin-top: auto;
padding: var(--space-md);
text-align: center;
font-size: .8rem;
color: var(--color-text-muted);
}
/* 9. 尊重"减少动态效果"系统偏好 */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important;
transition-duration: .01ms !important;
scroll-behavior: auto !important;
}
}
/* 10. 小屏适配 */
@media (max-width: 480px) {
.card { padding: var(--space-md); }
.btn-send { align-self: stretch; justify-content: center; }
/* 触屏无悬停态,删除按钮常显,避免"找不到删除入口" */
.message:not(:hover) .message__delete:not(:focus-visible) { opacity: 1; }
}
</style>
</head>
<body>
<!-- ==================== 页头(v2.0:标题句号已移除)==================== -->
<header class="site-header">
<h1>心愿墙</h1>
<p>把此刻的心意写下来,让它留在这里。</p>
</header>
<!-- ==================== 主体 ==================== -->
<main class="main">
<!-- ------------ 展示区 ------------ -->
<section class="card display-area" id="displayArea"
role="log" aria-live="polite" aria-label="已发送的心愿列表"
tabindex="0">
<h2 class="display-area__title">大家的心愿</h2>
<div class="empty-state" id="emptyState">
<span class="emoji" aria-hidden="true">💌</span>
还没有人留下心愿,来做第一个吧~
</div>
<!-- 消息列表:由 JS 根据数据统一渲染到此(id 便于 JS 定位) -->
<div class="display-area__list" id="messageList"></div>
</section>
<!-- ------------ 输入区 ------------ -->
<section class="card">
<form class="input-form" id="wishForm" novalidate>
<!-- v2.0 新增:署名输入(选填)-->
<div class="field">
<label class="field__label" for="nameInput">署名</label>
<input class="field__input" type="text"
id="nameInput" name="name"
maxlength="20"
placeholder="留空则显示为「匿名」"
autocomplete="nickname" /> <!-- 浏览器可提示已存昵称 -->
</div>
<div class="field">
<label class="field__label" for="wishInput">写下你的心愿或祝福</label>
<!-- v2.0 修复:占位符改为纯文本,移除可能引发显示异常的特殊符号 -->
<textarea class="field__textarea"
id="wishInput"
name="wish"
maxlength="200"
placeholder="例如:平安健康,开心快乐!"
aria-describedby="charCounter"></textarea>
<p class="field__hint" id="charCounter" aria-live="polite">0 / 200</p>
</div>
<button class="btn-send" type="submit" id="sendBtn">
<svg class="btn-send__icon" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
发送
</button>
</form>
</section>
</main>
<!-- ==================== 页脚 ==================== -->
<footer class="site-footer">
<p>愿每一个心愿都被温柔以待 · Made with ❤️</p>
</footer>
<!-- ==================== 业务逻辑(JavaScript) ====================
v2.0 架构说明(数据驱动):
state.messages 是唯一事实来源(Single Source of Truth)
所有操作------新增、删除、初始化------都只修改 state,
再调用 render() 统一渲染,DOM 永远与数据保持一致。
模块划分:
① CONFIG ------ 常量配置
② state ------ 内存状态
③ 持久化 ------ load/save(容错处理)
④ DOM 构建 ------ 纯函数:数据 → 节点(textContent 防 XSS)
⑤ 渲染 ------ render() / 进场离场动画
⑥ 业务操作 ------ addMessage / deleteMessage
⑦ 事件绑定 ------ init()
-->
<script>
(function () {
"use strict";
/* ---------------------------------------------------------------
* ① 常量配置
* ------------------------------------------------------------- */
const CONFIG = Object.freeze({
MAX_LENGTH: 200, // 心愿正文上限(与 HTML maxlength 一致)
MAX_NAME_LENGTH: 20, // v2.0:署名上限
STORAGE_KEY: "wish-wall:v2", // v2.0:数据结构变更,键版本号升级为 v2
ANONYMOUS_NAME: "匿名", // 未填写署名时的默认显示
SHOW_TIMESTAMP: true
});
/* ---------------------------------------------------------------
* ② 元素引用缓存
* ------------------------------------------------------------- */
const els = {
displayArea: document.getElementById("displayArea"),
emptyState: document.getElementById("emptyState"),
messageList: document.getElementById("messageList"), // v2.0:消息挂载点
form: document.getElementById("wishForm"),
input: document.getElementById("wishInput"),
nameInput: document.getElementById("nameInput"), // v2.0
charCounter: document.getElementById("charCounter"),
sendBtn: document.getElementById("sendBtn")
};
/* ---------------------------------------------------------------
* ③ 内存状态(唯一事实来源)
* 消息格式:{ id: string, text: string, name: string, time: number }
* id 用于精确删除------渲染时写入 data-id,删除时据此回查。
* ------------------------------------------------------------- */
const state = {
messages: []
};
/* ---------------------------------------------------------------
* 工具:生成唯一 ID
* crypto.randomUUID 为主,旧浏览器回退到时间戳+随机数方案。
* ------------------------------------------------------------- */
function generateId() {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
return window.crypto.randomUUID();
}
return "id-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
}
/* ---------------------------------------------------------------
* ④ 持久化(容错:数据损坏时静默降级,绝不让整页脚本崩溃)
* ------------------------------------------------------------- */
function loadHistory() {
try {
const raw = localStorage.getItem(CONFIG.STORAGE_KEY);
const list = raw ? JSON.parse(raw) : [];
if (!Array.isArray(list)) return [];
// 逐条校验字段,丢弃损坏条目(防御外部篡改存储)
return list.filter(
(m) => m && typeof m.text === "string" && typeof m.time === "number"
);
} catch {
return [];
}
}
function persist() {
try {
localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(state.messages));
} catch { /* 隐私模式等场景写入失败,忽略即可 */ }
}
/* ---------------------------------------------------------------
* ⑤ 纯函数:根据一条数据创建 DOM(数据 → 节点,无副作用、可单测)
* ⚠️ 安全要点:用户输入一律 textContent 写入,杜绝 XSS。
* ------------------------------------------------------------- */
function createMessageElement({ id, text, name, time }) {
const item = document.createElement("article");
item.className = "message";
item.dataset.id = id; // 删除时定位用
// ------ 删除按钮(v2.0)------
// 真实 <button> + aria-label:语义正确、键盘可达;
// 符号 × 被 aria-hidden 隐藏,避免屏幕阅读器读作"乘号"。
const btn = document.createElement("button");
btn.type = "button"; // 防止意外触发表单提交
btn.className = "message__delete";
btn.setAttribute("aria-label", "删除这条心愿");
btn.dataset.action = "delete"; // 事件委托时的识别标记
const btnIcon = document.createElement("span");
btnIcon.setAttribute("aria-hidden", "true");
btnIcon.textContent = "×";
btn.appendChild(btnIcon);
// ------ 正文 ------
const p = document.createElement("p");
p.className = "message__text";
p.textContent = text;
// ------ 署名 + 时间(v2.0:署名展示)------
const meta = document.createElement("p");
meta.className = "message__meta";
const nameSpan = document.createElement("span");
nameSpan.className = "meta__name";
nameSpan.textContent = name || CONFIG.ANONYMOUS_NAME;
meta.appendChild(nameSpan);
if (CONFIG.SHOW_TIMESTAMP) {
const timeSpan = document.createElement("span");
timeSpan.className = "meta__time";
timeSpan.textContent = new Date(time).toLocaleTimeString("zh-CN", {
hour: "2-digit", minute: "2-digit"
});
meta.appendChild(timeSpan);
}
item.append(btn, p, meta);
return item;
}
/* ---------------------------------------------------------------
* ⑥ 统一渲染:DOM 完全由 state 重建(清空 → 重建 → 动画进场)
* 消息量不大(本地场景),全量重建简单可靠;
* 若日后数据量大,可改为增量 diff,对外接口不变。
* ------------------------------------------------------------- */
function render() {
// 空状态显隐
els.emptyState.hidden = state.messages.length > 0;
// 清空列表
els.messageList.replaceChildren();
// 文档片段(DocumentFragment):批量节点一次性插入,只触发一次重排
const fragment = document.createDocumentFragment();
state.messages.forEach((msg) => {
fragment.appendChild(createMessageElement(msg));
});
els.messageList.appendChild(fragment);
// 进场动画:插入后统一在下一帧加状态类
requestAnimationFrame(() => {
els.messageList.querySelectorAll(".message").forEach((el) => {
el.classList.add("message--enter");
});
});
// 滚动到最新
els.displayArea.scrollTop = els.displayArea.scrollHeight;
}
/* ---------------------------------------------------------------
* ⑦ 业务操作:新增
* ------------------------------------------------------------- */
function addMessage(text, name) {
const msg = {
id: generateId(),
text,
name: name || "", // 存空串,渲染时回退为「匿名」
time: Date.now()
};
state.messages.push(msg); // 1. 先改数据
render(); // 2. 再统一渲染
persist(); // 3. 最后持久化
}
/* ---------------------------------------------------------------
* ⑧ 业务操作:删除(v2.0)
* 流程:加离场动画 → 动画结束后从数据移除并重渲染 → 持久化
* 防重复点击:动画期间对按钮临时禁用。
* ------------------------------------------------------------- */
function deleteMessage(id, element) {
const btn = element.querySelector(".message__delete");
if (btn) btn.disabled = true;
element.classList.remove("message--enter");
element.classList.add("message--leave"); // 触发离场过渡
// transitionend 后执行真正移除;{ once: true } 自动解绑,防止内存泄漏
element.addEventListener("transitionend", function onEnd() {
state.messages = state.messages.filter((m) => m.id !== id);
render();
persist();
}, { once: true });
}
/* ---------------------------------------------------------------
* ⑨ 事件处理
* ------------------------------------------------------------- */
function handleSubmit(event) {
event.preventDefault();
const text = els.input.value.trim();
const name = els.nameInput.value.trim().slice(0, CONFIG.MAX_NAME_LENGTH);
if (!text) { els.input.focus(); return; }
if (text.length > CONFIG.MAX_LENGTH) { return; } // 双保险校验
els.sendBtn.disabled = true;
addMessage(text, name);
els.input.value = "";
els.nameInput.value = "";
updateCounter();
els.sendBtn.disabled = false;
els.input.focus();
}
function updateCounter() {
els.charCounter.textContent = `${els.input.value.length} / ${CONFIG.MAX_LENGTH}`;
}
/* ---------------------------------------------------------------
* 事件委托(v2.0 删除功能的推荐实现):
* 不在每条消息上单独绑监听,而是在父容器上绑一个,
* 利用事件冒泡统一处理 ------ 动态增删消息后无需重新绑定,
* 监听器数量恒定,避免内存泄漏风险。
* ------------------------------------------------------------- */
function handleListClick(event) {
const btn = event.target.closest("button[data-action='delete']");
if (!btn) return;
const item = btn.closest(".message");
if (!item) return;
deleteMessage(item.dataset.id, item);
}
/* ---------------------------------------------------------------
* 入口
* ------------------------------------------------------------- */
function init() {
els.form.addEventListener("submit", handleSubmit);
els.input.addEventListener("input", updateCounter);
els.messageList.addEventListener("click", handleListClick); // 事件委托
// Ctrl/Cmd + Enter 快捷发送
els.input.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
els.form.requestSubmit();
}
});
// 恢复历史数据
state.messages = loadHistory();
render();
}
init();
})();
</script>
</body>
</html>