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

+2
View File
@@ -182,6 +182,8 @@ export interface PersonnelQuery {
q?: string
status?: string
personnelAffiliation?: string
/** driver | escort;不传则不过滤类型 */
personnelType?: string
page?: number
pageSize?: number
}
@@ -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
}
@@ -200,5 +200,16 @@ export function usePlRemotePersonnelOptions(options?: {
loaded.value = false
}
return { list, loading, loaded, search, onFocus, reset, invalidate }
async function ensureById(personnelId: string) {
if (!personnelId || list.value.some(item => item.id === personnelId)) return
try {
const res = await personnelApi.get(personnelId)
const detail = pickBxData<Personnel>(res)
if (detail?.id) list.value = [detail, ...list.value]
} catch {
// ignore
}
}
return { list, loading, loaded, search, onFocus, reset, invalidate, ensureById }
}
+248
View File
@@ -0,0 +1,248 @@
export type ColumnStatMethod = 'sum' | 'avg' | 'max' | 'min'
export type StatsCellAlign = 'left' | 'center' | 'right'
export type StatsRowColumn = {
key: string
interactive: boolean
prop?: string
money?: boolean
/** 显式对齐;缺省时由统计行从主表表头同步 */
align?: StatsCellAlign
}
export const COLUMN_STAT_METHOD_OPTIONS: { value: ColumnStatMethod | ''; label: string }[] = [
{ value: '', label: '无' },
{ value: 'sum', label: '求和' },
{ value: 'avg', label: '平均' },
{ value: 'max', label: '最大值' },
{ value: 'min', label: '最小值' }
]
export const COLUMN_STAT_METHOD_LABEL: Record<ColumnStatMethod, string> = {
sum: '求和',
avg: '平均',
max: '最大值',
min: '最小值'
}
export function normalizeCssTextAlign(value: string | null | undefined): StatsCellAlign {
const ta = String(value || 'left').toLowerCase()
if (ta === 'right' || ta === 'end') return 'right'
if (ta === 'center') return 'center'
return 'left'
}
/**
* 优先列配置 align;金额列默认 right;
* 其余同步主表列对齐(表体 `align`,无数据时回退表头)。
* 注意:多数业务表 `header-align=center` 且 `align=right`,不可只读表头。
*/
export function resolveStatsCellAlign(
col: Pick<StatsRowColumn, 'align' | 'money'>,
columnAlign?: string | null
): StatsCellAlign {
if (col.align === 'left' || col.align === 'center' || col.align === 'right') return col.align
if (col.money) return 'right'
if (columnAlign) return normalizeCssTextAlign(columnAlign)
return 'left'
}
/** 从主表读取各列 text-align:优先首行表体(对应 `align`),无行时回退表头 */
export function getTableColumnTextAligns(table: HTMLElement | null | undefined): string[] {
if (!table) return []
const collect = (cells: NodeListOf<Element>): string[] => {
const aligns: string[] = []
cells.forEach((node) => {
const el = node as HTMLElement
if (el.classList.contains('gutter')) return
aligns.push(getComputedStyle(el).textAlign || 'left')
})
return aligns
}
const firstRow = table.querySelector('.el-table__body-wrapper tbody tr')
if (firstRow) {
const fromBody = collect(firstRow.querySelectorAll('td.el-table__cell'))
if (fromBody.length) return fromBody
}
return collect(table.querySelectorAll('.el-table__header-wrapper th.el-table__cell'))
}
export function applyStatsCellAlignStyles(
cellEl: HTMLElement,
align: StatsCellAlign,
triggerSelector: string
) {
cellEl.style.justifyContent =
align === 'right' ? 'flex-end' : align === 'center' ? 'center' : 'flex-start'
cellEl.classList.remove('is-align-left', 'is-align-center', 'is-align-right')
cellEl.classList.add(`is-align-${align}`)
const trigger = cellEl.querySelector(triggerSelector) as HTMLElement | null
if (trigger) trigger.style.textAlign = align
}
export function parseStatNumber(raw: unknown): number | null {
if (raw == null || raw === '') return null
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null
let s = String(raw).trim()
if (!s) return null
s = s.replace(/^¥/, '').replace(/%$/, '').replace(/,/g, '')
const n = Number(s)
return Number.isFinite(n) ? n : null
}
function nonEmptyString(raw: unknown): string | null {
if (raw == null) return null
const s = String(raw).trim()
return s ? s : null
}
export function aggregateColumnValues(
values: unknown[],
method: ColumnStatMethod
): number | string | null {
const nums: number[] = []
const strs: string[] = []
for (const v of values) {
const n = parseStatNumber(v)
if (n != null) nums.push(n)
else {
const s = nonEmptyString(v)
if (s != null) strs.push(s)
}
}
if (method === 'sum') {
if (!nums.length) return null
return nums.reduce((a, b) => a + b, 0)
}
if (method === 'avg') {
if (!nums.length) return null
return nums.reduce((a, b) => a + b, 0) / nums.length
}
if (method === 'max') {
if (nums.length) return Math.max(...nums)
if (strs.length) return strs.reduce((a, b) => (a >= b ? a : b))
return null
}
if (nums.length) return Math.min(...nums)
if (strs.length) return strs.reduce((a, b) => (a <= b ? a : b))
return null
}
export function formatStatNumberResult(
value: number,
opts: { money?: boolean } = {}
): string {
if (opts.money) {
return value.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
if (Number.isInteger(value)) return String(value)
return value.toLocaleString('zh-CN', { maximumFractionDigits: 4 })
}
export function formatColumnStatDisplay(
method: ColumnStatMethod | '' | null | undefined,
values: unknown[],
opts: { money?: boolean } = {}
): string {
if (!method) return ''
const label = COLUMN_STAT_METHOD_LABEL[method]
const agg = aggregateColumnValues(values, method)
if (agg == null) return `${label} -`
if (typeof agg === 'number') {
return `${label} ${formatStatNumberResult(agg, opts)}`
}
return `${label} ${agg}`
}
export type ColumnStatConfigMap = Partial<Record<string, ColumnStatMethod>>
export function loadColumnStatConfig(storageKey: string): ColumnStatConfigMap {
try {
const raw = localStorage.getItem(storageKey)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object') return {}
const out: ColumnStatConfigMap = {}
for (const [k, v] of Object.entries(parsed)) {
if (v === 'sum' || v === 'avg' || v === 'max' || v === 'min') out[k] = v
}
return out
} catch {
return {}
}
}
export function saveColumnStatConfig(
config: ColumnStatConfigMap,
storageKey: string
): void {
const cleaned: ColumnStatConfigMap = {}
for (const [k, v] of Object.entries(config)) {
if (v === 'sum' || v === 'avg' || v === 'max' || v === 'min') cleaned[k] = v
}
localStorage.setItem(storageKey, JSON.stringify(cleaned))
}
export function setColumnStatMethod(
config: ColumnStatConfigMap,
prop: string,
method: ColumnStatMethod | ''
): ColumnStatConfigMap {
const next = { ...config }
if (!method) delete next[prop]
else next[prop] = method
return next
}
/** 构建与主表列顺序一致的统计行列(前导占位 + 固定列 + 可见可选列) */
export function buildStatsRowColumns(options: {
leadingSpacers?: number
alwaysColumns?: { prop: string; money?: boolean }[]
optionalDefs?: { prop: string }[]
isColumnVisible?: (prop: string) => boolean
moneyProps?: Set<string> | string[]
}): StatsRowColumn[] {
const {
leadingSpacers = 0,
alwaysColumns = [],
optionalDefs = [],
isColumnVisible = () => true,
moneyProps
} = options
const moneySet =
moneyProps instanceof Set
? moneyProps
: new Set(moneyProps || [])
const cols: StatsRowColumn[] = []
for (let i = 0; i < leadingSpacers; i++) {
cols.push({ key: `__spacer_${i}`, interactive: false })
}
for (const col of alwaysColumns) {
const money = col.money ?? moneySet.has(col.prop)
cols.push({
key: col.prop,
interactive: true,
prop: col.prop,
money,
...(money ? { align: 'right' as const } : {})
})
}
for (const def of optionalDefs) {
if (!isColumnVisible(def.prop)) continue
const money = moneySet.has(def.prop)
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money,
...(money ? { align: 'right' as const } : {})
})
}
return cols
}
+50 -11
View File
@@ -107,16 +107,15 @@
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary">
<div ref="advanceApplyTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="bx-advance-apply"
class="list-table"
:data="rows"
v-loading="loading"
stripe
show-summary
:summary-method="summaryMethod"
@selection-change="onSelectionChange"
@row-dblclick="openView"
@header-dragend="onAdvanceApplyHeaderDragEnd"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -233,6 +232,13 @@
</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="advanceApplyStatsRowRef"
storage-key="bx-advance-apply-column-stats"
:columns="advanceApplyStatsRowColumns"
:data="rows"
:table-wrap-el="advanceApplyTableWrapRef"
/>
</div>
<div class="pagination list-pager-bar">
@@ -412,6 +418,7 @@
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
@@ -449,7 +456,7 @@ import BaseDetailTableSection from '@/components/bx/base/BaseDetailTableSection.
import type { ViewField } from '@/components/business/system-view-dialog/types'
import { bxViewKv, bxViewKvMoney } from '@/utils/bx/view-kv'
import { buildApplyDateRangeQuery, type ApplyDateRange } from '@/utils/bx/apply-date-filter'
import { buildPageSummaryMethod, sumField } from '@/utils/bx/table-summary'
import type { StatsRowColumn } from '@/utils/list-column-stats'
type DialogKey = 'add' | 'view' | 'viewFlow'
@@ -487,6 +494,39 @@ const {
resetToDefault
} = usePersistedListColumns('bx-advance-apply-list-columns', listColumnDefs)
const ADVANCE_APPLY_MONEY_PROPS = new Set(['amount', 'actualAmount', 'offsetAmount'])
const advanceApplyTableWrapRef = ref<HTMLElement | null>(null)
const advanceApplyStatsRowRef = ref<{ resync?: () => void } | null>(null)
/** 列顺序与表头一致:selection、index、可选 applyTime、固定 applyCode、其余可选列 */
const advanceApplyStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false }
]
if (isColumnVisible('applyTime')) {
cols.push({ key: 'applyTime', interactive: true, prop: 'applyTime' })
}
cols.push({ key: 'applyCode', interactive: true, prop: 'applyCode' })
for (const def of listColumnDefs) {
if (def.prop === 'applyTime') continue
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: ADVANCE_APPLY_MONEY_PROPS.has(def.prop),
...(ADVANCE_APPLY_MONEY_PROPS.has(def.prop) ? { align: 'right' as const } : {})
})
}
return cols
})
function onAdvanceApplyHeaderDragEnd() {
advanceApplyStatsRowRef.value?.resync?.()
}
const filterForm = reactive({
keyword: '',
status: '',
@@ -845,11 +885,10 @@ async function handleDelete(row: any) {
}
}
const summaryMethod = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumField(data, 'amount'),
actualAmount: (data) => sumField(data, 'actualAmount', true),
offsetAmount: (data) => sumField(data, 'offsetAmount')
}
})
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
</style>
@@ -81,16 +81,15 @@
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary">
<div ref="pendingTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="bx-advance-approve"
class="list-table"
:data="pendingRows"
v-loading="pendingLoading"
stripe
show-summary
:summary-method="pendingSummaryMethod"
@selection-change="onSelectionChange"
@row-dblclick="openView"
@header-dragend="() => pendingStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -163,6 +162,13 @@
</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="pendingStatsRowRef"
storage-key="bx-advance-approve-pending-column-stats"
:columns="pendingStatsRowColumns"
:data="pendingRows"
:table-wrap-el="pendingTableWrapRef"
/>
</div>
<div class="pagination list-pager-bar">
@@ -249,15 +255,14 @@
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary">
<div ref="approvedTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="bx-advance-approve-2"
class="list-table"
:data="approvedRows"
v-loading="approvedLoading"
stripe
show-summary
:summary-method="approvedSummaryMethod"
@row-dblclick="openView"
@header-dragend="() => approvedStatsRowRef?.resync?.()"
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
@@ -343,6 +348,13 @@
</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="approvedStatsRowRef"
storage-key="bx-advance-approve-approved-column-stats"
:columns="approvedStatsRowColumns"
:data="approvedRows"
:table-wrap-el="approvedTableWrapRef"
/>
</div>
<div class="pagination list-pager-bar">
@@ -466,6 +478,7 @@
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
@@ -491,7 +504,7 @@ import BxViewKvTable from '@/components/bx/base/BxViewKvTable.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import { bxViewKv, bxViewKvLong, bxViewKvMoney } from '@/utils/bx/view-kv'
import { buildApplyDateRangeQuery, type ApplyDateRange } from '@/utils/bx/apply-date-filter'
import { buildPageSummaryMethod, sumField } from '@/utils/bx/table-summary'
import type { StatsRowColumn } from '@/utils/list-column-stats'
type DialogKey = 'view' | 'approve'
@@ -512,6 +525,43 @@ const {
resetToDefault
} = usePersistedListColumns('bx-advance-approve-list-columns', listColumnDefs)
const pendingTableWrapRef = ref<HTMLElement | null>(null)
const approvedTableWrapRef = ref<HTMLElement | null>(null)
const pendingStatsRowRef = ref<{ resync?: () => void } | null>(null)
const approvedStatsRowRef = ref<{ resync?: () => void } | null>(null)
function buildAdvanceApproveStatsColumns(opts: {
withSelection: boolean
includeStatus: boolean
}): StatsRowColumn[] {
const cols: StatsRowColumn[] = []
if (opts.withSelection) cols.push({ key: '__selection', interactive: false })
cols.push({ key: '__index', interactive: false })
if (isColumnVisible('applyTime')) {
cols.push({ key: 'applyTime', interactive: true, prop: 'applyTime' })
}
cols.push({ key: 'applyCode', interactive: true, prop: 'applyCode' })
for (const def of listColumnDefs) {
if (def.prop === 'applyTime') continue
if (def.prop === 'status' && !opts.includeStatus) continue
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: def.prop === 'amount'
})
}
return cols
}
const pendingStatsRowColumns = computed(() =>
buildAdvanceApproveStatsColumns({ withSelection: true, includeStatus: false })
)
const approvedStatsRowColumns = computed(() =>
buildAdvanceApproveStatsColumns({ withSelection: false, includeStatus: true })
)
const loadingMap = reactive<Record<string, boolean>>({})
const dialogs = reactive<Record<DialogKey, boolean>>({
view: false,
@@ -769,14 +819,6 @@ async function openView(row: any) {
}
}
const amountSummary = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumField(data, 'amount')
}
})
const pendingSummaryMethod = amountSummary
const approvedSummaryMethod = amountSummary
async function handleConfirmApprove() {
if (!approveRows.value.length) {
ElMessage.warning('没有可审批的申请')
@@ -818,6 +860,10 @@ async function handleConfirmApprove() {
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
.batch-action-alert {
margin-bottom: 12px;
+66 -15
View File
@@ -80,16 +80,15 @@
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary">
<div ref="pendingIssueTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="bx-advance-issue"
class="list-table"
:data="rows"
v-loading="loading"
stripe
show-summary
:summary-method="advanceIssueSummary"
@selection-change="onSelectionChange"
@row-dblclick="openView"
@header-dragend="() => pendingIssueStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -182,6 +181,13 @@
show-overflow-tooltip
/>
</ListTable>
<ListColumnStatsRow
ref="pendingIssueStatsRowRef"
storage-key="bx-advance-issue-pending-column-stats"
:columns="pendingIssueStatsRowColumns"
:data="rows"
:table-wrap-el="pendingIssueTableWrapRef"
/>
</div>
<div class="pagination list-pager-bar">
@@ -271,15 +277,14 @@
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary">
<div ref="issuedTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="bx-advance-issue-2"
class="list-table"
:data="rows"
v-loading="loading"
stripe
show-summary
:summary-method="advanceIssueSummary"
@row-dblclick="openView"
@header-dragend="() => issuedStatsRowRef?.resync?.()"
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
@@ -378,6 +383,13 @@
header-align="center"
/>
</ListTable>
<ListColumnStatsRow
ref="issuedStatsRowRef"
storage-key="bx-advance-issue-issued-column-stats"
:columns="issuedStatsRowColumns"
:data="rows"
:table-wrap-el="issuedTableWrapRef"
/>
</div>
<div class="pagination list-pager-bar">
@@ -516,6 +528,7 @@
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
@@ -535,7 +548,7 @@ import BxAttachmentReadonly from '@/components/bx/base/BxAttachmentReadonly.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import { bxViewKv } from '@/utils/bx/view-kv'
import { buildApplyDateRangeQuery, type ApplyDateRange } from '@/utils/bx/apply-date-filter'
import { buildPageSummaryMethod, sumField } from '@/utils/bx/table-summary'
import type { StatsRowColumn } from '@/utils/list-column-stats'
type DialogKey = 'issue' | 'view'
@@ -559,6 +572,46 @@ const {
resetToDefault
} = usePersistedListColumns('bx-advance-issue-list-columns', listColumnDefs)
const ISSUE_MONEY_PROPS = new Set(['amount', 'actualAmount', 'offsetAmount'])
const pendingIssueTableWrapRef = ref<HTMLElement | null>(null)
const issuedTableWrapRef = ref<HTMLElement | null>(null)
const pendingIssueStatsRowRef = ref<{ resync?: () => void } | null>(null)
const issuedStatsRowRef = ref<{ resync?: () => void } | null>(null)
function buildIssueStatsColumns(opts: {
withSelection: boolean
mode: 'pending' | 'issued'
}): StatsRowColumn[] {
const cols: StatsRowColumn[] = []
if (opts.withSelection) cols.push({ key: '__selection', interactive: false })
cols.push({ key: '__index', interactive: false })
if (isColumnVisible('applyTime')) {
cols.push({ key: 'applyTime', interactive: true, prop: 'applyTime' })
}
cols.push({ key: 'applyCode', interactive: true, prop: 'applyCode' })
for (const def of listColumnDefs) {
if (def.prop === 'applyTime') continue
if (opts.mode === 'pending' && (def.prop === 'issueTime' || def.prop === 'issueUserName')) continue
if (opts.mode === 'issued' && def.prop === 'approveTime') continue
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: ISSUE_MONEY_PROPS.has(def.prop)
})
}
return cols
}
const pendingIssueStatsRowColumns = computed(() =>
buildIssueStatsColumns({ withSelection: true, mode: 'pending' })
)
const issuedStatsRowColumns = computed(() =>
buildIssueStatsColumns({ withSelection: false, mode: 'issued' })
)
const loadingMap = reactive<Record<string, boolean>>({})
const dialogs = reactive<Record<DialogKey, boolean>>({
issue: false,
@@ -629,14 +682,6 @@ function issueStatusTagType(status: string): 'success' | 'warning' {
return status === 'issued' ? 'success' : 'warning'
}
const advanceIssueSummary = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumField(data, 'amount'),
actualAmount: (data) => sumField(data, 'actualAmount'),
offsetAmount: (data) => sumField(data, 'offsetAmount')
}
})
onMounted(() => {
loadApplicantOptions()
refresh()
@@ -798,3 +843,9 @@ async function openView(row: any) {
}
}
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
</style>
@@ -44,40 +44,50 @@
</div>
<div class="list-body list-wrap">
<ListTable table-key="bx-invoice-wallet-admin"
v-loading="loading"
class="list-table"
:data="rows"
stripe
@row-dblclick="openDetail"
>
<el-table-column type="index" label="序号" min-width="68" align="center" />
<el-table-column prop="ownerUserName" label="员工" min-width="120">
<template #default="{ row }">
<el-link type="primary" @click="openDetail(row)">{{ row.ownerUserName || '-' }}</el-link>
</template>
</el-table-column>
<el-table-column prop="totalAmount" label="总额(元)" min-width="120" align="right">
<template #default="{ row }">{{ formatMoneyYuan(row.totalAmount) }}</template>
</el-table-column>
<el-table-column prop="usedAmount" label="已报销占用(元)" min-width="140" align="right">
<template #default="{ row }">{{ formatMoneyYuan(row.usedAmount) }}</template>
</el-table-column>
<el-table-column prop="balance" label="余额(元)" min-width="120" align="right">
<template #default="{ row }">{{ formatMoneyYuan(row.balance) }}</template>
</el-table-column>
<el-table-column prop="invoiceCount" label="发票张数" min-width="100" align="center" />
<el-table-column label="操作" fixed="right" min-width="200">
<template #default="{ row }">
<el-button type="primary" link @click="openDetail(row)">
<el-icon><View /></el-icon>管理发票
</el-button>
<el-button type="primary" link @click="openProxyUpload(row)">
<el-icon><Upload /></el-icon>代传
</el-button>
</template>
</el-table-column>
</ListTable>
<div ref="walletAdminTableWrapRef" class="bx-list-table-wrap">
<ListTable table-key="bx-invoice-wallet-admin"
v-loading="loading"
class="list-table"
:data="rows"
stripe
@row-dblclick="openDetail"
@header-dragend="() => walletAdminStatsRowRef?.resync?.()"
>
<el-table-column type="index" label="序号" min-width="68" align="center" />
<el-table-column prop="ownerUserName" label="员工" min-width="120">
<template #default="{ row }">
<el-link type="primary" @click="openDetail(row)">{{ row.ownerUserName || '-' }}</el-link>
</template>
</el-table-column>
<el-table-column prop="totalAmount" label="总额(元)" min-width="120" align="right">
<template #default="{ row }">{{ formatMoneyYuan(row.totalAmount) }}</template>
</el-table-column>
<el-table-column prop="usedAmount" label="已报销占用(元)" min-width="140" align="right">
<template #default="{ row }">{{ formatMoneyYuan(row.usedAmount) }}</template>
</el-table-column>
<el-table-column prop="balance" label="余额(元)" min-width="120" align="right">
<template #default="{ row }">{{ formatMoneyYuan(row.balance) }}</template>
</el-table-column>
<el-table-column prop="invoiceCount" label="发票张数" min-width="100" align="center" />
<el-table-column label="操作" fixed="right" min-width="200">
<template #default="{ row }">
<el-button type="primary" link @click="openDetail(row)">
<el-icon><View /></el-icon>管理发票
</el-button>
<el-button type="primary" link @click="openProxyUpload(row)">
<el-icon><Upload /></el-icon>代传
</el-button>
</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="walletAdminStatsRowRef"
storage-key="bx-invoice-wallet-admin-column-stats"
:columns="walletAdminStatsRowColumns"
:data="rows"
:table-wrap-el="walletAdminTableWrapRef"
/>
</div>
<div class="pagination list-pager-bar">
<el-pagination
v-model:current-page="page"
@@ -264,7 +274,7 @@
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import {
Search,
RefreshLeft,
@@ -277,6 +287,8 @@
import { ElMessage, ElMessageBox } from 'element-plus'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import BxInvoiceFileSelect from '@/components/bx/base/BxInvoiceFileSelect.vue'
import BxAttachmentReadonly from '@/components/bx/base/BxAttachmentReadonly.vue'
import { useApplicantOptions } from '@/hooks/bx/useApplicantOptions'
@@ -294,6 +306,20 @@
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const walletAdminTableWrapRef = ref<HTMLElement | null>(null)
const walletAdminStatsRowRef = ref<{ resync?: () => void } | null>(null)
const walletAdminStatsRowColumns = computed((): StatsRowColumn[] => [
{ key: '__index', interactive: false },
{ key: 'ownerUserName', interactive: true, prop: 'ownerUserName' },
{ key: 'totalAmount', interactive: true, prop: 'totalAmount', money: true },
{ key: 'usedAmount', interactive: true, prop: 'usedAmount', money: true },
{ key: 'balance', interactive: true, prop: 'balance', money: true },
{ key: 'invoiceCount', interactive: true, prop: 'invoiceCount' },
{ key: '__ops', interactive: false }
])
const filterForm = reactive({ keyword: '' })
const quickSearchKeyword = ref('')
const detailVisible = ref(false)
@@ -537,6 +563,10 @@
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
.detail-empty-hint {
margin: 8px 0 0;
font-size: 13px;
@@ -72,60 +72,86 @@
<el-tabs v-model="activeTab" class="wallet-tabs">
<el-tab-pane label="发票列表" name="invoices">
<div class="list-body list-wrap">
<ListTable table-key="bx-invoice-wallet-mine"
v-loading="loading"
class="list-table"
:data="filteredInvoiceRows"
stripe
@row-dblclick="openInvoiceView"
>
<el-table-column type="index" label="序号" min-width="52" align="center" />
<el-table-column prop="amount" label="金额(元)" min-width="88" align="right">
<template #default="{ row }">
<el-link type="primary" @click="openInvoiceView(row)">
{{ formatMoneyYuan(row.amount) }}
</el-link>
</template>
</el-table-column>
<el-table-column label="附件" min-width="420">
<template #default="{ row }">
<BxAttachmentReadonly
v-if="invoiceThumbFiles(row).length"
class="invoice-att-thumbs"
:files="invoiceThumbFiles(row)"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || '-' }}</template>
</el-table-column>
<el-table-column prop="uploadByName" label="上传人" min-width="80">
<template #default="{ row }">{{ row.uploadByName || '-' }}</template>
</el-table-column>
<el-table-column prop="createTime" label="上传时间" min-width="140" />
</ListTable>
<div ref="invoiceTableWrapRef" class="bx-list-table-wrap">
<ListTable table-key="bx-invoice-wallet-mine"
v-loading="loading"
class="list-table"
:data="filteredInvoiceRows"
stripe
@row-dblclick="openInvoiceView"
@header-dragend="() => invoiceStatsRowRef?.resync?.()"
>
<el-table-column type="index" label="序号" min-width="52" align="center" />
<el-table-column prop="amount" label="金额(元)" min-width="88" align="right">
<template #default="{ row }">
<el-link type="primary" @click="openInvoiceView(row)">
{{ formatMoneyYuan(row.amount) }}
</el-link>
</template>
</el-table-column>
<el-table-column label="附件" min-width="420">
<template #default="{ row }">
<BxAttachmentReadonly
v-if="invoiceThumbFiles(row).length"
class="invoice-att-thumbs"
:files="invoiceThumbFiles(row)"
/>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || '-' }}</template>
</el-table-column>
<el-table-column prop="uploadByName" label="上传人" min-width="80">
<template #default="{ row }">{{ row.uploadByName || '-' }}</template>
</el-table-column>
<el-table-column prop="createTime" label="上传时间" min-width="140" />
</ListTable>
<ListColumnStatsRow
ref="invoiceStatsRowRef"
storage-key="bx-invoice-wallet-mine-invoices-column-stats"
:columns="invoiceStatsRowColumns"
:data="filteredInvoiceRows"
:table-wrap-el="invoiceTableWrapRef"
/>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="额度流水" name="ledgers">
<div class="list-body list-wrap">
<ListTable table-key="bx-invoice-wallet-mine-2" v-loading="ledgerLoading" class="list-table" :data="filteredLedgerRows" stripe>
<el-table-column type="index" label="序号" min-width="68" align="center" />
<el-table-column prop="createTime" label="时间" min-width="170" />
<el-table-column prop="entryType" label="类型" min-width="140">
<template #default="{ row }">
<el-tag size="small" :type="ledgerTagType(row.entryType)">
{{ ledgerTypeLabel(row.entryType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="amount" label="金额(元)" min-width="120" align="right">
<template #default="{ row }">{{ formatMoneyYuan(row.amount) }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || '-' }}</template>
</el-table-column>
</ListTable>
<div ref="ledgerTableWrapRef" class="bx-list-table-wrap">
<ListTable
table-key="bx-invoice-wallet-mine-2"
v-loading="ledgerLoading"
class="list-table"
:data="filteredLedgerRows"
stripe
@header-dragend="() => ledgerStatsRowRef?.resync?.()"
>
<el-table-column type="index" label="序号" min-width="68" align="center" />
<el-table-column prop="createTime" label="时间" min-width="170" />
<el-table-column prop="entryType" label="类型" min-width="140">
<template #default="{ row }">
<el-tag size="small" :type="ledgerTagType(row.entryType)">
{{ ledgerTypeLabel(row.entryType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="amount" label="金额(元)" min-width="120" align="right">
<template #default="{ row }">{{ formatMoneyYuan(row.amount) }}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || '-' }}</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="ledgerStatsRowRef"
storage-key="bx-invoice-wallet-mine-ledgers-column-stats"
:columns="ledgerStatsRowColumns"
:data="filteredLedgerRows"
:table-wrap-el="ledgerTableWrapRef"
/>
</div>
</div>
</el-tab-pane>
</el-tabs>
@@ -236,6 +262,8 @@
import { ElMessage } from 'element-plus'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import BxInvoiceFileSelect from '@/components/bx/base/BxInvoiceFileSelect.vue'
import BxAttachmentReadonly from '@/components/bx/base/BxAttachmentReadonly.vue'
import BxViewKvTable from '@/components/bx/base/BxViewKvTable.vue'
@@ -259,6 +287,28 @@
const invoiceRows = ref<any[]>([])
const ledgerRows = ref<any[]>([])
const summary = ref<any>(null)
const invoiceTableWrapRef = ref<HTMLElement | null>(null)
const invoiceStatsRowRef = ref<{ resync?: () => void } | null>(null)
const ledgerTableWrapRef = ref<HTMLElement | null>(null)
const ledgerStatsRowRef = ref<{ resync?: () => void } | null>(null)
const invoiceStatsRowColumns = computed((): StatsRowColumn[] => [
{ key: '__index', interactive: false },
{ key: 'amount', interactive: true, prop: 'amount', money: true },
{ key: '__attachments', interactive: true, prop: '__attachments' },
{ key: 'remark', interactive: true, prop: 'remark' },
{ key: 'uploadByName', interactive: true, prop: 'uploadByName' },
{ key: 'createTime', interactive: true, prop: 'createTime' }
])
const ledgerStatsRowColumns = computed((): StatsRowColumn[] => [
{ key: '__index', interactive: false },
{ key: 'createTime', interactive: true, prop: 'createTime' },
{ key: 'entryType', interactive: true, prop: 'entryType' },
{ key: 'amount', interactive: true, prop: 'amount', money: true },
{ key: 'remark', interactive: true, prop: 'remark' }
])
const uploadVisible = ref(false)
const viewVisible = ref(false)
const viewRow = ref<any>(null)
@@ -500,6 +550,10 @@
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
.panel-top-meta {
margin: 6px 0 0;
font-size: 13px;
+73 -17
View File
@@ -81,16 +81,15 @@
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary">
<div ref="pendingPaymentTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="bx-payment"
class="list-table"
:data="rows"
v-loading="loading"
stripe
show-summary
:summary-method="summaryMethod"
@selection-change="onSelectionChange"
@row-dblclick="openView"
@header-dragend="() => pendingPaymentStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -174,6 +173,14 @@
</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="pendingPaymentStatsRowRef"
storage-key="bx-payment-pending-column-stats"
:columns="pendingPaymentStatsRowColumns"
:data="rows"
:table-wrap-el="pendingPaymentTableWrapRef"
:get-cell-value="getPaymentStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
@@ -270,15 +277,14 @@
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary">
<div ref="processedPaymentTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="bx-payment-2"
class="list-table"
:data="processedRows"
v-loading="processedLoading"
stripe
show-summary
:summary-method="processedSummaryMethod"
@row-dblclick="openProcessedView"
@header-dragend="() => processedPaymentStatsRowRef?.resync?.()"
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
@@ -386,6 +392,14 @@
</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="processedPaymentStatsRowRef"
storage-key="bx-payment-processed-column-stats"
:columns="processedPaymentStatsRowColumns"
:data="processedRows"
:table-wrap-el="processedPaymentTableWrapRef"
:get-cell-value="getPaymentStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
@@ -677,6 +691,7 @@
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
@@ -705,7 +720,7 @@ import BxAttachmentReadonly from '@/components/bx/base/BxAttachmentReadonly.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import { bxViewKv, bxViewKvMoney } from '@/utils/bx/view-kv'
import { buildApplyDateRangeQuery, type ApplyDateRange } from '@/utils/bx/apply-date-filter'
import { buildPageSummaryMethod, sumField } from '@/utils/bx/table-summary'
import type { StatsRowColumn } from '@/utils/list-column-stats'
type DialogKey = 'viewDetail' | 'issue' | 'offset' | 'processedDetail'
@@ -728,6 +743,51 @@ const {
resetToDefault
} = usePersistedListColumns('bx-payment-list-columns', listColumnDefs)
const PAYMENT_MONEY_PROPS = new Set(['amount', 'offsetAmount', 'payAmount'])
const pendingPaymentTableWrapRef = ref<HTMLElement | null>(null)
const processedPaymentTableWrapRef = ref<HTMLElement | null>(null)
const pendingPaymentStatsRowRef = ref<{ resync?: () => void } | null>(null)
const processedPaymentStatsRowRef = ref<{ resync?: () => void } | null>(null)
function buildPaymentStatsColumns(opts: {
withSelection: boolean
mode: 'pending' | 'processed'
}): StatsRowColumn[] {
const cols: StatsRowColumn[] = []
if (opts.withSelection) cols.push({ key: '__selection', interactive: false })
cols.push({ key: '__index', interactive: false })
if (isColumnVisible('applyTime')) {
cols.push({ key: 'applyTime', interactive: true, prop: 'applyTime' })
}
cols.push({ key: 'applyCode', interactive: true, prop: 'applyCode' })
for (const def of listColumnDefs) {
if (def.prop === 'applyTime') continue
if (opts.mode === 'pending' && (def.prop === 'processStatus' || def.prop === 'payTime')) continue
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: PAYMENT_MONEY_PROPS.has(def.prop)
})
}
return cols
}
const pendingPaymentStatsRowColumns = computed(() =>
buildPaymentStatsColumns({ withSelection: true, mode: 'pending' })
)
const processedPaymentStatsRowColumns = computed(() =>
buildPaymentStatsColumns({ withSelection: false, mode: 'processed' })
)
function getPaymentStatCellValue(row: any, prop: string): unknown {
if (prop === 'payAmount') return row?.actualAmount
if (prop === 'processStatus') return processedStatusLabel(row?.status, row?.payStatus)
return row?.[prop]
}
const { applicantOptions, loadApplicantOptions } = useApplicantOptions()
const loadingMap = reactive<Record<string, boolean>>({})
const dialogs = reactive<Record<DialogKey, boolean>>({
@@ -1209,16 +1269,6 @@ async function submitOffset() {
}
}
const paymentSummary = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumField(data, 'amount'),
offsetAmount: (data) => sumField(data, 'offsetAmount'),
actualAmount: (data) => sumField(data, 'actualAmount')
}
})
const summaryMethod = paymentSummary
const processedSummaryMethod = paymentSummary
async function openProcessedView(row: any) {
processedViewData.value = row
processedApprovals.value = []
@@ -1297,3 +1347,9 @@ function processedStatusTagType(
return 'info'
}
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
</style>
@@ -97,16 +97,15 @@
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary">
<div ref="reimburseApplyTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="bx-reimburse-apply"
class="list-table"
:data="rows"
v-loading="loading"
stripe
show-summary
:summary-method="summaryMethod"
@selection-change="onSelectionChange"
@row-dblclick="openView"
@header-dragend="onReimburseApplyHeaderDragEnd"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -206,6 +205,14 @@
</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="reimburseApplyStatsRowRef"
storage-key="bx-reimburse-apply-column-stats"
:columns="reimburseApplyStatsRowColumns"
:data="rows"
:table-wrap-el="reimburseApplyTableWrapRef"
:get-cell-value="getReimburseApplyStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
@@ -564,6 +571,7 @@
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
@@ -609,7 +617,7 @@ import BxAttachmentReadonly from '@/components/bx/base/BxAttachmentReadonly.vue'
import BxInvoiceSelectDialog from '@/components/bx/base/BxInvoiceSelectDialog.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import { bxViewKv, bxViewKvLong, bxViewKvMoney } from '@/utils/bx/view-kv'
import { buildPageSummaryMethod, sumField } from '@/utils/bx/table-summary'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import {
type BxAttachmentItem,
loadBxAttachments,
@@ -679,6 +687,43 @@ const {
resetToDefault
} = usePersistedListColumns('bx-reimburse-apply-list-columns', listColumnDefs)
const reimburseApplyTableWrapRef = ref<HTMLElement | null>(null)
const reimburseApplyStatsRowRef = ref<{ resync?: () => void } | null>(null)
const reimburseApplyStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false }
]
if (isColumnVisible('applyTime')) {
cols.push({ key: 'applyTime', interactive: true, prop: 'applyTime' })
}
cols.push({ key: 'applyCode', interactive: true, prop: 'applyCode' })
for (const def of listColumnDefs) {
if (def.prop === 'applyTime') continue
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: def.prop === 'amount'
})
}
return cols
})
function getReimburseApplyStatCellValue(row: any, prop: string): unknown {
if (prop === 'expenseName') {
const lines = getReimburseExpenseItemLines(row.details, row)
return lines?.length ? lines.join('\n') : row.expenseName
}
return row?.[prop]
}
function onReimburseApplyHeaderDragEnd() {
reimburseApplyStatsRowRef.value?.resync?.()
}
const filterForm = reactive({
keyword: '',
status: '',
@@ -1329,12 +1374,6 @@ async function handleDelete(row: any) {
}
}
const summaryMethod = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumField(data, 'amount')
}
})
function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) {
const sums: string[] = []
columns.forEach((_: any, index: number) => {
@@ -1489,4 +1528,8 @@ function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) {
color: #fff;
}
}
.bx-list-table-wrap {
min-width: 0;
}
</style>
@@ -108,16 +108,15 @@
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary">
<div ref="pendingTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="bx-reimburse-approve"
class="list-table"
:data="pendingRows"
v-loading="pendingLoading"
stripe
show-summary
:summary-method="pendingSummaryMethod"
@selection-change="onSelectionChange"
@row-dblclick="openView"
@header-dragend="() => pendingStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -211,6 +210,14 @@
</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="pendingStatsRowRef"
storage-key="bx-reimburse-approve-pending-column-stats"
:columns="pendingStatsRowColumns"
:data="pendingRows"
:table-wrap-el="pendingTableWrapRef"
:get-cell-value="getReimburseApproveStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
@@ -324,15 +331,14 @@
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary">
<div ref="approvedTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="bx-reimburse-approve-2"
class="list-table"
:data="approvedRows"
v-loading="approvedLoading"
stripe
show-summary
:summary-method="approvedSummaryMethod"
@row-dblclick="openView"
@header-dragend="() => approvedStatsRowRef?.resync?.()"
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
@@ -439,6 +445,14 @@
</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="approvedStatsRowRef"
storage-key="bx-reimburse-approve-approved-column-stats"
:columns="approvedStatsRowColumns"
:data="approvedRows"
:table-wrap-el="approvedTableWrapRef"
:get-cell-value="getReimburseApproveStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
@@ -597,6 +611,7 @@
<script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
@@ -632,7 +647,7 @@ import BxAttachmentReadonly from '@/components/bx/base/BxAttachmentReadonly.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import { bxViewKv, bxViewKvLong } from '@/utils/bx/view-kv'
import { buildApplyDateRangeQuery, type ApplyDateRange } from '@/utils/bx/apply-date-filter'
import { buildPageSummaryMethod, sumField } from '@/utils/bx/table-summary'
import type { StatsRowColumn } from '@/utils/list-column-stats'
type DialogKey = 'viewDetail' | 'approve'
@@ -654,6 +669,51 @@ const {
resetToDefault
} = usePersistedListColumns('bx-reimburse-approve-list-columns', listColumnDefs)
const pendingTableWrapRef = ref<HTMLElement | null>(null)
const approvedTableWrapRef = ref<HTMLElement | null>(null)
const pendingStatsRowRef = ref<{ resync?: () => void } | null>(null)
const approvedStatsRowRef = ref<{ resync?: () => void } | null>(null)
function buildReimburseApproveStatsColumns(opts: {
withSelection: boolean
includeStatus: boolean
}): StatsRowColumn[] {
const cols: StatsRowColumn[] = []
if (opts.withSelection) cols.push({ key: '__selection', interactive: false })
cols.push({ key: '__index', interactive: false })
if (isColumnVisible('applyTime')) {
cols.push({ key: 'applyTime', interactive: true, prop: 'applyTime' })
}
cols.push({ key: 'applyCode', interactive: true, prop: 'applyCode' })
for (const def of listColumnDefs) {
if (def.prop === 'applyTime') continue
if (def.prop === 'status' && !opts.includeStatus) continue
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: def.prop === 'amount'
})
}
return cols
}
const pendingStatsRowColumns = computed(() =>
buildReimburseApproveStatsColumns({ withSelection: true, includeStatus: false })
)
const approvedStatsRowColumns = computed(() =>
buildReimburseApproveStatsColumns({ withSelection: false, includeStatus: true })
)
function getReimburseApproveStatCellValue(row: any, prop: string): unknown {
if (prop === 'expenseName') {
const lines = getReimburseExpenseItemLines(row.details, row)
return lines?.length ? lines.join('\n') : row.expenseName
}
return row?.[prop]
}
const pendingQuickSearchKeyword = ref('')
const approvedQuickSearchKeyword = ref('')
@@ -1021,14 +1081,6 @@ async function handleConfirmApprove() {
}
}
const amountSummary = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumField(data, 'amount')
}
})
const pendingSummaryMethod = amountSummary
const approvedSummaryMethod = amountSummary
function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) {
const sums: string[] = []
columns.forEach((_: any, index: number) => {
@@ -1048,6 +1100,10 @@ function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) {
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
.batch-action-alert {
margin-bottom: 12px;
+53 -19
View File
@@ -84,17 +84,17 @@
</div>
</div>
<div class="list-body list-wrap table-page-summary table-page-summary-biz-split">
<ListTable table-key="bx-view-all"
class="list-table"
:data="pager.pagedRows.value"
v-loading="loading"
stripe
show-summary
:summary-method="summaryMethod"
@row-dblclick="openViewDetail"
@selection-change="handleSelectionChange"
>
<div class="list-body list-wrap">
<div ref="allTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="bx-view-all"
class="list-table"
:data="pager.pagedRows.value"
v-loading="loading"
stripe
@row-dblclick="openViewDetail"
@selection-change="handleSelectionChange"
@header-dragend="() => allStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column
@@ -193,7 +193,15 @@
<span v-else>-</span>
</template>
</el-table-column>
</ListTable>
</ListTable>
<ListColumnStatsRow
ref="allStatsRowRef"
storage-key="bx-view-all-column-stats"
:columns="allStatsRowColumns"
:data="pager.pagedRows.value"
:table-wrap-el="allTableWrapRef"
/>
</div>
<div class="pagination list-pager-bar">
<span class="list-range">{{ pager.rangeText.value }}</span>
@@ -280,6 +288,8 @@
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { computed, onMounted, reactive, ref } from 'vue'
@@ -307,7 +317,6 @@ import { invoicesToLinkedAttachmentFiles } from '@/utils/bx/invoice-link-files'
import { matchesApplyDateRange, type ApplyDateRange } from '@/utils/bx/apply-date-filter'
import { usePagedTable } from '@/hooks/bx/usePagedTable'
import { useApplicantOptions } from '@/hooks/bx/useApplicantOptions'
import { buildPageSummaryMethod, sumFieldByBizType } from '@/utils/bx/table-summary'
type DialogKey = 'viewDetail'
@@ -338,6 +347,31 @@ const {
resetToDefault
} = usePersistedListColumns('bx-view-all-list-columns', listColumnDefs)
const allTableWrapRef = ref<HTMLElement | null>(null)
const allStatsRowRef = ref<{ resync?: () => void } | null>(null)
const allStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false }
]
if (isColumnVisible('applyTime')) {
cols.push({ key: 'applyTime', interactive: true, prop: 'applyTime' })
}
cols.push({ key: 'applyCode', interactive: true, prop: 'applyCode' })
for (const def of listColumnDefs) {
if (def.prop === 'applyTime') continue
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: def.prop === 'amount'
})
}
return cols
})
const quickSearchKeyword = ref('')
const appliedQuickSearch = ref('')
@@ -540,12 +574,6 @@ function handleQuickSearchClear() {
pager.currentPage.value = 1
}
const summaryMethod = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumFieldByBizType(data, 'amount')
}
})
function handleSelectionChange(rows: any[]) {
selectedRows.value = rows
}
@@ -615,3 +643,9 @@ onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
</style>
+42 -10
View File
@@ -77,16 +77,15 @@
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary table-page-summary-biz-split">
<div ref="mineTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="bx-view-mine"
class="list-table"
:data="pagedRows"
v-loading="tableLoading"
stripe
show-summary
:summary-method="summaryMethod"
@row-dblclick="openViewDetail"
@selection-change="handleSelectionChange"
@header-dragend="() => mineStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -178,6 +177,13 @@
</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="mineStatsRowRef"
storage-key="bx-view-mine-column-stats"
:columns="mineStatsRowColumns"
:data="pagedRows"
:table-wrap-el="mineTableWrapRef"
/>
</div>
<div class="pagination list-pager-bar">
@@ -264,6 +270,8 @@
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
@@ -290,7 +298,6 @@ import { type BxAttachmentItem, loadBxAttachments } from '@/utils/bx/attachment'
import { invoicesToLinkedAttachmentFiles } from '@/utils/bx/invoice-link-files'
import { buildApplyDateRangeQuery, matchesApplyDateRange, type ApplyDateRange } from '@/utils/bx/apply-date-filter'
import { usePagedTable } from '@/hooks/bx/usePagedTable'
import { buildPageSummaryMethod, sumFieldByBizType } from '@/utils/bx/table-summary'
type DialogKey = 'viewDetail'
@@ -320,6 +327,31 @@ const {
resetToDefault
} = usePersistedListColumns('bx-view-mine-list-columns', listColumnDefs)
const mineTableWrapRef = ref<HTMLElement | null>(null)
const mineStatsRowRef = ref<{ resync?: () => void } | null>(null)
const mineStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false }
]
if (isColumnVisible('applyTime')) {
cols.push({ key: 'applyTime', interactive: true, prop: 'applyTime' })
}
cols.push({ key: 'applyCode', interactive: true, prop: 'applyCode' })
for (const def of listColumnDefs) {
if (def.prop === 'applyTime') continue
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: def.prop === 'amount'
})
}
return cols
})
const quickSearchKeyword = ref('')
const filterForm = reactive({
@@ -431,12 +463,6 @@ function sortMergedRows(rows: any[]) {
return [...rows].sort((a, b) => String(b.applyTime || '').localeCompare(String(a.applyTime || '')))
}
const summaryMethod = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumFieldByBizType(data, 'amount')
}
})
async function loadData(options: { buttonLoading?: boolean } = {}) {
if (options.buttonLoading) searchLoading.value = true
tableLoading.value = true
@@ -576,3 +602,9 @@ onActivated(() => {
loadData()
})
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
</style>
+42 -10
View File
@@ -83,16 +83,15 @@
</div>
<div class="list-body list-wrap">
<div class="table-scroll table-page-summary">
<div ref="todoTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="bx-view-todo"
class="list-table"
:data="pagedRows"
v-loading="tableLoading"
stripe
show-summary
:summary-method="summaryMethod"
@selection-change="onSelectionChange"
@row-dblclick="openViewDetailFromRow"
@header-dragend="() => todoStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
@@ -179,6 +178,13 @@
</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="todoStatsRowRef"
storage-key="bx-view-todo-column-stats"
:columns="todoStatsRowColumns"
:data="pagedRows"
:table-wrap-el="todoTableWrapRef"
/>
</div>
<div class="pagination list-pager-bar">
@@ -285,6 +291,8 @@
<script setup lang="ts">
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
@@ -315,7 +323,6 @@ import { usePagedTable } from '@/hooks/bx/usePagedTable'
import { useApplicantOptions } from '@/hooks/bx/useApplicantOptions'
import { refreshMenuBadges } from '@/utils/menu-badge-registry'
import { useMenuStore } from '@/store/modules/menu'
import { buildPageSummaryMethod, sumField } from '@/utils/bx/table-summary'
type DialogKey = 'viewDetail' | 'approve'
@@ -351,6 +358,31 @@ const {
resetToDefault
} = usePersistedListColumns('bx-view-todo-list-columns', listColumnDefs)
const todoTableWrapRef = ref<HTMLElement | null>(null)
const todoStatsRowRef = ref<{ resync?: () => void } | null>(null)
const todoStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false }
]
if (isColumnVisible('applyTime')) {
cols.push({ key: 'applyTime', interactive: true, prop: 'applyTime' })
}
cols.push({ key: 'applyCode', interactive: true, prop: 'applyCode' })
for (const def of listColumnDefs) {
if (def.prop === 'applyTime') continue
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: def.prop === 'amount'
})
}
return cols
})
const filterForm = reactive({
code: '',
bizType: '' as string,
@@ -644,12 +676,6 @@ function handleQuickSearchClear() {
loadData()
}
const summaryMethod = buildPageSummaryMethod({
sumByProp: {
amount: (data) => sumField(data, 'amount')
}
})
function openViewDetailFromRow(row: any) {
openViewDetail(row)
}
@@ -684,3 +710,9 @@ onActivated(() => {
loadData()
})
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
</style>
@@ -147,6 +147,7 @@
</div>
<div class="list-body list-wrap"> <!-- 操作按钮栏 + 数据表格 -->
<div ref="contractTableWrapRef" class="contract-table-wrap">
<ListTable table-key="crm-contract"
v-loading="loading"
class="list-table"
@@ -156,6 +157,7 @@
@selection-change="handleSelectionChange"
@expand-change="handleExpandChange"
@row-dblclick="handleView"
@header-dragend="onContractHeaderDragEnd"
>
<el-table-column type="expand" width="55">
<template #default="scope">
@@ -456,6 +458,16 @@
<el-table-column v-if="isColumnVisible('remark')" label="备注" align="center" prop="remark" min-width="200" show-overflow-tooltip />
</ListTable>
<ListColumnStatsRow
ref="contractStatsRowRef"
storage-key="crm-contract-column-stats"
:columns="contractStatsRowColumns"
:data="contractList"
:table-wrap-el="contractTableWrapRef"
:get-cell-value="getContractStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
@@ -551,7 +563,9 @@ import {
} from '@/utils/crm-attachment'
import ListToolbarDateShortcuts from '@/components/core/list-toolbar-date-shortcuts/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { buildStatsRowColumns } from '@/utils/list-column-stats'
const props = withDefaults(
defineProps<{
@@ -991,6 +1005,45 @@ const formatContractProductInfoLines = (row: ContractVO): string[] => {
.filter((line) => line.length > 0)
}
const CONTRACT_MONEY_STAT_PROPS = new Set([
'totalContractAmount',
'amount',
'freight',
'barrelDeposit',
'unreceivedAmount',
'invoiceAmount',
'uninvoiceAmount'
])
const contractTableWrapRef = ref<HTMLElement | null>(null)
const contractStatsRowRef = ref<{ resync?: () => void } | null>(null)
const contractStatsRowColumns = computed(() =>
buildStatsRowColumns({
leadingSpacers: 2,
alwaysColumns: [
{ prop: 'contractNo' },
{ prop: 'contractName' }
],
optionalDefs: listColumnDefs,
isColumnVisible,
moneyProps: CONTRACT_MONEY_STAT_PROPS
})
)
function getContractStatCellValue(row: ContractVO, prop: string): unknown {
if (prop === 'totalContractAmount') return getContractTotalAmount(row)
if (prop === 'productInfo') {
const lines = formatContractProductInfoLines(row)
return lines.length ? lines.join('\n') : null
}
return (row as Record<string, unknown>)[prop]
}
function onContractHeaderDragEnd() {
contractStatsRowRef.value?.resync?.()
}
/** 多选框选中数据 */
const handleSelectionChange = (selection: ContractVO[]) => {
selectedRows.value = selection;
@@ -1501,4 +1554,8 @@ onMounted(() => {
}
}
.contract-table-wrap {
min-width: 0;
}
</style>
+235 -77
View File
@@ -383,42 +383,51 @@
</ListTable>
<div
ref="quoteStatsScrollRef"
class="quote-list-stats-row"
aria-label="列表列统计"
@scroll="onStatsRowScroll"
>
<div class="quote-list-stats-row__fixed">
<div class="quote-list-stats-row__cell is-fixed" style="width: 55px" />
<div class="quote-list-stats-row__cell is-fixed" style="width: 55px" />
</div>
<div
ref="quoteStatsScrollRef"
class="quote-list-stats-row__scroll"
@scroll="onStatsRowScroll"
>
<div class="quote-list-stats-row__inner">
<div
v-for="col in statsBusinessColumns"
:key="col.prop"
class="quote-list-stats-row__cell is-stat"
v-for="col in statsRowColumns"
:key="col.key"
class="quote-list-stats-row__cell"
:class="{ 'is-stat': col.interactive, 'is-spacer': !col.interactive }"
>
<el-dropdown trigger="click" @command="(cmd: string | number) => onStatCommand(col.prop, cmd)">
<span
class="quote-list-stats-row__trigger"
:class="{ 'is-empty': !statsCellText(col.prop, col.money) }"
>
{{ statsCellText(col.prop, col.money) || '统计' }}
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="opt in COLUMN_STAT_METHOD_OPTIONS"
:key="opt.label"
:command="opt.value"
>
{{ opt.label }}
</el-dropdown-item>
</el-dropdown-menu>
<el-popover
v-if="col.interactive && col.prop"
:visible="statsMenuOpenProp === col.prop"
placement="top-start"
:width="132"
:teleported="true"
trigger="click"
:hide-after="0"
popper-class="quote-list-stats-dropdown"
@update:visible="(v: boolean) => setStatsMenuOpen(col.prop!, v)"
>
<template #reference>
<button
type="button"
class="quote-list-stats-row__trigger"
:class="{ 'is-empty': !statsCellText(col.prop, !!col.money) }"
>
{{ statsCellText(col.prop, !!col.money) || '\u00a0' }}
</button>
</template>
</el-dropdown>
<div class="quote-list-stats-menu">
<button
v-for="opt in COLUMN_STAT_METHOD_OPTIONS"
:key="opt.label"
type="button"
class="quote-list-stats-menu__item"
:class="{ 'is-active': (columnStatConfig[col.prop!] || '') === opt.value }"
@click="pickColumnStat(col.prop!, opt.value)"
>
{{ opt.label }}
</button>
</div>
</el-popover>
</div>
</div>
</div>
@@ -863,8 +872,11 @@ import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import {
COLUMN_STAT_METHOD_OPTIONS,
QUOTE_MONEY_STAT_PROPS,
applyStatsCellAlignStyles,
formatColumnStatDisplay,
getTableColumnTextAligns,
loadColumnStatConfig,
resolveStatsCellAlign,
saveColumnStatConfig,
setColumnStatMethod,
type ColumnStatConfigMap,
@@ -1899,16 +1911,29 @@ const formatQuoteProductInfoLines = (row: QuoteVO): string[] => {
const columnStatConfig = ref<ColumnStatConfigMap>(loadColumnStatConfig())
const statsBusinessColumns = computed(() => {
const cols: { prop: string; money: boolean }[] = [
{ prop: 'quoteNo', money: false },
{ prop: 'quoteTitle', money: false }
type StatsRowColumn = {
key: string
interactive: boolean
prop?: string
money?: boolean
}
/** 与主表列顺序一致:展开、勾选占位 + 业务列 */
const statsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__expand', interactive: false },
{ key: '__selection', interactive: false },
{ key: 'quoteNo', interactive: true, prop: 'quoteNo', money: false },
{ key: 'quoteTitle', interactive: true, prop: 'quoteTitle', money: false }
]
for (const def of listColumnDefs) {
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: QUOTE_MONEY_STAT_PROPS.has(def.prop)
money: QUOTE_MONEY_STAT_PROPS.has(def.prop),
...(QUOTE_MONEY_STAT_PROPS.has(def.prop) ? { align: 'right' as const } : {})
})
}
return cols
@@ -1935,24 +1960,30 @@ function onSelectColumnStat(prop: string, method: ColumnStatMethod | '') {
saveColumnStatConfig(columnStatConfig.value)
}
function onStatCommand(prop: string, cmd: string | number) {
onSelectColumnStat(
prop,
(cmd === '' || cmd === 'sum' || cmd === 'avg' || cmd === 'max' || cmd === 'min'
? cmd
: '') as ColumnStatMethod | ''
)
const statsMenuOpenProp = ref<string | null>(null)
function setStatsMenuOpen(prop: string, visible: boolean) {
statsMenuOpenProp.value = visible ? prop : statsMenuOpenProp.value === prop ? null : statsMenuOpenProp.value
}
function pickColumnStat(prop: string, method: ColumnStatMethod | '') {
onSelectColumnStat(prop, method)
statsMenuOpenProp.value = null
}
const quoteTableWrapRef = ref<HTMLElement | null>(null)
const quoteStatsScrollRef = ref<HTMLElement | null>(null)
let quoteStatsScrollSyncing = false
let quoteHeaderResizeObserver: ResizeObserver | null = null
let quoteTableScrollElBound: HTMLElement | null = null
function getQuoteMainTableEl(): Element | null {
function getQuoteMainTableEl(): HTMLElement | null {
const wrap = quoteTableWrapRef.value
if (!wrap) return null
return wrap.querySelector(':scope > .el-table') || wrap.querySelector('.el-table')
return (
(wrap.querySelector(':scope > .el-table') as HTMLElement | null) ||
(wrap.querySelector('.el-table') as HTMLElement | null)
)
}
function getQuoteTableScrollEl(): HTMLElement | null {
@@ -1964,22 +1995,67 @@ function getQuoteTableScrollEl(): HTMLElement | null {
)
}
function syncStatsColumnWidths() {
function getQuoteHeaderWidthSources(): number[] {
const table = getQuoteMainTableEl()
if (!table) return
const headerCells = table.querySelectorAll('.el-table__header-wrapper th')
const cells = quoteTableWrapRef.value?.querySelectorAll(
'.quote-list-stats-row__scroll .quote-list-stats-row__cell'
)
if (!cells) return
const offset = 2
cells.forEach((el, i) => {
const th = headerCells[i + offset] as HTMLElement | undefined
if (!th) return
;(el as HTMLElement).style.flex = 'none'
;(el as HTMLElement).style.width = `${th.offsetWidth}px`
;(el as HTMLElement).style.minWidth = `${th.offsetWidth}px`
if (!table) return []
// 优先用 header colgroup(与实际列宽一致,并跳过 gutter)
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 >= statsRowColumns.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 syncStatsColumnWidths() {
const widths = getQuoteHeaderWidthSources()
const aligns = getTableColumnTextAligns(getQuoteMainTableEl())
const inner = quoteStatsScrollRef.value?.querySelector(
'.quote-list-stats-row__inner'
) as HTMLElement | null
const cells = quoteStatsScrollRef.value?.querySelectorAll('.quote-list-stats-row__cell')
if (!inner || !cells?.length || !widths.length) return
const cols = statsRowColumns.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]),
'.quote-list-stats-row__trigger'
)
}
inner.style.width = `${total}px`
inner.style.minWidth = `${total}px`
bindQuoteTableScrollListener()
const tableScroll = getQuoteTableScrollEl()
const statsScroll = quoteStatsScrollRef.value
if (tableScroll && statsScroll) {
statsScroll.scrollLeft = tableScroll.scrollLeft
}
}
function scheduleSyncStatsColumnWidths() {
@@ -2000,11 +2076,22 @@ function ensureQuoteHeaderResizeObserver() {
syncStatsColumnWidths()
})
quoteHeaderResizeObserver.observe(header)
header.querySelectorAll('th').forEach((th) => {
header.querySelectorAll('th.el-table__cell').forEach((th) => {
if ((th as HTMLElement).classList.contains('gutter')) return
quoteHeaderResizeObserver?.observe(th)
})
}
function bindQuoteTableScrollListener() {
const el = getQuoteTableScrollEl()
if (!el || el === quoteTableScrollElBound) return
if (quoteTableScrollElBound) {
quoteTableScrollElBound.removeEventListener('scroll', onQuoteTableScroll)
}
quoteTableScrollElBound = el
el.addEventListener('scroll', onQuoteTableScroll, { passive: true })
}
function onQuoteHeaderDragEnd() {
scheduleSyncStatsColumnWidths()
}
@@ -2036,7 +2123,7 @@ function onStatsRowScroll() {
}
watch(
[statsBusinessColumns, quoteList, visibleColumns],
[statsRowColumns, quoteList, visibleColumns],
() => {
scheduleSyncStatsColumnWidths()
},
@@ -2963,8 +3050,9 @@ watchDebounced(
onMounted(() => {
getList();
scheduleSyncStatsColumnWidths();
// ListTable 会在挂载后异步回写持久化列宽,补一次对齐
// ListTable 会在挂载后异步回写持久化列宽,补几次对齐
setTimeout(() => scheduleSyncStatsColumnWidths(), 150);
setTimeout(() => scheduleSyncStatsColumnWidths(), 400);
window.addEventListener('resize', scheduleSyncStatsColumnWidths);
});
@@ -2972,6 +3060,10 @@ onUnmounted(() => {
window.removeEventListener('resize', scheduleSyncStatsColumnWidths);
quoteHeaderResizeObserver?.disconnect();
quoteHeaderResizeObserver = null;
if (quoteTableScrollElBound) {
quoteTableScrollElBound.removeEventListener('scroll', onQuoteTableScroll);
quoteTableScrollElBound = null;
}
});
</script>
@@ -3285,50 +3377,76 @@ onUnmounted(() => {
}
.quote-list-stats-row {
display: flex;
align-items: stretch;
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;
overflow: hidden;
/* 隐藏自身滚动条,横向位置跟主表同步 */
scrollbar-width: none;
}
.quote-list-stats-row__fixed {
display: flex;
flex-shrink: 0;
.quote-list-stats-row::-webkit-scrollbar {
display: none;
}
.quote-list-stats-row__scroll {
.quote-list-stats-row__inner {
display: flex;
flex: 1;
min-width: 0;
overflow-x: auto;
align-items: stretch;
min-height: 44px;
box-sizing: border-box;
}
.quote-list-stats-row__cell {
display: flex;
align-items: center;
justify-content: center;
padding: 8px 10px;
justify-content: flex-start;
padding: 0 8px;
box-sizing: border-box;
border-right: 1px solid var(--mint-border, #e2e8f0);
flex: none;
overflow: hidden;
}
.quote-list-stats-row__cell.is-stat {
flex: 1 1 120px;
min-width: 120px;
.quote-list-stats-row__cell.is-align-left {
justify-content: flex-start;
}
.quote-list-stats-row__cell.is-align-center {
justify-content: center;
}
.quote-list-stats-row__cell.is-align-right {
justify-content: flex-end;
}
.quote-list-stats-row__cell.is-spacer {
pointer-events: none;
}
.quote-list-stats-row__trigger {
cursor: pointer;
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;
}
.quote-list-stats-row__trigger.is-empty {
@@ -3336,3 +3454,43 @@ onUnmounted(() => {
font-weight: 500;
}
</style>
<style lang="scss">
/* teleported 到 body,需非 scoped */
.quote-list-stats-dropdown {
z-index: 4000 !important;
padding: 6px !important;
}
.quote-list-stats-menu {
display: flex;
flex-direction: column;
gap: 2px;
}
.quote-list-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;
}
.quote-list-stats-menu__item:hover {
background: #ecfdf5;
color: #059669;
}
.quote-list-stats-menu__item.is-active {
background: #d1fae5;
color: #059669;
font-weight: 600;
}
</style>
@@ -56,3 +56,15 @@ describe('setColumnStatMethod', () => {
assert.deepEqual(b, {})
})
})
describe('resolveStatsCellAlign', () => {
it('prefers explicit align, then money right, then column align', async () => {
const { resolveStatsCellAlign } = await import('./quoteListColumnStats')
assert.equal(resolveStatsCellAlign({ align: 'right' }, 'left'), 'right')
assert.equal(resolveStatsCellAlign({ money: true }, 'center'), 'right')
assert.equal(resolveStatsCellAlign({}, 'right'), 'right')
assert.equal(resolveStatsCellAlign({}, 'center'), 'center')
assert.equal(resolveStatsCellAlign({ money: true }), 'right')
assert.equal(resolveStatsCellAlign({}), 'left')
})
})
@@ -1,23 +1,27 @@
export type ColumnStatMethod = 'sum' | 'avg' | 'max' | 'min'
/** @deprecated 请优先使用 `@/utils/list-column-stats`;本文件保留 quote 兼容导出 */
export {
aggregateColumnValues,
applyStatsCellAlignStyles,
buildStatsRowColumns,
COLUMN_STAT_METHOD_LABEL,
COLUMN_STAT_METHOD_OPTIONS,
formatColumnStatDisplay,
formatStatNumberResult,
getTableColumnTextAligns,
loadColumnStatConfig,
normalizeCssTextAlign,
parseStatNumber,
resolveStatsCellAlign,
saveColumnStatConfig,
setColumnStatMethod,
type ColumnStatConfigMap,
type ColumnStatMethod,
type StatsCellAlign,
type StatsRowColumn
} from '@/utils/list-column-stats'
export const COLUMN_STAT_STORAGE_KEY = 'crm-quote-column-stats'
export const COLUMN_STAT_METHOD_OPTIONS: { value: ColumnStatMethod | ''; label: string }[] = [
{ value: '', label: '无' },
{ value: 'sum', label: '求和' },
{ value: 'avg', label: '平均' },
{ value: 'max', label: '最大值' },
{ value: 'min', label: '最小值' }
]
export const COLUMN_STAT_METHOD_LABEL: Record<ColumnStatMethod, string> = {
sum: '求和',
avg: '平均',
max: '最大值',
min: '最小值'
}
/** 列表中按金额格式展示的列 prop */
export const QUOTE_MONEY_STAT_PROPS = new Set([
'discountAmount',
'finalAmount',
@@ -25,128 +29,3 @@ export const QUOTE_MONEY_STAT_PROPS = new Set([
'freight',
'barrelDeposit'
])
export function parseStatNumber(raw: unknown): number | null {
if (raw == null || raw === '') return null
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : null
let s = String(raw).trim()
if (!s) return null
s = s.replace(/^¥/, '').replace(/%$/, '').replace(/,/g, '')
const n = Number(s)
return Number.isFinite(n) ? n : null
}
function nonEmptyString(raw: unknown): string | null {
if (raw == null) return null
const s = String(raw).trim()
return s ? s : null
}
export function aggregateColumnValues(
values: unknown[],
method: ColumnStatMethod
): number | string | null {
const nums: number[] = []
const strs: string[] = []
for (const v of values) {
const n = parseStatNumber(v)
if (n != null) nums.push(n)
else {
const s = nonEmptyString(v)
if (s != null) strs.push(s)
}
}
if (method === 'sum') {
if (!nums.length) return null
return nums.reduce((a, b) => a + b, 0)
}
if (method === 'avg') {
if (!nums.length) return null
return nums.reduce((a, b) => a + b, 0) / nums.length
}
if (method === 'max') {
if (nums.length) return Math.max(...nums)
if (strs.length) return strs.reduce((a, b) => (a >= b ? a : b))
return null
}
// min
if (nums.length) return Math.min(...nums)
if (strs.length) return strs.reduce((a, b) => (a <= b ? a : b))
return null
}
export function formatStatNumberResult(
value: number,
opts: { money?: boolean } = {}
): string {
if (opts.money) {
return value.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
if (Number.isInteger(value)) return String(value)
return value.toLocaleString('zh-CN', { maximumFractionDigits: 4 })
}
/**
* 生成合计格展示文案。method 为空则返回 ''。
* 聚合结果为 null →「{标签} -」
*/
export function formatColumnStatDisplay(
method: ColumnStatMethod | '' | null | undefined,
values: unknown[],
opts: { money?: boolean } = {}
): string {
if (!method) return ''
const label = COLUMN_STAT_METHOD_LABEL[method]
const agg = aggregateColumnValues(values, method)
if (agg == null) return `${label} -`
if (typeof agg === 'number') {
return `${label} ${formatStatNumberResult(agg, opts)}`
}
return `${label} ${agg}`
}
export type ColumnStatConfigMap = Partial<Record<string, ColumnStatMethod>>
export function loadColumnStatConfig(
storageKey: string = COLUMN_STAT_STORAGE_KEY
): ColumnStatConfigMap {
try {
const raw = localStorage.getItem(storageKey)
if (!raw) return {}
const parsed = JSON.parse(raw) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object') return {}
const out: ColumnStatConfigMap = {}
for (const [k, v] of Object.entries(parsed)) {
if (v === 'sum' || v === 'avg' || v === 'max' || v === 'min') out[k] = v
}
return out
} catch {
return {}
}
}
export function saveColumnStatConfig(
config: ColumnStatConfigMap,
storageKey: string = COLUMN_STAT_STORAGE_KEY
): void {
const cleaned: ColumnStatConfigMap = {}
for (const [k, v] of Object.entries(config)) {
if (v === 'sum' || v === 'avg' || v === 'max' || v === 'min') cleaned[k] = v
}
localStorage.setItem(storageKey, JSON.stringify(cleaned))
}
export function setColumnStatMethod(
config: ColumnStatConfigMap,
prop: string,
method: ColumnStatMethod | ''
): ColumnStatConfigMap {
const next = { ...config }
if (!method) delete next[prop]
else next[prop] = method
return next
}
+66 -54
View File
@@ -130,18 +130,18 @@
</div>
<div class="list-body list-wrap">
<ListTable table-key="db-inventory"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
show-summary
:summary-method="getSummary"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<div ref="inventoryTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="db-inventory"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
@header-dragend="() => inventoryStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" />
<el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
<el-table-column
@@ -356,6 +356,15 @@
</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="inventoryStatsRowRef"
storage-key="db-inventory-column-stats"
:columns="inventoryStatsRowColumns"
:data="tableData"
:table-wrap-el="inventoryTableWrapRef"
:get-cell-value="getInventoryStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
@@ -512,7 +521,6 @@
* UI 模式 P02 · 复杂业务列表
* 参考:/#/crm/quote · src/views/crm/quote/index.vue
*/
import type { TableColumnCtx } from 'element-plus'
import {
Check,
Close,
@@ -529,6 +537,8 @@
import ListToolbarDateShortcuts from '@/components/core/list-toolbar-date-shortcuts/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { canDeleteByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete'
import { syncViewRowFromFlatList } from '@/utils/view-dialog-refresh'
@@ -582,6 +592,45 @@
resetToDefault
} = usePersistedListColumns('db-inventory-list-columns', listColumnDefs)
const inventoryTableWrapRef = ref<HTMLElement | null>(null)
const inventoryStatsRowRef = ref<{ resync?: () => void } | null>(null)
const inventoryStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false }
]
if (isColumnVisible('id')) {
cols.push({ key: 'id', interactive: true, prop: 'id' })
}
if (isColumnVisible('planNo')) {
cols.push({ key: 'planNo', interactive: true, prop: 'planNo' })
}
cols.push({ key: 'batchNo', interactive: true, prop: 'batchNo' })
for (const def of listColumnDefs) {
if (def.prop === 'id' || def.prop === 'planNo') continue
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop
})
}
return cols
})
const KG_TO_TON_PROPS = new Set(['netWeight', 'grossWeight', 'tareWeight', 'deduction'])
function getInventoryStatCellValue(row: InventoryRecord, prop: string): unknown {
if (KG_TO_TON_PROPS.has(prop)) {
return (Number((row as any)[prop]) || 0) / 1000
}
if (prop === 'createByName') {
return row.createByName || row.createBy || ''
}
return (row as any)?.[prop]
}
const exportLoading = ref(false)
const saving = ref(false)
const tableData = ref<InventoryRecord[]>([])
@@ -682,47 +731,6 @@
return sortProp.value === prop ? sortOrder.value : null
}
const getSummary = (param: { columns: TableColumnCtx<InventoryRecord>[]; data: InventoryRecord[] }) => {
const { columns } = param
const sums: string[] = []
columns.forEach((column, index) => {
if (column.type === 'selection') {
sums[index] = ''
return
}
if (column.type === 'index') {
sums[index] = '合计'
return
}
if (column.property === 'quantity') {
const totalQuantity = tableData.value.reduce(
(sum, row) => sum + (Number(row.quantity) || 0),
0
)
sums[index] = totalQuantity.toString()
return
}
if (
column.property === 'netWeight' ||
column.property === 'grossWeight' ||
column.property === 'tareWeight' ||
column.property === 'deduction'
) {
const prop = column.property as keyof InventoryRecord
const totalWeight = tableData.value.reduce(
(sum, row) => sum + (Number(row[prop]) || 0) / 1000,
0
)
sums[index] = totalWeight.toFixed(2)
return
}
sums[index] = ''
})
return sums
}
const compare = (
a: InventoryRecord,
b: InventoryRecord,
@@ -1175,6 +1183,10 @@
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
.db-date-with-shortcuts {
display: flex;
align-items: center;
+59 -48
View File
@@ -130,17 +130,17 @@
</div>
<div class="list-body list-wrap">
<ListTable table-key="db-plans"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
show-summary
:summary-method="getSummary"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<div ref="plansTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="db-plans"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
@header-dragend="() => plansStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" />
<el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
<el-table-column
@@ -289,7 +289,16 @@
{{ row.createByName || row.createBy || '—' }}
</template>
</el-table-column>
</ListTable>
</ListTable>
<ListColumnStatsRow
ref="plansStatsRowRef"
storage-key="db-plans-column-stats"
:columns="plansStatsRowColumns"
:data="tableData"
:table-wrap-el="plansTableWrapRef"
:get-cell-value="getPlansStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
@@ -465,7 +474,6 @@
* UI 模式 P02 · 复杂业务列表
* 参考:/#/crm/quote · src/views/crm/quote/index.vue
*/
import type { TableColumnCtx } from 'element-plus'
import {
Check,
Close,
@@ -481,6 +489,8 @@
import ListToolbarDateShortcuts from '@/components/core/list-toolbar-date-shortcuts/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import DbCrmProductSelect from '@/components/business/db/DbCrmProductSelect.vue'
import DbPlanRelatedViewDialog from '@/components/business/db/DbPlanRelatedViewDialog.vue'
@@ -524,6 +534,36 @@
resetToDefault
} = usePersistedListColumns('db-plans-list-columns', listColumnDefs)
const plansTableWrapRef = ref<HTMLElement | null>(null)
const plansStatsRowRef = ref<{ resync?: () => void } | null>(null)
const plansStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false },
{ key: 'planNo', interactive: true, prop: 'planNo' }
]
for (const def of listColumnDefs) {
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop === 'autoGenerate' ? 'autoGenerateEnabled' : def.prop
})
}
return cols
})
function getPlansStatCellValue(row: Plan, prop: string): unknown {
if (prop === 'executedQuantity') {
return (row.executedQuantity || 0) / 1000
}
if (prop === 'createByName') {
return row.createByName || row.createBy || ''
}
return (row as any)?.[prop]
}
const loading = ref(false)
const exportLoading = ref(false)
const saving = ref(false)
@@ -641,41 +681,6 @@
return 'info'
}
const getSummary = (param: { columns: TableColumnCtx<Plan>[]; data: Plan[] }) => {
const { columns } = param
const sums: string[] = []
columns.forEach((column, index) => {
if (column.type === 'selection') {
sums[index] = ''
return
}
if (column.type === 'index') {
sums[index] = '合计'
return
}
if (column.property === 'plannedQuantity') {
const totalPlanned = tableData.value.reduce(
(sum, row) => sum + (Number(row.plannedQuantity) || 0),
0
)
sums[index] = totalPlanned.toString()
return
}
if (column.label === '已执行量(吨)') {
const totalExecuted = tableData.value.reduce(
(sum, row) => sum + (row.executedQuantity || 0) / 1000,
0
)
sums[index] = totalExecuted.toFixed(2)
return
}
sums[index] = ''
})
return sums
}
const loadData = async () => {
loading.value = true
try {
@@ -1064,3 +1069,9 @@
loadData()
})
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
</style>
+69 -47
View File
@@ -109,17 +109,17 @@
</div>
<div class="list-body list-wrap">
<ListTable table-key="db-weighing"
class="list-table"
:data="paginatedData"
v-loading="loading"
stripe
row-key="id"
show-summary
:summary-method="getSummary"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<div ref="weighingTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="db-weighing"
class="list-table"
:data="paginatedData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
@header-dragend="() => weighingStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" />
<el-table-column type="index" label="序号" min-width="60" align="center" header-align="center" />
<el-table-column
@@ -306,6 +306,15 @@
</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="weighingStatsRowRef"
storage-key="db-weighing-column-stats"
:columns="weighingStatsRowColumns"
:data="paginatedData"
:table-wrap-el="weighingTableWrapRef"
:get-cell-value="getWeighingStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
@@ -506,7 +515,6 @@
* UI 模式 P02 · 复杂业务列表
* 参考:/#/crm/quote · src/views/crm/quote/index.vue
*/
import type { TableColumnCtx } from 'element-plus'
import {
Check,
Close,
@@ -520,6 +528,8 @@
import ListToolbarDateShortcuts from '@/components/core/list-toolbar-date-shortcuts/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import DbCrmProductSelect from '@/components/business/db/DbCrmProductSelect.vue'
import DbRelatedViewDialog from '@/components/business/db/DbRelatedViewDialog.vue'
@@ -571,6 +581,33 @@
resetToDefault
} = usePersistedListColumns('db-weighing-list-columns', listColumnDefs)
const weighingTableWrapRef = ref<HTMLElement | null>(null)
const weighingStatsRowRef = ref<{ resync?: () => void } | null>(null)
const weighingStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false }
]
if (isColumnVisible('inventoryDate')) {
cols.push({ key: 'inventoryDate', interactive: true, prop: 'inventoryDate' })
}
if (isColumnVisible('planNo')) {
cols.push({ key: 'planNo', interactive: true, prop: 'planNo' })
}
cols.push({ key: 'batchNo', interactive: true, prop: 'batchNo' })
for (const def of listColumnDefs) {
if (def.prop === 'inventoryDate' || def.prop === 'planNo') continue
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop
})
}
return cols
})
const loading = ref(false)
const exportLoading = ref(false)
const saving = ref(false)
@@ -693,6 +730,20 @@
(row.createdAt ? row.createdAt.split(' ')[0] : '') ||
''
function getWeighingStatCellValue(row: WeighingRecord, prop: string): unknown {
if (prop === 'inventoryDate') return getRecordDate(row)
if (prop === 'currentQuantity' || prop === 'netWeight') {
return (Number((row as any)[prop]) || 0) / 1000
}
if (prop === 'docStatus') {
return row.isVoided === 1 ? '已作废' : '正常'
}
if (prop === 'createByName') {
return row.createByName || row.createBy || ''
}
return (row as any)?.[prop]
}
const buildListQueryParams = (): WeighingListParams => {
const params: WeighingListParams = {}
const keyword = searchKeyword.value.trim()
@@ -709,41 +760,6 @@
return params
}
const getSummary = (param: { columns: TableColumnCtx<WeighingRecord>[] }) => {
const { columns } = param
const sums: string[] = []
columns.forEach((column, index) => {
if (column.type === 'selection') {
sums[index] = ''
return
}
if (column.type === 'index') {
sums[index] = '合计'
return
}
if (column.label === '本次量(吨)') {
const totalCurrent = paginatedData.value.reduce(
(sum, row) => sum + (row.currentQuantity || 0) / 1000,
0
)
sums[index] = totalCurrent.toFixed(2)
return
}
if (column.label === '净重(吨)') {
const totalNet = paginatedData.value.reduce(
(sum, row) => sum + (row.netWeight || 0) / 1000,
0
)
sums[index] = totalNet.toFixed(2)
return
}
sums[index] = ''
})
return sums
}
const calculateNetWeight = () => {
formData.netWeight = netWeight.value
}
@@ -1196,3 +1212,9 @@
openAddFromInventoryQuery()
})
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
</style>
@@ -69,15 +69,17 @@
</div>
<div class="list-body list-wrap">
<ListTable table-key="pl--shared-io-order-list-page"
class="list-table"
:data="rows"
v-loading="loading"
stripe
row-key="id"
@selection-change="(val: PlIoOrder[]) => (selectedRows = val)"
@row-dblclick="openEdit"
>
<div ref="ioOrderTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable :table-key="`pl-${kind}-io-order-list`"
class="list-table"
:data="rows"
v-loading="loading"
stripe
row-key="id"
@selection-change="(val: PlIoOrder[]) => (selectedRows = val)"
@row-dblclick="openEdit"
@header-dragend="() => ioOrderStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" />
<el-table-column
type="index"
@@ -202,6 +204,15 @@
<el-table-column prop="actualQtyTotal" label="实际数量合计" min-width="120" align="right" />
<el-table-column prop="siteFeedbackTime" label="最近提交" min-width="160" show-overflow-tooltip />
</ListTable>
<ListColumnStatsRow
ref="ioOrderStatsRowRef"
:storage-key="`pl-${kind}-column-stats`"
:columns="ioOrderStatsRowColumns"
:data="rows"
:table-wrap-el="ioOrderTableWrapRef"
:get-cell-value="getIoOrderStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
@@ -416,6 +427,8 @@
} from '@/components/business/brand-qty-chips-field/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarDateShortcuts from '@/components/core/list-toolbar-date-shortcuts/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import ViewKvTable from '@/components/business/system-view-dialog/ViewKvTable.vue'
import type { ViewField } from '@/components/business/system-view-dialog/types'
import {
@@ -441,6 +454,61 @@
const api = props.kind === 'inbound' ? inboundOrderApi : outboundOrderApi
const ioOrderTableWrapRef = ref<HTMLElement | null>(null)
const ioOrderStatsRowRef = ref<{ resync?: () => void } | null>(null)
const ioOrderStatsRowColumns = computed((): StatsRowColumn[] => [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false },
{ key: 'orderNo', interactive: true, prop: 'orderNo' },
{ key: 'status', interactive: true, prop: 'status' },
{ key: 'planNo', interactive: true, prop: 'planNo' },
{ key: 'partnerCompany', interactive: true, prop: 'partnerCompany' },
{ key: 'vehicleNo', interactive: true, prop: 'vehicleNo' },
{ key: 'pickupAddress', interactive: true, prop: 'pickupAddress' },
{ key: '__destination', interactive: true, prop: '__destination' },
{ key: 'productName', interactive: true, prop: 'productName' },
{ key: 'brand', interactive: true, prop: 'brand' },
{ key: 'brandQty', interactive: true, prop: 'brandQty' },
{ key: 'productSpec', interactive: true, prop: 'productSpec' },
{ key: 'productUnit', interactive: true, prop: 'productUnit' },
{ key: 'packageSpec', interactive: true, prop: 'packageSpec' },
{ key: 'deliveryMethod', interactive: true, prop: 'deliveryMethod' },
{ key: 'actualQtyTotal', interactive: true, prop: 'actualQtyTotal' },
{ key: 'siteFeedbackTime', interactive: true, prop: 'siteFeedbackTime' }
])
function joinItemLines(row: PlIoOrder, mapper: (item: PlIoOrder['items'][number]) => string): string {
return (row.items || []).map(mapper).filter((s) => s && s !== '—').join('\n')
}
function getIoOrderStatCellValue(row: PlIoOrder, prop: string): unknown {
if (prop === 'status') return statusLabel(row.status)
if (prop === '__destination') return row.destinationAddress || row.destinationName || ''
if (prop === 'productName') {
return joinItemLines(row, (item) => item.productName || '')
}
if (prop === 'brand') {
return joinItemLines(row, (item) => brandConstraintLabel(item))
}
if (prop === 'brandQty') {
return joinItemLines(row, (item) => resolveIoBrandQtyDisplay(item))
}
if (prop === 'productSpec') {
return joinItemLines(row, (item) => item.specification || '')
}
if (prop === 'productUnit') {
return joinItemLines(row, (item) => item.unitLabel || item.unit || '')
}
if (prop === 'packageSpec') {
return joinItemLines(row, (item) => item.packageSpec || '')
}
if (prop === 'deliveryMethod') {
return joinItemLines(row, (item) => deliveryMethodLabel(item.deliveryMethod))
}
return (row as any)?.[prop]
}
type IoOrderEditItem = PlIoOrder['items'][number] & {
brandQtyRows?: BrandQtyRow[]
}
@@ -809,6 +877,10 @@
</style>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
.io-order-multi-cell {
display: flex;
flex-direction: column;
@@ -53,15 +53,17 @@
</div>
<div class="list-body list-wrap">
<ListTable table-key="pl-freight-components-approval-done-panel"
class="list-table"
:data="settlementList"
v-loading="loading"
stripe
row-key="id"
@row-dblclick="handleView"
@selection-change="handleSelectionChange"
>
<div ref="doneTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="pl-freight-components-approval-done-panel"
class="list-table"
:data="settlementList"
v-loading="loading"
stripe
row-key="id"
@row-dblclick="handleView"
@selection-change="handleSelectionChange"
@header-dragend="() => doneStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
@@ -133,6 +135,15 @@
<template #default="{ row }">{{ row.submitterName || '—' }}</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="doneStatsRowRef"
storage-key="pl-freight-approval-done-column-stats"
:columns="doneStatsRowColumns"
:data="settlementList"
:table-wrap-el="doneTableWrapRef"
:get-cell-value="getDoneStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
@@ -158,6 +169,8 @@
<script setup lang="ts">
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { RefreshLeft, Search } from '@element-plus/icons-vue'
@@ -187,6 +200,36 @@ const {
resetToDefault
} = usePersistedListColumns('pl-freight-approval-done-columns', listColumnDefs)
const doneTableWrapRef = ref<HTMLElement | null>(null)
const doneStatsRowRef = ref<{ resync?: () => void } | null>(null)
const doneStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false },
{ key: 'settlementNo', interactive: true, prop: 'settlementNo' }
]
for (const def of listColumnDefs) {
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: def.prop === 'totalFreight'
})
}
return cols
})
function getDoneStatCellValue(row: FreightSettlement, prop: string): unknown {
if (prop === 'settlementStatus') return getSettlementStatusLabel(row.settlementStatus)
if (prop === 'approvalFinishedTime') {
return row.approvalFinishedTime ? formatDateTime(row.approvalFinishedTime) : ''
}
if (prop === 'submitterName') return row.submitterName || ''
return (row as any)?.[prop]
}
const loading = ref(false)
const selectedRows = ref<FreightSettlement[]>([])
const settlementList = ref<FreightSettlement[]>([])
@@ -284,3 +327,9 @@ onMounted(() => {
defineExpose({ refresh })
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
</style>
@@ -71,15 +71,17 @@
</div>
<div class="list-body list-wrap">
<ListTable table-key="pl-freight-components-approval-pending-panel"
class="list-table"
:data="settlementList"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<div ref="pendingTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="pl-freight-components-approval-pending-panel"
class="list-table"
:data="settlementList"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
@header-dragend="() => pendingStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
@@ -145,6 +147,15 @@
<template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="pendingStatsRowRef"
storage-key="pl-freight-approval-pending-column-stats"
:columns="pendingStatsRowColumns"
:data="settlementList"
:table-wrap-el="pendingTableWrapRef"
:get-cell-value="getPendingStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
@@ -190,6 +201,8 @@
<script setup lang="ts">
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { CircleCheck, CircleClose, RefreshLeft, Search } from '@element-plus/icons-vue'
@@ -216,6 +229,36 @@ const {
resetToDefault
} = usePersistedListColumns('pl-freight-approval-pending-columns', listColumnDefs)
const pendingTableWrapRef = ref<HTMLElement | null>(null)
const pendingStatsRowRef = ref<{ resync?: () => void } | null>(null)
const pendingStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false },
{ key: 'settlementNo', interactive: true, prop: 'settlementNo' }
]
for (const def of listColumnDefs) {
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: def.prop === 'totalFreight'
})
}
return cols
})
function getPendingStatCellValue(row: FreightSettlement, prop: string): unknown {
if (prop === 'currentApproverName') return row.currentApproverName || ''
if (prop === 'submitterName') return row.submitterName || ''
if (prop === 'createTime') {
return row.createTime ? formatDateTime(row.createTime) : ''
}
return (row as any)?.[prop]
}
const loading = ref(false)
const settlementList = ref<FreightSettlement[]>([])
const selectedRows = ref<FreightSettlement[]>([])
@@ -340,3 +383,9 @@ onMounted(() => {
defineExpose({ refresh })
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
</style>
@@ -55,15 +55,17 @@
</div>
<div class="list-body list-wrap">
<ListTable table-key="pl-freight-components-disburse-panel"
class="list-table"
:data="settlementList"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<div ref="disburseTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="pl-freight-components-disburse-panel"
class="list-table"
:data="settlementList"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
@header-dragend="() => disburseStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
@@ -122,6 +124,15 @@
<template #default="{ row }">{{ row.submitterName || '—' }}</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="disburseStatsRowRef"
storage-key="pl-freight-disburse-column-stats"
:columns="disburseStatsRowColumns"
:data="settlementList"
:table-wrap-el="disburseTableWrapRef"
:get-cell-value="getDisburseStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
@@ -154,6 +165,8 @@
<script setup lang="ts">
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { Money, RefreshLeft, Search } from '@element-plus/icons-vue'
@@ -181,6 +194,35 @@ const {
const emit = defineEmits<{ disbursed: [] }>()
const disburseTableWrapRef = ref<HTMLElement | null>(null)
const disburseStatsRowRef = ref<{ resync?: () => void } | null>(null)
const disburseStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false },
{ key: 'settlementNo', interactive: true, prop: 'settlementNo' }
]
for (const def of listColumnDefs) {
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: def.prop === 'totalFreight'
})
}
return cols
})
function getDisburseStatCellValue(row: FreightSettlement, prop: string): unknown {
if (prop === 'approvalFinishedTime') {
return row.approvalFinishedTime ? formatDateTime(row.approvalFinishedTime) : ''
}
if (prop === 'submitterName') return row.submitterName || ''
return (row as any)?.[prop]
}
const loading = ref(false)
const settlementList = ref<FreightSettlement[]>([])
const selectedRows = ref<FreightSettlement[]>([])
@@ -295,3 +337,9 @@ onMounted(() => {
defineExpose({ refresh })
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
</style>
@@ -47,15 +47,17 @@
</div>
<div class="list-body list-wrap">
<ListTable table-key="pl-freight-components-settled-panel"
class="list-table"
:data="settlementList"
v-loading="loading"
stripe
row-key="id"
@row-dblclick="handleView"
@selection-change="handleSelectionChange"
>
<div ref="settledTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="pl-freight-components-settled-panel"
class="list-table"
:data="settlementList"
v-loading="loading"
stripe
row-key="id"
@row-dblclick="handleView"
@selection-change="handleSelectionChange"
@header-dragend="() => settledStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
@@ -115,6 +117,15 @@
</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="settledStatsRowRef"
storage-key="pl-freight-settled-column-stats"
:columns="settledStatsRowColumns"
:data="settlementList"
:table-wrap-el="settledTableWrapRef"
:get-cell-value="getSettledStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
@@ -140,6 +151,8 @@
<script setup lang="ts">
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { RefreshLeft, Search } from '@element-plus/icons-vue'
@@ -167,6 +180,36 @@ const {
resetToDefault
} = usePersistedListColumns('pl-freight-settled-columns', listColumnDefs)
const settledTableWrapRef = ref<HTMLElement | null>(null)
const settledStatsRowRef = ref<{ resync?: () => void } | null>(null)
const settledStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false },
{ key: 'settlementNo', interactive: true, prop: 'settlementNo' }
]
for (const def of listColumnDefs) {
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: def.prop === 'totalFreight' || def.prop === 'disburseAmount'
})
}
return cols
})
function getSettledStatCellValue(row: FreightSettlement, prop: string): unknown {
if (prop === 'disburseAmount') return row.disburseAmount || 0
if (prop === 'disburseTime') {
return row.disburseTime ? formatDateTime(row.disburseTime) : ''
}
if (prop === 'settlementStatus') return getSettlementStatusLabel(row.settlementStatus)
return (row as any)?.[prop]
}
const loading = ref(false)
const selectedRows = ref<FreightSettlement[]>([])
const settlementList = ref<FreightSettlement[]>([])
@@ -256,3 +299,9 @@ onMounted(() => {
defineExpose({ refresh })
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
</style>
@@ -126,15 +126,17 @@
</div>
<div class="list-body list-wrap">
<ListTable table-key="pl-freight-components-settlement-bill-panel"
class="list-table"
:data="settlementList"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<div ref="billsTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="pl-freight-components-settlement-bill-panel"
class="list-table"
:data="settlementList"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
@header-dragend="() => billsStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
@@ -235,6 +237,15 @@
<template #default="{ row }">{{ formatDateTime(row.createTime) }}</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="billsStatsRowRef"
storage-key="pl-freight-settlement-bill-column-stats"
:columns="billsStatsRowColumns"
:data="settlementList"
:table-wrap-el="billsTableWrapRef"
:get-cell-value="getBillsStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
@@ -309,6 +320,8 @@
<script setup lang="ts">
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import {
@@ -352,6 +365,41 @@ const {
resetToDefault
} = usePersistedListColumns('pl-freight-settlement-bill-columns', listColumnDefs)
const billsTableWrapRef = ref<HTMLElement | null>(null)
const billsStatsRowRef = ref<{ resync?: () => void } | null>(null)
const billsStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false },
{ key: 'settlementNo', interactive: true, prop: 'settlementNo' }
]
for (const def of listColumnDefs) {
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: def.prop === 'totalFreight' || def.prop === 'disburseAmount'
})
}
return cols
})
function getBillsStatCellValue(row: FreightSettlement, prop: string): unknown {
if (prop === 'settlementStatus') return getSettlementStatusLabel(row.settlementStatus)
if (prop === 'disburseAmount') return row.disburseAmount || 0
if (prop === 'disburseTime') {
return row.disburseTime ? formatDateTime(row.disburseTime) : ''
}
if (prop === 'currentApproverName') return row.currentApproverName || ''
if (prop === 'submitterName') return row.submitterName || ''
if (prop === 'createTime') {
return row.createTime ? formatDateTime(row.createTime) : ''
}
return (row as any)?.[prop]
}
const loading = ref(false)
const cancelLoadingMap = reactive<Record<string, boolean>>({})
const {
@@ -540,3 +588,9 @@ onMounted(() => {
defineExpose({ refresh })
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
</style>
@@ -84,15 +84,17 @@
</div>
<div class="list-body list-wrap">
<ListTable table-key="pl-freight-components-unsettled-plan-panel"
class="list-table"
:data="planList"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleViewPlan"
>
<div ref="unsettledTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="pl-freight-components-unsettled-plan-panel"
class="list-table"
:data="planList"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleViewPlan"
@header-dragend="() => unsettledStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="48" align="center" />
<el-table-column
type="index"
@@ -178,6 +180,15 @@
}}</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="unsettledStatsRowRef"
storage-key="pl-freight-unsettled-plan-column-stats"
:columns="unsettledStatsRowColumns"
:data="planList"
:table-wrap-el="unsettledTableWrapRef"
:get-cell-value="getUnsettledStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
@@ -203,6 +214,8 @@
<script setup lang="ts">
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { DocumentAdd, RefreshLeft, Search } from '@element-plus/icons-vue'
@@ -236,6 +249,39 @@ const {
resetToDefault
} = usePersistedListColumns('pl-freight-unsettled-plan-columns', listColumnDefs)
const unsettledTableWrapRef = ref<HTMLElement | null>(null)
const unsettledStatsRowRef = ref<{ resync?: () => void } | null>(null)
const unsettledStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false },
{ key: 'planNo', interactive: true, prop: 'planNo' }
]
for (const def of listColumnDefs) {
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: def.prop === 'freightAmount'
})
}
return cols
})
function getUnsettledStatCellValue(row: UnsettledPlanItem, prop: string): unknown {
if (prop === 'planType') return getPlanTypeLabel(row.planType)
if (prop === 'partnerName') return row.customerName || row.supplierName || ''
if (prop === 'licensePlate') {
return formatPlVehiclePlateDisplay(row.licensePlate, undefined, vehicleTrailerLookup.value)
}
if (prop === 'archiveTime') {
return row.archiveTime ? formatDateTime(row.archiveTime) : ''
}
return (row as any)?.[prop]
}
const loading = ref(false)
const createLoading = ref(false)
const {
@@ -396,3 +442,9 @@ onMounted(() => {
defineExpose({ refresh })
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
</style>
+82 -10
View File
@@ -82,7 +82,7 @@
/>
<ListQuickSearchInput
v-model="quickSearch"
placeholder="计划编号/客户名称"
placeholder="计划编号/供方/需方/产品名称"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
@@ -109,15 +109,17 @@
</div>
<div class="list-body list-wrap">
<ListTable table-key="pl-plan-archive"
class="list-table"
:data="planList"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<div ref="archiveTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="pl-plan-archive"
class="list-table"
:data="planList"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
@header-dragend="() => archiveStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
@@ -295,6 +297,15 @@
align="center"
/>
</ListTable>
<ListColumnStatsRow
ref="archiveStatsRowRef"
storage-key="pl-plan-archive-column-stats"
:columns="archiveStatsRowColumns"
:data="planList"
:table-wrap-el="archiveTableWrapRef"
:get-cell-value="getArchiveStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
@@ -342,6 +353,8 @@
<script setup lang="ts">
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import {
FolderChecked,
@@ -412,6 +425,60 @@ const {
resetToDefault
} = usePersistedListColumns('pl-plan-archive-list-columns', listColumnDefs)
const archiveTableWrapRef = ref<HTMLElement | null>(null)
const archiveStatsRowRef = ref<{ resync?: () => void } | null>(null)
const archiveStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false },
{ key: 'planNo', interactive: true, prop: 'planNo' }
]
for (const def of listColumnDefs) {
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop
})
}
return cols
})
function getArchiveStatCellValue(row: Plan, prop: string): unknown {
if (prop === 'archiveStatus') return getPlPlanArchiveStatusLabel(row.archiveStatus)
if (prop === 'followStatus') return getPlPlanFollowStatusLabel(row.followStatus)
if (prop === 'planType') return getPlanTypeLabel(row.planType)
if (prop === 'productPlanQty') {
return (row.products || []).reduce((sum, p) => sum + (Number(p.quantity) || 0), 0)
}
if (prop === 'productActualQty') {
return (row.products || []).reduce((sum, p) => sum + (Number(p.actualQty) || 0), 0)
}
if (prop === 'productName') {
return (row.products || []).map((p) => p.productName || '').filter(Boolean).join('\n')
}
if (prop === 'productSpec') {
return (row.products || [])
.map((p) => p.model || p.packageSpec || '')
.filter(Boolean)
.join('\n')
}
if (prop === 'productUnit') {
return (row.products || []).map((p) => p.unit || '').filter(Boolean).join('\n')
}
if (prop === 'planAttachments') {
return ''
}
if (prop === 'licensePlate') {
return formatPlVehiclePlateDisplay(row.licensePlate, row.trailerNo)
}
if (prop === 'personnelSummary') {
return normalizePlPersonnelSummaryDisplay(row.personnelSummary)
}
return (row as any)?.[prop]
}
const selectedRows = ref<Plan[]>([])
const viewingPlan = ref<Plan | null>(null)
@@ -515,6 +582,7 @@ function handleReset() {
function handleQuickSearch() {
searchForm.planNo = ''
searchForm.customerName = ''
searchForm.productName = ''
pagination.page = 1
fetchData()
}
@@ -611,6 +679,10 @@ onMounted(() => {
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
.pl-plan-list-toolbar-row {
flex-wrap: wrap;
}
+1 -1
View File
@@ -73,7 +73,7 @@
/>
<ListQuickSearchInput
v-model="quickSearch"
placeholder="计划编号/客户名称"
placeholder="计划编号/供方/需方"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
+85 -10
View File
@@ -87,7 +87,7 @@
/>
<ListQuickSearchInput
v-model="quickSearch"
placeholder="计划编号/客户名称/产品名称/车牌号"
placeholder="计划编号/供方/需方/产品名称/车牌号"
@search="handleQuickSearch"
@clear="handleQuickSearchClear"
/>
@@ -173,15 +173,17 @@
<!-- 数据表格 -->
<div class="list-body list-wrap">
<ListTable table-key="pl-plan"
class="list-table"
:data="planList"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<div ref="planTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="pl-plan"
class="list-table"
:data="planList"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
@header-dragend="() => planStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
@@ -390,6 +392,15 @@
align="center"
/>
</ListTable>
<ListColumnStatsRow
ref="planStatsRowRef"
storage-key="pl-plan-column-stats"
:columns="planStatsRowColumns"
:data="planList"
:table-wrap-el="planTableWrapRef"
:get-cell-value="getPlanStatCellValue"
/>
</div>
<div class="pagination list-pager-bar pl-plan-list-pager-bar">
<div class="list-pager-selected">
@@ -528,6 +539,8 @@ import PlPlanArrangeDialog from '@/components/business/pl-plan-arrange-dialog/in
import PlPlanFormDialog from '@/components/business/pl-plan-form-dialog/index.vue'
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import PlPlanListCertCell from '@/components/business/pl-plan-list-cert-cell/index.vue'
import PlPlanListProductCell from '@/components/business/pl-plan-list-product-cell/index.vue'
@@ -595,6 +608,64 @@ const {
resetToDefault
} = usePersistedListColumns('pl-plan-list-columns-v2', listColumnDefs)
const planTableWrapRef = ref<HTMLElement | null>(null)
const planStatsRowRef = ref<{ resync?: () => void } | null>(null)
const planStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false },
{ key: 'planNo', interactive: true, prop: 'planNo' }
]
for (const def of listColumnDefs) {
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop
})
}
return cols
})
function getPlanStatCellValue(row: Plan, prop: string): unknown {
if (prop === 'followStatus') return getPlPlanFollowStatusLabel(row.followStatus)
if (prop === 'planType') return getPlanTypeLabel(row.planType)
if (prop === 'contractSummary') {
return planContractLines(row)
.map((l) => `${l.side}${l.no}`)
.join(' ')
}
if (prop === 'productPlanQty') {
return (row.products || []).reduce((sum, p) => sum + (Number(p.quantity) || 0), 0)
}
if (prop === 'productActualQty') {
return (row.products || []).reduce((sum, p) => sum + (Number(p.actualQty) || 0), 0)
}
if (prop === 'productName') {
return (row.products || []).map((p) => p.productName || '').filter(Boolean).join('\n')
}
if (prop === 'productSpec') {
return (row.products || []).map((p) => p.model || p.spec || '').filter(Boolean).join('\n')
}
if (prop === 'productPackageSpec') {
return (row.products || []).map((p) => p.packageSpec || '').filter(Boolean).join('\n')
}
if (prop === 'productUnit') {
return (row.products || []).map((p) => p.unit || '').filter(Boolean).join('\n')
}
if (prop === 'planAttachments') {
return ''
}
if (prop === 'licensePlate') {
return formatPlVehiclePlateDisplay(row.licensePlate, row.trailerNo)
}
if (prop === 'personnelSummary') {
return normalizePlPersonnelSummaryDisplay(row.personnelSummary)
}
return (row as any)?.[prop]
}
const selectedRows = ref<Plan[]>([])
const viewingPlan = ref<Plan | null>(null)
@@ -1010,6 +1081,10 @@ async function openArrangeFromQuery() {
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
.pl-plan-list-toolbar-row {
flex-wrap: nowrap;
overflow-x: auto;
@@ -99,15 +99,17 @@
</div>
<div class="list-body list-wrap">
<ListTable table-key="pl-vehicle-maintenance-apply"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<div ref="maintApplyTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="pl-vehicle-maintenance-apply"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
@header-dragend="() => maintApplyStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" />
<el-table-column
type="index"
@@ -198,6 +200,15 @@
align="center"
/>
</ListTable>
<ListColumnStatsRow
ref="maintApplyStatsRowRef"
storage-key="pl-vehicle-maintenance-apply-column-stats"
:columns="maintApplyStatsRowColumns"
:data="tableData"
:table-wrap-el="maintApplyTableWrapRef"
:get-cell-value="getMaintApplyStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
@@ -359,6 +370,8 @@
<script setup lang="ts">
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import {
@@ -415,6 +428,36 @@ const {
resetToDefault
} = usePersistedListColumns('pl-vehicle-maintenance-apply-list-columns', listColumnDefs)
const maintApplyTableWrapRef = ref<HTMLElement | null>(null)
const maintApplyStatsRowRef = ref<{ resync?: () => void } | null>(null)
const maintApplyStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false },
{ key: 'applyNo', interactive: true, prop: 'applyNo' }
]
for (const def of listColumnDefs) {
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop
})
}
return cols
})
function getMaintApplyStatCellValue(row: VehicleMaintenanceApply, prop: string): unknown {
if (prop === 'licensePlate') {
return formatPlVehiclePlateDisplay(row.licensePlate, undefined, vehicleTrailerLookup.value)
}
if (prop === 'maintenanceType') return getPlMaintenanceTypeLabel(row.maintenanceType)
if (prop === 'expectedPickupDate') return row.expectedPickupDate || ''
if (prop === 'status') return getPlMaintenanceApplyStatusLabel(row.status)
return (row as any)?.[prop]
}
const currentUserId = computed(() => {
const info = userStore.info as Record<string, unknown> | undefined
return String(info?.id || info?.userId || '')
@@ -773,3 +816,9 @@ onMounted(async () => {
fetchData()
})
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
</style>
@@ -114,15 +114,17 @@
</div>
<div class="list-body list-wrap">
<ListTable table-key="pl-vehicle-maintenance-process"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="applyId"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<div ref="maintProcessTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="pl-vehicle-maintenance-process"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="applyId"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
@header-dragend="() => maintProcessStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" />
<el-table-column
type="index"
@@ -244,6 +246,15 @@
<template #default="{ row }">{{ row.handlerName || '—' }}</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="maintProcessStatsRowRef"
storage-key="pl-vehicle-maintenance-process-column-stats"
:columns="maintProcessStatsRowColumns"
:data="tableData"
:table-wrap-el="maintProcessTableWrapRef"
:get-cell-value="getMaintProcessStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
@@ -403,6 +414,8 @@
<script setup lang="ts">
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { Check, Close, Delete, Edit, Plus, RefreshLeft, Search, Tools } from '@element-plus/icons-vue'
@@ -467,6 +480,41 @@ const {
resetToDefault
} = usePersistedListColumns('pl-vehicle-maintenance-process-list-columns', listColumnDefs)
const maintProcessTableWrapRef = ref<HTMLElement | null>(null)
const maintProcessStatsRowRef = ref<{ resync?: () => void } | null>(null)
const maintProcessStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false },
{ key: 'applyNo', interactive: true, prop: 'applyNo' }
]
for (const def of listColumnDefs) {
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: def.prop === 'amount'
})
}
return cols
})
function getMaintProcessStatCellValue(row: VehicleMaintenanceProcessBoardItem, prop: string): unknown {
if (prop === 'applyStatus') return getPlMaintenanceApplyStatusLabel(row.applyStatus)
if (prop === 'licensePlate') {
return formatPlVehiclePlateDisplay(row.licensePlate, undefined, vehicleTrailerLookup.value)
}
if (prop === 'maintenanceType') return getPlMaintenanceTypeLabel(row.maintenanceType)
if (prop === 'expectedPickupDate') return row.expectedPickupDate || ''
if (prop === 'processNo') return row.processNo || ''
if (prop === 'processDate') return row.processDate || ''
if (prop === 'amount') return row.amount != null ? Number(row.amount) : null
if (prop === 'handlerName') return row.handlerName || ''
return (row as any)?.[prop]
}
const submitLoading = ref(false)
const deleteLoadingMap = reactive<Record<string, boolean>>({})
const selectedRows = ref<VehicleMaintenanceProcessBoardItem[]>([])
@@ -861,6 +909,10 @@ onMounted(() => {
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
.apply-summary-block {
margin-bottom: 16px;
padding: 12px 14px;
@@ -81,15 +81,17 @@
</div>
<div class="list-body list-wrap">
<ListTable table-key="pl-vehicle-urea-apply"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<div ref="ureaApplyTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="pl-vehicle-urea-apply"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="id"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
@header-dragend="() => ureaApplyStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" />
<el-table-column
type="index"
@@ -183,6 +185,15 @@
align="center"
/>
</ListTable>
<ListColumnStatsRow
ref="ureaApplyStatsRowRef"
storage-key="pl-vehicle-urea-apply-column-stats"
:columns="ureaApplyStatsRowColumns"
:data="tableData"
:table-wrap-el="ureaApplyTableWrapRef"
:get-cell-value="getUreaApplyStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
@@ -324,6 +335,8 @@
<script setup lang="ts">
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import {
@@ -390,6 +403,35 @@ const {
resetToDefault
} = usePersistedListColumns('pl-vehicle-urea-apply-list-columns', listColumnDefs)
const ureaApplyTableWrapRef = ref<HTMLElement | null>(null)
const ureaApplyStatsRowRef = ref<{ resync?: () => void } | null>(null)
const ureaApplyStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false },
{ key: 'applyNo', interactive: true, prop: 'applyNo' }
]
for (const def of listColumnDefs) {
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: def.prop === 'applyAmount'
})
}
return cols
})
function getUreaApplyStatCellValue(row: VehicleUreaApply, prop: string): unknown {
if (prop === 'licensePlate') {
return formatPlVehiclePlateDisplay(row.licensePlate, undefined, vehicleTrailerLookup.value)
}
if (prop === 'status') return getPlUreaApplyStatusLabel(row.status)
return (row as any)?.[prop]
}
const submitLoading = ref(false)
const cancelLoadingMap = reactive<Record<string, boolean>>({})
const tableData = ref<VehicleUreaApply[]>([])
@@ -701,3 +743,9 @@ onMounted(async () => {
fetchData()
})
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
</style>
@@ -96,15 +96,17 @@
</div>
<div class="list-body list-wrap">
<ListTable table-key="pl-vehicle-urea-disburse"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="applyId"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<div ref="ureaDisburseTableWrapRef" class="table-scroll bx-list-table-wrap">
<ListTable table-key="pl-vehicle-urea-disburse"
class="list-table"
:data="tableData"
v-loading="loading"
stripe
row-key="applyId"
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
@header-dragend="() => ureaDisburseStatsRowRef?.resync?.()"
>
<el-table-column type="selection" min-width="55" align="center" />
<el-table-column
type="index"
@@ -229,6 +231,15 @@
<template #default="{ row }">{{ row.handlerName || '—' }}</template>
</el-table-column>
</ListTable>
<ListColumnStatsRow
ref="ureaDisburseStatsRowRef"
storage-key="pl-vehicle-urea-disburse-column-stats"
:columns="ureaDisburseStatsRowColumns"
:data="tableData"
:table-wrap-el="ureaDisburseTableWrapRef"
:get-cell-value="getUreaDisburseStatCellValue"
/>
</div>
<div class="pagination list-pager-bar">
<div class="list-pager-selected">
@@ -374,6 +385,8 @@
<script setup lang="ts">
import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue'
import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue'
import ListColumnStatsRow from '@/components/business/list-column-stats-row/index.vue'
import type { StatsRowColumn } from '@/utils/list-column-stats'
import { useDbInstantSearch } from '@/composables/useDbInstantSearch'
import { usePersistedListColumns } from '@/composables/usePersistedListColumns'
import { Check, Close, Delete, Edit, Money, Plus, RefreshLeft, Search } from '@element-plus/icons-vue'
@@ -435,6 +448,39 @@ const {
resetToDefault
} = usePersistedListColumns('pl-vehicle-urea-disburse-list-columns', listColumnDefs)
const ureaDisburseTableWrapRef = ref<HTMLElement | null>(null)
const ureaDisburseStatsRowRef = ref<{ resync?: () => void } | null>(null)
const ureaDisburseStatsRowColumns = computed((): StatsRowColumn[] => {
const cols: StatsRowColumn[] = [
{ key: '__selection', interactive: false },
{ key: '__index', interactive: false },
{ key: 'applyNo', interactive: true, prop: 'applyNo' }
]
for (const def of listColumnDefs) {
if (!isColumnVisible(def.prop)) continue
cols.push({
key: def.prop,
interactive: true,
prop: def.prop,
money: def.prop === 'applyAmount' || def.prop === 'amount'
})
}
return cols
})
function getUreaDisburseStatCellValue(row: VehicleUreaDisburseBoardItem, prop: string): unknown {
if (prop === 'applyStatus') return getPlUreaApplyStatusLabel(row.applyStatus)
if (prop === 'licensePlate') {
return formatPlVehiclePlateDisplay(row.licensePlate, undefined, vehicleTrailerLookup.value)
}
if (prop === 'disburseNo') return row.disburseNo || ''
if (prop === 'disburseDate') return row.disburseDate || ''
if (prop === 'amount') return row.amount != null ? Number(row.amount) : null
if (prop === 'handlerName') return row.handlerName || ''
return (row as any)?.[prop]
}
const submitLoading = ref(false)
const deleteLoadingMap = reactive<Record<string, boolean>>({})
const selectedRows = ref<VehicleUreaDisburseBoardItem[]>([])
@@ -793,6 +839,10 @@ onMounted(() => {
</script>
<style scoped lang="scss">
.bx-list-table-wrap {
min-width: 0;
}
.apply-summary-block {
margin-bottom: 16px;
padding: 12px 14px;