This commit is contained in:
zlwu committed 2026-08-17 09:22:31 +08:00
1 parent 42ad896456
commit 6b2147aa46
66 files changed
+617071 -28046

No files matched your search

@@ -312,7 +312,7 @@ async function getList() {
function syncTableSelection() {
const table = tableRef.value
if (!table) return
if (!table?.setCurrentRow) return
if (!selectedId.value) {
table.setCurrentRow(undefined)
return
@@ -426,7 +426,7 @@ function handleOpen() {
queryParams.keyword = ''
queryParams.customerId = props.customerId || undefined
nextTick(() => {
tableRef.value?.setCurrentRow(undefined)
tableRef.value?.setCurrentRow?.(undefined)
})
getList()
}
@@ -0,0 +1,418 @@
<!--
列表可配置列统计行:点选单元格向上弹出无/求和/平均/最大/最小;
仅统计当前页 data;偏好按 storageKey 存 localStorage。
父级须用同一容器包裹 ListTable/el-table 与本组件,并传入 table-wrap-el。
-->
<template>
<div
ref="statsScrollRef"
class="list-column-stats-row"
aria-label="列表列统计"
@scroll="onStatsRowScroll"
>
<div class="list-column-stats-row__inner">
<div
v-for="col in columns"
:key="col.key"
class="list-column-stats-row__cell"
:class="{ 'is-stat': col.interactive, 'is-spacer': !col.interactive }"
>
<el-popover
v-if="col.interactive && col.prop"
:visible="menuOpenProp === col.prop"
placement="top-start"
:width="132"
:teleported="true"
trigger="click"
:hide-after="0"
popper-class="list-column-stats-dropdown"
@update:visible="(v: boolean) => setMenuOpen(col.prop!, v)"
>
<template #reference>
<button
type="button"
class="list-column-stats-row__trigger"
:class="{ 'is-empty': !cellText(col) }"
>
{{ cellText(col) || '\u00a0' }}
</button>
</template>
<div class="list-column-stats-menu">
<button
v-for="opt in COLUMN_STAT_METHOD_OPTIONS"
:key="opt.label"
type="button"
class="list-column-stats-menu__item"
:class="{ 'is-active': (config[col.prop!] || '') === opt.value }"
@click="pick(col.prop!, opt.value)"
>
{{ opt.label }}
</button>
</div>
</el-popover>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import {
COLUMN_STAT_METHOD_OPTIONS,
applyStatsCellAlignStyles,
formatColumnStatDisplay,
getTableColumnTextAligns,
loadColumnStatConfig,
resolveStatsCellAlign,
saveColumnStatConfig,
setColumnStatMethod,
type ColumnStatConfigMap,
type ColumnStatMethod,
type StatsRowColumn
} from '@/utils/list-column-stats'
defineOptions({ name: 'ListColumnStatsRow' })
const props = withDefaults(
defineProps<{
storageKey: string
columns: StatsRowColumn[]
data: Record<string, unknown>[] | any[]
/** 外层包裹表 + 统计行的元素 */
tableWrapEl?: HTMLElement | null
/** 自定义取值;默认 row[prop] */
getCellValue?: (row: any, prop: string) => unknown
}>(),
{
tableWrapEl: null,
getCellValue: undefined
}
)
const statsScrollRef = ref<HTMLElement | null>(null)
const config = ref<ColumnStatConfigMap>({})
const menuOpenProp = ref<string | null>(null)
let scrollSyncing = false
let headerResizeObserver: ResizeObserver | null = null
let tableScrollElBound: HTMLElement | null = null
watch(
() => props.storageKey,
(key) => {
config.value = loadColumnStatConfig(key)
},
{ immediate: true }
)
const columnsRef = computed(() => props.columns || [])
function resolveCellValue(row: any, prop: string): unknown {
if (props.getCellValue) return props.getCellValue(row, prop)
return row?.[prop]
}
function cellValues(prop: string): unknown[] {
return (props.data || []).map((row) => resolveCellValue(row, prop))
}
function cellText(col: StatsRowColumn): string {
if (!col.prop) return ''
const method = config.value[col.prop]
return formatColumnStatDisplay(method, cellValues(col.prop), { money: !!col.money })
}
function setMenuOpen(prop: string, visible: boolean) {
menuOpenProp.value = visible ? prop : menuOpenProp.value === prop ? null : menuOpenProp.value
}
function pick(prop: string, method: ColumnStatMethod | '') {
config.value = setColumnStatMethod(config.value, prop, method)
saveColumnStatConfig(config.value, props.storageKey)
menuOpenProp.value = null
}
function getMainTableEl(): HTMLElement | null {
const wrap = props.tableWrapEl
if (!wrap) return null
return (
(wrap.querySelector(':scope > .el-table') as HTMLElement | null) ||
(wrap.querySelector('.el-table') as HTMLElement | null)
)
}
function getTableScrollEl(): HTMLElement | null {
const table = getMainTableEl()
if (!table) return null
return (
(table.querySelector('.el-table__body-wrapper .el-scrollbar__wrap') as HTMLElement | null) ||
(table.querySelector('.el-table__body-wrapper') as HTMLElement | null)
)
}
function getHeaderWidths(): number[] {
const table = getMainTableEl()
if (!table) return []
const cols = table.querySelectorAll('.el-table__header colgroup col')
const fromCols: number[] = []
cols.forEach((col) => {
const el = col as HTMLElement
if (el.getAttribute('name') === 'gutter') return
const w =
el.getBoundingClientRect().width || Number.parseFloat(el.getAttribute('width') || '') || 0
if (w > 0) fromCols.push(w)
})
if (fromCols.length >= columnsRef.value.length) return fromCols
const ths = table.querySelectorAll('.el-table__header-wrapper th.el-table__cell')
const fromThs: number[] = []
ths.forEach((th) => {
const el = th as HTMLElement
if (el.classList.contains('gutter')) return
const w = el.getBoundingClientRect().width
if (w > 0) fromThs.push(w)
})
return fromThs
}
function syncWidths() {
const widths = getHeaderWidths()
const aligns = getTableColumnTextAligns(getMainTableEl())
const inner = statsScrollRef.value?.querySelector(
'.list-column-stats-row__inner'
) as HTMLElement | null
const cells = statsScrollRef.value?.querySelectorAll('.list-column-stats-row__cell')
if (!inner || !cells?.length || !widths.length) return
const cols = columnsRef.value
const count = Math.min(cells.length, widths.length, cols.length)
let total = 0
for (let i = 0; i < count; i++) {
const w = Math.round(widths[i])
total += w
const el = cells[i] as HTMLElement
el.style.flex = 'none'
el.style.width = `${w}px`
el.style.minWidth = `${w}px`
el.style.maxWidth = `${w}px`
applyStatsCellAlignStyles(
el,
resolveStatsCellAlign(cols[i], aligns[i]),
'.list-column-stats-row__trigger'
)
}
inner.style.width = `${total}px`
inner.style.minWidth = `${total}px`
bindTableScrollListener()
const tableScroll = getTableScrollEl()
const statsScroll = statsScrollRef.value
if (tableScroll && statsScroll) {
statsScroll.scrollLeft = tableScroll.scrollLeft
}
}
function scheduleSync() {
nextTick(() => {
syncWidths()
ensureHeaderObserver()
})
}
function ensureHeaderObserver() {
const table = getMainTableEl()
const header = table?.querySelector('.el-table__header-wrapper') as HTMLElement | null
if (!header) return
if (headerResizeObserver) headerResizeObserver.disconnect()
headerResizeObserver = new ResizeObserver(() => syncWidths())
headerResizeObserver.observe(header)
header.querySelectorAll('th.el-table__cell').forEach((th) => {
if ((th as HTMLElement).classList.contains('gutter')) return
headerResizeObserver?.observe(th)
})
}
function bindTableScrollListener() {
const el = getTableScrollEl()
if (!el || el === tableScrollElBound) return
if (tableScrollElBound) {
tableScrollElBound.removeEventListener('scroll', onTableScroll)
}
tableScrollElBound = el
el.addEventListener('scroll', onTableScroll, { passive: true })
}
function onTableScroll() {
if (scrollSyncing) return
const tableScroll = getTableScrollEl()
const statsScroll = statsScrollRef.value
if (!tableScroll || !statsScroll) return
if (statsScroll.scrollLeft === tableScroll.scrollLeft) return
scrollSyncing = true
statsScroll.scrollLeft = tableScroll.scrollLeft
requestAnimationFrame(() => {
scrollSyncing = false
})
}
function onStatsRowScroll() {
if (scrollSyncing) return
const tableScroll = getTableScrollEl()
const statsScroll = statsScrollRef.value
if (!tableScroll || !statsScroll) return
if (tableScroll.scrollLeft === statsScroll.scrollLeft) return
scrollSyncing = true
tableScroll.scrollLeft = statsScroll.scrollLeft
requestAnimationFrame(() => {
scrollSyncing = false
})
}
/** 供父级在 header-dragend 后调用 */
function resync() {
scheduleSync()
}
defineExpose({ resync })
watch(
() => [props.columns, props.data, props.tableWrapEl] as const,
() => scheduleSync(),
{ deep: true }
)
onMounted(() => {
scheduleSync()
setTimeout(() => scheduleSync(), 150)
setTimeout(() => scheduleSync(), 400)
window.addEventListener('resize', scheduleSync)
})
onUnmounted(() => {
window.removeEventListener('resize', scheduleSync)
headerResizeObserver?.disconnect()
headerResizeObserver = null
if (tableScrollElBound) {
tableScrollElBound.removeEventListener('scroll', onTableScroll)
tableScrollElBound = null
}
})
</script>
<style scoped lang="scss">
.list-column-stats-row {
position: relative;
z-index: 5;
overflow-x: auto;
overflow-y: hidden;
border-top: 2px solid var(--el-color-primary, #10b981);
background: var(--mint-primary-bg, #ecfdf5);
font-weight: 600;
color: var(--mint-text, #1e293b);
min-height: 44px;
scrollbar-width: none;
}
.list-column-stats-row::-webkit-scrollbar {
display: none;
}
.list-column-stats-row__inner {
display: flex;
align-items: stretch;
min-height: 44px;
box-sizing: border-box;
}
.list-column-stats-row__cell {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0 8px;
box-sizing: border-box;
border-right: 1px solid var(--mint-border, #e2e8f0);
flex: none;
overflow: hidden;
}
.list-column-stats-row__cell.is-align-left {
justify-content: flex-start;
}
.list-column-stats-row__cell.is-align-center {
justify-content: center;
}
.list-column-stats-row__cell.is-align-right {
justify-content: flex-end;
}
.list-column-stats-row__cell.is-spacer {
pointer-events: none;
}
.list-column-stats-row__trigger {
display: block;
box-sizing: border-box;
width: 100%;
min-height: 28px;
margin: 0;
padding: 4px 0;
border: 0;
background: transparent;
cursor: pointer;
text-align: left;
line-height: 1.4;
font-size: 13px;
font-weight: inherit;
color: inherit;
outline: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.list-column-stats-row__trigger.is-empty {
color: var(--mint-text-muted, #64748b);
font-weight: 500;
}
</style>
<style lang="scss">
.list-column-stats-dropdown {
z-index: 4000 !important;
padding: 6px !important;
}
.list-column-stats-menu {
display: flex;
flex-direction: column;
gap: 2px;
}
.list-column-stats-menu__item {
display: block;
width: 100%;
margin: 0;
padding: 8px 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: #1e293b;
font-size: 13px;
text-align: left;
cursor: pointer;
line-height: 1.3;
}
.list-column-stats-menu__item:hover {
background: #ecfdf5;
color: #059669;
}
.list-column-stats-menu__item.is-active {
background: #d1fae5;
color: #059669;
font-weight: 600;
}
</style>
@@ -129,7 +129,9 @@
)
/**
* 代理底层 el-table,保证父组件 ref 上的 clearSelection / doLayout 等仍可用
* 代理底层 el-table,保证父组件 ref 上的 clearSelection / setCurrentRow / doLayout 等仍可用。
* 必须实现 has:Vue exposeProxy 用 `key in target` 决定是否转发,空对象 Proxy 若不声明 has
* 则 `in` 恒为 false,父组件拿到的方法永远是 undefined。
*/
const exposed = new Proxy(
{},
@@ -148,6 +150,17 @@
if (!table) return undefined
const value = table[prop as keyof typeof table]
return typeof value === 'function' ? value.bind(table) : value
},
has(_target, prop) {
if (
prop === 'elTableRef' ||
prop === 'applyColumnWidths' ||
prop === 'resetColumnWidths'
) {
return true
}
const table = tableRef.value
return !!table && prop in table
}
}
)
@@ -73,16 +73,20 @@
<el-form-item label="选择司机" prop="driverId">
<el-select
v-model="formData.driverId"
placeholder="请先选择车辆(可选)"
placeholder="请选择司机(可选)"
filterable
remote
clearable
:disabled="!formData.vehicleId || vehicleDriverList.length === 0"
:remote-method="searchDrivers"
:loading="driverSelectLoading"
:disabled="!formData.vehicleId"
@focus="onDriverSelectFocus"
>
<el-option
v-for="d in vehicleDriverList"
:key="d.personnelId"
:label="formatDriverLabel(d)"
:value="d.personnelId!"
v-for="d in driverList"
:key="d.id"
:label="formatDriverOptionLabel(d)"
:value="d.id"
/>
</el-select>
</el-form-item>
@@ -345,6 +349,7 @@ import {
transportApi,
type Plan,
type PlanProduct,
type Personnel,
type Transport,
type TransportArrangeResult,
type VehiclePersonnelRow
@@ -360,6 +365,7 @@ import type { PlanFormProductRow } from '@/composables/usePlContractToPlan'
import { usePlFreightCalc } from '@/composables/usePlFreightCalc'
import {
usePlRemoteCarrierOptions,
usePlRemotePersonnelOptions,
usePlRemoteVehicleOptions
} from '@/composables/usePlRemoteOptions'
import { loadVehicleViewBundle, type VehicleViewBundle } from '@/composables/usePlVehicleViewDetail'
@@ -370,6 +376,7 @@ import { isTonProductUnit } from '@/utils/crm-product-display'
import { formatDate } from '@/utils/format'
import { pickBxData } from '@/utils/http/bx'
import { canPrintSlipCommand, formatPlVehiclePlateDisplay, type PlPrintSlipCommand } from '@/utils/pl-display'
import { getPersonnelAffiliationLabel } from '@/utils/pl-personnel-cert'
const dictStore = useDictStore()
@@ -440,7 +447,18 @@ const {
getSelectedId: () => formData.carrierId,
listParams: { status: '1' }
})
const vehicleDriverList = ref<VehiclePersonnelRow[]>([])
/** 司机下拉:全部启用中的驾驶员(山峡自有 + 三方),不限车辆绑定 */
const {
list: driverList,
loading: driverSelectLoading,
search: searchDrivers,
onFocus: onDriverSelectFocus,
reset: resetDriverOptions,
ensureById: ensureSelectedDriverOption
} = usePlRemotePersonnelOptions({
getSelectedId: () => formData.driverId,
listParams: { status: '1', personnelType: 'driver' }
})
const prefilledDriverId = ref('')
const vehicleBundle = ref<VehicleViewBundle | null>(null)
const vehicleViewVisible = ref(false)
@@ -471,8 +489,7 @@ const formData = reactive({
})
const formRules = {
vehicleId: [{ required: true, message: '请选择车辆', trigger: 'change' }],
driverId: [{ required: true, message: '请选择司机', trigger: 'change' }]
vehicleId: [{ required: true, message: '请选择车辆', trigger: 'change' }]
}
const dialogTitle = computed(() => {
@@ -560,22 +577,25 @@ watch(canGenerateDibangPlan, allowed => {
}
})
function formatDriverLabel(d: VehiclePersonnelRow) {
function formatDriverOptionLabel(d: Personnel) {
const phone = d.phoneNo ? ` (${d.phoneNo})` : ''
return `${d.personnelName}${phone}`
const affiliation = getPersonnelAffiliationLabel(d.personnelAffiliation)
const suffix = affiliation ? ` · ${affiliation}` : ''
return `${d.personnelName}${phone}${suffix}`
}
/** 默认司机仍按车辆绑定人员规则预填;下拉选项已改为全量驾驶员 */
function applyVehiclePersonnel(personnelList: VehiclePersonnelRow[], preferredDriverId?: string) {
vehicleDriverList.value = personnelList.filter(p => p.role === 'driver' && p.personnelId)
if (preferredDriverId && vehicleDriverList.value.some(d => d.personnelId === preferredDriverId)) {
const vehicleDrivers = personnelList.filter(p => p.role === 'driver' && p.personnelId)
if (preferredDriverId && vehicleDrivers.some(d => d.personnelId === preferredDriverId)) {
formData.driverId = preferredDriverId
return
}
const defaultDriver = personnelList.find(p => p.role === 'driver' && p.isDefault === 1 && p.personnelId)
if (defaultDriver?.personnelId) {
formData.driverId = defaultDriver.personnelId
} else if (vehicleDriverList.value.length === 1 && vehicleDriverList.value[0].personnelId) {
formData.driverId = vehicleDriverList.value[0].personnelId
} else if (vehicleDrivers.length === 1 && vehicleDrivers[0].personnelId) {
formData.driverId = vehicleDrivers[0].personnelId
} else {
formData.driverId = ''
}
@@ -652,9 +672,9 @@ function resetState() {
resetArrangeActions()
plan.value = {}
products.value = []
vehicleDriverList.value = []
resetVehicleOptions()
resetCarrierOptions()
resetDriverOptions()
prefilledDriverId.value = ''
vehicleBundle.value = null
vehicleViewVisible.value = false
@@ -687,14 +707,13 @@ function resetState() {
}
async function loadMasterData() {
await Promise.all([searchVehicles(''), searchCarriers('')])
await Promise.all([searchVehicles(''), searchCarriers(''), searchDrivers('')])
}
async function handleVehicleChange(vehicleId: string, preferredDriverId?: string) {
if (!vehicleId) {
formData.driverId = ''
formData.carrierId = ''
vehicleDriverList.value = []
vehicleBundle.value = null
return
}
@@ -718,6 +737,9 @@ async function handleVehicleChange(vehicleId: string, preferredDriverId?: string
await ensureSelectedCarrierOption(formData.carrierId)
}
applyVehiclePersonnel(vehicleBundle.value.personnelList || [], preferredDriverId)
if (formData.driverId) {
await ensureSelectedDriverOption(formData.driverId)
}
} finally {
vehicleLoading.value = false
}