欢迎光临
我们一直在努力

【1024快乐】用HTML写一个本地版心愿墙页面

AI时代,不改热爱,1024快乐。


一、效果展示

这个网页在本地实现了心愿墙功能。

页面效果如图。

二、开发说明

1.借助AI大模型开发,但是我阅读检查并测试了代码,在本地Edge浏览器下运行流畅。

2. HTML代码和CSS、JS代码都放在同一个文件里,但分区明确。文件下载后即可双击运行。

3.可以增加和删除心愿。目前未设置批量删除功能,也不能修改已发送的心愿。

4.已发布的心愿使用localStorage 进行本地存储,刷新页面后也不丢失。信息不联网不上传。

5.采用响应式设计,兼顾网页端与移动端体验。

6.遵循网页开发最佳实践,遵循无障碍设计特性。

7.CSS设计令牌集中配置,便于后续扩展。

三、完整代码

<!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>

赞(0)
未经允许不得转载:171主机测评 » 【1024快乐】用HTML写一个本地版心愿墙页面
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址