af-mobile-client-vue3 1.6.51 → 1.6.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/data/FilePreview/FilePreviewDialog.vue +257 -0
- package/src/components/data/FilePreview/components/OfficePreview.vue +24 -7
- package/src/components/data/FilePreview/index.vue +18 -62
- package/src/router/routes.ts +6 -0
- package/src/utils/environment.ts +0 -1
- package/src/utils/fileDownload.ts +57 -27
- package/src/views/component/FilePreviewView/FilePreviewDialogView.vue +93 -0
- package/src/views/component/FilePreviewView/index.vue +60 -9
- package/src/views/component/index.vue +4 -0
package/package.json
CHANGED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { FilePreviewInitOptions } from './index.vue'
|
|
3
|
+
/**
|
|
4
|
+
* 文件预览弹窗组件
|
|
5
|
+
* 支持两种展示形式,通过 mode prop 控制:
|
|
6
|
+
* - drawer(默认):从底部滑出的抽屉,占屏幕 92% 高度
|
|
7
|
+
* - dialog:居中弹窗,固定宽高
|
|
8
|
+
*
|
|
9
|
+
* 底部操作栏(仅通过此组件打开时显示):
|
|
10
|
+
* - 下载
|
|
11
|
+
* - 用其他应用打开(仅 Flutter 环境,调用 openFileWithApp 方式二:传 url+name)
|
|
12
|
+
*
|
|
13
|
+
* 用法:
|
|
14
|
+
* <FilePreviewDialog ref="dialogRef" mode="dialog" @close="onClose" />
|
|
15
|
+
* dialogRef.value.open({ path: '/resource/...', name: '文件名.docx' })
|
|
16
|
+
*/
|
|
17
|
+
import { detectEnvironment } from '@af-mobile-client-vue3/utils/environment'
|
|
18
|
+
import { downloadAttachment, openFileWithApp } from '@af-mobile-client-vue3/utils/fileDownload'
|
|
19
|
+
import { Popup as VanPopup } from 'vant'
|
|
20
|
+
import { computed, ref } from 'vue'
|
|
21
|
+
import FilePreview from './index.vue'
|
|
22
|
+
|
|
23
|
+
const props = withDefaults(defineProps<{
|
|
24
|
+
/** 展示形式:drawer 底部抽屉(默认)| dialog 居中弹窗 */
|
|
25
|
+
mode?: 'drawer' | 'dialog'
|
|
26
|
+
}>(), {
|
|
27
|
+
mode: 'drawer',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const emit = defineEmits<{
|
|
31
|
+
(e: 'close'): void
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const visible = ref(false)
|
|
35
|
+
const fileName = ref('')
|
|
36
|
+
const fileUrl = ref('')
|
|
37
|
+
const previewRef = ref<InstanceType<typeof FilePreview> | null>(null)
|
|
38
|
+
const initOptions = ref<FilePreviewInitOptions | null>(null)
|
|
39
|
+
const loadingKey = ref<string | null>(null) // 当前正在执行的操作 key,null 表示空闲
|
|
40
|
+
|
|
41
|
+
const isApp = computed(() => detectEnvironment().isApp)
|
|
42
|
+
|
|
43
|
+
/** 操作按钮配置,后续新增按钮只需在此追加 */
|
|
44
|
+
interface ActionItem {
|
|
45
|
+
key: string
|
|
46
|
+
label: string
|
|
47
|
+
/** 仅在特定环境显示,不传则始终显示 */
|
|
48
|
+
show?: () => boolean
|
|
49
|
+
handler: () => Promise<void> | void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const actions = computed<ActionItem[]>(() => [
|
|
53
|
+
{
|
|
54
|
+
key: 'download',
|
|
55
|
+
label: '下载',
|
|
56
|
+
handler: handleDownload,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
key: 'openWithApp',
|
|
60
|
+
label: '用其他应用打开',
|
|
61
|
+
// 仅 Flutter App 环境显示
|
|
62
|
+
show: () => isApp.value,
|
|
63
|
+
handler: handleOpenWithApp,
|
|
64
|
+
},
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
/** 过滤出当前环境应显示的按钮 */
|
|
68
|
+
const visibleActions = computed(() =>
|
|
69
|
+
actions.value.filter(a => !a.show || a.show()),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
function open(options: FilePreviewInitOptions) {
|
|
73
|
+
fileName.value = options.name || options.path.split('/').pop()?.split('?')[0] || '文件预览'
|
|
74
|
+
fileUrl.value = options.path
|
|
75
|
+
initOptions.value = options
|
|
76
|
+
visible.value = true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function onOpened() {
|
|
80
|
+
if (initOptions.value)
|
|
81
|
+
previewRef.value?.init(initOptions.value)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function handleClose() {
|
|
85
|
+
visible.value = false
|
|
86
|
+
emit('close')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 下载文件 */
|
|
90
|
+
async function handleDownload() {
|
|
91
|
+
await downloadAttachment({ url: fileUrl.value, name: fileName.value })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 用其他应用打开(方式二:传 url+name,Flutter 端自动下载后调起系统分享面板)
|
|
96
|
+
*/
|
|
97
|
+
function handleOpenWithApp() {
|
|
98
|
+
openFileWithApp(fileUrl.value, fileName.value)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 执行操作按钮,防重复点击 */
|
|
102
|
+
async function runAction(action: ActionItem) {
|
|
103
|
+
if (loadingKey.value)
|
|
104
|
+
return
|
|
105
|
+
loadingKey.value = action.key
|
|
106
|
+
try {
|
|
107
|
+
await action.handler()
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
loadingKey.value = null
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
defineExpose({ open })
|
|
115
|
+
</script>
|
|
116
|
+
|
|
117
|
+
<template>
|
|
118
|
+
<VanPopup
|
|
119
|
+
v-model:show="visible"
|
|
120
|
+
:position="mode === 'drawer' ? 'bottom' : 'center'"
|
|
121
|
+
:style="mode === 'drawer'
|
|
122
|
+
? { height: '92vh' }
|
|
123
|
+
: { width: '90vw', height: '80vh', borderRadius: '12px' }"
|
|
124
|
+
:round="mode === 'drawer'"
|
|
125
|
+
@opened="onOpened"
|
|
126
|
+
>
|
|
127
|
+
<div class="dialog-root">
|
|
128
|
+
<!-- 顶部标题栏 -->
|
|
129
|
+
<div class="dialog-header" :class="{ 'dialog-header--center': mode === 'dialog' }">
|
|
130
|
+
<span class="dialog-title" :title="fileName">{{ fileName }}</span>
|
|
131
|
+
<button class="dialog-close" @click="handleClose">
|
|
132
|
+
✕
|
|
133
|
+
</button>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<!-- 预览区域 -->
|
|
137
|
+
<div class="dialog-body">
|
|
138
|
+
<FilePreview ref="previewRef" class="dialog-preview" />
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<!-- 底部操作栏 -->
|
|
142
|
+
<div class="dialog-actions">
|
|
143
|
+
<button
|
|
144
|
+
v-for="action in visibleActions"
|
|
145
|
+
:key="action.key"
|
|
146
|
+
class="action-btn"
|
|
147
|
+
:class="`action-btn--${action.key}`"
|
|
148
|
+
:disabled="!!loadingKey"
|
|
149
|
+
@pointerdown.stop="runAction(action)"
|
|
150
|
+
>
|
|
151
|
+
{{ loadingKey === action.key ? '处理中...' : action.label }}
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</VanPopup>
|
|
156
|
+
</template>
|
|
157
|
+
|
|
158
|
+
<style scoped>
|
|
159
|
+
.dialog-root {
|
|
160
|
+
display: flex;
|
|
161
|
+
flex-direction: column;
|
|
162
|
+
height: 100%;
|
|
163
|
+
overflow: hidden;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.dialog-header {
|
|
167
|
+
display: flex;
|
|
168
|
+
align-items: center;
|
|
169
|
+
justify-content: space-between;
|
|
170
|
+
padding: 0 16px;
|
|
171
|
+
height: 48px;
|
|
172
|
+
background: #1677ff;
|
|
173
|
+
flex-shrink: 0;
|
|
174
|
+
border-radius: 16px 16px 0 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.dialog-header--center {
|
|
178
|
+
border-radius: 12px 12px 0 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.dialog-title {
|
|
182
|
+
flex: 1;
|
|
183
|
+
font-size: 16px;
|
|
184
|
+
font-weight: 500;
|
|
185
|
+
color: #fff;
|
|
186
|
+
white-space: nowrap;
|
|
187
|
+
overflow: hidden;
|
|
188
|
+
text-overflow: ellipsis;
|
|
189
|
+
margin-right: 12px;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.dialog-close {
|
|
193
|
+
width: 28px;
|
|
194
|
+
height: 28px;
|
|
195
|
+
display: flex;
|
|
196
|
+
align-items: center;
|
|
197
|
+
justify-content: center;
|
|
198
|
+
background: transparent;
|
|
199
|
+
border: none;
|
|
200
|
+
color: #fff;
|
|
201
|
+
font-size: 16px;
|
|
202
|
+
cursor: pointer;
|
|
203
|
+
padding: 0;
|
|
204
|
+
flex-shrink: 0;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.dialog-body {
|
|
208
|
+
flex: 1;
|
|
209
|
+
overflow: hidden;
|
|
210
|
+
display: flex;
|
|
211
|
+
flex-direction: column;
|
|
212
|
+
min-height: 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.dialog-preview {
|
|
216
|
+
flex: 1;
|
|
217
|
+
height: 100%;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* 底部操作栏:按钮数量自适应 */
|
|
221
|
+
.dialog-actions {
|
|
222
|
+
display: flex;
|
|
223
|
+
gap: 10px;
|
|
224
|
+
padding: 10px 16px;
|
|
225
|
+
padding-bottom: calc(10px + env(safe-area-inset-bottom));
|
|
226
|
+
border-top: 1px solid #f0f0f0;
|
|
227
|
+
flex-shrink: 0;
|
|
228
|
+
background: #fff;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.action-btn {
|
|
232
|
+
flex: 1;
|
|
233
|
+
height: 40px;
|
|
234
|
+
border: none;
|
|
235
|
+
border-radius: 8px;
|
|
236
|
+
font-size: 14px;
|
|
237
|
+
cursor: pointer;
|
|
238
|
+
transition: opacity 0.15s;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.action-btn:disabled {
|
|
242
|
+
opacity: 0.5;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* 下载:主色蓝 */
|
|
246
|
+
.action-btn--download {
|
|
247
|
+
background: #1677ff;
|
|
248
|
+
color: #fff;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* 用其他应用打开:次级灰白 */
|
|
252
|
+
.action-btn--openWithApp {
|
|
253
|
+
background: #f5f5f5;
|
|
254
|
+
color: #333;
|
|
255
|
+
border: 1px solid #e0e0e0;
|
|
256
|
+
}
|
|
257
|
+
</style>
|
|
@@ -39,13 +39,14 @@ const VueOfficeExcel = defineAsyncComponent(async () => {
|
|
|
39
39
|
class="office-viewer"
|
|
40
40
|
@error="emit('error')"
|
|
41
41
|
/>
|
|
42
|
-
<!-- docx
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
42
|
+
<!-- docx 单独用缩放容器,整体缩小至 85% 以适配移动端屏幕 -->
|
|
43
|
+
<div v-else-if="kind === 'docx' || kind === 'doc'" class="docx-scale-wrapper">
|
|
44
|
+
<VueOfficeDocx
|
|
45
|
+
:src="src"
|
|
46
|
+
class="office-viewer docx-scaled"
|
|
47
|
+
@error="emit('error')"
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
49
50
|
<!-- xlsx / xls 共用 excel 渲染器 -->
|
|
50
51
|
<VueOfficeExcel
|
|
51
52
|
v-else-if="kind === 'xlsx' || kind === 'xls'"
|
|
@@ -62,4 +63,20 @@ const VueOfficeExcel = defineAsyncComponent(async () => {
|
|
|
62
63
|
min-height: 200px;
|
|
63
64
|
overflow: auto;
|
|
64
65
|
}
|
|
66
|
+
|
|
67
|
+
/* docx 缩放容器:裁掉 scale 产生的右侧和底部留白 */
|
|
68
|
+
.docx-scale-wrapper {
|
|
69
|
+
flex: 1;
|
|
70
|
+
overflow: hidden;
|
|
71
|
+
width: 100%;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* 整体缩放 85%,transform-origin 从左上角开始,保持左对齐 */
|
|
75
|
+
.docx-scaled {
|
|
76
|
+
transform: scale(0.7);
|
|
77
|
+
transform-origin: top left;
|
|
78
|
+
/* scale 不影响布局,需手动补偿宽度和高度 */
|
|
79
|
+
width: calc(100% / 0.7);
|
|
80
|
+
min-height: calc(100% / 0.7);
|
|
81
|
+
}
|
|
65
82
|
</style>
|
|
@@ -5,12 +5,10 @@
|
|
|
5
5
|
* 渲染策略:
|
|
6
6
|
* 1. image / video / audio → MediaPreview(原生标签)
|
|
7
7
|
* 2. text → TextPreview(<pre>)
|
|
8
|
-
* 3. pdf / docx /
|
|
9
|
-
*
|
|
10
|
-
* 4. doc 旧格式 / unsupported → 直接走 KkPreview
|
|
8
|
+
* 3. pdf / docx / xlsx → OfficePreview(@vue-office),失败自动降级到 KkPreview
|
|
9
|
+
* 4. doc / xls / unsupported → 直接走 KkPreview
|
|
11
10
|
*/
|
|
12
11
|
import type { PreviewFileKind } from '@af-mobile-client-vue3/utils/fileType'
|
|
13
|
-
import { downloadAttachment } from '@af-mobile-client-vue3/utils/fileDownload'
|
|
14
12
|
import { getPreviewFileKind } from '@af-mobile-client-vue3/utils/fileType'
|
|
15
13
|
import {
|
|
16
14
|
fetchResourceArrayBuffer,
|
|
@@ -18,7 +16,7 @@ import {
|
|
|
18
16
|
getPreviewPath,
|
|
19
17
|
resolveResourceUrl,
|
|
20
18
|
} from '@af-mobile-client-vue3/utils/resourceUrl'
|
|
21
|
-
import {
|
|
19
|
+
import { Empty as VanEmpty, Loading as VanLoading } from 'vant'
|
|
22
20
|
import { ref, shallowRef } from 'vue'
|
|
23
21
|
import KkPreview from './components/KkPreview.vue'
|
|
24
22
|
import MediaPreview from './components/MediaPreview.vue'
|
|
@@ -28,17 +26,16 @@ import TextPreview from './components/TextPreview.vue'
|
|
|
28
26
|
export interface FilePreviewInitOptions {
|
|
29
27
|
/** 后端 f_downloadpath 或完整 URL */
|
|
30
28
|
path: string
|
|
31
|
-
/**
|
|
29
|
+
/** 文件名(可选,默认从路径提取) */
|
|
32
30
|
name?: string
|
|
33
31
|
}
|
|
34
32
|
|
|
35
|
-
/**
|
|
33
|
+
/** 优先走 @vue-office,失败降级 kk */
|
|
36
34
|
const OFFICE_KINDS: PreviewFileKind[] = ['pdf', 'docx', 'xlsx']
|
|
37
35
|
|
|
38
36
|
/**
|
|
39
|
-
* 直接走 kk
|
|
40
|
-
* - doc 旧二进制格式,@vue-office 无法解析
|
|
41
|
-
* - xls 旧二进制格式,@vue-office 解析效果差
|
|
37
|
+
* 直接走 kk 预览的格式:
|
|
38
|
+
* - doc / xls 旧二进制格式,@vue-office 无法解析
|
|
42
39
|
* - unsupported 未知格式,交给 kkFileView 服务端判断
|
|
43
40
|
*/
|
|
44
41
|
const KK_ONLY_KINDS: PreviewFileKind[] = ['doc', 'xls', 'unsupported']
|
|
@@ -47,15 +44,12 @@ const kind = ref<PreviewFileKind>('unsupported')
|
|
|
47
44
|
const fileSource = shallowRef<string | ArrayBuffer>('')
|
|
48
45
|
const textContent = ref('')
|
|
49
46
|
const mediaSrc = ref('')
|
|
50
|
-
const rawUrl = ref('') // 文件原始 URL(kk 预览和下载共用)
|
|
51
47
|
const rawPath = ref('') // 文件相对路径(kk 预览用)
|
|
52
|
-
const downloadName = ref('')
|
|
53
48
|
const loading = ref(false)
|
|
54
49
|
const errorMsg = ref('')
|
|
55
50
|
/** true = office 渲染失败,已降级到 kk 预览 */
|
|
56
51
|
const officeFailed = ref(false)
|
|
57
52
|
|
|
58
|
-
/** office 渲染失败回调,切换到 kk 预览 */
|
|
59
53
|
function onOfficeFailed() {
|
|
60
54
|
officeFailed.value = true
|
|
61
55
|
}
|
|
@@ -67,11 +61,9 @@ async function init(options: FilePreviewInitOptions) {
|
|
|
67
61
|
return
|
|
68
62
|
}
|
|
69
63
|
|
|
70
|
-
|
|
64
|
+
const rawUrl = resolveResourceUrl(path)
|
|
71
65
|
kind.value = getPreviewFileKind(path)
|
|
72
|
-
rawUrl.value = resolveResourceUrl(path)
|
|
73
66
|
rawPath.value = path
|
|
74
|
-
downloadName.value = options.name || path.split('/').pop()?.split('?')[0] || '附件'
|
|
75
67
|
loading.value = true
|
|
76
68
|
errorMsg.value = ''
|
|
77
69
|
officeFailed.value = false
|
|
@@ -80,17 +72,16 @@ async function init(options: FilePreviewInitOptions) {
|
|
|
80
72
|
mediaSrc.value = ''
|
|
81
73
|
|
|
82
74
|
try {
|
|
83
|
-
if (
|
|
84
|
-
mediaSrc.value = rawUrl
|
|
75
|
+
if (['image', 'video', 'audio'].includes(kind.value)) {
|
|
76
|
+
mediaSrc.value = rawUrl
|
|
85
77
|
}
|
|
86
78
|
else if (kind.value === 'text') {
|
|
87
|
-
textContent.value = await fetchResourceText(rawUrl
|
|
79
|
+
textContent.value = await fetchResourceText(rawUrl)
|
|
88
80
|
}
|
|
89
81
|
else if (OFFICE_KINDS.includes(kind.value)) {
|
|
90
|
-
|
|
91
|
-
fileSource.value = await fetchResourceArrayBuffer(rawUrl.value)
|
|
82
|
+
fileSource.value = await fetchResourceArrayBuffer(rawUrl)
|
|
92
83
|
}
|
|
93
|
-
// doc / unsupported 直接走 KkPreview,无需预加载
|
|
84
|
+
// doc / xls / unsupported 直接走 KkPreview,无需预加载
|
|
94
85
|
}
|
|
95
86
|
catch (e: any) {
|
|
96
87
|
errorMsg.value = e?.message || '文件加载失败'
|
|
@@ -100,52 +91,32 @@ async function init(options: FilePreviewInitOptions) {
|
|
|
100
91
|
}
|
|
101
92
|
}
|
|
102
93
|
|
|
103
|
-
|
|
104
|
-
downloadAttachment({ url: rawUrl.value, name: downloadName.value })
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
defineExpose({ init, download: handleDownload })
|
|
94
|
+
defineExpose({ init })
|
|
108
95
|
</script>
|
|
109
96
|
|
|
110
97
|
<template>
|
|
111
98
|
<div class="file-preview-root">
|
|
112
|
-
<!-- 悬浮下载按钮,定位在预览区右上角 -->
|
|
113
|
-
<div v-if="rawUrl && !loading" class="preview-download-btn">
|
|
114
|
-
<VanButton size="small" type="primary" plain icon="down" @pointerdown.stop="handleDownload">
|
|
115
|
-
下载
|
|
116
|
-
</VanButton>
|
|
117
|
-
</div>
|
|
118
|
-
|
|
119
99
|
<!-- 加载中 -->
|
|
120
100
|
<VanLoading v-if="loading" class="preview-loading" type="spinner" vertical>
|
|
121
101
|
加载中...
|
|
122
102
|
</VanLoading>
|
|
123
103
|
|
|
124
104
|
<!-- 加载/解析出错 -->
|
|
125
|
-
<VanEmpty v-else-if="errorMsg" :description="errorMsg"
|
|
126
|
-
<VanButton v-if="rawUrl" size="small" type="primary" @click="handleDownload">
|
|
127
|
-
下载
|
|
128
|
-
</VanButton>
|
|
129
|
-
</VanEmpty>
|
|
105
|
+
<VanEmpty v-else-if="errorMsg" :description="errorMsg" />
|
|
130
106
|
|
|
131
107
|
<template v-else>
|
|
132
|
-
<!-- 媒体:图片 / 视频 / 音频 -->
|
|
133
108
|
<MediaPreview
|
|
134
109
|
v-if="kind === 'image' || kind === 'video' || kind === 'audio'"
|
|
135
110
|
:kind="kind"
|
|
136
111
|
:src="mediaSrc"
|
|
137
112
|
/>
|
|
138
113
|
|
|
139
|
-
<!-- 纯文本 -->
|
|
140
114
|
<TextPreview
|
|
141
115
|
v-else-if="kind === 'text'"
|
|
142
116
|
:content="textContent"
|
|
143
117
|
/>
|
|
144
118
|
|
|
145
|
-
<!--
|
|
146
|
-
Office 文档:先走 @vue-office
|
|
147
|
-
失败(officeFailed)或 doc/unsupported 直接走 KkPreview
|
|
148
|
-
-->
|
|
119
|
+
<!-- office:先 @vue-office,失败降级 kk -->
|
|
149
120
|
<OfficePreview
|
|
150
121
|
v-else-if="OFFICE_KINDS.includes(kind) && fileSource && !officeFailed"
|
|
151
122
|
:kind="kind"
|
|
@@ -153,18 +124,13 @@ defineExpose({ init, download: handleDownload })
|
|
|
153
124
|
@error="onOfficeFailed"
|
|
154
125
|
/>
|
|
155
126
|
|
|
156
|
-
<!-- kk 预览:office 降级 或 doc/unsupported 直接走 -->
|
|
127
|
+
<!-- kk 预览:office 降级 或 doc/xls/unsupported 直接走 -->
|
|
157
128
|
<KkPreview
|
|
158
129
|
v-else-if="officeFailed || KK_ONLY_KINDS.includes(kind)"
|
|
159
130
|
:file-path="rawPath"
|
|
160
131
|
/>
|
|
161
132
|
|
|
162
|
-
|
|
163
|
-
<VanEmpty v-else description="文件路径异常">
|
|
164
|
-
<VanButton v-if="rawUrl" size="small" type="primary" @click="handleDownload">
|
|
165
|
-
下载
|
|
166
|
-
</VanButton>
|
|
167
|
-
</VanEmpty>
|
|
133
|
+
<VanEmpty v-else description="文件路径异常" />
|
|
168
134
|
</template>
|
|
169
135
|
</div>
|
|
170
136
|
</template>
|
|
@@ -178,16 +144,6 @@ defineExpose({ init, download: handleDownload })
|
|
|
178
144
|
flex-direction: column;
|
|
179
145
|
background: #fff;
|
|
180
146
|
overflow: hidden;
|
|
181
|
-
/* 作为悬浮按钮的定位基准 */
|
|
182
|
-
position: relative;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
.preview-download-btn {
|
|
186
|
-
position: absolute;
|
|
187
|
-
top: 10px;
|
|
188
|
-
right: 12px;
|
|
189
|
-
z-index: 100;
|
|
190
|
-
pointer-events: all;
|
|
191
147
|
}
|
|
192
148
|
|
|
193
149
|
.preview-loading {
|
package/src/router/routes.ts
CHANGED
|
@@ -5,6 +5,7 @@ import GridView from '@af-mobile-client-vue3/layout/GridView/index.vue'
|
|
|
5
5
|
import Forbidden from '@af-mobile-client-vue3/views/common/Forbidden.vue'
|
|
6
6
|
import NotFound from '@af-mobile-client-vue3/views/common/NotFound.vue'
|
|
7
7
|
import EvaluateRecordView from '@af-mobile-client-vue3/views/component/EvaluateRecordView/index.vue'
|
|
8
|
+
import FilePreviewDialog from '@af-mobile-client-vue3/views/component/FilePreviewView/FilePreviewDialogView.vue'
|
|
8
9
|
import FilePreview from '@af-mobile-client-vue3/views/component/FilePreviewView/index.vue'
|
|
9
10
|
import IconifyView from '@af-mobile-client-vue3/views/component/IconifyView/index.vue'
|
|
10
11
|
import ComponentView from '@af-mobile-client-vue3/views/component/index.vue'
|
|
@@ -217,6 +218,11 @@ const routes: Array<RouteRecordRaw> = [
|
|
|
217
218
|
name: 'FilePreview',
|
|
218
219
|
component: FilePreview,
|
|
219
220
|
},
|
|
221
|
+
{
|
|
222
|
+
path: '/Component/FilePreviewDialog',
|
|
223
|
+
name: 'FilePreviewDialog',
|
|
224
|
+
component: FilePreviewDialog,
|
|
225
|
+
},
|
|
220
226
|
{
|
|
221
227
|
path: '/Component/StepView',
|
|
222
228
|
name: 'StepView',
|
package/src/utils/environment.ts
CHANGED
|
@@ -94,7 +94,6 @@ export function detectEnvironment(): EnvironmentInfo {
|
|
|
94
94
|
const isApp = (
|
|
95
95
|
/myapp|customapp|afmobileview|flutter/i.test(userAgent)
|
|
96
96
|
|| (Object.prototype.hasOwnProperty.call(window, 'webkit') && !!(window as any).webkit?.messageHandlers)
|
|
97
|
-
|| hasFlutterBridge()
|
|
98
97
|
)
|
|
99
98
|
|
|
100
99
|
// 检测浏览器环境
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { closeToast, showLoadingToast, showToast } from 'vant'
|
|
2
|
-
import {
|
|
2
|
+
import { detectEnvironment } from './environment'
|
|
3
3
|
import { getAttachmentDisplayName } from './fileType'
|
|
4
|
+
import { mobileUtil } from './mobileUtil'
|
|
4
5
|
import { resolveResourceUrl } from './resourceUrl'
|
|
5
6
|
|
|
6
7
|
export interface AttachmentDownloadSource {
|
|
@@ -10,16 +11,30 @@ export interface AttachmentDownloadSource {
|
|
|
10
11
|
name?: string
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
/**
|
|
15
|
+
* 解析附件下载地址:
|
|
16
|
+
* - /resource 开头:保持相对路径,由 nginx 转发,不拼接 origin
|
|
17
|
+
* - http/https 开头:直接使用
|
|
18
|
+
* - 其他情况:走 resolveResourceUrl 正常处理
|
|
19
|
+
*/
|
|
13
20
|
function getDownloadUrl(file: AttachmentDownloadSource): string {
|
|
14
|
-
|
|
21
|
+
const raw = file.url || file.f_downloadpath || ''
|
|
22
|
+
if (!raw)
|
|
23
|
+
return ''
|
|
24
|
+
if (raw.startsWith('/resource/') || /^https?:\/\//i.test(raw))
|
|
25
|
+
return raw
|
|
26
|
+
return resolveResourceUrl(raw)
|
|
15
27
|
}
|
|
16
28
|
|
|
29
|
+
/**
|
|
30
|
+
* 浏览器环境:fetch 文件内容后用隐藏 <a> 标签触发保存
|
|
31
|
+
* WebView 环境此方法无效(WebView 没有下载管理器)
|
|
32
|
+
*/
|
|
17
33
|
function triggerBlobDownload(blob: Blob, filename: string) {
|
|
18
34
|
const blobUrl = URL.createObjectURL(blob)
|
|
19
35
|
const link = document.createElement('a')
|
|
20
36
|
link.href = blobUrl
|
|
21
37
|
link.download = filename
|
|
22
|
-
link.rel = 'noopener'
|
|
23
38
|
link.style.display = 'none'
|
|
24
39
|
document.body.appendChild(link)
|
|
25
40
|
link.click()
|
|
@@ -27,29 +42,24 @@ function triggerBlobDownload(blob: Blob, filename: string) {
|
|
|
27
42
|
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000)
|
|
28
43
|
}
|
|
29
44
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
// try next bridge
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
return false
|
|
45
|
+
/**
|
|
46
|
+
* 用其他应用打开文件(方式二:传 url+name,Flutter 端自动下载后调起系统分享面板)
|
|
47
|
+
* 非 http/https 路径由 Flutter 端拼接 AppConfig.webUrl
|
|
48
|
+
*/
|
|
49
|
+
export function openFileWithApp(url: string, name: string): void {
|
|
50
|
+
mobileUtil.execute({
|
|
51
|
+
funcName: 'openFileWithApp',
|
|
52
|
+
param: { url, name },
|
|
53
|
+
callbackFunc: (result: any) => {
|
|
54
|
+
console.log('>>>> openFileWithApp: result: ', JSON.stringify(result))
|
|
55
|
+
},
|
|
56
|
+
})
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
60
|
+
* 下载附件,自动区分 Flutter WebView 和普通浏览器:
|
|
61
|
+
* - Flutter WebView:通过 mobileUtil 调用原生 downloadFile channel
|
|
62
|
+
* - 普通浏览器:fetch + blob 触发浏览器保存对话框
|
|
53
63
|
*/
|
|
54
64
|
export async function downloadAttachment(file: AttachmentDownloadSource): Promise<void> {
|
|
55
65
|
const url = getDownloadUrl(file)
|
|
@@ -60,18 +70,38 @@ export async function downloadAttachment(file: AttachmentDownloadSource): Promis
|
|
|
60
70
|
return
|
|
61
71
|
}
|
|
62
72
|
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
// Flutter WebView 环境:交给原生层下载
|
|
74
|
+
if (detectEnvironment().isApp) {
|
|
75
|
+
const toast = showLoadingToast({ message: '下载中...', forbidClick: true, duration: 0 })
|
|
76
|
+
try {
|
|
77
|
+
await new Promise<void>((resolve, reject) => {
|
|
78
|
+
mobileUtil.execute({
|
|
79
|
+
funcName: 'downloadFile',
|
|
80
|
+
param: { url, name: filename },
|
|
81
|
+
callbackFunc: (result: any) => {
|
|
82
|
+
result?.status === 'error'
|
|
83
|
+
? reject(new Error(result.msg || '下载失败'))
|
|
84
|
+
: resolve()
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
showToast('下载成功')
|
|
89
|
+
}
|
|
90
|
+
catch (e: any) {
|
|
91
|
+
showToast(e?.message || '下载失败')
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
closeToast()
|
|
95
|
+
}
|
|
65
96
|
return
|
|
66
97
|
}
|
|
67
98
|
|
|
99
|
+
// 普通浏览器环境:fetch + blob
|
|
68
100
|
const toast = showLoadingToast({ message: '下载中...', forbidClick: true, duration: 0 })
|
|
69
|
-
|
|
70
101
|
try {
|
|
71
102
|
const response = await fetch(url, { credentials: 'include' })
|
|
72
103
|
if (!response.ok)
|
|
73
104
|
throw new Error(`下载失败(${response.status})`)
|
|
74
|
-
|
|
75
105
|
const blob = await response.blob()
|
|
76
106
|
triggerBlobDownload(blob, filename)
|
|
77
107
|
showToast('下载成功')
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import FilePreviewDialog from '@af-mobile-client-vue3/components/data/FilePreview/FilePreviewDialog.vue'
|
|
3
|
+
import { ref } from 'vue'
|
|
4
|
+
|
|
5
|
+
const testFiles = [
|
|
6
|
+
{ name: '图片.png', path: '/resource/af-safecheck/images/92fde63667ec43b7a2d67f423624fccf.png' },
|
|
7
|
+
{ name: 'Word2003.doc', path: '/resource/af-revenue/word/62c684cd70ce4583aa1d6bf21164d465.doc' },
|
|
8
|
+
{ name: 'Word.docx', path: '/resource/af-revenue/word/46a87ec0a05143f98061a1ba43f19ed2.docx' },
|
|
9
|
+
{ name: 'Excel.xlsx', path: '/resource/af-scada/excel/c284a70c064048369369f43fef405b4e.xlsx' },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
const drawerRef = ref<InstanceType<typeof FilePreviewDialog> | null>(null)
|
|
13
|
+
const dialogRef = ref<InstanceType<typeof FilePreviewDialog> | null>(null)
|
|
14
|
+
|
|
15
|
+
function openDrawer(file: (typeof testFiles)[number]) {
|
|
16
|
+
drawerRef.value?.open({ path: file.path, name: file.name })
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function openDialog(file: (typeof testFiles)[number]) {
|
|
20
|
+
dialogRef.value?.open({ path: file.path, name: file.name })
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<div class="test-page">
|
|
26
|
+
<p class="test-title">
|
|
27
|
+
抽屉模式(drawer)
|
|
28
|
+
</p>
|
|
29
|
+
<div class="test-btns">
|
|
30
|
+
<button
|
|
31
|
+
v-for="file in testFiles"
|
|
32
|
+
:key="`drawer-${file.path}`"
|
|
33
|
+
class="test-btn"
|
|
34
|
+
@click="openDrawer(file)"
|
|
35
|
+
>
|
|
36
|
+
{{ file.name }}
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<p class="test-title">
|
|
41
|
+
弹窗模式(dialog)
|
|
42
|
+
</p>
|
|
43
|
+
<div class="test-btns">
|
|
44
|
+
<button
|
|
45
|
+
v-for="file in testFiles"
|
|
46
|
+
:key="`dialog-${file.path}`"
|
|
47
|
+
class="test-btn test-btn--dialog"
|
|
48
|
+
@click="openDialog(file)"
|
|
49
|
+
>
|
|
50
|
+
{{ file.name }}
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<!-- 抽屉实例 -->
|
|
55
|
+
<FilePreviewDialog ref="drawerRef" mode="drawer" />
|
|
56
|
+
<!-- 弹窗实例 -->
|
|
57
|
+
<FilePreviewDialog ref="dialogRef" mode="dialog" />
|
|
58
|
+
</div>
|
|
59
|
+
</template>
|
|
60
|
+
|
|
61
|
+
<style scoped>
|
|
62
|
+
.test-page {
|
|
63
|
+
padding: 24px 16px;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.test-title {
|
|
67
|
+
font-size: 15px;
|
|
68
|
+
font-weight: 600;
|
|
69
|
+
margin: 16px 0 10px;
|
|
70
|
+
color: #333;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.test-btns {
|
|
74
|
+
display: flex;
|
|
75
|
+
flex-direction: column;
|
|
76
|
+
gap: 10px;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.test-btn {
|
|
80
|
+
padding: 12px 16px;
|
|
81
|
+
background: #1677ff;
|
|
82
|
+
color: #fff;
|
|
83
|
+
border: none;
|
|
84
|
+
border-radius: 8px;
|
|
85
|
+
font-size: 14px;
|
|
86
|
+
cursor: pointer;
|
|
87
|
+
text-align: left;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.test-btn--dialog {
|
|
91
|
+
background: #52c41a;
|
|
92
|
+
}
|
|
93
|
+
</style>
|
|
@@ -1,24 +1,65 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import FilePreview from '@af-mobile-client-vue3/components/data/FilePreview/index.vue'
|
|
3
|
-
import { onMounted, ref } from 'vue'
|
|
3
|
+
import { nextTick, onMounted, ref } from 'vue'
|
|
4
4
|
|
|
5
|
-
const previewDocType = ref('
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
const previewDocType = ref('png')
|
|
6
|
+
const previewDocUrl = ref('/resource/af-safecheck/images/92fde63667ec43b7a2d67f423624fccf.png')
|
|
7
|
+
|
|
8
|
+
const testFiles = [
|
|
9
|
+
{
|
|
10
|
+
name: '图片.png',
|
|
11
|
+
path: '/resource/af-safecheck/images/92fde63667ec43b7a2d67f423624fccf.png',
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
name: 'Word2003.doc',
|
|
15
|
+
path: '/resource/af-revenue/word/62c684cd70ce4583aa1d6bf21164d465.doc',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: 'Word.docx',
|
|
19
|
+
path: '/resource/af-revenue/word/46a87ec0a05143f98061a1ba43f19ed2.docx',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'Excel.xlsx',
|
|
23
|
+
path: '/resource/af-scada/excel/c284a70c064048369369f43fef405b4e.xlsx',
|
|
24
|
+
},
|
|
25
|
+
]
|
|
10
26
|
|
|
11
27
|
const filePreviewRef = ref<InstanceType<typeof FilePreview> | null>(null)
|
|
12
28
|
|
|
13
|
-
|
|
29
|
+
/**
|
|
30
|
+
* 切换预览文件
|
|
31
|
+
*/
|
|
32
|
+
async function previewFile(file: (typeof testFiles)[number]) {
|
|
33
|
+
previewDocUrl.value = file.path
|
|
34
|
+
previewDocType.value = file.name.split('.').pop()?.toLowerCase() || ''
|
|
35
|
+
|
|
36
|
+
// 等待 FilePreview 根据 key 重新创建
|
|
37
|
+
await nextTick()
|
|
38
|
+
|
|
14
39
|
filePreviewRef.value?.init({
|
|
15
|
-
path:
|
|
40
|
+
path: file.path,
|
|
41
|
+
name: file.name,
|
|
16
42
|
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
onMounted(() => {
|
|
46
|
+
previewFile(testFiles[0])
|
|
17
47
|
})
|
|
18
48
|
</script>
|
|
19
49
|
|
|
20
50
|
<template>
|
|
21
51
|
<div>
|
|
52
|
+
<div class="toolbar">
|
|
53
|
+
<button
|
|
54
|
+
v-for="file in testFiles"
|
|
55
|
+
:key="file.path"
|
|
56
|
+
class="toolbar-btn"
|
|
57
|
+
@click="previewFile(file)"
|
|
58
|
+
>
|
|
59
|
+
{{ file.name }}
|
|
60
|
+
</button>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
22
63
|
<FilePreview
|
|
23
64
|
ref="filePreviewRef"
|
|
24
65
|
:key="`${previewDocType}-${previewDocUrl}`"
|
|
@@ -28,7 +69,17 @@ onMounted(() => {
|
|
|
28
69
|
</template>
|
|
29
70
|
|
|
30
71
|
<style scoped lang="less">
|
|
72
|
+
.toolbar {
|
|
73
|
+
margin-bottom: 12px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.toolbar-btn {
|
|
77
|
+
margin-right: 8px;
|
|
78
|
+
padding: 6px 12px;
|
|
79
|
+
cursor: pointer;
|
|
80
|
+
}
|
|
81
|
+
|
|
31
82
|
.doc-preview-file {
|
|
32
|
-
height:
|
|
83
|
+
height: 80vh;
|
|
33
84
|
}
|
|
34
85
|
</style>
|