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 charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>HoloLake公告栏</title>
|
<title>HoloLake公告栏</title>
|
||||||
<!-- 关键资源预加载 -->
|
<!-- CSS -->
|
||||||
<link rel="preload" href="style.css" as="style">
|
<link rel="stylesheet" href="css/style.css">
|
||||||
<link rel="preload" href="i18n.js" as="script">
|
|
||||||
<link rel="preload" href="script.js" as="script">
|
|
||||||
<link rel="stylesheet" href="style.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-container">
|
<div class="app-container">
|
||||||
|
|
@ -29,12 +26,12 @@
|
||||||
<!-- 频道标签栏 -->
|
<!-- 频道标签栏 -->
|
||||||
<nav class="channel-bar" role="tablist">
|
<nav class="channel-bar" role="tablist">
|
||||||
<button class="channel-tab active" data-channel="全部" role="tab" data-i18n="all">全部</button>
|
<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="system">系统公告</button>
|
||||||
<button class="channel-tab" data-channel="知秋" role="tab" data-i18n="dev">开发动态</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="team">团队消息</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- 骨架屏容器(独立于公告容器) -->
|
<!-- 骨架屏容器(确保它在公告容器之前) -->
|
||||||
<div id="skeleton-container" class="skeleton-wrapper">
|
<div id="skeleton-container" class="skeleton-wrapper">
|
||||||
<div class="skeleton-card">
|
<div class="skeleton-card">
|
||||||
<div class="skeleton skeleton-title"></div>
|
<div class="skeleton skeleton-title"></div>
|
||||||
|
|
@ -58,15 +55,14 @@
|
||||||
|
|
||||||
<!-- 底部状态栏 -->
|
<!-- 底部状态栏 -->
|
||||||
<footer class="footer">
|
<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>
|
<span class="footer-brand" data-i18n="footer">HoloLake光湖系统</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- JS 文件 -->
|
<!-- JS 文件 - 顺序很重要 -->
|
||||||
<script src="i18n.js"></script>
|
<script src="js/config.js"></script>
|
||||||
<script src="api.js"></script>
|
<script src="js/api.js"></script>
|
||||||
<script src="storage.js"></script>
|
<script src="js/bulletin.js"></script>
|
||||||
<script src="script.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
@ -6,560 +34,302 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: 'Segoe UI', 'Microsoft YaHei', sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
background: #0a1628;
|
background: var(--bg-primary);
|
||||||
color: #e0e6ed;
|
color: var(--text-primary);
|
||||||
min-height: 100vh;
|
line-height: 1.5;
|
||||||
|
transition: background-color 0.3s, color 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-container {
|
.app-container {
|
||||||
max-width: 580px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 16px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== 顶部标题栏 ========== */
|
/* ===== 头部样式 ===== */
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 24px 0 16px;
|
padding: 20px 0;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
font-size: 22px;
|
font-size: 1.8rem;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: #e0e6ed;
|
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
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;
|
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 {
|
.lang-switcher {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
margin-right: 15px;
|
gap: 4px;
|
||||||
gap: 5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-btn {
|
.lang-btn {
|
||||||
background: #f0f0f0; /* 改成浅灰色背景 */
|
padding: 6px 12px;
|
||||||
border: 1px solid #999; /* 深一点的边框 */
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 4px;
|
background: var(--bg-secondary);
|
||||||
padding: 4px 8px;
|
color: var(--text-secondary);
|
||||||
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 0.9rem;
|
||||||
transition: all 0.3s;
|
|
||||||
color: #333; /* 文字颜色深灰色,保证看得见 */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.lang-btn:hover {
|
.lang-btn:hover {
|
||||||
background: #f0f0f0;
|
background: var(--accent-primary);
|
||||||
}
|
|
||||||
|
|
||||||
.lang-btn.active {
|
|
||||||
background: #3498db;
|
|
||||||
color: white;
|
color: white;
|
||||||
border-color: #3498db;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 焦点样式(键盘导航用) */
|
.pinned-badge {
|
||||||
.lang-btn:focus,
|
background: #fef3c7;
|
||||||
.channel-tab:focus,
|
color: #92400e;
|
||||||
.retry-btn:focus,
|
padding: 4px 10px;
|
||||||
.bulletin-card:focus {
|
border-radius: 20px;
|
||||||
outline: 2px solid #3498db;
|
font-size: 0.85rem;
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 高对比度适配(可选) */
|
.subscribe-btn {
|
||||||
@media (prefers-contrast: high) {
|
background: var(--accent-primary);
|
||||||
.lang-btn.active {
|
color: white;
|
||||||
background: black;
|
border: none;
|
||||||
border-color: black;
|
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 {
|
.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%;
|
background-size: 200% 100%;
|
||||||
animation: skeleton-loading 1.5s infinite;
|
animation: loading 1.5s infinite;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes skeleton-loading {
|
@keyframes loading {
|
||||||
0% { background-position: 200% 0; }
|
0% { background-position: 200% 0; }
|
||||||
100% { background-position: -200% 0; }
|
100% { background-position: -200% 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skeleton-title {
|
||||||
|
width: 60%;
|
||||||
|
height: 24px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.skeleton-text {
|
.skeleton-text {
|
||||||
|
width: 100%;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
width: 80%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-text.short {
|
.skeleton-text.short {
|
||||||
width: 40%;
|
width: 40%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-title {
|
/* ===== 公告列表样式 ===== */
|
||||||
height: 24px;
|
.bulletin-container {
|
||||||
margin-bottom: 12px;
|
margin: 20px 0;
|
||||||
width: 60%;
|
min-height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-card {
|
.bulletin-item {
|
||||||
padding: 16px;
|
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;
|
border-radius: 8px;
|
||||||
background: var(--card-bg, #fff);
|
font-size: 1rem;
|
||||||
margin-bottom: 12px;
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 暗色主题适配 */
|
.retry-btn:hover {
|
||||||
[data-theme="dark"] .skeleton {
|
background: var(--accent-secondary);
|
||||||
background: linear-gradient(90deg, #2a2a2a 25%, #333 50%, #2a2a2a 75%);
|
transform: translateY(-2px);
|
||||||
background-size: 200% 100%;
|
}
|
||||||
animation: skeleton-loading 1.5s infinite;
|
|
||||||
|
/* 离线标记 */
|
||||||
|
.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