2026-03-02 18:12:32 +08:00

972 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>