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

View File

@ -1,160 +1,144 @@
// ========== 确保 HoloLake 命名空间存在 ==========
window.HoloLake = window.HoloLake || {};
// ===== script.js =====
// 知秋:主逻辑·动态渲染+loading+错误处理
// ========== 页面加载完成后执行 ==========
document.addEventListener('DOMContentLoaded', function() {
console.log('🚀 script.js 初始化');
// 恢复所有状态
restoreState();
// 绑定事件
bindEvents();
// 更新布局信息
updateLayoutInfo();
// 监听窗口大小变化
window.addEventListener('resize', function() {
updateLayoutInfo();
});
});
(function() {
// 状态变量
let allBulletins = []; // 所有公告从API拿
let currentChannel = '全部'; // 当前频道(默认全部)
let loading = false; // 是否正在加载
let error = null; // 错误信息
// ========== 恢复状态 ==========
function restoreState() {
console.log('🔍 restoreState 开始...');
try {
// 1. 恢复订阅状态
const isSubscribed = HoloLake.UserState.isSubscribed();
const subscribeBtn = document.querySelector('.subscribe-btn');
if (subscribeBtn) {
if (isSubscribed) {
subscribeBtn.classList.add('active');
subscribeBtn.textContent = '已订阅';
} else {
subscribeBtn.classList.remove('active');
subscribeBtn.textContent = '订阅';
}
// DOM元素
const container = document.querySelector('.bulletin-container');
const channelTabs = document.querySelectorAll('.channel-tab');
// 初始化
async function init() {
console.log("【知秋】M22公告栏·环节5启动");
await loadBulletins(); // 加载数据
render(); // 初次渲染
setupEventListeners(); // 事件监听
}
// 加载公告(核心!)
async function loadBulletins() {
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);
}
}
// ========== 绑定事件 ==========
function bindEvents() {
console.log('🔗 绑定事件...');
// 订阅按钮
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);
}
});
});
}
// 渲染公告列表使用环节4的createCard组件
function render() {
if (!container) return;
// ========== 按频道筛选 ==========
function filterBulletinsByChannel(channelName) {
const cards = document.querySelectorAll('.bulletin-card');
if (channelName === '全部') {
cards.forEach(card => {
card.style.display = 'flex';
});
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';
// 加载状态
if (loading) {
container.innerHTML = `
<div class="loading-state">
<div class="spinner"></div>
<p>知秋正在飞向服务器</p>
</div>
`;
return;
}
});
}
// ========== 更新布局信息 ==========
function updateLayoutInfo() {
const width = window.innerWidth;
let mode = '桌面';
if (width <= 480) {
mode = '手机';
} else if (width <= 768) {
mode = '平板';
// 错误状态
if (error) {
// 检查是否离线(断网)
const isOffline = !navigator.onLine;
const offlineHint = isOffline ? '📴 离线模式 · ' : '';
container.innerHTML = `
<div class="error-state">
<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;
gap: 8px;
padding: 16px 0;
@ -71,7 +71,7 @@ body {
overflow-x: auto;
}
.channel {
.channel-tab {
padding: 8px 18px;
border-radius: 20px;
border: 1px solid rgba(255,255,255,0.1);
@ -83,12 +83,12 @@ body {
white-space: nowrap;
}
.channel:hover {
.channel-tab:hover {
border-color: rgba(255, 183, 77, 0.3);
color: #b0bec5;
}
.channel.active {
.channel-tab.active {
background: rgba(255,183,77,0.15);
border-color: #ffb74d;
color: #ffb74d;
@ -224,24 +224,24 @@ body {
header h1 {
font-size: 18px;
}
.channel-bar {
.channel-tab-bar {
gap: 6px;
padding: 12px 0;
}
.channel {
.channel-tab {
padding: 6px 14px;
font-size: 12px;
}
.bulletin-card {
padding: 12px 10px;
}
.channel-bar {
.channel-tab-bar {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.channel-bar::-webkit-scrollbar {
.channel-tab-bar::-webkit-scrollbar {
display: none;
}
.bulletin-card {
@ -330,11 +330,11 @@ body {
height: 32px;
}
.channel-tabs {
.channel-tab-tabs {
gap: 8px;
}
.channel-tab {
.channel-tab-tab {
padding: 6px 12px;
font-size: 0.9rem;
}
@ -369,7 +369,7 @@ body {
margin-bottom: 4px;
}
.channel-tabs {
.channel-tab-tabs {
overflow-x: auto;
white-space: nowrap;
flex-wrap: nowrap;
@ -379,11 +379,11 @@ body {
scrollbar-width: none; /* Firefox隐藏滚动条 */
}
.channel-tabs::-webkit-scrollbar {
.channel-tab-tabs::-webkit-scrollbar {
display: none; /* Chrome/Safari隐藏滚动条 */
}
.channel-tab {
.channel-tab-tab {
display: inline-block;
float: none;
white-space: nowrap;
@ -394,4 +394,86 @@ body {
text-align: center;
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; /* 或者给标题栏底部加点间距 */
}