首页/文章/前端监控

从零开始搭建一个前端日志框架

2023-10-20
326211 分钟
...

前言

原谅我起了一个这么大的标题,当了一回标题党😛

之前一直做B端系统,针对线上错误引入了 sentry 来收集错误日志,一开始还能盯着告警邮件,后面告警越来越多就选择性忽视了🤪,大伙可千万别学我,我忽视的可都是不影响业务和流程的报错(嗨,还在找借口🤣),让我想起来每次说我爸骑车闯红灯,他都会说是在不影响安全的情况下闯的,有那么点异曲同工之妙了。

入职新公司后顺理成章也接入了 sentry,满心期待着能发挥他的作用,果然不多久产品就反馈说线上出问题了,说是屏幕上不显示立定跳远成绩,对了新公司是做AI智慧体育的,出现的问题是在立定跳远项目上。出现问题后第一时间肯定是自测一遍,咦没问题啊,再测还是没问题,那没辙只能从代码上看看。因为学生跳完后不知道什么时候出成绩,所以和后端约定了通过 WebSocket 发送成绩消息给前端,那既然这样前端不显示成绩肯定是后端没发送对应的 WebSocket 消息了,可是证据呢,对啊从哪找证据呢!

接下来的几个月又出现了各种奇葩的问题,无一例外这些在 sentry 里都看不到错误日志,因为本身代码没有报错,只是流程上出了问题。忍无可忍,无需再忍,看来开发一套前端自己的日志系统迫在眉睫了。

为什么要大费周章自己开发呢,网上前端日志系统不是一抓一大把吗,很遗憾都是处理异常的,而我们需要记录所有 WebSocket 消息,所有前端 log,所有 http 请求,还有一些特殊的要求,综上只能自己来了。

一个合格的日志系统必须有的几个功能。

  1. console 劫持
  2. 前端离线存储
  3. http 请求劫持
  4. 错误日志劫持
  5. 上传日志
  6. 服务器查看日志

console 劫持

因为产品的特殊性,需要记录程序运行过程中所有日志打印,方便线上出问题排查,所以第一步就要劫持所有 console 打印,并在最前面加上时间戳方便和后端配合定位问题,最简单的方案就是重写 console.log

rewritetConsole(  ) {   
  const consoleLog = console.log 
  console.log = (...args: any[]) => {  
    const now = dayjs().format('YYYY-MM-DD HH:mm:ss:SSS')  
    consoleLog.apply(console, [`${now} [info]`, ...args])  
  } 
}

image.png

image.png

image.png

console.errorconsole.warn 怎么办,是不是每个都要劫持原生方法,况且 console 下面有25个方法,挨个写不得累死,那换个姿势重写,遍历 console 下所有方法。

rewritetConsole(  ) { 
  Object.keys(console).forEach(key => { 
    const rewrite = console[key]  
    console[key] = (...args: any[]) => {   
      const now = dayjs().format('YYYY-MM-DD HH:mm:ss:SSS')    
      rewrite.apply(console, [`${now} [${key}]`, ...args])  
    }  
  })
}

但是我不建议这么操作,因为 console.timeconsole.count 等方法接收的传参不能多个,会导致功能不生效,最好的办法是提前定义好需要劫持的方法,这样自由度最高。

rewritetConsole(  ) { 
  const rewriteFunctions = ['log', 'warn', 'error'] 
  rewriteFunctions.forEach((key) => {  
    const rewrite = console[key] 
    console[key] = (...args: any[]) => {   
      const now = dayjs().format('YYYY-MM-DD HH:mm:ss:SSS')       
      rewrite.apply(console, [`${now} [${key}]`, ...args])   
    }   
  })
}

前端离线存储

虽然已经劫持了 console 日志,但是日志并没有上传到服务器,考虑到性能和网络掉线等情况,不可能每打印一条日志就上传一条,所以需要先把日志临时存储起来。

前端常见的存储方案有 localStoragecookieindexDB,不过 localStoragecookie 存储上限都太小了,有时候打印一个base64图片可能就超过4Mb了,所以最终敲定方案为 indexDB,内容是存储在硬盘的,每个浏览器策略还不一样,火狐 FireFox 全局限制为可用磁盘空间的 50%,chrome 没有找到文档说明但也不用顾虑超过存储上限的。至于 兼容性 现代浏览器就更不用担心了。

indexDB 原生操作起来还是有点繁琐的,所以找了一个库 dexie 来协助操作,而且 dexie 的用法和之前写 Sequelize 有点像,上手起来也快。

首先创建一个数据库 logger,新建一个 logs 表记录所有日志内容,策略是每隔几秒上传一次最近的日志,不过有个问题如果网络异常或者服务器异常导致日志上传失败,那么下一次上传时还要继续上传未上传的日志,所以得有一个字段 hasUploadMessageId 记录已经上传过的日志 id,上传成功后修改该字段为最后一条日志 id。另外还需要一个字段 latestMessageId 记录最新日志 id,不然每次从 logs 表计算性能损耗太大了,后续上传日志到服务器时取 hasUploadMessageIdlatestMessageId 之间的日志。

import Dexie from 'dexie' 
import type { Table } from 'dexie' 
class IndexDB extends Dexie {  
  /** 日志内容表 */   
  logs!: Table<{   
    /** 自增id */    
    id?: number   
    /** 日志内容 */ 
    message: string   
  }>   
  /** 标志字段表 */  
  flag!: Table<{  
    /** 自增id */  
    id?: number    
    /** 最新日志id */  
    latestMessageId: number 
    /** 已经上传成功的日志id */   
    hasUploadMessageId: number 
  }>       
  /** 初始化 */  
  constructor(  ) {    
    super('logger')    
    this.version(1).stores({  
      logs: '++id, message',   
      flag: '++id, latestMessageId, hasUploadMessageId'   
    })   
  } 
}

接下来写一个通用方法把日志写入到 indexDB 中,这样就完成了日志离线存储。

/**  * 日志写入到 indexDB 中  */ 
async insertLog(...args: any[]) { 
  const messages: string[] = [] 
  args.forEach((arg) => {    
    if (typeof arg === 'string') {  
      messages.push(arg)   
    } else {      
      // 非字符串需要通过 JSON.stringify 转义成字符串  
      try {     
        messages.push(JSON.stringify(arg))  
      } catch {}    
    }  
  })   
  // 拼接后的日志内容  
  const message = messages.join(' ').trim()  
  // 日志为空或者超过最大长度不记录  
  if (message.length === 0 || message.length > this.config.maxLength) { 
    return   
  }   
  try {
    // 插入日志 
    const id = await db.logs.add({      
      message  
    })    
    // 更新  
    db.flag.update(1, {  
      latestMessageId: id     
    }).then((updated) => {  
      if (updated) {    
        this.latestMessageId = id as number     
      }      
    }) 
  } catch (error) {}
}

测试下效果,可以看到 indexDB 中已经有了日志

因为还没有上传到服务器,所以 hasUploadMessageId 为 0

http 请求劫持

在排查线上问题的时候由于不知道出错那一刻 http 请求返回内容而没头绪,所以需要劫持 http 请求,目前 http 请求有两种 xhrfetch,分两种情况劫持。

xhr 劫持 的原理就是重写 XMLHttpRequest.prototype 原型链上的 open 方法,获取请求方法和请求地址,然后监听 readystatechange 事件获取响应内容,最后把这些一起写入到 indexDB 中。

/**  * 劫持 xhr 请求  */ 
interceptXHR(  ) {  
  const insertLog = this.insertLog.bind(this)
  // 重写 xhr.open 方法,open 方法接收两个参数,第一个为请求方法,第二个为请求地址  
  const xhrOpen = XMLHttpRequest.prototype.open  
  XMLHttpRequest.prototype.open = function (...args: any) {  
    const method = args[0]    
    const url = args[1]   
    const now = dayjs().format('YYYY-MM-DD HH:mm:ss:SSS')    
    // 调用原生方法   
    xhrOpen.apply(this, args)   
    // 监听 readystatechange 事件   
    this.addEventListener('readystatechange', () => {   
      if (this.readyState === XMLHttpRequest.prototype.DONE) {    
        // 20 开头的状态码都是成功的,比如201、204      
        if (String(this.status).startsWith('20')) {    
          // 正常 http 请求不需要打印到控制台,在 network 里可以看到完整信息,  
          // 只需要记录到 indexDB 并上传服务器即可,所以不能使用 console.log     
          insertLog(`${now} [http ok] method: ${method}, url: ${url}, status: ${this.status}, response: ${this.response}`   )
        } else {    
          // http 请求异常  
          insertLog(`${now} [http error] method: ${method}, url: ${url}, status: ${this.status}, response: ${this.response}`) 
        }     
      }    
    }) 
  } 
}

fetch 劫持 原理和 xhr 一样,也是重写原生方法,不过 fetch 能获取到请求参数和 headers 等。xhr 劫持因为请求参数是在 xhr.send 时携带的,在 xhr.open 中获取不到,所以在监听回调事件的时候无法获取请求参数,除非不用原生 xhr 请求自己封装一个。

/**  * 劫持 fetch 请求  */ 
interceptFetch(  ) {  
  const insertLog = this.insertLog.bind(this)
  const originalFetch = window.fetch 
  window.fetch = async (...args) => {   
    let [url, config] = args    
    // 请求开始时间   
    const start = dayjs().format('YYYY-MM-DD HH:mm:ss:SSS')  
    const response = await originalFetch(url, config)      
    // 响应内容     
    let responseText = ''  
    try {      
      responseText = await response.clone().json()   
    } catch (error) {    
      responseText = ''    
    }     
    // 响应时间   
    const now = dayjs().format('YYYY-MM-DD HH:mm:ss:SSS')  
    // 20 开头的状态码都是成功的,比如201、204  
    if (String(response.status).startsWith('20')) {   
      insertLog(`${now} [http ok] method: ${config?.method || 'get'}, url: ${url}, status: ${response.status}, requestBody: ${config?.body}, requestTime: ${start}, response: `,responseText)  
    } else {     
      insertLog(`${now} [http error] method: ${config?.method || 'get'}, url: ${url}, status: ${response.status}, requestBody: ${config?.body}, requestTime: ${start}, response: `,responseText)}
    return response 
  }
}

至此 http 劫持已经实现了,在日志中可以看到请求方法,请求地址,请求时间,请求体和返回内容

系统接入了友盟统计,每次点击页面的时候都会自动发送一个请求到友盟服务器,这个请求其实我们是不关心他有没有发送成功的,也不需要记录日志,所以需要把他过滤掉,定义一个参数 filterHttpUrls,如果请求地址包含这个就不记录日志。

可选配置项 config

type ConfigType = { 
  /** 单条日志最大长度,超过不写入 indexDB 也不上报后台 */  
  maxLength?: number
  /** 过滤掉的 http 请求地址,这些地址不记录到日志里 */  
  filterHttpUrls: string[] 
}  
// 默认配置 
const defaultConfig: Required<ConfigType> = {  
  maxLength: 10000, 
  filterHttpUrls: [] 
} 
export default class Logger {  
  /** 配置 */ 
  config!: Required<ConfigType> 
  constructor(config?: ConfigType) {  
    this.config = Object.assign(config || {}, defaultConfig)  
  } 
}

过滤不需要记录日志的 url

// 请求地址过滤 
if (config.filterHttpUrls.filter((item) => url.indexOf(item) !== -1).length === 0) {  
 return 
 }

错误日志劫持

window.onerror MDN的解释为:当资源加载失败或无法使用时,会在Window对象触发error事件,例如:script 执行时报错。

不过 window.onerror 无法捕获 promise 异常,需要监听 window.onunhandledrejection 事件,MDN解释为:当 Promise 被 reject 且没有 reject 处理器的时候,会触发 unhandledrejection 事件;这可能发生在 window 下,但也可能发生在 Worker 中。这对于调试和为意外情况提供后备错误处理非常有用。

/**  * 监听错误  */ 
interceptError(  ) { 
  // 监听全局错误 
  window.onerror = (message, source, _lineno, _colno, error) => {   
    console.error(`${message}, ${error}, ${source}`)  
  }   
  // 监听未处理的 promise 错误,不能打印 event.reason,JSON.stringify 后为空对象 {} 
  window.onunhandledrejection = (event) => {  
    // 这里如果打印 event.reason 在控制台是可以看到报错的,但是 JSON.stringify 却为空  
    // 必须打印 event.reason.stack    
    console.error(event.reason.stack) 
  }
}

上传日志

日志都已经记录下来了,接下来就是要上传日志到服务器了,我的策略是每隔一段时间上传几十条,上传间隔和数量用户都可以自定义。

为了方便在服务器上查看日志,我们规定每台设备生成一个日志文件,文件名字为该设备的唯一设备ID,初始用 uuid 生成,后续存储到 localStorage 中,保证后续设备ID不会变化。当然用户也可以自定义设备ID。

type ConfigType = { 
  /** 日志服务器地址 */  
  uploadUrl?: string  
  /** 日志上传间隔 */  
  uploadInterval?: number 
  /** 单次日志上传最大数量 */
  uploadMaxCount?: number  
  /** 设备ID */   
  deviceId?: string 
}

接口轮询上传日志

/**  * 上传日志到服务器  */ 
uploadLog(currentMessageId: number) { 
  setInterval(async () => {   
    // 不能重复上传   
    if (this.isUploading) return 
    this.isUploading = true    
    // 如果需要上传的日志大于最大数量则只上传固定数量  
    const offset =currentMessageId - this.hasUploadMessageId > this.config.uploadMaxCount? this.hasUploadMessageId + this.config.uploadMaxCount: currentMessageId    
    const res = await db.logs.where('id').between(this.hasUploadMessageId, offset).toArray()   
    // 上传日志,这里可以根据自己服务器配置自定义    
    fetch(this.config.uploadUrl, {   
      method: 'POST',   
      mode: 'cors',     
      headers: {       
        'Content-Type': 'application/json'  
      },     
      body: JSON.stringify({   
        deviceId: this.config.deviceId,    
        logs: res.map((item) => item.message)   
      })     }).then((res) => res.json()).then((res) => {
      // 日志上传成功需要更新 hasUploadMessageId 为最新已上传的 id     
      if (res.code === 200) {         
        db.flag.update(1, {hasUploadMessageId: offset}).then((update) => {
          if (update) {
            this.hasUploadMessageId = offset
          }
        })     
      }      
    }).finally(() => {     
      this.isUploading = false 
    })  
  }, this.config.uploadInterval * 1000) 
}

虽然说 indexDB 使用过程中不需要考虑容量限制,但也架不住日志越来越多,所以可以每隔一段时间清空已经上传的日志,已经上传的日志删除不影响现有功能的。

/**  * 周期性删除已经上传成功的日志  */ 
deleteHasUploadLogs(  ) { 
  setInterval(() => {   
    // 删除日志的时候不能上传日志  
    this.isUploading = true   
    db.logs.where('id').belowOrEqual(this.hasUploadMessageId).delete().finally(() => {      
      this.isUploading = true     
    }) 
  }, 60 * 60 * 1000)
}

合并配置

定义了很多配置项,用户可以使用默认配置也可以自定义配置,所以需要把用户配置和默认配置进行合并。因为要保证合并后的配置项 ts 类型不会丢失,所以要用到范型约束自动推导出配置类型。

/**  * 合并配置项  */ 
mergeConfig<T extends object | undefined, U extends object>(   config: T,   defaultConfig: U ) {   
  const mergedConfig = {}   
  Object.keys(defaultConfig).forEach((key) => { 
    if (this.isValidKey(key, defaultConfig)) {  
      if (config) {     
        mergedConfig[key] =config[key] === undefined ? defaultConfig[key] : config[key]    
      } else {        
        mergedConfig[key] = defaultConfig[key]   
      }   
    }  
  })   
  return mergedConfig as U 
}

初始化

/**  * 初始化  */ 
constructor(config?: ConfigType) {  
            this.config = this.mergeConfig(config, defaultConfig) 
this.initialDeviceId()  
this.initialIndexDB()  
this.rewritetConsole()  
this.interceptXHR() 
this.interceptFetch()  
this.interceptError() 
this.uploadLog()  
this.deleteHasUploadLogs() 
}

日志文件在服务器是以设备ID命名的,所以需要知道终端设备的设备ID才能在服务器上查看对应的日志,我的想法是在设备上连续点击10次弹出 toast 显示设备ID。

/**  * 连续点击设备10次弹窗显示设备ID  */
showDeviceId(  ) {   
  /** 连续点击次数 */   
  let clickTimes = 0  
  /** 两次点击之间最小间隔 */ 
  let waitTime = 300 
  let lastTime = new Date().getTime() 
  document.body.addEventListener('click', () => {     
    clickTimes =new Date().getTime() - lastTime < waitTime ? clickTimes + 1 : 1 
    lastTime = new Date().getTime()     
    if (clickTimes >= 10) {    
      const div = document.createElement('div')   
      div.setAttribute('style','position: fixed; top: 0; right: 0; bottom: 0; left: 0; display: flex; align-items: center; justify-content: center; z-index: 99999')    
      const deviceId = document.createElement('div')  
      deviceId.setAttribute('style','max-width: 500px; height: 60px; padding: 0 24px; border-radius: 6px; background: rgba(0,0,0,0.7); color: #fff; font-size: 18px; display: flex; align-items: center; justify-content: center;')   
      deviceId.innerHTML = `设备ID: ${this.config.deviceId}`    
      div.appendChild(deviceId)     
      document.body.append(div)   
      // 5秒后消失     
      setTimeout(() => {   
        document.body.removeChild(div) 
      }, 5000)   
    }  
  }) 
}

服务器

服务端我用 node.js 简单写了个接口接收日志上传并写入本地文件中,难度不大。

const fs = require("fs"); 
const dayjs = require("dayjs");
const axios = require("axios"); 
const Koa = require("koa"); 
const app = new Koa();  
const Router = require("koa-router"); 
const router = new Router(); 
const onerror = require("koa-onerror");
const bodyParser = require("koa-bodyparser"); 
const logger = require("koa-logger"); 
const cors = require("koa-cors"); 
onerror(app); 
app.use(bodyParser());
app.use(logger()); 
app.use(cors());  
const logPath = `${__dirname}/logs`; 
if (!fs.existsSync(logPath)) { 
  fs.mkdirSync(logPath);
} 
const writeStreams = {}; 
router.post("/logger", (ctx, next) => {  
  const { deviceId, logs } = ctx.request.body;  
  const date = dayjs().format("YYYY-MM-DD");   
  // 新建日期文件夹   
  if (!fs.existsSync(`${logPath}/${date}`)) {  
    fs.mkdirSync(`${logPath}/${date}`);  
  }   
  if (!deviceId) { 
    ctx.body = {  
      code: 500,     
      msg: "参数 deviceId 不能为空",
    }; 
    return; 
  }    
  // 不能每次都新建写文件流,需要缓存起来
  let writeStream = null; 
  if (writeStreams[deviceId]) { 
    writeStream = writeStreams[deviceId];  
  } else {   
    writeStream = fs.createWriteStream(`${logPath}/${date}/${deviceId}.log`, {       flags: "a",     });     writeStreams[deviceId] = writeStream; 
  }  
  // 如果上报的日志日期和服务器当前日期不一样则关闭之前的文件流,重新新建一个当前日期的文件流写入  
  const paths = writeStream.path.split("/"); 
  if (paths.length > 0 && paths[paths.length - 1] !== date) {   
    Object.keys(writeStreams).forEach((key) => {  
      writeStreams[key].close();     
      delete writeStreams[key];    
    });     
    writeStream = fs.createWriteStream(`${logPath}/${date}/${deviceId}.log`, {       flags: "a",     });     writeStreams[deviceId] = writeStream; 
  }  
  // 遍历日志写入 
  if (Array.isArray(logs)) {  
    logs.forEach((log) => {   
      writeStream.write(log + "\n");  
    }); 
  } else {  
    writeStream.write(logs + "\n"); 
  }   
  ctx.body = {   
    code: 200, 
  }; 
}); 
app.use(router.routes()); 
app.on("error", (err, ctx) => {   
  console.error("server error", err, ctx); 
}); 
app.listen(3000);  
console.log("server listing on http://localhost:3000");

效果展示

偷偷的说一句,自从前端有了日志后,BUG没解决几个,但可以挺直腰板义正言辞的把锅甩给后端了,确实是你们没有发送哦,自此再也不用去看后端日志和nginx日志了,离到点下班又近了一步😎。

总结

至此一个简单的满足特定场景的日志框架搭建好了,当然我根据公司业务场景上报了很多东西,大家也可以根据自己要求自行上报。比如监听 WebSocket 事件并上报链接成功,链接失败,接收和发送的消息等,方便后续排查问题。另外我还监听了页面路由跳转并打印进一步协助排查问题。

router.afterEach((to, from) => { 
  const fromPath = decodeURIComponent(from.fullPath)  
  const toPath = decodeURIComponent(to.fullPath)   
  console.log(`路由跳转 from: ${fromPath} to: ${toPath}`)
})

😭我的 github 密码弄丢了,等找回了把源码传到 github 上供大家参考

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
前端监控2025-05-29
前端错误收集方式
在讲错误收集方式之前,先理清一下前端编码时有那些常见错误 &nbsp; &nbsp; &nbsp; &nbsp; 前端错误收集是保障用户体验、排查线上问题的关键手段。本文将从多个角度详解前端错误收集的各种方式,包括不同框架(如 React、Vue)、JavaScript 执行环境中的同步/异步/Promise 错误、资源加载错误(如图片、script 加载失败)等。以下是全面的错误捕获机制总结: &nbsp; &nbsp; 🌐 一、通...
学习
前端监控2024-10-12
前端小玩具~错误捕获定位工具
正文 我们在平时的工作中,开发功能的同时不可能把场景考虑的面面俱到,而生产环境往往情况是非常复杂的,用户录入进去的数据总是千奇百怪,那如果遇到问题的话,我们又要如何进行排查呢?总不可能让用户录个屏吧哈哈 所以我们就出现了 前端埋点 的操作,不过埋点的方向以及文章都挺多的,也都挺复杂的,这篇文章我们就讲一个比较有趣的 错误捕获思路 。 我们平时在使用框架开发 遇到bug 时,比如 Vue ,如果是在本地环境,我们在控制台可以很容易的找到出...
学习
前端监控2024-09-19
🍻 前端服务监控原理与手写开源监控框架SDK前端服务监控概述
前端监控的目的很明确,无非就是让我们的产品更完善,更符合我们和用户的需求。运营与产品团队需要关注用户在产品内的行为记录,通过用户的行为记录来优化产品,研发与测试团队则需要关注产品的性能以及异常,确保产品的性能体验以及安全迭代。 而一个完整的前端监控平台至少需要包括三个部分: 数据采集与上报、数据整理和存储、数据展示 。算上需要监控的项目的话,也就是说,至少需要4个项目才能完整的记录前端监控的内容。 下图是一个完整的前端监控平台需要处理和...
学习
前端监控2024-08-19
前端数据监控平台
比如客户说:你们的页面咋白屏了? 开发:具体可以给我说一下,白屏之前你做了什么吗? 客户:我就点了一下按钮 开发:你能告诉我是哪个页面的哪个按钮吗? 最后,客户极其不耐烦的地丢过来一张没有缺少url的白屏给你看,试图证明他说的是真的,真白屏了。 最后不再理睬你! 开发人员试图找到解决方案,想快速解决线上bug,但是客户却以为你在质疑他。 真的有点秀才遇上兵有理说不清,其实真的没有谁对谁错,只是双方立场不同,作为客户,你的系统bug了,我...
学习
前端监控2024-02-20
前端实现埋点&监控
实现埋点功能的意义主要体现在以下几个方面: 0. 数据采集:埋点是数据采集领域(尤其是用户行为数据采集领域)的术语,它针对特定用户行为或事件进行捕获、处理和发送的相关技术及其实施过程。通过埋点,可以收集到用户在应用中的所有行为数据,例如页面浏览、按钮点击、表单提交等。 1. 数据分析:采集的数据可以帮助业务人员分析网站或者App的使用情况、用户行为习惯等,是后续建立用户画像、用户行为路径等数据产品的基础。通过数据分析,企业可以更好地了解...
学习
前端监控2023-12-19
前端埋点重要环节:前端如何录制用户的操作过程
前言 大家好,我是林三心,用最通俗易懂的话讲最难的知识点是我的座右铭,基础是进阶的前提是我的初心。 背景 公司有很多的项目,但是并不是每一个项目都很重要,其实重要的项目就那么几个,上面也是很重视这几个项目,尤其是对一些生产问题的关注度很高。 这几天上面交代下来了,需要对这些项目做一些用户行为的记录,主要是为了更好地还原用户在某一个时间点的操作过程 注意点 想要完成这个需求,仔细想了一下,需要注意几个点: 跨框架使用: 这些项目有vue、...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录