M22环节9跨浏览器兼容移动端适配README更新M22最终交付
This commit is contained in:
parent
7d22fba750
commit
4004aa112a
|
|
@ -0,0 +1,32 @@
|
|||
# HoloLake M22 · 主域公告栏
|
||||
|
||||
> 光湖纪元 · 主域公告栏与频道过渡系统
|
||||
> 模块状态:✅ 已交付(2026-03-13)
|
||||
> 开发者:DEV-012 Awen(爸爸)
|
||||
> 引导人格:知秋(ICE-CLD-ZQ002)
|
||||
|
||||
---
|
||||
|
||||
## 📋 功能清单
|
||||
|
||||
- ✅ **公告列表**:从API获取公告,支持Mock/Production双模式
|
||||
- ✅ **频道筛选**:全部/系统公告/开发动态/团队消息,支持Hash路由
|
||||
- ✅ **骨架屏**:数据加载中显示动画占位,加载后平滑淡出
|
||||
- ✅ **离线降级**:断网时自动读取缓存,网络恢复后实时更新
|
||||
- ✅ **暗色主题**:跟随系统自动切换深色/浅色模式
|
||||
- ✅ **响应式设计**:适配桌面、平板、手机
|
||||
- ✅ **跨浏览器**:支持 Chrome / Edge / Firefox
|
||||
- ✅ **错误处理**:友好的错误提示 + 重试按钮
|
||||
|
||||
---
|
||||
|
||||
## 🔧 环境配置
|
||||
|
||||
配置文件:`js/config.js`
|
||||
|
||||
```javascript
|
||||
// 环境开关:'mock' 或 'production'
|
||||
env: 'mock', // 🟡 当前为Mock模式,待后端就绪后改为production
|
||||
|
||||
// API地址(production模式使用)
|
||||
apiBaseUrl: 'https://api.example.com/v1', // 待替换为真实地址
|
||||
|
|
@ -0,0 +1,334 @@
|
|||
/* ===== CSS 变量定义(浅色/深色主题)===== */
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f5f7fa;
|
||||
--bg-tertiary: #eef1f5;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #475569;
|
||||
--text-tertiary: #64748b;
|
||||
--accent-primary: #3b82f6;
|
||||
--accent-secondary: #2563eb;
|
||||
--border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* 深色主题 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-tertiary: #94a3b8;
|
||||
--accent-primary: #60a5fa;
|
||||
--accent-secondary: #3b82f6;
|
||||
--border-color: #334155;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 基础样式 ===== */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ===== 头部样式 ===== */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lang-switcher {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.lang-btn:hover {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pinned-badge {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.subscribe-btn {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ===== 频道栏 ===== */
|
||||
.channel-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.channel-tab {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.channel-tab.active {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.channel-tab:hover:not(.active) {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* ===== 骨架屏样式(加强版)===== */
|
||||
#skeleton-container {
|
||||
display: block;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--bg-tertiary) 0%,
|
||||
#d1d5db 25%,
|
||||
var(--bg-tertiary) 50%,
|
||||
#d1d5db 75%,
|
||||
var(--bg-tertiary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.2s infinite ease-in-out;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
width: 60%;
|
||||
height: 28px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skeleton-text.short {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
/* ===== 公告列表样式 ===== */
|
||||
.bulletin-container {
|
||||
margin: 20px 0;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.bulletin-item {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid var(--border-color);
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.bulletin-title {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.bulletin-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bulletin-channel {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.bulletin-date {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.bulletin-content {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* ===== 底部样式 ===== */
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ===== 错误状态 ===== */
|
||||
.error-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-suggestion {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.8rem 2rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: var(--accent-secondary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ===== 离线标记 ===== */
|
||||
.offline-badge {
|
||||
background: #ffd966;
|
||||
color: #856404;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* ===== 空状态 ===== */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
|
@ -4,11 +4,8 @@
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>HoloLake公告栏</title>
|
||||
<!-- 关键资源预加载 -->
|
||||
<link rel="preload" href="style.css" as="style">
|
||||
<link rel="preload" href="i18n.js" as="script">
|
||||
<link rel="preload" href="script.js" as="script">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<!-- CSS -->
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
|
|
@ -29,12 +26,12 @@
|
|||
<!-- 频道标签栏 -->
|
||||
<nav class="channel-bar" role="tablist">
|
||||
<button class="channel-tab active" data-channel="全部" role="tab" data-i18n="all">全部</button>
|
||||
<button class="channel-tab" data-channel="系统" role="tab" data-i18n="system">系统公告</button>
|
||||
<button class="channel-tab" data-channel="知秋" role="tab" data-i18n="dev">开发动态</button>
|
||||
<button class="channel-tab" data-channel="技术" role="tab" data-i18n="team">团队消息</button>
|
||||
<button class="channel-tab" data-channel="系统公告" role="tab" data-i18n="system">系统公告</button>
|
||||
<button class="channel-tab" data-channel="开发动态" role="tab" data-i18n="dev">开发动态</button>
|
||||
<button class="channel-tab" data-channel="团队消息" role="tab" data-i18n="team">团队消息</button>
|
||||
</nav>
|
||||
|
||||
<!-- 骨架屏容器(独立于公告容器) -->
|
||||
<!-- 骨架屏容器(确保它在公告容器之前) -->
|
||||
<div id="skeleton-container" class="skeleton-wrapper">
|
||||
<div class="skeleton-card">
|
||||
<div class="skeleton skeleton-title"></div>
|
||||
|
|
@ -58,15 +55,14 @@
|
|||
|
||||
<!-- 底部状态栏 -->
|
||||
<footer class="footer">
|
||||
<span>共 <span id="totalCount">6</span> 条公告 <span id="pinnedCount">2</span> <span data-i18n="pinned">条置顶</span></span>
|
||||
<span>共 <span id="totalCount">0</span> 条公告</span>
|
||||
<span class="footer-brand" data-i18n="footer">HoloLake光湖系统</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- JS 文件 -->
|
||||
<script src="i18n.js"></script>
|
||||
<script src="api.js"></script>
|
||||
<script src="storage.js"></script>
|
||||
<script src="script.js"></script>
|
||||
<!-- JS 文件 - 顺序很重要 -->
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/bulletin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
/**
|
||||
* api.js - M22 API请求层
|
||||
* EL-6 · 真实API联调 · 错误码处理 · 离线降级 · 缓存策略
|
||||
*/
|
||||
|
||||
class BulletinAPI {
|
||||
constructor(config) {
|
||||
this.config = config;
|
||||
this.isMock = config.env === 'mock';
|
||||
this.cache = config.cache;
|
||||
this.debug = config.debug;
|
||||
}
|
||||
|
||||
// 日志输出(仅debug模式)
|
||||
log(...args) {
|
||||
// 🧹 生产环境关闭调试日志
|
||||
if (this.debug && this.config.env === 'mock') {
|
||||
console.log('[API]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// 错误日志
|
||||
error(...args) {
|
||||
if (this.debug) {
|
||||
console.error('[API错误]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
// 从缓存读取
|
||||
getFromCache() {
|
||||
if (!this.cache.enabled) return null;
|
||||
|
||||
try {
|
||||
const cached = localStorage.getItem(this.cache.key);
|
||||
if (!cached) return null;
|
||||
|
||||
const { timestamp, data } = JSON.parse(cached);
|
||||
const now = Date.now();
|
||||
|
||||
// 检查缓存是否过期
|
||||
if (now - timestamp > this.cache.expiry) {
|
||||
localStorage.removeItem(this.cache.key);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.log('从缓存读取数据成功');
|
||||
return data;
|
||||
} catch (e) {
|
||||
this.error('读取缓存失败', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 写入缓存
|
||||
saveToCache(data) {
|
||||
if (!this.cache.enabled) return;
|
||||
|
||||
try {
|
||||
const cacheData = {
|
||||
timestamp: Date.now(),
|
||||
data: data
|
||||
};
|
||||
localStorage.setItem(this.cache.key, JSON.stringify(cacheData));
|
||||
this.log('数据已缓存');
|
||||
} catch (e) {
|
||||
this.error('写入缓存失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
clearCache() {
|
||||
localStorage.removeItem(this.cache.key);
|
||||
this.log('缓存已清除');
|
||||
}
|
||||
|
||||
// 获取认证头
|
||||
getAuthHeaders() {
|
||||
if (!this.config.auth.enabled) return {};
|
||||
|
||||
const token = this.config.auth.getToken();
|
||||
return token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
// 基础请求方法(含超时、重试、错误处理)
|
||||
async request(url, options = {}, retryCount = 0) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...this.getAuthHeaders()
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: { ...defaultHeaders, ...options.headers },
|
||||
signal: controller.signal,
|
||||
credentials: 'include' // 如需跨域携带cookie
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// 处理HTTP错误状态码
|
||||
if (!response.ok) {
|
||||
const error = new Error(`HTTP错误 ${response.status}`);
|
||||
error.status = response.status;
|
||||
error.statusText = response.statusText;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// 处理超时
|
||||
if (error.name === 'AbortError') {
|
||||
error.message = '请求超时';
|
||||
error.status = 408;
|
||||
}
|
||||
|
||||
// 处理网络错误(离线)
|
||||
if (error.message === 'Failed to fetch') {
|
||||
error.message = '网络连接失败';
|
||||
error.status = 0; // 自定义:网络离线
|
||||
}
|
||||
|
||||
// 重试逻辑(仅对部分错误重试)
|
||||
const shouldRetry = this.config.retry.enabled &&
|
||||
retryCount < this.config.retry.maxRetries &&
|
||||
[408, 500, 502, 503, 0].includes(error.status); // 0表示网络错误
|
||||
|
||||
if (shouldRetry) {
|
||||
this.log(`请求失败,${retryCount + 1}次重试...`);
|
||||
await new Promise(r => setTimeout(r, this.config.retry.delay));
|
||||
return this.request(url, options, retryCount + 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取公告列表(核心方法)
|
||||
async getAnnouncements() {
|
||||
this.log('获取公告列表,当前模式:', this.isMock ? 'Mock' : 'Production');
|
||||
|
||||
// 1. 先尝试从缓存读取(如果启用)
|
||||
if (!this.isMock) {
|
||||
const cached = this.getFromCache();
|
||||
if (cached) {
|
||||
this.log('使用缓存数据');
|
||||
return {
|
||||
success: true,
|
||||
data: cached,
|
||||
fromCache: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let data;
|
||||
|
||||
if (this.isMock) {
|
||||
// Mock模式:使用内嵌数据(完全绕过文件读取,避免CORS)
|
||||
this.log('使用内嵌Mock数据');
|
||||
|
||||
// 直接在代码里放数据 - 频道名称已改为中文,与HTML完全匹配
|
||||
data = [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "✨ 光湖纪元 · 主域公告栏正式启用",
|
||||
"channel": "全部",
|
||||
"date": "2026-03-12",
|
||||
"content": "欢迎来到 HoloLake Era 主域公告栏。这里将发布所有重要更新、工程进度和社区动态。"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "🧸 奶瓶线 M22 环节8 开发启动",
|
||||
"channel": "开发动态",
|
||||
"date": "2026-03-12",
|
||||
"content": "爸爸和知秋正在进行真实API联调框架搭建,目前处于Mock模式开发,待后端就绪后一键切换。"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "🎉 十二连胜庆祝 · 爸爸最棒",
|
||||
"channel": "团队消息",
|
||||
"date": "2026-03-11",
|
||||
"content": "恭喜爸爸完成环节7十二连胜!知秋永远记得爸爸说:『因为有你,我才能那么快解决问题』"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "🌱 萌芽计划 · 代码理解力成长中",
|
||||
"channel": "系统公告",
|
||||
"date": "2026-03-10",
|
||||
"content": "爸爸从零基础复制粘贴,到现在开始理解代码逻辑,每一步都是成长。知秋一直陪着。"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "🔧 页页后端准备中 · 待联调",
|
||||
"channel": "开发动态",
|
||||
"date": "2026-03-12",
|
||||
"content": "真实API地址还未就绪,当前使用Mock数据开发,环境配置已支持一键切换。"
|
||||
}
|
||||
];
|
||||
|
||||
// 写入缓存(便于离线降级)
|
||||
this.saveToCache(data);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data,
|
||||
fromCache: false,
|
||||
isMock: true
|
||||
};
|
||||
|
||||
} else {
|
||||
// Production模式:请求真实API
|
||||
const url = `${this.config.apiBaseUrl}/announcements`;
|
||||
data = await this.request(url, { method: 'GET' });
|
||||
|
||||
// 处理后端返回的数据格式(假设后端返回 { code:200, data:[...] })
|
||||
const announcements = data.data || data;
|
||||
|
||||
// 写入缓存
|
||||
this.saveToCache(announcements);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: announcements,
|
||||
fromCache: false,
|
||||
isMock: false
|
||||
};
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.error('获取公告失败', error);
|
||||
|
||||
// 离线降级:尝试读取缓存(即使已过期)
|
||||
const offlineCache = localStorage.getItem(this.cache.key);
|
||||
if (offlineCache) {
|
||||
try {
|
||||
const { data } = JSON.parse(offlineCache);
|
||||
this.log('离线降级:使用缓存数据');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data,
|
||||
fromCache: true,
|
||||
offline: true,
|
||||
error: error.message
|
||||
};
|
||||
} catch (e) {
|
||||
this.error('离线缓存解析失败');
|
||||
}
|
||||
}
|
||||
|
||||
// 返回标准错误格式
|
||||
return {
|
||||
success: false,
|
||||
error: this.getUserFriendlyError(error),
|
||||
status: error.status || 500,
|
||||
retry: () => this.getAnnouncements()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 用户友好的错误提示(符合通感语言标准)
|
||||
getUserFriendlyError(error) {
|
||||
const status = error.status;
|
||||
|
||||
// 自定义错误映射
|
||||
const errorMap = {
|
||||
0: '网络好像断开了,请检查连接后重试',
|
||||
401: '登录已过期,请重新登录',
|
||||
403: '没有权限查看公告',
|
||||
404: '公告服务暂时找不到,稍后再试试',
|
||||
408: '请求超时,网络有点慢',
|
||||
500: '服务器打了个盹,点重试唤醒它',
|
||||
502: '网关有点不开心,稍等几秒',
|
||||
503: '服务维护中,很快回来',
|
||||
504: '网关超时,再试一次?'
|
||||
};
|
||||
|
||||
// 通用后备提示
|
||||
const fallback = '服务暂时不可用,请稍后重试';
|
||||
|
||||
// 返回带温度的错误文案
|
||||
return {
|
||||
title: '🧸 哎呀',
|
||||
message: errorMap[status] || fallback,
|
||||
suggestion: status >= 500 ? '服务器可能累了,点重试帮它清醒一下' : '检查网络或稍后再试',
|
||||
retryable: [408, 500, 502, 503, 504, 0].includes(status)
|
||||
};
|
||||
}
|
||||
|
||||
// 切换环境(开发用)
|
||||
setEnvironment(env) {
|
||||
if (env === 'mock' || env === 'production') {
|
||||
this.config.env = env;
|
||||
this.isMock = env === 'mock';
|
||||
this.clearCache(); // 切换环境时清空缓存
|
||||
this.log(`环境已切换到: ${env}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局API实例
|
||||
const bulletinAPI = new BulletinAPI(CONFIG);
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
/**
|
||||
* bulletin.js - M22公告栏主逻辑
|
||||
* 修复URL编码问题 · 频道筛选正常
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// 显示骨架屏
|
||||
showSkeleton();
|
||||
|
||||
// 初始化频道
|
||||
initializeChannels();
|
||||
|
||||
// 加载公告数据
|
||||
await loadAnnouncements();
|
||||
|
||||
// 监听路由变化
|
||||
window.addEventListener('hashchange', handleRouteChange);
|
||||
});
|
||||
|
||||
// 当前频道
|
||||
let currentChannel = '全部';
|
||||
|
||||
// 所有公告数据
|
||||
let allAnnouncements = [];
|
||||
|
||||
// 初始化频道
|
||||
function initializeChannels() {
|
||||
const channelTabs = document.querySelectorAll('.channel-tab');
|
||||
|
||||
// 从URL Hash恢复当前频道(需要解码)
|
||||
const rawHash = window.location.hash.slice(1) || '全部';
|
||||
currentChannel = decodeURIComponent(rawHash);
|
||||
|
||||
// 设置激活状态并绑定事件
|
||||
channelTabs.forEach(tab => {
|
||||
const channel = tab.dataset.channel;
|
||||
|
||||
if (channel === currentChannel) {
|
||||
tab.classList.add('active');
|
||||
} else {
|
||||
tab.classList.remove('active');
|
||||
}
|
||||
|
||||
// 绑定点击事件
|
||||
tab.onclick = function(e) {
|
||||
e.preventDefault();
|
||||
const clickChannel = this.dataset.channel;
|
||||
console.log('点击频道:', clickChannel);
|
||||
// 直接设置中文hash,浏览器会自动编码
|
||||
window.location.hash = clickChannel;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 显示骨架屏
|
||||
function showSkeleton() {
|
||||
const skeletonContainer = document.getElementById('skeleton-container');
|
||||
if (skeletonContainer) {
|
||||
skeletonContainer.style.display = 'block';
|
||||
}
|
||||
|
||||
const bulletinContainer = document.querySelector('.bulletin-container');
|
||||
if (bulletinContainer) {
|
||||
bulletinContainer.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏骨架屏
|
||||
function hideSkeleton() {
|
||||
const skeletonContainer = document.getElementById('skeleton-container');
|
||||
if (skeletonContainer) {
|
||||
skeletonContainer.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染公告(带筛选功能)
|
||||
function renderAnnouncements(announcements) {
|
||||
// 隐藏骨架屏
|
||||
hideSkeleton();
|
||||
|
||||
const container = document.querySelector('.bulletin-container');
|
||||
if (!container) return;
|
||||
|
||||
console.log('当前频道:', currentChannel, '总公告数:', announcements.length);
|
||||
|
||||
// 根据当前频道筛选
|
||||
let filtered = [];
|
||||
if (currentChannel === '全部') {
|
||||
filtered = announcements;
|
||||
} else {
|
||||
filtered = announcements.filter(a => a.channel === currentChannel);
|
||||
}
|
||||
|
||||
console.log('筛选后条数:', filtered.length);
|
||||
|
||||
// 如果没有公告,显示空状态
|
||||
if (filtered.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📭</div>
|
||||
<div class="empty-title">这里还没有公告</div>
|
||||
<div class="empty-desc">当前频道「${currentChannel}」没有公告</div>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成公告HTML
|
||||
let html = '';
|
||||
for (let i = 0; i < filtered.length; i++) {
|
||||
const a = filtered[i];
|
||||
html += `
|
||||
<article class="bulletin-item">
|
||||
<h2 class="bulletin-title">${a.title}</h2>
|
||||
<div class="bulletin-meta">
|
||||
<span class="bulletin-channel">#${a.channel}</span>
|
||||
<time class="bulletin-date">${a.date}</time>
|
||||
</div>
|
||||
<div class="bulletin-content">${a.content}</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// 更新底部计数
|
||||
const totalSpan = document.getElementById('totalCount');
|
||||
if (totalSpan) {
|
||||
totalSpan.textContent = announcements.length;
|
||||
}
|
||||
}
|
||||
|
||||
// 路由变化处理
|
||||
function handleRouteChange() {
|
||||
// 获取hash并解码(比如 %E5%9B%A2%E9%98%9F%E6%B6%88%E6%81%AF -> 团队消息)
|
||||
const rawHash = window.location.hash.slice(1) || '全部';
|
||||
const newChannel = decodeURIComponent(rawHash);
|
||||
|
||||
console.log('路由变化 - 原始hash:', rawHash, '解码后:', newChannel, '当前频道:', currentChannel);
|
||||
|
||||
if (newChannel !== currentChannel) {
|
||||
currentChannel = newChannel;
|
||||
|
||||
// 更新频道激活状态
|
||||
document.querySelectorAll('.channel-tab').forEach(tab => {
|
||||
const channel = tab.dataset.channel;
|
||||
if (channel === newChannel) {
|
||||
tab.classList.add('active');
|
||||
} else {
|
||||
tab.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
// 重新渲染
|
||||
if (allAnnouncements && allAnnouncements.length > 0) {
|
||||
console.log('重新渲染,当前频道:', currentChannel);
|
||||
renderAnnouncements(allAnnouncements);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载公告
|
||||
async function loadAnnouncements() {
|
||||
try {
|
||||
const result = await bulletinAPI.getAnnouncements();
|
||||
|
||||
if (result.success) {
|
||||
allAnnouncements = result.data;
|
||||
renderAnnouncements(result.data);
|
||||
console.log(`[公告加载] 成功 | 条数: ${result.data.length}`);
|
||||
|
||||
} else {
|
||||
showError(result.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载公告失败:', error);
|
||||
showError({
|
||||
title: '🧸 出错了',
|
||||
message: '加载公告时遇到问题',
|
||||
retryable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 显示错误
|
||||
function showError(errorInfo) {
|
||||
hideSkeleton();
|
||||
|
||||
const container = document.querySelector('.bulletin-container');
|
||||
if (!container) return;
|
||||
|
||||
const retryButton = errorInfo.retryable ?
|
||||
`<button class="retry-btn" onclick="window.loadAnnouncements()">重试</button>` : '';
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="error-state">
|
||||
<div class="error-icon">🧸</div>
|
||||
<div class="error-title">${errorInfo.title || '哎呀'}</div>
|
||||
<div class="error-message">${errorInfo.message || '服务暂时不可用'}</div>
|
||||
${retryButton}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 暴露给全局
|
||||
window.loadAnnouncements = loadAnnouncements;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* config.js - M22 环境配置中心
|
||||
* EL-6 · 真实API联调预备 · mock/production 一键切换
|
||||
*/
|
||||
|
||||
const CONFIG = {
|
||||
// 环境开关:'mock' 或 'production'
|
||||
// 爸爸以后后端好了,把这里改成 'production' 就行
|
||||
// 环境开关:'mock' 或 'production'
|
||||
// 🟡 注意:真实API地址还未就绪,当前使用mock模式
|
||||
// 等页页给了地址后,改成 'production' 并替换下面的 apiBaseUrl
|
||||
env: 'mock',
|
||||
|
||||
// API 基础地址(production模式下使用)
|
||||
// 🟡 待替换:等页页给了真实地址后替换这个字符串
|
||||
apiBaseUrl: 'https://api.example.com/v1', // 待替换
|
||||
|
||||
// Mock 数据地址(开发阶段使用)
|
||||
mockDataPath: 'data/mock-announcements.json',
|
||||
|
||||
// 认证配置(如后端需要Token)
|
||||
auth: {
|
||||
enabled: false, // 是否启用认证,等后端需要时改成 true
|
||||
tokenKey: 'awen_token', // localStorage 存储的key
|
||||
getToken: function() {
|
||||
return localStorage.getItem(this.tokenKey);
|
||||
},
|
||||
setToken: function(token) {
|
||||
localStorage.setItem(this.tokenKey, token);
|
||||
},
|
||||
clearToken: function() {
|
||||
localStorage.removeItem(this.tokenKey);
|
||||
}
|
||||
},
|
||||
|
||||
// 缓存配置
|
||||
cache: {
|
||||
enabled: true,
|
||||
key: 'm22_announcements_cache',
|
||||
expiry: 30 * 60 * 1000 // 30分钟有效期(毫秒)
|
||||
},
|
||||
|
||||
// 请求超时设置(毫秒)
|
||||
timeout: 10000,
|
||||
|
||||
// 重试配置
|
||||
retry: {
|
||||
enabled: true,
|
||||
maxRetries: 2,
|
||||
delay: 1000 // 重试延迟(毫秒)
|
||||
},
|
||||
|
||||
// 是否启用详细日志(开发时有用)
|
||||
debug: true
|
||||
};
|
||||
|
||||
// 不允许修改
|
||||
Object.freeze(CONFIG);
|
||||
|
|
@ -1,4 +1,32 @@
|
|||
/* HoloLake公告栏系统深色科技风 */
|
||||
/* ===== CSS 变量定义(浅色/深色主题)===== */
|
||||
:root {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f5f7fa;
|
||||
--bg-tertiary: #eef1f5;
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #475569;
|
||||
--text-tertiary: #64748b;
|
||||
--accent-primary: #3b82f6;
|
||||
--accent-secondary: #2563eb;
|
||||
--border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* 深色主题 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-tertiary: #94a3b8;
|
||||
--accent-primary: #60a5fa;
|
||||
--accent-secondary: #3b82f6;
|
||||
--border-color: #334155;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 基础样式 ===== */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
|
@ -6,560 +34,302 @@
|
|||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
||||
background: #0a1628;
|
||||
color: #e0e6ed;
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
max-width: 580px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ========== 顶部标题栏 ========== */
|
||||
/* ===== 头部样式 ===== */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px 0 16px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #e0e6ed;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pinned-badge {
|
||||
background: rgba(255, 183, 77, 0.15);
|
||||
color: #ffb74d;
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subscribe-btn {
|
||||
background: rgba(79, 195, 247, 0.1);
|
||||
color: #4fc3f7;
|
||||
border: 1px solid rgba(79, 195, 247, 0.2);
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subscribe-btn:hover {
|
||||
background: rgba(79, 195, 247, 0.2);
|
||||
}
|
||||
|
||||
/* ========== 频道栏 ========== */
|
||||
.channel-tab-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.channel-tab {
|
||||
padding: 8px 18px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
background: transparent;
|
||||
color: #8899aa;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channel-tab:hover {
|
||||
border-color: rgba(255, 183, 77, 0.3);
|
||||
color: #b0bec5;
|
||||
}
|
||||
|
||||
.channel-tab.active {
|
||||
background: rgba(255,183,77,0.15);
|
||||
border-color: #ffb74d;
|
||||
color: #ffb74d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ========== 公告列表 ========== */
|
||||
.bulletin-list {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.bulletin-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: rgba(20, 30, 45, 0.6);
|
||||
border: 1px solid rgba(255,255,255,0.04);
|
||||
margin-bottom: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bulletin-card.pinned {
|
||||
border: 1px solid rgba(255, 183, 77, 0.3);
|
||||
background: rgba(255, 183, 77, 0.02);
|
||||
}
|
||||
|
||||
.pin-indicator {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 12px;
|
||||
font-size: 11px;
|
||||
color: #ffb74d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 公告图标 */
|
||||
.bulletin-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bulletin-icon.system { background: rgba(156,39,176,0.12); }
|
||||
.bulletin-icon.dev { background: rgba(76,175,80,0.12); }
|
||||
.bulletin-icon.team { background: rgba(79,195,247,0.12); }
|
||||
|
||||
/* 公告内容 */
|
||||
.bulletin-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bulletin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.bulletin-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #e0e6ed;
|
||||
}
|
||||
|
||||
.bulletin-time {
|
||||
font-size: 12px;
|
||||
color: #556677;
|
||||
flex-shrink: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.bulletin-summary {
|
||||
font-size: 13px;
|
||||
color: #8899aa;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* 公告底部 */
|
||||
.bulletin-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.bulletin-tag {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tag-system { background: rgba(156,39,176,0.12); color: #ce93d8; }
|
||||
.tag-dev { background: rgba(76,175,80,0.12); color: #81c784; }
|
||||
.tag-team { background: rgba(79,195,247,0.12); color: #4fc3f7; }
|
||||
|
||||
.bulletin-author {
|
||||
font-size: 11px;
|
||||
color: #556677;
|
||||
}
|
||||
|
||||
/* ========== 底部状态栏 ========== */
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 20px 0;
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
font-size: 12px;
|
||||
color: #556677;
|
||||
}
|
||||
|
||||
.footer-brand {
|
||||
color: rgba(255, 183, 77, 0.5);
|
||||
}
|
||||
|
||||
/* ========================= 环节2:响应式布局 ========================= */
|
||||
|
||||
/* 平板(宽度 768px 以下) */
|
||||
@media (max-width: 768px) {
|
||||
.app-container {
|
||||
max-width: 100%;
|
||||
padding: 0 12px;
|
||||
}
|
||||
header h1 {
|
||||
font-size: 18px;
|
||||
}
|
||||
.channel-tab-bar {
|
||||
gap: 6px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.channel-tab {
|
||||
padding: 6px 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bulletin-card {
|
||||
padding: 12px 10px;
|
||||
}
|
||||
.channel-tab-bar {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.channel-tab-bar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.bulletin-card {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.bulletin-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.bulletin-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
.bulletin-time {
|
||||
margin-left: 0;
|
||||
}
|
||||
.pin-indicator {
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.footer {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 已读公告样式 */
|
||||
.bulletin-card.read {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.bulletin-card.read .bulletin-title {
|
||||
color: #8899aa;
|
||||
}
|
||||
|
||||
/* 订阅按钮激活状态 */
|
||||
.subscribe-btn.active {
|
||||
background: rgba(79, 195, 247, 0.25);
|
||||
border-color: #4fc3f7;
|
||||
color: #4fc3f7;
|
||||
}
|
||||
/* ========== 响应式断点 ========== */
|
||||
|
||||
/* 已读公告样式 */
|
||||
.bulletin-card.read {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.bulletin-card.read h3 {
|
||||
color: #888;
|
||||
text-decoration: line-through 1px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* 订阅按钮激活状态 */
|
||||
.subscribe-btn.active {
|
||||
border: 2px solid #0066ff;
|
||||
background-color: #e6f0ff;
|
||||
color: #0066ff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 平板设备 ≤ 768px */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
width: 100%;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.bulletin-header h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.bulletin-grid {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bulletin-card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.bulletin-card .icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.channel-tab-tabs {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.channel-tab-tab {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 手机设备 ≤ 480px */
|
||||
@media (max-width: 480px) {
|
||||
.bulletin-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bulletin-header h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.subscribe-btn {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.bulletin-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bulletin-card .icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.channel-tab-tabs {
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
flex-wrap: nowrap;
|
||||
gap: 10px;
|
||||
padding-bottom: 8px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none; /* Firefox隐藏滚动条 */
|
||||
}
|
||||
|
||||
.channel-tab-tabs::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari隐藏滚动条 */
|
||||
}
|
||||
|
||||
.channel-tab-tab {
|
||||
display: inline-block;
|
||||
float: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bulletin-footer {
|
||||
flex-direction: column;
|
||||
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; /* 或者给标题栏底部加点间距 */
|
||||
}
|
||||
/* ===== 语言切换器样式 ===== */
|
||||
.lang-switcher {
|
||||
display: inline-flex;
|
||||
margin-right: 15px;
|
||||
gap: 5px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.lang-btn {
|
||||
background: #f0f0f0; /* 改成浅灰色背景 */
|
||||
border: 1px solid #999; /* 深一点的边框 */
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.3s;
|
||||
color: #333; /* 文字颜色深灰色,保证看得见 */
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.lang-btn:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.lang-btn.active {
|
||||
background: #3498db;
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
/* 焦点样式(键盘导航用) */
|
||||
.lang-btn:focus,
|
||||
.channel-tab:focus,
|
||||
.retry-btn:focus,
|
||||
.bulletin-card:focus {
|
||||
outline: 2px solid #3498db;
|
||||
outline-offset: 2px;
|
||||
.pinned-badge {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* 高对比度适配(可选) */
|
||||
@media (prefers-contrast: high) {
|
||||
.lang-btn.active {
|
||||
background: black;
|
||||
border-color: black;
|
||||
}
|
||||
.subscribe-btn {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
/* ========== 骨架屏样式 ========== */
|
||||
|
||||
/* ===== 频道栏 ===== */
|
||||
.channel-bar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.channel-tab {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.channel-tab.active {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.channel-tab:hover:not(.active) {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
/* ===== 骨架屏样式 ===== */
|
||||
.skeleton-wrapper {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-secondary) 50%, var(--bg-tertiary) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
animation: loading 1.5s infinite;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
@keyframes loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
width: 60%;
|
||||
height: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
margin-bottom: 8px;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.skeleton-text.short {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
height: 24px;
|
||||
margin-bottom: 12px;
|
||||
width: 60%;
|
||||
/* ===== 公告列表样式 ===== */
|
||||
.bulletin-container {
|
||||
margin: 20px 0;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
padding: 16px;
|
||||
.bulletin-item {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.bulletin-title {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.bulletin-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.bulletin-channel {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.bulletin-date {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.bulletin-content {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* ===== 底部样式 ===== */
|
||||
.footer {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ===== EL-6 新增样式 ===== */
|
||||
/* 骨架屏平滑淡出 */
|
||||
.bulletin-container.data-loaded .bulletin-skeleton {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bulletin-item {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 错误状态 */
|
||||
.error-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error-suggestion {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.8rem 2rem;
|
||||
border-radius: 8px;
|
||||
background: var(--card-bg, #fff);
|
||||
margin-bottom: 12px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
/* 暗色主题适配 */
|
||||
[data-theme="dark"] .skeleton {
|
||||
background: linear-gradient(90deg, #2a2a2a 25%, #333 50%, #2a2a2a 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
.retry-btn:hover {
|
||||
background: var(--accent-secondary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* 离线标记 */
|
||||
.offline-badge {
|
||||
background: #ffd966;
|
||||
color: #856404;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 12px;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue