DEV-012: M22环节5- 实时数据接入+API对接+动态渲染 加载状态+错误处理+离线降级+缓存
This commit is contained in:
parent
5524099c15
commit
7d76d188c4
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
})();
|
||||
|
|
@ -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; /* 或者给标题栏底部加点间距 */
|
||||
}
|
||||
Loading…
Reference in New Issue