50c1afe9c8
多模块列表与导出展示创建人、创建时间;组织类型强制提交数字编码,避免中文 dictKey 反序列化失败。 Co-authored-by: Cursor <cursoragent@cursor.com>
141 lines
11 KiB
Vue
141 lines
11 KiB
Vue
<template>
|
|
<basic-container class="voucher-folder-page">
|
|
<section class="voucher-folder-page__search">
|
|
<el-form :model="query" label-position="right" label-width="160px">
|
|
<div class="voucher-folder-page__search-grid">
|
|
<el-form-item label="车牌号"><el-input v-model="query.plateNo" clearable placeholder="请输入" /></el-form-item>
|
|
<el-form-item label="是否关联运单">
|
|
<el-select v-model="query.matched" clearable placeholder="全部"><el-option label="是" :value="1" /><el-option label="否" :value="0" /></el-select>
|
|
</el-form-item>
|
|
<div class="voucher-folder-page__search-actions"><el-button type="primary" @click="search">查询</el-button><el-button @click="reset">重置</el-button></div>
|
|
</div>
|
|
</el-form>
|
|
</section>
|
|
<section class="voucher-folder-page__table-wrap">
|
|
<div class="voucher-folder-page__title">{{ voucher.voucherBatchNo || '执行凭证详情' }}</div>
|
|
<el-table v-loading="loading" :data="rows" border class="voucher-folder-page__table">
|
|
<el-table-column type="index" label="序号" width="70" />
|
|
<el-table-column prop="voucherNo" label="执行凭证号" min-width="220"><template #default="{ row }"><el-link type="primary" @click="openViewer(row)">{{ row.voucherNo }}</el-link></template></el-table-column>
|
|
<el-table-column prop="plateNo" label="车牌号" min-width="150" />
|
|
<el-table-column prop="folderName" label="文件夹名称" min-width="160" />
|
|
<el-table-column prop="matched" label="是否关联运单" width="130"><template #default="{ row }">{{ row.matched === 1 ? '是' : '否' }}</template></el-table-column>
|
|
<el-table-column prop="processStatus" label="处理状态" width="130" />
|
|
<el-table-column prop="createTime" label="创建时间" min-width="180" />
|
|
<el-table-column prop="updateTime" label="更新时间" min-width="180" />
|
|
<el-table-column label="操作" width="220" fixed="right"><template #default="{ row }"><div class="voucher-folder-page__actions"><el-link v-if="isSuperAdmin && row.matched !== 1" type="primary" @click="openReplace(row)">单个上传</el-link><el-link type="primary" @click="openViewer(row)">查看</el-link><el-link type="danger" @click="removeFolder(row)">删除</el-link></div></template></el-table-column>
|
|
</el-table>
|
|
<div class="voucher-folder-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="viewerVisible" title="执行凭证详情" width="960px" destroy-on-close>
|
|
<div v-loading="viewerLoading" class="voucher-folder-page__viewer"><div class="voucher-folder-page__viewer-info"><span>执行凭证号:{{ viewer.voucherNo || '-' }}</span><span>文件夹名称:{{ viewer.folderName || '-' }}</span><span>是否关联运单:{{ viewer.matched === 1 ? '是' : '否' }}</span><span>配载单号:<el-link v-if="viewer.loadingNo" type="primary" @click="goToLoading(viewer.loadingNo)">{{ viewer.loadingNo }}</el-link><template v-else>-</template></span></div><div v-if="viewerFiles.length" class="voucher-folder-page__images"><el-image v-for="(file, index) in viewerFiles" :key="file.id || file.url || index" :src="file.url" :preview-src-list="viewerFiles.map(item => item.url).filter(Boolean)" :initial-index="index" fit="cover" /></div><el-empty v-else description="暂无凭证文件" /></div>
|
|
</el-dialog>
|
|
|
|
<el-dialog v-model="replaceVisible" title="重新上传" width="680px" destroy-on-close>
|
|
<div class="voucher-folder-page__replace"><el-upload action="#" :auto-upload="false" :limit="1" accept=".jpg,.jpeg,.png,.bmp,.webp,.zip" :on-change="selectReplace" :on-remove="clearReplace"><el-button type="primary">选择文件</el-button></el-upload><el-progress v-if="replacing" class="voucher-folder-page__replace-progress" :percentage="replaceProgress" :status="replaceProgressStatus" :text-inside="false" /><p>{{ replaceProgress >= 100 ? '文件上传完成,正在处理凭证...' : '重新上传替换当前未匹配的凭证,支持图片或压缩包,大小不超过50M' }}</p></div>
|
|
<template #footer><el-button @click="replaceVisible = false">关闭</el-button><el-button type="primary" :loading="replacing" @click="confirmReplace">确认</el-button></template>
|
|
</el-dialog>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, onMounted, reactive, ref } from 'vue';
|
|
import { useRoute, useRouter } from 'vue-router';
|
|
import { useStore } from 'vuex';
|
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
|
import md5 from 'js-md5';
|
|
import * as api from '@/api/business/voucher-manage';
|
|
import { isAdministrator } from '@/utils/auth';
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const store = useStore();
|
|
const voucherId = route.query.id;
|
|
const isSuperAdmin = computed(() => {
|
|
const authority = store.getters.userInfo?.authority;
|
|
return isAdministrator({ authority });
|
|
});
|
|
const loading = ref(false), rows = ref([]), voucher = ref({ voucherBatchNo: route.query.batchNo || '' });
|
|
const page = reactive({ current: 1, size: 10, total: 0 });
|
|
const query = reactive({ plateNo: '', matched: undefined });
|
|
const viewerVisible = ref(false), viewerLoading = ref(false), viewer = ref({}), viewerFiles = ref([]);
|
|
const replaceVisible = ref(false), replacing = ref(false), replaceRow = ref(), replaceFile = ref(), replaceProgress = ref(0), replaceProgressStatus = ref('');
|
|
const load = async () => {
|
|
loading.value = true;
|
|
try {
|
|
const res = await api.getFolderPage(voucherId, page.current, page.size, query);
|
|
const data = res.data?.data || {};
|
|
rows.value = data.records || [];
|
|
page.total = data.total || 0;
|
|
if (!voucher.value.voucherBatchNo && rows.value[0]) voucher.value = { voucherBatchNo: rows.value[0].voucherBatchNo };
|
|
} finally { loading.value = false; }
|
|
};
|
|
const search = () => { page.current = 1; load(); };
|
|
const reset = () => { query.plateNo = ''; query.matched = undefined; search(); };
|
|
const openViewer = async row => {
|
|
viewerVisible.value = true; viewerLoading.value = true; viewer.value = row; viewerFiles.value = [];
|
|
try { const res = await api.getFolderDetail(voucherId, row.plateNo); viewer.value = res.data?.data || row; viewerFiles.value = viewer.value.files || []; } catch (error) { ElMessage.error(error.message || '获取凭证详情失败'); } finally { viewerLoading.value = false; }
|
|
};
|
|
const openReplace = row => { replaceRow.value = row; replaceFile.value = undefined; replaceProgress.value = 0; replaceProgressStatus.value = ''; replaceVisible.value = true; };
|
|
const selectReplace = upload => { replaceFile.value = upload.raw; };
|
|
const clearReplace = () => { replaceFile.value = undefined; replaceProgress.value = 0; replaceProgressStatus.value = ''; };
|
|
const uploadReplaceFile = async file => {
|
|
const chunkSize = 2 * 1024 * 1024;
|
|
const digest = md5.base64(await file.arrayBuffer());
|
|
const taskRes = await api.createFileTask({
|
|
md5: digest,
|
|
attachmentName: file.name,
|
|
size: file.size,
|
|
chunkSize,
|
|
chunkTotal: Math.ceil(file.size / chunkSize),
|
|
force: true,
|
|
businessType: 'voucher-replace',
|
|
businessId: `${voucherId}:${replaceRow.value.plateNo}`,
|
|
});
|
|
const task = taskRes.data?.data;
|
|
if (!task?.id || !task.objectKey) throw new Error('创建分片上传任务失败');
|
|
replaceProgress.value = 0;
|
|
for (let partNumber = 1; partNumber <= task.chunkTotal; partNumber += 1) {
|
|
const urlRes = await api.getPartUploadUrl(task.id, partNumber);
|
|
const part = file.slice((partNumber - 1) * chunkSize, Math.min(partNumber * chunkSize, file.size));
|
|
const response = await fetch(urlRes.data?.data, { method: 'PUT', body: part });
|
|
if (!response.ok) throw new Error(`分片${partNumber}上传失败`);
|
|
const etag = String(response.headers.get('etag') || '').replaceAll('"', '');
|
|
if (!etag) throw new Error('未获取到分片 ETag');
|
|
await api.updateFileTask({ id: task.id, partNumber, etag });
|
|
replaceProgress.value = Math.round((partNumber / task.chunkTotal) * 100);
|
|
}
|
|
const completedRes = await api.getFileTask(task.id);
|
|
const completedTask = completedRes.data?.data;
|
|
if (!completedTask || completedTask.status !== 'completed') throw new Error('文件分片合并未完成');
|
|
return completedTask;
|
|
};
|
|
const confirmReplace = async () => {
|
|
if (!replaceFile.value) return ElMessage.warning('请选择凭证文件');
|
|
if (replaceFile.value.size > 50 * 1024 * 1024) return ElMessage.warning('凭证文件大小不能超过50M');
|
|
replacing.value = true;
|
|
replaceProgress.value = 0;
|
|
replaceProgressStatus.value = '';
|
|
try {
|
|
const task = await uploadReplaceFile(replaceFile.value);
|
|
await api.replaceFolderByObject(voucherId, replaceRow.value.plateNo, {
|
|
objectKey: task.objectKey,
|
|
fileName: task.attachmentName || replaceFile.value.name,
|
|
size: replaceFile.value.size,
|
|
contentType: replaceFile.value.type,
|
|
});
|
|
ElMessage.success('上传成功,已重新匹配运单');
|
|
replaceVisible.value = false;
|
|
await load();
|
|
} catch (error) { replaceProgressStatus.value = 'exception'; ElMessage.error(error.message || '上传失败'); } finally { replacing.value = false; }
|
|
};
|
|
const removeFolder = row => ElMessageBox.confirm(`确认删除车牌”${row.plateNo}”的凭证吗?`, '提示', { type: 'warning' }).then(async () => { await api.removeFolder(voucherId, row.plateNo); ElMessage.success('删除成功'); load(); });
|
|
const goToLoading = loadingNo => { router.push({ path: '/business/loading-manage', query: { loadingNo } }); };
|
|
onMounted(() => { if (!voucherId) { ElMessage.error('缺少凭证批次参数'); router.back(); return; } load(); });
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.voucher-folder-page { &__search { padding: 12px 12px 4px; margin-bottom: 8px; background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,.04); } &__search-grid { display: grid; grid-template-columns: 360px 360px 1fr; gap: 8px 24px; } &__search-actions { display: flex; justify-content: flex-end; gap: 8px; } &__table-wrap { padding: 12px; background: #fff; } &__title { padding-bottom: 12px; color: #303133; font-size: 16px; font-weight: 600; } &__actions { display: flex; flex-wrap: wrap; gap: 8px; } &__pagination { display: flex; justify-content: flex-end; padding-top: 12px; } &__viewer { min-height: 360px; } &__viewer-info { display: flex; gap: 28px; padding-bottom: 20px; color: #303133; } &__images { display: grid; grid-template-columns: repeat(5, 140px); gap: 20px; } &__images :deep(.el-image) { width: 140px; height: 100px; border: 6px solid #cfcfcf; cursor: zoom-in; } &__replace { padding: 20px 28px 42px; } &__replace p { margin: 28px 0 0; color: #303133; font-size: 16px; } :deep(.el-table) { --el-table-border-color: #eff1f7; } :deep(.el-table__body tr:nth-child(even)>td) { background: #fafafa; } }
|
|
@media (max-width: 900px) { .voucher-folder-page__search-grid { grid-template-columns: 1fr 1fr; } .voucher-folder-page__search-actions { grid-column: 1 / -1; } }
|
|
</style>
|