starmusic-pc
1/**
2 * uni.* 兼容层(PC 版)
3 *
4 * 目标:让从 uni-app 移动端移植过来的 common/* 业务代码「零改动」运行在 Wails(WebView2) 里。
5 * 提供的能力与移动端语义保持一致:
6 * - 存储:内存表 + 防抖落盘到 localStorage(同步 API,语义与 uni 一致)
7 * - 音频:HTMLAudioElement 适配成 InnerAudioContext
8 * - 网络:优先走 Go 侧原生 HTTP(绕开 CORS),不可用时回退 fetch
9 * - 交互:toast / loading / modal 交给 Vue 层渲染(见 uni/ui-state.js)
10 * - 原生:文件对话框、下载到磁盘、剪贴板、窗口控制(见 uni/native.js)
11 *
12 * 必须在任何 common/* 模块之前被 import(main.js 的第一行)。
13 */
14import { pushToast, showLoading, hideLoading, openModal, updateLoading } from './ui-state.js'
15import {
16 hasNative,
17 invoke,
18 getAppInfo,
19 downloadToDisk,
20 setClipboard,
21 getClipboard,
22 openExternal,
23 pickImageNative,
24 pickImageByInput,
25} from './native.js'
26import { setNativeVersion, setNativeVersionCode } from '../common/update.js'
27
28// ============================================================
29// 存储层
30// ============================================================
31
32const STORE_PREFIX = 'starmusic_'
33
34/** 内存表:所有读写先经过内存,保证同步语义与跨模块一致性 */
35const memory = new Map()
36const dirty = new Set()
37let flushTimer = null
38
39/** 启动时一次性把 localStorage 里的业务数据载入内存 */
40const hydrate = () => {
41 try {
42 const len = localStorage.length
43 for (let i = 0; i < len; i++) {
44 const key = localStorage.key(i)
45 if (!key || key.indexOf(STORE_PREFIX) !== 0) continue
46 const raw = localStorage.getItem(key)
47 if (raw === null) continue
48 try {
49 memory.set(key, JSON.parse(raw))
50 } catch (e) {
51 memory.set(key, raw)
52 }
53 }
54 } catch (e) {
55 console.warn('[storage] 载入本地数据失败', e)
56 }
57}
58
59/** 落盘(只写脏键,避免全量序列化) */
60const flush = () => {
61 flushTimer = null
62 const keys = [...dirty]
63 dirty.clear()
64 for (const key of keys) {
65 try {
66 if (memory.has(key)) localStorage.setItem(key, JSON.stringify(memory.get(key)))
67 else localStorage.removeItem(key)
68 } catch (e) {
69 console.error('[storage] 写入失败', key, e)
70 if (e && (e.name === 'QuotaExceededError' || e.code === 22)) {
71 pushToast({ title: '本地存储空间不足,请清理封面或歌词缓存', icon: 'none', duration: 3600 })
72 }
73 }
74 }
75}
76
77const scheduleFlush = () => {
78 if (flushTimer) return
79 flushTimer = setTimeout(flush, 260)
80}
81
82/** 立刻落盘(导出备份等场景需要保证数据已写入) */
83export const flushStorage = () => {
84 if (flushTimer) {
85 clearTimeout(flushTimer)
86 flushTimer = null
87 }
88 flush()
89}
90
91const onBeforeUnload = () => {
92 if (dirty.size) flush()
93}
94
95const getStorageSync = (key) => {
96 if (!key) return ''
97 if (memory.has(key)) {
98 const value = memory.get(key)
99 return value === undefined || value === null ? '' : value
100 }
101 // 内存未命中:可能是外部(如导入备份)刚写入,直接读一次 localStorage
102 try {
103 const raw = localStorage.getItem(key)
104 if (raw === null) return ''
105 let parsed
106 try {
107 parsed = JSON.parse(raw)
108 } catch (e) {
109 parsed = raw
110 }
111 memory.set(key, parsed)
112 return parsed === undefined || parsed === null ? '' : parsed
113 } catch (e) {
114 return ''
115 }
116}
117
118const setStorageSync = (key, value) => {
119 if (!key) return
120 if (value === undefined || value === null) {
121 memory.delete(key)
122 } else {
123 memory.set(key, value)
124 }
125 dirty.add(key)
126 scheduleFlush()
127}
128
129const removeStorageSync = (key) => {
130 if (!key) return
131 memory.delete(key)
132 dirty.add(key)
133 scheduleFlush()
134}
135
136const clearStorageSync = () => {
137 for (const key of [...memory.keys()]) {
138 memory.delete(key)
139 dirty.add(key)
140 }
141 scheduleFlush()
142}
143
144const getStorageInfoSync = () => {
145 try {
146 return {
147 keys: [...memory.keys()],
148 currentSize: Math.round(JSON.stringify([...memory.entries()]).length / 1024),
149 limitSize: 10240,
150 }
151 } catch (e) {
152 return { keys: [...memory.keys()], currentSize: 0, limitSize: 10240 }
153 }
154}
155
156// ============================================================
157// 音频(InnerAudioContext 适配)
158// ============================================================
159
160const AUDIO_EVENTS = ['play', 'pause', 'stop', 'ended', 'canplay', 'error', 'timeupdate', 'waiting', 'progress']
161
162/**
163 * 事件名 → 回调方法名后缀。
164 * 注意不能简单首字母大写:timeupdate 必须映射为 TimeUpdate,
165 * 否则 player.js 里的 audio.onTimeUpdate(...) 会因方法不存在而抛错,导致播放初始化中断。
166 */
167const AUDIO_METHODS = {
168 play: 'Play',
169 pause: 'Pause',
170 stop: 'Stop',
171 ended: 'Ended',
172 canplay: 'Canplay',
173 error: 'Error',
174 timeupdate: 'TimeUpdate',
175 waiting: 'Waiting',
176 progress: 'Progress',
177}
178
179function createInnerAudioContext() {
180 const el = new Audio()
181 el.preload = 'auto'
182 el.autoplay = false
183 // 允许 WebView2 在后台继续播放(配合 Go 侧窗口失焦策略)
184 el.setAttribute('playsinline', '')
185
186 const handlers = {}
187 for (const name of AUDIO_EVENTS) handlers[name] = []
188
189 const fire = (name, arg) => {
190 const list = handlers[name]
191 if (!list || !list.length) return
192 for (const cb of list) {
193 try {
194 cb(arg)
195 } catch (e) {
196 console.error(`[audio:${name}]`, e)
197 }
198 }
199 }
200
201 el.addEventListener('play', () => fire('play'))
202 el.addEventListener('playing', () => fire('play'))
203 el.addEventListener('pause', () => fire('pause'))
204 el.addEventListener('ended', () => fire('ended'))
205 el.addEventListener('canplay', () => fire('canplay'))
206 el.addEventListener('loadedmetadata', () => fire('canplay'))
207 el.addEventListener('canplaythrough', () => fire('canplay'))
208 el.addEventListener('timeupdate', () => fire('timeupdate'))
209 el.addEventListener('progress', () => fire('progress'))
210 el.addEventListener('waiting', () => fire('waiting'))
211 el.addEventListener('error', () => {
212 const code = el.error ? el.error.code : -1
213 fire('error', { errMsg: `audio error ${code}`, code })
214 })
215
216 let currentSrc = ''
217
218 const ctx = {
219 _el: el,
220 /** App 端只读属性,这里保留可写以便 player.js 赋值不报错 */
221 obeyMuteSwitch: false,
222 autoplay: false,
223
224 get src() {
225 return currentSrc
226 },
227 set src(value) {
228 const next = value == null ? '' : String(value)
229 if (next === currentSrc) return
230 currentSrc = next
231 try {
232 if (!next) {
233 el.pause()
234 el.removeAttribute('src')
235 el.load()
236 } else {
237 el.src = next
238 el.load()
239 }
240 } catch (e) {
241 console.warn('[audio] 设置 src 失败', e)
242 }
243 },
244
245 get volume() {
246 return el.volume
247 },
248 set volume(v) {
249 const n = Number(v)
250 el.volume = Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 1
251 },
252
253 get playbackRate() {
254 return el.playbackRate
255 },
256 set playbackRate(v) {
257 const n = Number(v)
258 try {
259 el.playbackRate = Number.isFinite(n) && n > 0 ? n : 1
260 } catch (e) {
261 /* 忽略 */
262 }
263 },
264
265 get currentTime() {
266 const t = el.currentTime
267 return Number.isFinite(t) ? t : 0
268 },
269
270 get duration() {
271 // 直播流/未加载完成时 duration 为 Infinity,统一归一化为 0
272 return Number.isFinite(el.duration) ? el.duration : 0
273 },
274
275 get paused() {
276 return el.paused
277 },
278
279 get buffered() {
280 return el.buffered
281 },
282
283 play() {
284 try {
285 const p = el.play()
286 if (p && typeof p.catch === 'function') p.catch(() => fire('error', { errMsg: 'play() rejected', code: -2 }))
287 } catch (e) {
288 fire('error', { errMsg: String((e && e.message) || e), code: -3 })
289 }
290 },
291
292 pause() {
293 try {
294 el.pause()
295 } catch (e) {
296 /* 忽略 */
297 }
298 },
299
300 stop() {
301 try {
302 el.pause()
303 el.currentTime = 0
304 } catch (e) {
305 /* 忽略 */
306 }
307 fire('stop')
308 },
309
310 seek(time) {
311 try {
312 el.currentTime = Number(time) || 0
313 } catch (e) {
314 /* 忽略 */
315 }
316 },
317
318 destroy() {
319 try {
320 el.pause()
321 el.removeAttribute('src')
322 el.load()
323 } catch (e) {
324 /* 忽略 */
325 }
326 for (const name of AUDIO_EVENTS) handlers[name].length = 0
327 },
328 }
329
330 // 动态挂 onXxx / offXxx(方法名沿用 uni 的 PascalCase 约定)
331 for (const name of AUDIO_EVENTS) {
332 const suffix = AUDIO_METHODS[name] || name.charAt(0).toUpperCase() + name.slice(1)
333 ctx[`on${suffix}`] = (cb) => {
334 if (typeof cb === 'function') handlers[name].push(cb)
335 }
336 ctx[`off${suffix}`] = (cb) => {
337 const list = handlers[name]
338 const index = list.indexOf(cb)
339 if (index > -1) list.splice(index, 1)
340 }
341 }
342
343 /**
344 * 用 Proxy 兜底:移植过来的代码可能调用尚未实现的事件注册方法
345 * (例如 onSeeked / onBufferedUpdate)。返回空实现而不是 undefined,
346 * 可以避免「某个可选能力缺失」直接把整段初始化流程打断。
347 */
348 return new Proxy(ctx, {
349 get(target, prop, receiver) {
350 if (prop in target) return Reflect.get(target, prop, receiver)
351 if (typeof prop === 'string' && /^(on|off)[A-Z]/.test(prop)) return () => {}
352 return undefined
353 },
354 })
355}
356
357// ============================================================
358// 网络(优先原生,绕开 CORS)
359// ============================================================
360
361const normalizeHeaders = (header) => {
362 const out = {}
363 if (!header || typeof header !== 'object') return out
364 for (const [k, v] of Object.entries(header)) {
365 if (v === undefined || v === null) continue
366 out[k] = String(v)
367 }
368 return out
369}
370
371/** 判断响应体是否需要按二进制处理 */
372const isBinaryType = (responseType) =>
373 responseType === 'arraybuffer' || responseType === 'ArrayBuffer'
374
375/** base64 → ArrayBuffer */
376const base64ToArrayBuffer = (b64) => {
377 const bin = atob(b64)
378 const len = bin.length
379 const bytes = new Uint8Array(len)
380 for (let i = 0; i < len; i++) bytes[i] = bin.charCodeAt(i)
381 return bytes.buffer
382}
383
384/** 原生通道请求 */
385async function nativeRequest(opts) {
386 const binary = isBinaryType(opts.responseType)
387 const res = await invoke('HttpRequest', {
388 method: String(opts.method || 'GET').toUpperCase(),
389 url: opts.url,
390 headers: normalizeHeaders(opts.header),
391 body: typeof opts.data === 'string' && opts.data ? opts.data : opts.data ? JSON.stringify(opts.data) : '',
392 timeoutMs: Number(opts.timeout) || 20000,
393 binary,
394 })
395 if (!res) throw new Error('native unavailable')
396 let data
397 if (binary) data = new Uint8Array(base64ToArrayBuffer(res.body || ''))
398 else {
399 data = res.body
400 try {
401 data = JSON.parse(res.body)
402 } catch (e) {
403 /* 保持字符串 */
404 }
405 }
406 return {
407 data,
408 statusCode: res.statusCode,
409 header: res.header || {},
410 cookies: [],
411 errMsg: 'request:ok',
412 url: res.url || opts.url,
413 }
414}
415
416/** fetch 通道(原生不可用时的回退,可能受 CORS 限制) */
417function fetchRequest(opts) {
418 return new Promise((resolve, reject) => {
419 const controller = new AbortController()
420 const timer = setTimeout(() => controller.abort(), Math.max(1000, Number(opts.timeout) || 20000))
421
422 const headers = normalizeHeaders(opts.header)
423 const method = String(opts.method || 'GET').toUpperCase()
424 let body
425 if (opts.data !== undefined && opts.data !== null && method !== 'GET' && method !== 'HEAD') {
426 if (typeof opts.data === 'string') body = opts.data
427 else {
428 body = JSON.stringify(opts.data)
429 if (!headers['Content-Type'] && !headers['content-type']) headers['Content-Type'] = 'application/json'
430 }
431 }
432
433 const binary = isBinaryType(opts.responseType)
434
435 fetch(opts.url, { method, headers, body, signal: controller.signal, credentials: 'omit', cache: 'no-cache' })
436 .then(async (res) => {
437 const header = {}
438 try {
439 res.headers.forEach((v, k) => {
440 header[k] = v
441 })
442 } catch (e) {
443 /* 忽略 */
444 }
445 let data
446 if (binary) data = new Uint8Array(await res.arrayBuffer())
447 else {
448 const text = await res.text()
449 try {
450 data = JSON.parse(text)
451 } catch (e) {
452 data = text
453 }
454 }
455 resolve({ data, statusCode: res.status, header, cookies: [], errMsg: 'request:ok', url: res.url })
456 })
457 .catch((err) => {
458 const aborted = err && (err.name === 'AbortError' || /abort/i.test(String(err.message || '')))
459 reject(new Error(aborted ? 'request:fail abort' : `request:fail ${(err && err.message) || 'network error'}`))
460 })
461 .finally(() => {
462 clearTimeout(timer)
463 })
464 })
465}
466
467function request(opts = {}) {
468 const { success, fail, complete } = opts
469 let cancelled = false
470
471 const task = (async () => {
472 if (!opts.url) throw new Error('request:fail invalid url')
473 if (hasNative()) {
474 try {
475 return await nativeRequest(opts)
476 } catch (err) {
477 // 原生通道异常(非网络错误)时回退 fetch,提升健壮性
478 console.warn('[request] 原生通道失败,回退 fetch', err.message)
479 return fetchRequest(opts)
480 }
481 }
482 return fetchRequest(opts)
483 })()
484
485 task
486 .then((res) => {
487 if (cancelled) return
488 if (typeof success === 'function') success(res)
489 if (typeof complete === 'function') complete(res)
490 })
491 .catch((err) => {
492 if (cancelled) return
493 const res = { errMsg: String((err && err.message) || 'request:fail'), statusCode: 0, data: null, header: {} }
494 if (typeof fail === 'function') fail(res)
495 if (typeof complete === 'function') complete(res)
496 })
497
498 return {
499 abort() {
500 cancelled = true
501 },
502 }
503}
504
505// ============================================================
506// 下载
507// ============================================================
508
509function downloadFile(opts = {}) {
510 const { url, success, fail, complete } = opts
511 if (hasNative()) {
512 downloadToDisk(url, opts.filePath || '', '')
513 .then((res) => {
514 const result = { tempFilePath: res.path, filePath: res.path, statusCode: 200, errMsg: 'downloadFile:ok' }
515 if (typeof success === 'function') success(result)
516 if (typeof complete === 'function') complete(result)
517 })
518 .catch((err) => {
519 const result = { errMsg: `downloadFile:fail ${err.message}`, statusCode: 0 }
520 if (typeof fail === 'function') fail(result)
521 if (typeof complete === 'function') complete(result)
522 })
523 return { onProgressUpdate() {}, abort() {} }
524 }
525
526 // 浏览器回退:blob 下载
527 fetch(url)
528 .then((res) => res.blob())
529 .then((blob) => {
530 const objectUrl = URL.createObjectURL(blob)
531 const a = document.createElement('a')
532 a.href = objectUrl
533 a.download = opts.filePath || ''
534 document.body.appendChild(a)
535 a.click()
536 document.body.removeChild(a)
537 setTimeout(() => URL.revokeObjectURL(objectUrl), 4000)
538 const result = { tempFilePath: objectUrl, statusCode: 200, errMsg: 'downloadFile:ok' }
539 if (typeof success === 'function') success(result)
540 if (typeof complete === 'function') complete(result)
541 })
542 .catch((err) => {
543 const result = { errMsg: `downloadFile:fail ${err.message}`, statusCode: 0 }
544 if (typeof fail === 'function') fail(result)
545 if (typeof complete === 'function') complete(result)
546 })
547 return { onProgressUpdate() {}, abort() {} }
548}
549
550// ============================================================
551// 交互提示
552// ============================================================
553
554const showToast = (opts = {}) => {
555 const id = pushToast(opts)
556 const duration = Number(opts.duration) > 0 ? Number(opts.duration) : 2000
557 if (typeof opts.success === 'function') opts.success({ errMsg: 'showToast:ok' })
558 if (typeof opts.complete === 'function') opts.complete({ errMsg: 'showToast:ok' })
559 return id
560}
561
562const showModal = (opts = {}) => {
563 openModal(opts).then((res) => {
564 if (typeof opts.success === 'function') opts.success(res)
565 if (typeof opts.complete === 'function') opts.complete(res)
566 })
567}
568
569const showActionSheet = (opts = {}) => {
570 // 桌面端用居中弹窗承载(itemList 拼接),保证调用方不炸
571 const list = Array.isArray(opts.itemList) ? opts.itemList : []
572 if (!list.length) {
573 if (typeof opts.fail === 'function') opts.fail({ errMsg: 'showActionSheet:fail' })
574 return
575 }
576 openModal({
577 title: opts.title || '请选择',
578 content: list.join('\n'),
579 confirmText: '确定',
580 cancelText: '取消',
581 }).then((res) => {
582 if (res.confirm) {
583 if (typeof opts.success === 'function') opts.success({ tapIndex: 0 })
584 } else if (typeof opts.fail === 'function') opts.fail({ errMsg: 'showActionSheet:fail cancel' })
585 })
586}
587
588const setClipboardData = (opts = {}) => {
589 setClipboard(opts.data == null ? '' : opts.data).then((ok) => {
590 const res = { errMsg: ok ? 'setClipboardData:ok' : 'setClipboardData:fail' }
591 if (typeof opts.success === 'function' && ok) opts.success(res)
592 if (typeof opts.fail === 'function' && !ok) opts.fail(res)
593 if (typeof opts.complete === 'function') opts.complete(res)
594 })
595}
596
597const getClipboardData = (opts = {}) => {
598 getClipboard().then((data) => {
599 const res = { data, errMsg: 'getClipboardData:ok' }
600 if (typeof opts.success === 'function') opts.success(res)
601 if (typeof opts.complete === 'function') opts.complete(res)
602 })
603}
604
605// ============================================================
606// 系统信息 / 路由 / 空实现
607// ============================================================
608
609const getSystemInfoSync = () => {
610 const w = typeof window !== 'undefined' ? window.innerWidth : 1360
611 const h = typeof window !== 'undefined' ? window.innerHeight : 880
612 return {
613 platform: 'windows',
614 system: 'Windows',
615 brand: 'windows',
616 model: 'PC',
617 windowWidth: w,
618 windowHeight: h,
619 screenWidth: typeof screen !== 'undefined' ? screen.width : w,
620 screenHeight: typeof screen !== 'undefined' ? screen.height : h,
621 statusBarHeight: 0,
622 safeAreaInsets: { top: 0, bottom: 0, left: 0, right: 0 },
623 pixelRatio: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1,
624 language: 'zh-Hans',
625 version: '1.3.6',
626 }
627}
628
629/** 路由桥:由 App.vue 在挂载时注入,使 uni.navigateTo 等旧调用仍可工作 */
630let routerBridge = null
631const registerRouter = (bridge) => {
632 routerBridge = bridge
633}
634
635const navigateTo = (opts = {}) => {
636 if (routerBridge && typeof routerBridge.open === 'function') routerBridge.open(opts.url || '')
637 if (typeof opts.success === 'function') opts.success({ errMsg: 'navigateTo:ok' })
638 if (typeof opts.complete === 'function') opts.complete({ errMsg: 'navigateTo:ok' })
639}
640
641const noop = (opts = {}) => {
642 if (opts && typeof opts.success === 'function') opts.success({ errMsg: 'ok' })
643 if (opts && typeof opts.complete === 'function') opts.complete({ errMsg: 'ok' })
644}
645
646/** 选择图片:优先原生对话框,回退 input[type=file] */
647const chooseImage = (opts = {}) => {
648 const done = (paths) => {
649 const res = { tempFilePaths: paths, tempFiles: paths.map((p) => ({ path: p })), errMsg: 'chooseImage:ok' }
650 if (typeof opts.success === 'function') opts.success(res)
651 if (typeof opts.complete === 'function') opts.complete(res)
652 }
653 const failed = (err) => {
654 const res = { errMsg: `chooseImage:fail ${err.message}` }
655 if (typeof opts.fail === 'function') opts.fail(res)
656 if (typeof opts.complete === 'function') opts.complete(res)
657 }
658
659 pickImageNative(opts.title)
660 .then((picked) => {
661 if (picked) {
662 done([picked.dataUrl])
663 return null
664 }
665 return pickImageByInput('image/*').then((file) => done([file.content]))
666 })
667 .catch(failed)
668}
669
670/** 圆角选择器 / 画布上下文:移动端页面专用,桌面端页面不依赖,这里给出安全空实现 */
671const createSelectorQuery = () => ({
672 in: () => createSelectorQuery(),
673 select: () => createSelectorQuery(),
674 selectAll: () => createSelectorQuery(),
675 boundingClientRect: (cb) => {
676 if (typeof cb === 'function') setTimeout(() => cb(null), 0)
677 return createSelectorQuery()
678 },
679 fields: () => createSelectorQuery(),
680 exec: (cb) => {
681 if (typeof cb === 'function') setTimeout(() => cb([]), 0)
682 },
683})
684
685const createCanvasContext = () => ({
686 setFillStyle() {},
687 setStrokeStyle() {},
688 setGlobalAlpha() {},
689 setLineWidth() {},
690 beginPath() {},
691 moveTo() {},
692 lineTo() {},
693 arc() {},
694 rect() {},
695 fill() {},
696 stroke() {},
697 fillRect() {},
698 clearRect() {},
699 drawImage() {},
700 draw() {},
701 measureText: () => ({ width: 0 }),
702 setFontSize() {},
703 fillText() {},
704 save() {},
705 restore() {},
706})
707
708// ============================================================
709// 组装 globalThis.uni
710// ============================================================
711
712const uni = {
713 // 标记:供业务代码/调试区分运行环境
714 __platform: 'pc',
715 __native: hasNative(),
716 __registerRouter: registerRouter,
717
718 // 存储
719 getStorageSync,
720 setStorageSync,
721 removeStorageSync,
722 clearStorageSync,
723 getStorageInfoSync,
724
725 // 音频
726 createInnerAudioContext,
727
728 // 网络
729 request,
730 downloadFile,
731
732 // 交互
733 showToast,
734 hideToast: noop,
735 showLoading: (opts = {}) => {
736 showLoading(opts.title || '加载中...', !!opts.mask)
737 if (typeof opts.success === 'function') opts.success({ errMsg: 'showLoading:ok' })
738 if (typeof opts.complete === 'function') opts.complete({ errMsg: 'showLoading:ok' })
739 },
740 hideLoading,
741 showModal,
742 showActionSheet,
743 setClipboardData,
744 getClipboardData,
745
746 // 系统
747 getSystemInfoSync,
748 getSystemInfo: (opts = {}) => {
749 const info = getSystemInfoSync()
750 if (typeof opts.success === 'function') opts.success(info)
751 if (typeof opts.complete === 'function') opts.complete(info)
752 },
753
754 // 路由(桌面端由 App.vue 的 router bridge 接管)
755 navigateTo,
756 redirectTo: navigateTo,
757 reLaunch: (opts = {}) => {
758 if (routerBridge && typeof routerBridge.open === 'function') routerBridge.open(opts.url || '')
759 if (typeof opts.complete === 'function') opts.complete({ errMsg: 'reLaunch:ok' })
760 },
761 switchTab: (opts = {}) => {
762 if (routerBridge && typeof routerBridge.open === 'function') routerBridge.open(opts.url || '')
763 if (typeof opts.complete === 'function') opts.complete({ errMsg: 'switchTab:ok' })
764 },
765 navigateBack: (opts = {}) => {
766 if (routerBridge && typeof routerBridge.back === 'function') routerBridge.back()
767 if (typeof opts === 'function') opts({ errMsg: 'navigateBack:ok' })
768 else if (opts && typeof opts.complete === 'function') opts.complete({ errMsg: 'navigateBack:ok' })
769 },
770
771 // 图片
772 chooseImage,
773 // 桌面端不需要的原生能力,给出安全空实现
774 saveFile: (opts = {}) => {
775 if (typeof opts.success === 'function') opts.success({ savedFilePath: opts.tempFilePath || '' })
776 if (typeof opts.complete === 'function') opts.complete({ errMsg: 'saveFile:ok' })
777 },
778 removeSavedFile: noop,
779 vibrateShort: noop,
780 vibrateLong: noop,
781 hideKeyboard: noop,
782 setTabBarStyle: noop,
783 setTabBarItem: noop,
784 hideTabBar: noop,
785 showTabBar: noop,
786
787 // 移动端页面专用(桌面端不依赖)
788 createSelectorQuery,
789 createCanvasContext,
790 // 事件(业务层使用自有 event bus,这里仅为兼容占位)
791 $emit: () => {},
792 $on: () => {},
793 $off: () => {},
794}
795
796// 挂到全局:common/* 里的裸 `uni.xxx` 依赖它
797if (typeof globalThis !== 'undefined') globalThis.uni = uni
798if (typeof window !== 'undefined') window.uni = uni
799
800// 提前把原生(Go)侧版本号同步给 update 模块,避免异步竞态导致显示旧版本
801getAppInfo()
802 .then((info) => {
803 if (!info) return
804 if (info.version) setNativeVersion(info.version)
805 if (info.versionCode) setNativeVersionCode(info.versionCode)
806 })
807 .catch(() => {})
808
809// 卸载前强制落盘
810if (typeof window !== 'undefined') {
811 window.addEventListener('beforeunload', onBeforeUnload)
812 // 定期兜底落盘,防止异常退出丢数据
813 setInterval(() => {
814 if (dirty.size) flush()
815 }, 5000)
816}
817
818export { uni, updateLoading, openExternal }
819export default uni