From 5ae4bbcbd39d7e0acd05aa65d017e82be9949781 Mon Sep 17 00:00:00 2001
From: ZF sun <34314687@qq.com>
Date: Wed, 1 Jul 2026 15:36:55 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E7=BB=9F=E4=B8=80?=
=?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=B1=8F=E7=BB=84=E4=BB=B6=EF=BC=8C=E4=BC=98?=
=?UTF-8?q?=E5=8C=96=E8=B7=A8=E5=B9=B3=E5=8F=B0=E5=8A=A0=E8=BD=BD=E4=BD=93?=
=?UTF-8?q?=E9=AA=8C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
1. 新增splash-screen全局启动屏组件,适配非APP平台的加载过渡
2. 新增splash-state状态管理模块,统一处理启动屏的显示隐藏、软隐藏时序、兜底超时逻辑
3. 重构App.vue和main.js的加载逻辑,替换原有uni.showLoading方案
4. 在首页和空页面挂载启动屏组件,适配不同页面场景
5. 兼容H5平台降级处理,解决main.js挂载前无法渲染组件的问题
---
App.vue | 18 +-
common/js/splash-state.js | 225 +++++++++++++++++++++
components/splash-screen/splash-screen.vue | 178 ++++++++++++++++
main.js | 15 +-
pages/empty/index.vue | 1 +
pages/index/index.vue | 2 +
6 files changed, 420 insertions(+), 19 deletions(-)
create mode 100644 common/js/splash-state.js
create mode 100644 components/splash-screen/splash-screen.vue
diff --git a/App.vue b/App.vue
index a69db66..2da1637 100644
--- a/App.vue
+++ b/App.vue
@@ -4,6 +4,7 @@
import Lang from 'common/js/lang.js';
import ThemeOverride from 'common/js/theme-override.js';
import PrivacyManager from 'common/js/privacy.js';
+ import splashState from 'common/js/splash-state.js';
import {
Weixin
@@ -44,18 +45,15 @@
}
// remote-only 模式:
- // H5: main.js 已 await boot 完成, 这里秒回
+ // H5: main.js 已 await boot 完成, 这里秒回 (splash 也由 main.js 全权负责)
// 小程序/鸿蒙: main.js 同步 $mount(), boot 在这里异步执行
- // → boot 期间 showLoading 遮罩, 完成后 hideLoading
+ // → splashState.show() 同步设置 visible 标志, 框架推页面后 splash 组件挂载时读取 isShow() 渲染
const langBoot = Lang.boot();
this.$langReady = langBoot;
- let _hideLangLoading = false;
// #ifndef H5
- if (Lang.isRemoteOnly && Lang.isRemoteOnly()) {
- try {
- uni.showLoading({ title: '加载中...', mask: true });
- _hideLangLoading = true;
- } catch (e) {}
+ const _needSplash = Lang.isRemoteOnly && Lang.isRemoteOnly();
+ if (_needSplash) {
+ splashState.show();
}
// #endif
@@ -74,8 +72,8 @@
]);
// #ifndef H5
- if (_hideLangLoading) {
- try { uni.hideLoading(); } catch (e) {}
+ if (_needSplash) {
+ splashState.hide({ reason: 'boot-complete' });
}
// #endif
diff --git a/common/js/splash-state.js b/common/js/splash-state.js
new file mode 100644
index 0000000..8a7f38e
--- /dev/null
+++ b/common/js/splash-state.js
@@ -0,0 +1,225 @@
+// ============================================================
+// 启动屏单例状态
+//
+// 设计要点:
+// 1. 状态 + 事件双通道: 组件既可 watch visible,也可监听 'splash:hide' 事件
+// 解决 uni.$emit 在组件挂载前发出就丢失的问题 (memory lesson)
+// 2. 软隐藏 (softHide): 标记应该 hide, 等组件 mount 后再真正 hide
+// 解决 onLaunch 跑完 + hide() 早于 page mount 的时序问题
+// 3. 防重入: show()/hide() 多次调用安全,内部用 guard
+// 4. 兜底超时: 默认 8s 未关闭则强制 hide,防止卡死
+// 5. 平台隔离: APP-PLUS 有原生 splash,这里直接返回 false
+// 6. 跨平台兼容: 同步状态可被 Vue 组件直接 watch
+// ============================================================
+
+// APP-PLUS 直接返回 false (有原生启动屏)
+function needSplash() {
+ // #ifdef APP-PLUS
+ return false;
+ // #endif
+ // #ifndef APP-PLUS
+ return true;
+ // #endif
+}
+
+// H5 平台特殊处理: Lang.boot() 在 main.js 中 await, 此时 Vue 还没挂载,
+// 组件无法渲染, 内部降级为 uni.showLoading 做兜底 (虽然样式较丑但至少有遮罩)
+function useNativeLoading() {
+ // #ifdef H5
+ return true;
+ // #endif
+ // #ifndef H5
+ return false;
+ // #endif
+}
+
+const DEFAULT_TIMEOUT = 8000; // 总兜底超时
+const MOUNT_GRACE_MS = 1000; // 软隐藏后等组件 mount 的宽限时间
+
+class SplashState {
+ constructor() {
+ this._visible = false;
+ this._timeoutId = null;
+ this._listeners = [];
+ this._shownAt = 0;
+ // 软隐藏相关
+ this._pendingHide = null; // 软隐藏请求的 opts, 等待组件 mount
+ this._hasMounted = false; // 是否有 splash 组件 mount 过
+ this._mountGraceId = null; // 软隐藏兜底定时器
+ }
+
+ show(opts = {}) {
+ if (!needSplash()) return;
+ if (this._visible) return; // 防重入
+
+ this._visible = true;
+ this._shownAt = Date.now();
+ this._pendingHide = null;
+ this._hasMounted = false;
+ if (this._mountGraceId) {
+ clearTimeout(this._mountGraceId);
+ this._mountGraceId = null;
+ }
+ this._emit('splash:show');
+
+ // H5 降级: 走系统 loading (Vue 还没挂载,组件层无法渲染)
+ if (useNativeLoading()) {
+ try { uni.showLoading({ title: opts.title || '加载中...', mask: true }); } catch (e) {}
+ }
+
+ const timeout = opts.timeout || DEFAULT_TIMEOUT;
+ if (this._timeoutId) {
+ clearTimeout(this._timeoutId);
+ this._timeoutId = null;
+ }
+ this._timeoutId = setTimeout(() => {
+ // 兜底: 超时强制关闭,防止卡死 (走硬隐藏路径, 不等组件)
+ if (this._visible) {
+ console.warn('[splash-state] auto-hide after timeout', timeout);
+ this._doHide({ reason: 'timeout' });
+ }
+ }, timeout);
+ }
+
+ /**
+ * 隐藏 splash
+ * @param {Object} opts
+ * @param {string} opts.reason - 隐藏原因
+ * - 'boot-complete': 软隐藏, 等组件 mount 后再真关 (默认)
+ * - 'timeout': 硬隐藏, 立即关 (兜底用)
+ * - 'manual': 硬隐藏, 立即关
+ * @param {boolean} [opts.soft] - 强制走软隐藏
+ */
+ hide(opts = {}) {
+ const reason = opts.reason || 'manual';
+ // boot-complete 默认走软隐藏, 避免时序问题
+ const useSoft = opts.soft === true || (opts.soft !== false && reason === 'boot-complete');
+
+ if (useSoft) {
+ this._softHide({ reason });
+ } else {
+ this._doHide({ reason });
+ }
+ }
+
+ /**
+ * 软隐藏: 标记应该 hide, 等组件 mount
+ * 如果组件 1s 内没 mount, 兜底自动 hide
+ */
+ _softHide(opts) {
+ if (!this._visible) return; // 已经隐藏了
+ this._pendingHide = opts;
+
+ // 如果组件已经 mount, 立即隐藏
+ if (this._hasMounted) {
+ this._flushPendingHide();
+ return;
+ }
+
+ // 兜底: 1s 内没 mount, 强制隐藏
+ if (this._mountGraceId) clearTimeout(this._mountGraceId);
+ this._mountGraceId = setTimeout(() => {
+ if (this._pendingHide) {
+ this._flushPendingHide();
+ }
+ }, MOUNT_GRACE_MS);
+ }
+
+ /**
+ * 实际隐藏 (硬路径)
+ */
+ _doHide(opts) {
+ if (!this._visible) return; // 防重入
+ if (this._timeoutId) {
+ clearTimeout(this._timeoutId);
+ this._timeoutId = null;
+ }
+ if (this._mountGraceId) {
+ clearTimeout(this._mountGraceId);
+ this._mountGraceId = null;
+ }
+ this._pendingHide = null;
+ this._visible = false;
+ // H5 降级对应的关闭
+ if (useNativeLoading()) {
+ try { uni.hideLoading(); } catch (e) {}
+ }
+ this._emit('splash:hide', { reason: opts.reason || 'manual' });
+ }
+
+ /**
+ * 真正触发软隐藏
+ */
+ _flushPendingHide() {
+ const opts = this._pendingHide;
+ this._pendingHide = null;
+ if (this._mountGraceId) {
+ clearTimeout(this._mountGraceId);
+ this._mountGraceId = null;
+ }
+ this._doHide(opts || { reason: 'mount-completed' });
+ }
+
+ /**
+ * 组件 mount 后通知 state 模块
+ * - 标记 _hasMounted
+ * - 如果有 _pendingHide, 触发真关
+ * - H5 降级场景: 立即关掉原生 loading, 让 splash 组件接管视觉
+ */
+ notifyMounted() {
+ if (this._hasMounted) return;
+ this._hasMounted = true;
+ // H5 降级: 立即关掉原生 loading, 避免和 splash 组件重叠
+ if (useNativeLoading()) {
+ try { uni.hideLoading(); } catch (e) {}
+ }
+ if (this._pendingHide) {
+ // 给组件一帧时间渲染, 再隐藏 (避免一帧闪烁)
+ setTimeout(() => this._flushPendingHide(), 50);
+ }
+ }
+
+ /**
+ * 组件 unmount 时通知 (用于 _hasMounted 状态管理)
+ * 目前保留 _hasMounted=true, 因为一般只有 tab 页有 splash 组件, 不会动态增减
+ */
+ notifyUnmounted() {
+ // 不重置 _hasMounted, 因为软隐藏的 _pendingHide 处理不依赖 unmount
+ }
+
+ isShow() {
+ return this._visible;
+ }
+
+ hasPendingHide() {
+ return this._pendingHide != null;
+ }
+
+ elapsed() {
+ if (!this._visible) return 0;
+ return Date.now() - this._shownAt;
+ }
+
+ on(event, fn) {
+ if (!this._listeners[event]) this._listeners[event] = [];
+ this._listeners[event].push(fn);
+ }
+
+ off(event, fn) {
+ if (!this._listeners[event]) return;
+ this._listeners[event] = this._listeners[event].filter(f => f !== fn);
+ }
+
+ _emit(event, payload) {
+ const arr = this._listeners[event];
+ if (!arr || !arr.length) return;
+ for (let i = 0; i < arr.length; i++) {
+ try { arr[i](payload); } catch (e) { console.warn('[splash-state] listener err', e); }
+ }
+ }
+}
+
+const splashState = new SplashState();
+
+export default splashState;
+export { splashState };
diff --git a/components/splash-screen/splash-screen.vue b/components/splash-screen/splash-screen.vue
new file mode 100644
index 0000000..cbfb52e
--- /dev/null
+++ b/components/splash-screen/splash-screen.vue
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+ S
+
+ Shop
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/main.js b/main.js
index 5caf83a..6f98650 100644
--- a/main.js
+++ b/main.js
@@ -16,6 +16,7 @@ import {
} from './common/js/storage.js'
import configExternal from './common/js/config-external.js'
import PrivacyManager from './common/js/privacy.js'
+import splashState from './common/js/splash-state.js'
Vue.prototype.$store = store //挂在vue
@@ -67,26 +68,22 @@ uniStorage();
// 以下代码所有平台共享:
// - lang.js: bootCompleted 标志, boot 未完成时 lang() 返回空串
// - http.js: sendRequest 门控, boot 未完成时业务请求排队
-// - App.vue: onLaunch 中 showLoading + await boot + hideLoading
+// - splash-state.js: 统一启动屏状态 (H5 降级 uni.showLoading, 小程序/鸿蒙走 splash 组件)
+// - App.vue: onLaunch 中 splashState.show() + await boot + splashState.hide()
// ============================================================
// #ifdef H5
// H5: remote-only 下先 await boot 再 $mount(), 首屏直接有翻译数据
+// splashState.show() 内部 H5 降级为 uni.showLoading (await 期间 Vue 还没挂载,组件层无法渲染)
async function bootstrap() {
if (Lang.isRemoteOnly && Lang.isRemoteOnly()) {
- let _hideLoading = false;
- try {
- uni.showLoading({ title: '加载中...', mask: true });
- _hideLoading = true;
- } catch (e) {}
+ splashState.show();
try {
await Lang.boot();
} catch (e) {
console.warn('[i18n] remote-only boot fail', e);
} finally {
- if (_hideLoading) {
- try { uni.hideLoading(); } catch (e) {}
- }
+ splashState.hide({ reason: 'boot-complete' });
}
}
diff --git a/pages/empty/index.vue b/pages/empty/index.vue
index 0399adb..87cedad 100644
--- a/pages/empty/index.vue
+++ b/pages/empty/index.vue
@@ -1,5 +1,6 @@
+
diff --git a/pages/index/index.vue b/pages/index/index.vue
index 48930e7..28a1b88 100644
--- a/pages/index/index.vue
+++ b/pages/index/index.vue
@@ -142,6 +142,8 @@
+
+