2026-03-17 16:24:25 +08:00

1216 lines
40 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, onActivated, 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 { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { addNumbers } from '@/lib/decimal'
import { parseNumberOrNull } from '@/lib/number'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import {
ensurePricingMethodDetailRowsForServices,
getPricingMethodTotalsForService,
getPricingMethodTotalsForServices,
type PricingMethodTotals
} from '@/lib/pricingMethodTotals'
import { Pencil, Eraser, Trash2 } from 'lucide-vue-next'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
} from 'reka-ui'
import { Button } from '@/components/ui/button'
import { TooltipProvider } from '@/components/ui/tooltip'
import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql'
import { useTabStore } from '@/pinia/tab'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useKvStore } from '@/pinia/kv'
import ServiceCheckboxSelector from '@/components/shared/ServiceCheckboxSelector.vue'
interface ServiceItem {
id: string
code: string
name: string
type: ServiceMethodType
}
interface DetailRow {
id: string
code: string
name: string
process: number | null
investScale: number | null
landScale: number | null
workload: number | null
hourly: number | null
subtotal?: number | null
actions?: unknown
}
interface ZxFwViewState {
selectedIds?: string[]
selectedCodes?: string[]
detailRows: DetailRow[]
}
interface XmBaseInfoState {
projectIndustry?: string
}
interface ServiceMethodType {
scale?: boolean | null
onlyCostScale?: boolean | null
amount?: boolean | null
workDay?: boolean | null
}
const props = defineProps<{
contractId: string
contractName?: string
projectInfoKey?: string
}>()
const tabStore = useTabStore()
const zxFwPricingStore = useZxFwPricingStore()
const kvStore = useKvStore()
const PROJECT_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const PRICING_CLEAR_SKIP_TTL_MS = 5000
const PRICING_TOTALS_OPTIONS = { excludeInvestmentCostAndAreaRows: true } as const
const projectIndustry = ref('')
type ServiceListItem = {
code?: string
ref?: string
name: string
defCoe: number | null
isRoad?: boolean
isRailway?: boolean
isWaterway?: boolean
scale?: boolean | null
onlyCostScale?: boolean | null
amount?: boolean | null
workDay?: boolean | null
}
const toNullableBoolean = (value: unknown): boolean | null =>
typeof value === 'boolean' ? value : null
const resolveMethodEnabled = (value: boolean | null | undefined, fallback = true) =>
typeof value === 'boolean' ? value : fallback
const defaultServiceMethodType = {
scale: true,
onlyCostScale: false,
amount: true,
workDay: true
}
const serviceDict = computed<ServiceItem[]>(() => {
const industry = projectIndustry.value
if (!industry) return []
const filteredEntries = getServiceDictEntries()
.map(({ id, item }) => ({ id, item: item as ServiceListItem }))
.filter(({ item }) => {
const itemCode = item?.code || item?.ref
return Boolean(itemCode && item?.name) && item.defCoe !== null && isIndustryEnabledByType(item, getIndustryTypeValue(industry))
})
return filteredEntries.map(({ id, item }) => ({
id,
code: item.code || item.ref || '',
name: item.name,
type: {
scale: toNullableBoolean(item.scale),
onlyCostScale: toNullableBoolean(item.onlyCostScale),
amount: toNullableBoolean(item.amount),
workDay: toNullableBoolean(item.workDay)
}
}))
})
const serviceById = computed(() => new Map(serviceDict.value.map(item => [item.id, item])))
const serviceIdByCode = computed(() => new Map(serviceDict.value.map(item => [item.code, item.id])))
const serviceIdSignature = computed(() => serviceDict.value.map(item => item.id).join('|'))
const fixedBudgetRow: Pick<DetailRow, 'id' | 'code' | 'name'> = { id: 'fixed-budget-c', code: '', name: '小计' }
const isFixedRow = (row?: DetailRow | null) => row?.id === fixedBudgetRow.id
const selectedIds = computed<string[]>(() => zxFwPricingStore.contracts[props.contractId]?.selectedIds || [])
const detailRows = computed<DetailRow[]>(() =>
(zxFwPricingStore.contracts[props.contractId]?.detailRows || []).map(row => ({
id: String(row.id || ''),
code: row.code || '',
name: row.name || '',
process: String(row.id || '') === fixedBudgetRow.id ? null : (Number(row.process) === 1 ? 1 : 0),
investScale: typeof row.investScale === 'number' ? row.investScale : null,
landScale: typeof row.landScale === 'number' ? row.landScale : null,
workload: typeof row.workload === 'number' ? row.workload : null,
hourly: typeof row.hourly === 'number' ? row.hourly : null,
subtotal: typeof row.subtotal === 'number' ? row.subtotal : null,
actions: row.actions
}))
)
const getCurrentContractState = (): ZxFwViewState => {
const current = zxFwPricingStore.getContractState(props.contractId)
if (current) {
return {
selectedIds: Array.isArray(current.selectedIds) ? [...current.selectedIds] : [],
selectedCodes: Array.isArray(current.selectedCodes) ? [...current.selectedCodes] : [],
detailRows: (current.detailRows || []).map(row => ({
id: String(row.id || ''),
code: row.code || '',
name: row.name || '',
process: String(row.id || '') === fixedBudgetRow.id ? null : (Number(row.process) === 1 ? 1 : 0),
investScale: typeof row.investScale === 'number' ? row.investScale : null,
landScale: typeof row.landScale === 'number' ? row.landScale : null,
workload: typeof row.workload === 'number' ? row.workload : null,
hourly: typeof row.hourly === 'number' ? row.hourly : null,
subtotal: typeof row.subtotal === 'number' ? row.subtotal : null,
actions: row.actions
}))
}
}
return {
selectedIds: [],
detailRows: []
}
}
const setCurrentContractState = async (nextState: ZxFwViewState) => {
await zxFwPricingStore.setContractState(props.contractId, nextState)
}
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 deleteConfirmOpen = ref(false)
const pendingDeleteServiceId = 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.value.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.value.get(pendingClearServiceId.value)
if (dict) return `${dict.code}${dict.name}`
return pendingClearServiceId.value
})
const pendingDeleteServiceName = computed(() => {
if (!pendingDeleteServiceId.value) return ''
const row = detailRows.value.find(item => item.id === pendingDeleteServiceId.value)
if (row) return `${row.code}${row.name}`
const dict = serviceById.value.get(pendingDeleteServiceId.value)
if (dict) return `${dict.code}${dict.name}`
return pendingDeleteServiceId.value
})
const handleClearConfirmOpenChange = (open: boolean) => {
clearConfirmOpen.value = open
}
const handleDeleteConfirmOpenChange = (open: boolean) => {
deleteConfirmOpen.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 requestDeleteRow = (row: DetailRow) => {
if (isFixedRow(row)) return
pendingDeleteServiceId.value = row.id
deleteConfirmOpen.value = true
}
const confirmDeleteRow = async () => {
const id = pendingDeleteServiceId.value
if (!id) return
const row = detailRows.value.find(item => item.id === id)
if (!row || isFixedRow(row)) {
deleteConfirmOpen.value = false
pendingDeleteServiceId.value = null
return
}
await removeRow(row)
deleteConfirmOpen.value = false
pendingDeleteServiceId.value = null
}
const filteredServiceDict = computed(() => {
const keyword = pickerSearch.value.trim()
if (!keyword) return serviceDict.value
return serviceDict.value.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 => {
return parseNumberOrNull(newValue, { precision: 3 })
}
const isFiniteNumberValue = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value)
const sumNullableNumbers = (values: Array<number | null | undefined>): number | null => {
const validValues = values.filter(isFiniteNumberValue)
if (validValues.length === 0) return null
return addNumbers(...validValues)
}
const getServiceMethodTypeById = (serviceId: string) => {
const type = serviceById.value.get(serviceId)?.type
const scale = resolveMethodEnabled(type?.scale, defaultServiceMethodType.scale)
const onlyCostScale = resolveMethodEnabled(type?.onlyCostScale, defaultServiceMethodType.onlyCostScale)
const amount = resolveMethodEnabled(type?.amount, defaultServiceMethodType.amount)
const workDay = resolveMethodEnabled(type?.workDay, defaultServiceMethodType.workDay)
return { scale, onlyCostScale, amount, workDay }
}
const sanitizePricingTotalsByService = (serviceId: string, totals: PricingMethodTotals): PricingMethodTotals => {
const methodType = getServiceMethodTypeById(serviceId)
const isScaleEnabled = methodType.scale
const isLandScaleEnabled = isScaleEnabled && !methodType.onlyCostScale
return {
investScale: isScaleEnabled ? totals.investScale : null,
landScale: isLandScaleEnabled ? totals.landScale : null,
workload: methodType.amount ? totals.workload : null,
hourly: methodType.workDay ? totals.hourly : null
}
}
const sanitizePricingFieldsByService = (
serviceId: string,
values: Pick<DetailRow, 'investScale' | 'landScale' | 'workload' | 'hourly'>
) => {
const sanitized = sanitizePricingTotalsByService(serviceId, values)
return {
investScale: sanitized.investScale,
landScale: sanitized.landScale,
workload: sanitized.workload,
hourly: sanitized.hourly
}
}
const getMethodTotalFromRows = (
rows: DetailRow[],
field: 'investScale' | 'landScale' | 'workload' | 'hourly'
) => sumNullableNumbers(
rows
.filter(row => !isFixedRow(row))
.map(row => row[field])
)
const getMethodTotal = (field: 'investScale' | 'landScale' | 'workload' | 'hourly') =>
getMethodTotalFromRows(detailRows.value, field)
const getFixedRowSubtotal = () =>
sumNullableNumbers([
getMethodTotal('investScale'),
getMethodTotal('landScale'),
getMethodTotal('workload'),
getMethodTotal('hourly')
])
const getPricingPaneStorageKeys = (serviceId: string) =>
zxFwPricingStore.getServicePricingStorageKeys(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))
}
zxFwPricingStore.removeAllServicePricingMethodStates(props.contractId, serviceId)
// Reset后会立刻有逻辑读取IndexedDB计算默认值这里强制同步删除持久层避免读到旧数据。
await Promise.all(keys.map(key => kvStore.removeItem(key)))
}
const clearRowValues = async (row: DetailRow) => {
if (isFixedRow(row)) return
// 若该服务编辑页已打开,先关闭,避免子页面卸载时把旧数据写回。
tabStore.removeTab(`zxfw-edit-${props.contractId}-${row.id}`)
await nextTick()
await clearPricingPaneValues(row.id)
await ensurePricingMethodDetailRowsForServices({
contractId: props.contractId,
serviceIds: [row.id],
options: PRICING_TOTALS_OPTIONS
})
const totals = await getPricingMethodTotalsForService({
contractId: props.contractId,
serviceId: row.id,
options: PRICING_TOTALS_OPTIONS
})
const sanitizedTotals = sanitizePricingTotalsByService(row.id, totals)
const currentState = getCurrentContractState()
const clearedRows = currentState.detailRows.map(item =>
item.id !== row.id
? item
: {
...item,
investScale: sanitizedTotals.investScale,
landScale: sanitizedTotals.landScale,
workload: sanitizedTotals.workload,
hourly: sanitizedTotals.hourly
}
)
const nextInvestScale = getMethodTotalFromRows(clearedRows, 'investScale')
const nextLandScale = getMethodTotalFromRows(clearedRows, 'landScale')
const nextWorkload = getMethodTotalFromRows(clearedRows, 'workload')
const nextHourly = getMethodTotalFromRows(clearedRows, 'hourly')
const nextRows = clearedRows.map(item =>
isFixedRow(item)
? {
...item,
investScale: nextInvestScale,
landScale: nextLandScale,
workload: nextWorkload,
hourly: nextHourly,
subtotal: sumNullableNumbers([nextInvestScale, nextLandScale, nextWorkload, nextHourly])
}
: item
)
await setCurrentContractState({
...currentState,
detailRows: nextRows
})
}
const openEditTab = (row: DetailRow) => {
const serviceType = serviceById.value.get(row.id)?.type
tabStore.openTab({
id: `zxfw-edit-${props.contractId}-${row.id}`,
title: `服务编辑-${row.code}${row.name}`,
componentName: 'ZxFwView',
props: {
contractId: props.contractId,
contractName: props.contractName || '',
serviceId: row.id,
fwName: row.code + row.name,
type: serviceType ? { ...serviceType } : undefined,
projectInfoKey: props.projectInfoKey
}
})
}
const removeRow = async (row: DetailRow) => {
if (isFixedRow(row)) return
const nextIds = selectedIds.value.filter(id => id !== row.id)
await handleServiceSelectionChange(nextIds)
}
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('div', { class: 'zxfw-action-group' }, [
h('button', { class: 'zxfw-action-btn', 'data-action': 'edit', type: 'button' }, [
h(Pencil, { size: 13, 'aria-hidden': 'true' }),
h('span', '编辑')
]),
h('button', { class: 'zxfw-action-btn', 'data-action': 'clear', type: 'button' }, [
h(Eraser, { size: 13, 'aria-hidden': 'true' }),
h('span', '恢复默认')
]),
h('button', { class: 'zxfw-action-btn zxfw-action-btn--danger', 'data-action': 'delete', type: 'button' }, [
h(Trash2, { size: 13, 'aria-hidden': 'true' }),
h('span', '删除')
])
])
])
}
}
})
const ProcessCellRenderer = defineComponent({
name: 'ProcessCellRenderer',
props: {
params: {
type: Object as PropType<ICellRendererParams<DetailRow>>,
required: true
}
},
setup(props) {
return () => {
const row = props.params.data
if (!row || isFixedRow(row)) return null
const processValue = row.process === 1 ? 1 : 0
const onSelect = (event: Event, value: 0 | 1) => {
event.stopPropagation()
void props.params.context?.onSetProcess?.(row.id, value)
}
const radioName = `process-${row.id}`
return h('div', { class: 'flex items-center justify-center gap-4 w-full text-sm' }, [
h('label', { class: 'inline-flex items-center gap-1.5 cursor-pointer' }, [
h('input', {
type: 'radio',
name: radioName,
checked: processValue === 0,
class: 'cursor-pointer h-4 w-4',
onClick: (event: Event) => event.stopPropagation(),
onChange: (event: Event) => onSelect(event, 0)
}),
h('span', '编制')
]),
h('label', { class: 'inline-flex items-center gap-1.5 cursor-pointer' }, [
h('input', {
type: 'radio',
name: radioName,
checked: processValue === 1,
class: 'cursor-pointer h-4 w-4',
onClick: (event: Event) => event.stopPropagation(),
onChange: (event: Event) => onSelect(event, 1)
}),
h('span', '审核')
])
])
}
}
})
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '编码',
field: 'code',
minWidth: 50,
maxWidth: 100,
valueGetter: params => {
if (!params.data) return ''
if (isFixedRow(params.data)) return '小计'
return params.data.code
},
colSpan: params => (params.data && isFixedRow(params.data) ? 2 : 1)
},
{
headerName: '名称',
field: 'name',
minWidth: 250,
flex: 3,
tooltipField: 'name',
valueGetter: params => {
if (!params.data) return ''
if (isFixedRow(params.data)) return ''
return params.data.name
}
},
{
headerName: '工作环节',
field: 'process',
headerClass: 'ag-center-header zxfw-process-header',
minWidth: 170,
maxWidth: 220,
flex: 1.2,
editable: false,
sortable: false,
filter: false,
cellStyle: {
textAlign: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
},
valueGetter: params => {
if (!params.data || isFixedRow(params.data)) return null
return params.data.process === 1 ? 1 : 0
},
cellRenderer: ProcessCellRenderer
},
{
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 ? '' : formatThousandsFlexible(params.value, 3))
},
{
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, 3))
},
{
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 ? '' : formatThousandsFlexible(params.value, 3))
},
{
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 ? '' : formatThousandsFlexible(params.value, 3))
},
{
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 sumNullableNumbers([
params.data.investScale,
params.data.landScale,
params.data.workload,
params.data.hourly
])
},
valueFormatter: params => (params.value == null ? '' : formatThousandsFlexible(params.value, 3))
},
{
headerName: '操作',
field: 'actions',
minWidth: 220,
flex: 2,
maxWidth: 260,
editable: false,
sortable: false,
filter: false,
suppressMovable: true,
cellRenderer: ActionCellRenderer
}
]
const detailGridOptions: GridOptions<DetailRow> = {
...gridOptions,
treeData: false,
getDataPath: undefined,
context: {
onSetProcess: async (rowId: string, value: 0 | 1) => {
const currentState = getCurrentContractState()
let changed = false
const nextRows = currentState.detailRows.map(row => {
if (isFixedRow(row) || String(row.id) !== String(rowId)) return row
const nextValue = value === 1 ? 1 : 0
if ((row.process === 1 ? 1 : 0) === nextValue) return row
changed = true
return {
...row,
process: nextValue
}
})
if (!changed) return
await setCurrentContractState({
...currentState,
detailRows: nextRows
})
}
},
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)
return
}
if (action === 'delete') {
requestDeleteRow(params.data)
}
}
}
const applyFixedRowTotals = (rows: DetailRow[]) => {
const nextInvestScale = getMethodTotalFromRows(rows, 'investScale')
const nextLandScale = getMethodTotalFromRows(rows, 'landScale')
const nextWorkload = getMethodTotalFromRows(rows, 'workload')
const nextHourly = getMethodTotalFromRows(rows, 'hourly')
return rows.map(row =>
isFixedRow(row)
? {
...row,
investScale: nextInvestScale,
landScale: nextLandScale,
workload: nextWorkload,
hourly: nextHourly,
subtotal: sumNullableNumbers([nextInvestScale, nextLandScale, nextWorkload, nextHourly])
}
: row
)
}
const getSelectedServiceIdsWithoutFixed = () =>
detailRows.value
.filter(row => !isFixedRow(row))
.map(row => String(row.id))
const ensurePricingDetailRowsForCurrentSelection = async () => {
const serviceIds = getSelectedServiceIdsWithoutFixed()
if (serviceIds.length === 0) return
await ensurePricingMethodDetailRowsForServices({
contractId: props.contractId,
serviceIds,
options: PRICING_TOTALS_OPTIONS
})
}
const fillPricingTotalsForServiceIds = async (serviceIds: string[]) => {
const currentState = getCurrentContractState()
const targetIds = Array.from(
new Set(
serviceIds.filter(id =>
currentState.detailRows.some(row => !isFixedRow(row) && String(row.id) === String(id))
)
)
)
if (targetIds.length === 0) {
await setCurrentContractState({
...currentState,
detailRows: applyFixedRowTotals(currentState.detailRows)
})
return
}
await ensurePricingMethodDetailRowsForServices({
contractId: props.contractId,
serviceIds: targetIds,
options: PRICING_TOTALS_OPTIONS
})
const totalsByServiceId = await getPricingMethodTotalsForServices({
contractId: props.contractId,
serviceIds: targetIds,
options: PRICING_TOTALS_OPTIONS
})
const targetSet = new Set(targetIds.map(id => String(id)))
const nextRows = currentState.detailRows.map(row => {
if (isFixedRow(row) || !targetSet.has(String(row.id))) return row
const totalsRaw = totalsByServiceId.get(String(row.id))
const totals = totalsRaw ? sanitizePricingTotalsByService(String(row.id), totalsRaw) : null
if (!totals) return row
return {
...row,
investScale: totals.investScale,
landScale: totals.landScale,
workload: totals.workload,
hourly: totals.hourly
}
})
await setCurrentContractState({
...currentState,
detailRows: applyFixedRowTotals(nextRows)
})
}
const applySelection = async (codes: string[]) => {
const currentState = getCurrentContractState()
const prevSelectedSet = new Set(currentState.selectedIds || [])
const uniqueIds = Array.from(new Set(codes)).filter(
id => serviceById.value.has(id) && id !== fixedBudgetRow.id
)
const existingMap = new Map(currentState.detailRows.map(row => [row.id, row]))
const baseRows: DetailRow[] = uniqueIds
.map<DetailRow | null>(id => {
const dictItem = serviceById.value.get(id)
if (!dictItem) return null
const old = existingMap.get(id)
const nextValues = sanitizePricingFieldsByService(id, {
investScale: old?.investScale ?? null,
landScale: old?.landScale ?? null,
workload: old?.workload ?? null,
hourly: old?.hourly ?? null
})
return {
id: old?.id || id,
code: dictItem.code,
name: dictItem.name,
process: old?.process === 1 ? 1 : 0,
investScale: nextValues.investScale,
landScale: nextValues.landScale,
workload: nextValues.workload,
hourly: nextValues.hourly
}
})
.filter((row): row is DetailRow => row !== null)
const orderMap = new Map(serviceDict.value.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,
process: null,
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}`)
}
await setCurrentContractState({
...currentState,
selectedIds: uniqueIds,
detailRows: applyFixedRowTotals([...baseRows, fixedRow])
})
}
const handleServiceSelectionChange = async (ids: string[]) => {
const prevIds = [...selectedIds.value]
await applySelection(ids)
const nextSelectedIds = getCurrentContractState().selectedIds || []
const nextSelectedSet = new Set(nextSelectedIds)
const addedIds = nextSelectedIds.filter(id => !prevIds.includes(id) && nextSelectedSet.has(id))
await fillPricingTotalsForServiceIds(addedIds)
await ensurePricingDetailRowsForCurrentSelection()
}
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 () => {
await applySelection(pickerTempIds.value)
}
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
.value
.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 initializeContractState = async () => {
try {
await zxFwPricingStore.loadContract(props.contractId)
const data = zxFwPricingStore.getContractState(props.contractId)
const idsFromStorage = data?.selectedIds
|| (data?.selectedCodes || []).map(code => serviceIdByCode.value.get(code)).filter((id): id is string => Boolean(id))
await applySelection(idsFromStorage || [])
await ensurePricingDetailRowsForCurrentSelection()
} catch (error) {
console.error('initializeContractState failed:', error)
await setCurrentContractState({
selectedIds: [],
detailRows: applyFixedRowTotals([{
id: fixedBudgetRow.id,
code: fixedBudgetRow.code,
name: fixedBudgetRow.name,
process: null,
investScale: null,
landScale: null,
workload: null,
hourly: null,
subtotal: null,
actions: null
}])
})
}
}
const loadProjectIndustry = async () => {
try {
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY.value)
projectIndustry.value =
typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
} catch (error) {
console.error('loadProjectIndustry failed:', error)
projectIndustry.value = ''
}
}
watch(serviceIdSignature, () => {
const availableIds = new Set(serviceDict.value.map(item => item.id))
const nextSelectedIds = selectedIds.value.filter(id => availableIds.has(id))
if (nextSelectedIds.length !== selectedIds.value.length) {
void applySelection(nextSelectedIds)
}
})
const handleCellValueChanged = () => {}
onMounted(async () => {
await loadProjectIndustry()
await initializeContractState()
})
onActivated(async () => {
await loadProjectIndustry()
await initializeContractState()
})
onBeforeUnmount(() => {
stopDragSelect()
})
</script>
<template>
<TooltipProvider>
<div class="h-full min-h-0 flex flex-col gap-2">
<!-- 浏览框选择服务实现已抽离并停用改为直接复选框勾选 -->
<!-- <DialogRoot v-model:open="pickerOpen" @update:open="handlePickerOpenChange" /> -->
<ServiceCheckboxSelector :services="serviceDict" :model-value="selectedIds"
@update:model-value="handleServiceSelectionChange" />
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col overflow-hidden">
<div class="flex items-center justify-between border-b px-3 py-2">
<h3 class="text-xs font-semibold text-foreground leading-none">
咨询服务明细
</h3>
<div class="text-[11px] text-muted-foreground leading-none">按服务词典生成</div>
</div>
<div :class="agGridWrapClass">
<AgGridVue :style="agGridStyle" :rowData="detailRows" :columnDefs="columnDefs"
:gridOptions="detailGridOptions" :theme="myTheme" @cell-value-changed="handleCellValueChanged"
:enableClipboard="true" :localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="30"
: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>
<AlertDialogRoot :open="deleteConfirmOpen" @update:open="handleDeleteConfirmOpenChange">
<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">
将逻辑删除{{ pendingDeleteServiceName }}已填写的数据不会清空重新勾选后会恢复是否继续
</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="confirmDeleteRow">确认删除</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
</TooltipProvider>
</template>
<style scoped>
:deep(.zxfw-process-header .ag-header-cell-label) {
justify-content: center;
}
:deep(.zxfw-process-header .ag-header-cell-text) {
text-align: center;
}
</style>