进阶篇:Web离线应用

丢失网络连接是一个困扰 Web 用户多年的难题,即使是世界上最好的 Web App,如果因为网络原因访问不了它,那体验也是非常糟糕的。所谓 Web 离线应用,就是指在设备没有网络的情况下,Web App 依然可以运行。

离线应用的核心是离线缓存技术。用户第一次访问 Web App 后,将所需资源缓存在本地,以后的访问可以直读取缓存资源,就算没有网络连接也不防碍用户使用。

使用离线缓存技术带来三个优势:

  • 离线浏览 - 用户离线时可以访问 Web App;
  • 速度 - 资源直接来自磁盘,无需网络访问;
  • 减少服务器负载 - 浏览器将只从服务器下载更新过或更改过的资源。

前端有两种离线缓存技术:

  • AppCache: 也称 Application Cache。目前已经从 Web 标准中删除,请尽量避免使用。
  • Service Workers: 目前最新的离线缓存技术,是 Web Worker 的一部分。它比 AppCache 更加灵活,因为它可以通过 JavaScript 代码去控制缓存的逻辑。它也是构建 PWA 应用的关键技术之一。

如何判断是否离线?

要开发离线应用,首先应该知道设备是否处于离线状态下:

if (navigator.onLine){
    alert('联网中')
} else {
    alert('离线中')
}

同时,我们还可以监视网络状态改变,当网络状态在 联网/离线 状态变化时,指定执行相应处理。

window.addEventListener('online',function(){
    alert("网络重新连接了!!")
})
window.addEventListener('offline',function(){
    alert("网络断开了。。。")
})

Application Cache

HTML5 提供了一套可以让 Web App 离线运行的机制,开发者可以使用 Application Cache(AppCache)接口定义浏览器需要缓存的供离线用户使用的资源。即便是用户在离线的情况下刷新页面,被缓存的应用都应该被正确的加载和运行。

AppCache 就是从浏览器的缓存中分出来的一块缓存区。要想在这个缓存区保存数据,需要使用一个缓存清单文件(manifest file),列出要下载和缓存的资源。

兼容性

目前(2021.12) Chrome、Firefox、Edge 都已不支持该功能,Safari、IE10~11 还支持。

image-20211214222323886

查看浏览器是否支持 Application Cache:

if (window.applicationCache) {
    // 支持 Application Cache
}

引用清单文件

想要在应用中使用 AppCache,需要在应用页面的 html 节点上增加 manifest 属性:

<html manifest="example.appcache"></html>

manifest 属性链接到一个缓存清单文件,文件中列出了应用中需要缓存的资源列表。 manifest 属性需要包含在每一个需要缓存的页面上,浏览器不会缓存没有 manifest 属性的页面。在 manifest 文件中不需要列出所有希望缓存的页面,对于包含了 manifest 属性的页面,浏览器会隐式的将其缓存到 AppCache 中

缓存使用流程

使用 AppCache 会影响加载 Document 的流程:

  • 当浏览器访问一个包含了 manifest 属性的页面时,如果 AppCache 不存在,浏览器会加载主文档,然后获取所有在 manifest 文件中列出的需要加载的资源,创建 AppCache 的第一个版本。
  • 此后访问页面,浏览器会从 AppCache(浏览器缓存区) 加载主文档以及 manifest 文件中定义的的缓存资源。此外,浏览器会向 window.applicationCache 对象发送一个 checking 事件,通过 HTTP 请求获取 manifest 文件。
  • 如果当前的 manifest 文件是最新的,浏览器发送一个 noupdate 事件给 window.applicationCache 对象,更新结束。需要强调的是,如果在服务器端更新了任何缓存中的资源文件,必须同时更新 manifest 文件,以保证浏览器知道需要重新获取所有资源。
  • 如果 manifest 文件有更新,所有 manifest 文件中列出的需要缓存的文件(包括通过 applicationCache.add() 添加的资源)都会遵循基础的 HTTP 缓存规则,被加载到一个临时缓存中。对于每一个加载到临时缓存区的文件,浏览器会发送一个 progress 事件给 window.applicationCache 对象,如果过程中出现了任何错误,浏览器会发送一个 error 事件,停止更新的过程。
  • 当浏览器收到所有的文件之后,他们自动会被移动到一个实际的离线缓存区,同时 cahced 事件被发送给 window.applicationCache 对象。由于主文档已经缓存到浏览器中,已经更新的文档除非重新加载(包括人工或者程序内部),否则都不会被重新渲染。

缓存清单文件

Web App 中的 manifest 属性,可以通过相对路径和绝对路径(需要和 Web 应用在同一个域下)两种方式进行。manifest 文件的建议的文件扩展名是:.appcache,也可以使用任意的后缀,但是 MIME type 必须是 text/cache-manifest

比如,在 Apache 中提供此 MIME 类型,请将以下行添加到您的配置文件中:

AddType text/cache-manifest .appcache

或者,在 Google App Engine 的 app.yaml 文件中:

- url: /mystaticdir/(.*\.appcache)
  static_files: mystaticdir/\1
  mime_type: text/cache-manifest
  upload: mystaticdir/(.*\.appcache)

manifest 文件是一个列出了浏览器需要缓存(以及不需要缓存)的文件的简单文本文件,资源通过 URI 进行识别。

一个 manifest 文件看起来像这样:

CACHE MANIFEST

# 指定缓存的资源
/favicon.ico
index.html

CACHE:
stylesheet.css
images/logo.png
scripts/main.js

# 需要用户联网请求的资源
NETWORK:
*

FALLBACK:
# 如果main.py不可访问,则会提供Static.html
# offline.jpg将代替images/large/目录下的所有图片
# offline.html将代替所有其他无法访问的.html文件 
/main.py /static.html
images/large/ images/offline.jpg

清单可以有三个不同的部分:

  • CACHE: 在此标题下或紧接在 CACHE MANIFEST 之后列出的文件,在第一次下载后将被缓存。
  • NETWORK: 在此标题下列出的文件永远不会被缓存,且离线时是不可用的。但是,如果 CACHE 也指定了同一文件,则该文件直接使用缓存。通常可以使用 * 来指示所有其他文件都需要因特网连接。
  • FALLBACK: 在此标题下列出的文件,如果无法建立网络连接,则使用指定的文件替代。第一个 URI 是资源,第二个 URI 是网络请求失败或错误时使用的替代文件,这两个 URI 必须与清单文件来自同一来源。

有几点要注意:

  • CACHE MANIFEST 字符串是第一行并且是必需的。

  • CACHE,NETWORk 和 FALLBACK 三个段在 manifest 文件中出现的顺序是随意的,并且每个段在文件中出现的次数没有限制。

  • # 开头的是注释行,但也可满足其他用途。比如:更新注释行中的日期和版本号是一种使浏览器重新缓存文件的办法。

  • 如果有 manifest 属性的 html 文件没有在 manifest 文件中列出来,会将其自身作为需要缓存的资源,加入到 AppCache 中。

  • 如果清单或其中指定的资源无法下载,则整个缓存更新过程将失败。如果出现故障,浏览器将继续使用旧的应用程序缓存。

  • 不要在 manifest 文件中缓存自身,否则想要通知浏览器更新 manifest 是几乎不可能的。

  • /cached-page/cached-page?value=1/cached-page?value=2 被认为是单独的页面,它们的 AppCahe 是无法共享的。

    如果需要链接到有参数传递的资源,在 hash 上增加参数,例如:/cached-page#whatever?

applicationCache对象

JavaScript 提供了相应的 API 让我们去监控描述文件的状态,这个 API 的核心是 applicationCache 对象,这个对象有一个 status 属性用来标示缓存的当前状态,属性的值是常量,可能值如下:

  • 0: UNCACHE,无缓存,即没有与页面相关的应用缓存;
  • 1: IDLE,闲置,即应用缓存不在被更新的过程中;
  • 2: CHECKING,检查中,即正在加载 manifest 文件或者检查是否已更新;
  • 3: DOWNLOADING,下载中,即资源正在被下载到缓存中;
  • 4: UPDATEREADY,更新完成,即应用缓存已经更新了资源,而且所有资源都已下载完毕。但是没有使用 swapCache() 来更新,该状态会触发 updateready 事件;
  • 5: OBSOLETE,缓存废弃,即 AppCache 已经过时(manifest 文件已删除)。
const appCache = window.applicationCache
switch (appCache.status) {
    case appCache.UNCACHED: // appCache.UNCACHED === 0
        return 'UNCACHED'
        break
    case appCache.IDLE: // appCache.IDLE === 1
        return 'IDLE'
        break
    case appCache.CHECKING: // appCache.CHECKING === 2
        return 'CHECKING'
        break
    case appCache.DOWNLOADING: // appCache.DOWNLOADING === 3
        return 'DOWNLOADING'
        break
    case appCache.UPDATEREADY:  // appCache.UPDATEREADY === 4
        return 'UPDATEREADY'
        break
    case appCache.OBSOLETE: // appCache.OBSOLETE === 5
        return 'OBSOLETE'
        break
    default:
        return 'UKNOWN CACHE STATUS'
        break
}

正如您所料, applicationCache 对象会提供事件来监视缓存的状态。浏览器会针对下载进度、更新应用程序缓存和错误条件等事件触发事件。缓存状态的改变会相应触发以下事件:

  • checking:在浏览器为应用缓存查找更新时触发;
  • error:在检测更新或下载资源期间发生错误时触发;
  • noupdate:在检查 manifest 文件无变化时触发;
  • downloading:在开始下载应用缓存资源时触发;
  • progress:在文件下载应用缓存的过程中持续不断的触发;
  • updateready:在页面新的应用缓存下载完毕且通过 swapCache() 方法替换旧缓存时触发;
  • cached:在清单的第一个缓存之后触发。
// 在清单的第一个缓存之后触发。
appCache.addEventListener('cached', () => {}, false)

// 在浏览器为应用缓存查找更新时触发。
appCache.addEventListener('checking', () => {}, false)

// 在开始下载应用缓存资源时触发。
appCache.addEventListener('downloading', () => {}, false)

// 在检测更新或下载资源期间发生错误时触发。
appCache.addEventListener('error', () => {}, false)

// 在检查 manifest 文件无变化时触发。
appCache.addEventListener('noupdate', () => {}, false)

// manifest 文件返回 404 或 410 时触发,这会导致 AppCache 被删除。
appCache.addEventListener('obsolete', () => {}, false)

// 在文件下载应用缓存的过程中持续不断的触发。
appCache.addEventListener('progress', () => {}, false)

// 当清单资源重新下载完成时触发。
appCache.addEventListener('updateready', () => {}, false)

需要手动检查清单更新,首先调用 applicationCache.update() 更新用户的缓存(这需要更改清单文件);然后,当applicationCache.status 处于其 UPDATEREADY 状态时,调用 applicationCache.swapCache() 会将旧缓存交换为新缓存。

const appCache = window.applicationCache
appCache.update()
if (appCache.status === window.applicationCache.UPDATEREADY) {
    appCache.swapCache()
}

使用 update()swapCache() 方法更新了缓存,此时用户已经下载了页面和所有资源,但这些文件不会自动重新加载。所有,最后还需要再次刷新页面以获取最新版本的页面和资源,也可以 updateready 事件中定义一个刷新页面的操作:

window.applicationCache.addEventListener('updateready', function(e) {
    if (window.applicationCache.status === window.applicationCache.UPDATEREADY) {
        window.location.reload()
    }
}, false)

更新缓存

一旦应用被缓存,它就会保持缓存直到发生下列情况:

  • 用户清除浏览器的数据存储;
  • 缓存清单文件已修改。注意:更新清单中列出的文件并不意味着浏览器将重新缓存该资源,清单文件本身必须更改。比如:编辑图片资源或更改 JavaScript 函数,您必须修改 manifest 文件本身(如:修改注释中的日期或版本)以通知浏览器刷新缓存文件

缓存存储位置以及 AppCache 的清除

在 Chrome 中,可以通过点击 preferences -> Clear browsing data… 或者访问 chrome://appcache-internals/ 来清除离线缓存。Safari 在 preferences 中有一个类似的 Empty cache 设置项,但是设置之后需要重启浏览器。

AppCache 也会过时,如果应用的 manifest 文件被从服务器端删除,浏览器会删除使用 manifest 的所有应用缓存,同时发送 obsoleted 事件给 window.applicationCache 对象。AppCache 的状态被设置为 OBSOLETE

注意: Application Cache 被标准废弃了,这个接口设计很不合理,最明显的是它一直无法有效的解决离线资源精细化控制。因此,推荐使用更强大的 Service Worker。

Service Worker

Service Worker 本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。这个 API 旨在创建有效的离线体验,它会拦截网络请求并根据网络是否可用来采取适当的动作、更新来自服务器的的资源。它还提供入口以推送通知和访问后台同步 API。

Service Worker 是一个注册在指定源和路径下的事件驱动 worker。它采用 JavaScript 控制关联的页面或者网站,拦截并修改访问和资源请求,细粒度地缓存资源。你可以完全控制应用在特定情形(最常见的情形是网络不可用)下的表现。

Service Worker 有以下特性:

  • Service Worker 线程有自己完全独立的执行上下文。一旦被安装成功就永远存在,除非线程被程序主动解除,而且 Service Worker 在访问页面的时候可以直接被激活,如果关闭浏览器或者浏览器标签的时候会自动睡眠,以减少资源损耗。
  • Service Worker 是完全异步实现的,内部的接口的异步化都是通过 Promise 实现,并且在 Service Worker 中不能直接操作 DOM,出于安全和体验的考虑,UI 的渲染工作必须只能在主线程完成。
  • 出于安全的考虑 Service Worker 必须运行在 HTTPS 协议下,为了便于本地开发,localhost127.0.0.1 这种非 HTTPS 协议也被浏览器认为是安全源。(IP 地址:192.168.x.x,不行)。
  • Service Worker 可以拦截并代理请求,可以处理请求的返回内容,可以持久化缓存静态资源达到离线访问的效果,和 ApplicationCache 不同,Service Worker 的所有的离线内容开发者完全可控,甚至是可以控制动态请求,第三方静态资源等。
  • 由于 Service Worker 可以离线并且在后台工作,所以可以进行 推送消息(第六章会详细说明)、后台同步资源等功能,在不久的将来,利用 Service Worker 的这一特性,甚至可以衍生出更多的 Web App 原生化的功能。

兼容性

目前(2021.12) Chrome、Firefox、Safari、Edge、Opera 都已经全面支持 Service Workers,但 IE 6~11 都不支持。 由于 Service Workers 无法通过注入 polyfill 去实现兼容,所以在你打算使用它前请先调查清楚你的网页的运行场景。

image-20211214222854699

查看浏览器是否支持 Service Workers:

if (navigator.serviceWorker) {
    // 支持 serviceWorker
}

Service Worker 生命周期

Service Worker 的工作原理主要体现在它的生命周期上,一个 Service Worker 从被注册开始,就会经历自身的一些生命周期的节点,而在这些节点都可以去做一些特定的事情,比如一些复杂的计算、缓存的写入、缓存的读取等操作。通过这些生命周期节点的联合调度,Service Worker 才能完成复杂的资源离线缓存的工作。

每个 Service Worker 都有一个独立于 Web 页面的生命周期:

  • 在主线程成功注册 Service Worker 之后,开始下载并解析执行 Service Worker 文件,执行过程中开始安装 Service Worker,在此过程中会触发 worker 线程的 install 事件。
  • 如果 install 事件回调成功执行(在 install 回调中通常会做一些缓存读写的工作,可能会存在失败的情况),则开始激活 Service Worker,在此过程中会触发 worker 线程的 activate 事件,如果 install 事件回调执行失败,则生命周期进入 Error 终结状态,终止生命周期。 如果发生这种情况,不必担心,它下次会再试一次。
  • 完成激活之后,Service Worker 就能够控制作用域下的页面的资源请求,可以监听 fetch 事件。不过,激活之后,页面需要再次加载才会受其控制。因为注册是一个异步的过程,在激活完成后,当前页面要发的请求都已经发送完了。
  • 如果在激活后 Service Worker 被 unregister 或者有新的 Service Worker 版本更新,则当前 Service Worker 生命周期完结,进入 Terminated 终结状态。

简单来说,Service Worker 生命周期分为三个阶段:注册、安装、激活,还有最重要的一个拦截事件 fetch。

需要注意以下几点:

  • Service Worker 文件只在首次注册的时候执行了一次。
  • 安装、激活流程也只是在首次执行 Service Worker 文件的时候进行了一次。
  • 首次注册成功的 Service Worker 没能拦截当前页面的请求。
  • 非首次注册的 Service Worker 可以控制当前的页面并能拦截请求。

注册 Service Workers

若要安装 Service Worker,您需要通过在页面中对其进行注册来启动安装,其中最重要的是告诉浏览器 Service Worker 脚本文件的位置。如果注册成功,Service Worker 就会被下载到客户端并尝试安装或激活。

if ('serviceWorker' in navigator) {
    window.addEventListener('DOMContentLoaded',function() {
        // sw.js 为 Service Workers 脚本文件,在其中加入 Service Workers 安装、激活以及 fetch事件的逻辑。
        // scope 表示定义 Service Worker 注册范围的 URL
        navigator.serviceWorker.register('/sw.js').then((registration) => {
            console.log(registration.scope)
        }).catch((err) => {
            console.log('注册失败!', err)
        })
    })
}

Service Worker 注册的默认作用域是与 Service Worker 脚本文件路径相对的 ./。如果 Service Worker 脚本文件位于根网域, 就意味着 Service Worker 的作用域将是整个域名。如果位于 /example/ ,则它的默认作用域为 /example/,即只能管控 URI 以 /example/ 开头( /example/page1//example/page2/)的页面。

可以通过打开 chrome://inspect/#service-workers 检查 Service Worker 是否已启用,并查看所有注册了的 Service Workers。

另外,还可以通过 chrome://serviceworker-internals 来查看 Service Worker 详情。

注意: Service Worker 脚本文件只在首次注册的时候执行了一次;安装、激活流程也只是在首次执行该文件的时候进行了一次。

用户首次访问 Service Worker 控制的网站或页面时,Service Worker 注册后,会立刻被下载。

安装 Service Worker

Service Workers 在注册成功后会在其生命周期中派发出一些事件,通过监听对应的事件在特点的时间节点上做一些事情。在 Service Worker 脚本文件中,引入了新的关键字 self 代表当前的 Service Workers 实例。

在 Service Workers 安装成功后会派发出 install 事件,在这个事件中可以执行缓存资源的逻辑,实现代码如下:

// sw.js
const CACHE_VERSION = 'v1.0.0'
const urlsToCache = [
    '/others_01.html?type=8',
    '/assets/third/utils.js',
    '/assets/js/others_01_03.js',
]

self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open(CACHE_VERSION).then((cache) => {
            console.log('安装成功:', cacheAllowlist)
            return cache.addAll(urlsToCache)
        })
    )
})

我们以所需的缓存名称调用 caches.open(),之后再调用 cache.addAll() 并传入文件数组。 这是一个 promise 链(caches.open()cache.addAll())。 event.waitUntil() 方法带有 promise 参数并使用它来判断安装所花费的时间,以及安装是否成功。

如果所有文件都成功缓存,则将安装 Service Worker。 如有任何文件无法下载,则安装步骤将失败。 这可让您依赖于所定义的所有资源,但也意味着需要对您决定在安装步骤缓存的文件列表格外留意。 定义一个过长的文件列表将会增加文件缓存失败的几率,从而导致服务工作线程未能安装。

install 事件在安装成功后立即触发,并且它只能被每个 Service Worker 调用一次。如果 Service Worker 文件有更新,则浏览器将其视为一个不同的 Service Worker,并且它将获得自己的 install 事件。

使用 Service Worker(fetch)

在安装 Service Worker 且用户转至其他页面或刷新当前页面后,Service Worker 将开始接收 fetch 事件。

// sw.js
self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.match(event.request).then((response) => {
            if (response) {
                console.log('存在缓存:', event.request.url)
                return response
            }
            return fetch(event.request)
        })
    )
})

这里定义了 fetch 事件,并且在 event.respondWith() 中,传入来自 caches.match() 的一个 promise。 此方法拦截请求,并从 Service Worker 所创建的缓存中查找该请求缓存的结果。

如果发现匹配的响应,则返回缓存的值,否则,将调用 fetch 以发出网络请求,并将从网络检索到的任何数据作为结果返回。

你也可以通过 fetch 事件,向 Service Worker 添加新的缓存:

// sw.js
self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.match(event.request).then((response) => {
            if (response) {
                console.log('存在缓存:', event.request.url)
                return response
            }
            return fetch(event.request).then((response) => {
                // 注意:必需先复制 response。
                // 因为 response 是数据流,主体只能使用一次。 
                // 由于我们想要 response 能被浏览器使用,并将其传递到缓存以供使用,因此需要复制一份副本:一份发送给浏览器,另一份则保留在缓存。
                const responseToCache = response.clone()
                if(responseToCache && responseToCache.status === 200 && responseToCache.url.match('others_01_02.js')) {
                    caches.open(CACHE_VERSION).then((cache) => {
                        cache.put(event.request, responseToCache)
                        console.log('新增缓存:', responseToCache.url)
                    })
                }
                return response
            })
        })
    )
})

注意: 必需先复制 response。因为 response 是数据流,主体只能使用一次。 由于我们想要 response 能被浏览器使用,并将其传递到缓存以供使用,因此需要备份一份副本,一份发送给浏览器,另一份则保留在缓存。

注意: Chrome 开发者工具, Application -> Cache -> Cache Storage 可查看缓存的资源。

更新 Service Worker(activate)

浏览器会在以下情况,重新触发注册、安装、激活流程:

  • 一个前往作用域内页面的导航;
  • 更新 push 和 sync 等功能事件,没有 24 小时内进行更新检查;

  • Service Worker 脚本文件的 URI 或者内容更新;

Service Worker 脚本文件更新, 会触发以下流程:

  • Service Worker 脚本文件更新后, 用户打开页面时,浏览器会尝试在后台加载定义该文件。 如果该文件与其当前所用 Service Worker 脚本文件存在字节差异,则将其视为新 Service Worker。
  • 新 Service Worker 将会启动,且将会触发 install 事件。
  • 此时,旧 Service Worker 仍控制着当前页面,因此新 Service Worker 将进入 waiting 状态。
  • 直到旧 Service Worker 对所有终端失去控制(Service Worker 管控的所有页面都关闭)时,旧 Service Worker 才会被终止,新 Service Worker 取得控制权。
  • 新 Service Worker 取得控制权后,将会触发其 activate 事件。

activate 事件常用于管理缓存(比如:删除旧版本的缓存)。因为,如果您在安装事件中清除了旧缓存,则当前页面的旧 Service Worker 可能突然无法从缓存中提供文件。

activate 事件管理缓存的具体做法为:遍历 Service Worker 中的所有缓存版本,并删除未在白名单中定义的任何缓存版本。

// sw.js
self.addEventListener('activate', (event) => {
    console.log('update06')
    event.waitUntil(
        caches.keys().then((cacheNames) => {
            const cacheAllowVersions = [CACHE_VERSION]
            return Promise.all(
                cacheNames.map((name) => {
                    if (cacheAllowVersions.indexOf(name) === -1) {
                        return caches.delete(name)
                    }
                })
            )
        })
    )
})

Service Worker 一旦更新,需要等所有的终端都关闭之后,再重新打开页面才能激活新的 Service Worker,这个过程太复杂了。如果不想等所有的终端都关闭再打开的话,可采用以下方法:

  • skipWaiting: Service Worker 在全局提供了一个 skipWaiting() 方法来跳过 waiting 状态,直接激活新的 Service Worker。

    注意: 可能会出现其他终端还没有受当前终端激活的 Service Worker 控制的情况,切回其他终端之后,Service Worker 控制页面的效果可能不符合预期。

    self.addEventListener('install', (event) => {
        self.skipWaiting()
        event.waitUntil(
            caches.open(CACHE_VERSION).then((cache) => {
                console.log('安装成功:', cacheAllowlist)
                return cache.addAll(urlsToCache)
            })
        )
    })
    
  • 注销 Service Worker:

    navigator.serviceWorker.getRegistrations().then(function(registrations) {
        for(let registration of registrations) {
            registration.unregister()
        }
    })
    
  • 通过浏览器管理: 在 Chrome 浏览器的 chrome://serviceworker-internals/ ,或者在 Chrome 开发者工具 Application -> Service Workers 注销 Service Worker。

参考资料

使用 Application Cache

A Beginner's Guide to Using the Application Cache

Service Worker: 简介

Service Worker 生命周期

Service Worker

MDN Service_Worker_API

© lizhao all right reserved,powered by Gitbook文件修订时间: 2026-06-01 22:49:44

results matching ""

    No results matching ""