@musnows/scriverse 1.1.1 → 1.1.3
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/README.en.md +3 -0
- package/README.md +3 -0
- package/dist/ai-response-limit.js +12 -0
- package/dist/ai-response-limit.js.map +1 -0
- package/dist/ai.js +41 -17
- package/dist/ai.js.map +1 -1
- package/dist/app.js +24 -1
- package/dist/app.js.map +1 -1
- package/dist/public/app.js +40 -4
- package/dist/public/index.html +2 -1
- package/dist/public/mobile-app-runtime.js +662 -0
- package/dist/public/mobile-app.css +19 -0
- package/dist/public/mobile-offline-api.js +196 -0
- package/dist/public/mobile-service-worker.js +82 -0
- package/dist/public/mobile-sync-client.js +292 -0
- package/dist/public/mobile-sync-store.js +1004 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import { createMobileOfflineApi } from "/mobile-offline-api.js?v=20260922-mobile-offline-api-v1";
|
|
2
|
+
import { createMobileSyncClient } from "/mobile-sync-client.js?v=20260922-mobile-sync-client-v2";
|
|
3
|
+
import { MobileSyncStore } from "/mobile-sync-store.js?v=20260922-mobile-sync-store-v1";
|
|
4
|
+
|
|
5
|
+
const MOBILE_QUERY_KEY = "scriverseMobile";
|
|
6
|
+
const MOBILE_SESSION_KEY = "scriverse.mobile.session.v1";
|
|
7
|
+
const MOBILE_PROFILE_KEY = "scriverse.mobile.profile.v1";
|
|
8
|
+
const SYNC_PROTOCOL = { min: 1, max: 1 };
|
|
9
|
+
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
10
|
+
const OFFLINE_ACCESS_ROLES = new Set(["admin", "owner"]);
|
|
11
|
+
const MOBILE_LAUNCHER_URL = "https://localhost/?scriverseMobileLauncher=1&changeServer=1";
|
|
12
|
+
|
|
13
|
+
function isNativeMobileShell() {
|
|
14
|
+
try { return globalThis.Capacitor?.isNativePlatform?.() === true; } catch { return false; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function storageValue(storage, key) {
|
|
18
|
+
try { return storage?.getItem(key) ?? null; } catch { return null; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeStorageValue(storage, key, value) {
|
|
22
|
+
try { storage?.setItem(key, value); } catch { /* 存储不可用时保留当前运行状态 */ }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readJson(storage, key) {
|
|
26
|
+
const raw = storageValue(storage, key);
|
|
27
|
+
if (!raw) return null;
|
|
28
|
+
try {
|
|
29
|
+
const value = JSON.parse(raw);
|
|
30
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isUuid(value) {
|
|
37
|
+
return typeof value === "string"
|
|
38
|
+
&& /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function newUuid() {
|
|
42
|
+
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
|
43
|
+
const bytes = new Uint8Array(16);
|
|
44
|
+
globalThis.crypto?.getRandomValues?.(bytes);
|
|
45
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
46
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
47
|
+
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
48
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeUser(value) {
|
|
52
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !isUuid(value.userId)) return null;
|
|
53
|
+
return structuredClone(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function offlineSessionFromRecord(record) {
|
|
57
|
+
const user = normalizeUser(record?.user);
|
|
58
|
+
if (!user) return null;
|
|
59
|
+
return {
|
|
60
|
+
authenticated: true,
|
|
61
|
+
user,
|
|
62
|
+
csrfToken: null,
|
|
63
|
+
registrationMode: record.registrationMode === "invite" || record.registrationMode === "open"
|
|
64
|
+
? record.registrationMode
|
|
65
|
+
: "disabled",
|
|
66
|
+
setupRequired: false,
|
|
67
|
+
setupTokenRequired: false,
|
|
68
|
+
bootId: "mobile-offline",
|
|
69
|
+
offline: true
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isMobileAppRuntime(locationRef = globalThis.location) {
|
|
74
|
+
try {
|
|
75
|
+
return new URL(locationRef?.href ?? "https://scriverse.invalid/").searchParams.get(MOBILE_QUERY_KEY) === "1";
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function mobileOfflineHealth() {
|
|
82
|
+
return {
|
|
83
|
+
status: "ok",
|
|
84
|
+
version: "mobile-offline",
|
|
85
|
+
syncProtocol: SYNC_PROTOCOL,
|
|
86
|
+
development: false
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class MobileOfflineRuntime {
|
|
91
|
+
constructor({
|
|
92
|
+
fetchImpl = globalThis.fetch,
|
|
93
|
+
locationRef = globalThis.location,
|
|
94
|
+
storage = globalThis.localStorage
|
|
95
|
+
} = {}) {
|
|
96
|
+
this.fetchImpl = typeof fetchImpl === "function" ? fetchImpl.bind(globalThis) : null;
|
|
97
|
+
this.location = locationRef;
|
|
98
|
+
this.storage = storage;
|
|
99
|
+
this.enabled = isMobileAppRuntime(locationRef);
|
|
100
|
+
this.offline = false;
|
|
101
|
+
this.session = null;
|
|
102
|
+
this.csrfToken = null;
|
|
103
|
+
this.profileId = this.readProfileId();
|
|
104
|
+
this.store = null;
|
|
105
|
+
this.client = null;
|
|
106
|
+
this.api = null;
|
|
107
|
+
this.clientPromise = null;
|
|
108
|
+
this.bulkDownloadPromise = null;
|
|
109
|
+
this.bulkDownloadState = null;
|
|
110
|
+
this.onlineHandler = () => { void this.reconnect(); };
|
|
111
|
+
if (this.enabled) {
|
|
112
|
+
globalThis.addEventListener?.("online", this.onlineHandler);
|
|
113
|
+
this.registerServiceWorker();
|
|
114
|
+
this.installUi();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
readProfileId() {
|
|
119
|
+
const stored = storageValue(this.storage, MOBILE_PROFILE_KEY);
|
|
120
|
+
if (isUuid(stored)) return stored;
|
|
121
|
+
const profileId = newUuid();
|
|
122
|
+
writeStorageValue(this.storage, MOBILE_PROFILE_KEY, profileId);
|
|
123
|
+
return profileId;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
cachedSession() {
|
|
127
|
+
return offlineSessionFromRecord(readJson(this.storage, MOBILE_SESSION_KEY));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async getOfflineSession() {
|
|
131
|
+
if (!this.enabled) return null;
|
|
132
|
+
const session = this.cachedSession();
|
|
133
|
+
if (!session) return null;
|
|
134
|
+
await this.activateOffline(session);
|
|
135
|
+
return session;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
setSession(session) {
|
|
139
|
+
if (!this.enabled || session?.authenticated !== true) return;
|
|
140
|
+
const user = normalizeUser(session.user);
|
|
141
|
+
if (!user) return;
|
|
142
|
+
if (this.session?.user?.userId && this.session.user.userId !== user.userId) {
|
|
143
|
+
this.disposeClient();
|
|
144
|
+
}
|
|
145
|
+
this.session = { ...session, user };
|
|
146
|
+
this.csrfToken = typeof session.csrfToken === "string" ? session.csrfToken : null;
|
|
147
|
+
writeStorageValue(this.storage, MOBILE_SESSION_KEY, JSON.stringify({
|
|
148
|
+
user,
|
|
149
|
+
registrationMode: session.registrationMode,
|
|
150
|
+
updatedAt: new Date().toISOString()
|
|
151
|
+
}));
|
|
152
|
+
void this.ensureClient();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async activateOffline(session = this.cachedSession()) {
|
|
156
|
+
if (!this.enabled || !session?.user?.userId) return null;
|
|
157
|
+
this.offline = true;
|
|
158
|
+
this.setSession(session);
|
|
159
|
+
await this.ensureClient();
|
|
160
|
+
this.dispatchState("offline");
|
|
161
|
+
return session;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async ensureClient() {
|
|
165
|
+
if (!this.enabled || !this.session?.user?.userId) return null;
|
|
166
|
+
if (this.clientPromise) return this.clientPromise;
|
|
167
|
+
this.clientPromise = (async () => {
|
|
168
|
+
if (!this.store || this.store.userId !== this.session.user.userId) {
|
|
169
|
+
this.disposeClient();
|
|
170
|
+
this.store = new MobileSyncStore({ profileId: this.profileId, userId: this.session.user.userId });
|
|
171
|
+
await this.store.open();
|
|
172
|
+
await this.store.recoverSyncing();
|
|
173
|
+
}
|
|
174
|
+
if (!this.client) {
|
|
175
|
+
this.client = await createMobileSyncClient({
|
|
176
|
+
user: this.session.user,
|
|
177
|
+
store: this.store,
|
|
178
|
+
csrfToken: () => this.csrfToken,
|
|
179
|
+
fetchImpl: this.fetchImpl ?? globalThis.fetch
|
|
180
|
+
});
|
|
181
|
+
this.api = createMobileOfflineApi({ store: this.store, client: this.client });
|
|
182
|
+
if (!this.offline && globalThis.navigator?.onLine !== false) {
|
|
183
|
+
void this.client.syncAll().catch((error) => this.dispatchState("sync-failed", null, error));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return this.client;
|
|
187
|
+
})().finally(() => {
|
|
188
|
+
this.clientPromise = null;
|
|
189
|
+
});
|
|
190
|
+
return this.clientPromise;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
disposeClient() {
|
|
194
|
+
this.client?.dispose?.();
|
|
195
|
+
this.client = null;
|
|
196
|
+
this.api = null;
|
|
197
|
+
this.store?.close?.();
|
|
198
|
+
this.store = null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async request(path, options = {}) {
|
|
202
|
+
if (!this.offline) return null;
|
|
203
|
+
await this.ensureClient();
|
|
204
|
+
if (path === "/api/health") return mobileOfflineHealth();
|
|
205
|
+
if (path === "/api/ui-settings") return { toastPosition: "bottom-right", pageSizes: {}, galaxyFrameRate: 30 };
|
|
206
|
+
if (!this.api) throw new Error("手机端离线数据库不可用");
|
|
207
|
+
return this.api.request(path, options);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async requestOnline(path, options = {}) {
|
|
211
|
+
if (!this.fetchImpl) throw new Error("当前环境不支持网络请求");
|
|
212
|
+
const method = String(options.method ?? "GET").toUpperCase();
|
|
213
|
+
const headers = new Headers(options.headers ?? {});
|
|
214
|
+
headers.set("Accept", "application/json");
|
|
215
|
+
let body = options.body;
|
|
216
|
+
if (body !== undefined && !(body instanceof FormData) && typeof body !== "string") {
|
|
217
|
+
body = JSON.stringify(body);
|
|
218
|
+
headers.set("Content-Type", "application/json");
|
|
219
|
+
}
|
|
220
|
+
if (body instanceof FormData) headers.delete("Content-Type");
|
|
221
|
+
if (!SAFE_METHODS.has(method) && this.csrfToken) headers.set("X-CSRF-Token", this.csrfToken);
|
|
222
|
+
const response = await this.fetchImpl(path, {
|
|
223
|
+
...options,
|
|
224
|
+
method,
|
|
225
|
+
headers,
|
|
226
|
+
body,
|
|
227
|
+
credentials: "include",
|
|
228
|
+
redirect: "error",
|
|
229
|
+
cache: "no-store"
|
|
230
|
+
});
|
|
231
|
+
let payload = null;
|
|
232
|
+
if (response.status !== 204) payload = await response.json().catch(() => null);
|
|
233
|
+
if (!response.ok) {
|
|
234
|
+
const error = new Error(payload?.error?.message ?? `请求失败:${response.status}`);
|
|
235
|
+
error.code = payload?.error?.code ?? `MOBILE_HTTP_${response.status}`;
|
|
236
|
+
error.status = response.status;
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
return payload?.data ?? null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async syncAll() {
|
|
243
|
+
await this.ensureClient();
|
|
244
|
+
if (!this.client || this.offline || globalThis.navigator?.onLine === false) return [];
|
|
245
|
+
return this.client.syncAll();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async downloadWork(work) {
|
|
249
|
+
if (this.offline) throw new Error("当前处于离线状态,请恢复连接后下载作品");
|
|
250
|
+
await this.ensureClient();
|
|
251
|
+
if (!this.client) throw new Error("手机端离线数据库不可用");
|
|
252
|
+
const result = await this.client.downloadWork(work);
|
|
253
|
+
this.dispatchState("downloaded", work.id);
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
canManageOfflineAccess(work) {
|
|
258
|
+
return OFFLINE_ACCESS_ROLES.has(String(work?.accessRole ?? ""));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async setOfflineAccess(work, enabled) {
|
|
262
|
+
if (this.offline) throw new Error("当前处于离线状态,请恢复连接后管理离线访问");
|
|
263
|
+
if (!this.canManageOfflineAccess(work)) throw new Error("只有作品所有者或管理员可以管理离线访问");
|
|
264
|
+
const updated = await this.requestOnline(`/api/works/${encodeURIComponent(work.id)}/offline-access`, {
|
|
265
|
+
method: "PATCH",
|
|
266
|
+
body: { enabled: enabled === true }
|
|
267
|
+
});
|
|
268
|
+
Object.assign(work, updated);
|
|
269
|
+
return updated;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async downloadAllWorks(works, onProgress = null) {
|
|
273
|
+
if (this.offline) throw new Error("当前处于离线状态,请恢复连接后下载作品");
|
|
274
|
+
if (this.bulkDownloadPromise) return this.bulkDownloadPromise;
|
|
275
|
+
const candidates = Array.isArray(works)
|
|
276
|
+
? works.filter((work) => typeof work?.id === "string" && work.id.length > 0)
|
|
277
|
+
: [];
|
|
278
|
+
this.bulkDownloadPromise = (async () => {
|
|
279
|
+
const result = { total: candidates.length, downloaded: 0, alreadyCached: 0, skipped: 0, failed: [] };
|
|
280
|
+
let completed = 0;
|
|
281
|
+
for (const work of candidates) {
|
|
282
|
+
this.bulkDownloadState = {
|
|
283
|
+
completed,
|
|
284
|
+
total: result.total,
|
|
285
|
+
title: String(work.title ?? "未命名作品")
|
|
286
|
+
};
|
|
287
|
+
if (typeof onProgress === "function") onProgress(this.bulkDownloadState);
|
|
288
|
+
try {
|
|
289
|
+
if (await this.store?.getWork?.(work.id)) {
|
|
290
|
+
result.alreadyCached += 1;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if (work.offlineAccessEnabled !== true) {
|
|
294
|
+
if (!this.canManageOfflineAccess(work)) {
|
|
295
|
+
result.skipped += 1;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
await this.setOfflineAccess(work, true);
|
|
299
|
+
}
|
|
300
|
+
await this.downloadWork(work);
|
|
301
|
+
result.downloaded += 1;
|
|
302
|
+
} catch (error) {
|
|
303
|
+
result.failed.push({ workId: work.id, title: String(work.title ?? "未命名作品"), error });
|
|
304
|
+
} finally {
|
|
305
|
+
completed += 1;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return result;
|
|
309
|
+
})().finally(() => {
|
|
310
|
+
this.bulkDownloadPromise = null;
|
|
311
|
+
this.bulkDownloadState = null;
|
|
312
|
+
});
|
|
313
|
+
return this.bulkDownloadPromise;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async syncWork(workId) {
|
|
317
|
+
if (this.offline) throw new Error("当前处于离线状态,请恢复连接后同步");
|
|
318
|
+
await this.ensureClient();
|
|
319
|
+
if (!this.client) throw new Error("手机端离线数据库不可用");
|
|
320
|
+
const result = await this.client.syncWork(workId);
|
|
321
|
+
this.dispatchState("synced", workId);
|
|
322
|
+
return result;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
openServerSelector() {
|
|
326
|
+
if (!isNativeMobileShell() || !this.location?.assign) return;
|
|
327
|
+
this.location.assign(MOBILE_LAUNCHER_URL);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async aggregateStatus() {
|
|
331
|
+
await this.ensureClient();
|
|
332
|
+
if (!this.store) return { works: 0, pendingMutations: 0, conflicts: 0, rejected: 0 };
|
|
333
|
+
const works = await this.store.listWorks();
|
|
334
|
+
const summaries = await Promise.all(works.map((work) => this.store.statusSummary(work.workId)));
|
|
335
|
+
return summaries.reduce((total, summary) => ({
|
|
336
|
+
works: total.works + 1,
|
|
337
|
+
pendingMutations: total.pendingMutations + summary.pending + summary.syncing,
|
|
338
|
+
conflicts: total.conflicts + summary.conflicts,
|
|
339
|
+
rejected: total.rejected + summary.rejected
|
|
340
|
+
}), { works: 0, pendingMutations: 0, conflicts: 0, rejected: 0 });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async reconnect() {
|
|
344
|
+
if (!this.enabled || !this.offline || globalThis.navigator?.onLine === false) return;
|
|
345
|
+
try {
|
|
346
|
+
const session = await this.requestOnline("/api/auth/session");
|
|
347
|
+
if (session?.authenticated !== true || session.user?.userId !== this.session?.user?.userId) return;
|
|
348
|
+
this.offline = false;
|
|
349
|
+
this.setSession(session);
|
|
350
|
+
await this.syncAll();
|
|
351
|
+
const status = await this.aggregateStatus();
|
|
352
|
+
this.dispatchState(status.conflicts > 0 || status.rejected > 0 ? "attention" : "online");
|
|
353
|
+
if (status.conflicts === 0 && status.rejected === 0 && this.location?.reload) this.location.reload();
|
|
354
|
+
} catch {
|
|
355
|
+
this.offline = true;
|
|
356
|
+
this.dispatchState("offline");
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
dispatchState(status, workId = null, error = null) {
|
|
361
|
+
globalThis.dispatchEvent?.(new CustomEvent("scriverse-mobile-sync-state", {
|
|
362
|
+
detail: { status, workId, errorCode: error?.code ?? null }
|
|
363
|
+
}));
|
|
364
|
+
void this.updateUi();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
registerServiceWorker() {
|
|
368
|
+
const serviceWorker = globalThis.navigator?.serviceWorker;
|
|
369
|
+
if (!serviceWorker?.register) return;
|
|
370
|
+
serviceWorker.addEventListener("controllerchange", () => this.cacheCurrentAssets(), { once: true });
|
|
371
|
+
void serviceWorker.register("/mobile-service-worker.js?v=20260923-mobile-service-worker-v2", { scope: "/" })
|
|
372
|
+
.then(() => {
|
|
373
|
+
if (serviceWorker.controller) this.cacheCurrentAssets();
|
|
374
|
+
})
|
|
375
|
+
.catch((error) => console.warn("Mobile service worker registration failed", error));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
cacheCurrentAssets() {
|
|
379
|
+
const controller = globalThis.navigator?.serviceWorker?.controller;
|
|
380
|
+
if (!controller) return;
|
|
381
|
+
const locationUrl = new URL(this.location?.href ?? globalThis.location?.href);
|
|
382
|
+
const urls = [...new Set((globalThis.performance?.getEntriesByType?.("resource") ?? [])
|
|
383
|
+
.map((entry) => {
|
|
384
|
+
try {
|
|
385
|
+
const url = new URL(entry.name, locationUrl.href);
|
|
386
|
+
if (url.origin !== locationUrl.origin || url.pathname.startsWith("/api/")) return null;
|
|
387
|
+
return /\.(?:css|js|mjs|svg|png|webp|woff2?|ttf)(?:\?.*)?$/iu.test(url.pathname + url.search)
|
|
388
|
+
? url.href
|
|
389
|
+
: null;
|
|
390
|
+
} catch {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
})
|
|
394
|
+
.filter((url) => typeof url === "string"))].slice(0, 500);
|
|
395
|
+
controller.postMessage({ type: "CACHE_ASSETS", urls });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
installUi() {
|
|
399
|
+
const install = () => {
|
|
400
|
+
const host = document.querySelector(".top-actions");
|
|
401
|
+
if (!host || document.querySelector("#mobile-offline-button")) return;
|
|
402
|
+
if (isNativeMobileShell()) {
|
|
403
|
+
const serverButton = document.createElement("button");
|
|
404
|
+
serverButton.id = "mobile-server-button";
|
|
405
|
+
serverButton.className = "topbar-icon-button mobile-server-button";
|
|
406
|
+
serverButton.type = "button";
|
|
407
|
+
serverButton.textContent = "Server";
|
|
408
|
+
serverButton.setAttribute("aria-label", "切换 Server");
|
|
409
|
+
serverButton.title = "切换 Server";
|
|
410
|
+
serverButton.addEventListener("click", () => this.openServerSelector());
|
|
411
|
+
host.insertBefore(serverButton, host.querySelector("#theme-toggle") ?? null);
|
|
412
|
+
}
|
|
413
|
+
const button = document.createElement("button");
|
|
414
|
+
button.id = "mobile-offline-button";
|
|
415
|
+
button.className = "topbar-icon-button mobile-offline-button";
|
|
416
|
+
button.type = "button";
|
|
417
|
+
button.textContent = "下载";
|
|
418
|
+
button.setAttribute("aria-label", "下载云端数据到本地");
|
|
419
|
+
button.title = "下载云端数据到本地并管理离线副本";
|
|
420
|
+
button.addEventListener("click", () => { void this.openSyncDialog(); });
|
|
421
|
+
host.insertBefore(button, host.querySelector("#theme-toggle") ?? null);
|
|
422
|
+
this.ensureSyncDialog();
|
|
423
|
+
void this.updateUi();
|
|
424
|
+
};
|
|
425
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", install, { once: true });
|
|
426
|
+
else install();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
ensureSyncDialog() {
|
|
430
|
+
if (document.querySelector("#mobile-offline-dialog")) return;
|
|
431
|
+
const dialog = document.createElement("dialog");
|
|
432
|
+
dialog.id = "mobile-offline-dialog";
|
|
433
|
+
dialog.className = "dialog editor-dialog mobile-offline-dialog";
|
|
434
|
+
dialog.setAttribute("aria-labelledby", "mobile-offline-title");
|
|
435
|
+
const form = document.createElement("form");
|
|
436
|
+
form.method = "dialog";
|
|
437
|
+
form.className = "mobile-offline-dialog-form";
|
|
438
|
+
const header = document.createElement("div");
|
|
439
|
+
header.className = "dialog-header";
|
|
440
|
+
const heading = document.createElement("div");
|
|
441
|
+
const eyebrow = document.createElement("span");
|
|
442
|
+
eyebrow.className = "eyebrow";
|
|
443
|
+
eyebrow.textContent = "手机端同步";
|
|
444
|
+
const title = document.createElement("h2");
|
|
445
|
+
title.id = "mobile-offline-title";
|
|
446
|
+
title.textContent = "下载到本地";
|
|
447
|
+
const meta = document.createElement("p");
|
|
448
|
+
meta.id = "mobile-offline-meta";
|
|
449
|
+
meta.className = "dialog-header-meta";
|
|
450
|
+
heading.append(eyebrow, title, meta);
|
|
451
|
+
const close = document.createElement("button");
|
|
452
|
+
close.className = "dialog-close";
|
|
453
|
+
close.type = "submit";
|
|
454
|
+
close.setAttribute("aria-label", "关闭离线同步");
|
|
455
|
+
close.textContent = "×";
|
|
456
|
+
header.append(heading, close);
|
|
457
|
+
const content = document.createElement("div");
|
|
458
|
+
content.id = "mobile-offline-content";
|
|
459
|
+
content.className = "mobile-offline-content";
|
|
460
|
+
form.append(header, content);
|
|
461
|
+
dialog.append(form);
|
|
462
|
+
document.body.append(dialog);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async openSyncDialog() {
|
|
466
|
+
this.ensureSyncDialog();
|
|
467
|
+
await this.ensureClient();
|
|
468
|
+
const dialog = document.querySelector("#mobile-offline-dialog");
|
|
469
|
+
if (!(dialog instanceof HTMLDialogElement)) return;
|
|
470
|
+
const content = dialog.querySelector("#mobile-offline-content");
|
|
471
|
+
const meta = dialog.querySelector("#mobile-offline-meta");
|
|
472
|
+
content.replaceChildren();
|
|
473
|
+
const loading = document.createElement("p");
|
|
474
|
+
loading.className = "empty-copy";
|
|
475
|
+
loading.textContent = "正在读取云端作品……";
|
|
476
|
+
content.append(loading);
|
|
477
|
+
if (!dialog.open) dialog.showModal();
|
|
478
|
+
try {
|
|
479
|
+
const works = this.offline
|
|
480
|
+
? (await this.store?.listWorks?.() ?? []).map((work) => ({ id: work.workId, title: work.title, offlineAccessEnabled: true }))
|
|
481
|
+
: await this.loadOnlineWorks();
|
|
482
|
+
const cached = new Map((await this.store?.listWorks?.() ?? []).map((work) => [work.workId, work]));
|
|
483
|
+
const summary = await this.aggregateStatus();
|
|
484
|
+
meta.textContent = this.bulkDownloadState
|
|
485
|
+
? `正在下载“${this.bulkDownloadState.title}”(${this.bulkDownloadState.completed + 1}/${this.bulkDownloadState.total})……`
|
|
486
|
+
: this.offline
|
|
487
|
+
? `当前无网络,已保存 ${summary.works} 部作品;下载到本地的章节和设定仍可编辑,恢复连接后会自动同步。`
|
|
488
|
+
: `已保存 ${summary.works} 部作品,${summary.pendingMutations} 项待同步;下载会保存完整作品快照(作品、分卷、章节和设定)。`;
|
|
489
|
+
content.replaceChildren();
|
|
490
|
+
content.append(this.createSyncToolbar(works));
|
|
491
|
+
if (works.length === 0) {
|
|
492
|
+
const empty = document.createElement("p");
|
|
493
|
+
empty.className = "empty-copy";
|
|
494
|
+
empty.textContent = "暂无可用作品";
|
|
495
|
+
content.append(empty);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
for (const work of works) content.append(this.createWorkRow(work, cached.get(work.id)));
|
|
499
|
+
} catch (error) {
|
|
500
|
+
content.replaceChildren();
|
|
501
|
+
const failure = document.createElement("p");
|
|
502
|
+
failure.className = "empty-copy";
|
|
503
|
+
failure.textContent = error instanceof Error ? error.message : "离线同步信息读取失败";
|
|
504
|
+
content.append(failure);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async loadOnlineWorks() {
|
|
509
|
+
const works = [];
|
|
510
|
+
let page = 1;
|
|
511
|
+
while (page <= 100) {
|
|
512
|
+
const result = await this.requestOnline(`/api/works?page=${page}&limit=100`);
|
|
513
|
+
if (Array.isArray(result)) return result;
|
|
514
|
+
if (!Array.isArray(result?.items)) return works;
|
|
515
|
+
works.push(...result.items);
|
|
516
|
+
if (result.hasMore !== true || !Number.isInteger(result.nextPage) || result.nextPage <= page) return works;
|
|
517
|
+
page = result.nextPage;
|
|
518
|
+
}
|
|
519
|
+
return works;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
createSyncToolbar(works) {
|
|
523
|
+
const toolbar = document.createElement("div");
|
|
524
|
+
toolbar.className = "mobile-offline-toolbar settings-card";
|
|
525
|
+
const description = document.createElement("p");
|
|
526
|
+
description.className = "empty-copy";
|
|
527
|
+
description.textContent = this.offline
|
|
528
|
+
? "当前只能编辑已经下载到本地的作品。"
|
|
529
|
+
: "选择单部作品下载,或一次下载当前 Server 中可用的作品。";
|
|
530
|
+
toolbar.append(description);
|
|
531
|
+
if (!this.offline && works.length > 0) {
|
|
532
|
+
const downloadable = works.some((work) => work.offlineAccessEnabled === true || this.canManageOfflineAccess(work));
|
|
533
|
+
const button = document.createElement("button");
|
|
534
|
+
button.className = "primary-button";
|
|
535
|
+
button.type = "button";
|
|
536
|
+
button.textContent = this.bulkDownloadPromise ? "正在下载" : "下载全部可用作品";
|
|
537
|
+
button.disabled = !downloadable || Boolean(this.bulkDownloadPromise);
|
|
538
|
+
button.addEventListener("click", async () => {
|
|
539
|
+
button.disabled = true;
|
|
540
|
+
button.textContent = "正在下载";
|
|
541
|
+
description.textContent = `准备下载 ${works.length} 部作品……`;
|
|
542
|
+
try {
|
|
543
|
+
const result = await this.downloadAllWorks(works, (progress) => {
|
|
544
|
+
description.textContent = `正在下载“${progress.title}”(${progress.completed + 1}/${progress.total})……`;
|
|
545
|
+
});
|
|
546
|
+
const skipped = result.skipped > 0 ? `,跳过 ${result.skipped} 部(未获离线授权)` : "";
|
|
547
|
+
const completedDescription = result.failed.length > 0
|
|
548
|
+
? `下载完成:成功 ${result.downloaded} 部,失败 ${result.failed.length} 部${skipped}`
|
|
549
|
+
: `下载完成:新增 ${result.downloaded} 部,已有 ${result.alreadyCached} 部${skipped}`;
|
|
550
|
+
await this.openSyncDialog();
|
|
551
|
+
const updatedDescription = document.querySelector("#mobile-offline-content .mobile-offline-toolbar p");
|
|
552
|
+
if (updatedDescription) updatedDescription.textContent = completedDescription;
|
|
553
|
+
} catch (error) {
|
|
554
|
+
button.disabled = false;
|
|
555
|
+
description.textContent = error instanceof Error ? error.message : "批量下载失败";
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
toolbar.append(button);
|
|
559
|
+
}
|
|
560
|
+
return toolbar;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
createWorkRow(work, cached) {
|
|
564
|
+
const row = document.createElement("article");
|
|
565
|
+
row.className = "mobile-offline-work-row settings-card";
|
|
566
|
+
const copy = document.createElement("div");
|
|
567
|
+
const title = document.createElement("strong");
|
|
568
|
+
title.textContent = String(work.title ?? "未命名作品");
|
|
569
|
+
const status = document.createElement("small");
|
|
570
|
+
status.textContent = cached
|
|
571
|
+
? work.offlineAccessEnabled === true
|
|
572
|
+
? "已下载到本地,可离线编辑"
|
|
573
|
+
: "已下载到本地;Server 已关闭后续同步"
|
|
574
|
+
: work.offlineAccessEnabled === true
|
|
575
|
+
? "Server 已允许离线访问,可下载到本地"
|
|
576
|
+
: this.canManageOfflineAccess(work)
|
|
577
|
+
? "尚未允许离线访问,允许后即可下载"
|
|
578
|
+
: "作品所有者尚未允许离线访问";
|
|
579
|
+
copy.append(title, status);
|
|
580
|
+
const actions = document.createElement("div");
|
|
581
|
+
actions.className = "card-actions";
|
|
582
|
+
if (!this.offline && this.canManageOfflineAccess(work)) {
|
|
583
|
+
const access = document.createElement("button");
|
|
584
|
+
access.className = "ghost-button";
|
|
585
|
+
access.type = "button";
|
|
586
|
+
access.textContent = work.offlineAccessEnabled === true ? "关闭离线访问" : "允许离线访问";
|
|
587
|
+
access.addEventListener("click", async () => {
|
|
588
|
+
access.disabled = true;
|
|
589
|
+
try {
|
|
590
|
+
await this.setOfflineAccess(work, work.offlineAccessEnabled !== true);
|
|
591
|
+
await this.openSyncDialog();
|
|
592
|
+
} catch (error) {
|
|
593
|
+
access.disabled = false;
|
|
594
|
+
status.textContent = error instanceof Error ? error.message : "离线访问设置失败";
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
actions.append(access);
|
|
598
|
+
}
|
|
599
|
+
if (!cached && work.offlineAccessEnabled === true && !this.offline) {
|
|
600
|
+
const download = document.createElement("button");
|
|
601
|
+
download.className = "primary-button";
|
|
602
|
+
download.type = "button";
|
|
603
|
+
download.textContent = "下载到本地";
|
|
604
|
+
download.addEventListener("click", async () => {
|
|
605
|
+
download.disabled = true;
|
|
606
|
+
try {
|
|
607
|
+
await this.downloadWork(work);
|
|
608
|
+
await this.openSyncDialog();
|
|
609
|
+
} catch (error) {
|
|
610
|
+
download.disabled = false;
|
|
611
|
+
status.textContent = error instanceof Error ? error.message : "下载失败";
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
actions.append(download);
|
|
615
|
+
}
|
|
616
|
+
if (cached) {
|
|
617
|
+
const sync = document.createElement("button");
|
|
618
|
+
sync.className = "ghost-button";
|
|
619
|
+
sync.type = "button";
|
|
620
|
+
sync.textContent = this.offline ? "等待联网" : work.offlineAccessEnabled === true ? "立即同步" : "等待允许";
|
|
621
|
+
sync.disabled = this.offline || work.offlineAccessEnabled !== true;
|
|
622
|
+
sync.addEventListener("click", async () => {
|
|
623
|
+
sync.disabled = true;
|
|
624
|
+
try {
|
|
625
|
+
await this.syncWork(work.id);
|
|
626
|
+
await this.openSyncDialog();
|
|
627
|
+
} catch (error) {
|
|
628
|
+
sync.disabled = false;
|
|
629
|
+
status.textContent = error instanceof Error ? error.message : "同步失败";
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
actions.append(sync);
|
|
633
|
+
}
|
|
634
|
+
row.append(copy, actions);
|
|
635
|
+
return row;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async updateUi() {
|
|
639
|
+
const button = document.querySelector("#mobile-offline-button");
|
|
640
|
+
if (!(button instanceof HTMLButtonElement)) return;
|
|
641
|
+
const summary = await this.aggregateStatus();
|
|
642
|
+
const label = this.offline
|
|
643
|
+
? summary.pendingMutations + summary.conflicts + summary.rejected > 0
|
|
644
|
+
? `待同步 ${summary.pendingMutations + summary.conflicts + summary.rejected}`
|
|
645
|
+
: "离线"
|
|
646
|
+
: summary.pendingMutations + summary.conflicts + summary.rejected > 0
|
|
647
|
+
? `同步 ${summary.pendingMutations + summary.conflicts + summary.rejected}`
|
|
648
|
+
: "下载";
|
|
649
|
+
button.textContent = label;
|
|
650
|
+
button.title = this.offline ? "当前处于离线编辑模式" : "下载云端数据到本地并管理离线副本";
|
|
651
|
+
button.setAttribute("aria-label", button.title);
|
|
652
|
+
button.dataset.status = summary.conflicts > 0 || summary.rejected > 0 ? "attention" : this.offline ? "offline" : "ready";
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
dispose() {
|
|
656
|
+
globalThis.removeEventListener?.("online", this.onlineHandler);
|
|
657
|
+
this.disposeClient();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export const mobileOfflineRuntime = new MobileOfflineRuntime();
|
|
662
|
+
globalThis.scriverseMobileOfflineRuntime = mobileOfflineRuntime;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
.mobile-offline-button[data-status="attention"] { color: var(--accent); }
|
|
2
|
+
.mobile-offline-button[data-status="offline"] { color: var(--muted); }
|
|
3
|
+
.mobile-offline-dialog { width: min(620px, calc(100vw - 24px)); max-height: min(760px, calc(100dvh - 24px)); }
|
|
4
|
+
.mobile-offline-dialog-form { display: grid; min-height: 0; gap: 18px; }
|
|
5
|
+
.mobile-offline-content { display: grid; min-height: 0; gap: 10px; overflow: auto; }
|
|
6
|
+
.mobile-offline-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; }
|
|
7
|
+
.mobile-offline-toolbar p { margin: 0; }
|
|
8
|
+
.mobile-offline-work-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; }
|
|
9
|
+
.mobile-offline-work-row > div:first-child { display: grid; min-width: 0; gap: 5px; }
|
|
10
|
+
.mobile-offline-work-row strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
11
|
+
.mobile-offline-work-row small { color: var(--muted); line-height: 1.5; }
|
|
12
|
+
.mobile-offline-work-row .card-actions { flex: 0 0 auto; margin: 0; }
|
|
13
|
+
@media (max-width: 560px) {
|
|
14
|
+
.mobile-offline-dialog { padding: 16px; }
|
|
15
|
+
.mobile-offline-toolbar { align-items: stretch; flex-direction: column; }
|
|
16
|
+
.mobile-offline-work-row { align-items: stretch; flex-direction: column; gap: 10px; }
|
|
17
|
+
.mobile-offline-work-row .card-actions { justify-content: stretch; }
|
|
18
|
+
.mobile-offline-work-row .card-actions button { flex: 1 1 140px; min-height: 40px; }
|
|
19
|
+
}
|