vite-plugin-sw-offline 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.js ADDED
@@ -0,0 +1,430 @@
1
+ /**
2
+ * vite-plugin-sw-offline:单一 Vite 插件(dev 直出 SW / 离线页;build 写入 dist 并注入)
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ const PKG_ROOT = path.join(__dirname, '..');
9
+ const RUNTIME_DIR = path.join(PKG_ROOT, 'runtime');
10
+ const DEFAULT_OFFLINE_HTML = path.join(PKG_ROOT, 'templates', 'default', 'offline.html');
11
+ /** 默认离线页背景(与模板、runtime/sw.js 中 /static/offline-bg.jpg 一致) */
12
+ const DEFAULT_OFFLINE_BG_JPG = path.join(PKG_ROOT, 'assets', 'offline-bg.jpg');
13
+
14
+ const LOG = '[vite-plugin-sw-offline]';
15
+
16
+ /** 由本包提供、不再从业务项目 public/ 读取的文件名 */
17
+ const PACKAGE_SW_ASSET_NAMES = new Set(['sw.js', 'sw-register.js', 'offline.html', 'sw-noop.js']);
18
+
19
+ function resolveOfflineTemplatePath(swConfig) {
20
+ const custom = swConfig && swConfig.offlineTemplatePath;
21
+ if (custom && typeof custom === 'string') {
22
+ const abs = path.isAbsolute(custom) ? custom : path.resolve(process.cwd(), custom);
23
+ if (fs.existsSync(abs)) {
24
+ return abs;
25
+ }
26
+ console.warn(LOG, 'offlineTemplatePath not found, using default:', abs);
27
+ }
28
+ return DEFAULT_OFFLINE_HTML;
29
+ }
30
+
31
+ /**
32
+ * 离线页 logo 写成站点根路径,避免当前页在子路径时相对路径解析错;保留 query(如 ?v=1)
33
+ */
34
+ function normalizeOfflineLogoPath(raw) {
35
+ if (!raw || typeof raw !== 'string') return '';
36
+ const trimmed = raw.trim();
37
+ if (!trimmed) return '';
38
+ if (/^https?:\/\//i.test(trimmed)) return trimmed;
39
+ const q = trimmed.indexOf('?');
40
+ const pathOnly = q === -1 ? trimmed : trimmed.slice(0, q);
41
+ const query = q === -1 ? '' : trimmed.slice(q);
42
+ const withSlash = pathOnly.startsWith('/') ? pathOnly : `/${pathOnly}`;
43
+ return withSlash + query;
44
+ }
45
+
46
+ function injectOfflineHtml(content, swConfig) {
47
+ const logo = normalizeOfflineLogoPath((swConfig && swConfig.offlineLogoPath) || '');
48
+ if (logo) {
49
+ content = content.replace(/__OFFLINE_LOGO__/g, logo);
50
+ }
51
+ if (swConfig && swConfig.offlineDomain) {
52
+ content = content.replace(/__OFFLINE_DOMAIN__/g, swConfig.offlineDomain);
53
+ }
54
+ return content;
55
+ }
56
+
57
+ /** 未传入 cacheableApiPaths 时使用的默认白名单(与历史 sw.js 一致) */
58
+ const DEFAULT_CACHEABLE_API_PATHS = [
59
+ '/config/keFu.do',
60
+ '/config/queryConfig.do',
61
+ '/config/queryQuestion.do',
62
+ '/config/queryTradeZD.do',
63
+ '/news/getNewsList.do',
64
+ '/site/getBannerByPlat.do',
65
+ '/user/position/list.do',
66
+ '/stock/getStockSort.do',
67
+ '/user/getUserInfo.do'
68
+ ];
69
+
70
+ function resolveCacheableApiPaths(swConfig) {
71
+ const p = swConfig && swConfig.cacheableApiPaths;
72
+ if (Array.isArray(p) && p.length > 0) {
73
+ return p.filter((x) => typeof x === 'string' && x.trim().length > 0).map((x) => x.trim());
74
+ }
75
+ return DEFAULT_CACHEABLE_API_PATHS.slice();
76
+ }
77
+
78
+ function readRuntimeFile(name) {
79
+ return fs.readFileSync(path.join(RUNTIME_DIR, name), 'utf-8');
80
+ }
81
+
82
+ /** Service Worker 通用数值默认(与 runtime/sw.js 占位符一致) */
83
+ const DEFAULT_SERVICE_WORKER = {
84
+ /** fetchWithTimeout 用于可缓存 API */
85
+ apiTimeout: 30000,
86
+ /** 预留:图片缓存 TTL 语义,当前 sw 未读此常量 */
87
+ imageCacheMaxAge: 30 * 24 * 60 * 60 * 1000,
88
+ imageCacheMaxItems: 500,
89
+ staticCacheMaxItems: 200,
90
+ networkProbeTimeout: 15000
91
+ };
92
+
93
+ /**
94
+ * 合并 serviceWorker 与默认值(非法或过小则回退默认)
95
+ */
96
+ function resolveServiceWorker(swConfig) {
97
+ const raw =
98
+ swConfig && swConfig.serviceWorker && typeof swConfig.serviceWorker === 'object'
99
+ ? { ...swConfig.serviceWorker }
100
+ : {};
101
+ const pick = (key, min) => {
102
+ const v = raw[key];
103
+ const d = DEFAULT_SERVICE_WORKER[key];
104
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < min) {
105
+ return d;
106
+ }
107
+ return Math.floor(v);
108
+ };
109
+ return {
110
+ apiTimeout: pick('apiTimeout', 1000),
111
+ imageCacheMaxAge: pick('imageCacheMaxAge', 1),
112
+ imageCacheMaxItems: pick('imageCacheMaxItems', 1),
113
+ staticCacheMaxItems: pick('staticCacheMaxItems', 1),
114
+ networkProbeTimeout: pick('networkProbeTimeout', 1000)
115
+ };
116
+ }
117
+
118
+ function injectServiceWorkerPlaceholders(content, swConfig) {
119
+ const rt = resolveServiceWorker(swConfig);
120
+ return content
121
+ .replace('__SW_RT_API_TIMEOUT__', String(rt.apiTimeout))
122
+ .replace('__SW_RT_IMAGE_CACHE_MAX_AGE__', String(rt.imageCacheMaxAge))
123
+ .replace('__SW_RT_IMAGE_CACHE_MAX_ITEMS__', String(rt.imageCacheMaxItems))
124
+ .replace('__SW_RT_STATIC_CACHE_MAX_ITEMS__', String(rt.staticCacheMaxItems))
125
+ .replace('__SW_RT_NETWORK_PROBE_TIMEOUT__', String(rt.networkProbeTimeout));
126
+ }
127
+
128
+ function applySwJsPlaceholders(content, swConfig) {
129
+ content = injectServiceWorkerPlaceholders(content, swConfig);
130
+ const apiPathsJson = JSON.stringify(resolveCacheableApiPaths(swConfig));
131
+ content = content.replace(
132
+ 'const CACHEABLE_API_PATHS = __CACHEABLE_API_PATHS__;',
133
+ 'const CACHEABLE_API_PATHS = ' + apiPathsJson + ';'
134
+ );
135
+ const probeUrl = swConfig.networkProbeUrl;
136
+ if (probeUrl) {
137
+ content = content.replace(
138
+ "const NETWORK_PROBE_URL = '__NETWORK_PROBE_URL__';",
139
+ 'const NETWORK_PROBE_URL = ' + JSON.stringify(String(probeUrl)) + ';'
140
+ );
141
+ }
142
+ if (swConfig.swVersion) {
143
+ content = content.replace(
144
+ "const SW_BUILD_VERSION = '__SW_VERSION__';",
145
+ 'const SW_BUILD_VERSION = ' + JSON.stringify(String(swConfig.swVersion)) + ';'
146
+ );
147
+ }
148
+ const logoForSw = normalizeOfflineLogoPath((swConfig && swConfig.offlineLogoPath) || '');
149
+ content = content.replace(
150
+ "const OFFLINE_LOGO_PATH = '__OFFLINE_LOGO_PATH__';",
151
+ 'const OFFLINE_LOGO_PATH = ' + JSON.stringify(logoForSw) + ';'
152
+ );
153
+ return content;
154
+ }
155
+
156
+ function injectSwRegisterVersion(content, swVersion) {
157
+ if (!swVersion) return content;
158
+ return content.replace(
159
+ "var SW_VERSION = '__SW_VERSION__';",
160
+ 'var SW_VERSION = ' + JSON.stringify(String(swVersion)) + ';'
161
+ );
162
+ }
163
+
164
+ /**
165
+ * 解析 `networkProbeUrl`:支持字符串,或 `(ctx) => string` 在生成 sw.js 时调用。
166
+ * 未传、非函数非字符串、或解析结果为空 → 返回 ''(不注入,SW 内走默认同源探测)。
167
+ */
168
+ function resolveNetworkProbeUrlForInject(swConfig, probeCtx) {
169
+ const raw = swConfig && swConfig.networkProbeUrl;
170
+ if (raw == null) return '';
171
+ if (typeof raw === 'function') {
172
+ try {
173
+ const out = raw.call(swConfig, probeCtx || {});
174
+ return out == null || out === '' ? '' : String(out).trim();
175
+ } catch (e) {
176
+ console.warn(LOG, 'networkProbeUrl() failed:', e.message);
177
+ return '';
178
+ }
179
+ }
180
+ if (typeof raw === 'string') return raw.trim();
181
+ if (raw != null) {
182
+ console.warn(LOG, 'networkProbeUrl must be string or function, got:', typeof raw);
183
+ }
184
+ return '';
185
+ }
186
+
187
+ /** 生成注入 sw.js 用的配置:把 networkProbeUrl 回调解析为最终字符串,避免把函数带进其它逻辑 */
188
+ function swConfigForSwJsEmit(swConfig, probeCtx) {
189
+ const url = resolveNetworkProbeUrlForInject(swConfig, probeCtx);
190
+ return { ...swConfig, networkProbeUrl: url };
191
+ }
192
+
193
+ function loadInjectedSwJs(swConfig, probeCtx) {
194
+ return applySwJsPlaceholders(readRuntimeFile('sw.js'), swConfigForSwJsEmit(swConfig, probeCtx));
195
+ }
196
+
197
+ function loadInjectedSwRegister(swConfig) {
198
+ return injectSwRegisterVersion(readRuntimeFile('sw-register.js'), swConfig.swVersion);
199
+ }
200
+
201
+ function loadInjectedOfflineHtml(swConfig) {
202
+ const raw = fs.readFileSync(resolveOfflineTemplatePath(swConfig), 'utf-8');
203
+ return injectOfflineHtml(raw, swConfig);
204
+ }
205
+
206
+ /** 将包内默认背景图写入 dist/static/offline-bg.jpg(与 SW 预缓存路径一致) */
207
+ function copyDefaultOfflineBackground(targetDir, realOutDirLabel) {
208
+ if (!fs.existsSync(DEFAULT_OFFLINE_BG_JPG)) {
209
+ console.warn(LOG, 'package assets/offline-bg.jpg missing, skip static/offline-bg.jpg');
210
+ return;
211
+ }
212
+ const staticDir = path.join(targetDir, 'static');
213
+ fs.mkdirSync(staticDir, { recursive: true });
214
+ const dest = path.join(staticDir, 'offline-bg.jpg');
215
+ fs.copyFileSync(DEFAULT_OFFLINE_BG_JPG, dest);
216
+ console.log(LOG, `static/offline-bg.jpg -> ${realOutDirLabel}/static/offline-bg.jpg (from package)`);
217
+ }
218
+
219
+ /**
220
+ * SW 注册/脚本 URL 缓存破坏用版本号:未传 `options.swVersion` 时,在插件实例化时生成一次(同一次 dev/build 内不变)。
221
+ */
222
+ function resolveSwVersion(options) {
223
+ const v = options && options.swVersion;
224
+ if (v != null && String(v).trim() !== '') {
225
+ return String(v).trim();
226
+ }
227
+ return String(Date.now());
228
+ }
229
+
230
+ /**
231
+ * 单一 Vite 插件:开发服直出 SW / 离线页 / 默认背景图;构建写入 dist 并注入占位符(兼容 uni --outDir)。
232
+ * 选项字段见下方 DEFAULT_* 与各 replace;`outDir` / `fallbackOutDir` 仅用于解析产出目录,不会写入 SW。
233
+ *
234
+ * @param {Object} [options]
235
+ */
236
+ function vitePluginSwOffline(options = {}) {
237
+ const fallbackOutDir = options.outDir || options.fallbackOutDir || 'dist';
238
+ const swVersion = resolveSwVersion(options);
239
+ const swConfig = { ...options, swVersion };
240
+ delete swConfig.outDir;
241
+ delete swConfig.fallbackOutDir;
242
+
243
+ /** @type {{ command: string, mode: string }} */
244
+ let probeCtx = { command: 'serve', mode: 'development' };
245
+
246
+ const serveMap = {
247
+ '/sw.js': 'application/javascript',
248
+ '/sw-noop.js': 'application/javascript',
249
+ '/sw-register.js': 'application/javascript',
250
+ '/offline.html': 'text/html; charset=utf-8'
251
+ };
252
+
253
+ return {
254
+ name: 'vite-plugin-sw-offline',
255
+ enforce: 'pre',
256
+ configResolved(config) {
257
+ probeCtx = { command: config.command, mode: config.mode };
258
+ if (!options.swVersion || String(options.swVersion).trim() === '') {
259
+ console.log(LOG, 'swVersion (auto):', swVersion);
260
+ }
261
+ },
262
+ configureServer(server) {
263
+ let devReadyLogged = false;
264
+ const logDevReady = () => {
265
+ if (devReadyLogged) return;
266
+ devReadyLogged = true;
267
+ const ctx = { ...probeCtx, phase: 'serve' };
268
+ const probe = resolveNetworkProbeUrlForInject(swConfig, ctx);
269
+ console.log(
270
+ LOG,
271
+ 'dev: middleware serving /sw.js, /sw-register.js, /offline.html, /sw-noop.js, /static/offline-bg.jpg'
272
+ );
273
+ if (probe) {
274
+ console.log(LOG, 'dev: networkProbeUrl injected ->', probe);
275
+ } else {
276
+ console.log(LOG, 'dev: networkProbeUrl empty, SW will probe same-origin sw.js');
277
+ }
278
+ };
279
+ if (server.httpServer) {
280
+ if (server.httpServer.listening) {
281
+ logDevReady();
282
+ } else {
283
+ server.httpServer.once('listening', logDevReady);
284
+ }
285
+ } else {
286
+ logDevReady();
287
+ }
288
+
289
+ server.middlewares.use((req, res, next) => {
290
+ const urlPath = req.url.split('?')[0];
291
+ if (req.method === 'GET' && urlPath === '/static/offline-bg.jpg') {
292
+ try {
293
+ if (fs.existsSync(DEFAULT_OFFLINE_BG_JPG)) {
294
+ res.setHeader('Content-Type', 'image/jpeg');
295
+ res.setHeader('Cache-Control', 'no-store');
296
+ res.end(fs.readFileSync(DEFAULT_OFFLINE_BG_JPG));
297
+ return;
298
+ }
299
+ console.warn(LOG, 'default offline-bg.jpg missing in package');
300
+ } catch (e) {
301
+ console.warn(LOG, 'serve offline-bg.jpg failed:', e.message);
302
+ }
303
+ }
304
+
305
+ const contentType = serveMap[urlPath];
306
+ if (!contentType) {
307
+ next();
308
+ return;
309
+ }
310
+
311
+ let content;
312
+ try {
313
+ if (urlPath === '/offline.html') {
314
+ content = loadInjectedOfflineHtml(swConfig);
315
+ } else if (urlPath === '/sw.js') {
316
+ content = loadInjectedSwJs(swConfig, { ...probeCtx, phase: 'serve' });
317
+ } else if (urlPath === '/sw-register.js') {
318
+ content = loadInjectedSwRegister(swConfig);
319
+ } else {
320
+ content = readRuntimeFile(urlPath.slice(1));
321
+ }
322
+ } catch (e) {
323
+ console.warn(LOG, 'serve failed for', urlPath, e.message);
324
+ next();
325
+ return;
326
+ }
327
+
328
+ res.setHeader('Content-Type', contentType);
329
+ res.setHeader('Cache-Control', 'no-store');
330
+ res.end(content);
331
+ });
332
+ },
333
+ transformIndexHtml(html) {
334
+ if (swConfig.swVersion) {
335
+ return html.replace(/__SW_REGISTER_VERSION__/g, swConfig.swVersion);
336
+ }
337
+ return html;
338
+ },
339
+ closeBundle() {
340
+ const args = process.argv;
341
+ const outDirIdx = args.indexOf('--outDir');
342
+ const cliOutDir = outDirIdx !== -1 && args[outDirIdx + 1] ? args[outDirIdx + 1] : null;
343
+ const realOutDir = cliOutDir || fallbackOutDir;
344
+
345
+ const publicDir = path.resolve(process.cwd(), 'public');
346
+ const targetDir = path.resolve(process.cwd(), realOutDir);
347
+ console.log(LOG, 'copy-public target:', realOutDir);
348
+
349
+ if (fs.existsSync(publicDir) && fs.existsSync(targetDir)) {
350
+ const files = fs.readdirSync(publicDir);
351
+ files.forEach((file) => {
352
+ if (PACKAGE_SW_ASSET_NAMES.has(file)) {
353
+ return;
354
+ }
355
+ const srcFile = path.join(publicDir, file);
356
+ const destFile = path.join(targetDir, file);
357
+ if (!fs.statSync(srcFile).isFile()) {
358
+ return;
359
+ }
360
+ if (file === 'robots.txt' && swConfig.robotsTxtContent) {
361
+ fs.writeFileSync(destFile, swConfig.robotsTxtContent, 'utf-8');
362
+ console.log(LOG, `${file} -> ${realOutDir}/${file} (robots override)`);
363
+ } else {
364
+ fs.copyFileSync(srcFile, destFile);
365
+ console.log(LOG, `${file} -> ${realOutDir}/${file}`);
366
+ }
367
+ });
368
+ } else {
369
+ console.warn(LOG, 'copy-public skipped: public or target missing', targetDir);
370
+ }
371
+
372
+ if (fs.existsSync(targetDir)) {
373
+ fs.writeFileSync(path.join(targetDir, 'sw.js'), loadInjectedSwJs(swConfig, { ...probeCtx, phase: 'build' }), 'utf-8');
374
+ console.log(LOG, `sw.js -> ${realOutDir}/sw.js (injected)`);
375
+
376
+ fs.writeFileSync(
377
+ path.join(targetDir, 'sw-register.js'),
378
+ loadInjectedSwRegister(swConfig),
379
+ 'utf-8'
380
+ );
381
+ console.log(LOG, `sw-register.js -> ${realOutDir}/sw-register.js`);
382
+
383
+ fs.writeFileSync(path.join(targetDir, 'offline.html'), loadInjectedOfflineHtml(swConfig), 'utf-8');
384
+ console.log(LOG, `offline.html -> ${realOutDir}/offline.html`);
385
+
386
+ fs.writeFileSync(path.join(targetDir, 'sw-noop.js'), readRuntimeFile('sw-noop.js'), 'utf-8');
387
+ console.log(LOG, `sw-noop.js -> ${realOutDir}/sw-noop.js`);
388
+
389
+ copyDefaultOfflineBackground(targetDir, realOutDir);
390
+ }
391
+
392
+ if (swConfig.swVersion) {
393
+ const indexHtml = path.join(targetDir, 'index.html');
394
+ if (fs.existsSync(indexHtml)) {
395
+ let html = fs.readFileSync(indexHtml, 'utf-8');
396
+ html = html.replace(/__SW_REGISTER_VERSION__/g, swConfig.swVersion);
397
+ fs.writeFileSync(indexHtml, html, 'utf-8');
398
+ console.log(LOG, 'index.html: injected sw-register version', swConfig.swVersion);
399
+ }
400
+ }
401
+ }
402
+ };
403
+ }
404
+
405
+ /** 包内 runtime 目录(供文档或高级用法) */
406
+ function getRuntimeDir() {
407
+ return RUNTIME_DIR;
408
+ }
409
+
410
+ /** 默认离线页模板路径 */
411
+ function getDefaultOfflineTemplatePath() {
412
+ return DEFAULT_OFFLINE_HTML;
413
+ }
414
+
415
+ /** 包内默认离线背景图路径 */
416
+ function getDefaultOfflineBackgroundPath() {
417
+ return DEFAULT_OFFLINE_BG_JPG;
418
+ }
419
+
420
+ module.exports = {
421
+ vitePluginSwOffline,
422
+ getRuntimeDir,
423
+ getDefaultOfflineTemplatePath,
424
+ getDefaultOfflineBackgroundPath,
425
+ getDefaultCacheableApiPaths: () => DEFAULT_CACHEABLE_API_PATHS.slice(),
426
+ getDefaultServiceWorker: () => ({ ...DEFAULT_SERVICE_WORKER }),
427
+ resolveServiceWorker,
428
+ normalizeOfflineLogoPath,
429
+ injectOfflineHtml
430
+ };
@@ -0,0 +1,251 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <title>网络连接失败</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ body {
15
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
16
+ background-color: #131529;
17
+ background-image: url('/static/offline-bg.jpg');
18
+ background-size: cover;
19
+ background-position: top center;
20
+ background-repeat: no-repeat;
21
+ min-height: 100vh;
22
+ display: flex;
23
+ flex-direction: column;
24
+ align-items: center;
25
+ justify-content: center;
26
+ padding: 0 20px;
27
+ color: #ffffff;
28
+ }
29
+
30
+ .container {
31
+ text-align: center;
32
+ max-width: 400px;
33
+ width: 100%;
34
+ display: flex;
35
+ flex-direction: column;
36
+ align-items: center;
37
+ }
38
+
39
+ /* 品牌 LOGO */
40
+ .logo-wrap {
41
+ margin-bottom: 20px;
42
+ }
43
+
44
+ .logo-wrap img {
45
+ width: 120px;
46
+ height: auto;
47
+ display: block;
48
+ border-radius: 20px;
49
+ }
50
+
51
+ /* 模拟浏览器搜索框 */
52
+ .search-bar {
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: center;
56
+ width: 324px;
57
+ height: 36px;
58
+ border-radius: 18px;
59
+ border: 1px solid #447AFE;
60
+ background: #0E1628;
61
+ margin-bottom: 24px;
62
+ }
63
+
64
+ .search-bar-input {
65
+ width: 260px;
66
+ height: 28px;
67
+ border-radius: 18px;
68
+ background: #FFF;
69
+ display: flex;
70
+ align-items: center;
71
+ padding: 0 14px;
72
+ color: #4077FF;
73
+ font-family: "PingFang SC", -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
74
+ font-size: 20px;
75
+ font-weight: 600;
76
+ line-height: normal;
77
+ white-space: nowrap;
78
+ overflow: hidden;
79
+ text-overflow: ellipsis;
80
+ margin-left: 5px;
81
+ }
82
+
83
+ .search-bar-btn {
84
+ flex: 1;
85
+ height: 100%;
86
+ display: flex;
87
+ align-items: center;
88
+ justify-content: center;
89
+ }
90
+
91
+ .search-bar-btn svg {
92
+ width: 18px;
93
+ height: 18px;
94
+ color: rgba(255, 255, 255, 0.7);
95
+ }
96
+
97
+ /* 打字机光标闪烁 */
98
+ .typing-cursor {
99
+ display: inline-block;
100
+ color: #4077FF;
101
+ font-weight: 400;
102
+ animation: blink 0.8s step-end infinite;
103
+ margin-left: 1px;
104
+ }
105
+
106
+ @keyframes blink {
107
+ 0%, 100% { opacity: 1; }
108
+ 50% { opacity: 0; }
109
+ }
110
+
111
+ h1 {
112
+ font-size: 22px;
113
+ font-weight: 600;
114
+ margin-bottom: 16px;
115
+ color: #ffffff;
116
+ }
117
+
118
+ p {
119
+ font-size: 14px;
120
+ color: rgba(255, 255, 255, 0.6);
121
+ margin-bottom: 40px;
122
+ line-height: 1.8;
123
+ padding: 0 10px;
124
+ }
125
+
126
+ .buttons {
127
+ display: flex;
128
+ flex-direction: row;
129
+ align-items: center;
130
+ justify-content: center;
131
+ gap: 16px;
132
+ width: 100%;
133
+ }
134
+
135
+ .btn {
136
+ flex: 1;
137
+ max-width: 170px;
138
+ display: inline-flex;
139
+ align-items: center;
140
+ justify-content: center;
141
+ padding: 14px 20px;
142
+ font-size: 15px;
143
+ font-weight: 500;
144
+ border: none;
145
+ border-radius: 100px;
146
+ cursor: pointer;
147
+ text-decoration: none;
148
+ transition: opacity 0.2s;
149
+ -webkit-tap-highlight-color: transparent;
150
+ }
151
+
152
+ .btn:active {
153
+ opacity: 0.8;
154
+ }
155
+
156
+ .btn-secondary {
157
+ background: rgba(255, 255, 255, 0.1);
158
+ color: rgba(255, 255, 255, 0.85);
159
+ border: 1px solid rgba(255, 255, 255, 0.12);
160
+ }
161
+
162
+ .btn-primary {
163
+ background: linear-gradient(180deg, #6591FD 0%, #3D75FF 100%);
164
+ color: #ffffff;
165
+ box-shadow: 0 4px 16px rgba(74, 124, 255, 0.3);
166
+ }
167
+ </style>
168
+ </head>
169
+ <body>
170
+ <div class="container">
171
+ <!-- 品牌 LOGO(构建时注入路径,加载失败则隐藏) -->
172
+ <div class="logo-wrap" id="logoWrap">
173
+ <img src="__OFFLINE_LOGO__" alt="" onerror="document.getElementById('logoWrap').style.display='none'" />
174
+ </div>
175
+
176
+ <!-- 模拟浏览器搜索框 -->
177
+ <div class="search-bar">
178
+ <div class="search-bar-input">
179
+ <span id="typingText"></span><span class="typing-cursor">|</span>
180
+ </div>
181
+ <div class="search-bar-btn">
182
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
183
+ <circle cx="11" cy="11" r="7"></circle>
184
+ <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
185
+ </svg>
186
+ </div>
187
+ </div>
188
+
189
+ <h1>网络连接失败</h1>
190
+ <p>请检查网络设置后重试,或联系客服获取帮助,若无法重启,请您耐心等待并稍后再进行尝试。</p>
191
+
192
+ <div class="buttons">
193
+ <a class="btn btn-secondary" href="javascript:void(0)" id="contactBtn">联系客服</a>
194
+ <button class="btn btn-primary" onclick="location.reload()">重新加载</button>
195
+ </div>
196
+ </div>
197
+
198
+ <script>
199
+ // 打字机动画
200
+ (function() {
201
+ var text = '__OFFLINE_DOMAIN__';
202
+ var el = document.getElementById('typingText');
203
+ var i = 0;
204
+ var isDeleting = false;
205
+ var typeSpeed = 120;
206
+ var deleteSpeed = 60;
207
+ var pauseAfterType = 1800;
208
+ var pauseAfterDelete = 400;
209
+
210
+ function tick() {
211
+ if (!isDeleting) {
212
+ el.textContent = text.slice(0, i + 1);
213
+ i++;
214
+ if (i === text.length) {
215
+ isDeleting = true;
216
+ setTimeout(tick, pauseAfterType);
217
+ return;
218
+ }
219
+ setTimeout(tick, typeSpeed);
220
+ } else {
221
+ el.textContent = text.slice(0, i - 1);
222
+ i--;
223
+ if (i === 0) {
224
+ isDeleting = false;
225
+ setTimeout(tick, pauseAfterDelete);
226
+ return;
227
+ }
228
+ setTimeout(tick, deleteSpeed);
229
+ }
230
+ }
231
+
232
+ setTimeout(tick, 600);
233
+ })();
234
+
235
+ // 尝试从 localStorage 获取客服链接
236
+ document.getElementById('contactBtn').addEventListener('click', function() {
237
+ var customerServiceUrl = localStorage.getItem('customerServiceUrl');
238
+ if (customerServiceUrl) {
239
+ window.open(customerServiceUrl, '_blank');
240
+ } else {
241
+ alert('请在网络恢复后联系客服');
242
+ }
243
+ });
244
+
245
+ // 监听网络恢复
246
+ window.addEventListener('online', function() {
247
+ location.reload();
248
+ });
249
+ </script>
250
+ </body>
251
+ </html>