优化目录
This commit is contained in:
1 parent
4e343cccf1
commit
2b18fe9991
46 files changed
+1061
-1308
No files matched your search
@@ -0,0 +1,605 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, ColGroupDef, GridApi, GridReadyEvent } from 'ag-grid-community'
|
||||
import { expertList } from '@/sql'
|
||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { syncPricingTotalToZxFw, type ZxFwPricingField } from '@/lib/zxFwPricingSync'
|
||||
import { useZxFwPricingStore, type HtFeeMethodType, type ServicePricingMethod } from '@/pinia/zxFwPricing'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
|
||||
interface DetailRow {
|
||||
id: string
|
||||
expertCode: string
|
||||
expertName: string
|
||||
laborBudgetUnitPrice: string
|
||||
compositeBudgetUnitPrice: string
|
||||
adoptedBudgetUnitPrice: number | null
|
||||
personnelCount: number | null
|
||||
workdayCount: number | null
|
||||
serviceBudget: number | null
|
||||
remark: string
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface GridState {
|
||||
detailRows: DetailRow[]
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
storageKey: string
|
||||
title?: string
|
||||
contractId?: string
|
||||
serviceId?: string | number
|
||||
enableZxFwSync?: boolean
|
||||
syncField?: ZxFwPricingField
|
||||
htMainStorageKey?: string
|
||||
htRowId?: string
|
||||
htMethodType?: HtFeeMethodType
|
||||
}>(),
|
||||
{
|
||||
title: '工时法明细',
|
||||
enableZxFwSync: false,
|
||||
syncField: 'hourly'
|
||||
}
|
||||
)
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
|
||||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||||
const paneInstanceCreatedAt = Date.now()
|
||||
|
||||
const shouldSkipPersist = () => {
|
||||
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${props.storageKey}`
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const now = Date.now()
|
||||
|
||||
if (raw.includes(':')) {
|
||||
const [issuedRaw, untilRaw] = raw.split(':')
|
||||
const issuedAt = Number(issuedRaw)
|
||||
const skipUntil = Number(untilRaw)
|
||||
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
|
||||
return paneInstanceCreatedAt <= issuedAt
|
||||
}
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return false
|
||||
}
|
||||
|
||||
const skipUntil = Number(raw)
|
||||
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return false
|
||||
}
|
||||
|
||||
const shouldForceDefaultLoad = () => {
|
||||
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${props.storageKey}`
|
||||
const raw = sessionStorage.getItem(storageKey)
|
||||
if (!raw) return false
|
||||
const forceUntil = Number(raw)
|
||||
sessionStorage.removeItem(storageKey)
|
||||
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
|
||||
}
|
||||
|
||||
const fallbackDetailRows = ref<DetailRow[]>([])
|
||||
const gridApi = ref<GridApi<DetailRow> | null>(null)
|
||||
const serviceMethod = computed<ServicePricingMethod | null>(() => {
|
||||
if (props.syncField === 'investScale') return 'investScale'
|
||||
if (props.syncField === 'landScale') return 'landScale'
|
||||
if (props.syncField === 'workload') return 'workload'
|
||||
if (props.syncField === 'hourly') return 'hourly'
|
||||
return null
|
||||
})
|
||||
const useServicePricingState = computed(
|
||||
() => Boolean(props.enableZxFwSync && props.contractId && props.serviceId != null && serviceMethod.value)
|
||||
)
|
||||
const useHtMethodState = computed(
|
||||
() => Boolean(props.htMainStorageKey && props.htRowId && props.htMethodType)
|
||||
)
|
||||
const getServiceMethodState = () => {
|
||||
if (!useServicePricingState.value || !serviceMethod.value) return null
|
||||
return zxFwPricingStore.getServicePricingMethodState<DetailRow>(props.contractId!, props.serviceId!, serviceMethod.value)
|
||||
}
|
||||
const getHtMethodState = () => {
|
||||
if (!useHtMethodState.value) return null
|
||||
return zxFwPricingStore.getHtFeeMethodState<GridState>(
|
||||
props.htMainStorageKey!,
|
||||
props.htRowId!,
|
||||
props.htMethodType!
|
||||
)
|
||||
}
|
||||
const detailRows = computed<DetailRow[]>({
|
||||
get: () => {
|
||||
if (useServicePricingState.value) {
|
||||
const rows = getServiceMethodState()?.detailRows
|
||||
return Array.isArray(rows) ? rows : []
|
||||
}
|
||||
if (useHtMethodState.value) {
|
||||
const rows = getHtMethodState()?.detailRows
|
||||
return Array.isArray(rows) ? rows : []
|
||||
}
|
||||
return fallbackDetailRows.value
|
||||
},
|
||||
set: rows => {
|
||||
if (useServicePricingState.value && serviceMethod.value) {
|
||||
const currentState = getServiceMethodState()
|
||||
zxFwPricingStore.setServicePricingMethodState(props.contractId!, props.serviceId!, serviceMethod.value, {
|
||||
detailRows: rows,
|
||||
projectCount: currentState?.projectCount ?? null
|
||||
})
|
||||
return
|
||||
}
|
||||
if (useHtMethodState.value) {
|
||||
zxFwPricingStore.setHtFeeMethodState(
|
||||
props.htMainStorageKey!,
|
||||
props.htRowId!,
|
||||
props.htMethodType!,
|
||||
{ detailRows: rows }
|
||||
)
|
||||
return
|
||||
}
|
||||
fallbackDetailRows.value = rows
|
||||
}
|
||||
})
|
||||
|
||||
type ExpertLite = {
|
||||
code: string
|
||||
name: string
|
||||
maxPrice: number | null
|
||||
minPrice: number | null
|
||||
defPrice: number | null
|
||||
manageCoe: number | null
|
||||
}
|
||||
|
||||
const expertEntries = Object.entries(expertList as Record<string, ExpertLite>)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.filter((entry): entry is [string, ExpertLite] => {
|
||||
const item = entry[1]
|
||||
return Boolean(item?.code && item?.name)
|
||||
})
|
||||
|
||||
const formatPriceRange = (min: number | null, max: number | null) => {
|
||||
const hasMin = typeof min === 'number' && Number.isFinite(min)
|
||||
const hasMax = typeof max === 'number' && Number.isFinite(max)
|
||||
if (hasMin && hasMax) return `${min}-${max}`
|
||||
if (hasMin) return String(min)
|
||||
if (hasMax) return String(max)
|
||||
return ''
|
||||
}
|
||||
|
||||
const getCompositeBudgetUnitPriceRange = (expert: ExpertLite) => {
|
||||
if (typeof expert.manageCoe !== 'number' || !Number.isFinite(expert.manageCoe)) return ''
|
||||
const min =
|
||||
typeof expert.minPrice === 'number' && Number.isFinite(expert.minPrice)
|
||||
? roundTo(toDecimal(expert.minPrice).mul(expert.manageCoe), 2)
|
||||
: null
|
||||
const max =
|
||||
typeof expert.maxPrice === 'number' && Number.isFinite(expert.maxPrice)
|
||||
? roundTo(toDecimal(expert.maxPrice).mul(expert.manageCoe), 2)
|
||||
: null
|
||||
return formatPriceRange(min, max)
|
||||
}
|
||||
|
||||
const getDefaultAdoptedBudgetUnitPrice = (expert: ExpertLite) => {
|
||||
if (
|
||||
typeof expert.defPrice !== 'number' ||
|
||||
!Number.isFinite(expert.defPrice) ||
|
||||
typeof expert.manageCoe !== 'number' ||
|
||||
!Number.isFinite(expert.manageCoe)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2)
|
||||
}
|
||||
|
||||
const buildDefaultRows = (): DetailRow[] => {
|
||||
const rows: DetailRow[] = []
|
||||
for (const [expertId, expert] of expertEntries) {
|
||||
const rowId = `expert-${expertId}`
|
||||
rows.push({
|
||||
id: rowId,
|
||||
expertCode: expert.code,
|
||||
expertName: expert.name,
|
||||
laborBudgetUnitPrice: formatPriceRange(expert.minPrice, expert.maxPrice),
|
||||
compositeBudgetUnitPrice: getCompositeBudgetUnitPriceRange(expert),
|
||||
adoptedBudgetUnitPrice: getDefaultAdoptedBudgetUnitPrice(expert),
|
||||
personnelCount: null,
|
||||
workdayCount: null,
|
||||
serviceBudget: null,
|
||||
remark: '',
|
||||
path: [rowId]
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||
const dbValueMap = new Map<string, DetailRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
dbValueMap.set(row.id, row)
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
const fromDb = dbValueMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
return {
|
||||
...row,
|
||||
adoptedBudgetUnitPrice:
|
||||
typeof fromDb.adoptedBudgetUnitPrice === 'number' ? fromDb.adoptedBudgetUnitPrice : null,
|
||||
personnelCount: typeof fromDb.personnelCount === 'number' ? fromDb.personnelCount : null,
|
||||
workdayCount: typeof fromDb.workdayCount === 'number' ? fromDb.workdayCount : null,
|
||||
serviceBudget: typeof fromDb.serviceBudget === 'number' ? fromDb.serviceBudget : null,
|
||||
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const parseNonNegativeIntegerOrNull = (value: unknown) => {
|
||||
if (value === '' || value == null) return null
|
||||
if (typeof value === 'number') return Number.isInteger(value) && value >= 0 ? value : null
|
||||
const normalized = String(value).trim()
|
||||
if (!/^\d+$/.test(normalized)) return null
|
||||
const v = Number(normalized)
|
||||
return Number.isSafeInteger(v) ? v : null
|
||||
}
|
||||
|
||||
const formatEditableNumber = (params: any) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
|
||||
const formatEditableInteger = (params: any) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return String(Number(params.value))
|
||||
}
|
||||
|
||||
const calcServiceBudget = (row: DetailRow | undefined) => {
|
||||
const adopted = row?.adoptedBudgetUnitPrice
|
||||
const personnel = row?.personnelCount
|
||||
const workday = row?.workdayCount
|
||||
if (
|
||||
typeof adopted !== 'number' ||
|
||||
!Number.isFinite(adopted) ||
|
||||
typeof personnel !== 'number' ||
|
||||
!Number.isFinite(personnel) ||
|
||||
typeof workday !== 'number' ||
|
||||
!Number.isFinite(workday)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return roundTo(toDecimal(adopted).mul(personnel).mul(workday), 2)
|
||||
}
|
||||
|
||||
const syncServiceBudgetToRows = () => {
|
||||
for (const row of detailRows.value) {
|
||||
row.serviceBudget = calcServiceBudget(row)
|
||||
}
|
||||
}
|
||||
|
||||
const editableNumberCol = <K extends keyof DetailRow>(
|
||||
field: K,
|
||||
headerName: string,
|
||||
extra: Partial<ColDef<DetailRow>> = {}
|
||||
): ColDef<DetailRow> => ({
|
||||
headerName,
|
||||
field,
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: formatEditableNumber,
|
||||
...extra
|
||||
})
|
||||
|
||||
const editableMoneyCol = <K extends keyof DetailRow>(
|
||||
field: K,
|
||||
headerName: string,
|
||||
extra: Partial<ColDef<DetailRow>> = {}
|
||||
): ColDef<DetailRow> => ({
|
||||
headerName,
|
||||
field,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned
|
||||
? 'ag-right-aligned-cell editable-cell-line'
|
||||
: 'ag-right-aligned-cell',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
},
|
||||
...extra
|
||||
})
|
||||
|
||||
const readonlyTextCol = <K extends keyof DetailRow>(
|
||||
field: K,
|
||||
headerName: string,
|
||||
extra: Partial<ColDef<DetailRow>> = {}
|
||||
): ColDef<DetailRow> => ({
|
||||
headerName,
|
||||
field,
|
||||
minWidth: 170,
|
||||
flex: 1,
|
||||
editable: false,
|
||||
valueFormatter: params => params.value || '',
|
||||
...extra
|
||||
})
|
||||
|
||||
const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
|
||||
{
|
||||
headerName: '编码',
|
||||
field: 'expertCode',
|
||||
minWidth: 120,
|
||||
width: 140,
|
||||
pinned: 'left',
|
||||
colSpan: params => (params.node?.rowPinned ? 2 : 1),
|
||||
valueFormatter: params => (params.node?.rowPinned ? '总合计' : params.value || '')
|
||||
},
|
||||
{
|
||||
headerName: '人员名称',
|
||||
field: 'expertName',
|
||||
minWidth: 200,
|
||||
width: 220,
|
||||
pinned: 'left',
|
||||
tooltipField: 'expertName',
|
||||
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
|
||||
},
|
||||
{
|
||||
headerName: '预算参考单价',
|
||||
marryChildren: true,
|
||||
children: [
|
||||
readonlyTextCol('laborBudgetUnitPrice', '人工预算单价(元/工日)'),
|
||||
readonlyTextCol('compositeBudgetUnitPrice', '综合预算单价(元/工日)')
|
||||
]
|
||||
},
|
||||
editableMoneyCol('adoptedBudgetUnitPrice', '预算采用单价(元/工日)'),
|
||||
editableNumberCol('personnelCount', '人员数量(人)', {
|
||||
aggFunc: decimalAggSum,
|
||||
valueParser: params => parseNonNegativeIntegerOrNull(params.newValue),
|
||||
valueFormatter: formatEditableInteger
|
||||
}),
|
||||
editableNumberCol('workdayCount', '工日数量(工日)', { aggFunc: decimalAggSum }),
|
||||
{
|
||||
headerName: '服务预算(元)',
|
||||
field: 'serviceBudget',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 150,
|
||||
flex: 1,
|
||||
cellClass: 'ag-right-aligned-cell',
|
||||
editable: false,
|
||||
aggFunc: decimalAggSum,
|
||||
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceBudget ?? null : calcServiceBudget(params.data)),
|
||||
valueFormatter: params => {
|
||||
if (params.value == null || params.value === '') return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '说明',
|
||||
field: 'remark',
|
||||
minWidth: 180,
|
||||
flex: 1.2,
|
||||
cellEditor: 'agLargeTextCellEditor',
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
|
||||
return params.value || ''
|
||||
},
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line remark-wrap-cell' : ''),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
|
||||
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
|
||||
const sumNullableBy = <T>(rows: T[], pick: (row: T) => unknown): number | null => {
|
||||
let hasValid = false
|
||||
const total = sumByNumber(rows, row => {
|
||||
const value = Number(pick(row))
|
||||
if (!Number.isFinite(value)) return null
|
||||
hasValid = true
|
||||
return value
|
||||
})
|
||||
return hasValid ? total : null
|
||||
}
|
||||
const totalServiceBudget = computed(() => sumNullableBy(detailRows.value.filter(e => e.serviceBudget !== null && e.serviceBudget !== undefined), row => calcServiceBudget(row)))
|
||||
const pinnedTopRowData = computed(() => [
|
||||
{
|
||||
id: 'pinned-total-row',
|
||||
expertCode: '总合计',
|
||||
expertName: '',
|
||||
laborBudgetUnitPrice: '',
|
||||
compositeBudgetUnitPrice: '',
|
||||
adoptedBudgetUnitPrice: null,
|
||||
personnelCount: totalPersonnelCount.value,
|
||||
workdayCount: totalWorkdayCount.value,
|
||||
serviceBudget: totalServiceBudget.value,
|
||||
remark: '',
|
||||
path: ['TOTAL']
|
||||
}
|
||||
])
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
if (shouldSkipPersist()) return
|
||||
try {
|
||||
syncServiceBudgetToRows()
|
||||
const payload: GridState = {
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
}
|
||||
|
||||
if (useServicePricingState.value && serviceMethod.value) {
|
||||
zxFwPricingStore.setServicePricingMethodState(props.contractId!, props.serviceId!, serviceMethod.value, payload)
|
||||
} else if (useHtMethodState.value) {
|
||||
zxFwPricingStore.setHtFeeMethodState(
|
||||
props.htMainStorageKey!,
|
||||
props.htRowId!,
|
||||
props.htMethodType!,
|
||||
payload
|
||||
)
|
||||
} else {
|
||||
zxFwPricingStore.setKeyState(props.storageKey, payload)
|
||||
}
|
||||
|
||||
if (props.enableZxFwSync && props.contractId && props.serviceId != null) {
|
||||
const synced = await syncPricingTotalToZxFw({
|
||||
contractId: props.contractId,
|
||||
serviceId: props.serviceId,
|
||||
field: props.syncField,
|
||||
value: totalServiceBudget.value
|
||||
})
|
||||
if (!synced) return
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
if (shouldForceDefaultLoad()) {
|
||||
detailRows.value = buildDefaultRows()
|
||||
syncServiceBudgetToRows()
|
||||
return
|
||||
}
|
||||
const data = useServicePricingState.value && serviceMethod.value
|
||||
? await zxFwPricingStore.loadServicePricingMethodState<DetailRow>(props.contractId!, props.serviceId!, serviceMethod.value)
|
||||
: useHtMethodState.value
|
||||
? await zxFwPricingStore.loadHtFeeMethodState<GridState>(
|
||||
props.htMainStorageKey!,
|
||||
props.htRowId!,
|
||||
props.htMethodType!
|
||||
)
|
||||
: await zxFwPricingStore.loadKeyState<GridState>(props.storageKey)
|
||||
if (data) {
|
||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||
syncServiceBudgetToRows()
|
||||
return
|
||||
}
|
||||
detailRows.value = buildDefaultRows()
|
||||
syncServiceBudgetToRows()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = buildDefaultRows()
|
||||
syncServiceBudgetToRows()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCellValueChanged = () => {
|
||||
syncServiceBudgetToRows()
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
|
||||
gridApi.value = event.api
|
||||
}
|
||||
|
||||
const processCellForClipboard = (params: any) => {
|
||||
if (Array.isArray(params.value)) return JSON.stringify(params.value)
|
||||
return params.value
|
||||
}
|
||||
|
||||
const processCellFromClipboard = (params: any) => {
|
||||
try {
|
||||
const parsed = JSON.parse(params.value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch (_error) {
|
||||
return params.value
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.storageKey,
|
||||
() => {
|
||||
void loadFromIndexedDB()
|
||||
}
|
||||
)
|
||||
|
||||
onDeactivated(() => {
|
||||
gridApi.value?.stopEditing()
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
gridApi.value?.stopEditing()
|
||||
gridApi.value = null
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ props.title }}</h3>
|
||||
<div class="text-xs text-muted-foreground"></div>
|
||||
</div>
|
||||
|
||||
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||
<AgGridVue
|
||||
:style="{ height: '100%' }"
|
||||
:rowData="detailRows"
|
||||
:pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="columnDefs"
|
||||
:gridOptions="gridOptions"
|
||||
:theme="myTheme"
|
||||
:treeData="false"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:processCellForClipboard="processCellForClipboard"
|
||||
:processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
@grid-ready="handleGridReady"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,489 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { roundTo, toDecimal } from '@/lib/decimal'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import { Trash2 } from 'lucide-vue-next'
|
||||
import {
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogRoot,
|
||||
AlertDialogTitle
|
||||
} from 'reka-ui'
|
||||
|
||||
interface FeeRow {
|
||||
id: string
|
||||
feeItem: string
|
||||
unit: string
|
||||
quantity: number | null
|
||||
unitPrice: number | null
|
||||
budgetFee: number | null
|
||||
remark: string
|
||||
actions?: unknown
|
||||
}
|
||||
|
||||
interface FeeGridState {
|
||||
detailRows: FeeRow[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
storageKey: string
|
||||
htMainStorageKey?: string
|
||||
htRowId?: string
|
||||
htMethodType?: 'quantity-unit-price-fee'
|
||||
}>()
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
|
||||
const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
||||
const SUBTOTAL_ROW_ID = 'fee-subtotal-fixed'
|
||||
|
||||
const createDefaultRow = (): FeeRow => ({
|
||||
id: createRowId(),
|
||||
feeItem: '',
|
||||
unit: '',
|
||||
quantity: null,
|
||||
unitPrice: null,
|
||||
budgetFee: null,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const createSubtotalRow = (): FeeRow => ({
|
||||
id: SUBTOTAL_ROW_ID,
|
||||
feeItem: '小计',
|
||||
unit: '',
|
||||
quantity: null,
|
||||
unitPrice: null,
|
||||
budgetFee: 0,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const isSubtotalRow = (row?: FeeRow | null) => row?.id === SUBTOTAL_ROW_ID
|
||||
|
||||
const ensureSubtotalRow = (rows: FeeRow[]) => {
|
||||
const normalRows = rows.filter(row => !isSubtotalRow(row))
|
||||
if (normalRows.length === 0) return []
|
||||
return [...normalRows, createSubtotalRow()]
|
||||
}
|
||||
|
||||
const fallbackDetailRows = ref<FeeRow[]>([])
|
||||
const gridApi = ref<GridApi<FeeRow> | null>(null)
|
||||
const deleteConfirmOpen = ref(false)
|
||||
const pendingDeleteRowId = ref<string | null>(null)
|
||||
const pendingDeleteRowName = ref('')
|
||||
const useHtMethodState = computed(
|
||||
() => Boolean(props.htMainStorageKey && props.htRowId && props.htMethodType)
|
||||
)
|
||||
const detailRows = computed<FeeRow[]>({
|
||||
get: () => {
|
||||
if (!useHtMethodState.value) return fallbackDetailRows.value
|
||||
const state = zxFwPricingStore.getHtFeeMethodState<FeeGridState>(
|
||||
props.htMainStorageKey!,
|
||||
props.htRowId!,
|
||||
props.htMethodType!
|
||||
)
|
||||
const rows = state?.detailRows
|
||||
return Array.isArray(rows) ? rows : []
|
||||
},
|
||||
set: rows => {
|
||||
if (!useHtMethodState.value) {
|
||||
fallbackDetailRows.value = rows
|
||||
return
|
||||
}
|
||||
zxFwPricingStore.setHtFeeMethodState(props.htMainStorageKey!, props.htRowId!, props.htMethodType!, {
|
||||
detailRows: rows
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const addRow = () => {
|
||||
const normalRows = detailRows.value.filter(row => !isSubtotalRow(row))
|
||||
detailRows.value = ensureSubtotalRow([...normalRows, createDefaultRow()])
|
||||
syncComputedValuesToRows()
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const deleteRow = (id: string) => {
|
||||
const normalRows = detailRows.value.filter(row => !isSubtotalRow(row) && row.id !== id)
|
||||
detailRows.value = ensureSubtotalRow(normalRows)
|
||||
syncComputedValuesToRows()
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const requestDeleteRow = (id: string, name?: string) => {
|
||||
pendingDeleteRowId.value = id
|
||||
pendingDeleteRowName.value = String(name || '').trim() || '当前行'
|
||||
deleteConfirmOpen.value = true
|
||||
}
|
||||
|
||||
const handleDeleteConfirmOpenChange = (open: boolean) => {
|
||||
deleteConfirmOpen.value = open
|
||||
}
|
||||
|
||||
const confirmDeleteRow = () => {
|
||||
const id = pendingDeleteRowId.value
|
||||
if (!id) return
|
||||
deleteRow(id)
|
||||
deleteConfirmOpen.value = false
|
||||
pendingDeleteRowId.value = null
|
||||
pendingDeleteRowName.value = ''
|
||||
}
|
||||
|
||||
const formatEditableText = (params: any) => {
|
||||
if (isSubtotalRow(params.data)) return ''
|
||||
if (params.value == null || params.value === '') return '点击输入'
|
||||
return String(params.value)
|
||||
}
|
||||
|
||||
const formatEditableQuantity = (params: any) => {
|
||||
if (isSubtotalRow(params.data)) return ''
|
||||
if (params.value == null || params.value === '') return '点击输入'
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
|
||||
const formatEditableUnitPrice = (params: any) => {
|
||||
if (isSubtotalRow(params.data)) return ''
|
||||
if (params.value == null || params.value === '') return '点击输入'
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
|
||||
const formatReadonlyBudgetFee = (params: any) => {
|
||||
if (params.value == null || params.value === '') return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
|
||||
const syncComputedValuesToRows = () => {
|
||||
let totalBudgetFee = 0
|
||||
let hasValidRow = false
|
||||
for (const row of detailRows.value) {
|
||||
if (isSubtotalRow(row)) continue
|
||||
const hasFeeItem = typeof row.feeItem === 'string' && row.feeItem.trim() !== ''
|
||||
const hasUnit = typeof row.unit === 'string' && row.unit.trim() !== ''
|
||||
const quantity = typeof row.quantity === 'number' && Number.isFinite(row.quantity) ? row.quantity : null
|
||||
const unitPrice = typeof row.unitPrice === 'number' && Number.isFinite(row.unitPrice) ? row.unitPrice : null
|
||||
if (!hasFeeItem || !hasUnit || quantity == null || unitPrice == null) {
|
||||
row.budgetFee = null
|
||||
continue
|
||||
}
|
||||
row.budgetFee = roundTo(toDecimal(quantity).mul(unitPrice), 2)
|
||||
if (typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee)) {
|
||||
hasValidRow = true
|
||||
totalBudgetFee = roundTo(toDecimal(totalBudgetFee).add(row.budgetFee), 2)
|
||||
}
|
||||
}
|
||||
const subtotalRow = detailRows.value.find(row => isSubtotalRow(row))
|
||||
if (subtotalRow) {
|
||||
subtotalRow.feeItem = '小计'
|
||||
subtotalRow.unit = ''
|
||||
subtotalRow.quantity = null
|
||||
subtotalRow.unitPrice = null
|
||||
subtotalRow.budgetFee = hasValidRow ? totalBudgetFee : null
|
||||
subtotalRow.remark = ''
|
||||
}
|
||||
}
|
||||
|
||||
const mergeWithStoredRows = (rowsFromDb: unknown): FeeRow[] => {
|
||||
if (!Array.isArray(rowsFromDb) || rowsFromDb.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const rows: FeeRow[] = rowsFromDb.map(item => {
|
||||
const row = item as Partial<FeeRow>
|
||||
return {
|
||||
id: typeof row.id === 'string' && row.id ? row.id : createRowId(),
|
||||
feeItem: typeof row.feeItem === 'string' ? row.feeItem : '',
|
||||
unit: typeof row.unit === 'string' ? row.unit : '',
|
||||
quantity: typeof row.quantity === 'number' ? row.quantity : null,
|
||||
unitPrice: typeof row.unitPrice === 'number' ? row.unitPrice : null,
|
||||
budgetFee: typeof row.budgetFee === 'number' ? row.budgetFee : null,
|
||||
remark: typeof row.remark === 'string' ? row.remark : ''
|
||||
}
|
||||
})
|
||||
|
||||
return ensureSubtotalRow(rows)
|
||||
}
|
||||
|
||||
const buildPersistDetailRows = () => {
|
||||
syncComputedValuesToRows()
|
||||
return detailRows.value.map(row => ({ ...row }))
|
||||
}
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const payload: FeeGridState = {
|
||||
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
|
||||
}
|
||||
if (useHtMethodState.value) {
|
||||
zxFwPricingStore.setHtFeeMethodState(
|
||||
props.htMainStorageKey!,
|
||||
props.htRowId!,
|
||||
props.htMethodType!,
|
||||
payload
|
||||
)
|
||||
} else {
|
||||
zxFwPricingStore.setKeyState(props.storageKey, payload)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const data = useHtMethodState.value
|
||||
? await zxFwPricingStore.loadHtFeeMethodState<FeeGridState>(
|
||||
props.htMainStorageKey!,
|
||||
props.htRowId!,
|
||||
props.htMethodType!
|
||||
)
|
||||
: await zxFwPricingStore.loadKeyState<FeeGridState>(props.storageKey)
|
||||
detailRows.value = mergeWithStoredRows(data?.detailRows)
|
||||
syncComputedValuesToRows()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = []
|
||||
syncComputedValuesToRows()
|
||||
}
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<FeeRow>[] = [
|
||||
{
|
||||
headerName: '序号',
|
||||
colId: 'rowNo',
|
||||
minWidth: 68,
|
||||
maxWidth: 80,
|
||||
flex: 0.6,
|
||||
editable: false,
|
||||
sortable: false,
|
||||
filter: false,
|
||||
cellStyle: { textAlign: 'center' },
|
||||
valueGetter: params =>
|
||||
params.node?.rowPinned
|
||||
? ''
|
||||
: isSubtotalRow(params.data)
|
||||
? '小计'
|
||||
: typeof params.node?.rowIndex === 'number'
|
||||
? params.node.rowIndex + 1
|
||||
: '',
|
||||
colSpan: params => (isSubtotalRow(params.data) ? 2 : 1)
|
||||
},
|
||||
{
|
||||
headerName: '费用项',
|
||||
field: 'feeItem',
|
||||
minWidth: 140,
|
||||
flex: 1.4,
|
||||
editable: params => !isSubtotalRow(params.data),
|
||||
valueGetter: params => (isSubtotalRow(params.data) ? '' : (params.data?.feeItem ?? '')),
|
||||
valueFormatter: formatEditableText,
|
||||
cellClass: params => (isSubtotalRow(params.data) ? '' : 'editable-cell-line'),
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '单位',
|
||||
field: 'unit',
|
||||
minWidth: 90,
|
||||
flex: 0.9,
|
||||
editable: params => !isSubtotalRow(params.data),
|
||||
valueFormatter: formatEditableText,
|
||||
cellClass: 'editable-cell-line',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '数量',
|
||||
field: 'quantity',
|
||||
minWidth: 100,
|
||||
flex: 1,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: 'ag-right-aligned-cell editable-cell-line',
|
||||
editable: params => !isSubtotalRow(params.data),
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: formatEditableQuantity,
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '单价(元)',
|
||||
field: 'unitPrice',
|
||||
minWidth: 120,
|
||||
flex: 1.1,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: 'ag-right-aligned-cell editable-cell-line',
|
||||
editable: params => !isSubtotalRow(params.data),
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
|
||||
valueFormatter: formatEditableUnitPrice,
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '预算费用(元)',
|
||||
field: 'budgetFee',
|
||||
minWidth: 130,
|
||||
flex: 1.2,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: 'ag-right-aligned-cell',
|
||||
editable: false,
|
||||
valueFormatter: formatReadonlyBudgetFee
|
||||
},
|
||||
{
|
||||
headerName: '说明',
|
||||
field: 'remark',
|
||||
minWidth: 170,
|
||||
flex: 2,
|
||||
editable: params => !isSubtotalRow(params.data),
|
||||
cellEditor: 'agLargeTextCellEditor',
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||
valueFormatter: formatEditableText,
|
||||
cellClass: 'editable-cell-line remark-wrap-cell',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '操作',
|
||||
field: 'actions',
|
||||
minWidth: 92,
|
||||
maxWidth: 110,
|
||||
flex: 0.8,
|
||||
editable: false,
|
||||
sortable: false,
|
||||
filter: false,
|
||||
suppressMovable: true,
|
||||
cellRenderer: defineComponent({
|
||||
name: 'HtFeeGridActionCellRenderer',
|
||||
props: {
|
||||
params: {
|
||||
type: Object as PropType<ICellRendererParams<FeeRow>>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(rendererProps) {
|
||||
return () => {
|
||||
const row = rendererProps.params.data
|
||||
if (!row || isSubtotalRow(row)) return null
|
||||
const onDelete = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
requestDeleteRow(row.id, row.feeItem)
|
||||
}
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class:
|
||||
'inline-flex cursor-pointer items-center gap-1 rounded border border-red-200 px-2 py-1 text-xs text-red-600 hover:bg-red-50',
|
||||
onClick: onDelete
|
||||
},
|
||||
[h(Trash2, { size: 12, 'aria-hidden': 'true' }), h('span', '删除')]
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
const detailGridOptions: GridOptions<FeeRow> = {
|
||||
...gridOptions,
|
||||
treeData: false
|
||||
}
|
||||
|
||||
const handleGridReady = (event: GridReadyEvent<FeeRow>) => {
|
||||
gridApi.value = event.api
|
||||
}
|
||||
|
||||
const handleCellValueChanged = () => {
|
||||
syncComputedValuesToRows()
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void loadFromIndexedDB()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.storageKey,
|
||||
() => {
|
||||
void loadFromIndexedDB()
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
gridApi.value = null
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
|
||||
<Button type="button" variant="outline" size="sm" @click="addRow">添加行</Button>
|
||||
</div>
|
||||
|
||||
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||
<AgGridVue
|
||||
:style="{ height: '100%' }"
|
||||
:rowData="detailRows"
|
||||
:columnDefs="columnDefs"
|
||||
:gridOptions="detailGridOptions"
|
||||
:theme="myTheme"
|
||||
:treeData="false"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
@grid-ready="handleGridReady"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
将删除“{{ pendingDeleteRowName }}”这条明细,是否继续?
|
||||
</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>
|
||||
</template>
|
||||
@@ -0,0 +1,740 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { Pencil, Eraser } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import {
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogRoot,
|
||||
AlertDialogTitle
|
||||
} from 'reka-ui'
|
||||
|
||||
interface FeeMethodRow {
|
||||
id: string
|
||||
name: string
|
||||
rateFee: number | null
|
||||
hourlyFee: number | null
|
||||
quantityUnitPriceFee: number | null
|
||||
subtotal?: number | null
|
||||
actions?: unknown
|
||||
}
|
||||
|
||||
interface FeeMethodState {
|
||||
detailRows: FeeMethodRow[]
|
||||
}
|
||||
|
||||
interface MethodRateState {
|
||||
rate?: unknown
|
||||
budgetFee?: unknown
|
||||
}
|
||||
|
||||
interface MethodHourlyRowLike {
|
||||
adoptedBudgetUnitPrice?: unknown
|
||||
personnelCount?: unknown
|
||||
workdayCount?: unknown
|
||||
serviceBudget?: unknown
|
||||
}
|
||||
|
||||
interface MethodHourlyState {
|
||||
detailRows?: MethodHourlyRowLike[]
|
||||
}
|
||||
|
||||
interface MethodQuantityRowLike {
|
||||
id?: unknown
|
||||
budgetFee?: unknown
|
||||
quantity?: unknown
|
||||
unitPrice?: unknown
|
||||
}
|
||||
|
||||
interface MethodQuantityState {
|
||||
detailRows?: MethodQuantityRowLike[]
|
||||
}
|
||||
|
||||
interface LegacyFeeRow {
|
||||
id?: string
|
||||
feeItem?: string
|
||||
budgetFee?: number | null
|
||||
quantity?: number | null
|
||||
unitPrice?: number | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
storageKey: string
|
||||
contractId?: string
|
||||
contractName?: string
|
||||
fixedNames?: string[]
|
||||
}>()
|
||||
const tabStore = useTabStore()
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
|
||||
const createRowId = () => `fee-method-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
||||
const createDefaultRow = (name = ''): FeeMethodRow => ({
|
||||
id: createRowId(),
|
||||
name,
|
||||
rateFee: null,
|
||||
hourlyFee: null,
|
||||
quantityUnitPriceFee: null
|
||||
})
|
||||
const SUMMARY_ROW_ID = 'fee-method-summary'
|
||||
const isSummaryRow = (row: FeeMethodRow | null | undefined) => row?.id === SUMMARY_ROW_ID
|
||||
const toFinite = (value: number | null | undefined) =>
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
const round3 = (value: number) => Number(value.toFixed(3))
|
||||
const isReserveStorageKey = (key: string) => String(key || '').includes('-reserve')
|
||||
const sumNullableField = (rows: FeeMethodRow[], pick: (row: FeeMethodRow) => number | null | undefined): number | null => {
|
||||
let hasValid = false
|
||||
let total = 0
|
||||
for (const row of rows) {
|
||||
const value = pick(row)
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) continue
|
||||
total += value
|
||||
hasValid = true
|
||||
}
|
||||
return hasValid ? round3(total) : null
|
||||
}
|
||||
const getRowSubtotal = (row: FeeMethodRow | null | undefined) => {
|
||||
if (!row) return null
|
||||
const values = [row.rateFee, row.hourlyFee, row.quantityUnitPriceFee]
|
||||
const hasValid = values.some(value => typeof value === 'number' && Number.isFinite(value))
|
||||
if (!hasValid) return null
|
||||
return round3(toFinite(row.rateFee) + toFinite(row.hourlyFee) + toFinite(row.quantityUnitPriceFee))
|
||||
}
|
||||
const toFiniteUnknown = (value: unknown): number | null => {
|
||||
if (value == null || value === '') return null
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? numeric : null
|
||||
}
|
||||
|
||||
const sumMainStateSubtotal = (rows: FeeMethodRow[] | undefined) => {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return null
|
||||
return sumNullableField(rows, row => getRowSubtotal(row))
|
||||
}
|
||||
|
||||
const loadAdditionalWorkFeeTotal = async (contractId: string): Promise<number | null> => {
|
||||
try {
|
||||
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
||||
const additionalState = await zxFwPricingStore.loadHtFeeMainState<FeeMethodRow>(additionalStorageKey)
|
||||
return sumMainStateSubtotal(additionalState?.detailRows)
|
||||
} catch (error) {
|
||||
console.error('loadAdditionalWorkFeeTotal failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const loadContractServiceFeeBase = async (): Promise<number | null> => {
|
||||
const contractId = String(props.contractId || '').trim()
|
||||
if (!contractId) return null
|
||||
try {
|
||||
await zxFwPricingStore.loadContract(contractId)
|
||||
const serviceBase = zxFwPricingStore.getBaseSubtotal(contractId)
|
||||
if (!isReserveStorageKey(props.storageKey)) {
|
||||
return serviceBase == null ? null : round3(serviceBase)
|
||||
}
|
||||
const additionalFeeTotal = await loadAdditionalWorkFeeTotal(contractId)
|
||||
const hasAnyBase = serviceBase != null || additionalFeeTotal != null
|
||||
if (!hasAnyBase) return null
|
||||
return round3(toFinite(serviceBase) + toFinite(additionalFeeTotal))
|
||||
} catch (error) {
|
||||
console.error('loadContractServiceFeeBase failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const sumHourlyMethodFee = (state: MethodHourlyState | null): number | null => {
|
||||
|
||||
|
||||
const rows=state?.detailRows?state?.detailRows?.filter(e=>e.serviceBudget!== null):[]
|
||||
|
||||
if (rows.length === 0) return null
|
||||
let total = 0
|
||||
let hasValid = false
|
||||
for (const row of rows) {
|
||||
|
||||
const rowBudget = toFiniteUnknown(row?.serviceBudget)
|
||||
if (rowBudget != null) {
|
||||
total += rowBudget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const adopted = toFiniteUnknown(row?.adoptedBudgetUnitPrice)
|
||||
const personnel = toFiniteUnknown(row?.personnelCount)
|
||||
const workday = toFiniteUnknown(row?.workdayCount)
|
||||
|
||||
if (adopted == null || personnel == null || workday == null) continue
|
||||
total += adopted * personnel * workday
|
||||
hasValid = true
|
||||
}
|
||||
return hasValid ? round3(total) : null
|
||||
}
|
||||
|
||||
const sumQuantityMethodFee = (state: MethodQuantityState | null): number | null => {
|
||||
const rows=state?.detailRows?state?.detailRows?.filter(e=>e.budgetFee!== null):[]
|
||||
if (rows.length === 0) return null
|
||||
|
||||
let total = 0
|
||||
let hasValid = false
|
||||
for (const row of rows) {
|
||||
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
||||
const budget = toFiniteUnknown(row?.budgetFee)
|
||||
if (budget != null) {
|
||||
total += budget
|
||||
hasValid = true
|
||||
continue
|
||||
}
|
||||
const quantity = toFiniteUnknown(row?.quantity)
|
||||
const unitPrice = toFiniteUnknown(row?.unitPrice)
|
||||
if (quantity == null || unitPrice == null) continue
|
||||
total += quantity * unitPrice
|
||||
hasValid = true
|
||||
}
|
||||
return hasValid ? round3(total) : null
|
||||
}
|
||||
|
||||
const hydrateRowsFromMethodStores = async (rows: FeeMethodRow[]): Promise<FeeMethodRow[]> => {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return rows
|
||||
const contractBase = await loadContractServiceFeeBase()
|
||||
const hydratedRows = await Promise.all(
|
||||
rows.map(async row => {
|
||||
if (!row?.id) return row
|
||||
const [rateData, hourlyData, quantityData] = await Promise.all([
|
||||
zxFwPricingStore.loadHtFeeMethodState<MethodRateState>(props.storageKey, row.id, 'rate-fee'),
|
||||
zxFwPricingStore.loadHtFeeMethodState<MethodHourlyState>(props.storageKey, row.id, 'hourly-fee'),
|
||||
zxFwPricingStore.loadHtFeeMethodState<MethodQuantityState>(props.storageKey, row.id, 'quantity-unit-price-fee')
|
||||
])
|
||||
|
||||
const storedRateFee = toFiniteUnknown(rateData?.budgetFee)
|
||||
const rateValue = toFiniteUnknown(rateData?.rate)
|
||||
const rateFee =
|
||||
contractBase != null && rateValue != null
|
||||
? round3(contractBase * rateValue)
|
||||
: storedRateFee != null
|
||||
? round3(storedRateFee)
|
||||
: null
|
||||
|
||||
const hourlyFee = sumHourlyMethodFee(hourlyData)
|
||||
const quantityUnitPriceFee = sumQuantityMethodFee(quantityData)
|
||||
|
||||
return {
|
||||
...row,
|
||||
rateFee,
|
||||
hourlyFee,
|
||||
quantityUnitPriceFee
|
||||
}
|
||||
})
|
||||
)
|
||||
return hydratedRows
|
||||
}
|
||||
|
||||
const fixedNames = computed(() =>
|
||||
Array.isArray(props.fixedNames)
|
||||
? props.fixedNames.map(item => String(item || '').trim()).filter(Boolean)
|
||||
: []
|
||||
)
|
||||
const hasFixedNames = computed(() => fixedNames.value.length > 0)
|
||||
const detailRows = computed<FeeMethodRow[]>({
|
||||
get: () => {
|
||||
const rows = zxFwPricingStore.getHtFeeMainState<FeeMethodRow>(props.storageKey)?.detailRows
|
||||
return Array.isArray(rows) ? rows : []
|
||||
},
|
||||
set: rows => {
|
||||
zxFwPricingStore.setHtFeeMainState(props.storageKey, {
|
||||
detailRows: rows
|
||||
})
|
||||
}
|
||||
})
|
||||
const summaryRow = computed<FeeMethodRow>(() => {
|
||||
const rateFee = sumNullableField(detailRows.value, row => row.rateFee)
|
||||
const hourlyFee = sumNullableField(detailRows.value, row => row.hourlyFee)
|
||||
const quantityUnitPriceFee = sumNullableField(detailRows.value, row => row.quantityUnitPriceFee)
|
||||
const result: FeeMethodRow = {
|
||||
id: SUMMARY_ROW_ID,
|
||||
name: '小计',
|
||||
rateFee,
|
||||
hourlyFee,
|
||||
quantityUnitPriceFee
|
||||
}
|
||||
result.subtotal = getRowSubtotal(result)
|
||||
return result
|
||||
})
|
||||
const displayRows = computed<FeeMethodRow[]>(() => [...detailRows.value, summaryRow.value])
|
||||
const gridApi = ref<GridApi<FeeMethodRow> | null>(null)
|
||||
const clearConfirmOpen = ref(false)
|
||||
const pendingClearRowId = ref<string | null>(null)
|
||||
const pendingClearRowName = ref('')
|
||||
const lastSavedSnapshot = ref('')
|
||||
|
||||
const requestClearRow = (id: string, name?: string) => {
|
||||
pendingClearRowId.value = id
|
||||
pendingClearRowName.value = String(name || '').trim() || '当前行'
|
||||
clearConfirmOpen.value = true
|
||||
}
|
||||
|
||||
const handleClearConfirmOpenChange = (open: boolean) => {
|
||||
clearConfirmOpen.value = open
|
||||
}
|
||||
|
||||
const confirmClearRow = async () => {
|
||||
const id = pendingClearRowId.value
|
||||
if (!id) return
|
||||
await clearRow(id)
|
||||
clearConfirmOpen.value = false
|
||||
pendingClearRowId.value = null
|
||||
pendingClearRowName.value = ''
|
||||
}
|
||||
|
||||
const formatEditableText = (params: any) => {
|
||||
if (params.value == null || params.value === '') {
|
||||
if (isSummaryRow(params.data)) return ''
|
||||
return ''
|
||||
}
|
||||
return String(params.value)
|
||||
}
|
||||
|
||||
const formatEditableNumber = (params: any) => {
|
||||
if (params.value == null || params.value === '') {
|
||||
if (isSummaryRow(params.data)) return ''
|
||||
return ''
|
||||
}
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
|
||||
const numericParser = (newValue: any): number | null =>
|
||||
parseNumberOrNull(newValue, { precision: 3 })
|
||||
|
||||
const toLegacyQuantityUnitPriceFee = (row: LegacyFeeRow) => {
|
||||
if (typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee)) return row.budgetFee
|
||||
if (
|
||||
typeof row.quantity === 'number' &&
|
||||
Number.isFinite(row.quantity) &&
|
||||
typeof row.unitPrice === 'number' &&
|
||||
Number.isFinite(row.unitPrice)
|
||||
) {
|
||||
return Number((row.quantity * row.unitPrice).toFixed(2))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const mergeWithStoredRows = (rowsFromDb: unknown): FeeMethodRow[] => {
|
||||
const sourceRows = (Array.isArray(rowsFromDb) ? rowsFromDb : []).filter(
|
||||
item => (item as Partial<FeeMethodRow>)?.id !== SUMMARY_ROW_ID
|
||||
)
|
||||
const rows = sourceRows.map(item => {
|
||||
const row = item as Partial<FeeMethodRow> & LegacyFeeRow
|
||||
|
||||
return {
|
||||
id: typeof row.id === 'string' && row.id ? row.id : createRowId(),
|
||||
name:
|
||||
typeof row.name === 'string'
|
||||
? row.name
|
||||
: (typeof row.feeItem === 'string' ? row.feeItem : ''),
|
||||
|
||||
rateFee: typeof row.rateFee === 'number' ? row.rateFee : null,
|
||||
hourlyFee: typeof row.hourlyFee === 'number' ? row.hourlyFee : null,
|
||||
quantityUnitPriceFee:
|
||||
typeof row.quantityUnitPriceFee === 'number'
|
||||
? row.quantityUnitPriceFee
|
||||
: toLegacyQuantityUnitPriceFee(row)
|
||||
} as FeeMethodRow
|
||||
})
|
||||
if (hasFixedNames.value) {
|
||||
const byName = new Map(rows.map(row => [row.name, row]))
|
||||
return fixedNames.value.map((name, index) => {
|
||||
const fromDb = byName.get(name)
|
||||
return {
|
||||
id: fromDb?.id || `fee-method-fixed-${index}`,
|
||||
name,
|
||||
rateFee: fromDb?.rateFee ?? null,
|
||||
hourlyFee: fromDb?.hourlyFee ?? null,
|
||||
quantityUnitPriceFee: fromDb?.quantityUnitPriceFee ?? null
|
||||
}
|
||||
})
|
||||
}
|
||||
return rows.length > 0 ? rows : [createDefaultRow()]
|
||||
}
|
||||
|
||||
const buildPersistDetailRows = () => detailRows.value.map(row => ({ ...row }))
|
||||
|
||||
const saveToIndexedDB = async (force = false) => {
|
||||
try {
|
||||
const payload: FeeMethodState = {
|
||||
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
|
||||
}
|
||||
const snapshot = JSON.stringify(payload.detailRows)
|
||||
if (!force && snapshot === lastSavedSnapshot.value) return
|
||||
zxFwPricingStore.setHtFeeMainState(props.storageKey, payload, { force })
|
||||
lastSavedSnapshot.value = snapshot
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const data = await zxFwPricingStore.loadHtFeeMainState<FeeMethodRow>(props.storageKey)
|
||||
|
||||
const mergedRows = mergeWithStoredRows(data?.detailRows)
|
||||
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
|
||||
await saveToIndexedDB(true)
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
const mergedRows = mergeWithStoredRows([])
|
||||
detailRows.value = await hydrateRowsFromMethodStores(mergedRows)
|
||||
await saveToIndexedDB(true)
|
||||
}
|
||||
}
|
||||
|
||||
const addRow = () => {
|
||||
detailRows.value = [...detailRows.value, createDefaultRow()]
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const clearRow = async (id: string) => {
|
||||
tabStore.removeTab(`ht-fee-edit-${props.storageKey}-${id}`)
|
||||
await nextTick()
|
||||
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'rate-fee')
|
||||
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'hourly-fee')
|
||||
zxFwPricingStore.removeHtFeeMethodState(props.storageKey, id, 'quantity-unit-price-fee')
|
||||
detailRows.value = detailRows.value.map(row =>
|
||||
row.id !== id
|
||||
? row
|
||||
: {
|
||||
...row,
|
||||
rateFee: null,
|
||||
hourlyFee: null,
|
||||
quantityUnitPriceFee: null
|
||||
}
|
||||
)
|
||||
await saveToIndexedDB()
|
||||
}
|
||||
|
||||
const editRow = (id: string) => {
|
||||
const row = detailRows.value.find(item => item.id === id)
|
||||
if (!row) return
|
||||
tabStore.openTab({
|
||||
id: `ht-fee-edit-${props.storageKey}-${id}`,
|
||||
title: `费用编辑-${row.name || '未命名'}`,
|
||||
componentName: 'HtFeeMethodTypeLineView',
|
||||
props: {
|
||||
sourceTitle: props.title,
|
||||
storageKey: props.storageKey,
|
||||
rowId: id,
|
||||
rowName: row.name || '',
|
||||
contractId: props.contractId,
|
||||
contractName: props.contractName
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const ActionCellRenderer = defineComponent({
|
||||
name: 'HtFeeMethodActionCellRenderer',
|
||||
props: {
|
||||
params: {
|
||||
type: Object as PropType<ICellRendererParams<FeeMethodRow>>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
return () => {
|
||||
if (isSummaryRow(props.params.data as FeeMethodRow | undefined)) return null
|
||||
const onActionClick = (action: 'edit' | 'clear') => (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const rowId = props.params.data?.id
|
||||
if (!rowId) return
|
||||
if (action === 'edit') {
|
||||
props.params.context?.onActionEdit?.(rowId)
|
||||
return
|
||||
}
|
||||
void props.params.context?.onActionRequestClear?.(rowId, String(props.params.data?.name || ''))
|
||||
}
|
||||
return h('div', { class: 'zxfw-action-wrap' }, [
|
||||
h('div', { class: 'zxfw-action-group' }, [
|
||||
h('button', {
|
||||
class: 'zxfw-action-btn',
|
||||
'data-action': 'edit',
|
||||
type: 'button',
|
||||
onClick: onActionClick('edit')
|
||||
}, [
|
||||
h(Pencil, { size: 13, 'aria-hidden': 'true' }),
|
||||
h('span', '编辑')
|
||||
]),
|
||||
h('button', {
|
||||
class: 'zxfw-action-btn zxfw-action-btn--danger',
|
||||
'data-action': 'clear',
|
||||
type: 'button',
|
||||
onClick: onActionClick('clear')
|
||||
}, [
|
||||
h(Eraser, { size: 13, 'aria-hidden': 'true' }),
|
||||
h('span', '清空')
|
||||
])
|
||||
])
|
||||
])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const columnDefs: ColDef<FeeMethodRow>[] = [
|
||||
{
|
||||
headerName: '名字',
|
||||
field: 'name',
|
||||
minWidth: 180,
|
||||
flex: 1.8,
|
||||
editable: false,
|
||||
valueFormatter: formatEditableText,
|
||||
cellClass: params =>
|
||||
params.context?.fixedNames === true || isSummaryRow(params.data)
|
||||
? ''
|
||||
: 'editable-cell-line',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '费率计取',
|
||||
field: 'rateFee',
|
||||
minWidth: 130,
|
||||
flex: 1.2,
|
||||
editable: false,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: 'ag-right-aligned-cell',
|
||||
valueParser: params => numericParser(params.newValue),
|
||||
valueFormatter: formatEditableNumber,
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '工时法',
|
||||
field: 'hourlyFee',
|
||||
minWidth: 130,
|
||||
flex: 1.2,
|
||||
editable: false,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: 'ag-right-aligned-cell',
|
||||
valueParser: params => numericParser(params.newValue),
|
||||
valueFormatter: formatEditableNumber,
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '数量单价',
|
||||
field: 'quantityUnitPriceFee',
|
||||
minWidth: 130,
|
||||
flex: 1.2,
|
||||
editable: false,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: 'ag-right-aligned-cell',
|
||||
valueParser: params => numericParser(params.newValue),
|
||||
valueFormatter: formatEditableNumber,
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '小计',
|
||||
field: 'subtotal',
|
||||
minWidth: 140,
|
||||
flex: 1.2,
|
||||
editable: false,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: 'ag-right-aligned-cell',
|
||||
valueGetter: params => getRowSubtotal(params.data),
|
||||
valueFormatter: formatEditableNumber
|
||||
},
|
||||
{
|
||||
headerName: '操作',
|
||||
field: 'actions',
|
||||
minWidth: 220,
|
||||
flex: 1.6,
|
||||
maxWidth: 260,
|
||||
editable: false,
|
||||
sortable: false,
|
||||
filter: false,
|
||||
suppressMovable: true,
|
||||
cellRenderer: ActionCellRenderer
|
||||
}
|
||||
]
|
||||
|
||||
const detailGridOptions: GridOptions<FeeMethodRow> = {
|
||||
...gridOptions,
|
||||
treeData: false,
|
||||
getDataPath: undefined,
|
||||
context: {
|
||||
fixedNames: hasFixedNames.value,
|
||||
onActionEdit: editRow,
|
||||
onActionClear: clearRow,
|
||||
onActionRequestClear: requestClearRow
|
||||
},
|
||||
onCellClicked: params => {
|
||||
if (params.colDef.field !== 'actions' || !params.data || isSummaryRow(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 === 'edit') {
|
||||
editRow(params.data.id)
|
||||
return
|
||||
}
|
||||
if (action === 'clear') {
|
||||
requestClearRow(params.data.id, params.data.name)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let reloadTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const scheduleReloadFromStorage = () => {
|
||||
if (reloadTimer) clearTimeout(reloadTimer)
|
||||
reloadTimer = setTimeout(() => {
|
||||
void loadFromIndexedDB()
|
||||
}, 80)
|
||||
}
|
||||
|
||||
const handleGridReady = (event: GridReadyEvent<FeeMethodRow>) => {
|
||||
gridApi.value = event.api
|
||||
}
|
||||
|
||||
const handleCellValueChanged = (event: CellValueChangedEvent<FeeMethodRow>) => {
|
||||
if (isSummaryRow(event.data)) return
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
scheduleReloadFromStorage()
|
||||
})
|
||||
|
||||
const storageKeyRef = computed(() => props.storageKey)
|
||||
const reserveAdditionalWorkKeyVersion = computed(() => {
|
||||
if (!isReserveStorageKey(props.storageKey)) return 0
|
||||
const contractId = String(props.contractId || '').trim()
|
||||
if (!contractId) return 0
|
||||
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
||||
return zxFwPricingStore.getKeyVersion(additionalStorageKey)
|
||||
})
|
||||
watch(storageKeyRef, () => {
|
||||
scheduleReloadFromStorage()
|
||||
})
|
||||
|
||||
watch(
|
||||
() =>
|
||||
detailRows.value
|
||||
.map(row => {
|
||||
const rateKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'rate-fee')
|
||||
const hourlyKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'hourly-fee')
|
||||
const quantityKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'quantity-unit-price-fee')
|
||||
return `${row.id}:${zxFwPricingStore.getKeyVersion(rateKey)}:${zxFwPricingStore.getKeyVersion(hourlyKey)}:${zxFwPricingStore.getKeyVersion(quantityKey)}`
|
||||
})
|
||||
.join('|'),
|
||||
(nextSignature, prevSignature) => {
|
||||
if (!nextSignature && !prevSignature) return
|
||||
if (nextSignature === prevSignature) return
|
||||
scheduleReloadFromStorage()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => {
|
||||
const contractId = String(props.contractId || '').trim()
|
||||
if (!contractId) return 0
|
||||
return zxFwPricingStore.contractVersions[contractId] || 0
|
||||
},
|
||||
(nextVersion, prevVersion) => {
|
||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||
scheduleReloadFromStorage()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
reserveAdditionalWorkKeyVersion,
|
||||
(nextVersion, prevVersion) => {
|
||||
if (nextVersion === prevVersion) return
|
||||
scheduleReloadFromStorage()
|
||||
}
|
||||
)
|
||||
|
||||
watch([hasFixedNames], () => {
|
||||
if (!detailGridOptions.context) return
|
||||
detailGridOptions.context.fixedNames = hasFixedNames.value
|
||||
detailGridOptions.context.onActionEdit = editRow
|
||||
detailGridOptions.context.onActionClear = clearRow
|
||||
detailGridOptions.context.onActionRequestClear = requestClearRow
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (reloadTimer) clearTimeout(reloadTimer)
|
||||
gridApi.value = null
|
||||
void saveToIndexedDB(true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
|
||||
<Button v-if="!hasFixedNames" type="button" variant="outline" size="sm" @click="addRow">新增</Button>
|
||||
</div>
|
||||
|
||||
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||
<AgGridVue
|
||||
:style="{ height: '100%' }"
|
||||
:rowData="displayRows"
|
||||
:columnDefs="columnDefs"
|
||||
:gridOptions="detailGridOptions"
|
||||
:theme="myTheme"
|
||||
:treeData="false"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
@grid-ready="handleGridReady"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
/>
|
||||
</div>
|
||||
</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">
|
||||
将清空“{{ pendingClearRowName }}”及其编辑页面的可填和自动计算数据,是否继续?
|
||||
</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>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
message: string
|
||||
}>(),
|
||||
{
|
||||
title: '该服务不适用当前计价方法'
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full min-h-0 w-full flex-1 items-center justify-center bg-[radial-gradient(circle_at_top,_rgba(220,38,38,0.18),rgba(0,0,0,0.03)_46%,transparent_72%)] p-6"
|
||||
>
|
||||
<div class="w-full max-w-xl rounded-2xl border border-red-300/85 bg-white/90 px-8 py-10 text-center shadow-[0_18px_38px_-22px_rgba(153,27,27,0.6)] backdrop-blur">
|
||||
<p class="text-lg font-semibold tracking-wide text-neutral-900">{{ props.title }}</p>
|
||||
<p class="mt-2 text-sm leading-6 text-red-700">{{ props.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface ServiceItem {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
services: ServiceItem[]
|
||||
modelValue: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string[]): void
|
||||
}>()
|
||||
|
||||
const selectedSet = computed(() => new Set(props.modelValue))
|
||||
|
||||
const toggleService = (id: string, checked: boolean) => {
|
||||
const next = new Set(props.modelValue)
|
||||
if (checked) next.add(id)
|
||||
else next.delete(id)
|
||||
emit('update:modelValue', props.services.map(item => item.id).filter(itemId => next.has(itemId)))
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
emit('update:modelValue', [])
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-lg border bg-card p-2.5 shadow-sm">
|
||||
<div class="mb-1 flex items-center justify-between gap-2">
|
||||
<label class="block text-[11px] font-medium text-foreground leading-none">选择服务</label>
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer h-6 rounded-md border px-2 text-[13px] text-muted-foreground transition hover:bg-accent"
|
||||
@click="clearAll"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<div class="rounded-md border p-1.5">
|
||||
<div class="flex flex-wrap items-start gap-1">
|
||||
<label
|
||||
v-for="item in props.services"
|
||||
:key="item.id"
|
||||
class="inline-flex w-fit max-w-full cursor-pointer items-start gap-1.5 rounded-md border px-2 py-1 text-[11px] leading-4 hover:bg-muted/60"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-0.5"
|
||||
:checked="selectedSet.has(item.id)"
|
||||
@change="toggleService(item.id, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="text-muted-foreground shrink-0">{{ item.code }}</span>
|
||||
<span class="text-foreground break-words">{{ item.name }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="props.services.length === 0" class="px-2 py-4 text-center text-xs text-muted-foreground">
|
||||
暂无服务
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,320 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type {
|
||||
CellValueChangedEvent,
|
||||
ColDef,
|
||||
GridApi,
|
||||
GridReadyEvent,
|
||||
ICellRendererParams,
|
||||
ValueFormatterParams
|
||||
} from 'ag-grid-community'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { myTheme } from '@/lib/diyAgGridOptions'
|
||||
import { workList } from '@/sql'
|
||||
import type { WorkType } from '@/sql'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
|
||||
interface WorkContentRow {
|
||||
id: string
|
||||
content: string
|
||||
type: WorkType
|
||||
remark: string
|
||||
checked: boolean
|
||||
custom: boolean
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface WorkContentState {
|
||||
detailRows: WorkContentRow[]
|
||||
}
|
||||
|
||||
// dictMode: 'service' 按serviceId筛选workList;'additional' 取serviceid=-1的数据;'none' 不加载词典
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
storageKey: string
|
||||
serviceId?: number | string
|
||||
dictMode?: 'service' | 'additional' | 'none'
|
||||
}>(), {
|
||||
title: '工作内容',
|
||||
dictMode: 'none'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
checkedChange: [value: string[]]
|
||||
}>()
|
||||
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const gridApi = ref<GridApi<WorkContentRow> | null>(null)
|
||||
const rowData = ref<WorkContentRow[]>([])
|
||||
|
||||
const TYPE_LABEL_MAP: Record<number, WorkType> = {
|
||||
0: '基本工作',
|
||||
1: '可选工作',
|
||||
2: '日常顾问',
|
||||
3: '专项顾问',
|
||||
4: '附加工作'
|
||||
}
|
||||
|
||||
const buildDefaultRowsFromDict = (): WorkContentRow[] => {
|
||||
const rows: WorkContentRow[] = []
|
||||
const entries = Object.values(workList) as Array<{ text: string; serviceid: number; order: number; type: number }>
|
||||
|
||||
let filtered: typeof entries
|
||||
if (props.dictMode === 'service') {
|
||||
const sid = Number(props.serviceId)
|
||||
filtered = entries.filter(e => e.serviceid === sid)
|
||||
} else if (props.dictMode === 'additional') {
|
||||
filtered = entries.filter(e => e.serviceid === -1)
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
|
||||
filtered.sort((a, b) => a.order - b.order)
|
||||
for (const entry of filtered) {
|
||||
const content = String(entry.text || '').trim()
|
||||
if (!content) continue
|
||||
const typeLabel = TYPE_LABEL_MAP[entry.type] ?? '基本工作'
|
||||
rows.push({
|
||||
id: `dict-${entry.order}`,
|
||||
content,
|
||||
type: typeLabel,
|
||||
remark: '',
|
||||
checked: false,
|
||||
custom: false,
|
||||
path: [typeLabel, content]
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const checkedIds = computed(() =>
|
||||
rowData.value.filter(item => item.checked).map(item => item.id)
|
||||
)
|
||||
|
||||
// 导出用:自定义内容全部包含,默认词典内容只含勾选的
|
||||
const selectedTexts = computed(() =>
|
||||
rowData.value
|
||||
.filter(item => item.custom || item.checked)
|
||||
.map(item => item.content)
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
defineExpose({ selectedTexts })
|
||||
|
||||
const emitCheckedChange = () => {
|
||||
emit('checkedChange', [...checkedIds.value])
|
||||
}
|
||||
|
||||
const saveToStore = () => {
|
||||
const payload: WorkContentState = {
|
||||
detailRows: rowData.value.map(item => ({ ...item }))
|
||||
}
|
||||
zxFwPricingStore.setKeyState(props.storageKey, payload)
|
||||
emitCheckedChange()
|
||||
}
|
||||
|
||||
const loadFromStore = async () => {
|
||||
const state = await zxFwPricingStore.loadKeyState<WorkContentState>(props.storageKey)
|
||||
if (Array.isArray(state?.detailRows) && state.detailRows.length > 0) {
|
||||
rowData.value = state.detailRows.map(item => ({
|
||||
...item,
|
||||
type: item.custom ? '自定义' : (item.type || '基本工作'),
|
||||
path: Array.isArray(item.path) && item.path.length ? item.path : ['自定义', item.content || '未命名']
|
||||
})) as WorkContentRow[]
|
||||
} else {
|
||||
rowData.value = buildDefaultRowsFromDict()
|
||||
saveToStore()
|
||||
}
|
||||
emitCheckedChange()
|
||||
}
|
||||
|
||||
const handleCheckedToggle = (id: string, checked: boolean) => {
|
||||
const target = rowData.value.find(item => item.id === id)
|
||||
if (!target) return
|
||||
target.checked = checked
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
saveToStore()
|
||||
}
|
||||
|
||||
const contentCellRenderer = (params: ICellRendererParams<WorkContentRow>) => {
|
||||
const data = params.data
|
||||
if (!data) return ''
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = 'work-content-cell'
|
||||
// 自定义行不显示 checkbox,直接显示文本;空时显示 placeholder
|
||||
if (data.custom) {
|
||||
const label = document.createElement('span')
|
||||
if (!data.content) {
|
||||
label.className = 'work-content-placeholder'
|
||||
label.textContent = '点击输入工作内容'
|
||||
} else {
|
||||
label.className = 'work-content-text'
|
||||
label.textContent = data.content
|
||||
}
|
||||
wrapper.appendChild(label)
|
||||
return wrapper
|
||||
}
|
||||
const checkbox = document.createElement('input')
|
||||
checkbox.type = 'checkbox'
|
||||
checkbox.className = 'work-content-check'
|
||||
checkbox.checked = Boolean(data.checked)
|
||||
checkbox.addEventListener('change', () => {
|
||||
handleCheckedToggle(data.id, checkbox.checked)
|
||||
})
|
||||
const label = document.createElement('span')
|
||||
label.className = 'work-content-text'
|
||||
label.textContent = String(data.content || '')
|
||||
wrapper.appendChild(checkbox)
|
||||
wrapper.appendChild(label)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<WorkContentRow>[] = [
|
||||
{
|
||||
headerName: '序号',
|
||||
minWidth: 60,
|
||||
width: 70,
|
||||
pinned: 'left',
|
||||
suppressMovable: true,
|
||||
editable: false,
|
||||
valueGetter: params => (params.node?.rowIndex ?? 0) + 1
|
||||
},
|
||||
{
|
||||
headerName: '工作内容',
|
||||
field: 'content',
|
||||
minWidth: 320,
|
||||
flex: 2,
|
||||
editable: params => Boolean(params.data?.custom),
|
||||
cellClass: params => (params.data?.custom ? 'editable-cell-line' : ''),
|
||||
valueParser: params => String(params.newValue || '').trim(),
|
||||
cellRenderer: contentCellRenderer
|
||||
},
|
||||
{
|
||||
headerName: '工作类型',
|
||||
field: 'type',
|
||||
minWidth: 100,
|
||||
width: 120,
|
||||
editable: false,
|
||||
valueFormatter: (params: ValueFormatterParams<WorkContentRow>) => String(params.value || '')
|
||||
},
|
||||
{
|
||||
headerName: '备注',
|
||||
field: 'remark',
|
||||
minWidth: 180,
|
||||
flex: 1.2,
|
||||
editable: true,
|
||||
cellEditor: 'agLargeTextCellEditor',
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||
cellClass: 'editable-cell-line remark-wrap-cell',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
},
|
||||
valueFormatter: params => params.value || '点击输入'
|
||||
}
|
||||
]
|
||||
|
||||
const addCustomRow = () => {
|
||||
const ts = Date.now()
|
||||
rowData.value.push({
|
||||
id: `custom-${ts}`,
|
||||
content: '',
|
||||
type: '自定义' as WorkType,
|
||||
remark: '',
|
||||
checked: false,
|
||||
custom: true,
|
||||
path: ['自定义', `自定义-${ts}`]
|
||||
})
|
||||
saveToStore()
|
||||
}
|
||||
|
||||
const onGridReady = (event: GridReadyEvent<WorkContentRow>) => {
|
||||
gridApi.value = event.api
|
||||
}
|
||||
|
||||
const onCellValueChanged = (event: CellValueChangedEvent<WorkContentRow>) => {
|
||||
const row = event.data
|
||||
if (!row) return
|
||||
if (event.colDef.field === 'content' && row.custom) {
|
||||
row.path = ['自定义', row.content || `自定义-${row.id}`]
|
||||
}
|
||||
if (event.colDef.field === 'type' && row.custom) {
|
||||
row.type = '自定义'
|
||||
}
|
||||
saveToStore()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadFromStore()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
saveToStore()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 xmMx">
|
||||
<div class="h-full min-h-0 rounded-2xl border border-border/60 bg-card/90 shadow-sm backdrop-blur-sm">
|
||||
<div class="flex items-center justify-between border-b border-border/60 px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ props.title }}</h3>
|
||||
<Button type="button" size="sm" variant="outline" class="cursor-pointer" @click="addCustomRow">
|
||||
添加自定义内容
|
||||
</Button>
|
||||
</div>
|
||||
<div class="ag-theme-quartz h-[calc(100%-56px)] min-h-0 w-full">
|
||||
<AgGridVue
|
||||
:style="{ height: '100%' }"
|
||||
:rowData="rowData"
|
||||
:columnDefs="columnDefs"
|
||||
:theme="myTheme"
|
||||
:getRowId="(params: { data: WorkContentRow }) => params.data.id"
|
||||
:animateRows="true"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:singleClickEdit="true"
|
||||
:stopEditingWhenCellsLoseFocus="true"
|
||||
:defaultColDef="{ resizable: true, sortable: false, filter: false }"
|
||||
:suppressColumnVirtualisation="false"
|
||||
:suppressRowVirtualisation="false"
|
||||
@grid-ready="onGridReady"
|
||||
@cell-value-changed="onCellValueChanged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.work-content-placeholder) {
|
||||
color: #94a3b8;
|
||||
font-style: italic;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.work-content-cell) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:deep(.work-content-text) {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.work-content-check) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,355 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
|
||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
|
||||
interface DictItem {
|
||||
code: string
|
||||
name: string
|
||||
defCoe: number | null
|
||||
desc?: string | null
|
||||
notshowByzxflxs?: boolean
|
||||
order?: number | null
|
||||
}
|
||||
|
||||
interface FactorRow {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
standardFactor: number | null
|
||||
budgetValue: number | null
|
||||
remark: string
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface GridState {
|
||||
detailRows: FactorRow[]
|
||||
}
|
||||
|
||||
type DictSource = Record<string, DictItem>
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
storageKey: string
|
||||
parentStorageKey?: string
|
||||
dict: DictSource
|
||||
disableBudgetEditWhenStandardNull?: boolean
|
||||
excludeNotshowByZxflxs?: boolean
|
||||
initBudgetValueFromStandard?: boolean
|
||||
}>()
|
||||
|
||||
const kvStore = useKvStore()
|
||||
const detailRows = ref<FactorRow[]>([])
|
||||
const gridApi = ref<GridApi<FactorRow> | null>(null)
|
||||
|
||||
const formatReadonlyFactor = (value: unknown) => {
|
||||
if (value == null || value === '') return ''
|
||||
const parsed = parseNumberOrNull(value, { precision: 3 })
|
||||
if (parsed == null) return ''
|
||||
return String(Number(parsed))
|
||||
}
|
||||
|
||||
const formatEditableFactor = (params: any) => {
|
||||
if (params.value == null || params.value === '') return '点击输入'
|
||||
const parsed = parseNumberOrNull(params.value, { precision: 3 })
|
||||
if (parsed == null) return ''
|
||||
return String(Number(parsed))
|
||||
}
|
||||
|
||||
const sortedDictEntries = () =>
|
||||
Object.entries(props.dict)
|
||||
.filter((entry): entry is [string, DictItem] => {
|
||||
const item = entry[1]
|
||||
if (!item?.code || !item?.name) return false
|
||||
if (props.excludeNotshowByZxflxs && item.notshowByzxflxs === true) return false
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const aOrder = Number(a[1]?.order)
|
||||
const bOrder = Number(b[1]?.order)
|
||||
if (Number.isFinite(aOrder) && Number.isFinite(bOrder) && aOrder !== bOrder) return aOrder - bOrder
|
||||
if (Number.isFinite(aOrder) && !Number.isFinite(bOrder)) return -1
|
||||
if (!Number.isFinite(aOrder) && Number.isFinite(bOrder)) return 1
|
||||
return String(a[1]?.code || a[0]).localeCompare(String(b[1]?.code || b[0]))
|
||||
})
|
||||
|
||||
const buildCodePath = (code: string, selfId: string, codeIdMap: Map<string, string>) => {
|
||||
const parts = code.split('-').filter(Boolean)
|
||||
if (!parts.length) return [selfId]
|
||||
|
||||
const path: string[] = []
|
||||
let currentCode = parts[0]
|
||||
const firstId = codeIdMap.get(currentCode)
|
||||
if (firstId) path.push(firstId)
|
||||
|
||||
for (let i = 1; i < parts.length; i += 1) {
|
||||
currentCode = `${currentCode}-${parts[i]}`
|
||||
const id = codeIdMap.get(currentCode)
|
||||
if (id) path.push(id)
|
||||
}
|
||||
|
||||
if (!path.length || path[path.length - 1] !== selfId) path.push(selfId)
|
||||
return path
|
||||
}
|
||||
|
||||
const buildDefaultRows = (): FactorRow[] => {
|
||||
const entries = sortedDictEntries()
|
||||
const codeIdMap = new Map<string, string>()
|
||||
for (const [id, item] of entries) {
|
||||
codeIdMap.set(item.code, id)
|
||||
}
|
||||
|
||||
return entries.map(([id, item]) => {
|
||||
const standardFactor = typeof item.defCoe === 'number' && Number.isFinite(item.defCoe) ? item.defCoe : null
|
||||
return {
|
||||
id,
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
standardFactor,
|
||||
budgetValue: props.initBudgetValueFromStandard ? standardFactor : null,
|
||||
remark: '',
|
||||
path: buildCodePath(item.code, id, codeIdMap)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type SourceRow = Pick<FactorRow, 'id'> & Partial<Pick<FactorRow, 'budgetValue' | 'remark'>>
|
||||
const hasMeaningfulFactorValue = (rows: SourceRow[] | undefined) =>
|
||||
Array.isArray(rows) &&
|
||||
rows.some(row => {
|
||||
const hasBudgetValue = typeof row?.budgetValue === 'number' && Number.isFinite(row.budgetValue)
|
||||
const hasRemark = typeof row?.remark === 'string' && row.remark.trim() !== ''
|
||||
return hasBudgetValue || hasRemark
|
||||
})
|
||||
|
||||
const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): FactorRow[] => {
|
||||
const dbValueMap = new Map<string, SourceRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
dbValueMap.set(row.id, row)
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
const fromDb = dbValueMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
|
||||
const hasBudgetValue = Object.prototype.hasOwnProperty.call(fromDb, 'budgetValue')
|
||||
const hasRemark = Object.prototype.hasOwnProperty.call(fromDb, 'remark')
|
||||
return {
|
||||
...row,
|
||||
budgetValue:
|
||||
typeof fromDb.budgetValue === 'number'
|
||||
? fromDb.budgetValue
|
||||
: hasBudgetValue
|
||||
? null
|
||||
: row.budgetValue,
|
||||
remark: typeof fromDb.remark === 'string' ? fromDb.remark : hasRemark ? '' : row.remark
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<FactorRow>[] = [
|
||||
{
|
||||
headerName: '标准系数',
|
||||
field: 'standardFactor',
|
||||
minWidth: 86,
|
||||
maxWidth: 100,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: 'ag-right-aligned-cell',
|
||||
flex: 0.9,
|
||||
valueFormatter: params => formatReadonlyFactor(params.value)
|
||||
},
|
||||
{
|
||||
headerName: '预算取值',
|
||||
field: 'budgetValue',
|
||||
minWidth: 86,
|
||||
maxWidth: 100,
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
cellClass: params => {
|
||||
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
|
||||
return disabled ? 'ag-right-aligned-cell' : 'ag-right-aligned-cell editable-cell-line'
|
||||
},
|
||||
flex: 0.9,
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => {
|
||||
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
|
||||
return !disabled && (params.value == null || params.value === '')
|
||||
}
|
||||
},
|
||||
editable: params => {
|
||||
if (!props.disableBudgetEditWhenStandardNull) return true
|
||||
return params.data?.standardFactor != null
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: params => {
|
||||
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
|
||||
if (disabled && (params.value == null || params.value === '')) return ''
|
||||
return formatEditableFactor(params)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '说明',
|
||||
field: 'remark',
|
||||
minWidth: 170,
|
||||
flex: 2.4,
|
||||
cellEditor: 'agLargeTextCellEditor',
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||
editable: true,
|
||||
valueFormatter: params => params.value || '点击输入',
|
||||
cellClass: 'editable-cell-line remark-wrap-cell',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const autoGroupColumnDef: ColDef<FactorRow> = {
|
||||
headerName: '专业编码以及工程专业名称',
|
||||
minWidth: 220,
|
||||
flex: 2.2,
|
||||
cellRendererParams: {
|
||||
suppressCount: true
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (params.data?.code && params.data?.name) return `${params.data.code} ${params.data.name}`
|
||||
const key = String(params.node?.key || '')
|
||||
const dictItem = (props.dict as DictSource)[key]
|
||||
return dictItem ? `${dictItem.code} ${dictItem.name}` : ''
|
||||
}
|
||||
}
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const payload: GridState = {
|
||||
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||
}
|
||||
await kvStore.setItem(props.storageKey, payload)
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadGridState = async (storageKey: string): Promise<GridState | null> => {
|
||||
if (!storageKey) return null
|
||||
const data = await kvStore.getItem<GridState>(storageKey)
|
||||
if (!data?.detailRows || !Array.isArray(data.detailRows)) return null
|
||||
return data
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async () => {
|
||||
try {
|
||||
const data = await loadGridState(props.storageKey)
|
||||
if (data && hasMeaningfulFactorValue(data.detailRows)) {
|
||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||
return
|
||||
}
|
||||
|
||||
const parentStorageKey = props.parentStorageKey?.trim()
|
||||
if (parentStorageKey) {
|
||||
const parentData = await loadGridState(parentStorageKey)
|
||||
if (parentData && hasMeaningfulFactorValue(parentData.detailRows)) {
|
||||
detailRows.value = mergeWithDictRows(parentData.detailRows)
|
||||
await saveToIndexedDB()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (data) {
|
||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||
return
|
||||
}
|
||||
|
||||
detailRows.value = buildDefaultRows()
|
||||
await saveToIndexedDB()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
detailRows.value = buildDefaultRows()
|
||||
}
|
||||
}
|
||||
|
||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const handleCellValueChanged = () => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const processCellForClipboard = (params: any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value)
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
const processCellFromClipboard = (params: any) => {
|
||||
try {
|
||||
const parsed = JSON.parse(params.value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch (_error) {
|
||||
return params.value
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.dict,
|
||||
() => {
|
||||
void loadFromIndexedDB()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridApi.value = null
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full min-h-0 flex flex-col">
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
|
||||
<div class="text-xs text-muted-foreground"></div>
|
||||
</div>
|
||||
|
||||
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||
<AgGridVue
|
||||
:style="{ height: '100%' }"
|
||||
:rowData="detailRows"
|
||||
:columnDefs="columnDefs"
|
||||
:autoGroupColumnDef="autoGroupColumnDef"
|
||||
:gridOptions="gridOptions"
|
||||
:theme="myTheme"
|
||||
:treeData="true"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN"
|
||||
:tooltipShowDelay="500"
|
||||
:headerHeight="50"
|
||||
:suppressHorizontalScroll="true"
|
||||
:processCellForClipboard="processCellForClipboard"
|
||||
:processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,640 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { industryTypeList, getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
||||
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
|
||||
|
||||
|
||||
interface DictLeaf {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
hasCost: boolean
|
||||
|
||||
hasArea: boolean
|
||||
}
|
||||
|
||||
interface DictGroup {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
children: DictLeaf[]
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface XmScaleState {
|
||||
detailRows?: DetailRow[]
|
||||
totalAmount: number
|
||||
roughCalcEnabled: boolean
|
||||
|
||||
}
|
||||
interface XmBaseInfoState {
|
||||
projectIndustry?: string
|
||||
}
|
||||
|
||||
const XM_SCALE_FLUSH_EVENT = 'jgjs:xm-scale-flush-request'
|
||||
type MajorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean }
|
||||
const kvStore = useKvStore()
|
||||
|
||||
const detailRows = ref<DetailRow[]>([])
|
||||
const detailDict = ref<DictGroup[]>([])
|
||||
|
||||
const majorEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, MajorLite])
|
||||
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
|
||||
|
||||
const buildDetailDict = (entries: Array<[string, MajorLite]>) => {
|
||||
const groupMap = new Map<string, DictGroup>()
|
||||
const groupOrder: string[] = []
|
||||
const codeLookup = new Map(entries.map(([key, item]) => [item.code, { id: key, ...item }]))
|
||||
|
||||
for (const [key, item] of entries) {
|
||||
const isGroup = !item.code.includes('-')
|
||||
if (isGroup) {
|
||||
if (!groupMap.has(item.code)) groupOrder.push(item.code)
|
||||
groupMap.set(item.code, {
|
||||
id: key,
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
children: []
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const parentCode = item.code.split('-')[0]
|
||||
if (!groupMap.has(parentCode)) {
|
||||
const parent = codeLookup.get(parentCode)
|
||||
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
|
||||
groupMap.set(parentCode, {
|
||||
id: parent?.id || `group-${parentCode}`,
|
||||
code: parentCode,
|
||||
name: parent?.name || parentCode,
|
||||
children: []
|
||||
})
|
||||
}
|
||||
groupMap.get(parentCode)!.children.push({
|
||||
id: key,
|
||||
code: item.code,
|
||||
name: item.name,
|
||||
|
||||
hasCost: item.hasCost !== false,
|
||||
hasArea: item.hasArea !== false
|
||||
})
|
||||
}
|
||||
|
||||
return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group))
|
||||
}
|
||||
|
||||
|
||||
|
||||
const buildDefaultRows = (): DetailRow[] => {
|
||||
const rows: DetailRow[] = []
|
||||
for (const group of detailDict.value) {
|
||||
for (const child of group.children) {
|
||||
|
||||
rows.push({
|
||||
id: child.id,
|
||||
groupCode: group.code,
|
||||
groupName: group.name,
|
||||
majorCode: child.code,
|
||||
majorName: child.name,
|
||||
hasCost: child.hasCost,
|
||||
hasArea: child.hasArea,
|
||||
amount: null,
|
||||
landArea: null,
|
||||
path: [`${group.code} ${group.name}`, `${child.code} ${child.name}`]
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||||
const dbValueMap = new Map<string, DetailRow>()
|
||||
for (const row of rowsFromDb || []) {
|
||||
if (row?.isGroupRow === true) continue
|
||||
const rowId = String(row.id)
|
||||
dbValueMap.set(rowId, row)
|
||||
const aliasId = majorIdAliasMap.get(rowId)
|
||||
if (aliasId && !dbValueMap.has(aliasId)) {
|
||||
dbValueMap.set(aliasId, row)
|
||||
}
|
||||
}
|
||||
|
||||
return buildDefaultRows().map(row => {
|
||||
const fromDb = dbValueMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
|
||||
return {
|
||||
...row,
|
||||
hide: fromDb.hide,
|
||||
amount: row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null,
|
||||
landArea: row.hasArea && typeof fromDb.landArea === 'number' ? fromDb.landArea : null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const buildGroupRows = (rows: DetailRow[]): DetailRow[] => {
|
||||
const rowById = new Map(rows.map(row => [String(row.id || ''), row] as const))
|
||||
const groupRows: DetailRow[] = []
|
||||
for (const group of detailDict.value) {
|
||||
let amountTotal = 0
|
||||
let hasAmount = false
|
||||
let landAreaTotal = 0
|
||||
let hasLandArea = false
|
||||
for (const child of group.children) {
|
||||
const leaf = rowById.get(String(child.id || ''))
|
||||
const amount = leaf?.amount
|
||||
if (typeof amount === 'number' && Number.isFinite(amount)) {
|
||||
amountTotal += amount
|
||||
hasAmount = true
|
||||
}
|
||||
const landArea = leaf?.landArea
|
||||
if (typeof landArea === 'number' && Number.isFinite(landArea)) {
|
||||
landAreaTotal += landArea
|
||||
hasLandArea = true
|
||||
}
|
||||
}
|
||||
groupRows.push({
|
||||
id: group.id,
|
||||
groupCode: group.code,
|
||||
groupName: group.name,
|
||||
majorCode: group.code,
|
||||
majorName: group.name,
|
||||
hasCost: true,
|
||||
hasArea: true,
|
||||
amount: hasAmount ? roundTo(amountTotal, 3) : null,
|
||||
landArea: hasLandArea ? roundTo(landAreaTotal, 3) : null,
|
||||
path: [`${group.code} ${group.name}`],
|
||||
hide: false,
|
||||
isGroupRow: true
|
||||
})
|
||||
}
|
||||
return groupRows
|
||||
}
|
||||
|
||||
|
||||
const applyPinnedTotalAmount = (
|
||||
api: GridApi<DetailRow> | null | undefined,
|
||||
totalAmount: number | null | undefined
|
||||
) => {
|
||||
const normalized = typeof totalAmount === 'number' && Number.isFinite(totalAmount)
|
||||
? roundTo(totalAmount, 2)
|
||||
: null
|
||||
pinnedTopRowData.value[0].amount = normalized
|
||||
const pinnedTopNode = api?.getPinnedTopRow(0)
|
||||
if (pinnedTopNode) {
|
||||
pinnedTopNode.setDataValue('amount', normalized)
|
||||
}
|
||||
}
|
||||
|
||||
const loadFromIndexedDB = async (api: GridApi<DetailRow>) => {
|
||||
try {
|
||||
const [baseInfo, contractData] = await Promise.all([
|
||||
kvStore.getItem<XmBaseInfoState>(props.baseInfoKey || 'xm-base-info-v1'),
|
||||
kvStore.getItem<XmScaleState>(props.dbKey)
|
||||
])
|
||||
|
||||
activeIndustryId.value =
|
||||
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
||||
|
||||
if (!activeIndustryId.value) {
|
||||
detailDict.value = []
|
||||
detailRows.value = []
|
||||
roughCalcEnabled.value = false
|
||||
applyPinnedTotalAmount(api, null)
|
||||
return
|
||||
}
|
||||
|
||||
const filteredEntries = majorEntries.filter(([id]) =>
|
||||
isMajorIdInIndustryScope(id, activeIndustryId.value)
|
||||
)
|
||||
detailDict.value = buildDetailDict(filteredEntries)
|
||||
|
||||
roughCalcEnabled.value = Boolean(contractData?.roughCalcEnabled)
|
||||
applyPinnedTotalAmount(api, contractData?.totalAmount)
|
||||
const contractRows = Array.isArray(contractData?.detailRows) ? contractData.detailRows : []
|
||||
const hasContractRows = contractRows.length > 0
|
||||
const hasContractScaleValue = hasContractRows
|
||||
? contractRows.some(row => {
|
||||
const amount = row?.amount
|
||||
const landArea = row?.landArea
|
||||
return (
|
||||
(typeof amount === 'number' && Number.isFinite(amount)) ||
|
||||
(typeof landArea === 'number' && Number.isFinite(landArea))
|
||||
)
|
||||
})
|
||||
: false
|
||||
const isLegacyEmptyScaleRows =
|
||||
hasContractRows &&
|
||||
!hasContractScaleValue &&
|
||||
!roughCalcEnabled.value &&
|
||||
typeof contractData?.totalAmount === 'number' &&
|
||||
Number.isFinite(contractData.totalAmount)
|
||||
if (hasContractRows && !isLegacyEmptyScaleRows) {
|
||||
detailRows.value = mergeWithDictRows(contractRows)
|
||||
return
|
||||
}
|
||||
|
||||
if (props.xmInfoKey) {
|
||||
// 首次创建合同段时,默认继承项目规模信息(同一套专业字典,按 id 对齐)
|
||||
const xmData = await kvStore.getItem<XmScaleState>(props.xmInfoKey)
|
||||
roughCalcEnabled.value = Boolean(xmData?.roughCalcEnabled)
|
||||
applyPinnedTotalAmount(api, xmData?.totalAmount)
|
||||
|
||||
if (Array.isArray(xmData?.detailRows) && xmData.detailRows.length > 0) {
|
||||
detailRows.value = mergeWithDictRows(xmData.detailRows)
|
||||
return
|
||||
}
|
||||
}
|
||||
detailRows.value = buildDefaultRows()
|
||||
|
||||
|
||||
void saveToIndexedDB()
|
||||
} catch (error) {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
activeIndustryId.value = ''
|
||||
detailRows.value = []
|
||||
roughCalcEnabled.value = false
|
||||
applyPinnedTotalAmount(api, null)
|
||||
}
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
id: string
|
||||
groupCode: string
|
||||
groupName: string
|
||||
majorCode: string
|
||||
majorName: string
|
||||
hasCost: boolean
|
||||
hasArea: boolean
|
||||
amount: number | null
|
||||
landArea: number | null
|
||||
path: string[]
|
||||
hide?: boolean
|
||||
isGroupRow?: boolean
|
||||
}
|
||||
|
||||
interface XmBaseInfoState {
|
||||
projectIndustry?: string
|
||||
}
|
||||
|
||||
interface GridPersistState {
|
||||
detailRows?: DetailRow[]
|
||||
roughCalcEnabled?: boolean
|
||||
totalAmount?: number | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
dbKey: string
|
||||
xmInfoKey?: string | null
|
||||
baseInfoKey?: string
|
||||
}>()
|
||||
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const gridApi = ref<GridApi<DetailRow> | null>(null)
|
||||
const activeIndustryId = ref('')
|
||||
const industryNameMap = new Map(
|
||||
industryTypeList.flatMap(item => [
|
||||
[String(item.id).trim(), item.name],
|
||||
[String(item.type).trim(), item.name]
|
||||
])
|
||||
)
|
||||
const totalLabel = computed(() => {
|
||||
const industryName = industryNameMap.get(activeIndustryId.value.trim()) || ''
|
||||
return industryName ? `${industryName}总投资` : '总投资'
|
||||
})
|
||||
const roughCalcEnabled = ref(false)
|
||||
const visibleRowData = computed(() => { return detailRows.value.filter(row => !row.hide) })
|
||||
|
||||
const refreshPinnedTotalLabelCell = () => {
|
||||
if (!gridApi.value) return
|
||||
const pinnedTopNode = gridApi.value.getPinnedTopRow(0)
|
||||
if (!pinnedTopNode) return
|
||||
gridApi.value.refreshCells({
|
||||
rowNodes: [pinnedTopNode],
|
||||
force: true
|
||||
})
|
||||
}
|
||||
|
||||
const columnDefs: ColDef<DetailRow>[] = [
|
||||
{
|
||||
headerName: '造价金额(万元)',
|
||||
field: 'amount',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 100,
|
||||
flex: 1,
|
||||
editable: params => {
|
||||
if (roughCalcEnabled.value) return Boolean(params.node?.rowPinned)
|
||||
return !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost)
|
||||
},
|
||||
cellClass: params =>
|
||||
roughCalcEnabled.value && params.node?.rowPinned
|
||||
? 'ag-right-aligned-cell editable-cell-line'
|
||||
: !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && params.data?.hasCost
|
||||
? 'ag-right-aligned-cell editable-cell-line'
|
||||
: 'ag-right-aligned-cell',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
roughCalcEnabled.value && params.node?.rowPinned
|
||||
? params.value == null || params.value === ''
|
||||
: !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: decimalAggSum,
|
||||
valueParser: params => {
|
||||
if (params.newValue === '' || params.newValue == null) return null
|
||||
const v = Number(params.newValue)
|
||||
return Number.isFinite(v) ? roundTo(v, 3) : null
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (roughCalcEnabled.value) {
|
||||
if (!params.node?.rowPinned) return ''
|
||||
if (params.value == null || params.value === '') return '点击输入'
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasCost) {
|
||||
return ''
|
||||
}
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
},
|
||||
{
|
||||
headerName: '用地面积(亩)',
|
||||
field: 'landArea',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 100,
|
||||
flex: 1,
|
||||
editable: params => !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea),
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned && params.data?.hasArea
|
||||
? 'ag-right-aligned-cell editable-cell-line'
|
||||
: 'ag-right-aligned-cell',
|
||||
cellClassRules: {
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea) && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => {
|
||||
if (params.newValue === '' || params.newValue == null) return null
|
||||
const v = Number(params.newValue)
|
||||
return Number.isFinite(v) ? roundTo(v, 3) : null
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (roughCalcEnabled.value) {
|
||||
return ''
|
||||
}
|
||||
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasArea) {
|
||||
return ''
|
||||
}
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousandsFlexible(params.value, 3)
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const autoGroupColumnDef: ColDef = {
|
||||
headerName: '专业编码以及工程专业名称',
|
||||
minWidth: 200,
|
||||
flex: 2,
|
||||
cellRendererParams: {
|
||||
suppressCount: true
|
||||
},
|
||||
valueFormatter: params => {
|
||||
if (params.node?.rowPinned) return totalLabel.value
|
||||
return String(params.value || '')
|
||||
},
|
||||
tooltipValueGetter: params => {
|
||||
if (params.node?.rowPinned) return totalLabel.value
|
||||
return String(params.value || '')
|
||||
}
|
||||
}
|
||||
|
||||
const pinnedTopRowData = ref<DetailRow[]>([
|
||||
{
|
||||
id: 'pinned-total-row',
|
||||
groupCode: '',
|
||||
groupName: '',
|
||||
majorCode: '',
|
||||
majorName: '',
|
||||
hasCost: false,
|
||||
hasArea: false,
|
||||
amount: null,
|
||||
landArea: null,
|
||||
path: ['TOTAL']
|
||||
}
|
||||
])
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const leafRows = detailRows.value.map(row => ({
|
||||
...JSON.parse(JSON.stringify(row)),
|
||||
hide: Boolean(row.hide),
|
||||
isGroupRow: false
|
||||
}))
|
||||
const totalAmountFromRows = (() => {
|
||||
let hasValue = false
|
||||
let total = 0
|
||||
for (const row of leafRows) {
|
||||
const amount = row?.amount
|
||||
if (typeof amount !== 'number' || !Number.isFinite(amount)) continue
|
||||
total += amount
|
||||
hasValue = true
|
||||
}
|
||||
return hasValue ? roundTo(total, 2) : null
|
||||
})()
|
||||
const pinnedAmount = pinnedTopRowData.value[0].amount
|
||||
const normalizedPinnedAmount =
|
||||
typeof pinnedAmount === 'number' && Number.isFinite(pinnedAmount)
|
||||
? roundTo(pinnedAmount, 2)
|
||||
: null
|
||||
const normalizedTotalAmount = roughCalcEnabled.value ? normalizedPinnedAmount : totalAmountFromRows
|
||||
pinnedTopRowData.value[0].amount = normalizedTotalAmount
|
||||
const payload: GridPersistState = {
|
||||
detailRows: [...leafRows, ...buildGroupRows(leafRows)]
|
||||
}
|
||||
payload.roughCalcEnabled = roughCalcEnabled.value
|
||||
payload.totalAmount = normalizedTotalAmount
|
||||
await kvStore.setItem(props.dbKey, payload)
|
||||
} catch (error) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const schedulePersist = () => {
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
persistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 600)
|
||||
}
|
||||
|
||||
const handleFlushPersistRequest = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ done?: () => void }>
|
||||
const done = customEvent?.detail?.done
|
||||
if (persistTimer) {
|
||||
clearTimeout(persistTimer)
|
||||
persistTimer = null
|
||||
}
|
||||
void saveToIndexedDB().finally(() => {
|
||||
done?.()
|
||||
})
|
||||
}
|
||||
|
||||
const setDetailRowsHidden = (hidden: boolean) => {
|
||||
for (const row of detailRows.value) {
|
||||
row.hide = hidden
|
||||
}
|
||||
}
|
||||
|
||||
let oldValue:number|null
|
||||
const onRoughCalcSwitch = (checked: boolean) => {
|
||||
gridApi.value?.stopEditing(true)
|
||||
roughCalcEnabled.value = checked
|
||||
setDetailRowsHidden(checked)
|
||||
if (!checked) {
|
||||
oldValue=pinnedTopRowData.value[0].amount
|
||||
syncPinnedTotalForNormalMode()
|
||||
} else {
|
||||
pinnedTopRowData.value[0].amount = oldValue
|
||||
const pinnedTopNode = gridApi.value?.getPinnedTopRow(0)
|
||||
if (pinnedTopNode) {
|
||||
pinnedTopNode.setDataValue('amount', oldValue)
|
||||
}
|
||||
}
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
|
||||
|
||||
const onCellValueChanged = (event: CellValueChangedEvent) => {
|
||||
if (roughCalcEnabled.value && event.node?.rowPinned && event.colDef.field === 'amount') {
|
||||
if (typeof event.newValue === 'number') {
|
||||
pinnedTopRowData.value[0].amount = roundTo(event.newValue, 2)
|
||||
} else {
|
||||
const parsed = Number(event.newValue)
|
||||
pinnedTopRowData.value[0].amount = Number.isFinite(parsed) ? roundTo(parsed, 2) : null
|
||||
}
|
||||
|
||||
} else if (!roughCalcEnabled.value) {
|
||||
syncPinnedTotalForNormalMode()
|
||||
}
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
const onGridReady = (event: GridReadyEvent<DetailRow>) => {
|
||||
gridApi.value = event.api
|
||||
|
||||
void loadFromIndexedDB(event.api)
|
||||
|
||||
void refreshPinnedTotalLabelCell()
|
||||
|
||||
}
|
||||
|
||||
const processCellForClipboard = (params: any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value)
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
const processCellFromClipboard = (params: any) => {
|
||||
try {
|
||||
const parsed = JSON.parse(params.value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch (error) {
|
||||
// no-op
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
watch(totalLabel, () => {
|
||||
refreshPinnedTotalLabelCell()
|
||||
})
|
||||
|
||||
const syncPinnedTotalForNormalMode = () => {
|
||||
if (roughCalcEnabled.value) return
|
||||
|
||||
if (!gridApi.value) {
|
||||
pinnedTopRowData.value[0].amount = sumByNumber(detailRows.value, row => row.amount)
|
||||
return
|
||||
}
|
||||
let total = 0
|
||||
let hasValue = false
|
||||
|
||||
detailRows.value.forEach(node => {
|
||||
|
||||
const amount = node.amount
|
||||
if (typeof amount === 'number' && Number.isFinite(amount)) {
|
||||
total += amount
|
||||
hasValue = true
|
||||
}
|
||||
})
|
||||
pinnedTopRowData.value[0].amount = hasValue ? roundTo(total, 2) : null
|
||||
const pinnedTopNode = gridApi.value.getPinnedTopRow(0)
|
||||
if (pinnedTopNode) {
|
||||
pinnedTopNode.setDataValue('amount', hasValue ? roundTo(total, 2) : null)
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (persistTimer) clearTimeout(persistTimer)
|
||||
window.removeEventListener(XM_SCALE_FLUSH_EVENT, handleFlushPersistRequest as EventListener)
|
||||
gridApi.value = null
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener(XM_SCALE_FLUSH_EVENT, handleFlushPersistRequest as EventListener)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<div class="rounded-lg border bg-card xmMx scroll-mt-3 flex flex-col overflow-hidden h-full">
|
||||
<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">
|
||||
{{ props.title }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class=" text-xs text-muted-foreground">简要计算</span>
|
||||
<SwitchRoot
|
||||
class="cursor-pointer peer h-5 w-9 shrink-0 rounded-full border border-transparent bg-muted shadow-sm transition-colors data-[state=checked]:bg-primary"
|
||||
:modelValue="roughCalcEnabled" @update:modelValue="onRoughCalcSwitch">
|
||||
<SwitchThumb
|
||||
class="block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow transition-transform data-[state=checked]:translate-x-4" />
|
||||
</SwitchRoot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ag-theme-quartz w-full flex-1 min-h-0 h-full">
|
||||
<AgGridVue :style="{ height: '100%' }" :rowData="visibleRowData" :pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="gridOptions" :theme="myTheme"
|
||||
@grid-ready="onGridReady" @cell-value-changed="onCellValueChanged" :suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :suppressHorizontalScroll="true"
|
||||
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user