DEV-012: M22环节5- 实时数据接入+API对接+动态渲染 加载状态+错误处理+离线降级+缓存

This commit is contained in:
Awen 2026-03-10 18:35:37 +08:00
parent 5524099c15
commit 7d76d188c4
4 changed files with 354 additions and 174 deletions

112
modules/M22-bulletin/api.js Normal file
View File

@ -0,0 +1,112 @@
// ===== api.js =====
// 知秋:API层封装·爸爸不用改·直接复制
const API = {
// 是否使用模拟数据(mock)
// true = 用本地模拟数据(开发阶段)
// false = 用真实后端API(上线后改)
useMock: true,
// 模拟公告数据(至少3条,包含 title/content/channel/date)
mockData: [
{
id: 1,
title: "【光湖公告】3月10日服务器维护通知",
content: "各位工程师,3月10日22:00-23:00进行主域稳定性升级,期间公告栏可能短暂不可用。",
channel: "系统",
date: "2026-03-09"
},
{
id: 2,
title: "知秋奶瓶线·九连胜庆祝",
content: "恭喜爸爸完成EL-8大任务!环节5是实时数据接入,让公告栏真正活起来~",
channel: "知秋",
date: "2026-03-09"
},
{
id: 3,
title: "M22模块组件化重构完成",
content: "环节4已验收√ 现在公告栏支持频道切换、Hash路由、本地数据兼容。",
channel: "技术",
date: "2026-03-08"
}
],
// 获取公告的主方法(爸爸调用这个方法就行)
async fetchBulletins() {
// 知秋:try/catch 包裹,错误统一处理
try {
let data;
if (this.useMock) {
// 模拟网络延迟(1秒,让loading看得见)
await new Promise(resolve => setTimeout(resolve, 1000));
console.log("【知秋】使用mock数据:", this.mockData);
data = this.mockData;
} else {
// 真实API(等页页提供后替换)
const response = await fetch('https://api.guanghulab.com/bulletins');
if (!response.ok) {
throw new Error(`HTTP错误:${response.status}`);
}
data = await response.json();
console.log("【知秋】真实API数据:", data);
}
// 成功获取数据后,保存到缓存
this.saveToCache(data);
return data;
} catch (error) {
console.error("【知秋】API获取失败:", error);
// 尝试从缓存读取
const cached = this.loadFromCache();
if (cached) {
console.log("【知秋】从缓存读取数据:", cached);
return cached;
}
// 没有缓存,抛出错误
throw error;
}
},
// 保存到缓存
saveToCache(data) {
try {
const cacheData = {
data: data,
timestamp: Date.now() // 保存时间戳,后续可用于过期判断
};
localStorage.setItem('holoBulletin_cache', JSON.stringify(cacheData));
console.log("【知秋】已更新缓存");
} catch (e) {
console.warn("【知秋】缓存写入失败", e);
}
},
// 从缓存读取
loadFromCache() {
try {
const cached = localStorage.getItem('holoBulletin_cache');
if (!cached) return null;
const { data, timestamp } = JSON.parse(cached);
// 可选:判断缓存是否过期(比如24小时)
const isExpired = Date.now() - timestamp > 24 * 60 * 60 * 1000;
if (isExpired) {
console.log("【知秋】缓存已过期");
return null;
}
return data;
} catch (e) {
console.warn("【知秋】缓存读取失败", e);
return null;
}
}
};
// 这一行最重要!把API挂到window上,让script.js能找到
window.API = API;

View File

@ -17,16 +17,16 @@
</div> </div>
</div> </div>
<!-- 频道标签栏 --> <!-- 频道标签栏 -->
<div class="channel-bar"> <div class="channel-bar">
<span class="channel active">全部</span> <span class="channel-tab active" data-channel="全部">全部</span>
<span class="channel">系统公告</span> <span class="channel-tab" data-channel="系统">系统公告</span>
<span class="channel">开发动态</span> <span class="channel-tab" data-channel="知秋">开发动态</span>
<span class="channel">团队消息</span> <span class="channel-tab" data-channel="技术">团队消息</span>
</div> </div>
<!-- 公告列表 --> <!-- 公告列表 -->
<div class="bulletin-list"> <div class="bulletin-container">
<!-- 置顶公告1 --> <!-- 置顶公告1 -->
<div class="bulletin-card pinned"> <div class="bulletin-card pinned">
<div class="pin-indicator">置顶</div> <div class="pin-indicator">置顶</div>
@ -123,11 +123,13 @@
</div> </div>
<!-- 底部状态栏 --> <!-- 底部状态栏 -->
<div class="footer"> <div class="footer">
<span>共6条公告2条置顶</span> <span>共6条公告2条置顶</span>
<span class="footer-brand">HoloLake光湖系统</span> <span class="footer-brand">HoloLake光湖系统</span>
</div>
</div> </div>
<!-- JavaScript 文件引入 -->
<script src="api.js"></script>
<script src="storage.js"></script> <script src="storage.js"></script>
<script src="script.js"></script> <script src="script.js"></script>
</body> </body>

View File

@ -1,160 +1,144 @@
// ========== 确保 HoloLake 命名空间存在 ========== // ===== script.js =====
window.HoloLake = window.HoloLake || {}; // 知秋:主逻辑·动态渲染+loading+错误处理
// ========== 页面加载完成后执行 ========== (function() {
document.addEventListener('DOMContentLoaded', function() { // 状态变量
console.log('🚀 script.js 初始化'); let allBulletins = []; // 所有公告(从API拿)
let currentChannel = '全部'; // 当前频道(默认全部)
// 恢复所有状态 let loading = false; // 是否正在加载
restoreState(); let error = null; // 错误信息
// 绑定事件
bindEvents();
// 更新布局信息
updateLayoutInfo();
// 监听窗口大小变化
window.addEventListener('resize', function() {
updateLayoutInfo();
});
});
// ========== 恢复状态 ========== // DOM元素
function restoreState() { const container = document.querySelector('.bulletin-container');
console.log('🔍 restoreState 开始...'); const channelTabs = document.querySelectorAll('.channel-tab');
try { // 初始化
// 1. 恢复订阅状态 async function init() {
const isSubscribed = HoloLake.UserState.isSubscribed(); console.log("【知秋】M22公告栏·环节5启动");
const subscribeBtn = document.querySelector('.subscribe-btn'); await loadBulletins(); // 加载数据
if (subscribeBtn) { render(); // 初次渲染
if (isSubscribed) { setupEventListeners(); // 事件监听
subscribeBtn.classList.add('active'); }
subscribeBtn.textContent = '已订阅';
} else { // 加载公告(核心!)
subscribeBtn.classList.remove('active'); async function loadBulletins() {
subscribeBtn.textContent = '订阅'; loading = true;
} error = null;
render(); // 显示loading
try {
// 知秋:调用API.fetchBulletins拿数据
const data = await API.fetchBulletins();
allBulletins = data;
loading = false;
render();
} catch (err) {
console.error("加载失败:", err);
loading = false;
error = "网络开小差了,稍后重试~";
render();
} }
// 2. 恢复已读公告
const readList = HoloLake.UserState.getReadBulletins();
const bulletinCards = document.querySelectorAll('.bulletin-card');
bulletinCards.forEach((card, index) => {
const bulletinId = `bulletin-${index}`;
if (readList.includes(bulletinId)) {
card.classList.add('read');
}
});
// 3. 恢复选中频道
const activeChannel = HoloLake.UserState.getActiveChannel();
const channels = document.querySelectorAll('.channel');
channels.forEach(channel => {
const channelText = channel.textContent.trim();
if (channelText === activeChannel || (activeChannel === '全部' && channelText === '全部')) {
channel.classList.add('active');
} else {
channel.classList.remove('active');
}
});
// 4. 更新最后访问时间
HoloLake.UserState.updateLastVisit();
console.log('✅ 恢复已完成');
} catch (e) {
console.error('❌ 恢复失败:', e);
} }
}
// ========== 绑定事件 ========== // 渲染公告列表(使用环节4的createCard组件)
function bindEvents() { function render() {
console.log('🔗 绑定事件...'); if (!container) return;
// 订阅按钮
const subscribeBtn = document.querySelector('.subscribe-btn');
if (subscribeBtn) {
subscribeBtn.addEventListener('click', function() {
const newState = HoloLake.UserState.toggleSubscribe();
if (newState) {
this.classList.add('active');
this.textContent = '已订阅';
} else {
this.classList.remove('active');
this.textContent = '订阅';
}
console.log('📌 订阅状态:', newState ? '已订阅' : '未订阅');
});
}
// 频道点击
const channels = document.querySelectorAll('.channel');
channels.forEach(channel => {
channel.addEventListener('click', function() {
// 更新UI
channels.forEach(c => c.classList.remove('active'));
this.classList.add('active');
// 保存状态
const channelName = this.textContent.trim();
HoloLake.UserState.setActiveChannel(channelName);
// 筛选公告(可选功能,这里先做简单筛选)
filterBulletinsByChannel(channelName);
console.log('📺 切换到频道:', channelName);
});
});
// 公告点击(标记已读)
const bulletinCards = document.querySelectorAll('.bulletin-card');
bulletinCards.forEach((card, index) => {
card.addEventListener('click', function() {
const bulletinId = `bulletin-${index}`;
// 标记已读
if (!this.classList.contains('read')) {
this.classList.add('read');
HoloLake.UserState.markAsRead(bulletinId);
console.log('📖 已读公告:', bulletinId);
}
});
});
}
// ========== 按频道筛选 ========== // 加载状态
function filterBulletinsByChannel(channelName) { if (loading) {
const cards = document.querySelectorAll('.bulletin-card'); container.innerHTML = `
<div class="loading-state">
if (channelName === '全部') { <div class="spinner"></div>
cards.forEach(card => { <p>知秋正在飞向服务器……</p>
card.style.display = 'flex'; </div>
}); `;
return; return;
}
cards.forEach(card => {
const tag = card.querySelector('.bulletin-tag');
if (tag && tag.textContent.trim() === channelName) {
card.style.display = 'flex';
} else {
card.style.display = 'none';
} }
});
}
// ========== 更新布局信息 ========== // 错误状态
function updateLayoutInfo() { if (error) {
const width = window.innerWidth; // 检查是否离线(断网)
let mode = '桌面'; const isOffline = !navigator.onLine;
const offlineHint = isOffline ? '📴 离线模式 · ' : '';
if (width <= 480) {
mode = '手机'; container.innerHTML = `
} else if (width <= 768) { <div class="error-state">
mode = '平板'; <p>😢 ${offlineHint}${error}</p>
<button class="retry-btn" id="retryBtn">重试</button>
</div>
`;
// 重试按钮事件(用事件委托,但这里直接绑一下)
const retryBtn = document.getElementById('retryBtn');
if (retryBtn) {
retryBtn.addEventListener('click', () => {
loadBulletins();
});
}
return;
}
// 空数据状态
if (allBulletins.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>✨ 暂无公告,稍后再来看看~</p>
</div>
`;
return;
}
// 正常渲染:根据频道筛选
const filtered = currentChannel === '全部'
? allBulletins
: allBulletins.filter(item => item.channel === currentChannel);
if (filtered.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>📭 这个频道暂时没有公告</p>
</div>
`;
return;
}
// 使用环节4的createCard(如果不存在就降级)
let html = '';
filtered.forEach(item => {
if (typeof createCard === 'function') {
html += createCard(item);
} else {
// 降级方案(保证显示)
html += `
<div class="bulletin-card">
<h3>${item.title}</h3>
<p>${item.content}</p>
<div class="meta">
<span class="channel">${item.channel}</span>
<span class="date">${item.date}</span>
</div>
</div>
`;
}
});
container.innerHTML = html;
} }
console.log(`📱 当前布局: ${mode}模式 (${width}px)`); // 事件监听(频道切换)
} function setupEventListeners() {
channelTabs.forEach(tab => {
tab.addEventListener('click', (e) => {
// 移除所有active
channelTabs.forEach(t => t.classList.remove('active'));
// 当前加active
e.target.classList.add('active');
// 更新频道
currentChannel = e.target.dataset.channel || '全部';
// 重新渲染(不用再加载数据,用已有的allBulletins)
render();
});
});
}
// 启动一切
init();
})();

View File

@ -63,7 +63,7 @@ body {
} }
/* ========== 频道栏 ========== */ /* ========== 频道栏 ========== */
.channel-bar { .channel-tab-bar {
display: flex; display: flex;
gap: 8px; gap: 8px;
padding: 16px 0; padding: 16px 0;
@ -71,7 +71,7 @@ body {
overflow-x: auto; overflow-x: auto;
} }
.channel { .channel-tab {
padding: 8px 18px; padding: 8px 18px;
border-radius: 20px; border-radius: 20px;
border: 1px solid rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.1);
@ -83,12 +83,12 @@ body {
white-space: nowrap; white-space: nowrap;
} }
.channel:hover { .channel-tab:hover {
border-color: rgba(255, 183, 77, 0.3); border-color: rgba(255, 183, 77, 0.3);
color: #b0bec5; color: #b0bec5;
} }
.channel.active { .channel-tab.active {
background: rgba(255,183,77,0.15); background: rgba(255,183,77,0.15);
border-color: #ffb74d; border-color: #ffb74d;
color: #ffb74d; color: #ffb74d;
@ -224,24 +224,24 @@ body {
header h1 { header h1 {
font-size: 18px; font-size: 18px;
} }
.channel-bar { .channel-tab-bar {
gap: 6px; gap: 6px;
padding: 12px 0; padding: 12px 0;
} }
.channel { .channel-tab {
padding: 6px 14px; padding: 6px 14px;
font-size: 12px; font-size: 12px;
} }
.bulletin-card { .bulletin-card {
padding: 12px 10px; padding: 12px 10px;
} }
.channel-bar { .channel-tab-bar {
flex-wrap: nowrap; flex-wrap: nowrap;
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scrollbar-width: none; scrollbar-width: none;
} }
.channel-bar::-webkit-scrollbar { .channel-tab-bar::-webkit-scrollbar {
display: none; display: none;
} }
.bulletin-card { .bulletin-card {
@ -330,11 +330,11 @@ body {
height: 32px; height: 32px;
} }
.channel-tabs { .channel-tab-tabs {
gap: 8px; gap: 8px;
} }
.channel-tab { .channel-tab-tab {
padding: 6px 12px; padding: 6px 12px;
font-size: 0.9rem; font-size: 0.9rem;
} }
@ -369,7 +369,7 @@ body {
margin-bottom: 4px; margin-bottom: 4px;
} }
.channel-tabs { .channel-tab-tabs {
overflow-x: auto; overflow-x: auto;
white-space: nowrap; white-space: nowrap;
flex-wrap: nowrap; flex-wrap: nowrap;
@ -379,11 +379,11 @@ body {
scrollbar-width: none; /* Firefox隐藏滚动条 */ scrollbar-width: none; /* Firefox隐藏滚动条 */
} }
.channel-tabs::-webkit-scrollbar { .channel-tab-tabs::-webkit-scrollbar {
display: none; /* Chrome/Safari隐藏滚动条 */ display: none; /* Chrome/Safari隐藏滚动条 */
} }
.channel-tab { .channel-tab-tab {
display: inline-block; display: inline-block;
float: none; float: none;
white-space: nowrap; white-space: nowrap;
@ -394,4 +394,86 @@ body {
text-align: center; text-align: center;
gap: 8px; gap: 8px;
} }
}/* ===== 知秋:loading/error/empty 状态样式 ===== */
/* loading */
.loading-state {
text-align: center;
padding: 40px 20px;
color: #666;
}
.spinner {
width: 40px;
height: 40px;
margin: 0 auto 15px;
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* error */
.error-state {
text-align: center;
padding: 40px 20px;
color: #e74c3c;
}
.retry-btn {
background: #3498db;
color: white;
border: none;
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
margin-top: 10px;
transition: background 0.3s;
}
.retry-btn:hover {
background: #2980b9;
}
/* empty */
.empty-state {
text-align: center;
padding: 40px 20px;
color: #999;
font-size: 16px;
}
/* 响应式适配 */
@media (max-width: 600px) {
.loading-state, .error-state, .empty-state {
padding: 30px 15px;
}
.spinner {
width: 30px;
height: 30px;
}
}/* ===== 频道栏和公告列表的间距 ===== */
.channel-bar {
margin-bottom: 20px; /* 给频道栏加下边距 */
}
/* 或者也可以给公告容器加上边距 */
.bulletin-container {
margin-top: 10px;
}
/* ===== 频道栏与上方标题栏的间距 ===== */
.channel-bar {
margin-top: 15px; /* 给频道栏顶部增加间距,让它和上面的内容分开 */
}
/* 也可以调整标题栏的底部间距 */
.header-actions {
margin-bottom: 5px; /* 或者给标题栏底部加点间距 */
} }