feat(web-admin): 三方车辆/人员粘贴识别弹窗与入口

共享识别预览弹窗,支持冲突三选一,并在三方车辆与三方驾押人员工具栏接入。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-25 12:54:50 +08:00
1 parent c60ebfc082
commit 6258821273
3 files changed
+460 -2

No files matched your search

@@ -0,0 +1,430 @@
<!-- eslint-disable vue/no-v-model-argument -->
<template>
<el-dialog
v-model="innerVisible"
:title="dialogTitle"
width="820px"
align-center
destroy-on-close
append-to-body
class="dialog-form-layout pl-paste-recognize-dialog"
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
style="backdrop-filter: none"
@closed="handleClosed"
>
<el-form label-position="top" class="form-grid-2">
<el-form-item label="客户原文" class="form-col-span-2">
<el-input
v-model="rawText"
type="textarea"
:rows="8"
placeholder="粘贴客户提供的车辆与驾押信息后点击识别"
/>
</el-form-item>
<el-form-item class="form-col-span-2">
<el-button type="primary" @click="handleParse">
<el-icon><MagicStick /></el-icon>识别
</el-button>
</el-form-item>
<template v-if="parsed">
<el-form-item v-if="showVehicleFields" label="承运商" class="form-col-span-2" required>
<el-select
v-model="carrierId"
placeholder="请选择承运商"
filterable
clearable
style="width: 100%"
>
<el-option
v-for="c in carrierOptions"
:key="c.id"
:label="c.carrierName"
:value="c.id"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="mode === 'personnel-first' && hasPlate"
label="建车选项"
class="form-col-span-2"
>
<el-checkbox v-model="createVehicle">一并创建三方车辆并关联</el-checkbox>
</el-form-item>
<el-form-item v-if="showVehicleFields" label="主车号">
<el-input v-model="vehicleForm.licensePlate" maxlength="20" />
</el-form-item>
<el-form-item v-if="showVehicleFields" label="挂车号">
<el-input v-model="vehicleForm.trailerNo" maxlength="20" clearable />
</el-form-item>
<el-form-item v-if="showVehicleFields" label="核载(吨)">
<el-input-number
v-model="vehicleForm.loadCapacity"
:min="0"
:precision="2"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="驾驶员">
<el-input v-model="driverForm.personnelName" maxlength="100" />
</el-form-item>
<el-form-item label="驾驶员手机">
<el-input v-model="driverForm.phoneNo" maxlength="32" clearable />
</el-form-item>
<el-form-item label="驾驶员身份证" class="form-col-span-2">
<el-input v-model="driverForm.idCardNo" maxlength="32" clearable />
</el-form-item>
<el-form-item label="押运员">
<el-input v-model="escortForm.personnelName" maxlength="100" />
</el-form-item>
<el-form-item label="押运员电话">
<el-input v-model="escortForm.phoneNo" maxlength="32" clearable />
</el-form-item>
<el-form-item label="押运员身份证" class="form-col-span-2">
<el-input v-model="escortForm.idCardNo" maxlength="32" clearable />
</el-form-item>
<div v-if="conflictRows.length" class="form-col-span-2 paste-conflict-block">
<h4 class="paste-conflict-title">冲突处理</h4>
<div v-for="row in conflictRows" :key="row.key" class="paste-conflict-row">
<div class="paste-conflict-label">
{{ row.label }}已存在:{{ row.existingLabel }}
</div>
<el-radio-group v-model="row.action">
<el-radio value="reuse">复用</el-radio>
<el-radio value="overwrite">覆盖更新</el-radio>
<el-radio value="cancel">取消</el-radio>
</el-radio-group>
</div>
</div>
<el-alert
v-if="parsed.warnings.length"
class="form-col-span-2"
type="warning"
:closable="false"
:title="`有 ${parsed.warnings.length} 行未识别`"
:description="parsed.warnings.join(';')"
/>
</template>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="innerVisible = false">
<el-icon><Close /></el-icon>取消
</el-button>
<el-button type="primary" :loading="submitLoading" :disabled="!parsed" @click="handleSubmit">
<el-icon><Check /></el-icon>确认生成
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { Check, Close, MagicStick } from '@element-plus/icons-vue'
import {
carrierApi,
pasteRecognizeApi,
personnelApi,
vehicleApi,
type Carrier,
type PasteConflictAction,
type PasteConflictItem
} from '@/api/pl'
import { pickBxPageResult } from '@/utils/http/bx'
import {
parsePlCustomerVehicleText,
type PlPasteRecognizeResult
} from '@/utils/pl-paste-recognize'
const props = withDefaults(
defineProps<{
visible: boolean
mode: 'vehicle-bundle' | 'personnel-first'
carrierOptions?: Carrier[]
}>(),
{
carrierOptions: () => []
}
)
const emit = defineEmits<{
'update:visible': [boolean]
done: []
}>()
const innerVisible = computed({
get: () => props.visible,
set: (v: boolean) => emit('update:visible', v)
})
const dialogTitle = computed(() =>
props.mode === 'vehicle-bundle' ? '粘贴识别(整车套装)' : '粘贴识别(以人为主)'
)
const rawText = ref('')
const parsed = ref<PlPasteRecognizeResult | null>(null)
const carrierId = ref('')
const createVehicle = ref(true)
const submitLoading = ref(false)
const localCarriers = ref<Carrier[]>([])
const vehicleForm = reactive({
licensePlate: '',
trailerNo: '',
loadCapacity: undefined as number | undefined
})
const driverForm = reactive({
personnelName: '',
phoneNo: '',
idCardNo: ''
})
const escortForm = reactive({
personnelName: '',
phoneNo: '',
idCardNo: ''
})
type ConflictRow = {
key: 'vehicle' | 'driver' | 'escort'
label: string
existingId: string
existingLabel: string
action: PasteConflictAction
}
const conflictRows = ref<ConflictRow[]>([])
const carrierOptions = computed(() =>
props.carrierOptions.length ? props.carrierOptions : localCarriers.value
)
const hasPlate = computed(() => Boolean(vehicleForm.licensePlate.trim()))
const showVehicleFields = computed(() => {
if (props.mode === 'vehicle-bundle') return true
return createVehicle.value && hasPlate.value
})
async function ensureCarriers() {
if (props.carrierOptions.length) return
if (localCarriers.value.length) return
const res = await carrierApi.list({ page: 1, pageSize: 100 })
const page = pickBxPageResult<Carrier>(res)
localCarriers.value = page.list || []
}
async function handleParse() {
const result = parsePlCustomerVehicleText(rawText.value)
parsed.value = result
vehicleForm.licensePlate = result.vehicle?.licensePlate || ''
vehicleForm.trailerNo = result.vehicle?.trailerNo || ''
vehicleForm.loadCapacity = result.vehicle?.loadCapacity
driverForm.personnelName = result.driver?.personnelName || ''
driverForm.phoneNo = result.driver?.phoneNo || ''
driverForm.idCardNo = result.driver?.idCardNo || ''
escortForm.personnelName = result.escort?.personnelName || ''
escortForm.phoneNo = result.escort?.phoneNo || ''
escortForm.idCardNo = result.escort?.idCardNo || ''
conflictRows.value = []
createVehicle.value = Boolean(result.vehicle?.licensePlate)
if (!result.vehicle?.licensePlate && !result.driver && !result.escort) {
ElMessage.warning('未能识别有效内容,请检查原文')
return
}
await detectConflicts()
}
async function detectConflicts() {
const rows: ConflictRow[] = []
const needVehicle = showVehicleFields.value
if (needVehicle && vehicleForm.licensePlate.trim()) {
const res = await vehicleApi.list({
q: vehicleForm.licensePlate.trim(),
vehicleType: 'carrier',
page: 1,
pageSize: 20
})
const page = pickBxPageResult<{ id: string; licensePlate: string }>(res)
const hit = (page.list || []).find(
v => (v.licensePlate || '').trim() === vehicleForm.licensePlate.trim()
)
if (hit) {
rows.push({
key: 'vehicle',
label: '车牌',
existingId: hit.id,
existingLabel: hit.licensePlate,
action: 'reuse'
})
}
}
async function findPersonnelByIdCard(idCardNo: string, key: 'driver' | 'escort', label: string) {
const card = idCardNo.trim()
if (!card) return
const res = await personnelApi.list({
q: card,
personnelAffiliation: 'carrier',
page: 1,
pageSize: 50
})
const page = pickBxPageResult<{ id: string; personnelName: string; idCardNo?: string }>(res)
const hit = (page.list || []).find(
p => (p.idCardNo || '').trim().toUpperCase() === card.toUpperCase()
)
if (hit) {
rows.push({
key,
label,
existingId: hit.id,
existingLabel: `${hit.personnelName}(${hit.idCardNo})`,
action: 'reuse'
})
}
}
await findPersonnelByIdCard(driverForm.idCardNo, 'driver', '驾驶员')
await findPersonnelByIdCard(escortForm.idCardNo, 'escort', '押运员')
conflictRows.value = rows
}
async function handleSubmit() {
if (!parsed.value) return
if (props.mode === 'vehicle-bundle' && !vehicleForm.licensePlate.trim()) {
ElMessage.warning('请填写主车车牌')
return
}
if (showVehicleFields.value && !carrierId.value) {
ElMessage.warning('请选择承运商')
return
}
if (
props.mode === 'personnel-first' &&
!driverForm.personnelName.trim() &&
!escortForm.personnelName.trim()
) {
ElMessage.warning('请至少填写一名人员')
return
}
submitLoading.value = true
try {
await ensureCarriers()
if (!conflictRows.value.length) {
await detectConflicts()
}
if (conflictRows.value.some(r => r.action === 'cancel')) {
ElMessage.info('已取消本次生成')
return
}
const conflicts: PasteConflictItem[] = conflictRows.value.map(r => ({
key: r.key,
existingId: r.existingId,
action: r.action
}))
const carrier = carrierOptions.value.find(c => c.id === carrierId.value)
const needVehicle = showVehicleFields.value
await pasteRecognizeApi.commit({
mode: props.mode === 'vehicle-bundle' ? 'vehicle_bundle' : 'personnel_first',
createVehicle: needVehicle,
vehicle: needVehicle
? {
licensePlate: vehicleForm.licensePlate.trim(),
trailerNo: vehicleForm.trailerNo || undefined,
loadCapacity: vehicleForm.loadCapacity ?? undefined,
carrierId: carrierId.value,
carrierName: carrier?.carrierName
}
: null,
driver: driverForm.personnelName.trim()
? {
personnelName: driverForm.personnelName.trim(),
phoneNo: driverForm.phoneNo || undefined,
idCardNo: driverForm.idCardNo || undefined,
personnelType: 'driver'
}
: null,
escort: escortForm.personnelName.trim()
? {
personnelName: escortForm.personnelName.trim(),
phoneNo: escortForm.phoneNo || undefined,
idCardNo: escortForm.idCardNo || undefined,
personnelType: 'escort'
}
: null,
conflicts
})
ElMessage.success('生成成功')
emit('done')
innerVisible.value = false
} catch (e: any) {
const msg = e?.response?.data?.detail || e?.message || '生成失败'
ElMessage.error(typeof msg === 'string' ? msg : '生成失败')
} finally {
submitLoading.value = false
}
}
function handleClosed() {
rawText.value = ''
parsed.value = null
carrierId.value = ''
createVehicle.value = true
conflictRows.value = []
vehicleForm.licensePlate = ''
vehicleForm.trailerNo = ''
vehicleForm.loadCapacity = undefined
driverForm.personnelName = ''
driverForm.phoneNo = ''
driverForm.idCardNo = ''
escortForm.personnelName = ''
escortForm.phoneNo = ''
escortForm.idCardNo = ''
}
watch(
() => props.visible,
async v => {
if (v) await ensureCarriers()
}
)
</script>
<style scoped lang="scss">
.paste-conflict-block {
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
padding: 12px 14px;
background: var(--mint-bg, #f8fafc);
}
.paste-conflict-title {
margin: 0 0 10px;
font-size: 14px;
font-weight: 600;
}
.paste-conflict-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.paste-conflict-label {
min-width: 220px;
color: var(--mint-text-muted, #64748b);
font-size: 13px;
}
</style>