Files
tms-erp-web/src/views/vehicle/insurance-record.vue
T

815 lines
25 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="insurance-record-page">
<avue-crud
:option="option"
:table-loading="loading"
:data="data"
v-model:page="page"
:permission="permissionList"
:before-open="beforeOpen"
v-model="form"
ref="crud"
@row-update="rowUpdate"
@row-save="rowSave"
@row-del="rowDel"
@search-change="searchChange"
@search-reset="searchReset"
@selection-change="selectionChange"
@current-change="currentChange"
@size-change="sizeChange"
@refresh-change="refreshChange"
@on-load="onLoad"
>
<template #menu-left>
<el-button
type="primary"
icon="el-icon-upload"
plain
v-if="hasPermission('insurance_record_import')"
@click="handleImport"
>批量导入
</el-button>
<el-button
type="primary"
icon="el-icon-download"
plain
v-if="hasPermission('insurance_record_template')"
@click="handleTemplate"
>下载模板
</el-button>
<el-button
type="primary"
icon="el-icon-download"
plain
v-if="hasPermission('insurance_record_export')"
@click="handleExport"
>批量导出
</el-button>
<el-button
type="danger"
icon="el-icon-delete"
plain
v-if="hasPermission('insurance_record_delete')"
@click="handleDelete"
>批量删除
</el-button>
</template>
<template #vehicleNo="{ row, index }">
<el-button type="primary" link @click="$refs.crud.rowView(row, index)">
{{ row.vehicleNo }}
</el-button>
</template>
<template #vehicleNoForm>
<el-autocomplete
v-model="form.vehicleNo"
:fetch-suggestions="fetchVehicleOptions"
clearable
:placeholder="vehicleNoPlaceholder"
value-key="value"
class="insurance-record-page__vehicle-input"
/>
</template>
<template #insuredAmountForm>
<el-input
v-model="form.insuredAmount"
:disabled="boxType === 'view'"
inputmode="decimal"
placeholder="请输入"
@input="value => normalizeDecimalInput('insuredAmount', value)"
>
<template #append></template>
</el-input>
</template>
<template #premiumForm>
<el-input
v-model="form.premium"
:disabled="boxType === 'view'"
inputmode="decimal"
placeholder="请输入"
@input="value => normalizeDecimalInput('premium', value)"
>
<template #append></template>
</el-input>
</template>
<template #policyFileForm>
<div class="insurance-record-page__upload">
<div class="insurance-record-page__upload-info">
<div class="insurance-record-page__upload-icon" aria-hidden="true">
<span class="insurance-record-page__upload-card">
<span></span>
</span>
</div>
<div class="insurance-record-page__upload-text">
<strong>上传图片,自动识别填写</strong>
<span>支持JPGPNGBMP上传</span>
</div>
</div>
<el-upload
v-if="hasPermission('insurance_record_ocr')"
class="insurance-record-page__upload-action"
action="/api/blade-resource/oss/endpoint/put-file"
name="file"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handlePolicyUploadSuccess"
:on-error="handlePolicyUploadError"
:before-upload="validateOcrUpload"
accept=".jpg,.jpeg,.png,.bmp"
>
<el-button class="insurance-record-page__upload-button" type="primary" plain>
上传文件
</el-button>
</el-upload>
</div>
</template>
<template #policyFile-form>
<div class="insurance-record-page__upload">
<div class="insurance-record-page__upload-info">
<div class="insurance-record-page__upload-icon" aria-hidden="true">
<span class="insurance-record-page__upload-card">
<span></span>
</span>
</div>
<div class="insurance-record-page__upload-text">
<strong>上传图片,自动识别填写</strong>
<span>支持JPGPNGBMP上传</span>
</div>
</div>
<el-upload
v-if="hasPermission('insurance_record_ocr')"
class="insurance-record-page__upload-action"
action="/api/blade-resource/oss/endpoint/put-file"
name="file"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handlePolicyUploadSuccess"
:on-error="handlePolicyUploadError"
:before-upload="validateOcrUpload"
accept=".jpg,.jpeg,.png,.bmp"
>
<el-button class="insurance-record-page__upload-button" type="primary" plain>
上传文件
</el-button>
</el-upload>
</div>
</template>
</avue-crud>
<empty-pagination
:page="page"
@size-change="sizeChange"
@current-change="currentChange"
@load="onLoad(page, query)"
/>
<el-dialog title="保险记录数据导入" append-to-body v-model="excelBox" width="555px">
<avue-form :option="excelOption" v-model="excelForm">
<template #excelTemplate>
<el-button type="primary" @click="handleTemplate">
点击下载<i class="el-icon-download el-icon--right"></i>
</el-button>
</template>
</avue-form>
</el-dialog>
</basic-container>
</template>
<script>
import {
add,
getDetail,
getList,
recognizeGeneralOcr,
remove,
update,
} from '@/api/vehicle/insurance-record';
import { getList as getVehicleList } from '@/api/transportCapacity/transport-vehicle';
import { getList as getShipList } from '@/api/transportCapacity/transport-ship';
import { getList as getOcrTemplateList } from '@/api/base/insurance-ocr-template';
import { getDeptTree } from '@/api/system/dept';
import { exportBlob } from '@/api/common';
import { downloadXls } from '@/utils/util';
import { openImportDialog } from '@/utils/import-excel';
import { getToken } from '@/utils/auth';
import { getUploadHeaders } from '@/utils/upload';
import { normalizeSearchRangeParams } from '@/utils/search-range';
import { mapGetters } from 'vuex';
import { ElLoading } from 'element-plus';
import { excelOption, exportColumns, option } from '@/option/vehicle/insurance-record';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
const createTimeRangeMap = {
createTimeRange: ['createTimeStart', 'createTimeEnd'],
};
export default {
data() {
return {
form: {},
query: {},
loading: true,
excelBox: false,
excelForm: {},
isDetailLoading: false,
boxType: '',
option,
excelOption,
page: {
pageSize: 10,
pageSizes: [10, 20, 50, 100],
currentPage: 1,
total: 0,
},
selectionList: [],
data: [],
ocrTemplateOptions: [],
};
},
created() {
this.initDeptTree();
this.initOcrTemplateOptions();
},
computed: {
...mapGetters(['permission', 'userInfo']),
isAdmin() {
const authority = this.userInfo.authority || '';
return authority.includes('admin');
},
permissionList() {
return {
addBtn: this.hasPermission('insurance_record_add'),
viewBtn: this.hasPermission('insurance_record_view'),
delBtn: this.hasPermission('insurance_record_delete'),
editBtn: this.hasPermission('insurance_record_edit'),
};
},
vehicleNoPlaceholder() {
return this.form.vehicleType === '船舶' ? '输入船号模糊查询选择' : '输入车牌号模糊查询选择';
},
ids() {
const ids = [];
this.selectionList.forEach(ele => {
ids.push(ele.id);
});
return ids.join(',');
},
uploadHeaders() {
return getUploadHeaders();
},
},
watch: {
'form.vehicleType': {
handler(value, oldValue) {
this.updateVehicleTypeDisplays(value || '车辆', oldValue);
},
immediate: true,
},
},
methods: {
hasPermission(code) {
return this.isAdmin || this.validData(this.permission[code], false);
},
updateVehicleTypeDisplays(vehicleType, oldValue) {
const vehicleNoColumn = this.findColumn(this.option.column, 'vehicleNo');
vehicleNoColumn.placeholder =
vehicleType === '船舶' ? '输入船号模糊查询选择' : '输入车牌号模糊查询选择';
if (!this.isDetailLoading && oldValue && vehicleType !== oldValue && this.form.vehicleNo) {
this.form.vehicleNo = '';
}
},
initDeptTree() {
getDeptTree(this.userInfo.tenantId).then(res => {
const column = this.findColumn(this.option.column, 'createDept');
column.dicData = res.data.data;
});
},
initOcrTemplateOptions() {
getOcrTemplateList(1, 100, {})
.then(res => {
const records = res.data.data?.records || [];
this.ocrTemplateOptions = records.map(item => ({
label: item.name,
value: item.name,
mappingConfig: item.mappingConfig || '[]',
}));
const templateColumn = this.findColumn(this.option.column, 'ocrTemplate');
if (templateColumn) templateColumn.dicData = this.ocrTemplateOptions;
})
.catch(() => {
this.ocrTemplateOptions = [];
});
},
fetchVehicleOptions(queryString, callback) {
const vehicleType = this.form.vehicleType || '车辆';
const request = vehicleType === '船舶' ? getShipList : getVehicleList;
const params =
vehicleType === '船舶' ? { shipIdentifierNo: queryString } : { plateNo: queryString };
request(1, 20, params)
.then(res => {
const records = res.data.data.records || [];
callback(
records.map(item => ({
value: vehicleType === '船舶' ? item.shipIdentifierNo || item.shipName : item.plateNo,
}))
);
})
.catch(() => callback([]));
},
normalizeRow(row) {
const values = { ...row };
values.vehicleType = values.vehicleType || '车辆';
if (values.vehicleNo) {
values.vehicleNo =
values.vehicleType === '船舶'
? values.vehicleNo.trim()
: values.vehicleNo.trim().toUpperCase();
}
if (values.policyNo) {
values.policyNo = values.policyNo.trim();
}
return values;
},
normalizeDecimalInput(prop, value) {
const sanitized = String(value ?? '').replace(/[^\d.]/g, '');
const [integerPart, ...decimalParts] = sanitized.split('.');
this.form[prop] = decimalParts.length
? `${integerPart || '0'}.${decimalParts.join('').slice(0, 2)}`
: integerPart;
},
buildQuery(params = {}) {
return normalizeSearchRangeParams({ ...params, ...this.query }, createTimeRangeMap);
},
validateRow(row) {
if (row.startDate && row.endDate && row.endDate < row.startDate) {
this.$message.warning('结束日期不能早于开始日期');
return false;
}
if (Number(row.insuredAmount) < 0 || Number(row.premium) < 0) {
this.$message.warning('保额、保费不能小于 0');
return false;
}
if (row.remark && row.remark.length > 200) {
this.$message.warning('备注最多 200 个字');
return false;
}
return true;
},
rowSave(row, done, loading) {
const values = this.normalizeRow(row);
if (!this.validateRow(values)) {
loading();
return;
}
add(values).then(
() => {
this.onLoad(this.page);
this.$message({ type: 'success', message: '操作成功!' });
done();
},
error => {
window.console.log(error);
loading();
}
);
},
rowUpdate(row, index, done, loading) {
const values = this.normalizeRow(row);
if (!this.validateRow(values)) {
loading();
return;
}
update(values).then(
() => {
this.onLoad(this.page);
this.$message({ type: 'success', message: '操作成功!' });
done();
},
error => {
window.console.log(error);
loading();
}
);
},
rowDel(row) {
this.$confirm('确定删除该条数据,删除后不可恢复?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => remove(row.id))
.then(() => {
this.onLoad(this.page);
this.$message({ type: 'success', message: '操作成功!' });
});
},
handleDelete() {
if (this.selectionList.length === 0) {
this.$message.warning('请选择至少一条数据');
return;
}
this.$confirm('确定删除所选数据,删除后不可恢复?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => remove(this.ids))
.then(() => {
this.onLoad(this.page);
this.$message({ type: 'success', message: '操作成功!' });
this.$refs.crud.toggleSelection();
});
},
beforeOpen(done, type) {
this.boxType = type;
if (type === 'add') {
this.form.vehicleType = '车辆';
this.form.ocrTemplate = this.ocrTemplateOptions[0]?.value || '';
}
if (['edit', 'view'].includes(type)) {
this.isDetailLoading = true;
getDetail(this.form.id)
.then(res => {
this.form = {
...res.data.data,
vehicleType: res.data.data.vehicleType || '车辆',
};
})
.finally(() => {
this.isDetailLoading = false;
});
}
done();
},
searchReset() {
this.query = {};
this.onLoad(this.page);
},
searchChange(params, done) {
this.query = this.buildQuery(params);
this.page.currentPage = 1;
this.onLoad(this.page);
done();
},
selectionChange(list) {
this.selectionList = list;
},
selectionClear() {
this.selectionList = [];
this.$refs.crud.toggleSelection();
},
currentChange(currentPage) {
this.page.currentPage = currentPage;
},
sizeChange(pageSize) {
this.page.pageSize = pageSize;
},
refreshChange() {
this.onLoad(this.page, this.query);
},
onLoad(page, params = {}) {
this.loading = true;
const query = this.buildQuery(params);
getList(page.currentPage, page.pageSize, query)
.then(res => {
const data = res.data.data;
this.page.total = data.total;
this.data = data.records;
this.selectionClear();
})
.finally(() => {
this.loading = false;
});
},
handleImport() {
openImportDialog(this, '保险记录');
},
handleExport() {
this.$confirm('是否导出保险记录数据?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
NProgress.start();
exportBlob(
'/blade-transport/insurance-record/export-insurance-record',
this.buildExportParams(),
{ feedback: true }
)
.then(res => {
downloadXls(res.data, `保险记录${this.$dayjs().format('YYYY-MM-DD HH:mm:ss')}.xlsx`);
})
.finally(() => {
NProgress.done();
});
});
},
buildExportParams() {
return {
...this.buildQuery(),
ids: this.ids,
exportColumns: JSON.stringify(exportColumns),
[this.website.tokenHeader]: getToken(),
};
},
handleTemplate() {
exportBlob(
'/blade-transport/insurance-record/export-template',
{ exportColumns: JSON.stringify(exportColumns), [this.website.tokenHeader]: getToken() },
{ feedback: true }
).then(res => {
downloadXls(res.data, '保险记录模板.xlsx');
});
},
handlePolicyUploadSuccess(res) {
if (res?.code !== 200 || !res.data) {
this.$message.error(res?.msg || '上传失败');
return;
}
const imageUrl = res.data.link || res.data.url || res.data.domain || '';
if (!imageUrl) {
this.$message.warning('文件上传成功,未获取到文件地址,无法自动识别');
return;
}
this.form.policyFile = imageUrl;
this.recognizePolicyFile(imageUrl);
},
handlePolicyUploadError() {
this.$message.error('上传失败');
},
recognizePolicyFile(imageUrl) {
const loading = ElLoading.service({
lock: true,
text: '保单识别中',
background: 'rgba(255, 255, 255, 0.7)',
});
recognizeGeneralOcr(imageUrl)
.then(res => {
const result = res.data.data?.result || {};
const filledCount = this.applyTemplateRecognition(result);
if (filledCount > 0) {
this.$message.success(`保单识别完成,已填充${filledCount}项`);
} else {
this.$message.warning('保单识别完成,未匹配到模板字段,请手动填写保险信息');
}
})
.catch(() => {
this.$message.warning('文件上传成功,自动识别失败,请手动填写保险信息');
})
.finally(() => {
loading.close();
});
},
applyTemplateRecognition(data = {}) {
const template = this.ocrTemplateOptions.find(item => item.value === this.form.ocrTemplate);
const mappings = this.parseTemplateMappings(template?.mappingConfig);
const words = this.getGeneralOcrWords(data);
const fieldPropMap = {
保险类型: 'insuranceType',
保单号: 'policyNo',
开始日期: 'startDate',
结束日期: 'endDate',
保额: 'insuredAmount',
保费: 'premium',
发票号: 'invoiceNo',
开票日期: 'invoiceDate',
备注: 'remark',
};
let filledCount = 0;
mappings.forEach(item => {
const prop = fieldPropMap[item.key];
const value = this.getMappedOcrValue(words, item.value);
if (!prop || !value) return;
this.form[prop] = this.normalizeRecognizedValue(prop, value);
filledCount += 1;
});
return filledCount;
},
parseTemplateMappings(mappingConfig) {
try {
const mappings = JSON.parse(mappingConfig || '[]');
return Array.isArray(mappings)
? mappings.filter(
item => String(item?.key || '').trim() && String(item?.value || '').trim()
)
: [];
} catch (error) {
return [];
}
},
getGeneralOcrWords(data = {}) {
const wordsResult = data.words_result || data.wordsResult || [];
if (Array.isArray(wordsResult)) {
return wordsResult.map(item => item.words || item.value || '').filter(Boolean);
}
return Object.entries(wordsResult)
.flatMap(([key, value]) => [
key,
typeof value === 'object' ? value.words || value.value || '' : value,
])
.filter(Boolean);
},
getMappedOcrValue(words = [], mappingLabel = '') {
const label = String(mappingLabel || '').trim();
if (!label) return '';
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const normalizedWords = words.map(word => String(word || '').trim()).filter(Boolean);
const exactIndex = normalizedWords.findIndex(word =>
new RegExp(`^${escapedLabel}\\s*[:]?$`).test(word)
);
if (exactIndex >= 0) return normalizedWords[exactIndex + 1] || '';
const valueLine = normalizedWords.find(word =>
new RegExp(`${escapedLabel}\\s*[:]?\\s*(.+)$`).test(word)
);
if (!valueLine) return '';
return valueLine.replace(new RegExp(`^.*?${escapedLabel}\\s*[:]?\\s*`), '').trim();
},
normalizeRecognizedValue(prop, value) {
const text = String(value || '').trim();
if (['startDate', 'endDate', 'invoiceDate'].includes(prop)) {
const match = text.match(/(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})日?/);
return match
? `${match[1]}-${match[2].padStart(2, '0')}-${match[3].padStart(2, '0')}`
: text;
}
if (['insuredAmount', 'premium'].includes(prop)) {
return text.replace(/,/g, '').replace(/[^\d.]/g, '');
}
if (prop === 'insuranceType') {
const typeRules = [
{ value: '交强险', pattern: /(交强险|交通事故责任强制保险)/ },
{ value: '商业险', pattern: /(商业险|商业保险)/ },
{ value: '承运人责任险', pattern: /承运人.*责任/ },
{ value: '货运险', pattern: /(货运险|货物运输保险)/ },
{ value: '船舶险', pattern: /(船舶险|船舶保险)/ },
];
return typeRules.find(item => item.pattern.test(text))?.value || text;
}
if (prop === 'remark') return text.slice(0, 200);
return text;
},
validateOcrUpload(file) {
if (!this.form.ocrTemplate) {
this.$message.warning('请先选择OCR识别模板');
return false;
}
const validType = ['image/jpeg', 'image/png', 'image/jpg', 'image/bmp'].includes(file.type);
const validSize = file.size / 1024 / 1024 < 5;
if (!validType) this.$message.error('仅支持 JPG、PNG、BMP 图片');
if (!validSize) this.$message.error('图片大小不能超过 5MB');
return validType && validSize;
},
},
};
</script>
<style lang="scss" scoped>
.insurance-record-page {
:deep(.el-table th .cell),
:deep(.el-table td .cell) {
white-space: nowrap;
}
&__vehicle-input {
width: 100%;
}
}
:global(.insurance-record-page__upload) {
display: flex;
align-items: center;
justify-content: space-between;
//min-height: 88px;
padding: 16px 28px 16px 22px;
border: 1px dashed #8b8b8b;
border-radius: 8px;
background: #fff;
}
:global(.insurance-record-page__upload-info) {
display: flex;
align-items: center;
min-width: 0;
}
:global(.insurance-record-page__upload-icon) {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
margin-right: 18px;
color: #707070;
}
:global(.insurance-record-page__upload-icon::before),
:global(.insurance-record-page__upload-icon::after) {
position: absolute;
width: 11px;
height: 11px;
content: '';
}
:global(.insurance-record-page__upload-icon::before) {
top: 0;
left: 0;
border-top: 3px solid currentColor;
border-left: 3px solid currentColor;
border-radius: 3px 0 0;
}
:global(.insurance-record-page__upload-icon::after) {
right: 0;
bottom: 0;
border-right: 3px solid currentColor;
border-bottom: 3px solid currentColor;
border-radius: 0 0 3px;
}
:global(.insurance-record-page__upload-card) {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 22px;
border: 3px solid currentColor;
border-radius: 3px;
}
:global(.insurance-record-page__upload-card::before),
:global(.insurance-record-page__upload-card::after) {
position: absolute;
content: '';
}
:global(.insurance-record-page__upload-card::before) {
top: 5px;
left: 7px;
width: 6px;
height: 6px;
border: 2px solid currentColor;
border-radius: 2px;
}
:global(.insurance-record-page__upload-card::after) {
top: 6px;
right: 5px;
width: 6px;
height: 2px;
background: currentColor;
box-shadow: 0 6px 0 currentColor;
}
:global(.insurance-record-page__upload-card span) {
position: absolute;
right: 5px;
bottom: 4px;
width: 8px;
height: 2px;
background: currentColor;
}
:global(.insurance-record-page__upload-text) {
display: flex;
flex-direction: column;
gap: 4px;
color: #303133;
line-height: 1.25;
}
:global(.insurance-record-page__upload-text strong) {
font-weight: 700;
}
:global(.insurance-record-page__upload-text span) {
color: #303133;
font-weight: 500;
}
:global(.insurance-record-page__upload-action) {
flex: 0 0 auto;
margin-left: 18px;
}
:global(.insurance-record-page__upload-action .el-upload) {
display: block;
}
@media (max-width: 768px) {
:global(.insurance-record-page__upload) {
align-items: stretch;
flex-direction: column;
gap: 12px;
padding: 16px;
}
:global(.insurance-record-page__upload-action) {
margin-left: 0;
}
:global(.insurance-record-page__upload-button) {
width: 100%;
}
}
</style>