feat: 新增配送合同附件安全上传功能
- 增加合同专用 PDF 上传、鉴权预览和失败清理接口 - 支持草稿附件替换移除、并发保护和启用完整性校验 - 修复循环模板引用导致文件选择器无法打开的问题 - 补充专项测试、中文项目文档和操作日志
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
<!--
|
||||
功能:提供单个配送合同 PDF 附件的选择、拖拽、预览和移除控件。
|
||||
版本:v1.0.0
|
||||
-->
|
||||
<template>
|
||||
<div
|
||||
class="contract-file-control"
|
||||
:class="{ 'contract-file-disabled': disabled }"
|
||||
:tabindex="disabled ? -1 : 0"
|
||||
role="button"
|
||||
:aria-disabled="disabled"
|
||||
aria-label="选择合同附件 PDF"
|
||||
@click="openPicker"
|
||||
@keydown.enter.prevent="openPicker"
|
||||
@keydown.space.prevent="openPicker"
|
||||
@dragover.prevent
|
||||
@drop.prevent="selectDroppedFile"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="contract-file-input"
|
||||
type="file"
|
||||
accept=".pdf,application/pdf"
|
||||
:disabled="disabled"
|
||||
@change="selectPickedFile"
|
||||
/>
|
||||
<div class="contract-file-main">
|
||||
<icon-upload />
|
||||
<div>
|
||||
<strong>{{ title }}</strong>
|
||||
<div class="contract-file-hint">点击选择或拖拽 PDF,最大 10 MiB</div>
|
||||
<div v-if="attachment.requiresReupload" class="contract-file-warning">
|
||||
旧附件地址不可用,请重新上传
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-space @click.stop @keydown.stop>
|
||||
<a-button
|
||||
v-if="attachment.available && !attachment.selectedFile && !attachment.removed"
|
||||
size="small"
|
||||
@click="emit('preview')"
|
||||
>
|
||||
<template #icon><icon-eye /></template>预览
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="attachment.selectedFile"
|
||||
size="small"
|
||||
@click="emit('clear-selection')"
|
||||
>
|
||||
取消选择
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else-if="attachment.hasExisting && !attachment.removed"
|
||||
size="small"
|
||||
status="danger"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<template #icon><icon-delete /></template>移除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IconDelete, IconEye, IconUpload } from '@arco-design/web-vue/es/icon';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
export type ContractAttachmentFieldState = {
|
||||
selectedFile?: File;
|
||||
hasExisting: boolean;
|
||||
available: boolean;
|
||||
requiresReupload: boolean;
|
||||
removed: boolean;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
attachment: ContractAttachmentFieldState;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [file: File];
|
||||
remove: [];
|
||||
'clear-selection': [];
|
||||
preview: [];
|
||||
}>();
|
||||
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
const title = computed(() => {
|
||||
if (props.attachment.selectedFile) return props.attachment.selectedFile.name;
|
||||
if (props.attachment.removed) return '未选择合同附件';
|
||||
if (props.attachment.hasExisting) return '合同附件.pdf';
|
||||
return '选择合同附件';
|
||||
});
|
||||
|
||||
/** 打开组件内唯一的原生文件选择器。 */
|
||||
function openPicker() {
|
||||
if (!props.disabled) fileInput.value?.click();
|
||||
}
|
||||
|
||||
/** 读取点击选择的单个文件,并允许再次选择同名文件。 */
|
||||
function selectPickedFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (file) emit('select', file);
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
/** 接收拖拽到控件上的第一个文件。 */
|
||||
function selectDroppedFile(event: DragEvent) {
|
||||
if (props.disabled) return;
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (file) emit('select', file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.contract-file-control {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 88px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
border: 1px dashed var(--color-border-3);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.contract-file-control:hover,
|
||||
.contract-file-control:focus-visible {
|
||||
background: var(--color-fill-1);
|
||||
border-color: rgb(var(--primary-6));
|
||||
}
|
||||
.contract-file-control:focus-visible {
|
||||
box-shadow: 0 0 0 2px rgba(var(--primary-6), 0.2);
|
||||
}
|
||||
.contract-file-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.contract-file-input {
|
||||
display: none;
|
||||
}
|
||||
.contract-file-main {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.contract-file-main strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--color-text-1);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.contract-file-hint {
|
||||
margin-top: 4px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.contract-file-warning {
|
||||
margin-top: 4px;
|
||||
color: rgb(var(--warning-6));
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user