feat: 新增配送合同附件安全上传功能

- 增加合同专用 PDF 上传、鉴权预览和失败清理接口
- 支持草稿附件替换移除、并发保护和启用完整性校验
- 修复循环模板引用导致文件选择器无法打开的问题
- 补充专项测试、中文项目文档和操作日志
This commit is contained in:
czl231
2026-08-12 21:38:49 +08:00
parent deea31d79e
commit 99324acc89
16 changed files with 1379 additions and 16 deletions

View File

@@ -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>