Files
tms-erp-web/src/views/business/voucher-manage.vue
T
2026-09-10 22:50:18 +08:00

1528 lines
51 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>
<basic-container class="voucher-manage-page">
<section class="voucher-manage-page__search">
<el-form :model="query" label-position="right" label-width="88px">
<div class="voucher-manage-page__search-grid">
<el-form-item label="凭证批次号"
><el-input v-model="query.voucherBatchNo" clearable placeholder="请输入"
/></el-form-item>
<el-form-item label="审核状态"
><el-select v-model="query.auditStatus" clearable placeholder="全部"
><el-option label="待审核" value="待审核" /><el-option
label="审核通过"
value="审核通过" /><el-option label="审核驳回" value="审核驳回" /></el-select
></el-form-item>
<el-form-item label="处理状态"
><el-select v-model="query.processStatus" clearable placeholder="全部"
><el-option label="上传中" value="上传中" /><el-option
label="处理中"
value="处理中" /><el-option label="处理完成" value="处理完成" /></el-select
></el-form-item>
<el-form-item label="运单批次号"
><el-input v-model="query.waybillBatchNo" clearable placeholder="请输入"
/></el-form-item>
<template v-if="searchExpanded">
<el-form-item v-if="!isCarrier" label="上传来源"
><el-select v-model="query.uploadSource" clearable placeholder="全部"
><el-option label="内部" value="内部" /><el-option
label="承运商"
value="承运商" /></el-select
></el-form-item>
<el-form-item v-if="!isCarrier" label="承运商"
><el-input v-model="query.carrierName" clearable placeholder="请输入"
/></el-form-item>
<el-form-item label="创建日期"
><el-date-picker
v-model="createRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="-"
start-placeholder="请选择"
end-placeholder="请选择"
/></el-form-item>
</template>
<div class="voucher-manage-page__search-actions">
<el-button type="primary" @click="search">查询</el-button
><el-button @click="reset">重置</el-button
><el-link type="primary" @click="searchExpanded = !searchExpanded">{{
searchExpanded ? '收起' : '展开'
}}</el-link>
</div>
</div>
</el-form>
</section>
<section>
<div class="voucher-manage-page__toolbar">
<el-button type="primary" @click="openUpload">批量导入凭证</el-button
><el-button plain @click="openProgress">上传进度</el-button>
</div>
<el-table v-loading="loading" :data="rows" border class="voucher-manage-page__table">
<el-table-column type="selection" width="52" fixed="left" align="center" /><el-table-column
type="index"
label="序号"
width="58"
fixed="left"
align="center"
/>
<el-table-column prop="voucherBatchNo" label="凭证批次号" min-width="150" fixed="left"
><template #default="{ row }"
><el-link class="voucher-manage-page__link" type="primary" @click="view(row)">{{
row.voucherBatchNo
}}</el-link></template
></el-table-column
>
<el-table-column prop="waybillBatchNo" label="运单批次号" min-width="170" /><el-table-column
prop="fileName"
label="文件名"
min-width="200"
show-overflow-tooltip
/>
<el-table-column v-if="!isCarrier" prop="uploadSource" label="上传来源" width="100" /><el-table-column
v-if="!isCarrier"
prop="carrierName"
label="承运商名称"
min-width="160"
><template #default="{ row }">{{ row.carrierName || '-' }}</template></el-table-column
>
<el-table-column prop="processStatus" label="处理状态" width="120" /><el-table-column
prop="voucherCount"
label="凭证数量"
width="100"
/><el-table-column
prop="relatedWaybillCount"
label="凭证已关联数量"
width="125"
/><el-table-column prop="unRelatedWaybillCount" label="凭证未关联数量" width="125" />
<el-table-column prop="createTime" label="创建时间" min-width="170" />
<el-table-column
prop="auditStatus"
label="审核状态"
width="120"
fixed="right"
align="center"
><template #default="{ row }"
><span class="voucher-manage-page__audit-status"
><span>{{ row.auditStatus }}</span
><el-tooltip
v-if="row.auditStatus === '审核驳回' && hasRejectReason(row)"
:content="row.rejectReason"
placement="top"
><el-icon class="voucher-manage-page__audit-reject-icon"><WarnTriangleFilled /></el-icon
></el-tooltip></span></template
></el-table-column><el-table-column label="操作" width="320" fixed="right" align="center"
><template #default="{ row }"
><div class="voucher-manage-page__actions">
<el-link
v-if="!isCarrier && row.processStatus === '处理完成' && row.auditStatus === '待审核' && hasPermission('vouchermanage_audit')"
type="primary"
@click="handleAuditPass(row)"
>审核通过</el-link
><el-link
v-if="!isCarrier && row.processStatus === '处理完成' && row.auditStatus === '待审核' && hasPermission('vouchermanage_audit')"
type="warning"
@click="handleAuditReject(row)"
>审核驳回</el-link
><el-link v-if="row.processStatus === '处理完成'" type="primary" @click="view(row)"
>查看</el-link
><el-link
v-if="row.processStatus === '处理完成'"
type="primary"
@click="download(row)"
>下载</el-link
><el-link
v-if="row.auditStatus === '审核驳回'"
type="primary"
@click="openUpload(row, 'reupload')"
>重新上传</el-link
><el-link
v-if="row.auditStatus === '审核驳回'"
type="primary"
@click="openBatchDialog(row)"
>更换运单批次</el-link
><el-link
v-if="(row.processStatus !== '处理完成' || row.auditStatus === '待审核') && row.auditStatus !== '审核驳回'"
type="primary"
@click="openBatchDialog(row)"
>更换运单批次</el-link
><el-link
v-if="row.processStatus === '上传中' || row.auditStatus === '审核驳回'"
type="danger"
@click="removeRow(row)"
>删除</el-link
>
</div></template
></el-table-column
>
</el-table>
<div class="voucher-manage-page__pagination">
<el-pagination
v-model:current-page="page.current"
v-model:page-size="page.size"
:total="page.total"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next, sizes"
@current-change="load"
@size-change="load"
/>
</div>
</section>
</basic-container>
<el-dialog
v-model="detailVisible"
title="执行凭证详情"
width="92%"
top="5vh"
class="voucher-detail-dialog"
destroy-on-close
>
<div v-loading="detailLoading" class="voucher-manage-page__detail">
<div class="voucher-manage-page__detail-summary">
<div class="voucher-manage-page__detail-field">
<span class="voucher-manage-page__detail-label">执行凭证号</span>
<span class="voucher-manage-page__detail-value">{{ detailVoucherNo }}</span>
</div>
<div class="voucher-manage-page__detail-field">
<span class="voucher-manage-page__detail-label">文件夹名称</span>
<span class="voucher-manage-page__detail-value">{{ detailFolderName }}</span>
</div>
<div class="voucher-manage-page__detail-field">
<span class="voucher-manage-page__detail-label">是否关联运单</span>
<span class="voucher-manage-page__detail-value">{{ detailRelatedWaybill }}</span>
</div>
<div class="voucher-manage-page__detail-field">
<span class="voucher-manage-page__detail-label">配载单号</span>
<el-link
v-if="detailLoadingNo !== '-'"
class="voucher-manage-page__detail-value voucher-manage-page__detail-link"
type="primary"
>
{{ detailLoadingNo }}
</el-link>
<span v-else class="voucher-manage-page__detail-value">-</span>
</div>
</div>
<div v-if="detailImages.length" class="voucher-manage-page__detail-images">
<el-image
v-for="(image, index) in detailImages"
:key="image.url || index"
class="voucher-manage-page__detail-image"
:src="image.url"
:preview-src-list="detailImageUrls"
:initial-index="index"
fit="cover"
preview-teleported
/>
</div>
<div v-if="detailOtherFiles.length" class="voucher-manage-page__detail-files">
<div v-for="file in detailOtherFiles" :key="file.id || file.url || file.name" class="voucher-manage-page__detail-file">
<span class="voucher-manage-page__detail-file-name">{{ file.name }}</span>
<el-link v-if="file.url" type="primary" @click="openDetailFile(file)">查看</el-link>
</div>
</div>
<el-empty v-if="!detailImages.length && !detailOtherFiles.length" description="暂无凭证文件" :image-size="80" />
</div>
</el-dialog>
<el-dialog
v-model="uploadVisible"
:title="editing.id ? (uploadMode === 'reupload' ? '重新上传' : '更换运单批次') : '批量导入凭证'"
width="1200px"
destroy-on-close
:before-close="beforeUploadDialogClose"
>
<section-card>
<el-form
ref="uploadFormRef"
:model="editing"
:rules="rules"
label-position="right"
label-width="auto"
class="voucher-manage-page__upload-form"
>
<el-form-item v-if="!isCarrier" label="项目名称" prop="projectId"
><el-select
v-model="editing.projectId"
class="voucher-manage-page__project-select"
clearable
filterable
placeholder="请选择"
@change="changeProject"
><el-option
v-for="item in projectOptions"
:key="item.id"
:label="item.projectName"
:value="item.id" /></el-select
></el-form-item>
<el-form-item label="执行凭证" required
><el-upload
action="#"
accept=".zip,.7z"
:auto-upload="false"
:limit="1"
:on-change="selectVoucherFile"
:on-remove="clearFile"
><el-button
type="primary"
:loading="uploading"
:disabled="!editing.projectId || uploading"
>上传</el-button
><template #tip
><span class="voucher-manage-page__upload-tip"
>请上传车辆的执行凭证压缩包支持zip7z格式 上传附件大于50M时为超大附件确认提交后可在批量补录->上传任务中查看上传进度</span
></template
></el-upload
><el-progress
v-if="uploading || editing.fileTaskId"
:percentage="uploadPercent"
:status="uploadPercent === 100 ? 'success' : undefined"
class="voucher-manage-page__upload-progress"
/></el-form-item>
<el-form-item label="关联运输批次" prop="waybillImportBatchIds"
><el-button type="primary" @click="openBatchDialog()">选择</el-button></el-form-item
>
</el-form>
</section-card>
<el-table :data="selectedBatches" border
><el-table-column type="index" label="序号" width="80" /><el-table-column
prop="batchNo"
label="运输批次号"
min-width="220"
/><el-table-column prop="createTime" label="创建时间" min-width="170" /><el-table-column
prop="waybillCount"
label="运单数"
width="120"
/><el-table-column prop="createUserName" label="创建人" min-width="140" /><el-table-column
label="操作"
width="100"
><template #default="{ row }"
><el-link type="danger" @click="removeBatch(row)">删除</el-link></template
></el-table-column
></el-table
>
<template #footer
><el-button @click="closeUploadDialog">取消</el-button
><el-button type="primary" :loading="saving" @click="submitUpload"
>确认上传</el-button
></template
>
</el-dialog>
<el-dialog v-model="batchVisible" title="关联运单批次" width="1200px" destroy-on-close>
<el-form
:model="batchQuery"
label-position="right"
label-width="88px"
class="voucher-manage-page__batch-search"
><el-row :gutter="20"
><el-col :span="12"
><el-form-item label="运单批次号"
><el-input
v-model="batchQuery.batchNo"
clearable
placeholder="请输入" /></el-form-item></el-col
><el-col :span="12"
><el-form-item label="创建日期"
><el-date-picker
v-model="batchCreateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="-" /></el-form-item></el-col
><el-col :span="12"
><el-form-item label="创建人"
><el-input
v-model="batchQuery.createUser"
clearable
placeholder="请输入" /></el-form-item></el-col
><el-col :span="12"
><el-form-item label="运单数"
><el-input
v-model="batchQuery.waybillCount"
clearable
placeholder="请输入"
@input="handleWaybillCountInput" /></el-form-item></el-col
></el-row>
<div class="voucher-manage-page__search-actions">
<el-button type="primary" @click="loadBatches">查询</el-button
><el-button @click="resetBatchQuery">重置</el-button>
</div></el-form
>
<el-table
ref="batchTableRef"
:data="batchRows"
border
highlight-current-row
@current-change="batchSelection = $event ? [$event] : []"
><el-table-column
type="index"
label="序号"
width="80" /><el-table-column
prop="batchNo"
label="运单批次号"
min-width="240" /><el-table-column
prop="createTime"
label="创建时间"
min-width="180" /><el-table-column label="运单数" width="120"
><template #default="{ row }">{{ row.waybillCount ?? '-' }}</template></el-table-column
><el-table-column prop="createUserName" label="创建人" min-width="140" /><el-table-column
label="操作"
width="100"
align="center"
><template #default="{ row }"
><el-link type="primary" @click="selectBatch(row)">选择</el-link></template
></el-table-column
/></el-table>
<div class="voucher-manage-page__pagination">
<el-pagination
v-model:current-page="batchPage.current"
v-model:page-size="batchPage.size"
:total="batchPage.total"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next, sizes"
@current-change="loadBatches"
@size-change="loadBatches"
/>
</div>
<template #footer
><el-button @click="batchVisible = false">取消</el-button
><el-button type="primary" :loading="batchSaving" @click="confirmBatches">确认</el-button></template
>
</el-dialog>
<el-dialog
v-model="progressVisible"
title="上传进度"
width="1200px"
destroy-on-close
:before-close="beforeProgressDialogClose"
@opened="loadProgress"
>
<section-card class="voucher-manage-page__progress-search">
<el-form :model="progressQuery" label-position="right" label-width="88px"
><div class="voucher-manage-page__search-grid">
<el-form-item label="凭证批次号"
><el-input
v-model="progressQuery.businessId"
clearable
placeholder="请输入" /></el-form-item
><el-form-item label="文件名称"
><el-input
v-model="progressQuery.attachmentName"
clearable
placeholder="请输入" /></el-form-item
><el-form-item label="上传状态"
><el-select v-model="progressQuery.status" clearable placeholder="全部"
><el-option label="正在上传" value="uploading" /><el-option
label="已暂停"
value="paused" /><el-option label="上传完成" value="completed" /><el-option
label="上传失败"
value="failed" /></el-select
></el-form-item>
<div class="voucher-manage-page__search-actions">
<el-button type="primary" @click="searchProgress">查询</el-button
><el-button @click="resetProgress">重置</el-button>
</div>
</div></el-form
>
</section-card>
<el-table :data="progressRows" border class="voucher-manage-page__table"
><el-table-column type="selection" width="52" /><el-table-column
type="index"
label="序号"
width="58"
/><el-table-column prop="businessId" label="凭证批次号" min-width="160" /><el-table-column
prop="attachmentName"
label="文件名称"
min-width="250"
show-overflow-tooltip
/><el-table-column prop="suffix" label="文件类型" width="100" /><el-table-column
label="文件大小"
width="120"
><template #default="{ row }">{{ formatSize(row.size) }}</template></el-table-column
><el-table-column prop="createUserName" label="上传人" width="120" /><el-table-column
prop="createTime"
label="上传时间"
min-width="170"
/><el-table-column label="上传进度" min-width="220"
><template #default="{ row }"
><div class="voucher-manage-page__progress-cell">
<el-progress
:percentage="taskPercent(row)"
:stroke-width="10"
:show-text="false"
color="#409eff"
/><span class="voucher-manage-page__progress-value">{{ taskPercent(row) }}%</span>
</div></template
></el-table-column
><el-table-column label="上传状态" width="120"
><template #default="{ row }">{{ taskStatusName(row.status) }}</template></el-table-column
><el-table-column label="操作" width="180" fixed="right"
><template #default="{ row }"
><el-link
v-if="row.status === 'paused' || row.status === 'failed'"
type="primary"
@click="resumeTask(row)"
>继续上传</el-link
><el-link v-if="row.status === 'uploading'" type="danger" @click="pauseTask(row)"
>取消</el-link
></template
></el-table-column
></el-table
>
<div class="voucher-manage-page__pagination">
<el-pagination
v-model:current-page="progressPage.current"
v-model:page-size="progressPage.size"
:total="progressPage.total"
:page-sizes="[10, 20, 50]"
layout="total, prev, pager, next, sizes"
@current-change="loadProgress"
@size-change="loadProgress"
/>
</div>
</el-dialog>
<input
ref="resumeFileInput"
class="voucher-manage-page__hidden-file"
type="file"
accept=".zip,.7z"
@change="resumeFileSelected"
/>
</template>
<script setup>
import { computed, reactive, ref, watch, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import md5 from 'js-md5';
import { ElMessage, ElMessageBox } from 'element-plus';
import { WarnTriangleFilled } from '@element-plus/icons-vue';
import { getList as getProjectList } from '@/api/business/project-apply';
import * as api from '@/api/business/voucher-manage';
import SectionCard from '@/components/section-card/main.vue';
const router = useRouter();
const store = useStore();
// 权限判断
const permission = computed(() => store.getters.permission);
const userInfo = computed(() => store.getters.userInfo);
const isAdmin = computed(() => userInfo.value?.authority?.includes('admin'));
const hasPermission = code => {
if (isAdmin.value) return true;
return permission.value?.[code] === true;
};
const hasRejectReason = row => {
const reason = row?.rejectReason;
return typeof reason === 'string'
? reason.trim().length > 0
: reason !== undefined && reason !== null;
};
// 判断当前用户是否为承运商(顶级组织是否为"外部组织")
const isCarrier = ref(false);
const checkIfCarrier = () => {
const userInfoData = userInfo.value || {};
const topDeptName = userInfoData.top_dept_name || userInfoData.topDeptName || '';
isCarrier.value = topDeptName === '外部组织';
};
onMounted(() => {
checkIfCarrier();
});
const loading = ref(false),
rows = ref([]),
createRange = ref([]),
searchExpanded = ref(false),
detailVisible = ref(false),
detailLoading = ref(false),
detailRow = ref({}),
uploadVisible = ref(false),
batchVisible = ref(false),
batchDialogMode = ref('upload'),
batchTarget = ref(),
batchSaving = ref(false),
progressVisible = ref(false),
uploadFormRef = ref(),
uploading = ref(false),
saving = ref(false),
uploadPercent = ref(0),
resumeFileInput = ref(),
resumeTarget = ref(),
uploadAbortController = ref(),
uploadSession = ref(0),
closingUploadDialog = ref(false),
closingProgressDialog = ref(false),
cancelledTaskIds = ref(new Set()),
uploadCancelled = ref(false),
activeUploadTaskId = ref(),
selectedVoucherFile = ref(),
uploadMode = ref('');
const query = reactive({
voucherBatchNo: '',
auditStatus: '',
processStatus: '',
waybillBatchNo: '',
uploadSource: '',
carrierName: '',
});
const page = reactive({ current: 1, size: 10, total: 0 });
const editing = reactive({
id: '',
voucherBatchNo: '',
projectId: '',
projectName: '',
fileName: '',
fileUrl: '',
fileTaskId: '',
waybillImportBatchIds: [],
});
const projectOptions = ref([]);
const selectedBatches = ref([]),
batchRows = ref([]),
batchSelection = ref([]),
batchCreateRange = ref([]);
const batchQuery = reactive({ batchNo: '', createUser: '', waybillCount: undefined });
const batchPage = reactive({ current: 1, size: 10, total: 0 });
const progressQuery = reactive({ businessId: '', attachmentName: '', status: '' });
const progressPage = reactive({ current: 1, size: 10, total: 0 });
const progressRows = ref([]);
const rules = {
projectId: [{ required: true, message: '请选择项目名称' }],
waybillImportBatchIds: [{ required: true, message: '请关联运输批次' }],
};
const load = async () => {
loading.value = true;
try {
const res = await api.getList(page.current, page.size, {
...query,
createTimeStart: createRange.value?.[0] ? `${createRange.value[0]} 00:00:00` : '',
createTimeEnd: createRange.value?.[1] ? `${createRange.value[1]} 23:59:59` : '',
});
const data = res.data?.data || {};
rows.value = data.records || [];
page.total = data.total || 0;
} finally {
loading.value = false;
}
};
const search = () => {
page.current = 1;
load();
};
const reset = () => {
Object.assign(query, {
voucherBatchNo: '',
auditStatus: '',
processStatus: '',
waybillBatchNo: '',
uploadSource: '',
carrierName: '',
});
createRange.value = [];
search();
};
const loadProjects = async () => {
const res = await getProjectList(1, 9999, { temporaryCreditLimitSelectable: true });
projectOptions.value = res.data?.data?.records || [];
};
const changeProject = projectId => {
editing.projectName = projectOptions.value.find(item => item.id === projectId)?.projectName || '';
};
const openUpload = async (row, mode = '') => {
await loadProjects();
selectedVoucherFile.value = undefined;
uploadMode.value = mode;
Object.assign(editing, {
id: row?.id || '',
voucherBatchNo: row?.voucherBatchNo || '',
projectId: row?.projectId || '',
projectName: row?.projectName || '',
fileName: row?.fileName || '',
fileUrl: row?.fileUrl || '',
fileTaskId: row?.fileTaskId || '',
waybillImportBatchIds: [],
});
if (mode === 'reupload') Object.assign(editing, { fileName: '', fileUrl: '', fileTaskId: '' });
uploadPercent.value = 0;
selectedBatches.value = [];
uploadVisible.value = true;
if (row?.waybillBatchNo) {
await loadBatches();
selectedBatches.value = batchRows.value.filter(item =>
row.waybillBatchNo.split(',').includes(item.batchNo)
);
editing.waybillImportBatchIds = selectedBatches.value.map(item => item.id);
}
};
const clearFile = () => {
selectedVoucherFile.value = undefined;
Object.assign(editing, { fileName: '', fileUrl: '', fileTaskId: '' });
};
const isCurrentUploadTask = taskId =>
taskId && activeUploadTaskId.value && String(taskId) === String(activeUploadTaskId.value);
const stopCurrentUpload = async taskId => {
// 先使整个上传会话失效,阻止 MD5 计算、创建任务等异步流程返回后继续上传。
uploadCancelled.value = true;
uploadSession.value += 1;
uploadAbortController.value?.abort();
const currentTaskId = taskId || activeUploadTaskId.value || editing.fileTaskId;
if (!currentTaskId) return;
cancelledTaskIds.value.add(String(currentTaskId));
if (isCurrentUploadTask(currentTaskId)) {
uploading.value = false;
activeUploadTaskId.value = undefined;
}
try {
const taskRes = await api.getFileTask(currentTaskId);
if (taskRes.data?.data?.status === 'completed') return;
await api.pauseFileTask([currentTaskId]);
} catch (error) {
ElMessage.error(error.message || '暂停上传失败');
}
};
const closeUploadDialog = async (pauseUpload = true) => {
if (closingUploadDialog.value) return;
closingUploadDialog.value = true;
try {
if (pauseUpload) await stopCurrentUpload(editing.fileTaskId);
uploadVisible.value = false;
} finally {
closingUploadDialog.value = false;
}
};
const beforeUploadDialogClose = async done => {
if (closingUploadDialog.value) {
done();
return;
}
closingUploadDialog.value = true;
try {
await stopCurrentUpload(editing.fileTaskId);
done();
} finally {
closingUploadDialog.value = false;
}
};
const beforeProgressDialogClose = async done => {
if (closingProgressDialog.value) return;
closingProgressDialog.value = true;
try {
if (uploading.value || activeUploadTaskId.value) {
await stopCurrentUpload(activeUploadTaskId.value);
}
done();
} finally {
closingProgressDialog.value = false;
}
};
const ensureUploadSession = (sessionId, taskId) => {
if (
uploadCancelled.value ||
sessionId !== uploadSession.value ||
(taskId && cancelledTaskIds.value.has(String(taskId)))
) {
throw new DOMException('上传已暂停', 'AbortError');
}
};
const calculateMd5 = async file => {
const buffer = await file.arrayBuffer();
return md5.base64(buffer);
};
const uploadParts = async (file, task, sessionId) => {
uploading.value = true;
activeUploadTaskId.value = task.id;
const abortController = new AbortController();
uploadAbortController.value = abortController;
const chunkSize = task.chunkSize;
try {
for (
let partNumber = Math.max(1, (task.currentIndex || 0) + 1);
partNumber <= task.chunkTotal;
partNumber += 1
) {
ensureUploadSession(sessionId, task.id);
if (abortController.signal.aborted) throw new DOMException('上传已暂停', 'AbortError');
const urlRes = await api.getPartUploadUrl(task.id, partNumber);
ensureUploadSession(sessionId, task.id);
if (abortController.signal.aborted) throw new DOMException('上传已暂停', 'AbortError');
const url = urlRes.data?.data;
const part = file.slice(
(partNumber - 1) * chunkSize,
Math.min(partNumber * chunkSize, file.size)
);
const response = await fetch(url, {
method: 'PUT',
body: part,
signal: abortController.signal,
});
if (!response.ok) throw new Error(`分片${partNumber}上传失败`);
const etag = String(response.headers.get('etag') || '').replaceAll('"', '');
if (!etag) throw new Error('MinIO 未返回分片 ETag,请确认跨域配置已暴露 ETag 响应头');
await api.updateFileTask({ id: task.id, partNumber, etag });
ensureUploadSession(sessionId, task.id);
if (abortController.signal.aborted) throw new DOMException('上传已暂停', 'AbortError');
uploadPercent.value = Math.round((partNumber / task.chunkTotal) * 100);
if (progressVisible.value) await loadProgress();
}
ensureUploadSession(sessionId, task.id);
const fileUrlRes = await api.getFileTaskUrl(task.id);
ensureUploadSession(sessionId, task.id);
editing.fileName = file.name;
editing.fileTaskId = task.id;
editing.fileUrl = fileUrlRes.data?.data || '';
if (!editing.fileUrl) throw new Error('未获取到上传文件地址,请稍后重试');
await api.completeUploadFile({
voucherBatchNo: task.businessId,
fileTaskId: task.id,
fileName: file.name,
fileUrl: editing.fileUrl,
});
ElMessage.success('执行凭证上传完成');
} catch (error) {
if (error.name !== 'AbortError')
ElMessage.error(error.message || '上传中断,可在上传进度中继续上传');
throw error;
} finally {
uploading.value = false;
if (uploadAbortController.value === abortController) uploadAbortController.value = undefined;
if (activeUploadTaskId.value === task.id) activeUploadTaskId.value = undefined;
if (progressVisible.value) await loadProgress();
}
};
const ensureUploadDraft = async fileName => {
if (editing.id) return;
if (!editing.projectId) throw new Error('请先选择项目名称');
const res = await api.createUploadDraft({ projectId: editing.projectId, fileName });
const draft = res.data?.data;
if (!draft?.id || !draft?.voucherBatchNo) throw new Error('创建凭证上传记录失败');
Object.assign(editing, {
id: draft.id,
voucherBatchNo: draft.voucherBatchNo,
projectName: draft.projectName,
});
};
const uploadVoucher = async file => {
const raw = file.raw;
if (!raw) return;
uploadCancelled.value = false;
const sessionId = uploadSession.value + 1;
uploadSession.value = sessionId;
try {
if (!resumeTarget.value) await ensureUploadDraft(raw.name);
ensureUploadSession(sessionId);
const md5 = await calculateMd5(raw);
ensureUploadSession(sessionId);
const resumeTask = resumeTarget.value;
let task;
if (resumeTask) {
if (
md5 !== resumeTask.md5 ||
raw.name !== resumeTask.attachmentName ||
raw.size !== Number(resumeTask.size)
) {
ElMessage.error('请选择与上传任务相同的文件');
return;
}
const taskRes = await api.getFileTask(resumeTask.id);
ensureUploadSession(sessionId);
task = taskRes.data?.data;
if (!task || String(task.id) !== String(resumeTask.id)) {
ElMessage.error('上传任务不存在或已失效');
return;
}
await api.resumeFileTask(task.id);
ensureUploadSession(sessionId);
task.status = 'uploading';
if (progressVisible.value) await loadProgress();
} else {
const existingRes = await api.queryFileTask(md5);
ensureUploadSession(sessionId);
task = existingRes.data?.data;
}
const chunkSize = 5 * 1024 * 1024;
if (
!task ||
task.status === 'completed' ||
(!resumeTask && task.businessId !== editing.voucherBatchNo)
) {
const createRes = await api.createFileTask({
md5,
attachmentName: raw.name,
size: raw.size,
chunkSize,
chunkTotal: Math.ceil(raw.size / chunkSize),
force: !!task,
businessType: 'voucher',
businessId: editing.voucherBatchNo,
});
ensureUploadSession(sessionId);
task = createRes.data?.data;
}
editing.fileName = raw.name;
editing.fileTaskId = task.id;
activeUploadTaskId.value = task.id;
cancelledTaskIds.value.delete(String(task.id));
resumeTarget.value = undefined;
await uploadParts(raw, task, sessionId);
return Boolean(editing.fileUrl);
} catch (error) {
if (error.name !== 'AbortError')
ElMessage.error(error.message || '上传中断,可在上传进度中继续上传');
return false;
}
};
const selectVoucherFile = file => {
if (!file?.raw) return;
selectedVoucherFile.value = file.raw;
editing.fileName = file.raw.name;
editing.fileUrl = '';
editing.fileTaskId = '';
};
watch(uploadVisible, visible => {
// 兜底处理:右上角、遮罩、Esc 直接修改 v-model 时仍必须停止上传。
if (!visible && !closingUploadDialog.value) void stopCurrentUpload(editing.fileTaskId);
});
const openBatchDialog = async row => {
// 按钮 @click 会传入 MouseEvent,不能把事件对象当成凭证行。
const voucher = row instanceof Event ? undefined : row;
batchDialogMode.value = voucher?.id ? 'change' : 'upload';
batchTarget.value = voucher?.id ? voucher : undefined;
batchSelection.value = [];
batchVisible.value = true;
await loadBatches();
if (voucher?.waybillBatchNo) {
const currentBatch = batchRows.value.find(item => item.batchNo === voucher.waybillBatchNo);
if (currentBatch) batchSelection.value = [currentBatch];
}
};
const loadBatches = async () => {
const res = await api.getWaybillBatches(batchPage.current, batchPage.size, {
batchNo: batchQuery.batchNo,
createUser: batchQuery.createUser,
waybillCount: batchQuery.waybillCount,
createTimeStart: batchCreateRange.value?.[0] ? `${batchCreateRange.value[0]} 00:00:00` : '',
createTimeEnd: batchCreateRange.value?.[1] ? `${batchCreateRange.value[1]} 23:59:59` : '',
});
const data = res.data?.data || {};
batchRows.value = data.records || [];
batchPage.total = data.total || 0;
};
const handleWaybillCountInput = value => {
// 只允许输入正整数
const filtered = value.replace(/[^\d]/g, '');
if (filtered !== value) {
batchQuery.waybillCount = filtered;
}
// 移除前导零
if (filtered.length > 1 && filtered.startsWith('0')) {
batchQuery.waybillCount = filtered.replace(/^0+/, '');
}
};
const resetBatchQuery = () => {
Object.assign(batchQuery, { batchNo: '', createUser: '', waybillCount: undefined });
batchCreateRange.value = [];
batchPage.current = 1;
loadBatches();
};
const updateWaybillBatch = async (target, selected) => {
if (!target?.id) {
ElMessage.error('缺少凭证批次信息');
return false;
}
if (!selected?.batchNo) {
ElMessage.warning('请选择运单批次');
return false;
}
batchSaving.value = true;
try {
await api.changeWaybillBatch({
voucherId: target.id,
waybillImportBatchIds: [selected.id],
});
target.waybillBatchNo = selected.batchNo;
batchVisible.value = false;
ElMessage.success('运单批次更换成功,已提交重新匹配任务');
await load();
return true;
} catch (error) {
ElMessage.error(error.message || '运单批次更换失败');
return false;
} finally {
batchSaving.value = false;
}
};
const selectBatch = async row => {
if (batchDialogMode.value === 'change') {
batchSelection.value = [row];
await updateWaybillBatch(batchTarget.value, row);
return;
}
const batches = new Map();
selectedBatches.value.forEach(item => {
if (!batches.has(item.batchNo)) batches.set(item.batchNo, item);
});
if (!batches.has(row.batchNo)) batches.set(row.batchNo, row);
selectedBatches.value = [...batches.values()];
editing.waybillImportBatchIds = selectedBatches.value.map(item => item.id);
batchVisible.value = false;
};
const confirmBatches = async () => {
if (batchDialogMode.value === 'change') {
const selected = batchSelection.value[0];
await updateWaybillBatch(batchTarget.value, selected);
return;
}
const batches = new Map();
batchSelection.value.forEach(item => {
if (!batches.has(item.batchNo)) batches.set(item.batchNo, item);
});
selectedBatches.value = [...batches.values()];
editing.waybillImportBatchIds = selectedBatches.value.map(item => item.id);
batchVisible.value = false;
};
const removeBatch = row => {
selectedBatches.value = selectedBatches.value.filter(item => item.id !== row.id);
editing.waybillImportBatchIds = selectedBatches.value.map(item => item.id);
};
const submitUpload = async () => {
saving.value = true;
try {
const valid = await uploadFormRef.value.validate().catch(() => false);
if (!valid) return;
if (!editing.fileUrl) {
if (!selectedVoucherFile.value) {
ElMessage.warning('请先选择执行凭证');
return;
}
const uploaded = await uploadVoucher({ raw: selectedVoucherFile.value });
if (!uploaded) return;
selectedVoucherFile.value = undefined;
}
await api.submit({
...editing,
waybillImportBatchIds: selectedBatches.value.map(item => item.id),
});
ElMessage.success('提交成功');
await closeUploadDialog(false);
load();
} finally {
saving.value = false;
}
};
const openProgress = () => {
progressVisible.value = true;
};
const loadProgress = async () => {
const res = await api.getFileTaskList(progressPage.current, progressPage.size, {
...progressQuery,
businessType: 'voucher',
});
const data = res.data?.data || {};
progressRows.value = (data.records || []).slice().sort((left, right) => {
const leftTime = left.createTime ? new Date(left.createTime).getTime() : 0;
const rightTime = right.createTime ? new Date(right.createTime).getTime() : 0;
if (rightTime !== leftTime) return rightTime - leftTime;
return String(right.id || '').localeCompare(String(left.id || ''));
});
progressPage.total = data.total || 0;
};
const searchProgress = () => {
progressPage.current = 1;
loadProgress();
};
const resetProgress = () => {
Object.assign(progressQuery, { businessId: '', attachmentName: '', status: '' });
searchProgress();
};
const taskPercent = row =>
row.chunkTotal ? Math.round(((row.currentIndex || 0) / row.chunkTotal) * 100) : 0;
const taskStatusName = status =>
({ uploading: '正在上传', paused: '已暂停', completed: '上传完成', failed: '上传失败' }[status] ||
status);
const formatSize = size =>
size >= 1024 ** 3 ? `${(size / 1024 ** 3).toFixed(1)}G` : `${(size / 1024 ** 2).toFixed(1)}M`;
const pauseTask = async row => {
if (row.status === 'completed') return;
if (isCurrentUploadTask(row.id)) {
await stopCurrentUpload(row.id);
} else {
await api.pauseFileTask([row.id]);
cancelledTaskIds.value.add(String(row.id));
}
ElMessage.success('已取消上传');
loadProgress();
};
const resumeTask = row => {
resumeTarget.value = row;
activeUploadTaskId.value = row.id;
resumeFileInput.value?.click();
};
const resumeFileSelected = event => {
const [raw] = event.target.files || [];
if (raw) uploadVoucher({ raw });
event.target.value = '';
};
const removeRow = row =>
ElMessageBox.confirm(`确认删除凭证批次“${row.voucherBatchNo}”吗?`, '提示', {
type: 'warning',
}).then(async () => {
await api.remove(row.id);
ElMessage.success('删除成功');
load();
});
const detailValue = value =>
value === undefined || value === null || value === '' ? '-' : value;
const firstDetailValue = (row, keys) => {
for (const key of keys) {
const value = row?.[key];
if (value !== undefined && value !== null && value !== '') return value;
}
return undefined;
};
const detailVoucherNo = computed(() =>
detailValue(
firstDetailValue(detailRow.value, ['voucherNo', 'executeVoucherNo', 'voucherBatchNo'])
)
);
const detailFolderName = computed(() =>
detailValue(
firstDetailValue(detailRow.value, [
'folderName',
'directoryName',
'sourceFolderName',
'voucherFolderName',
'folder',
'fileName',
])
)
);
const detailRelatedWaybill = computed(() => {
const value = firstDetailValue(detailRow.value, [
'isRelatedWaybill',
'isRelateWaybill',
'isRelationWaybill',
'relatedWaybill',
'relatedWaybillFlag',
'hasRelatedWaybill',
]);
if (typeof value === 'boolean') return value ? '是' : '否';
if (typeof value === 'number') return value > 0 ? '是' : '否';
if (typeof value === 'string' && value.trim()) {
if (['1', 'true', 'yes', '是'].includes(value.trim().toLowerCase())) return '是';
if (['0', 'false', 'no', '否'].includes(value.trim().toLowerCase())) return '否';
return value;
}
const count = firstDetailValue(detailRow.value, ['relatedWaybillCount', 'waybillCount']);
if (count !== undefined && count !== null && count !== '') {
return Number(count) > 0 ? '是' : '否';
}
return firstDetailValue(detailRow.value, ['waybillBatchNo', 'waybillNo']) ? '是' : '-';
});
const detailLoadingNo = computed(() =>
detailValue(
firstDetailValue(detailRow.value, [
'loadingNo',
'loadingCode',
'loadBatchNo',
'loadingBatchNo',
'stowageNo',
'stowageCode',
'stowageBatchNo',
'shipmentNo',
'waybillBatchNo',
'waybillNo',
])
)
);
const imageFieldKeys = [
'voucherImages',
'voucherImageList',
'executeVoucherImages',
'executeVoucherImageList',
'voucherImgList',
'voucherImageUrls',
'imageList',
'imgList',
'imageUrls',
'images',
'pictureList',
'pictures',
'photoList',
'attachments',
'attachmentList',
'fileList',
];
const fileFieldKeys = ['voucherFiles', 'files', 'fileDetails', 'fileList'];
const toDetailImageItems = value => {
if (Array.isArray(value)) return value.flatMap(item => toDetailImageItems(item));
if (typeof value === 'string') {
const text = value.trim();
if (!text) return [];
try {
return toDetailImageItems(JSON.parse(text));
} catch {
return text.split(/[,\n;]/).map(item => item.trim()).filter(Boolean);
}
}
if (value && typeof value === 'object') {
const nested = value.records || value.list || value.items || value.data || value.urls;
if (nested !== undefined) return toDetailImageItems(nested);
}
return value && typeof value === 'object' ? [value] : [];
};
const normalizeDetailImage = item => {
if (typeof item === 'string') return { url: item, name: item.split('/').pop() };
const url =
item?.url ||
item?.link ||
item?.fileUrl ||
item?.downloadUrl ||
item?.domain ||
item?.src ||
item?.imageUrl ||
item?.path;
return url ? { url: String(url), name: item.name || item.fileName || '' } : null;
};
const normalizeDetailFile = item => {
if (typeof item === 'string') {
return { name: item.split('/').pop() || item, url: item, fileType: '' };
}
const url = item?.url || item?.link || item?.fileUrl || item?.downloadUrl || item?.domain || item?.src;
const name = item?.fileName || item?.name || item?.entryName || item?.imageName || '-';
return {
...item,
id: item?.id,
name,
url: url ? String(url) : '',
fileType: item?.fileType || '',
contentType: item?.contentType || '',
};
};
const isDetailImage = file =>
file.fileType === 'image' ||
String(file.contentType || '').startsWith('image/') ||
/\.(jpe?g|png|bmp|webp|gif)$/i.test(file.name || '');
const detailFiles = computed(() => {
for (const key of fileFieldKeys) {
const value = detailRow.value?.[key];
if (value === undefined || value === null || value === '') continue;
const files = toDetailImageItems(value).map(normalizeDetailFile).filter(Boolean);
if (files.length) return files;
}
return [];
});
const detailImages = computed(() => {
if (detailFiles.value.length) return detailFiles.value.filter(file => isDetailImage(file));
for (const key of imageFieldKeys) {
const value = detailRow.value?.[key];
if (value === undefined || value === null || value === '') continue;
const images = toDetailImageItems(value).map(normalizeDetailImage).filter(Boolean);
if (images.length) return images;
}
const singleImage = normalizeDetailImage(detailRow.value?.voucherImage || detailRow.value?.image);
return singleImage ? [singleImage] : [];
});
const detailOtherFiles = computed(() =>
detailFiles.value.filter(file => !isDetailImage(file))
);
const detailImageUrls = computed(() => detailImages.value.map(image => image.url));
const openDetailFile = file => {
if (file?.url) window.open(file.url, '_blank');
};
const view = async row => {
router.push({
path: '/business/voucher-manage/detail',
query: { id: row.id, batchNo: row.voucherBatchNo },
});
};
const download = row => window.open(row.fileUrl, '_blank');
const handleAuditPass = row =>
ElMessageBox.confirm(`确认审核通过凭证批次"${row.voucherBatchNo}"吗?`, '提示', {
type: 'warning',
}).then(async () => {
await api.auditPass(row.id);
ElMessage.success('审核通过');
load();
});
const handleAuditReject = row =>
ElMessageBox.prompt('请输入驳回原因(可选)', '审核驳回', {
confirmButtonText: '确认',
cancelButtonText: '取消',
inputPattern: /.*/,
}).then(async ({ value }) => {
await api.auditReject(row.id, value || '');
ElMessage.success('审核驳回');
load();
});
load();
</script>
<style lang="scss" scoped>
:global(.voucher-detail-dialog.el-dialog) {
max-width: 996px;
border: 1px solid #dcdfe6;
border-radius: 0;
overflow: hidden;
}
:global(.voucher-detail-dialog .el-dialog__header) {
padding: 20px 24px;
margin-right: 0;
//background: #f5f5f5;
border-bottom: 1px solid #dcdfe6;
}
:global(.voucher-detail-dialog .el-dialog__headerbtn) {
top: 20px;
right: 24px;
}
:global(.voucher-detail-dialog .el-dialog__body) {
max-height: calc(100vh - 130px);
padding: 0;
overflow-y: auto;
background: #fff;
}
.voucher-manage-page {
&__search {
padding: 12px 12px 4px;
margin-bottom: 8px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
&__search-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px 24px;
}
&__search-actions {
display: flex;
grid-column: 1 / -1;
justify-content: flex-end;
align-items: center;
gap: 8px;
min-height: 32px;
}
&__toolbar {
display: flex;
align-items: center;
gap: 12px;
margin: 12px 0;
}
&__table {
width: 100%;
}
&__link {
padding: 0;
min-height: auto;
}
&__audit-status {
display: inline-flex;
align-items: center;
}
&__audit-reject-icon {
margin-left: 4px;
color: #f56c6c;
cursor: help;
}
&__actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2px 12px;
}
&__pagination {
display: flex;
justify-content: flex-end;
padding: 12px 0 0;
background: #fff;
}
&__project-select {
width: 280px;
}
&__hidden-file {
display: none;
}
&__upload-form :deep(.el-form-item) {
margin-bottom: 14px;
}
&__upload-tip {
margin-left: 16px;
color: #606266;
}
&__upload-progress {
margin-top: 12px;
:deep(.el-progress__text) {
font-size: 14px;
font-weight: 500;
}
:deep(.el-progress-bar__outer) {
background: #eff1f7;
border-radius: 6px;
}
:deep(.el-progress-bar__inner) {
border-radius: 6px;
}
}
&__progress-cell {
display: flex;
align-items: center;
gap: 8px;
:deep(.el-progress) {
flex: 1;
}
:deep(.el-progress-bar__outer) {
background: #eff1f7;
border-radius: 6px;
}
}
&__progress-value {
min-width: 38px;
color: #303133;
text-align: right;
}
&__batch-search {
padding: 12px;
margin-bottom: 8px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
:deep(.el-form-item) {
margin-bottom: 8px;
}
:deep(.el-form-item__label) {
white-space: nowrap;
}
:deep(.el-input),
:deep(.el-select),
:deep(.el-date-editor.el-input),
:deep(.el-date-editor.el-input__wrapper),
:deep(.el-date-editor--daterange) {
width: 100%;
}
:deep(.voucher-manage-page__project-select) {
width: 280px;
}
:deep(.voucher-manage-page__batch-search .el-input),
:deep(.voucher-manage-page__batch-search .el-input-number),
:deep(.voucher-manage-page__batch-search .el-date-editor) {
width: 50%;
}
:deep(.el-table) {
--el-table-border-color: #eff1f7;
--el-table-row-hover-bg-color: #f5f7fa;
}
:deep(.el-table__body tr:nth-child(even) > td) {
background: #fafafa;
}
&__detail {
min-height: 420px;
padding: 40px 46px 68px;
box-sizing: border-box;
}
&__detail-summary {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
column-gap: 56px;
row-gap: 16px;
margin-bottom: 32px;
}
&__detail-field {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
column-gap: 16px;
align-items: center;
min-height: 24px;
font-size: 14px;
line-height: 24px;
}
&__detail-label,
&__detail-value {
color: #303133;
white-space: nowrap;
}
&__detail-value {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
&__detail-link {
justify-self: start;
font-size: inherit;
line-height: inherit;
}
&__detail-images {
display: grid;
grid-template-columns: repeat(5, 140px);
gap: 32px;
justify-content: start;
}
&__detail-image {
width: 140px;
height: 100px;
box-sizing: border-box;
overflow: hidden;
cursor: zoom-in;
background: #fff;
border: 6px solid #cfcfcf;
:deep(.el-image__inner) {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
&__detail-files {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid #eff1f7;
}
&__detail-file {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 28px;
}
&__detail-file-name {
min-width: 0;
overflow: hidden;
color: #303133;
text-overflow: ellipsis;
white-space: nowrap;
}
}
@media (max-width: 900px) {
.voucher-manage-page {
&__detail-images {
grid-template-columns: repeat(4, 140px);
}
}
}
@media (max-width: 680px) {
.voucher-manage-page {
&__detail {
padding: 28px 24px 40px;
}
&__detail-summary {
grid-template-columns: 1fr;
row-gap: 12px;
}
&__detail-images {
grid-template-columns: repeat(2, minmax(0, 140px));
gap: 20px;
}
}
}
</style>