Compare commits

...

3 Commits

Author SHA1 Message Date
zzs
68c77b35d7 wip: save file 2025-03-21 18:52:41 +08:00
zzs
261932cb84 wip: event read 2025-03-21 17:06:09 +08:00
zzs
c20347d95e wip: init iframe settings 2025-03-21 11:51:27 +08:00
13 changed files with 297 additions and 203 deletions
src
api/base
components/IFrame/src
hooks/setting
logics
router/guard
settings
types
utils
views/infra/bookmark

View File

@ -3,3 +3,9 @@ export interface UploadApiResult {
code: number code: number
url: string url: string
} }
export interface UploadFullApiResult<T> {
msg: string
code: number
data: T
}

View File

@ -1,10 +1,10 @@
import type { AxiosProgressEvent } from 'axios' import type { AxiosProgressEvent } from 'axios'
import type { UploadApiResult } from './model/uploadModel' import type { UploadApiResult, UploadFullApiResult } from './model/uploadModel'
import { defHttp } from '@/utils/http/axios' import { defHttp } from '@/utils/http/axios'
import type { UploadFileParams } from '@/types/axios' import type { FileDO, UploadFileParams } from '@/types/axios'
import { useGlobSetting } from '@/hooks/setting' import { useGlobSetting } from '@/hooks/setting'
const { uploadUrl = '' } = useGlobSetting() const { uploadUrl = '', uploadUrlFull } = useGlobSetting()
/** /**
* @description: Upload interface * @description: Upload interface
@ -18,3 +18,12 @@ export function uploadApi(params: UploadFileParams, onUploadProgress: (progressE
params, params,
) )
} }
export function uploadOneFile(params: UploadFileParams) {
return defHttp.uploadFile<UploadFullApiResult<FileDO>>(
{
url: uploadUrlFull,
},
params,
)
}

View File

@ -8,6 +8,7 @@ const props = defineProps({
type: [String, Number], type: [String, Number],
}, },
}) })
const loading = ref(true) const loading = ref(true)
const height = ref<string | number>('') const height = ref<string | number>('')
const frameRef = ref<null | HTMLIFrameElement>(null) const frameRef = ref<null | HTMLIFrameElement>(null)
@ -25,22 +26,8 @@ onMounted(() => {
}, 300) }, 300)
}) })
function sendMessageToIframe(message) {
if (frameRef.value && frameRef.value.contentWindow)
frameRef.value.contentWindow.postMessage(message, '*')
}
defineExpose({ defineExpose({
sendMessageToIframe, frameRef,
})
onMounted(() => {
//
window.addEventListener('message', (event) => {
//
if (event.data)
console.log(event.data)
})
}) })
</script> </script>

View File

@ -26,6 +26,7 @@ export function useGlobSetting(): Readonly<GlobConfig> {
shortName: VITE_GLOB_APP_SHORT_NAME, shortName: VITE_GLOB_APP_SHORT_NAME,
urlPrefix: VITE_GLOB_API_URL_PREFIX, urlPrefix: VITE_GLOB_API_URL_PREFIX,
uploadUrl: `${VITE_GLOB_API_URL}/infra/file/upload`, uploadUrl: `${VITE_GLOB_API_URL}/infra/file/upload`,
uploadUrlFull: `${VITE_GLOB_API_URL}/infra/file/upload-full`,
tenantEnable: VITE_GLOB_APP_TENANT_ENABLE, tenantEnable: VITE_GLOB_APP_TENANT_ENABLE,
captchaEnable: VITE_GLOB_APP_CAPTCHA_ENABLE, captchaEnable: VITE_GLOB_APP_CAPTCHA_ENABLE,
} }

View File

@ -21,6 +21,7 @@ import { getCommonStoragePrefix, getStorageShortName } from '@/utils/env'
import { Persistent } from '@/utils/cache/persistent' import { Persistent } from '@/utils/cache/persistent'
import { deepMerge } from '@/utils' import { deepMerge } from '@/utils'
import { ThemeEnum } from '@/enums/appEnum' import { ThemeEnum } from '@/enums/appEnum'
import { isInIframe } from '@/utils/iframe'
// Initial project configuration // Initial project configuration
export function initAppConfigStore() { export function initAppConfigStore() {
@ -47,6 +48,11 @@ export function initAppConfigStore() {
catch (error) { catch (error) {
console.log(error) console.log(error)
} }
// 如果当前界面被当作iframe使用设置页面全屏显示
projCfg.fullContent = isInIframe()
projCfg.showSettingButton = !isInIframe()
appStore.setProjectConfig(projCfg) appStore.setProjectConfig(projCfg)
// init dark mode // init dark mode

View File

@ -31,6 +31,10 @@ export function createPermissionGuard(router: Router) {
// return // return
// } // }
const routerToken = to.query.token || to.query.access_token || undefined
if (routerToken)
userStore.setAccessToken(routerToken as string)
const token = userStore.getAccessToken const token = userStore.getAccessToken
// Whitelist can be directly entered // Whitelist can be directly entered

View File

@ -44,7 +44,7 @@ const setting: ProjectConfig = {
// 色弱模式 // 色弱模式
colorWeak: false, colorWeak: false,
// 是否取消菜单,顶部,多标签页显示, 用于可能内嵌在别的系统内 // 是否取消菜单,顶部,多标签页显示, 用于可能内嵌在别的系统内
fullContent: false, fullContent: true,
// 主题内容宽度 // 主题内容宽度
contentMode: ContentEnum.FULL, contentMode: ContentEnum.FULL,
// 是否显示logo // 是否显示logo
@ -155,8 +155,8 @@ const setting: ProjectConfig = {
// 是否使用全局错误捕获 // 是否使用全局错误捕获
useErrorHandle: false, useErrorHandle: false,
// 是否开启回到顶部 // 是否开启回到顶部
useOpenBackTop: true, useOpenBackTop: false,
// 是否可以嵌入iframe页面 // 是否可以嵌入iframe页面
canEmbedIFramePage: true, canEmbedIFramePage: true,
// 切换界面的时候是否删除未关闭的message及notify // 切换界面的时候是否删除未关闭的message及notify
closeMessageOnSwitch: true, closeMessageOnSwitch: true,

18
src/types/axios.d.ts vendored
View File

@ -54,3 +54,21 @@ export interface UploadFileParams {
filename?: string filename?: string
[key: string]: any [key: string]: any
} }
// 文件完整信息
export interface FileDO {
// 编号,数据库自增
id: string
// 配置编号 关联 FileConfigDO. getId()
configId: string
// 原文件名
name: string
// 路径,即文件名
path: string
// 访问地址
url: string
// 文件的 MIME 类型,例如 "application/ octet-stream"
type: string
// 文件大小
size: string
}

View File

@ -157,6 +157,8 @@ export interface GlobConfig {
apiUrl: string apiUrl: string
// Upload url // Upload url
uploadUrl?: string uploadUrl?: string
// Upload url 返回完整文件信息
uploadUrlFull?: string
// Service interface url prefix // Service interface url prefix
urlPrefix?: string urlPrefix?: string
// Project abbreviation // Project abbreviation

View File

@ -4,10 +4,14 @@ import { getAccessToken } from '@/utils/auth'
/** /**
* *
* @param fileId * @param fileId
* @param wopi_client
* @param wopi_server
*/ */
export async function initFilePreviewUrl(fileId: string | number): Promise<string> { export async function initFilePreviewUrl(fileId: string | number, wopi_client?: string | undefined, wopi_server?: string | undefined): Promise<string> {
const wopi_client = await getConfigKey('wopi_client_addr') if (!wopi_client || !wopi_server) {
const wopi_server = await getConfigKey('wopi_server_ip_addr') wopi_client = await getConfigKey('wopi_client_addr')
wopi_server = await getConfigKey('wopi_server_ip_addr')
}
const wopi = `${wopi_server}/admin-api/infra/file/preview/wopi/files/${fileId}?access_token=${getAccessToken()}` const wopi = `${wopi_server}/admin-api/infra/file/preview/wopi/files/${fileId}?access_token=${getAccessToken()}`
return `${wopi_client}?lang=zh-cn&WOPISrc=${encodeURIComponent(wopi)}` return `${wopi_client}?lang=zh-cn&WOPISrc=${encodeURIComponent(wopi)}`
} }

10
src/utils/iframe.ts Normal file
View File

@ -0,0 +1,10 @@
// 检测当前页面是否被嵌套在 iframe 中
export function isInIframe(): boolean {
try {
return window.self !== window.top
}
catch (e) {
// 如果跨域访问被拒绝catch 会捕获异常
return true
}
}

View File

@ -0,0 +1,25 @@
/**
*
*/
export type ReceiveEventData =
| {
MessageId: 'OPEN_FILE'
Values: Blob | File
}
| {
MessageId: 'ACTION_SAVE'
Values?: null
}
| {
MessageId: never
Values?: never
}
/**
*
*/
export type CallbackEventData =
| {
Action: 'SAVE'
Values?: never
}

View File

@ -1,215 +1,237 @@
<script setup lang="ts"> <script setup lang="ts">
import { Button } from 'ant-design-vue' import { onMounted, ref } from 'vue'
import { ref, watchEffect } from 'vue' import { useEventListener } from '@vueuse/core'
import { useFileDialog } from '@vueuse/core' import axios from 'axios'
import { useRouter } from 'vue-router'
import { IFrame } from '@/components/IFrame' import { IFrame } from '@/components/IFrame'
import { FileSelectModal } from '@/components/FileSelect'
import { useModal } from '@/components/Modal' import { isInIframe } from '@/utils/iframe'
import type { FileVO } from '@/api/infra/file'
import { initFilePreviewUrl } from '@/utils/file/preview'
import type { BookmarkGenData, BookmarkQueryRespVo, PictureExData, TextExData } from '@/api/infra/bookmark'
import { getAllBookmarks, getDoBookmarkReplace } from '@/api/infra/bookmark'
import { useMessage } from '@/hooks/web/useMessage' import { useMessage } from '@/hooks/web/useMessage'
import { convertFileToBase64 } from '@/utils/file/base64Conver' import { uploadOneFile } from '@/api/base/upload'
import { initFilePreviewUrl } from '@/utils/file/preview'
import { getConfigKey } from '@/api/infra/config'
import type { FileDO } from '@/types/axios'
import { useGlobSetting } from '@/hooks/setting'
import { getAccessToken } from '@/utils/auth'
defineOptions({ name: 'BookmarkReplace' }) defineOptions({ name: 'BookmarkReplace' })
const isEmbed = isInIframe()
const globSetting = useGlobSetting()
const previewUrl = ref<string | undefined>(undefined) const previewUrl = ref<string | undefined>(undefined)
const frameComponent = ref<{ frameRef: HTMLIFrameElement } | null>(null)
const [registerModal, { openModal }] = useModal()
const currentEditFile = ref<FileVO | undefined>(undefined)
async function handleSelect(file: FileVO) {
currentEditFile.value = file
previewUrl.value = await initFilePreviewUrl(file.id)
await handleQueryBookmark()
}
const bookmarks = ref<BookmarkQueryRespVo[]>([])
interface FormItemType {
name: string
type: 'PICTURE_DESC' | 'PICTURE' | 'TEXT'
value: string | File
fileName: string
width: number
height: number
}
const formItems = ref<FormItemType[]>([])
const initFinish = ref<boolean>(false)
watchEffect(() => {
if (currentEditFile.value) {
bookmarks.value = []
formItems.value = []
}
})
const { createMessage } = useMessage() const { createMessage } = useMessage()
async function handleQueryBookmark() { const wopi_client = ref(undefined)
bookmarks.value = [] const wopi_server = ref(undefined)
formItems.value = []
initFinish.value = false
if (currentEditFile.value)
bookmarks.value = await getAllBookmarks(currentEditFile.value.id)
else const editStatus = ref<{ Modified: boolean }>({ Modified: false })
createMessage.error('请选择文件!') const currentEditFile = ref<FileDO | undefined>(undefined)
onMounted(async () => {
wopi_client.value = await getConfigKey('wopi_client_addr')
wopi_server.value = await getConfigKey('wopi_server_ip_addr')
for (const bk of bookmarks.value) /**
formItems.value.push({ name: bk.name, type: bk.type, value: '', fileName: '', width: 0, height: 0 }) * 接收消息
*/
useEventListener(window, 'message', async (e: MessageEvent) => {
logEvent(e)
initFinish.value = true let data: any = null
}
const { files, open, reset, onChange } = useFileDialog({ if (typeof e.data === 'string')
accept: 'image/*', data = JSON.parse(e.data)
directory: false,
if (typeof e.data === 'object')
data = e.data
const ActionId = data.ActionId
const MessageId = data.MessageId
// Event
switch (ActionId) {
case 'OPEN_FILE':
await handleFileBinary(e)
break
}
console.warn(`MessageId:${MessageId}`)
// WOPI Client
switch (MessageId) {
//
case 'Doc_ModifiedStatus':
handleUpdateModifiedStatus(e)
break
//
case 'UI_Save':
console.warn('UI_Save')
handleUserSaveOp()
break
}
})
}) })
let currentEdit /**
* 更新是否编辑状态
function handleSelectPicture(item: FormItemType) { * 保存后会更新是否编辑为否正常编辑后会更新是否编辑为是
reset() * 用于区分是否要向第三方发送保存文件的回调
open() * @param e
currentEdit = item */
function handleUpdateModifiedStatus(e: MessageEvent) {
editStatus.value.Modified = JSON.parse(e.data).Values.Modified
} }
onChange(() => { let pollingInterval: NodeJS.Timeout | null = null
if (files.value?.length && files.value?.length > 0 && currentEdit) { let lastCallTime: number | null = null
const file = files.value.item(0)
if (file) { async function handleUserSaveOp() {
currentEdit.value = file console.warn('handleUserSaveOp')
currentEdit.fileName = file.name
} // 1:
if (!editStatus.value.Modified) {
//
downloadAndSendCurrentEditFile()
return
} }
})
const router = useRouter() // 2: /
//
lastCallTime = Date.now()
async function handleGenerateNew() { //
if (currentEditFile.value) { if (pollingInterval) {
const data: BookmarkGenData[] = [] clearInterval(pollingInterval)
for (const item of formItems.value) { pollingInterval = null
if (!item.value) }
continue
if (item.type === 'TEXT') { //
data.push({ name: item.name, extData: { pollingInterval = setInterval(() => {
type: 'TEXT', // 2a:
value: item.value as string, if (!editStatus.value.Modified) {
} as TextExData }) downloadAndSendCurrentEditFile()
} cleanup()
if (item.type === 'PICTURE') {
const file = item.value as File
data.push({ name: item.name, extData: {
type: 'PICTURE',
value: await convertFileToBase64(item.value as File),
width: item.width,
height: item.height,
pictureName: file.name,
dataType: 'BASE64',
} as PictureExData })
}
if (item.type === 'PICTURE_DESC') {
const file = item.value as File
data.push({ name: item.name, extData: {
type: 'PICTURE_DESC',
value: await convertFileToBase64(item.value as File),
pictureName: file.name,
dataType: 'BASE64',
} as PictureExData })
}
}
if (data.length <= 0) {
createMessage.error('请填写替换值!')
return return
} }
const result = await getDoBookmarkReplace(currentEditFile.value.id, data) // 2b: 10
const previewUrl = await initFilePreviewUrl(result) const currentTime = Date.now()
const encode = encodeURIComponent(previewUrl) if (lastCallTime && currentTime - lastCallTime >= 10000) {
await router.push(`/infra/file/file-preview?url=${encode}`) createMessage.error('保存操作执行超时!')
cleanup()
}
}, 500) // 500ms
}
//
function cleanup() {
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
} }
lastCallTime = null
}
/**
* 下载当前编辑的文件发送给调用者
*/
async function downloadAndSendCurrentEditFile() {
console.warn('downloadAndSendCurrentEditFile')
if (!currentEditFile.value)
return
try {
// 1. responseType: 'arraybuffer'
const response = await axios.get(`${globSetting.apiUrl}/infra/file/download/${currentEditFile.value.id}`, {
responseType: 'arraybuffer', // [!code focus]
headers: {
Authorization: `Bearer ${getAccessToken()}`,
},
})
// 2.
if (response.status !== 200)
createMessage.error(`请求失败,状态码:${response.status}`)
// 3. ArrayBuffer
const arrayBuffer: ArrayBuffer = response.data
sendMessageToCaller({
ActionId: 'SAVE_FILE',
Payload: {
name: currentEditFile.value.name,
buffer: arrayBuffer,
},
})
}
catch (error) {
console.error('文件下载失败:', error)
createMessage.error('文件下载失败,请查看控制台日志')
throw error
}
}
/**
* 第三方上传文件
* @param e
*/
async function handleFileBinary(e: MessageEvent) {
const { name, type, buffer } = e.data.Payload
const blob = new Blob([buffer], { type })
const uploadResult = await uploadOneFile({
filename: name,
file: new File([blob], name, { type }),
})
currentEditFile.value = uploadResult.data.data as FileDO
previewUrl.value = await initFilePreviewUrl(currentEditFile.value.id, wopi_client.value, wopi_server.value)
}
/**
* 发送消息给WopiClient
* 用于执行书签相关操作
*/
// function sendMessageToWopiClient(data: any) {
// if (!frameComponent.value?.frameRef.contentWindow)
// createMessage.error('WOPI Client iframe ')
// try {
// frameComponent.value?.frameRef.contentWindow?.postMessage(data, '*')
// }
// catch (e) {
// console.error(e)
// createMessage.error(``)
// }
// }
/**
* 发送消息给第三方调用者
* 用于转发部分WopiClient回调
*/
function sendMessageToCaller(data: any) {
try {
const targetWindow = window.parent
targetWindow.postMessage(data, '*')
}
catch (e) {
console.error(e)
createMessage.error(`消息发送失败,查看控制台日志!`)
}
}
function logEvent(e: MessageEvent) {
console.log('=============receive message start=======')
console.log(e.data)
console.log(e.origin)
} }
</script> </script>
<template> <template>
<div> <div>
<div class="flex flex-col p-2 pt-4"> <div class="flex flex-col" :class="{ 'p-2 pt-4': !isEmbed }">
<div class="h-[calc(100vh-105px)] w-full flex flex-row"> <div class="w-full flex flex-row" :class="{ 'h-[calc(100vh)]': isEmbed, 'h-[calc(100vh-105px)]': !isEmbed }">
<div class="mr-2 h-full w-25% flex flex-col"> <div class="w-full flex flex-row bg-white dark:bg-black">
<div class="mb-2 flex flex-row items-center justify-between rounded bg-white px-2 py-2 dark:bg-black"> <i-frame v-if="previewUrl" ref="frameComponent" class="w-full bg-white dark:bg-black" :src="previewUrl" :height="isEmbed ? 'calc(100vh)' : 'calc(100vh) - 105px'" />
<div class="flex flex-row items-center">
<Button type="primary" class="mr-2" @click="openModal()">
选择文件
</Button>
: {{ currentEditFile?.name }}
</div>
<div class="flex flex-row items-center">
<Button @click="handleQueryBookmark">
获取书签
</Button>
</div>
</div>
<div class="h-full w-full overflow-scroll rounded bg-white p-4 dark:bg-black">
<a-form
v-if="initFinish"
class="w-full"
layout="vertical"
:label-col="{ span: 16 }"
>
<a-col v-for="(item) in formItems" :key="item.name">
<a-form-item :label="item.name">
<a-radio-group v-model:value="item.type" :disabled="item.type === 'PICTURE_DESC'" @change="item.value = null">
<a-radio value="TEXT">
文本
</a-radio>
<a-radio value="PICTURE">
图片
</a-radio>
<a-radio v-if="item.type === 'PICTURE_DESC'" value="PICTURE_DESC" :disabled="true">
图片替换
</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="替换值">
<a-input v-if="item.type === 'TEXT'" v-model:value="item.value" />
<a-button v-if="item.type.includes('PICTURE')" @click="handleSelectPicture(item)">
选择图片
</a-button>
<p v-if="item.type.includes('PICTURE') && item.value && item?.fileName" class="overflow-hidden">
{{ item?.fileName }}
</p>
</a-form-item>
<a-form-item v-if="item.type === 'PICTURE'" label="图片 宽(px)">
<a-input v-model:value="item.width" type="number" />
</a-form-item>
<a-form-item v-if="item.type === 'PICTURE'" label="图片 高(px)">
<a-input v-model:value="item.height" type="number" />
</a-form-item>
<a-divider />
</a-col>
</a-form>
<a-button v-if="formItems.length > 0" type="primary" @click="handleGenerateNew">
生成替换后文件
</a-button>
</div>
</div>
<div class="w-75% flex flex-row bg-white dark:bg-black">
<i-frame v-if="previewUrl" class="w-full bg-white dark:bg-black" :src="previewUrl" height="calc(100vh - 105px)" />
</div> </div>
</div> </div>
</div> </div>
<FileSelectModal v-bind="$attrs" @register="registerModal" @select="handleSelect" />
</div> </div>
</template> </template>