From e488b86c35967402b95accd10b75915c0562e13e Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Mon, 9 Nov 2026 15:04:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(mobile):=20=E5=B0=8F=E7=A8=8B=E5=BA=8F=20We?= =?UTF-8?q?bView=20=E9=99=84=E4=BB=B6=E4=B8=8B=E8=BD=BD=E6=94=B9=E8=B5=B0?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E5=8E=9F=E7=94=9F=E9=A2=84=E8=A7=88=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 鸿蒙微信 ArkWeb 下 H5 window.open 常静默失败,仅出现「开始下载…」。 OA/市场附件在小程序 WebView 内改为跳转 pages/oa/file-preview, 用 uni.downloadFile + openDocument 打开,浏览器环境仍用预签名直链。 Refs: #74 Co-authored-by: Cursor --- mobile-h5/pages.json | 8 + mobile-h5/pages/oa/file-preview/index.vue | 196 ++++++++++++++++++ mobile-h5/utils/oa-file-preview.js | 51 +++++ mobile-h5/utils/oa-file-preview.test.js | 25 +++ mobile-oa-h5/src/utils/download-attachment.js | 24 ++- .../src/utils/download-attachment.test.js | 53 +++++ .../src/utils/host-file-preview-nav.js | 178 ++++++++++++++++ .../src/utils/host-file-preview-nav.test.js | 36 ++++ 8 files changed, 566 insertions(+), 5 deletions(-) create mode 100644 mobile-h5/pages/oa/file-preview/index.vue create mode 100644 mobile-h5/utils/oa-file-preview.js create mode 100644 mobile-h5/utils/oa-file-preview.test.js create mode 100644 mobile-oa-h5/src/utils/host-file-preview-nav.js create mode 100644 mobile-oa-h5/src/utils/host-file-preview-nav.test.js diff --git a/mobile-h5/pages.json b/mobile-h5/pages.json index 5d73b80c..c796593f 100644 --- a/mobile-h5/pages.json +++ b/mobile-h5/pages.json @@ -95,6 +95,14 @@ "navigationBarTextStyle": "black" } }, + { + "path": "pages/oa/file-preview/index", + "style": { + "navigationBarTitleText": "附件预览", + "navigationBarBackgroundColor": "#ecfdf5", + "navigationBarTextStyle": "black" + } + }, { "path": "pages/market/webview/index", "style": { diff --git a/mobile-h5/pages/oa/file-preview/index.vue b/mobile-h5/pages/oa/file-preview/index.vue new file mode 100644 index 00000000..42815cbf --- /dev/null +++ b/mobile-h5/pages/oa/file-preview/index.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/mobile-h5/utils/oa-file-preview.js b/mobile-h5/utils/oa-file-preview.js new file mode 100644 index 00000000..0b6c63fb --- /dev/null +++ b/mobile-h5/utils/oa-file-preview.js @@ -0,0 +1,51 @@ +/** + * OA / 市场附件:小程序宿主预览页的 query 与 openDocument 类型 + */ + +import { + pickQueryValue, + safeDecodeURIComponent +} from './oa-pdf-preview-query.js' + +const OPEN_DOCUMENT_EXTS = { + pdf: 'pdf', + doc: 'doc', + docx: 'docx', + xls: 'xls', + xlsx: 'xlsx', + ppt: 'ppt', + pptx: 'pptx' +} + +export function fileExtension(fileName) { + const match = String(fileName || '') + .trim() + .toLowerCase() + .match(/\.([a-z0-9]+)$/) + return match ? match[1] : '' +} + +export function openDocumentFileType(fileName) { + const ext = fileExtension(fileName) + return OPEN_DOCUMENT_EXTS[ext] || '' +} + +/** + * @returns {{ fileUrl: string, legacyUrl: string, name: string, fileType: string, rawKeys: string[] }} + */ +export function parseFilePreviewQuery(query) { + const rawKeys = Object.keys(query && typeof query === 'object' ? query : {}) + const fileUrl = safeDecodeURIComponent( + pickQueryValue(query, ['fileUrl', 'fileurl', 'file_url', 'f']) + ) + const legacyUrl = safeDecodeURIComponent( + pickQueryValue(query, ['url', 'downloadUrl', 'download_url']) + ) + const name = + safeDecodeURIComponent(pickQueryValue(query, ['name', 'fileName', 'file_name', 'n'])) || + 'file.bin' + const fileType = + safeDecodeURIComponent(pickQueryValue(query, ['fileType', 'file_type', 't'])) || + openDocumentFileType(name) + return { fileUrl, legacyUrl, name, fileType, rawKeys } +} diff --git a/mobile-h5/utils/oa-file-preview.test.js b/mobile-h5/utils/oa-file-preview.test.js new file mode 100644 index 00000000..f96c1937 --- /dev/null +++ b/mobile-h5/utils/oa-file-preview.test.js @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import { + openDocumentFileType, + parseFilePreviewQuery +} from './oa-file-preview.js' + +assert.equal(openDocumentFileType('a.xlsx'), 'xlsx') +assert.equal(openDocumentFileType('a.zip'), '') + +const parsed = parseFilePreviewQuery({ + fileUrl: encodeURIComponent('oa-approval/202610/abc.xlsx'), + n: 'file.xlsx' +}) +assert.equal(parsed.fileUrl, 'oa-approval/202610/abc.xlsx') +assert.equal(parsed.name, 'file.xlsx') +assert.equal(parsed.fileType, 'xlsx') + +const fallback = parseFilePreviewQuery({ + f: 'oa-approval/x.pdf' +}) +assert.equal(fallback.fileUrl, 'oa-approval/x.pdf') +assert.equal(fallback.name, 'file.bin') +assert.equal(fallback.fileType, '') + +console.log('oa-file-preview: ok') diff --git a/mobile-oa-h5/src/utils/download-attachment.js b/mobile-oa-h5/src/utils/download-attachment.js index 314a3256..cf04748b 100644 --- a/mobile-oa-h5/src/utils/download-attachment.js +++ b/mobile-oa-h5/src/utils/download-attachment.js @@ -1,7 +1,11 @@ -/** 附件下载:优先后端预签名 URL,保证跨域场景保留原始文件名 */ +/** 附件下载:小程序 WebView 走宿主原生页;其它环境用预签名 URL 打开 */ import { fetchAttachmentDownloadUrl } from '../api/oa.js' import { attachmentFullUrl } from './attachment.js' +import { + isWeChatMiniWebView, + navigateToHostFilePreview +} from './host-file-preview-nav.js' function openDownloadUrl(href, fileName) { // 微信 web-view 对 a[download] 支持差;优先新开页打开带 Content-Disposition 的地址 @@ -17,13 +21,19 @@ function openDownloadUrl(href, fileName) { document.body.removeChild(a) } +async function openViaHostPreview(fileUrl, fileName, deps = {}) { + const navigate = deps.navigateToHostFilePreview || navigateToHostFilePreview + const detectMini = deps.isWeChatMiniWebView || isWeChatMiniWebView + if (!detectMini(deps.window)) return false + const nav = await navigate({ fileUrl, fileName }, { window: deps.window }) + if (nav && nav.ok) return true + throw new Error((nav && nav.message) || '无法打开附件预览页') +} + /** * @param {{ fileUrl?: string, url?: string, fileName?: string, name?: string }} file */ -export async function downloadAttachmentFile( - file, - options = {} -) { +export async function downloadAttachmentFile(file, options = {}) { const hasAuthorizedResolver = typeof options.fetchDownloadUrl === 'function' const fetchDownloadUrl = options.fetchDownloadUrl || fetchAttachmentDownloadUrl const fileUrl = String(file?.fileUrl || file?.url || '').trim() @@ -31,6 +41,10 @@ export async function downloadAttachmentFile( const explicitFileName = String(file?.fileName || file?.name || '').trim() const fallbackFileName = explicitFileName || fileUrl.split('/').pop() || 'download' + // 小程序 WebView(含鸿蒙):跳转宿主 downloadFile + openDocument,避免 window.open 静默失败 + const openedOnHost = await openViaHostPreview(fileUrl, fallbackFileName, options) + if (openedOnHost) return + try { const res = await fetchDownloadUrl({ fileUrl, diff --git a/mobile-oa-h5/src/utils/download-attachment.test.js b/mobile-oa-h5/src/utils/download-attachment.test.js index 477d674c..171d7507 100644 --- a/mobile-oa-h5/src/utils/download-attachment.test.js +++ b/mobile-oa-h5/src/utils/download-attachment.test.js @@ -18,6 +18,7 @@ test('uses an injected authorized resolver before opening the signed download ur await downloadAttachmentFile( { fileUrl: 'market/specialist/protocol/a.png', fileName: '协议扫描件.png' }, { + isWeChatMiniWebView: () => false, fetchDownloadUrl: async (payload) => { requested = payload return { downloadUrl: 'https://files.test/signed-download' } @@ -52,6 +53,7 @@ test('never falls back to the raw object url when an authorized resolver rejects downloadAttachmentFile( { fileUrl: 'https://files.test/private.pdf', fileName: '私密附件.pdf' }, { + isWeChatMiniWebView: () => false, fetchDownloadUrl: async () => { throw new Error('附件不存在或无权下载') } @@ -65,3 +67,54 @@ test('never falls back to the raw object url when an authorized resolver rejects assert.deepEqual(opened, []) }) + +test('mini program webview navigates to host file preview instead of window.open', async () => { + const originalWindow = globalThis.window + const opened = [] + const navCalls = [] + globalThis.window = { + open(href) { + opened.push(href) + return {} + } + } + + try { + await downloadAttachmentFile( + { fileUrl: 'oa-approval/202610/task.xlsx', fileName: '生产任务.xlsx' }, + { + isWeChatMiniWebView: () => true, + navigateToHostFilePreview: async (payload) => { + navCalls.push(payload) + return { ok: true, via: 'test' } + }, + fetchDownloadUrl: async () => { + throw new Error('should not fetch download-url in mini path') + } + } + ) + } finally { + globalThis.window = originalWindow + } + + assert.deepEqual(navCalls, [ + { fileUrl: 'oa-approval/202610/task.xlsx', fileName: '生产任务.xlsx' } + ]) + assert.deepEqual(opened, []) +}) + +test('mini program navigate failure surfaces as download error', async () => { + await assert.rejects( + downloadAttachmentFile( + { fileUrl: 'oa-approval/x.xlsx', fileName: 'x.xlsx' }, + { + isWeChatMiniWebView: () => true, + navigateToHostFilePreview: async () => ({ + ok: false, + message: '无法跳转附件预览页' + }) + } + ), + /无法跳转附件预览页/ + ) +}) diff --git a/mobile-oa-h5/src/utils/host-file-preview-nav.js b/mobile-oa-h5/src/utils/host-file-preview-nav.js new file mode 100644 index 00000000..4f857800 --- /dev/null +++ b/mobile-oa-h5/src/utils/host-file-preview-nav.js @@ -0,0 +1,178 @@ +/** + * 微信 web-view → 小程序附件预览/下载页 + * 对齐 OA 打印 PDF 的宿主跳转;避免 H5 window.open 在鸿蒙 ArkWeb 静默失败。 + */ + +import { + getOaPrintHostApis, + isWeChatMiniWebView, + printNavLog +} from '../oa/print/oa-pdf-preview-nav.js' + +const OPEN_DOCUMENT_EXTS = new Set([ + 'pdf', + 'doc', + 'docx', + 'xls', + 'xlsx', + 'ppt', + 'pptx' +]) + +export function fileExtension(fileName) { + const match = String(fileName || '') + .trim() + .toLowerCase() + .match(/\.([a-z0-9]+)$/) + return match ? match[1] : '' +} + +/** openDocument 支持的类型;其它扩展返回空串 */ +export function openDocumentFileType(fileName) { + const ext = fileExtension(fileName) + return OPEN_DOCUMENT_EXTS.has(ext) ? ext : '' +} + +/** query 只用 ASCII 名,避免 navigateTo 被中文撑爆;扩展名保留供宿主识别类型 */ +export function asciiHostFileName(fileName) { + const ext = fileExtension(fileName) || 'bin' + return `file.${ext}` +} + +/** + * @returns {{ path: string, queryLen: number, fileUrlLen: number, name: string, fileType: string }} + */ +export function buildHostFilePreviewPath({ fileUrl, fileName } = {}) { + const key = String(fileUrl || '').trim() + const name = asciiHostFileName(fileName) + const fileType = openDocumentFileType(name) + const q = + 'fileUrl=' + + encodeURIComponent(key) + + '&f=' + + encodeURIComponent(key) + + '&n=' + + encodeURIComponent(name) + const path = '/pages/oa/file-preview/index?' + q + return { + path, + queryLen: q.length, + fileUrlLen: key.length, + name, + fileType + } +} + +/** + * @returns {Promise<{ ok: boolean, via?: string, path?: string, message?: string, assumed?: boolean }>} + */ +export function navigateToHostFilePreview({ fileUrl, fileName } = {}, deps = {}) { + const { path, queryLen, fileUrlLen, name, fileType } = buildHostFilePreviewPath({ + fileUrl, + fileName + }) + const win = + deps.window !== undefined + ? deps.window + : typeof window !== 'undefined' + ? window + : undefined + const log = deps.log || printNavLog + const { wxApi, uniApi } = getOaPrintHostApis(win) + + log('file-preview navigate start', { + fileUrlLen, + queryLen, + pathLen: path.length, + name, + fileType, + fileUrlPreview: String(fileUrl || '').slice(0, 120), + hasWindowWx: !!(win && win.wx), + hasWxMiniNavigate: !!(wxApi && wxApi.miniProgram && wxApi.miniProgram.navigateTo), + hasUniWebViewNavigate: !!(uniApi && uniApi.webView && uniApi.webView.navigateTo), + __wxjs_environment: win && win.__wxjs_environment + }) + + if (!String(fileUrl || '').trim()) { + log('file-preview navigate abort: empty fileUrl') + return Promise.resolve({ ok: false, message: '缺少 fileUrl' }) + } + + if (path.length > 900) { + log('file-preview warn path long', { pathLen: path.length }) + } + + const invokeNavigate = (label, navigateFn) => + new Promise((resolve) => { + if (typeof navigateFn !== 'function') { + resolve(null) + return + } + let settled = false + const done = (result) => { + if (settled) return + settled = true + resolve(result) + } + try { + navigateFn({ + url: path, + success: (res) => { + log(label + ' success', res) + done({ ok: true, via: label, path }) + }, + fail: (err) => { + log(label + ' fail', err) + done({ + ok: false, + via: label, + path, + message: (err && (err.errMsg || err.message)) || 'navigateTo fail' + }) + } + }) + setTimeout(() => { + if (!settled) { + log(label + ' no-callback → assume ok', { pathLen: path.length }) + done({ ok: true, via: label, path, assumed: true }) + } + }, 80) + } catch (e) { + log(label + ' throw', String(e && e.message ? e.message : e)) + done({ + ok: false, + via: label, + path, + message: String(e && e.message ? e.message : e) + }) + } + }) + + return invokeNavigate( + 'wx.miniProgram.navigateTo', + wxApi && wxApi.miniProgram && wxApi.miniProgram.navigateTo + ? wxApi.miniProgram.navigateTo.bind(wxApi.miniProgram) + : null + ).then((wxRes) => { + if (wxRes && wxRes.ok) return wxRes + if (wxRes && wxRes.ok === false) { + log('file-preview wx failed, try uni', wxRes.message) + } + return invokeNavigate( + 'uni.webView.navigateTo', + uniApi && uniApi.webView && uniApi.webView.navigateTo + ? uniApi.webView.navigateTo.bind(uniApi.webView) + : null + ).then((uniRes) => { + if (uniRes) return uniRes + if (wxRes) return wxRes + log('file-preview navigate unavailable') + return { + ok: false, + message: '无法跳转附件预览页:未找到 wx/uni navigateTo' + } + }) + }) +} + +export { isWeChatMiniWebView } diff --git a/mobile-oa-h5/src/utils/host-file-preview-nav.test.js b/mobile-oa-h5/src/utils/host-file-preview-nav.test.js new file mode 100644 index 00000000..d01fc6a8 --- /dev/null +++ b/mobile-oa-h5/src/utils/host-file-preview-nav.test.js @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + asciiHostFileName, + buildHostFilePreviewPath, + openDocumentFileType +} from './host-file-preview-nav.js' + +test('ascii host file name keeps extension only', () => { + assert.equal(asciiHostFileName('26-10生产任务.xlsx'), 'file.xlsx') + assert.equal(asciiHostFileName('report.PDF'), 'file.pdf') + assert.equal(asciiHostFileName('plain'), 'file.bin') +}) + +test('openDocument file types cover office docs', () => { + assert.equal(openDocumentFileType('a.xlsx'), 'xlsx') + assert.equal(openDocumentFileType('a.XLS'), 'xls') + assert.equal(openDocumentFileType('a.docx'), 'docx') + assert.equal(openDocumentFileType('a.zip'), '') +}) + +test('host file preview path is short and points at file-preview page', () => { + const built = buildHostFilePreviewPath({ + fileUrl: 'oa-approval/202610/abc.xlsx', + fileName: '26-10生产任务.xlsx' + }) + assert.ok(built.path.startsWith('/pages/oa/file-preview/index?')) + assert.ok(built.path.includes('fileUrl=')) + assert.ok(built.path.includes('&f=')) + assert.ok(built.path.includes('&n=file.xlsx')) + assert.equal(built.fileType, 'xlsx') + assert.ok(!built.path.includes('%E7%94%9F')) + assert.ok(built.queryLen < 400) + assert.ok(built.path.length < 500) +})