Files
tms-erp-web/src/views/business/components/contract-attachment-section.vue
T

863 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="contract-attachment-section">
<section
v-if="!dialogOnly"
class="contract-manage-form__section contract-manage-form__section--panel contract-manage-form__section--attachment"
>
<div class="contract-manage-form__attachment-head">
<div class="dialog-section-title">{{ title }}</div>
<el-button type="primary" :disabled="!rows.length" @click="batchDownload"
>批量下载</el-button
>
</div>
<el-table
:data="rows"
border
class="contract-manage-form__attachment-table"
@selection-change="selected = $event"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column v-if="attachmentType" label="附件类型" min-width="160" align="center">
<template #default="{ row, $index }">
<span v-if="readonly || isRowLocked(row)">{{ row.fileType || '-' }}</span>
<el-select
v-else
:model-value="row.fileType"
placeholder="请选择"
style="width: 100%"
teleported
clearable
@update:model-value="value => updateRowFileType($index, value)"
>
<el-option
v-for="item in contractAttachmentTypeOptions"
:key="`${item.value}-${item.label}`"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column
label="文件名"
min-width="240"
align="left"
header-align="left"
show-overflow-tooltip
class-name="is-ellipsis-link"
>
<template #default="{ row }">
<span
class="attachment-file-name"
role="button"
tabindex="0"
@click="preview?.(row, rows)"
@keydown.enter="preview?.(row, rows)"
@keydown.space.prevent="preview?.(row, rows)"
>{{ attachmentName(row) }}</span>
</template>
</el-table-column>
<el-table-column v-if="description" label="附件描述" min-width="220">
<template #default="{ row, $index }">
<span v-if="readonly || isRowLocked(row)">{{ row.description || '-' }}</span>
<el-input
v-else
:model-value="row.description"
maxlength="200"
@update:model-value="value => updateRowDescription($index, value)"
/>
</template>
</el-table-column>
<el-table-column label="文件大小" width="120" align="center">
<template #default="{ row }">{{ formatSize(row.size) }}</template>
</el-table-column>
<el-table-column prop="uploadUserName" label="上传人" width="140" align="center" />
<el-table-column prop="uploadTime" label="上传时间" width="180" align="center" sortable />
<el-table-column v-if="!readonly" label="操作" width="100" align="center" fixed="right">
<template #default="{ row, $index }">
<span v-if="isRowLocked(row)">-</span>
<el-link v-else type="danger" @click="remove($index)">删除</el-link>
</template>
</el-table-column>
</el-table>
<div v-if="!readonly" class="contract-manage-form__attachment-upload">
<template v-if="useUploadDialog">
<el-button type="primary" plain @click="openUploadDialog">上传附件</el-button>
<span class="contract-manage-form__attachment-tip">
支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过50M
</span>
</template>
<vehicle-attachment-upload
v-else
:model-value="rows"
:file-types="attachmentFileTypes"
:max-size="50"
show-tip
tip="支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过50M"
:show-file-list="false"
button-text="上传附件"
@change="handleChange"
@success="handleUploadSuccess"
/>
</div>
</section>
<el-dialog
v-model="uploadDialogVisible"
title="附件上传"
width="560px"
append-to-body
destroy-on-close
:close-on-click-modal="false"
class="contract-attachment-upload-dialog"
@closed="resetUploadDialog"
>
<div class="contract-attachment-upload-dialog__body">
<div class="contract-attachment-upload-dialog__field">
<span class="contract-attachment-upload-dialog__label">附件位置</span>
<el-select
v-model="uploadDialogLocation"
placeholder="请选择附件位置"
style="width: 100%"
@change="syncUploadDialogTypeByLocation"
>
<el-option
v-for="item in resolvedLocationOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</div>
<div class="contract-attachment-upload-dialog__field">
<span class="contract-attachment-upload-dialog__label">附件类型</span>
<el-select v-model="uploadDialogType" placeholder="请选择附件类型" style="width: 100%">
<el-option
v-for="item in contractAttachmentTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
<div class="contract-attachment-upload-dialog__uploader">
<el-upload
drag
multiple
:action="uploadAction"
:headers="uploadHeaders"
:accept="acceptText"
:show-file-list="true"
:before-upload="beforeUpload"
:on-success="handleDialogSuccess"
:on-error="handleDialogError"
:on-remove="handleDialogRemove"
:file-list="uploadDialogFileList"
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
</el-upload>
</div>
</div>
<template #footer>
<div class="contract-attachment-upload-dialog__footer">
<el-button type="primary" @click="confirmUploadDialog">确定</el-button>
<el-button type="primary" @click="uploadDialogVisible = false">取消</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script>
import { UploadFilled } from '@element-plus/icons-vue';
import { baseUrl } from '@/config/env';
import { getUploadHeaders } from '@/utils/upload';
import { downloadFileByUrl } from '@/utils/util';
import { getDictionary as getBizDictionary } from '@/api/system/dictbiz';
const CONTRACT_ATTACHMENT_TYPE_OTHER = '其他文件';
/** 用印材料历史取值兼容别名 */
export const CONTRACT_SEAL_MATERIAL_LOCATION = '用印材料';
export const CONTRACT_SEAL_MATERIAL_TYPE_ALIASES = {
合同文件: CONTRACT_SEAL_MATERIAL_LOCATION,
};
/** 佐证材料附件位置(历史:其它附件) */
export const CONTRACT_SUPPORTING_ATTACHMENT_LOCATION = '附件(佐证材料等)';
export const CONTRACT_ATTACHMENT_TYPE_DICT = 'contract_attachment_type';
export const CONTRACT_SUPPORTING_ATTACHMENT_TYPE_DICT = 'contract_supporting_attachment_type';
export const isContractSupportingAttachmentLocation = location => {
const text = String(location || '');
return (
text === CONTRACT_SUPPORTING_ATTACHMENT_LOCATION ||
text === '其它附件' ||
text === '其他附件'
);
};
export const resolveAttachmentTypeDictCode = (location, fallback = CONTRACT_ATTACHMENT_TYPE_DICT) =>
isContractSupportingAttachmentLocation(location)
? CONTRACT_SUPPORTING_ATTACHMENT_TYPE_DICT
: fallback || CONTRACT_ATTACHMENT_TYPE_DICT;
export const defaultContractAttachmentTypeOptions = [
{ label: '用印材料', value: '用印材料' },
{ label: '双章归档文件', value: '双章归档文件' },
{ label: '其他文件', value: CONTRACT_ATTACHMENT_TYPE_OTHER },
];
// 特批附件归口附件(佐证材料等)区上传:高风险/黑名单客商合同必传,后端按 fileType=特批附件 校验并挂特批面板(与后端 HcsSyncConstant.ATTACHMENT_TYPE_SPEC_EXPLANATION 对齐)
export const defaultContractSupportingAttachmentTypeOptions = [
{ label: '特批附件', value: '特批附件' },
{ label: '其他文件', value: CONTRACT_ATTACHMENT_TYPE_OTHER },
];
/** @deprecated 兼容旧引用,实际选项由业务字典动态加载 */
export const contractAttachmentTypeOptions = defaultContractAttachmentTypeOptions;
export const isContractSealMaterialLocation = location => {
const text = String(location || '');
return text === CONTRACT_SEAL_MATERIAL_LOCATION || text === '合同文件';
};
export const attachmentFileTypes = [
'pdf',
'bmp',
'jpeg',
'png',
'jpg',
'doc',
'docx',
'ppt',
'pptx',
'xlsx',
'xls',
'eml',
'msg',
'zip',
];
export const attachmentName = row => row?.originalName || row?.name || row?.fileName || '附件';
export const attachmentUrl = row =>
row?.url || row?.link || row?.fileUrl || row?.downloadUrl || row?.domain || '';
export const isAttachmentApproved = row =>
row?.approved === true ||
row?.approved === 1 ||
row?.approved === '1' ||
row?.approvalStatus === 'approved';
const normalizeAttachmentTypeText = value => {
let text = String(value || '').split(/[?#]/)[0];
try {
text = decodeURIComponent(text);
} catch (error) {
// ignore decode error
}
return text
.replace(/\.[^.\\/]+$/, '')
.toLowerCase()
.replace(/[\s_\-—–·•()()\[\]【】{}《》<>“”"'、,,。.]/g, '');
};
const isOtherAttachmentTypeOption = item => {
const text = `${item?.label || ''}${item?.value || ''}`;
return (
String(item?.value) === CONTRACT_ATTACHMENT_TYPE_OTHER ||
String(item?.value) === '其它文件' ||
text.includes('其他文件') ||
text.includes('其它文件') ||
text.includes('其他') ||
text.includes('其它')
);
};
const attachmentTypeKeywords = fileType => {
const text = String(fileType || '');
if (text.includes('双章')) return ['双章归档文件', '双章归档', '双章'];
if (
text.includes('用印') ||
(text.includes('合同') && !text.includes('其他') && !text.includes('其它'))
) {
return ['用印材料', '合同文件', '用印', '合同'];
}
if (isOtherAttachmentTypeOption({ label: text, value: text })) return [];
return text ? [text] : [];
};
const resolveOtherAttachmentType = (options = []) => {
const list = options.length ? options : defaultContractAttachmentTypeOptions;
const matched = list.find(isOtherAttachmentTypeOption);
return matched?.value || CONTRACT_ATTACHMENT_TYPE_OTHER;
};
export const resolveContractAttachmentType = (
row = {},
options = defaultContractAttachmentTypeOptions
) => {
const list = options.length ? options : defaultContractAttachmentTypeOptions;
const otherType = resolveOtherAttachmentType(list);
const values = list.map(item => item.value);
const rawFileType = String(row.fileType || '');
const aliasedFileType = CONTRACT_SEAL_MATERIAL_TYPE_ALIASES[rawFileType] || rawFileType;
if (values.includes(aliasedFileType)) return aliasedFileType;
if (values.includes(rawFileType)) return rawFileType;
const matchedByLabel = list.find(
item => String(item.label) === aliasedFileType || String(item.label) === rawFileType
);
if (matchedByLabel) return matchedByLabel.value;
// 行已带类型但字典未命中(如历史保存值、字典缺失)时保留原值,避免重开页面时被降级为其他文件
if (rawFileType) return aliasedFileType;
const fileName = normalizeAttachmentTypeText(attachmentName(row));
if (!fileName) return otherType;
const matched = list
.filter(item => !isOtherAttachmentTypeOption(item))
.flatMap(item =>
attachmentTypeKeywords(item.value)
.concat(attachmentTypeKeywords(item.label))
.map(keyword => ({
value: item.value,
keyword: normalizeAttachmentTypeText(keyword),
}))
)
.filter(item => item.keyword)
.sort((a, b) => b.keyword.length - a.keyword.length)
.find(item => fileName.includes(item.keyword));
// 文件名未命中任何附件类型时,默认「其他文件」
return matched?.value || otherType;
};
export const normalizeContractFileRows = (rows, options = defaultContractAttachmentTypeOptions) =>
(rows || []).map(row => ({
...row,
fileType: resolveContractAttachmentType(row, options),
description: row.description || '',
}));
const mapDictOptions = (response, dictCode = CONTRACT_ATTACHMENT_TYPE_DICT) => {
const data = response?.data?.data || response?.data || [];
const records = Array.isArray(data) ? data : data.records || [];
const applySealAlias = dictCode === CONTRACT_ATTACHMENT_TYPE_DICT;
return records
.map(item => {
const rawLabel = String(item.dictValue || '');
const rawValue = String(item.dictKey || '');
const label = applySealAlias
? CONTRACT_SEAL_MATERIAL_TYPE_ALIASES[rawLabel] ||
(rawLabel === '合同文件' ? CONTRACT_SEAL_MATERIAL_LOCATION : rawLabel)
: rawLabel;
const value = applySealAlias
? CONTRACT_SEAL_MATERIAL_TYPE_ALIASES[rawValue] ||
(rawValue === '合同文件' ? CONTRACT_SEAL_MATERIAL_LOCATION : rawValue)
: rawValue;
return { label, value };
})
.filter(item => item.label && item.value);
};
const defaultOptionsByDictCode = dictCode =>
dictCode === CONTRACT_SUPPORTING_ATTACHMENT_TYPE_DICT
? [...defaultContractSupportingAttachmentTypeOptions]
: [...defaultContractAttachmentTypeOptions];
export default {
name: 'ContractAttachmentSection',
components: { UploadFilled },
props: {
title: { type: String, required: true },
rows: { type: Array, default: () => [] },
description: Boolean,
attachmentType: Boolean,
readonly: Boolean,
lockApproved: Boolean,
markApprovedOnUpload: Boolean,
useUploadDialog: Boolean,
dialogOnly: Boolean,
attachmentLocation: { type: String, default: CONTRACT_SEAL_MATERIAL_LOCATION },
attachmentLocationOptions: { type: Array, default: null },
/** 附件类型业务字典 code,默认用印材料字典;佐证材料传 contract_supporting_attachment_type */
attachmentTypeDictCode: {
type: String,
default: CONTRACT_ATTACHMENT_TYPE_DICT,
},
preview: { type: Function, default: null },
},
emits: ['update:rows', 'upload-to-location', 'upload-confirmed'],
data() {
return {
selected: [],
attachmentFileTypes,
contractAttachmentTypeOptions: [...defaultContractAttachmentTypeOptions],
activeAttachmentTypeDictCode: CONTRACT_ATTACHMENT_TYPE_DICT,
uploadDialogVisible: false,
uploadDialogLocation: CONTRACT_SEAL_MATERIAL_LOCATION,
uploadDialogType: resolveOtherAttachmentType(defaultContractAttachmentTypeOptions),
uploadDialogFiles: [],
uploadDialogFileList: [],
};
},
computed: {
uploadAction() {
return `${baseUrl}/blade-resource/oss/endpoint/put-file`;
},
uploadHeaders() {
return getUploadHeaders();
},
acceptText() {
return this.attachmentFileTypes.map(item => `.${item}`).join(',');
},
defaultAttachmentType() {
return resolveOtherAttachmentType(this.contractAttachmentTypeOptions);
},
resolvedAttachmentTypeDictCode() {
return this.attachmentTypeDictCode || CONTRACT_ATTACHMENT_TYPE_DICT;
},
resolvedLocationOptions() {
const options = (this.attachmentLocationOptions || []).filter(Boolean);
if (options.length) {
return options.map(item => {
if (item === '合同文件') return CONTRACT_SEAL_MATERIAL_LOCATION;
if (item === '其它附件' || item === '其他附件') {
return CONTRACT_SUPPORTING_ATTACHMENT_LOCATION;
}
return item;
});
}
return [CONTRACT_SEAL_MATERIAL_LOCATION, CONTRACT_SUPPORTING_ATTACHMENT_LOCATION];
},
},
watch: {
attachmentTypeDictCode: {
immediate: false,
handler() {
this.loadAttachmentTypeOptions(this.resolvedAttachmentTypeDictCode, true);
},
},
},
created() {
this.loadAttachmentTypeOptions(this.resolvedAttachmentTypeDictCode, true);
},
methods: {
attachmentName,
currentUploadUserName() {
const raw = this.$store.getters.userInfo;
const userInfo = raw && !Array.isArray(raw) ? raw : {};
// 与顶部栏一致优先 real_name;用印材料 / 佐证材料共用
return (
userInfo.real_name ||
userInfo.realName ||
userInfo.nick_name ||
userInfo.nickName ||
userInfo.user_name ||
userInfo.userName ||
userInfo.account ||
''
);
},
loadAttachmentTypeOptions(dictCode = this.resolvedAttachmentTypeDictCode, alignRows = false) {
const code = dictCode || CONTRACT_ATTACHMENT_TYPE_DICT;
this.activeAttachmentTypeDictCode = code;
const fallbackOptions = defaultOptionsByDictCode(code);
return getBizDictionary({ code })
.then(res => {
// 切换字典期间若已发起更新请求,忽略过期响应
if (this.activeAttachmentTypeDictCode !== code) return;
const options = mapDictOptions(res, code);
if (options.length) {
// 字典缺少「其他文件」时补上,保证未匹配文件名可默认选中
this.contractAttachmentTypeOptions = options.some(isOtherAttachmentTypeOption)
? options
: [...options, { label: '其他文件', value: CONTRACT_ATTACHMENT_TYPE_OTHER }];
} else {
this.contractAttachmentTypeOptions = fallbackOptions;
}
if (
!this.contractAttachmentTypeOptions.some(
item => String(item.value) === String(this.uploadDialogType)
)
) {
this.uploadDialogType = this.defaultAttachmentType;
}
// 字典加载后,把已有行的中文类型名对齐到 dictKey,保证下拉可选中
if (alignRows && this.attachmentType && (this.rows || []).length) {
const aligned = (this.rows || []).map(row => ({
...row,
fileType: resolveContractAttachmentType(row, this.contractAttachmentTypeOptions),
}));
const changed = aligned.some(
(row, index) => row.fileType !== this.rows[index]?.fileType
);
if (changed) this.update(aligned);
}
})
.catch(() => {
if (this.activeAttachmentTypeDictCode !== code) return;
this.contractAttachmentTypeOptions = fallbackOptions;
this.uploadDialogType = this.defaultAttachmentType;
});
},
isRowLocked(row) {
return this.lockApproved && isAttachmentApproved(row);
},
update(rows) {
this.$emit('update:rows', Array.isArray(rows) ? rows.map(item => ({ ...item })) : []);
},
enrichUploadedRow(row, existing) {
const uploadUserName = this.currentUploadUserName();
const uploadTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
const next = {
...existing,
...row,
description: row.description || existing?.description || '',
// 新上传行始终写入当前用户;已有行保留原上传人
uploadUserName: existing?.uploadUserName || uploadUserName || row.uploadUserName || '',
uploadTime: existing?.uploadTime || row.uploadTime || uploadTime,
approved: existing?.approved || row.approved || false,
};
if (this.attachmentType) {
next.fileType = existing?.fileType
? existing.fileType
: resolveContractAttachmentType(
{ ...next, fileType: '' },
this.contractAttachmentTypeOptions
);
}
if (this.markApprovedOnUpload && !existing) next.approved = true;
return next;
},
findExistingRow(row, rows = this.rows) {
const fileUrl = attachmentUrl(row);
return (rows || []).find(item => {
const sameUid = row.uid && item.uid && String(row.uid) === String(item.uid);
const sameUrl = fileUrl && attachmentUrl(item) === fileUrl;
return sameUid || sameUrl;
});
},
updateRowFileType(index, value) {
const rows = [...(this.rows || [])];
const row = rows[index];
if (!row || this.isRowLocked(row)) return;
rows[index] = { ...row, fileType: value };
this.update(rows);
},
updateRowDescription(index, value) {
const rows = [...(this.rows || [])];
const row = rows[index];
if (!row || this.isRowLocked(row)) return;
rows[index] = { ...row, description: value };
this.update(rows);
},
resolveTypeByLocation(location) {
const options = this.contractAttachmentTypeOptions || [];
const locationText = String(location || '');
if (
isContractSupportingAttachmentLocation(locationText) ||
locationText.includes('佐证') ||
locationText.includes('其它') ||
locationText.includes('其他')
) {
return this.defaultAttachmentType;
}
const matched = options.find(
item =>
String(item.value) === locationText ||
String(item.label) === locationText ||
String(item.value).includes('用印') ||
String(item.label).includes('用印') ||
String(item.value).includes('合同') ||
String(item.label).includes('合同')
);
return matched?.value || options[0]?.value || this.defaultAttachmentType;
},
syncUploadDialogTypeByLocation() {
const dictCode = resolveAttachmentTypeDictCode(
this.uploadDialogLocation,
this.resolvedAttachmentTypeDictCode
);
const applyType = () => {
this.uploadDialogType = this.resolveTypeByLocation(this.uploadDialogLocation);
};
if (dictCode !== this.activeAttachmentTypeDictCode) {
this.loadAttachmentTypeOptions(dictCode, false).finally(applyType);
return;
}
applyType();
},
openUploadDialog() {
const options = this.resolvedLocationOptions;
this.uploadDialogLocation = options.includes(this.attachmentLocation)
? this.attachmentLocation
: options[0] || this.attachmentLocation;
this.syncUploadDialogTypeByLocation();
this.uploadDialogFiles = [];
this.uploadDialogFileList = [];
this.uploadDialogVisible = true;
},
resetUploadDialog() {
this.uploadDialogFiles = [];
this.uploadDialogFileList = [];
this.uploadDialogLocation = this.attachmentLocation;
this.syncUploadDialogTypeByLocation();
},
beforeUpload(file) {
const extension = String(file.name || '')
.split('.')
.pop()
?.toLowerCase();
if (!this.attachmentFileTypes.includes(extension)) {
this.$message.warning('文件类型不符合上传要求');
return false;
}
if (file.size / 1024 / 1024 > 50) {
this.$message.warning('单文件大小不能超过 50MB');
return false;
}
return true;
},
normalizeUploadFile(file, response) {
const data = response?.data || file.response?.data || {};
const url = data.link || data.url || data.domain || file.url || '';
const originalName =
data.originalName || file.name || data.name || attachmentName({ url }) || '附件';
return {
uid: file.uid,
originalName,
name: originalName,
url,
link: data.link || url,
size: data.size || data.attachSize || file.size || '',
};
},
handleDialogSuccess(response, file, fileList) {
if (!response || response.success === false || (response.code && response.code !== 200)) {
this.$message.error(response?.msg || '上传失败');
this.uploadDialogFileList = (fileList || []).filter(item => item.uid !== file.uid);
return;
}
const normalized = this.normalizeUploadFile(file, response);
this.uploadDialogFileList = fileList || [];
this.uploadDialogFiles = [
...this.uploadDialogFiles.filter(item => String(item.uid) !== String(file.uid)),
normalized,
];
const locationText = String(this.uploadDialogLocation || '');
if (!(locationText.includes('其它') || locationText.includes('其他'))) {
this.uploadDialogType = resolveContractAttachmentType(
{
...normalized,
fileType: '',
},
this.contractAttachmentTypeOptions
);
} else {
this.uploadDialogType = this.uploadDialogType || this.defaultAttachmentType;
}
this.$message.success('上传成功');
},
handleDialogError() {
this.$message.error('上传失败');
},
handleDialogRemove(file) {
this.uploadDialogFiles = this.uploadDialogFiles.filter(
item => String(item.uid) !== String(file.uid)
);
this.uploadDialogFileList = this.uploadDialogFileList.filter(
item => String(item.uid) !== String(file.uid)
);
},
confirmUploadDialog() {
if (!this.uploadDialogFiles.length) {
this.$message.warning('请先上传附件');
return;
}
if (!this.uploadDialogLocation) {
this.$message.warning('请选择附件位置');
return;
}
if (!this.uploadDialogType) {
this.$message.warning('请选择附件类型');
return;
}
const uploadUserName = this.currentUploadUserName();
const uploadTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
const appended = this.uploadDialogFiles.map(row => ({
...row,
fileType: this.uploadDialogType,
description: row.description || '',
uploadUserName: uploadUserName || row.uploadUserName || '',
uploadTime: row.uploadTime || uploadTime,
...(this.markApprovedOnUpload ? { approved: true } : {}),
}));
// 统一交给父组件按附件位置写入对应列表,避免同位置双写或跨位置不显示
this.$emit('upload-to-location', {
location: this.uploadDialogLocation,
rows: appended,
});
this.uploadDialogVisible = false;
this.$emit('upload-confirmed', {
location: this.uploadDialogLocation,
rows: appended,
});
},
handleChange(list) {
if (!Array.isArray(list)) return;
// 上传组件偶发回传空列表时,禁止清空已有表格数据
if (!list.length) return;
const existingRows = this.rows || [];
const nextRows = list.map(row => {
const existing = this.findExistingRow(row, existingRows);
if (existing && this.isRowLocked(existing)) return { ...existing };
return this.enrichUploadedRow(row, existing);
});
existingRows.forEach(item => {
if (!this.isRowLocked(item)) return;
if (this.findExistingRow(item, nextRows)) return;
nextRows.unshift({ ...item });
});
this.update(nextRows);
},
handleUploadSuccess(file) {
const url = attachmentUrl(file);
if (!file || !url) return;
if (this.findExistingRow(file)) return;
this.update([...(this.rows || []), this.enrichUploadedRow(file)]);
},
remove(index) {
const row = this.rows[index];
if (this.isRowLocked(row)) {
this.$message.warning('已审核通过的附件不允许删除');
return;
}
const rows = [...(this.rows || [])];
rows.splice(index, 1);
this.update(rows);
},
download(row) {
const url = attachmentUrl(row);
if (!url) {
this.$message.warning('附件地址为空,无法下载');
return;
}
downloadFileByUrl(url, attachmentName(row));
},
batchDownload() {
const sourceRows = this.selected.length ? this.selected : this.rows || [];
const downloadableRows = sourceRows.filter(row => attachmentUrl(row));
if (!downloadableRows.length) {
this.$message.warning(this.selected.length ? '所选附件暂无可下载地址' : '暂无可下载附件');
return;
}
downloadableRows.forEach((row, index) => {
window.setTimeout(() => this.download(row), index * 400);
});
},
formatSize(size) {
const value = Number(size);
if (!value) return '';
if (value < 1024) return `${value}B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`;
return `${(value / 1024 / 1024).toFixed(1)}MB`;
},
},
};
</script>
<style lang="scss" scoped>
.contract-manage-form__section {
margin: 12px 0 0;
padding: 14px 16px 16px;
overflow: visible;
background: #fff;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
.dialog-section-title {
margin-bottom: 20px;
color: #303133;
font-size: 16px;
font-weight: 600;
&::before {
display: inline-block;
width: 4px;
height: 16px;
margin-right: 8px;
vertical-align: -2px;
background: #409eff;
content: '';
}
}
}
.contract-manage-form__attachment-table {
:deep(.el-table__body-wrapper),
:deep(.el-table__header-wrapper),
:deep(.el-table__cell),
:deep(.cell) {
overflow: visible;
}
}
.contract-manage-form__attachment-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.dialog-section-title {
margin-bottom: 0;
}
}
.contract-manage-form__attachment-upload {
display: flex;
align-items: center;
margin-top: 12px;
}
.contract-manage-form__attachment-tip {
margin-left: 30px;
color: #909399;
font-size: 13px;
line-height: 1.4;
}
.contract-attachment-upload-dialog {
&__body {
padding: 4px 8px 0;
}
&__field {
display: flex;
align-items: center;
margin-bottom: 16px;
}
&__label {
flex: none;
width: 72px;
color: #606266;
text-align: right;
margin-right: 12px;
}
&__uploader {
margin-top: 8px;
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
height: 180px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
}
&__footer {
display: flex;
justify-content: center;
gap: 12px;
}
}
</style>