Files
tms-erp-web/src/views/business/voucher-manage.vue
T
2026-08-13 18:53:49 +08:00

962 lines
33 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="160px">
<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 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 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 prop="uploadSource" label="上传来源" width="100" /><el-table-column
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="115"
/><el-table-column prop="unRelatedWaybillCount" label="未关联运单" width="115" />
<el-table-column prop="status" label="状态" width="100"
><template #default="{ row }">{{
Number(row.status) === 1 ? '启用' : '停用'
}}</template></el-table-column
><el-table-column prop="createTime" label="创建时间" min-width="170" />
<el-table-column
prop="auditStatus"
label="审核状态"
width="120"
fixed="right"
align="center"
/><el-table-column label="操作" width="320" fixed="right" align="left"
><template #default="{ row }"
><div class="voucher-manage-page__actions">
<el-link
v-if="row.processStatus === '处理完成' && row.auditStatus === '待审核'"
type="primary"
>流程</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.processStatus !== '处理完成' || row.auditStatus === '待审核'"
type="primary"
@click="openUpload(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="uploadVisible"
:title="editing.id ? '更换运单批次' : '批量导入凭证'"
width="1200px"
destroy-on-close
:before-close="beforeUploadDialogClose"
>
<el-form
ref="uploadFormRef"
:model="editing"
:rules="rules"
label-position="right"
label-width="auto"
class="voucher-manage-page__upload-form"
>
<el-form-item 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="执行凭证" prop="fileUrl"
><el-upload
action="#"
accept=".zip,.7z"
:auto-upload="false"
:limit="1"
:on-change="uploadVoucher"
: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 格式采用分片上传上传中断后可在上传进度继续上传</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>
<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="160px"
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-number v-model="batchQuery.waybillCount" :min="0" /></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
@selection-change="batchSelection = $event"
><el-table-column type="selection" width="56" /><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>
<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" @click="confirmBatches">确认</el-button></template
>
</el-dialog>
<el-dialog
v-model="progressVisible"
title="上传进度"
width="1200px"
destroy-on-close
:before-close="beforeProgressDialogClose"
@opened="loadProgress"
>
<section class="voucher-manage-page__progress-search">
<el-form :model="progressQuery" label-position="right" label-width="160px"
><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>
<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="160" 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 } from 'vue';
import md5 from 'js-md5';
import { ElMessage, ElMessageBox } from 'element-plus';
import { getList as getProjectList } from '@/api/business/project-apply';
import * as api from '@/api/business/voucher-manage';
const loading = ref(false),
rows = ref([]),
createRange = ref([]),
searchExpanded = ref(false),
uploadVisible = ref(false),
batchVisible = 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();
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 => {
await loadProjects();
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: [],
});
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 = () => 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 () => {
if (closingUploadDialog.value) return;
closingUploadDialog.value = true;
try {
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 || '';
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);
} catch (error) {
if (error.name !== 'AbortError')
ElMessage.error(error.message || '上传中断,可在上传进度中继续上传');
}
};
watch(uploadVisible, visible => {
// 兜底处理:右上角、遮罩、Esc 直接修改 v-model 时仍必须停止上传。
if (!visible) void stopCurrentUpload(editing.fileTaskId);
});
const openBatchDialog = async () => {
batchVisible.value = true;
await loadBatches();
};
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 resetBatchQuery = () => {
Object.assign(batchQuery, { batchNo: '', createUser: '', waybillCount: undefined });
batchCreateRange.value = [];
batchPage.current = 1;
loadBatches();
};
const confirmBatches = () => {
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 {
if (uploading.value && editing.fileTaskId) {
await stopCurrentUpload(editing.fileTaskId);
}
const valid = await uploadFormRef.value.validate().catch(() => false);
if (!valid) return;
if (!editing.fileUrl && !editing.fileTaskId) {
ElMessage.warning('请先选择执行凭证');
return;
}
await api.submit({
...editing,
waybillImportBatchIds: selectedBatches.value.map(item => item.id),
});
ElMessage.success('提交成功');
await closeUploadDialog();
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 || [];
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 view = row => ElMessage.info(`凭证批次:${row.voucherBatchNo}`);
const download = row => window.open(row.fileUrl, '_blank');
load();
</script>
<style lang="scss" scoped>
.voucher-manage-page {
&__search {
padding: 12px 12px 4px;
margin-bottom: 8px;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
&__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;
}
&__actions {
display: flex;
flex-wrap: wrap;
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: 16px;
}
&__upload-tip {
margin-left: 16px;
color: #606266;
}
&__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;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
margin-bottom: 8px;
}
: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(.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;
}
}
</style>