2026-03-19 15:57:28 +08:00

1182 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, shallowRef, watch } from 'vue'
import type { PropType } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
import type { FirstDataRenderedEvent } from 'ag-grid-community'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { addNumbers, roundTo } from '@/lib/decimal'
import { parseNumberOrNull } from '@/lib/number'
import { formatThousands, 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
finalFee?: 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
}
/** 仅保留明确的 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
/**
* 统一把 store/raw row 转成页面使用的 DetailRow 结构。
* 被 detailRows 计算属性和 getCurrentContractState 复用,避免重复映射逻辑。
*/
const normalizeDetailRow = (row: Partial<DetailRow>): DetailRow => ({
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,
finalFee: typeof row.finalFee === 'number' ? row.finalFee : null,
actions: row.actions
})
const selectedIds = computed<string[]>(() => zxFwPricingStore.contracts[props.contractId]?.selectedIds || [])
const detailRows = computed<DetailRow[]>(() =>
(zxFwPricingStore.contracts[props.contractId]?.detailRows || []).map(row => normalizeDetailRow(row as Partial<DetailRow>))
)
/**
* 获取当前合同状态的“可变副本”。
* 所有写回前都先读这里,避免直接修改 store 引用对象。
*/
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 => normalizeDetailRow(row as Partial<DetailRow>))
}
}
return {
selectedIds: [],
detailRows: []
}
}
/** 统一写回当前合同状态到 store。 */
const setCurrentContractState = async (nextState: ZxFwViewState) => {
await zxFwPricingStore.setContractState(props.contractId, nextState)
}
const gridApi = shallowRef<GridApi<DetailRow> | null>(null)
/** 记录 grid api供编辑后局部刷新固定行使用。 */
const onGridReady = (event: GridReadyEvent<DetailRow>) => {
gridApi.value = event.api
scheduleAutoRowHeights()
}
let autoHeightSyncTimer: ReturnType<typeof setTimeout> | null = null
const syncAutoRowHeights = async () => {
await nextTick()
const api = gridApi.value
if (!api) return
api.resetRowHeights()
api.onRowHeightChanged()
}
const scheduleAutoRowHeights = () => {
if (autoHeightSyncTimer) clearTimeout(autoHeightSyncTimer)
autoHeightSyncTimer = setTimeout(() => {
autoHeightSyncTimer = null
void syncAutoRowHeights()
}, 0)
}
const onFirstDataRendered = (_event: FirstDataRenderedEvent<DetailRow>) => {
scheduleAutoRowHeights()
}
const clearConfirmOpen = ref(false)
const pendingClearServiceId = ref<string | null>(null)
const deleteConfirmOpen = ref(false)
const pendingDeleteServiceId = ref<string | null>(null)
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
}
/** 表格数字输入解析器(保留 3 位小数)。 */
const numericParser = (newValue: any): number | null => {
return parseNumberOrNull(newValue, { precision: 3 })
}
/** 类型守卫:有限数字。 */
const isFiniteNumberValue = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value)
/** 可空数字求和:全为空返回 null。 */
const sumNullableNumbers = (values: Array<number | null | undefined>): number | null => {
const validValues = values.filter(isFiniteNumberValue)
if (validValues.length === 0) return null
return addNumbers(...validValues)
}
const isSameNullableNumber = (a: number | null | undefined, b: number | null | undefined, precision = 3) => {
if (a == null && b == null) return true
if (a == null || b == null) return false
return roundTo(a, precision) === roundTo(b, precision)
}
/**
* 读取服务词典中的计价方法开关(规模/工作量/工时),并应用默认值。
* 该方法被 sanitizePricingTotalsByService 复用。
*/
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 }
}
/**
* 按服务方法开关过滤计价合计。
* 被 clearRowValues/fillPricingTotalsForServiceIds 等入口复用。
*/
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
}
}
/**
* 对 4 个计价字段做开关过滤(例如 onlyCostScale 会禁用用地规模法)。
* 主要在“选择服务、加载历史值”时复用。
*/
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
}
}
type PricingMethodField = 'investScale' | 'landScale' | 'workload' | 'hourly'
/**
* 计算某列在“非固定行”上的合计。
* 被 getMethodTotal 和固定行汇总逻辑复用。
*/
const getMethodTotalFromRows = (
rows: DetailRow[],
field: PricingMethodField
) => sumNullableNumbers(
rows
.filter(row => !isFixedRow(row))
.map(row => row[field])
)
/** 当前页面行数据中某计价列总计(仅非固定行)。 */
const getMethodTotal = (field: PricingMethodField) =>
getMethodTotalFromRows(detailRows.value, field)
/**
* 生成 4 个计价法列的公共配置,减少重复定义。
* 值来源统一:固定行取列合计,普通行取自身字段。
*/
const createMethodColumn = (
headerName: string,
field: PricingMethodField,
minWidth: number
): ColDef<DetailRow> => ({
headerName,
field,
headerClass: 'ag-right-aligned-header',
minWidth,
flex: 1.5,
cellClass: 'ag-right-aligned-cell',
editable: false,
valueGetter: params => {
if (!params.data) return null
if (isFixedRow(params.data)) return getMethodTotal(field)
return params.data[field]
},
valueParser: params => numericParser(params.newValue),
valueFormatter: params => (params.value == null ? '' : formatThousandsFlexible(params.value, 3))
})
/** 获取某服务对应四个计价页的存储键。 */
const getPricingPaneStorageKeys = (serviceId: string) =>
zxFwPricingStore.getServicePricingStorageKeys(props.contractId, serviceId)
/**
* 清空某服务在 4 个计价页的缓存与持久化数据。
* 会写入短期 skip/force 标记,避免“刚删完又被旧数据回填”。
*/
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 => {
if (item.id !== row.id) return item
const newSubtotal = sumNullableNumbers([sanitizedTotals.investScale, sanitizedTotals.landScale, sanitizedTotals.workload, sanitizedTotals.hourly])
return {
...item,
investScale: sanitizedTotals.investScale,
landScale: sanitizedTotals.landScale,
workload: sanitizedTotals.workload,
hourly: sanitizedTotals.hourly,
subtotal: newSubtotal != null ? roundTo(newSubtotal, 2) : null,
finalFee: newSubtotal != null ? roundTo(newSubtotal, 2) : null
}
})
await setCurrentContractState({
...currentState,
detailRows: applyFixedRowTotals(clearedRows)
})
}
/** 打开服务编辑子页。 */
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
}
})
}
/** 删除单行服务(本质是更新 selectedIds。 */
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 NameCellRenderer = defineComponent({
name: 'NameCellRenderer',
props: {
params: {
type: Object as PropType<ICellRendererParams<DetailRow>>,
required: true
}
},
setup(props) {
return () => {
const row = props.params.data
if (!row || isFixedRow(row)) return ''
return h('div', { class: 'zxfw-name-wrap' }, String(props.params.value || row.name || ''))
}
}
})
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) ? 3 : 1)
},
{
headerName: '名称',
field: 'name',
minWidth: 150,
flex: 3,
cellClass: 'zxfw-name-cell',
wrapText: true,
autoHeight: true,
cellStyle: { 'line-height': 1.6 },
cellRenderer: NameCellRenderer,
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: 150,
maxWidth: 200,
flex: 1,
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
},
createMethodColumn('投资规模法', 'investScale', 100),
createMethodColumn('用地规模法', 'landScale', 100),
createMethodColumn('工作量法', 'workload', 90),
createMethodColumn('工时法', 'hourly', 90),
{
headerName: '小计',
field: 'subtotal',
headerClass: 'ag-right-aligned-header',
flex: 2,
minWidth: 100,
cellClass: 'ag-right-aligned-cell',
editable: false,
valueGetter: params => {
if (!params.data) return null
return params.data.subtotal
},
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
},
{
headerName: '确认金额',
field: 'finalFee',
headerClass: 'ag-right-aligned-header',
flex: 2,
minWidth: 110,
cellClass: 'ag-right-aligned-cell',
editable: params => !isFixedRow(params.data),
valueGetter: params => {
if (!params.data) return null
return params.data.finalFee
},
// valueSetter: params => {
// const parsed = parseNumberOrNull(params.newValue, { precision: 2 })
// const val = parsed != null ? roundTo(parsed, 2) : null
// if (params.data.finalFee === val) return false
// params.data.finalFee = val
// return true
// },
valueParser: params => {
const parsed = parseNumberOrNull(params.newValue, { precision: 2 })
return parsed != null ? roundTo(parsed, 2) : null
},
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
},
{
headerName: '操作',
field: 'actions',
minWidth: 200,
flex: 1.5,
maxWidth: 220,
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)
}
}
}
/**
* 只重算固定行(小计行)汇总,不覆盖普通行 finalFee。
* 主要用于用户手动编辑 finalFee 后的同步场景。
*/
const applyFixedRowSummary = (rows: DetailRow[]) => {
const nextInvestScale = getMethodTotalFromRows(rows, 'investScale')
const nextLandScale = getMethodTotalFromRows(rows, 'landScale')
const nextWorkload = getMethodTotalFromRows(rows, 'workload')
const nextHourly = getMethodTotalFromRows(rows, 'hourly')
const totalFinalFee = sumNullableNumbers(rows.filter(r => !isFixedRow(r)).map(r => r.finalFee))
return rows.map(row =>
isFixedRow(row)
? {
...row,
investScale: nextInvestScale,
landScale: nextLandScale,
workload: nextWorkload,
hourly: nextHourly,
subtotal: sumNullableNumbers([nextInvestScale, nextLandScale, nextWorkload, nextHourly]),
finalFee: totalFinalFee != null ? roundTo(totalFinalFee, 2) : null
}
: row
)
}
/**
* 计价法金额发生变化时调用:
* 1) 普通行 finalFee 强制同步 subtotal
* 2) 固定行汇总(四列+确认金额)统一重算
*/
const applyFixedRowTotals = (rows: DetailRow[]) => {
const syncedRows = rows.map(row => {
if (isFixedRow(row)) return row
const rowSubtotal = sumNullableNumbers([row.investScale, row.landScale, row.workload, row.hourly])
return {
...row,
finalFee: rowSubtotal != null ? roundTo(rowSubtotal, 2) : null
}
})
return applyFixedRowSummary(syncedRows)
}
/** 获取当前已选服务 id排除固定小计行。 */
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
})
}
/**
* 刷新指定服务的 4 个计价法合计,并回写到 zxFw 明细。
* 计价法变更场景统一走这里,最终会触发 applyFixedRowTotals。
*/
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: applyFixedRowSummary(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
const newSubtotal = sumNullableNumbers([totals.investScale, totals.landScale, totals.workload, totals.hourly])
const methodChanged = !(
isSameNullableNumber(row.investScale, totals.investScale)
&& isSameNullableNumber(row.landScale, totals.landScale)
&& isSameNullableNumber(row.workload, totals.workload)
&& isSameNullableNumber(row.hourly, totals.hourly)
)
return {
...row,
investScale: totals.investScale,
landScale: totals.landScale,
workload: totals.workload,
hourly: totals.hourly,
finalFee: methodChanged
? (newSubtotal != null ? roundTo(newSubtotal, 2) : null)
: row.finalFee
}
})
await setCurrentContractState({
...currentState,
detailRows: applyFixedRowSummary(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,
subtotal: typeof old?.subtotal === 'number' ? old.subtotal : null,
finalFee: typeof old?.finalFee === 'number' ? old.finalFee : null
}
})
.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: typeof fixedOld?.subtotal === 'number' ? fixedOld.subtotal : null,
finalFee: typeof fixedOld?.finalFee === 'number' ? fixedOld.finalFee : 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: applyFixedRowSummary([...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 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()
// 重新获取所有已选服务的计价总额,确保从编辑页返回后 finalFee 和小计行都更新
const allServiceIds = getSelectedServiceIdsWithoutFixed()
if (allServiceIds.length > 0) {
await fillPricingTotalsForServiceIds(allServiceIds)
}
} 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,
finalFee: 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)
}
})
watch(
() => detailRows.value.map(row => `${row.id}:${row.name}`).join('|'),
() => {
scheduleAutoRowHeights()
}
)
onBeforeUnmount(() => {
if (autoHeightSyncTimer) {
clearTimeout(autoHeightSyncTimer)
autoHeightSyncTimer = null
}
})
/**
* 处理表格单元格编辑:当前只接管 finalFee 列。
* 编辑后仅重算固定行,避免覆盖用户刚输入的确认金额。
*/
const handleCellValueChanged = async (event: any) => {
if (event.colDef?.field !== 'finalFee') return
const row = event.data as DetailRow | undefined
if (!row || isFixedRow(row)) return
const newValue = event.newValue != null ? roundTo(Number(event.newValue), 2) : null
const currentState = getCurrentContractState()
const nextRows = currentState.detailRows.map(item =>
item.id === row.id ? { ...item, finalFee: newValue } : item
)
const finalRows = applyFixedRowSummary(nextRows)
await setCurrentContractState({
...currentState,
detailRows: finalRows
})
// 真实更新小计行的 rowNode.data确保 AG Grid 显示最新值
const api = gridApi.value
if (api) {
const fixedRowData = finalRows.find(r => isFixedRow(r))
const fixedNode = api.getRowNode(fixedBudgetRow.id)
if (fixedNode && fixedRowData) {
fixedNode.setData(fixedRowData)
}
}
}
onMounted(async () => {
await loadProjectIndustry()
await initializeContractState()
})
onActivated(async () => {
await loadProjectIndustry()
await initializeContractState()
})
</script>
<template>
<TooltipProvider>
<div class="h-full min-h-0 flex flex-col gap-2">
<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"
:animateRows="true"
@grid-ready="onGridReady"
@first-data-rendered="onFirstDataRendered"
: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;
}
:deep(.ag-cell:not(.ag-cell-auto-height)) {
display: flex;
align-items: center;
}
:deep(.zxfw-name-cell.ag-cell-auto-height) {
display: flex;
align-items: center;
}
:deep(.zxfw-name-cell.ag-cell-auto-height .ag-cell-wrapper),
:deep(.zxfw-name-cell.ag-cell-auto-height .ag-cell-value) {
display: flex;
align-items: center;
width: 100%;
white-space: normal;
}
:deep(.zxfw-name-wrap) {
display: flex;
align-items: center;
width: 100%;
white-space: normal;
word-break: break-word;
overflow-wrap: anywhere;
line-height: 1.6;
}
</style>