From 6a6d5f9deb4c5ed23bfa66754e1a75e35bf89264 Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Mon, 13 Jul 2026 14:56:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=A2=9E=E5=8A=A0=E5=AE=88=E5=8D=AB?= =?UTF-8?q?=E7=BB=84=E5=90=88=E5=BC=8F=E5=87=BD=E6=95=B0=E6=9C=AC=E8=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/composables/useAliveGuard.js | 89 +++++ frontend/composables/useShare.js | 98 +++--- frontend/pages/castlove/create.vue | 83 ++--- frontend/pages/castlove/index.vue | 68 ++-- .../castlove/lenticular/lenticular-create.vue | 78 ++--- .../castlove/lenticular/lenticular-result.vue | 65 ++-- .../pages/castlove/self-created/create.vue | 98 +++--- frontend/pages/components/ShareModal.vue | 13 +- frontend/pages/discover/discover.vue | 26 +- frontend/pages/discover/generation-result.vue | 304 +++++++++--------- 10 files changed, 524 insertions(+), 398 deletions(-) create mode 100644 frontend/composables/useAliveGuard.js diff --git a/frontend/composables/useAliveGuard.js b/frontend/composables/useAliveGuard.js new file mode 100644 index 0000000..ae1f35f --- /dev/null +++ b/frontend/composables/useAliveGuard.js @@ -0,0 +1,89 @@ +// frontend/composables/useAliveGuard.js +// +// 用途:UniApp iOS WebView 的 JS Framework 在原生异步回调回来时,如果页面已销毁, +// 调 n.fireEvent 会报 "undefined is not an object (evaluating 'n.fireEvent')" +// (warning 级别,不影响原生操作结果,但日志噪音大且难定位) +// +// 解法:在 setup() 里创建 alive 守卫,页面卸载/组件销毁时翻成 false; +// 所有原生异步回调(uni.* / plus.io.* / plus.nativeObj.* 的 success/fail/complete) +// 包一层 guard,守卫死亡后直接吞掉结果,不更新 state、不弹 toast、不继续后续链式调用。 +// +// 注意:我们不取消原生操作本身 — 用户已经触发了上传/保存/文件读取,这些操作的实际副作用 +// (网络请求发出、文件落盘)应该正常完成。只是 JS 这一侧不应当再触碰一个已死页面的 state。 +// +// 用法: +// import { useAliveGuard } from '@/composables/useAliveGuard.js' +// +// const { isAlive, die, guard } = useAliveGuard() +// +// uni.uploadFile({ +// url, filePath, +// success: guard((res) => { /* 仅 alive=true 时执行 */ }), +// fail: guard((err) => { /* 同上 */ }), +// complete: guard(() => { /* 同上 */ }) +// }) +// +// // plus.io 链式回调: +// plus.io.resolveLocalFileSystemURL( +// path, +// guard((entry) => { entry.file(guard((file) => { ... })) }), +// guard((err) => { ... }) +// ) +// +// // 主动检查: +// if (!isAlive()) return +// +// 自动绑定生命周期: +// - 页面上下文(setup 里当前实例是 page):绑定 onUnload(页面关闭时死) +// - 组件上下文(setup 里当前实例是 component):绑定 onBeforeUnmount(v-if=false / 父组件销毁时死) +// - 两者都不存在(罕见):返回的 alive 永远为 true(等同不守卫,留给调用方自行 die()) + +import { onBeforeUnmount } from 'vue' +import { onUnload } from '@dcloudio/uni-app' + +/** + * @returns {{ + * isAlive: () => boolean, + * die: () => void, + * guard: (fn: T) => T + * }} + */ +export function useAliveGuard() { + let alive = true + let bound = false + + // 优先 onUnload(页面级生命周期,从 @dcloudio/uni-app 导入) + // 页面里调 onUnload 是注册的页面 onUnload 钩子;组件里调可能抛 "onUnload is not a function" + try { + onUnload(() => { alive = false }) + bound = true + } catch (_) { + /* 组件上下文,onUnload 不可用,下面兜底 */ + } + + // 兜底 onBeforeUnmount(Vue 组件级生命周期) + if (!bound) { + try { + onBeforeUnmount(() => { alive = false }) + } catch (_) { + /* 都不在 setup 里调,保持 alive=true,调用方自行 die() */ + } + } + + return { + isAlive: () => alive, + /** 主动翻成 false(罕见用 — 大多数场景生命周期钩子已经处理) */ + die: () => { alive = false }, + /** + * 包装回调:页面/组件已死时 no-op,活着时透传执行 + * 非函数参数原样返回,方便链式写:guard(maybeUndefined) + */ + guard(fn) { + if (typeof fn !== 'function') return fn + return function guardedCallback(...args) { + if (!alive) return undefined + return fn.apply(this, args) + } + } + } +} \ No newline at end of file diff --git a/frontend/composables/useShare.js b/frontend/composables/useShare.js index 83a2e1a..3e8e8f9 100644 --- a/frontend/composables/useShare.js +++ b/frontend/composables/useShare.js @@ -2,11 +2,12 @@ // 分享状态机 + 合成调度 + 错误处理 + 监控埋点 // (spec § 5 + § 5.3.1 + § 7 + § 8) -import { ref, nextTick, onBeforeUnmount } from 'vue'; -import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app'; +import { ref, nextTick } from 'vue'; +import { onShow, onHide, onUnload } from '@dcloudio/uni-app'; import { composeShareImage, computeComposeKey } from '@/utils/image-compositor.js'; import { pickRandomSlogan } from '@/utils/brand-slogans.js'; import { getShareQrcodeApi, trackShareApi } from '@/utils/api.js'; +import { useAliveGuard } from '@/composables/useAliveGuard.js'; const APP_PACKAGES = { weixin_friend: { pname: 'com.tencent.mm', bundleid: 'com.tencent.xinWeChat' }, @@ -39,6 +40,12 @@ export function useShare(props) { const currentSlogan = ref(pickRandomSlogan()); const failCount = ref(0); // L3 degradation counter + // ============ Alive Guard ============ + // 分享进行中若用户关闭弹窗/页面,所有原生异步回调(plus.io / uni.saveImageToPhotosAlbum / + // plus.gallery.save)回来时会被 guard() 吞掉,不再触碰 state、不弹 toast、不继续链式调用, + // 避免 iOS WebView 上 "TypeError: undefined is not an object (evaluating 'n.fireEvent')" + const { isAlive, guard } = useAliveGuard(); + // ============ Timers ============ const shareTimeoutTimer = ref(null); const fallbackTimer = ref(null); @@ -49,8 +56,11 @@ export function useShare(props) { // ============ Lifecycle ============ (async () => { - try { const info = await uni.getSystemInfo(); systemType.value = info.platform || 'other'; } - catch { systemType.value = 'other'; } + try { + const info = await uni.getSystemInfo(); + if (!isAlive()) return; + systemType.value = info.platform || 'other'; + } catch { /* alive 守卫兜底 */ } })(); onShow(() => { if (state.value === 'sharing') { @@ -60,7 +70,8 @@ export function useShare(props) { onHide(() => { if (shareTimeoutTimer.value) { clearTimeout(shareTimeoutTimer.value); shareTimeoutTimer.value = null; } }); - onBeforeUnmount(clearShareTimers); + // useAliveGuard 已绑 onUnload 清活守卫,这里再清一次 timers 保持同步 + onUnload(() => { clearShareTimers(); }); // ============ L1 Cache ============ const l1Cache = new Map(); @@ -84,7 +95,10 @@ export function useShare(props) { const pkg = APP_PACKAGES[action]; if (!pkg) return true; return new Promise(resolve => { - plus.runtime.isApplicationExist({ pname: pkg.pname, bundleid: pkg.bundleid }, e => resolve(!!e.exist)); + plus.runtime.isApplicationExist( + { pname: pkg.pname, bundleid: pkg.bundleid }, + guard(e => resolve(!!e.exist)) + ); }); } @@ -128,34 +142,34 @@ export function useShare(props) { // 先确保 _doc 存在 plus.io.resolveLocalFileSystemURL( '_doc/', - (docDir) => { + guard((docDir) => { // 解析 _www 源文件 plus.io.resolveLocalFileSystemURL( srcPath, - (srcEntry) => { + guard((srcEntry) => { srcEntry.copyTo( docDir, fileName, - (destEntry) => { + guard((destEntry) => { console.log('[useShare] static copied to', destEntry.fullPath); resolve(destEntry.fullPath); - }, - (copyErr) => { + }), + guard((copyErr) => { console.warn('[useShare] static copy fail, fallback raw', copyErr); resolve(staticPath); - } + }) ); - }, - (resolveErr) => { + }), + guard((resolveErr) => { console.warn('[useShare] static resolve fail, fallback raw', resolveErr); resolve(staticPath); - } + }) ); - }, - (docErr) => { + }), + guard((docErr) => { console.warn('[useShare] _doc resolve fail', docErr); resolve(staticPath); - } + }) ); }); } @@ -180,38 +194,38 @@ export function useShare(props) { // plus.io.resolveLocalFileSystemURL 拿到 source FileEntry plus.io.resolveLocalFileSystemURL( tempPath, - (srcEntry) => { + guard((srcEntry) => { // 拿沙盒目录 DirectoryEntry plus.io.resolveLocalFileSystemURL( sandboxRoot, - (sandboxDir) => { + guard((sandboxDir) => { srcEntry.copyTo( sandboxDir, fileName, - (destEntry) => { + guard((destEntry) => { clearTimeout(timeoutId); console.log('[useShare] copyToSandbox done:', destEntry.fullPath); resolve(destEntry.fullPath); - }, - (copyErr) => { + }), + guard((copyErr) => { clearTimeout(timeoutId); console.warn('[useShare] copyToSandbox fail:', copyErr); reject(new Error(copyErr.message || 'copyTo failed')); - } + }) ); - }, - (dirErr) => { + }), + guard((dirErr) => { clearTimeout(timeoutId); console.warn('[useShare] resolve sandbox dir fail:', dirErr); reject(new Error(dirErr.message || 'sandbox dir resolve failed')); - } + }) ); - }, - (srcErr) => { + }), + guard((srcErr) => { clearTimeout(timeoutId); console.warn('[useShare] resolve temp fail:', srcErr); reject(new Error(srcErr.message || 'temp resolve failed')); - } + }) ); }); } @@ -221,9 +235,9 @@ export function useShare(props) { if (typeof plus === 'undefined' || !plus.io || !sandboxPath) return; plus.io.resolveLocalFileSystemURL( sandboxPath, - (entry) => { + guard((entry) => { try { entry.remove(() => {}, () => {}); } catch {} - }, + }), () => {} ); } @@ -368,15 +382,15 @@ export function useShare(props) { await new Promise((resolve, reject) => { uni.saveImageToPhotosAlbum({ filePath: tempFilePath, - success: (res) => { + success: guard((res) => { console.log('[useShare] uni.saveImageToPhotosAlbum success', res); uni.showToast({ title: '已保存到相册' }); resolve(); - }, - fail: (e) => { + }), + fail: guard((e) => { console.warn('[useShare] uni.saveImageToPhotosAlbum fail, fallback to plus.gallery.save', e); reject(new Error(e.errMsg || JSON.stringify(e) || 'save failed')); - } + }) }); }); } catch (eUni) { @@ -391,17 +405,17 @@ export function useShare(props) { await new Promise((resolve, reject) => { plus.gallery.save( sandboxPath, - (res) => { + guard((res) => { console.log('[useShare] plus.gallery.save success', res); uni.showToast({ title: '已保存到相册' }); cleanupSandboxFile(sandboxPath); resolve(); - }, - (err) => { + }), + guard((err) => { console.warn('[useShare] plus.gallery.save fail', err); cleanupSandboxFile(sandboxPath); reject(err); - } + }) ); }); } catch (eSandbox) { @@ -419,8 +433,8 @@ export function useShare(props) { const shareRes = await new Promise((resolve) => { uni.share({ provider, scene, imageUrl: tempFilePath, - success: (r) => resolve({ ok: true, msg: r.errMsg }), - fail: (e) => resolve({ ok: false, msg: e.errMsg || 'share failed' }) + success: guard((r) => resolve({ ok: true, msg: r.errMsg })), + fail: guard((e) => resolve({ ok: false, msg: e.errMsg || 'share failed' })) }); }); if (shareRes.ok) result = 'success'; diff --git a/frontend/pages/castlove/create.vue b/frontend/pages/castlove/create.vue index 18b76a0..1821eb2 100644 --- a/frontend/pages/castlove/create.vue +++ b/frontend/pages/castlove/create.vue @@ -160,6 +160,7 @@ import { onLoad, onUnload } from '@dcloudio/uni-app'; import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js'; import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js'; import { getSandboxFileUri, clearSandboxSubdir } from '@/utils/ioPath.js'; +import { useAliveGuard } from '@/composables/useAliveGuard.js'; import ConfirmModal from '@/components/ConfirmModal.vue'; import './create-laser-upload.css'; import { @@ -239,6 +240,10 @@ const onConfirmModal = () => { confirmModal.value.visible = false; }; +// alive 守卫:页面销毁后,原生异步回调(chooseImage/getFileInfo/plus.io.FileReader)回来时 +// 不再触碰 state,避免 iOS WebView 上 "TypeError: undefined is not an object (evaluating 'n.fireEvent')" +const { guard } = useAliveGuard(); + // 弹窗取消回调 const onCancelModal = () => { if (confirmModal.value.confirmCallback) { @@ -332,13 +337,13 @@ const openImagePicker = (lenticularSlot) => { uni.chooseImage({ count: 1, sourceType: ['album', 'camera'], - success: (res) => { + success: guard((res) => { const filePath = res.tempFilePaths[0]; const tempFile = res.tempFiles && res.tempFiles[0]; uni.getFileInfo({ filePath: filePath, - success: (fileInfo) => { + success: guard((fileInfo) => { const maxSize = 5 * 1024 * 1024; if (fileInfo.size > maxSize) { pendingLenticularSlot.value = ''; @@ -359,19 +364,19 @@ const openImagePicker = (lenticularSlot) => { const rawName = (tempFile && tempFile.name) ? tempFile.name : filePath.split('/').pop(); originalFileName.value = rawName; convertImageToBase64(filePath, originalFileName.value); - }, - fail: (error) => { + }), + fail: guard((error) => { pendingLenticularSlot.value = ''; console.error('获取文件信息失败:', error); uni.showToast({ title: '获取文件信息失败', icon: 'none' }); - } + }) }); - }, - fail: (err) => { + }), + fail: guard((err) => { pendingLenticularSlot.value = ''; console.error('选择图片失败:', err); uni.showToast({ title: '选择图片失败', icon: 'none' }); - } + }) }); }; @@ -423,7 +428,7 @@ const convertImageToBase64Native = (filePath, fileName) => { fs.readFile({ filePath: filePath, encoding: 'base64', - success: (res) => { + success: guard((res) => { const ext = fileName.toLowerCase().split('.').pop(); let mimeType = 'image/jpeg'; if (ext === 'png') mimeType = 'image/png'; @@ -431,42 +436,42 @@ const convertImageToBase64Native = (filePath, fileName) => { applyUploadResult(filePath, dataUrl); console.log('[CreatePage] Base64转换成功 (小程序)'); console.log('[CreatePage] Base64长度:', dataUrl.length); - }, - fail: (error) => { + }), + fail: guard((error) => { pendingLenticularSlot.value = ''; console.error('[CreatePage] Base64转换失败 (小程序):', error); uni.hideLoading(); uni.showToast({ title: '图片处理失败', icon: 'none', duration: 2000 }); isUploading.value = false; - } + }) }); // #endif // #ifdef APP-PLUS - plus.io.resolveLocalFileSystemURL(filePath, (entry) => { - entry.file((file) => { + plus.io.resolveLocalFileSystemURL(filePath, guard((entry) => { + entry.file(guard((file) => { const reader = new plus.io.FileReader(); - reader.onloadend = (e) => { + reader.onloadend = guard((e) => { applyUploadResult(filePath, e.target.result); console.log('[CreatePage] Base64转换成功 (App)'); console.log('[CreatePage] Base64长度:', (e.target.result && e.target.result.length) || 0); - }; - reader.onerror = (error) => { + }); + reader.onerror = guard((error) => { pendingLenticularSlot.value = ''; console.error('[CreatePage] Base64转换失败 (App):', error); uni.hideLoading(); uni.showToast({ title: '图片处理失败', icon: 'none', duration: 2000 }); isUploading.value = false; - }; + }); reader.readAsDataURL(file); - }); - }, (error) => { + })); + }), guard((error) => { pendingLenticularSlot.value = ''; console.error('[CreatePage] 读取文件失败 (App):', error); uni.hideLoading(); uni.showToast({ title: '图片处理失败', icon: 'none', duration: 2000 }); isUploading.value = false; - }); + })); // #endif }; @@ -528,7 +533,7 @@ const uploadImageToOss = async (base64Data, ossData) => { filePath: filePath, data: base64Content, encoding: 'base64', - success: () => { + success: guard(() => { uni.uploadFile({ url: ossData.host, filePath: filePath, @@ -543,22 +548,22 @@ const uploadImageToOss = async (base64Data, ossData) => { 'x-oss-signature': ossData.signature, 'x-oss-signature-version': ossData.x_oss_signature_version }, - success: (uploadRes) => { + success: guard((uploadRes) => { if (uploadRes.statusCode === 200 || uploadRes.statusCode === 204) { const imageUrl = `${ossData.host}/${ossData.dir}${fileName}`; resolve(imageUrl); } else { reject(new Error(`上传失败,状态码: ${uploadRes.statusCode}`)); } - }, - fail: (error) => { + }), + fail: guard((error) => { reject(error); - } + }) }); - }, - fail: (error) => { + }), + fail: guard((error) => { reject(error); - } + }) }); // #endif @@ -569,10 +574,10 @@ const uploadImageToOss = async (base64Data, ossData) => { const base64ContentApp = base64Data.split(',')[1]; const bitmap = new plus.nativeObj.Bitmap('temp'); - bitmap.loadBase64Data(base64Data, () => { + bitmap.loadBase64Data(base64Data, guard(() => { // 异步拿到沙盒 file:// 路径后再 save getSandboxFileUri(['castlove', 'tmp'], fileName).then((tempFilePath) => { - bitmap.save(tempFilePath, { overwrite: true }, () => { + bitmap.save(tempFilePath, { overwrite: true }, guard(() => { console.log('[CreatePage] App临时文件保存成功:', tempFilePath); bitmap.clear(); @@ -590,7 +595,7 @@ const uploadImageToOss = async (base64Data, ossData) => { 'x-oss-signature': ossData.signature, 'x-oss-signature-version': ossData.x_oss_signature_version }, - success: (uploadRes) => { + success: guard((uploadRes) => { console.log('[CreatePage] App上传响应:', uploadRes); if (uploadRes.statusCode === 200 || uploadRes.statusCode === 204) { const imageUrl = `${ossData.host}/${ossData.dir}${fileName}`; @@ -602,27 +607,27 @@ const uploadImageToOss = async (base64Data, ossData) => { } else { reject(new Error(`上传失败,状态码: ${uploadRes.statusCode}`)); } - }, - fail: (error) => { + }), + fail: guard((error) => { console.error('[CreatePage] App上传失败:', error); reject(error); - } + }) }); - }, (error) => { + }, guard((error) => { console.error('[CreatePage] App保存临时文件失败:', error); bitmap.clear(); reject(new Error('保存临时文件失败')); - }); + }))); }).catch((err) => { console.error('[CreatePage] getSandboxFileUri 失败:', err); bitmap.clear(); reject(err); }); - }, (error) => { + }, guard((error) => { console.error('[CreatePage] App加载base64失败:', error); bitmap.clear(); reject(new Error('加载图片数据失败')); - }); + }))); // #endif }); }; diff --git a/frontend/pages/castlove/index.vue b/frontend/pages/castlove/index.vue index 6a59667..afe4d50 100644 --- a/frontend/pages/castlove/index.vue +++ b/frontend/pages/castlove/index.vue @@ -105,9 +105,13 @@ import Header from "../components/Header.vue"; import ConfirmModal from '@/components/ConfirmModal.vue'; import BottomNav from "../components/BottomNav.vue"; import { getOssSignatureApi, deleteMintOrderApi } from '@/utils/api.js'; +import { useAliveGuard } from '@/composables/useAliveGuard.js'; const navExpanded = ref(false); +// alive 守卫:页面销毁后,原生异步回调不再触碰 state,避免 iOS WebView 报错 +const { guard } = useAliveGuard(); + // 通用确认弹窗状态 const confirmModal = ref({ visible: false, @@ -176,14 +180,14 @@ const chooseImage = () => { uni.chooseImage({ count: 1, sourceType: ['album', 'camera'], - success: (res) => { + success: guard((res) => { const filePath = res.tempFilePaths[0]; const tempFile = res.tempFiles && res.tempFiles[0]; // 获取文件信息进行验证 uni.getFileInfo({ filePath: filePath, - success: (fileInfo) => { + success: guard((fileInfo) => { // 验证文件大小(5MB = 5 * 1024 * 1024 bytes) const maxSize = 5 * 1024 * 1024; if (fileInfo.size > maxSize) { @@ -217,8 +221,8 @@ const chooseImage = () => { // 验证通过,转换为 base64 convertImageToBase64(filePath, originalFileName.value); - }, - fail: (error) => { + }), + fail: guard((error) => { console.error('获取文件信息失败:', error); uni.showToast({ title: '获取文件信息失败', @@ -226,14 +230,14 @@ const chooseImage = () => { }); } }); - }, - fail: (err) => { + }), + fail: guard((err) => { console.error('选择图片失败:', err); uni.showToast({ title: '选择图片失败', icon: 'none' }); - } + }) }); }; @@ -311,28 +315,28 @@ const convertImageToBase64Native = (filePath, fileName) => { fs.readFile({ filePath: filePath, encoding: 'base64', - success: (res) => { + success: guard((res) => { const ext = fileName.toLowerCase().split('.').pop(); let mimeType = 'image/jpeg'; if (ext === 'png') { mimeType = 'image/png'; } - + uploadedImageBase64.value = `data:${mimeType};base64,${res.data}`; uploadedImage.value = filePath; - + console.log('[CastloveContent] Base64转换成功 (小程序)'); - + uni.hideLoading(); uni.showToast({ title: '图片加载成功', icon: 'success', duration: 1500 }); - + isUploading.value = false; - }, - fail: (error) => { + }), + fail: guard((error) => { console.error('[CastloveContent] Base64转换失败:', error); uni.hideLoading(); uni.showToast({ @@ -341,31 +345,31 @@ const convertImageToBase64Native = (filePath, fileName) => { duration: 2000 }); isUploading.value = false; - } + }) }); // #endif - + // #ifdef APP-PLUS // App环境:使用plus.io API - plus.io.resolveLocalFileSystemURL(filePath, (entry) => { - entry.file((file) => { + plus.io.resolveLocalFileSystemURL(filePath, guard((entry) => { + entry.file(guard((file) => { const reader = new plus.io.FileReader(); - reader.onloadend = (e) => { + reader.onloadend = guard((e) => { uploadedImageBase64.value = e.target.result; uploadedImage.value = filePath; - + console.log('[CastloveContent] Base64转换成功 (App)'); - + uni.hideLoading(); uni.showToast({ title: '图片加载成功', icon: 'success', duration: 1500 }); - + isUploading.value = false; - }; - reader.onerror = (error) => { + }); + reader.onerror = guard((error) => { console.error('[CastloveContent] Base64转换失败:', error); uni.hideLoading(); uni.showToast({ @@ -374,10 +378,10 @@ const convertImageToBase64Native = (filePath, fileName) => { duration: 2000 }); isUploading.value = false; - }; + }); reader.readAsDataURL(file); - }); - }, (error) => { + })); + }), guard((error) => { console.error('[CastloveContent] 读取文件失败:', error); uni.hideLoading(); uni.showToast({ @@ -386,7 +390,7 @@ const convertImageToBase64Native = (filePath, fileName) => { duration: 2000 }); isUploading.value = false; - }); + })); // #endif }; @@ -420,7 +424,7 @@ const uploadImageToOss = async (filePath, fileName) => { 'x-oss-signature': signRes.data.signature, 'x-oss-signature-version': signRes.data.x_oss_signature_version }, - success: (uploadRes) => { + success: guard((uploadRes) => { try { if (uploadRes.statusCode === 200 || uploadRes.statusCode === 204) { // 3. 拼接完整URL @@ -449,8 +453,8 @@ const uploadImageToOss = async (filePath, fileName) => { } finally { isUploading.value = false; } - }, - fail: (error) => { + }), + fail: guard((error) => { console.error('OSS上传失败:', error); uni.hideLoading(); uni.showToast({ @@ -459,7 +463,7 @@ const uploadImageToOss = async (filePath, fileName) => { duration: 2000 }); isUploading.value = false; - } + }) }); } catch (error) { diff --git a/frontend/pages/castlove/lenticular/lenticular-create.vue b/frontend/pages/castlove/lenticular/lenticular-create.vue index 8cfab1e..19d3995 100644 --- a/frontend/pages/castlove/lenticular/lenticular-create.vue +++ b/frontend/pages/castlove/lenticular/lenticular-create.vue @@ -111,6 +111,7 @@ import { onLoad,onUnload } from '@dcloudio/uni-app'; import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js'; import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js'; import { getSandboxFileUri, clearSandboxSubdir } from '@/utils/ioPath.js'; +import { useAliveGuard } from '@/composables/useAliveGuard.js'; import ConfirmModal from '@/components/ConfirmModal.vue'; import { buildCastloveFormSnapshot, @@ -146,6 +147,9 @@ onUnload(() => { } }); +// alive 守卫:页面销毁后,原生异步回调不再触碰 state,避免 iOS WebView 报错 +const { guard } = useAliveGuard(); + function safeDecodeParam(v) { if (v == null || v === '') return ''; const s = typeof v === 'string' ? v : String(v); @@ -260,13 +264,13 @@ const openImagePicker = (lenticularSlot) => { uni.chooseImage({ count: 1, sourceType: ['album', 'camera'], - success: (res) => { + success: guard((res) => { const filePath = res.tempFilePaths[0]; const tempFile = res.tempFiles && res.tempFiles[0]; uni.getFileInfo({ filePath: filePath, - success: (fileInfo) => { + success: guard((fileInfo) => { const maxSize = 5 * 1024 * 1024; const slotLabel = lenticularSlot === 'bg' ? '背景图' : '主体图'; if (fileInfo.size > maxSize) { @@ -298,19 +302,19 @@ const openImagePicker = (lenticularSlot) => { const rawName = (tempFile && tempFile.name) ? tempFile.name : filePath.split('/').pop(); originalFileName.value = rawName; convertImageToBase64(filePath, originalFileName.value); - }, - fail: (error) => { + }), + fail: guard((error) => { pendingLenticularSlot.value = ''; console.error('获取文件信息失败:', error); uni.showToast({ title: '获取文件信息失败', icon: 'none' }); - } + }) }); - }, - fail: (err) => { + }), + fail: guard((err) => { pendingLenticularSlot.value = ''; console.error('选择图片失败:', err); uni.showToast({ title: '选择图片失败', icon: 'none' }); - } + }) }); }; @@ -362,7 +366,7 @@ const convertImageToBase64Native = (filePath, fileName) => { fs.readFile({ filePath: filePath, encoding: 'base64', - success: (res) => { + success: guard((res) => { const ext = fileName.toLowerCase().split('.').pop(); let mimeType = 'image/jpeg'; if (ext === 'png') mimeType = 'image/png'; @@ -370,42 +374,42 @@ const convertImageToBase64Native = (filePath, fileName) => { applyUploadResult(filePath, dataUrl); console.log('[LenticularCreate] Base64转换成功 (小程序)'); console.log('[LenticularCreate] Base64长度:', dataUrl.length); - }, - fail: (error) => { + }), + fail: guard((error) => { pendingLenticularSlot.value = ''; console.error('[LenticularCreate] Base64转换失败 (小程序):', error); uni.hideLoading(); uni.showToast({ title: '图片处理失败', icon: 'none', duration: 2000 }); isUploading.value = false; - } + }) }); // #endif // #ifdef APP-PLUS - plus.io.resolveLocalFileSystemURL(filePath, (entry) => { - entry.file((file) => { + plus.io.resolveLocalFileSystemURL(filePath, guard((entry) => { + entry.file(guard((file) => { const reader = new plus.io.FileReader(); - reader.onloadend = (e) => { + reader.onloadend = guard((e) => { applyUploadResult(filePath, e.target.result); console.log('[LenticularCreate] Base64转换成功 (App)'); console.log('[LenticularCreate] Base64长度:', (e.target.result && e.target.result.length) || 0); - }; - reader.onerror = (error) => { + }); + reader.onerror = guard((error) => { pendingLenticularSlot.value = ''; console.error('[LenticularCreate] Base64转换失败 (App):', error); uni.hideLoading(); uni.showToast({ title: '图片处理失败', icon: 'none', duration: 2000 }); isUploading.value = false; - }; + }); reader.readAsDataURL(file); - }); - }, (error) => { + })); + }), guard((error) => { pendingLenticularSlot.value = ''; console.error('[LenticularCreate] 读取文件失败 (App):', error); uni.hideLoading(); uni.showToast({ title: '图片处理失败', icon: 'none', duration: 2000 }); isUploading.value = false; - }); + })); // #endif }; @@ -465,7 +469,7 @@ const uploadImageToOss = async (base64Data, ossData) => { filePath: filePath, data: base64Content, encoding: 'base64', - success: () => { + success: guard(() => { uni.uploadFile({ url: ossData.host, filePath: filePath, @@ -480,22 +484,22 @@ const uploadImageToOss = async (base64Data, ossData) => { 'x-oss-signature': ossData.signature, 'x-oss-signature-version': ossData.x_oss_signature_version }, - success: (uploadRes) => { + success: guard((uploadRes) => { if (uploadRes.statusCode === 200 || uploadRes.statusCode === 204) { const imageUrl = `${ossData.host}/${ossData.dir}${fileName}`; resolve(imageUrl); } else { reject(new Error(`上传失败,状态码: ${uploadRes.statusCode}`)); } - }, - fail: (error) => { + }), + fail: guard((error) => { reject(error); - } + }) }); - }, - fail: (error) => { + }), + fail: guard((error) => { reject(error); - } + }) }); // #endif @@ -505,9 +509,9 @@ const uploadImageToOss = async (base64Data, ossData) => { const base64ContentApp = base64Data.split(',')[1]; const bitmap = new plus.nativeObj.Bitmap('temp'); - bitmap.loadBase64Data(base64ContentApp, () => { + bitmap.loadBase64Data(base64ContentApp, guard(() => { getSandboxFileUri(['castlove-lenticular', 'tmp'], fileName).then((tempFilePath) => { - bitmap.save(tempFilePath, { overwrite: true }, () => { + bitmap.save(tempFilePath, { overwrite: true }, guard(() => { console.log('[LenticularCreate] App临时文件保存成功:', tempFilePath); bitmap.clear(); @@ -525,7 +529,7 @@ const uploadImageToOss = async (base64Data, ossData) => { 'x-oss-signature': ossData.signature, 'x-oss-signature-version': ossData.x_oss_signature_version }, - success: (uploadRes) => { + success: guard((uploadRes) => { console.log('[LenticularCreate] App上传响应:', uploadRes); if (uploadRes.statusCode === 200 || uploadRes.statusCode === 204) { const imageUrl = `${ossData.host}/${ossData.dir}${fileName}`; @@ -537,17 +541,17 @@ const uploadImageToOss = async (base64Data, ossData) => { } else { reject(new Error(`上传失败,状态码: ${uploadRes.statusCode}`)); } - }, - fail: (error) => { + }), + fail: guard((error) => { console.error('[LenticularCreate] App上传失败:', error); reject(error); - } + }) }); - }, (error) => { + }, guard((error) => { console.error('[LenticularCreate] App保存临时文件失败:', error); bitmap.clear(); reject(new Error('保存临时文件失败')); - }); + }))); }).catch((err) => { console.error('[LenticularCreate] getSandboxFileUri 失败:', err); bitmap.clear(); diff --git a/frontend/pages/castlove/lenticular/lenticular-result.vue b/frontend/pages/castlove/lenticular/lenticular-result.vue index 2cbf597..0bc3b3c 100644 --- a/frontend/pages/castlove/lenticular/lenticular-result.vue +++ b/frontend/pages/castlove/lenticular/lenticular-result.vue @@ -65,9 +65,11 @@ diff --git a/frontend/pages/discover/discover.vue b/frontend/pages/discover/discover.vue index f96ee49..8227a24 100644 --- a/frontend/pages/discover/discover.vue +++ b/frontend/pages/discover/discover.vue @@ -47,10 +47,14 @@ import CreateFeed from './create-feed.vue'; import { getOssSignatureApi, createMintOrderApi } from '@/utils/api.js'; import { resolveH5OssPostUrl } from '@/utils/h5OssPostUrl.js'; import { getSandboxFileUri } from '@/utils/ioPath.js'; +import { useAliveGuard } from '@/composables/useAliveGuard.js'; const activeTab = ref(0); const formData = ref(null); +// alive 守卫:页面销毁后,原生异步回调不再触碰 state,避免 iOS WebView 报错 +const { guard } = useAliveGuard(); + // 切换标签 const switchTab = (index) => { activeTab.value = index; @@ -118,21 +122,21 @@ const uploadImageToOss = async (base64Image, ossData) => { const sandboxUri = await getSandboxFileUri(['discover', 'tmp'], fileName) return new Promise((resolve, reject) => { const bitmap = new plus.nativeObj.Bitmap('temp_skip'); - bitmap.loadBase64Data(base64Image, () => { - bitmap.save(sandboxUri, { overwrite: true }, () => { + bitmap.loadBase64Data(base64Image, guard(() => { + bitmap.save(sandboxUri, { overwrite: true }, guard(() => { bitmap.clear(); resolve(sandboxUri); - }, (error) => { + }, guard((error) => { bitmap.clear(); reject(new Error('图片保存失败')); - }); - }, (error) => { + }))); + }, guard((error) => { bitmap.clear(); reject(new Error('加载base64失败')); - }); + }))); }); })(); - + // 上传到OSS imageUrl = await new Promise((resolve, reject) => { uni.uploadFile({ @@ -149,17 +153,17 @@ const uploadImageToOss = async (base64Image, ossData) => { 'x-oss-signature': ossData.signature, 'x-oss-signature-version': ossData.x_oss_signature_version }, - success: (uploadRes) => { + success: guard((uploadRes) => { if (uploadRes.statusCode === 200 || uploadRes.statusCode === 204) { const url = `${ossData.host}/${ossData.dir}${fileName}`; resolve(url); } else { reject(new Error(`上传失败,状态码: ${uploadRes.statusCode}`)); } - }, - fail: (error) => { + }), + fail: guard((error) => { reject(error); - } + }) }); }); // #endif diff --git a/frontend/pages/discover/generation-result.vue b/frontend/pages/discover/generation-result.vue index bf55ca6..ec3baca 100644 --- a/frontend/pages/discover/generation-result.vue +++ b/frontend/pages/discover/generation-result.vue @@ -2,12 +2,12 @@ - + - + @@ -21,68 +21,39 @@ X 2 - + - + - + - + - + - - + + - + - + 重新生成 - + {{ primaryActionLabel }} @@ -91,9 +62,11 @@