# 配置项

# config.disable

类型: boolean
默认: false

用于快速屏蔽是所有自定义的 api, 直接通往服务器.

FQA

如何禁用单个自定义的接口
不需要提供配置, 方法比较多.

  • 改一下路径, 让接口匹配不上自定义接口即可.
  • 直接注释掉想跳过的接口.

# config.osIp

类型: string
默认: 本地网卡第一个 IPv4 地址

想绑定到 mm 程序的 IP, 会自动添加到 调试链接 中.

示例

绑定已经有内网穿透的IP到 x-test-api 上

{
  osIp: `8.8.8.8`
}

例如 8.8.8.8 是一个已经映射了外网的服务器, 那么请求的响应体上的 x-test-api 上的 IP 会被替换.

# 替换前
x-test-api: http://127.0.0.0:9005/#/history,v/get/ip

# 替换后
x-test-api: http://8.8.8.8:9005/#/history,v/get/ip

# config.port

类型: number | string
默认: 9000

服务端口, 用于接口调用.

# config.testPort

类型: number | string
默认: 9005

调试端口, 用于生成测试页面服务

# config.replayPort

类型: number | string
默认: 9001

重放端口, 用于使用服务端口产生的缓存数据.

# config.replayProxy

类型: boolean
默认: true

记录中不存在所需请求时, 是否转发请求到 proxy.

  • false 不转发
  • true 转发

# config.replayProxyFind

类型: function
默认:

      replayProxyFind (item) {
        const bodyPath = item.data.res.bodyPath
        if(bodyPath && bodyPath.match(/\.json$/)) {
          const bodyPathCwd = require(`path`).join(process.cwd(), bodyPath)
          const body = require(bodyPathCwd)
          return body.status === 200 || body.status === `200`
        } else {
          return false
        }
      },

自定义请求重放时的逻辑.

# config.hostMode

类型: boolean
默认: false

是否通过修改 host 文件来实现代码 0侵入.

  • true 是
  • false 否
注意
  • 启用时 port 配置将被忽略, 将自动与 proxy 端口保持一致, 因为 host 文件无法实现类似 127.0.0.1:9000 a.com:8080 的指定端口效果, 所以只能通过在本地启动与目标一致的端口来解决这个问题.
  • 由于是使用更改 host 文件来实现的, 当程序异常退出时没有还原修改会导致无法访问原地址.
  • 仅支持 proxy 是域名的情况, 这是由 host 文件的工作性质决定的, 即 ip 就直接访问 ip, 不会经过 host 文件.
  • proxy 中的拦截功能将失效, 即不能再转发到目标服务器(因为已经在 host 声明目标服务器就是自己, 自己再指定自己就陷入循环了).

# config.updateToken

类型: boolean | string | string[] | object
默认: true

指定如何从上一个 http 请求获取数据到重放和调试时的 http 请求上.

对象的 key 指定了从上一个请求的的哪里获取值.

/** 
 * 不使用
 */
updateToken = false

/**
 * 相当于 `{'req.headers.authorization': 'req.headers.authorization'}`
 */
updateToken = true

/**
 * 相当于 `{'req.headers.auth': 'req.headers.auth'}`
 */
updateToken = `auth`

/**
 * 相当于
 * ```
 * {
 *   'req.headers.auth': 'req.headers.auth',
 *   'req.headers.auth2': 'req.headers.auth2',
 * }
 * ```
 */
updateToken = [`auth`, `auth2`] 

/**
 * 自定义每个 key/value 的对应关系, 目前仅支持 `req.headers.*` , value 可使用函数 `({req}) => [key, value]`
 */
updateToken = {
  'req.headers.authorization': 'req.headers.authorization',
  'req.headers.a': 'req.headers.b',
  'req.headers.a2': ({req, value}) => { // 动态设置, 参数中的 value 是上一次请求中对应路径的值
    return ['req.headers.b2', `123456`] // 第一项是要设置的 key, 第二项是 value
  },
}

# config.apiInHeader

类型: boolean | string
默认: true

是否在 header 中添加调试 api 地址.

  • false 不添加调试地址
  • true 时 x-test-api.
  • string: 时为自定义 header 字段.

# config.proxy

类型: string | object
默认: http://www.httpbin.org/

提示:你可以在测试用例 (opens new window)中搜索 config.proxy 来查看更多功能演示.

代理到远程的目标域名,为对象时每个键是分别对应一个要自定义代理的路由.

注: 是对象时, 需要存在键 / 表示默认域名.
此功能可以自定义拦截过程, 类似 webpack 中的 devServer.proxy .

  • string 直接请求转发到指定地址.
  • object 相当于传入 proxy 的配置.

参考 proxy (opens new window).

# 快速修改 json response

支持以简便的方式快速处理 json 格式的 response, 使用数组语法: [A, B].
数组中不同个数和类型有不同的处理方式, 参考下表:

个数 [A, B] 类型 处理方式 处理前 操作 处理后
0, 1 [any] 直接替换 {a: 1} [][undefined]
{a: 1} [123] 123
2 [string, any] 替换指路径的值 {a: 1} ['a', 2] {a: 2}
{a: {b: 1, c: 2}} ['a.b', undefined] {a: {c: 2}}
2 [object, ...] 浅合并 {a: {a: 1}} [{a: {b: 2}, c: 1}, '...'] {a: {b: 2}, c: 1}
[object, deep] 深合并 {a: {a: 1}} [{a: {b: 2}, c: 1}, 'deep'] {a: {a: 1, b: 2}, c: 1}

A 或 B 支持传入函数, 可以接收 {req, json}, 返回值是前端最终收到的值.

示例

进一步解释表格中的示例.

直接替换

处理前
{
  "a": 1
}

操作, 直接替换为空
[] 或 [undefined]

处理后
undefined

直接替换

处理前
{
  "a": 1
}

操作, 直接替换为 123
[123]

处理后
123

替换指定路径的值

处理前
{
  "a": 1
}

操作, 把 a 的值替换为 2
['a', 2]

处理后
{
  "a": 2
}

替换指定路径的值

处理前
{
  "a": {
    "b": 1,
    "c": 2
  }
}

操作, 把 a 下面 b 的值删除
['a.b', undefined]

处理后
{
  "a": {
    "c": 2
  }
}

浅合并

处理前
{
  "a": {
    "a": 1
  },
  "c": 1
}

操作, 合并时直接替换, 此示例会直接替换掉 a 对象
[
  {
    "a": {
      "b": 2
    },
    "c": 1
  },
  "..."
]

处理后
{
  "a": {
    "b": 2
  },
  "c": 1
}

深合并

处理前
{
  "a": {
    "a": 1
  }
}

操作, 深层合并对象, 此对象会合并 a 对象
[
  {
    "a": {
      "b": 2
    },
    "c": 1
  },
  "deep"
]

处理后
{
  "a": {
    "a": 1,
    "b": 2
  },
  "c": 1
}

# 请求转发

可以方便的支持任意路径转发, 以下演示转发到其他域名:



 


proxy: {
  '/': `https://httpbin.org/`,
  '/get': `https://www.httpbin.org/ip`,
},

# config.remote

类型: boolean
默认: false
是否启用外网映射.

注意

此修改需要重启才能生效, 由于此服务是使用 ngrok 的免费服务提供的, 无需注册, 所以有一些 ngrok 给予的限制, 具体限制以 ngrok 的错误提示为准, 例如:

  • 不固定的随机生成外网 URL
  • 限制每个 URL 的使用时长
  • 限制每分钟请求数
  • 较慢的网络速度(这与大陆网络环境也有一定的关系)
  • 强制的版本更新

虽然看起来有点苛刻, 但是对于免注册来说已经很良心了, 对于常见 api 测试来说已经足够了.

由于 mockm 在 v1.1.25-alpha.15 之前不知道 ngrok 对于未注册用户会强制要求更新而导致外网映射功能无法正常使用.

要解决此问题有两个方案:

  • 方案1: 更新 mockm 到 v1.1.25-alpha.15 以上的版本
  • 方案2: 手动进入 mockm 的安装目录(node_modules\mockm)运行命令 npx ngrok update 来更新 ngrok

# config.remoteToken

类型: string | string[]
默认: []

外网映射程序所使用的 authtoken, 以数组形式提供多个 token, 分别用于 port/testPort/replayPort 服务的通道
目前 ngrok 已注册的免费用户仅可使用 1 通道, 如果你的 tokenA 支持 3 个通道, 可以这样重复使用: [tokenA, tokenA, tokenA]

# config.openApi

类型: string | array | object
默认: http://httpbin.org/spec.json

关联的 openApi 数据文件, 支持 json 格式, 会自动根据当前的 api 匹配对应的 swagger 文档. 支持多个 api 源.

  • string 直接使用一个 openApi
  • array api 与每项相比, 取匹配度最高的, 都不匹配时取第一条
  • object api 与对象的 key 作为正则进行匹配(new RegExp(key, 'i').test(pathname)), 优先从 url 目录层级较多的开始比较, 都不匹配时取第一条

支持 basic-auth 认证, 在 url 上携带用户名密码即可, 例如 http://name:passwd@httpbin.org/spec.json (opens new window).

当为 object 时, key 也作为 ui 界面上 swagger 调试地址的匹配. 例如某个服务有 openApi 地址, 在里面有一个 api 是 /user, 但是最终部署之后经过代理的请求接口实际是 /api/server/user, 这种情况下 key 的值就是 /api/server.

# config.cors

类型: boolean
默认: true

是否允许通过跨域.

  • true 自动允许跨域
  • false 不对源跨域方式做任何处理

# config.dataDir

类型: string
默认: ${os.homedir()}/.mockm/${configPathByName}/httpData/

http 请求数据保存目录.

# config.dbJsonPath

类型: string
默认: ${config.dataDir}/db.json

json 数据生成的保存位置.

# config.dbCover

类型: boolean
默认: false

是否在重载时重新根据 config.db 生成新的数据文件.

  • false 不重新生成, 但会补充新添加的数据
  • true 总是重新生成
FQA

修改了 config.db 中的深层对象的属性没有生效
由于对象的数据已经生成了, 改变对象中的某个属性, 这个对象也不会改变.
如果需要改变, 删除 config.dbJsonPath 文件中的对应的对象, 再保存一下 mm.config.js 即可.

# config.db

类型: object | function
默认: {}

json-server (opens new window) 使用的 json 数据.

  • object 直接作为数据使用
  • function 应返回一个对象
示例

随机生成带有 id, 用户, 阅读量, 作者等信息的 40 至 60 本书数据, 以及相关的增删查改接口. 不到 1分钟即可实现这些功能.

module.exports = util => {
  return {
    db: util.libObj.mockjs.mock({
      'books|40-60': [
        {
          'id|+1': 1,
          user: /\d\d/,
          view: /\d\d\d\d/,
          'type|1': [`js`, `css`, `html`],
          'discount|1': [`0`, `1`],
          author: {
            'name|1': [`张三`, `李四`],
          },
          title: '@ctitle',
        }
      ],
    }),
  }
}

所有的创建或修改都会像真实的后台接口把操作结果存储在数据库一样.

  • 基本操作
    GET /books -- 获取所有
    POST /books -- 增加一条
    GET /books/1 -- 获取某条
    PUT /books/1 -- 修改某条
    PATCH /books/1 -- 部分修改某条
    DELETE /books/1 -- 删除某条

  • 过滤
    GET /books?discount=1&type=js -- 不同字段查询
    GET /books?id=1&id=2 -- 相同字段不同的值
    GET /books?author.name=张三 -- 使用点查询深层数据

  • 分页
    GET /books?_page=2 -- 分页
    GET /books?_page=2&_limit=5 -- 分页并指定每页数量

  • 排序
    GET /books?_sort=view&_order=asc -- 排序
    GET /books?_sort=user,view&_order=desc,asc -- 多字段排序

  • 截取
    GET /books?_start=2&_end=5 -- 截取 _start 到 _end 之间的内容
    GET /books?_start=20&_limit=10 -- 截取 _start 后面的 _limit 条内容

  • 运算
    GET /books?view_gte=3000&view_lte=7000 -- 范围 _gte _lte
    GET /books?id_ne=1 -- 排除 _ne
    GET /books?type_like=css|js -- 过滤器 _like, 支持正则

  • 全文检索
    GET /books?q=张三 -- 精确全文匹配

# config.route

类型: object
默认: {}

路由映射.

假设接口 /books/1 希望能通过 /test/db/api/ 前缀访问, 配置如下:

{
  '/test/db/api/*': '/$1', // /test/db/api/books/1 => /books/1
}

参考 json-server (opens new window).

# config.apiWeb

类型: string
默认: ./webApi.json

从 web 页面创建的接口数据, 会与 config.api 合并, config.api 具有优先权

# config.apiWebWrap

类型: boolean | function
默认: wrapApiData

统一包装从 web 页面创建的接口数据.

这是默认统一使用的包裹结构: wrapApiData

function wrapApiData({data, code = 200}) { // 包裹 api 的返回值
  code = String(code)
  return {
    code,
    success: Boolean(code.match(/^[2]/)), // 如果状态码以2开头则为 true
    data,
  }
}

# config.api

类型: object | function
默认: {}

自建 api.

提示:你可以在测试用例 (opens new window)中搜索 config.api 来查看更多功能演示.

  • object 对象的 key 为 api 路由.
  • function 可以获得工具库, 参考 config.api.fn. 函数应返回一个对象.

当与 config.proxy 中的路由冲突时, config.api 优先.

对象的 key 为 api 路由, 请求方法 /路径, 请求方法可省略, 示例:

  • /api/1 省略请求方法, 可以使用所有 http 方法访问接口, 例如 get post put patch delete head options trace.
  • get /api/2 指定请求方法, 例如只能使用 get 方法访问接口
  • ws /api/3 创建一个 websocket 接口
  • use /api/4 自定义一个中间件, 作用于任何 method 的任何子路由

非 use 时, value 可以是函数或 json, 为 json 时直接返回 json 数据.

api: {
  // 当为基本数据类型时, 直接返回数据
  'get /api/1': {msg: `ok`},
  // http 接收的参数, 参考 example 中间件 http://expressjs.com/en/guide/using-middleware.html
  'get /api/2' (req, res, next) {
    res.send({msg: `ok`})
  },
  // websocket 接收的参数, 参考 https://github.com/websockets/ws
  'ws /api/3' (ws, req) {
    ws.on('message', (msg) => ws.send(msg))
  }
  // 使用中间件实现静态资源访问, config.static 就是基于此方式实现的
  // 当然, use 也支持数组
  'use /news/': require('serve-static')(`${__dirname}/public`),
  // 拦截 config.db 生成的接口
  '/books/:id' (req, res, next) { // 在所有自定义 api 之前添加中间件
    req.body.a = 1 // 修改用户传入的数据
    next()
    res.mm.resHandleJsonApi = (arg) => {
      arg.res.locals.data // 当前接口的 json-server 原始数据
      arg.data // 经预处理的数据, 例如将分页统计放置于响应体中
      arg.resHandleJsonApi // 是全局 config.resHandleJsonApi 的引用, 若无需处理则直接 return arg.data
      arg.data.a = 2 // 修改响应, 不会存储到 db.json
      return arg.resHandleJsonApi(arg)
    }
  },
},

# config.resHandleReplay

类型: function
默认: ({req, res}) => wrapApiData({code: 200, data: {}})

处理重放请求出错时会进入这个方法.

TIP

对于没有记录 res 的请求, 返回 404 可能会导致前端页面频繁提示错误(如果有做这个功能), 所以这里直接告诉前面接口正常(200ok), 并返回前约定的接口数据结构, 让前端页面可以尽量正常运行.

# config.resHandleJsonApi

类型: function
默认: ({req, res: { statusCode: code }, data}) => wrapApiData({code, data})

由 config.db 生成的接口的最后一个拦截器, 可以用来构建项目所需的数据结构.

# config.watch

类型: string | array[string]
默认: []

指定一些目录或文件路径, 当它们被修改时自动重载服务. 支持绝对路径和相对于配置文件的路径.

# config.clearHistory

类型: boolean | object | function
默认: false

启动时清理冗余的请求记录.

  • boolean 是否启用
    • false 不清理记录
    • true 使用默认配置清理记录
  • object 启用并传入配置
    • retentionTime 从多少分钟前的历史中选择要清除的项目
      • 默认 60 * 24 * 3, 即 3 天前
    • num 相同内容保留条数, 正数时保留新记录, 负数时保留旧记录
      • 默认 1
  • function 自定义清理函数, 获取 history 列表, 返回要删除的 id 列表

默认配置使用以下几个内容来判断内容相同.

  • 请求 URL
  • 请求方法,
  • 状态码,
  • 请求体 MD5,
  • 响应体 MD5,

# config.guard

类型: boolean
默认: false

当程序异常退出时, 是否自动重启.

# config.backOpenApi

类型: boolean | number
默认: 10

每隔多少分钟检测 openApi 更新记录, 保存到 ${config.dataDir}/openApiHistory 目录中.

  • boolean 是否启用
    • false 禁用
    • true 使用默认配置
  • number 启用并设置检测的分钟数

# config.static

类型: string | object | array
默认: undefined

配置静态文件访问地址, 优先级大于 proxy, 支持 history 模式.

  • string 可以是相对于运行目录的路径, 或绝对路径
  • object
    • path: string 浏览器访问的 url 前缀, 默认 /
    • fileDir: string 本地文件的位置. 可以是相对于运行目录的路径, 或绝对路径
    • list: boolean 是否显示目录列表, 当 mode 不为 history 时可用
    • mode: string 配置访问模式, 可选 historyhash(默认值)
    • option: object 模式的更多配置,例如history (opens new window)hash (opens new window)
  • array[object] 使用多个配置
示例
{
  static: `public`, // 访问 http://127.0.0.1:9000/ 则表示访问 public 中的静态文件, 默认索引文件为 index.html
  static: { // 访问 dist 目录下 history 模式的项目
    fileDir: `dist`,
    mode: `history`,
  },
  static: [ // 不同的路径访问不同的静态文件目录
    {
      path: `/web1`,
      fileDir: `/public1`,
    },
    {
      path: `/web2`,
      fileDir: `/public2`,
    },
    {
      path: `/web3`,
      fileDir: `/public3`,
      mode: `history`,
    },
  ],
}

# config.disableRecord

类型: boolean | string | string[] | DisableRecord | DisableRecord[]
默认: false

哪些请求不记录.

  • boolean 是否禁用
    • false 默认不禁用
    • true 不记录所有
  • string 禁用的 path
  • DisableRecord 使用对象配置
    interface DisableRecord {
      /**
       * 请求地址, 将被转换为正则
       */
      path: string,
    
      /**
       * 请求方法, 不指定时为匹配所有
       */
      method: Method,
    
      /**
       * 仅记录后 n 条, 0 表示不记录
       * @default
       * 0
       */
      num: number,
    }
    
  • DisableRecord[] 使用多个配置

# config.bodyParser

类型: Object
默认: 参考下文

向 bodyParser 中间件传入配置.

// 默认值
config.bodyParser = {
  json: {
    limit: `100mb`,
    extended: false,
  },
  urlencoded: {
    extended: false,
  },
}

# config.https

类型: configHttps
默认: 参考类型定义

为服务配置 https 协议, 默认情况下只需填写 key/cert, 即可实现在同一端口同时支持 http/https.

interface configHttps {
  /**
   * 私钥文件地址, 例如 *.key
   */
  key: String,

  /**
   * 公钥文件地址, 例如 *.crt, *.cer
   */
  cert: String,
  
  /**
   * 是否重定向到 https
   * @default true
   */
  redirect: Boolean,
  
  /**
   * 配置 https 使用的端口, 默认同 config.port
   */
  port: number | string,
  /**
   * 配置 https 使用的端口, 默认同 config.testPort
   */
  testPort: number | string,
  /**
   * 配置 https 使用的端口, 默认同 config.replayPort
   */
  replayPort: number | string,
}

实现类似 nginx 80/443 端口 https 支持:

const config = {
  port: 80,
  https: {
    key: `./key/https.key`,
    cert: `./key/https.cer`,
    port: 443,
  },
}

# config.plugin

类型: Plugin[]
默认: []

通过插件可以对 mockm 的各个生命周期进行操作。

每个插件是一个对象,结构如下:

module.exports = {
  /**
   * 插件的唯一标识
   * string, 必填
   */
  key: `base`,
  /**
   * 支持的宿主版本
   * array[string], 非必填
   * 若版本不被支持时会给予警告
   */
  hostVersion: [],
  /**
   * 插件入口
   * function, 需要返回一个对象
   * 在这里获取用户转给插件的配置
   * 约定: 当用户传入 false 时不启用插件
   */
  async main({hostInfo, pluginConfig, config, util} = {}){
    return {
      /**
       * 宿主应用配置项完成
       * 例如创建了程序所需目录结构
       * 可以在这里创建插件所需目录结构
       */
      async hostFileCreated(){},
      /**
       * server listen 调用成功
       * info
       */
      async serverCreated(info){},
      /**
       * app 初始化完成, 在这个时候
       * - 只有 ws 和 http/https 支持
       * - 没有 bodyParse urlencodedParser logger 也没有宿主的 proxy db api 各种功能
       * - 当调用 next 方法之后才进入其他中间件
       * 可以在这里注册一个优先级较高的中间件, 例如请求拦截
       */
      async useCreated(app){},
      /**
       * app 中的解析器初始化完成, 在这个时候
       * - 只有 bodyParser urlencodedParser logger
       * - 没有宿主的 proxy db api 各种功能
       * - 当调用 next 方法之后才进入其他中间件
       * 在这里的中间件可以获取 req.body 数据, 到这里的请求会被 log 记录
       */
      async useParserCreated(app){},
      /**
       * config.api 已解析完成
       * - 它不再是一个函数, 而是一个对象
       * - side 方法还未展示
       * 可以在这里使用它, 例如注入新的接口
       */
      async apiParsed(api, apiUtil){},
      /**
       * config.api 对象中的每个接口已解析完成为一个 api 详情列表
       * - side 方法已被展开
       * - method route action 等信息已被展开
       * 可以在这里使用它, 例如生成接口文档
       * @param {*} serverRouterList 
       */
      async apiListParsed(serverRouterList = []) {},
    }
  },
}

# 插件示例: 一

解析 token 为 userId, 让接下来的所有接口都可以通过 req.userId 获取到用户信息.

创建插件文件: get-user.js

module.exports = {
  key: `get-user`,
  main({ config }) {
    return {
      useCreated(app) {
        app.use((req, res, next) => {
          const token = req.header(`Blade-Auth`) || ``
          const list = token.split(` `) || []
          req.userId = list[1]
          next()
        })
      },
    }
  },
}

使用插件文件: mm.config.js

const getUser = require(`./get-user.js`)
module.exports = (util) => {
  return {
    plugin: [getUser],
    api: {
      // 任意一个接口都可以获取已解析的 req.userId
      async '/getId'(req, res, next) {
        res.json({
          id: req.userId,
        })
      },
    },
  }
}

# 插件示例: 二

  • 让 config.db 返回的 data.results 为 data.records
  • 让 config.db 返回的 data.count 为 data.total
  • 让 config.db 的分页参数是 query.size 和 query.current
module.exports = {
  key: `change-page`,
  main({ config }) {
    config.resHandleJsonApi = ({ req, res: { statusCode: code }, data }) => {
      function wrapApiData({ data, code = 200 }) {
        // 包裹 api 的返回值
        code = String(code)
        data.results && ((data.records = data.results), delete data.results)
        data.count && ((data.total = data.count), delete data.count)
        return {
          code,
          success: Boolean(code.match(/^[2]/)), // 如果状态码以2开头则为 true
          data,
        }
      }
      return wrapApiData({ data, code })
    }
    return {
      useCreated(app) {
        app.use((req, res, next) => {
          const { size, current } = req.query
          size && ((req.query._limit = size), delete req.query.size)
          current && ((req.query._page = current), delete req.query.current)
          next()
        })
      },
    }
  },
}

# 插件示例: 三

验证数据格式和生成接口文档:mm.config.js

module.exports = async (util) => {
  const joi = await util.tool.generate.initPackge(`joi`)
  return {
    plugin: [util.plugin.validate, util.plugin.apiDoc],
    api: {
      'post /api/login': util.side({
        tags: [`admin`],
        summary: `登录接口`,
        schema: {
          body: joi
            .object({
              name: joi.string().default(`wll8`).required().description(`用户名`),
            })
            .description(`用户信息`),
        },
        async action(req, res) {
          res.json(
            globalThis.config.apiWebWrap({
              data: req.body,
            }),
          )
        },
      }),
    },
  }
}

上面的 mm.config.js 配置中添加了 validate (opens new window) 插件和 apiDoc (opens new window) 插件,然后创建了一个请求方法为 post 路径为 /api/logo 登录接口,接口分类为 admin,接口描述为 登录接口,接收参数是必填的 string 类型的 name 字段。

浏览器打开 http://127.0.0.1:9000/doc/ 即可看到生成的接口文档。

最后更新时间: 9/29/2024, 6:27:34 PM