This commit is contained in:
zlwu committed 2026-07-05 10:07:24 +08:00
1 parent 33c038b8d9
commit 54b27a3f0e
253 files changed
+30060 -14298

No files matched your search

+35
View File
@@ -13,6 +13,41 @@ export function buildAmapNavigationUrl(
return `https://uri.amap.com/navigation?to=${lngText},${latText},${toName}&mode=car&coordinate=gaode&callnative=1`
}
/** 构建高德 URI 标记点链接(Web/App 查看位置) */
export function buildAmapMarkerUrl(
lng?: number | null,
lat?: number | null,
name?: string
): string | null {
if (lng == null || lat == null || Number.isNaN(Number(lng)) || Number.isNaN(Number(lat))) {
return null
}
const lngText = Number(lng).toFixed(6)
const latText = Number(lat).toFixed(6)
const markerName = encodeURIComponent((name || '位置').trim() || '位置')
return `https://uri.amap.com/marker?position=${lngText},${latText}&name=${markerName}&coordinate=gaode&callnative=0`
}
/** 按地址关键词打开高德地图搜索(无坐标时的兜底) */
export function buildAmapSearchUrl(keyword?: string | null): string | null {
const text = keyword?.trim()
if (!text) return null
return `https://www.amap.com/search?query=${encodeURIComponent(text)}`
}
/** 优先坐标标记,否则按地址搜索 */
export function buildAmapLocationUrl(options: {
lng?: number | null
lat?: number | null
name?: string
address?: string
}): string | null {
return (
buildAmapMarkerUrl(options.lng, options.lat, options.name || options.address) ||
buildAmapSearchUrl(options.address || options.name)
)
}
export function appendAmapNavLine(lines: string[], label: string, url: string | null) {
if (url) {
lines.push(`${label}:${url}`)
+72
View File
@@ -0,0 +1,72 @@
import { formatSumMoney } from './format'
export interface SummaryColumn {
property?: string
type?: string
}
export interface PageSummaryParams {
columns: SummaryColumn[]
data: Record<string, unknown>[]
}
export interface BuildPageSummaryOptions {
/** 放置合计标签的列 prop,默认 applyCode;默认不展示标签文案 */
labelProp?: string
/** 自定义标签文案;默认返回空字符串 */
labelFormatter?: (count: number) => string
/** 列 prop -> 合计值(已格式化字符串) */
sumByProp?: Record<string, (data: Record<string, unknown>[]) => string>
}
/** 对数值字段求和并格式化为合计金额 */
export function sumField(
data: Record<string, unknown>[],
field: string,
optional = false
): string {
const total = data.reduce((sum, row) => {
const value = row[field]
if (optional && (value == null || value === '')) return sum
return sum + Number(value || 0)
}, 0)
return formatSumMoney(total)
}
/** 按业务类型(备用金 / 报销单)分列求和,用于合并列表合计行 */
export function sumFieldByBizType(
data: Record<string, unknown>[],
field: string,
bizTypeField = 'bizType'
): string {
let advanceTotal = 0
let reimburseTotal = 0
for (const row of data) {
const amount = Number(row[field] || 0)
const bizType = row[bizTypeField]
if (bizType === '备用金') advanceTotal += amount
else if (bizType === '报销单') reimburseTotal += amount
}
return `备用金总额 ${formatSumMoney(advanceTotal)} / 报销单总额 ${formatSumMoney(reimburseTotal)}`
}
/**
* 生成 Element Plus 表格合计行方法;按当前可见 columns 动态对齐,兼容列显隐。
*/
export function buildPageSummaryMethod(options: BuildPageSummaryOptions = {}) {
const {
labelProp = 'applyCode',
labelFormatter = () => '',
sumByProp = {}
} = options
return ({ columns, data }: PageSummaryParams) => {
const label = labelFormatter(data.length)
return columns.map((col) => {
const prop = col.property
if (prop === labelProp) return label
if (prop && sumByProp[prop]) return sumByProp[prop](data)
return ''
})
}
}
+9 -1
View File
@@ -1,4 +1,4 @@
import type { TagType, ViewField } from '@/components/business/system-view-dialog/types'
import type { TagType, ViewField, ViewHighlight } from '@/components/business/system-view-dialog/types'
import { formatViewValue } from '@/components/business/system-view-dialog/types'
type DictStoreLike = {
@@ -37,3 +37,11 @@ export function crmTagField(
): ViewField {
return { label, value: display || '—', tagType, ...extra }
}
export function crmHighlight(
label: string,
value: unknown,
extra?: Partial<ViewHighlight>
): ViewHighlight {
return { label, value: formatViewValue(value), ...extra }
}
+24 -1
View File
@@ -112,6 +112,29 @@ export function formatPlanProductListText(
return `${name}${specPart},${qtyUnit}`
}
/** 司机公开页产品一行展示(含运输计划产品备注) */
export function formatPublicTransportProductLine(product: {
productName?: string
model?: string
packageSpec?: string
unit?: string
quantity?: number | string | null
remark?: string
}) {
const name = product.productName?.trim() || '—'
const parts: string[] = []
const model = product.model?.trim()
const packageSpec = product.packageSpec?.trim()
if (model) parts.push(model)
if (packageSpec) parts.push(packageSpec)
const qty = getPlanProductPlannedQuantity(product)
const unitRaw = product.unit || '吨'
parts.push(qty !== '—' ? `${qty} ${unitRaw}` : unitRaw)
const remark = product.remark?.trim()
parts.push(`备注:${remark || '—'}`)
return `${name}(${parts.join(',')})`
}
export function getPlPlanCompanyLabel(company?: string) {
const map: Record<string, string> = { shanxia: '安庆山峡', yundong: '安徽云栋' }
return company ? map[company] || company : '—'
@@ -142,7 +165,7 @@ export function getPlPlanFollowStatusLabel(status?: string) {
draft: '草稿',
processing: '进行中',
completed: '已完成',
cancelled: '已取消'
cancelled: '已作废'
}
return status ? map[status] || status : '—'
}
+71
View File
@@ -0,0 +1,71 @@
import type { CertRow } from '@/api/pl'
export const PL_PERSONNEL_CERT_MISSING_SUFFIX = '(证书缺失)'
export const PL_PERSONNEL_DRIVER_REQUIRED_CERT_NAMES = [
'身份证',
'驾驶证',
'从业资格证'
] as const
export const PL_PERSONNEL_ESCORT_REQUIRED_CERT_NAMES = ['身份证', '从业资格证'] as const
export type PersonnelCertRole = 'driver' | 'escort'
export function matchRequiredCertName(
actualName: string | undefined,
requiredName: string
): boolean {
const name = actualName?.trim() || ''
if (!name) return false
if (requiredName === '从业资格证') {
return (
name === '从业资格证' ||
name === '危化品从业资格证' ||
name.includes('从业资格证')
)
}
return name === requiredName
}
export function isPersonnelCertUploaded(cert?: Pick<CertRow, 'certName' | 'attachmentUrls'>) {
return Boolean(cert?.certName?.trim() && cert?.attachmentUrls?.trim())
}
export function getRequiredPersonnelCertNames(role: PersonnelCertRole): readonly string[] {
return role === 'driver'
? PL_PERSONNEL_DRIVER_REQUIRED_CERT_NAMES
: PL_PERSONNEL_ESCORT_REQUIRED_CERT_NAMES
}
export function getMissingPersonnelCertNames(
role: PersonnelCertRole,
certList: CertRow[] = []
): string[] {
return getRequiredPersonnelCertNames(role).filter(requiredName => {
const matched = certList.find(cert => matchRequiredCertName(cert.certName, requiredName))
return !isPersonnelCertUploaded(matched)
})
}
export function hasPersonnelCertMissing(
role: PersonnelCertRole,
certList: CertRow[] = []
): boolean {
return getMissingPersonnelCertNames(role, certList).length > 0
}
/** 人员主数据页:至少满足押运员必备证书(身份证 + 从业资格证) */
export function hasPersonnelMasterCertMissing(certList: CertRow[] = []): boolean {
return hasPersonnelCertMissing('escort', certList)
}
export function getPersonnelCertMissingSuffix(
certList: CertRow[] = [],
role?: PersonnelCertRole
): string {
if (role) {
return hasPersonnelCertMissing(role, certList) ? PL_PERSONNEL_CERT_MISSING_SUFFIX : ''
}
return hasPersonnelMasterCertMissing(certList) ? PL_PERSONNEL_CERT_MISSING_SUFFIX : ''
}
+88 -27
View File
@@ -3,8 +3,24 @@ import type { CertRow } from '@/api/pl'
export const PL_VEHICLE_CERT_PART_HEAD = 'head'
export const PL_VEHICLE_CERT_PART_TRAILER = 'trailer'
export const PL_VEHICLE_CERT_MISSING_SUFFIX = '(证书缺失)'
/** @deprecated 保存不再阻断,仅保留供历史引用 */
export const PL_VEHICLE_REQUIRED_CERT_NAMES = ['行驶证', '营运证'] as const
export const PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES = [
'行驶证',
'营运证',
'保险单'
] as const
export const PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES = [
'行驶证',
'营运证',
'保险单',
'罐检报告'
] as const
export const PL_VEHICLE_CERT_NAME_OPTIONS = [
'行驶证',
'营运证',
@@ -12,7 +28,8 @@ export const PL_VEHICLE_CERT_NAME_OPTIONS = [
'驾驶证',
'押运员证',
'危化品从业资格证',
'保险单'
'保险单',
'罐检报告'
]
export type VehicleCertPart = typeof PL_VEHICLE_CERT_PART_HEAD | typeof PL_VEHICLE_CERT_PART_TRAILER
@@ -53,16 +70,61 @@ export function mergeVehicleCertsByPart<T extends CertRow>(
return [...normalizedHead, ...normalizedTrailer]
}
function isCertFilled(cert?: CertRow) {
if (!cert) return false
return Boolean(
cert.certName?.trim() &&
cert.certNo?.trim() &&
cert.expireDate &&
cert.attachmentUrls?.trim()
)
export function isVehicleCertUploaded(cert?: Pick<CertRow, 'certName' | 'attachmentUrls'>) {
return Boolean(cert?.certName?.trim() && cert?.attachmentUrls?.trim())
}
export function getMissingSelfVehiclePartCertNames(
certList: CertRow[] = [],
part: VehicleCertPart
): string[] {
const requiredNames =
part === PL_VEHICLE_CERT_PART_TRAILER
? PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
: PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES
return requiredNames.filter(requiredName => {
const matched = certList.find(cert => cert.certName?.trim() === requiredName)
return !isVehicleCertUploaded(matched)
})
}
export function hasSelfVehicleCertMissing(options: {
certList?: CertRow[]
trailerNo?: string | null
}): boolean {
const { certList = [], trailerNo } = options
const { headCerts, trailerCerts } = splitVehicleCertsByPart(certList)
const hasTrailer = Boolean(trailerNo?.trim())
if (getMissingSelfVehiclePartCertNames(headCerts, PL_VEHICLE_CERT_PART_HEAD).length > 0) {
return true
}
if (hasTrailer && getMissingSelfVehiclePartCertNames(trailerCerts, PL_VEHICLE_CERT_PART_TRAILER).length > 0) {
return true
}
return false
}
export function getSelfVehicleCertMissingSuffix(options: {
certList?: CertRow[]
trailerNo?: string | null
}): string {
return hasSelfVehicleCertMissing(options) ? PL_VEHICLE_CERT_MISSING_SUFFIX : ''
}
export function getSelfVehiclePartCertMissingSuffix(
certList: CertRow[] = [],
part: VehicleCertPart
): string {
return getMissingSelfVehiclePartCertNames(certList, part).length > 0
? PL_VEHICLE_CERT_MISSING_SUFFIX
: ''
}
/** @deprecated 保存不再阻断证书完整性,仅校验证书名称非空等基础项 */
export function validateVehicleRequiredCerts(options: {
headCerts: CertRow[]
trailerCerts: CertRow[]
@@ -71,28 +133,12 @@ export function validateVehicleRequiredCerts(options: {
trailerNo: string
onError: (message: string) => void
}) {
const { headCerts, trailerCerts, hasTrailer, licensePlate, trailerNo, onError } = options
const parts: Array<{ label: string; certs: CertRow[] }> = [
{ label: licensePlate?.trim() || '主车', certs: headCerts }
]
if (hasTrailer) {
parts.push({ label: trailerNo?.trim() || '挂车', certs: trailerCerts })
}
for (const part of parts) {
for (const certName of PL_VEHICLE_REQUIRED_CERT_NAMES) {
const matched = part.certs.find(item => item.certName?.trim() === certName)
if (!isCertFilled(matched)) {
onError(`请完善「${part.label}」的${certName}(证号、有效期、附件均必填)`)
return false
}
}
}
const { headCerts, trailerCerts, hasTrailer } = options
for (const certs of [headCerts, ...(hasTrailer ? [trailerCerts] : [])]) {
for (const cert of certs) {
if (!cert.certName?.trim()) {
onError('证书名称不能为空')
options.onError('证书名称不能为空')
return false
}
}
@@ -108,3 +154,18 @@ export function filterVehicleCertsForSubmit<T extends CertRow>(
) {
return mergeVehicleCertsByPart(headCerts, trailerCerts, hasTrailer)
}
export function validateVehicleCertNamesNotEmpty(
certLists: CertRow[][],
onError: (message: string) => void
): boolean {
for (const certs of certLists) {
for (const cert of certs) {
if (!cert.certName?.trim()) {
onError('证书名称不能为空')
return false
}
}
}
return true
}
+141
View File
@@ -0,0 +1,141 @@
const TOOLBAR_SELECTOR = '.page-container .list-toolbar, .console-container .list-toolbar'
const FILTER_AUTO_TOGGLE_CLASS = 'btn-filter-toggle-auto'
const FILTER_TOGGLE_ICON_CLASS = 'btn-list-filter-toggle'
const QUICK_SEARCH_GROUP_CLASS = 'quick-search-group'
const ensureQuickSearchGroup = (quickSearchInput: HTMLElement): HTMLElement => {
const parent = quickSearchInput.parentElement
if (parent?.classList.contains(QUICK_SEARCH_GROUP_CLASS)) {
return parent
}
const group = document.createElement('div')
group.className = QUICK_SEARCH_GROUP_CLASS
quickSearchInput.replaceWith(group)
group.appendChild(quickSearchInput)
return group
}
const findPrecedingFilterBlock = (toolbar: HTMLElement): HTMLElement | null => {
if (toolbar.closest('.el-dialog')) return null
let prev = toolbar.previousElementSibling as HTMLElement | null
while (prev) {
if (prev.classList.contains('filter-block')) return prev
if (
prev.classList.contains('list-body') ||
prev.classList.contains('list-toolbar') ||
prev.classList.contains('panel-top') ||
prev.classList.contains('stat-grid')
) {
return null
}
prev = prev.previousElementSibling as HTMLElement | null
}
return null
}
const setFilterBlockExpanded = (
filterBlock: HTMLElement,
button: HTMLButtonElement,
expanded: boolean
) => {
filterBlock.classList.toggle('is-filter-collapsed', !expanded)
filterBlock.classList.toggle('is-filter-expanded', expanded)
button.setAttribute('aria-expanded', expanded ? 'true' : 'false')
button.setAttribute('aria-label', expanded ? '收起检索' : '展开检索')
button.setAttribute('title', expanded ? '收起检索' : '展开检索')
}
const createToggleButton = (filterBlock: HTMLElement): HTMLButtonElement => {
const button = document.createElement('button')
button.type = 'button'
button.className = FILTER_TOGGLE_ICON_CLASS
button.setAttribute('aria-expanded', 'false')
button.setAttribute('aria-label', '展开检索')
button.setAttribute('title', '展开检索')
button.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
const expanded = filterBlock.classList.contains('is-filter-expanded')
setFilterBlockExpanded(filterBlock, button, !expanded)
})
return button
}
const resolveFilterToggleAnchor = (
toolbar: HTMLElement
): { anchor: HTMLElement; quickSearchInput: HTMLElement | null } | null => {
const quickSearchInput = toolbar.querySelector<HTMLElement>('.quick-search-input')
if (quickSearchInput) {
return { anchor: ensureQuickSearchGroup(quickSearchInput), quickSearchInput }
}
const selectedAnchor = toolbar.querySelector<HTMLElement>('.list-toolbar-selected')
if (selectedAnchor) {
return { anchor: selectedAnchor, quickSearchInput: null }
}
const rowAnchor = toolbar.querySelector<HTMLElement>('.list-toolbar-row')
if (rowAnchor) {
return { anchor: rowAnchor, quickSearchInput: null }
}
return null
}
const ensureToolbarFilterToggle = (toolbar: HTMLElement, filterBlock: HTMLElement) => {
if (!filterBlock.classList.contains('is-filter-expanded')) {
filterBlock.classList.add('is-filter-collapsed')
}
const resolved = resolveFilterToggleAnchor(toolbar)
if (!resolved) return
const { anchor } = resolved
const existingToggle = toolbar.querySelector<HTMLButtonElement>(`.${FILTER_TOGGLE_ICON_CLASS}`)
if (existingToggle && existingToggle.parentElement !== anchor) {
existingToggle.remove()
}
let toggle = anchor.querySelector<HTMLButtonElement>(`:scope > .${FILTER_TOGGLE_ICON_CLASS}`)
if (!toggle) {
toggle = createToggleButton(filterBlock)
anchor.insertBefore(toggle, anchor.firstChild)
}
setFilterBlockExpanded(
filterBlock,
toggle,
filterBlock.classList.contains('is-filter-expanded')
)
}
const removeAutoFilterToggles = (): void => {
document.querySelectorAll<HTMLElement>(`.${FILTER_AUTO_TOGGLE_CLASS}`).forEach((node) => {
node.remove()
})
}
export const refreshFilterCollapse = (): void => {
removeAutoFilterToggles()
document.querySelectorAll<HTMLElement>(TOOLBAR_SELECTOR).forEach((toolbar) => {
const filterBlock = findPrecedingFilterBlock(toolbar)
if (!filterBlock) return
ensureToolbarFilterToggle(toolbar, filterBlock)
})
}
export const setupFilterCollapseObserver = (): MutationObserver => {
refreshFilterCollapse()
const observer = new MutationObserver(() => {
refreshFilterCollapse()
})
observer.observe(document.body, {
childList: true,
subtree: true
})
return observer
}
@@ -0,0 +1,29 @@
const INTERACTIVE_SELECTOR =
'button, a, .el-link, .el-dropdown, input, textarea, select, .el-switch, .el-radio, .el-select, .btn-op-item, .el-table__expand-icon'
/**
* 主列表行单击切换多选:点击行即选中/取消,点击链接、按钮、展开图标等交互元素时不触发。
*/
export function setupListTableRowSelect() {
const handler = (event: MouseEvent) => {
const target = event.target as HTMLElement | null
if (!target) return
if (target.closest('.el-table-column--selection')) return
const row = target.closest('.list-table .el-table__body-wrapper tbody tr.el-table__row')
if (!row) return
if (target.closest(INTERACTIVE_SELECTOR)) return
const checkboxInput = row.querySelector(
'.el-table-column--selection .el-checkbox__input'
) as HTMLElement | null
if (!checkboxInput) return
checkboxInput.click()
}
document.body.addEventListener('click', handler)
return () => document.body.removeEventListener('click', handler)
}
@@ -0,0 +1,35 @@
import type { Ref } from 'vue'
export function syncViewRowFromFlatList<T extends { id: string | number }>(
viewRow: Ref<T | null>,
list: T[]
) {
const current = viewRow.value
if (!current?.id) return
const updated = list.find(row => row.id === current.id)
if (updated) viewRow.value = updated
}
export function findNodeInTreeById<T extends { id: string | number; children?: T[] }>(
nodes: T[],
id: string | number
): T | undefined {
for (const node of nodes) {
if (node.id === id) return node
if (node.children?.length) {
const found = findNodeInTreeById(node.children, id)
if (found) return found
}
}
return undefined
}
export function syncViewRowFromTree<T extends { id: string | number; children?: T[] }>(
viewRow: Ref<T | null>,
roots: T[]
) {
const current = viewRow.value
if (!current?.id) return
const updated = findNodeInTreeById(roots, current.id)
if (updated) viewRow.value = updated
}
+34
View File
@@ -34,6 +34,7 @@ export function isViewFieldLong(field: ViewField, displayText: string): boolean
/** 是否占满整行(备注超长、显式 fullSpan / span:'full') */
export function isViewFieldFullSpan(field: ViewField, displayText: string): boolean {
if (field.fullSpan === false) return false
if (field.fullSpan === true || field.span === 'full') return true
if (displayText === '—') return false
return displayText.length >= VIEW_KV_FULL_MIN_LEN
@@ -156,3 +157,36 @@ export function buildViewKvTableQuadRows(
}
return rows
}
export type ViewKvTableQuintRow =
| { type: 'long'; field: ViewField }
| { type: 'quint'; fields: ViewField[] }
/** KV 表格模式(五组十列):长字段独占行,短字段最多五个一组 */
export function buildViewKvTableQuintRows(
fields: ViewField[],
formatValue: (val: unknown) => string
): ViewKvTableQuintRow[] {
const rows: ViewKvTableQuintRow[] = []
let i = 0
while (i < fields.length) {
const field = fields[i]
const text = getViewFieldDisplayText(field, formatValue)
if (isViewFieldLong(field, text) || isViewFieldFullSpan(field, text)) {
rows.push({ type: 'long', field })
i += 1
continue
}
const group: ViewField[] = [field]
i += 1
while (i < fields.length && group.length < 5) {
const next = fields[i]
const nextText = getViewFieldDisplayText(next, formatValue)
if (isViewFieldLong(next, nextText) || isViewFieldFullSpan(next, nextText)) break
group.push(next)
i += 1
}
rows.push({ type: 'quint', fields: group })
}
return rows
}