This commit is contained in:
zlwu committed 2026-08-21 09:35:11 +08:00
1 parent b9ff9c9d67
commit 44749ae716
87 files changed
+35089 -21918

No files matched your search

+3
View File
@@ -495,6 +495,9 @@ export function fetchRoleList(params?: { page?: number; pageSize?: number; keywo
totalPages: number
}>({ url: '/web/admin/role-management', params })
}
export function fetchRoleDetail(id: string) {
return request.get<Api.Role.RoleItem>({ url: `/web/admin/role-management/${id}` })
}
export function fetchCreateRole(data: {
roleName: string
dataScope?: string
@@ -248,13 +248,13 @@
} from '@/utils/pl-cert-slots'
import { matchRequiredCertName } from '@/utils/pl-personnel-cert'
import {
PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES,
PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES,
PL_VEHICLE_CERT_PART_HEAD,
PL_VEHICLE_CERT_PART_TRAILER,
normalizeVehicleCertPart
normalizeVehicleCertPart,
shouldRequireVehicleTrailerCerts,
shouldShowVehicleTrailerCertSlots
} from '@/utils/pl-vehicle-cert'
type CertRowWithKey = CertRowWithMeta & { _key: string }
@@ -308,7 +308,7 @@
)
const headPlateLabel = computed(() => props.licensePlate?.trim() || '—')
const hasTrailerPlateOption = computed(() => Boolean(props.trailerNo?.trim()))
const hasTrailerPlateOption = computed(() => shouldShowVehicleTrailerCertSlots(props.trailerNo))
const vehiclePlateOptions = computed(() => {
const options = [
@@ -339,15 +339,17 @@
return props.requiredCertNames
}
if (!props.enforceSelfVehicleRequired && !props.enforceCarrierVehicleRequired) return []
const part = normalizeVehicleCertPart(row.vehiclePart)
// 三方仅展示槽位,不按名称强制必填(依赖 _required=false)
if (props.enforceCarrierVehicleRequired) {
return part === PL_VEHICLE_CERT_PART_TRAILER
? PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
: PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES
return []
}
return part === PL_VEHICLE_CERT_PART_TRAILER
? PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
: PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES
const part = normalizeVehicleCertPart(row.vehiclePart)
if (part === PL_VEHICLE_CERT_PART_TRAILER) {
return shouldRequireVehicleTrailerCerts('self', props.trailerNo)
? PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
: []
}
return PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES
}
function isRowRequired(row: CertRowWithKey) {
@@ -57,8 +57,13 @@
<el-form-item label="主车号">
<el-input v-model="vehicleForm.licensePlate" maxlength="20" />
</el-form-item>
<el-form-item label="挂车号">
<el-input v-model="vehicleForm.trailerNo" maxlength="20" clearable />
<el-form-item label="挂车号" required>
<el-input
v-model="vehicleForm.trailerNo"
placeholder="必填;无挂车可填「货拉拉」"
maxlength="20"
clearable
/>
</el-form-item>
<el-form-item label="核载(吨)">
<el-input-number
@@ -310,6 +315,10 @@
ElMessage.warning('请填写主车车牌')
return
}
if (showVehicleFields.value && !vehicleForm.trailerNo.trim()) {
ElMessage.warning('请填写挂车号')
return
}
if (
props.mode === 'personnel-first' &&
!driverForm.personnelName.trim() &&
@@ -345,7 +354,7 @@
vehicle: needVehicle
? {
licensePlate: vehicleForm.licensePlate.trim(),
trailerNo: vehicleForm.trailerNo || undefined,
trailerNo: vehicleForm.trailerNo.trim(),
loadCapacity: vehicleForm.loadCapacity ?? undefined,
carrierId: carrierId.value || undefined,
carrierName: carrier?.carrierName
@@ -30,6 +30,7 @@ import {
PL_VEHICLE_CERT_PART_HEAD,
PL_VEHICLE_CERT_PART_TRAILER,
getVehiclePartRequiredCertBadges,
shouldShowVehicleTrailerCertSlots,
type PlVehicleCertCheckType
} from '@/utils/pl-vehicle-cert'
@@ -46,7 +47,7 @@ const props = withDefaults(
}
)
const hasTrailer = computed(() => Boolean(props.trailerNo?.trim()))
const hasTrailer = computed(() => shouldShowVehicleTrailerCertSlots(props.trailerNo))
const badgeGroups = computed(() => {
const groups = [
@@ -61,7 +61,8 @@ import {
PL_VEHICLE_CERT_PART_HEAD,
PL_VEHICLE_CERT_PART_TRAILER,
mergeVehicleCertsByPart,
splitVehicleCertsByPart
splitVehicleCertsByPart,
shouldShowVehicleTrailerCertSlots
} from '@/utils/pl-vehicle-cert'
type CertRowWithKey = CertRow & { _key?: string }
@@ -98,7 +99,7 @@ const unifiedCertFormRef = ref<CertFormListRef>()
const headCertFormRef = ref<CertFormListRef>()
const trailerCertFormRef = ref<CertFormListRef>()
const hasTrailer = computed(() => Boolean(props.trailerNo?.trim()))
const hasTrailer = computed(() => shouldShowVehicleTrailerCertSlots(props.trailerNo))
const headTabLabel = computed(() => props.licensePlate?.trim() || '主车')
const trailerTabLabel = computed(() => props.trailerNo?.trim() || '挂车')
@@ -48,6 +48,7 @@ import {
PL_VEHICLE_CERT_PART_TRAILER,
getVehiclePartCertMissingSuffix,
type PlVehicleCertCheckType,
shouldShowVehicleTrailerCertSlots,
splitVehicleCertsByPart
} from '@/utils/pl-vehicle-cert'
@@ -89,7 +90,7 @@ const trailerPartName = computed(
`${PL_VEHICLE_CERT_PART_TRAILER}-${props.vehicleId || props.trailerNo || props.licensePlate || 'vehicle'}`
)
const hasTrailer = computed(() => Boolean(props.trailerNo?.trim()))
const hasTrailer = computed(() => shouldShowVehicleTrailerCertSlots(props.trailerNo))
const headTabLabel = computed(() => props.licensePlate?.trim() || '主车')
const trailerTabLabel = computed(() => props.trailerNo?.trim() || '挂车')
@@ -30,7 +30,7 @@
<el-form-item label="挂车号" prop="trailerNo">
<el-input
v-model="formData.trailerNo"
placeholder="请输入挂车号"
placeholder="必填;无挂车可填「货拉拉」"
maxlength="20"
clearable
/>
@@ -167,7 +167,7 @@
:cert-name-options="certNameOptions"
layout="table"
:enforce-self-vehicle-required="props.vehicleType === 'self'"
:enforce-carrier-vehicle-required="false"
:enforce-carrier-vehicle-required="props.vehicleType === 'carrier'"
/>
</el-form>
@@ -271,7 +271,8 @@
const formRules = computed(() => {
const rules: Record<string, unknown[]> = {
licensePlate: [{ required: true, message: '请输入车牌号', trigger: 'blur' }]
licensePlate: [{ required: true, message: '请输入车牌号', trigger: 'blur' }],
trailerNo: [{ required: true, message: '请输入挂车号', trigger: 'blur' }]
}
if (props.vehicleType === 'self') {
rules.vehicleStatus = [{ required: true, message: '请选择车辆状态', trigger: 'change' }]
File diff suppressed because it is too large. Load diff
@@ -31,7 +31,7 @@
item.customTitle || formatMenuTitle(item.title)
}}</span>
<span
v-if="list.length > 1 && !item.fixedTab"
v-if="list.length > 1 && store.isTabClosable(item)"
class="worktab-close inline-flex flex-cc relative rounded-full tad-200"
@click.stop="closeWorktab('current', item.path)"
>
+3 -1
View File
@@ -8,6 +8,7 @@ export const MENU_BADGE_CODES = {
PL_VEHICLE_UREA_DISBURSE: 'pl:vehicle-urea-disburse:list',
PL_PLAN_CERT: 'pl:plan-cert:list',
PL_PLAN_ARCHIVE: 'pl:plan-archive:list',
PL_PLAN_CONTRACT: 'pl:plan-contract:list',
BX_ADVANCE_APPROVE: 'bx:advanceApprove:list',
BX_REIMBURSE_APPROVE: 'bx:reimburseApprove:list',
BX_VIEW_TODO: 'bx:viewTodo:list',
@@ -29,5 +30,6 @@ export const PL_MENU_BADGE_CODES = [
MENU_BADGE_CODES.PL_VEHICLE_MAINTENANCE_PROCESS,
MENU_BADGE_CODES.PL_VEHICLE_UREA_DISBURSE,
MENU_BADGE_CODES.PL_PLAN_CERT,
MENU_BADGE_CODES.PL_PLAN_ARCHIVE
MENU_BADGE_CODES.PL_PLAN_ARCHIVE,
MENU_BADGE_CODES.PL_PLAN_CONTRACT
] as const
+31 -4
View File
@@ -93,13 +93,31 @@ export const useWorktabStore = defineStore(
return opened.value.find((tab) => tab.path === path)
}
/** 门户首页(不可关闭、不可取消固定) */
const isHomeTab = (tab: Partial<WorkTab>): boolean => {
const home = HOME_PAGE_PATH || '/home'
return tab.path === home || tab.name === 'PortalHome'
}
/**
* 检查标签页是否可关闭
*/
const isTabClosable = (tab: WorkTab): boolean => {
if (isHomeTab(tab)) return false
return !tab.fixedTab
}
/** 确保首页始终为固定标签(修复持久化丢失 fixedTab 的情况) */
const ensureHomeTabFixed = (): void => {
const home = HOME_PAGE_PATH || '/home'
opened.value = opened.value.map((tab) =>
isHomeTab(tab) && !tab.fixedTab ? { ...tab, path: tab.path || home, fixedTab: true } : tab
)
if (current.value.path && isHomeTab(current.value) && !current.value.fixedTab) {
current.value = { ...current.value, fixedTab: true }
}
}
/**
* 过滤不应展示在 worktab 中的父级占位路由。
* `/system` 仅用于承载与重定向,真实标签页为其子路由(如 `/system/info/tenant`)。
@@ -247,7 +265,8 @@ export const useWorktabStore = defineStore(
}
if (existingIndex === -1) {
const newTab = { ...tab, fixedTab: singlePage ? false : tab.fixedTab }
const shouldFix = !singlePage && (Boolean(tab.fixedTab) || isHomeTab(tab))
const newTab = { ...tab, fixedTab: shouldFix }
if (singlePage) {
markTabsToRemove(opened.value.filter((t) => t.path !== newTab.path))
@@ -261,9 +280,9 @@ export const useWorktabStore = defineStore(
return
}
const insertIndex = tab.fixedTab ? findFixedTabInsertIndex() : opened.value.length
const insertIndex = shouldFix ? findFixedTabInsertIndex() : opened.value.length
if (tab.fixedTab) {
if (shouldFix) {
opened.value.splice(insertIndex, 0, newTab)
} else {
opened.value.push(newTab)
@@ -275,6 +294,8 @@ export const useWorktabStore = defineStore(
} else {
// 更新现有标签页(当动态路由参数或查询变更时,复用同一标签)
const existingTab = opened.value[existingIndex]
const shouldFix =
!singlePage && (Boolean(tab.fixedTab) || Boolean(existingTab.fixedTab) || isHomeTab(tab))
opened.value[existingIndex] = {
...existingTab,
@@ -282,7 +303,7 @@ export const useWorktabStore = defineStore(
params: tab.params,
query: tab.query,
title: tab.title || existingTab.title,
fixedTab: singlePage ? false : (tab.fixedTab ?? existingTab.fixedTab),
fixedTab: shouldFix,
keepAlive: tab.keepAlive ?? existingTab.keepAlive,
name: tab.name || existingTab.name,
icon: tab.icon || existingTab.icon
@@ -563,6 +584,10 @@ export const useWorktabStore = defineStore(
}
const tab = { ...opened.value[targetIndex] }
// 首页始终固定,不允许取消
if (isHomeTab(tab) && tab.fixedTab) {
return
}
tab.fixedTab = !tab.fixedTab
// 移除原位置
@@ -620,6 +645,8 @@ export const useWorktabStore = defineStore(
opened.value = validTabs
}
ensureHomeTabFixed()
if (isSinglePageMode()) {
enforceSinglePageTabs()
} else {
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
CRM_INVOICE_PASTE_SAMPLE_ANQING,
CRM_INVOICE_PASTE_SAMPLE_JIANGFENG,
parseCrmInvoicePaste,
mergeInvoiceRemark
} from './crm-invoice-paste-recognize'
describe('parseCrmInvoicePaste · 安庆山峡样例', () => {
it('maps core fields and packs extra into remarkParts', () => {
const r = parseCrmInvoicePaste(CRM_INVOICE_PASTE_SAMPLE_ANQING)
assert.equal(r.companyName, '安庆市山峡化工有限公司')
assert.equal(r.taxNo, '9134080006084212XH')
assert.equal(r.bankName, '中国建设银行安庆分行城中支行')
assert.equal(r.bankAccount, '34050168860800000121')
assert.equal(r.companyAddress, '安徽省安庆市高新区霞虹路5号')
assert.equal(r.phoneFax, '0556-5348918')
assert.ok(r.filledCount >= 6)
assert.ok(r.remarkParts.some((p) => p.includes('银行行号') && p.includes('105368000056')))
assert.ok(r.remarkParts.some((p) => p.includes('邮编') && p.includes('246000')))
assert.ok(r.remarkParts.some((p) => p.includes('邮寄') && p.includes('汪璇')))
// 邮寄块内地址不得覆盖单位地址
assert.equal(r.companyAddress, '安徽省安庆市高新区霞虹路5号')
})
})
describe('parseCrmInvoicePaste · 江峰生物样例', () => {
it('handles spaced labels, spaced account, and 地址及电话', () => {
const r = parseCrmInvoicePaste(CRM_INVOICE_PASTE_SAMPLE_JIANGFENG)
assert.equal(r.companyName, '安徽江峰生物科技有限公司')
assert.equal(r.taxNo, '91340200MA8PYJNB8X')
assert.equal(r.bankName, '中国建设银行芜湖经开区支行')
assert.equal(r.bankAccount, '34050167880800002481')
assert.ok((r.companyAddress || '').includes('安徽省芜湖市'))
assert.ok(!(r.companyAddress || '').includes('0553'))
assert.equal(r.phoneFax, '0553-5201019')
assert.ok(r.remarkParts.some((p) => p.includes('银行行号') && p.includes('105362050066')))
})
})
describe('parseCrmInvoicePaste · 边界', () => {
it('returns empty filledCount for blank text', () => {
const r = parseCrmInvoicePaste(' \n ')
assert.equal(r.filledCount, 0)
assert.ok(r.warnings.length > 0)
})
it('does not treat 名称 inside unrelated prose without colon as company', () => {
const r = parseCrmInvoicePaste('请查收开票资料,谢谢')
assert.equal(r.filledCount, 0)
})
})
describe('mergeInvoiceRemark', () => {
it('appends new parts without duplicating existing', () => {
const next = mergeInvoiceRemark('已有备注', ['银行行号:105', '邮编:246000'])
assert.equal(next, '已有备注;银行行号:105;邮编:246000')
const again = mergeInvoiceRemark(next, ['银行行号:105', '邮寄:张三'])
assert.equal(again, '已有备注;银行行号:105;邮编:246000;邮寄:张三')
})
it('returns existing when parts empty', () => {
assert.equal(mergeInvoiceRemark('x', []), 'x')
assert.equal(mergeInvoiceRemark('', ['a']), 'a')
})
})
@@ -0,0 +1,167 @@
export const CRM_INVOICE_PASTE_SAMPLE_ANQING = `开票资料:
公司名称:安庆市山峡化工有限公司
纳税人识别号:9134080006084212XH
开户行:中国建设银行安庆分行城中支行
帐号:34050168860800000121
银行行号:105368000056
地址:安徽省安庆市高新区霞虹路5号
电话/传真:0556-5348918
邮编:246000
发票邮寄地址:
姓名:汪璇
电话:18895395317
地址:安徽省安庆市高新区霞虹路5号`
export const CRM_INVOICE_PASTE_SAMPLE_JIANGFENG = `单位名:安徽江峰生物科技有限公司
税 号:91340200MA8PYJNB8X
地址及电话:安徽省芜湖市皖江江北新兴产业集中区沈巷片区起步区皖跃路与和睦路交叉口西南角福州路4号
银行开户行:中国建设银行芜湖经开区支行
账 号:3405 0167 8808 0000 2481
银行行号:1053 6205 0066
电话:0553-5201019`
export interface CrmInvoicePasteResult {
companyName?: string
taxNo?: string
companyAddress?: string
legalRepresentative?: string
agent?: string
phoneFax?: string
bankName?: string
bankAccount?: string
remarkParts: string[]
warnings: string[]
filledCount: number
}
function normalizeSpaces(s: string): string {
return (s || '').replace(/\s+/g, ' ').trim()
}
function splitMailBlock(text: string): { main: string; mail: string } {
const idx = text.search(/发票邮寄地址\s*[::]?/)
if (idx < 0) return { main: text, mail: '' }
return {
main: text.slice(0, idx),
mail: text.slice(idx).replace(/^发票邮寄地址\s*[::]?/, '')
}
}
function pickLine(block: string, labelRe: RegExp): string {
const lines = block.split(/\r?\n/)
for (const raw of lines) {
const line = raw.trim()
if (!line) continue
const m = line.match(labelRe)
if (m && m[1] != null) return normalizeSpaces(m[1])
}
return ''
}
const PHONE_TAIL =
/((?:0\d{2,3}-?\d{7,8})|(?:1\d{10}))\s*$/
export function parseCrmInvoicePaste(raw: string): CrmInvoicePasteResult {
const warnings: string[] = []
const text = (raw || '').replace(/\r\n/g, '\n').trim()
if (!text) {
return { remarkParts: [], warnings: ['请先粘贴开票资料文本'], filledCount: 0 }
}
const { main, mail } = splitMailBlock(text)
const remarkParts: string[] = []
const companyName = pickLine(
main,
/^(?:公司名称|单位名称|单位名|名称)\s*[::]\s*(.+)$/
)
const taxNo = pickLine(
main,
/^(?:纳税人识别号|纳税识别号|统一社会信用代码|税\s*号|税号)\s*[::]\s*([0-9A-Za-z]+)\s*$/
)
const bankName = pickLine(
main,
/^(?:银行开户行|开户银行|开户行)\s*[::]\s*(.+)$/
)
let bankAccount = pickLine(
main,
/^(?:银行账号|账\s*号|帐\s*号|账号|帐号)\s*[::]\s*([0-9\s]+)\s*$/
)
if (bankAccount) bankAccount = bankAccount.replace(/\s+/g, '')
let companyAddress = ''
let phoneFax = ''
const addrPhone = pickLine(main, /^(?:地址及电话)\s*[::]\s*(.+)$/)
if (addrPhone) {
const m = addrPhone.match(PHONE_TAIL)
if (m && m.index != null) {
phoneFax = m[1]
companyAddress = normalizeSpaces(addrPhone.slice(0, m.index))
} else {
companyAddress = addrPhone
}
} else {
companyAddress = pickLine(main, /^(?:单位地址|地\s*址|地址)\s*[::]\s*(.+)$/)
}
if (!phoneFax) {
phoneFax =
pickLine(main, /^(?:电话\/传真|电话传真|传真)\s*[::]\s*(.+)$/) ||
pickLine(main, /^(?:电\s*话|电话)\s*[::]\s*(.+)$/)
}
const legalRepresentative = pickLine(
main,
/^(?:法定代表人|法人代表|法人)\s*[::]\s*(.+)$/
)
const agent = pickLine(main, /^(?:委托代理人|代理人)\s*[::]\s*(.+)$/)
const bankCode = pickLine(main, /^(?:银行行号|联行号|行号)\s*[::]\s*([0-9\s]+)\s*$/)
if (bankCode) remarkParts.push(`银行行号:${bankCode.replace(/\s+/g, '')}`)
const zip = pickLine(main, /^(?:邮\s*编|邮编)\s*[::]\s*(\d{6})\s*$/)
if (zip) remarkParts.push(`邮编:${zip}`)
if (mail.trim()) {
const name = pickLine(mail, /^(?:姓\s*名|姓名)\s*[::]\s*(.+)$/)
const phone = pickLine(mail, /^(?:电\s*话|电话|手机)\s*[::]\s*(.+)$/)
const addr = pickLine(mail, /^(?:地\s*址|地址)\s*[::]\s*(.+)$/)
const bits = [name && `收件人 ${name}`, phone, addr].filter(Boolean) as string[]
if (bits.length) remarkParts.push(`邮寄:${bits.join(' / ')}`)
}
const fields = {
companyName: companyName || undefined,
taxNo: taxNo || undefined,
companyAddress: companyAddress || undefined,
legalRepresentative: legalRepresentative || undefined,
agent: agent || undefined,
phoneFax: phoneFax || undefined,
bankName: bankName || undefined,
bankAccount: bankAccount || undefined
}
const filledCount = Object.values(fields).filter(Boolean).length
if (!companyName) warnings.push('未识别到单位名称')
if (!taxNo) warnings.push('未识别到税号')
if (filledCount === 0) warnings.push('未能识别任何开票字段,请检查格式或手动填写')
return { ...fields, remarkParts, warnings, filledCount }
}
export function mergeInvoiceRemark(existing: string | undefined, parts: string[]): string {
const base = (existing || '').trim()
const extras = (parts || []).map((p) => p.trim()).filter(Boolean)
if (!extras.length) return base
let next = base
for (const p of extras) {
if (!next) {
next = p
continue
}
if (next.includes(p)) continue
next = `${next};${p}`
}
return next
}
+81
View File
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
collectSelfVehicleRequiredCertErrors,
collectCarrierVehicleRequiredCertErrors,
ensureSelfVehicleCertSlots,
ensureCarrierVehicleCertSlots
} from './pl-cert-slots'
import {
isHuolalaTrailerNo,
shouldRequireVehicleTrailerCerts,
shouldShowVehicleTrailerCertSlots,
PL_VEHICLE_CERT_PART_TRAILER
} from './pl-vehicle-cert'
function completeSelfHeadCerts() {
return [
{ certName: '行驶证', expireDate: '2030-01-01', vehiclePart: 'head' },
{ certName: '营运证', expireDate: '2030-01-01', vehiclePart: 'head' },
{ certName: '保险单', expireDate: '2030-01-01', vehiclePart: 'head' }
]
}
describe('trailer cert rule helpers', () => {
it('detects 货拉拉 and gates trailer slot/require', () => {
assert.equal(isHuolalaTrailerNo('货拉拉'), true)
assert.equal(isHuolalaTrailerNo(' 货拉拉 '), true)
assert.equal(isHuolalaTrailerNo('皖LP359'), false)
assert.equal(shouldShowVehicleTrailerCertSlots('货拉拉'), false)
assert.equal(shouldShowVehicleTrailerCertSlots('皖LP359'), true)
assert.equal(shouldRequireVehicleTrailerCerts('self', '货拉拉'), false)
assert.equal(shouldRequireVehicleTrailerCerts('self', '皖LP359'), true)
assert.equal(shouldRequireVehicleTrailerCerts('carrier', '皖LP359'), false)
})
})
describe('ensure trailer cert slots', () => {
it('hides trailer slots for 货拉拉', () => {
const selfSlots = ensureSelfVehicleCertSlots([], '货拉拉')
assert.equal(
selfSlots.some(c => c.vehiclePart === PL_VEHICLE_CERT_PART_TRAILER),
false
)
const carrierSlots = ensureCarrierVehicleCertSlots([], '货拉拉')
assert.equal(
carrierSlots.some(c => c.vehiclePart === PL_VEHICLE_CERT_PART_TRAILER),
false
)
})
it('shows required trailer slots for self normal trailer', () => {
const slots = ensureSelfVehicleCertSlots([], '皖LP359')
const trailer = slots.filter(c => c.vehiclePart === PL_VEHICLE_CERT_PART_TRAILER)
assert.ok(trailer.length > 0)
assert.ok(trailer.every(c => c._required === true))
})
it('shows optional trailer slots for carrier normal trailer', () => {
const slots = ensureCarrierVehicleCertSlots([], '皖LP359')
const trailer = slots.filter(c => c.vehiclePart === PL_VEHICLE_CERT_PART_TRAILER)
assert.ok(trailer.length > 0)
assert.ok(trailer.every(c => c._required === false))
})
})
describe('collect trailer cert errors', () => {
it('skips trailer validation for self 货拉拉', () => {
const errors = collectSelfVehicleRequiredCertErrors(completeSelfHeadCerts(), '货拉拉')
assert.deepEqual(errors, [])
})
it('requires trailer certs for self normal trailer', () => {
const errors = collectSelfVehicleRequiredCertErrors(completeSelfHeadCerts(), '皖LP359')
assert.ok(errors.some(e => e.includes('挂车')))
})
it('does not require trailer certs for carrier', () => {
const errors = collectCarrierVehicleRequiredCertErrors([], '皖LP359')
assert.deepEqual(errors, [])
})
})
+40 -15
View File
@@ -10,7 +10,9 @@ import {
type PlVehicleCertCheckType,
getVehicleHeadRequiredCertNames,
getVehicleTrailerRequiredCertNames,
normalizeVehicleCertPart
normalizeVehicleCertPart,
shouldRequireVehicleTrailerCerts,
shouldShowVehicleTrailerCertSlots
} from '@/utils/pl-vehicle-cert'
export type CertRowWithMeta = CertRow & { _key?: string; _required?: boolean }
@@ -75,6 +77,11 @@ export function collectVehicleRequiredCertErrors(
trailerNo: string | null | undefined,
vehicleType: PlVehicleCertCheckType
): string[] {
// 三方车辆证件不强制
if (vehicleType === 'carrier') {
return []
}
const headCerts = certList.filter(
c => normalizeVehicleCertPart(c.vehiclePart) === PL_VEHICLE_CERT_PART_HEAD
)
@@ -87,7 +94,7 @@ export function collectVehicleRequiredCertErrors(
getVehicleHeadRequiredCertNames(vehicleType),
'主车'
),
...(trailerNo?.trim()
...(shouldRequireVehicleTrailerCerts(vehicleType, trailerNo)
? collectRequiredCertSlotErrors(
trailerCerts,
getVehicleTrailerRequiredCertNames(vehicleType),
@@ -131,10 +138,11 @@ function findCertIndexForRequired(
export function mergeCertListWithRequiredSlots(
existing: CertRowWithMeta[],
requiredNames: readonly string[],
options?: { vehiclePart?: string; stripManagedNames?: readonly string[] }
options?: { vehiclePart?: string; stripManagedNames?: readonly string[]; markRequired?: boolean }
): CertRowWithMeta[] {
const vehiclePart = options?.vehiclePart
const stripManagedNames = options?.stripManagedNames
const markRequired = options?.markRequired !== false
const normalizedPart = vehiclePart ? normalizeVehicleCertPart(vehiclePart) : null
let source = existing
@@ -167,13 +175,13 @@ export function mergeCertListWithRequiredSlots(
...next[idx],
certName: requiredName,
vehiclePart: normalizedPart || next[idx].vehiclePart || PL_VEHICLE_CERT_PART_HEAD,
_required: true
_required: markRequired
}
}
return createEmptyCertRow(
requiredName,
normalizedPart || PL_VEHICLE_CERT_PART_HEAD,
true
markRequired
)
})
@@ -187,18 +195,26 @@ export function mergeCertListWithRequiredSlots(
function ensureVehicleCertSlotsByNames(
certList: CertRowWithMeta[],
trailerNo: string | null | undefined,
headRequired: readonly string[],
trailerRequired: readonly string[]
headNames: readonly string[],
trailerNames: readonly string[],
options?: { headRequired?: boolean; trailerRequired?: boolean }
): CertRowWithMeta[] {
let merged = mergeCertListWithRequiredSlots(certList, headRequired, {
vehiclePart: PL_VEHICLE_CERT_PART_HEAD
const headRequired = options?.headRequired !== false
const trailerRequired = Boolean(options?.trailerRequired)
let merged = mergeCertListWithRequiredSlots(certList, headNames, {
vehiclePart: PL_VEHICLE_CERT_PART_HEAD,
markRequired: headRequired
})
if (trailerNo?.trim()) {
merged = mergeCertListWithRequiredSlots(merged, trailerRequired, {
vehiclePart: PL_VEHICLE_CERT_PART_TRAILER
if (shouldShowVehicleTrailerCertSlots(trailerNo)) {
merged = mergeCertListWithRequiredSlots(merged, trailerNames, {
vehiclePart: PL_VEHICLE_CERT_PART_TRAILER,
markRequired: trailerRequired
})
} else {
merged = stripTrailerRequiredSlots(merged, trailerRequired)
// 无挂车号或货拉拉:移除全部挂车侧证书行
merged = merged.filter(
cert => normalizeVehicleCertPart(cert.vehiclePart) !== PL_VEHICLE_CERT_PART_TRAILER
)
}
return merged
}
@@ -211,7 +227,11 @@ export function ensureSelfVehicleCertSlots(
certList,
trailerNo,
PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES,
{
headRequired: true,
trailerRequired: shouldRequireVehicleTrailerCerts('self', trailerNo)
}
)
}
@@ -223,7 +243,12 @@ export function ensureCarrierVehicleCertSlots(
certList,
trailerNo,
PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES,
{
// 三方主车/挂车证件均展示槽位但不强制
headRequired: false,
trailerRequired: false
}
)
}
@@ -0,0 +1,45 @@
import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
export type PlanContractDisplayLine = {
side: string
no: string
pending?: boolean
}
type PlanContractLike = {
contractId?: string
contractNo?: string
}
type PlanContractParties = {
supplierName?: string | null
customerName?: string | null
supplierContracts?: PlanContractLike[]
demanderContracts?: PlanContractLike[]
}
/** 单侧:有编号显示编号;非内置缺合同「待补充」;内置缺合同「—」 */
export function planContractSideLines(
side: '供' | '需',
contracts: PlanContractLike[] | undefined,
customerName?: string | null
): PlanContractDisplayLine[] {
const lines: PlanContractDisplayLine[] = []
for (const item of contracts || []) {
const no = item.contractNo || item.contractId
if (no) lines.push({ side, no: String(no) })
}
if (lines.length) return lines
if (isPlInternalCustomerName(customerName)) {
return [{ side, no: '—' }]
}
return [{ side, no: '待补充', pending: true }]
}
/** 合同列:供/需各至少一行 */
export function planContractLines(plan: PlanContractParties): PlanContractDisplayLine[] {
return [
...planContractSideLines('供', plan.supplierContracts, plan.supplierName),
...planContractSideLines('需', plan.demanderContracts, plan.customerName)
]
}
+27 -2
View File
@@ -25,8 +25,29 @@ export const PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES = ['行驶证', '营运
export const PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES = ['行驶证', '营运证'] as const
/** 特殊挂车号:不展示挂车证件槽位、不校验挂车证件 */
export const PL_HUOLALA_TRAILER_NO = '货拉拉'
export type PlVehicleCertCheckType = 'self' | 'carrier'
export function isHuolalaTrailerNo(trailerNo?: string | null): boolean {
return (trailerNo || '').trim() === PL_HUOLALA_TRAILER_NO
}
/** 有挂车号且非货拉拉时展示挂车证件区 */
export function shouldShowVehicleTrailerCertSlots(trailerNo?: string | null): boolean {
const text = (trailerNo || '').trim()
return Boolean(text) && !isHuolalaTrailerNo(text)
}
/** 仅山峡自有 + 正常挂车号时强制挂车证件 */
export function shouldRequireVehicleTrailerCerts(
vehicleType: PlVehicleCertCheckType,
trailerNo?: string | null
): boolean {
return vehicleType === 'self' && shouldShowVehicleTrailerCertSlots(trailerNo)
}
export const PL_VEHICLE_CERT_NAME_OPTIONS = [
'行驶证',
'营运证',
@@ -126,7 +147,11 @@ export function hasVehicleCertMissing(options: {
}): boolean {
const { vehicleType, certList = [], trailerNo } = options
const { headCerts, trailerCerts } = splitVehicleCertsByPart(certList)
const hasTrailer = Boolean(trailerNo?.trim())
// 三方车辆证件不强制,列表缺失后缀仅对自有生效
if (vehicleType === 'carrier') {
return false
}
if (
getMissingVehiclePartCertNames(headCerts, PL_VEHICLE_CERT_PART_HEAD, vehicleType).length > 0
@@ -135,7 +160,7 @@ export function hasVehicleCertMissing(options: {
}
if (
hasTrailer &&
shouldRequireVehicleTrailerCerts(vehicleType, trailerNo) &&
getMissingVehiclePartCertNames(trailerCerts, PL_VEHICLE_CERT_PART_TRAILER, vehicleType)
.length > 0
) {
@@ -207,6 +207,12 @@
<template v-if="!form.id">
<el-divider content-position="left">开票信息</el-divider>
<div class="form-grid-4">
<el-form-item label="快速识别">
<el-button class="btn-ghost" @click="openInvoicePasteDialog">
<el-icon><DocumentCopy /></el-icon>
快速识别
</el-button>
</el-form-item>
<el-form-item label="单位名称(章)">
<el-input
v-model="invoiceForm.companyName"
@@ -276,11 +282,49 @@
</div>
</template>
</el-dialog>
<el-dialog
v-model="invoicePasteDialog.visible"
title="快速识别开票信息"
width="640px"
class="dialog-form-layout"
align-center
append-to-body
modal-class="order-module-dialog-mask"
:close-on-click-modal="false"
@close="closeInvoicePasteDialog"
>
<div class="invoice-paste-panel">
<p class="invoice-paste-panel__tip">粘贴开票资料文本后点「识别并填入」,将覆盖新增表单中的开票信息对应字段</p>
<el-input
v-model="invoicePasteText"
type="textarea"
:rows="10"
placeholder="在此粘贴客户发来的开票资料…"
/>
</div>
<template #footer>
<div class="dialog-footer">
<el-button class="btn-ghost" @click="clearInvoicePaste">
<el-icon><Delete /></el-icon>
清空
</el-button>
<el-button class="btn-ghost" @click="closeInvoicePasteDialog">
<el-icon><Close /></el-icon>
取消
</el-button>
<el-button class="btn-main" type="primary" @click="handleInvoicePasteRecognize">
<el-icon><Check /></el-icon>
识别并填入
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, nextTick } from 'vue'
import { Close, Check, Refresh } from '@element-plus/icons-vue'
import { Close, Check, Refresh, DocumentCopy, Delete } from '@element-plus/icons-vue'
import type { FormItemRule } from 'element-plus'
import { ElMessage } from 'element-plus'
import {
@@ -292,6 +336,10 @@ import {
import type { CustomerForm as CustomerFormType } from '@/api/crm/customerTypes'
import { addInvoiceInfo } from '@/api/crm/invoiceInfo'
import type { InvoiceInfoForm } from '@/api/crm/invoiceInfo'
import {
parseCrmInvoicePaste,
mergeInvoiceRemark
} from '@/utils/crm-invoice-paste-recognize'
import { useDictStore } from '@/store/modules/dict'
import { useUserStore } from '@/store/modules/user'
import { fetchUserOptions } from '@/api/admin'
@@ -475,6 +523,60 @@ function resetInvoiceForm() {
}
}
const invoicePasteDialog = reactive({
visible: false
})
const invoicePasteText = ref('')
function openInvoicePasteDialog() {
invoicePasteText.value = ''
invoicePasteDialog.visible = true
}
function closeInvoicePasteDialog() {
invoicePasteDialog.visible = false
invoicePasteText.value = ''
}
function clearInvoicePaste() {
invoicePasteText.value = ''
}
function handleInvoicePasteRecognize() {
const parsed = parseCrmInvoicePaste(invoicePasteText.value)
if (!parsed.filledCount && !parsed.remarkParts.length) {
ElMessage.warning(parsed.warnings[0] || '未能识别,请检查格式或手动填写')
return
}
const keys = [
'companyName',
'taxNo',
'companyAddress',
'legalRepresentative',
'agent',
'phoneFax',
'bankName',
'bankAccount'
] as const
for (const k of keys) {
const v = parsed[k]
if (v) invoiceForm.value[k] = v
}
if (parsed.remarkParts.length) {
invoiceForm.value.remark = mergeInvoiceRemark(invoiceForm.value.remark, parsed.remarkParts)
}
const tip =
`已识别 ${parsed.filledCount} 个字段` +
(parsed.remarkParts.length ? `,另有 ${parsed.remarkParts.length} 项写入备注` : '') +
(parsed.warnings.length ? `;${parsed.warnings.join(';')}` : '')
if (parsed.warnings.length) {
ElMessage.warning(tip)
} else {
ElMessage.success(tip)
}
closeInvoicePasteDialog()
}
async function syncInvoiceInfoAfterCreate(customerId: string | number) {
if (!hasInvoiceInfoFilled()) return
@@ -635,4 +737,11 @@ defineExpose({ open })
min-width: 0;
}
}
.invoice-paste-panel__tip {
margin: 0 0 10px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
line-height: 1.5;
}
</style>
@@ -134,6 +134,26 @@
@close="handleClose"
>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="crm-dialog-form">
<div class="invoice-paste-panel">
<div class="invoice-paste-panel__hd">
<span class="invoice-paste-panel__title">粘贴识别</span>
<span class="invoice-paste-panel__tip">粘贴开票资料文本后点「识别并填入」,将覆盖下方对应字段</span>
</div>
<el-input
v-model="pasteText"
type="textarea"
:rows="5"
placeholder="在此粘贴客户发来的开票资料…"
/>
<div class="invoice-paste-panel__actions">
<el-button class="btn-ghost" @click="clearPaste">
<el-icon><Delete /></el-icon>清空
</el-button>
<el-button class="btn-main" type="primary" @click="handleRecognizePaste">
<el-icon><DocumentCopy /></el-icon>识别并填入
</el-button>
</div>
</div>
<div class="form-grid-2">
<el-form-item label="单位名称(章)" prop="companyName" class="form-col-span-2">
<el-input v-model="form.companyName" placeholder="请输入单位名称" maxlength="200" />
@@ -186,10 +206,14 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Plus, Edit, Delete, Star, Close, Check } from '@element-plus/icons-vue'
import { Plus, Edit, Delete, Star, Close, Check, DocumentCopy } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import request from '@/utils/http'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
parseCrmInvoicePaste,
mergeInvoiceRemark
} from '@/utils/crm-invoice-paste-recognize'
interface InvoiceInfoVO {
id: string | number
@@ -235,6 +259,7 @@ const dialog = ref({
})
const formRef = ref()
const pasteText = ref('')
const form = ref<InvoiceInfoForm>({
id: undefined,
customerId: '',
@@ -355,6 +380,44 @@ const handleClose = () => {
reset()
}
const clearPaste = () => {
pasteText.value = ''
}
const handleRecognizePaste = () => {
const parsed = parseCrmInvoicePaste(pasteText.value)
if (!parsed.filledCount && !parsed.remarkParts.length) {
ElMessage.warning(parsed.warnings[0] || '未能识别,请检查格式或手动填写')
return
}
const keys = [
'companyName',
'taxNo',
'companyAddress',
'legalRepresentative',
'agent',
'phoneFax',
'bankName',
'bankAccount'
] as const
for (const k of keys) {
const v = parsed[k]
if (v) form.value[k] = v
}
if (parsed.remarkParts.length) {
form.value.remark = mergeInvoiceRemark(form.value.remark, parsed.remarkParts)
}
const tip =
`已识别 ${parsed.filledCount} 个字段` +
(parsed.remarkParts.length ? `,另有 ${parsed.remarkParts.length} 项写入备注` : '') +
(parsed.warnings.length ? `;${parsed.warnings.join(';')}` : '')
if (parsed.warnings.length) {
ElMessage.warning(tip)
} else {
ElMessage.success(tip)
}
}
const reset = () => {
form.value = {
id: undefined,
@@ -371,6 +434,7 @@ const reset = () => {
remark: '',
}
formRef.value?.resetFields()
pasteText.value = ''
}
defineExpose({ getList })
@@ -384,4 +448,37 @@ defineExpose({ getList })
.invoice-empty-alert {
margin-bottom: 12px;
}
.invoice-paste-panel {
margin-bottom: 16px;
padding: 12px;
border: 1px dashed #6ee7b7;
border-radius: 8px;
background: var(--primary-bg, #ecfdf5);
}
.invoice-paste-panel__hd {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
}
.invoice-paste-panel__title {
font-weight: 600;
color: var(--primary-dark, #059669);
}
.invoice-paste-panel__tip {
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
.invoice-paste-panel__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
</style>
+3 -5
View File
@@ -197,7 +197,7 @@
v-model="dialogVisible"
class="dialog-form-layout duty-day-dialog"
:title="`配置 · ${editingDate}`"
width="960px"
width="1100px"
align-center
destroy-on-close
:close-on-click-modal="false"
@@ -221,7 +221,7 @@
</div>
</div>
<template v-if="slot.enabled">
<div class="slot-fields-row">
<div class="form-grid-4 slot-fields-row">
<el-form-item label="值班时段">
<el-select
:model-value="slot.timeRangeValue"
@@ -229,7 +229,7 @@
allow-create
default-first-option
clearable
placeholder="选择或输入,如 09:00-17:00 / 18:00-次日07:00"
placeholder="选择或输入时段"
style="width: 100%"
@change="(v) => onTimeRangeChange(slot, v as string)"
>
@@ -241,8 +241,6 @@
/>
</el-select>
</el-form-item>
</div>
<div class="form-grid-3 slot-fields-row">
<el-form-item label="值班安排">
<el-select
v-model="slot.pendingUserId"
+2 -2
View File
@@ -74,10 +74,10 @@
if (menuType.value !== MenuTypeEnum.PORTAL) {
settingStore.switchMenuLayouts(MenuTypeEnum.PORTAL)
}
// 门户:开启多页签;首页固定 Tab
// 门户:开启多页签;首页固定 Tab(不可关闭)
settingStore.setWorkTab(true)
const home = homePath.value || HOME_PAGE_PATH || '/home'
if (home && !worktabStore.opened.some((t) => t.path === home)) {
if (home) {
worktabStore.openTab(
{
path: home,
+129 -64
View File
@@ -1,12 +1,8 @@
<!-- eslint-disable vue/no-v-model-argument -->
<!-- 运输计划合同完善:仅缺合同计划,行内展开分侧保存 -->
<template>
<div class="page-container">
<section class="panel">
<div class="panel-top pl-plan-contract-panel-top">
<h2 class="title-bar-heading">运输计划合同完善</h2>
<span class="pl-plan-contract-stat">待完善 {{ pagination.total }} 条</span>
</div>
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
@@ -77,6 +73,7 @@
<el-button class="btn-ghost" :loading="loading" @click="fetchData">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<span class="pl-plan-contract-stat">待完善 {{ pagination.total }} 条</span>
</div>
</div>
@@ -92,7 +89,7 @@
@expand-change="handleExpandChange"
@row-dblclick="handleView"
>
<el-table-column type="expand" min-width="48">
<el-table-column type="expand" class-name="pl-contract-expand-col" width="1">
<template #default="{ row }">
<div class="pl-contract-expand" v-if="expandDraft[row.id]">
<div class="pl-contract-expand__grid">
@@ -200,6 +197,52 @@
{{ getPlanTypeLabel(row.planType) }}
</template>
</el-table-column>
<el-table-column
label="合同缺口"
min-width="260"
align="left"
header-align="left"
class-name="col-name-wrap"
label-class-name="col-name-wrap"
>
<template #default="{ row }">
<div class="pl-gap-contract-cell">
<div class="pl-plan-contract-cell">
<div
v-for="(line, index) in planContractLines(row)"
:key="`${line.side}-${line.no}-${index}`"
class="pl-plan-contract-cell__row"
>
<span class="pl-plan-contract-cell__side">{{ line.side }}</span>
<span
class="pl-plan-contract-cell__no"
:class="{ 'is-pending': line.pending }"
>{{ line.no }}</span>
</div>
</div>
<el-button
link
type="warning"
class="pl-gap-contract-cell__action"
@click.stop="toggleExpand(row)"
>
<el-icon><EditPen /></el-icon>
{{ isExpanded(row.id) ? '收起' : '补充' }}
</el-button>
</div>
</template>
</el-table-column>
<el-table-column prop="followStatus" label="跟进状态" min-width="110" align="center">
<template #default="{ row }">
<el-tag
:type="getPlPlanFollowStatusTagType(row.followStatus)"
size="small"
effect="dark"
>
{{ getPlPlanFollowStatusLabel(row.followStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="supplierName"
label="供方"
@@ -234,7 +277,7 @@
</el-table-column>
<el-table-column
label="产品名称"
min-width="240"
min-width="160"
align="left"
class-name="col-name-wrap"
label-class-name="col-name-wrap"
@@ -268,39 +311,6 @@
<PlPlanListProductCell :products="row.products" field="actualQty" />
</template>
</el-table-column>
<el-table-column label="合同缺口" min-width="130" align="center">
<template #default="{ row }">
<el-tag :type="gapTag(row).type" size="small" effect="dark">
{{ gapTag(row).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="followStatus" label="跟进状态" min-width="110" align="center">
<template #default="{ row }">
<el-tag
:type="getPlPlanFollowStatusTagType(row.followStatus)"
size="small"
effect="dark"
>
{{ getPlPlanFollowStatusLabel(row.followStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" fixed="right" min-width="200" align="center">
<template #default="{ row }">
<el-button
link
type="warning"
@click="toggleExpand(row)"
>
<el-icon><EditPen /></el-icon>
{{ isExpanded(row.id) ? '收起' : '展开补充' }}
</el-button>
<el-button link type="primary" @click="handleView(row)">
<el-icon><View /></el-icon>查看计划
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination list-pager-bar">
@@ -327,7 +337,7 @@
</template>
<script setup lang="ts">
import { Check, EditPen, Refresh, RefreshLeft, Search, View } from '@element-plus/icons-vue'
import { Check, EditPen, Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
import {
planApi,
type PlanContractGapItem,
@@ -343,6 +353,7 @@ import {
getPlPlanFollowStatusLabel,
getPlPlanFollowStatusTagType
} from '@/utils/pl-display'
import { planContractLines } from '@/utils/pl-plan-contract-display'
import { refreshMenuBadges } from '@/utils/menu-badge-registry'
import { useMenuStore } from '@/store/modules/menu'
@@ -395,20 +406,6 @@ function linksFingerprint(links: PlanContractLink[]) {
.join(',')
}
function gapTag(row: PlanContractGapItem) {
const sides = row.gapSides || []
if (sides.includes('supplier') && sides.includes('demander')) {
return { text: '两侧均缺', type: 'danger' as const }
}
if (sides.includes('supplier')) {
return { text: '缺供方合同', type: 'warning' as const }
}
if (sides.includes('demander')) {
return { text: '缺需方合同', type: 'warning' as const }
}
return { text: '—', type: 'info' as const }
}
function getPlanTypeLabel(type?: string | null) {
const map: Record<string, string> = {
direct: '直销单',
@@ -422,11 +419,11 @@ function partySubText(row: PlanContractGapItem, side: 'supplier' | 'demander') {
if (side === 'supplier') {
if (row.supplierContractExempt) return '内置 · 合同可免'
const n = row.supplierContracts?.length || 0
return n ? `已关联 ${n} 份` : '未关联'
return n ? `已关联${n}份合同` : '未关联合同'
}
if (row.demanderContractExempt) return '内置 · 合同可免'
const n = row.demanderContracts?.length || 0
return n ? `已关联 ${n} 份` : '未关联'
return n ? `已关联${n}份合同` : '未关联合同'
}
function sideBoxClass(row: PlanContractGapItem, side: 'supplier' | 'demander') {
@@ -647,18 +644,24 @@ onMounted(() => {
</script>
<style scoped lang="scss">
.pl-plan-contract-panel-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px 12px;
border-bottom: 1px solid #eef2f6;
.pl-plan-list-toolbar-row {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
> .list-toolbar-date-shortcuts,
> .quick-search-input,
> .quick-search-group,
> .el-button,
> .pl-plan-contract-stat {
flex-shrink: 0;
}
}
.pl-plan-contract-stat {
display: inline-flex;
align-items: center;
margin-left: auto;
padding: 4px 10px;
border-radius: 8px;
font-size: 12px;
@@ -755,4 +758,66 @@ onMounted(() => {
gap: 8px;
margin-top: 10px;
}
:deep(.pl-contract-expand-col) {
.cell {
padding: 0 !important;
}
.el-table__expand-icon {
display: none;
}
}
.pl-gap-contract-cell {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
width: 100%;
}
.pl-gap-contract-cell__action {
flex-shrink: 0;
margin-top: 0;
}
.pl-plan-contract-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
min-width: 0;
flex: 1;
text-align: left;
}
.pl-plan-contract-cell__row {
display: flex;
align-items: baseline;
gap: 6px;
width: 100%;
min-width: 0;
font-size: 13px;
line-height: 1.5;
color: var(--text, #1e293b);
}
.pl-plan-contract-cell__side {
flex-shrink: 0;
font-weight: 600;
color: var(--mint-text-muted, #64748b);
}
.pl-plan-contract-cell__no {
min-width: 0;
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;
&.is-pending {
font-weight: 600;
color: #d97706;
}
}
</style>
+40 -53
View File
@@ -268,29 +268,15 @@
show-overflow-tooltip
/>
<el-table-column
v-if="isColumnVisible('contractSummary')"
label="合同"
min-width="220"
align="left"
header-align="left"
class-name="col-name-wrap"
label-class-name="col-name-wrap"
v-if="isColumnVisible('licensePlate')"
prop="licensePlate"
label="车牌号"
min-width="160"
show-overflow-tooltip
align="center"
>
<template #default="{ row }">
<div
v-if="planContractLines(row).length"
class="pl-plan-contract-cell"
>
<div
v-for="(line, index) in planContractLines(row)"
:key="`${line.side}-${line.no}-${index}`"
class="pl-plan-contract-cell__row"
>
<span class="pl-plan-contract-cell__side">{{ line.side }}</span>
<span class="pl-plan-contract-cell__no">{{ line.no }}</span>
</div>
</div>
<span v-else class="pl-plan-contract-cell__empty">—</span>
{{ formatPlVehiclePlateDisplay(row.licensePlate, row.trailerNo) }}
</template>
</el-table-column>
<el-table-column
@@ -374,18 +360,6 @@
<PlPlanListCertCell :products="row.products" compact hide-product />
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('licensePlate')"
prop="licensePlate"
label="车牌号"
min-width="160"
show-overflow-tooltip
align="center"
>
<template #default="{ row }">
{{ formatPlVehiclePlateDisplay(row.licensePlate, row.trailerNo) }}
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('personnelSummary')"
prop="personnelSummary"
@@ -405,6 +379,31 @@
min-width="100"
align="center"
/>
<el-table-column
v-if="isColumnVisible('contractSummary')"
label="合同"
min-width="220"
align="left"
header-align="left"
class-name="col-name-wrap"
label-class-name="col-name-wrap"
>
<template #default="{ row }">
<div class="pl-plan-contract-cell">
<div
v-for="(line, index) in planContractLines(row)"
:key="`${line.side}-${line.no}-${index}`"
class="pl-plan-contract-cell__row"
>
<span class="pl-plan-contract-cell__side">{{ line.side }}</span>
<span
class="pl-plan-contract-cell__no"
:class="{ 'is-pending': line.pending }"
>{{ line.no }}</span>
</div>
</div>
</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="planStatsRowRef"
@@ -568,6 +567,7 @@ import ListToolbarDateShortcuts from '@/components/core/list-toolbar-date-shortc
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { useListDateShortcut } from '@/composables/useListDateShortcut'
import { canBatchPrintSlip } from '@/utils/pl-batch-print'
import { planContractLines } from '@/utils/pl-plan-contract-display'
import {
canPrintSlipCommand,
formatPlVehiclePlateDisplay,
@@ -607,7 +607,7 @@ const listColumnDefs = [
{ prop: 'planType', label: '单据类型' },
{ prop: 'supplierName', label: '供方' },
{ prop: 'customerName', label: '需方' },
{ prop: 'contractSummary', label: '合同' },
{ prop: 'licensePlate', label: '车牌号' },
{ prop: 'productName', label: '产品名称' },
{ prop: 'productSpec', label: '型号/规格' },
{ prop: 'productPackageSpec', label: '包装规格' },
@@ -615,9 +615,9 @@ const listColumnDefs = [
{ prop: 'productPlanQty', label: '计划数量' },
{ prop: 'productActualQty', label: '实际数量' },
{ prop: 'planAttachments', label: '计划附件' },
{ prop: 'licensePlate', label: '车牌号' },
{ prop: 'personnelSummary', label: '人员' },
{ prop: 'followUserName', label: '跟进人', defaultVisible: false }
{ prop: 'followUserName', label: '跟进人', defaultVisible: false },
{ prop: 'contractSummary', label: '合同' }
]
const {
@@ -785,20 +785,6 @@ function getPlanTypeLabel(type?: string | null) {
return map[type] || type
}
/** 合同列:供/需合同各占一行展示 */
function planContractLines(plan: Plan): Array<{ side: string; no: string }> {
const lines: Array<{ side: string; no: string }> = []
for (const item of plan.supplierContracts || []) {
const no = item.contractNo || item.contractId
if (no) lines.push({ side: '供', no: String(no) })
}
for (const item of plan.demanderContracts || []) {
const no = item.contractNo || item.contractId
if (no) lines.push({ side: '需', no: String(no) })
}
return lines
}
async function fetchData() {
loading.value = true
try {
@@ -1190,9 +1176,10 @@ async function openArrangeFromQuery() {
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;
}
.pl-plan-contract-cell__empty {
color: var(--mint-text-muted, #64748b);
&.is-pending {
font-weight: 600;
color: #d97706;
}
}
</style>
@@ -260,7 +260,12 @@
<el-input v-model="formData.licensePlate" placeholder="请输入车牌号" maxlength="20" />
</el-form-item>
<el-form-item label="挂车号" prop="trailerNo">
<el-input v-model="formData.trailerNo" placeholder="请输入挂车号" maxlength="20" clearable />
<el-input
v-model="formData.trailerNo"
placeholder="必填;无挂车可填「货拉拉」"
maxlength="20"
clearable
/>
</el-form-item>
<el-form-item label="载重(吨)" prop="loadCapacity">
<el-input-number
@@ -621,6 +626,7 @@ const formData = reactive({
const formRules = {
licensePlate: [{ required: true, message: '请输入车牌号', trigger: 'blur' }],
trailerNo: [{ required: true, message: '请输入挂车号', trigger: 'blur' }],
vehicleStatus: [{ required: true, message: '请选择车辆状态', trigger: 'change' }],
carrierId: [{ required: true, message: '请选择承运商', trigger: 'change' }]
}
+9 -3
View File
@@ -268,7 +268,12 @@
<el-input v-model="formData.licensePlate" placeholder="请输入车牌号" maxlength="20" />
</el-form-item>
<el-form-item label="挂车号" prop="trailerNo">
<el-input v-model="formData.trailerNo" placeholder="请输入挂车号" maxlength="20" clearable />
<el-input
v-model="formData.trailerNo"
placeholder="必填;无挂车可填「货拉拉」"
maxlength="20"
clearable
/>
</el-form-item>
<el-form-item label="载重(吨)" prop="loadCapacity">
<el-input-number
@@ -398,7 +403,7 @@
:cert-name-options="certNameOptions"
layout="table"
:enforce-self-vehicle-required="false"
:enforce-carrier-vehicle-required="false"
enforce-carrier-vehicle-required
/>
</el-form>
<template #footer>
@@ -640,7 +645,8 @@ const formData = reactive({
})
const formRules = {
licensePlate: [{ required: true, message: '请输入车牌号', trigger: 'blur' }]
licensePlate: [{ required: true, message: '请输入车牌号', trigger: 'blur' }],
trailerNo: [{ required: true, message: '请输入挂车号', trigger: 'blur' }]
}
function getStatusTagType(status: string) {
+2 -1
View File
@@ -219,7 +219,8 @@
备用金发放: MENU_BADGE_CODES.BX_ADVANCE_ISSUE,
报销审批: MENU_BADGE_CODES.BX_REIMBURSE_APPROVE,
支付发放冲抵: MENU_BADGE_CODES.BX_PAYMENT,
待我审批: MENU_BADGE_CODES.BX_VIEW_TODO
待我审批: MENU_BADGE_CODES.BX_VIEW_TODO,
运输计划合同完善: MENU_BADGE_CODES.PL_PLAN_CONTRACT
}
const router = useRouter()
+11 -6
View File
@@ -460,17 +460,22 @@
margin-top: 10px;
}
.person {
padding: 4px 9px;
font-size: 12px;
color: #6b7b74;
background: #f3f7f5;
border: 1px solid #dcebe4;
padding: 5px 11px;
font-size: 13px;
font-weight: 600;
line-height: 1.3;
color: #fff;
background: #0a9f6d;
border: none;
border-radius: 999px;
box-shadow: 0 1px 2px rgba(10, 159, 109, 0.25);
}
.person.empty {
font-weight: 400;
color: #8a9a93;
background: transparent;
border-style: dashed;
border: 1px dashed #c5d5ce;
box-shadow: none;
}
.field {
display: block;
File diff suppressed because it is too large. Load diff
+106 -27
View File
@@ -45,7 +45,11 @@
<el-button class="btn-ghost" :loading="loading" @click="handleReset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button class="btn-main" type="primary" :loading="loading" @click="handleSearch"
<el-button
class="btn-main"
type="primary"
:loading="loading"
@click="handleSearch"
><el-icon><Search /></el-icon>搜索</el-button
>
</div>
@@ -55,7 +59,7 @@
<div class="list-toolbar">
<div class="list-toolbar-row">
<ListQuickSearchInput
<ListQuickSearchInput
v-model="quickSearchKeyword"
placeholder="账号/姓名/手机号"
@search="handleQuickSearch"
@@ -105,7 +109,8 @@
</div>
<div class="list-body list-wrap">
<ListTable table-key="system-user"
<ListTable
table-key="system-user"
class="list-table"
:data="tableData"
v-loading="loading"
@@ -114,7 +119,12 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="selection"
min-width="55"
align="center"
header-align="center"
/>
<el-table-column
type="index"
label="序号"
@@ -151,7 +161,27 @@
header-align="center"
>
<template #default="{ row }">
{{ formatUserDeptDisplay(row) }}
{{ row.deptName || '—' }}
</template>
</el-table-column>
<el-table-column
v-if="isColumnVisible('roles')"
prop="roles"
label="角色"
min-width="180"
align="center"
header-align="center"
>
<template #default="{ row }">
<template v-if="resolveUserRoles(row).length">
<template v-for="(role, idx) in resolveUserRoles(row)" :key="role.id">
<span v-if="idx > 0" class="user-role-sep">、</span>
<el-link type="primary" @click.stop="handleOpenRole(role.id)">{{
role.label
}}</el-link>
</template>
</template>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column
@@ -191,9 +221,9 @@
</ListTable>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<div class="list-pager-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
</div>
<el-pagination
:current-page="pagination.page"
@@ -324,14 +354,38 @@
删除
</el-button>
</template>
<template #userRoles>
<div class="user-view-roles">
<template v-if="viewRow && resolveUserRoles(viewRow).length">
<el-tag
v-for="role in resolveUserRoles(viewRow)"
:key="role.id"
type="info"
size="small"
class="user-role-tag"
@click="handleOpenRole(role.id)"
>
{{ role.label }}
</el-tag>
</template>
<span v-else>—</span>
</div>
</template>
</SystemViewDialog>
<RoleManageDialogs ref="roleDialogsRef" />
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { canDeleteByCreator, filterDeletableByCreator, allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import {
canDeleteByCreator,
filterDeletableByCreator,
allRowsDeletableByCreator,
CREATOR_DELETE_TIP
} from '@/utils/creator-delete'
import { syncViewRowFromFlatList } from '@/utils/view-dialog-refresh'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
@@ -346,6 +400,7 @@
Delete
} from '@element-plus/icons-vue'
import SystemViewDialog from '@/components/business/system-view-dialog/index.vue'
import RoleManageDialogs from '@/components/business/role-manage-dialogs/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
@@ -359,12 +414,12 @@
fetchDeptTree,
fetchRoleOptions
} from '@/api/admin'
import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
import { useDictStore } from '@/store/modules/dict'
import { buildPathLabelMapFromTreeRoots } from '@/utils/tree-path'
import { useDictStore } from '@/store/modules/dict'
defineOptions({ name: 'SystemUser' })
defineOptions({ name: 'SystemUser' })
const dictStore = useDictStore()
const dictStore = useDictStore()
const roleOptions = ref<Array<{ label: string; value: string }>>([])
@@ -396,13 +451,20 @@ const dictStore = useDictStore()
return deptPathLabelMap.value.get(id) || row.deptName || '—'
}
function formatUserRoleDisplay(row: Api.User.UserItem) {
function resolveUserRoles(row: Api.User.UserItem) {
const ids = row.roleIds || []
if (!ids.length) return '—'
const labels = ids
.map((id) => roleOptions.value.find((o) => o.value === id)?.label)
.filter((name): name is string => Boolean(name))
return labels.length ? labels.join('、') : '—'
return ids
.map((id) => {
const label = roleOptions.value.find((o) => o.value === id)?.label
return label ? { id, label } : null
})
.filter((item): item is { id: string; label: string } => Boolean(item))
}
const roleDialogsRef = ref<InstanceType<typeof RoleManageDialogs> | null>(null)
function handleOpenRole(roleId: string) {
roleDialogsRef.value?.openView(roleId)
}
const searchForm = reactive<{ keyword: string; status: string }>({ keyword: '', status: '' })
@@ -415,18 +477,14 @@ const dictStore = useDictStore()
const listColumnDefs = [
{ prop: 'userNo', label: '用户账号' },
{ prop: 'deptName', label: '所属组织' },
{ prop: 'roles', label: '角色' },
{ prop: 'phoneNo', label: '手机号' },
{ prop: 'status', label: '状态' },
{ prop: 'createTime', label: '创建时间', defaultVisible: false }
]
const {
visibleColumns,
checkAll,
isIndeterminate,
isColumnVisible,
resetToDefault
} = usePersistedListColumns('system-user-list-columns', listColumnDefs)
const { visibleColumns, checkAll, isIndeterminate, isColumnVisible, resetToDefault } =
usePersistedListColumns('system-user-list-columns', listColumnDefs)
const dialogVisible = ref(false)
const dialogTitle = ref('新增用户')
@@ -722,9 +780,14 @@ const dictStore = useDictStore()
long: false
},
{ label: '创建时间', value: row.createTime, long: false },
{ label: '角色', value: formatUserRoleDisplay(row), long: false },
{ label: '备注', value: row.remark, long: false }
]
},
{
title: '角色',
titleStyle: 'field' as const,
fields: [],
slot: 'userRoles'
}
]
})
@@ -823,4 +886,20 @@ const dictStore = useDictStore()
margin-bottom: 0;
}
}
.user-role-tag {
margin: 2px 4px;
cursor: pointer;
}
.user-role-sep {
color: var(--mint-text-muted, var(--el-text-color-secondary));
}
.user-view-roles {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
</style>