<script lang="ts" setup>
|
import type { fileItem } from './props';
|
|
import { computed, defineAsyncComponent, onBeforeUnmount, ref, unref, watch } from 'vue';
|
|
import { useGlobSetting } from '@jnpf/hooks';
|
import { createImgPreview } from '@jnpf/ui';
|
import { downloadByUrl, toFileSize } from '@jnpf/utils';
|
|
import { useUserStore } from '@vben/stores';
|
|
import { CloseOutlined, DownloadOutlined, EditOutlined, EyeOutlined, LoadingOutlined, PaperClipOutlined } from '@ant-design/icons-vue';
|
import { Form, message } from 'ant-design-vue';
|
import { cloneDeep } from 'lodash-es';
|
|
import { getDownloadUrl, getPackDownloadUrl } from '#/api/core/common';
|
import { getWorkflowDownload, getWorkflowPreview } from '#/api/onlineDev/workflowFile';
|
import { selectUploadTemplate } from '#/api/onlineDev/uploadTemplate';
|
import type { UploadTemplateQuery } from '#/api/onlineDev/uploadTemplate';
|
// 直接引模块文件而不是 index 桶文件:桶文件会带出 Editor.vue,
|
// 而 UploadFile 几乎每个表单都在用,没必要让编辑器进它的依赖图
|
import { getDocumentServerUrl } from '#/components/OnlyOfficeEditor/src/loadDocsApi';
|
import { $t } from '#/locales';
|
import { onlineUtils } from '#/utils/jnpf';
|
|
import { allowsFileAction } from './fileAccess';
|
import { useFilePermissionAccess } from './filePermissionContext';
|
import Preview from './Preview.vue';
|
import { uploadFileProps } from './props';
|
import FileUploader from './SimpleUploader/FileUploader.vue';
|
import { useUploadOnlineEdit } from './useUploadOnlineEdit';
|
import { canReplaceWithTemplate, templateFileError } from './templateSelection';
|
|
const TemplateSelectModal = defineAsyncComponent(() => import('./TemplateSelectModal.vue'));
|
|
defineOptions({ inheritAttrs: false, name: 'JnpfUploadFile' });
|
const props = defineProps(uploadFileProps);
|
const emit = defineEmits(['update:value', 'change']);
|
|
const userStore = useUserStore();
|
const globSetting = useGlobSetting();
|
const fileList = ref<fileItem[]>(Array.isArray(unref(props.value)) ? cloneDeep(unref(props.value)) : []);
|
const apiUrl = ref(globSetting.apiURL);
|
const videoTypeList = new Set(['avi', 'avi', 'flv', 'flv', 'mkv', 'mov', 'mp3', 'mp4', 'mpeg', 'mpg', 'mpg', 'ram', 'rm', 'rm', 'rmvb', 'swf', 'wma', 'wmv']);
|
const imgTypeList = new Set(['bmp', 'gif', 'jpeg', 'jpg', 'png']);
|
const zipTypeList = new Set(['7z', 'arj', 'rar', 'z', 'zip']);
|
// 走 OnlyOffice 预览的 Office 格式,与后端 documentType 映射保持一致。
|
// 不含 pdf:平台自带的预览服务本来就能好好渲染 pdf,没必要为它改变现状。
|
const officeTypeList = new Set('csv doc docm docx dot dotx odp ods odt otp ots ott pot potx pps ppsx ppt pptm pptx rtf xls xlsm xlsx xlt xltx'.split(' '));
|
const filePreviewRef = ref<any>(null);
|
const fileUploaderRef = ref<any>(null);
|
const formItemContext = Form.useInjectFormItemContext();
|
|
function applyFiles(files: fileItem[]) {
|
fileList.value = cloneDeep(files);
|
emit('update:value', unref(fileList));
|
emit('change', unref(fileList));
|
formItemContext.onFieldChange();
|
}
|
const fileAccess = useFilePermissionAccess(props);
|
const officeEdit = useUploadOnlineEdit(
|
props,
|
() => fileList.value,
|
applyFiles,
|
() => fileAccess.value,
|
);
|
const { blocked, failure, canDiscard, canEdit } = officeEdit;
|
const templateQuery = ref<UploadTemplateQuery>();
|
let templateEpoch = 0;
|
const maySelectTemplate = computed(() => officeEdit.enabled.value && canReplaceWithTemplate(fileAccess.value, fileList.value.length));
|
const templateDisabledReason = computed(() => {
|
if (props.disabled || props.detailed) return '当前表单为只读状态';
|
if (blocked.value) return failure.value || '附件处理中,请稍后重试';
|
if (!canReplaceWithTemplate(fileAccess.value, fileList.value.length)) return '没有新增或替换附件的权限';
|
return officeEdit.templateUnavailableReason.value;
|
});
|
function validateTemplate(file: fileItem) {
|
return templateFileError(file, props);
|
}
|
function openTemplateSelector() {
|
if (props.disabled || props.detailed || blocked.value || !maySelectTemplate.value) return;
|
templateEpoch = accessEpoch;
|
templateQuery.value = { context: officeEdit.accessContext(), currentPage: 1, keyword: '' };
|
}
|
async function applyTemplate(query: UploadTemplateQuery & { templateId: string; fileId: string }) {
|
if (props.disabled || props.detailed || blocked.value || !maySelectTemplate.value || templateEpoch !== accessEpoch)
|
throw new Error('表单、附件或权限已变化,请重新打开模板选择');
|
await officeEdit.replaceFrom(async () => {
|
const { data } = await selectUploadTemplate(query);
|
const error = validateTemplate(data);
|
if (error) throw new Error(error);
|
return [data];
|
});
|
}
|
const mayView = computed(() => allowsFileAction(fileAccess.value, 'VIEW'));
|
const mayDownload = computed(() => props.allowDownload && allowsFileAction(fileAccess.value, 'DOWNLOAD'));
|
const mayAdd = computed(() => allowsFileAction(fileAccess.value, 'ADD'));
|
const mayRemove = computed(() => allowsFileAction(fileAccess.value, 'REMOVE'));
|
const inlineDiscard = computed(() => props.showUploadList && !props.simple && fileList.value.length === 1);
|
const discardTitle = '放弃修改,恢复此上传控件上次保存表单时的全部附件及内容';
|
const getBindValue = computed(() => ({
|
...props,
|
disabled: props.disabled || blocked.value || !mayAdd.value,
|
allowDownload: mayDownload.value,
|
fileAccess: fileAccess.value,
|
value: fileList.value,
|
}));
|
|
watch(
|
() => unref(props.value),
|
(val) => {
|
fileList.value = val && Array.isArray(val) ? cloneDeep(val) : [];
|
},
|
{
|
deep: true,
|
immediate: true,
|
},
|
);
|
|
defineExpose({ uploadFile });
|
|
// 仅保护新流程异步文件请求;切换记录、附件或撤权后丢弃旧响应。
|
let accessEpoch = 0;
|
watch(
|
() => JSON.stringify([officeEdit.accessContext(), fileAccess.value, fileList.value]),
|
() => {
|
accessEpoch++;
|
templateQuery.value = undefined;
|
},
|
{ deep: true, flush: 'sync' },
|
);
|
onBeforeUnmount(() => {
|
accessEpoch++;
|
});
|
|
function handleSimplePreview(file: fileItem) {
|
if (!props.simple) return;
|
handlePreview(file);
|
}
|
async function handlePreview(file: fileItem, previous = false) {
|
if (!mayView.value) return;
|
if (videoTypeList.has(file.fileExtension || '')) {
|
message.error($t('component.upload.videoNoPreview'));
|
return;
|
}
|
if (zipTypeList.has(file.fileExtension || '')) {
|
message.error($t('component.upload.zipNoPreview'));
|
return;
|
}
|
if (fileAccess.value !== undefined) {
|
const source = protectedSource(file, previous);
|
if (!source) return;
|
const epoch = accessEpoch;
|
let data;
|
try {
|
const mode = officeEdit.previewEnabled.value && officeTypeList.has(file.fileExtension || '') && getDocumentServerUrl() ? 'office' : 'platform';
|
({ data } = await getWorkflowPreview(source, mode));
|
} catch {
|
return;
|
}
|
if (epoch !== accessEpoch || !mayView.value) return;
|
if (data.kind === 'image' && data.url) {
|
createImgPreview({ imageList: [getImgUrl(data.url)] });
|
} else if (data.kind === 'office' && data.ticket) {
|
if (!getDocumentServerUrl()) {
|
message.error('当前流程附件预览需要配置 OnlyOffice 服务');
|
return;
|
}
|
const auditDocument =
|
['0f857eea0b07410f99ea35dc816cf5d2', '2312c264f497ddbd86b04bb1805620c3'].includes(source.modelId) && source.field === 'wenjian' && !source.parentField;
|
onlineUtils.openOfficeDocument({
|
file: { ...data.file, fileId: data.ticket, name: data.fileName },
|
fileType: 'annex',
|
mode: (auditDocument || fileAccess.value?.reviewEnabled) && /\.docx$/i.test(data.fileName) ? 'viewComments' : 'view',
|
bizScene: 'workflow.uploadPreview',
|
bizModule: 'workflow-file-preview',
|
bizDataId: data.ticket,
|
});
|
} else if (data.kind === 'platform' && data.url) {
|
const authorizedFile = data.file as fileItem;
|
filePreviewRef.value?.init(authorizedFile, { url: data.url, download: () => handleDownload(authorizedFile) });
|
}
|
return;
|
}
|
// 图片预览
|
if (imgTypeList.has(file.fileExtension || '')) {
|
const imageList = [getImgUrl(file.url)];
|
createImgPreview({ imageList });
|
return;
|
}
|
// Office 文档走 OnlyOffice 只读预览。没配 DS 地址时回落到平台自带的预览服务——
|
// 否则交付环境忘填 VITE_GLOB_ONLYOFFICE_URL 就会把原本能用的预览一起弄坏
|
if (officeEdit.previewEnabled.value && officeTypeList.has(file.fileExtension || '') && getDocumentServerUrl()) {
|
onlineUtils.openOfficeDocument({ file, fileType: props.type, mode: 'view' });
|
return;
|
}
|
// 文件预览
|
filePreviewRef.value?.init(file);
|
}
|
function getImgUrl(url) {
|
const userInfo: any = userStore.getUserInfo || {};
|
const securityKey = userInfo?.securityKey || '';
|
if (!securityKey) return apiUrl.value + url;
|
const realUrl = `${apiUrl.value + url + (url.includes('?') ? '&' : '?')}s=${securityKey}`;
|
return realUrl;
|
}
|
function handleDownload(file: fileItem) {
|
if (!mayDownload.value || !file.fileId) return;
|
if (fileAccess.value !== undefined) {
|
const source = protectedSource(file);
|
if (!source) return;
|
const epoch = accessEpoch;
|
getWorkflowDownload([source])
|
.then(({ data }) => {
|
if (epoch === accessEpoch && mayDownload.value) downloadByUrl({ fileName: data.downloadName, url: data.downloadVo.url });
|
})
|
.catch(() => undefined);
|
return;
|
}
|
getDownloadUrl(props.type, file.fileId).then((res) => {
|
downloadByUrl({ fileName: file.name, url: res.data.url });
|
});
|
}
|
// 批量下载
|
function handleDownloadAll() {
|
if (!mayDownload.value) return;
|
if (fileAccess.value !== undefined) {
|
const sources = fileList.value.map((file) => protectedSource(file));
|
if (!sources.length || sources.some((source) => !source)) return;
|
const epoch = accessEpoch;
|
getWorkflowDownload(sources.filter((source) => source !== undefined))
|
.then(({ data }) => {
|
if (epoch === accessEpoch && mayDownload.value) downloadByUrl({ fileName: data.downloadName, url: data.downloadVo.url });
|
})
|
.catch(() => undefined);
|
return;
|
}
|
const data: any[] = [];
|
for (let i = 0; i < unref(fileList).length; i++) {
|
const item: any = unref(fileList)[i];
|
data.push({ fileId: item.fileId, fileName: item.name });
|
}
|
getPackDownloadUrl(props.type, data).then((res) => {
|
if (!res.data && !res.data.downloadVo) return;
|
downloadByUrl({ fileName: res.data.downloadName, url: res.data.downloadVo.url });
|
});
|
}
|
function protectedSource(file: fileItem, previous = false) {
|
const context = officeEdit.accessContext();
|
if (!context.modelId || !context.recordId || !context.field || (context.parentField && !context.rowId)) {
|
message.warning('请先保存表单和子表记录,再访问该流程附件');
|
return;
|
}
|
const shared = !previous && officeEdit.sharedSource(file);
|
if (shared) return { ...context, ...shared };
|
const formal = previous ? officeEdit.previousFile(file) : officeEdit.formalFile(file);
|
if (!formal) {
|
message.warning('该附件尚未随表单保存或流程办理,请保存后再预览或下载');
|
return;
|
}
|
return { ...context, fileId: formal.fileId };
|
}
|
function handleRemove(index: number) {
|
if (!mayRemove.value) return;
|
if (props.disabled || props.detailed || blocked.value) return;
|
officeEdit.replace(fileList.value.filter((_, i) => i !== index)).catch(() => undefined);
|
}
|
function uploadFile() {
|
if (!mayAdd.value) return;
|
if (props.disabled || props.detailed || blocked.value) return;
|
const isTopLimit = props.limit ? fileList.value.length >= props.limit : false;
|
if (isTopLimit) {
|
message.error($t('component.upload.fileMaxNumber', [props.limit]));
|
return false;
|
}
|
fileUploaderRef.value?.openUploader();
|
}
|
function handleFileSuccess(data) {
|
if (fileAccess.value !== undefined && (!mayAdd.value || props.disabled || props.detailed)) return;
|
const isTopLimit = props.limit ? fileList.value.length >= props.limit : false;
|
if (isTopLimit) {
|
message.error($t('component.upload.fileMaxNumber', [props.limit]));
|
return false;
|
}
|
officeEdit.add(data);
|
}
|
</script>
|
|
<template>
|
<div :class="$attrs.class" class="upload-file-container">
|
<div v-if="officeEdit.syncWarning.value" class="text-xs text-orange-500">{{ officeEdit.syncWarning.value }}</div>
|
<TemplateSelectModal
|
v-if="templateQuery"
|
:query="templateQuery"
|
:replacing="fileList.length > 0"
|
:validate="validateTemplate"
|
:select="applyTemplate"
|
@close="templateQuery = undefined" />
|
<div class="flex justify-between" v-if="showType !== 'dragger'">
|
<div v-if="!detailed" class="flex items-center gap-2">
|
<a-button :disabled="disabled || blocked || !mayAdd" pre-icon="icon-ym icon-ym-btn-upload" @click="uploadFile">
|
{{ buttonText }}
|
</a-button>
|
<a-tooltip v-if="officeEdit.templateFeatureEnabled.value && type === 'annex'" :title="templateDisabledReason">
|
<span><a-button :disabled="!!templateDisabledReason" @click="openTemplateSelector">选择模板</a-button></span>
|
</a-tooltip>
|
</div>
|
<p
|
v-if="mayDownload && fileList.length > 0 && showAllDownload"
|
class="link-text"
|
:class="{ 'workflow-file-action': fileAccess !== undefined }"
|
:data-input-table-interactive="fileAccess !== undefined ? '' : undefined"
|
@click="handleDownloadAll">
|
<DownloadOutlined />{{ $t('component.upload.downloadAll') }}
|
</p>
|
</div>
|
<a-button
|
v-if="canDiscard && !inlineDiscard"
|
type="link"
|
:class="{ 'workflow-file-action': fileAccess !== undefined }"
|
:disabled="blocked"
|
:title="discardTitle"
|
@click="officeEdit.discard">
|
<template #icon><i class="icon-ym icon-ym-undo" aria-hidden="true"></i></template>
|
放弃附件修改
|
</a-button>
|
<div v-if="fileAccess && fileAccess.status !== 'ready'" class="upload-file__tip">文件权限暂不可用,请稍后重试</div>
|
<div v-if="officeEdit.editStatus.value" class="upload-file__tip" role="status" aria-live="polite">
|
<LoadingOutlined class="mr-1" />{{ officeEdit.editStatus.value }}
|
</div>
|
<div v-if="failure" class="upload-file__tip">{{ failure }}</div>
|
<div v-if="officeEdit.pendingRevisionResult.value && !officeEdit.busy.value" class="flex gap-2">
|
<a-button :disabled="officeEdit.busy.value" @click="officeEdit.retryRevisionResult">重试同步编辑结果</a-button>
|
<a-button :disabled="officeEdit.busy.value" @click="officeEdit.discardRevisionResult">放弃本次附件编辑</a-button>
|
</div>
|
<div v-if="tipText && !detailed && showType !== 'dragger'" class="upload-file__tip">{{ tipText }}</div>
|
<FileUploader ref="fileUploaderRef" v-bind="getBindValue" @file-success="handleFileSuccess" v-show="!detailed" />
|
<a-tooltip v-if="showType === 'dragger' && officeEdit.templateFeatureEnabled.value && type === 'annex' && !detailed" :title="templateDisabledReason">
|
<span><a-button class="mt-2" :disabled="!!templateDisabledReason" @click="openTemplateSelector">选择模板</a-button></span>
|
</a-tooltip>
|
<div v-if="showUploadList" :class="{ 'upload-file-list__simple': simple }" class="upload-file-list">
|
<div v-for="(file, index) in fileList" :key="file.fileId" :class="{ 'upload-file-list__item_detail': detailed }" class="upload-file-list__item">
|
<a
|
:title="file.name + (file.fileSize && !simple ? toFileSize(file.fileSize) : '')"
|
class="upload-file-list__item-name"
|
:class="{ 'workflow-file-action': fileAccess !== undefined && simple && mayView }"
|
@click="handleSimplePreview(file)">
|
<PaperClipOutlined v-if="showIcon" />
|
{{ file.name }}{{ file.fileSize && !simple ? ` ${toFileSize(file.fileSize)}` : '' }}
|
</a>
|
<span
|
class="upload-file-list__item-actions"
|
:class="{ 'workflow-file-action': fileAccess !== undefined }"
|
:data-input-table-interactive="fileAccess !== undefined ? '' : undefined">
|
<button
|
v-if="canDiscard && inlineDiscard"
|
type="button"
|
class="upload-file-discard"
|
:title="discardTitle"
|
:aria-label="discardTitle"
|
:disabled="blocked"
|
@click="officeEdit.discard">
|
<i class="icon-ym icon-ym-undo" aria-hidden="true"></i>
|
</button>
|
<EditOutlined
|
v-if="canEdit(file)"
|
:title="officeEdit.reviewOnly.value ? '审批模式' : '在线编辑'"
|
:class="{ 'edit-disabled': blocked }"
|
@click="officeEdit.open(file)" />
|
<EyeOutlined v-if="showView && mayView" :title="$t('component.upload.view')" @click="handlePreview(file)" />
|
<DownloadOutlined v-if="mayDownload && showDownload" :title="$t('component.upload.download')" @click="handleDownload(file)" />
|
<CloseOutlined v-show="mayRemove && !disabled && !detailed && !blocked" :title="$t('component.upload.del')" @click="handleRemove(index)" />
|
</span>
|
</div>
|
</div>
|
<Preview ref="filePreviewRef" :show-download="mayDownload && simple" :type="type" />
|
</div>
|
</template>
|
<style lang="scss" scoped>
|
.upload-file-container {
|
// 子表禁用单元格会穿透点击;仅恢复受控附件的独立操作,权限仍由按钮及后端校验。
|
.workflow-file-action {
|
pointer-events: auto;
|
}
|
|
.edit-disabled {
|
cursor: not-allowed;
|
opacity: 0.4;
|
}
|
.link-text {
|
line-height: 32px;
|
|
.anticon {
|
margin-right: 2px;
|
}
|
}
|
|
.upload-file__tip {
|
margin-top: 5px;
|
font-size: 12px;
|
line-height: 1.2;
|
color: var(--text-color-secondary);
|
word-break: break-all;
|
}
|
|
.upload-file-list {
|
&.upload-file-list__simple {
|
.upload-file-list__item {
|
color: var(--primary-color);
|
|
&:first-child {
|
margin-top: 4px !important;
|
}
|
|
&:hover {
|
background-color: unset !important;
|
}
|
|
.upload-file-list__item-name {
|
padding-left: 0;
|
|
.anticon {
|
display: none;
|
}
|
}
|
|
.upload-file-list__item-actions {
|
display: none;
|
}
|
}
|
}
|
|
.upload-file-list__item {
|
box-sizing: border-box;
|
display: flex;
|
align-items: center;
|
width: 100%;
|
margin-top: 5px;
|
font-size: 14px;
|
line-height: 26px;
|
color: var(--text-color-label);
|
border-radius: 4px;
|
|
a {
|
color: inherit;
|
}
|
|
&:first-child {
|
margin-top: 10px;
|
}
|
|
&:hover {
|
background-color: var(--selected-hover-bg);
|
|
.upload-file-list__item-name {
|
color: var(--primary-color);
|
}
|
}
|
|
.upload-file-list__item-name {
|
flex: 1;
|
min-width: 0;
|
padding-left: 4px;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
|
.anticon {
|
height: 100%;
|
margin-right: 5px;
|
color: #909399;
|
}
|
}
|
|
.upload-file-list__item-actions {
|
display: flex;
|
flex-shrink: 0;
|
gap: 6px;
|
align-items: center;
|
padding: 0 5px;
|
|
.anticon {
|
display: inline-block;
|
color: var(--text-color-label);
|
cursor: pointer;
|
opacity: 0.75;
|
}
|
|
.upload-file-discard {
|
display: inline-flex;
|
align-items: center;
|
padding: 0;
|
font: inherit;
|
background: transparent;
|
border: 0;
|
cursor: pointer;
|
|
.icon-ym {
|
font-size: inherit;
|
line-height: 1;
|
color: var(--text-color-label);
|
opacity: 0.75;
|
}
|
|
&:hover:not(:disabled) .icon-ym {
|
color: var(--primary-color);
|
opacity: 1;
|
}
|
|
&:disabled .icon-ym {
|
cursor: not-allowed;
|
opacity: 0.4;
|
}
|
}
|
}
|
}
|
}
|
|
.list-enter-active,
|
.list-leave-active {
|
transition: all 1s ease;
|
}
|
|
.list-enter-from,
|
.list-leave-to {
|
opacity: 0;
|
transform: translateY(-30px);
|
}
|
}
|
</style>
|