310 lines
11 KiB
JavaScript
Raw Permalink Normal View History

/**
* 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);