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 @@
+
+
+ {{ tip }}
+
+
+
+
+
+
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)
+})