972 lines
32 KiB
Vue
972 lines
32 KiB
Vue
<script setup lang="ts">
|
||
import { computed, defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import type { ComponentPublicInstance, PropType } from 'vue'
|
||
import { AgGridVue } from 'ag-grid-vue3'
|
||
import type { ColDef, GridOptions, ICellRendererParams } from 'ag-grid-community'
|
||
import localforage from 'localforage'
|
||
import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
|
||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||
import { addNumbers } from '@/lib/decimal'
|
||
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
|
||
import { getPricingMethodTotalsForServices } from '@/lib/pricingMethodTotals'
|
||
import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
||
import { Search } from 'lucide-vue-next'
|
||
import {
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogOverlay,
|
||
AlertDialogPortal,
|
||
AlertDialogRoot,
|
||
AlertDialogTitle,
|
||
DialogClose,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogOverlay,
|
||
DialogPortal,
|
||
DialogRoot,
|
||
DialogTitle,
|
||
DialogTrigger
|
||
} from 'reka-ui'
|
||
import { Button } from '@/components/ui/button'
|
||
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
|
||
import { serviceList } from '@/sql'
|
||
import { useTabStore } from '@/pinia/tab'
|
||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||
|
||
interface ServiceItem {
|
||
id: string
|
||
code: string
|
||
name: string
|
||
}
|
||
|
||
interface DetailRow {
|
||
id: string
|
||
code: string
|
||
name: string
|
||
investScale: number | null
|
||
landScale: number | null
|
||
workload: number | null
|
||
hourly: number | null
|
||
subtotal?: number | null
|
||
actions?: unknown
|
||
}
|
||
|
||
interface ZxFwState {
|
||
selectedIds?: string[]
|
||
selectedCodes?: string[]
|
||
detailRows: DetailRow[]
|
||
}
|
||
|
||
const props = defineProps<{
|
||
contractId: string
|
||
}>()
|
||
const tabStore = useTabStore()
|
||
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
||
const DB_KEY = computed(() => `zxFW-${props.contractId}`)
|
||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||
const PRICING_CLEAR_SKIP_TTL_MS = 5000
|
||
|
||
type ServiceListItem = { code?: string; ref?: string; name: string; defCoe: number | null }
|
||
const serviceDict: ServiceItem[] = Object.entries(serviceList as Record<string, ServiceListItem>)
|
||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||
.filter((entry): entry is [string, ServiceListItem] => {
|
||
const item = entry[1]
|
||
const itemCode = item?.code || item?.ref
|
||
return Boolean(itemCode && item?.name) && item.defCoe !== null
|
||
})
|
||
.map(([key, item]) => ({
|
||
id: key,
|
||
code: item.code || item.ref || '',
|
||
name: item.name
|
||
}))
|
||
|
||
const serviceById = new Map(serviceDict.map(item => [item.id, item]))
|
||
const serviceIdByCode = new Map(serviceDict.map(item => [item.code, item.id]))
|
||
const fixedBudgetRow: Pick<DetailRow, 'id' | 'code' | 'name'> = { id: 'fixed-budget-c', code: 'C', name: '合同预算' }
|
||
const isFixedRow = (row?: DetailRow | null) => row?.id === fixedBudgetRow.id
|
||
|
||
const selectedIds = ref<string[]>([])
|
||
const detailRows = ref<DetailRow[]>([])
|
||
const rootRef = ref<HTMLElement | null>(null)
|
||
const gridSectionRef = ref<HTMLElement | null>(null)
|
||
const agGridRef = ref<HTMLElement | null>(null)
|
||
const agGridHeight = ref(580)
|
||
let snapScrollHost: HTMLElement | null = null
|
||
let snapTimer: ReturnType<typeof setTimeout> | null = null
|
||
let snapLockTimer: ReturnType<typeof setTimeout> | null = null
|
||
let isSnapping = false
|
||
let hostResizeObserver: ResizeObserver | null = null
|
||
|
||
const updateGridCardHeight = () => {
|
||
if (!snapScrollHost || !rootRef.value) return
|
||
const contentWrap = rootRef.value.parentElement
|
||
const style = contentWrap ? window.getComputedStyle(contentWrap) : null
|
||
const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0
|
||
const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0
|
||
const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom))
|
||
agGridHeight.value = nextHeight-40
|
||
}
|
||
|
||
|
||
const bindSnapScrollHost = () => {
|
||
snapScrollHost = rootRef.value?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null
|
||
if (!snapScrollHost) return
|
||
snapScrollHost.addEventListener('scroll', handleSnapHostScroll, { passive: true })
|
||
hostResizeObserver?.disconnect()
|
||
hostResizeObserver = new ResizeObserver(() => {
|
||
updateGridCardHeight()
|
||
})
|
||
hostResizeObserver.observe(snapScrollHost)
|
||
updateGridCardHeight()
|
||
}
|
||
|
||
const unbindSnapScrollHost = () => {
|
||
if (snapScrollHost) {
|
||
snapScrollHost.removeEventListener('scroll', handleSnapHostScroll)
|
||
}
|
||
hostResizeObserver?.disconnect()
|
||
hostResizeObserver = null
|
||
snapScrollHost = null
|
||
}
|
||
|
||
const trySnapToGrid = () => {
|
||
if (isSnapping || !snapScrollHost || !agGridRef.value) return
|
||
|
||
const hostRect = snapScrollHost.getBoundingClientRect()
|
||
const gridRect = agGridRef.value.getBoundingClientRect()
|
||
const offsetTop = gridRect.top - hostRect.top
|
||
const inVisibleBand = gridRect.bottom > hostRect.top + 40 && gridRect.top < hostRect.bottom - 40
|
||
const inSnapRange = offsetTop > -120 && offsetTop < 180
|
||
|
||
if (!inVisibleBand || !inSnapRange) return
|
||
|
||
isSnapping = true
|
||
agGridRef.value.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
if (snapLockTimer) clearTimeout(snapLockTimer)
|
||
snapLockTimer = setTimeout(() => {
|
||
isSnapping = false
|
||
}, 420)
|
||
}
|
||
|
||
function handleSnapHostScroll() {
|
||
if (isSnapping) return
|
||
if (snapTimer) clearTimeout(snapTimer)
|
||
snapTimer = setTimeout(() => {
|
||
trySnapToGrid()
|
||
}, 90)
|
||
}
|
||
|
||
const pickerOpen = ref(false)
|
||
const pickerTempIds = ref<string[]>([])
|
||
const pickerSearch = ref('')
|
||
const pickerListScrollRef = ref<HTMLElement | null>(null)
|
||
const clearConfirmOpen = ref(false)
|
||
const pendingClearServiceId = ref<string | null>(null)
|
||
const dragSelecting = ref(false)
|
||
const dragMoved = ref(false)
|
||
let dragSelectChecked = false
|
||
const dragBaseIds = ref<string[]>([])
|
||
const dragStartPoint = ref({ x: 0, y: 0 })
|
||
const dragCurrentPoint = ref({ x: 0, y: 0 })
|
||
const pickerItemElMap = new Map<string, HTMLElement>()
|
||
let dragTouchedIds = new Set<string>()
|
||
let dragAutoScrollRafId: number | null = null
|
||
|
||
const DRAG_AUTO_SCROLL_EDGE = 36
|
||
const DRAG_AUTO_SCROLL_MAX_STEP = 16
|
||
|
||
const stopDragAutoScroll = () => {
|
||
if (dragAutoScrollRafId !== null) {
|
||
cancelAnimationFrame(dragAutoScrollRafId)
|
||
dragAutoScrollRafId = null
|
||
}
|
||
}
|
||
|
||
const resolveDragAutoScrollStep = () => {
|
||
const scroller = pickerListScrollRef.value
|
||
if (!scroller) return 0
|
||
const rect = scroller.getBoundingClientRect()
|
||
const pointerY = dragCurrentPoint.value.y
|
||
let step = 0
|
||
|
||
if (pointerY > rect.bottom - DRAG_AUTO_SCROLL_EDGE) {
|
||
const ratio = Math.min(2, (pointerY - (rect.bottom - DRAG_AUTO_SCROLL_EDGE)) / DRAG_AUTO_SCROLL_EDGE)
|
||
step = Math.max(2, Math.round(ratio * DRAG_AUTO_SCROLL_MAX_STEP))
|
||
} else if (pointerY < rect.top + DRAG_AUTO_SCROLL_EDGE) {
|
||
const ratio = Math.min(2, ((rect.top + DRAG_AUTO_SCROLL_EDGE) - pointerY) / DRAG_AUTO_SCROLL_EDGE)
|
||
step = -Math.max(2, Math.round(ratio * DRAG_AUTO_SCROLL_MAX_STEP))
|
||
}
|
||
return step
|
||
}
|
||
|
||
const runDragAutoScroll = () => {
|
||
if (!dragSelecting.value) {
|
||
stopDragAutoScroll()
|
||
return
|
||
}
|
||
|
||
const scroller = pickerListScrollRef.value
|
||
if (scroller) {
|
||
const step = resolveDragAutoScrollStep()
|
||
if (step !== 0) {
|
||
const prev = scroller.scrollTop
|
||
scroller.scrollTop += step
|
||
if (scroller.scrollTop !== prev) {
|
||
applyDragSelectionByRect()
|
||
}
|
||
}
|
||
}
|
||
|
||
dragAutoScrollRafId = requestAnimationFrame(runDragAutoScroll)
|
||
}
|
||
|
||
const startDragAutoScroll = () => {
|
||
if (dragAutoScrollRafId !== null) return
|
||
dragAutoScrollRafId = requestAnimationFrame(runDragAutoScroll)
|
||
}
|
||
|
||
const selectedServiceText = computed(() => {
|
||
if (selectedIds.value.length === 0) return ''
|
||
const names = selectedIds.value
|
||
.map(id => serviceById.get(id)?.name || '')
|
||
.filter(Boolean)
|
||
if (names.length <= 2) return names.join('、')
|
||
return `${names.slice(0, 2).join('、')} 等 ${names.length} 项`
|
||
})
|
||
|
||
const pendingClearServiceName = computed(() => {
|
||
if (!pendingClearServiceId.value) return ''
|
||
const row = detailRows.value.find(item => item.id === pendingClearServiceId.value)
|
||
if (row) return `${row.code}${row.name}`
|
||
const dict = serviceById.get(pendingClearServiceId.value)
|
||
if (dict) return `${dict.code}${dict.name}`
|
||
return pendingClearServiceId.value
|
||
})
|
||
|
||
const handleClearConfirmOpenChange = (open: boolean) => {
|
||
clearConfirmOpen.value = open
|
||
}
|
||
|
||
const requestClearRow = (row: DetailRow) => {
|
||
if (isFixedRow(row)) return
|
||
pendingClearServiceId.value = row.id
|
||
clearConfirmOpen.value = true
|
||
}
|
||
|
||
const confirmClearRow = async () => {
|
||
const id = pendingClearServiceId.value
|
||
if (!id) return
|
||
const row = detailRows.value.find(item => item.id === id)
|
||
if (!row || isFixedRow(row)) {
|
||
clearConfirmOpen.value = false
|
||
pendingClearServiceId.value = null
|
||
return
|
||
}
|
||
await clearRowValues(row)
|
||
clearConfirmOpen.value = false
|
||
pendingClearServiceId.value = null
|
||
}
|
||
|
||
const filteredServiceDict = computed(() => {
|
||
const keyword = pickerSearch.value.trim()
|
||
if (!keyword) return serviceDict
|
||
return serviceDict.filter(item => item.code.includes(keyword) || item.name.includes(keyword))
|
||
})
|
||
|
||
const dragRectStyle = computed(() => {
|
||
if (!dragSelecting.value) return {}
|
||
const left = Math.min(dragStartPoint.value.x, dragCurrentPoint.value.x)
|
||
const top = Math.min(dragStartPoint.value.y, dragCurrentPoint.value.y)
|
||
const width = Math.abs(dragCurrentPoint.value.x - dragStartPoint.value.x)
|
||
const height = Math.abs(dragCurrentPoint.value.y - dragStartPoint.value.y)
|
||
return {
|
||
left: `${left}px`,
|
||
top: `${top}px`,
|
||
width: `${width}px`,
|
||
height: `${height}px`
|
||
}
|
||
})
|
||
|
||
const numericParser = (newValue: any): number | null => {
|
||
if (newValue === '' || newValue == null) return null
|
||
const num = Number(newValue)
|
||
return Number.isFinite(num) ? num : null
|
||
}
|
||
|
||
const valueOrZero = (v: number | null | undefined) => (typeof v === 'number' ? v : 0)
|
||
|
||
const getMethodTotalFromRows = (
|
||
rows: DetailRow[],
|
||
field: 'investScale' | 'landScale' | 'workload' | 'hourly'
|
||
) =>
|
||
rows.reduce((sum, row) => {
|
||
if (isFixedRow(row)) return sum
|
||
return addNumbers(sum, valueOrZero(row[field]))
|
||
}, 0)
|
||
|
||
const getMethodTotal = (field: 'investScale' | 'landScale' | 'workload' | 'hourly') =>
|
||
getMethodTotalFromRows(detailRows.value, field)
|
||
|
||
const getFixedRowSubtotal = () =>
|
||
addNumbers(
|
||
getMethodTotal('investScale'),
|
||
getMethodTotal('landScale'),
|
||
getMethodTotal('workload'),
|
||
getMethodTotal('hourly')
|
||
)
|
||
|
||
const getPricingPaneStorageKeys = (serviceId: string) => [
|
||
`tzGMF-${props.contractId}-${serviceId}`,
|
||
`ydGMF-${props.contractId}-${serviceId}`,
|
||
`gzlF-${props.contractId}-${serviceId}`,
|
||
`hourlyPricing-${props.contractId}-${serviceId}`
|
||
]
|
||
|
||
const clearPricingPaneValues = async (serviceId: string) => {
|
||
const keys = getPricingPaneStorageKeys(serviceId)
|
||
const clearIssuedAt = Date.now()
|
||
const skipUntil = clearIssuedAt + PRICING_CLEAR_SKIP_TTL_MS
|
||
const skipToken = `${clearIssuedAt}:${skipUntil}`
|
||
for (const key of keys) {
|
||
sessionStorage.setItem(`${PRICING_CLEAR_SKIP_PREFIX}${key}`, skipToken)
|
||
sessionStorage.setItem(`${PRICING_FORCE_DEFAULT_PREFIX}${key}`, String(skipUntil))
|
||
}
|
||
await Promise.all(keys.map(key => localforage.removeItem(key)))
|
||
pricingPaneReloadStore.markReload(props.contractId, serviceId)
|
||
}
|
||
|
||
const clearRowValues = async (row: DetailRow) => {
|
||
if (isFixedRow(row)) return
|
||
|
||
// 若该服务编辑页已打开,先关闭,避免子页面卸载时把旧数据写回缓? tabStore.removeTab(`zxfw-edit-${props.contractId}-${row.id}`)
|
||
await nextTick()
|
||
await clearPricingPaneValues(row.id)
|
||
// const totals = await getPricingMethodTotalsForService({
|
||
// contractId: props.contractId,
|
||
// serviceId: row.id
|
||
// })
|
||
const clearedRows = detailRows.value.map(item =>
|
||
item.id !== row.id
|
||
? item
|
||
: {
|
||
...item,
|
||
investScale: null,
|
||
landScale: null,
|
||
workload: null,
|
||
hourly: null
|
||
}
|
||
)
|
||
const nextInvestScale = getMethodTotalFromRows(clearedRows, 'investScale')
|
||
const nextLandScale = getMethodTotalFromRows(clearedRows, 'landScale')
|
||
const nextWorkload = getMethodTotalFromRows(clearedRows, 'workload')
|
||
const nextHourly = getMethodTotalFromRows(clearedRows, 'hourly')
|
||
detailRows.value = clearedRows.map(item =>
|
||
isFixedRow(item)
|
||
? {
|
||
...item,
|
||
investScale: nextInvestScale,
|
||
landScale: nextLandScale,
|
||
workload: nextWorkload,
|
||
hourly: nextHourly,
|
||
subtotal: addNumbers(nextInvestScale, nextLandScale, nextWorkload, nextHourly)
|
||
}
|
||
: item
|
||
)
|
||
await saveToIndexedDB()
|
||
}
|
||
|
||
const openEditTab = (row: DetailRow) => {
|
||
tabStore.openTab({
|
||
id: `zxfw-edit-${props.contractId}-${row.id}`,
|
||
title: `服务编辑-${row.code}${row.name}`,
|
||
componentName: 'ZxFwView',
|
||
props: { contractId: props.contractId, contractName: row.name ,serviceId: row.id,fwName:row.code+row.name}
|
||
})
|
||
}
|
||
|
||
const ActionCellRenderer = defineComponent({
|
||
name: 'ActionCellRenderer',
|
||
props: {
|
||
params: {
|
||
type: Object as PropType<ICellRendererParams<DetailRow>>,
|
||
required: true
|
||
}
|
||
},
|
||
setup(props) {
|
||
return () => {
|
||
if (isFixedRow(props.params.data)) return null
|
||
return h('div', { class: 'zxfw-action-wrap' }, [
|
||
h(TooltipRoot, null, {
|
||
default: () => [
|
||
h(TooltipTrigger, { asChild: true }, {
|
||
default: () =>
|
||
h('button', { class: 'zxfw-action-btn', 'data-action': 'edit' }, '✏️')
|
||
}),
|
||
h(TooltipContent, { side: 'top' }, { default: () => '编辑' })
|
||
]
|
||
}),
|
||
h(TooltipRoot, null, {
|
||
default: () => [
|
||
h(TooltipTrigger, { asChild: true }, {
|
||
default: () =>
|
||
h('button', { class: 'zxfw-action-btn', 'data-action': 'clear' }, '🧹')
|
||
}),
|
||
h(TooltipContent, { side: 'top' }, { default: () => '清空' })
|
||
]
|
||
})
|
||
])
|
||
}
|
||
}
|
||
})
|
||
|
||
const columnDefs: ColDef<DetailRow>[] = [
|
||
{ headerName: '编码', field: 'code', minWidth: 50, maxWidth: 100 },
|
||
{ headerName: '名称', field: 'name', minWidth: 250, flex: 3, tooltipField: 'name' },
|
||
{
|
||
headerName: '投资规模法',
|
||
field: 'investScale',
|
||
headerClass: 'ag-right-aligned-header',
|
||
minWidth: 140,
|
||
flex: 2,
|
||
cellClass: 'ag-right-aligned-cell',
|
||
editable: false,
|
||
|
||
valueGetter: params => {
|
||
if (!params.data) return null
|
||
if (isFixedRow(params.data)) return getMethodTotal('investScale')
|
||
return params.data.investScale
|
||
},
|
||
valueParser: params => numericParser(params.newValue),
|
||
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value))
|
||
},
|
||
{
|
||
headerName: '用地规模法',
|
||
field: 'landScale',
|
||
headerClass: 'ag-right-aligned-header',
|
||
minWidth: 140,
|
||
flex: 2,
|
||
cellClass: 'ag-right-aligned-cell',
|
||
editable: false,
|
||
|
||
valueGetter: params => {
|
||
if (!params.data) return null
|
||
if (isFixedRow(params.data)) return getMethodTotal('landScale')
|
||
return params.data.landScale
|
||
},
|
||
valueParser: params => numericParser(params.newValue),
|
||
valueFormatter: params => (params.value == null ? '' : formatThousandsFlexible(params.value, 2))
|
||
},
|
||
{
|
||
headerName: '工作量法',
|
||
field: 'workload',
|
||
headerClass: 'ag-right-aligned-header',
|
||
minWidth: 120,
|
||
flex: 2,
|
||
cellClass: 'ag-right-aligned-cell',
|
||
editable: false,
|
||
|
||
valueGetter: params => {
|
||
if (!params.data) return null
|
||
if (isFixedRow(params.data)) return getMethodTotal('workload')
|
||
return params.data.workload
|
||
},
|
||
// editable: params => !params.node?.rowPinned && !isFixedRow(params.data),
|
||
valueParser: params => numericParser(params.newValue),
|
||
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value))
|
||
},
|
||
{
|
||
headerName: '工时法',
|
||
field: 'hourly',
|
||
headerClass: 'ag-right-aligned-header',
|
||
minWidth: 120,
|
||
flex: 2,
|
||
cellClass: 'ag-right-aligned-cell',
|
||
editable: false,
|
||
|
||
valueGetter: params => {
|
||
if (!params.data) return null
|
||
if (isFixedRow(params.data)) return getMethodTotal('hourly')
|
||
return params.data.hourly
|
||
},
|
||
// editable: params => !params.node?.rowPinned && !isFixedRow(params.data),
|
||
valueParser: params => numericParser(params.newValue),
|
||
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value))
|
||
},
|
||
{
|
||
headerName: '小计',
|
||
field: 'subtotal',
|
||
headerClass: 'ag-right-aligned-header',
|
||
flex: 3,
|
||
|
||
minWidth: 120,
|
||
cellClass: 'ag-right-aligned-cell',
|
||
editable: false,
|
||
valueGetter: params => {
|
||
if (!params.data) return null
|
||
if (isFixedRow(params.data)) return getFixedRowSubtotal()
|
||
return addNumbers(
|
||
valueOrZero(params.data.investScale),
|
||
valueOrZero(params.data.landScale),
|
||
valueOrZero(params.data.workload),
|
||
valueOrZero(params.data.hourly)
|
||
)
|
||
},
|
||
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value))
|
||
},
|
||
{
|
||
headerName: '操作',
|
||
field: 'actions',
|
||
minWidth: 50,
|
||
flex: 1,
|
||
maxWidth: 120,
|
||
editable: false,
|
||
sortable: false,
|
||
filter: false,
|
||
suppressMovable: true,
|
||
cellRenderer: ActionCellRenderer
|
||
}
|
||
]
|
||
|
||
const detailGridOptions: GridOptions<DetailRow> = {
|
||
...gridOptions,
|
||
treeData: false,
|
||
getDataPath: undefined,
|
||
onCellClicked: async params => {
|
||
if (params.colDef.field !== 'actions' || !params.data || isFixedRow(params.data)) return
|
||
const target = params.event?.target as HTMLElement | null
|
||
const btn = target?.closest('button[data-action]') as HTMLButtonElement | null
|
||
const action = btn?.dataset.action
|
||
if (action === 'clear') {
|
||
requestClearRow(params.data)
|
||
return
|
||
}
|
||
if (action === 'edit') {
|
||
openEditTab(params.data)
|
||
}
|
||
}
|
||
}
|
||
|
||
const fillPricingTotalsForSelectedRows = async () => {
|
||
const serviceRows = detailRows.value.filter(row => !isFixedRow(row))
|
||
if (serviceRows.length === 0) return
|
||
|
||
const totalsByServiceId = await getPricingMethodTotalsForServices({
|
||
contractId: props.contractId,
|
||
serviceIds: serviceRows.map(row => row.id)
|
||
})
|
||
|
||
detailRows.value = detailRows.value.map(row => {
|
||
if (isFixedRow(row)) return row
|
||
const totals = totalsByServiceId.get(String(row.id))
|
||
if (!totals) return row
|
||
return {
|
||
...row,
|
||
investScale: totals.investScale,
|
||
landScale: totals.landScale,
|
||
workload: totals.workload,
|
||
hourly: totals.hourly
|
||
}
|
||
})
|
||
}
|
||
|
||
const applySelection = (codes: string[]) => {
|
||
const prevSelectedSet = new Set(selectedIds.value)
|
||
const uniqueIds = Array.from(new Set(codes)).filter(
|
||
id => serviceById.has(id) && id !== fixedBudgetRow.id
|
||
)
|
||
const existingMap = new Map(detailRows.value.map(row => [row.id, row]))
|
||
|
||
const baseRows: DetailRow[] = uniqueIds
|
||
.map(id => {
|
||
const dictItem = serviceById.get(id)
|
||
if (!dictItem) return null
|
||
|
||
const old = existingMap.get(id)
|
||
return {
|
||
id: old?.id || id,
|
||
code: dictItem.code,
|
||
name: dictItem.name,
|
||
investScale: old?.investScale ?? null,
|
||
landScale: old?.landScale ?? null,
|
||
workload: old?.workload ?? null,
|
||
hourly: old?.hourly ?? null
|
||
}
|
||
})
|
||
.filter((row): row is DetailRow => Boolean(row))
|
||
|
||
const orderMap = new Map(serviceDict.map((item, index) => [item.id, index]))
|
||
baseRows.sort((a, b) => (orderMap.get(a.id) || 0) - (orderMap.get(b.id) || 0))
|
||
|
||
const fixedOld = existingMap.get(fixedBudgetRow.id)
|
||
const fixedRow: DetailRow = {
|
||
id: fixedOld?.id || fixedBudgetRow.id,
|
||
code: fixedBudgetRow.code,
|
||
name: fixedBudgetRow.name,
|
||
investScale: typeof fixedOld?.investScale === 'number' ? fixedOld.investScale : null,
|
||
landScale: typeof fixedOld?.landScale === 'number' ? fixedOld.landScale : null,
|
||
workload: typeof fixedOld?.workload === 'number' ? fixedOld.workload : null,
|
||
hourly: typeof fixedOld?.hourly === 'number' ? fixedOld.hourly : null,
|
||
subtotal: null,
|
||
actions: null
|
||
}
|
||
|
||
const removedIds = Array.from(prevSelectedSet).filter(id => !uniqueIds.includes(id))
|
||
for (const id of removedIds) {
|
||
tabStore.removeTab(`zxfw-edit-${props.contractId}-${id}`)
|
||
}
|
||
|
||
selectedIds.value = uniqueIds
|
||
detailRows.value = [...baseRows, fixedRow]
|
||
}
|
||
|
||
const preparePickerOpen = () => {
|
||
pickerTempIds.value = [...selectedIds.value]
|
||
pickerSearch.value = ''
|
||
}
|
||
|
||
const closePicker = () => {
|
||
stopDragSelect()
|
||
pickerOpen.value = false
|
||
}
|
||
|
||
const handlePickerOpenChange = (open: boolean) => {
|
||
if (open) {
|
||
preparePickerOpen()
|
||
} else {
|
||
stopDragSelect()
|
||
}
|
||
pickerOpen.value = open
|
||
}
|
||
|
||
const confirmPicker = async () => {
|
||
applySelection(pickerTempIds.value)
|
||
try {
|
||
// await fillPricingTotalsForSelectedRows()
|
||
await saveToIndexedDB()
|
||
} catch (error) {
|
||
console.error('confirmPicker failed:', error)
|
||
await saveToIndexedDB()
|
||
}
|
||
}
|
||
|
||
const clearPickerSelection = () => {
|
||
pickerTempIds.value = []
|
||
}
|
||
|
||
|
||
|
||
const applyTempChecked = (code: string, checked: boolean) => {
|
||
const exists = pickerTempIds.value.includes(code)
|
||
if (checked && !exists) {
|
||
pickerTempIds.value = [...pickerTempIds.value, code]
|
||
return
|
||
}
|
||
if (!checked && exists) {
|
||
pickerTempIds.value = pickerTempIds.value.filter(item => item !== code)
|
||
}
|
||
}
|
||
|
||
const setPickerItemRef = (
|
||
code: string,
|
||
el: Element | ComponentPublicInstance | null
|
||
) => {
|
||
if (el instanceof HTMLElement) {
|
||
pickerItemElMap.set(code, el)
|
||
return
|
||
}
|
||
pickerItemElMap.delete(code)
|
||
}
|
||
|
||
const isRectIntersect = (
|
||
a: { left: number; right: number; top: number; bottom: number },
|
||
b: { left: number; right: number; top: number; bottom: number }
|
||
) => !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom)
|
||
|
||
const applyDragSelectionByRect = () => {
|
||
const rect = {
|
||
left: Math.min(dragStartPoint.value.x, dragCurrentPoint.value.x),
|
||
right: Math.max(dragStartPoint.value.x, dragCurrentPoint.value.x),
|
||
top: Math.min(dragStartPoint.value.y, dragCurrentPoint.value.y),
|
||
bottom: Math.max(dragStartPoint.value.y, dragCurrentPoint.value.y)
|
||
}
|
||
|
||
const nextSelectedSet = new Set(dragBaseIds.value)
|
||
for (const [code, el] of pickerItemElMap.entries()) {
|
||
const itemRect = el.getBoundingClientRect()
|
||
const hit = isRectIntersect(rect, itemRect)
|
||
if (hit) {
|
||
dragTouchedIds.add(code)
|
||
}
|
||
}
|
||
|
||
for (const code of dragTouchedIds) {
|
||
if (dragSelectChecked) {
|
||
nextSelectedSet.add(code)
|
||
} else {
|
||
nextSelectedSet.delete(code)
|
||
}
|
||
}
|
||
|
||
pickerTempIds.value = serviceDict
|
||
.map(item => item.id)
|
||
.filter(id => nextSelectedSet.has(id))
|
||
}
|
||
|
||
const stopDragSelect = () => {
|
||
dragSelecting.value = false
|
||
dragMoved.value = false
|
||
dragBaseIds.value = []
|
||
dragTouchedIds.clear()
|
||
stopDragAutoScroll()
|
||
window.removeEventListener('mousemove', onDragSelectingMove)
|
||
window.removeEventListener('mouseup', stopDragSelect)
|
||
}
|
||
|
||
const onDragSelectingMove = (event: MouseEvent) => {
|
||
dragCurrentPoint.value = { x: event.clientX, y: event.clientY }
|
||
if (!dragMoved.value) {
|
||
const dx = Math.abs(event.clientX - dragStartPoint.value.x)
|
||
const dy = Math.abs(event.clientY - dragStartPoint.value.y)
|
||
if (dx >= 3 || dy >= 3) {
|
||
dragMoved.value = true
|
||
}
|
||
}
|
||
applyDragSelectionByRect()
|
||
}
|
||
|
||
const startDragSelect = (event: MouseEvent, code: string) => {
|
||
dragSelecting.value = true
|
||
dragMoved.value = false
|
||
dragBaseIds.value = [...pickerTempIds.value]
|
||
dragTouchedIds = new Set([code])
|
||
dragSelectChecked = !pickerTempIds.value.includes(code)
|
||
dragStartPoint.value = { x: event.clientX, y: event.clientY }
|
||
dragCurrentPoint.value = { x: event.clientX, y: event.clientY }
|
||
applyTempChecked(code, dragSelectChecked)
|
||
startDragAutoScroll()
|
||
window.addEventListener('mousemove', onDragSelectingMove)
|
||
window.addEventListener('mouseup', stopDragSelect)
|
||
}
|
||
|
||
const handleDragHover = (_code: string) => {
|
||
if (!dragSelecting.value || !dragMoved.value) return
|
||
applyDragSelectionByRect()
|
||
}
|
||
|
||
const saveToIndexedDB = async () => {
|
||
try {
|
||
const payload: ZxFwState = {
|
||
selectedIds: [...selectedIds.value],
|
||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||
}
|
||
await localforage.setItem(DB_KEY.value, payload)
|
||
} catch (error) {
|
||
console.error('saveToIndexedDB failed:', error)
|
||
}
|
||
}
|
||
|
||
const loadFromIndexedDB = async () => {
|
||
try {
|
||
const data = await localforage.getItem<ZxFwState>(DB_KEY.value)
|
||
if (!data) {
|
||
selectedIds.value = []
|
||
detailRows.value = []
|
||
return
|
||
}
|
||
|
||
const idsFromStorage = data.selectedIds
|
||
|| (data.selectedCodes || []).map(code => serviceIdByCode.get(code)).filter((id): id is string => Boolean(id))
|
||
applySelection(idsFromStorage)
|
||
|
||
const savedRowMap = new Map((data.detailRows || []).map(row => [row.id, row]))
|
||
detailRows.value = detailRows.value.map(row => {
|
||
const old = savedRowMap.get(row.id)
|
||
if (!old) return row
|
||
return {
|
||
...row,
|
||
investScale: typeof old.investScale === 'number' ? old.investScale : null,
|
||
landScale: typeof old.landScale === 'number' ? old.landScale : null,
|
||
workload: typeof old.workload === 'number' ? old.workload : null,
|
||
hourly: typeof old.hourly === 'number' ? old.hourly : null
|
||
}
|
||
})
|
||
|
||
try {
|
||
// await fillPricingTotalsForSelectedRows()
|
||
} catch (error) {
|
||
console.error('fillPricingTotalsForSelectedRows failed:', error)
|
||
}
|
||
} catch (error) {
|
||
console.error('loadFromIndexedDB failed:', error)
|
||
selectedIds.value = []
|
||
detailRows.value = []
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => pricingPaneReloadStore.getReloadVersion(props.contractId, ZXFW_RELOAD_SERVICE_KEY),
|
||
(nextVersion, prevVersion) => {
|
||
if (nextVersion === prevVersion || nextVersion === 0) return
|
||
void loadFromIndexedDB()
|
||
}
|
||
)
|
||
|
||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||
const handleCellValueChanged = () => {
|
||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||
gridPersistTimer = setTimeout(() => {
|
||
void saveToIndexedDB()
|
||
}, 1000)
|
||
}
|
||
|
||
const scrollToGridSection = () => {
|
||
const target = gridSectionRef.value || agGridRef.value
|
||
target?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await loadFromIndexedDB()
|
||
bindSnapScrollHost()
|
||
requestAnimationFrame(() => {
|
||
updateGridCardHeight()
|
||
})
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
unbindSnapScrollHost()
|
||
stopDragSelect()
|
||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||
if (snapTimer) clearTimeout(snapTimer)
|
||
if (snapLockTimer) clearTimeout(snapLockTimer)
|
||
void saveToIndexedDB()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<TooltipProvider>
|
||
<div ref="rootRef" class="space-y-6">
|
||
<DialogRoot v-model:open="pickerOpen" @update:open="handlePickerOpenChange">
|
||
<div class="rounded-lg border bg-card p-4 shadow-sm shrink-0">
|
||
<div class="mb-2 flex items-center justify-between gap-3">
|
||
<label class="block text-sm font-medium text-foreground">选择服务</label>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<input :value="selectedServiceText" readonly placeholder="请点击右侧“浏览”选择服务"
|
||
class="h-10 w-full rounded-md border bg-background px-3 text-sm text-foreground outline-none" />
|
||
<TooltipRoot>
|
||
<TooltipTrigger as-child>
|
||
<DialogTrigger as-child>
|
||
<button type="button"
|
||
class="inline-flex h-10 w-10 items-center justify-center rounded-md border text-sm hover:bg-accent cursor-pointer">
|
||
<Search class="h-4 w-4" />
|
||
</button>
|
||
</DialogTrigger>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top">浏览服务词典</TooltipContent>
|
||
</TooltipRoot>
|
||
</div>
|
||
</div>
|
||
<DialogPortal>
|
||
<DialogOverlay class="fixed inset-0 z-50 bg-black/40" />
|
||
<DialogContent
|
||
class="fixed left-1/2 top-1/2 z-[60] w-[96vw] max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background shadow-xl p-0">
|
||
<DialogTitle class="sr-only">选择服务词典</DialogTitle>
|
||
<DialogDescription class="sr-only">浏览并选择服务词典</DialogDescription>
|
||
<div class="flex items-center justify-between border-b px-5 py-4">
|
||
<h4 class="text-base font-semibold">选择服务词典</h4>
|
||
<DialogClose as-child>
|
||
<button type="button"
|
||
class="inline-flex cursor-pointer h-8 items-center rounded-md border px-3 text-sm hover:bg-accent">
|
||
关闭
|
||
</button>
|
||
</DialogClose>
|
||
</div>
|
||
|
||
<div ref="pickerListScrollRef" class="max-h-[420px] overflow-auto px-5 py-4">
|
||
<div class="mb-3">
|
||
<input v-model="pickerSearch" type="text" placeholder="输入编码或名称过滤"
|
||
class="h-9 w-full rounded-md border bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring" />
|
||
</div>
|
||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
|
||
<label v-for="item in filteredServiceDict" :key="item.id" :ref="el => setPickerItemRef(item.id, el)"
|
||
:class="[
|
||
'picker-item-clickable flex select-none items-center gap-2 rounded-md border px-3 py-2 text-sm',
|
||
dragMoved ? 'cursor-default picker-item-dragging' : 'cursor-pointer',
|
||
pickerTempIds.includes(item.id) ? 'picker-item-selected' : '',
|
||
dragSelecting && pickerTempIds.includes(item.id) ? 'picker-item-selected-drag' : ''
|
||
]"
|
||
@mousedown.prevent="startDragSelect($event, item.id)" @mouseenter="handleDragHover(item.id)"
|
||
@click.prevent>
|
||
<input type="checkbox" :checked="pickerTempIds.includes(item.id)" class="pointer-events-none" />
|
||
<span class="text-muted-foreground">{{ item.code }}</span>
|
||
<span>{{ item.name }}</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center justify-end gap-2 border-t px-5 py-3">
|
||
<Button type="button" variant="outline" @click="clearPickerSelection">
|
||
清空
|
||
</Button>
|
||
<DialogClose as-child>
|
||
<Button type="button" @click="confirmPicker">
|
||
确认选择
|
||
</Button>
|
||
</DialogClose>
|
||
</div>
|
||
</DialogContent>
|
||
<div v-if="dragSelecting"
|
||
class="pointer-events-none fixed z-[70] rounded-sm border border-sky-500/90 bg-sky-400/10"
|
||
:style="dragRectStyle" />
|
||
</DialogPortal>
|
||
</DialogRoot>
|
||
|
||
<div
|
||
ref="gridSectionRef"
|
||
class="rounded-lg border bg-card xmMx scroll-mt-3" :style="{ height: `${agGridHeight}px` }"
|
||
>
|
||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||
<h3
|
||
class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary"
|
||
@click="scrollToGridSection"
|
||
>
|
||
咨询服务明细
|
||
</h3>
|
||
<div class="text-xs text-muted-foreground">按服务词典生成</div>
|
||
</div>
|
||
|
||
<div ref="agGridRef" class="ag-theme-quartz w-full h-full" >
|
||
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :columnDefs="columnDefs" :gridOptions="detailGridOptions"
|
||
:theme="myTheme" @cell-value-changed="handleCellValueChanged" :enableClipboard="true"
|
||
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :undoRedoCellEditing="true"
|
||
:undoRedoCellEditingLimit="20" />
|
||
</div>
|
||
</div>
|
||
|
||
<AlertDialogRoot :open="clearConfirmOpen" @update:open="handleClearConfirmOpenChange">
|
||
<AlertDialogPortal>
|
||
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
|
||
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
|
||
<AlertDialogTitle class="text-base font-semibold">确认清空服务数据</AlertDialogTitle>
|
||
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
|
||
将清空“{{ pendingClearServiceName }}”的计价数据是否继续?
|
||
</AlertDialogDescription>
|
||
<div class="mt-4 flex items-center justify-end gap-2">
|
||
<AlertDialogCancel as-child>
|
||
<Button variant="outline">取消</Button>
|
||
</AlertDialogCancel>
|
||
<AlertDialogAction as-child>
|
||
<Button variant="destructive" @click="confirmClearRow">确认清空</Button>
|
||
</AlertDialogAction>
|
||
</div>
|
||
</AlertDialogContent>
|
||
</AlertDialogPortal>
|
||
</AlertDialogRoot>
|
||
|
||
</div>
|
||
</TooltipProvider>
|
||
</template>
|